diff --git a/internal/cli/install.go b/internal/cli/install.go index 0e5601f..b88fa55 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -276,6 +276,12 @@ func runInstall(cmd *cobra.Command, _ []string) error { if err := nginx.EnsureDefaultVhost(); err != nil { return err } + // Issue the dashboard TLS cert before writing the vhost so the SSL block + // is emitted on first install. Best-effort: a transient mkcert failure + // leaves an HTTP-only vhost, which the next start re-attempts. + if err := certs.IssueCert("lerd.localhost", []string{"lerd.localhost"}, filepath.Join(config.CertsDir(), "sites")); err != nil { + fmt.Printf(" WARN: lerd cert: %v\n", err) + } if err := nginx.EnsureLerdVhost(); err != nil { return err } diff --git a/internal/cli/startstop.go b/internal/cli/startstop.go index 54cb424..e1da4ec 100644 --- a/internal/cli/startstop.go +++ b/internal/cli/startstop.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/geodro/lerd/internal/certs" "github.com/geodro/lerd/internal/config" "github.com/geodro/lerd/internal/dns" gitpkg "github.com/geodro/lerd/internal/git" @@ -437,6 +438,12 @@ func runStart(_ *cobra.Command, _ []string) error { if err := nginx.EnsureNginxConfig(); err != nil { fmt.Printf(" WARN: nginx config: %v\n", err) } + // Issue the dashboard TLS cert before writing the vhost so the SSL block + // is emitted on the first start after upgrade. IssueCert is a no-op when + // the cert already exists, so this is cheap on subsequent starts. + if err := certs.IssueCert("lerd.localhost", []string{"lerd.localhost"}, filepath.Join(config.CertsDir(), "sites")); err != nil { + fmt.Printf(" WARN: lerd cert: %v\n", err) + } if err := nginx.EnsureLerdVhost(); err != nil { fmt.Printf(" WARN: lerd vhost: %v\n", err) } diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index 4a38928..0c83d87 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -850,58 +850,110 @@ func EnsureLerdVhost() error { return err } + // SSL is the desired state: every other lerd site gets HTTPS by default, + // and shipping the dashboard over plain HTTP leaks the auth cookie to + // anything that can sniff loopback (rare but trivial). On a fresh install + // EnsureLerdVhost runs before mkcert has issued the cert, so fall back to + // HTTP-only until the next start picks up the freshly issued files — + // otherwise nginx refuses to start with a missing-cert error and the + // whole stack is wedged. + sslEnabled := lerdCertExists() + var content string if runtime.GOOS == "darwin" { token, err := LoadOrGenerateTrustToken() if err != nil { return fmt.Errorf("loading trust token: %w", err) } - content = fmt.Sprintf(`server { - listen 80; - listen [::]:80; - server_name lerd.localhost; + content = darwinLerdVhost(token, sslEnabled) + } else { + content = linuxLerdVhost(config.UISocketPath(), sslEnabled) + } + return os.WriteFile(filepath.Join(config.NginxConfD(), "lerd.localhost.conf"), []byte(content), 0644) +} - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Lerd-Trust %s; +// lerdCertExists reports whether mkcert has already issued the dashboard +// cert and key. Used to decide between the HTTPS vhost (preferred) and the +// HTTP-only fallback (fresh install before mkcert ran). +func lerdCertExists() bool { + sitesDir := filepath.Join(config.CertsDir(), "sites") + if _, err := os.Stat(filepath.Join(sitesDir, "lerd.localhost.crt")); err != nil { + return false + } + if _, err := os.Stat(filepath.Join(sitesDir, "lerd.localhost.key")); err != nil { + return false + } + return true +} - location = / { - proxy_pass http://host.containers.internal:7073; +// Cert paths inside the lerd-nginx container — the host's CertsDir is +// bind-mounted to /etc/nginx/certs in the quadlet, so nginx sees them here +// regardless of the host's XDG layout. +const ( + lerdCertContainerPath = "/etc/nginx/certs/lerd.localhost.crt" + lerdKeyContainerPath = "/etc/nginx/certs/lerd.localhost.key" +) + +func linuxLerdVhost(socketPath string, ssl bool) string { + locations := fmt.Sprintf(` location = / { + proxy_pass http://unix:%[1]s:; } location ^~ /icons/ { - proxy_pass http://host.containers.internal:7073; + proxy_pass http://unix:%[1]s:$request_uri; } location ^~ /assets/ { - proxy_pass http://host.containers.internal:7073; + proxy_pass http://unix:%[1]s:$request_uri; } location = /manifest.webmanifest { - proxy_pass http://host.containers.internal:7073; + proxy_pass http://unix:%[1]s:$request_uri; } location = /sw.js { - proxy_pass http://host.containers.internal:7073; + proxy_pass http://unix:%[1]s:$request_uri; } location = /offline.html { - proxy_pass http://host.containers.internal:7073; + proxy_pass http://unix:%[1]s:$request_uri; } location / { return 444; - } + }`, socketPath) + + if !ssl { + return fmt.Sprintf(`server { + listen 80; + listen [::]:80; + server_name lerd.localhost; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + +%s } -`, token) - } else { - content = fmt.Sprintf(`server { +`, locations) + } + + return fmt.Sprintf(`server { listen 80; listen [::]:80; server_name lerd.localhost; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name lerd.localhost; + + ssl_certificate %s; + ssl_certificate_key %s; proxy_http_version 1.1; proxy_set_header Host $host; @@ -909,37 +961,83 @@ func EnsureLerdVhost() error { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - location = / { - proxy_pass http://unix:%[1]s:; +%s +} +`, lerdCertContainerPath, lerdKeyContainerPath, locations) +} + +func darwinLerdVhost(token string, ssl bool) string { + locations := ` location = / { + proxy_pass http://host.containers.internal:7073; } location ^~ /icons/ { - proxy_pass http://unix:%[1]s:$request_uri; + proxy_pass http://host.containers.internal:7073; } location ^~ /assets/ { - proxy_pass http://unix:%[1]s:$request_uri; + proxy_pass http://host.containers.internal:7073; } location = /manifest.webmanifest { - proxy_pass http://unix:%[1]s:$request_uri; + proxy_pass http://host.containers.internal:7073; } location = /sw.js { - proxy_pass http://unix:%[1]s:$request_uri; + proxy_pass http://host.containers.internal:7073; } location = /offline.html { - proxy_pass http://unix:%[1]s:$request_uri; + proxy_pass http://host.containers.internal:7073; } location / { return 444; - } + }` + + if !ssl { + return fmt.Sprintf(`server { + listen 80; + listen [::]:80; + server_name lerd.localhost; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Lerd-Trust %s; + +%s } -`, config.UISocketPath()) +`, token, locations) } - return os.WriteFile(filepath.Join(config.NginxConfD(), "lerd.localhost.conf"), []byte(content), 0644) + + return fmt.Sprintf(`server { + listen 80; + listen [::]:80; + server_name lerd.localhost; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name lerd.localhost; + + ssl_certificate %s; + ssl_certificate_key %s; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Lerd-Trust %s; + +%s +} +`, lerdCertContainerPath, lerdKeyContainerPath, token, locations) } // EnsureNginxConfig copies the base nginx.conf to the data dir if it is missing. diff --git a/internal/nginx/manager_test.go b/internal/nginx/manager_test.go index 2d20d16..4528a4a 100644 --- a/internal/nginx/manager_test.go +++ b/internal/nginx/manager_test.go @@ -581,17 +581,49 @@ func TestEnsureDefaultVhost_writesDefaultConf(t *testing.T) { } } +// writeLerdCert touches the dashboard cert and key files so EnsureLerdVhost +// emits the SSL server block. The certs package owns the actual mkcert call; +// the nginx layer only writes the config and assumes the cert exists on disk. +func writeLerdCert(t *testing.T) { + t.Helper() + sitesDir := filepath.Join(config.CertsDir(), "sites") + if err := os.MkdirAll(sitesDir, 0755); err != nil { + t.Fatalf("mkdir sites: %v", err) + } + for _, suffix := range []string{".crt", ".key"} { + if err := os.WriteFile(filepath.Join(sitesDir, "lerd.localhost"+suffix), []byte("stub"), 0600); err != nil { + t.Fatalf("writing %s: %v", suffix, err) + } + } +} + func TestEnsureLerdVhost_linuxProxiesUnixSocket(t *testing.T) { if runtime.GOOS == "darwin" { t.Skip("Linux uses the unix socket vhost; macOS uses TCP via host.containers.internal") } confD := setupConfD(t) + writeLerdCert(t) if err := EnsureLerdVhost(); err != nil { t.Fatalf("EnsureLerdVhost: %v", err) } content := readConf(t, filepath.Join(confD, "lerd.localhost.conf")) - // On Linux the vhost MUST proxy via the unix socket. host.containers.internal + // HTTP-to-HTTPS redirect: port 80 must 301 to https — dashboard is HTTPS-only. + if !strings.Contains(content, "return 301 https://$host$request_uri") { + t.Errorf("expected HTTP-to-HTTPS 301 redirect in:\n%s", content) + } + // SSL server block must listen on 443 with cert paths under /etc/nginx/certs/sites/. + if !strings.Contains(content, "listen 443 ssl") { + t.Errorf("expected SSL listen 443 in:\n%s", content) + } + if !strings.Contains(content, "ssl_certificate /etc/nginx/certs/lerd.localhost.crt") { + t.Errorf("expected ssl_certificate path in:\n%s", content) + } + if !strings.Contains(content, "ssl_certificate_key /etc/nginx/certs/lerd.localhost.key") { + t.Errorf("expected ssl_certificate_key path in:\n%s", content) + } + + // On Linux the SSL block MUST proxy via the unix socket. host.containers.internal // is the failure mode the fix removes; if a future refactor reintroduces // it on Linux, this test catches the regression. want := "proxy_pass http://unix:" + config.UISocketPath() @@ -608,6 +640,24 @@ func TestEnsureLerdVhost_linuxProxiesUnixSocket(t *testing.T) { } } +func TestEnsureLerdVhost_fallsBackToHTTPWhenCertMissing(t *testing.T) { + // Fresh installs hit EnsureLerdVhost before mkcert has issued the + // lerd.localhost cert. Without graceful fallback nginx would refuse to + // start with a missing-cert error; the vhost must stay HTTP-only until + // the next start picks up the freshly issued cert. + confD := setupConfD(t) + if err := EnsureLerdVhost(); err != nil { + t.Fatalf("EnsureLerdVhost: %v", err) + } + content := readConf(t, filepath.Join(confD, "lerd.localhost.conf")) + if strings.Contains(content, "listen 443") { + t.Errorf("vhost should be HTTP-only when cert is missing, got:\n%s", content) + } + if strings.Contains(content, "return 301 https") { + t.Errorf("vhost should not redirect to HTTPS when cert is missing, got:\n%s", content) + } +} + // ── Forwarded headers & custom.d include hook ──────────────────────────────── func TestEnsureForwardedConf_writesMapBlocks(t *testing.T) { @@ -775,12 +825,24 @@ func TestEnsureLerdVhost_darwinProxiesHostContainersInternal(t *testing.T) { t.Skip("macOS uses TCP via host.containers.internal because unix sockets don't traverse the podman-machine virtio-fs boundary as functional sockets") } confD := setupConfD(t) + writeLerdCert(t) if err := EnsureLerdVhost(); err != nil { t.Fatalf("EnsureLerdVhost: %v", err) } content := readConf(t, filepath.Join(confD, "lerd.localhost.conf")) - // On macOS the vhost MUST proxy via TCP to host.containers.internal:7073 + // HTTP-to-HTTPS redirect on port 80. + if !strings.Contains(content, "return 301 https://$host$request_uri") { + t.Errorf("expected HTTP-to-HTTPS 301 redirect in:\n%s", content) + } + if !strings.Contains(content, "listen 443 ssl") { + t.Errorf("expected SSL listen 443 in:\n%s", content) + } + if !strings.Contains(content, "ssl_certificate /etc/nginx/certs/lerd.localhost.crt") { + t.Errorf("expected ssl_certificate path in:\n%s", content) + } + + // On macOS the SSL block MUST proxy via TCP to host.containers.internal:7073 // and MUST inject the X-Lerd-Trust header so lerd-ui's gate sees the // proxied request as loopback (it arrives via the bridge, not 127.0.0.1). if !strings.Contains(content, "proxy_pass http://host.containers.internal:7073") {