diff --git a/docs/onebox.run-v1.schema.json b/docs/onebox.run-v1.schema.json index 6096d13c..a7e9a0be 100644 --- a/docs/onebox.run-v1.schema.json +++ b/docs/onebox.run-v1.schema.json @@ -1163,12 +1163,8 @@ "^x-": {} }, "properties": { - "cert_resolver": { - "description": "Traefik certificate resolver used by terminating TLS routes.", - "type": "string" - }, "config": { - "description": "Repository-relative proxy configuration directory owned by the project. It must contain exactly one static traefik.yml or traefik.yaml, use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, and may not reuse Onebox-generated router or service names. Expects a path inside the repository, with no control character or shell metacharacter.", + "description": "Repository-relative proxy configuration directory owned by the project. It must contain exactly one static traefik.yml or traefik.yaml, use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, may not reuse Onebox-generated router or service names, and must define certificatesResolvers.letsencrypt when a route terminates TLS. Expects a path inside the repository, with no control character or shell metacharacter.", "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", "type": "string" }, diff --git a/internal/app/generate.go b/internal/app/generate.go index 08621352..2087c781 100644 --- a/internal/app/generate.go +++ b/internal/app/generate.go @@ -558,10 +558,11 @@ func (p *Spec) routeLabels(n Names, name string, w Workload) map[string]any { if r.TLS == "passthrough" { out[pre+"tls.passthrough"] = "true" } - // Without a resolver the router terminates TLS with no certificate - // to terminate it with. - if p.Proxy.CertResolver != "" && r.TLS == "terminate" { - out[pre+"tls.certresolver"] = p.Proxy.CertResolver + // Resolver selection is part of the managed proxy implementation, not + // authored project data. The generated static configuration defines + // this same private identity. + if p.Proxy.Managed && r.TLS == "terminate" { + out[pre+"tls.certresolver"] = ManagedCertificateResolver } } // Named explicitly: with more than one service defined on a container, diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index 2d9e1b98..71f41d56 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -325,16 +325,40 @@ func TestJobsDoNotRestartOrAutoStart(t *testing.T) { } } -// TestTLSTerminationNamesAResolver: terminating TLS without one yields a router -// that never obtains a certificate. -func TestTLSTerminationNamesAResolver(t *testing.T) { - y := appFixture + "proxy: {cert_resolver: le}\n" - out := string(render(t, y)) - if !strings.Contains(out, "tls.certresolver: le") { - t.Errorf("expected a certificate resolver on the terminating router\n%s", out) +// TestTLSTerminationUsesManagedResolver: certificate acquisition is part of +// the managed proxy implementation, not an opaque identifier authors align by +// hand with a separate Traefik file. +func TestTLSTerminationUsesManagedResolver(t *testing.T) { + out := string(render(t, appFixture)) + if !strings.Contains(out, "tls.certresolver: "+ManagedCertificateResolver) { + t.Errorf("expected the managed certificate resolver on the terminating router\n%s", out) + } +} + +func TestOperatorOwnedProxyDoesNotUseManagedResolver(t *testing.T) { + out := string(render(t, appFixture+"proxy: {managed: false}\n")) + if strings.Contains(out, "tls.certresolver") { + t.Errorf("operator-owned proxy must not reference Onebox's private resolver\n%s", out) } - if strings.Contains(string(render(t, appFixture)), "certresolver") { - t.Error("no resolver declared, so none should be emitted") +} + +func TestCertificateResolverIsNotAProjectField(t *testing.T) { + _, err := LoadBytes([]byte(appFixture+"proxy: {cert_resolver: le}\n"), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "cert_resolver") { + t.Fatalf("implementation-specific resolver name must be refused: %v", err) + } +} + +func TestHasTerminatingTLSDistinguishesPassthrough(t *testing.T) { + spec := &Spec{Workloads: map[string]Workload{ + "database": {Routes: []Route{{TLS: "passthrough"}}}, + }} + if spec.HasTerminatingTLS() { + t.Fatal("passthrough TLS must not require the proxy to present a certificate") + } + spec.Workloads["web"] = Workload{Routes: []Route{{TLS: "terminate"}}} + if !spec.HasTerminatingTLS() { + t.Fatal("terminating TLS must require the managed certificate resolver") } } diff --git a/internal/app/names.go b/internal/app/names.go index 676b2dc1..a46260af 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -400,6 +400,23 @@ func routesOf(w Workload) []Route { // expanded, so callers never handle two shapes. func (w Workload) NormalisedRoutes() []Route { return routesOf(w) } +// HasTerminatingTLS reports whether the resolved project needs the managed +// proxy's certificate resolver. Passthrough routes carry TLS without asking +// the proxy to obtain or present a certificate. +func (p *Spec) HasTerminatingTLS() bool { + if p == nil { + return false + } + for _, workload := range p.Workloads { + for _, route := range workload.NormalisedRoutes() { + if route.TLS == "terminate" { + return true + } + } + } + return false +} + // Join is the injective separator rule above, exported for derived identifiers // that live outside this file — a backup repository prefix among them. func Join(parts ...string) string { return join(parts...) } diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index ee6e7b24..16cc0244 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -92,7 +92,7 @@ { "case": "conformance/base_path absolute", "loads": true, - "digest": "1427479dd14b45dd67d0ede585e8a7101f77d8e0b138fcc9bfd2f3b564b487c7" + "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" }, { "case": "conformance/bind source containing a compose separator", @@ -132,7 +132,7 @@ { "case": "conformance/encrypted env file entry", "loads": true, - "digest": "434f069335e673392453c8bc0a6f32a598958bffd0f716f28383523036b535bc" + "digest": "d87891ad760c2aca38053753731a3bf03ddab6f987d4cd9e87110af4715708d3" }, { "case": "conformance/env file entry without a file", @@ -182,7 +182,7 @@ { "case": "conformance/hook with local", "loads": true, - "digest": "1427479dd14b45dd67d0ede585e8a7101f77d8e0b138fcc9bfd2f3b564b487c7" + "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" }, { "case": "conformance/host proxy name", @@ -247,12 +247,12 @@ { "case": "conformance/migration_policy expand-only", "loads": true, - "digest": "1427479dd14b45dd67d0ede585e8a7101f77d8e0b138fcc9bfd2f3b564b487c7" + "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" }, { "case": "conformance/minimum project", "loads": true, - "digest": "1427479dd14b45dd67d0ede585e8a7101f77d8e0b138fcc9bfd2f3b564b487c7" + "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" }, { "case": "conformance/missing api_version", @@ -287,7 +287,7 @@ { "case": "conformance/notification with no events", "loads": true, - "digest": "1427479dd14b45dd67d0ede585e8a7101f77d8e0b138fcc9bfd2f3b564b487c7" + "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" }, { "case": "conformance/one-char identifier", @@ -317,7 +317,7 @@ { "case": "conformance/provider-qualified route middlewares", "loads": true, - "digest": "cc817cfa6f0647847b9a34010783db9497e2d60960e95957485be215f08d90ba" + "digest": "68e64d82ad9afb845f41e9bfe57a920c0452649a74644877f6f69949714edbc4" }, { "case": "conformance/proxy kind none with a route", @@ -352,7 +352,7 @@ { "case": "conformance/relative env_file", "loads": true, - "digest": "d3af0da3bfa662975b00c94eb9501a74189cde107b86026b150e4475feab659a" + "digest": "9d40880f62c05790ff682afc8c185f0ebc454e77522e9e734ef8f256461dea91" }, { "case": "conformance/relative read-only bind mount", @@ -367,7 +367,7 @@ { "case": "conformance/repeated route middleware remains ordered", "loads": true, - "digest": "2a8bec87fde4d5e1645ad522d54f18d86f8e5a6c77dfd777ca66152bed65e7cc" + "digest": "2422cebd0a6c76e245e576cfefa0bb2596966f7e47dda8c3fa42013633339b3e" }, { "case": "conformance/rolling workload with published host port", @@ -377,7 +377,7 @@ { "case": "conformance/routes list", "loads": true, - "digest": "7d7266883dfcde7b48cefa5f2e3836a345bf8b6bd87713b4b32ea713c850b549" + "digest": "c44552dd7728f71b6232b172d17b60dff503ae9e576cc039db083dd76365a488" }, { "case": "conformance/scheduled job", @@ -402,12 +402,12 @@ { "case": "conformance/service scalar", "loads": true, - "digest": "571a4c0cb8201688ff94d3c60628f62828e7d25afda366979b5784e7d6e38964 postgres=51d530eb9102fbd1" + "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 postgres=51d530eb9102fbd1" }, { "case": "conformance/settings key that is a real driver flag", "loads": true, - "digest": "571a4c0cb8201688ff94d3c60628f62828e7d25afda366979b5784e7d6e38964 redis=f5e4171f39cd0dcb" + "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 redis=f5e4171f39cd0dcb" }, { "case": "conformance/settings key with a shell metacharacter", @@ -497,7 +497,7 @@ { "case": "conformance/url check with contains and advisory", "loads": true, - "digest": "1427479dd14b45dd67d0ede585e8a7101f77d8e0b138fcc9bfd2f3b564b487c7" + "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" }, { "case": "conformance/volume scalar with a path", @@ -527,7 +527,7 @@ { "case": "conformance/x- extension accepted", "loads": true, - "digest": "1427479dd14b45dd67d0ede585e8a7101f77d8e0b138fcc9bfd2f3b564b487c7" + "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" }, { "case": "conformance/zero replicas", @@ -537,17 +537,17 @@ { "case": "corpus/authentik.yml", "loads": true, - "digest": "d0220d95733508a9110a85f6af50b299692026415d97e57352c48702faffe5cd postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" + "digest": "64ea7ff80a65ba1594bc9565675e7ff194b3acdb44540ebb2e84a46d2e4c4bf1 postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" }, { "case": "corpus/ext-authentik-managed.yml", "loads": true, - "digest": "da313d71c6a9b57aae74c5be68075777b19f16d357f67f789921a554000996fe postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" + "digest": "baf976b3c71b90c78e1bcab801a9765b6111abae7df5600a15233a9811c99a0e postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" }, { "case": "corpus/ext-authentik.yml", "loads": true, - "digest": "8b2e005e2280ad9d915b27dc2ef9f6ba31280b786d8530b6925879475ee203e5" + "digest": "e5aebd1b4f98af7beaa603ae44d981486150335461306d7cabf173476bdf27ee" }, { "case": "corpus/ext-frigate.yml", @@ -557,7 +557,7 @@ { "case": "corpus/ext-gitea.yml", "loads": true, - "digest": "e9d3e9e4359b0da7a29a1d2cd9353658104adcaf32de45c7bad5dcdbf6178f1d postgres=e70cc45c347098f9" + "digest": "96f5d0556ae66e0c5052343fb6d763a16686510181a1406266864c5eed49be80 postgres=e70cc45c347098f9" }, { "case": "corpus/ext-immich-sourced.yml", @@ -572,32 +572,32 @@ { "case": "corpus/ext-n8n.yml", "loads": true, - "digest": "4145591ba026f00d1b1084956dc6ce92b615fc5932b1e56e69de3a0e1091c541 postgres=809549d286e2dbdc redis=86933b446609e6d8" + "digest": "1b9c415f24ea342e79a3c14b5ad6bb485f29df88b7478371e99ee7f59006d86a postgres=809549d286e2dbdc redis=86933b446609e6d8" }, { "case": "corpus/ext-paperless.yml", "loads": true, - "digest": "de07ca4a5abbd366436d29dd70e4405a7dd6baa348a3bc09a8b61c147919d237" + "digest": "8ec597bfc4bc7d71d879e5f7dc12d94009c326fdba1720b65d7c5ea726e7a183" }, { "case": "corpus/ext-plausible.yml", "loads": true, - "digest": "c093d6a45796f3e738ea19e2c72efaa924460a2cf821c0d3032be68aa9ba5915 events=1e798590e3a5dda4 postgres=b1fac70440c33545" + "digest": "9919231c9f5b2702f56fda1f56bae20a1d3912039e8013144af7ce204b986f2f events=1e798590e3a5dda4 postgres=b1fac70440c33545" }, { "case": "corpus/ext-umami.yml", "loads": true, - "digest": "24c1c39649d61ed9ca06452e76f6e0a5de1f1cdd101af0e9d847887098369c76 postgres=36c6c38ba304b445" + "digest": "4088b4fc05094ac274c76205c774fb6b99f03c00abc97f7e2574eadf4ca7c6f6 postgres=36c6c38ba304b445" }, { "case": "corpus/ghost.yml", "loads": true, - "digest": "aa3190b08d80e2bb07ba6a8ebf643516a038753c18968a8c92db1d93fef79270 mysql=0f13a6374095d11b" + "digest": "ba2aaf586bcb73298f6175fb36813d8720a3f67d58c7bc023bf35a564b1e19d3 mysql=0f13a6374095d11b" }, { "case": "corpus/gitea.yml", "loads": true, - "digest": "54bf36e4db730b17c4d27f41f8bfa54ce2bc500760cdbbae46269681ddd5893a" + "digest": "c6347add0af261bc2f87d29691f6de5b14401d63c7034ed5ba8c0798913d7d52" }, { "case": "corpus/goal.yml", @@ -607,7 +607,7 @@ { "case": "corpus/immich.yml", "loads": true, - "digest": "344b9afe9b799dd490dd416375459b09cb43bd09be00eb804f3b3b5485b36aca" + "digest": "d8e311adf9eb1ae4fc7e1362e53de2ba2e6ccefa2deefd0a8b1a976c7b5bfa06" }, { "case": "corpus/monk.yml", @@ -617,17 +617,17 @@ { "case": "corpus/n8n.yml", "loads": true, - "digest": "a616524bfe4b42cd337a268d7473f9ae744229986b0b896ca26f40e6e1f2301b" + "digest": "45034dd978133e0aab7db59c3ee86a2157e306a608a8633feeb204b4958c5fef" }, { "case": "corpus/paperless.yml", "loads": true, - "digest": "c2cbb33556877e23c9c39c8a04d795390b10f14902fc7f22fa79c1ba0fb154c1" + "digest": "80991cdd434564e0612397e3f64427088ce51d87f30e2502cdc9a496773ba81c" }, { "case": "corpus/penpot.yml", "loads": true, - "digest": "270e3f55730f45153a20297ca217c2bc8c38f1d7b6967f3c685208b4f465cb61 postgres=fc584b1b50db23a6 redis=fcccb6a023ae5734" + "digest": "4d7d95dd74e291c481366cf606f5b2537fa5bf97cf126bea50df9fa927d26da3 postgres=fc584b1b50db23a6 redis=fcccb6a023ae5734" }, { "case": "corpus/pursue.yml", @@ -642,21 +642,21 @@ { "case": "corpus/rocketchat.yml", "loads": true, - "digest": "8597758e9fed4ce6924026bdf3a26d5b7fd86b6707cea4acb75ef7885c865690 mongodb=eaca06e5d1b88e4b" + "digest": "0b1a575f884bc3ae2aa87bde1df62dc2a00068ba17c56f2288fa7412eb918a96 mongodb=eaca06e5d1b88e4b" }, { "case": "corpus/umami.yml", "loads": true, - "digest": "e2f035cfaf76b1cafe87f11da6bca3d8b7bfd2d53aed72f3a97f377784bcef90" + "digest": "ea01ea117cbbae96ad9e6f3bc9dff6bf40839cdb21dcc540aca078e2d79d503b" }, { "case": "corpus/uptime-kuma.yml", "loads": true, - "digest": "7f32b82b133217adb388a7cdf0c00dff353352205b978bf726f809fe6151c7f9" + "digest": "e3f215d2cbbd19d05f6c4dd7b07f5b2e7cb28a79fc1877ec2ca25b06ff2feef4" }, { "case": "corpus/vaultwarden.yml", "loads": true, - "digest": "b002b3920af5ed01e5f5c1203d4ba87f8c1fc17aa6a33874a775cccba6d6f245" + "digest": "d09dabcf3ed38023913407762fa2d2c6a25db15625ae90386ee23cd9628ab838" } ] diff --git a/internal/app/types.go b/internal/app/types.go index d6e42908..ba8ba417 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -488,14 +488,18 @@ type Registry struct { PasswordEnv string `json:"password_env,omitempty" description:"Local environment-variable name containing the registry password or token." example:"GHCR_TOKEN"` } +// ManagedCertificateResolver is the private Traefik resolver identity shared +// by generated static configuration and terminating router labels. It is not a +// project-file value: Onebox owns both ends of this reference. +const ManagedCertificateResolver = "letsencrypt" + type Proxy struct { - Managed bool `json:"managed" description:"Let Onebox converge the host-scoped proxy when routes are declared."` - Kind string `json:"kind" description:"Proxy implementation, or none to disable routing." default:"traefik-docker"` - Image string `json:"image,omitempty" description:"Container image used for the managed proxy."` - Config string `json:"config,omitempty" description:"Repository-relative proxy configuration directory owned by the project. It must contain exactly one static traefik.yml or traefik.yaml, use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, and may not reuse Onebox-generated router or service names."` - Network string `json:"network" description:"External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved." default:"ob-ingress"` - CertResolver string `json:"cert_resolver,omitempty" description:"Traefik certificate resolver used by terminating TLS routes."` - Entrypoints map[string]ProxyEntrypoint `json:"entrypoints,omitempty" description:"Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a declared proxy.config must define matching Traefik entrypoints."` + Managed bool `json:"managed" description:"Let Onebox converge the host-scoped proxy when routes are declared."` + Kind string `json:"kind" description:"Proxy implementation, or none to disable routing." default:"traefik-docker"` + Image string `json:"image,omitempty" description:"Container image used for the managed proxy."` + Config string `json:"config,omitempty" description:"Repository-relative proxy configuration directory owned by the project. It must contain exactly one static traefik.yml or traefik.yaml, use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, may not reuse Onebox-generated router or service names, and must define certificatesResolvers.letsencrypt when a route terminates TLS."` + Network string `json:"network" description:"External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved." default:"ob-ingress"` + Entrypoints map[string]ProxyEntrypoint `json:"entrypoints,omitempty" description:"Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a declared proxy.config must define matching Traefik entrypoints."` } type ProxyEntrypoint struct { diff --git a/internal/engine/bootstrap_test.go b/internal/engine/bootstrap_test.go index f3e5463d..9a1a0fd9 100644 --- a/internal/engine/bootstrap_test.go +++ b/internal/engine/bootstrap_test.go @@ -282,7 +282,7 @@ func TestBootstrapEnsuresManagedProxyBeforeServices(t *testing.T) { if err := os.Mkdir(filepath.Join(dir, "traefik"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "traefik", "traefik.yml"), []byte("ping: {}\nproviders:\n file:\n directory: /etc/traefik/dynamic\n"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "traefik", "traefik.yml"), []byte(testManagedProxyStatic), 0o600); err != nil { t.Fatal(err) } cfg := testConfig() diff --git a/internal/engine/proxy.go b/internal/engine/proxy.go index 0ea42e02..38816d63 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -40,7 +40,7 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo defer os.RemoveAll(staging) discoveryImage := proxy.DiscoveryImage(e.Opts.Runner.Version) hash, err := proxy.StageForApp(localCfg, staging, e.Spec.Proxy.Image, discoveryImage, - e.Spec.Name, e.Spec.Proxy.Network, e.Spec.Proxy.Entrypoints) + e.Spec.Name, e.Spec.Proxy.Network, e.Spec.Proxy.Entrypoints, e.Spec.HasTerminatingTLS()) if err != nil { return err } diff --git a/internal/engine/proxy_test.go b/internal/engine/proxy_test.go index 7d04c912..926e28e2 100644 --- a/internal/engine/proxy_test.go +++ b/internal/engine/proxy_test.go @@ -3,6 +3,7 @@ package engine import ( "bytes" "context" + "errors" "os" "path/filepath" "strings" @@ -13,6 +14,8 @@ import ( "github.com/labstack/onebox/internal/transport" ) +const testManagedProxyStatic = "ping: {}\nproviders:\n file:\n directory: /etc/traefik/dynamic\ncertificatesResolvers:\n " + app.ManagedCertificateResolver + ":\n acme:\n storage: /letsencrypt/acme.json\n httpChallenge:\n entryPoint: web\n" + // proxyFixture: a managed-proxy engine whose LocalDir holds traefik/{traefik.yml,.env}, // plus the staged payload hash the engine will compute for it. func proxyFixture(t *testing.T, f *transport.Fake) (*Engine, string, *bytes.Buffer) { @@ -22,7 +25,7 @@ func proxyFixture(t *testing.T, f *transport.Fake) (*Engine, string, *bytes.Buff t.Fatal(err) } for name, content := range map[string]string{ - "traefik.yml": "ping: {}\nproviders:\n file:\n directory: /etc/traefik/dynamic\n", + "traefik.yml": testManagedProxyStatic, ".env": "CF_DNS_API_TOKEN=supersecrettoken\n", } { if err := os.WriteFile(filepath.Join(dir, "traefik", name), []byte(content), 0o600); err != nil { @@ -32,7 +35,7 @@ func proxyFixture(t *testing.T, f *transport.Fake) (*Engine, string, *bytes.Buff cfg := testConfig() cfg.Proxy = app.Proxy{Kind: "traefik-docker", Managed: true, Config: "traefik"} hash, err := proxy.StageForApp(filepath.Join(dir, "traefik"), t.TempDir(), "", - proxy.DiscoveryImage("dev"), cfg.Name, "", nil) + proxy.DiscoveryImage("dev"), cfg.Name, "", nil, cfg.HasTerminatingTLS()) if err != nil { t.Fatal(err) } @@ -41,6 +44,33 @@ func proxyFixture(t *testing.T, f *transport.Fake) (*Engine, string, *bytes.Buff return e, hash, &out } +func TestEnsureProxyRefusesMissingResolverBeforeHostMutation(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "traefik"), 0o755); err != nil { + t.Fatal(err) + } + static := "ping: {}\nproviders:\n file:\n directory: /etc/traefik/dynamic\n" + if err := os.WriteFile(filepath.Join(dir, "traefik", "traefik.yml"), []byte(static), 0o600); err != nil { + t.Fatal(err) + } + cfg := testConfig() + web := cfg.Workloads["web"] + web.Domain = "app.example.com" + web.Port = 7500 + cfg.Workloads["web"] = web + cfg.Proxy = app.Proxy{Kind: "traefik-docker", Managed: true, Config: "traefik"} + f := &transport.Fake{} + e := New(cfg, testProject(t), f, Options{Sleep: noSleep, LocalDir: dir}) + err := e.EnsureProxy(context.Background(), "D1", false) + var missing *proxy.CertificateResolverMissingError + if !errors.As(err, &missing) { + t.Fatalf("missing resolver must be refused with its typed error: %v", err) + } + if len(f.Commands) != 0 { + t.Fatalf("resolver contract must be checked before host mutation: %v", f.Commands) + } +} + // proxyPS answers container queries for the onebox-proxy project: present only // after `up -d` (or from the start when preRunning). func proxyPS(f *transport.Fake, preRunning bool) func(string) (transport.Result, bool) { diff --git a/internal/engine/proxystatus.go b/internal/engine/proxystatus.go index 68c66eb1..a5c374b8 100644 --- a/internal/engine/proxystatus.go +++ b/internal/engine/proxystatus.go @@ -184,7 +184,7 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { defer os.RemoveAll(staging) px.localHash, err = proxy.StageForApp(localCfg, staging, e.Spec.Proxy.Image, proxy.DiscoveryImage(e.Opts.Runner.Version), e.Spec.Name, - e.Spec.Proxy.Network, e.Spec.Proxy.Entrypoints) + e.Spec.Proxy.Network, e.Spec.Proxy.Entrypoints, e.Spec.HasTerminatingTLS()) return err }, } diff --git a/internal/engine/proxystatus_test.go b/internal/engine/proxystatus_test.go index c715a239..2ecf7ff1 100644 --- a/internal/engine/proxystatus_test.go +++ b/internal/engine/proxystatus_test.go @@ -52,11 +52,11 @@ func statusProxyEngine(t *testing.T, appliedHash *string, acme string, proxyHeal if err := os.Mkdir(filepath.Join(dir, "traefik"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "traefik", "traefik.yml"), []byte("ping: {}\nproviders:\n file:\n directory: /etc/traefik/dynamic\n"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "traefik", "traefik.yml"), []byte(testManagedProxyStatic), 0o600); err != nil { t.Fatal(err) } localHash, err := proxy.StageForApp(filepath.Join(dir, "traefik"), t.TempDir(), "", - proxy.DiscoveryImage("dev"), "sample", "", nil) + proxy.DiscoveryImage("dev"), "sample", "", nil, true) if err != nil { t.Fatal(err) } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index cd81110b..ac7825d7 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -249,7 +249,7 @@ func CertExpiries(acmeJSON []byte) ([]CertExpiry, error) { // static configuration while Onebox retains the socketless discovery boundary. // // The certificate resolver is defined but no email is set, so it is inert -// until a route asks for it via `proxy.cert_resolver`. +// until a terminating route asks for the private managed resolver identity. const defaultStaticConfigHeader = `# Written by Onebox because the project declared no proxy.config. # Declare one to take ownership of Traefik's static configuration. ping: {} @@ -268,7 +268,7 @@ entryPoints: ` const defaultStaticConfigFooter = `certificatesResolvers: - letsencrypt: + ` + app.ManagedCertificateResolver + `: acme: storage: /letsencrypt/acme.json httpChallenge: @@ -287,11 +287,11 @@ func renderStaticConfig(entrypoints map[string]app.ProxyEntrypoint) []byte { return []byte(out.String()) } -func Stage(localCfgDir, stagingDir, image, network string, entrypoints map[string]app.ProxyEntrypoint) (string, error) { - return StageForApp(localCfgDir, stagingDir, image, DiscoveryImage(""), "onebox", network, entrypoints) +func Stage(localCfgDir, stagingDir, image, network string, entrypoints map[string]app.ProxyEntrypoint, requireCertificateResolver bool) (string, error) { + return StageForApp(localCfgDir, stagingDir, image, DiscoveryImage(""), "onebox", network, entrypoints, requireCertificateResolver) } -func StageForApp(localCfgDir, stagingDir, image, discoveryImage, application, network string, entrypoints map[string]app.ProxyEntrypoint) (string, error) { +func StageForApp(localCfgDir, stagingDir, image, discoveryImage, application, network string, entrypoints map[string]app.ProxyEntrypoint, requireCertificateResolver bool) (string, error) { if application == "" { application = "onebox" } @@ -334,7 +334,7 @@ func StageForApp(localCfgDir, stagingDir, image, discoveryImage, application, ne if err != nil { return "", err } - if err := validateSocketlessStaticConfig(staticBody); err != nil { + if err := validateSocketlessStaticConfig(staticBody, requireCertificateResolver); err != nil { return "", fmt.Errorf("proxy.config %s: %w", staticConfigs[0], err) } @@ -422,7 +422,15 @@ func dynamicConfigExtension(name string) bool { // validateSocketlessStaticConfig prevents a custom configuration from // silently keeping the Docker provider enabled or omitting the directory where // the isolated controller publishes its sanitized view. -func validateSocketlessStaticConfig(body []byte) error { +// CertificateResolverMissingError identifies the custom-config contract that +// failed without reducing it to message matching for callers or tests. +type CertificateResolverMissingError struct{ Name string } + +func (e *CertificateResolverMissingError) Error() string { + return fmt.Sprintf("terminating TLS routes require certificatesResolvers.%s in the static configuration; define it or remove proxy.config to use Onebox's managed ACME configuration", e.Name) +} + +func validateSocketlessStaticConfig(body []byte, requireCertificateResolver bool) error { var document map[string]any if err := yaml.Unmarshal(body, &document); err != nil { return fmt.Errorf("parse static configuration: %w", err) @@ -447,6 +455,13 @@ func validateSocketlessStaticConfig(body []byte) error { return errors.New("set providers.file.watch to true or omit it; Onebox discovery requires live configuration updates") } } + if requireCertificateResolver { + resolvers, ok := document["certificatesResolvers"].(map[string]any) + resolver, defined := resolvers[app.ManagedCertificateResolver].(map[string]any) + if !ok || !defined || len(resolver) == 0 { + return &CertificateResolverMissingError{Name: app.ManagedCertificateResolver} + } + } return nil } diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index d565eeca..10ba6a46 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -8,6 +8,7 @@ import ( "crypto/x509/pkix" "encoding/base64" "encoding/pem" + "errors" "fmt" "math/big" "os" @@ -21,6 +22,7 @@ import ( ) const testSocketlessStatic = "ping: {}\nproviders:\n file:\n directory: /etc/traefik/dynamic\n" +const testSocketlessStaticWithResolver = testSocketlessStatic + "certificatesResolvers:\n " + app.ManagedCertificateResolver + ":\n acme:\n storage: /letsencrypt/acme.json\n httpChallenge:\n entryPoint: web\n" func writeCfg(t *testing.T, files map[string]string) string { t.Helper() @@ -98,6 +100,35 @@ func TestDefaultProxyRenderingIsSocketless(t *testing.T) { } } +func TestManagedTLSRouterReferencesDefaultStaticResolver(t *testing.T) { + spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 +app: sample +environments: + production: {server: root@example.com} +workloads: + web: + role: application + image: nginx:1.27 + domain: app.example.com + port: 80 +`), "ob.yml") + if err != nil { + t.Fatal(err) + } + runtime, err := spec.Render("production", "r1", nil) + if err != nil { + t.Fatal(err) + } + label := "tls.certresolver: " + app.ManagedCertificateResolver + if !strings.Contains(string(runtime.Bytes), label) { + t.Fatalf("terminating router does not reference the managed resolver %q:\n%s", app.ManagedCertificateResolver, runtime.Bytes) + } + declaration := "certificatesResolvers:\n " + app.ManagedCertificateResolver + ":" + if !strings.Contains(string(renderStaticConfig(nil)), declaration) { + t.Fatalf("default static configuration does not define the router's resolver %q:\n%s", app.ManagedCertificateResolver, renderStaticConfig(nil)) + } +} + func TestRenderAdditionalEntrypoints(t *testing.T) { entrypoints := map[string]app.ProxyEntrypoint{ "otlp-http": {Port: 4318}, @@ -131,7 +162,7 @@ func TestStage(t *testing.T) { ".env": "CF_DNS_API_TOKEN=x\n", }) staging := t.TempDir() - hash, err := Stage(cfgDir, staging, "", "", nil) + hash, err := Stage(cfgDir, staging, "", "", nil, false) if err != nil { t.Fatal(err) } @@ -160,7 +191,7 @@ func TestStage(t *testing.T) { // determinism + sensitivity staging2 := t.TempDir() - hash2, err := Stage(cfgDir, staging2, "", "", nil) + hash2, err := Stage(cfgDir, staging2, "", "", nil, false) if err != nil { t.Fatal(err) } @@ -170,7 +201,7 @@ func TestStage(t *testing.T) { if err := os.WriteFile(filepath.Join(cfgDir, "dynamic.yml"), []byte("http: {middlewares: {}}\n"), 0o600); err != nil { t.Fatal(err) } - hash3, err := Stage(cfgDir, t.TempDir(), "", "", nil) + hash3, err := Stage(cfgDir, t.TempDir(), "", "", nil, false) if err != nil { t.Fatal(err) } @@ -184,7 +215,7 @@ func TestStageCustomConfigPublishesEntrypointsWithoutRewritingIt(t *testing.T) { cfgDir := writeCfg(t, map[string]string{"traefik.yml": staticBody}) staging := t.TempDir() entrypoints := map[string]app.ProxyEntrypoint{"otlp-grpc": {Port: 4317}} - if _, err := Stage(cfgDir, staging, "", "", entrypoints); err != nil { + if _, err := Stage(cfgDir, staging, "", "", entrypoints, false); err != nil { t.Fatal(err) } compose, err := os.ReadFile(filepath.Join(staging, "compose.yaml")) @@ -205,7 +236,7 @@ func TestStageCustomConfigPublishesEntrypointsWithoutRewritingIt(t *testing.T) { func TestStageRequiresTraefikConfig(t *testing.T) { cfgDir := writeCfg(t, map[string]string{"dynamic.yml": "http: {}\n"}) - if _, err := Stage(cfgDir, t.TempDir(), "", "", nil); err == nil || + if _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false); err == nil || !strings.Contains(err.Error(), "traefik.yml") || !strings.Contains(err.Error(), "traefik.yaml") { t.Fatalf("want both supported static config names in the contract error, got %v", err) } @@ -214,7 +245,7 @@ func TestStageRequiresTraefikConfig(t *testing.T) { func TestStageAcceptsTraefikYAML(t *testing.T) { cfgDir := writeCfg(t, map[string]string{"traefik.yaml": testSocketlessStatic}) staging := t.TempDir() - if _, err := Stage(cfgDir, staging, "", "", nil); err != nil { + if _, err := Stage(cfgDir, staging, "", "", nil, false); err != nil { t.Fatalf("traefik.yaml must be accepted: %v", err) } if _, err := os.Stat(filepath.Join(staging, "config", "traefik.yaml")); err != nil { @@ -226,7 +257,7 @@ func TestStageRejectsDockerProviderInCustomConfig(t *testing.T) { cfgDir := writeCfg(t, map[string]string{ "traefik.yml": "ping: {}\nproviders:\n docker: {}\n", }) - _, err := Stage(cfgDir, t.TempDir(), "", "", nil) + _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false) if err == nil || !strings.Contains(err.Error(), "remove providers.docker") { t.Fatalf("Docker provider must be refused for managed Traefik: %v", err) } @@ -234,7 +265,7 @@ func TestStageRejectsDockerProviderInCustomConfig(t *testing.T) { func TestStageExplainsCustomFileProviderMigration(t *testing.T) { cfgDir := writeCfg(t, map[string]string{"traefik.yml": "ping: {}\nproviders:\n file:\n directory: /etc/traefik\n"}) - _, err := Stage(cfgDir, t.TempDir(), "", "", nil) + _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false) if err == nil || !strings.Contains(err.Error(), "/etc/traefik/dynamic") { t.Fatalf("custom config migration must name the required directory: %v", err) } @@ -248,13 +279,30 @@ func TestStageRequiresLiveDirectoryProvider(t *testing.T) { for name, static := range tests { t.Run(name, func(t *testing.T) { cfgDir := writeCfg(t, map[string]string{"traefik.yml": static}) - if _, err := Stage(cfgDir, t.TempDir(), "", "", nil); err == nil { + if _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false); err == nil { t.Fatal("non-live file provider must be refused") } }) } } +func TestStageRequiresManagedResolverForTerminatingTLS(t *testing.T) { + cfgDir := writeCfg(t, map[string]string{"traefik.yml": testSocketlessStatic}) + if _, err := StageForApp(cfgDir, t.TempDir(), "", "", "onebox", "", nil, false); err != nil { + t.Fatalf("a config without the resolver remains valid when no route terminates TLS: %v", err) + } + _, err := StageForApp(cfgDir, t.TempDir(), "", "", "onebox", "", nil, true) + var missing *CertificateResolverMissingError + if !errors.As(err, &missing) || missing.Name != app.ManagedCertificateResolver { + t.Fatalf("missing managed resolver must be a typed refusal: %v", err) + } + + configured := writeCfg(t, map[string]string{"traefik.yml": testSocketlessStaticWithResolver}) + if _, err := StageForApp(configured, t.TempDir(), "", "", "onebox", "", nil, true); err != nil { + t.Fatalf("custom configuration defining the managed resolver must be accepted: %v", err) + } +} + func TestStageRejectsProviderOverridesInEnv(t *testing.T) { for _, declaration := range []string{ "TRAEFIK_PROVIDERS_DOCKER=true\n", @@ -265,7 +313,7 @@ func TestStageRejectsProviderOverridesInEnv(t *testing.T) { "traefik.yml": testSocketlessStatic, ".env": "CF_DNS_API_TOKEN=allowed\n" + declaration, }) - if _, err := Stage(cfgDir, t.TempDir(), "", "", nil); err == nil || !strings.Contains(err.Error(), "managed proxy provider settings") { + if _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false); err == nil || !strings.Contains(err.Error(), "managed proxy provider settings") { t.Fatalf("provider override %q must be refused: %v", declaration, err) } } @@ -286,7 +334,7 @@ func TestStageRejectsGeneratedDynamicNameCollisions(t *testing.T) { "traefik.yml": testSocketlessStatic, "dynamic" + ext: dynamic, }) - _, err := StageForApp(cfgDir, t.TempDir(), "", "", "onebox", "", nil) + _, err := StageForApp(cfgDir, t.TempDir(), "", "", "onebox", "", nil, false) if err == nil || !strings.Contains(err.Error(), "Onebox-reserved prefix") { t.Fatalf("generated-name collision must be refused: %v", err) } @@ -299,7 +347,7 @@ func TestStageAllowsUnrelatedCustomDynamicObjects(t *testing.T) { "traefik.yml": testSocketlessStatic, "dynamic.yml": "http:\n routers:\n external_status:\n rule: Host(`status.example.com`)\n", }) - if _, err := StageForApp(cfgDir, t.TempDir(), "", "", "onebox", "", nil); err != nil { + if _, err := StageForApp(cfgDir, t.TempDir(), "", "", "onebox", "", nil, false); err != nil { t.Fatalf("unrelated file-provider object must remain supported: %v", err) } } @@ -309,7 +357,7 @@ func TestStageReservesGeneratedDiscoveryFilename(t *testing.T) { "traefik.yml": testSocketlessStatic, "onebox.yml": `{}`, }) - _, err := Stage(cfgDir, t.TempDir(), "", "", nil) + _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false) if err == nil || !strings.Contains(err.Error(), "reserves onebox.yml") { t.Fatalf("generated discovery filename collision = %v", err) } @@ -320,7 +368,7 @@ func TestStageRejectsAmbiguousTraefikConfig(t *testing.T) { "traefik.yml": "ping: {}\n", "traefik.yaml": "ping: {}\n", }) - if _, err := Stage(cfgDir, t.TempDir(), "", "", nil); err == nil || !strings.Contains(err.Error(), "both") { + if _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false); err == nil || !strings.Contains(err.Error(), "both") { t.Fatalf("two static config files must be refused as ambiguous: %v", err) } } @@ -330,7 +378,7 @@ func TestStageRejectsSubdirs(t *testing.T) { if err := os.Mkdir(filepath.Join(cfgDir, "extra"), 0o755); err != nil { t.Fatal(err) } - if _, err := Stage(cfgDir, t.TempDir(), "", "", nil); err == nil || !strings.Contains(err.Error(), "flat") { + if _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false); err == nil || !strings.Contains(err.Error(), "flat") { t.Fatalf("want flat-dir contract error, got %v", err) } } @@ -386,7 +434,7 @@ func TestCertExpiries(t *testing.T) { // same one every time. func TestDefaultStaticConfigIsWrittenWhenNoneIsDeclared(t *testing.T) { staging := t.TempDir() - hash, err := Stage("", staging, "traefik:v3.7", "ob-ingress", nil) + hash, err := Stage("", staging, "traefik:v3.7", "ob-ingress", nil, true) if err != nil { t.Fatalf("a project without proxy.config must still bootstrap: %v", err) } @@ -406,13 +454,13 @@ func TestDefaultStaticConfigIsWrittenWhenNoneIsDeclared(t *testing.T) { func TestDeclaredEntrypointsChangeProxyIdentity(t *testing.T) { plainDir := t.TempDir() - plainHash, err := Stage("", plainDir, "", "", nil) + plainHash, err := Stage("", plainDir, "", "", nil, false) if err != nil { t.Fatal(err) } entrypoints := map[string]app.ProxyEntrypoint{"otlp-grpc": {Port: 4317}} staging := t.TempDir() - entrypointHash, err := Stage("", staging, "", "", entrypoints) + entrypointHash, err := Stage("", staging, "", "", entrypoints, false) if err != nil { t.Fatal(err) } @@ -435,7 +483,7 @@ func TestDeclaredConfigStillOwnsItAndSaysWhatIsMissing(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "other.yml"), []byte("x: 1\n"), 0o600); err != nil { t.Fatal(err) } - _, err := Stage(dir, t.TempDir(), "traefik:v3.7", "ob-ingress", nil) + _, err := Stage(dir, t.TempDir(), "traefik:v3.7", "ob-ingress", nil, false) if err == nil { t.Fatal("a declared config directory without traefik.yml or traefik.yaml must be refused") } diff --git a/internal/transport/ssh.go b/internal/transport/ssh.go index 71bb30e0..134f47fd 100644 --- a/internal/transport/ssh.go +++ b/internal/transport/ssh.go @@ -186,8 +186,10 @@ func sshHandshake(ctx context.Context, conn net.Conn, address obtarget.Address, HostKeyAlgorithms: knownHostKeyAlgos(hostKeys, dialed), } handshakeDeadline := time.Now().Add(handshakeTimeout) + deadlineFromContext := false if deadline, ok := ctx.Deadline(); ok && deadline.Before(handshakeDeadline) { handshakeDeadline = deadline + deadlineFromContext = true } // Best effort: a TCP conn reports a clean i/o timeout this way, but an SSH // channel refuses deadlines outright ("ssh: tcpChan: deadline not @@ -237,6 +239,17 @@ func sshHandshake(ctx context.Context, conn net.Conn, address obtarget.Address, if ctxErr := ctx.Err(); ctxErr != nil { return nil, hopError(stage, address, phaseNone, ctxErr) } + deadlineReached := !time.Now().Before(handshakeDeadline) + if gaveUp || (deadlineReached && isTransportFailure(err)) { + // The timer and context deadline target the same instant. The timer + // or a TCP deadline can close the connection just before context + // publishes its error, turning a deadline into a misleading handshake + // failure unless the deadline source is retained explicitly. + if deadlineFromContext { + return nil, hopError(stage, address, phaseNone, context.DeadlineExceeded) + } + return nil, hopError(stage, address, phaseNone, errHandshakeAbandoned) + } return nil, hopError(stage, address, phaseOf(err), err) } if ctxErr := ctx.Err(); ctxErr != nil { diff --git a/internal/transport/ssh_jump_test.go b/internal/transport/ssh_jump_test.go index 09774091..70006799 100644 --- a/internal/transport/ssh_jump_test.go +++ b/internal/transport/ssh_jump_test.go @@ -248,6 +248,9 @@ func TestSilentTargetBehindAJumpIsBoundedByTheHandshakeTimeout(t *testing.T) { if err == nil { t.Fatal("a silent target completed a handshake") } + if !errors.Is(err, errHandshakeAbandoned) { + t.Fatalf("error = %v, want the handshake timeout", err) + } case <-time.After(10 * time.Second): t.Fatal("connect to a silent target never returned") } @@ -436,3 +439,22 @@ func TestExpiredDeadlineNamesTheHop(t *testing.T) { t.Fatalf("error %q does not name the target hop", err) } } + +// A direct TCP connection honors SetDeadline, so its read can return an I/O +// timeout just before the context publishes DeadlineExceeded. The caller's +// deadline remains the public error regardless of which wake-up wins. +func TestDirectDeadlineWinsTheTCPHandshakeRace(t *testing.T) { + home, _ := sshTestHome(t) + silent := newSilentListener(t) + writeKnownHostsFor(t, home, silent.Addr().String(), generateSigner(t).PublicKey()) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, err := NewSSHContext(ctx, "root@"+silent.Addr().String()) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("direct silent handshake = %v, want context deadline", err) + } + if !strings.HasPrefix(err.Error(), "ssh ") { + t.Fatalf("error %q does not name the direct hop", err) + } +} diff --git a/site/public/onebox.run-v1.schema.json b/site/public/onebox.run-v1.schema.json index 6096d13c..a7e9a0be 100644 --- a/site/public/onebox.run-v1.schema.json +++ b/site/public/onebox.run-v1.schema.json @@ -1163,12 +1163,8 @@ "^x-": {} }, "properties": { - "cert_resolver": { - "description": "Traefik certificate resolver used by terminating TLS routes.", - "type": "string" - }, "config": { - "description": "Repository-relative proxy configuration directory owned by the project. It must contain exactly one static traefik.yml or traefik.yaml, use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, and may not reuse Onebox-generated router or service names. Expects a path inside the repository, with no control character or shell metacharacter.", + "description": "Repository-relative proxy configuration directory owned by the project. It must contain exactly one static traefik.yml or traefik.yaml, use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, may not reuse Onebox-generated router or service names, and must define certificatesResolvers.letsencrypt when a route terminates TLS. Expects a path inside the repository, with no control character or shell metacharacter.", "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", "type": "string" }, diff --git a/site/src/content/docs/reference/fields/proxy.mdx b/site/src/content/docs/reference/fields/proxy.mdx index e4a3fb2c..1a544002 100644 --- a/site/src/content/docs/reference/fields/proxy.mdx +++ b/site/src/content/docs/reference/fields/proxy.mdx @@ -17,14 +17,13 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`cert_resolver` · `config` · `entrypoints` · `image` · `kind` · `managed` · `network` · `port` +`config` · `entrypoints` · `image` · `kind` · `managed` · `network` · `port` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `cert_resolver` | string | — | Traefik certificate resolver used by terminating TLS routes. | -| `config` | string | — | Repository-relative proxy configuration directory owned by the project. It must contain exactly one static traefik.yml or traefik.yaml, use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, and may not reuse Onebox-generated router or service names. Expects a path inside the repository, with no control character or shell metacharacter. | +| `config` | string | — | Repository-relative proxy configuration directory owned by the project. It must contain exactly one static traefik.yml or traefik.yaml, use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, may not reuse Onebox-generated router or service names, and must define certificatesResolvers.letsencrypt when a route terminates TLS. Expects a path inside the repository, with no control character or shell metacharacter. | | `entrypoints` | map | — | Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a declared proxy.config must define matching Traefik entrypoints. | | `entrypoints..port` | integer | — | Host and proxy-container TCP port used by this listener. | | `image` | string | — | Container image used for the managed proxy. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:…. |