Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions gateway/Caddyfile.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions gateway/caddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}
}
Expand Down
40 changes: 40 additions & 0 deletions gateway/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}),
}
}

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion gateway/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
Loading