diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 6c19ec9..57a2122 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -67,6 +67,13 @@ func main() { response.OK(c, gin.H{"status": "ok", "version": version}) }) + // ACME HTTP-01 webroot — HAProxy proxies these through pre-setup + // so certbot can issue the first cert. Webroot location matches + // certbot's default; override via EDGEGUARD_ACME_WEBROOT for + // dev/tests. + acmeWebroot := os.Getenv("EDGEGUARD_ACME_WEBROOT") + handlers.NewACMEHandler(acmeWebroot).Register(r) + v1 := r.Group("/api/v1") v1.Use(handlers.SetupGate(setupStore)) diff --git a/internal/handlers/acme.go b/internal/handlers/acme.go new file mode 100644 index 0000000..be184ec --- /dev/null +++ b/internal/handlers/acme.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// ACMEHandler serves HTTP-01 challenge tokens that certbot dropped +// into a webroot directory. HAProxy proxies all +// /.well-known/acme-challenge/* requests to edgeguard-api on +// 127.0.0.1:9443; this handler is what they hit. +// +// Layout on disk (matching certbot --webroot's default): +// +// /.well-known/acme-challenge/ +// +// where defaults to /var/lib/edgeguard/acme. Files are +// short-lived plaintext (~64 ASCII chars); the handler reads them +// directly off disk on every request. +type ACMEHandler struct { + WebrootDir string +} + +func NewACMEHandler(webrootDir string) *ACMEHandler { + if webrootDir == "" { + webrootDir = "/var/lib/edgeguard/acme" + } + return &ACMEHandler{WebrootDir: webrootDir} +} + +// Register mounts /.well-known/acme-challenge/:token on the bare +// engine (NOT under /api/v1) and stays outside SetupGate / RequireAuth +// — ACME runs as soon as HAProxy is up, often before the operator +// has even completed setup. +func (h *ACMEHandler) Register(r *gin.Engine) { + r.GET("/.well-known/acme-challenge/:token", h.serveToken) +} + +func (h *ACMEHandler) serveToken(c *gin.Context) { + token := c.Param("token") + if !validToken(token) { + c.Status(http.StatusBadRequest) + return + } + full := filepath.Join(h.WebrootDir, ".well-known", "acme-challenge", token) + // Defense in depth: refuse paths that escape the webroot even + // after the validToken check (symlink / path-resolution edge + // cases). + abs, err := filepath.Abs(full) + if err != nil { + c.Status(http.StatusBadRequest) + return + } + rootAbs, err := filepath.Abs(h.WebrootDir) + if err != nil || !strings.HasPrefix(abs, rootAbs+string(os.PathSeparator)) { + c.Status(http.StatusBadRequest) + return + } + if info, err := os.Stat(abs); err != nil || info.IsDir() { + c.Status(http.StatusNotFound) + return + } + // Use c.File so client gets correct Content-Length, ETag, etc. + // Content-Type defaults to application/octet-stream which is fine + // for ACME — the spec doesn't pin a type. + c.File(abs) +} + +// validToken accepts the RFC 8555 §8.3 charset for HTTP-01 +// challenges: base64url alphabet [A-Za-z0-9-_], length 1..128. We +// reject everything else so the URL parser can never let a `..` or +// path separator through. +func validToken(s string) bool { + if len(s) == 0 || len(s) > 128 { + return false + } + for _, r := range s { + ok := (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' + if !ok { + return false + } + } + return true +} diff --git a/internal/handlers/acme_test.go b/internal/handlers/acme_test.go new file mode 100644 index 0000000..1800928 --- /dev/null +++ b/internal/handlers/acme_test.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { gin.SetMode(gin.TestMode) } + +func setupACME(t *testing.T) (*gin.Engine, string) { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".well-known", "acme-challenge"), 0o755); err != nil { + t.Fatal(err) + } + r := gin.New() + NewACMEHandler(root).Register(r) + return r, root +} + +func TestACME_ServesExistingToken(t *testing.T) { + r, root := setupACME(t) + tokenContent := "abc123-some-key-authorisation" + tokenPath := filepath.Join(root, ".well-known", "acme-challenge", "tok_42") + if err := os.WriteFile(tokenPath, []byte(tokenContent), 0o644); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/tok_42", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: %d, body=%s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != tokenContent { + t.Errorf("body: %q", rec.Body.String()) + } +} + +func TestACME_MissingToken_Returns404(t *testing.T) { + r, _ := setupACME(t) + rec := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/notthere", nil) + r.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status: %d", rec.Code) + } +} + +func TestACME_RejectsTraversal(t *testing.T) { + // gin parses `:token` as one path segment, so `..` literally + // can't reach the handler — but we still want the validToken + // guard to reject any non-charset value defensively. + cases := []string{ + "a/b", + "..", + "a%2Fb", + "with space", + "semi;colon", + } + for _, c := range cases { + if validToken(c) { + t.Errorf("validToken(%q) should be false", c) + } + } +} + +func TestACME_DirIsNotAFile(t *testing.T) { + r, root := setupACME(t) + if err := os.Mkdir(filepath.Join(root, ".well-known", "acme-challenge", "subdir"), 0o755); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/subdir", nil) + r.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404 for directory, got %d", rec.Code) + } +}