diff --git a/docs/onebox.run-v1.schema.json b/docs/onebox.run-v1.schema.json index 484319f5..df4232c1 100644 --- a/docs/onebox.run-v1.schema.json +++ b/docs/onebox.run-v1.schema.json @@ -578,10 +578,11 @@ "type": "object" }, "domain": { - "description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes.", + "description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.", "examples": [ "shop.example.com" ], + "pattern": "^[^\\x00-\\x1f\\x7f`*]+$", "type": "string" }, "environments": { @@ -1169,10 +1170,45 @@ }, "properties": { "config": { - "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 or redefine the managed onebox-compress middleware. 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. A managed DNS challenge may use a directory containing only .env for provider credentials. 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, must define certificatesResolvers.letsencrypt for exact terminating routes, and must define the DNS-01 certificatesResolvers.onebox-wildcard for wildcard terminating routes. Dynamic files may not reuse Onebox-generated router or service names or redefine the managed onebox-compress middleware. Expects a path inside the repository, with no control character or shell metacharacter.", "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", "type": "string" }, + "dns_challenge": { + "additionalProperties": false, + "description": "Managed ACME DNS-01 challenge used to issue wildcard certificates. Provider credentials belong in proxy.config/.env; Onebox continues to own the static proxy configuration.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "provider": { + "description": "Traefik DNS challenge provider name. Its credential variables must be supplied through proxy.config/.env. Expects a lower-case Traefik DNS provider name such as cloudflare or route53.", + "examples": [ + "cloudflare" + ], + "pattern": "^[a-z][a-z0-9_-]*$", + "type": "string" + }, + "resolvers": { + "description": "DNS resolvers used to verify challenge propagation, written as host:port.", + "examples": [ + [ + "1.1.1.1:53" + ] + ], + "items": { + "description": "Expects a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port.", + "pattern": "^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\\[[0-9A-Fa-f:.]+\\]):[0-9]{1,5}$", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "provider" + ], + "type": "object" + }, "entrypoints": { "additionalProperties": { "additionalProperties": false, @@ -1262,12 +1298,74 @@ "description": "Ingress routes exposed by this workload.", "items": { "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "domain": { + "const": "*" + } + }, + "required": [ + "domain" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + }, + "tls": { + "enum": [ + "none", + "passthrough" + ] + } + }, + "required": [ + "protocol", + "tls" + ] + } + } + ], + "oneOf": [ + { + "not": { + "required": [ + "wildcard_suffix" + ] + }, + "required": [ + "domain" + ] + }, + { + "not": { + "required": [ + "domain" + ] + }, + "required": [ + "wildcard_suffix" + ] + } + ], "patternProperties": { "^x-": {} }, "properties": { "domain": { - "description": "DNS name matched by the proxy.", + "anyOf": [ + { + "description": "Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.", + "pattern": "^[^\\x00-\\x1f\\x7f`*]+$" + }, + { + "const": "*" + } + ], + "description": "Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix.", "examples": [ "shop.example.com" ], @@ -1330,6 +1428,15 @@ "none" ], "type": "string" + }, + "wildcard_suffix": { + "description": "DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain.", + "examples": [ + "preview.example.com" + ], + "maxLength": 253, + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$", + "type": "string" } }, "type": "object" @@ -2051,10 +2158,11 @@ "type": "string" }, "domain": { - "description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes.", + "description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.", "examples": [ "shop.example.com" ], + "pattern": "^[^\\x00-\\x1f\\x7f`*]+$", "type": "string" }, "drain": { @@ -2662,12 +2770,74 @@ "description": "Ingress routes exposed by this workload.", "items": { "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "domain": { + "const": "*" + } + }, + "required": [ + "domain" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + }, + "tls": { + "enum": [ + "none", + "passthrough" + ] + } + }, + "required": [ + "protocol", + "tls" + ] + } + } + ], + "oneOf": [ + { + "not": { + "required": [ + "wildcard_suffix" + ] + }, + "required": [ + "domain" + ] + }, + { + "not": { + "required": [ + "domain" + ] + }, + "required": [ + "wildcard_suffix" + ] + } + ], "patternProperties": { "^x-": {} }, "properties": { "domain": { - "description": "DNS name matched by the proxy.", + "anyOf": [ + { + "description": "Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.", + "pattern": "^[^\\x00-\\x1f\\x7f`*]+$" + }, + { + "const": "*" + } + ], + "description": "Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix.", "examples": [ "shop.example.com" ], @@ -2730,6 +2900,15 @@ "none" ], "type": "string" + }, + "wildcard_suffix": { + "description": "DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain.", + "examples": [ + "preview.example.com" + ], + "maxLength": 253, + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$", + "type": "string" } }, "type": "object" diff --git a/internal/app/constraints.go b/internal/app/constraints.go index 2c4be61e..7b281033 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -108,6 +108,21 @@ var ( gRegistryUser = grammar{"registry username", regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@+-]*$`), "a username of letters, digits and . _ @ + -"} + gDNSProvider = grammar{"DNS challenge provider", regexp.MustCompile(`^[a-z][a-z0-9_-]*$`), + "a lower-case Traefik DNS provider name such as cloudflare or route53"} + + gDNSResolver = grammar{"DNS resolver", regexp.MustCompile(`^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\[[0-9A-Fa-f:.]+\]):[0-9]{1,5}$`), + "a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port"} + + // Exact route hosts predate strict hostname validation. Keep accepting their + // established spellings (including upper-case and a trailing dot), while + // excluding the characters that can escape Traefik's backtick literal. + gRouteHost = grammar{"route host", regexp.MustCompile("^[^\\x00-\\x1f\\x7f`*]+$"), + "an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing"} + + gWildcardSuffix = grammar{"wildcard DNS suffix", regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`), + "a lower-case ASCII or Punycode DNS hostname whose labels contain 1 to 63 characters"} + gCalVer = grammar{"version", buildinfo.ReleaseVersionPattern, "a CalVer release such as v2026.8.0"} diff --git a/internal/app/generate.go b/internal/app/generate.go index 206605b8..9d2e2574 100644 --- a/internal/app/generate.go +++ b/internal/app/generate.go @@ -544,10 +544,14 @@ func (p *Spec) routeLabels(n Names, name string, w Workload) map[string]any { svcName := n.ProxyServiceFor(name, i) router := n.Router(name, i) kind := "http" - rule := fmt.Sprintf("Host(`%s`)", r.Domain) + rule := fmt.Sprintf("Host(`%s`)", r.HostPattern()) + if r.WildcardSuffix != "" { + suffix := strings.ReplaceAll(r.WildcardSuffix, ".", `\.`) + rule = fmt.Sprintf("HostRegexp(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.%s$`)", suffix) + } if r.Protocol == "tcp" { kind = "tcp" - rule = fmt.Sprintf("HostSNI(`%s`)", r.Domain) + rule = fmt.Sprintf("HostSNI(`%s`)", r.HostPattern()) } else if r.Path != "" && r.Path != "/" { rule += fmt.Sprintf(" && PathPrefix(`%s`)", r.Path) } @@ -574,7 +578,12 @@ func (p *Spec) routeLabels(n Names, name string, w Workload) map[string]any { // authored project data. The generated static configuration defines // this same private identity. if p.Proxy.Managed && r.TLS == "terminate" { - out[pre+"tls.certresolver"] = ManagedCertificateResolver + resolver := ManagedCertificateResolver + if r.WildcardSuffix != "" { + resolver = ManagedWildcardCertificateResolver + out[pre+"tls.domains[0].main"] = r.HostPattern() + } + out[pre+"tls.certresolver"] = resolver } } // 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 c6d628e8..4c96a576 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -362,6 +362,34 @@ func TestHasTerminatingTLSDistinguishesPassthrough(t *testing.T) { } } +func TestWildcardRouteRendersSafeHostRegexpAndDNSResolver(t *testing.T) { + project := `api_version: onebox.run/v1 +app: preview +environments: {production: {server: root@example.com}} +workloads: + web: + image: nginx + routes: [{wildcard_suffix: preview.example.com, port: 8080}] +proxy: + config: traefik + dns_challenge: {provider: cloudflare} +` + out := string(render(t, project)) + if !strings.Contains(out, `HostRegexp(`+"`"+`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.preview\.example\.com$$`+"`"+`)`) { + t.Fatalf("wildcard route missing single-label HostRegexp matcher:\n%s", out) + } + if strings.Contains(out, "Host(`*.preview.example.com`)") { + t.Fatalf("wildcard route must not rely on Host wildcard semantics:\n%s", out) + } + if !strings.Contains(out, "tls.certresolver: "+ManagedWildcardCertificateResolver) || + !strings.Contains(out, "tls.domains[0].main: '*.preview.example.com'") { + t.Fatalf("wildcard route lost managed TLS:\n%s", out) + } + if strings.Count(out, "tls.certresolver: "+ManagedCertificateResolver) != 0 { + t.Fatalf("wildcard route must not change exact-route HTTP-01 issuance:\n%s", out) + } +} + // TestEveryDraftRenders runs generation over the real conversion drafts. func TestEveryDraftRenders(t *testing.T) { dir := filepath.Join("testdata", "corpus") diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go index 1b519ce7..eb03f542 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -344,6 +344,7 @@ var schemaConstraints = []struct { {[]string{"workloads", "*", "operator_run"}, enum(eJobOperatorRun)}, {[]string{"workloads", "*", "data_effect"}, enum(eDataEffect)}, {[]string{"workloads", "*", "compose"}, pattern(gComposeRef)}, + {[]string{"workloads", "*", "domain"}, pattern(gRouteHost)}, {[]string{"workloads", "*", "port"}, portBounds()}, {[]string{"workloads", "*", "working_dir"}, pattern(gAbsPath)}, {[]string{"workloads", "*", "env_files", "items", "file"}, pattern(gRepoPath)}, @@ -386,6 +387,33 @@ var schemaConstraints = []struct { {[]string{"workloads", "*", "resources", "memory"}, pattern(gSize)}, {[]string{"workloads", "*", "resources", "cpus"}, pattern(gCpus)}, {[]string{"workloads", "*", "persistence", "mode"}, enum(ePersistence)}, + {[]string{"workloads", "*", "routes", "items"}, map[string]any{ + "oneOf": []any{ + map[string]any{"required": []any{"domain"}, "not": map[string]any{"required": []any{"wildcard_suffix"}}}, + map[string]any{"required": []any{"wildcard_suffix"}, "not": map[string]any{"required": []any{"domain"}}}, + }, + "allOf": []any{map[string]any{ + "if": map[string]any{ + "required": []any{"domain"}, + "properties": map[string]any{"domain": map[string]any{"const": "*"}}, + }, + "then": map[string]any{ + "required": []any{"protocol", "tls"}, + "properties": map[string]any{ + "protocol": map[string]any{"const": "tcp"}, + "tls": map[string]any{"enum": []any{"none", "passthrough"}}, + }, + }, + }}, + }}, + {[]string{"workloads", "*", "routes", "items", "domain"}, map[string]any{"anyOf": []any{ + pattern(gRouteHost), + map[string]any{"const": "*"}, + }}}, + {[]string{"workloads", "*", "routes", "items", "wildcard_suffix"}, map[string]any{ + "pattern": gWildcardSuffix.pattern.String(), + "maxLength": 253, + }}, {[]string{"workloads", "*", "routes", "items", "path"}, pattern(gURLPath)}, {[]string{"workloads", "*", "routes", "items", "port"}, portBounds()}, {[]string{"workloads", "*", "routes", "items", "protocol"}, enum(eRouteProtocol)}, @@ -477,6 +505,9 @@ var schemaConstraints = []struct { {[]string{"proxy", "kind"}, enum(eProxyKind)}, {[]string{"proxy", "image"}, pattern(gImageRef)}, {[]string{"proxy", "config"}, pattern(gRepoPath)}, + {[]string{"proxy", "dns_challenge", "provider"}, pattern(gDNSProvider)}, + {[]string{"proxy", "dns_challenge", "resolvers", "items"}, pattern(gDNSResolver)}, + {[]string{"proxy", "dns_challenge"}, map[string]any{"required": []any{"provider"}}}, {[]string{"proxy", "entrypoints"}, propertyNames(gIdent)}, {[]string{"proxy", "entrypoints", "*", "port"}, portBounds()}, {[]string{"deployment", "migration_policy"}, enum(eMigrationPolicy)}, diff --git a/internal/app/load.go b/internal/app/load.go index 7dee3344..108c42e0 100644 --- a/internal/app/load.go +++ b/internal/app/load.go @@ -520,6 +520,13 @@ func crossFieldRules(p *Spec) error { if err := checkRouteCollisions(p); err != nil { return err } + if p.Proxy.DNSChallenge != nil && (!p.Proxy.Managed || p.Proxy.Kind == "none") { + return errf("project_invalid", "proxy.dns_challenge", "", "dns_challenge requires a Onebox-managed proxy") + } + if p.Proxy.Managed && p.HasWildcardTerminatingTLS() && p.Proxy.DNSChallenge == nil && p.Proxy.Config == "" { + return errf("project_invalid", "proxy.dns_challenge", "", + "managed terminating wildcard routes require an ACME DNS challenge; declare proxy.dns_challenge and provide its credentials through proxy.config/.env") + } if p.Proxy.Kind != "none" && p.Proxy.Managed && p.Proxy.Config == "" { knownEntrypoints := map[string]struct{}{"web": {}, "websecure": {}} for name := range p.Proxy.Entrypoints { @@ -614,27 +621,55 @@ func crossFieldRules(p *Spec) error { // both workloads at load time costs one error message and saves an outage // nobody can explain. // -// The address is entrypoint, protocol, domain and path together, because two +// The address is entrypoint, protocol, host claim and path together, because two // routes differing in any of them are genuinely distinct — the same host on // two listeners is how a project serves HTTP and gRPC side by side. func checkRouteCollisions(p *Spec) error { - type claim struct{ workload string } - seen := map[string]claim{} + type claim struct { + workload string + route Route + } + var seen []claim for _, name := range sortedKeys(p.Workloads) { for _, r := range p.Workloads[name].NormalisedRoutes() { - key := r.Entrypoint + " " + r.Protocol + " " + r.Domain + r.Path - if prev, taken := seen[key]; taken { - return errf("route_collision", "workloads."+name+".routes", "", - "workloads %q and %q both claim %s on the %q entrypoint; "+ - "the proxy would route to one of them and nothing would say which", - prev.workload, name, r.Domain+r.Path, r.Entrypoint) + for _, prev := range seen { + if routesOverlap(prev.route, r) { + return errf("route_collision", "workloads."+name+".routes", "", + "workloads %q and %q claim overlapping hosts %s and %s on the %q entrypoint; "+ + "split them by path or remove one claim so routing does not depend on proxy priority", + prev.workload, name, prev.route.HostPattern()+prev.route.Path, r.HostPattern()+r.Path, r.Entrypoint) + } } - seen[key] = claim{workload: name} + seen = append(seen, claim{workload: name, route: r}) } } return nil } +func routesOverlap(a, b Route) bool { + if a.Entrypoint != b.Entrypoint || a.Protocol != b.Protocol || a.Path != b.Path { + return false + } + if a.WildcardSuffix != "" && b.WildcardSuffix != "" { + return a.WildcardSuffix == b.WildcardSuffix + } + if a.WildcardSuffix == "" && b.WildcardSuffix == "" { + if a.Domain == "*" || b.Domain == "*" { + return true + } + return canonicalRouteHost(a.Domain) == canonicalRouteHost(b.Domain) + } + if a.WildcardSuffix == "" { + a, b = b, a + } + prefix, ok := strings.CutSuffix(canonicalRouteHost(b.Domain), "."+a.WildcardSuffix) + return ok && prefix != "" && !strings.Contains(prefix, ".") +} + +func canonicalRouteHost(host string) string { + return strings.ToLower(strings.TrimSuffix(host, ".")) +} + // checkDerivedNames refuses an over-long generated name rather than truncating. func checkDerivedNames(p *Spec) error { check := func(kind, name string) error { diff --git a/internal/app/load_test.go b/internal/app/load_test.go index cf479c2b..f2bb043d 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -63,6 +63,96 @@ func TestManagedGeneratedProxyRequiresDeclaredRouteEntrypoint(t *testing.T) { } } +func TestWildcardRouteContract(t *testing.T) { + valid := base + `workloads: + web: + image: nginx + routes: [{wildcard_suffix: preview.example.com, port: 8080}] +proxy: + config: traefik + dns_challenge: {provider: cloudflare, resolvers: ["1.1.1.1:53"]} +` + if _, err := LoadBytes([]byte(valid), "ob.yml"); err != nil { + t.Fatalf("valid wildcard route: %v", err) + } + + for _, tc := range []struct { + name string + body string + want string + }{ + {"missing dns challenge", wl("web: {image: nginx, routes: [{wildcard_suffix: example.com, port: 80}] }"), "dns_challenge"}, + {"both host forms", wl("web: {image: nginx, routes: [{domain: api.example.com, wildcard_suffix: example.com, port: 80}] }"), "exactly one"}, + {"neither host form", wl("web: {image: nginx, routes: [{port: 80}] }"), "exactly one"}, + {"bare wildcard", wl("web: {image: nginx, routes: [{wildcard_suffix: '*', port: 80, tls: none}] }"), "DNS hostname"}, + {"embedded wildcard", wl("web: {image: nginx, routes: [{wildcard_suffix: '*.example.com', port: 80, tls: none}] }"), "DNS hostname"}, + {"uppercase suffix", wl("web: {image: nginx, routes: [{wildcard_suffix: Example.com, port: 80, tls: none}] }"), "lower-case"}, + {"tcp wildcard", wl("web: {image: nginx, routes: [{wildcard_suffix: example.com, port: 80, protocol: tcp, tls: passthrough}] }"), "only for HTTP"}, + {"exact matcher injection", wl("web: {image: nginx, routes: [{domain: 'x`) || Host(`*', port: 80}] }"), "route host"}, + {"wildcard in exact route", wl("web: {image: nginx, routes: [{domain: '*.example.com', port: 80}] }"), "wildcard_suffix"}, + {"wildcard in exact shorthand", wl("web: {image: nginx, domain: '*.example.com', port: 80}"), "wildcard_suffix"}, + {"catch-all exact route", wl("web: {image: nginx, routes: [{domain: '*', port: 80}] }"), "wildcard_suffix"}, + {"dns challenge needs config", min + "proxy: {dns_challenge: {provider: cloudflare}}\n", "proxy.config"}, + {"invalid resolver", min + "proxy: {config: traefik, dns_challenge: {provider: cloudflare, resolvers: [1.1.1.1]}}\n", "host:port"}, + {"unmanaged dns challenge", min + "proxy: {managed: false, config: traefik, dns_challenge: {provider: cloudflare}}\n", "managed proxy"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadBytes([]byte(tc.body), "ob.yml") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want text %q", err, tc.want) + } + }) + } + for _, domain := range []string{"API.Example.COM", "api.example.com."} { + if _, err := LoadBytes([]byte(wl("web: {image: nginx, routes: [{domain: '"+domain+"', port: 80, tls: none}] }")), "ob.yml"); err != nil { + t.Errorf("existing exact route spelling %q must remain valid: %v", domain, err) + } + } + if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{domain: '*', protocol: tcp, tls: none, port: 9000}] }")), "ob.yml"); err != nil { + t.Errorf("existing plaintext TCP catch-all must remain valid: %v", err) + } + if _, err := LoadBytes([]byte(wl("gateway: {image: nginx, routes: [{domain: '*', protocol: tcp, tls: passthrough, port: 9000}] }")), "ob.yml"); err != nil { + t.Errorf("existing TLS-passthrough TCP catch-all must remain valid: %v", err) + } +} + +func TestWildcardRouteOverlap(t *testing.T) { + project := func(left, right string) string { + return base + "workloads:\n exact: {image: nginx, routes: [" + left + "]}\n wildcard: {image: nginx, routes: [" + right + "]}\n" + } + for _, tc := range []struct { + name string + left string + right string + collides bool + }{ + {"immediate child", "{domain: shop.example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", true}, + {"same wildcard", "{wildcard_suffix: example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", true}, + {"case-insensitive exact child", "{domain: Shop.Example.COM., port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", true}, + {"apex does not overlap", "{domain: example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", false}, + {"nested host does not overlap", "{domain: a.b.example.com, port: 80, tls: none}", "{wildcard_suffix: example.com, port: 81, tls: none}", false}, + {"different path", "{domain: shop.example.com, path: /api, port: 80, tls: none}", "{wildcard_suffix: example.com, path: /, port: 81, tls: none}", false}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadBytes([]byte(project(tc.left, tc.right)), "ob.yml") + if tc.collides && (err == nil || !strings.Contains(err.Error(), "route_collision")) { + t.Fatalf("expected collision, got %v", err) + } + if !tc.collides && err != nil { + t.Fatalf("unexpected collision: %v", err) + } + }) + } +} + +func TestPlaintextTCPCatchAllOverlapsEveryHost(t *testing.T) { + catchAll := Route{Domain: "*", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"} + exact := Route{Domain: "db.example.com", Protocol: "tcp", TLS: "none", Path: "/", Entrypoint: "database"} + if !routesOverlap(catchAll, exact) || !routesOverlap(exact, catchAll) { + t.Fatal("plaintext TCP catch-all must collide with every exact host on the same route address") + } +} + type conformanceCase struct { name string yaml string diff --git a/internal/app/names.go b/internal/app/names.go index 8de7a819..cec826e8 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -411,6 +411,16 @@ func routesOf(w Workload) []Route { // expanded, so callers never handle two shapes. func (w Workload) NormalisedRoutes() []Route { return routesOf(w) } +// HostPattern returns the host matcher value represented by the route. A +// wildcard suffix is deliberately expanded here rather than accepted as an +// authored matcher expression, so no regular expression reaches Traefik. +func (r Route) HostPattern() string { + if r.WildcardSuffix != "" { + return "*." + r.WildcardSuffix + } + return r.Domain +} + // 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. @@ -428,6 +438,39 @@ func (p *Spec) HasTerminatingTLS() bool { return false } +// HasExactTerminatingTLS reports whether an exact-host router needs the +// managed HTTP-01 resolver. Wildcard routers use a separate DNS-01 resolver so +// adding one cannot change renewal policy for existing exact routes. +func (p *Spec) HasExactTerminatingTLS() bool { + if p == nil { + return false + } + for _, w := range p.Workloads { + for _, route := range w.NormalisedRoutes() { + if route.WildcardSuffix == "" && route.TLS == "terminate" { + return true + } + } + } + return false +} + +// HasWildcardTerminatingTLS reports whether the managed certificate resolver +// must be able to issue a wildcard certificate. +func (p *Spec) HasWildcardTerminatingTLS() bool { + if p == nil { + return false + } + for _, workload := range p.Workloads { + for _, route := range workload.NormalisedRoutes() { + if route.WildcardSuffix != "" && 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/types.go b/internal/app/types.go index 973f33c0..2edacaf5 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -213,14 +213,15 @@ type Image struct { } type Route struct { - Domain string `json:"domain" description:"DNS name matched by the proxy." example:"shop.example.com"` - Path string `json:"path" description:"URL path prefix matched by an HTTP route." default:"/"` - Port int `json:"port" description:"Container port receiving routed traffic." example:"3000"` - Entrypoint string `json:"entrypoint" description:"Named proxy listener used for the route." default:"websecure"` - Protocol string `json:"protocol" description:"Routing protocol: http, tcp, or udp." default:"http"` - Scheme string `json:"scheme" description:"Backend connection scheme: http, https, h2c, tcp, or udp." default:"http"` - TLS string `json:"tls" description:"TLS handling: terminate, passthrough, or none." default:"terminate"` - Middlewares []MiddlewareRef `json:"middlewares,omitempty" description:"Ordered provider-qualified middleware references applied to this route."` + Domain string `json:"domain,omitempty" description:"Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix." example:"shop.example.com"` + WildcardSuffix string `json:"wildcard_suffix,omitempty" description:"DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain." example:"preview.example.com"` + Path string `json:"path" description:"URL path prefix matched by an HTTP route." default:"/"` + Port int `json:"port" description:"Container port receiving routed traffic." example:"3000"` + Entrypoint string `json:"entrypoint" description:"Named proxy listener used for the route." default:"websecure"` + Protocol string `json:"protocol" description:"Routing protocol: http, tcp, or udp." default:"http"` + Scheme string `json:"scheme" description:"Backend connection scheme: http, https, h2c, tcp, or udp." default:"http"` + TLS string `json:"tls" description:"TLS handling: terminate, passthrough, or none." default:"terminate"` + Middlewares []MiddlewareRef `json:"middlewares,omitempty" description:"Ordered provider-qualified middleware references applied to this route."` } // MiddlewareRef names dynamic proxy configuration without opening the @@ -543,19 +544,29 @@ type Registry struct { // project-file value: Onebox owns both ends of this reference. const ManagedCertificateResolver = "letsencrypt" +// ManagedWildcardCertificateResolver keeps wildcard DNS-01 credentials from +// changing issuance for exact routes, which continue to use HTTP-01 above. +const ManagedWildcardCertificateResolver = "onebox-wildcard" + 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. 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 or redefine the managed onebox-compress middleware."` - 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 proxy.config containing custom traefik.yml or traefik.yaml 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. Dynamic YAML or TOML files extend Onebox's managed configuration. A managed DNS challenge may use a directory containing only .env for provider credentials. 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, must define certificatesResolvers.letsencrypt for exact terminating routes, and must define the DNS-01 certificatesResolvers.onebox-wildcard for wildcard terminating routes. Dynamic files may not reuse Onebox-generated router or service names or redefine the managed onebox-compress middleware."` + 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 proxy.config containing custom traefik.yml or traefik.yaml must define matching Traefik entrypoints."` + DNSChallenge *ProxyDNSChallenge `json:"dns_challenge,omitempty" description:"Managed ACME DNS-01 challenge used to issue wildcard certificates. Provider credentials belong in proxy.config/.env; Onebox continues to own the static proxy configuration."` } type ProxyEntrypoint struct { Port int `json:"port" description:"Host and proxy-container TCP port used by this listener." example:"4317"` } +type ProxyDNSChallenge struct { + Provider string `json:"provider" description:"Traefik DNS challenge provider name. Its credential variables must be supplied through proxy.config/.env." example:"cloudflare"` + Resolvers []string `json:"resolvers,omitempty" description:"DNS resolvers used to verify challenge propagation, written as host:port." example:"1.1.1.1:53"` +} + // EnvFile is one contributor of environment values. // // A plaintext file and an encrypted one differ in how the bytes are obtained diff --git a/internal/app/validate.go b/internal/app/validate.go index b15d0b4e..839fef6e 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "strconv" "strings" "time" @@ -83,6 +84,19 @@ func validateTopLevel(p *Spec) error { if err := gRepoPath.checkOptional("proxy.config", p.Proxy.Config); err != nil { return err } + if p.Proxy.DNSChallenge != nil { + if err := gDNSProvider.check("proxy.dns_challenge.provider", p.Proxy.DNSChallenge.Provider); err != nil { + return err + } + if p.Proxy.Config == "" { + return errf("project_invalid", "proxy.dns_challenge", "", "managed DNS challenge credentials require proxy.config pointing to a directory containing .env") + } + for i, resolver := range p.Proxy.DNSChallenge.Resolvers { + if !validDNSResolver(resolver) { + return errf("project_invalid", indexed("proxy.dns_challenge.resolvers", i), "", "%q is not a DNS resolver address written as host:port", resolver) + } + } + } seenEntrypointPorts := map[int]string{80: "web", 443: "websecure"} for _, name := range sortedKeys(p.Proxy.Entrypoints) { path := "proxy.entrypoints." + name @@ -182,6 +196,15 @@ func validateTopLevel(p *Spec) error { return nil } +func validDNSResolver(value string) bool { + if !gDNSResolver.pattern.MatchString(value) { + return false + } + colon := strings.LastIndexByte(value, ':') + port, err := strconv.Atoi(value[colon+1:]) + return err == nil && port >= 1 && port <= 65535 +} + // validateEnvFiles holds every entry to the same rules wherever it is declared, // so a scope cannot quietly accept something another scope refuses. func validateEnvFiles(entries []EnvFile, path string) error { @@ -268,14 +291,34 @@ func validateWorkload(w Workload, path string) error { } } if w.Domain != "" && w.Port != 0 { + if err := gRouteHost.check(path+".domain", w.Domain); err != nil { + return err + } if err := checkPort(path+".port", w.Port); err != nil { return err } } for i, r := range w.Routes { rp := indexed(path+".routes", i) - if r.Domain == "" { - return errf("project_invalid", rp+".domain", "", "a route must name a domain") + if (r.Domain == "") == (r.WildcardSuffix == "") { + return errf("project_invalid", rp, "", "a route must declare exactly one of domain or wildcard_suffix") + } + if r.Domain != "" { + if r.Domain == "*" && r.Protocol == "tcp" && (r.TLS == "none" || r.TLS == "passthrough") { + // HostSNI(`*`) is Traefik's TCP catch-all for plaintext and + // TLS passthrough. It predates wildcard HTTP routes and remains + // the one intentional exception to exact-host syntax. + } else if err := gRouteHost.check(rp+".domain", r.Domain); err != nil { + return err + } + } + if r.WildcardSuffix != "" { + if err := validateWildcardSuffix(rp+".wildcard_suffix", r.WildcardSuffix); err != nil { + return err + } + if r.Protocol != "http" { + return errf("project_invalid", rp+".wildcard_suffix", "", "wildcard_suffix is supported only for HTTP routes") + } } if err := gURLPath.check(rp+".path", r.Path); err != nil { return err @@ -471,6 +514,13 @@ func validateWorkload(w Workload, path string) error { return nil } +func validateWildcardSuffix(path, value string) error { + if len(value) > 253 { + return errf("project_invalid", path, "", "%q is not a DNS hostname: it exceeds 253 characters", value) + } + return gWildcardSuffix.check(path, value) +} + func validateHealth(h *Health, path string) error { if h == nil { return nil diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index e25d9a01..78a47888 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -48,7 +48,13 @@ type HTTPRouter struct { } type HTTPTLS struct { - CertResolver string `json:"certResolver,omitempty" yaml:"certResolver,omitempty"` + CertResolver string `json:"certResolver,omitempty" yaml:"certResolver,omitempty"` + Domains []TLSDomain `json:"domains,omitempty" yaml:"domains,omitempty"` +} + +type TLSDomain struct { + Main string `json:"main" yaml:"main"` + SANs []string `json:"sans,omitempty" yaml:"sans,omitempty"` } type TCPRouter struct { @@ -197,6 +203,12 @@ func collectHTTP(container Container, ip string, routers map[string]httpCandidat } if truthy(container.Labels[prefix+"tls"]) { router.TLS = &HTTPTLS{CertResolver: container.Labels[prefix+"tls.certresolver"]} + if main := container.Labels[prefix+"tls.domains[0].main"]; main != "" { + router.TLS.Domains = []TLSDomain{{ + Main: main, + SANs: splitList(container.Labels[prefix+"tls.domains[0].sans"]), + }} + } } candidate := httpCandidate{created: container.Created, id: container.ID, router: router} if current, exists := routers[name]; !exists || newer(candidate.created, candidate.id, current.created, current.id) { diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go index 327e6dcd..eda755ec 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -56,6 +56,29 @@ func TestBuildPreservesHealthAwareHTTPRouting(t *testing.T) { } } +func TestBuildPreservesWildcardRuleAndCertificateDomain(t *testing.T) { + labels := map[string]string{ + "traefik.http.routers.preview_web_r0.rule": "HostRegexp(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.preview\\.example\\.com$`)", + "traefik.http.routers.preview_web_r0.entrypoints": "websecure", + "traefik.http.routers.preview_web_r0.tls": "true", + "traefik.http.routers.preview_web_r0.tls.certresolver": "onebox-wildcard", + "traefik.http.routers.preview_web_r0.tls.domains[0].main": "*.preview.example.com", + "traefik.http.routers.preview_web_r0.service": "preview_web", + "traefik.http.services.preview_web.loadbalancer.server.port": "8080", + } + document, err := Build([]Container{routedContainer("healthy", time.Now(), "healthy", "172.20.0.2", labels)}, "ob-ingress") + if err != nil { + t.Fatal(err) + } + router := document.HTTP.Routers["preview_web_r0"] + if router.Rule != "HostRegexp(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.preview\\.example\\.com$`)" || router.TLS == nil || router.TLS.CertResolver != "onebox-wildcard" { + t.Fatalf("wildcard router = %+v", router) + } + if len(router.TLS.Domains) != 1 || router.TLS.Domains[0].Main != "*.preview.example.com" { + t.Fatalf("wildcard certificate domain = %+v", router.TLS.Domains) + } +} + func TestBuildUsesNewestHealthyRouterDuringRollAndRollback(t *testing.T) { base := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) labels := func(domain string) map[string]string { diff --git a/internal/engine/proxy.go b/internal/engine/proxy.go index 38816d63..0a62bf68 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -39,8 +39,9 @@ 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.HasTerminatingTLS()) + hash, err := proxy.StageForAppManaged(localCfg, staging, e.Spec.Proxy.Image, discoveryImage, + e.Spec.Name, e.Spec.Proxy.Network, e.Spec.Proxy.Entrypoints, e.Spec.HasExactTerminatingTLS(), + e.Spec.HasWildcardTerminatingTLS(), e.Spec.Proxy.DNSChallenge) if err != nil { return err } diff --git a/internal/engine/proxystatus.go b/internal/engine/proxystatus.go index a5c374b8..20f92fff 100644 --- a/internal/engine/proxystatus.go +++ b/internal/engine/proxystatus.go @@ -21,13 +21,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 hash the host applied - owner string // sole application identity from the host owner record - ownerEnv string // environment identity when the record is not legacy - acme string // raw acme.json; parsed at render, and keys never leave - localHash string // hash of the locally staged config (computed offline) + health string // proxy container health, parsed from docker ps .Status + discovery bool // isolated Docker discovery controller is running + applied string // config hash the host applied + owner string // sole application identity from the host owner record + ownerEnv string // environment identity when the record is not legacy + acme []string // raw ACME stores; parsed at render, and keys never leave + localHash string // hash of the locally staged config (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 @@ -155,19 +155,21 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { return nil }, func() error { - path := hp.Acme + "/acme.json" - res, err := e.T.Run(ctx, readableFileProbe(path)) - if err != nil { - return err - } - if issue, refused := statusFileIssue("the certificate store", path, res); refused { - px.acmeIssue = issue - return nil - } - if res.ExitCode != 0 { - return statusReadResult("proxy certificate store", res, nil) + for _, name := range []string{"acme.json", "acme-wildcard.json"} { + path := hp.Acme + "/" + name + res, err := e.T.Run(ctx, readableFileProbe(path)) + if err != nil { + return err + } + if issue, refused := statusFileIssue("the certificate store", path, res); refused { + px.acmeIssue = issue + return nil + } + if res.ExitCode != 0 { + return statusReadResult("proxy certificate store", res, nil) + } + px.acme = append(px.acme, res.Stdout) } - px.acme = res.Stdout return nil }, func() error { @@ -182,9 +184,10 @@ func (e *Engine) proxyReads(ctx context.Context, px *proxyRaw) []func() error { return err } defer os.RemoveAll(staging) - px.localHash, err = proxy.StageForApp(localCfg, staging, e.Spec.Proxy.Image, + px.localHash, err = proxy.StageForAppManaged(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.HasTerminatingTLS()) + e.Spec.Proxy.Network, e.Spec.Proxy.Entrypoints, e.Spec.HasExactTerminatingTLS(), + e.Spec.HasWildcardTerminatingTLS(), e.Spec.Proxy.DNSChallenge) return err }, } @@ -319,7 +322,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 := proxy.CertExpiries([]byte(px.acme)) + certs, err := proxyCertExpiries(px.acme) if err != nil { fmt.Fprintf(e.Opts.Out, " cert store unreadable ⚠ (%v)\n", err) return true, nil @@ -335,3 +338,15 @@ func (e *Engine) renderProxy(px proxyRaw) (bool, error) { } return diverged, nil } + +func proxyCertExpiries(stores []string) ([]proxy.CertExpiry, error) { + var out []proxy.CertExpiry + for _, store := range stores { + certs, err := proxy.CertExpiries([]byte(store)) + if err != nil { + return nil, err + } + out = append(out, certs...) + } + return out, nil +} diff --git a/internal/engine/proxystatus_test.go b/internal/engine/proxystatus_test.go index 2ecf7ff1..baee5500 100644 --- a/internal/engine/proxystatus_test.go +++ b/internal/engine/proxystatus_test.go @@ -116,6 +116,29 @@ func TestStatusManagedProxyInSync(t *testing.T) { } } +func TestStatusAggregatesExactAndWildcardCertificateStores(t *testing.T) { + applied := "" + exact := acmeFixture(t, "app.example.com", time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)) + wildcard := acmeFixture(t, "*.preview.example.com", time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)) + e, f, out, _ := statusProxyEngine(t, &applied, exact, "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("status: %v\n%s", err, out.String()) + } + for _, domain := range []string{"app.example.com", "*.preview.example.com"} { + if !strings.Contains(out.String(), domain) { + t.Errorf("certificate from managed store %q missing:\n%s", domain, out.String()) + } + } +} + func TestStatusManagedProxyEnvironmentQualifiedOwner(t *testing.T) { applied := "" acme := acmeFixture(t, "app.example.com", time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)) diff --git a/internal/engine/status_snapshot.go b/internal/engine/status_snapshot.go index 25437c80..64df81f3 100644 --- a/internal/engine/status_snapshot.go +++ b/internal/engine/status_snapshot.go @@ -11,7 +11,6 @@ import ( "github.com/labstack/onebox/internal/app" "github.com/labstack/onebox/internal/buildinfo" "github.com/labstack/onebox/internal/journal" - "github.com/labstack/onebox/internal/proxy" "github.com/labstack/onebox/internal/release" ) @@ -496,7 +495,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 == "" { - certs, err := proxy.CertExpiries([]byte(raw.acme)) + certs, err := proxyCertExpiries(raw.acme) 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 e7b5bb0b..dbc3bee0 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -7,7 +7,8 @@ // 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. +// proxy .env is meaningful with custom static configuration or a managed +// DNS-01 challenge, where it supplies the provider's credentials. package proxy import ( @@ -308,12 +309,25 @@ func newProxyConfigHash() hash.Hash { } func renderStaticConfig(entrypoints map[string]app.ProxyEntrypoint) []byte { + return renderStaticConfigWithDNS(entrypoints, nil) +} + +func renderStaticConfigWithDNS(entrypoints map[string]app.ProxyEntrypoint, dns *app.ProxyDNSChallenge) []byte { var out strings.Builder out.WriteString(defaultStaticConfigHeader) for _, name := range sortedEntrypointNames(entrypoints) { fmt.Fprintf(&out, " %s:\n address: \":%d\"\n", name, entrypoints[name].Port) } 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) + if len(dns.Resolvers) > 0 { + out.WriteString(" resolvers:\n") + for _, resolver := range dns.Resolvers { + fmt.Fprintf(&out, " - %q\n", resolver) + } + } + } return []byte(out.String()) } @@ -322,11 +336,21 @@ func Stage(localCfgDir, stagingDir, image, network string, entrypoints map[strin } func StageForApp(localCfgDir, stagingDir, image, discoveryImage, application, network string, entrypoints map[string]app.ProxyEntrypoint, requireCertificateResolver bool) (string, error) { + return StageForAppManaged(localCfgDir, stagingDir, image, discoveryImage, application, network, entrypoints, requireCertificateResolver, false, nil) +} + +// StageForAppManaged stages a proxy with the managed ACME policy required by +// the application's routes. Wildcard termination requires DNS-01 either from +// dns or from a custom static configuration. +func StageForAppManaged(localCfgDir, stagingDir, image, discoveryImage, application, network string, entrypoints map[string]app.ProxyEntrypoint, requireExactCertificateResolver, requireWildcardTLS bool, dns *app.ProxyDNSChallenge) (string, error) { if application == "" { application = "onebox" } if localCfgDir == "" { - return stageDefault(stagingDir, image, discoveryImage, application, network, entrypoints) + if requireWildcardTLS && dns == nil { + return "", errors.New("managed terminating wildcard routes require an ACME DNS challenge") + } + return stageDefaultManaged(stagingDir, image, discoveryImage, application, network, entrypoints, dns) } entries, err := os.ReadDir(localCfgDir) if err != nil { @@ -367,18 +391,27 @@ func StageForApp(localCfgDir, stagingDir, image, discoveryImage, application, ne localCfgDir) } customStatic := len(staticConfigs) == 1 - if !customStatic && !hasDynamic { + if dns != nil && customStatic { + return "", errors.New("proxy.dns_challenge cannot be combined with project-owned traefik.yml or traefik.yaml; remove the static file so Onebox can render DNS-01") + } + if requireWildcardTLS && !customStatic && dns == nil { + return "", errors.New("managed terminating wildcard routes require proxy.dns_challenge, or a project-owned traefik.yml or traefik.yaml defining ACME DNS-01") + } + if !customStatic && !hasDynamic && !(dns != nil && hasEnv) { 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 !customStatic && hasEnv { + if !customStatic && hasEnv && dns == nil { return "", fmt.Errorf("proxy.config .env requires traefik.yml or traefik.yaml; " + "Onebox's managed static configuration does not consume custom proxy environment variables") } + if dns != nil && !hasEnv { + return "", errors.New("proxy.dns_challenge requires proxy.config/.env containing the DNS provider credentials") + } staticName := "traefik.yml" - staticBody := renderStaticConfig(entrypoints) + staticBody := renderStaticConfigWithDNS(entrypoints, dns) if customStatic { staticName = staticConfigs[0] var err error @@ -386,7 +419,7 @@ func StageForApp(localCfgDir, stagingDir, image, discoveryImage, application, ne if err != nil { return "", err } - if err := validateSocketlessStaticConfig(staticBody, requireCertificateResolver); err != nil { + if err := validateSocketlessStaticConfig(staticBody, requireExactCertificateResolver, requireWildcardTLS); err != nil { return "", fmt.Errorf("proxy.config %s: %w", staticName, err) } } else { @@ -460,9 +493,9 @@ func StageForApp(localCfgDir, stagingDir, image, discoveryImage, application, ne return hex.EncodeToString(h.Sum(nil)), nil } -// stageDefault writes the configuration Onebox owns, so a project that declares -// a domain and nothing else can bootstrap. -func stageDefault(stagingDir, image, discoveryImage, application, network string, entrypoints map[string]app.ProxyEntrypoint) (string, error) { +// stageDefaultManaged writes the configuration Onebox owns, so a project that +// declares a domain and nothing else can bootstrap. +func stageDefaultManaged(stagingDir, image, discoveryImage, application, network string, entrypoints map[string]app.ProxyEntrypoint, dns *app.ProxyDNSChallenge) (string, error) { cfgOut := filepath.Join(stagingDir, "config") if err := os.MkdirAll(cfgOut, 0o755); err != nil { return "", err @@ -474,7 +507,7 @@ func stageDefault(stagingDir, image, discoveryImage, application, network string if err := os.MkdirAll(dynamicOut, 0o755); err != nil { return "", err } - body := renderStaticConfig(entrypoints) + body := renderStaticConfigWithDNS(entrypoints, dns) if err := os.WriteFile(filepath.Join(cfgOut, "traefik.yml"), body, 0o600); err != nil { return "", err } @@ -523,7 +556,7 @@ func (e *CertificateResolverMissingError) Error() string { 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 { +func validateSocketlessStaticConfig(body []byte, requireExactCertificateResolver, requireWildcardTLS bool) error { var document map[string]any if err := yaml.Unmarshal(body, &document); err != nil { return fmt.Errorf("parse static configuration: %w", err) @@ -548,11 +581,32 @@ func validateSocketlessStaticConfig(body []byte, requireCertificateResolver bool return errors.New("set providers.file.watch to true or omit it; Onebox discovery requires live configuration updates") } } - if requireCertificateResolver { + if requireExactCertificateResolver || requireWildcardTLS { 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} + if !ok { + name := app.ManagedCertificateResolver + if !requireExactCertificateResolver { + name = app.ManagedWildcardCertificateResolver + } + return &CertificateResolverMissingError{Name: name} + } + if requireExactCertificateResolver { + resolver, defined := resolvers[app.ManagedCertificateResolver].(map[string]any) + if !defined || len(resolver) == 0 { + return &CertificateResolverMissingError{Name: app.ManagedCertificateResolver} + } + } + if requireWildcardTLS { + resolver, defined := resolvers[app.ManagedWildcardCertificateResolver].(map[string]any) + if !defined || len(resolver) == 0 { + return &CertificateResolverMissingError{Name: app.ManagedWildcardCertificateResolver} + } + acme, _ := resolver["acme"].(map[string]any) + dns, _ := acme["dnsChallenge"].(map[string]any) + provider, _ := dns["provider"].(string) + if provider == "" { + return fmt.Errorf("terminating wildcard routes require certificatesResolvers.%s.acme.dnsChallenge.provider in the custom static configuration", app.ManagedWildcardCertificateResolver) + } } } return nil @@ -609,8 +663,8 @@ func validateSocketlessEnv(body []byte) error { return fmt.Errorf("parse dotenv: %w", err) } for key := range values { - if key == "TRAEFIK_CONFIGFILE" || strings.HasPrefix(key, "TRAEFIK_PROVIDERS_") { - return fmt.Errorf("remove %s; managed proxy provider settings must remain in the validated traefik.yml or traefik.yaml", key) + if strings.HasPrefix(key, "TRAEFIK_") { + return fmt.Errorf("remove %s; managed proxy static settings must remain in the validated traefik.yml or traefik.yaml", key) } } return nil diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 05c3febe..04a5b9de 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -102,6 +102,63 @@ func TestDefaultProxyRenderingIsSocketless(t *testing.T) { } } +func TestManagedDNSChallengeRenderingAndStaging(t *testing.T) { + dns := &app.ProxyDNSChallenge{Provider: "cloudflare", Resolvers: []string{"1.1.1.1:53", "[2606:4700:4700::1111]:53"}} + static := string(renderStaticConfigWithDNS(nil, dns)) + for _, want := range []string{ + "dnsChallenge:\n provider: cloudflare", + `- "1.1.1.1:53"`, + `- "[2606:4700:4700::1111]:53"`, + } { + if !strings.Contains(static, want) { + t.Fatalf("managed DNS configuration missing %q:\n%s", want, static) + } + } + if !strings.Contains(static, "httpChallenge") || !strings.Contains(static, " "+app.ManagedWildcardCertificateResolver+":") { + t.Fatalf("DNS-01 configuration must preserve exact-route HTTP-01 and add a wildcard resolver:\n%s", static) + } + + cfgDir := writeCfg(t, map[string]string{".env": "CF_DNS_API_TOKEN=placeholder\n"}) + staging := t.TempDir() + if _, err := StageForAppManaged(cfgDir, staging, "", "", "sample", "", nil, true, true, dns); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(filepath.Join(staging, "config", "traefik.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "dnsChallenge:") { + t.Fatalf("staged static configuration lost DNS-01:\n%s", body) + } + compose, err := os.ReadFile(filepath.Join(staging, "compose.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(compose), "env_file: [config/.env]") { + t.Fatalf("DNS provider credentials are not mounted:\n%s", compose) + } +} + +func TestManagedWildcardTLSRejectsIncompleteDNSConfiguration(t *testing.T) { + dns := &app.ProxyDNSChallenge{Provider: "cloudflare"} + if _, err := StageForAppManaged(writeCfg(t, map[string]string{"dynamic.yml": "http: {}\n"}), t.TempDir(), "", "", "sample", "", nil, true, true, dns); err == nil || !strings.Contains(err.Error(), ".env") { + t.Fatalf("missing provider credentials must fail: %v", err) + } + if _, err := StageForAppManaged(writeCfg(t, map[string]string{"dynamic.yml": "http: {}\n"}), t.TempDir(), "", "", "sample", "", nil, true, true, nil); err == nil || !strings.Contains(err.Error(), "proxy.dns_challenge") { + t.Fatalf("managed HTTP-01 must not claim wildcard support: %v", err) + } + + httpOnly := writeCfg(t, map[string]string{"traefik.yml": testSocketlessStaticWithResolver}) + if _, err := StageForAppManaged(httpOnly, t.TempDir(), "", "", "sample", "", nil, false, true, nil); err == nil || !strings.Contains(err.Error(), app.ManagedWildcardCertificateResolver) { + t.Fatalf("custom HTTP-01 resolver must not claim wildcard support: %v", err) + } + + dnsStatic := testSocketlessStatic + "certificatesResolvers:\n " + app.ManagedWildcardCertificateResolver + ":\n acme:\n storage: /letsencrypt/acme-wildcard.json\n dnsChallenge:\n provider: cloudflare\n" + if _, err := StageForAppManaged(writeCfg(t, map[string]string{"traefik.yml": dnsStatic}), t.TempDir(), "", "", "sample", "", nil, false, true, nil); err != nil { + t.Fatalf("custom DNS-01 resolver should remain supported: %v", err) + } +} + func TestManagedTLSRouterReferencesDefaultStaticResolver(t *testing.T) { spec, err := app.LoadBytes([]byte(`api_version: onebox.run/v1 app: sample @@ -454,13 +511,16 @@ func TestStageRejectsProviderOverridesInEnv(t *testing.T) { "TRAEFIK_PROVIDERS_DOCKER=true\n", "TRAEFIK_PROVIDERS_FILE_WATCH=false\n", "TRAEFIK_CONFIGFILE=/etc/traefik/alternate.yml\n", + "TRAEFIK_ENTRYPOINTS_WEB_ADDRESS=:8080\n", + "TRAEFIK_API_INSECURE=true\n", + "TRAEFIK_CERTIFICATESRESOLVERS_LE_ACME_EMAIL=ops@example.com\n", } { cfgDir := writeCfg(t, map[string]string{ "traefik.yml": testSocketlessStatic, ".env": "CF_DNS_API_TOKEN=allowed\n" + declaration, }) - 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) + if _, err := Stage(cfgDir, t.TempDir(), "", "", nil, false); err == nil || !strings.Contains(err.Error(), "managed proxy static settings") { + t.Fatalf("static override %q must be refused: %v", declaration, err) } } } diff --git a/site/public/onebox.run-v1.schema.json b/site/public/onebox.run-v1.schema.json index 484319f5..df4232c1 100644 --- a/site/public/onebox.run-v1.schema.json +++ b/site/public/onebox.run-v1.schema.json @@ -578,10 +578,11 @@ "type": "object" }, "domain": { - "description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes.", + "description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.", "examples": [ "shop.example.com" ], + "pattern": "^[^\\x00-\\x1f\\x7f`*]+$", "type": "string" }, "environments": { @@ -1169,10 +1170,45 @@ }, "properties": { "config": { - "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 or redefine the managed onebox-compress middleware. 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. A managed DNS challenge may use a directory containing only .env for provider credentials. 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, must define certificatesResolvers.letsencrypt for exact terminating routes, and must define the DNS-01 certificatesResolvers.onebox-wildcard for wildcard terminating routes. Dynamic files may not reuse Onebox-generated router or service names or redefine the managed onebox-compress middleware. Expects a path inside the repository, with no control character or shell metacharacter.", "pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$", "type": "string" }, + "dns_challenge": { + "additionalProperties": false, + "description": "Managed ACME DNS-01 challenge used to issue wildcard certificates. Provider credentials belong in proxy.config/.env; Onebox continues to own the static proxy configuration.", + "patternProperties": { + "^x-": {} + }, + "properties": { + "provider": { + "description": "Traefik DNS challenge provider name. Its credential variables must be supplied through proxy.config/.env. Expects a lower-case Traefik DNS provider name such as cloudflare or route53.", + "examples": [ + "cloudflare" + ], + "pattern": "^[a-z][a-z0-9_-]*$", + "type": "string" + }, + "resolvers": { + "description": "DNS resolvers used to verify challenge propagation, written as host:port.", + "examples": [ + [ + "1.1.1.1:53" + ] + ], + "items": { + "description": "Expects a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port.", + "pattern": "^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\\[[0-9A-Fa-f:.]+\\]):[0-9]{1,5}$", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "provider" + ], + "type": "object" + }, "entrypoints": { "additionalProperties": { "additionalProperties": false, @@ -1262,12 +1298,74 @@ "description": "Ingress routes exposed by this workload.", "items": { "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "domain": { + "const": "*" + } + }, + "required": [ + "domain" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + }, + "tls": { + "enum": [ + "none", + "passthrough" + ] + } + }, + "required": [ + "protocol", + "tls" + ] + } + } + ], + "oneOf": [ + { + "not": { + "required": [ + "wildcard_suffix" + ] + }, + "required": [ + "domain" + ] + }, + { + "not": { + "required": [ + "domain" + ] + }, + "required": [ + "wildcard_suffix" + ] + } + ], "patternProperties": { "^x-": {} }, "properties": { "domain": { - "description": "DNS name matched by the proxy.", + "anyOf": [ + { + "description": "Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.", + "pattern": "^[^\\x00-\\x1f\\x7f`*]+$" + }, + { + "const": "*" + } + ], + "description": "Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix.", "examples": [ "shop.example.com" ], @@ -1330,6 +1428,15 @@ "none" ], "type": "string" + }, + "wildcard_suffix": { + "description": "DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain.", + "examples": [ + "preview.example.com" + ], + "maxLength": 253, + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$", + "type": "string" } }, "type": "object" @@ -2051,10 +2158,11 @@ "type": "string" }, "domain": { - "description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes.", + "description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.", "examples": [ "shop.example.com" ], + "pattern": "^[^\\x00-\\x1f\\x7f`*]+$", "type": "string" }, "drain": { @@ -2662,12 +2770,74 @@ "description": "Ingress routes exposed by this workload.", "items": { "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "domain": { + "const": "*" + } + }, + "required": [ + "domain" + ] + }, + "then": { + "properties": { + "protocol": { + "const": "tcp" + }, + "tls": { + "enum": [ + "none", + "passthrough" + ] + } + }, + "required": [ + "protocol", + "tls" + ] + } + } + ], + "oneOf": [ + { + "not": { + "required": [ + "wildcard_suffix" + ] + }, + "required": [ + "domain" + ] + }, + { + "not": { + "required": [ + "domain" + ] + }, + "required": [ + "wildcard_suffix" + ] + } + ], "patternProperties": { "^x-": {} }, "properties": { "domain": { - "description": "DNS name matched by the proxy.", + "anyOf": [ + { + "description": "Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.", + "pattern": "^[^\\x00-\\x1f\\x7f`*]+$" + }, + { + "const": "*" + } + ], + "description": "Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix.", "examples": [ "shop.example.com" ], @@ -2730,6 +2900,15 @@ "none" ], "type": "string" + }, + "wildcard_suffix": { + "description": "DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain.", + "examples": [ + "preview.example.com" + ], + "maxLength": 253, + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$", + "type": "string" } }, "type": "object" diff --git a/site/src/content/docs/reference/fields/proxy.mdx b/site/src/content/docs/reference/fields/proxy.mdx index a3e9c99b..0d1dbef2 100644 --- a/site/src/content/docs/reference/fields/proxy.mdx +++ b/site/src/content/docs/reference/fields/proxy.mdx @@ -17,16 +17,21 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`config` · `entrypoints` · `image` · `kind` · `managed` · `network` · `port` +`config` · `dns_challenge` · `entrypoints` · `image` · `kind` · `managed` · `network` · `port` · `provider` · `resolvers` ## Reference | Field | Type | Default | What it does | | --- | --- | --- | --- | -| `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 or redefine the managed onebox-compress middleware. Expects a path inside the repository, with no control character or shell metacharacter. | +| `config` | string | — | Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. A managed DNS challenge may use a directory containing only .env for provider credentials. 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, must define certificatesResolvers.letsencrypt for exact terminating routes, and must define the DNS-01 certificatesResolvers.onebox-wildcard for wildcard terminating routes. Dynamic files may not reuse Onebox-generated router or service names or redefine the managed onebox-compress middleware. Expects a path inside the repository, with no control character or shell metacharacter. | +| `dns_challenge` | object | — | Managed ACME DNS-01 challenge used to issue wildcard certificates. Provider credentials belong in proxy.config/.env; Onebox continues to own the static proxy configuration. | +| `dns_challenge.provider` `*` | string | — | Traefik DNS challenge provider name. Its credential variables must be supplied through proxy.config/.env. Expects a lower-case Traefik DNS provider name such as cloudflare or route53. | +| `dns_challenge.resolvers` | list | — | DNS resolvers used to verify challenge propagation, written as host:port. | | `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. | | `managed` | boolean | — | Let Onebox converge the host-scoped proxy when routes are declared. | | `network` | string | `ob-ingress` | External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved. | + +`*` marks a field that is required within its own object. diff --git a/site/src/content/docs/reference/fields/top-level.mdx b/site/src/content/docs/reference/fields/top-level.mdx index 9b16d730..43d20e5a 100644 --- a/site/src/content/docs/reference/fields/top-level.mdx +++ b/site/src/content/docs/reference/fields/top-level.mdx @@ -18,7 +18,7 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`api_version` · `app` · `args` · `base_path` · `build` · `compose` · `context` · `dockerfile` · `domain` · `entrypoint` · `exec` · `health` · `http` · `image` · `interval` · `middlewares` · `path` · `port` · `protocol` · `pull` · `reference` · `retries` · `routes` · `scheme` · `start_period` · `target` · `tcp` · `tls` · `within` +`api_version` · `app` · `args` · `base_path` · `build` · `compose` · `context` · `dockerfile` · `domain` · `entrypoint` · `exec` · `health` · `http` · `image` · `interval` · `middlewares` · `path` · `port` · `protocol` · `pull` · `reference` · `retries` · `routes` · `scheme` · `start_period` · `target` · `tcp` · `tls` · `wildcard_suffix` · `within` ## Reference @@ -33,7 +33,7 @@ cannot drift from what `ob validate` accepts. | `build.dockerfile` | string | — | Repository-relative Dockerfile path. Expects a path inside the repository, with no control character or shell metacharacter. | | `build.target` | string | — | Named Dockerfile stage to build. | | `compose` | string | — | Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service. | -| `domain` | string | — | Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. | +| `domain` | string | — | Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing. | | `health` | object | — | Readiness check used to gate rolling replacement. Also accepts an HTTP health path. | | `health.exec` | — | — | Health command as a shell string or direct argument list. | | `health.http` | string | — | HTTP path probed inside the container. Expects a path beginning with /. | @@ -48,7 +48,7 @@ cannot drift from what `ob validate` accepts. | `image.reference` | string | — | Complete container image reference, optionally tagged or digest-pinned. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:…. | | `port` | integer | — | Container port used with domain shorthand and as the default HTTP health port. | | `routes` | list | — | Ingress routes exposed by this workload. | -| `routes[].domain` | string | — | DNS name matched by the proxy. | +| `routes[].domain` | string | — | Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix. | | `routes[].entrypoint` | string | `websecure` | Named proxy listener used for the route. | | `routes[].middlewares` | list | — | Ordered provider-qualified middleware references applied to this route. | | `routes[].path` | string | `/` | URL path prefix matched by an HTTP route. Expects a path beginning with /. | @@ -56,5 +56,6 @@ cannot drift from what `ob validate` accepts. | `routes[].protocol` | `http` · `tcp` | `http` | Routing protocol: http, tcp, or udp. | | `routes[].scheme` | `http` · `https` · `h2c` | `http` | Backend connection scheme: http, https, h2c, tcp, or udp. | | `routes[].tls` | `terminate` · `passthrough` · `none` | `terminate` | TLS handling: terminate, passthrough, or none. | +| `routes[].wildcard_suffix` | string | — | DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain. | `*` marks a field that is required within its own object. diff --git a/site/src/content/docs/reference/fields/workloads.mdx b/site/src/content/docs/reference/fields/workloads.mdx index 1e21301a..e938c99b 100644 --- a/site/src/content/docs/reference/fields/workloads.mdx +++ b/site/src/content/docs/reference/fields/workloads.mdx @@ -19,7 +19,7 @@ cannot drift from what `ob validate` accepts. ## Fields on this page -`args` · `attempts` · `backoff` · `bind` · `build` · `catch_up` · `command` · `compose` · `condition` · `container` · `context` · `cpus` · `cron` · `data_effect` · `default` · `deploy_lock` · `deployment_phase` · `description` · `dockerfile` · `domain` · `drain` · `driver` · `entrypoint` · `enum` · `env` · `env_files` · `exec` · `execution` · `extra_hosts` · `file` · `grace` · `health` · `host` · `hostname` · `http` · `id` · `image` · `init` · `inputs` · `interval` · `labels` · `logging` · `max_backoff` · `memory` · `middlewares` · `mode` · `name` · `needs` · `notify` · `operator_run` · `options` · `outputs` · `path` · `pattern` · `persistence` · `port` · `protocol` · `provider` · `published_ports` · `pull` · `reference` · `replicas` · `resources` · `retention` · `retries` · `retry` · `role` · `routes` · `schedule` · `scheme` · `shutdown_grace` · `signal` · `source` · `start_period` · `stdin_open` · `steps` · `strategy` · `target` · `tcp` · `timeout` · `timezone` · `tls` · `tty` · `user` · `volumes` · `wait` · `within` · `working_dir` +`args` · `attempts` · `backoff` · `bind` · `build` · `catch_up` · `command` · `compose` · `condition` · `container` · `context` · `cpus` · `cron` · `data_effect` · `default` · `deploy_lock` · `deployment_phase` · `description` · `dockerfile` · `domain` · `drain` · `driver` · `entrypoint` · `enum` · `env` · `env_files` · `exec` · `execution` · `extra_hosts` · `file` · `grace` · `health` · `host` · `hostname` · `http` · `id` · `image` · `init` · `inputs` · `interval` · `labels` · `logging` · `max_backoff` · `memory` · `middlewares` · `mode` · `name` · `needs` · `notify` · `operator_run` · `options` · `outputs` · `path` · `pattern` · `persistence` · `port` · `protocol` · `provider` · `published_ports` · `pull` · `reference` · `replicas` · `resources` · `retention` · `retries` · `retry` · `role` · `routes` · `schedule` · `scheme` · `shutdown_grace` · `signal` · `source` · `start_period` · `stdin_open` · `steps` · `strategy` · `target` · `tcp` · `timeout` · `timezone` · `tls` · `tty` · `user` · `volumes` · `wait` · `wildcard_suffix` · `within` · `working_dir` ## Reference @@ -34,7 +34,7 @@ cannot drift from what `ob validate` accepts. | `.compose` | string | — | Existing Compose service to adopt, as repository path#service. Expects a reference of the form path/to/compose.yaml#service. | | `.data_effect` | `none` · `migration` · `destructive` · `unknown` | — | Job data impact used by rollback and abort gates. | | `.deployment_phase` | `none` · `pre_release` · `post_release` | `none` | Deployment phase for this job: none, pre_release, or post_release. | -| `.domain` | string | — | Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. | +| `.domain` | string | — | Domain shorthand for one HTTPS route; requires port and cannot be combined with routes. Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing. | | `.drain` | object | — | Signal and timing used to remove a container from traffic before stopping it. | | `.drain.grace` | string | — | Maximum graceful-shutdown time before forced termination, at most 7d. Expects a duration such as 30s, 5m, 1h30m or 14d. | | `.drain.signal` | string | `TERM` | Signal sent to begin graceful shutdown. Expects a signal name such as TERM or QUIT. | @@ -98,7 +98,7 @@ cannot drift from what `ob validate` accepts. | `.resources.memory` | string | — | Container memory limit. Expects a size such as 512MB or 1.5GB. | | `.role` | `application` · `worker` · `daemon` · `job` | — | Lifecycle role: application, worker, daemon, or job. | | `.routes` | list | — | Ingress routes exposed by this workload. | -| `.routes[].domain` | string | — | DNS name matched by the proxy. | +| `.routes[].domain` | string | — | Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix. | | `.routes[].entrypoint` | string | `websecure` | Named proxy listener used for the route. | | `.routes[].middlewares` | list | — | Ordered provider-qualified middleware references applied to this route. | | `.routes[].path` | string | `/` | URL path prefix matched by an HTTP route. Expects a path beginning with /. | @@ -106,6 +106,7 @@ cannot drift from what `ob validate` accepts. | `.routes[].protocol` | `http` · `tcp` | `http` | Routing protocol: http, tcp, or udp. | | `.routes[].scheme` | `http` · `https` · `h2c` | `http` | Backend connection scheme: http, https, h2c, tcp, or udp. | | `.routes[].tls` | `terminate` · `passthrough` · `none` | `terminate` | TLS handling: terminate, passthrough, or none. | +| `.routes[].wildcard_suffix` | string | — | DNS suffix whose immediate subdomains are matched. For example, example.com matches shop.example.com but not example.com or a.b.example.com. Mutually exclusive with domain. | | `.schedule` | object | — | Host-resident recurring schedule and run policy for a job, independent of its deployment phase and operator-run policy. | | `.schedule.catch_up` | boolean | `true` | Run once after the host returns if an elapsed schedule was missed while it was offline. | | `.schedule.cron` | string | — | Five-field cron schedule translated to a host timer. Expects five cron fields. | diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 935900b7..55a452cb 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -166,9 +166,9 @@ 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. +directory. A proxy `.env` is accepted with project-owned static configuration +or with `proxy.dns_challenge`; it may carry ordinary DNS-provider credentials, +but not `TRAEFIK_*` static overrides. In both modes, the filenames `onebox.yml`, `onebox.yaml`, and `onebox-managed.yml`, the `dynamic/` mountpoint, the `onebox-compress` @@ -177,6 +177,53 @@ middleware, and HTTP or TCP router or service names beginning with the derived and `ob proxy apply` refuse an incompatible configuration with migration guidance rather than silently starting a proxy with missing or stale routes. +### Wildcard host routes + +Use `wildcard_suffix` when one HTTP workload should receive every immediate +subdomain below a suffix: + +```yaml +proxy: + config: traefik + dns_challenge: + provider: cloudflare + resolvers: ["1.1.1.1:53"] + +workloads: + preview: + image: ghcr.io/acme/preview:1.0.0 + routes: + - {wildcard_suffix: preview.example.com, port: 3000} +``` + +This matches `branch.preview.example.com`, but not the suffix itself or +`a.branch.preview.example.com`. Declare the apex as a separate exact `domain` +route when it should be served too. A route declares exactly one of `domain` +or `wildcard_suffix`; Onebox does not accept authored regular expressions or a +bare catch-all. It renders the suffix as the anchored Traefik rule +``HostRegexp(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.preview\.example\.com$`)``. +Exact and wildcard claims +that overlap on the same entrypoint, +protocol, and path are refused instead of relying on implicit proxy priority. +The existing `domain: "*"` form remains available only for a plaintext or TLS +passthrough TCP route (`protocol: tcp` with `tls: none` or `tls: passthrough`), +where Traefik requires ``HostSNI(`*`)``; +it overlaps every TCP host claim on the same entrypoint and path. + +Terminating TLS for a wildcard requires ACME DNS-01. `proxy.dns_challenge` +keeps the static Traefik configuration managed by Onebox while selecting the +DNS provider. Put the provider's required environment variables in +`proxy.config/.env`; the directory may contain only that file. Onebox stages it +with mode `0600` and mounts it only into Traefik. Do not commit provider tokens. +Exact routes continue to use the separate `letsencrypt` HTTP-01 resolver, so +adding a wildcard does not change issuance or renewal for existing domains. +The provider names and credential variables are defined by +[Traefik's DNS challenge provider documentation](https://doc.traefik.io/traefik/reference/install-configuration/tls/certificate-resolvers/acme/#providers). +If `proxy.config` contains a project-owned +`traefik.yml` or `traefik.yaml` instead, configure DNS-01 under +`certificatesResolvers.onebox-wildcard` there and omit +`proxy.dns_challenge`. + ### Additional proxy entrypoints Declare an entrypoint when clients must reach the managed proxy on a port other