From 787f9342cbe979afb7cfc4c95bbec49636aafc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erhan=20=C3=9CRG=C3=9CN?= <405591@ogr.ktu.edu.tr> Date: Sat, 16 May 2026 01:17:06 +0300 Subject: [PATCH 1/2] feat(nginx): emit HTTPS server block for lerd.localhost dashboard Dashboard previously served over plain HTTP while every other .test site got TLS by default, leaking the auth cookie to anything sniffing loopback. EnsureLerdVhost now generates a 443 ssl server (with the existing lerd.localhost cert produced by mkcert) and 301-redirects port 80 to HTTPS on both Linux (unix socket proxy) and macOS (host.containers.internal + X-Lerd-Trust). Fresh installs run before mkcert has issued the cert; the helper lerdCertExists keeps the HTTP-only fallback in that window so nginx does not refuse to start with a missing-cert error. The next start picks up the freshly issued files. --- internal/nginx/manager.go | 160 ++++++++++++++++++++++++++------- internal/nginx/manager_test.go | 66 +++++++++++++- 2 files changed, 193 insertions(+), 33 deletions(-) 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") { From 7848e4a1907debae72687bb8f24e2956540a7473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erhan=20=C3=9CRG=C3=9CN?= <405591@ogr.ktu.edu.tr> Date: Sat, 16 May 2026 01:17:06 +0300 Subject: [PATCH 2/2] feat(cli): issue lerd.localhost cert before writing dashboard vhost EnsureLerdVhost reads the cert/key off disk to decide between the HTTPS vhost and the HTTP-only fallback, so the cert has to exist by the time it runs. Both install and start now call certs.IssueCert for lerd.localhost ahead of EnsureLerdVhost. IssueCert is a no-op when the files already exist, so subsequent starts pay nothing. --- internal/cli/install.go | 6 ++++++ internal/cli/startstop.go | 7 +++++++ 2 files changed, 13 insertions(+) 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) }