From 6bb6ae7cbe61df83a5bc291afe7ff9a95ae39ce3 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:08:55 -0700 Subject: [PATCH 1/6] fix(deploy): retire the managed Caddy block when a release leaves caddy ingress A non-caddy deploy or rollback left the block written by an earlier caddy release, so Caddy kept retrying certificates and serving a dead upstream (plan U5: dash.example.com, observe.example.com, omi-rss). The block is now removed after the state commit, under the operation's fence and a CAS on its exact hash, and shown as a plan effect. A failed operation never touches it. Rollback resolved its route expectation against the current release's ingress before the target's record could change it, so host-to-caddy rendered an empty CAS list. It now resolves unconditionally and applies the target's ingress; a failed host-to-caddy commit removes the block it wrote instead of rendering a route for a release that had none. State now names the target release's domain. Found along the way: the release record dropped tls.http_only, so a folderless rollback or a route restore re-rendered an HTTP-only site with HTTPS. Co-Authored-By: Claude Opus 5.5 --- internal/caddy/inspect.go | 229 +++++++++++++++++++++ internal/cli/plan.go | 1 + internal/cli/planeffects.go | 27 +++ internal/cli/planeffects_test.go | 25 +++ internal/deploy/deploy.go | 22 +- internal/deploy/deploy_test.go | 6 +- internal/deploy/rollback.go | 56 ++++-- internal/deploy/staleroute.go | 68 +++++++ internal/deploy/staleroute_test.go | 301 ++++++++++++++++++++++++++++ internal/releasemeta/releasemeta.go | 1 + 10 files changed, 719 insertions(+), 17 deletions(-) create mode 100644 internal/caddy/inspect.go create mode 100644 internal/deploy/staleroute.go create mode 100644 internal/deploy/staleroute_test.go diff --git a/internal/caddy/inspect.go b/internal/caddy/inspect.go new file mode 100644 index 0000000..a1a1650 --- /dev/null +++ b/internal/caddy/inspect.go @@ -0,0 +1,229 @@ +package caddy + +// Read-only view of the Caddyfile for `teploy domains check`: which sites exist, +// who owns them, how their TLS is served and what they proxy to. + +import ( + "context" + "net" + "sort" + "strings" +) + +// TLS modes of a site. +const ( + SiteTLSACME = "acme" // Caddy obtains a public certificate + SiteTLSInternal = "internal" // Caddy's local CA + SiteTLSCustom = "custom" // operator-supplied certificate + SiteTLSHTTPOnly = "http_only" // served on plain HTTP +) + +// RouteSite is one top-level site block as `domains check` sees it. +type RouteSite struct { + // Owner is the app whose managed region holds the block ("" = hand-written). + Owner string + // Hosts are the names the block serves, lowercased, without scheme or port. + Hosts []string + TLS string + // Upstreams are the dials of the block's reverse_proxy directives + // (host:port), excluding the managed webhook fragment. + Upstreams []string +} + +// ReadCaddyfile returns the server's Caddyfile; present=false when it has none. +func (c *Client) ReadCaddyfile(ctx context.Context) (content string, present bool, err error) { + data, present, err := readServerFile(ctx, c.exec, caddyfilePath) + return string(data), present, err +} + +// InspectRoutes lists every site of a Caddyfile, attributing each to the managed +// region holding it. An unparseable file is an error: a route view built from +// half a file would report routes as missing. +func InspectRoutes(content string) ([]RouteSite, error) { + lines := strings.Split(content, "\n") + regions := map[string][]string{} + var order []string + manual := make([]string, 0, len(lines)) + current := "" + for _, line := range lines { + if current == "" { + if name, ok := beginMarkerApp(line); ok { + current = name + if _, seen := regions[name]; !seen { + order = append(order, name) + } + manual = append(manual, "") + continue + } + manual = append(manual, line) + continue + } + if name, ok := endMarkerApp(line); ok && name == current { + current = "" + manual = append(manual, "") + continue + } + regions[current] = append(regions[current], line) + manual = append(manual, "") + } + // An unterminated region is unreadable as a route; the editors refuse it too. + if current != "" { + return nil, &unterminatedRegionError{app: current} + } + + var sites []RouteSite + blocks, err := ParseSites(strings.Join(manual, "\n")) + if err != nil { + return nil, err + } + for _, b := range blocks { + sites = append(sites, inspectSite("", b)) + } + for _, app := range order { + blocks, err := ParseSites(strings.Join(regions[app], "\n")) + if err != nil { + return nil, err + } + for _, b := range blocks { + sites = append(sites, inspectSite(app, b)) + } + } + return sites, nil +} + +type unterminatedRegionError struct{ app string } + +func (e *unterminatedRegionError) Error() string { + return "the managed block for " + e.app + " has no end marker" +} + +// RegionHosts lists the hosts a managed region serves. +func RegionHosts(region string) []string { + body := make([]string, 0) + for _, line := range strings.Split(region, "\n") { + if _, ok := beginMarkerApp(line); ok { + continue + } + if _, ok := endMarkerApp(line); ok { + continue + } + body = append(body, line) + } + blocks, err := ParseSites(strings.Join(body, "\n")) + if err != nil { + return nil + } + var hosts []string + for _, b := range blocks { + hosts = append(hosts, inspectSite("", b).Hosts...) + } + return hosts +} + +func inspectSite(owner string, b SiteBlock) RouteSite { + site := RouteSite{Owner: owner, TLS: SiteTLSACME} + allPlainHTTP := len(b.Addresses) > 0 + for _, addr := range b.Addresses { + host, plainHTTP := addressHost(addr) + if !plainHTTP { + allPlainHTTP = false + } + if host != "" { + site.Hosts = append(site.Hosts, host) + } + } + if allPlainHTTP { + site.TLS = SiteTLSHTTPOnly + } + inWebhook := false + depth := 0 + inProxy := false + proxyDepth := 0 + for i, raw := range b.Lines { + if i == 0 { + continue + } + trimmed := strings.TrimSpace(raw) + if strings.HasPrefix(trimmed, "# TEPLOY WEBHOOK BEGIN") { + inWebhook = true + continue + } + if strings.HasPrefix(trimmed, "# TEPLOY WEBHOOK END") { + inWebhook = false + continue + } + code, err := codeLine(raw) + if err != nil { + continue + } + fields := strings.Fields(code) + net := braceNet(code) + if len(fields) > 0 && !inWebhook { + switch { + case fields[0] == "reverse_proxy": + site.Upstreams = append(site.Upstreams, dialTokens(fields[1:])...) + if net > 0 { + inProxy, proxyDepth = true, depth+net + } + case fields[0] == "to" && inProxy && depth == proxyDepth: + site.Upstreams = append(site.Upstreams, dialTokens(fields[1:])...) + case fields[0] == "tls" && depth == 0 && site.TLS == SiteTLSACME && len(fields) > 1: + if fields[1] == "internal" { + site.TLS = SiteTLSInternal + } else if fields[1] != "{" && !strings.Contains(fields[1], "@") { + site.TLS = SiteTLSCustom + } + } + } + depth += net + if inProxy && depth < proxyDepth { + inProxy = false + } + } + sort.Strings(site.Upstreams) + return site +} + +// addressHost reduces a site address to its host and reports whether it is +// plain HTTP (scheme http:// or port 80). An address with no host (":8080") +// yields "". +func addressHost(addr string) (host string, plainHTTP bool) { + a := strings.TrimSpace(addr) + if i := strings.IndexAny(a, " \t"); i >= 0 { + a = a[:i] + } + if rest, ok := strings.CutPrefix(a, "http://"); ok { + a, plainHTTP = rest, true + } else { + a = strings.TrimPrefix(a, "https://") + } + if i := strings.IndexByte(a, '/'); i >= 0 { + a = a[:i] + } + if h, p, err := net.SplitHostPort(a); err == nil { + a = h + if p == "80" { + plainHTTP = true + } + } + return strings.ToLower(strings.Trim(a, "[]")), plainHTTP +} + +// dialTokens extracts upstream dials from directive arguments: matchers, +// paths and option braces are not dials. +func dialTokens(args []string) []string { + var out []string + for _, tok := range args { + switch { + case tok == "{", strings.HasPrefix(tok, "@"), strings.HasPrefix(tok, "/"), tok == "*": + continue + } + if i := strings.Index(tok, "://"); i >= 0 { + tok = tok[i+3:] + } + if tok != "" { + out = append(out, tok) + } + } + return out +} diff --git a/internal/cli/plan.go b/internal/cli/plan.go index 3a96b17..705db12 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -194,6 +194,7 @@ func buildPlanRecord(ctx context.Context, exec ssh.Executor, out io.Writer, appC rec.User = exec.User() rec.Effects.Routing = routingEffects(appCfg, current, view) + rec.Effects.Routing = append(rec.Effects.Routing, staleRouteEffects(ctx, exec, out, appCfg)...) rec.Effects.Env = envEffects(appCfg, view) rec.Effects.Storage = storageEffects(appCfg.App, appCfg, view) rec.Effects.Resources = resourceEffects(appCfg, view) diff --git a/internal/cli/planeffects.go b/internal/cli/planeffects.go index 1a95de1..125910d 100644 --- a/internal/cli/planeffects.go +++ b/internal/cli/planeffects.go @@ -10,12 +10,16 @@ package cli // unknown" entries rather than fabricated "no change". import ( + "context" "fmt" + "io" "sort" "strconv" "strings" + "github.com/useteploy/teploy/internal/caddy" "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/ssh" "github.com/useteploy/teploy/internal/state" ) @@ -106,6 +110,29 @@ func routingEffects(appCfg *config.AppConfig, current *state.AppState, view *con return effects } +// staleRouteEffects shows the managed Caddy block a non-caddy deploy will +// remove: left by an earlier caddy release, it is a visible part of the plan. +// It reads the server, so it sits beside the pure diffs rather than among them. +func staleRouteEffects(ctx context.Context, exec ssh.Executor, out io.Writer, appCfg *config.AppConfig) []planEffect { + if appCfg.UsesCaddy() || appCfg.IsStatic() { + return nil + } + region, present, err := caddy.NewClient(exec).ReadManagedBlock(ctx, appCfg.App) + if err != nil { + fmt.Fprintf(out, "Warning: could not check for a stale Caddy route: %v\n", err) + return nil + } + if !present { + return nil + } + return []planEffect{{ + Action: "remove", + Name: "stale caddy route", + From: strings.Join(caddy.RegionHosts(region), ","), + Detail: "left by an earlier caddy-ingress release; removed after the state commit, kept if this deploy fails", + }} +} + // envEffects diffs environment KEY presence (values are redacted by the // manifest contract; a value change is not plannable — only key and // env-file-reference changes are). Env-file NAMES diff separately: the diff --git a/internal/cli/planeffects_test.go b/internal/cli/planeffects_test.go index 3e2a6ae..f1f9d15 100644 --- a/internal/cli/planeffects_test.go +++ b/internal/cli/planeffects_test.go @@ -1,12 +1,15 @@ package cli import ( + "bytes" + "context" "encoding/json" "strings" "testing" "github.com/useteploy/teploy/internal/config" "github.com/useteploy/teploy/internal/releasemeta" + "github.com/useteploy/teploy/internal/ssh" "github.com/useteploy/teploy/internal/state" ) @@ -300,3 +303,25 @@ func TestPlanRecordJSON_Shape(t *testing.T) { t.Errorf("image.needs_build = %v", img["needs_build"]) } } + +func TestStaleRouteEffects(t *testing.T) { + block := "# TEPLOY BEGIN blog\n# TEPLOY GENERATION 2\nblog.dev, www.blog.dev {\n\treverse_proxy blog-web-a:3000\n}\n# TEPLOY END blog\n" + var out bytes.Buffer + withFile := func(content string) *ssh.MockExecutor { + m := ssh.NewMockExecutor("1.2.3.4") + m.Files["/deployments/caddy/Caddyfile"] = []byte(content) + return m + } + host := &config.AppConfig{App: "blog", Ingress: config.IngressHost, Port: 3000} + effects := staleRouteEffects(context.Background(), withFile(block), &out, host) + if len(effects) != 1 || effects[0].Action != "remove" || effects[0].From != "blog.dev,www.blog.dev" { + t.Fatalf("a non-caddy plan must show the block going: %+v", effects) + } + if got := staleRouteEffects(context.Background(), withFile("# nothing\n"), &out, host); got != nil { + t.Errorf("no block, no effect: %+v", got) + } + caddy := &config.AppConfig{App: "blog", Domain: "blog.dev"} + if got := staleRouteEffects(context.Background(), withFile(block), &out, caddy); got != nil { + t.Errorf("a caddy plan keeps its route: %+v", got) + } +} diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index ed9eb10..4800e4e 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -458,6 +458,13 @@ func (d *Deployer) deployFenced(ctx context.Context, cfg Config, lk *state.Lock) } resolvedRouteHash = caddy.ManagedRegionHash(region) } + // A non-caddy release retires any managed block a caddy release left + // behind (TCL-10's stale half, U5). Resolved here, removed after the state + // commit: a deploy that fails never touches the route. + var staleRoute staleRouteBlock + if !cfg.usesCaddy() { + staleRoute = resolveStaleRoute(ctx, d.caddy, d.out, cfg.App) + } // switchedRouteHash is re-resolved after a SUCCESSFUL switch (below): // the block this deploy wrote but has not yet committed — the restore // path's CAS accepts exactly {resolved, switched} and refuses anything @@ -1099,6 +1106,12 @@ func (d *Deployer) deployFenced(ctx context.Context, cfg Config, lk *state.Lock) return d.abortStateCommit(ctx, cfg, current, started, startedIDs, displacedHostWeb, assetAttempt, restoreRouteCAS(resolvedRouteHash, switchedRouteHash), start, err) } + // 13a. The non-caddy release is authoritative: retire the stale route. + var staleRouteErr error + if !cfg.usesCaddy() { + staleRouteErr = retireStaleRoute(ctx, d.caddy, d.out, cfg.App, cfg.Ingress, staleRoute, fromGeneration, guardPrefix) + } + // 13b. Record the release metadata (F14). The containers are live and // the route/state are committed — a record failure is a degraded // rollback window, not a failed deploy, and it converges on the next @@ -1305,10 +1318,14 @@ func (d *Deployer) deployFenced(ctx context.Context, cfg Config, lk *state.Lock) // 16. Log the real outcome (C01-5): clean success only when // retirement completed; a partial retirement is a degraded success. degradedReason := strings.Join(retireIncomplete, "; ") - d.logDeploy(ctx, cfg, true, degradedReason, start) if degradedReason != "" { fmt.Fprintf(d.out, "Warning: deployed, but predecessor retirement is incomplete — %s\n", degradedReason) } + if staleRouteErr != nil { + retireIncomplete = append(retireIncomplete, fmt.Sprintf("stale Caddy route not removed: %v", staleRouteErr)) + degradedReason = strings.Join(retireIncomplete, "; ") + } + d.logDeploy(ctx, cfg, true, degradedReason, start) duration := time.Since(start) @@ -1641,7 +1658,7 @@ func (d *Deployer) restoreRouteFromReceipt(ctx context.Context, cfg Config, curr fw := cfg.Firewall access := cfg.Access if rec.Caddy != nil { - tls = caddy.TLS{Cert: rec.Caddy.TLSCert, Key: rec.Caddy.TLSKey, Internal: rec.Caddy.TLSInternal} + tls = caddy.TLS{Cert: rec.Caddy.TLSCert, Key: rec.Caddy.TLSKey, Internal: rec.Caddy.TLSInternal, HTTPOnly: rec.Caddy.TLSHTTPOnly} caddyExtra = rec.Caddy.CaddyExtra cache = rec.Caddy.Cache if rec.Caddy.Firewall != nil { @@ -1793,6 +1810,7 @@ func (d *Deployer) recordRelease(ctx context.Context, cfg Config, att releasemet TLSCert: cfg.TLSCert, TLSKey: cfg.TLSKey, TLSInternal: cfg.TLSInternal, + TLSHTTPOnly: cfg.TLSHTTPOnly, CaddyExtra: cfg.CaddyExtra, Cache: maps.Clone(cfg.Cache), } diff --git a/internal/deploy/deploy_test.go b/internal/deploy/deploy_test.go index fb2499a..b44047d 100644 --- a/internal/deploy/deploy_test.go +++ b/internal/deploy/deploy_test.go @@ -1456,11 +1456,13 @@ func TestDeploy_IngressExternalSkipsCaddy(t *testing.T) { t.Fatalf("Deploy: %v", err) } - // No call should reach Caddy admin API or Caddyfile. + // No call should reach Caddy's admin API or change the Caddyfile. Reading + // it is allowed: that is how a stale block from a caddy release is found, + // and this server has none. for _, call := range mock.Calls { lower := strings.ToLower(call) if strings.Contains(call, "localhost:2019") || - strings.Contains(call, "/deployments/caddy/Caddyfile") || + (strings.Contains(call, "/deployments/caddy/Caddyfile") && !strings.HasPrefix(call, "if [ ! -e ")) || strings.Contains(call, "caddy reload") || strings.Contains(lower, "/config/apps/http/servers") { t.Errorf("ingress: external should issue zero Caddy commands; got: %s", call) diff --git a/internal/deploy/rollback.go b/internal/deploy/rollback.go index 9b61c35..c1dcbd2 100644 --- a/internal/deploy/rollback.go +++ b/internal/deploy/rollback.go @@ -183,17 +183,11 @@ func rollback(ctx context.Context, exec ssh.Executor, out io.Writer, cfg Rollbac fromGeneration := current.Generation nextGeneration := fromGeneration + 1 // The exact-block route CAS expectation (A12/T05): the app's managed - // region as resolved NOW, under the lock. The route switch commits - // only if this is still the live region; a successor's block refuses - // the switch with both generations named. - resolvedRouteHash := "" - if cfg.usesCaddy() { - region, _, rerr := cd.ReadManagedBlock(ctx, cfg.App) - if rerr != nil { - return fmt.Errorf("refusing to roll back %s with an unreadable route authority: %w", cfg.App, rerr) - } - resolvedRouteHash = caddy.ManagedRegionHash(region) - } + // region as resolved NOW, under the lock. The route switch commits only + // if this is still the live region; a successor's block refuses the switch + // with both generations named. + routeRegion, routePresent, routeReadErr := cd.ReadManagedBlock(ctx, cfg.App) + resolvedRouteHash := caddy.ManagedRegionHash(routeRegion) // switchedRouteHash is re-resolved after a SUCCESSFUL switch: the block // this rollback wrote but has not committed. switchedRouteHash := resolvedRouteHash @@ -241,6 +235,20 @@ func rollback(ctx context.Context, exec ssh.Executor, out io.Writer, cfg Rollbac warnRotatedSinceRelease(ctx, exec, out, cfg.App, target, rec) } + // The target's record decides the ingress this rollback lands on. A + // caddy target refuses an unreadable route authority; a non-caddy target + // never needed the Caddyfile and retires any block after the state commit. + var staleRoute staleRouteBlock + if cfg.usesCaddy() { + if routeReadErr != nil { + return fmt.Errorf("refusing to roll back %s with an unreadable route authority: %w", cfg.App, routeReadErr) + } + } else if routeReadErr != nil { + fmt.Fprintf(out, "Warning: could not check for a stale Caddy route for %s: %v\n", cfg.App, routeReadErr) + } else if routePresent { + staleRoute = staleRouteFromRegion(routeRegion) + } + // The recorded primary container port (TCL-14): with more than one // published port, a fields[0] pick cannot tell the HTTP surface from an // auxiliary listener. Health checks probe the primary's host binding and @@ -563,8 +571,16 @@ func rollback(ctx context.Context, exec ssh.Executor, out io.Writer, cfg Rollbac if ingress == "" && current.IngressMode != "" { ingress = current.IngressMode } + // The target release's record names its domain: a rollback across an + // ingress or domain change must not keep the rolled-back-from release's. domain := current.Domain - if domain == "" { + switch { + case rec != nil && rec.Domain != "": + domain = rec.Domain + case rec != nil && !rec.Backfilled && !cfg.usesCaddy(): + domain = "" + } + if domain == "" && cfg.usesCaddy() { domain = cfg.Domain } newState := state.NewAppliedState(current, deploymentType, ingress, domain) @@ -651,6 +667,13 @@ func rollback(ctx context.Context, exec ssh.Executor, out io.Writer, cfg Rollbac return fmt.Errorf("committing authoritative applied state after rollback route switch: %w; the original route was restored, the original workload was left running, and the uncommitted target was stopped", err) } + // The non-caddy target is authoritative: retire the route the rolled-back- + // from caddy release left. Degraded, never a failed rollback: the next + // deploy retries it and `teploy domains check` reports it until then. + if !cfg.usesCaddy() { + _ = retireStaleRoute(ctx, cd, out, cfg.App, cfg.Ingress, staleRoute, fromGeneration, lk.GuardPrefix()) + } + // 6. The authoritative commit succeeded; the prior current workload can // now be stopped (match by version label — see step 2). A fence loss // here (F16) means another operation owns the app: refuse further @@ -735,6 +758,11 @@ func restoreRollbackRoute(ctx context.Context, exec ssh.Executor, out io.Writer, if len(routeCAS) > 0 { restoreCaddy = restoreCaddy.WithRouteCAS(cfg.App, routeCAS, generation) } + // The rolled-back-from release is not caddy-routed: it has no route to put + // back, only the block this rollback wrote to take out. + if current.IngressMode != "" && current.IngressMode != "caddy" { + return restoreCaddy.RemoveRoute(ctx, cfg.App) + } rec, recErr := releasemeta.Read(ctx, exec, cfg.App, current.CurrentHash) switch { @@ -795,7 +823,7 @@ func restoreRollbackRouteFromReceipt(ctx context.Context, cd *caddy.Client, cfg fw := cfg.Firewall access := cfg.Access if rec.Caddy != nil { - tls = caddy.TLS{Cert: rec.Caddy.TLSCert, Key: rec.Caddy.TLSKey, Internal: rec.Caddy.TLSInternal} + tls = caddy.TLS{Cert: rec.Caddy.TLSCert, Key: rec.Caddy.TLSKey, Internal: rec.Caddy.TLSInternal, HTTPOnly: rec.Caddy.TLSHTTPOnly} caddyExtra = rec.Caddy.CaddyExtra cache = rec.Caddy.Cache if rec.Caddy.Firewall != nil { @@ -906,6 +934,8 @@ func applyRecordToRollback(cfg *RollbackConfig, rec *releasemeta.Record, healthC cfg.TLSCert = rec.Caddy.TLSCert cfg.TLSKey = rec.Caddy.TLSKey cfg.TLSInternal = rec.Caddy.TLSInternal + // Records written before this field keep what the caller resolved. + cfg.TLSHTTPOnly = cfg.TLSHTTPOnly || rec.Caddy.TLSHTTPOnly cfg.CaddyExtra = rec.Caddy.CaddyExtra cfg.Cache = rec.Caddy.Cache if rec.Caddy.Firewall != nil { diff --git a/internal/deploy/staleroute.go b/internal/deploy/staleroute.go new file mode 100644 index 0000000..0e1ac88 --- /dev/null +++ b/internal/deploy/staleroute.go @@ -0,0 +1,68 @@ +package deploy + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/useteploy/teploy/internal/caddy" + "github.com/useteploy/teploy/internal/state" +) + +// staleRouteBlock is the managed Caddy block a non-caddy release found on the +// server: left by an earlier caddy-ingress release, it keeps Caddy obtaining +// certificates and serving a dead upstream for a domain teploy no longer +// routes (one such block burned Let's Encrypt rate limits ~56 times a week). +type staleRouteBlock struct { + present bool + hash string + webhook bool +} + +// resolveStaleRoute reads the app's managed block under the app lock. A read +// failure only warns: a non-caddy deploy never depended on the Caddyfile, and +// a server running no Caddy at all reads as absent. +func resolveStaleRoute(ctx context.Context, cd *caddy.Client, out io.Writer, app string) staleRouteBlock { + region, present, err := cd.ReadManagedBlock(ctx, app) + if err != nil { + fmt.Fprintf(out, "Warning: could not check for a stale Caddy route for %s: %v\n", app, err) + return staleRouteBlock{} + } + if !present { + return staleRouteBlock{} + } + return staleRouteFromRegion(region) +} + +func staleRouteFromRegion(region string) staleRouteBlock { + return staleRouteBlock{ + present: true, + hash: caddy.ManagedRegionHash(region), + webhook: strings.Contains(region, "# TEPLOY WEBHOOK BEGIN"), + } +} + +// retireStaleRoute removes the block once the non-caddy release is committed: +// under the operation's fence guard and an exact-block compare-and-swap, so a +// successor's block is never removed and a failed operation (which never +// reaches this point) leaves the route intact. Failure leaves the block in +// place for the next deploy and is reported; traffic is already committed. +func retireStaleRoute(ctx context.Context, cd *caddy.Client, out io.Writer, app, ingress string, block staleRouteBlock, generation uint64, guardPrefix string) error { + if !block.present { + return nil + } + fmt.Fprintf(out, "Removing the stale Caddy route for %s (ingress: %s)...\n", app, ingress) + if block.webhook { + fmt.Fprintf(out, " Note: the block carried the autodeploy webhook route; it stops working until a caddy-ingress release or another front serves it\n") + } + err := cd.WithCommitGuard(guardPrefix).WithRouteCAS(app, []string{block.hash}, generation).RemoveRoute(ctx, app) + if err == nil { + return nil + } + if state.FenceLost(err) { + err = fmt.Errorf("%w: refusing to remove %s's route", state.ErrFenceLost, app) + } + fmt.Fprintf(out, "Warning: the stale Caddy route for %s was not removed: %v\n", app, err) + return err +} diff --git a/internal/deploy/staleroute_test.go b/internal/deploy/staleroute_test.go new file mode 100644 index 0000000..0425b1c --- /dev/null +++ b/internal/deploy/staleroute_test.go @@ -0,0 +1,301 @@ +package deploy + +// A release that stops being caddy-routed retires the managed block its +// predecessor left (plan U5): after the state commit, under the fence and an +// exact-block CAS. These run the real Deploy and Rollback over the mock, which +// evaluates the guards and the CAS against recorded file state. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/useteploy/teploy/internal/releasemeta" + "github.com/useteploy/teploy/internal/ssh" + "github.com/useteploy/teploy/internal/state" +) + +const ( + staleWebBlock = "# TEPLOY BEGIN web\n# TEPLOY GENERATION 3\nweb.teploy-fixture.dev {\n\treverse_proxy web-web-old123:3000\n}\n# TEPLOY END web" + otherBlock = "# TEPLOY BEGIN other\nother.teploy-fixture.dev {\n\treverse_proxy other-web-a:3000\n}\n# TEPLOY END other" +) + +func staleRouteMock(extra ...ssh.MockCommand) *ssh.MockExecutor { + cmds := append(extra, + ssh.MockCommand{Match: "mkdir -p /deployments/web", Output: ""}, + ssh.MockCommand{Match: "mkdir /deployments/web/.lock", Output: ""}, + ssh.MockCommand{Match: "if [ ! -e '/deployments/web/state.json' ]", Output: "absent"}, + ssh.MockCommand{Match: "if [ ! -e '/deployments/web/state' ]", Output: "present\ncurrent_port=49152\ncurrent_hash=old123\n"}, + ssh.MockCommand{Match: "docker ps --filter label=teploy.app=web", Output: ""}, + ssh.MockCommand{Match: "ss -tln", Output: ""}, + ssh.MockCommand{Match: "docker run", Output: "container-id"}, + ssh.MockCommand{Match: "docker inspect -f '{{.State.Status}}'", Output: "running"}, + ssh.MockCommand{Match: "docker stop", Output: ""}, + ssh.MockCommand{Match: "docker rm", Output: ""}, + ssh.MockCommand{Match: "curl -s -o /dev/null", Output: "200"}, + ssh.MockCommand{Match: "docker exec caddy caddy reload", Output: ""}, + ssh.MockCommand{Match: "a=$(docker exec caddy md5sum", Output: "TEPLOY_CADDY_OK"}, + ssh.MockCommand{Match: "mkdir /deployments/caddy/.lock", Output: ""}, + ssh.MockCommand{Match: "printf %s", Output: ""}, + ssh.MockCommand{Match: "rm -rf /deployments/web/.lock", Output: ""}, + ) + m := ssh.NewMockExecutor("1.2.3.4", cmds...) + m.Files["/deployments/caddy/Caddyfile"] = []byte(staleWebBlock + "\n\n" + otherBlock + "\n") + return m +} + +func staleRouteDeployConfig() Config { + return Config{App: "web", Image: "web:new", Version: "new456", Ingress: "host", ContainerPort: 3000, + Health: HealthConfig{Timeout: 5 * time.Second, Interval: 10 * time.Millisecond}} +} + +func TestDeploy_NonCaddyRetiresStaleBlockAfterCommit(t *testing.T) { + for _, ingress := range []string{"host", "external"} { + t.Run(ingress, func(t *testing.T) { + m := staleRouteMock() + cfg := staleRouteDeployConfig() + cfg.Ingress = ingress + if ingress == "external" { + cfg.Domain = "web.teploy-fixture.dev" + } + var out bytes.Buffer + if err := NewDeployer(m, &out).Deploy(context.Background(), cfg); err != nil { + t.Fatalf("Deploy: %v\n%s", err, out.String()) + } + caddyfile := string(m.Files["/deployments/caddy/Caddyfile"]) + if strings.Contains(caddyfile, "TEPLOY BEGIN web") { + t.Errorf("stale block survived:\n%s", caddyfile) + } + if !strings.Contains(caddyfile, "TEPLOY BEGIN other") { + t.Errorf("another app's block was removed:\n%s", caddyfile) + } + if !strings.Contains(out.String(), "Removing the stale Caddy route") { + t.Errorf("removal not announced:\n%s", out.String()) + } + // Order and composition: the state commit lands first, and the edit + // runs under the deploy's fence plus the CAS on the exact block. + commit, removal := -1, -1 + for i, c := range m.Calls { + if commit < 0 && strings.Contains(c, "state.json.tmp-") { + commit = i + } + if strings.Contains(c, "/deployments/caddy/Caddyfile") && strings.Contains(c, "mv ") { + removal = i + } + } + if commit < 0 || removal < 0 || commit > removal { + t.Errorf("state commit (idx %d) must precede the guarded removal (idx %d)", commit, removal) + } + }) + } +} + +func TestDeploy_NoBlockMakesNoCaddyEdit(t *testing.T) { + m := staleRouteMock() + m.Files["/deployments/caddy/Caddyfile"] = []byte(otherBlock + "\n") + var out bytes.Buffer + if err := NewDeployer(m, &out).Deploy(context.Background(), staleRouteDeployConfig()); err != nil { + t.Fatalf("Deploy: %v\n%s", err, out.String()) + } + for _, c := range m.Calls { + if strings.Contains(c, "caddy reload") { + t.Errorf("no block to remove, but Caddy was reloaded: %s", c) + } + } +} + +func TestDeploy_NoCaddyfileIsNotAProblem(t *testing.T) { + m := staleRouteMock() + delete(m.Files, "/deployments/caddy/Caddyfile") + var out bytes.Buffer + if err := NewDeployer(m, &out).Deploy(context.Background(), staleRouteDeployConfig()); err != nil { + t.Fatalf("Deploy: %v\n%s", err, out.String()) + } + if strings.Contains(out.String(), "stale Caddy route") { + t.Errorf("a server without a Caddyfile warned about routes:\n%s", out.String()) + } +} + +func TestDeploy_FailureLeavesStaleBlockIntact(t *testing.T) { + cases := map[string]ssh.MockCommand{ + "state commit fails": {Match: "UPLOAD:/deployments/web/state.json.tmp-", Err: fmt.Errorf("disk full")}, + "health check fails": {Match: "curl -s -o /dev/null", Output: "500"}, + } + for name, failing := range cases { + t.Run(name, func(t *testing.T) { + m := staleRouteMock(failing) + before := string(m.Files["/deployments/caddy/Caddyfile"]) + cfg := staleRouteDeployConfig() + cfg.Health = HealthConfig{Timeout: 50 * time.Millisecond, Interval: 10 * time.Millisecond} + var out bytes.Buffer + if err := NewDeployer(m, &out).Deploy(context.Background(), cfg); err == nil { + t.Fatalf("expected the deploy to fail\n%s", out.String()) + } + if got := string(m.Files["/deployments/caddy/Caddyfile"]); got != before { + t.Errorf("a failed deploy changed the route:\n%s", got) + } + }) + } +} + +// swapOnCommand lands a successor's edit of the block between the deploy's +// resolution and its removal. +type swapOnCommand struct { + *ssh.MockExecutor + trigger string + fired bool + content string +} + +func (s *swapOnCommand) Run(ctx context.Context, cmd string) (string, error) { + if !s.fired && strings.Contains(cmd, s.trigger) { + s.fired = true + s.MockExecutor.Files["/deployments/caddy/Caddyfile"] = []byte(s.content) + } + return s.MockExecutor.Run(ctx, cmd) +} + +func TestDeploy_StaleRemovalCASRefusesAChangedBlock(t *testing.T) { + successor := strings.Replace(staleWebBlock, "GENERATION 3", "GENERATION 9", 1) + m := staleRouteMock() + swap := &swapOnCommand{MockExecutor: m, trigger: "docker run", content: successor + "\n"} + var out bytes.Buffer + // The deploy itself stands: traffic is committed, the route edit is cleanup. + if err := NewDeployer(swap, &out).Deploy(context.Background(), staleRouteDeployConfig()); err != nil { + t.Fatalf("Deploy: %v\n%s", err, out.String()) + } + if !swap.fired { + t.Fatal("the swap never ran") + } + if got := string(m.Files["/deployments/caddy/Caddyfile"]); !strings.Contains(got, "GENERATION 9") { + t.Errorf("the changed block was removed anyway:\n%s", got) + } + if !strings.Contains(out.String(), "was not removed") { + t.Errorf("refusal not reported:\n%s", out.String()) + } +} + +// --- rollback, both directions --- + +const rollbackCaddyBlock = "# TEPLOY BEGIN myapp\n# TEPLOY GENERATION 7\nmyapp.com {\n\treverse_proxy myapp-web-v2:3000\n}\n# TEPLOY END myapp" + +func hostRecord(t *testing.T) string { + t.Helper() + return marshalRecord(t, &releasemeta.Record{DeploymentType: "container", IngressMode: "host", + Health: &releasemeta.Health{Path: "/health", TimeoutSeconds: 30, IntervalSeconds: 1}, + Ports: []releasemeta.Port{{HostPort: 49152, ContainerPort: 3000, Primary: true, Fixed: true}}}) +} + +func caddyRecord(t *testing.T) string { + t.Helper() + return marshalRecord(t, &releasemeta.Record{DeploymentType: "container", IngressMode: "caddy", Domain: "myapp.com", + Health: &releasemeta.Health{Path: "/health", TimeoutSeconds: 30, IntervalSeconds: 1}, + Ports: []releasemeta.Port{{HostPort: 49152, ContainerPort: 3000, Primary: true}}}) +} + +func stateWithIngress(ingress, domain string) string { + return fmt.Sprintf(`{"schema_version":2,"deployment_type":"container","ingress_mode":%q,"domain":%q,"updated_at":"2026-07-22T10:00:00Z","current_port":49153,"current_hash":"v2","previous_port":49152,"previous_hash":"v1"}`, ingress, domain) +} + +func readState(t *testing.T, m *ssh.MockExecutor) state.AppState { + t.Helper() + var st state.AppState + if err := json.Unmarshal(m.Files["/deployments/myapp/state.json"], &st); err != nil { + t.Fatalf("state: %v", err) + } + return st +} + +func TestRollback_CaddyToHostRetiresBlock(t *testing.T) { + m := ssh.NewMockExecutor("1.2.3.4", rollbackFixtureCommands(hostRecord(t))...) + m.Files["/deployments/caddy/Caddyfile"] = []byte(rollbackCaddyBlock + "\n\n" + otherBlock + "\n") + var out bytes.Buffer + if err := Rollback(context.Background(), m, &out, rollbackCfg()); err != nil { + t.Fatalf("Rollback: %v\n%s", err, out.String()) + } + caddyfile := string(m.Files["/deployments/caddy/Caddyfile"]) + if strings.Contains(caddyfile, "TEPLOY BEGIN myapp") { + t.Errorf("block survived the rollback to a host release:\n%s", caddyfile) + } + if !strings.Contains(caddyfile, "TEPLOY BEGIN other") { + t.Errorf("another app's block was removed:\n%s", caddyfile) + } + st := readState(t, m) + if st.IngressMode != "host" || st.Domain != "" { + t.Errorf("state should name the target release: ingress=%q domain=%q", st.IngressMode, st.Domain) + } +} + +func TestRollback_CaddyToHostFailureLeavesBlock(t *testing.T) { + before := rollbackCaddyBlock + "\n" + cfg := rollbackCfg() + var out bytes.Buffer + shortRecord := marshalRecord(t, &releasemeta.Record{DeploymentType: "container", IngressMode: "host", + Health: &releasemeta.Health{Path: "/health", TimeoutSeconds: 1, IntervalSeconds: 1}, + Ports: []releasemeta.Port{{HostPort: 49152, ContainerPort: 3000, Primary: true, Fixed: true}}}) + m := ssh.NewMockExecutor("1.2.3.4", + append([]ssh.MockCommand{{Match: "curl -s -o /dev/null", Output: "500"}}, rollbackFixtureCommands(shortRecord)...)...) + m.Files["/deployments/caddy/Caddyfile"] = []byte(before) + if err := Rollback(context.Background(), m, &out, cfg); err == nil { + t.Fatalf("expected the rollback to fail\n%s", out.String()) + } + if got := string(m.Files["/deployments/caddy/Caddyfile"]); got != before { + t.Errorf("a failed rollback changed the route:\n%s", got) + } +} + +func hostCurrentCommands(record string) []ssh.MockCommand { + return append([]ssh.MockCommand{ + {Match: "if [ ! -e '/deployments/myapp/state.json' ]", Output: "present\n" + stateWithIngress("host", "")}, + }, rollbackFixtureCommands(record)...) +} + +func TestRollback_HostToCaddyWritesRoute(t *testing.T) { + m := ssh.NewMockExecutor("1.2.3.4", hostCurrentCommands(caddyRecord(t))...) + m.Files["/deployments/caddy/Caddyfile"] = []byte(otherBlock + "\n") + var out bytes.Buffer + if err := Rollback(context.Background(), m, &out, rollbackCfg()); err != nil { + t.Fatalf("Rollback: %v\n%s", err, out.String()) + } + caddyfile := string(m.Files["/deployments/caddy/Caddyfile"]) + if !strings.Contains(caddyfile, "TEPLOY BEGIN myapp") || !strings.Contains(caddyfile, "myapp-web-v1:3000") { + t.Errorf("route for the caddy target missing:\n%s", caddyfile) + } + if st := readState(t, m); st.IngressMode != "caddy" || st.Domain != "myapp.com" { + t.Errorf("state should name the target release: ingress=%q domain=%q", st.IngressMode, st.Domain) + } +} + +func TestRollback_HostToCaddyCommitFailureRemovesWhatItWrote(t *testing.T) { + m := ssh.NewMockExecutor("1.2.3.4", + append([]ssh.MockCommand{{Match: "UPLOAD:/deployments/myapp/state.json.tmp-", Err: errors.New("disk full")}}, hostCurrentCommands(caddyRecord(t))...)...) + m.Files["/deployments/caddy/Caddyfile"] = []byte(otherBlock + "\n") + var out bytes.Buffer + if err := Rollback(context.Background(), m, &out, rollbackCfg()); err == nil { + t.Fatalf("expected the commit to fail\n%s", out.String()) + } + if caddyfile := string(m.Files["/deployments/caddy/Caddyfile"]); strings.Contains(caddyfile, "TEPLOY BEGIN myapp") { + t.Errorf("the rolled-back-from host release has no route; the written block must go:\n%s", caddyfile) + } +} + +func TestRollback_RestoreKeepsHTTPOnly(t *testing.T) { + rec := marshalRecord(t, &releasemeta.Record{DeploymentType: "container", IngressMode: "caddy", Domain: "myapp.com", + Health: &releasemeta.Health{Path: "/health", TimeoutSeconds: 30, IntervalSeconds: 1}, + Caddy: &releasemeta.CaddyRoute{TLSHTTPOnly: true}, + Ports: []releasemeta.Port{{HostPort: 49152, ContainerPort: 3000, Primary: true}}}) + m := ssh.NewMockExecutor("1.2.3.4", rollbackFixtureCommands(rec)...) + m.Files["/deployments/caddy/Caddyfile"] = []byte(rollbackCaddyBlock + "\n") + var out bytes.Buffer + if err := Rollback(context.Background(), m, &out, rollbackCfg()); err != nil { + t.Fatalf("Rollback: %v\n%s", err, out.String()) + } + if caddyfile := string(m.Files["/deployments/caddy/Caddyfile"]); !strings.Contains(caddyfile, "http://myapp.com") { + t.Errorf("the recorded http_only setting was lost:\n%s", caddyfile) + } +} diff --git a/internal/releasemeta/releasemeta.go b/internal/releasemeta/releasemeta.go index 6821600..2bcb230 100644 --- a/internal/releasemeta/releasemeta.go +++ b/internal/releasemeta/releasemeta.go @@ -91,6 +91,7 @@ type CaddyRoute struct { TLSCert string `json:"tls_cert,omitempty"` TLSKey string `json:"tls_key,omitempty"` TLSInternal bool `json:"tls_internal,omitempty"` + TLSHTTPOnly bool `json:"tls_http_only,omitempty"` CaddyExtra string `json:"caddy_extra,omitempty"` Cache map[string]string `json:"cache,omitempty"` Firewall *caddy.Firewall `json:"firewall,omitempty"` From 4209725466f2fe461361c5d7b305495ca1869238 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:09:05 -0700 Subject: [PATCH 2/6] feat(config): refuse reserved domain names under caddy ingress RFC 2606/6761 names (example.com/.net/.org, *.example, .test, .invalid, .localhost, localhost) can never get a public certificate, so Caddy retries ACME for them forever. They are refused under caddy ingress unless tls.internal or tls.http_only is set; host and external ingress get a label-only warning. Compose import no longer fabricates .example.com and asks for a domain. Test fixtures that went through validate() moved to teploy-fixture.dev; the rule has no escape hatch. Co-Authored-By: Claude Opus 5.5 --- internal/cli/apply_test.go | 10 +-- internal/cli/build_test.go | 4 +- internal/cli/config_test.go | 2 +- internal/cli/doctor_test.go | 6 +- internal/cli/env_config_guard_test.go | 4 +- internal/cli/errevelope_test.go | 2 +- internal/cli/init.go | 6 +- internal/cli/redeploy_test.go | 4 +- internal/cli/root_test.go | 2 +- internal/config/app.go | 58 ++++++++++++++++ internal/config/buildcontext_test.go | 10 +-- internal/config/compose.go | 5 +- internal/config/compose_test.go | 20 ++++++ internal/config/firewall_test.go | 12 ++-- internal/config/git_source_test.go | 16 ++--- internal/config/hardening_test.go | 2 +- internal/config/resources_test.go | 2 +- internal/config/validate_test.go | 73 ++++++++++++++++++++- internal/confighome/git_test.go | 8 +-- internal/confighome/seed_test.go | 4 +- internal/confighome/templatesecrets_test.go | 6 +- internal/confighome/validate_test.go | 8 +-- 22 files changed, 205 insertions(+), 59 deletions(-) diff --git a/internal/cli/apply_test.go b/internal/cli/apply_test.go index 18af968..49ce93d 100644 --- a/internal/cli/apply_test.go +++ b/internal/cli/apply_test.go @@ -72,11 +72,11 @@ func planFromConfig(t *testing.T, dir, image, destination string) *PlanRecord { // drift — never execute the stale plan. func TestApplyDrift_ConfigChangedBetweenPlanAndApply(t *testing.T) { dir := t.TempDir() - writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.example.com\nport: 3000\n") + writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.teploy-fixture.dev\nport: 3000\n") rec := planFromConfig(t, dir, "registry/blog:v1", "") // The world moves: port edited after the plan was reviewed. - writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.example.com\nport: 8080\n") + writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.teploy-fixture.dev\nport: 8080\n") appCfg, image, digest, version, err := applyResolveCurrent(context.Background(), &Flags{}, rec) if err != nil { @@ -104,7 +104,7 @@ func TestApplyDrift_ConfigChangedBetweenPlanAndApply(t *testing.T) { // the config digest). func TestApplyDrift_OverlayFlipInvalidates(t *testing.T) { dir := t.TempDir() - writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.example.com\nport: 3000\nenv:\n BASE: kept\n") + writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.teploy-fixture.dev\nport: 3000\nenv:\n BASE: kept\n") if err := os.WriteFile(filepath.Join(dir, "teploy.staging.yml"), []byte("env:\n STAGED: \"1\"\n"), 0o644); err != nil { t.Fatal(err) } @@ -130,7 +130,7 @@ func TestApplyDrift_OverlayFlipInvalidates(t *testing.T) { // — the same resolution verifies clean. func TestApplyStable_ConfigUnchanged(t *testing.T) { dir := t.TempDir() - writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.example.com\nport: 3000\n") + writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.teploy-fixture.dev\nport: 3000\n") rec := planFromConfig(t, dir, "registry/blog:v1", "") appCfg, _, digest, version, err := applyResolveCurrent(context.Background(), &Flags{}, rec) @@ -157,7 +157,7 @@ func TestApplyStable_ConfigUnchanged(t *testing.T) { // apply moves the state generation — the stale plan refuses. func TestApplyDrift_DeployHappenedInBetween(t *testing.T) { dir := t.TempDir() - writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.example.com\nport: 3000\n") + writeAppConfig(t, dir, "app: blog\nimage: registry/blog:v1\ndomain: blog.teploy-fixture.dev\nport: 3000\n") rec := planFromConfig(t, dir, "registry/blog:v1", "") rec.TargetState = PlanTargetState{Deployed: true, Generation: 7, CurrentHash: "old99"} rec.PlanID = computePlanID(rec) diff --git a/internal/cli/build_test.go b/internal/cli/build_test.go index 0433467..2ae038e 100644 --- a/internal/cli/build_test.go +++ b/internal/cli/build_test.go @@ -58,7 +58,7 @@ func captureStdout(t *testing.T, fn func()) string { // connection and possibly an rsync. func TestBuildRefusesWhatItCannotBuild(t *testing.T) { t.Run("static app has no image", func(t *testing.T) { - chdirTemp(t, "app: brochure\ntype: static\nsource: dist\ndomain: example.com\nserver: web1\n") + chdirTemp(t, "app: brochure\ntype: static\nsource: dist\ndomain: teploy-fixture.dev\nserver: web1\n") err := runBuild(&Flags{}, "", "") if err == nil || !strings.Contains(err.Error(), "static app") { t.Fatalf("expected a static-app refusal, got %v", err) @@ -66,7 +66,7 @@ func TestBuildRefusesWhatItCannotBuild(t *testing.T) { }) t.Run("no server to build on", func(t *testing.T) { - chdirTemp(t, "app: api\ndomain: example.com\n") + chdirTemp(t, "app: api\ndomain: teploy-fixture.dev\n") err := runBuild(&Flags{}, "", "") if err == nil || !strings.Contains(err.Error(), "no server specified") { t.Fatalf("expected a missing-server refusal, got %v", err) diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 5676b3d..4119c69 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -33,7 +33,7 @@ func localConfigTarget(t *testing.T) *configTarget { return &configTarget{app: "blog", server: "prod", exec: ssh.NewLocalExecutor()} } -const blogConfig = "app: blog\ndomain: blog.example.com\nimage: nginx:1.27\nenv:\n LOG_LEVEL: info\n" +const blogConfig = "app: blog\ndomain: blog.teploy-fixture.dev\nimage: nginx:1.27\nenv:\n LOG_LEVEL: info\n" func TestCommitConfigValidatesAndRecordsEvent(t *testing.T) { tg := localConfigTarget(t) diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index b1783f7..5882f12 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -220,7 +220,7 @@ func TestDoctorHumanTable(t *testing.T) { func TestDoctorConfigCheck(t *testing.T) { t.Run("ok", func(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte("app: blog\nserver: prod\ndomain: blog.example.com\n"), 0600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte("app: blog\nserver: prod\ndomain: blog.teploy-fixture.dev\n"), 0600); err != nil { t.Fatal(err) } cfg, err := config.LoadApp(dir) @@ -238,7 +238,7 @@ func TestDoctorConfigCheck(t *testing.T) { t.Run("grammar error surfaced", func(t *testing.T) { dir := t.TempDir() - source := "app: blog\nserver: prod\ndomain: blog.example.com\ningress: bogus-mode\n" + source := "app: blog\nserver: prod\ndomain: blog.teploy-fixture.dev\ningress: bogus-mode\n" if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte(source), 0600); err != nil { t.Fatal(err) } @@ -673,7 +673,7 @@ func TestDoctorExitCode(t *testing.T) { func TestDoctorEndToEndAllOK(t *testing.T) { t.Setenv("HOME", t.TempDir()) dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte("app: blog\nserver: 192.0.2.10\ndomain: blog.example.com\nimage: example/blog:v1\n"), 0600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte("app: blog\nserver: 192.0.2.10\ndomain: blog.teploy-fixture.dev\nimage: example/blog:v1\n"), 0600); err != nil { t.Fatal(err) } t.Chdir(dir) diff --git a/internal/cli/env_config_guard_test.go b/internal/cli/env_config_guard_test.go index 36edefc..0453015 100644 --- a/internal/cli/env_config_guard_test.go +++ b/internal/cli/env_config_guard_test.go @@ -36,7 +36,7 @@ func TestCheckEnvConfigGuard(t *testing.T) { ctx := context.Background() ex := localConfigHome(t) - if _, err := confighome.Write(ctx, ex, "blog", []byte("app: blog\nimage: nginx:1\ndomain: blog.example.com\nenv:\n LOG_LEVEL: debug\nenv_literal:\n FEATURE_FLAG: 'on'\n"), "", confighome.Author{Name: "t", Email: "t@teploy"}, "create"); err != nil { + if _, err := confighome.Write(ctx, ex, "blog", []byte("app: blog\nimage: nginx:1\ndomain: blog.teploy-fixture.dev\nenv:\n LOG_LEVEL: debug\nenv_literal:\n FEATURE_FLAG: 'on'\n"), "", confighome.Author{Name: "t", Email: "t@teploy"}, "create"); err != nil { t.Fatal(err) } @@ -86,7 +86,7 @@ func TestServerSecretConfig(t *testing.T) { ctx := context.Background() ex := localConfigHome(t) - if _, err := confighome.Write(ctx, ex, "vaultapp", []byte("app: vaultapp\nimage: nginx:1\ndomain: vault.example.com\nsecret:\n provider: openbao\n accessory: bao\n"), "", confighome.Author{Name: "t", Email: "t@teploy"}, "create"); err != nil { + if _, err := confighome.Write(ctx, ex, "vaultapp", []byte("app: vaultapp\nimage: nginx:1\ndomain: vault.teploy-fixture.dev\nsecret:\n provider: openbao\n accessory: bao\n"), "", confighome.Author{Name: "t", Email: "t@teploy"}, "create"); err != nil { t.Fatal(err) } sc, ok, err := serverSecretConfig(ctx, ex, "vaultapp") diff --git a/internal/cli/errevelope_test.go b/internal/cli/errevelope_test.go index 8381caf..e71a462 100644 --- a/internal/cli/errevelope_test.go +++ b/internal/cli/errevelope_test.go @@ -24,7 +24,7 @@ func driveRoot(t *testing.T, args ...string) error { return root.Execute() } -const invalidModeConfig = "app: demo\ndomain: demo.example.com\nserver: prod\nhealth:\n mode: bogus\n" +const invalidModeConfig = "app: demo\ndomain: demo.teploy-fixture.dev\nserver: prod\nhealth:\n mode: bogus\n" // TestMachineErrorEnvelopeConfigLoad drives a real failing deploy (an // invalid teploy.yml) and asserts the machine error envelope: emitted on diff --git a/internal/cli/init.go b/internal/cli/init.go index 3e49f86..89c643d 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -80,11 +80,7 @@ func initFlow(reader *bufio.Reader, dir string, useTOML, force bool) (string, er // Domain. No fabricated default — an example.com placeholder that then // deploys is worse than a question. Empty switches to ingress:host // (publish a raw port), which validate() requires a port for. - defaultDomain := cfg.Domain - if strings.HasSuffix(defaultDomain, ".example.com") { - defaultDomain = "" - } - cfg.Domain = prompt(reader, "Domain (empty = publish a raw port, no HTTPS routing)", defaultDomain) + cfg.Domain = prompt(reader, "Domain (empty = publish a raw port, no HTTPS routing)", cfg.Domain) if cfg.Domain == "" { cfg.Ingress = config.IngressHost defaultPort := cfg.Port diff --git a/internal/cli/redeploy_test.go b/internal/cli/redeploy_test.go index d139015..bfffe3b 100644 --- a/internal/cli/redeploy_test.go +++ b/internal/cli/redeploy_test.go @@ -22,7 +22,7 @@ func TestEventTrigger(t *testing.T) { func TestRedeployRefusesStaticFolder(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte("app: site\ntype: static\ndomain: site.example.com\nserver: prod\nsource: ./dist\n"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte("app: site\ntype: static\ndomain: site.teploy-fixture.dev\nserver: prod\nsource: ./dist\n"), 0o600); err != nil { t.Fatal(err) } cwd, _ := os.Getwd() @@ -36,7 +36,7 @@ func TestRedeployRefusesStaticFolder(t *testing.T) { func TestRedeployNeedsServer(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte("app: blog\nimage: nginx:1\ndomain: blog.example.com\n"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte("app: blog\nimage: nginx:1\ndomain: blog.teploy-fixture.dev\n"), 0o600); err != nil { t.Fatal(err) } cwd, _ := os.Getwd() diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index ef92415..cc21467 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -32,7 +32,7 @@ func TestProjectDirEstablishesRelativeProjectSemantics(t *testing.T) { } } writeProjectFile(t, filepath.Join(projectDir, "teploy.yml"), `app: dash-contract -domain: dash-contract.example.com +domain: dash-contract.teploy-fixture.dev server: prod context: build dockerfile: Dockerfile diff --git a/internal/config/app.go b/internal/config/app.go index 018c1c3..0a2a394 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -1156,6 +1156,56 @@ func validVolumeDestination(dest string) error { return nil } +// ReservedDomainHosts lists the hosts of a domain field that can never get a public +// certificate because RFC 2606 / RFC 6761 reserve them: example.com/.net/.org +// and their subdomains, *.example, .test, .invalid, .localhost and localhost. +func ReservedDomainHosts(domain string) []string { + var out []string + for _, h := range strings.Split(domain, ",") { + if h = strings.TrimSpace(h); h != "" && IsReservedDomain(h) { + out = append(out, h) + } + } + return out +} + +// IsReservedDomain reports whether one host name is reserved (see +// ReservedDomainHosts). Matching is case-insensitive and ignores a trailing +// dot and a leading wildcard label. +func IsReservedDomain(host string) bool { + h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + h = strings.TrimPrefix(h, "*.") + for _, name := range []string{"example.com", "example.net", "example.org", "example", "test", "invalid", "localhost"} { + if h == name || strings.HasSuffix(h, "."+name) { + return true + } + } + return false +} + +// ValidateRoutableDomain refuses a reserved name on a Caddy-routed app unless +// the certificate is not public anyway: Caddy would otherwise retry ACME for a +// name no CA will issue, burning rate limits for as long as the block lives. +func ValidateRoutableDomain(domain string, tlsInternal, httpOnly bool) error { + if tlsInternal || httpOnly { + return nil + } + if hosts := ReservedDomainHosts(domain); len(hosts) > 0 { + return fmt.Errorf("'domain' %s is reserved (RFC 2606/6761) and can never get a public certificate — use a real domain, or set 'tls: {internal: true}' or 'tls: {http_only: true}' for a private name", strings.Join(hosts, ", ")) + } + return nil +} + +// ReservedDomainLabelWarnings words the same names as a warning for ingress +// modes where teploy serves no route: the domain is only a label there. +func ReservedDomainLabelWarnings(domain, ingress string) []string { + var warns []string + for _, h := range ReservedDomainHosts(domain) { + warns = append(warns, fmt.Sprintf("domain %q is a reserved name and is only a label under 'ingress: %s': nothing routes it, and it will never resolve publicly", h, ingress)) + } + return warns +} + func (c *AppConfig) validate() error { if err := ValidateName(c.App); err != nil { return err @@ -1165,6 +1215,11 @@ func (c *AppConfig) validate() error { if err := ValidateDomain(c.Domain, c.Ingress == IngressHost); err != nil { return err } + if c.UsesCaddy() { + if err := ValidateRoutableDomain(c.Domain, c.TLS != nil && c.TLS.Internal, c.TLS != nil && c.TLS.HTTPOnly); err != nil { + return err + } + } if c.Platform != "" && !validPlatform.MatchString(c.Platform) { return fmt.Errorf("'platform' must be os/arch (e.g. linux/amd64, linux/arm64), got %q", c.Platform) } @@ -1567,6 +1622,9 @@ func LoadApp(dir string) (*AppConfig, error) { return nil, invalidConfig(err) } if composeCfg != nil { + if composeCfg.Domain == "" { + return nil, invalidConfig(fmt.Errorf("invalid Compose config: Compose files carry no domain — run `teploy init` to generate a teploy.yml with one, or add a teploy.yml with `domain:` (or `ingress: host` and `port:` to publish a raw port)")) + } if err := composeCfg.validate(); err != nil { return nil, fmt.Errorf("invalid Compose config: %w", err) } diff --git a/internal/config/buildcontext_test.go b/internal/config/buildcontext_test.go index 285d463..a60f3cc 100644 --- a/internal/config/buildcontext_test.go +++ b/internal/config/buildcontext_test.go @@ -20,7 +20,7 @@ func writeAndLoad(t *testing.T, body string) (*AppConfig, error) { func TestLoadApp_DockerfileAndContextParse(t *testing.T) { cfg, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev context: . dockerfile: server/monolith/Dockerfile `) @@ -37,7 +37,7 @@ dockerfile: server/monolith/Dockerfile func TestLoadApp_DockerfileWithImageRejected(t *testing.T) { _, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev image: registry.example.com/myapp:latest dockerfile: server/Dockerfile `) @@ -51,7 +51,7 @@ dockerfile: server/Dockerfile func TestLoadApp_ContextOnStaticRejected(t *testing.T) { _, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev type: static source: ./dist context: web @@ -66,7 +66,7 @@ context: web func TestLoadApp_AbsoluteContextRejected(t *testing.T) { _, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev context: /etc `) if err == nil { @@ -79,7 +79,7 @@ context: /etc func TestLoadApp_EscapingDockerfileRejected(t *testing.T) { _, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev dockerfile: ../../etc/Dockerfile `) if err == nil { diff --git a/internal/config/compose.go b/internal/config/compose.go index 7ed945a..06ac899 100644 --- a/internal/config/compose.go +++ b/internal/config/compose.go @@ -225,8 +225,9 @@ func mapCompose(dir string, compose composeFile) (*AppConfig, error) { cfg.Context = webBuildContext cfg.Dockerfile = webDockerfile - // Set domain placeholder — user must set this. - cfg.Domain = cfg.App + ".example.com" + // Compose has no domain: nothing is fabricated. A placeholder that deploys + // sends Caddy after a certificate it can never get, forever. validate() + // requires the real name. // Web process gets empty command (use image CMD). cfg.Processes["web"] = parseCommand(webService.Command) diff --git a/internal/config/compose_test.go b/internal/config/compose_test.go index 41c3baf..20c024d 100644 --- a/internal/config/compose_test.go +++ b/internal/config/compose_test.go @@ -1628,3 +1628,23 @@ services: t.Errorf("bind classification broken for the imported keys") } } + +// Compose carries no domain, and none is invented: a fabricated +// .example.com is what sent Caddy after certificates it could never get. +func TestLoadApp_ComposeRequiresADomain(t *testing.T) { + dir := t.TempDir() + content := "services:\n web:\n image: nginx:1\n ports:\n - \"80:80\"\n" + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + cfg, err := LoadCompose(dir) + if err != nil { + t.Fatalf("LoadCompose: %v", err) + } + if cfg.Domain != "" { + t.Fatalf("Compose import fabricated a domain: %q", cfg.Domain) + } + if _, err := LoadApp(dir); err == nil || !strings.Contains(err.Error(), "no domain") { + t.Fatalf("LoadApp should ask for a domain, got %v", err) + } +} diff --git a/internal/config/firewall_test.go b/internal/config/firewall_test.go index 60ae76f..bd0a90e 100644 --- a/internal/config/firewall_test.go +++ b/internal/config/firewall_test.go @@ -7,7 +7,7 @@ import ( func TestLoadApp_FirewallParses(t *testing.T) { cfg, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev firewall: allow_ips: - 10.0.0.0/8 @@ -31,7 +31,7 @@ firewall: func TestLoadApp_FirewallBadIPRejected(t *testing.T) { _, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev firewall: deny_ips: - not-an-ip @@ -43,7 +43,7 @@ firewall: func TestLoadApp_FirewallBadBodySizeRejected(t *testing.T) { _, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev firewall: max_body_size: 10 potatoes `) @@ -54,7 +54,7 @@ firewall: func TestLoadApp_FirewallUserAgentInjectionRejected(t *testing.T) { _, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev firewall: block_user_agents: - "bad}\nexample.com {" @@ -66,7 +66,7 @@ firewall: func TestLoadApp_FirewallOnStaticRejected(t *testing.T) { _, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev type: static source: dist firewall: @@ -80,7 +80,7 @@ firewall: func TestLoadApp_FirewallOnExternalIngressRejected(t *testing.T) { _, err := writeAndLoad(t, `app: myapp -domain: app.example.com +domain: app.teploy-fixture.dev ingress: external firewall: deny_ips: diff --git a/internal/config/git_source_test.go b/internal/config/git_source_test.go index 27069d0..344d1f9 100644 --- a/internal/config/git_source_test.go +++ b/internal/config/git_source_test.go @@ -78,7 +78,7 @@ func TestLoadAppRefusesGitInFolder(t *testing.T) { t.Fatal(err) } } - write("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n") + write("app: demo\ndomain: demo.teploy-fixture.dev\ngit:\n repository: git@github.com:org/repo\n branch: main\n") _, err := LoadApp(dir) if err == nil || !strings.Contains(err.Error(), "Teploy-home") { t.Fatalf("folder LoadApp with git: = %v, want Teploy-home refusal", err) @@ -91,25 +91,25 @@ func TestLoadAppBytesGitValidation(t *testing.T) { _, err := LoadAppBytes([]byte(body)) return err } - if err := load("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n"); err != nil { + if err := load("app: demo\ndomain: demo.teploy-fixture.dev\ngit:\n repository: git@github.com:org/repo\n branch: main\n"); err != nil { t.Fatalf("valid git: rejected: %v", err) } - if err := load("app: demo\ndomain: demo.invalid\nimage: nginx:1\ngit:\n repository: git@github.com:org/repo\n branch: main\n"); err == nil || !strings.Contains(err.Error(), "image") { + if err := load("app: demo\ndomain: demo.teploy-fixture.dev\nimage: nginx:1\ngit:\n repository: git@github.com:org/repo\n branch: main\n"); err == nil || !strings.Contains(err.Error(), "image") { t.Fatalf("image+git = %v, want refusal", err) } - if err := load("app: demo\ndomain: demo.invalid\ngit:\n branch: main\n"); err == nil || !strings.Contains(err.Error(), "repository") { + if err := load("app: demo\ndomain: demo.teploy-fixture.dev\ngit:\n branch: main\n"); err == nil || !strings.Contains(err.Error(), "repository") { t.Fatalf("missing repository = %v", err) } - if err := load("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n submodules: true\n"); err == nil || !strings.Contains(err.Error(), "submodules") { + if err := load("app: demo\ndomain: demo.teploy-fixture.dev\ngit:\n repository: git@github.com:org/repo\n branch: main\n submodules: true\n"); err == nil || !strings.Contains(err.Error(), "submodules") { t.Fatalf("submodules: true = %v, want refusal", err) } - if err := load("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n poll: 30s\n"); err == nil || !strings.Contains(err.Error(), "poll") { + if err := load("app: demo\ndomain: demo.teploy-fixture.dev\ngit:\n repository: git@github.com:org/repo\n branch: main\n poll: 30s\n"); err == nil || !strings.Contains(err.Error(), "poll") { t.Fatalf("poll: 30s = %v, want refusal", err) } - if err := load("app: demo\ndomain: demo.invalid\nbuild_secrets: [TOKEN]\n"); err == nil || !strings.Contains(err.Error(), "build_secrets") { + if err := load("app: demo\ndomain: demo.teploy-fixture.dev\nbuild_secrets: [TOKEN]\n"); err == nil || !strings.Contains(err.Error(), "build_secrets") { t.Fatalf("build_secrets without git = %v, want refusal", err) } - if err := load("app: demo\ndomain: demo.invalid\ngit:\n repository: git@github.com:org/repo\n branch: main\n paths: [\"../escape\"]\n"); err == nil || !strings.Contains(err.Error(), "paths") { + if err := load("app: demo\ndomain: demo.teploy-fixture.dev\ngit:\n repository: git@github.com:org/repo\n branch: main\n paths: [\"../escape\"]\n"); err == nil || !strings.Contains(err.Error(), "paths") { t.Fatalf("escaping path = %v, want refusal", err) } } diff --git a/internal/config/hardening_test.go b/internal/config/hardening_test.go index de008c1..037029c 100644 --- a/internal/config/hardening_test.go +++ b/internal/config/hardening_test.go @@ -106,7 +106,7 @@ func TestLoadApp_InvalidDomain(t *testing.T) { func TestLoadApp_ValidDomains(t *testing.T) { dir := t.TempDir() - domains := []string{"myapp.com", "sub.myapp.com", "192.168.1.1.nip.io", "my-app.example.com"} + domains := []string{"myapp.com", "sub.myapp.com", "192.168.1.1.nip.io", "my-app.teploy-fixture.dev"} for _, domain := range domains { content := fmt.Sprintf("app: myapp\ndomain: %s\n", domain) diff --git a/internal/config/resources_test.go b/internal/config/resources_test.go index a17951a..6117881 100644 --- a/internal/config/resources_test.go +++ b/internal/config/resources_test.go @@ -50,7 +50,7 @@ func TestValidateResources_AcceptsDockerSyntax(t *testing.T) { func TestValidate_NamesTheOffendingAccessory(t *testing.T) { cfg := &AppConfig{ App: "myapp", - Domain: "example.com", + Domain: "teploy-fixture.dev", Accessories: map[string]AccessoryConfig{ "redis": {Image: "redis:7"}, "nucleus": {Image: "nucleus:latest", Memory: "8gb"}, diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 36904f0..90d2d0c 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -1,6 +1,9 @@ package config -import "testing" +import ( + "strings" + "testing" +) // TestValidateName_RejectsShellMetacharacters guards the ad-hoc --app // deploy path (internal/cli/deploy.go's runAdHocDeploy) and the --app flag @@ -86,3 +89,71 @@ func TestValidateDomain(t *testing.T) { } } } + +func TestIsReservedDomain(t *testing.T) { + for host, want := range map[string]bool{ + "example.com": true, + "dash.example.com": true, + "DASH.EXAMPLE.COM.": true, + "example.net": true, + "a.example.org": true, + "foo.example": true, + "example": true, + "app.test": true, + "app.invalid": true, + "app.localhost": true, + "localhost": true, + "*.example.com": true, + "example.com.au": false, + "notexample.com": false, + "my-example.org": false, + "contest": false, + "thelocalhost": false, + "latest.dev": false, + "teploy-fixture.dev": false, + } { + if got := IsReservedDomain(host); got != want { + t.Errorf("IsReservedDomain(%q) = %v, want %v", host, got, want) + } + } +} + +func TestValidate_ReservedDomainRule(t *testing.T) { + load := func(t *testing.T, yaml string) error { + t.Helper() + _, err := LoadAppBytes([]byte(yaml)) + return err + } + refused := map[string]string{ + "caddy default": "app: a\ndomain: a.example.com\n", + "explicit caddy": "app: a\ningress: caddy\ndomain: a.test\n", + "one of several": "app: a\ndomain: real.dev, a.invalid\n", + "localhost": "app: a\ndomain: localhost\n", + "static is caddy": "app: a\ntype: static\nsource: dist\ndomain: a.example.org\n", + } + for name, cfg := range refused { + t.Run(name, func(t *testing.T) { + err := load(t, cfg) + if err == nil || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("expected the reserved-name refusal, got %v", err) + } + }) + } + allowed := map[string]string{ + "tls internal": "app: a\ndomain: a.test\ntls:\n internal: true\n", + "tls http only": "app: a\ndomain: a.example.com\ntls:\n http_only: true\n", + "host label": "app: a\ningress: host\nport: 3000\ndomain: a.example.com\n", + "external": "app: a\ningress: external\ndomain: a.example.com\n", + "real name": "app: a\ndomain: a.teploy-fixture.dev\n", + } + for name, cfg := range allowed { + t.Run(name, func(t *testing.T) { + if err := load(t, cfg); err != nil { + t.Fatalf("should load: %v", err) + } + }) + } + if warns := ReservedDomainLabelWarnings("a.example.com, real.dev", IngressHost); len(warns) != 1 || !strings.Contains(warns[0], "label") { + t.Errorf("host ingress must warn label-only once: %v", warns) + } +} diff --git a/internal/confighome/git_test.go b/internal/confighome/git_test.go index 3e7f025..ca78516 100644 --- a/internal/confighome/git_test.go +++ b/internal/confighome/git_test.go @@ -7,16 +7,16 @@ import ( func TestLoadGitSource(t *testing.T) { git := "git:\n repository: git@github.com:org/repo\n branch: main\n" - if _, err := Load([]byte("app: demo\ndomain: demo.invalid\n"+git), "demo"); err != nil { + if _, err := Load([]byte("app: demo\ndomain: demo.teploy-fixture.dev\n"+git), "demo"); err != nil { t.Fatalf("git: without image rejected: %v", err) } - if _, err := Load([]byte("app: demo\ndomain: demo.invalid\nimage: nginx:1\n"+git), "demo"); err == nil || !strings.Contains(err.Error(), "image") { + if _, err := Load([]byte("app: demo\ndomain: demo.teploy-fixture.dev\nimage: nginx:1\n"+git), "demo"); err == nil || !strings.Contains(err.Error(), "image") { t.Fatalf("image+git = %v, want refusal", err) } - if _, err := Load([]byte("app: demo\ndomain: demo.invalid\ntype: static\nsource: ./site\n"+git), "demo"); err == nil || !strings.Contains(err.Error(), "git") { + if _, err := Load([]byte("app: demo\ndomain: demo.teploy-fixture.dev\ntype: static\nsource: ./site\n"+git), "demo"); err == nil || !strings.Contains(err.Error(), "git") { t.Fatalf("static+git = %v, want refusal", err) } - if _, err := Load([]byte("app: demo\ndomain: demo.invalid\n"+git+"build_secrets: [NPM_TOKEN]\n"), "demo"); err != nil { + if _, err := Load([]byte("app: demo\ndomain: demo.teploy-fixture.dev\n"+git+"build_secrets: [NPM_TOKEN]\n"), "demo"); err != nil { t.Fatalf("git + build_secrets rejected: %v", err) } } diff --git a/internal/confighome/seed_test.go b/internal/confighome/seed_test.go index 48ee6d9..3388fb2 100644 --- a/internal/confighome/seed_test.go +++ b/internal/confighome/seed_test.go @@ -13,7 +13,7 @@ import ( func TestSeedReproducesFolderConfig(t *testing.T) { folder := []byte(`app: blog server: prod -domain: Blog.example.com, www.blog.example.com +domain: Blog.teploy-fixture.dev, www.blog.example.com image: example/blog:1.4 port: 3000 replicas: 2 @@ -59,7 +59,7 @@ tls: } func TestSeedRefusesWhatTheServerDoesNotKeep(t *testing.T) { - cfg, _ := config.LoadAppBytes([]byte("app: blog\ndomain: b.example.com\nimage: x:1\nenv:\n DB_URL: secret:DB_URL\nhooks:\n pre_deploy: bin/migrate\naccessories:\n db:\n image: postgres:16\n")) + cfg, _ := config.LoadAppBytes([]byte("app: blog\ndomain: b.teploy-fixture.dev\nimage: x:1\nenv:\n DB_URL: secret:DB_URL\nhooks:\n pre_deploy: bin/migrate\naccessories:\n db:\n image: postgres:16\n")) manifest, _, _ := config.NormalizeAndDigest(cfg, "x:1") _, problems, err := Seed(&releasemeta.Record{ImageRef: "x:1"}, manifest) if err != nil { diff --git a/internal/confighome/templatesecrets_test.go b/internal/confighome/templatesecrets_test.go index a46de73..94c4a71 100644 --- a/internal/confighome/templatesecrets_test.go +++ b/internal/confighome/templatesecrets_test.go @@ -7,7 +7,7 @@ import ( func TestExtractSecretsKeepsValuesOutOfTheConfig(t *testing.T) { rendered := []byte(`app: wiki -domain: wiki.example.com +domain: wiki.teploy-fixture.dev image: wiki:2 env: DATABASE_URL: postgres://wiki:Gen3ratedPw@wiki-db:5432/wiki @@ -38,7 +38,7 @@ accessories: } func TestExtractSecretsRefusesSecretsOutsideEnv(t *testing.T) { - _, _, err := ExtractSecrets([]byte("app: wiki\ndomain: w.example.com\nimage: wiki:2\nprocesses:\n web: serve --password Gen3ratedPw\n"), []string{"Gen3ratedPw"}) + _, _, err := ExtractSecrets([]byte("app: wiki\ndomain: w.teploy-fixture.dev\nimage: wiki:2\nprocesses:\n web: serve --password Gen3ratedPw\n"), []string{"Gen3ratedPw"}) if err == nil || !strings.Contains(err.Error(), "outside env:") { t.Fatalf("got %v", err) } @@ -47,7 +47,7 @@ func TestExtractSecretsRefusesSecretsOutsideEnv(t *testing.T) { // Rendered templates carry their literal values as env_literal (never // expanded); a generated secret there must leave the config too. func TestExtractSecretsFromRenderedLiteralEnv(t *testing.T) { - rendered := []byte("app: pa\ndomain: pa.example.com\nimage: adminer:4\nenv_literal:\n ADMINER_DEFAULT_SERVER: pa-db\naccessories:\n db:\n image: postgres:16\n env_literal:\n POSTGRES_PASSWORD: Gen3ratedPw\n POSTGRES_DB: pa\n") + rendered := []byte("app: pa\ndomain: pa.teploy-fixture.dev\nimage: adminer:4\nenv_literal:\n ADMINER_DEFAULT_SERVER: pa-db\naccessories:\n db:\n image: postgres:16\n env_literal:\n POSTGRES_PASSWORD: Gen3ratedPw\n POSTGRES_DB: pa\n") out, moved, err := ExtractSecrets(rendered, []string{"Gen3ratedPw"}) if err != nil { t.Fatal(err) diff --git a/internal/confighome/validate_test.go b/internal/confighome/validate_test.go index 82d5c0b..337e072 100644 --- a/internal/confighome/validate_test.go +++ b/internal/confighome/validate_test.go @@ -8,7 +8,7 @@ import ( ) func TestLoadAcceptsPortableConfig(t *testing.T) { - cfg, err := Load([]byte("app: blog\ndomain: blog.example.com\nimage: nginx:1.27\nenv:\n LOG_LEVEL: info\n PRICE: \"$$5\"\n DB_PASSWORD: secret:DB_PASSWORD\n"), "blog") + cfg, err := Load([]byte("app: blog\ndomain: blog.teploy-fixture.dev\nimage: nginx:1.27\nenv:\n LOG_LEVEL: info\n PRICE: \"$$5\"\n DB_PASSWORD: secret:DB_PASSWORD\n"), "blog") if err != nil { t.Fatal(err) } @@ -20,7 +20,7 @@ func TestLoadAcceptsPortableConfig(t *testing.T) { func TestLoadRefusesNonPortableConfig(t *testing.T) { raw := `app: blog -domain: blog.example.com +domain: blog.teploy-fixture.dev server: prod env_files: [.env.prod] env: @@ -42,7 +42,7 @@ tls: } func TestLoadRefusesLocalStaticBuildAndWrongApp(t *testing.T) { - _, err := Load([]byte("app: site\ntype: static\ndomain: s.example.com\nsource: dist\nbuild: [\"npm run build\"]\n"), "other") + _, err := Load([]byte("app: site\ntype: static\ndomain: s.teploy-fixture.dev\nsource: dist\nbuild: [\"npm run build\"]\n"), "other") if err == nil || !strings.Contains(err.Error(), "build_remote") || !strings.Contains(err.Error(), "source:") || !strings.Contains(err.Error(), `belongs to "other"`) { t.Fatalf("got %v", err) } @@ -73,7 +73,7 @@ func TestPrivilegedChange(t *testing.T) { } func TestLoadKeepsShellVarsOutsideEnv(t *testing.T) { - raw := "app: blog\ndomain: blog.example.com\nimage: nginx:1\nprocesses:\n web: sh -c 'exec server --root $HOME'\nhooks:\n pre_deploy: echo $APP_VERSION\n" + raw := "app: blog\ndomain: blog.teploy-fixture.dev\nimage: nginx:1\nprocesses:\n web: sh -c 'exec server --root $HOME'\nhooks:\n pre_deploy: echo $APP_VERSION\n" if _, err := Load([]byte(raw), "blog"); err != nil { t.Fatalf("$VAR outside env: is never expanded and must be accepted: %v", err) } From 65f67df7e566d22235a5ff67e0428decc566ebf9 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:09:05 -0700 Subject: [PATCH 3/6] fix(dns): check each host, treat Cloudflare answers as proxied, align validate with deploy validateDomainDNS passed the raw comma list to the resolver, so every multi-host first deploy failed. validate checked DNS for host-ingress apps while deploy skipped them; both now share dnsCheckApplies. Addresses compare by value, and a domain answering only from Cloudflare's ranges is a note, not a mismatch. Ad-hoc deploys refuse reserved names too. Co-Authored-By: Claude Opus 5.5 --- internal/cli/deploy.go | 13 ++- internal/cli/deployserver.go | 9 +- internal/cli/dnscheck.go | 66 +++++++++--- internal/cli/validate.go | 13 ++- internal/cli/validate_test.go | 29 +++++ internal/dns/public.go | 194 ++++++++++++++++++++++++++++++++++ internal/dns/public_test.go | 113 ++++++++++++++++++++ internal/dns/validate.go | 40 +++++-- internal/dns/validate_test.go | 35 ++++++ 9 files changed, 475 insertions(+), 37 deletions(-) create mode 100644 internal/dns/public.go create mode 100644 internal/dns/public_test.go diff --git a/internal/cli/deploy.go b/internal/cli/deploy.go index 7deff40..62bb750 100644 --- a/internal/cli/deploy.go +++ b/internal/cli/deploy.go @@ -136,6 +136,10 @@ func runAdHocDeploy(flags *Flags, serverName, appName, image, domain string, por if err := config.ValidateDomain(domain, false); err != nil { return refuseAdmission(err) } + // Ad-hoc deploys are always caddy-routed and have no tls: settings. + if err := config.ValidateRoutableDomain(domain, false, false); err != nil { + return refuseAdmission(err) + } } if serverName == "" { serverName = flags.Host @@ -624,14 +628,17 @@ func deployAppConfig(flags *Flags, appCfg *config.AppConfig, serverName, image, // Skipped for host ingress (publishes a raw port, no domain) and for // external ingress (the user's own front — Cloudflare Tunnel / nginx / // ALB — serves the domain, so it resolves there, not to the server IP). - if !skipDNSCheck && appCfg.Domain != "" && - appCfg.Ingress != config.IngressHost && appCfg.Ingress != config.IngressExternal { + if !skipDNSCheck && dnsCheckApplies(appCfg) { current, _ := state.Read(ctx, executor, appCfg.App) if current == nil { fmt.Println("Validating DNS...") - if err := validateDomainDNS(ctx, executor, appCfg.Domain, host); err != nil { + notes, err := validateDomainDNS(ctx, executor, appCfg.Domain, host) + if err != nil { return err } + for _, note := range notes { + fmt.Printf(" Note: %s\n", note) + } fmt.Println(" DNS validated") } } diff --git a/internal/cli/deployserver.go b/internal/cli/deployserver.go index 3da4bfa..0adfacb 100644 --- a/internal/cli/deployserver.go +++ b/internal/cli/deployserver.go @@ -175,13 +175,16 @@ func deployServerConfigLocked(ctx context.Context, executor ssh.Executor, host, } else if err := ensureImage(ctx, docker.NewClient(executor), image, os.Stdout); err != nil { return err } - if !skipDNSCheck && appCfg.Domain != "" && - appCfg.Ingress != config.IngressHost && appCfg.Ingress != config.IngressExternal { + if !skipDNSCheck && dnsCheckApplies(appCfg) { if current, _ := state.Read(ctx, executor, app); current == nil { fmt.Println("Validating DNS...") - if err := validateDomainDNS(ctx, executor, appCfg.Domain, host); err != nil { + notes, err := validateDomainDNS(ctx, executor, appCfg.Domain, host) + if err != nil { return err } + for _, note := range notes { + fmt.Printf(" Note: %s\n", note) + } fmt.Println(" DNS validated") } } diff --git a/internal/cli/dnscheck.go b/internal/cli/dnscheck.go index 4262955..827eee0 100644 --- a/internal/cli/dnscheck.go +++ b/internal/cli/dnscheck.go @@ -2,27 +2,61 @@ package cli import ( "context" + "errors" + "fmt" "strings" + "github.com/useteploy/teploy/internal/config" "github.com/useteploy/teploy/internal/dns" "github.com/useteploy/teploy/internal/ssh" ) -// validateDomainDNS checks the domain points at the server. When the SSH -// host does not match (a server reached over its mesh IP), the server's -// own interface addresses are accepted too: a box registered by its -// Tailscale IP otherwise refused every first deploy of a public domain -// (2026-10-25 live finding, fresh OVH box set up with --network -// tailscale). A NAT'd server whose public IP is not on an interface -// still needs --skip-dns-check. -func validateDomainDNS(ctx context.Context, executor ssh.Executor, domain, host string) error { - err := dns.Validate(domain, host, nil) - if err == nil || executor == nil { - return err - } - out, runErr := executor.Run(ctx, "hostname -I") - if runErr == nil && dns.Resolves(domain, strings.Fields(out), nil) { - return nil +// dnsCheckApplies is the one rule deploy and validate share: only a +// Caddy-routed domain must point at this server. Host ingress publishes a raw +// port and external ingress is served by the user's own front, so neither +// domain resolves here by design. +func dnsCheckApplies(appCfg *config.AppConfig) bool { + return appCfg.Domain != "" && appCfg.UsesCaddy() +} + +// validateDomainDNS checks every host of a (possibly comma-separated) domain +// field points at the server. When the SSH host does not match (a server +// reached over its mesh IP), the server's own interface addresses are accepted +// too: a box registered by its Tailscale IP otherwise refused every first +// deploy of a public domain (2026-10-25 live finding, fresh OVH box set up with +// --network tailscale). A NAT'd server whose public IP is not on an interface +// still needs --skip-dns-check. A host answering from Cloudflare is proxied: +// its origin cannot be read from DNS, so it passes with a returned note instead +// of reading as a mismatch. +func validateDomainDNS(ctx context.Context, executor ssh.Executor, domain, host string) (notes []string, err error) { + var failures []error + var interfaceIPs []string + interfacesRead := false + for _, d := range strings.Split(domain, ",") { + d = strings.TrimSpace(d) + if d == "" { + continue + } + verr := dns.Validate(d, host, nil) + if verr == nil { + continue + } + if errors.Is(verr, dns.ErrProxied) { + notes = append(notes, fmt.Sprintf("%s answers from Cloudflare (proxied); its origin cannot be verified from DNS", d)) + continue + } + if executor != nil { + if !interfacesRead { + interfacesRead = true + if out, runErr := executor.Run(ctx, "hostname -I"); runErr == nil { + interfaceIPs = strings.Fields(out) + } + } + if dns.Resolves(d, interfaceIPs, nil) { + continue + } + } + failures = append(failures, verr) } - return err + return notes, errors.Join(failures...) } diff --git a/internal/cli/validate.go b/internal/cli/validate.go index 67ccbbe..b8cc0c5 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -65,6 +65,9 @@ func runValidate(flags *Flags) error { // it here explicitly so that's a documented, expected outcome instead // of a surprise discovered mid-deploy. result.Warns = append(result.Warns, nonPublicDomainWarnings(appCfg)...) + if !appCfg.UsesCaddy() { + result.Warns = append(result.Warns, config.ReservedDomainLabelWarnings(appCfg.Domain, appCfg.Ingress)...) + } // 3. Check server connectivity. if appCfg.Server == "" && flags.Host == "" { @@ -100,13 +103,13 @@ func runValidate(flags *Flags) error { result.Errors = append(result.Errors, "Docker is not installed or not running on server") } - // Check DNS. Skipped for ingress: external — the user's own - // front (Cloudflare Tunnel / nginx / ALB) serves the domain, so - // it legitimately resolves there, not to the server IP. - if appCfg.Domain != "" && appCfg.Ingress != config.IngressExternal { - if err := validateDomainDNS(ctx, executor, appCfg.Domain, host); err != nil { + // Check DNS with the rule deploy applies. + if dnsCheckApplies(appCfg) { + notes, err := validateDomainDNS(ctx, executor, appCfg.Domain, host) + if err != nil { result.Warns = append(result.Warns, fmt.Sprintf("DNS: %v", err)) } + result.Warns = append(result.Warns, notes...) } } } diff --git a/internal/cli/validate_test.go b/internal/cli/validate_test.go index 4206b21..ace22a3 100644 --- a/internal/cli/validate_test.go +++ b/internal/cli/validate_test.go @@ -1,12 +1,14 @@ package cli import ( + "context" "os" "path/filepath" "strings" "testing" "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/ssh" ) // TestNonPublicDomainWarnings_PlainDomainNoWarning confirms the @@ -145,3 +147,30 @@ func TestBuildPrereqCheck_ImageSkipsBuildCheck(t *testing.T) { t.Errorf("a pre-built image needs no build check, got warns=%v errs=%v", warns, errs) } } + +func TestDNSCheckAppliesOnlyToCaddyRoutedDomains(t *testing.T) { + for _, tc := range []struct { + cfg config.AppConfig + want bool + }{ + {config.AppConfig{Domain: "a.dev"}, true}, + {config.AppConfig{Domain: "a.dev", Ingress: config.IngressCaddy}, true}, + {config.AppConfig{Domain: "a.dev", Ingress: config.IngressHost}, false}, + {config.AppConfig{Domain: "a.dev", Ingress: config.IngressExternal}, false}, + {config.AppConfig{}, false}, + } { + if got := dnsCheckApplies(&tc.cfg); got != tc.want { + t.Errorf("dnsCheckApplies(%+v) = %v", tc.cfg, got) + } + } +} + +// Every multi-host first deploy used to fail: the whole comma list went to the +// resolver as one name. +func TestValidateDomainDNS_ChecksEachHost(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", ssh.MockCommand{Match: "hostname -I", Output: "127.0.0.1"}) + _, err := validateDomainDNS(context.Background(), mock, "localhost, localhost", "127.0.0.1") + if err != nil { + t.Fatalf("both hosts resolve to the server: %v", err) + } +} diff --git a/internal/dns/public.go b/internal/dns/public.go new file mode 100644 index 0000000..fd568c3 --- /dev/null +++ b/internal/dns/public.go @@ -0,0 +1,194 @@ +package dns + +import ( + "context" + "errors" + "net" + "sort" + "strings" + "sync" + "time" +) + +// Check outcomes for `teploy domains check`. +const ( + StatusOK = "ok" + StatusNXDomain = "nxdomain" + StatusWrongTarget = "wrong_target" + StatusProxied = "proxied" + StatusUnknown = "unknown" + StatusSkipped = "skipped" +) + +// PublicResolvers are the fixed resolvers a domain check asks. The machine's +// own resolver may be a tailnet or split-horizon one, which answers differently +// from what the rest of the internet sees. +var PublicResolvers = []string{"1.1.1.1:53", "8.8.8.8:53"} + +// PublicAnswer is what the public resolvers said about one name. +type PublicAnswer struct { + // Answers is the union across resolvers, sorted. + Answers []string + // NotFound is true when a resolver said the name has no addresses and none + // returned any: "no DNS record", as opposed to a resolver failure. + NotFound bool + // Err is set when no resolver gave an answer at all. + Err error +} + +// lookupFunc is one resolver's lookup, replaceable in tests. +type lookupFunc func(ctx context.Context, resolver, host string) ([]string, error) + +func publicLookup(ctx context.Context, resolver, host string) ([]string, error) { + r := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, resolver) + }, + } + addrs, err := r.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + out := make([]string, 0, len(addrs)) + for _, a := range addrs { + out = append(out, a.IP.String()) + } + return out, nil +} + +// LookupPublic asks every public resolver concurrently, each bounded by +// perResolver, so one blackholed resolver delays only itself. A trailing dot +// keeps the machine's search domains out of the name. +func LookupPublic(ctx context.Context, host string, perResolver time.Duration) PublicAnswer { + return lookupVia(ctx, publicLookup, PublicResolvers, host, perResolver) +} + +func lookupVia(ctx context.Context, lookup lookupFunc, resolvers []string, host string, perResolver time.Duration) PublicAnswer { + name := strings.TrimSuffix(host, ".") + "." + var mu sync.Mutex + var wg sync.WaitGroup + seen := map[string]bool{} + notFound := false + var errs []error + for _, resolver := range resolvers { + wg.Add(1) + go func(resolver string) { + defer wg.Done() + qctx, cancel := context.WithTimeout(ctx, perResolver) + defer cancel() + ips, err := lookup(qctx, resolver, name) + mu.Lock() + defer mu.Unlock() + var dnsErr *net.DNSError + switch { + case err == nil: + for _, ip := range ips { + seen[ip] = true + } + if len(ips) == 0 { + notFound = true + } + case errors.As(err, &dnsErr) && dnsErr.IsNotFound: + notFound = true + default: + errs = append(errs, err) + } + }(resolver) + } + wg.Wait() + res := PublicAnswer{} + for ip := range seen { + res.Answers = append(res.Answers, ip) + } + sort.Strings(res.Answers) + switch { + case len(res.Answers) > 0: + case notFound: + res.NotFound = true + default: + res.Err = errors.Join(errs...) + if res.Err == nil { + res.Err = errors.New("no resolver answered") + } + } + return res +} + +// Classify turns an answer into a status. serverIPs are the addresses that +// count as this server; targetKnown is false when no public address could be +// determined for it, in which case a non-matching answer proves nothing. +func Classify(ans PublicAnswer, serverIPs []string, targetKnown bool) string { + switch { + case ans.Err != nil: + return StatusUnknown + case ans.NotFound || len(ans.Answers) == 0: + return StatusNXDomain + case anyMatch(ans.Answers, serverIPs): + return StatusOK + case AllCloudflare(ans.Answers): + return StatusProxied + case !targetKnown: + return StatusUnknown + default: + return StatusWrongTarget + } +} + +// cloudflareRangesAsOf dates the list below (https://www.cloudflare.com/ips/). +// A stale list can only miss a new range, which reads wrong_target for that +// answer until updated; ranges are additions in practice. +const cloudflareRangesAsOf = "2026-11-04" + +var cloudflareRanges = mustCIDRs( + "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", + "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", + "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", + "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22", + "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", + "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32", +) + +func mustCIDRs(cidrs ...string) []*net.IPNet { + out := make([]*net.IPNet, 0, len(cidrs)) + for _, c := range cidrs { + _, n, err := net.ParseCIDR(c) + if err != nil { + panic(err) + } + out = append(out, n) + } + return out +} + +// CloudflareRangesAsOf reports the date of the embedded list. +func CloudflareRangesAsOf() string { return cloudflareRangesAsOf } + +// IsCloudflare reports whether one address is in Cloudflare's published ranges. +func IsCloudflare(ip string) bool { + parsed := net.ParseIP(ip) + if parsed == nil { + return false + } + for _, n := range cloudflareRanges { + if n.Contains(parsed) { + return true + } + } + return false +} + +// AllCloudflare reports whether every address is Cloudflare's (and there is at +// least one). +func AllCloudflare(ips []string) bool { + if len(ips) == 0 { + return false + } + for _, ip := range ips { + if !IsCloudflare(ip) { + return false + } + } + return true +} diff --git a/internal/dns/public_test.go b/internal/dns/public_test.go new file mode 100644 index 0000000..876d5c0 --- /dev/null +++ b/internal/dns/public_test.go @@ -0,0 +1,113 @@ +package dns + +import ( + "context" + "errors" + "net" + "testing" + "time" +) + +func TestClassify(t *testing.T) { + server := []string{"192.0.2.10"} + cases := []struct { + name string + ans PublicAnswer + targetKnown bool + want string + }{ + {"answer is the server", PublicAnswer{Answers: []string{"192.0.2.10"}}, true, StatusOK}, + {"one of several is the server", PublicAnswer{Answers: []string{"192.0.2.10", "198.51.100.1"}}, true, StatusOK}, + {"no record", PublicAnswer{NotFound: true}, true, StatusNXDomain}, + {"elsewhere", PublicAnswer{Answers: []string{"198.51.100.1"}}, true, StatusWrongTarget}, + {"cloudflare", PublicAnswer{Answers: []string{"104.16.0.1", "172.67.1.1"}}, true, StatusProxied}, + {"partly cloudflare", PublicAnswer{Answers: []string{"104.16.0.1", "198.51.100.1"}}, true, StatusWrongTarget}, + {"resolvers failed", PublicAnswer{Err: errors.New("timeout")}, true, StatusUnknown}, + {"server address unknown", PublicAnswer{Answers: []string{"198.51.100.1"}}, false, StatusUnknown}, + {"no record even with unknown server", PublicAnswer{NotFound: true}, false, StatusNXDomain}, + {"cloudflare even with unknown server", PublicAnswer{Answers: []string{"104.16.0.1"}}, false, StatusProxied}, + } + for _, c := range cases { + if got := Classify(c.ans, server, c.targetKnown); got != c.want { + t.Errorf("%s: Classify = %q, want %q", c.name, got, c.want) + } + } +} + +func TestLookupVia(t *testing.T) { + notFound := &net.DNSError{Err: "no such host", IsNotFound: true} + t.Run("a blackholed resolver delays only itself", func(t *testing.T) { + start := time.Now() + got := lookupVia(context.Background(), func(ctx context.Context, resolver, host string) ([]string, error) { + if resolver == "slow" { + <-ctx.Done() + return nil, ctx.Err() + } + return []string{"192.0.2.1"}, nil + }, []string{"slow", "fast"}, "site.dev", 50*time.Millisecond) + if len(got.Answers) != 1 || got.Err != nil || got.NotFound { + t.Fatalf("got %+v", got) + } + if time.Since(start) > 2*time.Second { + t.Fatal("the lookup outlived its per-resolver bound") + } + }) + t.Run("a timeout is not a missing record", func(t *testing.T) { + got := lookupVia(context.Background(), func(ctx context.Context, resolver, host string) ([]string, error) { + return nil, context.DeadlineExceeded + }, []string{"a", "b"}, "site.dev", time.Second) + if got.NotFound || got.Err == nil { + t.Fatalf("got %+v", got) + } + }) + t.Run("not found from one resolver", func(t *testing.T) { + got := lookupVia(context.Background(), func(ctx context.Context, resolver, host string) ([]string, error) { + if resolver == "a" { + return nil, notFound + } + return nil, context.DeadlineExceeded + }, []string{"a", "b"}, "site.dev", time.Second) + if !got.NotFound || got.Err != nil { + t.Fatalf("got %+v", got) + } + }) + t.Run("an answer wins over a not found", func(t *testing.T) { + got := lookupVia(context.Background(), func(ctx context.Context, resolver, host string) ([]string, error) { + if resolver == "a" { + return nil, notFound + } + return []string{"192.0.2.1"}, nil + }, []string{"a", "b"}, "site.dev", time.Second) + if got.NotFound || len(got.Answers) != 1 { + t.Fatalf("got %+v", got) + } + }) + t.Run("the name is queried fully qualified", func(t *testing.T) { + var seen string + lookupVia(context.Background(), func(ctx context.Context, resolver, host string) ([]string, error) { + seen = host + return nil, notFound + }, []string{"a"}, "site.dev", time.Second) + if seen != "site.dev." { + t.Fatalf("queried %q", seen) + } + }) +} + +func TestIsCloudflare(t *testing.T) { + for ip, want := range map[string]bool{ + "104.21.3.4": true, + "172.67.1.1": true, + "2606:4700::1": true, + "192.0.2.1": false, + "100.83.22.26": false, + "not an address": false, + } { + if got := IsCloudflare(ip); got != want { + t.Errorf("IsCloudflare(%q) = %v", ip, got) + } + } + if CloudflareRangesAsOf() == "" { + t.Error("the range list must be dated") + } +} diff --git a/internal/dns/validate.go b/internal/dns/validate.go index bb880cc..eef01b7 100644 --- a/internal/dns/validate.go +++ b/internal/dns/validate.go @@ -1,6 +1,7 @@ package dns import ( + "errors" "fmt" "net" ) @@ -8,8 +9,14 @@ import ( // Resolver resolves a hostname to IP addresses. type Resolver func(host string) ([]string, error) +// ErrProxied marks a domain whose answers all belong to Cloudflare: it may well +// reach this server through the proxy, which DNS cannot show. Callers treat it +// as a hint, not a mismatch. +var ErrProxied = errors.New("domain is proxied by Cloudflare") + // Validate checks that the domain's DNS A record points at the server IP. -// Returns nil if any resolved IP matches, or an error with the expected IP. +// Returns nil if any resolved IP matches, an error wrapping ErrProxied when +// every answer is a Cloudflare address, or an error with the expected IP. // It also returns an error when either name resolves to no addresses. // Pass nil for resolve to use the default net.LookupHost. func Validate(domain, serverHost string, resolve Resolver) error { @@ -33,12 +40,11 @@ func Validate(domain, serverHost string, resolve Resolver) error { return fmt.Errorf("could not resolve server %s: no addresses returned", serverHost) } - for _, dip := range domainIPs { - for _, sip := range serverIPs { - if dip == sip { - return nil - } - } + if anyMatch(domainIPs, serverIPs) { + return nil + } + if AllCloudflare(domainIPs) { + return fmt.Errorf("%w: %s resolves to %s", ErrProxied, domain, domainIPs[0]) } return fmt.Errorf( @@ -61,12 +67,26 @@ func Resolves(domain string, ips []string, resolve Resolver) bool { if err != nil { return false } - for _, dip := range domainIPs { - for _, ip := range ips { - if dip == ip { + return anyMatch(domainIPs, ips) +} + +// anyMatch compares addresses by value, so two spellings of one IPv6 address +// match. +func anyMatch(a, b []string) bool { + for _, x := range a { + for _, y := range b { + if sameIP(x, y) { return true } } } return false } + +func sameIP(a, b string) bool { + if a == b { + return true + } + x, y := net.ParseIP(a), net.ParseIP(b) + return x != nil && y != nil && x.Equal(y) +} diff --git a/internal/dns/validate_test.go b/internal/dns/validate_test.go index e3ace7e..2ca7cd6 100644 --- a/internal/dns/validate_test.go +++ b/internal/dns/validate_test.go @@ -1,6 +1,7 @@ package dns import ( + "errors" "fmt" "strings" "testing" @@ -148,3 +149,37 @@ func TestValidate_ServerResolvesToNoAddresses(t *testing.T) { t.Errorf("expected no addresses returned message, got: %v", err) } } + +func TestValidate_CloudflareAnswerIsProxiedNotMismatch(t *testing.T) { + resolve := func(host string) ([]string, error) { + if host == "site.test" { + return []string{"104.16.1.1", "2606:4700::1"}, nil + } + return []string{"192.0.2.10"}, nil + } + err := Validate("site.test", "192.0.2.10", resolve) + if !errors.Is(err, ErrProxied) { + t.Fatalf("Validate = %v, want ErrProxied", err) + } + mixed := func(host string) ([]string, error) { + if host == "site.test" { + return []string{"104.16.1.1", "198.51.100.7"}, nil + } + return []string{"192.0.2.10"}, nil + } + if err := Validate("site.test", "192.0.2.10", mixed); err == nil || errors.Is(err, ErrProxied) { + t.Fatalf("a mixed answer is a mismatch, got %v", err) + } +} + +func TestValidate_ComparesAddressesByValue(t *testing.T) { + resolve := func(host string) ([]string, error) { + if host == "site.test" { + return []string{"2001:0db8:0000:0000:0000:0000:0000:0001"}, nil + } + return []string{"2001:db8::1"}, nil + } + if err := Validate("site.test", "server", resolve); err != nil { + t.Fatalf("two spellings of one address must match: %v", err) + } +} From 02616f73ba231b1246b7ad210f7316cd6a8fd9ff Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:16:30 -0700 Subject: [PATCH 4/6] feat(domains): add teploy domains check Per (server, app, host): DNS from fixed public resolvers (1.1.1.1, 8.8.8.8) with ok | nxdomain | wrong_target | proxied | unknown | skipped, using the server's interface addresses plus a best-effort outbound probe, and a dated Cloudflare range list; certificate failures per identifier from docker logs, with null rather than 0 on short or unavailable logs; the route's owner, whether it is stale or orphaned, and whether upstreams resolve in Caddy's network. Read-only, nothing new on the server, works as a non-root SSH user. Concurrency and deadlines are bounded per server, per resolver and per sweep. An unreachable server is reported, not absent. Pinned in contracts/ (rev 17) and advertised as domains-check-v1 for Dash's gate. Co-Authored-By: Claude Opus 5.5 --- AUDIT_OPEN.md | 28 + CHANGELOG.md | 32 + README.md | 25 + contracts/MANIFEST.md | 2 + .../domains-check-envelope/valid/sweep.json | 182 ++++ .../fixtures/version-handshake/valid/mi2.json | 1 + .../schema/domains-check-envelope.schema.json | 105 ++ internal/caddy/inspect.go | 8 +- internal/caddy/inspect_test.go | 113 ++ internal/cli/domains.go | 243 +++++ internal/cli/domains_test.go | 129 +++ internal/cli/machineinterface.go | 8 +- internal/cli/machineinterface_test.go | 1 + internal/cli/root.go | 1 + internal/domains/domains.go | 971 ++++++++++++++++++ internal/domains/domains_test.go | 436 ++++++++ 16 files changed, 2280 insertions(+), 5 deletions(-) create mode 100644 contracts/fixtures/domains-check-envelope/valid/sweep.json create mode 100644 contracts/schema/domains-check-envelope.schema.json create mode 100644 internal/caddy/inspect_test.go create mode 100644 internal/cli/domains.go create mode 100644 internal/cli/domains_test.go create mode 100644 internal/domains/domains.go create mode 100644 internal/domains/domains_test.go diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index 34d3eea..f31b680 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -1,5 +1,33 @@ # Open audit items +## 2026-11-05 — Dash auto-config Slice 5: domain health (CLI side) + +Plan: `../_internal/DASH_AUTOCONFIG_PLAN_2026-11-03.md` section 8. Branch +`slice5-domain-health`. + +Closed: U5 root cause and TCL-10's stale half (a release leaving caddy ingress +retires the block after the state commit, CAS + fence; rollback both directions); +reserved names under caddy ingress; compose placeholder; the three DNS bugs +(multi-host, validate/deploy disagreement, equality-only); `domains check`. +Found along the way and fixed: host-to-caddy rollback rendered an empty CAS list; +the release record dropped `tls.http_only`; rollback state kept the old domain. + +Open: +- teploy-observe's `teploy.yml` still carries `domain: observe.example.com` under + `ingress: host`. Legal (label-only warning); remove it there. +- A crash between the state commit and the block removal leaves the block until + the next deploy; `domains check` reports it as `stale_managed` meanwhile. +- A managed block whose owner is not deployed is reported (`orphan_managed`), not + removed. Removal is plan P5 (`route remove`). +- The DNS lookup uses Go's resolver with a fixed dial target, which still honors + the local /etc/hosts before asking. A hosts entry for a public name would hide + the public answer. +- `tls.obtain`/`tls.renew` parsing assumes Caddy's default JSON log; another log + format reads `unknown` for the whole server (`unavailable`), never zero. +- Public address detection: interface addresses plus a best-effort probe of + 1.1.1.1's trace endpoint from the server. Without either a foreign answer reads + `unknown`. Explicit `public_ips` server fields remain plan v2. + ## 2026-11-03 — Dash auto-config Slice 2: backup correctness (G6, G7) Plan: `../_internal/DASH_AUTOCONFIG_PLAN_2026-11-03.md` (U3, U4, P1 G6/G7, diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b725a..4b4219d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ## [Unreleased] +### Added + +- `teploy domains check [--server X] [--app A] [--log-window D] [--json]`: DNS + from fixed public resolvers, Caddy certificate failures and route health per + (server, app, host). Read-only; advertised as `domains-check-v1`. What cannot + be established reads `unknown`, never healthy. + +### Changed + +- Reserved names (RFC 2606/6761: example.com/.net/.org, *.example, .test, + .invalid, .localhost, localhost) are refused under caddy ingress unless + `tls.internal` or `tls.http_only` is set, and warned as label-only under host + and external ingress. **A `teploy.yml` with such a domain under caddy ingress + stops loading.** +- Compose import no longer fabricates `.example.com`; it asks for a domain. + +### Fixed + +- A deploy or rollback that leaves caddy ingress removes the managed Caddy block + the earlier release left, after the state commit, under the fence and a CAS on + the block's hash, and `teploy plan` shows it. Those blocks kept Caddy retrying + certificates for names nothing served. A failed deploy leaves the block alone. +- Rollback from a host or external release to a caddy release works: it resolved + its route expectation against the wrong ingress and rendered an empty CAS. +- A release record keeps `tls.http_only`, so a folderless rollback or a route + restore no longer re-renders an HTTP-only site with HTTPS. +- State after a rollback names the target release's domain. +- Multi-host domains pass the first-deploy DNS check (the comma list went to the + resolver as one name), `validate` no longer DNS-checks host-ingress apps that + `deploy` skips, addresses compare by value, and a Cloudflare answer is a note + rather than a mismatch. + ## [0.1.50] - 2026-11-04 ### Fixed diff --git a/README.md b/README.md index 7b652bd..5f19d5f 100644 --- a/README.md +++ b/README.md @@ -568,6 +568,31 @@ teploy network setup # VPN mesh (tailscale, headscale, netbird) teploy network status / join # VPN management ``` +### Domains + +```bash +teploy domains check # every server in servers.yml +teploy domains check --server edge --app web +teploy domains check --json # contracts/schema/domains-check-envelope.schema.json +``` + +One row per (server, app, host), plus hosts only a hand-written Caddy block +serves. Read-only, run from your machine over SSH, nothing new on the server: + +- **dns**: what 1.1.1.1 and 8.8.8.8 say, not your local resolver: `ok`, + `nxdomain`, `wrong_target`, `proxied` (Cloudflare answers; a hint, not a + failure), `unknown` (no resolver answered, or the server's public address + could not be determined), `skipped`. +- **tls**: certificate failures per name from Caddy's logs (`--log-window`, + default 14 days), with how long the name has been failing. Logs under a day + old or unreadable give `unknown`, never 0. +- **route**: `managed`, `manual`, `stale_managed` (the app is no longer + caddy-routed), `orphan_managed` (no such app here), `none`, and whether each + upstream resolves in Caddy's network. + +`ingress: external` and an unfronted `ingress: host` domain are labels: they +carry a `skip` reason. A server that cannot be reached is reported as such. + ### Accessories ``` teploy accessory list # list running accessories diff --git a/contracts/MANIFEST.md b/contracts/MANIFEST.md index 686bd34..1eb7603 100644 --- a/contracts/MANIFEST.md +++ b/contracts/MANIFEST.md @@ -10,6 +10,7 @@ Neutron/Nucleus dependency and a public mirror. | Corpus rev | Emitting CLI | Machine Interface | Notes | |---|---|---|---| +| 17 | main (dash auto-config slice 5: domain health) | 2 | Additive. New artifact domains-check-envelope (`teploy domains check [--server] [--app] [--log-window] --json`: per (server, app, host) DNS via the fixed public resolvers, Caddy certificate failures from its logs — `failures` is null, never 0, on short or unavailable logs — and the route with upstream resolution; per-server state so an unreachable server is explicit), fixture GENERATED from the real `sweepDomains` over a mock executor. version-handshake advertises `domains-check-v1` (regenerated mi2.json). release-record's `caddy` gains optional `tls_http_only`. No MI bump. | | 16 | main (server-authority spec P3 / plan D4, git sources) | 2 | Additive. New artifacts source-show-envelope (`config source show --json`: repository/branch/poll, public credential facts — type, deploy-key public half + fingerprint, token username, pinned host-key fingerprints — and the per-ref admission state), source-key-envelope (`config source key --json` for ssh/token and `host-key --json`) and trigger-poll-envelope (`trigger poll --json`: one result object per app — ref, head, admitted, reason vocabulary incl. force-push/retry-limit/not-due, deployed_release), all fixtures GENERATED from the real DTOs. version-handshake advertises `git-source-v1` (git: code source, per-app credentials outside /deployments, build_secrets) and `trigger-poll-v1` (the one admitter + reset/retry/install-timer) (regenerated mi2.json). No MI bump. | | 15 | main (server-authority spec D3: backup target) | 2 | Additive. One backup target per server, stored outside /deployments with age-encrypted credentials and read at run time (`server backup-target set/show/unset`; `backup-target-v1`). New artifacts backup-target-envelope, backup-list-envelope, backup-create-envelope, backup-restore-envelope — fixtures GENERATED from the real DTO encoders (`backup list --json` now reports parsed rows {id, kind, accessory, size_bytes, created_at, key} plus schedules discovered from teploy cron markers; the old bare-array-of-raw-`aws s3 ls`-lines `backup list --json` shape is gone — it was never a corpus artifact and pinned no consumer). `backup create/restore --json` envelopes; restore quiesces the app under its fenced lock (backup-json-v1). version-handshake advertises `backup-target-v1` and `backup-json-v1` (regenerated mi2.json). No MI bump. | | 14 | main (server-authority plan D2, folderless reads) | 2 | Additive. New artifacts releases-envelope (`teploy releases --app X --host S --json`: container release records newest first with current/rollback_available (+reason); static apps list retained releases — both via the envelope, the folder-static legacy bare array is unchanged), rollback-result (`teploy rollback [--to X] --json`: {app, server, from, to, ok}; failures keep the error envelope) and redeploy-result (`teploy redeploy [--json]`: {app, server, home, release, image, ok}). Fixtures GENERATED from the real DTOs. version-handshake advertises `secret-stdin-v1` (secret set KEY --stdin; folderless secret set/get/list/rm/rotate), `releases-json-v1`, `rollback-json-v1`, `redeploy-v1` (fresh deploy of the current release's recorded image, env re-resolved) and `env-config-guard-v1` (env set/unset refuse keys a Teploy-home config declares) (regenerated mi2.json). No MI bump. || 13 | main (server-authority spec P2 support) | 2 | Additive. New artifact config-merge-envelope (`teploy config merge --base --file - --json`: merged config + conflicting keys, read-only), fixture GENERATED from the real configMergeDTO. version-handshake advertises `config-merge-v1`, `config-unprivileged-v1` (`config ... --unprivileged` refuses host-reaching changes) and `deploy-home-v1` (`deploy --app X --image T --home teploy` creates a new Teploy-home app) (regenerated mi2.json). No MI bump. | @@ -54,6 +55,7 @@ Neutron/Nucleus dependency and a public mirror. | backup-list-envelope | yes (rev 15) | valid x2 (app, legacy-schedule; real backupListDTO) | teploy-cli | | backup-create-envelope | yes (rev 15) | valid x2 (volumes, accessory; real backupCreateDTO) | teploy-cli | | backup-restore-envelope | yes (rev 15) | valid x2 (ok, unhealthy; real backupRestoreDTO) | teploy-cli | +| domains-check-envelope | yes (rev 17) | valid (one server with each finding, one unreachable; real `sweepDomains`) | teploy-cli | | releases-envelope | yes (rev 14) | valid x2 (container app, static app; real releasesDTO) | teploy-cli | | rollback-result | yes (rev 14) | valid (real rollbackResult) | teploy-cli | | redeploy-result | yes (rev 14) | valid x2 (teploy-home, folder-home; real redeployDTO) | teploy-cli | diff --git a/contracts/fixtures/domains-check-envelope/valid/sweep.json b/contracts/fixtures/domains-check-envelope/valid/sweep.json new file mode 100644 index 0000000..8c0f11b --- /dev/null +++ b/contracts/fixtures/domains-check-envelope/valid/sweep.json @@ -0,0 +1,182 @@ +{ + "machine_interface": 2, + "checked_at": "2026-11-04T12:00:00Z", + "log_window_seconds": 1209600, + "resolvers": [ + "1.1.1.1:53", + "8.8.8.8:53" + ], + "cloudflare_ranges_as_of": "2026-11-04", + "servers": [ + { + "server": "down", + "host": "198.51.100.1", + "state": "unreachable", + "checked_at": "2026-11-04T12:00:00Z", + "error": "dial tcp 198.51.100.1:22: i/o timeout", + "addresses": [], + "public_address_known": false, + "caddy": { + "container": "unknown", + "caddyfile": "unreadable", + "logs": "unavailable", + "log_coverage_seconds": 0 + }, + "issues": [] + }, + { + "server": "edge", + "host": "192.0.2.10", + "state": "ok", + "checked_at": "2026-11-04T12:00:00Z", + "addresses": [ + "172.17.0.1", + "192.0.2.10" + ], + "public_address_known": true, + "caddy": { + "container": "present", + "caddyfile": "present", + "logs": "ok", + "log_coverage_seconds": 1209600 + }, + "issues": [] + } + ], + "domains": [ + { + "server": "edge", + "app": "blog", + "host": "blog.dev", + "ingress": "caddy", + "dns": { + "status": "ok", + "answers": [ + "192.0.2.10" + ] + }, + "tls": { + "status": "ok", + "failures": 0 + }, + "route": { + "kind": "managed", + "owner": "blog", + "tls": "acme", + "upstreams": [ + { + "dial": "blog-web-v1:3000", + "resolves": "yes" + } + ], + "upstream_resolves": "yes" + } + }, + { + "server": "edge", + "app": "dash", + "host": "dash.example.com", + "ingress": "host", + "skip": "reserved_name", + "dns": { + "status": "skipped", + "answers": [], + "detail": "reserved name: never publicly resolvable" + }, + "tls": { + "status": "failing", + "failures": 1, + "failing_since": "2026-10-25T11:00:00Z", + "last_failure_at": "2026-11-04T11:00:00Z", + "last_error": "[dash.example.com] Obtain: no solvers" + }, + "route": { + "kind": "stale_managed", + "owner": "dash", + "tls": "acme", + "upstreams": [ + { + "dial": "dash-web-old:3456", + "resolves": "no" + } + ], + "upstream_resolves": "no" + } + }, + { + "server": "edge", + "app": "front", + "host": "front.dev", + "ingress": "external", + "skip": "external_label", + "dns": { + "status": "skipped", + "answers": [] + }, + "tls": { + "status": "not_applicable", + "failures": null + }, + "route": { + "kind": "none", + "upstreams": [], + "upstream_resolves": "not_applicable" + } + }, + { + "server": "edge", + "app": "", + "host": "hand.dev", + "ingress": "", + "dns": { + "status": "nxdomain", + "answers": [], + "detail": "no address record at the public resolvers" + }, + "tls": { + "status": "ok", + "failures": 0 + }, + "route": { + "kind": "manual", + "tls": "acme", + "upstreams": [ + { + "dial": "127.0.0.1:9000", + "resolves": "not_applicable" + } + ], + "upstream_resolves": "yes" + } + }, + { + "server": "edge", + "app": "blog", + "host": "www.blog.dev", + "ingress": "caddy", + "dns": { + "status": "proxied", + "answers": [ + "104.16.0.1" + ], + "detail": "answers from Cloudflare: origin cannot be read from DNS" + }, + "tls": { + "status": "ok", + "failures": 0 + }, + "route": { + "kind": "managed", + "owner": "blog", + "tls": "acme", + "upstreams": [ + { + "dial": "blog-web-v1:3000", + "resolves": "yes" + } + ], + "upstream_resolves": "yes" + } + } + ] +} diff --git a/contracts/fixtures/version-handshake/valid/mi2.json b/contracts/fixtures/version-handshake/valid/mi2.json index 9d1b5ab..4616b31 100644 --- a/contracts/fixtures/version-handshake/valid/mi2.json +++ b/contracts/fixtures/version-handshake/valid/mi2.json @@ -10,6 +10,7 @@ "config-unprivileged-v1", "deploy-home-v1", "doctor-diagnostics", + "domains-check-v1", "env-config-guard-v1", "env-set-stdin", "error-envelope", diff --git a/contracts/schema/domains-check-envelope.schema.json b/contracts/schema/domains-check-envelope.schema.json new file mode 100644 index 0000000..73f05b5 --- /dev/null +++ b/contracts/schema/domains-check-envelope.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://teploy.github.io/contracts/schema/domains-check-envelope.schema.json", + "title": "`teploy domains check --json` envelope: DNS, certificate and route evidence per (server, app, host) (rev 17). What could not be established reads unknown, never healthy.", + "type": "object", + "required": ["machine_interface", "checked_at", "log_window_seconds", "resolvers", "cloudflare_ranges_as_of", "servers", "domains"], + "properties": { + "machine_interface": {"type": "integer", "minimum": 1}, + "checked_at": {"type": "string", "format": "date-time"}, + "log_window_seconds": {"type": "integer", "minimum": 1}, + "resolvers": {"type": "array", "items": {"type": "string"}}, + "cloudflare_ranges_as_of": {"type": "string"}, + "servers": { + "type": "array", + "items": { + "type": "object", + "required": ["server", "host", "state", "checked_at", "addresses", "public_address_known", "caddy", "issues"], + "properties": { + "server": {"type": "string", "minLength": 1}, + "host": {"type": "string"}, + "state": {"enum": ["ok", "partial", "unreachable"]}, + "checked_at": {"type": "string", "format": "date-time"}, + "error": {"type": "string"}, + "addresses": {"type": "array", "items": {"type": "string"}}, + "public_address_known": {"type": "boolean"}, + "caddy": { + "type": "object", + "required": ["container", "caddyfile", "logs", "log_coverage_seconds"], + "properties": { + "container": {"enum": ["present", "absent", "stopped", "unknown"]}, + "caddyfile": {"enum": ["present", "absent", "unparseable", "unreadable"]}, + "logs": {"enum": ["ok", "short", "unavailable", "no_caddy"]}, + "log_coverage_seconds": {"type": "integer", "minimum": 0}, + "logs_truncated": {"type": "boolean"} + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "required": ["scope", "message"], + "properties": {"scope": {"type": "string"}, "message": {"type": "string"}} + } + } + } + } + }, + "domains": { + "type": "array", + "items": { + "type": "object", + "required": ["server", "app", "host", "ingress", "dns", "tls", "route"], + "properties": { + "server": {"type": "string", "minLength": 1}, + "app": {"type": "string", "description": "empty for a hand-written block no app claims"}, + "host": {"type": "string", "minLength": 1}, + "ingress": {"enum": ["caddy", "host", "external", ""]}, + "skip": {"enum": ["external_label", "host_label", "ip_literal", "wildcard", "private_name", "reserved_name", "invalid_name"]}, + "dns": { + "type": "object", + "required": ["status", "answers"], + "properties": { + "status": {"enum": ["ok", "nxdomain", "wrong_target", "proxied", "unknown", "skipped"]}, + "answers": {"type": "array", "items": {"type": "string"}}, + "detail": {"type": "string"} + } + }, + "tls": { + "type": "object", + "required": ["status", "failures"], + "properties": { + "status": {"enum": ["ok", "failing", "unknown", "not_applicable"]}, + "failures": {"type": ["integer", "null"], "minimum": 0, "description": "null when unknown; never 0 on short or unavailable logs"}, + "failing_since": {"type": "string", "format": "date-time"}, + "last_failure_at": {"type": "string", "format": "date-time"}, + "last_success_at": {"type": "string", "format": "date-time"}, + "last_error": {"type": "string"} + } + }, + "route": { + "type": "object", + "required": ["kind", "upstreams", "upstream_resolves"], + "properties": { + "kind": {"enum": ["managed", "manual", "stale_managed", "orphan_managed", "none", "unknown"]}, + "owner": {"type": "string"}, + "tls": {"enum": ["acme", "internal", "custom", "http_only"]}, + "upstreams": { + "type": "array", + "items": { + "type": "object", + "required": ["dial", "resolves"], + "properties": { + "dial": {"type": "string"}, + "resolves": {"enum": ["yes", "no", "unknown", "not_applicable"]} + } + } + }, + "upstream_resolves": {"enum": ["yes", "no", "partial", "unknown", "not_applicable"]} + } + } + } + } + } + } +} diff --git a/internal/caddy/inspect.go b/internal/caddy/inspect.go index a1a1650..5fc9438 100644 --- a/internal/caddy/inspect.go +++ b/internal/caddy/inspect.go @@ -157,13 +157,13 @@ func inspectSite(owner string, b SiteBlock) RouteSite { continue } fields := strings.Fields(code) - net := braceNet(code) + delta := braceNet(code) if len(fields) > 0 && !inWebhook { switch { case fields[0] == "reverse_proxy": site.Upstreams = append(site.Upstreams, dialTokens(fields[1:])...) - if net > 0 { - inProxy, proxyDepth = true, depth+net + if delta > 0 { + inProxy, proxyDepth = true, depth+delta } case fields[0] == "to" && inProxy && depth == proxyDepth: site.Upstreams = append(site.Upstreams, dialTokens(fields[1:])...) @@ -175,7 +175,7 @@ func inspectSite(owner string, b SiteBlock) RouteSite { } } } - depth += net + depth += delta if inProxy && depth < proxyDepth { inProxy = false } diff --git a/internal/caddy/inspect_test.go b/internal/caddy/inspect_test.go new file mode 100644 index 0000000..8b89ef1 --- /dev/null +++ b/internal/caddy/inspect_test.go @@ -0,0 +1,113 @@ +package caddy + +import ( + "reflect" + "testing" +) + +const inspectFixture = `{ + email ops@teploy-fixture.dev +} + +# TEPLOY BEGIN blog +# TEPLOY GENERATION 4 +blog.dev, www.blog.dev { + # TEPLOY WEBHOOK BEGIN blog + @teploy_hook_blog { + method POST + path /teploy-webhook/blog + } + handle @teploy_hook_blog { + reverse_proxy host.docker.internal:9999 + } + # TEPLOY WEBHOOK END blog + reverse_proxy blog-web-abc:3000 +} +# TEPLOY END blog + +# TEPLOY BEGIN lb +lb.dev { + reverse_proxy { + to lb-web-a-1:8080 lb-web-a-2:8080 + health_uri /health + } +} +# TEPLOY END lb + +# TEPLOY BEGIN internal-app +private.dev { + tls internal + reverse_proxy http://internal-app-web-a:80 +} +# TEPLOY END internal-app + +http://plain.dev, http://plain2.dev:80 { + reverse_proxy 127.0.0.1:3000 +} + +custom.dev { + tls /etc/caddy/tls/c.pem /etc/caddy/tls/k.pem + file_server +} + +:8080 { + respond "hello" +} +` + +func TestInspectRoutes(t *testing.T) { + sites, err := InspectRoutes(inspectFixture) + if err != nil { + t.Fatalf("InspectRoutes: %v", err) + } + byHost := map[string]RouteSite{} + for _, s := range sites { + for _, h := range s.Hosts { + byHost[h] = s + } + } + cases := []struct { + host, owner, tls string + upstreams []string + }{ + {"blog.dev", "blog", SiteTLSACME, []string{"blog-web-abc:3000"}}, + {"www.blog.dev", "blog", SiteTLSACME, []string{"blog-web-abc:3000"}}, + {"lb.dev", "lb", SiteTLSACME, []string{"lb-web-a-1:8080", "lb-web-a-2:8080"}}, + {"private.dev", "internal-app", SiteTLSInternal, []string{"internal-app-web-a:80"}}, + {"plain.dev", "", SiteTLSHTTPOnly, []string{"127.0.0.1:3000"}}, + {"plain2.dev", "", SiteTLSHTTPOnly, []string{"127.0.0.1:3000"}}, + {"custom.dev", "", SiteTLSCustom, nil}, + } + for _, c := range cases { + s, ok := byHost[c.host] + if !ok { + t.Errorf("%s: no site", c.host) + continue + } + if s.Owner != c.owner || s.TLS != c.tls || !reflect.DeepEqual(s.Upstreams, c.upstreams) { + t.Errorf("%s: got owner=%q tls=%q upstreams=%v", c.host, s.Owner, s.TLS, s.Upstreams) + } + } + if _, ok := byHost[""]; ok { + t.Error("an address with no host must not produce a host") + } +} + +func TestInspectRoutes_UnreadableFileIsAnError(t *testing.T) { + for name, content := range map[string]string{ + "unterminated region": "# TEPLOY BEGIN a\na.dev {\n}\n", + "top-level import": "import sites/*\n", + "unbalanced": "a.dev {\n", + } { + if _, err := InspectRoutes(content); err == nil { + t.Errorf("%s: a half-read route view must be an error", name) + } + } +} + +func TestRegionHosts(t *testing.T) { + got := RegionHosts("# TEPLOY BEGIN blog\n# TEPLOY GENERATION 2\nBlog.dev, https://www.blog.dev {\n\treverse_proxy a:1\n}\n# TEPLOY END blog") + if !reflect.DeepEqual(got, []string{"blog.dev", "www.blog.dev"}) { + t.Errorf("RegionHosts = %v", got) + } +} diff --git a/internal/cli/domains.go b/internal/cli/domains.go new file mode 100644 index 0000000..727f711 --- /dev/null +++ b/internal/cli/domains.go @@ -0,0 +1,243 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "sort" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/dns" + "github.com/useteploy/teploy/internal/domains" + "github.com/useteploy/teploy/internal/ssh" +) + +// Bounds of a sweep. One slow or dead server costs only its own budget. +const ( + domainsServerConcurrency = 4 + domainsConnectTimeout = 15 * time.Second + domainsServerTimeout = 90 * time.Second + domainsSweepTimeout = 3 * time.Minute +) + +func newDomainsCmd(flags *Flags) *cobra.Command { + cmd := &cobra.Command{ + Use: "domains", + Short: "Check what serves your domains", + } + var server, app string + var window time.Duration + check := &cobra.Command{ + Use: "check", + Short: "Report DNS, certificate and route health per domain", + Long: `For each (server, app, host) report: + + dns what the public resolvers 1.1.1.1 and 8.8.8.8 say, not the local one: + ok | nxdomain | wrong_target | proxied (a hint) | unknown | skipped + tls Caddy's certificate failures per identifier, from its logs. Logs that + are short or unreadable give "unknown", never 0 + route who serves the name: managed, manual, stale_managed (the app is no + longer caddy-routed), orphan_managed (no such app), or none, and + whether its upstreams resolve in Caddy's network + +With no --server every server in servers.yml is checked. Read-only. A server +that cannot be reached is reported as such, not as healthy. With --json the +command exits 0 whenever the envelope was produced; read servers[].state.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runDomainsCheck(cmd.Context(), flags, cmd.OutOrStdout(), server, app, window) + }, + } + check.Flags().StringVar(&server, "server", "", "check one server (servers.yml name or host)") + check.Flags().StringVar(&app, "app", "", "report one app") + check.Flags().DurationVar(&window, "log-window", domains.DefaultLogWindow, "how far back to read Caddy's logs") + cmd.AddCommand(check) + return cmd +} + +type domainsTarget struct { + name string + host string + user string + key string +} + +func domainsTargets(flags *Flags, server string) ([]domainsTarget, error) { + if server != "" || flags.Host != "" { + name := server + if name == "" { + name = flags.Host + } + host, user, key, err := config.ResolveServer(name, flags.Host, flags.User, flags.Key) + if err != nil { + return nil, fmt.Errorf("resolving server %q: %w", name, err) + } + return []domainsTarget{{name: name, host: host, user: user, key: key}}, nil + } + path, err := config.DefaultServersPath() + if err != nil { + return nil, err + } + servers, err := config.ListServers(path) + if err != nil { + return nil, err + } + if len(servers) == 0 { + return nil, fmt.Errorf("no servers configured: add one with `teploy server add` or pass --server") + } + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + targets := make([]domainsTarget, 0, len(names)) + for _, name := range names { + host, user, key, err := config.ResolveServer(name, "", flags.User, flags.Key) + if err != nil { + targets = append(targets, domainsTarget{name: name}) + continue + } + targets = append(targets, domainsTarget{name: name, host: host, user: user, key: key}) + } + return targets, nil +} + +func runDomainsCheck(parent context.Context, flags *Flags, out io.Writer, server, app string, window time.Duration) error { + if app != "" { + if err := config.ValidateName(app); err != nil { + return err + } + } + if window <= 0 { + return fmt.Errorf("--log-window must be positive") + } + if parent == nil { + parent = context.Background() + } + targets, err := domainsTargets(flags, server) + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(parent, os.Interrupt) + defer stop() + ctx, cancel := context.WithTimeout(ctx, domainsSweepTimeout) + defer cancel() + + connect := func(ctx context.Context, t domainsTarget) (ssh.Executor, error) { + if t.host == "" { + return nil, fmt.Errorf("server %s has no resolvable host", t.name) + } + dialCtx, dialCancel := context.WithTimeout(ctx, domainsConnectTimeout) + defer dialCancel() + return ssh.Connect(dialCtx, ssh.ConnectConfig{Host: t.host, User: t.user, KeyPath: t.key}) + } + env := sweepDomains(ctx, targets, connect, domains.Options{LogWindow: window, App: app}, domains.NewDNSCache(), time.Now().UTC()) + if flags.JSON { + return json.NewEncoder(out).Encode(env) + } + return printDomains(out, env) +} + +// sweepDomains checks every target under bounded concurrency. A server that +// fails still gets its envelope entry: its absence must not read as healthy. +func sweepDomains(ctx context.Context, targets []domainsTarget, connect func(context.Context, domainsTarget) (ssh.Executor, error), opts domains.Options, cache *domains.DNSCache, now time.Time) domains.Envelope { + if opts.LogWindow <= 0 { + opts.LogWindow = domains.DefaultLogWindow + } + env := domains.Envelope{ + MachineInterface: MachineInterface, + CheckedAt: now, + LogWindowSeconds: int64(opts.LogWindow / time.Second), + Resolvers: append([]string{}, dns.PublicResolvers...), + CloudflareRangesAsOf: dns.CloudflareRangesAsOf(), + Servers: make([]domains.Server, len(targets)), + Domains: []domains.Domain{}, + } + rows := make([][]domains.Domain, len(targets)) + sem := make(chan struct{}, domainsServerConcurrency) + var wg sync.WaitGroup + for i, t := range targets { + wg.Add(1) + go func(i int, t domainsTarget) { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + env.Servers[i] = unreachableServer(t, now, ctx.Err()) + return + } + serverCtx, cancel := context.WithTimeout(ctx, domainsServerTimeout) + defer cancel() + exec, err := connect(serverCtx, t) + if err != nil { + env.Servers[i] = unreachableServer(t, now, err) + return + } + defer exec.Close() + env.Servers[i], rows[i] = domains.CheckServer(serverCtx, exec, t.name, opts, cache) + env.Servers[i].Host = t.host + }(i, t) + } + wg.Wait() + for _, r := range rows { + env.Domains = append(env.Domains, r...) + } + return env +} + +func unreachableServer(t domainsTarget, now time.Time, err error) domains.Server { + return domains.Server{Server: t.name, Host: t.host, State: domains.ServerUnreachable, CheckedAt: now, + Error: strings.Join(strings.Fields(err.Error()), " "), Addresses: []string{}, Issues: []domains.Issue{}, + Caddy: domains.CaddyStatus{Container: "unknown", Caddyfile: "unreadable", Logs: domains.LogsUnavailable}} +} + +func printDomains(out io.Writer, env domains.Envelope) error { + if len(env.Domains) > 0 { + fmt.Fprintf(out, "%-14s %-22s %-34s %-13s %-14s %s\n", "SERVER", "APP", "HOST", "DNS", "CERT", "ROUTE") + } + for _, d := range env.Domains { + cert := d.TLS.Status + if d.TLS.Status == domains.TLSFailing && d.TLS.Failures != nil { + cert = fmt.Sprintf("failing x%d", *d.TLS.Failures) + } + route := d.Route.Kind + if d.Route.UpstreamResolves == domains.ResolvesNo || d.Route.UpstreamResolves == domains.ResolvesPartial { + route += " (upstream " + d.Route.UpstreamResolves + ")" + } + dnsCol := d.DNS.Status + if d.Skip != "" { + dnsCol += " [" + d.Skip + "]" + } + fmt.Fprintf(out, "%-14s %-22s %-34s %-13s %-14s %s\n", d.Server, d.App, d.Host, dnsCol, cert, route) + } + if len(env.Domains) == 0 { + fmt.Fprintln(out, "No domains on the checked servers") + } + failed := 0 + for _, s := range env.Servers { + switch s.State { + case domains.ServerUnreachable: + failed++ + fmt.Fprintf(out, "\n%s: could not be checked: %s\n", s.Server, s.Error) + case domains.ServerPartial: + for _, i := range s.Issues { + fmt.Fprintf(out, "\n%s: %s: %s\n", s.Server, i.Scope, i.Message) + } + } + if s.State != domains.ServerUnreachable && s.Caddy.Logs != domains.LogsOK && s.Caddy.Logs != domains.LogsNoCaddy { + fmt.Fprintf(out, "\n%s: Caddy logs are %s; certificate failures read unknown\n", s.Server, s.Caddy.Logs) + } + } + if failed > 0 { + return fmt.Errorf("%d server(s) could not be checked", failed) + } + return nil +} diff --git a/internal/cli/domains_test.go b/internal/cli/domains_test.go new file mode 100644 index 0000000..d304557 --- /dev/null +++ b/internal/cli/domains_test.go @@ -0,0 +1,129 @@ +package cli + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/useteploy/teploy/internal/dns" + "github.com/useteploy/teploy/internal/domains" + "github.com/useteploy/teploy/internal/ssh" +) + +var domainsFixtureNow = time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + +// domainsFixtureExecutor models one server with one of each finding. +func domainsFixtureExecutor() *ssh.MockExecutor { + frame := func(name, st string) string { + return fmt.Sprintf("A %s\nS 0 %s\nG absent\nL \nM 0\nR 0\n", name, base64.StdEncoding.EncodeToString([]byte(st))) + } + state := func(ingress, domain string) string { + return fmt.Sprintf(`{"schema_version":2,"deployment_type":"container","ingress_mode":%q,"domain":%q,"updated_at":"2026-11-01T00:00:00Z","current_hash":"v1","generation":2}`, ingress, domain) + } + first := fmt.Sprintf(`{"level":"info","ts":%d,"msg":"start"}`, domainsFixtureNow.Add(-30*24*time.Hour).Unix()) + failing := fmt.Sprintf(`{"level":"error","ts":%d,"logger":"tls.obtain","msg":"will retry","error":"[dash.example.com] Obtain: no solvers","elapsed":864000}`, domainsFixtureNow.Add(-time.Hour).Unix()) + m := ssh.NewMockExecutor("192.0.2.10", + ssh.MockCommand{Match: "for f in /deployments/*/state.json", Output: "blog\ndash\nfront\n"}, + ssh.MockCommand{Match: "for app in", Output: frame("blog", state("caddy", "blog.dev, www.blog.dev")) + frame("dash", state("host", "dash.example.com")) + frame("front", state("external", "front.dev"))}, + ssh.MockCommand{Match: "hostname -I", Output: "192.0.2.10 172.17.0.1"}, + ssh.MockCommand{Match: "curl -4", Output: "ip=192.0.2.10\n"}, + ssh.MockCommand{Match: "o=$(docker container inspect", Output: "TEPLOY_CADDY_RUNNING=true\nTEPLOY_LOG_FIRST " + first + "\n" + failing + "\nTEPLOY_LOG_RC=0\n"}, + ssh.MockCommand{Match: "printf '%s\\n'", Output: "0 blog-web-v1\n2 dash-web-old\n"}, + ) + m.Files["/deployments/caddy/Caddyfile"] = []byte("# TEPLOY BEGIN blog\nblog.dev, www.blog.dev {\n\treverse_proxy blog-web-v1:3000\n}\n# TEPLOY END blog\n\n# TEPLOY BEGIN dash\ndash.example.com {\n\treverse_proxy dash-web-old:3456\n}\n# TEPLOY END dash\n\nhand.dev {\n\treverse_proxy 127.0.0.1:9000\n}\n") + return m +} + +func domainsFixtureEnvelope(t *testing.T) domains.Envelope { + t.Helper() + cache := domains.NewDNSCacheWith(func(ctx context.Context, host string) dns.PublicAnswer { + switch host { + case "www.blog.dev": + return dns.PublicAnswer{Answers: []string{"104.16.0.1"}} + case "hand.dev": + return dns.PublicAnswer{NotFound: true} + } + return dns.PublicAnswer{Answers: []string{"192.0.2.10"}} + }) + targets := []domainsTarget{{name: "down", host: "198.51.100.1"}, {name: "edge", host: "192.0.2.10"}} + connect := func(ctx context.Context, t domainsTarget) (ssh.Executor, error) { + if t.name == "down" { + return nil, errors.New("dial tcp 198.51.100.1:22: i/o timeout") + } + return domainsFixtureExecutor(), nil + } + opts := domains.Options{Now: func() time.Time { return domainsFixtureNow }} + return sweepDomains(context.Background(), targets, connect, opts, cache, domainsFixtureNow) +} + +func TestContractsDomainsCheckGolden(t *testing.T) { + writeFixture(t, "domains-check-envelope/valid/sweep.json", domainsFixtureEnvelope(t)) +} + +func TestSweepDomains_AnUnreachableServerIsReportedNotHealthy(t *testing.T) { + env := domainsFixtureEnvelope(t) + var down *domains.Server + for i := range env.Servers { + if env.Servers[i].Server == "down" { + down = &env.Servers[i] + } + } + if down == nil || down.State != domains.ServerUnreachable || down.Error == "" { + t.Fatalf("server missing or healthy: %+v", down) + } + for _, d := range env.Domains { + if d.Server == "down" { + t.Errorf("rows must not be invented for an unreachable server: %+v", d) + } + } + var buf bytes.Buffer + if err := printDomains(&buf, env); err == nil { + t.Error("the human form must exit non-zero when a server could not be checked") + } + if !strings.Contains(buf.String(), "could not be checked") { + t.Errorf("output: %s", buf.String()) + } +} + +// A dead server costs its own budget, not the sweep's: the others finish. +func TestSweepDomains_SlowServerDoesNotStallOthers(t *testing.T) { + cache := domains.NewDNSCacheWith(func(ctx context.Context, host string) dns.PublicAnswer { + return dns.PublicAnswer{Answers: []string{"192.0.2.10"}} + }) + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + connect := func(ctx context.Context, t domainsTarget) (ssh.Executor, error) { + if t.name == "slow" { + <-ctx.Done() + return nil, ctx.Err() + } + return domainsFixtureExecutor(), nil + } + start := time.Now() + env := sweepDomains(ctx, []domainsTarget{{name: "slow", host: "a"}, {name: "edge", host: "b"}}, connect, domains.Options{}, cache, domainsFixtureNow) + if time.Since(start) > 5*time.Second { + t.Fatal("the sweep ignored its deadline") + } + if env.Servers[0].State != domains.ServerUnreachable { + t.Errorf("slow = %+v", env.Servers[0]) + } + if env.Servers[1].State == domains.ServerUnreachable || len(env.Domains) == 0 { + t.Errorf("the healthy server's result was lost: %+v", env.Servers[1]) + } +} + +func TestDomainsCheck_RejectsBadInput(t *testing.T) { + flags := &Flags{Host: "192.0.2.10"} + var buf bytes.Buffer + if err := runDomainsCheck(context.Background(), flags, &buf, "", "../etc; rm", time.Hour); err == nil { + t.Error("a hostile --app must be refused before anything runs") + } + if err := runDomainsCheck(context.Background(), flags, &buf, "", "", -time.Hour); err == nil { + t.Error("a negative window must be refused") + } +} diff --git a/internal/cli/machineinterface.go b/internal/cli/machineinterface.go index 682b1f2..8842d16 100644 --- a/internal/cli/machineinterface.go +++ b/internal/cli/machineinterface.go @@ -181,6 +181,11 @@ const ( // under env:/env_literal: by the app's Teploy-home config, pointing // at `teploy config set` / dash Settings. CapEnvConfigGuardV1 = "env-config-guard-v1" + // CapDomainsCheckV1: `teploy domains check [--server X] [--app A] + // [--log-window D] --json` reports DNS (public resolvers), Caddy certificate + // failures and the route per (server, app, host); read-only, runs from the + // caller over SSH with nothing new on the server. + CapDomainsCheckV1 = "domains-check-v1" ) // MachineCapabilities returns every capability token this build @@ -223,7 +228,8 @@ func MachineCapabilities() []string { CapReleasesJsonV1, CapRollbackJsonV1, CapRedeployV1, - CapEnvConfigGuardV1} + CapEnvConfigGuardV1, + CapDomainsCheckV1} sort.Strings(tokens) return tokens } diff --git a/internal/cli/machineinterface_test.go b/internal/cli/machineinterface_test.go index bde5325..5dd111d 100644 --- a/internal/cli/machineinterface_test.go +++ b/internal/cli/machineinterface_test.go @@ -86,6 +86,7 @@ func TestCapabilityTokenRegistry(t *testing.T) { "app-list-machine", "autodeploy-redeploy", "doctor-diagnostics", + "domains-check-v1", "env-set-stdin", "error-envelope", "health-modes", diff --git a/internal/cli/root.go b/internal/cli/root.go index 3dfc9d4..111689b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -133,6 +133,7 @@ func NewRootCmd(version string) *cobra.Command { root.AddCommand(newInitCmd(flags)) root.AddCommand(newValidateCmd(flags)) root.AddCommand(newDoctorCmd(flags, version)) + root.AddCommand(newDomainsCmd(flags)) root.AddCommand(newMetaCmd(flags)) root.AddCommand(newPlanCmd(flags)) root.AddCommand(newApplyCmd(flags)) diff --git a/internal/domains/domains.go b/internal/domains/domains.go new file mode 100644 index 0000000..63b77cc --- /dev/null +++ b/internal/domains/domains.go @@ -0,0 +1,971 @@ +// Package domains implements `teploy domains check`: for each (server, app, +// host) it reports what public DNS says, whether Caddy can get a certificate, +// and what routes the name, so Dash and a person at a terminal read the same +// evidence. +// +// Everything here is read-only and needs nothing on the server beyond what a +// deploy already needs (docker, a shell, read access to /deployments). The rule +// throughout: what could not be established reads "unknown", never healthy. +package domains + +import ( + "context" + "encoding/json" + "fmt" + "net" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/useteploy/teploy/internal/caddy" + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/dns" + "github.com/useteploy/teploy/internal/ssh" + "github.com/useteploy/teploy/internal/state" +) + +// Route kinds. +const ( + RouteManaged = "managed" // the owning app's managed block + RouteManual = "manual" // a hand-written block + RouteStaleManaged = "stale_managed" // managed block of an app that is not caddy-routed + RouteOrphanManaged = "orphan_managed" // managed block of an app with no state on this server + RouteNone = "none" + RouteUnknown = "unknown" +) + +// Skip reasons: why a row is not (wholly) a health signal. +const ( + SkipExternalLabel = "external_label" // ingress external, no route: the domain is a label + SkipHostLabel = "host_label" // ingress host, nothing fronts it + SkipIPLiteral = "ip_literal" + SkipWildcard = "wildcard" + SkipPrivateName = "private_name" // only resolves inside a network + SkipReservedName = "reserved_name" + SkipInvalidName = "invalid_name" +) + +// TLS outcomes. +const ( + TLSOK = "ok" + TLSFailing = "failing" + TLSUnknown = "unknown" + TLSNotApplicable = "not_applicable" +) + +// Upstream resolution outcomes, per upstream and aggregated per route. +const ( + ResolvesYes = "yes" + ResolvesNo = "no" + ResolvesPartial = "partial" // aggregate only: some upstreams do not resolve + ResolvesUnknown = "unknown" + ResolvesNotApplicable = "not_applicable" +) + +// Log coverage. +const ( + LogsOK = "ok" + LogsShort = "short" // under a day: not enough to call zero failures + LogsUnavailable = "unavailable" // could not be read + LogsNoCaddy = "no_caddy" // no caddy container here +) + +// Server states. +const ( + ServerOK = "ok" + ServerPartial = "partial" // reached, but part of the evidence is missing + ServerUnreachable = "unreachable" // nothing was established +) + +// Envelope is the `domains check --json` document (contracts/schema/ +// domains-check-envelope.schema.json). +type Envelope struct { + MachineInterface int `json:"machine_interface"` + CheckedAt time.Time `json:"checked_at"` + LogWindowSeconds int64 `json:"log_window_seconds"` + Resolvers []string `json:"resolvers"` + CloudflareRangesAsOf string `json:"cloudflare_ranges_as_of"` + Servers []Server `json:"servers"` + Domains []Domain `json:"domains"` +} + +// Issue is one piece of evidence that could not be collected. +type Issue struct { + Scope string `json:"scope"` + Message string `json:"message"` +} + +// CaddyStatus is what was learned about the server's Caddy. +type CaddyStatus struct { + // Container is present | absent | stopped | unknown. + Container string `json:"container"` + // Caddyfile is present | absent | unparseable | unreadable. + Caddyfile string `json:"caddyfile"` + Logs string `json:"logs"` + // LogCoverageSeconds is how far back the logs reach, capped at the window. + LogCoverageSeconds int64 `json:"log_coverage_seconds"` + // LogsTruncated is true when more failure lines existed than were read, so + // counts are lower bounds. + LogsTruncated bool `json:"logs_truncated,omitempty"` +} + +// Server is one server's outcome. Rows exist only for what was established. +type Server struct { + Server string `json:"server"` + Host string `json:"host"` + State string `json:"state"` + CheckedAt time.Time `json:"checked_at"` + Error string `json:"error,omitempty"` + // Addresses are every address counted as this server; PublicAddressKnown is + // false when none of them is public, so a non-matching DNS answer proves + // nothing and reads unknown. + Addresses []string `json:"addresses"` + PublicAddressKnown bool `json:"public_address_known"` + Caddy CaddyStatus `json:"caddy"` + Issues []Issue `json:"issues"` +} + +// DNSResult is the public-resolver view of a host. +type DNSResult struct { + // Status is ok | nxdomain | wrong_target | proxied | unknown | skipped. + Status string `json:"status"` + Answers []string `json:"answers"` + Detail string `json:"detail,omitempty"` +} + +// TLSResult is Caddy's certificate-obtain history for a host. Failures is null +// when it is unknown; it is never 0 on short or unavailable logs. +type TLSResult struct { + Status string `json:"status"` + Failures *int `json:"failures"` + FailingSince *time.Time `json:"failing_since,omitempty"` + LastFailureAt *time.Time `json:"last_failure_at,omitempty"` + LastSuccessAt *time.Time `json:"last_success_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +// Upstream is one proxied dial and whether it resolves in Caddy's network. +type Upstream struct { + Dial string `json:"dial"` + Resolves string `json:"resolves"` +} + +// RouteResult is what serves a host in this server's Caddy. +type RouteResult struct { + Kind string `json:"kind"` + // Owner is the app whose managed block holds the route ("" when manual). + Owner string `json:"owner,omitempty"` + // TLS is acme | internal | custom | http_only; "" with no route. + TLS string `json:"tls,omitempty"` + Upstreams []Upstream `json:"upstreams"` + // UpstreamResolves aggregates Upstreams: yes | no | partial | unknown | + // not_applicable (no proxy upstream to check). + UpstreamResolves string `json:"upstream_resolves"` +} + +// Domain is one (server, app, host) row. App is "" for a hand-written block no +// deployed app claims. +type Domain struct { + Server string `json:"server"` + App string `json:"app"` + Host string `json:"host"` + Ingress string `json:"ingress"` + Skip string `json:"skip,omitempty"` + DNS DNSResult `json:"dns"` + TLS TLSResult `json:"tls"` + Route RouteResult `json:"route"` +} + +// Options bounds one check. +type Options struct { + // LogWindow is how far back Caddy's logs are read. + LogWindow time.Duration + // App limits rows to one app ("" = all). + App string + // DNSBudget bounds the DNS phase of one server; what has not been answered + // by then reads unknown. + DNSBudget time.Duration + // Now is the clock (tests). + Now func() time.Time +} + +const ( + // DefaultLogWindow reaches the "failing for 14 days" evidence. + DefaultLogWindow = 14 * 24 * time.Hour + // minLogCoverage is how much log history makes "zero failures" believable: + // Caddy retries a failing obtain at least every 6 hours. + minLogCoverage = 24 * time.Hour + // maxLogLines caps what one server sends back. + maxLogLines = 2000 + // dnsPerResolver bounds one resolver for one name. + dnsPerResolver = 4 * time.Second + // dnsConcurrency bounds lookups across the whole sweep. + dnsConcurrency = 16 + // maxErrorChars bounds a stored Caddy error. + maxErrorChars = 300 +) + +// DNSCache shares lookups across servers and bounds their concurrency, so one +// name on two servers is asked once and a slow resolver cannot pile up. +type DNSCache struct { + lookup func(ctx context.Context, host string) dns.PublicAnswer + sem chan struct{} + mu sync.Mutex + done map[string]*dnsEntry +} + +type dnsEntry struct { + once sync.Once + ans dns.PublicAnswer +} + +// NewDNSCache returns a cache over the fixed public resolvers. +func NewDNSCache() *DNSCache { + return NewDNSCacheWith(func(ctx context.Context, host string) dns.PublicAnswer { + return dns.LookupPublic(ctx, host, dnsPerResolver) + }) +} + +// NewDNSCacheWith returns a cache over a given lookup (tests). +func NewDNSCacheWith(lookup func(ctx context.Context, host string) dns.PublicAnswer) *DNSCache { + return &DNSCache{lookup: lookup, sem: make(chan struct{}, dnsConcurrency), done: map[string]*dnsEntry{}} +} + +func (c *DNSCache) get(ctx context.Context, host string) dns.PublicAnswer { + c.mu.Lock() + e := c.done[host] + if e == nil { + e = &dnsEntry{} + c.done[host] = e + } + c.mu.Unlock() + e.once.Do(func() { + select { + case c.sem <- struct{}{}: + defer func() { <-c.sem }() + e.ans = c.lookup(ctx, host) + case <-ctx.Done(): + e.ans = dns.PublicAnswer{Err: ctx.Err()} + } + }) + return e.ans +} + +// AppInfo is the part of one app's state a check needs. +type AppInfo struct { + Name string + Domain string + Ingress string +} + +// CheckServer runs every read against one connected server. It never fails as a +// whole: what it could not read lands in Server.Issues and reads unknown. +func CheckServer(ctx context.Context, exec ssh.Executor, name string, opts Options, cache *DNSCache) (Server, []Domain) { + now := time.Now + if opts.Now != nil { + now = opts.Now + } + if opts.LogWindow <= 0 { + opts.LogWindow = DefaultLogWindow + } + if opts.DNSBudget <= 0 { + opts.DNSBudget = 30 * time.Second + } + srv := Server{Server: name, Host: exec.Host(), State: ServerOK, CheckedAt: now().UTC(), Addresses: []string{}, Issues: []Issue{}} + issue := func(scope string, err error) { + srv.Issues = append(srv.Issues, Issue{Scope: scope, Message: cleanText(err.Error(), maxErrorChars)}) + } + + apps, stateKnown := readApps(ctx, exec, &srv, issue) + + sites, routesKnown := readSites(ctx, exec, &srv, issue) + srv.Addresses, srv.PublicAddressKnown = readAddresses(ctx, exec) + + obtain := readObtainLog(ctx, exec, &srv, opts.LogWindow, now(), issue) + + rows := buildRows(name, apps, sites, routesKnown, stateKnown, opts.App) + + resolveUpstreams(ctx, exec, rows, &srv, issue) + applyTLS(rows, obtain, srv.Caddy) + + dnsCtx, cancel := context.WithTimeout(ctx, opts.DNSBudget) + defer cancel() + applyDNS(dnsCtx, rows, srv.Addresses, srv.PublicAddressKnown, cache) + + if len(srv.Issues) > 0 { + srv.State = ServerPartial + } + return srv, rows +} + +// readApps lists the deployed apps with their ingress and domain. known=false +// means the app list itself could not be read, so ownership cannot be judged. +func readApps(ctx context.Context, exec ssh.Executor, srv *Server, issue func(string, error)) (apps []AppInfo, known bool) { + out, err := exec.Run(ctx, `for f in /deployments/*/state.json /deployments/*/state; do [ -f "$f" ] && basename "$(dirname "$f")"; done | sort -u`) + if err != nil { + issue("apps", err) + return nil, false + } + var names []string + for _, n := range strings.Fields(out) { + if config.ValidateName(n) == nil { + names = append(names, n) + } + } + snaps, err := state.ReadAppSnapshots(ctx, exec, names) + if err != nil { + issue("apps", err) + return nil, false + } + known = true + for _, snap := range snaps { + if snap.StateErr != nil { + issue("app:"+snap.App, snap.StateErr) + known = false + continue + } + if snap.State == nil || snap.State.Tombstone { + continue + } + ingress := snap.State.IngressMode + if ingress == "" { + ingress = config.IngressCaddy + } + apps = append(apps, AppInfo{Name: snap.App, Domain: snap.State.Domain, Ingress: ingress}) + } + return apps, known +} + +func readSites(ctx context.Context, exec ssh.Executor, srv *Server, issue func(string, error)) (sites []caddy.RouteSite, known bool) { + content, present, err := caddy.NewClient(exec).ReadCaddyfile(ctx) + switch { + case err != nil: + srv.Caddy.Caddyfile = "unreadable" + issue("caddyfile", err) + return nil, false + case !present: + srv.Caddy.Caddyfile = "absent" + return nil, true + } + sites, err = caddy.InspectRoutes(content) + if err != nil { + srv.Caddy.Caddyfile = "unparseable" + issue("caddyfile", err) + return nil, false + } + srv.Caddy.Caddyfile = "present" + return sites, true +} + +// readAddresses collects what counts as this server: every interface address +// plus the address it reaches the internet from (a NAT'd box's public IP is on +// no interface). The probe goes to the same operator as the first resolver, and +// is best effort: without it, and with no public interface, a non-matching DNS +// answer reads unknown rather than wrong_target. +func readAddresses(ctx context.Context, exec ssh.Executor) (addrs []string, publicKnown bool) { + seen := map[string]bool{} + add := func(raw string) { + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + return + } + s := ip.String() + if !seen[s] { + seen[s] = true + addrs = append(addrs, s) + } + if isPublicIP(ip) { + publicKnown = true + } + } + add(exec.Host()) + if out, err := exec.Run(ctx, "hostname -I 2>/dev/null || true"); err == nil { + for _, f := range strings.Fields(out) { + add(f) + } + } + probe := "curl -4 -fsS --max-time 4 https://1.1.1.1/cdn-cgi/trace 2>/dev/null | grep '^ip=' || true" + if out, err := exec.Run(ctx, probe); err == nil { + for _, line := range strings.Split(out, "\n") { + if v, ok := strings.CutPrefix(strings.TrimSpace(line), "ip="); ok { + add(v) + } + } + } + sort.Strings(addrs) + return addrs, publicKnown +} + +var cgnat = func() *net.IPNet { _, n, _ := net.ParseCIDR("100.64.0.0/10"); return n }() + +func isPublicIP(ip net.IP) bool { + return ip.IsGlobalUnicast() && !ip.IsPrivate() && !cgnat.Contains(ip) +} + +// logCommand reads Caddy's certificate failures and successes, bounded in time +// and size, and frames what it found. Every value in it is a constant or a +// validated duration. +func logCommand(window time.Duration) string { + since := strconv.FormatInt(int64(window/time.Second), 10) + "s" + return `o=$(docker container inspect -f '{{.State.Running}}' caddy 2>&1) || { case "$o" in *"No such"*) echo TEPLOY_CADDY_ABSENT;; *) echo TEPLOY_CADDY_UNKNOWN;; esac; exit 0; }; ` + + `echo "TEPLOY_CADDY_RUNNING=$o"; ` + + `t=; command -v timeout >/dev/null 2>&1 && t='timeout 30'; ` + + `printf 'TEPLOY_LOG_FIRST '; docker logs caddy 2>&1 | head -n 1 | cut -c1-300; ` + + `{ $t docker logs --since ` + since + ` caddy 2>&1; echo "TEPLOY_LOG_RC=$?"; } | ` + + `grep -E '"logger":"tls\.(obtain|renew)"|^TEPLOY_LOG_RC=' | ` + + `grep -E '"level":"error"|obtained successfully|renewed successfully|^TEPLOY_LOG_RC=' | ` + + `tail -n ` + strconv.Itoa(maxLogLines) + ` | cut -c1-4000` +} + +func readObtainLog(ctx context.Context, exec ssh.Executor, srv *Server, window time.Duration, now time.Time, issue func(string, error)) map[string]*ObtainStats { + out, err := exec.Run(ctx, logCommand(window)) + if err != nil { + srv.Caddy.Container = "unknown" + srv.Caddy.Logs = LogsUnavailable + issue("caddy_logs", err) + return nil + } + parsed := parseObtainOutput(out, now, window) + srv.Caddy.Container = parsed.container + srv.Caddy.Logs = parsed.logs + srv.Caddy.LogCoverageSeconds = parsed.coverageSeconds + srv.Caddy.LogsTruncated = parsed.truncated + if parsed.problem != "" { + issue("caddy_logs", fmt.Errorf("%s", parsed.problem)) + } + return parsed.stats +} + +// ObtainStats is one identifier's certificate history in the log window. +type ObtainStats struct { + Failures int + Failing bool + FailingSince time.Time + LastFailureAt time.Time + LastSuccessAt time.Time + LastError string +} + +type obtainOutput struct { + container string + logs string + coverageSeconds int64 + truncated bool + problem string + stats map[string]*ObtainStats +} + +var bracketIdentifier = regexp.MustCompile(`^\[([^\]\s]+)\]`) + +// parseObtainOutput turns logCommand's framed output into per-identifier stats. +// Coverage is the age of the oldest retained line, so a quiet Caddy with old +// logs is not mistaken for a freshly recreated one. +func parseObtainOutput(out string, now time.Time, window time.Duration) obtainOutput { + res := obtainOutput{container: "unknown", logs: LogsUnavailable, stats: map[string]*ObtainStats{}} + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + first := strings.TrimSpace(lines[0]) + switch { + case first == "TEPLOY_CADDY_ABSENT": + res.container, res.logs = "absent", LogsNoCaddy + return res + case first == "TEPLOY_CADDY_UNKNOWN": + res.problem = "could not inspect the caddy container" + return res + case strings.HasPrefix(first, "TEPLOY_CADDY_RUNNING="): + if strings.HasSuffix(first, "=true") { + res.container = "present" + } else { + res.container = "stopped" + } + default: + res.problem = "unrecognized log output" + return res + } + + rc := -1 + logLines := 0 + haveFirst := false + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "TEPLOY_LOG_FIRST"): + raw := strings.TrimSpace(strings.TrimPrefix(line, "TEPLOY_LOG_FIRST")) + if raw == "" { + continue // an empty log: nothing retained yet + } + var head struct { + TS float64 `json:"ts"` + } + if json.Unmarshal([]byte(raw), &head) == nil && head.TS > 0 { + haveFirst = true + age := now.Sub(unixFloat(head.TS)) + if age > window { + age = window + } + if age < 0 { + age = 0 + } + res.coverageSeconds = int64(age / time.Second) + } else if !strings.HasPrefix(raw, "{") { + // A configured console log format: nothing below would match, + // and zero matches must not read as zero failures. + res.problem = "caddy's log format is not JSON, so certificate failures cannot be counted" + return res + } + case strings.HasPrefix(line, "TEPLOY_LOG_RC="): + rc, _ = strconv.Atoi(strings.TrimPrefix(line, "TEPLOY_LOG_RC=")) + case line != "": + logLines++ + recordObtainLine(res.stats, line) + } + } + if rc != 0 { + res.problem = fmt.Sprintf("reading caddy's logs did not finish (exit %d)", rc) + return res + } + res.truncated = logLines >= maxLogLines + if haveFirst && time.Duration(res.coverageSeconds)*time.Second >= minDuration(minLogCoverage, window) { + res.logs = LogsOK + } else { + res.logs = LogsShort + } + return res +} + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +func unixFloat(ts float64) time.Time { + sec := int64(ts) + return time.Unix(sec, int64((ts-float64(sec))*1e9)).UTC() +} + +func recordObtainLine(stats map[string]*ObtainStats, line string) { + var rec struct { + Level string `json:"level"` + TS float64 `json:"ts"` + Msg string `json:"msg"` + Identifier string `json:"identifier"` + Error string `json:"error"` + Elapsed float64 `json:"elapsed"` + } + if json.Unmarshal([]byte(line), &rec) != nil || rec.TS == 0 { + return + } + id := rec.Identifier + if id == "" { + if m := bracketIdentifier.FindStringSubmatch(rec.Error); m != nil { + id = m[1] + } + } + id = strings.ToLower(strings.TrimSpace(id)) + if id == "" { + return + } + s := stats[id] + if s == nil { + s = &ObtainStats{} + stats[id] = s + } + at := unixFloat(rec.TS) + if rec.Level != "error" { + if strings.Contains(rec.Msg, "successfully") { + s.Failing = false + s.FailingSince = time.Time{} + s.LastSuccessAt = at + } + return + } + // Each attempt logs one line per issuer plus one "will retry"; the retry + // line is the attempt count and carries how long the obtain has been failing. + if rec.Msg == "will retry" { + s.Failures++ + } + since := at + if rec.Elapsed > 0 { + since = at.Add(-time.Duration(rec.Elapsed * float64(time.Second))) + } + if !s.Failing || since.Before(s.FailingSince) { + s.FailingSince = since + } + s.Failing = true + s.LastFailureAt = at + if rec.Error != "" { + s.LastError = cleanText(rec.Error, maxErrorChars) + } +} + +// credentialURL strips userinfo from anything URL-shaped in a stored error. +var credentialURL = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^/\s@"]+@`) + +// cleanText makes untrusted log text safe to store and print: no control +// characters, no URL credentials, bounded length. +func cleanText(s string, max int) string { + s = credentialURL.ReplaceAllString(s, "$1") + s = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return ' ' + } + return r + }, s) + s = strings.TrimSpace(s) + if r := []rune(s); len(r) > max { + s = string(r[:max]) + "..." + } + return s +} + +var privateSuffixes = []string{".internal", ".lan", ".home.arpa", ".ts.net", ".intranet", ".corp", ".local"} + +// classifyName decides whether a host can have public DNS at all. +func classifyName(host string) string { + if len(host) > 253 { + return SkipInvalidName + } + if net.ParseIP(host) != nil { + return SkipIPLiteral + } + if strings.HasPrefix(host, "*.") { + return SkipWildcard + } + if config.ValidateDomain(host, false) != nil { + return SkipInvalidName + } + if config.IsReservedDomain(host) { + return SkipReservedName + } + if !strings.Contains(host, ".") { + return SkipPrivateName + } + for _, suffix := range privateSuffixes { + if strings.HasSuffix(host, suffix) { + return SkipPrivateName + } + } + return "" +} + +// buildRows joins apps and sites into rows: every host an app's state names, +// plus every host a site serves that no app names. +func buildRows(server string, apps []AppInfo, sites []caddy.RouteSite, routesKnown, stateKnown bool, onlyApp string) []Domain { + ingressOf := make(map[string]string, len(apps)) + for _, a := range apps { + ingressOf[a.Name] = a.Ingress + } + siteByHost := map[string]caddy.RouteSite{} + var wildcards []caddy.RouteSite + for _, site := range sites { + for _, h := range site.Hosts { + if strings.HasPrefix(h, "*.") { + wildcards = append(wildcards, site) + continue + } + if _, dup := siteByHost[h]; !dup { + siteByHost[h] = site + } + } + } + lookup := func(host string) (caddy.RouteSite, bool) { + if s, ok := siteByHost[host]; ok { + return s, true + } + if i := strings.IndexByte(host, '.'); i >= 0 { + pattern := "*" + host[i:] + for _, s := range wildcards { + for _, h := range s.Hosts { + if h == pattern { + return s, true + } + } + } + } + return caddy.RouteSite{}, false + } + + claimed := map[string]bool{} + hostSeen := map[string]bool{} + var rows []Domain + add := func(app, ingress, host string) { + key := app + "\x00" + host + if claimed[key] { + return + } + claimed[key] = true + hostSeen[host] = true + row := Domain{Server: server, App: app, Host: host, Ingress: ingress, + DNS: DNSResult{Status: dns.StatusSkipped, Answers: []string{}}, TLS: TLSResult{Status: TLSNotApplicable}} + switch site, ok := lookup(host); { + case !routesKnown: + row.Route = RouteResult{Kind: RouteUnknown, Upstreams: []Upstream{}, UpstreamResolves: ResolvesUnknown} + case !ok: + row.Route = RouteResult{Kind: RouteNone, Upstreams: []Upstream{}, UpstreamResolves: ResolvesNotApplicable} + default: + row.Route = routeFor(site, ingressOf, stateKnown) + } + row.Skip = skipFor(row, host) + rows = append(rows, row) + } + + for _, a := range apps { + for _, h := range strings.Split(a.Domain, ",") { + if h = normalizeHost(h); h != "" { + add(a.Name, a.Ingress, h) + } + } + } + // Hosts only the Caddyfile knows: a managed block keeps its owner as the + // row's app; a hand-written block has none. + for _, site := range sites { + for _, h := range site.Hosts { + h = normalizeHost(h) + if h == "" || hostSeen[h] { + continue + } + add(site.Owner, ingressOf[site.Owner], h) + } + } + if onlyApp != "" { + kept := rows[:0] + for _, r := range rows { + if r.App == onlyApp || r.Route.Owner == onlyApp { + kept = append(kept, r) + } + } + rows = kept + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Host != rows[j].Host { + return rows[i].Host < rows[j].Host + } + return rows[i].App < rows[j].App + }) + return rows +} + +// normalizeHost lowercases a name and refuses anything that cannot be one, with +// the raw text kept out of rows that a terminal or page will print. +func normalizeHost(raw string) string { + h := strings.ToLower(strings.TrimSpace(raw)) + h = strings.TrimSuffix(h, ".") + return cleanText(h, 300) +} + +func routeFor(site caddy.RouteSite, ingressOf map[string]string, stateKnown bool) RouteResult { + r := RouteResult{Owner: site.Owner, TLS: site.TLS, Upstreams: []Upstream{}, UpstreamResolves: ResolvesNotApplicable} + for _, dial := range site.Upstreams { + r.Upstreams = append(r.Upstreams, Upstream{Dial: cleanText(dial, 300), Resolves: ResolvesUnknown}) + } + if len(r.Upstreams) > 0 { + r.UpstreamResolves = ResolvesUnknown + } + switch { + case site.Owner == "": + r.Kind = RouteManual + default: + ingress, deployed := ingressOf[site.Owner] + switch { + case !deployed && stateKnown: + r.Kind = RouteOrphanManaged + case !deployed: + r.Kind = RouteUnknown + case ingress != config.IngressCaddy && ingress != "": + r.Kind = RouteStaleManaged + default: + r.Kind = RouteManaged + } + } + return r +} + +func skipFor(row Domain, host string) string { + if reason := classifyName(host); reason != "" { + return reason + } + if row.Route.Kind == RouteNone { + switch row.Ingress { + case config.IngressExternal: + return SkipExternalLabel + case config.IngressHost: + return SkipHostLabel + } + } + return "" +} + +var upstreamHostRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// upstreamHost returns the name to resolve for a dial, or why not: an IP literal +// has nothing to resolve; a placeholder or odd token cannot be checked. Names +// are validated before they reach a shell, and quoted there. +func upstreamHost(dial string) (name, verdict string) { + host := dial + if h, _, err := net.SplitHostPort(dial); err == nil { + host = h + } + host = strings.Trim(host, "[]") + if net.ParseIP(host) != nil { + return "", ResolvesNotApplicable + } + if !upstreamHostRE.MatchString(host) { + return "", ResolvesUnknown + } + return host, "" +} + +// resolveUpstreams asks Caddy's own network, in one docker exec, whether each +// upstream name resolves: a name teploy no longer has a container for fails +// here even though the route still reads as configured. +func resolveUpstreams(ctx context.Context, exec ssh.Executor, rows []Domain, srv *Server, issue func(string, error)) { + wanted := map[string]bool{} + for i := range rows { + for _, u := range rows[i].Route.Upstreams { + if name, _ := upstreamHost(u.Dial); name != "" { + wanted[name] = true + } + } + } + answers := map[string]string{} + if len(wanted) > 0 && srv.Caddy.Container == "present" { + names := make([]string, 0, len(wanted)) + for n := range wanted { + names = append(names, ssh.ShellQuote(n)) + } + sort.Strings(names) + cmd := "printf '%s\\n' " + strings.Join(names, " ") + ` | docker exec -i caddy sh -c 'while read -r h; do getent hosts "$h" >/dev/null 2>&1; echo "$? $h"; done'` + out, err := exec.Run(ctx, cmd) + if err != nil { + issue("upstreams", err) + } + for _, line := range strings.Split(out, "\n") { + rc, name, ok := strings.Cut(strings.TrimSpace(line), " ") + if !ok || !wanted[name] { + continue + } + switch rc { + case "0": + answers[name] = ResolvesYes + case "2": + answers[name] = ResolvesNo + } + } + } + for i := range rows { + r := &rows[i].Route + yes, no, unknown := 0, 0, 0 + for j := range r.Upstreams { + name, verdict := upstreamHost(r.Upstreams[j].Dial) + if name != "" { + if verdict = answers[name]; verdict == "" { + verdict = ResolvesUnknown + } + } + r.Upstreams[j].Resolves = verdict + switch verdict { + case ResolvesYes, ResolvesNotApplicable: + yes++ + case ResolvesNo: + no++ + default: + unknown++ + } + } + switch { + case len(r.Upstreams) == 0: + case no > 0 && yes == 0 && unknown == 0: + r.UpstreamResolves = ResolvesNo + case no > 0: + r.UpstreamResolves = ResolvesPartial + case unknown > 0: + r.UpstreamResolves = ResolvesUnknown + default: + r.UpstreamResolves = ResolvesYes + } + } +} + +// applyTLS fills certificate evidence on rows Caddy would obtain for. Unknown +// when the logs cannot back a claim; zero failures only on logs long enough. +func applyTLS(rows []Domain, stats map[string]*ObtainStats, status CaddyStatus) { + for i := range rows { + row := &rows[i] + if row.Route.TLS != caddy.SiteTLSACME || row.Skip == SkipIPLiteral || row.Skip == SkipPrivateName { + continue + } + s := stats[row.Host] + if s == nil { + if status.Logs == LogsOK { + zero := 0 + row.TLS = TLSResult{Status: TLSOK, Failures: &zero} + } else { + row.TLS = TLSResult{Status: TLSUnknown} + } + continue + } + failures := s.Failures + row.TLS = TLSResult{Failures: &failures, LastError: s.LastError} + if !s.LastFailureAt.IsZero() { + at := s.LastFailureAt + row.TLS.LastFailureAt = &at + } + if !s.LastSuccessAt.IsZero() { + at := s.LastSuccessAt + row.TLS.LastSuccessAt = &at + } + switch { + case s.Failing: + row.TLS.Status = TLSFailing + since := s.FailingSince + row.TLS.FailingSince = &since + case status.Logs == LogsOK || !s.LastSuccessAt.IsZero(): + row.TLS.Status = TLSOK + default: + row.TLS.Status = TLSUnknown + } + } +} + +// applyDNS runs the public lookups concurrently, each row bounded by the DNS +// budget and the shared limiter. +func applyDNS(ctx context.Context, rows []Domain, addrs []string, publicKnown bool, cache *DNSCache) { + var wg sync.WaitGroup + for i := range rows { + row := &rows[i] + switch row.Skip { + case SkipReservedName: + row.DNS.Detail = "reserved name: never publicly resolvable" + continue + case SkipPrivateName: + row.DNS.Detail = "private name: only resolves inside a network" + continue + case SkipIPLiteral, SkipWildcard, SkipInvalidName, SkipExternalLabel: + continue + } + wg.Add(1) + go func(row *Domain) { + defer wg.Done() + ans := cache.get(ctx, row.Host) + row.DNS.Answers = append([]string{}, ans.Answers...) + row.DNS.Status = dns.Classify(ans, addrs, publicKnown) + switch row.DNS.Status { + case dns.StatusNXDomain: + row.DNS.Detail = "no address record at the public resolvers" + case dns.StatusProxied: + row.DNS.Detail = "answers from Cloudflare: origin cannot be read from DNS" + case dns.StatusUnknown: + if ans.Err != nil { + row.DNS.Detail = "public resolvers did not answer" + } else { + row.DNS.Detail = "this server's public address could not be determined" + } + } + }(row) + } + wg.Wait() +} diff --git a/internal/domains/domains_test.go b/internal/domains/domains_test.go new file mode 100644 index 0000000..fc19064 --- /dev/null +++ b/internal/domains/domains_test.go @@ -0,0 +1,436 @@ +package domains + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/useteploy/teploy/internal/dns" + "github.com/useteploy/teploy/internal/ssh" +) + +var checkNow = time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + +func ts(t time.Time) string { return fmt.Sprintf("%.3f", float64(t.UnixMilli())/1000) } + +func TestParseObtainOutput(t *testing.T) { + window := 14 * 24 * time.Hour + oldLine := fmt.Sprintf(`{"level":"info","ts":%s,"msg":"start"}`, ts(checkNow.Add(-30*24*time.Hour))) + retry := func(at time.Time, id string, elapsed float64) string { + return fmt.Sprintf(`{"level":"error","ts":%s,"logger":"tls.obtain","msg":"will retry","error":"[%s] Obtain: acme failed","attempt":3,"elapsed":%f}`, ts(at), id, elapsed) + } + issuer := func(at time.Time, id string) string { + return fmt.Sprintf(`{"level":"error","ts":%s,"logger":"tls.obtain","msg":"could not get certificate from issuer","identifier":"%s","error":"[%s] acme failed"}`, ts(at), id, id) + } + frame := func(first string, lines ...string) string { + return "TEPLOY_CADDY_RUNNING=true\nTEPLOY_LOG_FIRST " + first + "\n" + strings.Join(lines, "\n") + "\nTEPLOY_LOG_RC=0\n" + } + + t.Run("failing identifier carries how long it has failed", func(t *testing.T) { + at := checkNow.Add(-time.Hour) + res := parseObtainOutput(frame(oldLine, issuer(at, "bad.dev"), retry(at, "bad.dev", 3*86400)), checkNow, window) + s := res.stats["bad.dev"] + if s == nil || !s.Failing || s.Failures != 1 { + t.Fatalf("stats = %+v", s) + } + want := at.Add(-3 * 24 * time.Hour) + if s.FailingSince.Sub(want).Abs() > time.Second { + t.Errorf("failing since %v, want about %v", s.FailingSince, want) + } + if res.logs != LogsOK || res.coverageSeconds != int64(window/time.Second) { + t.Errorf("logs=%s coverage=%d", res.logs, res.coverageSeconds) + } + }) + t.Run("a later success clears the streak", func(t *testing.T) { + at := checkNow.Add(-2 * time.Hour) + good := fmt.Sprintf(`{"level":"info","ts":%s,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"ok.dev"}`, ts(at.Add(time.Hour))) + res := parseObtainOutput(frame(oldLine, retry(at, "ok.dev", 60), good), checkNow, window) + s := res.stats["ok.dev"] + if s.Failing || s.Failures != 1 || s.LastSuccessAt.IsZero() || !s.FailingSince.IsZero() { + t.Errorf("stats = %+v", s) + } + }) + t.Run("short logs", func(t *testing.T) { + recent := fmt.Sprintf(`{"level":"info","ts":%s,"msg":"start"}`, ts(checkNow.Add(-10*time.Minute))) + if res := parseObtainOutput(frame(recent), checkNow, window); res.logs != LogsShort { + t.Errorf("logs = %s, want short", res.logs) + } + }) + t.Run("empty logs are short, not clean", func(t *testing.T) { + if res := parseObtainOutput(frame(""), checkNow, window); res.logs != LogsShort { + t.Errorf("logs = %s", res.logs) + } + }) + t.Run("an interrupted read is unavailable", func(t *testing.T) { + out := "TEPLOY_CADDY_RUNNING=true\nTEPLOY_LOG_FIRST " + oldLine + "\nTEPLOY_LOG_RC=124\n" + res := parseObtainOutput(out, checkNow, window) + if res.logs != LogsUnavailable || res.problem == "" { + t.Errorf("logs=%s problem=%q", res.logs, res.problem) + } + }) + t.Run("console log format cannot read as zero failures", func(t *testing.T) { + res := parseObtainOutput(frame("2026/11/04 INFO starting"), checkNow, window) + if res.logs != LogsUnavailable { + t.Errorf("logs = %s", res.logs) + } + }) + t.Run("no caddy", func(t *testing.T) { + res := parseObtainOutput("TEPLOY_CADDY_ABSENT\n", checkNow, window) + if res.container != "absent" || res.logs != LogsNoCaddy { + t.Errorf("%+v", res) + } + }) + t.Run("stopped caddy", func(t *testing.T) { + res := parseObtainOutput("TEPLOY_CADDY_RUNNING=false\nTEPLOY_LOG_FIRST "+oldLine+"\nTEPLOY_LOG_RC=0\n", checkNow, window) + if res.container != "stopped" || res.logs != LogsOK { + t.Errorf("%+v", res) + } + }) + t.Run("hostile log text is cleaned", func(t *testing.T) { + line := `{"level":"error","ts":` + ts(checkNow) + `,"msg":"will retry","identifier":"x.dev","error":"failed at https://user:hunter2@acme.dev/x \u001b[31mred\u0007"}` + res := parseObtainOutput(frame(oldLine, line), checkNow, window) + got := res.stats["x.dev"].LastError + if strings.Contains(got, "hunter2") || strings.ContainsAny(got, "\x1b\x07") { + t.Errorf("stored error kept what it must not: %q", got) + } + }) + t.Run("truncation is surfaced", func(t *testing.T) { + lines := make([]string, maxLogLines) + for i := range lines { + lines[i] = retry(checkNow, "x.dev", 1) + } + if res := parseObtainOutput(frame(oldLine, lines...), checkNow, window); !res.truncated { + t.Error("truncated flag not set") + } + }) +} + +// fleetMock answers the commands one check runs. The Caddyfile and state files +// go through the mock's framed reads. +func fleetMock(caddyfile, logs string, apps map[string]string, resolves string, extra ...ssh.MockCommand) *ssh.MockExecutor { + var names []string + for name := range apps { + names = append(names, name) + } + cmds := append(extra, + ssh.MockCommand{Match: "for f in /deployments/*/state.json", Output: strings.Join(names, "\n")}, + ssh.MockCommand{Match: "for app in", Output: snapshotOutput(apps)}, + ssh.MockCommand{Match: "hostname -I", Output: "192.0.2.10 100.64.0.5 172.17.0.1"}, + ssh.MockCommand{Match: "curl -4", Output: ""}, + ssh.MockCommand{Match: "o=$(docker container inspect", Output: logs}, + ssh.MockCommand{Match: "printf '%s\\n'", Output: resolves}, + ) + m := ssh.NewMockExecutor("100.64.0.5", cmds...) + m.Files["/deployments/caddy/Caddyfile"] = []byte(caddyfile) + return m +} + +// snapshotOutput renders the framing state.ReadAppSnapshots parses. +func snapshotOutput(apps map[string]string) string { + var b strings.Builder + for name, st := range apps { + fmt.Fprintf(&b, "A %s\nS 0 %s\nG absent\nL \nM 0\nR 0\n", name, base64.StdEncoding.EncodeToString([]byte(st))) + } + return b.String() +} + +func appState(ingress, domain string) string { + return fmt.Sprintf(`{"schema_version":2,"deployment_type":"container","ingress_mode":%q,"domain":%q,"updated_at":"2026-11-01T00:00:00Z","current_hash":"v1","generation":2}`, ingress, domain) +} + +func logsOK(lines ...string) string { + first := fmt.Sprintf(`{"level":"info","ts":%s,"msg":"start"}`, ts(checkNow.Add(-30*24*time.Hour))) + return "TEPLOY_CADDY_RUNNING=true\nTEPLOY_LOG_FIRST " + first + "\n" + strings.Join(append(lines, "TEPLOY_LOG_RC=0"), "\n") + "\n" +} + +func staticDNS(answers map[string]dns.PublicAnswer) *DNSCache { + return NewDNSCacheWith(func(ctx context.Context, host string) dns.PublicAnswer { + if a, ok := answers[host]; ok { + return a + } + return dns.PublicAnswer{Answers: []string{"192.0.2.10"}} + }) +} + +func runCheck(t *testing.T, m *ssh.MockExecutor, cache *DNSCache, opts Options) (Server, map[string]Domain) { + t.Helper() + opts.Now = func() time.Time { return checkNow } + srv, rows := CheckServer(context.Background(), m, "s1", opts, cache) + byHost := map[string]Domain{} + for _, r := range rows { + byHost[r.Host] = r + } + return srv, byHost +} + +func TestCheckServer_JoinsTheLayers(t *testing.T) { + caddyfile := `# TEPLOY BEGIN blog +blog.dev, www.blog.dev { + reverse_proxy blog-web-v1:3000 +} +# TEPLOY END blog + +# TEPLOY BEGIN dash +dash.example.com { + reverse_proxy dash-web-old:3456 +} +# TEPLOY END dash + +# TEPLOY BEGIN gone +gone.dev { + reverse_proxy gone-web-v1:80 +} +# TEPLOY END gone + +hand.dev { + reverse_proxy 127.0.0.1:9000 +} + +http://plain.dev { + reverse_proxy blog-web-v1:3000 +} +` + failing := fmt.Sprintf(`{"level":"error","ts":%s,"logger":"tls.obtain","msg":"will retry","error":"[dash.example.com] Obtain: no such host","elapsed":864000}`, ts(checkNow.Add(-time.Hour))) + m := fleetMock(caddyfile, logsOK(failing), map[string]string{ + "blog": appState("caddy", "blog.dev, www.blog.dev"), + "dash": appState("host", "dash.example.com"), + "front": appState("external", "front.dev"), + "label": appState("host", "label.dev"), + }, "0 blog-web-v1\n2 dash-web-old\n2 gone-web-v1\n") + cache := staticDNS(map[string]dns.PublicAnswer{ + "www.blog.dev": {Answers: []string{"104.16.0.1"}}, + "hand.dev": {NotFound: true}, + "gone.dev": {Answers: []string{"198.51.100.9"}}, + "plain.dev": {Err: fmt.Errorf("timeout")}, + }) + srv, rows := runCheck(t, m, cache, Options{}) + + if srv.State != ServerOK || !srv.PublicAddressKnown || srv.Caddy.Logs != LogsOK { + t.Fatalf("server = %+v", srv) + } + check := func(host string, f func(Domain)) { + t.Helper() + r, ok := rows[host] + if !ok { + t.Fatalf("no row for %s", host) + } + f(r) + } + check("blog.dev", func(r Domain) { + if r.App != "blog" || r.DNS.Status != dns.StatusOK || r.Route.Kind != RouteManaged || r.Route.UpstreamResolves != ResolvesYes || + r.TLS.Status != TLSOK || r.TLS.Failures == nil || *r.TLS.Failures != 0 { + t.Errorf("%+v", r) + } + }) + check("www.blog.dev", func(r Domain) { + if r.DNS.Status != dns.StatusProxied { + t.Errorf("%+v", r.DNS) + } + }) + check("dash.example.com", func(r Domain) { + if r.Route.Kind != RouteStaleManaged || r.Route.Owner != "dash" || r.Route.UpstreamResolves != ResolvesNo { + t.Errorf("route %+v", r.Route) + } + if r.Skip != SkipReservedName || r.DNS.Status != dns.StatusSkipped { + t.Errorf("a reserved name is not looked up: skip=%s dns=%+v", r.Skip, r.DNS) + } + if r.TLS.Status != TLSFailing || r.TLS.FailingSince == nil || *r.TLS.Failures != 1 { + t.Errorf("tls %+v", r.TLS) + } + }) + check("gone.dev", func(r Domain) { + if r.Route.Kind != RouteOrphanManaged || r.DNS.Status != dns.StatusWrongTarget { + t.Errorf("%+v", r) + } + }) + check("hand.dev", func(r Domain) { + if r.App != "" || r.Route.Kind != RouteManual || r.DNS.Status != dns.StatusNXDomain || r.Route.UpstreamResolves != ResolvesYes { + t.Errorf("%+v", r) + } + }) + check("plain.dev", func(r Domain) { + if r.TLS.Status != TLSNotApplicable || r.DNS.Status != dns.StatusUnknown || r.Route.TLS != "http_only" { + t.Errorf("%+v", r) + } + }) + check("front.dev", func(r Domain) { + if r.Skip != SkipExternalLabel || r.DNS.Status != dns.StatusSkipped || r.Route.Kind != RouteNone { + t.Errorf("%+v", r) + } + }) + check("label.dev", func(r Domain) { + if r.Skip != SkipHostLabel || r.Route.Kind != RouteNone || r.Ingress != "host" { + t.Errorf("%+v", r) + } + }) +} + +func TestCheckServer_NeverReadsHealthyWithoutEvidence(t *testing.T) { + caddyfile := "blog.dev {\n\treverse_proxy blog-web-v1:3000\n}\n" + t.Run("logs unreadable: certificate unknown, not zero", func(t *testing.T) { + m := fleetMock(caddyfile, "", map[string]string{}, "0 blog-web-v1\n", + ssh.MockCommand{Match: "o=$(docker container inspect", Err: fmt.Errorf("connection reset")}) + srv, rows := runCheck(t, m, staticDNS(nil), Options{}) + r := rows["blog.dev"] + if r.TLS.Status != TLSUnknown || r.TLS.Failures != nil { + t.Errorf("tls = %+v", r.TLS) + } + if srv.State != ServerPartial || len(srv.Issues) == 0 { + t.Errorf("server = %+v", srv) + } + }) + t.Run("short logs: certificate unknown, not zero", func(t *testing.T) { + recent := fmt.Sprintf(`{"level":"info","ts":%s,"msg":"start"}`, ts(checkNow.Add(-5*time.Minute))) + logs := "TEPLOY_CADDY_RUNNING=true\nTEPLOY_LOG_FIRST " + recent + "\nTEPLOY_LOG_RC=0\n" + _, rows := runCheck(t, fleetMock(caddyfile, logs, map[string]string{}, "0 blog-web-v1\n"), staticDNS(nil), Options{}) + if r := rows["blog.dev"]; r.TLS.Status != TLSUnknown || r.TLS.Failures != nil { + t.Errorf("tls = %+v", r.TLS) + } + }) + t.Run("short logs with a failure still report it", func(t *testing.T) { + recent := fmt.Sprintf(`{"level":"info","ts":%s,"msg":"start"}`, ts(checkNow.Add(-5*time.Minute))) + bad := fmt.Sprintf(`{"level":"error","ts":%s,"msg":"will retry","error":"[blog.dev] Obtain: x"}`, ts(checkNow)) + logs := "TEPLOY_CADDY_RUNNING=true\nTEPLOY_LOG_FIRST " + recent + "\n" + bad + "\nTEPLOY_LOG_RC=0\n" + _, rows := runCheck(t, fleetMock(caddyfile, logs, map[string]string{}, "0 blog-web-v1\n"), staticDNS(nil), Options{}) + if r := rows["blog.dev"]; r.TLS.Status != TLSFailing { + t.Errorf("tls = %+v", r.TLS) + } + }) + t.Run("upstream check failed: unknown, not resolving", func(t *testing.T) { + m := fleetMock(caddyfile, logsOK(), map[string]string{}, "", ssh.MockCommand{Match: "printf '%s\\n'", Err: fmt.Errorf("exec failed")}) + srv, rows := runCheck(t, m, staticDNS(nil), Options{}) + if r := rows["blog.dev"]; r.Route.UpstreamResolves != ResolvesUnknown { + t.Errorf("route = %+v", r.Route) + } + if srv.State != ServerPartial { + t.Errorf("state = %s", srv.State) + } + }) + t.Run("unparseable Caddyfile: route unknown, app rows stay", func(t *testing.T) { + m := fleetMock("import sites/*\n", logsOK(), map[string]string{"blog": appState("caddy", "blog.dev")}, "") + srv, rows := runCheck(t, m, staticDNS(nil), Options{}) + if r := rows["blog.dev"]; r.Route.Kind != RouteUnknown || r.Route.UpstreamResolves != ResolvesUnknown { + t.Errorf("route = %+v", r.Route) + } + if srv.Caddy.Caddyfile != "unparseable" || srv.State != ServerPartial { + t.Errorf("server = %+v", srv) + } + }) + t.Run("no public address known: a foreign answer is unknown", func(t *testing.T) { + m := ssh.NewMockExecutor("100.64.0.5", + ssh.MockCommand{Match: "for f in /deployments/*/state.json", Output: "blog"}, + ssh.MockCommand{Match: "for app in", Output: snapshotOutput(map[string]string{"blog": appState("caddy", "blog.dev")})}, + ssh.MockCommand{Match: "hostname -I", Output: "192.168.1.5"}, + ssh.MockCommand{Match: "curl -4", Output: ""}, + ssh.MockCommand{Match: "o=$(docker container inspect", Output: logsOK()}, + ) + srv, rows := runCheck(t, m, staticDNS(map[string]dns.PublicAnswer{"blog.dev": {Answers: []string{"198.51.100.9"}}}), Options{}) + if srv.PublicAddressKnown { + t.Error("no public address here") + } + if r := rows["blog.dev"]; r.DNS.Status != dns.StatusUnknown { + t.Errorf("dns = %+v", r.DNS) + } + }) +} + +// Hostile strings from a state file or a Caddyfile reach a resolver or a shell +// only after validation, and always quoted. +func TestCheckServer_HostileNames(t *testing.T) { + evil := "$(touch /tmp/pwned).dev" + caddyfile := "# TEPLOY BEGIN blog\nblog.dev {\n\treverse_proxy 'x;rm -rf /':80\n\treverse_proxy {$UPSTREAM}:80\n\treverse_proxy good-name:80\n}\n# TEPLOY END blog\n" + m := fleetMock(caddyfile, logsOK(), map[string]string{"blog": appState("caddy", "blog.dev, "+evil)}, "0 good-name\n") + looked := map[string]bool{} + cache := NewDNSCacheWith(func(ctx context.Context, host string) dns.PublicAnswer { + looked[host] = true + return dns.PublicAnswer{Answers: []string{"192.0.2.10"}} + }) + _, rows := runCheck(t, m, cache, Options{}) + if looked[evil] { + t.Error("an invalid name went to the resolver") + } + if r := rows[evil]; r.Skip != SkipInvalidName || r.DNS.Status != dns.StatusSkipped { + t.Errorf("row = %+v", r) + } + for _, c := range m.Calls { + if strings.Contains(c, "pwned") || strings.Contains(c, "rm -rf") || strings.Contains(c, "UPSTREAM") { + t.Errorf("a hostile string reached a command: %s", c) + } + } + if r := rows["blog.dev"]; r.Route.UpstreamResolves != ResolvesUnknown { + t.Errorf("an uncheckable upstream cannot read as resolving: %+v", r.Route) + } + var quoted bool + for _, c := range m.Calls { + if strings.HasPrefix(c, "printf '%s\\n' ") && strings.Contains(c, "'good-name'") { + quoted = true + } + } + if !quoted { + t.Error("upstream names must be shell-quoted") + } +} + +func TestCheckServer_AppFilterKeepsOwnedRows(t *testing.T) { + caddyfile := "# TEPLOY BEGIN blog\nblog.dev {\n\treverse_proxy a:1\n}\n# TEPLOY END blog\n\nhand.dev {\n\trespond ok\n}\n" + m := fleetMock(caddyfile, logsOK(), map[string]string{"blog": appState("caddy", "blog.dev"), "other": appState("caddy", "other.dev")}, "0 a\n") + _, rows := runCheck(t, m, staticDNS(nil), Options{App: "blog"}) + if len(rows) != 1 || rows["blog.dev"].App != "blog" { + t.Errorf("rows = %+v", rows) + } +} + +// One name on several servers is asked once; a stuck lookup ends with the DNS +// budget and reads unknown. +func TestDNSCache_SharesAndBounds(t *testing.T) { + var calls atomic.Int32 + cache := NewDNSCacheWith(func(ctx context.Context, host string) dns.PublicAnswer { + calls.Add(1) + return dns.PublicAnswer{Answers: []string{"192.0.2.10"}} + }) + for i := 0; i < 3; i++ { + cache.get(context.Background(), "a.dev") + } + if calls.Load() != 1 { + t.Errorf("asked %d times", calls.Load()) + } + + stuck := NewDNSCacheWith(func(ctx context.Context, host string) dns.PublicAnswer { + <-ctx.Done() + return dns.PublicAnswer{Err: ctx.Err()} + }) + m := fleetMock("blog.dev {\n\trespond ok\n}\n", logsOK(), map[string]string{}, "") + start := time.Now() + _, rows := runCheck(t, m, stuck, Options{DNSBudget: 50 * time.Millisecond}) + if time.Since(start) > 5*time.Second { + t.Fatal("a stuck resolver stalled the check") + } + if rows["blog.dev"].DNS.Status != dns.StatusUnknown { + t.Errorf("dns = %+v", rows["blog.dev"].DNS) + } +} + +func TestLogCommand_IsBoundedAndConstant(t *testing.T) { + cmd := logCommand(14 * 24 * time.Hour) + for _, want := range []string{"--since 1209600s", "timeout 30", "tail -n 2000", "docker container inspect", "TEPLOY_LOG_RC"} { + if !strings.Contains(cmd, want) { + t.Errorf("command lacks %q: %s", want, cmd) + } + } +} + +// The envelope's null is the contract: never 0 when the count is unknown. +func TestTLSResult_UnknownFailuresEncodeAsNull(t *testing.T) { + raw, err := json.Marshal(TLSResult{Status: TLSUnknown}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"failures":null`) { + t.Errorf("got %s", raw) + } +} From 1ee806ac416e8aef39727de07e36f68bb2de3078 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:18:15 -0700 Subject: [PATCH 5/6] fix(domains): treat unrouted placeholders as labels and live previews as managed The live fleet run read dash.example.com and observe.example.com as missing routes: the reserved-name check ran before the host-ingress label. A preview's route has no app state, so a live one read as orphaned; it is now orphaned only once its containers stop resolving. Repeated dials of one upstream list once. Co-Authored-By: Claude Opus 5.5 --- internal/caddy/inspect.go | 2 ++ internal/domains/domains.go | 21 ++++++++++++++------ internal/domains/domains_test.go | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/internal/caddy/inspect.go b/internal/caddy/inspect.go index 5fc9438..8e5cab0 100644 --- a/internal/caddy/inspect.go +++ b/internal/caddy/inspect.go @@ -6,6 +6,7 @@ package caddy import ( "context" "net" + "slices" "sort" "strings" ) @@ -181,6 +182,7 @@ func inspectSite(owner string, b SiteBlock) RouteSite { } } sort.Strings(site.Upstreams) + site.Upstreams = slices.Compact(site.Upstreams) return site } diff --git a/internal/domains/domains.go b/internal/domains/domains.go index 63b77cc..8ba5b99 100644 --- a/internal/domains/domains.go +++ b/internal/domains/domains.go @@ -784,10 +784,9 @@ func routeFor(site caddy.RouteSite, ingressOf map[string]string, stateKnown bool return r } +// skipFor checks the label case first: an unrouted host or external domain is +// a label whatever its name looks like, including a reserved placeholder. func skipFor(row Domain, host string) string { - if reason := classifyName(host); reason != "" { - return reason - } if row.Route.Kind == RouteNone { switch row.Ingress { case config.IngressExternal: @@ -796,7 +795,7 @@ func skipFor(row Domain, host string) string { return SkipHostLabel } } - return "" + return classifyName(host) } var upstreamHostRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) @@ -876,6 +875,11 @@ func resolveUpstreams(ctx context.Context, exec ssh.Executor, rows []Domain, srv unknown++ } } + // Preview routes have no app state of their own; a preview whose + // containers still resolve is live, and only a dead one is left behind. + if r.Kind == RouteOrphanManaged && strings.Contains(r.Owner, "-preview-") && len(r.Upstreams) > 0 && yes == len(r.Upstreams) { + r.Kind = RouteManaged + } switch { case len(r.Upstreams) == 0: case no > 0 && yes == 0 && unknown == 0: @@ -937,14 +941,19 @@ func applyDNS(ctx context.Context, rows []Domain, addrs []string, publicKnown bo var wg sync.WaitGroup for i := range rows { row := &rows[i] - switch row.Skip { + if row.Skip == SkipExternalLabel { + continue + } + // Name rules apply to a label too: a host-ingress placeholder has no + // public record to look for. + switch classifyName(row.Host) { case SkipReservedName: row.DNS.Detail = "reserved name: never publicly resolvable" continue case SkipPrivateName: row.DNS.Detail = "private name: only resolves inside a network" continue - case SkipIPLiteral, SkipWildcard, SkipInvalidName, SkipExternalLabel: + case SkipIPLiteral, SkipWildcard, SkipInvalidName: continue } wg.Add(1) diff --git a/internal/domains/domains_test.go b/internal/domains/domains_test.go index fc19064..3f13b8b 100644 --- a/internal/domains/domains_test.go +++ b/internal/domains/domains_test.go @@ -434,3 +434,36 @@ func TestTLSResult_UnknownFailuresEncodeAsNull(t *testing.T) { t.Errorf("got %s", raw) } } + +// A placeholder on an unrouted host-ingress app is a label, not a missing route, +// and nothing is looked up for it. +func TestCheckServer_ReservedHostIngressNameIsALabel(t *testing.T) { + m := fleetMock("hand.dev {\n\trespond ok\n}\n", logsOK(), map[string]string{"dash": appState("host", "dash.example.com")}, "") + looked := map[string]bool{} + cache := NewDNSCacheWith(func(ctx context.Context, host string) dns.PublicAnswer { + looked[host] = true + return dns.PublicAnswer{Answers: []string{"192.0.2.10"}} + }) + _, rows := runCheck(t, m, cache, Options{}) + r := rows["dash.example.com"] + if r.Skip != SkipHostLabel || r.Route.Kind != RouteNone || r.DNS.Status != dns.StatusSkipped { + t.Errorf("row = %+v", r) + } + if looked["dash.example.com"] { + t.Error("a reserved name was looked up") + } +} + +// A preview has no app state: live while its containers resolve, left behind +// once they do not. Repeated dials of one upstream are listed once. +func TestCheckServer_PreviewRoutes(t *testing.T) { + caddyfile := "# TEPLOY BEGIN app-preview-p-live\nlive.dev {\n\treverse_proxy a:80\n\treverse_proxy a:80\n}\n# TEPLOY END app-preview-p-live\n\n# TEPLOY BEGIN app-preview-p-dead\ndead.dev {\n\treverse_proxy b:80\n}\n# TEPLOY END app-preview-p-dead\n" + m := fleetMock(caddyfile, logsOK(), map[string]string{}, "0 a\n2 b\n") + _, rows := runCheck(t, m, staticDNS(nil), Options{}) + if r := rows["live.dev"]; r.Route.Kind != RouteManaged || len(r.Route.Upstreams) != 1 { + t.Errorf("live preview = %+v", r.Route) + } + if r := rows["dead.dev"]; r.Route.Kind != RouteOrphanManaged || r.Route.UpstreamResolves != ResolvesNo { + t.Errorf("dead preview = %+v", r.Route) + } +} From a1a975791674f787175e817a40478eb30bf6b2b4 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:51:05 -0700 Subject: [PATCH 6/6] fix(config): move the reserved-name rule to deploy admission It ran in validate(), on the base file before overlays merged, so a placeholder replaced by teploy.prod.yml failed to load, and rollback, logs and every folder command stopped at the same load mid-incident; Compose-only folders too. The rule is now ValidateDeployDomain, applied after the merge by deploy, multi-server, server-config, autodeploy, plan and validate. Read and recovery commands never refuse, and redeploy skips it. Custom certificates and localhost names are exempt, since neither goes to ACME. First-deploy DNS skips tls.internal, tls.http_only and localhost names. Also from review: tls_http_only is a *bool, so the record can turn the flag off (a folder rollback to an HTTPS release wrote http:// and dropped HTTPS); a failed stale-route removal on rollback is logged as degraded; the plan warns when the block carries the webhook route or proxies beyond this app's containers. domains check: public address known per family, so a correct A answer no longer reads wrong_target on a NAT'd box with public IPv6; a failing wildcard obtain covers its hosts; an empty Caddy log no longer glues markers together; a matched line that cannot be attributed reads unknown rather than zero. Co-Authored-By: Claude Opus 5.5 --- AUDIT_OPEN.md | 16 +++- CHANGELOG.md | 16 ++-- README.md | 5 +- contracts/MANIFEST.md | 2 +- .../domains-check-envelope/valid/sweep.json | 4 + .../schema/domains-check-envelope.schema.json | 6 +- internal/cli/autodeploy_serve.go | 3 + internal/cli/deploy.go | 8 +- internal/cli/deployserver.go | 7 ++ internal/cli/dnscheck.go | 8 +- internal/cli/plan.go | 3 + internal/cli/planeffects.go | 19 +++- internal/cli/planeffects_test.go | 8 ++ internal/cli/redeploy.go | 5 + internal/cli/validate.go | 7 ++ internal/cli/validate_test.go | 39 ++++++++ internal/config/app.go | 62 ++++++++---- internal/config/compose.go | 4 +- internal/config/compose_test.go | 11 ++- internal/config/validate_test.go | 60 ++++++++++-- internal/deploy/deploy.go | 5 +- internal/deploy/rollback.go | 23 +++-- internal/deploy/staleroute_test.go | 94 ++++++++++++++++++- internal/dns/public.go | 22 +++-- internal/dns/public_test.go | 28 +++++- internal/domains/domains.go | 70 ++++++++++---- internal/domains/domains_test.go | 84 +++++++++++++++++ internal/releasemeta/releasemeta.go | 13 ++- 28 files changed, 544 insertions(+), 88 deletions(-) diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index f31b680..aed8387 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -12,9 +12,19 @@ reserved names under caddy ingress; compose placeholder; the three DNS bugs Found along the way and fixed: host-to-caddy rollback rendered an empty CAS list; the release record dropped `tls.http_only`; rollback state kept the old domain. +Review fixes (same branch): the reserved-name rule moved out of `validate()` to +deploy admission (`ValidateDeployDomain`: deploy, multi-server, server-config, +autodeploy, plan, validate), after overlays merge, so rollback, logs and other +read and recovery commands never refuse, and Compose-only folders load; redeploy +skips it. Custom certificates and localhost names are exempt. Public address is +known per family. A wildcard's failing obtain covers its hosts. `tls_http_only` is +a `*bool`, so the record can turn the flag off. Log parsing: an empty log is +short, and a matched line that cannot be attributed makes logs `partial`. + Open: -- teploy-observe's `teploy.yml` still carries `domain: observe.example.com` under - `ingress: host`. Legal (label-only warning); remove it there. +- teploy-observe's `teploy.yml` still carries `domain:` and `server: + observe.example.com` under `ingress: host`. Legal (label-only warning); remove + it there. - A crash between the state commit and the block removal leaves the block until the next deploy; `domains check` reports it as `stale_managed` meanwhile. - A managed block whose owner is not deployed is reported (`orphan_managed`), not @@ -24,6 +34,8 @@ Open: the public answer. - `tls.obtain`/`tls.renew` parsing assumes Caddy's default JSON log; another log format reads `unknown` for the whole server (`unavailable`), never zero. +- `teploy scale` builds its deploy config without deploy admission: it resizes + what is already running, the same reasoning as redeploy. - Public address detection: interface addresses plus a best-effort probe of 1.1.1.1's trace endpoint from the server. Without either a foreign answer reads `unknown`. Explicit `public_ips` server fields remain plan v2. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4219d..1b55ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,13 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ### Changed - Reserved names (RFC 2606/6761: example.com/.net/.org, *.example, .test, - .invalid, .localhost, localhost) are refused under caddy ingress unless - `tls.internal` or `tls.http_only` is set, and warned as label-only under host - and external ingress. **A `teploy.yml` with such a domain under caddy ingress - stops loading.** -- Compose import no longer fabricates `.example.com`; it asks for a domain. + .invalid) are refused by `deploy`, `plan` and `validate` under caddy ingress, + after overlays merge, unless `tls.internal`, `tls.http_only` or your own + `tls: {cert, key}` is set. They are warned as label-only under host and external + ingress. The file still loads: `rollback`, `logs` and other read and recovery + commands never refuse over it. localhost names are not refused. +- Compose import no longer fabricates `.example.com`; `deploy` asks for a + domain, and the folder still loads for everything else. ### Fixed @@ -28,8 +30,8 @@ All notable changes to teploy are documented here. Format follows [Keep a Change certificates for names nothing served. A failed deploy leaves the block alone. - Rollback from a host or external release to a caddy release works: it resolved its route expectation against the wrong ingress and rendered an empty CAS. -- A release record keeps `tls.http_only`, so a folderless rollback or a route - restore no longer re-renders an HTTP-only site with HTTPS. +- A release record keeps `tls.http_only`, so a rollback or a route restore follows + what that release served, in both directions. - State after a rollback names the target release's domain. - Multi-host domains pass the first-deploy DNS check (the comma list went to the resolver as one name), `validate` no longer DNS-checks host-ingress apps that diff --git a/README.md b/README.md index 5f19d5f..fc7a5b8 100644 --- a/README.md +++ b/README.md @@ -584,8 +584,9 @@ serves. Read-only, run from your machine over SSH, nothing new on the server: failure), `unknown` (no resolver answered, or the server's public address could not be determined), `skipped`. - **tls**: certificate failures per name from Caddy's logs (`--log-window`, - default 14 days), with how long the name has been failing. Logs under a day - old or unreadable give `unknown`, never 0. + default 14 days), with how long the name has been failing; a wildcard's failure + covers the names under it. Logs under a day old, unreadable, or with a line + that cannot be attributed give `unknown`, never 0. - **route**: `managed`, `manual`, `stale_managed` (the app is no longer caddy-routed), `orphan_managed` (no such app here), `none`, and whether each upstream resolves in Caddy's network. diff --git a/contracts/MANIFEST.md b/contracts/MANIFEST.md index 1eb7603..9350157 100644 --- a/contracts/MANIFEST.md +++ b/contracts/MANIFEST.md @@ -10,7 +10,7 @@ Neutron/Nucleus dependency and a public mirror. | Corpus rev | Emitting CLI | Machine Interface | Notes | |---|---|---|---| -| 17 | main (dash auto-config slice 5: domain health) | 2 | Additive. New artifact domains-check-envelope (`teploy domains check [--server] [--app] [--log-window] --json`: per (server, app, host) DNS via the fixed public resolvers, Caddy certificate failures from its logs — `failures` is null, never 0, on short or unavailable logs — and the route with upstream resolution; per-server state so an unreachable server is explicit), fixture GENERATED from the real `sweepDomains` over a mock executor. version-handshake advertises `domains-check-v1` (regenerated mi2.json). release-record's `caddy` gains optional `tls_http_only`. No MI bump. | +| 17 | main (dash auto-config slice 5: domain health) | 2 | Additive. New artifact domains-check-envelope (`teploy domains check [--server] [--app] [--log-window] --json`: per (server, app, host) DNS via the fixed public resolvers, Caddy certificate failures from its logs — `failures` is null, never 0, on short or unavailable logs — and the route with upstream resolution; per-server state so an unreachable server is explicit), fixture GENERATED from the real `sweepDomains` over a mock executor. version-handshake advertises `domains-check-v1` (regenerated mi2.json). release-record's `caddy` gains optional `tls_http_only` (absent on older records; every caddy release since records true or false). No MI bump. | | 16 | main (server-authority spec P3 / plan D4, git sources) | 2 | Additive. New artifacts source-show-envelope (`config source show --json`: repository/branch/poll, public credential facts — type, deploy-key public half + fingerprint, token username, pinned host-key fingerprints — and the per-ref admission state), source-key-envelope (`config source key --json` for ssh/token and `host-key --json`) and trigger-poll-envelope (`trigger poll --json`: one result object per app — ref, head, admitted, reason vocabulary incl. force-push/retry-limit/not-due, deployed_release), all fixtures GENERATED from the real DTOs. version-handshake advertises `git-source-v1` (git: code source, per-app credentials outside /deployments, build_secrets) and `trigger-poll-v1` (the one admitter + reset/retry/install-timer) (regenerated mi2.json). No MI bump. | | 15 | main (server-authority spec D3: backup target) | 2 | Additive. One backup target per server, stored outside /deployments with age-encrypted credentials and read at run time (`server backup-target set/show/unset`; `backup-target-v1`). New artifacts backup-target-envelope, backup-list-envelope, backup-create-envelope, backup-restore-envelope — fixtures GENERATED from the real DTO encoders (`backup list --json` now reports parsed rows {id, kind, accessory, size_bytes, created_at, key} plus schedules discovered from teploy cron markers; the old bare-array-of-raw-`aws s3 ls`-lines `backup list --json` shape is gone — it was never a corpus artifact and pinned no consumer). `backup create/restore --json` envelopes; restore quiesces the app under its fenced lock (backup-json-v1). version-handshake advertises `backup-target-v1` and `backup-json-v1` (regenerated mi2.json). No MI bump. | | 14 | main (server-authority plan D2, folderless reads) | 2 | Additive. New artifacts releases-envelope (`teploy releases --app X --host S --json`: container release records newest first with current/rollback_available (+reason); static apps list retained releases — both via the envelope, the folder-static legacy bare array is unchanged), rollback-result (`teploy rollback [--to X] --json`: {app, server, from, to, ok}; failures keep the error envelope) and redeploy-result (`teploy redeploy [--json]`: {app, server, home, release, image, ok}). Fixtures GENERATED from the real DTOs. version-handshake advertises `secret-stdin-v1` (secret set KEY --stdin; folderless secret set/get/list/rm/rotate), `releases-json-v1`, `rollback-json-v1`, `redeploy-v1` (fresh deploy of the current release's recorded image, env re-resolved) and `env-config-guard-v1` (env set/unset refuse keys a Teploy-home config declares) (regenerated mi2.json). No MI bump. || 13 | main (server-authority spec P2 support) | 2 | Additive. New artifact config-merge-envelope (`teploy config merge --base --file - --json`: merged config + conflicting keys, read-only), fixture GENERATED from the real configMergeDTO. version-handshake advertises `config-merge-v1`, `config-unprivileged-v1` (`config ... --unprivileged` refuses host-reaching changes) and `deploy-home-v1` (`deploy --app X --image T --home teploy` creates a new Teploy-home app) (regenerated mi2.json). No MI bump. | diff --git a/contracts/fixtures/domains-check-envelope/valid/sweep.json b/contracts/fixtures/domains-check-envelope/valid/sweep.json index 8c0f11b..b3eb735 100644 --- a/contracts/fixtures/domains-check-envelope/valid/sweep.json +++ b/contracts/fixtures/domains-check-envelope/valid/sweep.json @@ -16,6 +16,8 @@ "error": "dial tcp 198.51.100.1:22: i/o timeout", "addresses": [], "public_address_known": false, + "public_ipv4_known": false, + "public_ipv6_known": false, "caddy": { "container": "unknown", "caddyfile": "unreadable", @@ -34,6 +36,8 @@ "192.0.2.10" ], "public_address_known": true, + "public_ipv4_known": true, + "public_ipv6_known": false, "caddy": { "container": "present", "caddyfile": "present", diff --git a/contracts/schema/domains-check-envelope.schema.json b/contracts/schema/domains-check-envelope.schema.json index 73f05b5..9228067 100644 --- a/contracts/schema/domains-check-envelope.schema.json +++ b/contracts/schema/domains-check-envelope.schema.json @@ -14,7 +14,7 @@ "type": "array", "items": { "type": "object", - "required": ["server", "host", "state", "checked_at", "addresses", "public_address_known", "caddy", "issues"], + "required": ["server", "host", "state", "checked_at", "addresses", "public_address_known", "public_ipv4_known", "public_ipv6_known", "caddy", "issues"], "properties": { "server": {"type": "string", "minLength": 1}, "host": {"type": "string"}, @@ -23,13 +23,15 @@ "error": {"type": "string"}, "addresses": {"type": "array", "items": {"type": "string"}}, "public_address_known": {"type": "boolean"}, + "public_ipv4_known": {"type": "boolean"}, + "public_ipv6_known": {"type": "boolean"}, "caddy": { "type": "object", "required": ["container", "caddyfile", "logs", "log_coverage_seconds"], "properties": { "container": {"enum": ["present", "absent", "stopped", "unknown"]}, "caddyfile": {"enum": ["present", "absent", "unparseable", "unreadable"]}, - "logs": {"enum": ["ok", "short", "unavailable", "no_caddy"]}, + "logs": {"enum": ["ok", "short", "partial", "unavailable", "no_caddy"]}, "log_coverage_seconds": {"type": "integer", "minimum": 0}, "logs_truncated": {"type": "boolean"} } diff --git a/internal/cli/autodeploy_serve.go b/internal/cli/autodeploy_serve.go index 7c9143c..03aa66f 100644 --- a/internal/cli/autodeploy_serve.go +++ b/internal/cli/autodeploy_serve.go @@ -634,6 +634,9 @@ func triggerAutoDeploy(ctx context.Context, executor ssh.Executor, app, branch, if appCfg.App != app { return fmt.Errorf("teploy.yml in %s declares app %q, expected %q — refusing to deploy the wrong app", buildDir, appCfg.App, app) } + if err := config.ValidateDeployDomain(appCfg); err != nil { + return err + } // Local file references resolve against the CHECKOUT, not the resident // process's working directory (audit T31): the systemd unit has no diff --git a/internal/cli/deploy.go b/internal/cli/deploy.go index 62bb750..22035ba 100644 --- a/internal/cli/deploy.go +++ b/internal/cli/deploy.go @@ -137,7 +137,7 @@ func runAdHocDeploy(flags *Flags, serverName, appName, image, domain string, por return refuseAdmission(err) } // Ad-hoc deploys are always caddy-routed and have no tls: settings. - if err := config.ValidateRoutableDomain(domain, false, false); err != nil { + if err := config.ValidateRoutableDomain(domain, nil); err != nil { return refuseAdmission(err) } } @@ -436,6 +436,9 @@ func selectServersByRoleTag(names []string, all map[string]config.Server, role s // and runApply (a verified C05 plan record — planID stamps the receipt). func deployAppConfig(flags *Flags, appCfg *config.AppConfig, serverName, image, version string, skipDNSCheck, migrateVolumes bool, planID string, plans ...*PlanRecord) error { var err error + if err := config.ValidateDeployDomain(appCfg); err != nil { + return refuseAdmission(err) + } // 2. Resolve server (single-server deploy). if serverName == "" { @@ -1015,6 +1018,9 @@ func versionFromImage(image string) string { // runMultiDeploy handles deploying to multiple servers in parallel. func runMultiDeploy(flags *Flags, appCfg *config.AppConfig, image, version string, skipDNSCheck bool, parallel int, migrateVolumes bool) error { + if err := config.ValidateDeployDomain(appCfg); err != nil { + return refuseAdmission(err) + } // Resolve parallel setting. if parallel <= 0 { parallel = appCfg.Parallel diff --git a/internal/cli/deployserver.go b/internal/cli/deployserver.go index 0adfacb..1c04c84 100644 --- a/internal/cli/deployserver.go +++ b/internal/cli/deployserver.go @@ -118,6 +118,13 @@ func deployServerConfigLocked(ctx context.Context, executor ssh.Executor, host, if err != nil { return refuseAdmission(err) } + // A redeploy is recovery: it re-runs what is already running and must not + // refuse mid-incident over a domain rule that arrived after that release. + if !isRedeployTrigger(ctx) { + if err := config.ValidateDeployDomain(appCfg); err != nil { + return refuseAdmission(err) + } + } if appCfg.IsStatic() { return refuseAdmission(fmt.Errorf("static sites in Teploy home need a code source, which this teploy version does not support yet — keep %s in folder home (`teploy config home folder`)", app)) } diff --git a/internal/cli/dnscheck.go b/internal/cli/dnscheck.go index 827eee0..f721df8 100644 --- a/internal/cli/dnscheck.go +++ b/internal/cli/dnscheck.go @@ -15,7 +15,13 @@ import ( // Caddy-routed domain must point at this server. Host ingress publishes a raw // port and external ingress is served by the user's own front, so neither // domain resolves here by design. +// +// tls.internal and tls.http_only never ask a public CA, and are typically private +// names or origins behind a proxy, so public DNS proves nothing about them. func dnsCheckApplies(appCfg *config.AppConfig) bool { + if appCfg.TLS != nil && (appCfg.TLS.Internal || appCfg.TLS.HTTPOnly) { + return false + } return appCfg.Domain != "" && appCfg.UsesCaddy() } @@ -34,7 +40,7 @@ func validateDomainDNS(ctx context.Context, executor ssh.Executor, domain, host interfacesRead := false for _, d := range strings.Split(domain, ",") { d = strings.TrimSpace(d) - if d == "" { + if d == "" || config.IsLocalhostName(d) { continue } verr := dns.Validate(d, host, nil) diff --git a/internal/cli/plan.go b/internal/cli/plan.go index 705db12..a11194d 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -296,6 +296,9 @@ func runPlan(flags *Flags, version, image, destination, outFile string) error { if err != nil { return err } + if err := config.ValidateDeployDomain(appCfg); err != nil { + return refuseAdmission(err) + } serverName := resolvePlanServerName(appCfg) executor, err := connectForApp(ctx, flags, appCfg) if err != nil { diff --git a/internal/cli/planeffects.go b/internal/cli/planeffects.go index 125910d..78615f5 100644 --- a/internal/cli/planeffects.go +++ b/internal/cli/planeffects.go @@ -13,6 +13,7 @@ import ( "context" "fmt" "io" + "slices" "sort" "strconv" "strings" @@ -125,11 +126,27 @@ func staleRouteEffects(ctx context.Context, exec ssh.Executor, out io.Writer, ap if !present { return nil } + detail := "left by an earlier caddy-ingress release; removed after the state commit, kept if this deploy fails" + if strings.Contains(region, "# TEPLOY WEBHOOK BEGIN") { + detail += "; WARNING: it carries the autodeploy webhook route, which stops working" + } + // An upstream that is not one of this app's own containers outlives the + // release, so the block may be serving traffic that removal would cut. + var foreign []string + for _, u := range caddy.RegionUpstreams(region) { + if !strings.HasPrefix(u, appCfg.App+"-") { + foreign = append(foreign, u) + } + } + if len(foreign) > 0 { + sort.Strings(foreign) + detail += "; WARNING: it also proxies to " + strings.Join(slices.Compact(foreign), ", ") + ", not this app's containers, and removal cuts that traffic" + } return []planEffect{{ Action: "remove", Name: "stale caddy route", From: strings.Join(caddy.RegionHosts(region), ","), - Detail: "left by an earlier caddy-ingress release; removed after the state commit, kept if this deploy fails", + Detail: detail, }} } diff --git a/internal/cli/planeffects_test.go b/internal/cli/planeffects_test.go index f1f9d15..49ec5d6 100644 --- a/internal/cli/planeffects_test.go +++ b/internal/cli/planeffects_test.go @@ -320,6 +320,14 @@ func TestStaleRouteEffects(t *testing.T) { if got := staleRouteEffects(context.Background(), withFile("# nothing\n"), &out, host); got != nil { t.Errorf("no block, no effect: %+v", got) } + if d := effects[0].Detail; strings.Contains(d, "WARNING") { + t.Errorf("an ordinary block warns about nothing: %s", d) + } + risky := "# TEPLOY BEGIN blog\nblog.dev {\n\t# TEPLOY WEBHOOK BEGIN blog\n\t@h {\n\t\tpath /x\n\t}\n\t# TEPLOY WEBHOOK END blog\n\treverse_proxy other-box:8080\n}\n# TEPLOY END blog\n" + got := staleRouteEffects(context.Background(), withFile(risky), &out, host) + if len(got) != 1 || !strings.Contains(got[0].Detail, "webhook") || !strings.Contains(got[0].Detail, "other-box:8080") { + t.Errorf("the plan must warn about what removal cuts: %+v", got) + } caddy := &config.AppConfig{App: "blog", Domain: "blog.dev"} if got := staleRouteEffects(context.Background(), withFile(block), &out, caddy); got != nil { t.Errorf("a caddy plan keeps its route: %+v", got) diff --git a/internal/cli/redeploy.go b/internal/cli/redeploy.go index ddd74f6..0831d3c 100644 --- a/internal/cli/redeploy.go +++ b/internal/cli/redeploy.go @@ -47,6 +47,11 @@ func withRedeployTrigger(ctx context.Context) context.Context { return context.WithValue(ctx, redeployKey{}, true) } +func isRedeployTrigger(ctx context.Context) bool { + _, ok := ctx.Value(redeployKey{}).(bool) + return ok +} + func eventTrigger(ctx context.Context) string { if _, ok := ctx.Value(redeployKey{}).(bool); ok { return "redeploy" diff --git a/internal/cli/validate.go b/internal/cli/validate.go index b8cc0c5..29fae29 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -49,6 +49,13 @@ func runValidate(flags *Flags) error { return outputResult(flags, result) } + // 1b. The domain rule deploy admission applies. + if err := config.ValidateDeployDomain(appCfg); err != nil { + result.Valid = false + result.Errors = append(result.Errors, fmt.Sprintf("config: %v", err)) + return outputResult(flags, result) + } + // 2. Check build prerequisites. warns, errs := buildPrereqCheck(appCfg) result.Warns = append(result.Warns, warns...) diff --git a/internal/cli/validate_test.go b/internal/cli/validate_test.go index ace22a3..ee8fa81 100644 --- a/internal/cli/validate_test.go +++ b/internal/cli/validate_test.go @@ -158,6 +158,9 @@ func TestDNSCheckAppliesOnlyToCaddyRoutedDomains(t *testing.T) { {config.AppConfig{Domain: "a.dev", Ingress: config.IngressHost}, false}, {config.AppConfig{Domain: "a.dev", Ingress: config.IngressExternal}, false}, {config.AppConfig{}, false}, + {config.AppConfig{Domain: "app.test", TLS: &config.TLSConfig{Internal: true}}, false}, + {config.AppConfig{Domain: "a.dev", TLS: &config.TLSConfig{HTTPOnly: true}}, false}, + {config.AppConfig{Domain: "a.dev", TLS: &config.TLSConfig{Cert: "c", Key: "k"}}, true}, } { if got := dnsCheckApplies(&tc.cfg); got != tc.want { t.Errorf("dnsCheckApplies(%+v) = %v", tc.cfg, got) @@ -174,3 +177,39 @@ func TestValidateDomainDNS_ChecksEachHost(t *testing.T) { t.Fatalf("both hosts resolve to the server: %v", err) } } + +// A placeholder stops a deploy and a plan before any connection, and never a +// load: the refusal is deploy admission's. +func TestDeployAdmission_RefusesBeforeAnyEffect(t *testing.T) { + cfg := &config.AppConfig{App: "a", Domain: "a.example.com", Image: "nginx:1", Server: "192.0.2.1"} + err := deployAppConfig(&Flags{}, cfg, "192.0.2.1", "", "", false, false, "") + if err == nil || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("deploy: %v", err) + } + if err := runMultiDeploy(&Flags{}, cfg, "", "", false, 1, false); err == nil || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("multi deploy: %v", err) + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "teploy.yml"), []byte("app: a\ndomain: a.example.com\nimage: nginx:1\nserver: 192.0.2.1\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := config.LoadApp(dir); err != nil { + t.Fatalf("rollback and logs load this file: %v", err) + } + t.Chdir(dir) + if err := runPlan(&Flags{}, "", "", "", ""); err == nil || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("plan: %v", err) + } + flags := &Flags{JSON: true} + if err := runValidate(flags); err == nil { + t.Fatal("validate must report it invalid") + } +} + +func TestValidateDomainDNS_SkipsLocalhostNames(t *testing.T) { + notes, err := validateDomainDNS(context.Background(), nil, "foo.localhost", "192.0.2.1") + if err != nil || len(notes) != 0 { + t.Fatalf("notes=%v err=%v", notes, err) + } +} diff --git a/internal/config/app.go b/internal/config/app.go index 0a2a394..adc364c 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -581,6 +581,9 @@ const ( // AppConfig represents the teploy configuration (yml, yaml, or toml). type AppConfig struct { + // composeImport marks a config read from a Compose file, which has no domain. + composeImport bool + App string `yaml:"app" toml:"app"` Domain string `yaml:"domain" toml:"domain"` Type string `yaml:"type,omitempty" toml:"type"` @@ -1183,19 +1186,49 @@ func IsReservedDomain(host string) bool { return false } -// ValidateRoutableDomain refuses a reserved name on a Caddy-routed app unless -// the certificate is not public anyway: Caddy would otherwise retry ACME for a -// name no CA will issue, burning rate limits for as long as the block lives. -func ValidateRoutableDomain(domain string, tlsInternal, httpOnly bool) error { - if tlsInternal || httpOnly { +// IsLocalhostName reports localhost and *.localhost, which Caddy serves from its +// local CA without any public certificate. +func IsLocalhostName(host string) bool { + h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + return h == "localhost" || strings.HasSuffix(h, ".localhost") +} + +// ValidateRoutableDomain refuses a reserved name on a Caddy-routed app when the +// certificate would have to be a public one: Caddy would retry ACME for a name +// no CA will issue, burning rate limits for as long as the block lives. A local +// CA, plain HTTP or the operator's own certificate never go to ACME, and neither +// does a localhost name. +func ValidateRoutableDomain(domain string, tls *TLSConfig) error { + if tls != nil && (tls.Internal || tls.HTTPOnly || tls.Cert != "") { return nil } - if hosts := ReservedDomainHosts(domain); len(hosts) > 0 { - return fmt.Errorf("'domain' %s is reserved (RFC 2606/6761) and can never get a public certificate — use a real domain, or set 'tls: {internal: true}' or 'tls: {http_only: true}' for a private name", strings.Join(hosts, ", ")) + var refused []string + for _, h := range ReservedDomainHosts(domain) { + if !IsLocalhostName(h) { + refused = append(refused, h) + } + } + if len(refused) > 0 { + return fmt.Errorf("'domain' %s is reserved (RFC 2606/6761) and can never get a public certificate — use a real domain, or set 'tls: {internal: true}', 'tls: {http_only: true}' or your own 'tls: {cert, key}' for a private name", strings.Join(refused, ", ")) } return nil } +// ValidateDeployDomain is deploy admission's domain rule, for the config after +// every overlay has merged. It is deliberately not part of validate(): a base +// file whose placeholder an overlay replaces is correct, and rollback, logs and +// the rest of the read and recovery commands load the same file mid-incident and +// must never refuse over a domain. +func ValidateDeployDomain(c *AppConfig) error { + if !c.UsesCaddy() { + return nil + } + if c.Domain == "" { + return fmt.Errorf("'domain' is required to deploy with Caddy ingress: Compose files carry no domain — run `teploy init` to generate a teploy.yml with one, or add a teploy.yml with `domain:` (or `ingress: host` and `port:` to publish a raw port)") + } + return ValidateRoutableDomain(c.Domain, c.TLS) +} + // ReservedDomainLabelWarnings words the same names as a warning for ingress // modes where teploy serves no route: the domain is only a label there. func ReservedDomainLabelWarnings(domain, ingress string) []string { @@ -1211,15 +1244,12 @@ func (c *AppConfig) validate() error { return err } // Host ingress publishes a raw port and needs no domain; every other mode - // routes by hostname and requires one. - if err := ValidateDomain(c.Domain, c.Ingress == IngressHost); err != nil { + // routes by hostname and requires one. A Compose import has none to give: + // it loads for read and recovery commands, and deploy admission asks for it + // (ValidateDeployDomain). + if err := ValidateDomain(c.Domain, c.Ingress == IngressHost || c.composeImport); err != nil { return err } - if c.UsesCaddy() { - if err := ValidateRoutableDomain(c.Domain, c.TLS != nil && c.TLS.Internal, c.TLS != nil && c.TLS.HTTPOnly); err != nil { - return err - } - } if c.Platform != "" && !validPlatform.MatchString(c.Platform) { return fmt.Errorf("'platform' must be os/arch (e.g. linux/amd64, linux/arm64), got %q", c.Platform) } @@ -1622,9 +1652,7 @@ func LoadApp(dir string) (*AppConfig, error) { return nil, invalidConfig(err) } if composeCfg != nil { - if composeCfg.Domain == "" { - return nil, invalidConfig(fmt.Errorf("invalid Compose config: Compose files carry no domain — run `teploy init` to generate a teploy.yml with one, or add a teploy.yml with `domain:` (or `ingress: host` and `port:` to publish a raw port)")) - } + composeCfg.composeImport = true if err := composeCfg.validate(); err != nil { return nil, fmt.Errorf("invalid Compose config: %w", err) } diff --git a/internal/config/compose.go b/internal/config/compose.go index 06ac899..865bf97 100644 --- a/internal/config/compose.go +++ b/internal/config/compose.go @@ -226,8 +226,8 @@ func mapCompose(dir string, compose composeFile) (*AppConfig, error) { cfg.Dockerfile = webDockerfile // Compose has no domain: nothing is fabricated. A placeholder that deploys - // sends Caddy after a certificate it can never get, forever. validate() - // requires the real name. + // sends Caddy after a certificate it can never get, forever. Deploy admission + // asks for the real name (ValidateDeployDomain). // Web process gets empty command (use image CMD). cfg.Processes["web"] = parseCommand(webService.Command) diff --git a/internal/config/compose_test.go b/internal/config/compose_test.go index 20c024d..5a063ee 100644 --- a/internal/config/compose_test.go +++ b/internal/config/compose_test.go @@ -1631,20 +1631,21 @@ services: // Compose carries no domain, and none is invented: a fabricated // .example.com is what sent Caddy after certificates it could never get. -func TestLoadApp_ComposeRequiresADomain(t *testing.T) { +// The folder still loads for read and recovery commands; deploying asks for one. +func TestLoadApp_ComposeLoadsButDeployNeedsADomain(t *testing.T) { dir := t.TempDir() content := "services:\n web:\n image: nginx:1\n ports:\n - \"80:80\"\n" if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(content), 0644); err != nil { t.Fatal(err) } - cfg, err := LoadCompose(dir) + cfg, err := LoadApp(dir) if err != nil { - t.Fatalf("LoadCompose: %v", err) + t.Fatalf("a Compose-only folder must load: %v", err) } if cfg.Domain != "" { t.Fatalf("Compose import fabricated a domain: %q", cfg.Domain) } - if _, err := LoadApp(dir); err == nil || !strings.Contains(err.Error(), "no domain") { - t.Fatalf("LoadApp should ask for a domain, got %v", err) + if err := ValidateDeployDomain(cfg); err == nil || !strings.Contains(err.Error(), "Compose files carry no domain") { + t.Fatalf("deploy admission should ask for a domain, got %v", err) } } diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 90d2d0c..08e33f1 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -1,6 +1,8 @@ package config import ( + "os" + "path/filepath" "strings" "testing" ) @@ -118,22 +120,24 @@ func TestIsReservedDomain(t *testing.T) { } } -func TestValidate_ReservedDomainRule(t *testing.T) { - load := func(t *testing.T, yaml string) error { +func TestValidateDeployDomain(t *testing.T) { + load := func(t *testing.T, yaml string) *AppConfig { t.Helper() - _, err := LoadAppBytes([]byte(yaml)) - return err + cfg, err := LoadAppBytes([]byte(yaml)) + if err != nil { + t.Fatalf("a reserved name must load, for read and recovery commands: %v", err) + } + return cfg } refused := map[string]string{ "caddy default": "app: a\ndomain: a.example.com\n", "explicit caddy": "app: a\ningress: caddy\ndomain: a.test\n", "one of several": "app: a\ndomain: real.dev, a.invalid\n", - "localhost": "app: a\ndomain: localhost\n", "static is caddy": "app: a\ntype: static\nsource: dist\ndomain: a.example.org\n", } for name, cfg := range refused { t.Run(name, func(t *testing.T) { - err := load(t, cfg) + err := ValidateDeployDomain(load(t, cfg)) if err == nil || !strings.Contains(err.Error(), "reserved") { t.Fatalf("expected the reserved-name refusal, got %v", err) } @@ -142,18 +146,58 @@ func TestValidate_ReservedDomainRule(t *testing.T) { allowed := map[string]string{ "tls internal": "app: a\ndomain: a.test\ntls:\n internal: true\n", "tls http only": "app: a\ndomain: a.example.com\ntls:\n http_only: true\n", + "custom cert": "app: a\ndomain: app.test\ntls:\n cert: c.pem\n key: k.pem\n", + "localhost": "app: a\ndomain: localhost\n", + "foo.localhost": "app: a\ndomain: foo.localhost\n", "host label": "app: a\ningress: host\nport: 3000\ndomain: a.example.com\n", "external": "app: a\ningress: external\ndomain: a.example.com\n", "real name": "app: a\ndomain: a.teploy-fixture.dev\n", } for name, cfg := range allowed { t.Run(name, func(t *testing.T) { - if err := load(t, cfg); err != nil { - t.Fatalf("should load: %v", err) + if err := ValidateDeployDomain(load(t, cfg)); err != nil { + t.Fatalf("should be admitted: %v", err) } }) } if warns := ReservedDomainLabelWarnings("a.example.com, real.dev", IngressHost); len(warns) != 1 || !strings.Contains(warns[0], "label") { t.Errorf("host ingress must warn label-only once: %v", warns) } + if !IsReservedDomain("foo.localhost") { + t.Error("localhost names stay reserved for DNS classification") + } +} + +// The overlay decides: a placeholder in the base file replaced by +// teploy.prod.yml loads and is admitted, and without the overlay the base still +// loads, so `teploy rollback` and logs work from that folder mid-incident. +func TestReservedDomain_OverlayDecides(t *testing.T) { + dir := t.TempDir() + write := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("teploy.yml", "app: a\ndomain: placeholder.example.com\nimage: nginx:1\n") + write("teploy.prod.yml", "domain: real.teploy-fixture.dev\n") + + merged, err := LoadAppWithDestination(dir, "prod", OverlayOptions{}) + if err != nil { + t.Fatalf("a placeholder an overlay replaces must load: %v", err) + } + if merged.Domain != "real.teploy-fixture.dev" { + t.Fatalf("domain = %q", merged.Domain) + } + if err := ValidateDeployDomain(merged); err != nil { + t.Fatalf("the merged config is admitted: %v", err) + } + + base, err := LoadApp(dir) + if err != nil { + t.Fatalf("read and recovery commands must load the base: %v", err) + } + if err := ValidateDeployDomain(base); err == nil { + t.Fatal("deploying the placeholder itself is still refused") + } } diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index 4800e4e..fea8ff7 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -1658,7 +1658,7 @@ func (d *Deployer) restoreRouteFromReceipt(ctx context.Context, cfg Config, curr fw := cfg.Firewall access := cfg.Access if rec.Caddy != nil { - tls = caddy.TLS{Cert: rec.Caddy.TLSCert, Key: rec.Caddy.TLSKey, Internal: rec.Caddy.TLSInternal, HTTPOnly: rec.Caddy.TLSHTTPOnly} + tls = caddy.TLS{Cert: rec.Caddy.TLSCert, Key: rec.Caddy.TLSKey, Internal: rec.Caddy.TLSInternal, HTTPOnly: rec.Caddy.HTTPOnlyOr(cfg.TLSHTTPOnly)} caddyExtra = rec.Caddy.CaddyExtra cache = rec.Caddy.Cache if rec.Caddy.Firewall != nil { @@ -1806,11 +1806,12 @@ func (d *Deployer) recordRelease(ctx context.Context, cfg Config, att releasemet } } if cfg.usesCaddy() { + httpOnly := cfg.TLSHTTPOnly rec.Caddy = &releasemeta.CaddyRoute{ TLSCert: cfg.TLSCert, TLSKey: cfg.TLSKey, TLSInternal: cfg.TLSInternal, - TLSHTTPOnly: cfg.TLSHTTPOnly, + TLSHTTPOnly: &httpOnly, CaddyExtra: cfg.CaddyExtra, Cache: maps.Clone(cfg.Cache), } diff --git a/internal/deploy/rollback.go b/internal/deploy/rollback.go index c1dcbd2..1fe33fd 100644 --- a/internal/deploy/rollback.go +++ b/internal/deploy/rollback.go @@ -670,8 +670,11 @@ func rollback(ctx context.Context, exec ssh.Executor, out io.Writer, cfg Rollbac // The non-caddy target is authoritative: retire the route the rolled-back- // from caddy release left. Degraded, never a failed rollback: the next // deploy retries it and `teploy domains check` reports it until then. + degradedReason := "" if !cfg.usesCaddy() { - _ = retireStaleRoute(ctx, cd, out, cfg.App, cfg.Ingress, staleRoute, fromGeneration, lk.GuardPrefix()) + if err := retireStaleRoute(ctx, cd, out, cfg.App, cfg.Ingress, staleRoute, fromGeneration, lk.GuardPrefix()); err != nil { + degradedReason = fmt.Sprintf("stale Caddy route not removed: %v", err) + } } // 6. The authoritative commit succeeded; the prior current workload can @@ -723,12 +726,14 @@ func rollback(ctx context.Context, exec ssh.Executor, out io.Writer, cfg Rollbac // 7. Log. state.AppendLog(ctx, exec, state.LogEntry{ - Timestamp: time.Now().UTC(), - App: cfg.App, - Type: "rollback", - Hash: target, - Success: true, - DurationMs: time.Since(start).Milliseconds(), + Timestamp: time.Now().UTC(), + App: cfg.App, + Type: "rollback", + Hash: target, + Success: true, + Degraded: degradedReason != "", + DegradedReason: degradedReason, + DurationMs: time.Since(start).Milliseconds(), }) duration := time.Since(start) @@ -823,7 +828,7 @@ func restoreRollbackRouteFromReceipt(ctx context.Context, cd *caddy.Client, cfg fw := cfg.Firewall access := cfg.Access if rec.Caddy != nil { - tls = caddy.TLS{Cert: rec.Caddy.TLSCert, Key: rec.Caddy.TLSKey, Internal: rec.Caddy.TLSInternal, HTTPOnly: rec.Caddy.TLSHTTPOnly} + tls = caddy.TLS{Cert: rec.Caddy.TLSCert, Key: rec.Caddy.TLSKey, Internal: rec.Caddy.TLSInternal, HTTPOnly: rec.Caddy.HTTPOnlyOr(cfg.TLSHTTPOnly)} caddyExtra = rec.Caddy.CaddyExtra cache = rec.Caddy.Cache if rec.Caddy.Firewall != nil { @@ -935,7 +940,7 @@ func applyRecordToRollback(cfg *RollbackConfig, rec *releasemeta.Record, healthC cfg.TLSKey = rec.Caddy.TLSKey cfg.TLSInternal = rec.Caddy.TLSInternal // Records written before this field keep what the caller resolved. - cfg.TLSHTTPOnly = cfg.TLSHTTPOnly || rec.Caddy.TLSHTTPOnly + cfg.TLSHTTPOnly = rec.Caddy.HTTPOnlyOr(cfg.TLSHTTPOnly) cfg.CaddyExtra = rec.Caddy.CaddyExtra cfg.Cache = rec.Caddy.Cache if rec.Caddy.Firewall != nil { diff --git a/internal/deploy/staleroute_test.go b/internal/deploy/staleroute_test.go index 0425b1c..647f466 100644 --- a/internal/deploy/staleroute_test.go +++ b/internal/deploy/staleroute_test.go @@ -285,9 +285,10 @@ func TestRollback_HostToCaddyCommitFailureRemovesWhatItWrote(t *testing.T) { } func TestRollback_RestoreKeepsHTTPOnly(t *testing.T) { + httpOnly := true rec := marshalRecord(t, &releasemeta.Record{DeploymentType: "container", IngressMode: "caddy", Domain: "myapp.com", Health: &releasemeta.Health{Path: "/health", TimeoutSeconds: 30, IntervalSeconds: 1}, - Caddy: &releasemeta.CaddyRoute{TLSHTTPOnly: true}, + Caddy: &releasemeta.CaddyRoute{TLSHTTPOnly: &httpOnly}, Ports: []releasemeta.Port{{HostPort: 49152, ContainerPort: 3000, Primary: true}}}) m := ssh.NewMockExecutor("1.2.3.4", rollbackFixtureCommands(rec)...) m.Files["/deployments/caddy/Caddyfile"] = []byte(rollbackCaddyBlock + "\n") @@ -299,3 +300,94 @@ func TestRollback_RestoreKeepsHTTPOnly(t *testing.T) { t.Errorf("the recorded http_only setting was lost:\n%s", caddyfile) } } + +// The record wins both ways: a folder that is http_only now, rolling back to a +// release recorded as HTTPS, must not drop HTTPS. A record too old to carry the +// field keeps what the caller resolved. +func TestRollback_RecordCanTurnHTTPOnlyOff(t *testing.T) { + off := false + for _, tc := range []struct { + name string + recorded *bool + want string + }{ + {"recorded off", &off, "myapp.com {"}, + {"old record", nil, "http://myapp.com"}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := marshalRecord(t, &releasemeta.Record{DeploymentType: "container", IngressMode: "caddy", Domain: "myapp.com", + Health: &releasemeta.Health{Path: "/health", TimeoutSeconds: 30, IntervalSeconds: 1}, + Caddy: &releasemeta.CaddyRoute{TLSHTTPOnly: tc.recorded}, + Ports: []releasemeta.Port{{HostPort: 49152, ContainerPort: 3000, Primary: true}}}) + m := ssh.NewMockExecutor("1.2.3.4", rollbackFixtureCommands(rec)...) + m.Files["/deployments/caddy/Caddyfile"] = []byte(rollbackCaddyBlock + "\n") + cfg := rollbackCfg() + cfg.TLSHTTPOnly = true + var out bytes.Buffer + if err := Rollback(context.Background(), m, &out, cfg); err != nil { + t.Fatalf("Rollback: %v\n%s", err, out.String()) + } + caddyfile := string(m.Files["/deployments/caddy/Caddyfile"]) + if !strings.Contains(caddyfile, tc.want) { + t.Errorf("want %q in:\n%s", tc.want, caddyfile) + } + }) + } +} + +// A refused removal after a committed rollback is degraded, on the record. +func TestRollback_RetireFailureRecordedAsDegraded(t *testing.T) { + m := ssh.NewMockExecutor("1.2.3.4", + append([]ssh.MockCommand{{Match: "docker exec caddy caddy reload", Err: errors.New("reload refused")}}, rollbackFixtureCommands(hostRecord(t))...)...) + m.Files["/deployments/caddy/Caddyfile"] = []byte(rollbackCaddyBlock + "\n") + var out bytes.Buffer + if err := Rollback(context.Background(), m, &out, rollbackCfg()); err != nil { + t.Fatalf("the rollback itself stands: %v\n%s", err, out.String()) + } + raw, ok := logEntryFromCalls(m) + if !ok { + t.Fatalf("no log entry\n%s", out.String()) + } + var entry state.LogEntry + if err := json.Unmarshal(raw, &entry); err != nil { + t.Fatal(err) + } + if !entry.Success || !entry.Degraded || !strings.Contains(entry.DegradedReason, "stale Caddy route") { + t.Errorf("log entry = %+v", entry) + } +} + +// Deploy's twin of the rollback guard: a caddy deploy over a host release whose +// commit fails takes out the block it wrote rather than rendering a route for a +// release that had none. +func TestDeploy_CaddyOverHostCommitFailureRemovesBlock(t *testing.T) { + st := fmt.Sprintf(`{"schema_version":2,"deployment_type":"container","ingress_mode":"host","updated_at":"2026-07-22T10:00:00Z","current_port":3000,"current_hash":"old123"}`) + m := ssh.NewMockExecutor("1.2.3.4", + ssh.MockCommand{Match: "UPLOAD:/deployments/web/state.json.tmp-", Err: errors.New("disk full")}, + ssh.MockCommand{Match: "mkdir -p /deployments/web", Output: ""}, + ssh.MockCommand{Match: "mkdir /deployments/web/.lock", Output: ""}, + ssh.MockCommand{Match: "if [ ! -e '/deployments/web/state.json' ]", Output: "present\n" + st}, + ssh.MockCommand{Match: "docker ps", Output: ""}, + ssh.MockCommand{Match: "ss -tln", Output: ""}, + ssh.MockCommand{Match: "docker run", Output: "container-id"}, + ssh.MockCommand{Match: "docker inspect -f '{{.State.Status}}'", Output: "running"}, + ssh.MockCommand{Match: "docker stop", Output: ""}, + ssh.MockCommand{Match: "docker rm", Output: ""}, + ssh.MockCommand{Match: "curl -s -o /dev/null", Output: "200"}, + ssh.MockCommand{Match: "docker exec caddy caddy reload", Output: ""}, + ssh.MockCommand{Match: "a=$(docker exec caddy md5sum", Output: "TEPLOY_CADDY_OK"}, + ssh.MockCommand{Match: "mkdir /deployments/caddy/.lock", Output: ""}, + ssh.MockCommand{Match: "printf %s", Output: ""}, + ssh.MockCommand{Match: "rm -rf /deployments/web/.lock", Output: ""}, + ) + m.Files["/deployments/caddy/Caddyfile"] = []byte(otherBlock + "\n") + var out bytes.Buffer + cfg := Config{App: "web", Image: "web:new", Version: "new456", Domain: "web.teploy-fixture.dev", ContainerPort: 3000, + Health: HealthConfig{Timeout: 5 * time.Second, Interval: 10 * time.Millisecond}} + if err := NewDeployer(m, &out).Deploy(context.Background(), cfg); err == nil { + t.Fatalf("expected the commit to fail\n%s", out.String()) + } + if caddyfile := string(m.Files["/deployments/caddy/Caddyfile"]); strings.Contains(caddyfile, "TEPLOY BEGIN web") { + t.Errorf("the block must go:\n%s", caddyfile) + } +} diff --git a/internal/dns/public.go b/internal/dns/public.go index fd568c3..383ab2b 100644 --- a/internal/dns/public.go +++ b/internal/dns/public.go @@ -117,9 +117,11 @@ func lookupVia(ctx context.Context, lookup lookupFunc, resolvers []string, host } // Classify turns an answer into a status. serverIPs are the addresses that -// count as this server; targetKnown is false when no public address could be -// determined for it, in which case a non-matching answer proves nothing. -func Classify(ans PublicAnswer, serverIPs []string, targetKnown bool) string { +// count as this server. publicV4 and publicV6 say whether a public address of +// that family is known for it: a public IPv6 on an interface says nothing about +// the IPv4 a NAT'd box answers on, so a non-matching answer in a family with no +// known address proves nothing. +func Classify(ans PublicAnswer, serverIPs []string, publicV4, publicV6 bool) string { switch { case ans.Err != nil: return StatusUnknown @@ -129,11 +131,17 @@ func Classify(ans PublicAnswer, serverIPs []string, targetKnown bool) string { return StatusOK case AllCloudflare(ans.Answers): return StatusProxied - case !targetKnown: - return StatusUnknown - default: - return StatusWrongTarget } + for _, a := range ans.Answers { + ip := net.ParseIP(a) + if ip == nil { + continue + } + if known := map[bool]bool{true: publicV4, false: publicV6}[ip.To4() != nil]; !known { + return StatusUnknown + } + } + return StatusWrongTarget } // cloudflareRangesAsOf dates the list below (https://www.cloudflare.com/ips/). diff --git a/internal/dns/public_test.go b/internal/dns/public_test.go index 876d5c0..c38cdf6 100644 --- a/internal/dns/public_test.go +++ b/internal/dns/public_test.go @@ -16,6 +16,8 @@ func TestClassify(t *testing.T) { targetKnown bool want string }{ + // A public IPv6 on an interface says nothing about the box's IPv4. + {"v4 answer, only v6 known", PublicAnswer{Answers: []string{"198.51.100.1"}}, false, StatusUnknown}, {"answer is the server", PublicAnswer{Answers: []string{"192.0.2.10"}}, true, StatusOK}, {"one of several is the server", PublicAnswer{Answers: []string{"192.0.2.10", "198.51.100.1"}}, true, StatusOK}, {"no record", PublicAnswer{NotFound: true}, true, StatusNXDomain}, @@ -28,12 +30,36 @@ func TestClassify(t *testing.T) { {"cloudflare even with unknown server", PublicAnswer{Answers: []string{"104.16.0.1"}}, false, StatusProxied}, } for _, c := range cases { - if got := Classify(c.ans, server, c.targetKnown); got != c.want { + if got := Classify(c.ans, server, c.targetKnown, c.targetKnown); got != c.want { t.Errorf("%s: Classify = %q, want %q", c.name, got, c.want) } } } +func TestClassify_PerFamily(t *testing.T) { + server := []string{"192.0.2.10", "2001:db8::1"} + v4 := PublicAnswer{Answers: []string{"198.51.100.1"}} + v6 := PublicAnswer{Answers: []string{"2001:db8::99"}} + both := PublicAnswer{Answers: []string{"198.51.100.1", "2001:db8::99"}} + for _, c := range []struct { + name string + ans PublicAnswer + v4Known, v6K bool + want string + }{ + {"A answer, public IPv6 only", v4, false, true, StatusUnknown}, + {"A answer, public IPv4 known", v4, true, false, StatusWrongTarget}, + {"AAAA answer, public IPv4 only", v6, true, false, StatusUnknown}, + {"AAAA answer, public IPv6 known", v6, false, true, StatusWrongTarget}, + {"both families, one unknown", both, true, false, StatusUnknown}, + {"both families, both known", both, true, true, StatusWrongTarget}, + } { + if got := Classify(c.ans, server, c.v4Known, c.v6K); got != c.want { + t.Errorf("%s: %q, want %q", c.name, got, c.want) + } + } +} + func TestLookupVia(t *testing.T) { notFound := &net.DNSError{Err: "no such host", IsNotFound: true} t.Run("a blackholed resolver delays only itself", func(t *testing.T) { diff --git a/internal/domains/domains.go b/internal/domains/domains.go index 8ba5b99..a8ca8b6 100644 --- a/internal/domains/domains.go +++ b/internal/domains/domains.go @@ -69,6 +69,7 @@ const ( const ( LogsOK = "ok" LogsShort = "short" // under a day: not enough to call zero failures + LogsPartial = "partial" // a matched line could not be parsed: same LogsUnavailable = "unavailable" // could not be read LogsNoCaddy = "no_caddy" // no caddy container here ) @@ -119,11 +120,14 @@ type Server struct { State string `json:"state"` CheckedAt time.Time `json:"checked_at"` Error string `json:"error,omitempty"` - // Addresses are every address counted as this server; PublicAddressKnown is - // false when none of them is public, so a non-matching DNS answer proves - // nothing and reads unknown. + // Addresses are every address counted as this server. PublicAddressKnown is + // false when none of them is public; the per-family flags decide DNS: an + // answer in a family with no known public address proves nothing and reads + // unknown. Addresses []string `json:"addresses"` PublicAddressKnown bool `json:"public_address_known"` + PublicIPv4Known bool `json:"public_ipv4_known"` + PublicIPv6Known bool `json:"public_ipv6_known"` Caddy CaddyStatus `json:"caddy"` Issues []Issue `json:"issues"` } @@ -282,7 +286,8 @@ func CheckServer(ctx context.Context, exec ssh.Executor, name string, opts Optio apps, stateKnown := readApps(ctx, exec, &srv, issue) sites, routesKnown := readSites(ctx, exec, &srv, issue) - srv.Addresses, srv.PublicAddressKnown = readAddresses(ctx, exec) + srv.Addresses, srv.PublicIPv4Known, srv.PublicIPv6Known = readAddresses(ctx, exec) + srv.PublicAddressKnown = srv.PublicIPv4Known || srv.PublicIPv6Known obtain := readObtainLog(ctx, exec, &srv, opts.LogWindow, now(), issue) @@ -293,7 +298,7 @@ func CheckServer(ctx context.Context, exec ssh.Executor, name string, opts Optio dnsCtx, cancel := context.WithTimeout(ctx, opts.DNSBudget) defer cancel() - applyDNS(dnsCtx, rows, srv.Addresses, srv.PublicAddressKnown, cache) + applyDNS(dnsCtx, rows, srv, cache) if len(srv.Issues) > 0 { srv.State = ServerPartial @@ -365,7 +370,7 @@ func readSites(ctx context.Context, exec ssh.Executor, srv *Server, issue func(s // no interface). The probe goes to the same operator as the first resolver, and // is best effort: without it, and with no public interface, a non-matching DNS // answer reads unknown rather than wrong_target. -func readAddresses(ctx context.Context, exec ssh.Executor) (addrs []string, publicKnown bool) { +func readAddresses(ctx context.Context, exec ssh.Executor) (addrs []string, publicV4, publicV6 bool) { seen := map[string]bool{} add := func(raw string) { ip := net.ParseIP(strings.TrimSpace(raw)) @@ -378,7 +383,11 @@ func readAddresses(ctx context.Context, exec ssh.Executor) (addrs []string, publ addrs = append(addrs, s) } if isPublicIP(ip) { - publicKnown = true + if ip.To4() != nil { + publicV4 = true + } else { + publicV6 = true + } } } add(exec.Host()) @@ -396,7 +405,7 @@ func readAddresses(ctx context.Context, exec ssh.Executor) (addrs []string, publ } } sort.Strings(addrs) - return addrs, publicKnown + return addrs, publicV4, publicV6 } var cgnat = func() *net.IPNet { _, n, _ := net.ParseCIDR("100.64.0.0/10"); return n }() @@ -413,7 +422,7 @@ func logCommand(window time.Duration) string { return `o=$(docker container inspect -f '{{.State.Running}}' caddy 2>&1) || { case "$o" in *"No such"*) echo TEPLOY_CADDY_ABSENT;; *) echo TEPLOY_CADDY_UNKNOWN;; esac; exit 0; }; ` + `echo "TEPLOY_CADDY_RUNNING=$o"; ` + `t=; command -v timeout >/dev/null 2>&1 && t='timeout 30'; ` + - `printf 'TEPLOY_LOG_FIRST '; docker logs caddy 2>&1 | head -n 1 | cut -c1-300; ` + + `printf 'TEPLOY_LOG_FIRST %s\n' "$(docker logs caddy 2>&1 | head -n 1 | cut -c1-300)"; ` + `{ $t docker logs --since ` + since + ` caddy 2>&1; echo "TEPLOY_LOG_RC=$?"; } | ` + `grep -E '"logger":"tls\.(obtain|renew)"|^TEPLOY_LOG_RC=' | ` + `grep -E '"level":"error"|obtained successfully|renewed successfully|^TEPLOY_LOG_RC=' | ` + @@ -487,6 +496,7 @@ func parseObtainOutput(out string, now time.Time, window time.Duration) obtainOu rc := -1 logLines := 0 + unparsed := 0 haveFirst := false for _, line := range lines[1:] { line = strings.TrimSpace(line) @@ -519,7 +529,9 @@ func parseObtainOutput(out string, now time.Time, window time.Duration) obtainOu rc, _ = strconv.Atoi(strings.TrimPrefix(line, "TEPLOY_LOG_RC=")) case line != "": logLines++ - recordObtainLine(res.stats, line) + if !recordObtainLine(res.stats, line) { + unparsed++ + } } } if rc != 0 { @@ -532,6 +544,14 @@ func parseObtainOutput(out string, now time.Time, window time.Duration) obtainOu } else { res.logs = LogsShort } + // A failure line that cannot be attributed could be anyone's: zero failures + // is not believable for any name. + if unparsed > 0 { + if res.logs == LogsOK { + res.logs = LogsPartial + } + res.problem = fmt.Sprintf("%d certificate log line(s) could not be read", unparsed) + } return res } @@ -547,7 +567,8 @@ func unixFloat(ts float64) time.Time { return time.Unix(sec, int64((ts-float64(sec))*1e9)).UTC() } -func recordObtainLine(stats map[string]*ObtainStats, line string) { +// recordObtainLine reports false for a matched line it could not attribute. +func recordObtainLine(stats map[string]*ObtainStats, line string) bool { var rec struct { Level string `json:"level"` TS float64 `json:"ts"` @@ -557,7 +578,7 @@ func recordObtainLine(stats map[string]*ObtainStats, line string) { Elapsed float64 `json:"elapsed"` } if json.Unmarshal([]byte(line), &rec) != nil || rec.TS == 0 { - return + return false } id := rec.Identifier if id == "" { @@ -567,7 +588,7 @@ func recordObtainLine(stats map[string]*ObtainStats, line string) { } id = strings.ToLower(strings.TrimSpace(id)) if id == "" { - return + return false } s := stats[id] if s == nil { @@ -581,7 +602,7 @@ func recordObtainLine(stats map[string]*ObtainStats, line string) { s.FailingSince = time.Time{} s.LastSuccessAt = at } - return + return true } // Each attempt logs one line per issuer plus one "will retry"; the retry // line is the attempt count and carries how long the obtain has been failing. @@ -600,6 +621,19 @@ func recordObtainLine(stats map[string]*ObtainStats, line string) { if rec.Error != "" { s.LastError = cleanText(rec.Error, maxErrorChars) } + return true +} + +// statsFor finds a host's certificate history: its own, or the wildcard +// certificate that covers it. +func statsFor(stats map[string]*ObtainStats, host string) *ObtainStats { + if s := stats[host]; s != nil { + return s + } + if i := strings.IndexByte(host, '.'); i >= 0 { + return stats["*"+host[i:]] + } + return nil } // credentialURL strips userinfo from anything URL-shaped in a stored error. @@ -902,7 +936,7 @@ func applyTLS(rows []Domain, stats map[string]*ObtainStats, status CaddyStatus) if row.Route.TLS != caddy.SiteTLSACME || row.Skip == SkipIPLiteral || row.Skip == SkipPrivateName { continue } - s := stats[row.Host] + s := statsFor(stats, row.Host) if s == nil { if status.Logs == LogsOK { zero := 0 @@ -937,7 +971,7 @@ func applyTLS(rows []Domain, stats map[string]*ObtainStats, status CaddyStatus) // applyDNS runs the public lookups concurrently, each row bounded by the DNS // budget and the shared limiter. -func applyDNS(ctx context.Context, rows []Domain, addrs []string, publicKnown bool, cache *DNSCache) { +func applyDNS(ctx context.Context, rows []Domain, srv Server, cache *DNSCache) { var wg sync.WaitGroup for i := range rows { row := &rows[i] @@ -961,7 +995,7 @@ func applyDNS(ctx context.Context, rows []Domain, addrs []string, publicKnown bo defer wg.Done() ans := cache.get(ctx, row.Host) row.DNS.Answers = append([]string{}, ans.Answers...) - row.DNS.Status = dns.Classify(ans, addrs, publicKnown) + row.DNS.Status = dns.Classify(ans, srv.Addresses, srv.PublicIPv4Known, srv.PublicIPv6Known) switch row.DNS.Status { case dns.StatusNXDomain: row.DNS.Detail = "no address record at the public resolvers" @@ -971,7 +1005,7 @@ func applyDNS(ctx context.Context, rows []Domain, addrs []string, publicKnown bo if ans.Err != nil { row.DNS.Detail = "public resolvers did not answer" } else { - row.DNS.Detail = "this server's public address could not be determined" + row.DNS.Detail = "this server's public address for that record type could not be determined" } } }(row) diff --git a/internal/domains/domains_test.go b/internal/domains/domains_test.go index 3f13b8b..e44e501 100644 --- a/internal/domains/domains_test.go +++ b/internal/domains/domains_test.go @@ -5,6 +5,9 @@ import ( "encoding/base64" "encoding/json" "fmt" + "os" + "os/exec" + "path/filepath" "strings" "sync/atomic" "testing" @@ -467,3 +470,84 @@ func TestCheckServer_PreviewRoutes(t *testing.T) { t.Errorf("dead preview = %+v", r.Route) } } + +// A failing wildcard certificate is the certificate of every name under it. +func TestCheckServer_WildcardObtainCoversItsHosts(t *testing.T) { + failing := fmt.Sprintf(`{"level":"error","ts":%s,"logger":"tls.obtain","msg":"will retry","error":"[*.foo.dev] Obtain: dns challenge failed","elapsed":200000}`, ts(checkNow.Add(-time.Hour))) + caddyfile := "*.foo.dev {\n\treverse_proxy a:1\n}\n\nown.foo.dev {\n\treverse_proxy a:1\n}\n" + good := fmt.Sprintf(`{"level":"info","ts":%s,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"own.foo.dev"}`, ts(checkNow.Add(-time.Hour))) + m := fleetMock(caddyfile, logsOK(failing, good), map[string]string{"x": appState("caddy", "x.foo.dev, own.foo.dev")}, "0 a\n") + _, rows := runCheck(t, m, staticDNS(nil), Options{}) + if r := rows["x.foo.dev"]; r.TLS.Status != TLSFailing || r.TLS.FailingSince == nil { + t.Errorf("a host under a failing wildcard read %+v", r.TLS) + } + if r := rows["*.foo.dev"]; r.TLS.Status != TLSFailing || r.Skip != SkipWildcard { + t.Errorf("wildcard row = %+v", r) + } + if r := rows["own.foo.dev"]; r.TLS.Status != TLSOK { + t.Errorf("its own certificate wins: %+v", r.TLS) + } +} + +func TestCheckServer_AddressFamiliesAreKnownSeparately(t *testing.T) { + // A NAT'd home box: public IPv6 on an interface, and the IPv4 probe failed. + natted := func() *ssh.MockExecutor { + return ssh.NewMockExecutor("100.64.0.5", + ssh.MockCommand{Match: "for f in /deployments/*/state.json", Output: "blog"}, + ssh.MockCommand{Match: "for app in", Output: snapshotOutput(map[string]string{"blog": appState("caddy", "blog.dev")})}, + ssh.MockCommand{Match: "hostname -I", Output: "192.168.1.5 2001:569:7dba:a00::1"}, + ssh.MockCommand{Match: "curl -4", Output: ""}, + ssh.MockCommand{Match: "o=$(docker container inspect", Output: logsOK()}, + ) + } + srv, rows := runCheck(t, natted(), staticDNS(map[string]dns.PublicAnswer{"blog.dev": {Answers: []string{"207.6.43.143"}}}), Options{}) + if srv.PublicIPv4Known || !srv.PublicIPv6Known || !srv.PublicAddressKnown { + t.Fatalf("server = %+v", srv) + } + if r := rows["blog.dev"]; r.DNS.Status != dns.StatusUnknown { + t.Errorf("a correct A answer must not read wrong_target when the IPv4 is unknown: %+v", r.DNS) + } + _, rows = runCheck(t, natted(), staticDNS(map[string]dns.PublicAnswer{"blog.dev": {Answers: []string{"2001:db8::9"}}}), Options{}) + if r := rows["blog.dev"]; r.DNS.Status != dns.StatusWrongTarget { + t.Errorf("an AAAA answer in a known family is evidence: %+v", r.DNS) + } +} + +// Runs the real command in a real shell against a fake docker: an empty log used +// to glue the status marker onto the first-line marker. +func TestLogCommand_EmptyLogInARealShell(t *testing.T) { + dir := t.TempDir() + fake := `#!/bin/sh +case "$1" in +container) echo true ;; +logs) ;; +esac +` + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(fake), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/sh", "-c", logCommand(14*24*time.Hour)) + cmd.Env = append(os.Environ(), "PATH="+dir+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%v: %s", err, out) + } + res := parseObtainOutput(string(out), checkNow, 14*24*time.Hour) + if res.logs != LogsShort || res.problem != "" { + t.Errorf("an empty log is short, not unreadable: %+v\noutput: %q", res, out) + } +} + +// A long error cut by the size bound cannot be attributed, so no name can claim +// zero failures; a name with its own evidence still reports it. +func TestParseObtainOutput_UnparseableLineMeansUnknown(t *testing.T) { + cut := fmt.Sprintf(`{"level":"error","ts":%s,"logger":"tls.obtain","msg":"will retry","error":"[other.dev] Obtain: %s`, ts(checkNow), strings.Repeat("x", 50)) + caddyfile := "blog.dev {\n\treverse_proxy a:1\n}\n" + srv, rows := runCheck(t, fleetMock(caddyfile, logsOK(cut), map[string]string{}, "0 a\n"), staticDNS(nil), Options{}) + if srv.Caddy.Logs != LogsPartial || srv.State != ServerPartial { + t.Errorf("server = %+v", srv) + } + if r := rows["blog.dev"]; r.TLS.Status != TLSUnknown || r.TLS.Failures != nil { + t.Errorf("zero is not believable with an unread failure line: %+v", r.TLS) + } +} diff --git a/internal/releasemeta/releasemeta.go b/internal/releasemeta/releasemeta.go index 2bcb230..ee194e7 100644 --- a/internal/releasemeta/releasemeta.go +++ b/internal/releasemeta/releasemeta.go @@ -91,13 +91,24 @@ type CaddyRoute struct { TLSCert string `json:"tls_cert,omitempty"` TLSKey string `json:"tls_key,omitempty"` TLSInternal bool `json:"tls_internal,omitempty"` - TLSHTTPOnly bool `json:"tls_http_only,omitempty"` + // TLSHTTPOnly is nil on records written before this field existed; every + // caddy release since records it, so the record can turn the flag off too. + TLSHTTPOnly *bool `json:"tls_http_only,omitempty"` CaddyExtra string `json:"caddy_extra,omitempty"` Cache map[string]string `json:"cache,omitempty"` Firewall *caddy.Firewall `json:"firewall,omitempty"` Access *caddy.Access `json:"access,omitempty"` } +// HTTPOnlyOr is the recorded plain-HTTP setting, or fallback on a record too old +// to carry one. +func (r *CaddyRoute) HTTPOnlyOr(fallback bool) bool { + if r == nil || r.TLSHTTPOnly == nil { + return fallback + } + return *r.TLSHTTPOnly +} + // Static records a type:static release's serving configuration — the piece // state.json never retained, which is what made state-only static rollback // impossible before F13/F14.