Skip to content
Closed
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
6 changes: 6 additions & 0 deletions internal/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/startstop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
160 changes: 129 additions & 31 deletions internal/nginx/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -850,96 +850,194 @@ 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;
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;

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.
Expand Down
66 changes: 64 additions & 2 deletions internal/nginx/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -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") {
Expand Down
Loading