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 }