Browse Source

docker deployment and telemetry

master
forest 1 month ago
parent
commit
53b21090f8
22 changed files with 545 additions and 618 deletions
  1. +8
    -0
      .dockerignore
  2. +2
    -0
      .gitignore
  3. +44
    -0
      Dockerfile
  4. +41
    -0
      build-docker.sh
  5. +0
    -497
      docker-compose.yml
  6. +34
    -0
      example_config.json
  7. +78
    -12
      frontend.go
  8. +6
    -6
      frontend/alpha-profile.gotemplate.html
  9. +28
    -27
      frontend/static/image-sources/generate_webp.sh
  10. +2
    -2
      frontend/static/install.sh
  11. +6
    -6
      frontend_admin_panel.go
  12. +6
    -3
      frontend_howto.go
  13. +83
    -23
      frontend_login.go
  14. +40
    -26
      frontend_profile.go
  15. +4
    -0
      gandi_service.go
  16. +1
    -1
      ingress_service.go
  17. +72
    -1
      main.go
  18. +5
    -5
      public_api.go
  19. +12
    -0
      scheduled_tasks.go
  20. +64
    -0
      telemetry.go
  21. +5
    -4
      threshold/src_remote_install.sh
  22. +4
    -5
      threshold_provisioning_service.go

+ 8
- 0
.dockerignore View File

@ -0,0 +1,8 @@
threshold
releases
postgres-data
greenhouse-daemon
dockerbuild
threshold
readme
postgres-data

+ 2
- 0
.gitignore View File

@ -24,3 +24,5 @@ greenhouse-daemon/daemon.log
greenhouse-daemon/old
greenhouse-daemon/old2
greenhouse-daemon/old3
dockerbuild
postgres-data

+ 44
- 0
Dockerfile View File

@ -0,0 +1,44 @@
FROM golang:1.15.2-alpine as build
ARG GOARCH=
ARG GO_BUILD_ARGS=
RUN mkdir /build
WORKDIR /build
COPY . .
RUN go build -v $GO_BUILD_ARGS -o /build/greenhouse .
FROM alpine
WORKDIR /greenhouse
RUN apk add --no-cache openssh-client
COPY --from=build /build/greenhouse /greenhouse/greenhouse
# for now I think this will be set up manually.
# RUN mkdir -p /greenhouse/greenhouse-daemon && \
# curl -sS -o "daemon.gz" "https://picopublish.sequentialread.com/files/greenhouse-daemon-alpha-rc0-315e67e-82d8-linux-$GOARCH.gz" && \
# gzip --stdout --decompress "daemon.gz" > "/greenhouse/greenhouse-daemon/greenhouse-daemon" && \
# rm "daemon.gz" && chmod +x "/greenhouse/greenhouse-daemon/greenhouse-daemon" && \
# curl -sS -o "threshold.gz" "https://picopublish.sequentialread.com/files/threshold-0.0.0-6cfcabd-f27e-linux-$GOARCH.gz" && \
# gzip --stdout --decompress "threshold.gz" > "/greenhouse/greenhouse-daemon/greenhouse-threshold" && \
# rm "threshold.gz" && chmod +x "/greenhouse/greenhouse-daemon/greenhouse-threshold" && \
# curl -sS -o "caddy.gz" "https://picopublish.sequentialread.com/files/caddy-v2.4.0-beta.2-forest-078f12e0-b4a8-linux-$GOARCH.gz" && \
# gzip --stdout --decompress "caddy.gz" > "/greenhouse/greenhouse-daemon/greenhouse-caddy" && \
# rm "caddy.gz" && chmod +x "/greenhouse/greenhouse-daemon/greenhouse-caddy" && \
# echo '{
# "admin": {
# "disabled": false,
# "listen": "unix///var/run/greenhouse-daemon-caddy-admin.sock",
# "config": {
# "persist": false
# }
# }
# }' > /greenhouse/greenhouse-daemon/caddy-config.json
COPY ./example_config.json /greenhouse/config.json
COPY ./frontend /greenhouse/frontend
COPY ./schema_versions /greenhouse/schema_versions
ENTRYPOINT ["/greenhouse/greenhouse"]

+ 41
- 0
build-docker.sh View File

@ -0,0 +1,41 @@
#!/bin/bash -e
VERSION="0.0.6"
rm -rf dockerbuild || true
mkdir dockerbuild
#cp Dockerfile dockerbuild/Dockerfile-amd64
cp Dockerfile dockerbuild/Dockerfile-arm
#cp Dockerfile dockerbuild/Dockerfile-arm64
#sed -E 's|FROM alpine|FROM amd64/alpine|' -i dockerbuild/Dockerfile-amd64
sed -E 's|FROM alpine|FROM arm32v7/alpine|' -i dockerbuild/Dockerfile-arm
#sed -E 's|FROM alpine|FROM arm64v8/alpine|' -i dockerbuild/Dockerfile-arm64
#sed -E 's/GOARCH=/GOARCH=amd64/' -i dockerbuild/Dockerfile-amd64
sed -E 's/GOARCH=/GOARCH=arm/' -i dockerbuild/Dockerfile-arm
#sed -E 's/GOARCH=/GOARCH=arm64/' -i dockerbuild/Dockerfile-arm64
#docker build -f dockerbuild/Dockerfile-amd64 -t sequentialread/greenhouse:$VERSION-amd64 .
docker build -f dockerbuild/Dockerfile-arm -t sequentialread/greenhouse:$VERSION-arm .
#docker build -f dockerbuild/Dockerfile-arm64 -t sequentialread/greenhouse:$VERSION-arm64 .
#docker push sequentialread/greenhouse:$VERSION-amd64
docker push sequentialread/greenhouse:$VERSION-arm
#docker push sequentialread/greenhouse:$VERSION-arm64
export DOCKER_CLI_EXPERIMENTAL=enabled
# docker manifest create sequentialread/greenhouse:$VERSION \
# sequentialread/greenhouse:$VERSION-amd64 \
# sequentialread/greenhouse:$VERSION-arm \
# sequentialread/greenhouse:$VERSION-arm64
docker manifest create sequentialread/greenhouse:$VERSION sequentialread/greenhouse:$VERSION-arm \
#docker manifest annotate --arch amd64 sequentialread/greenhouse:$VERSION sequentialread/greenhouse:$VERSION-amd64
docker manifest annotate --arch arm sequentialread/greenhouse:$VERSION sequentialread/greenhouse:$VERSION-arm
#docker manifest annotate --arch arm64 sequentialread/greenhouse:$VERSION sequentialread/greenhouse:$VERSION-arm64
docker manifest push sequentialread/greenhouse:$VERSION

+ 0
- 497
docker-compose.yml View File

@ -1,497 +0,0 @@
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:

+ 34
- 0
example_config.json View File

@ -0,0 +1,34 @@
{
"FrontendPort": 8080,
"FrontendDomain": "greenhouse-alpha.server.garden",
"DatabaseConnectionString": "host=localhost port=5432 user=postgres password=dev database=postgres sslmode=disable",
"DatabaseType": "postgres",
"DatabaseSchema": "public",
"DigitalOceanAPIKey": "",
"DigitalOceanRegion": "tor1",
"DigitalOceanImage": "ubuntu-18-04-x64",
"DigitalOceanSSHAuthorizedKeys": [
{
"Name": "Forest's SSH Key",
"PublicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKD3XzZTbTteIgnaFY+fiiOl9EnNN+twyNchnWjCkYqv forest@tower"
},
{
"Name": "Greenhouse SSH Key",
"PublicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4vgkLngsS8FprpDmCBQhFsIz/jcFhT3fD56nnbgdeM greenhouse@greenhouse"
}
],
"GandiAPIKey": "",
"SSHPrivateKeyFile": "/greenhouse/config/greenhouse_ed25519",
"BackblazeBucketName": "",
"BackblazeKeyId": "",
"BackblazeSecretKey": "",
"ThresholdManagementPort": 9057,
"ThresholdPort": 9056,
"SMTP": {
"Host": "",
"Port": 465,
"Username": "",
"Password": "",
"Encryption": "SMTPS"
}
}

+ 78
- 12
frontend.go View File

@ -11,6 +11,7 @@ import (
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
@ -84,15 +85,15 @@ func initFrontend(workingDirectory string, config *Config, model *DBModel, backe
app.handleWithSessionNotRequired("/", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
pageContent, err := app.renderTemplateToHTML("index.html", nil)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
highlightContent, err := app.renderTemplateToHTML("index-highlight.html", nil)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
app.buildPage(responseWriter, session, highlightContent, pageContent, "")
app.buildPage(responseWriter, request, session, highlightContent, pageContent, "")
})
registerHowtoRoutes(&app)
@ -109,6 +110,22 @@ func initFrontend(workingDirectory string, config *Config, model *DBModel, backe
log.Printf("serving static files from %s", staticFilesDir)
app.Router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(staticFilesDir))))
releasesDir := filepath.Join(workingDirectory, "releases")
log.Printf("serving releases from %s", releasesDir)
// https://stackoverflow.com/questions/49589685/good-way-to-disable-directory-listing-with-http-fileserver-in-go
noDirectoryListingHTTPDir := justFilesFilesystem{fs: http.Dir(releasesDir), readDirBatchSize: 20}
releasesStaticFileHandler := http.StripPrefix("/releases/", http.FileServer(noDirectoryListingHTTPDir))
app.Router.PathPrefix("/releases/").HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
if !strings.Contains(strings.ToLower(request.URL.Path), "greenhouse") {
http.Error(responseWriter, "404 not found", http.StatusNotFound)
}
go postTelemetryFromRequest("release_download", request, &app, request.URL.Path)
releasesStaticFileHandler.ServeHTTP(responseWriter, request)
})
return app
}
@ -248,8 +265,11 @@ func (app *FrontendApp) setSession(responseWriter http.ResponseWriter, session *
return nil
}
func (app *FrontendApp) unhandledError(responseWriter http.ResponseWriter, err error) {
func (app *FrontendApp) unhandledError(responseWriter http.ResponseWriter, request *http.Request, err error) {
log.Printf("500 internal server error: %+v\n", err)
go postTelemetryFromRequest("unhandled-error", request, app, fmt.Sprintf("%s: %s", request.URL.Path, err))
responseWriter.Header().Add("Content-Type", "text/plain")
responseWriter.WriteHeader(http.StatusInternalServerError)
responseWriter.Write([]byte("500 internal server error"))
@ -279,7 +299,7 @@ func (app *FrontendApp) handleWithSessionImpl(path string, required bool, requir
log.Printf("handleWithSession(): %s\n", string(bytes))
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
} else {
log.Printf("%d, %d, %t, %t", requireUserId, session.TenantId, requireUserId != 0, session.TenantId != requireUserId)
if (required && session.TenantId == 0) || (requireUserId != 0 && session.TenantId != requireUserId) {
@ -305,7 +325,7 @@ func (app *FrontendApp) handleWithSessionImpl(path string, required bool, requir
})
}
func (app *FrontendApp) buildPage(responseWriter http.ResponseWriter, session Session, highlight, page template.HTML, pageClass string) {
func (app *FrontendApp) buildPage(responseWriter http.ResponseWriter, request *http.Request, session Session, highlight, page template.HTML, pageClass string) {
var buffer bytes.Buffer
templateName := "page.html"
pageTemplate, hasPageTemplate := app.HTMLTemplates[templateName]
@ -324,7 +344,7 @@ func (app *FrontendApp) buildPage(responseWriter http.ResponseWriter, session Se
app.deleteCookie(responseWriter, "flash")
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
} else {
io.Copy(responseWriter, &buffer)
}
@ -343,16 +363,16 @@ func (app *FrontendApp) renderTemplateToHTML(templateName string, data interface
return template.HTML(buffer.String()), nil
}
func (app *FrontendApp) buildPageFromTemplate(responseWriter http.ResponseWriter, session Session, templateName string, data interface{}) {
app.buildPageFromTemplateWithClass(responseWriter, session, templateName, data, "")
func (app *FrontendApp) buildPageFromTemplate(responseWriter http.ResponseWriter, request *http.Request, session Session, templateName string, data interface{}) {
app.buildPageFromTemplateWithClass(responseWriter, request, session, templateName, data, "")
}
func (app *FrontendApp) buildPageFromTemplateWithClass(responseWriter http.ResponseWriter, session Session, templateName string, data interface{}, pageClass string) {
func (app *FrontendApp) buildPageFromTemplateWithClass(responseWriter http.ResponseWriter, request *http.Request, session Session, templateName string, data interface{}, pageClass string) {
content, err := app.renderTemplateToHTML(templateName, data)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
} else {
app.buildPage(responseWriter, session, template.HTML(""), content, pageClass)
app.buildPage(responseWriter, request, session, template.HTML(""), content, pageClass)
}
}
@ -396,6 +416,52 @@ func (app *FrontendApp) reloadTemplates() {
}
type justFilesFilesystem struct {
fs http.FileSystem
// readDirBatchSize - configuration parameter for `Readdir` func
readDirBatchSize int
}
func (fs justFilesFilesystem) Open(name string) (http.File, error) {
f, err := fs.fs.Open(name)
if err != nil {
return nil, err
}
return neuteredStatFile{File: f, readDirBatchSize: fs.readDirBatchSize}, nil
}
type neuteredStatFile struct {
http.File
readDirBatchSize int
}
func (e neuteredStatFile) Stat() (os.FileInfo, error) {
s, err := e.File.Stat()
if err != nil {
return nil, err
}
if s.IsDir() {
LOOP:
for {
fl, err := e.File.Readdir(e.readDirBatchSize)
switch err {
case io.EOF:
break LOOP
case nil:
for _, f := range fl {
if f.Name() == "index.html" {
return s, err
}
}
default:
return nil, err
}
}
return nil, os.ErrNotExist
}
return s, err
}
// func hashTemplateAndStaticFiles(workingDirectory string) string {
// filenameMatch := regexp.MustCompile("(\\.gotemplate)|(\\.html)|(\\.css)|(\\.js)$")
// toHash := map[string]bool{}


+ 6
- 6
frontend/alpha-profile.gotemplate.html View File

@ -110,7 +110,7 @@
<input type="radio" name="client" value="windows" id="client-windows" {{ if eq .OperatingSystem "windows" }}checked="checked"{{ end }}></input>
<label class="tab" for="client-windows">
<div class="horizontal align-center">
<img class="os-image" src="static/images/windows.webp"/> windows
<img class="os-image" src="/static/images/windows.webp"/> windows
</div>
</label>
<div class="vertical tab-content">
@ -118,8 +118,8 @@
You may download our installer:
</p>
<blockquote>
<a class="horizontal align-center" href="https://picopublish.sequentialread.com/files/greenhouse-setup-alpha-rc0.exe">
<img class="os-image" src="static/images/windows-installer.webp"/> greenhouse-setup-alpha-rc0.exe
<a class="horizontal align-center" href="/releases/greenhouse-setup-alpha-rc0.exe">
<img class="os-image" src="/static/images/windows-installer.webp"/> greenhouse-setup-alpha-rc0.exe
</a>
</blockquote>
<br/>
@ -181,7 +181,7 @@
<input type="radio" name="client" value="macos" id="client-macos" {{ if eq .OperatingSystem "macos" }}checked="checked"{{ end }}></input>
<label class="tab" for="client-macos">
<div class="horizontal align-center">
<img class="os-image" src="static/images/macos.webp"/> mac os
<img class="os-image" src="/static/images/macos.webp"/> mac os
</div>
</label>
<div class="vertical tab-content">
@ -189,8 +189,8 @@
During the alpha test phase, we're only offering a simple application download for MacOS.
</p>
<blockquote>
<a class="horizontal align-center" href="https://picopublish.sequentialread.com/files/greenhouse-desktop-alpha-rc0.dmg">
<img class="os-image" src="static/images/macos_disk_image.webp"/> greenhouse-desktop-alpha-rc0.dmg
<a class="horizontal align-center" href="/releases/greenhouse-desktop-alpha-rc0.dmg">
<img class="os-image" src="/static/images/macos_disk_image.webp"/> greenhouse-desktop-alpha-rc0.dmg
</a>
</blockquote>
<br/>


+ 28
- 27
frontend/static/image-sources/generate_webp.sh View File

@ -36,32 +36,33 @@ pngToWebp () {
cwebp -q "$quality" -m 6 -preset text -alpha_filter best "$pngFilename" -o "$webpFilename"
}
svgToWebp "greenhouse-border.svg" "$quality" "icon"
svgToWebp "free.svg" "$quality" "icon"
# svgToWebp "blah.svg" "$quality" "120" "105"
# svgToWebp "greenhouse-border.svg" "$quality" "icon"
# svgToWebp "free.svg" "$quality" "icon"
# # svgToWebp "blah.svg" "$quality" "120" "105"
pngToWebp "mascot-angry.png" "$lowerquality" "icon"
pngToWebp "mascot-dead.png" "$lowerquality" "icon"
pngToWebp "mascot-laptop.png" "$lowerquality" "icon"
pngToWebp "mascot-reading.png" "$lowerquality" "icon"
pngToWebp "mascot-sad.png" "$lowerquality" "icon"
pngToWebp "mascot-science.png" "$lowerquality" "icon"
pngToWebp "mascot-surfing.png" "$lowerquality" "icon"
pngToWebp "beginner.png" "$quality" "icon"
pngToWebp "custodialcloud.png" "$lowerquality" "text"
pngToWebp "hybridcloud.png" "$lowerquality" "text"
pngToWebp "selfhost.png" "$lowerquality" "text"
pngToWebp "namecheap-1.png" "$quality" "text"
pngToWebp "namecheap-2.png" "$quality" "text"
pngToWebp "namecheap-3.png" "$quality" "text"
pngToWebp "shine.png" "$quality" "icon"
pngToWebp "macos.png" "$quality" "icon"
pngToWebp "macos_disk_image.png" "$quality" "icon"
pngToWebp "tux.png" "$quality" "icon"
pngToWebp "windows.png" "$quality" "icon"
pngToWebp "windows-installer.png" "$quality" "icon"
# pngToWebp "intel.png" "$quality" "text"
# pngToWebp "amd.png" "$quality" "text"
# pngToWebp "arm.png" "$quality" "text"
pngToWebp "raspberrypi.png" "$quality" "icon"
# pngToWebp "mascot-angry.png" "$lowerquality" "icon"
# pngToWebp "mascot-dead.png" "$lowerquality" "icon"
pngToWebp "mascot-dancing.png" "$lowerquality" "icon"
# pngToWebp "mascot-laptop.png" "$lowerquality" "icon"
# pngToWebp "mascot-reading.png" "$lowerquality" "icon"
# pngToWebp "mascot-sad.png" "$lowerquality" "icon"
# pngToWebp "mascot-science.png" "$lowerquality" "icon"
# pngToWebp "mascot-surfing.png" "$lowerquality" "icon"
# pngToWebp "beginner.png" "$quality" "icon"
# pngToWebp "custodialcloud.png" "$lowerquality" "text"
# pngToWebp "hybridcloud.png" "$lowerquality" "text"
# pngToWebp "selfhost.png" "$lowerquality" "text"
# pngToWebp "namecheap-1.png" "$quality" "text"
# pngToWebp "namecheap-2.png" "$quality" "text"
# pngToWebp "namecheap-3.png" "$quality" "text"
# pngToWebp "shine.png" "$quality" "icon"
# pngToWebp "macos.png" "$quality" "icon"
# pngToWebp "macos_disk_image.png" "$quality" "icon"
# pngToWebp "tux.png" "$quality" "icon"
# pngToWebp "windows.png" "$quality" "icon"
# pngToWebp "windows-installer.png" "$quality" "icon"
# # pngToWebp "intel.png" "$quality" "text"
# # pngToWebp "amd.png" "$quality" "text"
# # pngToWebp "arm.png" "$quality" "text"
# pngToWebp "raspberrypi.png" "$quality" "icon"

+ 2
- 2
frontend/static/install.sh View File

@ -122,8 +122,8 @@ update_binary()
if ! printf "%s" "$sha256sum_output" | sha256sum -c > /dev/null 2>&1 ; then
printf "Downloading %s binary...\n" "$binary_name"
picopublish_url="https://picopublish.sequentialread.com/files"
download_url="$picopublish_url/$file_name-$binary_version-linux-$binary_arch.gz"
releases_url="https://greenhouse-alpha.server.garden/releases"
download_url="$releases_url/$file_name-$binary_version-linux-$binary_arch.gz"
temporary_gzip_file="/tmp/greenhouse-install-$file_name.gz"
curl_log_file="/tmp/greenhouse-install-download-$file_name.log"


+ 6
- 6
frontend_admin_panel.go View File

@ -41,7 +41,7 @@ func registerAdminPanelRoutes(app *FrontendApp) {
} else {
err := app.Model.DeleteVPSInstance(split[0], split[1])
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
}
@ -59,7 +59,7 @@ func registerAdminPanelRoutes(app *FrontendApp) {
BytesMonthly: DEFAULT_INSTANCE_MONTHLY_BYTES,
})
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
}
@ -127,18 +127,18 @@ func registerAdminPanelRoutes(app *FrontendApp) {
validVpsInstances, dbOnlyInstances, cloudOnlyInstances, err := app.Backend.GetInstances()
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
tenants, err := app.Model.GetTenants()
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
tenantVpsInstanceRows, err := app.Model.GetTenantVPSInstanceRows(billingYear, billingMonth)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
@ -255,7 +255,7 @@ func registerAdminPanelRoutes(app *FrontendApp) {
HashOfSessionId: hashOfSessionId,
}
app.buildPageFromTemplate(responseWriter, session, "admin.html", data)
app.buildPageFromTemplate(responseWriter, request, session, "admin.html", data)
})
}


+ 6
- 3
frontend_howto.go View File

@ -24,20 +24,20 @@ func registerHowtoRoutes(app *FrontendApp) {
if session.TenantId != 0 {
tenant, err := app.Model.GetTenant(session.TenantId)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
templateData.Subdomain = tenant.Subdomain
}
app.buildPageFromTemplate(responseWriter, session, "about-dns.html", templateData)
app.buildPageFromTemplate(responseWriter, request, session, "about-dns.html", templateData)
},
)
app.handleWithSessionNotRequired(
"/install-greenhouse-self-hosting-software-linux",
func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
app.buildPageFromTemplate(responseWriter, session, "install-linux.html", struct{}{})
app.buildPageFromTemplate(responseWriter, request, session, "install-linux.html", struct{}{})
},
)
@ -61,6 +61,9 @@ func registerHowtoRoutes(app *FrontendApp) {
http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError)
return
}
go postTelemetryFromRequest("release_download", request, app, scriptFileName)
responseWriter.Header().Add("Content-Type", "application/x-sh")
responseWriter.Header().Add("Content-Length", strconv.Itoa(int(stat.Size())))
io.Copy(responseWriter, file)


+ 83
- 23
frontend_login.go View File

@ -7,7 +7,6 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
@ -22,7 +21,8 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
if request.Method == "POST" {
remoteUsersHMAC := request.PostFormValue("hmac")
loginSuccess := false
tenantId, databasesHashedPassword, emailVerified := app.Model.GetLoginInfo(request.PostFormValue("email"))
email := request.PostFormValue("email")
tenantId, databasesHashedPassword, emailVerified := app.Model.GetLoginInfo(email)
if remoteUsersHMAC == "" {
saltedPassword := fmt.Sprintf("%s%s", app.PasswordHashSalt, request.PostFormValue("password"))
@ -38,7 +38,7 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
} else {
databasesHashedPasswordBytes, err := base64.StdEncoding.DecodeString(databasesHashedPassword)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
hmacFromDB := hmac.New(sha256.New, databasesHashedPasswordBytes)
@ -49,12 +49,23 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
}
}
returnTo := "/profile"
if request.PostFormValue("returnTo") != "" && app.basicURLPathRegex.MatchString(request.PostFormValue("returnTo")) {
returnTo = request.PostFormValue("returnTo")
}
loginTelemetry := fmt.Sprintf(
"JS HMAC Login: %t, loginSuccess: %t, emailVerified: %t, returnTo: %s",
remoteUsersHMAC != "", loginSuccess, emailVerified, returnTo,
)
go postTelemetry("login", getRemoteIpFromRequest(request), email, loginTelemetry)
if loginSuccess {
err := app.setSession(
responseWriter,
&Session{
TenantId: tenantId,
Email: strings.ToLower(request.PostFormValue("email")),
Email: strings.ToLower(email),
EmailVerified: emailVerified,
// we will use the SameSite Lax cookie policy until the first time that the user re-logs-in AFTER confirming thier
@ -69,12 +80,8 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
},
)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
} else {
returnTo := "/profile"
if request.PostFormValue("returnTo") != "" && app.basicURLPathRegex.MatchString(request.PostFormValue("returnTo")) {
returnTo = request.PostFormValue("returnTo")
}
http.Redirect(responseWriter, request, returnTo, http.StatusFound)
}
return
@ -96,7 +103,7 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
ReturnTo: returnTo,
}
app.buildPageFromTemplate(responseWriter, session, "login.html", data)
app.buildPageFromTemplate(responseWriter, request, session, "login.html", data)
})
app.handleWithSessionNotRequired("/register", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
@ -122,6 +129,7 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
hashedPasswordByteArray := sha256.Sum256([]byte(saltedPassword))
hashedPassword = base64.StdEncoding.EncodeToString(hashedPasswordByteArray[:])
}
if (*session.Flash)["error"] == "" {
tenantId, err := app.Model.Register(data.Email, hashedPassword)
if err != nil {
@ -132,7 +140,7 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
emailVerificationToken := hex.EncodeToString(emailVerificationTokenBuffer)
err := app.Model.CreateEmailVerificationToken(emailVerificationToken, tenantId, time.Now().Add(time.Hour))
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
err = app.setSession(
@ -146,7 +154,7 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
},
)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
protocol := "https"
@ -158,23 +166,41 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
specialPort = fmt.Sprintf(":%d", app.Port)
}
err = emailService.SendEmail(
fmt.Sprintf("Please verify your account on %s", app.Domain),
fmt.Sprintf("Please verify your email on %s", app.Domain),
data.Email,
fmt.Sprintf(
"Please click the following link to verify your account: %s://%s%s/verify-email/%s",
"Please click the following link to verify your email: %s://%s%s/verify-email/%s",
protocol, app.Domain, specialPort, emailVerificationToken,
),
)
if err != nil {
(*session.Flash)["error"] += fmt.Sprintln(err)
registerTelemetry := fmt.Sprintf(
"registration (JS HMAC: %t, tokenHash=%s) send email failed: %s",
hashedPassword != "", getHashForTelemetry(app, emailVerificationToken), (*session.Flash)["error"],
)
go postTelemetry("register", getRemoteIpFromRequest(request), data.Email, registerTelemetry)
} else {
registerTelemetry := fmt.Sprintf(
"registration (JS HMAC: %t, tokenHash=%s) success!!",
hashedPassword != "", getHashForTelemetry(app, emailVerificationToken),
)
go postTelemetry("register", getRemoteIpFromRequest(request), data.Email, registerTelemetry)
http.Redirect(responseWriter, request, "/verify-email", http.StatusFound)
return
}
}
} else {
registerTelemetry := fmt.Sprintf(
"registration (JS HMAC: %t) not attempted because: %s",
hashedPassword != "", (*session.Flash)["error"],
)
go postTelemetry("register", getRemoteIpFromRequest(request), data.Email, registerTelemetry)
}
}
app.buildPageFromTemplate(responseWriter, session, "register.html", data)
app.buildPageFromTemplate(responseWriter, request, session, "register.html", data)
})
verifyEmailHandler := func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
@ -183,7 +209,7 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
log.Printf("verify email handler: [%s] [%d]\n", mux.Vars(request)["token"], session.TenantId)
//log.Printf("verify email handler: [%s] [%d]\n", mux.Vars(request)["token"], session.TenantId)
if mux.Vars(request)["token"] != "" && session.TenantId != 0 {
err := app.Model.VerifyEmail(mux.Vars(request)["token"], session.TenantId)
if err != nil {
@ -191,7 +217,12 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
} else {
err = app.Backend.InitializeTenant(session.TenantId, session.Email)
if err != nil {
app.unhandledError(responseWriter, err)
go postTelemetryFromRequest("verify-email", request, app, fmt.Sprintf(
"tokenHash=%s InitializeTenant failed: %s",
getHashForTelemetry(app, mux.Vars(request)["token"]), err,
))
app.unhandledError(responseWriter, request, err)
return
}
@ -206,16 +237,21 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
}
err = app.setSession(responseWriter, newSession)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
go postTelemetryFromRequest("verify-email", request, app, fmt.Sprintf(
"tokenHash=%s success!!",
getHashForTelemetry(app, mux.Vars(request)["token"]),
))
app.setFlash(responseWriter, session, "info", "Your email address has been verified!")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
}
app.buildPageFromTemplate(responseWriter, session, "verify-email.html", nil)
app.buildPageFromTemplate(responseWriter, request, session, "verify-email.html", nil)
}
app.handleWithSession("/verify-email", verifyEmailHandler)
@ -225,7 +261,7 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
if session.TenantId != 0 {
err := app.Model.LogoutTenant(session.TenantId)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
}
@ -244,26 +280,40 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
return
}
telemetryValue := fmt.Sprintf("POST id=%s name=%s", getHashForTelemetry(app, appAuthSessionId), name)
go postTelemetry("app-auth-session", getRemoteIpFromRequest(request), "", telemetryValue)
app.ApplicationAuthSessions[appAuthSessionId] = &ApplicationAuthSession{
Name: name,
}
responseWriter.Write([]byte("OK"))
} else {
telemetryValue := fmt.Sprintf("GET id=%s", getHashForTelemetry(app, appAuthSessionId))
appAuthSession, hasAppAuthSession := app.ApplicationAuthSessions[appAuthSessionId]
if hasAppAuthSession {
if appAuthSession.TenantId != 0 {
telemetryValue = fmt.Sprintf("%s name=%s", telemetryValue, appAuthSession.Name)
apiToken, _, err := app.Backend.CreateAPIToken(appAuthSession.TenantId, appAuthSession.Name)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
telemetryValue = fmt.Sprintf("%s success!!", telemetryValue)
go postTelemetry("app-auth-session", getRemoteIpFromRequest(request), "", telemetryValue)
responseWriter.Header().Set("Content-Type", "text/plain")
responseWriter.Write([]byte(apiToken))
} else {
telemetryValue = fmt.Sprintf("%s 401 unauthorized", telemetryValue)
go postTelemetry("app-auth-session", getRemoteIpFromRequest(request), "", telemetryValue)
http.Error(responseWriter, "401 unauthorized", http.StatusUnauthorized)
}
} else {
telemetryValue = fmt.Sprintf("%s 404 not found", telemetryValue)
go postTelemetry("app-auth-session", getRemoteIpFromRequest(request), "", telemetryValue)
http.Error(responseWriter, "404 not found", http.StatusNotFound)
}
}
@ -271,11 +321,21 @@ func registerLoginRoutes(app *FrontendApp, emailService *EmailService) {
app.handleWithSession("/app-connect", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
appAuthSessionId := request.URL.Query().Get("session")
telemetryValue := fmt.Sprintf("GET id=%s", getHashForTelemetry(app, appAuthSessionId))
appAuthSession, hasAppAuthSession := app.ApplicationAuthSessions[appAuthSessionId]
if hasAppAuthSession && appAuthSession.TenantId == 0 {
app.ApplicationAuthSessions[appAuthSessionId].TenantId = session.TenantId
app.buildPageFromTemplate(responseWriter, session, "app-connect.html", nil)
app.buildPageFromTemplate(responseWriter, request, session, "app-connect.html", nil)
telemetryValue = fmt.Sprintf("%s success!!", telemetryValue)
go postTelemetryFromRequest("app-connect", request, app, telemetryValue)
} else {
telemetryValue = fmt.Sprintf("%s 404 not found", telemetryValue)
go postTelemetryFromRequest("app-connect", request, app, telemetryValue)
http.Error(responseWriter, "404 not found", http.StatusNotFound)
}
})


+ 40
- 26
frontend_profile.go View File

@ -19,25 +19,6 @@ import (
func registerProfileRoutes(app *FrontendApp) {
// TODO remove this?
app.handleWithSessionNotRequired("/ostest", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
goatCounterOSName := os_from_user_agent_header.Parse(request.Header.Get("User-Agent")).OSName
simplifiedOSName := map[string]string{
"iOS": "macos",
"macOS": "macos",
"Windows Phone": "windows",
"Windows": "windows",
}[goatCounterOSName]
if simplifiedOSName == "" {
simplifiedOSName = "linux"
}
responseWriter.Header().Set("Content-Type", "text/plain")
responseWriter.Write([]byte(simplifiedOSName))
})
app.handleWithSession("/profile", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
if !session.EmailVerified {
// anti-XSS: only set path into the flash cookie if it matches a basic url pattern
@ -56,14 +37,14 @@ func registerProfileRoutes(app *FrontendApp) {
tenant, err := app.Model.GetTenant(session.TenantId)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
billingYear, billingMonth, _, _, _ := getBillingTimeInfo()
usageTotal, err := app.Model.GetTenantUsageTotal(session.TenantId, billingYear, billingMonth)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
@ -78,6 +59,7 @@ func registerProfileRoutes(app *FrontendApp) {
}
action := request.PostFormValue("action")
if action == "update_free_subdomain" {
postedFreeSubdomain := strings.ToLower(request.PostFormValue("subdomain"))
subdomainRegex := regexp.MustCompile("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
if !subdomainRegex.MatchString(postedFreeSubdomain) {
@ -90,6 +72,10 @@ func registerProfileRoutes(app *FrontendApp) {
}
alreadyTaken, err := app.Model.SetFreeSubdomain(session.TenantId, postedFreeSubdomain)
if err != nil {
go postTelemetryFromRequest("update_free_subdomain", request, app, fmt.Sprintf(
"'%s': db error: %s", postedFreeSubdomain, err,
))
errorMessage := "unable to update your subdomain: internal server error"
log.Printf("%s: %+v", errorMessage, err)
app.setFlash(responseWriter, session, "error", errorMessage)
@ -104,6 +90,10 @@ func registerProfileRoutes(app *FrontendApp) {
err = app.Backend.Reallocate(false, false)
if err != nil {
go postTelemetryFromRequest("update_free_subdomain", request, app, fmt.Sprintf(
"'%s': reallocate error: %s", postedFreeSubdomain, err,
))
errorMessage := "unable to update your subdomain: internal server error"
log.Printf("%s: %+v", errorMessage, err)
app.setFlash(responseWriter, session, "error", errorMessage)
@ -111,6 +101,10 @@ func registerProfileRoutes(app *FrontendApp) {
return
}
go postTelemetryFromRequest("update_free_subdomain", request, app, fmt.Sprintf(
"'%s': success!!", postedFreeSubdomain,
))
successMessage := fmt.Sprintf("Success! Your personal subdomain is now '%s.%s'\n", postedFreeSubdomain, freeSubdomainDomain)
app.setFlash(responseWriter, session, "info", successMessage)
} else if action == "add_external_domain" {
@ -130,6 +124,10 @@ func registerProfileRoutes(app *FrontendApp) {
usersPersonalSubdomain := fmt.Sprintf("%s.%s", tenant.Subdomain, freeSubdomainDomain)
valid, err := app.Backend.ValidateExternalDomain(postedExternalDomain, usersPersonalSubdomain, false)
if err != nil {
go postTelemetryFromRequest("add_external_domain", request, app, fmt.Sprintf(
"'%s' -> '%s': validate: %s", postedExternalDomain, usersPersonalSubdomain, err,
))
errorMessage := fmt.Sprintf("unable to update your subdomain: %s", err)
log.Printf("%s: %+v", errorMessage, err)
app.setFlash(responseWriter, session, "error", errorMessage)
@ -137,6 +135,11 @@ func registerProfileRoutes(app *FrontendApp) {
return
}
if !valid {
go postTelemetryFromRequest("add_external_domain", request, app, fmt.Sprintf(
"'%s' -> '%s': CNAME is missing", postedExternalDomain, usersPersonalSubdomain,
))
app.setFlash(responseWriter, session, "error", fmt.Sprintf(
"the domain '%s' does not appear to have a CNAME record pointing to '%s'. Either you have not created the CNAME record set yet, it was not created correctly, or not enough time has elapsed for the DNS record change to propagate.",
postedExternalDomain, usersPersonalSubdomain,
@ -147,6 +150,10 @@ func registerProfileRoutes(app *FrontendApp) {
err = app.Model.AddExternalDomain(session.TenantId, postedExternalDomain)
if err != nil {
go postTelemetryFromRequest("add_external_domain", request, app, fmt.Sprintf(
"'%s' -> '%s': model error: %s", postedExternalDomain, usersPersonalSubdomain, err,
))
errorMessage := "unable to update your subdomain: internal server error"
log.Printf("%s: %+v", errorMessage, err)
app.setFlash(responseWriter, session, "error", errorMessage)
@ -154,6 +161,10 @@ func registerProfileRoutes(app *FrontendApp) {
return
}
go postTelemetryFromRequest("add_external_domain", request, app, fmt.Sprintf(
"'%s' -> '%s': success!!", postedExternalDomain, usersPersonalSubdomain,
))
app.setFlash(responseWriter, session, "info", fmt.Sprintf("Success! '%s' has been added as an external domain. You may now create tunnels from '%s' using any of your greenhouse clients. \n", postedExternalDomain, postedExternalDomain))
} else if action == "create_api_token" {
@ -171,9 +182,12 @@ func registerProfileRoutes(app *FrontendApp) {
apiToken, name, err := app.Backend.CreateAPIToken(session.TenantId, keyName)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
go postTelemetryFromRequest("create_api_token", request, app, fmt.Sprintf("'%s': success!!", keyName))
app.setFlash(responseWriter, session, "api-token", apiToken)
app.setFlash(responseWriter, session, "api-token-name", name)
app.setFlash(responseWriter, session, "info", fmt.Sprintf("Success! Your new '%s' API Token is %s. It will not be displayed again, so make sure to copy and paste it or write it down now!\n", keyName, apiToken))
@ -234,13 +248,13 @@ func registerProfileRoutes(app *FrontendApp) {
HashOfSessionId: hashOfSessionId,
OperatingSystem: simplifiedOSName,
}
app.buildPageFromTemplateWithClass(responseWriter, session, "alpha-profile.html", data, "no-horizontal-margin")
app.buildPageFromTemplateWithClass(responseWriter, request, session, "alpha-profile.html", data, "no-horizontal-margin")
})
app.handleWithSession("/profile/usage_graph.png", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
// tenant, err := app.Model.GetTenant(session.TenantId)
// if err != nil {
// app.unhandledError(responseWriter, err)
// app.unhandledError(responseWriter, request, err)
// return
// }
@ -248,7 +262,7 @@ func registerProfileRoutes(app *FrontendApp) {
usageMetrics, err := app.Model.GetTenantUsageMetrics(session.TenantId, start, end)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}
@ -391,7 +405,7 @@ func registerProfileRoutes(app *FrontendApp) {
buffer := bytes.NewBuffer([]byte{})
err = graph.Render(chart.PNG, buffer)
if err != nil {
app.unhandledError(responseWriter, err)
app.unhandledError(responseWriter, request, err)
return
}


+ 4
- 0
gandi_service.go View File

@ -64,6 +64,10 @@ func (service *GandiService) UpdateFreeSubdomains(freeSubdomains map[string][]st
)
}
if len(requestBody.Items) == 0 {
return nil
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return err


+ 1
- 1
ingress_service.go View File

@ -41,7 +41,7 @@ type GreenhouseDaemonStatus struct {
ApplyConfigStatusError string `json:"apply_config_status_error"`
}
const adminThresholdNodeId = "greenhouse_internal_node"
const adminThresholdNodeId = "greenhouse-internal-node"
const greenhouseExternalDomain = "greenhouse-alpha.server.garden"


+ 72
- 1
main.go View File

@ -11,6 +11,7 @@ import (
"regexp"
"runtime"
"runtime/debug"
"strconv"
"time"
"git.sequentialread.com/forest/greenhouse/pki"
@ -50,6 +51,7 @@ const isProduction = false
func main() {
_, err := time.LoadLocation("UTC")
if err != nil {
go postTelemetry("greenhouse-cloud", "", "admin", fmt.Sprintf("load UTC location failed: %s", err))
panic(errors.Wrap(err, "can't start the app because can't load UTC location"))
}
@ -59,6 +61,7 @@ func main() {
emailService := &config.SMTP
err = emailService.