| index 16455f514..3a41e9dbb 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -46,6 +46,18 @@ |
| // with a public hostname; recorded here only so the fleet status |
| // page's entire config surface is documented in one place rather than |
| // split across this file and the tofu module. |
| +// |
| +// Domains config surface (read by domainsLoop / domainsTickConfig): |
| +// |
| +// - PLATFORM_APPS_DOMAIN — the deployment's platform apps domain |
| +// (flagship: oncarlos.com). UNSET = the domains feature is inert: no |
| +// platform domains minted, no bucket manifests read, no health stamps — |
| +// zero behavior change, same litestream-convention gate as the loops |
| +// above. |
| +// - PLATFORM_EDGE_IPS — comma-separated edge IPs custom-domain DNS checks |
| +// compare against. Default: this box's public IPv4 from IMDS |
| +// (best-effort; if neither is knowable, DNS stamping stands down |
| +// entirely rather than guess "elsewhere" about every host). |
| package host |
| |
| import ( |
| @@ -55,6 +67,8 @@ import ( |
| "io" |
| "log" |
| "math/rand/v2" |
| + "net" |
| + "net/http" |
| "os" |
| "os/exec" |
| "path" |
| @@ -63,6 +77,7 @@ import ( |
| "time" |
| |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -119,6 +134,7 @@ func RunAgent(args []string) { |
| |
| go adoptLoop(reg) |
| go statusLoop(reg) |
| + go domainsLoop(reg, peekAcmeCache(args)) |
| |
| // The same serving loop as `carlos edge`, with the activator injected. |
| edge.RunWithActivator(args, act) |
| @@ -181,6 +197,150 @@ func adoptLoop(reg *registry.Registry) { |
| } |
| } |
| |
| +// peekAcmeCache pre-parses the agent args for -acme-cache the same way |
| +// peekRegistryPath peeks -registry: the edge does the authoritative parse, |
| +// this just lets the domains loop stamp cert expiry from the SAME cache dir |
| +// the edge actually mints into when the operator moved it off the default. |
| +// "" = flag not given; domainsTickConfig then derives the edge's own default |
| +// ($CARLOS_DATA_DIR/acme). |
| +func peekAcmeCache(args []string) string { |
| + fs := flag.NewFlagSet("agent", flag.ContinueOnError) |
| + fs.SetOutput(io.Discard) |
| + fs.String("registry", "", "") |
| + acme := fs.String("acme-cache", "", "") |
| + fs.String("https", "", "") |
| + fs.String("http", "", "") |
| + fs.String("fallback", "", "") |
| + fs.Bool("dev", false, "") |
| + _ = fs.Parse(args) |
| + return *acme |
| +} |
| + |
| +// domainsTickConfig builds one reconcile pass's Config from the environment |
| +// — pure (getenv + the already-read public IP injected) so the whole env |
| +// contract is testable without t.Setenv. PLATFORM_APPS_DOMAIN unset returns |
| +// the zero Config, on which Reconcile is inert by contract. When no edge IP |
| +// is knowable at all (no PLATFORM_EDGE_IPS, no IMDS answer), Resolve stays |
| +// nil so DNS stamping stands down: with nothing to compare a resolution |
| +// against, "pointing" vs "elsewhere" would be a guess, and unknowns are |
| +// never guessed. |
| +func domainsTickConfig(getenv func(string) string, publicIP string) domains.Config { |
| + domain := strings.TrimSpace(getenv("PLATFORM_APPS_DOMAIN")) |
| + if domain == "" { |
| + return domains.Config{} |
| + } |
| + var ips []string |
| + for _, ip := range strings.Split(getenv("PLATFORM_EDGE_IPS"), ",") { |
| + if ip = strings.TrimSpace(ip); ip != "" { |
| + ips = append(ips, ip) |
| + } |
| + } |
| + if len(ips) == 0 && publicIP != "" { |
| + ips = []string{publicIP} |
| + } |
| + dd := getenv("CARLOS_DATA_DIR") |
| + if dd == "" { |
| + dd = "/data" |
| + } |
| + cfg := domains.Config{ |
| + AppsDomain: domain, |
| + EdgeIPs: ips, |
| + // The same default the edge's -acme-cache flag carries |
| + // (edge.RunWithActivator): $CARLOS_DATA_DIR/acme. domainsLoop |
| + // overrides this when the flag was given explicitly. |
| + AcmeDir: filepath.Join(dd, "acme"), |
| + Now: time.Now, |
| + } |
| + if len(ips) > 0 { |
| + cfg.Resolve = resolveHost |
| + } |
| + return cfg |
| +} |
| + |
| +// resolveHost is the production DNS seam: the default resolver with a hard |
| +// 3s timeout, so one dead nameserver can't stall a whole reconcile pass. |
| +func resolveHost(host string) ([]string, error) { |
| + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| + defer cancel() |
| + return net.DefaultResolver.LookupHost(ctx, host) |
| +} |
| + |
| +// readPublicIP best-effort reads this box's public IPv4 from EC2 IMDSv2 |
| +// (token PUT, then the public-ipv4 leaf). Any failure — not on EC2, IMDS |
| +// disabled, no public address — returns "", and the caller treats that as |
| +// "unknown", never as an error: a box without a discoverable public IP is a |
| +// valid deployment whose DNS checks simply stand down. |
| +func readPublicIP() string { |
| + client := &http.Client{Timeout: 2 * time.Second} |
| + req, err := http.NewRequest(http.MethodPut, "http://169.254.169.254/latest/api/token", nil) |
| + if err != nil { |
| + return "" |
| + } |
| + req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "60") |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + return "" |
| + } |
| + token, _ := io.ReadAll(resp.Body) |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return "" |
| + } |
| + req, err = http.NewRequest(http.MethodGet, "http://169.254.169.254/latest/meta-data/public-ipv4", nil) |
| + if err != nil { |
| + return "" |
| + } |
| + req.Header.Set("X-aws-ec2-metadata-token", strings.TrimSpace(string(token))) |
| + resp, err = client.Do(req) |
| + if err != nil { |
| + return "" |
| + } |
| + defer resp.Body.Close() |
| + body, _ := io.ReadAll(resp.Body) |
| + if resp.StatusCode != http.StatusOK { |
| + return "" |
| + } |
| + ip := strings.TrimSpace(string(body)) |
| + if net.ParseIP(ip) == nil { |
| + return "" |
| + } |
| + return ip |
| +} |
| + |
| +// domainsLoop runs the domains reconcile pass forever on the STATUS tick's |
| +// cadence (5min + jitter), not the adopt loop's hour: the console's |
| +// custom-domain guidance promises "Checks run every few minutes", and a DNS |
| +// fix that takes up to an hour to turn green would read as broken. Thin and |
| +// untested by convention (like adoptLoop/statusLoop) — everything with |
| +// behavior lives in domainsTickConfig and domains.Reconcile. Missing config |
| +// disables the loop with one log line, the same inert-until-configured |
| +// shape as its siblings. |
| +func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| + ctx := context.Background() |
| + cfg := domainsTickConfig(os.Getenv, readPublicIP()) |
| + if cfg.AppsDomain == "" { |
| + log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set") |
| + return |
| + } |
| + if acmeCacheFlag != "" { |
| + cfg.AcmeDir = acmeCacheFlag |
| + } |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Printf("agent: domains loop disabled: %v", err) |
| + return |
| + } |
| + log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, edge IPs %v", cfg.AppsDomain, cfg.EdgeIPs) |
| + delay := time.Minute |
| + for { |
| + time.Sleep(delay) |
| + delay = 5*time.Minute + rand.N(time.Minute) |
| + if err := domains.Reconcile(ctx, store, reg, cfg); err != nil { |
| + log.Printf("agent: domains pass: %v", err) |
| + } |
| + } |
| +} |
| + |
| // resolveStatusBucket applies the status/deployment bucket defaulting |
| // documented in this file's package comment: an explicit CARLOS_STATUS_BUCKET |
| // always wins, and when it's unset the status loop falls back to whatever |