Browse Source

greenhouse's first time hosting itself!

TCP only for now, still need to integrate with Caddy
master
forest 5 months ago
parent
commit
dff4cb8106
13 changed files with 925 additions and 60 deletions
  1. +2
    -1
      .gitignore
  2. +163
    -28
      backend.go
  3. +3
    -3
      db_model.go
  4. +497
    -0
      docker-compose.yml
  5. +18
    -0
      frontend.go
  6. +14
    -0
      frontend/admin.gotemplate.html
  7. +1
    -0
      main.go
  8. +40
    -18
      pki_service.go
  9. +13
    -0
      threshold/install.sh
  10. +6
    -0
      threshold/remote_install.sh
  11. +3
    -0
      threshold/run.sh
  12. +20
    -0
      threshold/src_remote_install.sh
  13. +145
    -10
      threshold_provisioning_service.go

+ 2
- 1
.gitignore View File

@ -3,4 +3,5 @@
config.json
favicon.ico
frontend/static/*.png
frontend/static/*.svg
frontend/static/*.svg
threshold/threshold

+ 163
- 28
backend.go View File

@ -29,16 +29,21 @@ type BaseHTTPService struct {
type BackendApp struct {
BaseHTTPService
WorkingDirectory string
EmailService *EmailService
Model *DBModel
DigitalOcean *DigitalOceanService
Gandi *GandiService
FreeSubdomainDomain string
BackblazeB2 *BackblazeB2Service
SSH *SSHService
ThresholdProvisioning *ThresholdProvisioningService
ThresholdManagementPort int
WorkingDirectory string
EmailService *EmailService
Model *DBModel
DigitalOcean *DigitalOceanService
Gandi *GandiService
FreeSubdomainDomain string
BackblazeB2 *BackblazeB2Service
SSH *SSHService
ThresholdProvisioning *ThresholdProvisioningService
ThresholdManagementPort int
ThresholdPort int
AdminTenantId int
AdminThresholdNodeId string
GreenhouseListenPort int
GreenhouseThresholdServiceId string
}
type ThresholdMetrics struct {
@ -83,8 +88,6 @@ const shufflingCircuitBreakerLimit = 1000
const GIGABYTE = int64(1000000000)
const TERABYTE = int64(1000000000000)
const mainCAName = "greenhouse_CA"
const managementAPIAuthCAName = "greenhouse_management_api_auth_CA"
const managementClientCertSubject = "management@greenhouse.server.garden"
const freeSubdomainDomain = "greenhouseusers.com"
@ -99,18 +102,28 @@ func initBackend(
emailService *EmailService,
) *BackendApp {
adminThresholdNodeId := "greenhouse_node_id"
greenhouseThresholdServiceId := "greenhouse_https"
toReturn := BackendApp{
WorkingDirectory: workingDirectory,
EmailService: emailService,
Model: model,
DigitalOcean: NewDigitalOceanService(config),
Gandi: NewGandiService(config, freeSubdomainDomain),
FreeSubdomainDomain: freeSubdomainDomain,
BackblazeB2: NewBackblazeB2Service(config),
SSH: NewSSHService(config),
ThresholdProvisioning: NewThresholdProvisioningService(config, pkiService),
ThresholdManagementPort: config.ThresholdManagementPort,
}
WorkingDirectory: workingDirectory,
EmailService: emailService,
Model: model,
DigitalOcean: NewDigitalOceanService(config),
Gandi: NewGandiService(config, freeSubdomainDomain),
FreeSubdomainDomain: freeSubdomainDomain,
BackblazeB2: NewBackblazeB2Service(config),
SSH: NewSSHService(config),
ThresholdProvisioning: NewThresholdProvisioningService(config, pkiService, config.AdminTenantId, adminThresholdNodeId, greenhouseThresholdServiceId),
ThresholdPort: config.ThresholdPort,
ThresholdManagementPort: config.ThresholdManagementPort,
AdminTenantId: config.AdminTenantId,
AdminThresholdNodeId: adminThresholdNodeId,
GreenhouseListenPort: config.FrontendPort,
GreenhouseThresholdServiceId: greenhouseThresholdServiceId,
}
// TODO move this BaseHTTPService into the ThresholdProvisioning service?
toReturn.ClientFactory = func() (*http.Client, *time.Time, error) {
caCert, err := pkiService.GetCACertificate(mainCAName)
@ -119,7 +132,7 @@ func initBackend(
}
expiryTime := time.Now().Add(time.Hour * 24)
cert, err := pkiService.GetTLSCertificate(managementAPIAuthCAName, managementClientCertSubject, []net.IP{}, expiryTime)
cert, err := pkiService.GetClientTLSCertificate(managementAPIAuthCAName, managementClientCertSubject, expiryTime)
if err != nil {
return nil, nil, err
}
@ -855,7 +868,8 @@ func (app *BackendApp) Rebalance() (bool, error) {
existingInstanceTenants := newConfig[instanceId]
for tenantId, tenantConfig := range instanceTenants {
existingTenantConfig, has := existingInstanceTenants[tenantId]
if !has || !existingTenantConfig.DeepEquals(tenantConfig) {
// TODO remove hardcoded true
if !has || !existingTenantConfig.DeepEquals(tenantConfig) || true {
changedInstanceIds[instanceId] = true
}
}
@ -879,6 +893,8 @@ func (app *BackendApp) Rebalance() (bool, error) {
i++
}
log.Println("saving tenants assignments to threshold servers...")
results := doInParallel(false, actions...)
for _, result := range results {
@ -905,14 +921,124 @@ func (app *BackendApp) Rebalance() (bool, error) {
}
}
log.Println("updating tenants free subdomains...")
err = app.Gandi.UpdateFreeSubdomains(freeSubdomains)
if err != nil {
return true, err
}
log.Println("rebalance complete!")
return true, nil
}
func (app *BackendApp) WriteThresholdConfig() error {
tenants, err := app.Model.GetTenants()
if err != nil {
return err
}
for tenantId, tenant := range tenants {
if tenantId == app.AdminTenantId {
err := app.ThresholdProvisioning.WriteClientConfig(
app.AdminTenantId,
fmt.Sprintf("%s.%s", tenant.Subdomain, app.FreeSubdomainDomain),
"TODO_api_key_here",
app.AdminThresholdNodeId,
app.GreenhouseListenPort,
"./threshold/",
)
if err != nil {
log.Println("wrote threshold config!")
}
return err
}
}
return errors.New("admin tenant not found")
}
func (app *BackendApp) ConfigureThresholdServer() error {
log.Println("configuring threshold server...")
billingYear, billingMonth, _ := getBillingTimeInfo()
tenants, err := app.Model.GetTenants()
if err != nil {
return err
}
rows, err := app.Model.GetTenantVPSInstanceRows(billingYear, billingMonth)
if err != nil {
return err
}
vpsInstances, err := app.Model.GetVPSInstances()
if err != nil {
return err
}
actions := []func() taskResult{}
for _, row := range rows {
if row.TenantId == app.AdminTenantId {
vpsInstance, hasVpsInstance := vpsInstances[row.GetVPSInstanceId()]
_, hasTenant := tenants[row.TenantId]
if hasVpsInstance && hasTenant {
actions = append(actions, func() taskResult {
url := fmt.Sprintf("https://%s:%d/tunnels", vpsInstance.IPV4, app.ThresholdPort)
jsonBytes, err := json.Marshal([]ThresholdTunnel{ThresholdTunnel{
ClientId: fmt.Sprintf("%d.%s", app.AdminTenantId, app.AdminThresholdNodeId),
ListenPort: 10001, //TODO 80 and 443
ListenAddress: "0.0.0.0",
// TODO use hostname when switching to https
//ListenHostnameGlob: fmt.Sprintf("%s.%s", tenant.Subdomain, app.FreeSubdomainDomain),
BackEndService: app.GreenhouseThresholdServiceId,
}})
log.Println(string(jsonBytes))
if err != nil {
return taskResult{
Name: row.GetVPSInstanceId(),
Err: errors.Wrapf(
err,
"json serialization error calling %s (threshold admin tenant management API)",
url,
),
}
}
_, err = app.ThresholdProvisioning.MyHTTP200(
"PUT",
url,
bytes.NewBuffer(jsonBytes),
func(request *http.Request) { request.Header.Set("Content-Type", "application/json") },
)
if err != nil {
return taskResult{Name: row.GetVPSInstanceId(), Err: err}
}
return taskResult{Name: row.GetVPSInstanceId(), Err: nil}
})
}
}
}
if len(actions) == 0 {
return errors.New("admin tenant threshold server assignment not found")
}
errorStrings := []string{}
results := doInParallel(false, actions...)
for _, result := range results {
if result.Err != nil {
errorStrings = append(errorStrings, fmt.Sprintf("Can't update threshold tunnels for %s: %+v", result.Name, result.Err))
}
}
if len(errorStrings) > 0 {
return errors.Errorf("ConfigureThresholdServer(): \n%s", strings.Join(errorStrings, "\n"))
}
return nil
}
func getInstanceProjectedUsage(
instance *VPSInstance,
workingAllocations *map[string]map[int]bool,
@ -988,9 +1114,9 @@ func (app *BackendApp) SpawnNewMultitenantInstance() (*VPSInstance, error) {
if len(ips) == 0 {
return nil, errors.Errorf("failed to convert instance IPs '%s' or '%s' to net.IP", instance.IPV4, instance.IPV6)
}
provisionThresholdScript, err := app.ThresholdProvisioning.GetMultitenantInstallScript(instance.GetId(), ips)
provisionThresholdScript, err := app.ThresholdProvisioning.GetServerInstallScript(instance.GetId(), ips)
if err != nil {
return nil, errors.Wrap(err, "failed to ThresholdProvisioning.GetMultitenantInstallScript")
return nil, errors.Wrap(err, "failed to ThresholdProvisioning.GetServerInstallScript")
}
// TODO use potentially different username depending on cloud provider
@ -1064,7 +1190,7 @@ func (app *BackendApp) saveVpsInstanceTenantSettings(
)
}
_, err = app.MyHTTP200(
returnedBytes, err := app.MyHTTP200(
"PUT",
url,
bytes.NewBuffer(jsonBytes),
@ -1074,6 +1200,15 @@ func (app *BackendApp) saveVpsInstanceTenantSettings(
return err
}
log.Printf("returnedBytes: \n%s\n", string(returnedBytes))
returnedBytes2, err := app.MyHTTP200("GET", url, nil, nil)
if err != nil {
return err
}
log.Printf("returnedBytes2: \n%s\n", string(returnedBytes2))
err = app.Model.SaveInstanceConfiguration(billingYear, billingMonth, instance, config)
if err != nil {
return errors.Wrapf(err, "Can't save instance configuration to database for %s", instance.GetId())


+ 3
- 3
db_model.go View File

@ -655,7 +655,7 @@ func (model *DBModel) PutKeyPair(caName, name string, key, cert []byte) error {
return errors.Wrap(err, "PutKeyPair(): ")
}
func (model *DBModel) GetKeyPair(caName, name string) ([]byte, []byte, error) {
func (model *DBModel) GetServerKeyPair(caName, name string) ([]byte, []byte, error) {
var key, cert []byte
rows, err := model.DB.Query(`
@ -665,12 +665,12 @@ func (model *DBModel) GetKeyPair(caName, name string) ([]byte, []byte, error) {
caName, name,
)
if err != nil {
return nil, nil, errors.Wrap(err, "GetKeyPair(): ")
return nil, nil, errors.Wrap(err, "GetServerKeyPair(): ")
}
for rows.Next() {
err = rows.Scan(&key, &cert)
if err != nil {
return nil, nil, errors.Wrap(err, "GetKeyPair(): ")
return nil, nil, errors.Wrap(err, "GetServerKeyPair(): ")
}
return key, cert, nil
}


+ 497
- 0
docker-compose.yml View File

@ -0,0 +1,497 @@
version: "3.3"
services:
caddy:
image: caddy:2.3.0
networks:
- blog
- internet-access
- sequentialread
- gitea
ports:
- "80:80"
- "443:443"
command: ["/bin/sh", "-c", "rm -f /caddysocket/caddy.sock && caddy run -config /config/config.json"]
volumes:
- type: bind
source: ./caddy/static
target: /srv/static
read_only: true
- type: bind
source: ./caddy/config
target: /config
read_only: true
- type: bind
source: ./caddy/data
target: /data
- type: bind
source: ./caddy/log
target: /var/log
- type: volume
source: caddy-socket-volume
target: /caddysocket
caddy-config:
image: sequentialread/caddy-config:0.0.13
#mem_limit: 50m
networks:
- sequentialread
volumes:
- type: bind
source: ./security-gateway/
target: /dockersocket/
- type: volume
source: caddy-socket-volume
target: /caddysocket
environment:
- DOCKER_SOCKET=/dockersocket/docker.sock
- CADDY_ACME_DOMAINS_CSV=*.sequentialread.com
- CADDY_ACME_ISSUER_URL=https://acme-v02.api.letsencrypt.org/directory
- CADDY_ACME_CLIENT_EMAIL_ADDRESS=forest.n.johnson@gmail.com
docker-api-security-gateway:
image: sequentialread/docker-api-security-gateway:0.0.16
#mem_limit: 50m
userns_mode: "host"
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
read_only: true
- type: bind
source: ./security-gateway/
target: /var/run/docker-api-security-gateway/
environment:
DOCKER_API_PROXY_DEBUG: "false"
DOCKER_API_PROXY_ALLOW_0_METHODREGEX: ^GET$$
DOCKER_API_PROXY_ALLOW_0_URIREGEX: ^/info(\?)?$$
DOCKER_API_PROXY_ALLOW_1_METHODREGEX: ^GET$$
DOCKER_API_PROXY_ALLOW_1_URIREGEX: ^/containers/json(\?)?((filters=%7B%22status%22%3A%5B%22running%22%5D%7D&)?limit=0)?$$
DOCKER_API_PROXY_ALLOW_2_METHODREGEX: ^GET$$
DOCKER_API_PROXY_ALLOW_2_URIREGEX: ^/containers/[a-f0-9]+/stats(\?)?(stream=0)?$$
DOCKER_API_PROXY_ALLOW_3_METHODREGEX: ^GET$$
DOCKER_API_PROXY_ALLOW_3_URIREGEX: ^/containers/[a-f0-9]+/json(\?)?$$
DOCKER_API_PROXY_RESULTFILTERS_0_METHODREGEX: .*
DOCKER_API_PROXY_RESULTFILTERS_0_URIREGEX: ^/containers/[a-f0-9]+/json(\?)?$$
DOCKER_API_PROXY_RESULTFILTERS_0_BLACKLIST_0_PATH: .Config.Env
DOCKER_API_PROXY_HTTP_LISTENUNIXSOCKET: /var/run/docker-api-security-gateway/docker.sock
DOCKER_API_PROXY_HTTP_LISTENUNIXSOCKETUID: 165536
DOCKER_API_PROXY_HTTP_LISTENUNIXSOCKETGID: 165536
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_API_VERSION: '1.40'
goatcounter:
image: sequentialread/goatcounter:1.4.2-9
restart: always
entrypoint: ["/bin/sh"]
command: ["-c", "/app/goatcounter migrate -createdb all && /app/goatcounter serve -tls none -listen '*:8080'"]
networks:
sequentialread:
ipv4_address: ${SEQUENTIALREAD_NETWORK_GOATCOUNTER_IPV4}
volumes:
- type: bind
source: ./goatcounter/db
target: /app/db
labels:
sequentialread-8080-public-port: 443
sequentialread-8080-public-protocol: https
sequentialread-8080-public-hostnames: "goatcounter.sequentialread.com,goatcounter.beta.sequentialread.com"
sequentialread-8080-container-protocol: http
goatcounter-log-publisher:
image: sequentialread/goatcounter:1.4.2-34
restart: always
entrypoint: ["/bin/sh"]
command: ["-c", "tail -F /caddylog/caddy-goatcounter.log | ./goatcounter-caddy-log-adapter | ./goatcounter import -site http://goatcounter.sequentialread.com:8080 -format combined-vhost -- -"]
extra_hosts:
- "goatcounter.beta.sequentialread.com:${SEQUENTIALREAD_NETWORK_GOATCOUNTER_IPV4}"
- "goatcounter.sequentialread.com:${SEQUENTIALREAD_NETWORK_GOATCOUNTER_IPV4}"
volumes:
- type: bind
source: ./goatcounter/db
target: /app/db
- type: bind
source: ./caddy/log
target: /caddylog
networks:
- sequentialread
environment:
- GOATCOUNTER_API_KEY=${GOATCOUNTER_API_KEY}
- LOGADAPTER_INCLUDESUCCESSORFAILUREINKEY=false
- LOGADAPTER_DEBUG=false
- LOGADAPTER_ALWAYSINCLUDEURISCSV=,
- LOGADAPTER_BLACKLISTIPSCSV=192.168.0.1,192.168.0.46,71.34.24.79
- LOGADAPTER_DOMAINS_0_MATCHHOSTNAMEREGEX=^(www\.)?((grafana|git|stream|pwm|captcha|comments)\.)?(beta\.)?sequentialread.com
- LOGADAPTER_DOMAINS_0_CONTENTTYPEWHITELISTREGEX=[^/]+/html
- LOGADAPTER_DOMAINS_1_MATCHHOSTNAMEREGEX=(goatcounter|influxdb)
- LOGADAPTER_DOMAINS_1_CONTENTTYPEWHITELISTREGEX=DROP_ALL
influxdb:
image: influxdb:1.8.4
restart: always
networks:
- sequentialread
volumes:
- type: bind
source: ./influxdb/data/
target: /var/lib/influxdb
environment:
- INFLUXDB_REPORTING_DISABLED=true
- INFLUXDB_HTTP_AUTH_ENABLED=true
- INFLUXDB_HTTP_PPROF_AUTH_ENABLED=true
- INFLUXDB_HTTP_DEBUG_PPROF_ENABLED=true
labels:
sequentialread-8086-public-port: 443
sequentialread-8086-public-protocol: https
sequentialread-8086-public-hostnames: "influxdb.sequentialread.com,influxdb.beta.sequentialread.com"
sequentialread-8086-container-protocol: http
telegraf:
image: telegraf:1.17.3
restart: always
# in order to get telegraf to report the host's network io correctly,
# it has to run on the host network... and the only way to do that
# would be to disable the user namespace remap. so we manually set it to the
# remapped UID and GID
userns_mode: "host"
network_mode: "host"
user: 165536:165536
# now that we are on the hosts network, in order to talk to influx we have to go in through the
# front door: Caddy, which is listening on 80 and 443 on localhost.
extra_hosts:
- "influxdb.beta.sequentialread.com:127.0.0.1"
- "influxdb.sequentialread.com:127.0.0.1"
entrypoint: /bin/sh
# this runs telegraf and then ends the container process when telegraf logs "Cannot connect to the Docker daemon"
# Just a simple way to forcing it to restart when the docker daemon socket file gets pulled out from under it
command:
- "-c"
- |
echo '' > t.log
sh -c '/entrypoint.sh --config /telegrafconfig/telegraf.conf 2>&1 | tee t.log' &
tpid=$$?
tail -f t.log | grep -q 'Cannot connect to the Docker daemon'
kill $$tpid
volumes:
- type: bind
source: ./security-gateway/
target: /dockersocket/
- type: bind
source: /
target: /hostfs
read_only: true
- type: bind
source: ./telegraf
target: /telegrafconfig
read_only: true
depends_on:
- docker-api-security-gateway
environment:
- HOST_MOUNT_PREFIX=/hostfs
- HOST_PROC=/hostfs/proc
- TELEGRAF_DOCKER_SOCKET=/dockersocket/docker.sock
- TELEGRAF_INFLUX_PASSWORD=${TELEGRAF_INFLUX_PASSWORD}
- TELEGRAF_INFLUX_URL=https://influxdb.sequentialread.com
grafana:
image: grafana/grafana:7.4.3
# The Grafana docker container is missing the /etc/nsswitch.conf file which
# many things rely on in order to know how to properly look up domain names.
# See: https://github.com/gliderlabs/docker-alpine/issues/367
# without this file, grafana will only use the dns server to resolve names,
# it won't look at the /etc/hosts file at all.
# Since grafana normally runs as a separate user, we have to override the container to run as root 1st
# so it can write the nsswitch.conf file, then run the grafana start script as the grafana user.
user: '0'
entrypoint: /bin/sh
command: ["-c", "echo 'hosts: files dns' > /etc/nsswitch.conf && su -s '/bin/sh' -c '/run.sh' grafana"]
extra_hosts:
- "smtp.gmail.com:${SEQUENTIALREAD_NETWORK_EXTERNAL_SERVICE_IPV4}"
networks:
- sequentialread
volumes:
- type: bind
source: ./grafana/data/
target: /var/lib/grafana
environment:
GF_SERVER_ROOT_URL: 'https://grafana.sequentialread.com'
GF_SERVER_ENABLE_GZIP: 'true'
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
GF_SECURITY_DISABLE_GRAVATAR: 'true'
GF_SECURITY_COOKIE_SECURE: 'true'
GF_SECURITY_SECRET_KEY: ${GRAFANA_SECRET_KEY}
GF_AUTH_ANONYMOUS_ENABLED: 'true'
GF_AUTH_ANONYMOUS_ORG_NAME: sequentialread
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
GF_SMTP_ENABLED: 'true'
GF_SMTP_HOST: 'smtp.gmail.com:465'
GF_SMTP_PASSWORD: ${GMAIL_PASSWORD}
GF_SMTP_USER: gitlab.sequentialread.com@gmail.com
GF_SMTP_FROM_ADDRESS: gitlab.sequentialread.com@gmail.com
GF_SMTP_STARTTLS_POLICY: MandatoryStartTLS
labels:
sequentialread-3000-public-port: 443
sequentialread-3000-public-protocol: https
sequentialread-3000-public-hostnames: "grafana.sequentialread.com,grafana.beta.sequentialread.com"
sequentialread-3000-container-protocol: http
sequentialread-external-service:
image: sequentialread/external-service:0.0.14
restart: always
networks:
sequentialread:
ipv4_address: ${SEQUENTIALREAD_NETWORK_EXTERNAL_SERVICE_IPV4}
internet-access:
environment:
DEBUG_LOG: 'true'
SERVICE_0_LISTEN: ':465'
SERVICE_0_DIAL: 'smtp.gmail.com:465'
SERVICE_1_LISTEN: ':8080'
SERVICE_1_DIAL: '192.168.0.46:8080'
pwm:
image: sequentialread/sequentialread-password-manager:2.0.6
restart: always
networks:
- sequentialread
environment:
- SEQUENTIALREAD_PWM_BACKBLAZE_BUCKET_NAME=sequentialread-password-manager
- SEQUENTIALREAD_PWM_BACKBLAZE_BUCKET_REGION=us-west-000
- SEQUENTIALREAD_PWM_BACKBLAZE_ACCESS_KEY_ID=0003ea77f5997840000000015
- SEQUENTIALREAD_PWM_BACKBLAZE_SECRET_ACCESS_KEY=${PWM_BACKBLAZE_SECRET_ACCESS_KEY}
volumes:
- type: bind
source: ./pwm/data/
target: /app/data/
labels:
sequentialread-8073-public-port: 443
sequentialread-8073-public-protocol: https
sequentialread-8073-public-hostnames: "pwm.sequentialread.com,pwm.beta.sequentialread.com"
sequentialread-8073-container-protocol: http
picopublish:
image: sequentialread/picopublish:0.1.7
restart: always
networks:
- sequentialread
environment:
- PICO_PUBLISH_PASSWORD=${PICOPUBLISH_PASSWORD}
volumes:
- type: bind
source: ./picopublish/data
target: /app/data
labels:
sequentialread-8080-public-port: 443
sequentialread-8080-public-protocol: https
sequentialread-8080-public-hostnames: "picopublish.sequentialread.com,picopublish.beta.sequentialread.com"
sequentialread-8080-container-protocol: http
webclip:
image: sequentialread/webclip:0.0.2
restart: always
networks:
- sequentialread
labels:
sequentialread-8080-public-port: 443
sequentialread-8080-public-protocol: https
sequentialread-8080-public-hostnames: "webclip.sequentialread.com,webclip.beta.sequentialread.com"
sequentialread-8080-container-protocol: http
gitea:
image: sequentialread/gitea-armv7:1.13.2
restart: always
mem_limit: 600m
ports:
- "10022:10022"
networks:
- gitea
volumes:
- type: bind
source: ./gitea/data
target: /data
environment:
- APP_NAME='SequentialRead Git'
- UI_META_AUTHOR='Forest Johnson (SequentialRead)'
- UI_META_DESCRIPTION='Forest's self-hosted git server for sequentialread.com'
- SERVER_LANDING_PAGE='explore'
- SSH_PORT=10022
- SSH_LISTEN_PORT=10022
- ROOT_URL=https://git.beta.sequentialread.com
- DISABLE_REGISTRATION=true
- INSTALL_LOCK=true
- SECRET_KEY=${GITEA_SECRET_KEY}
- DB_TYPE=mysql
- DB_HOST=gitea-mariadb
- DB_NAME=gitea
- DB_USER=gitea
- DB_PASSWD=${GITEA_MARIADB_GITEA_PASSWORD}
depends_on:
- gitea-mariadb
labels:
sequentialread-3000-public-port: 443
sequentialread-3000-public-protocol: https
sequentialread-3000-public-hostnames: "www.git.sequentialread.com,git.sequentialread.com,git.beta.sequentialread.com"
sequentialread-3000-container-protocol: http
gitea-mariadb:
image: sequentialread/mariadb-armv7:10.3
restart: always
networks:
- gitea
volumes:
- type: bind
source: ./gitea-mariadb/
target: /var/lib/mysql
environment:
- MYSQL_ROOT_PASSWORD=${GITEA_MARIADB_ROOT_PASSWORD}
- MYSQL_DATABASE=gitea
- MYSQL_USER=gitea
- MYSQL_PASSWORD=${GITEA_MARIADB_GITEA_PASSWORD}
stream:
image: sequentialread/owncast-caching-proxy:0.0.18
networks:
- sequentialread
command: '${SEQUENTIALREAD_NETWORK_EXTERNAL_SERVICE_IPV4}:8080'
volumes:
- type: bind
source: ./owncast-caching-proxy/cache
target: /app/cache
environment:
DEBUG: 0
labels:
sequentialread-8080-public-port: 443
sequentialread-8080-public-protocol: https
sequentialread-8080-public-hostnames: "stream.sequentialread.com,stream.beta.sequentialread.com"
sequentialread-8080-container-protocol: http
ghost:
image: ghost:3.41-alpine
restart: always
extra_hosts:
- "smtp.gmail.com:${BLOG_NETWORK_EXTERNAL_SERVICE_IPV4}"
networks:
- blog
volumes:
- type: bind
source: ./ghost
target: /var/lib/ghost/content
environment:
- NODE_ENV=production
- url=https://sequentialread.com
- database__client=sqlite3
- database__connection__filename=content/data/ghost-prod.db
- database__useNullAsDefault=true
- database__debug=false
- mail__from=gitlab.sequentialread.com@gmail.com
- mail__transport=SMTP
- mail__options__host=smtp.gmail.com
- mail__options__port=465
- mail__options__auth__user=gitlab.sequentialread.com@gmail.com
- mail__options__auth__pass=${GMAIL_PASSWORD}
labels:
sequentialread-2368-public-port: 443
sequentialread-2368-public-protocol: https
sequentialread-2368-public-hostnames: "sequentialread.com,www.sequentialread.com,beta.sequentialread.com,www.beta.sequentialread.com"
sequentialread-2368-container-protocol: http
comments:
image: sequentialread/comments:0.1.45
restart: always
extra_hosts:
- "smtp.gmail.com:${BLOG_NETWORK_EXTERNAL_SERVICE_IPV4}"
- "captcha.sequentialread.com:${BLOG_NETWORK_EXTERNAL_SERVICE_IPV4}"
networks:
- blog
volumes:
- type: bind
source: ./comments
target: /app/data
environment:
- COMMENTS_LISTEN_PORT=8080
- COMMENTS_BASE_URL=https://comments.sequentialread.com
- COMMENTS_HASH_SALT=klnv5ii043nbkjz__g34nnk_34wgn26lqlwqb7841mf
- COMMENTS_CORS_ORIGINS=https://sequentialread.com,https://www.sequentialread.com,https://beta.sequentialread.com,https://www.beta.sequentialread.com
- COMMENTS_CAPTCHA_API_TOKEN=${CAPTCHA_API_TOKEN}
- COMMENTS_CAPTCHA_URL=https://captcha.sequentialread.com
- COMMENTS_CAPTCHA_DIFFICULTY_LEVEL=8
- COMMENTS_EMAIL_HOST=smtp.gmail.com
- COMMENTS_EMAIL_PORT=465
- COMMENTS_EMAIL_USER=gitlab.sequentialread.com@gmail.com
- COMMENTS_EMAIL_PASSWORD=${GMAIL_PASSWORD}
- COMMENTS_NOTIFICATION_TARGET=forest.n.johnson@gmail.com
- COMMENTS_ADMIN_PASSWORD=${COMMENTS_ADMIN_PASSWORD}
labels:
sequentialread-8080-public-port: 443
sequentialread-8080-public-protocol: https
sequentialread-8080-public-hostnames: "comments.sequentialread.com,comments.beta.sequentialread.com"
sequentialread-8080-container-protocol: http
blog-external-service:
image: sequentialread/external-service:0.0.14
restart: always
networks:
blog:
ipv4_address: ${BLOG_NETWORK_EXTERNAL_SERVICE_IPV4}
internet-access:
environment:
DEBUG_LOG: 'true'
SERVICE_0_LISTEN: ':465'
SERVICE_0_DIAL: 'smtp.gmail.com:465'
SERVICE_1_LISTEN: ':443'
SERVICE_1_DIAL: '172.17.0.1:443'
captcha:
image: sequentialread/pow-captcha:0.0.11
restart: always
networks:
- blog
volumes:
- type: bind
source: ./captcha/tokens
target: /app/PoW_Captcha_API_Tokens
environment:
- POW_CAPTCHA_ADMIN_API_TOKEN=${CAPTCHA_ADMIN_API_TOKEN}
labels:
sequentialread-2370-public-port: 443
sequentialread-2370-public-protocol: https
sequentialread-2370-public-hostnames: "captcha.sequentialread.com,captcha.beta.sequentialread.com"
sequentialread-2370-container-protocol: http
networks:
internet-access:
driver: bridge
ipam:
driver: default
config:
- subnet: "${INTERNET_ACCESS_NETWORK_CIDR}"
gitea:
driver: bridge
internal: true
ipam:
driver: default
config:
- subnet: "${GITEA_NETWORK_CIDR}"
sequentialread:
driver: bridge
internal: true
ipam:
driver: default
config:
- subnet: "${SEQUENTIALREAD_NETWORK_CIDR}"
blog:
driver: bridge
internal: true
ipam:
driver: default
config:
- subnet: "${BLOG_NETWORK_CIDR}"
volumes:
caddy-socket-volume:

+ 18
- 0
frontend.go View File

@ -377,6 +377,24 @@ func initFrontend(workingDirectory string, config *Config, model *DBModel, backe
}
})()
} else if action == "configure_threshold_client" {
err := app.Backend.WriteThresholdConfig()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("configure_threshold_client failed: %+v\n", err))
log.Printf("configure_threshold_client failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", "wrote threshold client config!\n")
log.Println("wrote threshold client config!")
}
} else if action == "configure_threshold_server" {
err := app.Backend.ConfigureThresholdServer()
if err != nil {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("configure_threshold_server failed: %+v\n", err))
log.Printf("configure_threshold_server failed: %+v\n", err)
} else {
app.setFlash(responseWriter, session, "info", "configured threshold server!\n")
log.Println("configured threshold server!")
}
} else {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("Unknown action '%s'\n", action))
}


+ 14
- 0
frontend/admin.gotemplate.html View File

@ -15,6 +15,20 @@
<input type="submit" value="rebalance"/>
</form>
</li>
<li>
<form method="POST" action="#">
<input type="hidden" name="hashOfSessionId" value="{{ .HashOfSessionId }}"/>
<input type="hidden" name="action" value="configure_threshold_client"/>
<input type="submit" value="configure_threshold_client"/>
</form>
</li>
<li>
<form method="POST" action="#">
<input type="hidden" name="hashOfSessionId" value="{{ .HashOfSessionId }}"/>
<input type="hidden" name="action" value="configure_threshold_server"/>
<input type="submit" value="configure_threshold_server"/>
</form>
</li>
</ul>
</div>


+ 1
- 0
main.go View File

@ -32,6 +32,7 @@ type Config struct {
BackblazeBucketName string
BackblazeKeyId string
BackblazeSecretKey string
ThresholdPort int
ThresholdManagementPort int
ThresholdManagementClientKey string
ThresholdManagementClientCertificate string


+ 40
- 18
pki_service.go View File

@ -35,14 +35,13 @@ func NewPKIService(config *Config, db *DBModel) *PKIService {
}
}
func (service *PKIService) GetTLSCertificate(
func (service *PKIService) GetClientTLSCertificate(
caName string,
subject string,
ipAddresses []net.IP,
expiry time.Time,
) (tls.Certificate, error) {
key, cert, err := service.GetKeyPair(caName, subject, ipAddresses, expiry)
key, cert, err := service.GetClientKeyPair(caName, subject, []string{subject}, expiry)
if err != nil {
return tls.Certificate{}, err
}
@ -59,33 +58,56 @@ func (service *PKIService) GetTLSCertificate(
)
}
func (service *PKIService) GetKeyPair(
func (service *PKIService) GetClientKeyPair(
caName string,
subject string,
emailAddresses []string,
expiry time.Time,
) (*rsa.PrivateKey, *x509.Certificate, error) {
return service.GetKeyPairImpl(caName, subject, nil, emailAddresses, expiry)
}
func (service *PKIService) GetServerKeyPair(
caName string,
subject string,
ipAddresses []net.IP,
expiry time.Time,
) (*rsa.PrivateKey, *x509.Certificate, error) {
return service.GetKeyPairImpl(caName, subject, ipAddresses, nil, expiry)
}
func (service *PKIService) GetKeyPairImpl(
caName string,
subject string,
ipAddresses []net.IP,
emailAddresses []string,
expiry time.Time,
) (*rsa.PrivateKey, *x509.Certificate, error) {
signer, err := service.getCA(caName)
if err != nil {
return nil, nil, err
}
request := easypki.Request{
Name: subject,
Template: &x509.Certificate{
NotBefore: time.Now().Add(-time.Hour),
NotAfter: expiry,
IsCA: false,
Subject: getSubject(subject),
},
}
if ipAddresses != nil {
request.Template.IPAddresses = ipAddresses
}
if emailAddresses != nil {
request.Template.EmailAddresses = emailAddresses
}
bundle, err := service.EasyPKI.GetBundle(signer.Name, subject)
if err == ErrDoesNotExist || time.Now().After(bundle.Cert.NotAfter) {
err = service.EasyPKI.Sign(
signer,
&easypki.Request{
Name: subject,
Template: &x509.Certificate{
NotBefore: time.Now().Add(-time.Hour),
NotAfter: expiry,
IsCA: false,
Subject: getSubject(subject),
IPAddresses: ipAddresses,
},
},
)
err = service.EasyPKI.Sign(signer, &request)
if err != nil {
return nil, nil, err
}
@ -140,7 +162,7 @@ func (store *GreenhouseEasyPKIStore) Add(caName, name string, isCa bool, key, ce
}
func (store *GreenhouseEasyPKIStore) Fetch(caName, name string) ([]byte, []byte, error) {
key, cert, err := store.DB.GetKeyPair(caName, name)
key, cert, err := store.DB.GetServerKeyPair(caName, name)
if key == nil || cert == nil {
return nil, nil, ErrDoesNotExist
}


+ 13
- 0
threshold/install.sh View File

@ -0,0 +1,13 @@
#!/bin/bash
ARCH=amd64
rm -f ./threshold
curl -sS "https://f000.backblazeb2.com/file/server-garden-artifacts/threshold-$ARCH.tar.gz" > "./threshold-$ARCH.tar.gz"
tar -x -f "./threshold-$ARCH.tar.gz"
chmod +x ./threshold
rm "./threshold-$ARCH.tar.gz"

+ 6
- 0
threshold/remote_install.sh View File

@ -0,0 +1,6 @@
#!/bin/bash
remote=$1
base64InstallScript="$(cat src_remote_install.sh | base64 -w 0)"
echo "sh -c 'echo $base64InstallScript | base64 -d | sh'" | ssh "root@$remote"

+ 3
- 0
threshold/run.sh View File

@ -0,0 +1,3 @@
#!/bin/bash
./threshold -mode client -configFile config.json

+ 20
- 0
threshold/src_remote_install.sh View File

@ -0,0 +1,20 @@
cd /opt/threshold
systemctl stop threshold
rm -f threshold
curl -sS "https://f000.backblazeb2.com/file/server-garden-artifacts/threshold-amd64.tar.gz" > "./threshold-amd64.tar.gz"
tar -x -f "./threshold-amd64.tar.gz"
chmod +x ./threshold
rm "./threshold-amd64.tar.gz"
systemctl start threshold
sleep 1
journalctl -u threshold -n 50 --no-pager

+ 145
- 10
threshold_provisioning_service.go View File

@ -1,28 +1,166 @@
package main
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"net"
"net/http"
"path"
"strconv"
"strings"
"time"
errors "git.sequentialread.com/forest/pkg-errors"
)
const mainCAName = "greenhouse_CA"
const managementAPIAuthCAName = "greenhouse_management_api_auth_CA"
const thresholdCertsDomain = "greenhouse.server.garden"
type ThresholdProvisioningService struct {
PKI *PKIService
BaseHTTPService
PKI *PKIService
GreenhouseThresholdServiceId string
}
type ThresholdTunnel struct {
ClientId string
ListenPort int
ListenAddress string
ListenHostnameGlob string
BackEndService string
HaProxyProxyProtocol bool
}
func NewThresholdProvisioningService(config *Config, pkiService *PKIService, adminTenantId int, adminThresholdNodeId, greenhouseThresholdServiceId string) *ThresholdProvisioningService {
toReturn := &ThresholdProvisioningService{
PKI: pkiService,
GreenhouseThresholdServiceId: greenhouseThresholdServiceId,
}
toReturn.ClientFactory = func() (*http.Client, *time.Time, error) {
clientId := fmt.Sprintf("%d.%s", adminTenantId, adminThresholdNodeId)
adminTenantClientCertSubject := fmt.Sprintf("%s@%s", clientId, thresholdCertsDomain)
caCert, err := pkiService.GetCACertificate(mainCAName)
if err != nil {
return nil, nil, err
}
expiryTime := time.Now().Add(time.Hour * 24)
cert, err := pkiService.GetClientTLSCertificate(mainCAName, adminTenantClientCertSubject, expiryTime)
if err != nil {
return nil, nil, err
}
caCertPool := x509.NewCertPool()
caCertPool.AddCert(caCert)
tlsClientConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caCertPool,
}
tlsClientConfig.BuildNameToCertificate()
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsClientConfig,
},
Timeout: 10 * time.Second,
}, &expiryTime, nil
}
return toReturn
}
func NewThresholdProvisioningService(config *Config, pkiService *PKIService) *ThresholdProvisioningService {
func (service *ThresholdProvisioningService) WriteClientConfig(
tenantId int,
greenhouseDomain string,
greenhouseAPIKey string,
nodeId string,
greenhouseListenPort int,
clientDirectory string,
) error {
clientId := fmt.Sprintf("%d.%s", tenantId, nodeId)
certificateSubject := fmt.Sprintf("%s@%s", clientId, thresholdCertsDomain)
clientConfig := `
{
"DebugLog": false,
"ClientId": "{{clientId}}",
"GreenhouseDomain": "{{greenhouseDomain}}",
"GreenhouseAPIKey": "{{greenhouseAPIKey}}",
"ServiceToLocalAddrMap": {
"{{greenhouseThresholdServiceId}}": "127.0.0.1:{{greenhouseListenPort}}"
},
"CaCertificateFilesGlob": "greenhouse_CA.crt",
"ClientTlsKeyFile": "{{certificateSubject}}.key",
"ClientTlsCertificateFile": "{{certificateSubject}}.crt"
}`
return &ThresholdProvisioningService{
PKI: pkiService,
substitutions := map[string]string{
"clientId": clientId,
"certificateSubject": certificateSubject,
"greenhouseDomain": greenhouseDomain,
"greenhouseAPIKey": greenhouseAPIKey,
"greenhouseListenPort": strconv.Itoa(greenhouseListenPort),
"greenhouseThresholdServiceId": service.GreenhouseThresholdServiceId,
}
for k, v := range substitutions {
clientConfig = strings.ReplaceAll(clientConfig, fmt.Sprintf("{{%s}}", k), v)
}
err := ioutil.WriteFile(path.Join(clientDirectory, "config.json"), []byte(clientConfig), 0755)
if err != nil {
return err
}
mainCA, err := service.PKI.GetCACertificate(mainCAName)
if err != nil {
return errors.Wrap(err, "GetCACertificate")
}
mainCABytes := pem.EncodeToMemory(&pem.Block{
Bytes: mainCA.Raw,
Type: "CERTIFICATE",
})
err = ioutil.WriteFile(path.Join(clientDirectory, "greenhouse_CA.crt"), mainCABytes, 0755)
if err != nil {
return err
}
expiry := time.Now().Add(time.Hour * time.Duration(24*31*12*99))
thresholdKey, thresholdCert, err := service.PKI.GetClientKeyPair(mainCAName, certificateSubject, []string{certificateSubject}, expiry)
thresholdKeyBytes := pem.EncodeToMemory(&pem.Block{
Bytes: x509.MarshalPKCS1PrivateKey(thresholdKey),
Type: "RSA PRIVATE KEY",
})
thresholdCertBytes := pem.EncodeToMemory(&pem.Block{
Bytes: thresholdCert.Raw,
Type: "CERTIFICATE",
})
err = ioutil.WriteFile(path.Join(clientDirectory, fmt.Sprintf("%s.key", certificateSubject)), thresholdKeyBytes, 0755)
if err != nil {
return err
}
err = ioutil.WriteFile(path.Join(clientDirectory, fmt.Sprintf("%s.crt", certificateSubject)), thresholdCertBytes, 0755)
if err != nil {
return err
}
return nil
}
func (service *ThresholdProvisioningService) GetMultitenantInstallScript(tlsCertificateSubject string, tlsCertificateIps []net.IP) (string, error) {
func (service *ThresholdProvisioningService) GetServerInstallScript(tlsCertificateSubject string, tlsCertificateIps []net.IP) (string, error) {
installScript := `#!/bin/bash
@ -179,11 +317,8 @@ WantedBy=multi-user.target
Type: "CERTIFICATE",
})
// TODO make the subject based on the instance id. Like digitalocean-xyz123.greenhouse.server.garden
// this way the cert will be unique to each instance and its harder to steal/exploit
// this would require either DNS or manual validation by the clients.
expiry := time.Now().Add(time.Hour * time.Duration(24*31*12*99))
thresholdKey, thresholdCert, err := service.PKI.GetKeyPair(mainCAName, tlsCertificateSubject, tlsCertificateIps, expiry)
thresholdKey, thresholdCert, err := service.PKI.GetServerKeyPair(mainCAName, tlsCertificateSubject, tlsCertificateIps, expiry)
thresholdKeyBytes := pem.EncodeToMemory(&pem.Block{
Bytes: x509.MarshalPKCS1PrivateKey(thresholdKey),
@ -197,7 +332,7 @@ WantedBy=multi-user.target
substitutions := map[string]string{
"ARTIFACTS_BASE_URL": "https://f000.backblazeb2.com/file/server-garden-artifacts",
"ARCH": "amd64",
"TAR_SHA256": "896065ff8c723df967c7a626d06f6ff0fb553cf256ddb97a36ce800e74c8654d",
"TAR_SHA256": "3f9a0e0d92d3b2073392cff5ebc2bccfb1caca067163c624a96cbc6075a5bd37",
"THRESHOLD_DOMAIN": "greenhouse.server.garden",
"GREENHOUSE_MANAGEMENT_API_AUTH_CA": string(managementAPIAuthCABytes),
"GREENHOUSE_CA": string(mainCABytes),


Loading…
Cancel
Save