Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7f59a66a | ||
|
|
6afcb131ef | ||
|
|
cd187f9ef5 | ||
|
|
3a4cb1c327 |
@@ -146,3 +146,41 @@ CI (`.gitea/workflows/build-server.yml`): tests on every push touching `server/`
|
|||||||
tagging `server-v1.2.3` builds + pushes the container image to the Gitea registry and
|
tagging `server-v1.2.3` builds + pushes the container image to the Gitea registry and
|
||||||
attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same
|
attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same
|
||||||
artifacts `--self-update` consumes.
|
artifacts `--self-update` consumes.
|
||||||
|
|
||||||
|
## TLS for the admin UI
|
||||||
|
|
||||||
|
The binary terminates TLS itself; there is no reverse proxy in the design. It already serves TLS
|
||||||
|
for the control plane, so this is reuse rather than new machinery, and it keeps the "one process,
|
||||||
|
one config file" property. A proxy would also invite someone to eventually front the control plane
|
||||||
|
too — which would break SPKI pinning, because clients pin *that* certificate's key.
|
||||||
|
|
||||||
|
```
|
||||||
|
ECHOLOT_ADMIN_LISTEN=[2001:db8::2]:443
|
||||||
|
ECHOLOT_ADMIN_TLS_CERT=/etc/echolot/admin.pem
|
||||||
|
ECHOLOT_ADMIN_TLS_KEY=/etc/echolot/admin.key
|
||||||
|
ECHOLOT_ADMIN_BASE_URL=https://admin.example.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Certificates come from any ACME client. **DNS-01 is the one to use here**: it needs no inbound
|
||||||
|
port 80, which matters on a host where 80 is awkward or already spoken for.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
acme.sh --issue --dns dns_cf -d admin.example.net \
|
||||||
|
--key-file /etc/echolot/admin.key \
|
||||||
|
--fullchain-file /etc/echolot/admin.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**No reload hook is needed.** The certificate is re-read when the files change, so a renewal that
|
||||||
|
drops new files in place is picked up on the next handshake. That is deliberate: a reload hook is
|
||||||
|
the part of a renewal setup that quietly stops working, months later, and is noticed only once the
|
||||||
|
certificate has already expired. A torn write — renewal tools write cert and key separately — keeps
|
||||||
|
the previous certificate rather than failing the listener.
|
||||||
|
|
||||||
|
Serving the admin UI in plaintext on a non-loopback address is refused: the session cookie is a
|
||||||
|
bearer credential for everything the server can do, and the OIDC authorization code arrives in a
|
||||||
|
URL. Bind to loopback and use an SSH tunnel (`ssh -L 8444:localhost:8444 host`), supply a
|
||||||
|
certificate, or set `ECHOLOT_ADMIN_INSECURE=1` if you mean it.
|
||||||
|
|
||||||
|
The control-plane certificate is deliberately *not* hot-reloaded. Clients pin its public key, so
|
||||||
|
replacing it is a rotation an operator should have to think about, not something that happens
|
||||||
|
because a file changed.
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"echo-lot.app/server/internal/acmehttp"
|
||||||
"echo-lot.app/server/internal/adminauth"
|
"echo-lot.app/server/internal/adminauth"
|
||||||
"echo-lot.app/server/internal/canarydns"
|
"echo-lot.app/server/internal/canarydns"
|
||||||
|
"echo-lot.app/server/internal/certreload"
|
||||||
"echo-lot.app/server/internal/compat"
|
"echo-lot.app/server/internal/compat"
|
||||||
"echo-lot.app/server/internal/config"
|
"echo-lot.app/server/internal/config"
|
||||||
"echo-lot.app/server/internal/control"
|
"echo-lot.app/server/internal/control"
|
||||||
@@ -73,6 +75,26 @@ func run() error {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case actions.Help:
|
case actions.Help:
|
||||||
|
// Compatibility shim for one release.
|
||||||
|
//
|
||||||
|
// Serving became an explicit verb, but self-update is run by the *old* binary — so the
|
||||||
|
// repair added to the updater cannot fix the very update that installs the new one. A
|
||||||
|
// unit written before this change starts us with no arguments, and without this branch
|
||||||
|
// the service would simply stop working, unattended, on a host nobody is watching.
|
||||||
|
//
|
||||||
|
// Only when systemd started us: INVOCATION_ID is set by systemd for every service
|
||||||
|
// invocation and by nothing else, so a person at a terminal still gets usage. Remove
|
||||||
|
// this once no deployment predates --serve.
|
||||||
|
if os.Getenv("INVOCATION_ID") != "" {
|
||||||
|
slog.Warn("started by systemd with no verb — this unit predates --serve; " +
|
||||||
|
"repairing it and serving anyway")
|
||||||
|
if repaired, err := system.RepairExecStart(); err != nil {
|
||||||
|
slog.Error("could not repair the unit; fix ExecStart by hand", "err", err)
|
||||||
|
} else if repaired {
|
||||||
|
slog.Info("systemd unit updated to pass --serve")
|
||||||
|
}
|
||||||
|
return serve(cfg)
|
||||||
|
}
|
||||||
config.Usage(os.Stderr)
|
config.Usage(os.Stderr)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
return nil
|
return nil
|
||||||
@@ -294,7 +316,47 @@ func serve(cfg *config.Config) error {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
||||||
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
|
if cfg.AdminTLSCert != "" {
|
||||||
|
// Terminated here rather than behind a reverse proxy: this binary already serves TLS for
|
||||||
|
// the control plane, so it is reuse rather than new machinery, and one process with one
|
||||||
|
// config file is the property that makes this pleasant to run. A proxy would also invite
|
||||||
|
// someone to later front the control plane too, which would break SPKI pinning.
|
||||||
|
reloader, err := certreload.New(cfg.AdminTLSCert, cfg.AdminTLSKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("admin TLS: %w", err)
|
||||||
|
}
|
||||||
|
adminSrv.TLSConfig = reloader.TLSConfig()
|
||||||
|
if exp := reloader.NotAfter(); !exp.IsZero() {
|
||||||
|
slog.Info("admin UI TLS", "listen", cfg.AdminListen, "cert_expires", exp.Format(time.RFC3339))
|
||||||
|
if time.Until(exp) < 14*24*time.Hour {
|
||||||
|
slog.Warn("admin certificate expires soon", "expires", exp.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServeTLS("", "")) }()
|
||||||
|
} else {
|
||||||
|
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
|
// UDP data plane — one socket per configured address. Distinct sockets
|
||||||
// (not wildcard) also guarantee responses leave from the address the
|
// (not wildcard) also guarantee responses leave from the address the
|
||||||
|
|||||||
@@ -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
|
||||||
|
// <webroot>/.well-known/acme-challenge/<token>, 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package certreload serves a TLS certificate that can be replaced on disk without a restart.
|
||||||
|
//
|
||||||
|
// The hard part of TLS is never termination — the stdlib does that — it is renewal. A certificate
|
||||||
|
// obtained from an ACME client expires every sixty days, and the usual arrangement is a renewal
|
||||||
|
// hook that reloads or restarts the service. That hook is the part that quietly fails: it works
|
||||||
|
// when it is written and then, months later, does not, and nobody notices until the certificate
|
||||||
|
// has already expired.
|
||||||
|
//
|
||||||
|
// So the certificate is re-read when the file changes. There is no hook to forget, no reload to
|
||||||
|
// coordinate, and a renewal that drops new files in place is picked up on the next handshake.
|
||||||
|
//
|
||||||
|
// Deliberately not used for the control plane. Clients pin that certificate's public key
|
||||||
|
// (probe-protocol.md §1), so swapping it at runtime would silently break every enrolled device —
|
||||||
|
// there the operator *should* have to think, and a restart is the least of what a key rotation
|
||||||
|
// costs. Two listeners, two different right answers.
|
||||||
|
package certreload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reloader holds a certificate and refreshes it when the files on disk change.
|
||||||
|
type Reloader struct {
|
||||||
|
certPath, keyPath string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
cert *tls.Certificate
|
||||||
|
certMod time.Time
|
||||||
|
keyMod time.Time
|
||||||
|
checked time.Time
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New loads the pair once so a bad path fails at startup rather than at the first handshake,
|
||||||
|
// when the only symptom is a connection error at the far end.
|
||||||
|
func New(certPath, keyPath string) (*Reloader, error) {
|
||||||
|
r := &Reloader{certPath: certPath, keyPath: keyPath, interval: 30 * time.Second}
|
||||||
|
if err := r.load(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSConfig returns a config that asks this reloader for the certificate on every handshake.
|
||||||
|
func (r *Reloader) TLSConfig() *tls.Config {
|
||||||
|
return &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
GetCertificate: r.getCertificate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloader) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
r.maybeReload()
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
if r.cert == nil {
|
||||||
|
return nil, fmt.Errorf("no certificate loaded")
|
||||||
|
}
|
||||||
|
return r.cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeReload stats the files at most once per interval.
|
||||||
|
//
|
||||||
|
// Rate-limited because this runs on every handshake: a busy listener would otherwise stat twice
|
||||||
|
// per connection, and a certificate that is thirty seconds stale has never mattered to anyone.
|
||||||
|
func (r *Reloader) maybeReload() {
|
||||||
|
r.mu.RLock()
|
||||||
|
fresh := time.Since(r.checked) < r.interval
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.checked = time.Now()
|
||||||
|
certMod, keyMod := modTime(r.certPath), modTime(r.keyPath)
|
||||||
|
unchanged := certMod.Equal(r.certMod) && keyMod.Equal(r.keyMod)
|
||||||
|
r.mu.Unlock()
|
||||||
|
if unchanged {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A failed reload keeps the certificate already in memory. Renewal tools write the two files
|
||||||
|
// separately, so there is a window where the pair does not match; serving the previous
|
||||||
|
// certificate through that window is strictly better than serving none.
|
||||||
|
_ = r.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloader) load() error {
|
||||||
|
cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loading %s / %s: %w", r.certPath, r.keyPath, err)
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.cert = &cert
|
||||||
|
r.certMod, r.keyMod = modTime(r.certPath), modTime(r.keyPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotAfter is when the loaded certificate expires, for the admin UI to show and for a startup
|
||||||
|
// warning. An expiry an operator can see is one they can act on before a browser tells them.
|
||||||
|
func (r *Reloader) NotAfter() time.Time {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
if r.cert == nil || r.cert.Leaf == nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return r.cert.Leaf.NotAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
func modTime(path string) time.Time {
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return fi.ModTime()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package certreload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writePair(t *testing.T, dir, cn string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tmpl := x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
certPath := filepath.Join(dir, "cert.pem")
|
||||||
|
keyPath := filepath.Join(dir, "key.pem")
|
||||||
|
cb := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
kb, _ := x509.MarshalECPrivateKey(key)
|
||||||
|
if err := os.WriteFile(certPath, cb, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return certPath, keyPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadPathsFailAtStartupNotAtHandshake(t *testing.T) {
|
||||||
|
if _, err := New("/nonexistent/cert.pem", "/nonexistent/key.pem"); err == nil {
|
||||||
|
t.Fatal("a missing certificate was accepted; the failure would surface as an " +
|
||||||
|
"unexplained connection error at the client instead")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: a renewal that drops new files in place is picked up without a restart and
|
||||||
|
// without a reload hook that can silently stop working.
|
||||||
|
func TestANewCertificateOnDiskIsPickedUp(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, keyPath := writePair(t, dir, "first")
|
||||||
|
r, err := New(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.interval = 0 // check on every handshake, rather than waiting out the rate limit
|
||||||
|
|
||||||
|
got, err := r.getCertificate(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := got.Leaf
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond) // ensure a distinct mtime
|
||||||
|
writePair(t, dir, "second")
|
||||||
|
|
||||||
|
got, err = r.getCertificate(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Leaf != nil && first != nil && got.Leaf.SerialNumber.Cmp(first.SerialNumber) == 0 {
|
||||||
|
t.Fatal("the replaced certificate was not picked up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renewal tools write the certificate and the key separately, so there is a window where the two
|
||||||
|
// do not match. Serving the previous certificate through it beats serving none.
|
||||||
|
func TestAHalfWrittenPairKeepsTheOldCertificate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, keyPath := writePair(t, dir, "good")
|
||||||
|
r, err := New(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.interval = 0
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if err := os.WriteFile(certPath, []byte("-----BEGIN CERTIFICATE-----\ntruncated\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := r.getCertificate(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a torn write took the listener down: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("no certificate served during a torn write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryIsVisible(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certPath, keyPath := writePair(t, dir, "x")
|
||||||
|
r, err := New(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := r.NotAfter(); got.IsZero() || time.Until(got) > 48*time.Hour {
|
||||||
|
t.Fatalf("expiry not reported sensibly: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -78,10 +79,47 @@ type Config struct {
|
|||||||
// Break-glass admin username; the password lives hashed in the state store.
|
// Break-glass admin username; the password lives hashed in the state store.
|
||||||
AdminUser string // ECHOLOT_ADMIN_USER / --admin-user
|
AdminUser string // ECHOLOT_ADMIN_USER / --admin-user
|
||||||
|
|
||||||
|
// Secret for the *confidential* admin client. Prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE: a path
|
||||||
|
// keeps the secret out of the environment, where it is readable by anything that can see
|
||||||
|
// /proc/<pid>/environ and lands in every dump of the unit's configuration.
|
||||||
|
OIDCClientSecret string // ECHOLOT_OIDC_CLIENT_SECRET / _FILE
|
||||||
|
|
||||||
|
// Where the admin UI is reachable, used to build the OIDC redirect URI. Must match what is
|
||||||
|
// registered at the IdP exactly.
|
||||||
|
AdminBaseURL string // ECHOLOT_ADMIN_BASE_URL / --admin-base-url
|
||||||
|
// TLS for the admin listener. Without these it serves plaintext, which is only acceptable on
|
||||||
|
// loopback — see checkAdminExposure.
|
||||||
|
AdminTLSCert string // ECHOLOT_ADMIN_TLS_CERT / --admin-tls-cert
|
||||||
|
AdminTLSKey string // ECHOLOT_ADMIN_TLS_KEY / --admin-tls-key
|
||||||
|
// Deliberate override for serving the admin UI in plaintext off loopback, so that decision
|
||||||
|
// 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 <state-dir>/acme.
|
||||||
|
ACMEWebroot string // ECHOLOT_ACME_WEBROOT / --acme-webroot
|
||||||
|
|
||||||
// Mode
|
// Mode
|
||||||
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
|
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// secretOr reads ECHOLOT_<key>, or the contents of the file named by ECHOLOT_<key>_FILE.
|
||||||
|
//
|
||||||
|
// The file form exists because a secret in the environment is readable by anything that can see
|
||||||
|
// /proc/<pid>/environ and lands in every dump of the unit's configuration. A path costs nothing
|
||||||
|
// and keeps the value in one file whose permissions an operator can reason about.
|
||||||
|
func secretOr(key, def string) string {
|
||||||
|
if path := envOr(key+"_FILE", ""); path != "" {
|
||||||
|
if b, err := os.ReadFile(path); err == nil {
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return envOr(key, def)
|
||||||
|
}
|
||||||
|
|
||||||
// envInt reads ECHOLOT_<key> as an integer with a fallback.
|
// envInt reads ECHOLOT_<key> as an integer with a fallback.
|
||||||
func envInt(key string, def int) int {
|
func envInt(key string, def int) int {
|
||||||
if v := envOr(key, ""); v != "" {
|
if v := envOr(key, ""); v != "" {
|
||||||
@@ -130,6 +168,13 @@ func Load(args []string) (*Config, *Actions, error) {
|
|||||||
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI")
|
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI")
|
||||||
fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)")
|
fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)")
|
||||||
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
|
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
|
||||||
|
fs.StringVar(&c.OIDCClientSecret, "oidc-client-secret", secretOr("OIDC_CLIENT_SECRET", ""), "secret for the confidential admin client; prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE")
|
||||||
|
fs.StringVar(&c.AdminBaseURL, "admin-base-url", envOr("ADMIN_BASE_URL", ""), "public URL of the admin UI, for the OIDC redirect (e.g. https://admin.example.net)")
|
||||||
|
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 <state-dir>/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.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.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")
|
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")
|
||||||
@@ -160,6 +205,11 @@ func Load(args []string) (*Config, *Actions, error) {
|
|||||||
!a.SetAdminPassword && !a.Version {
|
!a.SetAdminPassword && !a.Version {
|
||||||
a.Help = true
|
a.Help = true
|
||||||
}
|
}
|
||||||
|
if a.Serve {
|
||||||
|
if err := c.checkAdminExposure(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.Docker && (a.InstallSystemd || a.UninstallSystemd || a.SelfUpdate) {
|
if c.Docker && (a.InstallSystemd || a.UninstallSystemd || a.SelfUpdate) {
|
||||||
return nil, nil, fmt.Errorf("systemd/self-update actions are native-mode only (container detected; override with ECHOLOT_DOCKER=0 if this is wrong)")
|
return nil, nil, fmt.Errorf("systemd/self-update actions are native-mode only (container detected; override with ECHOLOT_DOCKER=0 if this is wrong)")
|
||||||
}
|
}
|
||||||
@@ -188,6 +238,37 @@ should be something you asked for.
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkAdminExposure refuses to serve an unencrypted admin UI on a non-loopback address.
|
||||||
|
//
|
||||||
|
// The admin session cookie is a bearer credential for everything this server can do, and the OIDC
|
||||||
|
// authorization code arrives in a URL. In plaintext, both are readable by anyone on the path — and
|
||||||
|
// on a globally routable address "the path" means the internet. This is a hard stop rather than a
|
||||||
|
// warning because a warning in a log is not read by the person who most needs it, and because the
|
||||||
|
// two safe answers are cheap: bind to loopback and tunnel, or supply a certificate.
|
||||||
|
func (c *Config) checkAdminExposure() error {
|
||||||
|
if c.AdminTLSCert != "" || c.AdminInsecure {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, addr := range Addrs(c.AdminListen) {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||||
|
if host == "" || ip == nil || ip.IsLoopback() {
|
||||||
|
continue // loopback, or a name we cannot judge; RFC 8252 blesses loopback plaintext
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"refusing to serve the admin UI in plaintext on %s: the session cookie and the OIDC "+
|
||||||
|
"authorization code would cross the network in the clear.\n"+
|
||||||
|
" Fix it one of three ways:\n"+
|
||||||
|
" - bind to 127.0.0.1 and reach it over an SSH tunnel (no certificate needed)\n"+
|
||||||
|
" - set ECHOLOT_ADMIN_TLS_CERT and ECHOLOT_ADMIN_TLS_KEY\n"+
|
||||||
|
" - set ECHOLOT_ADMIN_INSECURE=1 if you genuinely mean it", addr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Addrs splits a comma-separated listen spec into individual addresses.
|
// Addrs splits a comma-separated listen spec into individual addresses.
|
||||||
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind
|
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind
|
||||||
// (":8443") would also claim addresses reserved for other purposes (e.g. an
|
// (":8443") would also claim addresses reserved for other purposes (e.g. an
|
||||||
|
|||||||
Reference in New Issue
Block a user