From aca3e069ace543e416da7b8385ef5577570eb8a1 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 7 Nov 2026 04:34:55 -0800 Subject: [PATCH 1/6] fix(status): judge certificates only from resolvers Onebox configures A resolver key left in acme.json by an older proxy configuration keeps a certificate that nothing renews. ob status judged it like any other and reported a healthy host as diverged once it entered the renewal floor. Certificates now carry the resolver key and store file they came from. Only certificates from letsencrypt and onebox-wildcard can be overdue. Others are listed as orphaned, with the key to remove, and never set Diverged. Both status views share one judging helper and one sort order. Fixes #235 --- internal/engine/proxystatus.go | 92 ++++++++++++++++++++----- internal/engine/proxystatus_test.go | 91 +++++++++++++++++++++++- internal/engine/status_snapshot.go | 25 +++---- internal/engine/status_snapshot_test.go | 25 +++++++ internal/proxy/proxy.go | 13 ++-- internal/proxy/proxy_test.go | 2 +- 6 files changed, 213 insertions(+), 35 deletions(-) diff --git a/internal/engine/proxystatus.go b/internal/engine/proxystatus.go index 0a7ba88d..d58fc9de 100644 --- a/internal/engine/proxystatus.go +++ b/internal/engine/proxystatus.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "os" + "sort" "strconv" "strings" + "time" "github.com/labstack/onebox/internal/app" "github.com/labstack/onebox/internal/proxy" @@ -20,13 +22,13 @@ const renewalFloorDays = 21 // app-side reads. type proxyRaw struct { ids []string - health string // proxy container health, parsed from docker ps .Status - discovery bool // isolated Docker discovery controller is running - applied string // config and Compose identity the host applied - owner string // sole application identity from the host owner record - ownerEnv string // environment identity from the owner record - acme []string // raw ACME stores; parsed at render, and keys never leave - localHash string // locally staged config and Compose identity (computed offline) + health string // proxy container health, parsed from docker ps .Status + discovery bool // isolated Docker discovery controller is running + applied string // config and Compose identity the host applied + owner string // sole application identity from the host owner record + ownerEnv string // environment identity from the owner record + acme []acmeStore // raw ACME stores; parsed at render, and keys never leave + localHash string // locally staged config and Compose identity (computed offline) // Why a read could not be trusted, when it could not. Recorded rather // than raised: gather returns on the first error and Status renders // nothing after it, so raising costs the operator every other fact about @@ -167,7 +169,7 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { if res.ExitCode != 0 { return statusReadResult("proxy certificate store", res, nil) } - px.acme = append(px.acme, res.Stdout) + px.acme = append(px.acme, acmeStore{file: name, body: res.Stdout}) } return nil }, @@ -314,35 +316,93 @@ func (e *Engine) renderProxy(px proxyRaw) (bool, error) { fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%s)\n", px.acmeIssue) return true, nil } - certs, err := proxyCertExpiries(px.acme) + certs, err := judgeCertificates(px.acme, e.Opts.Now()) if err != nil { fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%v)\n", err) return true, nil } for _, c := range certs { - days := int(c.NotAfter.Sub(e.Opts.Now()).Hours() / 24) mark := "" - if days < renewalFloorDays { + switch { + case c.Orphaned: + mark = e.ui.Dim(fmt.Sprintf(" not renewed: resolver %q is not configured (remove it from %s while the proxy is stopped)", c.Resolver, c.Store)) + case c.Overdue: mark = e.ui.Warn(" RENEWAL OVERDUE ⚠ (lego renews at 30d — check CF_DNS_API_TOKEN / proxy logs)") diverged = true } - e.ui.Println(fmt.Sprintf(" cert %-20s expires %s %s%s", c.Domain, c.NotAfter.UTC().Format("2006-01-02"), e.ui.Dim(fmt.Sprintf("(%dd)", days)), mark)) + e.ui.Println(fmt.Sprintf(" cert %-20s expires %s %s%s", c.Domain, c.NotAfter.UTC().Format("2006-01-02"), e.ui.Dim(fmt.Sprintf("(%dd)", c.Days)), mark)) } return diverged, nil } -func proxyCertExpiries(stores []string) ([]proxy.CertExpiry, error) { - var out []proxy.CertExpiry +// acmeStore is one ACME store file as read from the host. The body holds +// private keys, so it is parsed in place and never rendered. +type acmeStore struct { + file string + body string +} + +// judgedCertificate is one certificate from an ACME store with the verdict +// that both status views report. +type judgedCertificate struct { + proxy.CertExpiry + Store string // store file the certificate was read from + Days int // whole days until expiry + Overdue bool // a managed resolver failed to renew it in time + Orphaned bool // its resolver is not one Onebox configures +} + +// judgeCertificates parses every store and judges each certificate by the +// resolver that issued it. Only the resolvers Onebox configures renew +// certificates for Onebox routes, so only their certificates can be overdue. +// A key under any other name is left behind by an older proxy configuration: +// nothing renews it, and judging its expiry would report a healthy host as +// diverged forever. Those certificates are reported as orphaned instead, so +// the operator can remove the stale key. +// +// Certificates are sorted, because the store's resolver keys parse from a map +// and both views must print the same order on every run. +func judgeCertificates(stores []acmeStore, now time.Time) ([]judgedCertificate, error) { + var out []judgedCertificate for _, store := range stores { - certs, err := proxy.CertExpiries([]byte(store)) + certs, err := proxy.CertExpiries([]byte(store.body)) if err != nil { return nil, err } - out = append(out, certs...) + for _, cert := range certs { + days := int(cert.NotAfter.Sub(now).Hours() / 24) + orphaned := !managedCertificateResolver(cert.Resolver) + out = append(out, judgedCertificate{ + CertExpiry: cert, + Store: store.file, + Days: days, + Overdue: !orphaned && days < renewalFloorDays, + Orphaned: orphaned, + }) + } } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.Domain != b.Domain { + return a.Domain < b.Domain + } + if a.Resolver != b.Resolver { + return a.Resolver < b.Resolver + } + if a.Store != b.Store { + return a.Store < b.Store + } + return a.NotAfter.Before(b.NotAfter) + }) return out, nil } +// managedCertificateResolver reports whether name is a resolver that Onebox's +// routes use. A custom static configuration must define these same names. +func managedCertificateResolver(name string) bool { + return name == app.ManagedCertificateResolver || name == app.ManagedWildcardCertificateResolver +} + func proxyDriftParts(applied string, local proxy.Identity) []string { remote, ok := proxy.ParseIdentity(applied) if _, valid := proxy.ParseIdentity(local.String()); !ok || !valid { diff --git a/internal/engine/proxystatus_test.go b/internal/engine/proxystatus_test.go index 69d3d1c2..b1eb7a72 100644 --- a/internal/engine/proxystatus_test.go +++ b/internal/engine/proxystatus_test.go @@ -25,6 +25,31 @@ import ( ) func acmeFixture(t *testing.T, domain string, notAfter time.Time) string { + t.Helper() + return acmeStoreFixture(map[string][]string{ + app.ManagedCertificateResolver: {acmeCertificate(t, domain, notAfter)}, + }) +} + +// acmeStoreFixture builds a store from resolver keys and their certificate +// entries, as written by acmeCertificate. +func acmeStoreFixture(resolvers map[string][]string) string { + var body strings.Builder + body.WriteString("{") + first := true + for name, certs := range resolvers { + if !first { + body.WriteString(",") + } + first = false + fmt.Fprintf(&body, `%q:{"Certificates":[%s]}`, name, strings.Join(certs, ",")) + } + body.WriteString("}") + return body.String() +} + +// acmeCertificate is one entry of a resolver's Certificates list. +func acmeCertificate(t *testing.T, domain string, notAfter time.Time) string { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -41,7 +66,7 @@ func acmeFixture(t *testing.T, domain string, notAfter time.Time) string { t.Fatal(err) } pemB := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - return fmt.Sprintf(`{"le":{"Certificates":[{"domain":{"main":%q},"certificate":%q}]}}`, + return fmt.Sprintf(`{"domain":{"main":%q},"certificate":%q}`, domain, base64.StdEncoding.EncodeToString(pemB)) } @@ -210,6 +235,70 @@ func TestStatusManagedProxyCertRenewalOverdue(t *testing.T) { } } +// staleResolverStore is the store from issue #235: a resolver key left behind +// by an older proxy configuration still holds a certificate for a domain that +// the configured resolver also covers. Nothing renews the leftover, so it sits +// inside the renewal floor while the served certificate has plenty of runway. +func staleResolverStore(t *testing.T) string { + t.Helper() + return acmeStoreFixture(map[string][]string{ + "le": {acmeCertificate(t, "app.example.com", time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC))}, // 7d out + app.ManagedCertificateResolver: {acmeCertificate(t, "app.example.com", time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC))}, // 78d out + }) +} + +func TestStatusIgnoresCertificateFromUnconfiguredResolver(t *testing.T) { + applied := "" + e, _, out, _ := statusProxyEngine(t, &applied, staleResolverStore(t), "healthy") + if err := e.Status(context.Background()); err != nil { + t.Fatalf("an orphaned resolver's certificate must not make status diverge: %v\n%s", err, out.String()) + } + if strings.Contains(out.String(), "RENEWAL OVERDUE") { + t.Fatalf("orphaned certificate must not be flagged overdue:\n%s", out.String()) + } + if !strings.Contains(out.String(), `resolver "le" is not configured`) || !strings.Contains(out.String(), "acme.json") { + t.Fatalf("orphaned certificate must name its resolver and store:\n%s", out.String()) + } +} + +// A newer certificate under a leftover resolver is not served, so it must not +// hide a managed resolver whose renewal is failing. +func TestStatusFlagsManagedCertificateEvenWhenOrphanedOneIsNewer(t *testing.T) { + applied := "" + acme := acmeStoreFixture(map[string][]string{ + app.ManagedCertificateResolver: {acmeCertificate(t, "app.example.com", time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC))}, // 10d out + "le": {acmeCertificate(t, "app.example.com", time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC))}, // 60d out + }) + e, _, out, _ := statusProxyEngine(t, &applied, acme, "healthy") + if err := e.Status(context.Background()); err == nil { + t.Fatalf("the managed certificate is overdue, so status must diverge:\n%s", out.String()) + } + if n := strings.Count(out.String(), "RENEWAL OVERDUE"); n != 1 { + t.Fatalf("exactly the managed certificate must be flagged; got %d:\n%s", n, out.String()) + } +} + +func TestStatusJudgesWildcardResolverCertificates(t *testing.T) { + applied := "" + wildcard := acmeStoreFixture(map[string][]string{ + app.ManagedWildcardCertificateResolver: {acmeCertificate(t, "*.preview.example.com", time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC))}, + }) + e, f, out, _ := statusProxyEngine(t, &applied, acmeFixture(t, "app.example.com", time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)), "healthy") + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + if strings.Contains(cmd, "acme-wildcard.json") { + return transport.Result{Stdout: wildcard}, true + } + return base(cmd) + } + if err := e.Status(context.Background()); err == nil { + t.Fatalf("an overdue wildcard certificate must make status diverge:\n%s", out.String()) + } + if !strings.Contains(out.String(), "RENEWAL OVERDUE") { + t.Fatalf("overdue wildcard certificate must be flagged:\n%s", out.String()) + } +} + // An up-but-unhealthy proxy (health parsed from ps .Status) must force // divergence — all other proxy tests only ever pass "healthy". func TestStatusManagedProxyUnhealthy(t *testing.T) { diff --git a/internal/engine/status_snapshot.go b/internal/engine/status_snapshot.go index c213239e..32e0881e 100644 --- a/internal/engine/status_snapshot.go +++ b/internal/engine/status_snapshot.go @@ -102,11 +102,17 @@ type StatusProxy struct { } // StatusCertificate is the public, private-key-free portion of an ACME entry. +// Resolver is the store key that holds the certificate. Orphaned marks a +// certificate under a resolver Onebox does not configure: nothing renews it, +// so it is never overdue and never makes the proxy diverge. type StatusCertificate struct { Domain string `json:"domain"` + Resolver string `json:"resolver"` + Store string `json:"store"` NotAfter time.Time `json:"not_after"` DaysRemaining int `json:"days_remaining"` RenewalOverdue bool `json:"renewal_overdue"` + Orphaned bool `json:"orphaned"` } // StatusWarning describes an observation that could not be completed. @@ -497,31 +503,26 @@ func makeStatusProxy(raw proxyRaw, readComplete []bool, now time.Time, applicati // input, the ungated form would answer "nothing is near expiry" about a // store it never opened. if complete(statusProxyCertificatesRead) && raw.acmeIssue == "" { - certs, err := proxyCertExpiries(raw.acme) + certs, err := judgeCertificates(raw.acme, now) if err != nil { status.Complete = false status.Issues = append(status.Issues, "certificate store is unreadable") parseWarning = &StatusWarning{Component: "proxy.certificates", Message: err.Error()} } else { for _, cert := range certs { - days := int(cert.NotAfter.Sub(now).Hours() / 24) - overdue := days < renewalFloorDays status.Certificates = append(status.Certificates, StatusCertificate{ Domain: cert.Domain, + Resolver: cert.Resolver, + Store: cert.Store, NotAfter: cert.NotAfter.UTC(), - DaysRemaining: days, - RenewalOverdue: overdue, + DaysRemaining: cert.Days, + RenewalOverdue: cert.Overdue, + Orphaned: cert.Orphaned, }) - if overdue { + if cert.Overdue { status.Issues = append(status.Issues, fmt.Sprintf("certificate renewal is overdue for %s", cert.Domain)) } } - sort.Slice(status.Certificates, func(i, j int) bool { - if status.Certificates[i].Domain == status.Certificates[j].Domain { - return status.Certificates[i].NotAfter.Before(status.Certificates[j].NotAfter) - } - return status.Certificates[i].Domain < status.Certificates[j].Domain - }) } } diff --git a/internal/engine/status_snapshot_test.go b/internal/engine/status_snapshot_test.go index 248b3bea..92bd6b25 100644 --- a/internal/engine/status_snapshot_test.go +++ b/internal/engine/status_snapshot_test.go @@ -633,6 +633,31 @@ func TestStatusSnapshotReportsRefusedProxyReadsWithoutLosingTheRest(t *testing.T } } +func TestStatusSnapshotIgnoresCertificateFromUnconfiguredResolver(t *testing.T) { + applied := "" + e, _, _, _ := statusProxyEngine(t, &applied, staleResolverStore(t), "healthy") + + snapshot, err := e.StatusSnapshot(context.Background()) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if snapshot.Diverged || snapshot.Proxy == nil || snapshot.Proxy.Diverged || len(snapshot.Proxy.Issues) != 0 { + t.Fatalf("an orphaned resolver's certificate must not make the snapshot diverge: %#v", snapshot.Proxy) + } + certs := snapshot.Proxy.Certificates + if len(certs) != 2 { + t.Fatalf("both certificates must stay listed: %#v", certs) + } + // Sorted by domain, then resolver: "le" before "letsencrypt". + orphan, served := certs[0], certs[1] + if orphan.Resolver != "le" || orphan.Store != "acme.json" || !orphan.Orphaned || orphan.RenewalOverdue || orphan.DaysRemaining != 7 { + t.Fatalf("leftover certificate must be orphaned, not overdue: %#v", orphan) + } + if served.Resolver != app.ManagedCertificateResolver || served.Orphaned || served.RenewalOverdue || served.DaysRemaining != 78 { + t.Fatalf("managed certificate must be judged normally: %#v", served) + } +} + func TestStatusSnapshotManagedProxy(t *testing.T) { applied := "" acme := acmeFixture(t, "app.example.com", time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 002475bd..76f1db8e 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -185,15 +185,18 @@ func sortedEntrypointNames(entrypoints map[string]app.ProxyEntrypoint) []string // CertExpiry is one issued certificate's identity and lifetime — the ONLY // data status extracts from acme.json (which also holds private keys; those -// never leave the parse). +// never leave the parse). Resolver is the store key the certificate sits +// under, which names the Traefik certificate resolver that issued it. type CertExpiry struct { + Resolver string Domain string NotAfter time.Time } // CertExpiries parses a lego/Traefik acme.json store and returns each -// certificate's main domain and expiry. An empty store (fresh acme.json is an -// empty file) returns nothing; malformed JSON errors so status can say so. +// certificate's resolver, main domain and expiry. An empty store (fresh +// acme.json is an empty file) returns nothing; malformed JSON errors so status +// can say so. func CertExpiries(acmeJSON []byte) ([]CertExpiry, error) { if len(strings.TrimSpace(string(acmeJSON))) == 0 { return nil, nil @@ -212,7 +215,7 @@ func CertExpiries(acmeJSON []byte) ([]CertExpiry, error) { return nil, fmt.Errorf("acme store: %w", err) } var out []CertExpiry - for _, resolver := range store { + for name, resolver := range store { for _, c := range resolver.Certificates { pemChain, err := base64.StdEncoding.DecodeString(c.Certificate) if err != nil { @@ -226,7 +229,7 @@ func CertExpiries(acmeJSON []byte) ([]CertExpiry, error) { if err != nil { continue } - out = append(out, CertExpiry{Domain: c.Domain.Main, NotAfter: cert.NotAfter}) + out = append(out, CertExpiry{Resolver: name, Domain: c.Domain.Main, NotAfter: cert.NotAfter}) } } return out, nil diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 381401e5..f328dcb4 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -641,7 +641,7 @@ func TestCertExpiries(t *testing.T) { if err != nil { t.Fatal(err) } - if len(certs) != 1 || certs[0].Domain != "app.example.com" || !certs[0].NotAfter.Equal(exp) { + if len(certs) != 1 || certs[0].Resolver != "le" || certs[0].Domain != "app.example.com" || !certs[0].NotAfter.Equal(exp) { t.Fatalf("got %+v", certs) } From 6a0153fec4e97f4495197b7965b4bb7b7e1d58a0 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 7 Nov 2026 04:47:55 -0800 Subject: [PATCH 2/6] chore(deps): upgrade Go and site dependencies Go: xo/terminfo v1.3.1, go.yaml.in/yaml/v4 v4.0.0. Site: astro 7.4.7, @astrojs/starlight 0.42.10, @astrojs/markdown-remark 7.3.6. spf13/pflag stays at v1.0.10: v1.1.0 prints every boolean flag as --flag[=true|false] in help output. TypeScript stays on 6.x. --- go.mod | 4 +- go.sum | 8 +- site/package-lock.json | 721 ++++++++++++++++++++++++++--------------- site/package.json | 4 +- 4 files changed, 463 insertions(+), 274 deletions(-) diff --git a/go.mod b/go.mod index b0ad3f92..d994dfd2 100644 --- a/go.mod +++ b/go.mod @@ -40,8 +40,8 @@ require ( github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/sirupsen/logrus v1.10.2 // indirect github.com/xhit/go-str2duration/v2 v2.2.0 // indirect - github.com/xo/terminfo v1.2.0 // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect + github.com/xo/terminfo v1.3.1 // indirect + go.yaml.in/yaml/v4 v4.0.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect diff --git a/go.sum b/go.sum index 09a30623..d896482b 100644 --- a/go.sum +++ b/go.sum @@ -72,13 +72,13 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/xhit/go-str2duration/v2 v2.2.0 h1:SofrD1D+8IuhhkP+OiG1ij4Zq+FiiYSEY/sORBanDG0= github.com/xhit/go-str2duration/v2 v2.2.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= -github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= -github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= +github.com/xo/terminfo v1.3.1 h1:4ZwPCuu4PEFw0PkNp3Hv/x3gqvgLCzYhurszsj101cM= +github.com/xo/terminfo v1.3.1/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= -go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go.yaml.in/yaml/v4 v4.0.0 h1:/LG1pNO84Z4l603j/xMn69CcS5OYhMQ/f7l2LoQUGuw= +go.yaml.in/yaml/v4 v4.0.0/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= diff --git a/site/package-lock.json b/site/package-lock.json index 7e1299ba..8d610bf1 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -9,10 +9,10 @@ "version": "0.0.0", "dependencies": { "@astrojs/markdown-remark": "^7.3.4", - "@astrojs/starlight": "0.42.7", + "@astrojs/starlight": "0.42.10", "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", - "astro": "7.4.2", + "astro": "7.4.7", "sharp": "^0.35.5" }, "devDependencies": { @@ -220,9 +220,9 @@ } }, "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", - "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.7.tgz", + "integrity": "sha512-zJccwplOAbgfZju95IUHazLnx9RAPtPN2YGJXI6UbrgeaR9QfPjGrQL6ERkSAtnVCA4Q+83ApGyaX5zdpCaKkQ==", "license": "MIT", "optional": true, "dependencies": { @@ -236,8 +236,8 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + "@emnapi/core": "^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3", + "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3" } }, "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { @@ -342,20 +342,20 @@ } }, "node_modules/@astrojs/markdown-remark": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.3.4.tgz", - "integrity": "sha512-AB8S4EQKSryzH624iMDtvC+1AD5nSPzYpCXxc7e1H5ydXhv9B5Td2QfCPA0JyEwqsJpsIsPlW0iOqiy1imdqYw==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.3.6.tgz", + "integrity": "sha512-L90VeM6yRCjTiqJvHmIZo2GwSYcWlvX/30T8S03NH2LUShiKIfCQusvQWFUU6sjmI07WouDPv2kz30SEaVCcdQ==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.13.0", - "@astrojs/prism": "4.1.1", + "@astrojs/internal-helpers": "0.13.1", + "@astrojs/prism": "4.1.2", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.19.0", "estree-util-visit": "^2.0.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", - "hast-util-to-html": "^9.0.5", - "hast-util-to-text": "^4.0.2", + "hast-util-to-html": "^9.0.6", + "hast-util-to-text": "^4.0.3", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", @@ -366,11 +366,39 @@ "source-map": "^0.8.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", - "unist-util-visit": "^5.1.0", - "unist-util-visit-parents": "^6.0.2", + "unist-util-visit": "^5.2.0", + "unist-util-visit-parents": "^6.1.0", "vfile": "^6.0.3" } }, + "node_modules/@astrojs/markdown-remark/node_modules/@astrojs/internal-helpers": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.13.1.tgz", + "integrity": "sha512-LgGLGzoC0wCT3mjDYrxz+hct/tws0enpWsLJW3bswHR3PKgusp/H9xRIT8rTdsyvLM0TcRhhvRL30J2oaZNcqA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "picomatch": "^4.0.7", + "retext-smartypants": "^6.2.0", + "shiki": "^4.5.0", + "smol-toml": "^1.9.1", + "unified": "^11.0.5", + "yaml": "^2.9.1" + } + }, + "node_modules/@astrojs/markdown-remark/node_modules/@astrojs/prism": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.1.2.tgz", + "integrity": "sha512-jMrUEpFDarDO1KWPvVc9eSa8DSfwBlwEOHwX+8zvZQpiqXRGDxFnOXHsYqV6DP7w+0qIgTr98gS1GdQlm8ypWg==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.23.4" + } + }, "node_modules/@astrojs/markdown-satteri": { "version": "0.4.5", "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.5.tgz", @@ -420,28 +448,26 @@ } }, "node_modules/@astrojs/sitemap": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz", - "integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz", + "integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==", "license": "MIT", "dependencies": { "sitemap": "^9.0.0", - "stream-replace-string": "^2.0.0", "zod": "^4.3.6" } }, "node_modules/@astrojs/starlight": { - "version": "0.42.7", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.7.tgz", - "integrity": "sha512-mlbit5iRjLx5eIcLf84Q2CwkrrsEgZrQwKKBK+vRf+ZGiSrwy1UGdT1hUfAIaHrMfcvkDpL+gw3aua7HobiKFg==", + "version": "0.42.10", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.10.tgz", + "integrity": "sha512-gdy/kWrcEKv4fJLTdO+zwPf4auBsDZNb7diklRT9LCR4MsTaMHRa15V3o6YACnOoZMflqxY9/3BLmExb/cne9g==", "license": "MIT", "dependencies": { - "@astrojs/markdown-satteri": "^0.4.0", - "@astrojs/mdx": "^8.0.0", - "@astrojs/sitemap": "^3.7.3", + "@astrojs/markdown-satteri": "^0.4.3", + "@astrojs/mdx": "^8.0.3", + "@astrojs/sitemap": "^3.7.4", "@pagefind/default-ui": "^1.3.0", "@types/hast": "^3.0.5", - "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "astro-expressive-code": "^0.44.0", "bcp-47": "^2.1.0", @@ -451,7 +477,6 @@ "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", "i18next": "^26.0.7", - "js-yaml": "^4.1.1", "klona": "^2.0.6", "magic-string": "^1.2.3", "mdast-util-directive": "^3.1.0", @@ -463,7 +488,8 @@ "ultrahtml": "^1.6.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", - "vfile": "^6.0.3" + "vfile": "^6.0.3", + "yaml": "^2.9.1" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0", @@ -476,9 +502,9 @@ } }, "node_modules/@astrojs/telemetry": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.4.1.tgz", - "integrity": "sha512-SMkvMsGOpM0FMAoT5pzhgI3LZC0JSPWf7Evwop+fZ3N6UkusPM7lI+sOJjMNI5OJrVoiei1/AGo1cY7sikTc5A==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.4.2.tgz", + "integrity": "sha512-2JOMRUxJrBSfiVDfeKLv1wBmvSIx1Y6u2YEO8awZ9qSq3u/rNAiUyFdOYi8dHv91kHrmYEQX7pRDw0J4xyj3UA==", "license": "MIT", "dependencies": { "ci-info": "^4.4.0", @@ -546,6 +572,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bruits/satteri-android-arm64": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-android-arm64/-/satteri-android-arm64-0.11.7.tgz", + "integrity": "sha512-ej+1rIKs+yb6/OtcIe1SO22OXjJV0qqUopl5VNZF8Sqc/Pj4m1DXoqBQsPxW5OUYYiDUymIy6hnnn8M78VYk2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@bruits/satteri-darwin-arm64": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", @@ -572,6 +611,19 @@ "darwin" ] }, + "node_modules/@bruits/satteri-freebsd-x64": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-freebsd-x64/-/satteri-freebsd-x64-0.11.7.tgz", + "integrity": "sha512-EFlqw+RDRo3wYdy1ZSR/dNYhfWjuZL86PYwRcpLWWEZskjfpNnhIdxS13Xw2QJE1hSjTGVq4aZaVvlUjoj8p5A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@bruits/satteri-linux-arm64-gnu": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", @@ -1544,9 +1596,9 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", - "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/oxc-project" @@ -1650,9 +1702,9 @@ ] }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", - "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", "cpu": [ "arm" ], @@ -1666,9 +1718,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", - "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "cpu": [ "arm64" ], @@ -1682,9 +1734,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", - "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "cpu": [ "arm64" ], @@ -1698,9 +1750,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", - "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "cpu": [ "x64" ], @@ -1714,9 +1766,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", - "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "cpu": [ "x64" ], @@ -1730,9 +1782,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", - "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "cpu": [ "arm" ], @@ -1746,9 +1798,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", - "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "cpu": [ "arm64" ], @@ -1765,9 +1817,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", - "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "cpu": [ "arm64" ], @@ -1784,9 +1836,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", - "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "cpu": [ "ppc64" ], @@ -1803,9 +1855,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", - "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "cpu": [ "s390x" ], @@ -1822,9 +1874,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", - "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "cpu": [ "x64" ], @@ -1841,9 +1893,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", - "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "cpu": [ "x64" ], @@ -1860,9 +1912,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", - "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "cpu": [ "arm64" ], @@ -1876,9 +1928,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", - "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "cpu": [ "arm64" ], @@ -1892,9 +1944,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", - "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "cpu": [ "x64" ], @@ -2056,12 +2108,6 @@ "@types/unist": "*" } }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "license": "MIT" - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -2384,12 +2430,6 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT" }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -2419,15 +2459,15 @@ } }, "node_modules/astro": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.4.2.tgz", - "integrity": "sha512-FAH4cdDlFaVnQGjazLTEIC5vJqGlo5XANc1wIbzQHIqAjJTll/RWTUs+SPNW1JdezG4YGxMZllaE5A41T2kzhA==", + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.4.7.tgz", + "integrity": "sha512-fH54IOaCkZY1paEjygKn8CPfuJ3w8ns7PourcPML5AouJkQuTBqBQ2RbbIbBhutZbg2qn5se6Yl9DYybzqjC0Q==", "license": "MIT", "dependencies": { - "@astrojs/compiler-rs": "^0.5.1", - "@astrojs/internal-helpers": "0.13.0", - "@astrojs/markdown-satteri": "0.4.5", - "@astrojs/telemetry": "3.4.1", + "@astrojs/compiler-rs": "^0.5.2", + "@astrojs/internal-helpers": "0.13.1", + "@astrojs/markdown-satteri": "0.4.7", + "@astrojs/telemetry": "3.4.2", "@capsizecss/unpack": "^4.0.1", "@clack/prompts": "^1.8.1", "@oslojs/encoding": "^1.1.0", @@ -2438,10 +2478,10 @@ "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", - "devalue": "^6.0.2", + "devalue": "^6.0.3", "diff": "^9.0.0", "dset": "^3.1.4", - "es-module-lexer": "^3.0.3", + "es-module-lexer": "^3.0.4", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.6", @@ -2449,33 +2489,33 @@ "html-escaper": "3.0.3", "http-cache-semantics": "^4.3.0", "jsonc-parser": "^3.3.1", - "magic-string": "^1.4.3", + "magic-string": "^1.4.5", "magicast": "^0.5.5", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^3.0.0", "p-limit": "^7.3.3", - "p-queue": "^9.3.5", + "p-queue": "^10.0.0", "package-manager-detector": "^1.9.0", "piccolore": "^0.1.3", "picomatch": "^4.0.7", - "rolldown": "^1.2.13", + "rolldown": "^1.2.16", "shiki": "^4.5.0", "smol-toml": "^1.9.1", "svgo": "^4.1.0", "tinyclip": "^1.0.3", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", - "tinyps": "^0.5.0", + "tinyps": "^0.5.1", "ultrahtml": "^1.7.0", "unifont": "^1.0.3", - "unstorage": "^1.17.5", + "unstorage": "^1.17.6", "verkit": "^0.5.0", - "vite": "^8.3.4", + "vite": "^8.3.5", "vitefu": "^1.1.3", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", - "zod": "^4.6.6" + "zod": "^4.6.7" }, "bin": { "astro": "bin/astro.mjs" @@ -2514,6 +2554,257 @@ "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" } }, + "node_modules/astro/node_modules/@astrojs/internal-helpers": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.13.1.tgz", + "integrity": "sha512-LgGLGzoC0wCT3mjDYrxz+hct/tws0enpWsLJW3bswHR3PKgusp/H9xRIT8rTdsyvLM0TcRhhvRL30J2oaZNcqA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "picomatch": "^4.0.7", + "retext-smartypants": "^6.2.0", + "shiki": "^4.5.0", + "smol-toml": "^1.9.1", + "unified": "^11.0.5", + "yaml": "^2.9.1" + } + }, + "node_modules/astro/node_modules/@astrojs/markdown-satteri": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.7.tgz", + "integrity": "sha512-MZvnec/898GGGHQZfkArVbkdZmL1imjIM8RWkmzbAmse737zr8D9inUjFQ5eLBXV3vPobOUs/ZRV/D6Po/bVDg==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.13.1", + "@astrojs/prism": "4.1.2", + "github-slugger": "^2.0.0", + "satteri": "^0.11.6" + } + }, + "node_modules/astro/node_modules/@astrojs/prism": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.1.2.tgz", + "integrity": "sha512-jMrUEpFDarDO1KWPvVc9eSa8DSfwBlwEOHwX+8zvZQpiqXRGDxFnOXHsYqV6DP7w+0qIgTr98gS1GdQlm8ypWg==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.23.4" + } + }, + "node_modules/astro/node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.11.7.tgz", + "integrity": "sha512-yllQOX1kVWrwqL7tamQlp4NP0loIb4KuadjuNrAe4JEOerHpgYISPRDb8DqLipgvP7Ks4e87LydZpdakMR1rhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/astro/node_modules/@bruits/satteri-darwin-x64": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.11.7.tgz", + "integrity": "sha512-RMQrKzEZRZSiYyLFifiSuk0nBH6uAY1TVMNR/h/aE4rr2OS6l82e4MJTpC6H/tqSMoE+3CwbkUKBQGLHIweYlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/astro/node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.11.7.tgz", + "integrity": "sha512-aeH365GHaqv01wRSiHaGUlpL28U8e9UnV9LxjIF6BOyTj71/M5bop4HKomcis4VxFZjd68fDqNyU22yFkF/h7A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/astro/node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.11.7.tgz", + "integrity": "sha512-72WlBhuwOHm+2Nv4Qf1WzfS75PHOLz81sYkDJnmj0Rm2C4Xax5faSiTDt7vWo1IcK6rDlkuRDNnWIw8uIKWuVQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/astro/node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.11.7.tgz", + "integrity": "sha512-gDp4v1jjckeNCetLtrMUfDV4CKGYL6fayDPdoaQq09nkDCSrclxEFK7kAmRuHhoalgsao4bZMFqQr3HQtN2NEQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/astro/node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.11.7.tgz", + "integrity": "sha512-zqzSpQLA6V3J/TO2Vjuc1VRQPIQQdoSYmzKKFL+3GwGlbXiFQP2ZUDSLw9xlm0tTKXkCM9ANr4jMiDASZ+JGUw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/astro/node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.11.7.tgz", + "integrity": "sha512-L46f6rtlaBR4fsTWGCTFcXtbtka2GyM3l9TsmE0FgTxjXGq+sndYg5X3hzlhWDkNvJpRtCHCmgTRJ5+cIi4o3A==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/astro/node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.11.7.tgz", + "integrity": "sha512-R2+o6EbJK4pqbd086H9BGUVYAyZ5F6FnShfoyRIu0Zu9DTE3H6EU/Z+ua7p9dmYf6JQQbE9DCwwavi4xiaGAhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/astro/node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.11.7.tgz", + "integrity": "sha512-OIyBFJ35uMu5CH5RWnIR3bKsZwScKZ7vXmhCohZ+qonT1SkxmHCEf4H8YRWThRL2EXdVDjkjhfOsaiA4O24Vvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/astro/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/astro/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/astro/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/astro/node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.7.tgz", + "integrity": "sha512-zJccwplOAbgfZju95IUHazLnx9RAPtPN2YGJXI6UbrgeaR9QfPjGrQL6ERkSAtnVCA4Q+83ApGyaX5zdpCaKkQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3", + "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3" + } + }, + "node_modules/astro/node_modules/satteri": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.11.7.tgz", + "integrity": "sha512-xDOSxkc1ew3z3IovIjmsqJaVHRXTGYJkPvx0LL/U6o90km8kSU+pmrZVvnWd5creJeE0UnvMEienlkPuYQdJaA==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-android-arm64": "0.11.7", + "@bruits/satteri-darwin-arm64": "0.11.7", + "@bruits/satteri-darwin-x64": "0.11.7", + "@bruits/satteri-freebsd-x64": "0.11.7", + "@bruits/satteri-linux-arm64-gnu": "0.11.7", + "@bruits/satteri-linux-arm64-musl": "0.11.7", + "@bruits/satteri-linux-x64-gnu": "0.11.7", + "@bruits/satteri-linux-x64-musl": "0.11.7", + "@bruits/satteri-wasm32-wasi": "0.11.7", + "@bruits/satteri-win32-arm64-msvc": "0.11.7", + "@bruits/satteri-win32-x64-msvc": "0.11.7" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -3221,12 +3512,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/expressive-code": { "version": "0.44.1", "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.1.tgz", @@ -3659,9 +3944,9 @@ } }, "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.6.tgz", + "integrity": "sha512-1SZUxCN3TtJAE7e2BQN2MBHsoZ1gnmi9GoCP5Di59CSWptIezSAW09yDZyVbanb78V9eCwrKqdQsjtfVTxYHWA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -3741,15 +4026,12 @@ } }, "node_modules/hast-util-to-text": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", - "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.3.tgz", + "integrity": "sha512-5yrlWOvGFs6CqHriUpSsNrjzT/Tdo+IOQudbznxOn8K5qblp18MJyzOg8IgDOKQ8MYgQcJA7SusuCjSpDr8mSA==", "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unist-util-find-after": "^5.0.0" + "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", @@ -3932,28 +4214,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -4258,18 +4518,18 @@ } }, "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/magic-string": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", - "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" @@ -5525,28 +5785,12 @@ } }, "node_modules/p-queue": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.5.tgz", - "integrity": "sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.4", - "p-timeout": "^7.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-timeout": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", - "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-10.0.0.tgz", + "integrity": "sha512-P2wrCri3bhGLPo3kX7qYVXbo/5GvaVATU0DpO24CbeT53Y4z35t8JUWku04ERpXHUYB5rPMi3TkWE3fmJgx5rA==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6135,12 +6379,12 @@ } }, "node_modules/rolldown": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", - "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.155.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6150,21 +6394,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.15", - "@rolldown/binding-android-arm64": "1.2.15", - "@rolldown/binding-darwin-arm64": "1.2.15", - "@rolldown/binding-darwin-x64": "1.2.15", - "@rolldown/binding-freebsd-x64": "1.2.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", - "@rolldown/binding-linux-arm64-gnu": "1.2.15", - "@rolldown/binding-linux-arm64-musl": "1.2.15", - "@rolldown/binding-linux-ppc64-gnu": "1.2.15", - "@rolldown/binding-linux-s390x-gnu": "1.2.15", - "@rolldown/binding-linux-x64-gnu": "1.2.15", - "@rolldown/binding-linux-x64-musl": "1.2.15", - "@rolldown/binding-openharmony-arm64": "1.2.15", - "@rolldown/binding-win32-arm64-msvc": "1.2.15", - "@rolldown/binding-win32-x64-msvc": "1.2.15" + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "node_modules/satteri": { @@ -6350,12 +6594,6 @@ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "license": "MIT" }, - "node_modules/stream-replace-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", - "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", - "license": "MIT" - }, "node_modules/string-width": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", @@ -6623,20 +6861,6 @@ "undici": "^8.0.0" } }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", - "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -6718,14 +6942,14 @@ } }, "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.2.0.tgz", + "integrity": "sha512-fpsYs/VVoZxfGY9/j3FQpZmN8wdLJaV1Pt38AaJNZu9uKIHcFU3halCdBveEq1HGKO1OEfJY65w1zvoFV1b3qQ==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" + "unist-util-visit-parents": "^6.1.0" }, "funding": { "type": "opencollective", @@ -6760,19 +6984,19 @@ } }, "node_modules/unstorage": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", - "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.6.tgz", + "integrity": "sha512-gjKtcmn/KwZuJZO9o2sc0esAQnhmTw0LviDHwevTXbJMYs0dRTp8ZEEWXHU0vTpzbN3cA0yxlTeyXssMp3zDqw==", "license": "MIT", "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", - "h3": "^1.15.10", - "lru-cache": "^11.2.7", + "h3": "^1.15.11", + "lru-cache": "^11.5.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", - "ufo": "^1.6.3" + "ufo": "^1.6.4" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", @@ -6783,7 +7007,7 @@ "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", - "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", @@ -7179,13 +7403,6 @@ "vscode-uri": "^3.1.0" } }, - "node_modules/vscode-css-languageservice/node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "dev": true, - "license": "MIT" - }, "node_modules/vscode-html-languageservice": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", @@ -7217,13 +7434,13 @@ } }, "node_modules/vscode-jsonrpc": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-10.0.0.tgz", - "integrity": "sha512-USSfxg0vDIpTQqlQBGxF3mBARyoS8cU7LHLpbYNh6dOJt32dlskxPWB0Irrh5TllTmyf7oNLAvqDqzklbwwnpA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.17.0" + "node": ">=14.0.0" } }, "node_modules/vscode-languageserver": { @@ -7240,14 +7457,14 @@ } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.18.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.5.tgz", - "integrity": "sha512-FW4fBMzZ99N8xeob4py0l050wgCoRgTpEq5Xcsb9lM1bc29UKGR/wSV+4knaqFxssFHajy7OzNiKP/m3U1ZEuA==", + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", "dev": true, "license": "MIT", "dependencies": { - "vscode-jsonrpc": "10.0.0", - "vscode-languageserver-types": "3.18.5" + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" } }, "node_modules/vscode-languageserver-textdocument": { @@ -7258,34 +7475,6 @@ "license": "MIT" }, "node_modules/vscode-languageserver-types": { - "version": "3.18.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.5.tgz", - "integrity": "sha512-kPd6+JQNz3HMJvjKlqUxERSlZHAntl/aGpsk7ImwIh0ewDKDcjgAEyVSYciPiOJD3ftbK9IrfiqUeTZLSbCizw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "dev": true, - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver/node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", diff --git a/site/package.json b/site/package.json index 637ebe6f..c204a5a4 100644 --- a/site/package.json +++ b/site/package.json @@ -15,10 +15,10 @@ }, "dependencies": { "@astrojs/markdown-remark": "^7.3.4", - "@astrojs/starlight": "0.42.7", + "@astrojs/starlight": "0.42.10", "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", - "astro": "7.4.2", + "astro": "7.4.7", "sharp": "^0.35.5" }, "devDependencies": { From 2778dd7684cfc1823a8bbf86e3f1f4bb52b259cc Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 7 Nov 2026 04:52:40 -0800 Subject: [PATCH 3/6] fix(status): judge certificates by the resolvers the static config defines Hardcoding letsencrypt and onebox-wildcard as managed left the #235 symptom in place for a host that dropped its wildcard routes, and for a resolver key sitting in a store file it does not write. Status now reads certificatesResolvers.*.acme.storage from the static configuration it already stages, and renews-or-orphans each certificate by its (resolver, store file) pair. Custom resolvers in a project-owned traefik.yml are judged too. When the configuration cannot be read, no certificate is judged overdue or orphaned. The orphan hint names the store's full host path. --- internal/engine/proxystatus.go | 72 +++++++++++++----------- internal/engine/proxystatus_test.go | 73 ++++++++++++++++++++++--- internal/engine/status_snapshot.go | 15 +++-- internal/engine/status_snapshot_test.go | 2 +- internal/proxy/proxy.go | 49 +++++++++++++++++ internal/proxy/proxy_test.go | 47 ++++++++++++++++ 6 files changed, 213 insertions(+), 45 deletions(-) diff --git a/internal/engine/proxystatus.go b/internal/engine/proxystatus.go index d58fc9de..5a138241 100644 --- a/internal/engine/proxystatus.go +++ b/internal/engine/proxystatus.go @@ -21,14 +21,15 @@ const renewalFloorDays = 21 // proxyRaw is the managed proxy's status reads, gathered concurrently with the // app-side reads. type proxyRaw struct { - ids []string - health string // proxy container health, parsed from docker ps .Status - discovery bool // isolated Docker discovery controller is running - applied string // config and Compose identity the host applied - owner string // sole application identity from the host owner record - ownerEnv string // environment identity from the owner record - acme []acmeStore // raw ACME stores; parsed at render, and keys never leave - localHash string // locally staged config and Compose identity (computed offline) + ids []string + health string // proxy container health, parsed from docker ps .Status + discovery bool // isolated Docker discovery controller is running + applied string // config and Compose identity the host applied + owner string // sole application identity from the host owner record + ownerEnv string // environment identity from the owner record + acme []acmeStore // raw ACME stores; parsed at render, and keys never leave + certStores map[string]string // resolver → ACME store file, from the staged static config + localHash string // locally staged config and Compose identity (computed offline) // Why a read could not be trusted, when it could not. Recorded rather // than raised: gather returns on the first error and Status renders // nothing after it, so raising costs the operator every other fact about @@ -169,7 +170,7 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { if res.ExitCode != 0 { return statusReadResult("proxy certificate store", res, nil) } - px.acme = append(px.acme, acmeStore{file: name, body: res.Stdout}) + px.acme = append(px.acme, acmeStore{file: name, path: path, body: res.Stdout}) } return nil }, @@ -181,6 +182,10 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { defer os.RemoveAll(staging) id, err := e.stageProxy(staging) px.localHash = id.String() + if err != nil { + return err + } + px.certStores, err = proxy.CertificateStores(staging) return err }, } @@ -316,7 +321,7 @@ func (e *Engine) renderProxy(px proxyRaw) (bool, error) { fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%s)\n", px.acmeIssue) return true, nil } - certs, err := judgeCertificates(px.acme, e.Opts.Now()) + certs, err := judgeCertificates(px.acme, px.certStores, e.Opts.Now()) if err != nil { fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%v)\n", err) return true, nil @@ -325,7 +330,7 @@ func (e *Engine) renderProxy(px proxyRaw) (bool, error) { mark := "" switch { case c.Orphaned: - mark = e.ui.Dim(fmt.Sprintf(" not renewed: resolver %q is not configured (remove it from %s while the proxy is stopped)", c.Resolver, c.Store)) + mark = e.ui.Dim(fmt.Sprintf(" not renewed: no configured resolver %q writes this store (remove the key from %s while the proxy is stopped)", c.Resolver, c.Store)) case c.Overdue: mark = e.ui.Warn(" RENEWAL OVERDUE ⚠ (lego renews at 30d — check CF_DNS_API_TOKEN / proxy logs)") diverged = true @@ -338,7 +343,8 @@ func (e *Engine) renderProxy(px proxyRaw) (bool, error) { // acmeStore is one ACME store file as read from the host. The body holds // private keys, so it is parsed in place and never rendered. type acmeStore struct { - file string + file string // file name, as a resolver's acme.storage names it + path string // where the file lives on the host body string } @@ -346,38 +352,44 @@ type acmeStore struct { // that both status views report. type judgedCertificate struct { proxy.CertExpiry - Store string // store file the certificate was read from + Store string // host path of the store the certificate was read from Days int // whole days until expiry - Overdue bool // a managed resolver failed to renew it in time - Orphaned bool // its resolver is not one Onebox configures + Overdue bool // a configured resolver failed to renew it in time + Orphaned bool // no configured resolver writes it to this store } // judgeCertificates parses every store and judges each certificate by the -// resolver that issued it. Only the resolvers Onebox configures renew -// certificates for Onebox routes, so only their certificates can be overdue. -// A key under any other name is left behind by an older proxy configuration: -// nothing renews it, and judging its expiry would report a healthy host as -// diverged forever. Those certificates are reported as orphaned instead, so -// the operator can remove the stale key. +// resolver that issued it. A certificate is renewed only when the static +// configuration defines its resolver with this very store as its storage, so +// only such a certificate can be overdue. A key that fails that test was left +// behind by an older proxy configuration (a renamed resolver, wildcard routes +// that were removed, a storage path that moved). Nothing renews it, and +// judging its expiry would report a healthy host as diverged forever, so it is +// reported as orphaned instead and the operator can remove the stale key. +// +// stores maps each configured resolver to its store file. A nil map means the +// configuration could not be read: no certificate is then judged overdue or +// orphaned, because status reports only what it positively observed. // // Certificates are sorted, because the store's resolver keys parse from a map // and both views must print the same order on every run. -func judgeCertificates(stores []acmeStore, now time.Time) ([]judgedCertificate, error) { +func judgeCertificates(raw []acmeStore, stores map[string]string, now time.Time) ([]judgedCertificate, error) { var out []judgedCertificate - for _, store := range stores { + for _, store := range raw { certs, err := proxy.CertExpiries([]byte(store.body)) if err != nil { return nil, err } for _, cert := range certs { days := int(cert.NotAfter.Sub(now).Hours() / 24) - orphaned := !managedCertificateResolver(cert.Resolver) + file, configured := stores[cert.Resolver] + renewed := configured && file == store.file out = append(out, judgedCertificate{ CertExpiry: cert, - Store: store.file, + Store: store.path, Days: days, - Overdue: !orphaned && days < renewalFloorDays, - Orphaned: orphaned, + Overdue: renewed && days < renewalFloorDays, + Orphaned: stores != nil && !renewed, }) } } @@ -397,12 +409,6 @@ func judgeCertificates(stores []acmeStore, now time.Time) ([]judgedCertificate, return out, nil } -// managedCertificateResolver reports whether name is a resolver that Onebox's -// routes use. A custom static configuration must define these same names. -func managedCertificateResolver(name string) bool { - return name == app.ManagedCertificateResolver || name == app.ManagedWildcardCertificateResolver -} - func proxyDriftParts(applied string, local proxy.Identity) []string { remote, ok := proxy.ParseIdentity(applied) if _, valid := proxy.ParseIdentity(local.String()); !ok || !valid { diff --git a/internal/engine/proxystatus_test.go b/internal/engine/proxystatus_test.go index b1eb7a72..9ae440ef 100644 --- a/internal/engine/proxystatus_test.go +++ b/internal/engine/proxystatus_test.go @@ -256,7 +256,7 @@ func TestStatusIgnoresCertificateFromUnconfiguredResolver(t *testing.T) { if strings.Contains(out.String(), "RENEWAL OVERDUE") { t.Fatalf("orphaned certificate must not be flagged overdue:\n%s", out.String()) } - if !strings.Contains(out.String(), `resolver "le" is not configured`) || !strings.Contains(out.String(), "acme.json") { + if !strings.Contains(out.String(), `no configured resolver "le" writes this store`) || !strings.Contains(out.String(), "/var/lib/onebox/_host/proxy/acme/acme.json") { t.Fatalf("orphaned certificate must name its resolver and store:\n%s", out.String()) } } @@ -278,10 +278,13 @@ func TestStatusFlagsManagedCertificateEvenWhenOrphanedOneIsNewer(t *testing.T) { } } -func TestStatusJudgesWildcardResolverCertificates(t *testing.T) { +// A host that once served wildcard routes keeps their certificate after the +// routes and the DNS challenge are removed. The static configuration no longer +// defines the wildcard resolver, so nothing renews that certificate. +func TestStatusIgnoresWildcardCertificateAfterResolverIsRemoved(t *testing.T) { applied := "" wildcard := acmeStoreFixture(map[string][]string{ - app.ManagedWildcardCertificateResolver: {acmeCertificate(t, "*.preview.example.com", time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC))}, + app.ManagedWildcardCertificateResolver: {acmeCertificate(t, "*.preview.example.com", time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC))}, }) e, f, out, _ := statusProxyEngine(t, &applied, acmeFixture(t, "app.example.com", time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)), "healthy") base := f.Dynamic @@ -291,11 +294,67 @@ func TestStatusJudgesWildcardResolverCertificates(t *testing.T) { } return base(cmd) } - if err := e.Status(context.Background()); err == nil { - t.Fatalf("an overdue wildcard certificate must make status diverge:\n%s", out.String()) + if err := e.Status(context.Background()); err != nil { + t.Fatalf("an unconfigured wildcard resolver's certificate must not make status diverge: %v\n%s", err, out.String()) } - if !strings.Contains(out.String(), "RENEWAL OVERDUE") { - t.Fatalf("overdue wildcard certificate must be flagged:\n%s", out.String()) + if strings.Contains(out.String(), "RENEWAL OVERDUE") { + t.Fatalf("orphaned wildcard certificate must not be flagged overdue:\n%s", out.String()) + } +} + +// A configured resolver renews only the store its acme.storage names. The +// same key in the other store file is a leftover. +func TestStatusIgnoresConfiguredResolverKeyInOtherStore(t *testing.T) { + applied := "" + misplaced := acmeFixture(t, "app.example.com", time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)) + e, f, out, _ := statusProxyEngine(t, &applied, acmeFixture(t, "app.example.com", time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)), "healthy") + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + if strings.Contains(cmd, "acme-wildcard.json") { + return transport.Result{Stdout: misplaced}, true + } + return base(cmd) + } + if err := e.Status(context.Background()); err != nil { + t.Fatalf("a resolver key in a store it does not write must not make status diverge: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "/var/lib/onebox/_host/proxy/acme/acme-wildcard.json") { + t.Fatalf("orphaned certificate must name the store it sits in:\n%s", out.String()) + } +} + +func TestJudgeCertificatesUsesConfiguredStores(t *testing.T) { + now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) + soon := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + raw := []acmeStore{ + {file: "acme.json", path: "/h/acme.json", body: acmeFixture(t, "app.example.com", soon)}, + {file: "acme-wildcard.json", path: "/h/acme-wildcard.json", body: acmeStoreFixture(map[string][]string{ + app.ManagedWildcardCertificateResolver: {acmeCertificate(t, "*.preview.example.com", soon)}, + })}, + } + withDNS := map[string]string{ + app.ManagedCertificateResolver: "acme.json", + app.ManagedWildcardCertificateResolver: "acme-wildcard.json", + } + certs, err := judgeCertificates(raw, withDNS, now) + if err != nil { + t.Fatal(err) + } + for _, c := range certs { + if !c.Overdue || c.Orphaned { + t.Fatalf("both resolvers are configured for their stores, so both certificates are overdue: %#v", certs) + } + } + + // The configuration could not be read: claim neither verdict. + certs, err = judgeCertificates(raw, nil, now) + if err != nil { + t.Fatal(err) + } + for _, c := range certs { + if c.Overdue || c.Orphaned { + t.Fatalf("an unread configuration must not produce a verdict: %#v", certs) + } } } diff --git a/internal/engine/status_snapshot.go b/internal/engine/status_snapshot.go index 32e0881e..059e2cf2 100644 --- a/internal/engine/status_snapshot.go +++ b/internal/engine/status_snapshot.go @@ -102,9 +102,10 @@ type StatusProxy struct { } // StatusCertificate is the public, private-key-free portion of an ACME entry. -// Resolver is the store key that holds the certificate. Orphaned marks a -// certificate under a resolver Onebox does not configure: nothing renews it, -// so it is never overdue and never makes the proxy diverge. +// Resolver is the store key that holds the certificate and Store is the host +// path of the store file. Orphaned marks a certificate that no resolver in the +// static configuration writes to that store: nothing renews it, so it is never +// overdue and never makes the proxy diverge. type StatusCertificate struct { Domain string `json:"domain"` Resolver string `json:"resolver"` @@ -503,7 +504,13 @@ func makeStatusProxy(raw proxyRaw, readComplete []bool, now time.Time, applicati // input, the ungated form would answer "nothing is near expiry" about a // store it never opened. if complete(statusProxyCertificatesRead) && raw.acmeIssue == "" { - certs, err := judgeCertificates(raw.acme, now) + // The resolver-to-store map comes from the local staging read. Without + // it nothing can be judged, and judgeCertificates claims nothing. + stores := raw.certStores + if !complete(statusProxyLocalHashRead) { + stores = nil + } + certs, err := judgeCertificates(raw.acme, stores, now) if err != nil { status.Complete = false status.Issues = append(status.Issues, "certificate store is unreadable") diff --git a/internal/engine/status_snapshot_test.go b/internal/engine/status_snapshot_test.go index 92bd6b25..3b2a90c6 100644 --- a/internal/engine/status_snapshot_test.go +++ b/internal/engine/status_snapshot_test.go @@ -650,7 +650,7 @@ func TestStatusSnapshotIgnoresCertificateFromUnconfiguredResolver(t *testing.T) } // Sorted by domain, then resolver: "le" before "letsencrypt". orphan, served := certs[0], certs[1] - if orphan.Resolver != "le" || orphan.Store != "acme.json" || !orphan.Orphaned || orphan.RenewalOverdue || orphan.DaysRemaining != 7 { + if orphan.Resolver != "le" || orphan.Store != "/var/lib/onebox/_host/proxy/acme/acme.json" || !orphan.Orphaned || orphan.RenewalOverdue || orphan.DaysRemaining != 7 { t.Fatalf("leftover certificate must be orphaned, not overdue: %#v", orphan) } if served.Resolver != app.ManagedCertificateResolver || served.Orphaned || served.RenewalOverdue || served.DaysRemaining != 78 { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 76f1db8e..91e35c4d 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -23,6 +23,7 @@ import ( "fmt" "hash" "os" + "path" "path/filepath" "sort" "strings" @@ -235,6 +236,54 @@ func CertExpiries(acmeJSON []byte) ([]CertExpiry, error) { return out, nil } +// acmeStorageDir is where the proxy container mounts the host's ACME store +// directory. Only a store under it is one the host can read back. +const acmeStorageDir = "/letsencrypt" + +// CertificateStores reads the static configuration staged in stagingDir and +// returns, for each certificate resolver it defines, the file name of the ACME +// store that resolver writes inside the host's store directory. A resolver +// whose storage lies elsewhere, or is left to Traefik's default, writes a +// store the host does not keep, so it is left out. Status uses the result to +// tell certificates that a configured resolver renews from keys that an older +// configuration left behind. +func CertificateStores(stagingDir string) (map[string]string, error) { + var body []byte + for _, name := range []string{"traefik.yml", "traefik.yaml"} { + b, err := os.ReadFile(filepath.Join(stagingDir, "config", name)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + body = b + break + } + if body == nil { + return nil, errors.New("staged proxy configuration has no traefik.yml or traefik.yaml") + } + var document struct { + CertificatesResolvers map[string]struct { + ACME struct { + Storage string `yaml:"storage"` + } `yaml:"acme"` + } `yaml:"certificatesResolvers"` + } + if err := yaml.Unmarshal(body, &document); err != nil { + return nil, fmt.Errorf("parse static configuration: %w", err) + } + stores := make(map[string]string, len(document.CertificatesResolvers)) + for name, resolver := range document.CertificatesResolvers { + storage := path.Clean(resolver.ACME.Storage) + if resolver.ACME.Storage == "" || path.Dir(storage) != acmeStorageDir { + continue + } + stores[name] = path.Base(storage) + } + return stores, nil +} + // Stage builds the local staging payload for the host proxy: compose.yaml + // config/, and returns the payload hash — the // identity used for cross-app conflict detection and change detection. diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index f328dcb4..077d8045 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -659,6 +659,53 @@ func TestCertExpiries(t *testing.T) { } } +func TestCertificateStores(t *testing.T) { + stage := func(t *testing.T, name, body string) string { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config", name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return dir + } + for _, tc := range []struct { + name string + file string + body string + want map[string]string + }{ + {"default", "traefik.yml", string(renderStaticConfig(nil)), + map[string]string{app.ManagedCertificateResolver: "acme.json"}}, + {"dns challenge", "traefik.yml", string(renderStaticConfigWithDNS(nil, &app.ProxyDNSChallenge{Provider: "cloudflare"})), + map[string]string{app.ManagedCertificateResolver: "acme.json", app.ManagedWildcardCertificateResolver: "acme-wildcard.json"}}, + {"custom yaml, storage outside the store directory", "traefik.yaml", + "certificatesResolvers:\n letsencrypt:\n acme:\n storage: /letsencrypt/acme.json\n other:\n acme:\n storage: /data/acme.json\n unset:\n acme: {}\n", + map[string]string{app.ManagedCertificateResolver: "acme.json"}}, + {"no resolvers", "traefik.yml", "ping: {}\n", map[string]string{}}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := CertificateStores(stage(t, tc.file, tc.body)) + if err != nil { + t.Fatal(err) + } + if len(got) != len(tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + for k, v := range tc.want { + if got[k] != v { + t.Fatalf("got %v, want %v", got, tc.want) + } + } + }) + } + if _, err := CertificateStores(t.TempDir()); err == nil { + t.Fatal("a staging directory without a static configuration must error") + } +} + // A project that declares a domain and nothing else must be able to bootstrap. // Onebox owns the proxy; requiring the author to write Traefik's static // configuration first contradicts that, and the file they would write is the From c7ed3876adafeef0069a74a7514cea56255515a1 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 7 Nov 2026 04:57:29 -0800 Subject: [PATCH 4/6] fix(status): judge certificates by the host's applied static config Judging by the locally staged config was wrong whenever the host ran an older one: a resolver removed locally but still renewing on the host was called orphaned, and the hint told the operator to delete a certificate the proxy still serves. Status now reads traefik.yml or traefik.yaml from the host's proxy config directory in the certificate read and reads every store file a resolver there names, as well as acme.json and acme-wildcard.json. When that file is refused or does not parse, the issue is recorded, nothing is called orphaned and certificates are judged by expiry alone, so a real overdue renewal is never hidden. The local config read is back to its original job. The /letsencrypt mount target is one constant shared by the compose template, the static config and the parser. --- internal/engine/proxystatus.go | 94 +++++++++++++++++++++++++---- internal/engine/proxystatus_test.go | 90 +++++++++++++++++++++++++-- internal/engine/status_snapshot.go | 14 ++--- internal/proxy/proxy.go | 51 ++++++---------- internal/proxy/proxy_test.go | 30 +++------ 5 files changed, 202 insertions(+), 77 deletions(-) diff --git a/internal/engine/proxystatus.go b/internal/engine/proxystatus.go index 5a138241..449a1f1f 100644 --- a/internal/engine/proxystatus.go +++ b/internal/engine/proxystatus.go @@ -28,7 +28,7 @@ type proxyRaw struct { owner string // sole application identity from the host owner record ownerEnv string // environment identity from the owner record acme []acmeStore // raw ACME stores; parsed at render, and keys never leave - certStores map[string]string // resolver → ACME store file, from the staged static config + certStores map[string]string // resolver → ACME store file, from the applied static config; nil when unknown localHash string // locally staged config and Compose identity (computed offline) // Why a read could not be trusted, when it could not. Recorded rather // than raised: gather returns on the first error and Status renders @@ -37,6 +37,9 @@ type proxyRaw struct { ownerIssue string appliedIssue string acmeIssue string + // certConfigIssue: the applied static configuration could not be read or + // parsed, so certificates are judged by expiry alone. + certConfigIssue string } func (px proxyRaw) ownershipIssue(application, environment string) string { @@ -157,7 +160,13 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { return nil }, func() error { - for _, name := range []string{"acme.json", "acme-wildcard.json"} { + // The static configuration the host applied says which resolver + // writes which store, and so which stores to read. It is read + // first, in the same step as the stores it names. + if err := e.readCertificateConfig(ctx, hp, px); err != nil { + return err + } + for _, name := range certificateStoreFiles(px.certStores) { path := hp.Acme + "/" + name res, err := e.T.Run(ctx, readableFileProbe(path)) if err != nil { @@ -182,15 +191,63 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { defer os.RemoveAll(staging) id, err := e.stageProxy(staging) px.localHash = id.String() - if err != nil { - return err - } - px.certStores, err = proxy.CertificateStores(staging) return err }, } } +// readCertificateConfig reads the static configuration the host applied and +// records which store each of its resolvers writes. The host's file is the +// one that matters: the running proxy renews by it, and a local change that +// was never applied renews nothing yet. A refused read or a configuration +// that does not parse is recorded, not raised, and leaves certStores nil so +// the certificates are still judged by expiry. No file at all is a host that +// never applied a proxy, which has no certificates to judge. +func (e *Engine) readCertificateConfig(ctx context.Context, hp proxy.Paths, px *proxyRaw) error { + for _, name := range proxy.StaticConfigNames { + path := hp.ConfigDir + "/" + name + res, err := e.T.Run(ctx, readableFileProbe(path)) + if err != nil { + return err + } + if issue, refused := statusFileIssue("the applied static configuration", path, res); refused { + px.certConfigIssue = issue + return nil + } + if res.ExitCode != 0 { + return statusReadResult("proxy static configuration", res, nil) + } + if strings.TrimSpace(res.Stdout) == "" { + continue + } + stores, err := proxy.CertificateStores([]byte(res.Stdout)) + if err != nil { + px.certConfigIssue = fmt.Sprintf("the applied static configuration at %s could not be parsed (%v); certificates are judged by expiry alone", path, err) + return nil + } + px.certStores = stores + return nil + } + return nil +} + +// certificateStoreFiles lists the store files to read: the two Onebox has +// always written, so that keys left in them stay visible, plus any other file +// the applied configuration points a resolver at. +func certificateStoreFiles(stores map[string]string) []string { + files := []string{"acme.json", "acme-wildcard.json"} + seen := map[string]bool{"acme.json": true, "acme-wildcard.json": true} + var extra []string + for _, file := range stores { + if !seen[file] { + seen[file] = true + extra = append(extra, file) + } + } + sort.Strings(extra) + return append(files, extra...) +} + // readableFileProbe reads a status file that may legitimately be absent: // 0 with the contents on stdout, 2 present but unreadable, 5 absence could not // be established, 0 and empty when there is nothing there. @@ -321,6 +378,10 @@ func (e *Engine) renderProxy(px proxyRaw) (bool, error) { fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%s)\n", px.acmeIssue) return true, nil } + if px.certConfigIssue != "" { + fmt.Fprintf(e.Opts.Out, " cert resolvers unknown ⚠ (%s)\n", px.certConfigIssue) + diverged = true + } certs, err := judgeCertificates(px.acme, px.certStores, e.Opts.Now()) if err != nil { fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%v)\n", err) @@ -330,7 +391,7 @@ func (e *Engine) renderProxy(px proxyRaw) (bool, error) { mark := "" switch { case c.Orphaned: - mark = e.ui.Dim(fmt.Sprintf(" not renewed: no configured resolver %q writes this store (remove the key from %s while the proxy is stopped)", c.Resolver, c.Store)) + mark = e.ui.Dim(fmt.Sprintf(" not renewed: the applied configuration has no resolver %q writing this store (remove the key from %s while the proxy is stopped)", c.Resolver, c.Store)) case c.Overdue: mark = e.ui.Warn(" RENEWAL OVERDUE ⚠ (lego renews at 30d — check CF_DNS_API_TOKEN / proxy logs)") diverged = true @@ -367,9 +428,11 @@ type judgedCertificate struct { // judging its expiry would report a healthy host as diverged forever, so it is // reported as orphaned instead and the operator can remove the stale key. // -// stores maps each configured resolver to its store file. A nil map means the -// configuration could not be read: no certificate is then judged overdue or -// orphaned, because status reports only what it positively observed. +// stores maps each resolver of the applied configuration to its store file. +// A nil map means that configuration is unknown. Then no certificate is +// called orphaned, since that would invite deleting one the proxy may still +// renew, and every certificate is judged by its expiry, since hiding a real +// overdue renewal is the worse mistake. // // Certificates are sorted, because the store's resolver keys parse from a map // and both views must print the same order on every run. @@ -382,14 +445,19 @@ func judgeCertificates(raw []acmeStore, stores map[string]string, now time.Time) } for _, cert := range certs { days := int(cert.NotAfter.Sub(now).Hours() / 24) - file, configured := stores[cert.Resolver] - renewed := configured && file == store.file + // Without the configuration every certificate might be renewed, + // so each is judged by its expiry, as before resolvers were known. + renewed := true + if stores != nil { + file, configured := stores[cert.Resolver] + renewed = configured && file == store.file + } out = append(out, judgedCertificate{ CertExpiry: cert, Store: store.path, Days: days, Overdue: renewed && days < renewalFloorDays, - Orphaned: stores != nil && !renewed, + Orphaned: !renewed, }) } } diff --git a/internal/engine/proxystatus_test.go b/internal/engine/proxystatus_test.go index 9ae440ef..168250f6 100644 --- a/internal/engine/proxystatus_test.go +++ b/internal/engine/proxystatus_test.go @@ -104,6 +104,8 @@ func statusProxyEngine(t *testing.T, appliedHash *string, acme string, proxyHeal return transport.Result{Stdout: "sample production\n"}, true case strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/acme/acme.json'"): return transport.Result{Stdout: acme}, true + case strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/config/traefik.yml'"): + return transport.Result{Stdout: testManagedProxyStatic}, true case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app='sample'"): return transport.Result{Stdout: "S1|web|R7|Up (healthy)\n" + "W1|worker|R7|Up (healthy)\nPG1|postgres|R7|Up (healthy)\n"}, true @@ -256,7 +258,7 @@ func TestStatusIgnoresCertificateFromUnconfiguredResolver(t *testing.T) { if strings.Contains(out.String(), "RENEWAL OVERDUE") { t.Fatalf("orphaned certificate must not be flagged overdue:\n%s", out.String()) } - if !strings.Contains(out.String(), `no configured resolver "le" writes this store`) || !strings.Contains(out.String(), "/var/lib/onebox/_host/proxy/acme/acme.json") { + if !strings.Contains(out.String(), `the applied configuration has no resolver "le" writing this store`) || !strings.Contains(out.String(), "/var/lib/onebox/_host/proxy/acme/acme.json") { t.Fatalf("orphaned certificate must name its resolver and store:\n%s", out.String()) } } @@ -323,6 +325,86 @@ func TestStatusIgnoresConfiguredResolverKeyInOtherStore(t *testing.T) { } } +// withHostCommand answers one more host command ahead of the fixture's own. +func withHostCommand(f *transport.Fake, match string, result transport.Result) { + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + if strings.Contains(cmd, match) { + return result, true + } + return base(cmd) + } +} + +// The host still runs a configuration whose wildcard resolver the local +// project has since removed. That resolver still renews on the host, so its +// failing renewal must be reported, not called a leftover to delete. +func TestStatusJudgesByAppliedConfigNotLocalConfig(t *testing.T) { + applied := "" + e, f, out, _ := statusProxyEngine(t, &applied, acmeFixture(t, "app.example.com", time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)), "healthy") + withHostCommand(f, "cat '/var/lib/onebox/_host/proxy/config/traefik.yml'", transport.Result{ + Stdout: testManagedProxyStatic + " " + app.ManagedWildcardCertificateResolver + ":\n acme:\n storage: /letsencrypt/acme-wildcard.json\n dnsChallenge:\n provider: cloudflare\n", + }) + withHostCommand(f, "acme-wildcard.json", transport.Result{Stdout: acmeStoreFixture(map[string][]string{ + app.ManagedWildcardCertificateResolver: {acmeCertificate(t, "*.preview.example.com", time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC))}, + })}) + if err := e.Status(context.Background()); err == nil { + t.Fatalf("the applied wildcard resolver's certificate is overdue, so status must diverge:\n%s", out.String()) + } + if !strings.Contains(out.String(), "RENEWAL OVERDUE") || strings.Contains(out.String(), "remove the key") { + t.Fatalf("a resolver the host applied must be judged, never offered for removal:\n%s", out.String()) + } +} + +// A resolver may write a store under any name in the store directory. Status +// reads that file too, so its certificates are judged. +func TestStatusReadsStoreNamedByAppliedConfig(t *testing.T) { + applied := "" + e, f, out, _ := statusProxyEngine(t, &applied, "", "healthy") + withHostCommand(f, "cat '/var/lib/onebox/_host/proxy/config/traefik.yml'", transport.Result{ + Stdout: "certificatesResolvers:\n letsencrypt:\n acme:\n storage: /letsencrypt/certs.json\n", + }) + withHostCommand(f, "cat '/var/lib/onebox/_host/proxy/acme/certs.json'", transport.Result{ + Stdout: acmeFixture(t, "app.example.com", time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)), + }) + if err := e.Status(context.Background()); err == nil { + t.Fatalf("the certificate in the configured store is overdue, so status must diverge:\n%s", out.String()) + } + if !strings.Contains(out.String(), "RENEWAL OVERDUE") { + t.Fatalf("the configured store must be read and judged:\n%s", out.String()) + } +} + +// An applied configuration that cannot be parsed must not hide an overdue +// renewal, and must not mark anything for removal. +func TestStatusJudgesByExpiryWhenAppliedConfigIsUnreadable(t *testing.T) { + applied := "" + e, f, _, _ := statusProxyEngine(t, &applied, staleResolverStore(t), "healthy") + withHostCommand(f, "cat '/var/lib/onebox/_host/proxy/config/traefik.yml'", transport.Result{Stdout: "certificatesResolvers: ["}) + + snapshot, err := e.StatusSnapshot(context.Background()) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + px := snapshot.Proxy + if px == nil || px.Complete || !px.Diverged { + t.Fatalf("an unparsable applied configuration must leave the proxy incomplete and diverged: %#v", px) + } + overdue := false + for _, c := range px.Certificates { + if c.Orphaned { + t.Fatalf("no certificate may be called orphaned without the configuration: %#v", px.Certificates) + } + overdue = overdue || c.RenewalOverdue + } + if !overdue { + t.Fatalf("the 7-day certificate must still be judged overdue by expiry: %#v", px.Certificates) + } + if px.LocalConfigHash == "" { + t.Fatalf("the local configuration read is independent and must survive: %#v", px) + } +} + func TestJudgeCertificatesUsesConfiguredStores(t *testing.T) { now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) soon := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) @@ -346,14 +428,14 @@ func TestJudgeCertificatesUsesConfiguredStores(t *testing.T) { } } - // The configuration could not be read: claim neither verdict. + // The configuration is unknown: judge by expiry, orphan nothing. certs, err = judgeCertificates(raw, nil, now) if err != nil { t.Fatal(err) } for _, c := range certs { - if c.Overdue || c.Orphaned { - t.Fatalf("an unread configuration must not produce a verdict: %#v", certs) + if !c.Overdue || c.Orphaned { + t.Fatalf("an unknown configuration must fall back to expiry alone: %#v", certs) } } } diff --git a/internal/engine/status_snapshot.go b/internal/engine/status_snapshot.go index 059e2cf2..56d145f7 100644 --- a/internal/engine/status_snapshot.go +++ b/internal/engine/status_snapshot.go @@ -104,7 +104,7 @@ type StatusProxy struct { // StatusCertificate is the public, private-key-free portion of an ACME entry. // Resolver is the store key that holds the certificate and Store is the host // path of the store file. Orphaned marks a certificate that no resolver in the -// static configuration writes to that store: nothing renews it, so it is never +// host's applied static configuration writes to that store: nothing renews it, so it is never // overdue and never makes the proxy diverge. type StatusCertificate struct { Domain string `json:"domain"` @@ -482,6 +482,10 @@ func makeStatusProxy(raw proxyRaw, readComplete []bool, now time.Time, applicati status.Complete = false status.Issues = append(status.Issues, raw.acmeIssue) } + if complete(statusProxyCertificatesRead) && raw.certConfigIssue != "" { + status.Complete = false + status.Issues = append(status.Issues, raw.certConfigIssue) + } if complete(statusProxyOwnerRead) { status.Owner = raw.owner // The read returned, but it returned a record we would not act on. @@ -504,13 +508,7 @@ func makeStatusProxy(raw proxyRaw, readComplete []bool, now time.Time, applicati // input, the ungated form would answer "nothing is near expiry" about a // store it never opened. if complete(statusProxyCertificatesRead) && raw.acmeIssue == "" { - // The resolver-to-store map comes from the local staging read. Without - // it nothing can be judged, and judgeCertificates claims nothing. - stores := raw.certStores - if !complete(statusProxyLocalHashRead) { - stores = nil - } - certs, err := judgeCertificates(raw.acme, stores, now) + certs, err := judgeCertificates(raw.acme, raw.certStores, now) if err != nil { status.Complete = false status.Issues = append(status.Issues, "certificate store is unreadable") diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 91e35c4d..7d4a8478 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -138,7 +138,7 @@ services: %s volumes: - ./config:/etc/traefik:ro - ./dynamic:/etc/traefik/dynamic:ro - - ./acme:/letsencrypt + - ./acme:`+AcmeStorageDir+` tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"] healthcheck: test: ["CMD", "traefik", "healthcheck"] @@ -236,33 +236,22 @@ func CertExpiries(acmeJSON []byte) ([]CertExpiry, error) { return out, nil } -// acmeStorageDir is where the proxy container mounts the host's ACME store +// AcmeStorageDir is where the proxy container mounts the host's ACME store // directory. Only a store under it is one the host can read back. -const acmeStorageDir = "/letsencrypt" - -// CertificateStores reads the static configuration staged in stagingDir and -// returns, for each certificate resolver it defines, the file name of the ACME -// store that resolver writes inside the host's store directory. A resolver -// whose storage lies elsewhere, or is left to Traefik's default, writes a -// store the host does not keep, so it is left out. Status uses the result to -// tell certificates that a configured resolver renews from keys that an older -// configuration left behind. -func CertificateStores(stagingDir string) (map[string]string, error) { - var body []byte - for _, name := range []string{"traefik.yml", "traefik.yaml"} { - b, err := os.ReadFile(filepath.Join(stagingDir, "config", name)) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return nil, err - } - body = b - break - } - if body == nil { - return nil, errors.New("staged proxy configuration has no traefik.yml or traefik.yaml") - } +const AcmeStorageDir = "/letsencrypt" + +// StaticConfigNames are the file names a static configuration may have, in +// the order Traefik is pointed at them. +var StaticConfigNames = []string{"traefik.yml", "traefik.yaml"} + +// CertificateStores returns, for each certificate resolver that a static +// configuration defines, the file name of the ACME store that resolver writes +// inside the host's store directory. A resolver whose storage lies elsewhere, +// or is left to Traefik's default, writes a store the host does not keep, so +// it is left out. Status uses the result to tell certificates that a +// configured resolver renews from keys that an older configuration left +// behind. +func CertificateStores(staticConfig []byte) (map[string]string, error) { var document struct { CertificatesResolvers map[string]struct { ACME struct { @@ -270,13 +259,13 @@ func CertificateStores(stagingDir string) (map[string]string, error) { } `yaml:"acme"` } `yaml:"certificatesResolvers"` } - if err := yaml.Unmarshal(body, &document); err != nil { + if err := yaml.Unmarshal(staticConfig, &document); err != nil { return nil, fmt.Errorf("parse static configuration: %w", err) } stores := make(map[string]string, len(document.CertificatesResolvers)) for name, resolver := range document.CertificatesResolvers { storage := path.Clean(resolver.ACME.Storage) - if resolver.ACME.Storage == "" || path.Dir(storage) != acmeStorageDir { + if resolver.ACME.Storage == "" || path.Dir(storage) != AcmeStorageDir { continue } stores[name] = path.Base(storage) @@ -328,7 +317,7 @@ entryPoints: const defaultStaticConfigFooter = `certificatesResolvers: ` + app.ManagedCertificateResolver + `: acme: - storage: /letsencrypt/acme.json + storage: ` + AcmeStorageDir + `/acme.json httpChallenge: entryPoint: web ` @@ -369,7 +358,7 @@ func renderStaticConfigWithDNS(entrypoints map[string]app.ProxyEntrypoint, dns * } out.WriteString(defaultStaticConfigFooter) if dns != nil { - fmt.Fprintf(&out, " %s:\n acme:\n storage: /letsencrypt/acme-wildcard.json\n dnsChallenge:\n provider: %s\n", app.ManagedWildcardCertificateResolver, dns.Provider) + fmt.Fprintf(&out, " %s:\n acme:\n storage: "+AcmeStorageDir+"/acme-wildcard.json\n dnsChallenge:\n provider: %s\n", app.ManagedWildcardCertificateResolver, dns.Provider) if len(dns.Resolvers) > 0 { out.WriteString(" resolvers:\n") for _, resolver := range dns.Resolvers { diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 077d8045..c7193edb 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -660,34 +660,22 @@ func TestCertExpiries(t *testing.T) { } func TestCertificateStores(t *testing.T) { - stage := func(t *testing.T, name, body string) string { - t.Helper() - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "config"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "config", name), []byte(body), 0o600); err != nil { - t.Fatal(err) - } - return dir - } for _, tc := range []struct { name string - file string body string want map[string]string }{ - {"default", "traefik.yml", string(renderStaticConfig(nil)), + {"default", string(renderStaticConfig(nil)), map[string]string{app.ManagedCertificateResolver: "acme.json"}}, - {"dns challenge", "traefik.yml", string(renderStaticConfigWithDNS(nil, &app.ProxyDNSChallenge{Provider: "cloudflare"})), + {"dns challenge", string(renderStaticConfigWithDNS(nil, &app.ProxyDNSChallenge{Provider: "cloudflare"})), map[string]string{app.ManagedCertificateResolver: "acme.json", app.ManagedWildcardCertificateResolver: "acme-wildcard.json"}}, - {"custom yaml, storage outside the store directory", "traefik.yaml", - "certificatesResolvers:\n letsencrypt:\n acme:\n storage: /letsencrypt/acme.json\n other:\n acme:\n storage: /data/acme.json\n unset:\n acme: {}\n", - map[string]string{app.ManagedCertificateResolver: "acme.json"}}, - {"no resolvers", "traefik.yml", "ping: {}\n", map[string]string{}}, + {"custom, storage outside the store directory", + "certificatesResolvers:\n letsencrypt:\n acme:\n storage: /letsencrypt/certs.json\n other:\n acme:\n storage: /data/acme.json\n unset:\n acme: {}\n escape:\n acme:\n storage: /letsencrypt/../etc/passwd\n", + map[string]string{app.ManagedCertificateResolver: "certs.json"}}, + {"no resolvers", "ping: {}\n", map[string]string{}}, } { t.Run(tc.name, func(t *testing.T) { - got, err := CertificateStores(stage(t, tc.file, tc.body)) + got, err := CertificateStores([]byte(tc.body)) if err != nil { t.Fatal(err) } @@ -701,8 +689,8 @@ func TestCertificateStores(t *testing.T) { } }) } - if _, err := CertificateStores(t.TempDir()); err == nil { - t.Fatal("a staging directory without a static configuration must error") + if _, err := CertificateStores([]byte("certificatesResolvers: [")); err == nil { + t.Fatal("malformed configuration must error") } } From 1b00670592f61bfc3e068d193a78dec34f62cda6 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 7 Nov 2026 05:01:48 -0800 Subject: [PATCH 5/6] fix(status): never orphan a certificate whose resolver store is unclear A resolver with a relative storage path, mixed-case keys, or a storage value status cannot place was treated as unconfigured, so its renewing certificate was called orphaned with a hint to delete it. CertificateStores now matches keys without regard to case, resolves a relative storage path from /, and lists every defined resolver, mapping an unplaceable store to "". Certificates of such a resolver are judged by expiry and never orphaned. Any failed read of the applied config is recorded, not raised, so the store reads in the same step still run. The parse issue no longer quotes YAML errors, which can repeat values from the file, and the overdue issue names the resolver and store. --- internal/engine/proxystatus.go | 22 +++++++---- internal/engine/proxystatus_test.go | 31 +++++++++++++++ internal/engine/status_snapshot.go | 2 +- internal/proxy/proxy.go | 58 ++++++++++++++++++----------- internal/proxy/proxy_test.go | 12 ++++-- 5 files changed, 93 insertions(+), 32 deletions(-) diff --git a/internal/engine/proxystatus.go b/internal/engine/proxystatus.go index 449a1f1f..7884fbb3 100644 --- a/internal/engine/proxystatus.go +++ b/internal/engine/proxystatus.go @@ -199,9 +199,9 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { // readCertificateConfig reads the static configuration the host applied and // records which store each of its resolvers writes. The host's file is the // one that matters: the running proxy renews by it, and a local change that -// was never applied renews nothing yet. A refused read or a configuration -// that does not parse is recorded, not raised, and leaves certStores nil so -// the certificates are still judged by expiry. No file at all is a host that +// was never applied renews nothing yet. A failed read or a configuration that +// does not parse is recorded, not raised, and leaves certStores nil so the +// certificates are still judged by expiry. Only a transport error is raised. No file at all is a host that // never applied a proxy, which has no certificates to judge. func (e *Engine) readCertificateConfig(ctx context.Context, hp proxy.Paths, px *proxyRaw) error { for _, name := range proxy.StaticConfigNames { @@ -214,15 +214,21 @@ func (e *Engine) readCertificateConfig(ctx context.Context, hp proxy.Paths, px * px.certConfigIssue = issue return nil } + // Any other failure, such as cat losing a race with ob proxy apply + // swapping the directory, is recorded too: the stores this step reads + // next matter more than the configuration that refines them. if res.ExitCode != 0 { - return statusReadResult("proxy static configuration", res, nil) + px.certConfigIssue = fmt.Sprintf("the applied static configuration at %s could not be read (exit %d); certificates are judged by expiry alone", path, res.ExitCode) + return nil } if strings.TrimSpace(res.Stdout) == "" { continue } + // The parse error is not quoted: it can repeat values from the file, + // and the message reaches the terminal and the JSON snapshot. stores, err := proxy.CertificateStores([]byte(res.Stdout)) if err != nil { - px.certConfigIssue = fmt.Sprintf("the applied static configuration at %s could not be parsed (%v); certificates are judged by expiry alone", path, err) + px.certConfigIssue = fmt.Sprintf("the applied static configuration at %s is not valid YAML; certificates are judged by expiry alone", path) return nil } px.certStores = stores @@ -239,7 +245,7 @@ func certificateStoreFiles(stores map[string]string) []string { seen := map[string]bool{"acme.json": true, "acme-wildcard.json": true} var extra []string for _, file := range stores { - if !seen[file] { + if file != "" && !seen[file] { seen[file] = true extra = append(extra, file) } @@ -447,10 +453,12 @@ func judgeCertificates(raw []acmeStore, stores map[string]string, now time.Time) days := int(cert.NotAfter.Sub(now).Hours() / 24) // Without the configuration every certificate might be renewed, // so each is judged by its expiry, as before resolvers were known. + // A resolver whose store CertificateStores could not place ("") + // might write here, so it is given the benefit of the doubt. renewed := true if stores != nil { file, configured := stores[cert.Resolver] - renewed = configured && file == store.file + renewed = configured && (file == "" || file == store.file) } out = append(out, judgedCertificate{ CertExpiry: cert, diff --git a/internal/engine/proxystatus_test.go b/internal/engine/proxystatus_test.go index 168250f6..ee40be3e 100644 --- a/internal/engine/proxystatus_test.go +++ b/internal/engine/proxystatus_test.go @@ -405,6 +405,37 @@ func TestStatusJudgesByExpiryWhenAppliedConfigIsUnreadable(t *testing.T) { } } +// A resolver whose store cannot be placed might write anywhere, so its +// certificates are judged by expiry and never offered for removal. +func TestJudgeCertificatesTrustsResolverWithUnplacedStore(t *testing.T) { + now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) + raw := []acmeStore{{file: "acme.json", path: "/h/acme.json", body: acmeFixture(t, "app.example.com", time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC))}} + certs, err := judgeCertificates(raw, map[string]string{app.ManagedCertificateResolver: ""}, now) + if err != nil { + t.Fatal(err) + } + if len(certs) != 1 || certs[0].Orphaned || !certs[0].Overdue { + t.Fatalf("certificate of a resolver with an unplaced store must be judged by expiry: %#v", certs) + } +} + +// A failed read of the applied configuration must not cost the certificate +// expiries that the same step reads next. +func TestStatusKeepsCertificatesWhenAppliedConfigReadFails(t *testing.T) { + applied := "" + e, f, _, _ := statusProxyEngine(t, &applied, acmeFixture(t, "app.example.com", time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)), "healthy") + withHostCommand(f, "cat '/var/lib/onebox/_host/proxy/config/traefik.yml'", transport.Result{ExitCode: 1}) + + snapshot, err := e.StatusSnapshot(context.Background()) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + px := snapshot.Proxy + if px == nil || len(px.Certificates) != 1 || !px.Certificates[0].RenewalOverdue { + t.Fatalf("certificates must still be read and judged by expiry: %#v", px) + } +} + func TestJudgeCertificatesUsesConfiguredStores(t *testing.T) { now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) soon := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) diff --git a/internal/engine/status_snapshot.go b/internal/engine/status_snapshot.go index 56d145f7..f7f823c6 100644 --- a/internal/engine/status_snapshot.go +++ b/internal/engine/status_snapshot.go @@ -525,7 +525,7 @@ func makeStatusProxy(raw proxyRaw, readComplete []bool, now time.Time, applicati Orphaned: cert.Orphaned, }) if cert.Overdue { - status.Issues = append(status.Issues, fmt.Sprintf("certificate renewal is overdue for %s", cert.Domain)) + status.Issues = append(status.Issues, fmt.Sprintf("certificate renewal is overdue for %s (resolver %s, %s)", cert.Domain, cert.Resolver, cert.Store)) } } } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 7d4a8478..e5292b84 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -244,35 +244,51 @@ const AcmeStorageDir = "/letsencrypt" // the order Traefik is pointed at them. var StaticConfigNames = []string{"traefik.yml", "traefik.yaml"} -// CertificateStores returns, for each certificate resolver that a static -// configuration defines, the file name of the ACME store that resolver writes -// inside the host's store directory. A resolver whose storage lies elsewhere, -// or is left to Traefik's default, writes a store the host does not keep, so -// it is left out. Status uses the result to tell certificates that a -// configured resolver renews from keys that an older configuration left -// behind. +// CertificateStores returns every certificate resolver that a static +// configuration defines, mapped to the file name of the ACME store it writes +// inside the host's store directory. A resolver whose store cannot be placed +// there maps to "": its storage is unset, of an unexpected type, or outside +// the directory. Status must not call such a resolver's certificates leftovers, +// because it cannot tell where that resolver writes. +// +// Keys match without regard to case, as Traefik's own loader matches them. A +// relative storage path is resolved from /, the proxy's working directory. func CertificateStores(staticConfig []byte) (map[string]string, error) { - var document struct { - CertificatesResolvers map[string]struct { - ACME struct { - Storage string `yaml:"storage"` - } `yaml:"acme"` - } `yaml:"certificatesResolvers"` - } + var document map[string]any if err := yaml.Unmarshal(staticConfig, &document); err != nil { - return nil, fmt.Errorf("parse static configuration: %w", err) - } - stores := make(map[string]string, len(document.CertificatesResolvers)) - for name, resolver := range document.CertificatesResolvers { - storage := path.Clean(resolver.ACME.Storage) - if resolver.ACME.Storage == "" || path.Dir(storage) != AcmeStorageDir { + return nil, errors.New("the static configuration is not valid YAML") + } + stores := map[string]string{} + resolvers, _ := foldKey(document, "certificatesResolvers").(map[string]any) + for name, value := range resolvers { + stores[name] = "" + resolver, _ := value.(map[string]any) + acme, _ := foldKey(resolver, "acme").(map[string]any) + storage, _ := foldKey(acme, "storage").(string) + if storage == "" { continue } - stores[name] = path.Base(storage) + if !path.IsAbs(storage) { + storage = "/" + storage + } + storage = path.Clean(storage) + if path.Dir(storage) == AcmeStorageDir { + stores[name] = path.Base(storage) + } } return stores, nil } +// foldKey returns the value under key, matched without regard to case. +func foldKey(document map[string]any, key string) any { + for k, v := range document { + if strings.EqualFold(k, key) { + return v + } + } + return nil +} + // Stage builds the local staging payload for the host proxy: compose.yaml + // config/, and returns the payload hash — the // identity used for cross-app conflict detection and change detection. diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index c7193edb..bfda262b 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -670,8 +670,14 @@ func TestCertificateStores(t *testing.T) { {"dns challenge", string(renderStaticConfigWithDNS(nil, &app.ProxyDNSChallenge{Provider: "cloudflare"})), map[string]string{app.ManagedCertificateResolver: "acme.json", app.ManagedWildcardCertificateResolver: "acme-wildcard.json"}}, {"custom, storage outside the store directory", - "certificatesResolvers:\n letsencrypt:\n acme:\n storage: /letsencrypt/certs.json\n other:\n acme:\n storage: /data/acme.json\n unset:\n acme: {}\n escape:\n acme:\n storage: /letsencrypt/../etc/passwd\n", - map[string]string{app.ManagedCertificateResolver: "certs.json"}}, + "certificatesResolvers:\n letsencrypt:\n acme:\n storage: /letsencrypt/certs.json\n other:\n acme:\n storage: /data/acme.json\n unset:\n acme: {}\n escape:\n acme:\n storage: /letsencrypt/../etc/passwd\n typed:\n acme:\n storage: [x]\n", + map[string]string{app.ManagedCertificateResolver: "certs.json", "other": "", "unset": "", "escape": "", "typed": ""}}, + {"relative storage resolves from /", + "certificatesResolvers:\n letsencrypt:\n acme:\n storage: letsencrypt/acme.json\n", + map[string]string{app.ManagedCertificateResolver: "acme.json"}}, + {"keys match without regard to case", + "certificatesresolvers:\n letsencrypt:\n ACME:\n Storage: /letsencrypt/acme.json\n", + map[string]string{app.ManagedCertificateResolver: "acme.json"}}, {"no resolvers", "ping: {}\n", map[string]string{}}, } { t.Run(tc.name, func(t *testing.T) { @@ -683,7 +689,7 @@ func TestCertificateStores(t *testing.T) { t.Fatalf("got %v, want %v", got, tc.want) } for k, v := range tc.want { - if got[k] != v { + if file, ok := got[k]; !ok || file != v { t.Fatalf("got %v, want %v", got, tc.want) } } From a32ab8643281160e2b37a86a18a9b8b61296640e Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 7 Nov 2026 07:13:59 -0800 Subject: [PATCH 6/6] fix(status): drop deletion advice and match Traefik's config lookup Traefik reads its static configuration only at startup, so a proxy not restarted after an apply can still renew under a key the file no longer names. The orphaned hint now says the certificate is not renewed by the applied configuration and names the resolver and store, without telling the operator to delete it. Resolver names match without regard to case. traefik.yaml is read before traefik.yml, the order Traefik searches. The text view prints a config issue before an unreadable store returns, as the JSON snapshot does, and every config issue says certificates are judged by expiry alone. --- internal/engine/proxystatus.go | 37 ++++++++++++++++++++++------- internal/engine/proxystatus_test.go | 16 +++++++++++-- internal/proxy/proxy.go | 5 ++-- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/internal/engine/proxystatus.go b/internal/engine/proxystatus.go index 7884fbb3..104945ce 100644 --- a/internal/engine/proxystatus.go +++ b/internal/engine/proxystatus.go @@ -211,7 +211,7 @@ func (e *Engine) readCertificateConfig(ctx context.Context, hp proxy.Paths, px * return err } if issue, refused := statusFileIssue("the applied static configuration", path, res); refused { - px.certConfigIssue = issue + px.certConfigIssue = issue + "; certificates are judged by expiry alone" return nil } // Any other failure, such as cat losing a race with ob proxy apply @@ -237,6 +237,22 @@ func (e *Engine) readCertificateConfig(ctx context.Context, hp proxy.Paths, px * return nil } +// resolverStore looks a resolver up without regard to case, because Traefik +// matches its configuration that way and a store key may differ in case from +// the name the configuration spells. A missed match would call a renewing +// certificate a leftover, so the lenient match is the safe one. +func resolverStore(stores map[string]string, resolver string) (string, bool) { + if file, ok := stores[resolver]; ok { + return file, true + } + for name, file := range stores { + if strings.EqualFold(name, resolver) { + return file, true + } + } + return "", false +} + // certificateStoreFiles lists the store files to read: the two Onebox has // always written, so that keys left in them stay visible, plus any other file // the applied configuration points a resolver at. @@ -380,14 +396,16 @@ func (e *Engine) renderProxy(px proxyRaw) (bool, error) { fmt.Fprintf(e.Opts.Out, "proxy %-12s %-10s %s owner: %s\n", proxy.ContainerName, health, state, owner) // cert runway — the renewal loop is the proxy's one silent failure mode - if px.acmeIssue != "" { - fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%s)\n", px.acmeIssue) - return true, nil - } + // Printed before the store check so the text view names the same issues + // as the JSON snapshot when both reads fail. if px.certConfigIssue != "" { fmt.Fprintf(e.Opts.Out, " cert resolvers unknown ⚠ (%s)\n", px.certConfigIssue) diverged = true } + if px.acmeIssue != "" { + fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%s)\n", px.acmeIssue) + return true, nil + } certs, err := judgeCertificates(px.acme, px.certStores, e.Opts.Now()) if err != nil { fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%v)\n", err) @@ -397,7 +415,7 @@ func (e *Engine) renderProxy(px proxyRaw) (bool, error) { mark := "" switch { case c.Orphaned: - mark = e.ui.Dim(fmt.Sprintf(" not renewed: the applied configuration has no resolver %q writing this store (remove the key from %s while the proxy is stopped)", c.Resolver, c.Store)) + mark = e.ui.Dim(fmt.Sprintf(" not renewed by the applied configuration (resolver %q in %s)", c.Resolver, c.Store)) case c.Overdue: mark = e.ui.Warn(" RENEWAL OVERDUE ⚠ (lego renews at 30d — check CF_DNS_API_TOKEN / proxy logs)") diverged = true @@ -432,7 +450,10 @@ type judgedCertificate struct { // behind by an older proxy configuration (a renamed resolver, wildcard routes // that were removed, a storage path that moved). Nothing renews it, and // judging its expiry would report a healthy host as diverged forever, so it is -// reported as orphaned instead and the operator can remove the stale key. +// reported as orphaned instead. Status does not advise deleting it: Traefik +// reads its static configuration only at startup, so a proxy that was not +// restarted after an apply may still renew under a key the file no longer +// names. // // stores maps each resolver of the applied configuration to its store file. // A nil map means that configuration is unknown. Then no certificate is @@ -457,7 +478,7 @@ func judgeCertificates(raw []acmeStore, stores map[string]string, now time.Time) // might write here, so it is given the benefit of the doubt. renewed := true if stores != nil { - file, configured := stores[cert.Resolver] + file, configured := resolverStore(stores, cert.Resolver) renewed = configured && (file == "" || file == store.file) } out = append(out, judgedCertificate{ diff --git a/internal/engine/proxystatus_test.go b/internal/engine/proxystatus_test.go index ee40be3e..5b1dd8b4 100644 --- a/internal/engine/proxystatus_test.go +++ b/internal/engine/proxystatus_test.go @@ -258,7 +258,7 @@ func TestStatusIgnoresCertificateFromUnconfiguredResolver(t *testing.T) { if strings.Contains(out.String(), "RENEWAL OVERDUE") { t.Fatalf("orphaned certificate must not be flagged overdue:\n%s", out.String()) } - if !strings.Contains(out.String(), `the applied configuration has no resolver "le" writing this store`) || !strings.Contains(out.String(), "/var/lib/onebox/_host/proxy/acme/acme.json") { + if !strings.Contains(out.String(), `not renewed by the applied configuration (resolver "le" in /var/lib/onebox/_host/proxy/acme/acme.json)`) { t.Fatalf("orphaned certificate must name its resolver and store:\n%s", out.String()) } } @@ -351,7 +351,7 @@ func TestStatusJudgesByAppliedConfigNotLocalConfig(t *testing.T) { if err := e.Status(context.Background()); err == nil { t.Fatalf("the applied wildcard resolver's certificate is overdue, so status must diverge:\n%s", out.String()) } - if !strings.Contains(out.String(), "RENEWAL OVERDUE") || strings.Contains(out.String(), "remove the key") { + if !strings.Contains(out.String(), "RENEWAL OVERDUE") || strings.Contains(out.String(), "not renewed") { t.Fatalf("a resolver the host applied must be judged, never offered for removal:\n%s", out.String()) } } @@ -436,6 +436,18 @@ func TestStatusKeepsCertificatesWhenAppliedConfigReadFails(t *testing.T) { } } +func TestJudgeCertificatesMatchesResolverWithoutCase(t *testing.T) { + now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) + raw := []acmeStore{{file: "acme.json", path: "/h/acme.json", body: acmeFixture(t, "app.example.com", time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC))}} + certs, err := judgeCertificates(raw, map[string]string{"LetsEncrypt": "acme.json"}, now) + if err != nil { + t.Fatal(err) + } + if len(certs) != 1 || certs[0].Orphaned || !certs[0].Overdue { + t.Fatalf("a resolver spelled in another case is still the one that renews: %#v", certs) + } +} + func TestJudgeCertificatesUsesConfiguredStores(t *testing.T) { now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) soon := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index e5292b84..40cea0a6 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -241,8 +241,9 @@ func CertExpiries(acmeJSON []byte) ([]CertExpiry, error) { const AcmeStorageDir = "/letsencrypt" // StaticConfigNames are the file names a static configuration may have, in -// the order Traefik is pointed at them. -var StaticConfigNames = []string{"traefik.yml", "traefik.yaml"} +// the order Traefik searches /etc/traefik for them. Traefik also looks for +// traefik.toml first, but Onebox refuses to stage one. +var StaticConfigNames = []string{"traefik.yaml", "traefik.yml"} // CertificateStores returns every certificate resolver that a static // configuration defines, mapped to the file name of the ACME store it writes