diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 894484c..d16b386 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -37,6 +37,7 @@ import ( "syscall" "time" + "echo-lot.app/server/internal/acmehttp" "echo-lot.app/server/internal/adminauth" "echo-lot.app/server/internal/canarydns" "echo-lot.app/server/internal/certreload" @@ -336,6 +337,27 @@ func serve(cfg *config.Config) error { go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }() } + // ACME HTTP-01 responder. Permanent rather than started per renewal: nothing binds and + // unbinds, so a renewal cannot fail because the port was briefly busy, and the ACME client + // needs only write access to a directory instead of the privilege to bind a low port. + if cfg.ACMEHTTPListen != "" { + webroot := cfg.ACMEWebroot + if webroot == "" { + webroot = filepath.Join(cfg.StateDir, "acme") + } + if err := acmehttp.EnsureWebroot(webroot); err != nil { + return fmt.Errorf("acme webroot: %w", err) + } + acmeSrv := &http.Server{ + Addr: cfg.ACMEHTTPListen, + Handler: acmehttp.Handler(webroot, cfg.AdminBaseURL), + ReadHeaderTimeout: 10 * time.Second, + } + slog.Info("acme http-01 responder", "listen", cfg.ACMEHTTPListen, "webroot", webroot, + "redirects_to", cfg.AdminBaseURL) + go func() { errCh <- fmt.Errorf("acme-http: %w", acmeSrv.ListenAndServe()) }() + } + // UDP data plane — one socket per configured address. Distinct sockets // (not wildcard) also guarantee responses leave from the address the // request arrived on, which stun-5780 will rely on. diff --git a/server/internal/acmehttp/acmehttp.go b/server/internal/acmehttp/acmehttp.go new file mode 100644 index 0000000..d899188 --- /dev/null +++ b/server/internal/acmehttp/acmehttp.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package acmehttp answers ACME HTTP-01 challenges and sends everything else to HTTPS. +// +// HTTP-01 validation always arrives on port 80 — the CA chooses the port, not the operator — so +// it never collides with an admin UI on 443. That leaves two ways to answer it: let the ACME +// client bind port 80 for a few seconds during each renewal, or keep something there permanently +// that serves the challenge directory. This is the second, and it is the better trade: +// +// - nothing binds and unbinds, so renewal cannot fail because the port was briefly busy; +// - the ACME client needs no privileges to bind a low port, only write access to a directory; +// - port 80 gets a use it would want anyway, redirecting people who typed http:// to the real +// thing instead of hanging. +// +// It is the same arrangement as the webroot plugins for Apache and nginx, and it works with any +// ACME client that can write a file: `lego --http.webroot`, `certbot --webroot`, `acme.sh -w`. +// +// The ACME client stays an external program on purpose. lego is also a Go library, but importing +// it would put a large dependency tree into a server that deliberately has none — and the CLI does +// the same job from a timer. +package acmehttp + +import ( + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" +) + +// ChallengePath is the fixed prefix ACME uses. It is not configurable, by the specification. +const ChallengePath = "/.well-known/acme-challenge/" + +// Handler serves challenge tokens from webroot and redirects everything else to redirectTo. +// +// webroot is the directory an ACME client writes into; the tokens themselves land in +// /.well-known/acme-challenge/, which is exactly what --http.webroot expects. +func Handler(webroot, redirectTo string) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc(ChallengePath, func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.URL.Path, ChallengePath) + // Tokens are base64url from the CA. Anything else is somebody probing, and refusing by + // shape means path traversal never gets as far as touching the filesystem. + if token == "" || !validToken(token) { + http.NotFound(w, r) + return + } + body, err := os.ReadFile(filepath.Join(webroot, filepath.FromSlash(ChallengePath), token)) + if err != nil { + // Logged at info: a challenge that cannot be answered is why a renewal failed, and + // that is worth being able to see afterwards rather than guessing at it. + slog.Info("acme challenge not found", "token", token, "webroot", webroot) + http.NotFound(w, r) + return + } + slog.Info("answered acme challenge", "token", token, "from", r.RemoteAddr) + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write(body) + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if redirectTo == "" { + http.Error(w, "this port serves ACME challenges only", http.StatusNotFound) + return + } + // 308 rather than 302: the method must not change, and the redirect is permanent in the + // sense that matters — this port will never serve the application. + http.Redirect(w, r, strings.TrimRight(redirectTo, "/")+r.URL.RequestURI(), http.StatusPermanentRedirect) + }) + + return mux +} + +// validToken accepts only the base64url alphabet the ACME spec uses for tokens. +// +// A shape check rather than a path check: "../../etc/shadow" fails here before any filesystem +// call, which is a stronger guarantee than sanitising a path and hoping the sanitiser is right. +func validToken(s string) bool { + if len(s) > 128 { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + default: + return false + } + } + // A bare "." or ".." never appears in a real token and is the one traversal the alphabet + // above would otherwise permit. + return s != "." && s != ".." +} + +// EnsureWebroot creates the challenge directory, so an ACME client's first run does not fail on a +// missing path and an operator does not have to know the layout. +func EnsureWebroot(webroot string) error { + return os.MkdirAll(filepath.Join(webroot, filepath.FromSlash(ChallengePath)), 0o755) +} diff --git a/server/internal/acmehttp/acmehttp_test.go b/server/internal/acmehttp/acmehttp_test.go new file mode 100644 index 0000000..3f40f75 --- /dev/null +++ b/server/internal/acmehttp/acmehttp_test.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package acmehttp + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func serve(t *testing.T, redirectTo string) (http.Handler, string) { + t.Helper() + root := t.TempDir() + if err := EnsureWebroot(root); err != nil { + t.Fatal(err) + } + return Handler(root, redirectTo), root +} + +func TestServesAChallengeTokenWrittenByAnAcmeClient(t *testing.T) { + h, root := serve(t, "https://admin.example.net") + // Exactly what `lego --http.webroot` writes. + token := "abc-123_XYZ" + want := "abc-123_XYZ.keyauthorization-part" + if err := os.WriteFile(filepath.Join(root, ".well-known", "acme-challenge", token), []byte(want), 0o644); err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+token, nil)) + if rec.Code != 200 { + t.Fatalf("challenge not served: %d", rec.Code) + } + if rec.Body.String() != want { + t.Fatalf("body = %q, want %q", rec.Body.String(), want) + } +} + +// The token comes from the network and is used to build a path. Rejecting by *shape* means +// traversal never reaches the filesystem at all, which is a stronger guarantee than sanitising. +func TestTraversalNeverTouchesTheFilesystem(t *testing.T) { + h, root := serve(t, "https://admin.example.net") + secret := filepath.Join(filepath.Dir(root), "secret.txt") + if err := os.WriteFile(secret, []byte("do not serve me"), 0o600); err != nil { + t.Fatal(err) + } + for _, bad := range []string{ + "../secret.txt", + "..%2Fsecret.txt", + "../../etc/passwd", + "..", + ".", + "a/b", + "tok%20en", // a space arrives percent-encoded; a literal one is not a valid request line + } { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+bad, nil)) + if rec.Code == 200 && rec.Body.String() == "do not serve me" { + t.Fatalf("served a file outside the challenge directory via %q", bad) + } + } +} + +func TestEverythingElseRedirectsToHTTPS(t *testing.T) { + h, _ := serve(t, "https://admin.example.net") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/devices?page=2", nil)) + if rec.Code != http.StatusPermanentRedirect { + t.Fatalf("code = %d, want 308", rec.Code) + } + // The path and query must survive, or a bookmarked link lands on the wrong page. + if got := rec.Header().Get("Location"); got != "https://admin.example.net/devices?page=2" { + t.Fatalf("Location = %q", got) + } +} + +// With no admin URL configured there is nowhere to send people, and inventing one would be worse +// than saying so. +func TestNoRedirectTargetIsHonest(t *testing.T) { + h, _ := serve(t, "") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } +} + +func TestMissingTokenIsNotFound(t *testing.T) { + h, _ := serve(t, "https://admin.example.net") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+"never-written", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 4390c3e..d0edfcd 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -95,6 +95,13 @@ type Config struct { // is made rather than stumbled into. AdminInsecure bool // ECHOLOT_ADMIN_INSECURE / --admin-insecure + // Port-80 listener that answers ACME HTTP-01 challenges and redirects everything else to + // the admin UI. Empty disables it. HTTP-01 always arrives on port 80 — the CA picks the + // port — so this never collides with the admin UI on 443. + ACMEHTTPListen string // ECHOLOT_ACME_HTTP_LISTEN / --acme-http-listen + // Directory an ACME client writes challenge tokens into. Defaults to /acme. + ACMEWebroot string // ECHOLOT_ACME_WEBROOT / --acme-webroot + // Mode Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) } @@ -166,6 +173,8 @@ func Load(args []string) (*Config, *Actions, error) { fs.StringVar(&c.AdminTLSCert, "admin-tls-cert", envOr("ADMIN_TLS_CERT", ""), "TLS certificate for the admin listener") fs.StringVar(&c.AdminTLSKey, "admin-tls-key", envOr("ADMIN_TLS_KEY", ""), "TLS key for the admin listener") fs.BoolVar(&c.AdminInsecure, "admin-insecure", envOr("ADMIN_INSECURE", "") == "1", "allow the admin UI in plaintext off loopback (you are on your own)") + fs.StringVar(&c.ACMEHTTPListen, "acme-http-listen", envOr("ACME_HTTP_LISTEN", ""), "port-80 listener for ACME HTTP-01 challenges and http->https redirects") + fs.StringVar(&c.ACMEWebroot, "acme-webroot", envOr("ACME_WEBROOT", ""), "directory an ACME client writes challenges into (default /acme)") fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443") fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)") fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")