From 897244448b22e8520a2404f1f18cd07e28543130 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Thu, 27 Aug 2026 19:19:08 -0700 Subject: [PATCH] feat(proxy): support dynamic extensions Generate managed static configuration when proxy.config contains only dynamic files, while preserving full custom static ownership when a Traefik config is present.\n\nCreate the nested dynamic mountpoint before Docker binds it beneath the read-only configuration mount. --- docs/onebox.run-v1.schema.json | 4 +- internal/app/types.go | 4 +- internal/proxy/proxy.go | 112 +++++++++++---- internal/proxy/proxy_test.go | 128 +++++++++++++++--- site/public/onebox.run-v1.schema.json | 4 +- .../content/docs/reference/fields/proxy.mdx | 4 +- .../content/docs/reference/project-file.mdx | 68 ++++++---- 7 files changed, 244 insertions(+), 80 deletions(-) diff --git a/docs/onebox.run-v1.schema.json b/docs/onebox.run-v1.schema.json index a7e9a0be..e13483b1 100644 --- a/docs/onebox.run-v1.schema.json +++ b/docs/onebox.run-v1.schema.json @@ -1164,7 +1164,7 @@ }, "properties": { "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, 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.", + "description": "Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. Including traefik.yml or traefik.yaml instead takes ownership of the static configuration, which must use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, and must define certificatesResolvers.letsencrypt when a route terminates TLS. Dynamic files may not reuse Onebox-generated router or service names. Expects a path inside the repository, with no control character or shell metacharacter.", "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", "type": "string" }, @@ -1187,7 +1187,7 @@ }, "type": "object" }, - "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.", + "description": "Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a proxy.config containing custom traefik.yml or traefik.yaml must define matching Traefik entrypoints.", "propertyNames": { "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$" }, diff --git a/internal/app/types.go b/internal/app/types.go index ba8ba417..9d9a20fc 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -497,9 +497,9 @@ 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, may not reuse Onebox-generated router or service names, and must define certificatesResolvers.letsencrypt when a route terminates TLS."` + Config string `json:"config,omitempty" description:"Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. Including traefik.yml or traefik.yaml instead takes ownership of the static configuration, which must use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, and must define certificatesResolvers.letsencrypt when a route terminates TLS. Dynamic files 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"` - 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."` + 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 proxy.config containing custom traefik.yml or traefik.yaml must define matching Traefik entrypoints."` } type ProxyEntrypoint struct { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index ac7825d7..f325426c 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -3,11 +3,11 @@ // host, shared by every ob app on it, living under /var/lib/ob/_host/ — // a name no app can take (app names match ^[a-z][a-z0-9-]*$). // -// The app supplies the Traefik configuration as a flat dir (proxy.config): -// traefik.yml or traefik.yaml required (static config; must declare ping: {} — -// the container healthcheck gates on it — and ACME storage at -// /letsencrypt/acme.json); -// dynamic.yml and .env optional. ob renders the compose around it. +// The app may supply Traefik configuration as a flat dir (proxy.config). +// Dynamic YAML or TOML extends the configuration Onebox writes. Supplying +// traefik.yml or traefik.yaml instead takes ownership of the static +// configuration while Onebox retains the socketless discovery boundary. A +// proxy .env is meaningful only with that custom static configuration. package proxy import ( @@ -20,6 +20,7 @@ import ( "encoding/pem" "errors" "fmt" + "hash" "os" "path/filepath" "regexp" @@ -245,13 +246,14 @@ func CertExpiries(acmeJSON []byte) ([]CertExpiry, error) { // configuration before their first deploy contradicts that, and the file they // would write is the same one every time: watch the sanitized file-provider // directory, answer the health check, and put ACME where the volume is. A -// project that needs something else still declares `proxy.config`; it owns the -// static configuration while Onebox retains the socketless discovery boundary. +// project that needs something else can add dynamic files through +// `proxy.config`, or include traefik.yml/traefik.yaml there to own the 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 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. +const defaultStaticConfigHeader = `# Written by Onebox because the project declared no custom static proxy configuration. +# Add dynamic files through proxy.config, or include traefik.yml/traefik.yaml to own this file. ping: {} providers: file: @@ -277,6 +279,19 @@ const defaultStaticConfigFooter = `certificatesResolvers: const DefaultStaticConfig = defaultStaticConfigHeader + defaultStaticConfigFooter +// proxyStagingLayoutIdentity makes required non-file artifacts part of the +// applied proxy identity. Bump it whenever a deploy must reconcile a changed +// directory layout even if every authored file and rendered Compose byte stays +// the same. v2 adds config/dynamic: Docker cannot create that nested mountpoint +// below the read-only /etc/traefik parent mount. +const proxyStagingLayoutIdentity = "onebox-proxy-layout:v2\x00config/dynamic:directory\x00" + +func newProxyConfigHash() hash.Hash { + h := sha256.New() + _, _ = h.Write([]byte(proxyStagingLayoutIdentity)) + return h +} + func renderStaticConfig(entrypoints map[string]app.ProxyEntrypoint) []byte { var out strings.Builder out.WriteString(defaultStaticConfigHeader) @@ -305,54 +320,92 @@ func StageForApp(localCfgDir, stagingDir, image, discoveryImage, application, ne names := make([]string, 0, len(entries)) staticConfigs := make([]string, 0, 2) hasEnv := false + hasDynamic := false for _, e := range entries { if e.IsDir() { return "", fmt.Errorf("proxy.config must be a flat dir of config files; %s/ is a directory", e.Name()) } + if e.Name() == "dynamic" { + return "", fmt.Errorf("proxy.config reserves dynamic/ as the generated file-provider mountpoint; rename %s", e.Name()) + } names = append(names, e.Name()) if e.Name() == "onebox.yml" || e.Name() == "onebox.yaml" { return "", fmt.Errorf("proxy.config reserves onebox.yml and onebox.yaml for generated socketless discovery output; rename that file") } + if e.Name() == "traefik.toml" { + return "", fmt.Errorf("proxy.config does not support traefik.toml as static configuration; use traefik.yml or traefik.yaml, or rename it as a dynamic extension") + } if e.Name() == "traefik.yml" || e.Name() == "traefik.yaml" { staticConfigs = append(staticConfigs, e.Name()) + } else if dynamicConfigExtension(e.Name()) { + hasDynamic = true } if e.Name() == ".env" { hasEnv = true } } - if len(staticConfigs) == 0 { - return "", fmt.Errorf("proxy.config: %s contains neither traefik.yml nor traefik.yaml. "+ - "Remove proxy.config to use the configuration Onebox writes, or add one of those files to own it", - localCfgDir) - } if len(staticConfigs) > 1 { return "", fmt.Errorf("proxy.config: %s contains both traefik.yml and traefik.yaml; keep exactly one static configuration file", localCfgDir) } - sort.Strings(names) - staticBody, err := os.ReadFile(filepath.Join(localCfgDir, staticConfigs[0])) - if err != nil { - return "", err + customStatic := len(staticConfigs) == 1 + if !customStatic && !hasDynamic { + return "", fmt.Errorf("proxy.config: %s contains no dynamic .yml, .yaml, or .toml files; "+ + "remove proxy.config to use only Onebox's managed configuration, or add a dynamic extension", + localCfgDir) } - if err := validateSocketlessStaticConfig(staticBody, requireCertificateResolver); err != nil { - return "", fmt.Errorf("proxy.config %s: %w", staticConfigs[0], err) + if !customStatic && hasEnv { + return "", fmt.Errorf("proxy.config .env requires traefik.yml or traefik.yaml; " + + "Onebox's managed static configuration does not consume custom proxy environment variables") + } + + staticName := "traefik.yml" + staticBody := renderStaticConfig(entrypoints) + if customStatic { + staticName = staticConfigs[0] + var err error + staticBody, err = os.ReadFile(filepath.Join(localCfgDir, staticName)) + if err != nil { + return "", err + } + if err := validateSocketlessStaticConfig(staticBody, requireCertificateResolver); err != nil { + return "", fmt.Errorf("proxy.config %s: %w", staticName, err) + } + } else { + // Hash and stage the effective configuration, not merely the authored + // extension. This keeps drift detection sensitive to Onebox default and + // entrypoint changes while the user owns only dynamic policy. + names = append(names, staticName) } + sort.Strings(names) cfgOut := filepath.Join(stagingDir, "config") if err := os.MkdirAll(cfgOut, 0o755); err != nil { return "", err } + // Docker cannot create a nested bind-mount target below the read-only + // /etc/traefik mount. The parent source must therefore carry the empty + // mountpoint before Compose creates the proxy container. + if err := os.MkdirAll(filepath.Join(cfgOut, "dynamic"), 0o755); err != nil { + return "", err + } dynamicOut := filepath.Join(stagingDir, "dynamic") if err := os.MkdirAll(dynamicOut, 0o755); err != nil { return "", err } - h := sha256.New() + h := newProxyConfigHash() for _, name := range names { - b, err := os.ReadFile(filepath.Join(localCfgDir, name)) - if err != nil { - return "", err + var b []byte + if !customStatic && name == staticName { + b = staticBody + } else { + var err error + b, err = os.ReadFile(filepath.Join(localCfgDir, name)) + if err != nil { + return "", err + } } - if name != staticConfigs[0] && dynamicConfigExtension(name) { + if name != staticName && dynamicConfigExtension(name) { if err := validateDynamicOwnership(name, b, application); err != nil { return "", fmt.Errorf("proxy.config %s: %w", name, err) } @@ -367,7 +420,7 @@ func StageForApp(localCfgDir, stagingDir, image, discoveryImage, application, ne if err := os.WriteFile(filepath.Join(cfgOut, name), b, 0o600); err != nil { return "", err } - if name != staticConfigs[0] && dynamicConfigExtension(name) { + if name != staticName && dynamicConfigExtension(name) { if err := os.WriteFile(filepath.Join(dynamicOut, name), b, 0o600); err != nil { return "", err } @@ -391,6 +444,9 @@ func stageDefault(stagingDir, image, discoveryImage, application, network string if err := os.MkdirAll(cfgOut, 0o755); err != nil { return "", err } + if err := os.MkdirAll(filepath.Join(cfgOut, "dynamic"), 0o755); err != nil { + return "", err + } if err := os.MkdirAll(filepath.Join(stagingDir, "dynamic"), 0o755); err != nil { return "", err } @@ -398,7 +454,7 @@ func stageDefault(stagingDir, image, discoveryImage, application, network string if err := os.WriteFile(filepath.Join(cfgOut, "traefik.yml"), body, 0o600); err != nil { return "", err } - h := sha256.New() + h := newProxyConfigHash() fmt.Fprintf(h, "%s\x00%d\x00", "traefik.yml", len(body)) h.Write(body) @@ -427,7 +483,7 @@ func dynamicConfigExtension(name string) bool { 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) + return fmt.Sprintf("terminating TLS routes require certificatesResolvers.%s in the custom static configuration; define it or remove traefik.yml/traefik.yaml to use Onebox's managed ACME configuration", e.Name) } func validateSocketlessStaticConfig(body []byte, requireCertificateResolver bool) error { diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 10ba6a46..b4d3dac6 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -4,9 +4,11 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" "crypto/x509" "crypto/x509/pkix" "encoding/base64" + "encoding/hex" "encoding/pem" "errors" "fmt" @@ -188,6 +190,9 @@ func TestStage(t *testing.T) { if _, err := os.Stat(filepath.Join(staging, "dynamic", "dynamic.yml")); err != nil { t.Fatalf("custom dynamic provider file was not staged for the socketless directory: %v", err) } + if info, err := os.Stat(filepath.Join(staging, "config", "dynamic")); err != nil || !info.IsDir() { + t.Fatalf("nested dynamic mountpoint was not staged below the read-only config mount: %v", err) + } // determinism + sensitivity staging2 := t.TempDir() @@ -210,6 +215,40 @@ func TestStage(t *testing.T) { } } +func TestStageCustomStaticHashMigratesRequiredMountpoint(t *testing.T) { + cfgDir := writeCfg(t, map[string]string{"traefik.yml": testSocketlessStatic}) + got, err := Stage(cfgDir, t.TempDir(), "", "", nil, false) + if err != nil { + t.Fatal(err) + } + + // Reproduce the pre-layout-marker identity. The required empty directory + // has no file bytes, so without an explicit marker an already-applied custom + // configuration would remain a no-op and never receive config/dynamic. + legacy := sha256.New() + fmt.Fprintf(legacy, "%s\x00%d\x00", "traefik.yml", len(testSocketlessStatic)) + _, _ = legacy.Write([]byte(testSocketlessStatic)) + compose := RenderCompose("", "", false, nil) + fmt.Fprintf(legacy, "compose.yaml\x00%d\x00", len(compose)) + _, _ = legacy.Write(compose) + if got == hex.EncodeToString(legacy.Sum(nil)) { + t.Fatal("the required config/dynamic mountpoint must migrate the applied proxy identity") + } +} + +func TestStageDefaultCreatesNestedDynamicMountpoint(t *testing.T) { + staging := t.TempDir() + if _, err := Stage("", staging, "", "", nil, true); err != nil { + t.Fatal(err) + } + for _, directory := range []string{"config/dynamic", "dynamic"} { + info, err := os.Stat(filepath.Join(staging, directory)) + if err != nil || !info.IsDir() { + t.Fatalf("staged directory %s: %v", directory, err) + } + } +} + func TestStageCustomConfigPublishesEntrypointsWithoutRewritingIt(t *testing.T) { staticBody := testSocketlessStatic + "entryPoints: {}\n" cfgDir := writeCfg(t, map[string]string{"traefik.yml": staticBody}) @@ -234,11 +273,52 @@ 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, 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) +func TestStageDynamicExtensionUsesManagedStaticConfig(t *testing.T) { + cfgDir := writeCfg(t, map[string]string{ + "dynamic.yml": "http:\n middlewares:\n compress:\n compress: {}\n", + }) + entrypoints := map[string]app.ProxyEntrypoint{"otlp-grpc": {Port: 4317}} + staging := t.TempDir() + hash, err := Stage(cfgDir, staging, "", "", entrypoints, true) + if err != nil { + t.Fatalf("dynamic-only proxy.config must extend the managed configuration: %v", err) + } + static, err := os.ReadFile(filepath.Join(staging, "config", "traefik.yml")) + if err != nil { + t.Fatalf("managed static configuration was not staged: %v", err) + } + for _, want := range []string{ + "# Written by Onebox", + "directory: /etc/traefik/dynamic", + "certificatesResolvers:", + "otlp-grpc:\n address: \":4317\"", + } { + if !strings.Contains(string(static), want) { + t.Errorf("managed static configuration is missing %q:\n%s", want, static) + } + } + dynamic, err := os.ReadFile(filepath.Join(staging, "dynamic", "dynamic.yml")) + if err != nil || !strings.Contains(string(dynamic), "compress:") { + t.Fatalf("dynamic extension was not staged into the watched directory: %v\n%s", err, dynamic) + } + + changedHash, err := Stage(cfgDir, t.TempDir(), "", "", map[string]app.ProxyEntrypoint{"otlp-http": {Port: 4318}}, true) + if err != nil { + t.Fatal(err) + } + if changedHash == hash { + t.Fatal("managed static changes must alter the identity of a dynamic-only configuration") + } +} + +func TestStageDynamicExtensionRejectsEnvWithoutCustomStatic(t *testing.T) { + cfgDir := writeCfg(t, map[string]string{ + "dynamic.yml": "http: {}\n", + ".env": "CF_DNS_API_TOKEN=unused\n", + }) + _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false) + if err == nil || !strings.Contains(err.Error(), ".env requires traefik.yml or traefik.yaml") { + t.Fatalf("an environment file without custom static configuration must be refused: %v", err) } } @@ -253,6 +333,14 @@ func TestStageAcceptsTraefikYAML(t *testing.T) { } } +func TestStageRejectsAmbiguousTraefikTOML(t *testing.T) { + cfgDir := writeCfg(t, map[string]string{"traefik.toml": "[http.middlewares.compress.compress]\n"}) + _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false) + if err == nil || !strings.Contains(err.Error(), "does not support traefik.toml as static configuration") { + t.Fatalf("Traefik's third native static filename must not be mistaken for a dynamic extension: %v", err) + } +} + func TestStageRejectsDockerProviderInCustomConfig(t *testing.T) { cfgDir := writeCfg(t, map[string]string{ "traefik.yml": "ping: {}\nproviders:\n docker: {}\n", @@ -330,10 +418,7 @@ func TestStageRejectsGeneratedDynamicNameCollisions(t *testing.T) { if strings.HasPrefix(name, "toml") { ext = ".toml" } - cfgDir := writeCfg(t, map[string]string{ - "traefik.yml": testSocketlessStatic, - "dynamic" + ext: dynamic, - }) + cfgDir := writeCfg(t, map[string]string{"dynamic" + ext: dynamic}) _, 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) @@ -344,7 +429,6 @@ func TestStageRejectsGeneratedDynamicNameCollisions(t *testing.T) { func TestStageAllowsUnrelatedCustomDynamicObjects(t *testing.T) { cfgDir := writeCfg(t, map[string]string{ - "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, false); err != nil { @@ -363,6 +447,17 @@ func TestStageReservesGeneratedDiscoveryFilename(t *testing.T) { } } +func TestStageReservesGeneratedDynamicMountpoint(t *testing.T) { + cfgDir := writeCfg(t, map[string]string{ + "traefik.yml": testSocketlessStatic, + "dynamic": "not a directory\n", + }) + _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false) + if err == nil || !strings.Contains(err.Error(), "reserves dynamic/") { + t.Fatalf("generated dynamic mountpoint collision = %v", err) + } +} + func TestStageRejectsAmbiguousTraefikConfig(t *testing.T) { cfgDir := writeCfg(t, map[string]string{ "traefik.yml": "ping: {}\n", @@ -476,18 +571,19 @@ func TestDeclaredEntrypointsChangeProxyIdentity(t *testing.T) { } } -// A declared directory still owns the configuration entirely, and one missing -// either supported static file says what to do about it. -func TestDeclaredConfigStillOwnsItAndSaysWhatIsMissing(t *testing.T) { +// A declared directory must contribute meaningful dynamic or static +// configuration. Silently accepting an unrelated file would make the author +// believe an extension was active while Traefik never read it. +func TestDeclaredConfigWithoutTraefikFilesSaysWhatToDo(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "other.yml"), []byte("x: 1\n"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "README.txt"), []byte("notes\n"), 0o600); err != nil { t.Fatal(err) } _, 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") + t.Fatal("a declared config directory without dynamic or static Traefik files must be refused") } - if !strings.Contains(err.Error(), "Remove proxy.config") { + if !strings.Contains(err.Error(), "remove proxy.config") || !strings.Contains(err.Error(), "dynamic extension") { t.Errorf("the refusal must say how to resolve it: %v", err) } } diff --git a/site/public/onebox.run-v1.schema.json b/site/public/onebox.run-v1.schema.json index a7e9a0be..e13483b1 100644 --- a/site/public/onebox.run-v1.schema.json +++ b/site/public/onebox.run-v1.schema.json @@ -1164,7 +1164,7 @@ }, "properties": { "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, 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.", + "description": "Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. Including traefik.yml or traefik.yaml instead takes ownership of the static configuration, which must use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, and must define certificatesResolvers.letsencrypt when a route terminates TLS. Dynamic files may not reuse Onebox-generated router or service names. Expects a path inside the repository, with no control character or shell metacharacter.", "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", "type": "string" }, @@ -1187,7 +1187,7 @@ }, "type": "object" }, - "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.", + "description": "Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a proxy.config containing custom traefik.yml or traefik.yaml must define matching Traefik entrypoints.", "propertyNames": { "pattern": "^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$" }, diff --git a/site/src/content/docs/reference/fields/proxy.mdx b/site/src/content/docs/reference/fields/proxy.mdx index 1a544002..0bdf3fd2 100644 --- a/site/src/content/docs/reference/fields/proxy.mdx +++ b/site/src/content/docs/reference/fields/proxy.mdx @@ -23,8 +23,8 @@ cannot drift from what `ob validate` accepts. | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `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. | +| `config` | string | — | Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. Including traefik.yml or traefik.yaml instead takes ownership of the static configuration, which must use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, and must define certificatesResolvers.letsencrypt when a route terminates TLS. Dynamic files may not reuse Onebox-generated router or service names. 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 proxy.config containing custom traefik.yml or traefik.yaml 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:…. | | `kind` | `traefik-docker` · `none` | `traefik-docker` | Proxy implementation, or none to disable routing. | diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 71122d8c..799d2e17 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -131,10 +131,12 @@ backs up nor removes it; provision and protect that path separately. For data Onebox should own, prefer a managed named volume instead. **The proxy** — if anything is routed, Onebox runs Traefik and writes its static -configuration. Declare `proxy.config` to own that configuration instead. The -external `proxy.network` may be changed, but `default` is reserved for the -application's own Compose network. The derived `_default` and `ob_` -names are reserved too; routed projects must use a distinct ingress network. +configuration. Declare `proxy.config` with dynamic YAML or TOML files to extend +that managed configuration. Include `traefik.yml` or `traefik.yaml` in the same +directory only when you need to own the static configuration too. The external +`proxy.network` may be changed, but `default` is reserved for the application's +own Compose network. The derived `_default` and `ob_` names are +reserved too; routed projects must use a distinct ingress network. Managed Traefik runs as a non-root user and never receives the Docker socket. An isolated Onebox discovery controller observes only this application's @@ -149,18 +151,26 @@ application mutation. Updating the Onebox runner therefore upgrades the default proxy boundary automatically; an operator does not need to edit the project or run a separate migration command. -Custom `proxy.config` keeps this boundary. Its static file must not enable -`providers.docker` and must configure -`providers.file.directory: /etc/traefik/dynamic`. Watching must remain enabled, -and `providers.file.filename` cannot be combined with that directory. Other -YAML and TOML files in the flat configuration directory are staged into the -watched directory. The filenames `onebox.yml` and `onebox.yaml`, plus HTTP and -TCP router or service names beginning with the derived `_` prefix, are -reserved for generated discovery output. A proxy `.env` may carry ordinary DNS -provider credentials, but not `TRAEFIK_PROVIDERS_*` or `TRAEFIK_CONFIGFILE` -static overrides. `ob deploy`, `ob bootstrap`, and `ob proxy apply` refuse an -incompatible custom configuration with migration guidance rather than silently -starting a proxy with missing or stale routes. +`proxy.config` keeps this boundary and has two levels. With dynamic YAML or TOML +alone, Onebox continues to write the static configuration and stages those files +into its watched directory. This is the normal way to add middleware, TLS +options, transports, or other routing policy without duplicating the managed +entrypoints, health check, provider, and ACME configuration. + +If the directory includes `traefik.yml` or `traefik.yaml`, that file becomes the +project-owned static configuration. It must not enable `providers.docker` and +must configure `providers.file.directory: /etc/traefik/dynamic`. Watching must +remain enabled, and `providers.file.filename` cannot be combined with that +directory. A proxy `.env` is accepted only in this mode; it may carry ordinary +DNS-provider credentials, but not `TRAEFIK_PROVIDERS_*` or +`TRAEFIK_CONFIGFILE` static overrides. + +In both modes, the filenames `onebox.yml` and `onebox.yaml`, the `dynamic/` +mountpoint, and HTTP or TCP router or service names beginning with the derived +`_` prefix are reserved for generated discovery output. `ob deploy`, +`ob bootstrap`, and `ob proxy apply` refuse an incompatible configuration with +migration guidance rather than silently starting a proxy with missing or stale +routes. ### Additional proxy entrypoints @@ -192,10 +202,11 @@ remote clients; workloads on the same host can communicate over Onebox's internal networks without opening another host port. The route's `port` is the workload's listening port, while `entrypoint` selects the proxy listener. -When Onebox writes the static proxy configuration, it also writes these named -entrypoints. If `proxy.config` supplies your own static configuration, Onebox -still publishes the ports, but that configuration must define matching -entrypoint names and addresses. +When Onebox writes the static proxy configuration, including when +`proxy.config` contains only dynamic extensions, it also writes these named +entrypoints. If `proxy.config` supplies your own `traefik.yml` or +`traefik.yaml`, Onebox still publishes the ports, but that configuration must +define matching entrypoint names and addresses. ### Route middleware @@ -218,14 +229,15 @@ workloads: ``` The definitions belong to the provider named by the suffix. For example, -`admin-auth@file` names `http.middlewares.admin-auth` in the dynamic Traefik -configuration supplied through `proxy.config`; that configuration must enable -the `/etc/traefik/dynamic` file-provider directory. A Onebox-managed proxy therefore requires `proxy.config` -when a route names middleware. With `proxy.managed: false`, the operator-owned -proxy provides the referenced resources instead. Onebox preserves list order -when it attaches the chain to the generated router. HTTP and TCP routes both -support middleware references; the referenced middleware must match the -route's protocol. +`admin-auth@file` names `http.middlewares.admin-auth` in a dynamic Traefik file +under `proxy.config`. Onebox supplies the watched file provider when the +directory contains only dynamic extensions; the project must supply it when the +directory also contains custom static configuration. A Onebox-managed proxy +therefore requires `proxy.config` when a route names middleware. With +`proxy.managed: false`, the operator-owned proxy provides the referenced +resources instead. Onebox preserves list order when it attaches the chain to +the generated router. HTTP and TCP routes both support middleware references; +the referenced middleware must match the route's protocol. :::caution[Compose is an output, not an input] A Compose file you wrote cannot be the contract — the declaration is, and Onebox