diff --git a/.env b/.env index 5e971a13baa..38e5d489897 100644 --- a/.env +++ b/.env @@ -34,6 +34,19 @@ SHELLHUB_PROXY=false # Enable automatic HTTPS with Let's Encrypt. SHELLHUB_AUTO_SSL=false +# Path to a TLS certificate for the gateway to serve as-is, instead of obtaining +# one from Let's Encrypt. The only way to run HTTPS on a name no public CA will +# sign: an internal hostname, or a domain this deployment does not own. +# NOTICE: Only used when automatic HTTPS is enabled. +# NOTICE: Set both this and SHELLHUB_TLS_KEY_FILE, or the gateway will not start. +# VALUES: An absolute path inside the gateway container +SHELLHUB_TLS_CERT_FILE= + +# Path to the private key for SHELLHUB_TLS_CERT_FILE. +# NOTICE: Set both this and SHELLHUB_TLS_CERT_FILE, or the gateway will not start. +# VALUES: An absolute path inside the gateway container +SHELLHUB_TLS_KEY_FILE= + SHELLHUB_DATABASE=postgres SHELLHUB_POSTGRES_HOST=postgres diff --git a/docker-compose.yml b/docker-compose.yml index 99fca07e628..b6985b5103a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,6 +85,8 @@ services: - SHELLHUB_EDITION=${SHELLHUB_EDITION} - SHELLHUB_AUTO_SSL=${SHELLHUB_AUTO_SSL} - SHELLHUB_ACME_CA_SERVER=${SHELLHUB_ACME_CA_SERVER} + - SHELLHUB_TLS_CERT_FILE=${SHELLHUB_TLS_CERT_FILE} + - SHELLHUB_TLS_KEY_FILE=${SHELLHUB_TLS_KEY_FILE} - SHELLHUB_GATEWAY_ACCESS_LOGS=${SHELLHUB_GATEWAY_ACCESS_LOGS} - SHELLHUB_DATABASE=${SHELLHUB_DATABASE} depends_on: @@ -94,8 +96,10 @@ services: - ${SHELLHUB_BIND_ADDRESS}:${SHELLHUB_HTTP_PORT}:80 networks: - shellhub + extra_hosts: + - "healthcheck.internal:127.0.0.1" healthcheck: - test: wget -q --spider -T 5 http://gateway/healthcheck || exit 1 + test: wget -q --spider -T 5 http://healthcheck.internal/healthz || exit 1 interval: 30s start_period: 10s redis: diff --git a/gateway/Caddyfile.tmpl b/gateway/Caddyfile.tmpl index 34c660639ae..12fa359773f 100644 --- a/gateway/Caddyfile.tmpl +++ b/gateway/Caddyfile.tmpl @@ -70,6 +70,11 @@ } {{ .SiteAddress }} { +{{- if and .TLS .TLSCertFile .TLSKeyFile }} + # Also what keeps Caddy off ACME: with no tls directive the default applies, + # which is to ask a public authority for this name. + tls {{ .TLSCertFile }} {{ .TLSKeyFile }} +{{- end }} encode gzip zstd # X-Forwarded-Port carries the port the client addressed, which is the one in @@ -185,6 +190,18 @@ } } } + +# The container healthcheck. Written http:// so automatic HTTPS cannot redirect +# it, and given a name of its own so it can share port 80 with the site above: +# the probe runs inside this container, where every other name available to it is +# one this proxy does not serve. docker-compose.yml repeats the name under +# extra_hosts, so the two have to move together. It reports that the proxy is up, +# and nothing else -- the API has its own. +http://healthcheck.internal { + handle /healthz { + respond "ok" 200 + } +} {{- if $cfg.WebEndpoints }} # Every device tunnel lives on its own subdomain of this domain. One block diff --git a/gateway/README.md b/gateway/README.md index c0cec7df9ce..265dbbe803c 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -46,6 +46,16 @@ Losing it means asking the CA for everything again, and Let's Encrypt caps how o answer, so a deployment that cares should mount a named volume there rather than rely on the anonymous one Docker creates. +`SHELLHUB_TLS_CERT_FILE` and `SHELLHUB_TLS_KEY_FILE` serve a certificate the operator supplies +instead of obtaining one, which is the only way to run HTTPS on a name no public authority will +sign: an internal hostname, or a domain this deployment does not own. Both are paths inside the +gateway container, so whatever holds them has to be mounted there. + +They are read only when `SHELLHUB_AUTO_SSL` is enabled, and they are all or nothing: setting one +without the other stops the gateway from starting, checked even with automatic HTTPS off. A +half-set pair is a typo in every case, and the alternative to refusing it is quietly asking a +public CA for a name the operator meant to serve themselves. + ### Behind a load balancer `SHELLHUB_PROXY` enables the PROXY protocol, and `SHELLHUB_PROXY_TRUSTED_IPS` names the peers diff --git a/gateway/caddy.go b/gateway/caddy.go index 6a7a54ecefe..bbe92daa2c6 100644 --- a/gateway/caddy.go +++ b/gateway/caddy.go @@ -34,6 +34,8 @@ type caddyfileData struct { ACMECAServer string TrustedProxies string + TLSCertFile string + TLSKeyFile string // WebEndpointsInternalTLS asks for a locally-signed certificate instead of // a public one. Caddy falls back to its internal authority on its own for a @@ -90,6 +92,9 @@ func newCaddyfileData(cfg *GatewayConfig) *caddyfileData { ACMECAServer: cfg.ACMECAServer, TrustedProxies: cfg.ProxyTrustedIPs, + TLSCertFile: cfg.TLSCertFile, + TLSKeyFile: cfg.TLSKeyFile, + WebEndpointsInternalTLS: !certmagic.SubjectQualifiesForPublicCert("*." + domain), } } diff --git a/gateway/caddy_test.go b/gateway/caddy_test.go index e7870f2f7fe..1b68a51a5b0 100644 --- a/gateway/caddy_test.go +++ b/gateway/caddy_test.go @@ -47,6 +47,11 @@ func configurations() map[string]*GatewayConfig { "development": with(func(c *GatewayConfig) { c.Env = "development"; c.EnableEnterprise = true }), "dev community": with(func(c *GatewayConfig) { c.Env = "development" }), "no access logs": with(func(c *GatewayConfig) { c.EnableAccessLogs = false }), + "supplied certificate": with(func(c *GatewayConfig) { + c.EnableAutoSSL = true + c.TLSCertFile = "/etc/shellhub/certs/fullchain.pem" + c.TLSKeyFile = "/etc/shellhub/certs/privkey.pem" + }), } } @@ -156,6 +161,41 @@ func TestCaddyfileTLSFollowsTheFlagAlone(t *testing.T) { assert.NotContains(t, string(rendered), "auto_https off") } +// TestCaddyfileServesTheSuppliedCertificate pins both halves of the conditional, +// because a mistake in either is silent: the directive missing means an ACME +// request nobody asked for, and the directive appearing when no certificate was +// supplied means a site that cannot serve at all. +func TestCaddyfileServesTheSuppliedCertificate(t *testing.T) { + cfg := configurations()["supplied certificate"] + + rendered, err := Caddyfile(cfg) + require.NoError(t, err) + + assert.Contains(t, string(rendered), "tls "+cfg.TLSCertFile+" "+cfg.TLSKeyFile) + + without, err := Caddyfile(configurations()["auto ssl"]) + require.NoError(t, err) + + assert.NotContains(t, string(without), "tls ", + "automatic issuance is the default and must stay untouched when no certificate is supplied") +} + +// TestHealthcheckAnswersOnEveryConfiguration asserts every shape rather than one, +// because the bug it replaces was a healthcheck that passed under one setting and +// failed under another -- and passed by accident, on Caddy's reply to a name it +// did not serve, so nobody had reason to look at it. +func TestHealthcheckAnswersOnEveryConfiguration(t *testing.T) { + for description, cfg := range configurations() { + t.Run(description, func(t *testing.T) { + rendered, err := Caddyfile(cfg) + require.NoError(t, err) + + assert.Contains(t, string(rendered), "http://healthcheck.internal", + "the healthcheck site must not depend on how TLS is configured") + }) + } +} + // TestCaddyfileStoresCertificatesOnTheVolume guards a mistake that is invisible // until a container is recreated and the CA is asked for everything again: // Caddy's default storage is a directory under $HOME, which no volume covers. diff --git a/gateway/config.go b/gateway/config.go index 33d6dcee74a..b2c664c30db 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -54,7 +54,17 @@ type GatewayConfig struct { // ACMECAServer is the directory a certificate is asked for. Empty means the // default, which is Let's Encrypt's production endpoint; point it at their // staging endpoint to rehearse without spending the real rate limit. - ACMECAServer string `env:"SHELLHUB_ACME_CA_SERVER"` + ACMECAServer string `env:"SHELLHUB_ACME_CA_SERVER"` + + // TLSCertFile and TLSKeyFile name a certificate to serve instead of obtaining + // one, which is the only way to run TLS on a name no public authority will + // sign. + // + // Both or neither, checked even with TLS off: a half-set pair is a typo in + // every case, and the alternative to refusing it is quietly asking a public + // CA instead of serving what the operator supplied. + TLSCertFile string `env:"SHELLHUB_TLS_CERT_FILE" validate:"required_with=TLSKeyFile"` + TLSKeyFile string `env:"SHELLHUB_TLS_KEY_FILE" validate:"required_with=TLSCertFile"` Database string `env:"SHELLHUB_DATABASE,default=mongo"` EnableAccessLogs bool `env:"SHELLHUB_GATEWAY_ACCESS_LOGS,default=true"`