Skip to content

Commit 37afc69

Browse files
authored
feat(proxy): support managed wildcard host routes (#198)
* feat(proxy): support managed wildcard host routes * fix(proxy): harden wildcard route contract * fix(proxy): complete wildcard routing contract
1 parent 04e74ea commit 37afc69

23 files changed

Lines changed: 1007 additions & 96 deletions

‎docs/onebox.run-v1.schema.json‎

Lines changed: 184 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -578,10 +578,11 @@
578578
"type": "object"
579579
},
580580
"domain": {
581-
"description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes.",
581+
"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.",
582582
"examples": [
583583
"shop.example.com"
584584
],
585+
"pattern": "^[^\\x00-\\x1f\\x7f`*]+$",
585586
"type": "string"
586587
},
587588
"environments": {
@@ -1169,10 +1170,45 @@
11691170
},
11701171
"properties": {
11711172
"config": {
1172-
"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.",
1173+
"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.",
11731174
"pattern": "^[^/\\x00-\\x1f'\"$`\\\\][^\\x00-\\x1f'\"$`\\\\]*$",
11741175
"type": "string"
11751176
},
1177+
"dns_challenge": {
1178+
"additionalProperties": false,
1179+
"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.",
1180+
"patternProperties": {
1181+
"^x-": {}
1182+
},
1183+
"properties": {
1184+
"provider": {
1185+
"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.",
1186+
"examples": [
1187+
"cloudflare"
1188+
],
1189+
"pattern": "^[a-z][a-z0-9_-]*$",
1190+
"type": "string"
1191+
},
1192+
"resolvers": {
1193+
"description": "DNS resolvers used to verify challenge propagation, written as host:port.",
1194+
"examples": [
1195+
[
1196+
"1.1.1.1:53"
1197+
]
1198+
],
1199+
"items": {
1200+
"description": "Expects a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port.",
1201+
"pattern": "^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\\[[0-9A-Fa-f:.]+\\]):[0-9]{1,5}$",
1202+
"type": "string"
1203+
},
1204+
"type": "array"
1205+
}
1206+
},
1207+
"required": [
1208+
"provider"
1209+
],
1210+
"type": "object"
1211+
},
11761212
"entrypoints": {
11771213
"additionalProperties": {
11781214
"additionalProperties": false,
@@ -1262,12 +1298,74 @@
12621298
"description": "Ingress routes exposed by this workload.",
12631299
"items": {
12641300
"additionalProperties": false,
1301+
"allOf": [
1302+
{
1303+
"if": {
1304+
"properties": {
1305+
"domain": {
1306+
"const": "*"
1307+
}
1308+
},
1309+
"required": [
1310+
"domain"
1311+
]
1312+
},
1313+
"then": {
1314+
"properties": {
1315+
"protocol": {
1316+
"const": "tcp"
1317+
},
1318+
"tls": {
1319+
"enum": [
1320+
"none",
1321+
"passthrough"
1322+
]
1323+
}
1324+
},
1325+
"required": [
1326+
"protocol",
1327+
"tls"
1328+
]
1329+
}
1330+
}
1331+
],
1332+
"oneOf": [
1333+
{
1334+
"not": {
1335+
"required": [
1336+
"wildcard_suffix"
1337+
]
1338+
},
1339+
"required": [
1340+
"domain"
1341+
]
1342+
},
1343+
{
1344+
"not": {
1345+
"required": [
1346+
"domain"
1347+
]
1348+
},
1349+
"required": [
1350+
"wildcard_suffix"
1351+
]
1352+
}
1353+
],
12651354
"patternProperties": {
12661355
"^x-": {}
12671356
},
12681357
"properties": {
12691358
"domain": {
1270-
"description": "DNS name matched by the proxy.",
1359+
"anyOf": [
1360+
{
1361+
"description": "Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.",
1362+
"pattern": "^[^\\x00-\\x1f\\x7f`*]+$"
1363+
},
1364+
{
1365+
"const": "*"
1366+
}
1367+
],
1368+
"description": "Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix.",
12711369
"examples": [
12721370
"shop.example.com"
12731371
],
@@ -1330,6 +1428,15 @@
13301428
"none"
13311429
],
13321430
"type": "string"
1431+
},
1432+
"wildcard_suffix": {
1433+
"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.",
1434+
"examples": [
1435+
"preview.example.com"
1436+
],
1437+
"maxLength": 253,
1438+
"pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$",
1439+
"type": "string"
13331440
}
13341441
},
13351442
"type": "object"
@@ -2051,10 +2158,11 @@
20512158
"type": "string"
20522159
},
20532160
"domain": {
2054-
"description": "Domain shorthand for one HTTPS route; requires port and cannot be combined with routes.",
2161+
"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.",
20552162
"examples": [
20562163
"shop.example.com"
20572164
],
2165+
"pattern": "^[^\\x00-\\x1f\\x7f`*]+$",
20582166
"type": "string"
20592167
},
20602168
"drain": {
@@ -2662,12 +2770,74 @@
26622770
"description": "Ingress routes exposed by this workload.",
26632771
"items": {
26642772
"additionalProperties": false,
2773+
"allOf": [
2774+
{
2775+
"if": {
2776+
"properties": {
2777+
"domain": {
2778+
"const": "*"
2779+
}
2780+
},
2781+
"required": [
2782+
"domain"
2783+
]
2784+
},
2785+
"then": {
2786+
"properties": {
2787+
"protocol": {
2788+
"const": "tcp"
2789+
},
2790+
"tls": {
2791+
"enum": [
2792+
"none",
2793+
"passthrough"
2794+
]
2795+
}
2796+
},
2797+
"required": [
2798+
"protocol",
2799+
"tls"
2800+
]
2801+
}
2802+
}
2803+
],
2804+
"oneOf": [
2805+
{
2806+
"not": {
2807+
"required": [
2808+
"wildcard_suffix"
2809+
]
2810+
},
2811+
"required": [
2812+
"domain"
2813+
]
2814+
},
2815+
{
2816+
"not": {
2817+
"required": [
2818+
"domain"
2819+
]
2820+
},
2821+
"required": [
2822+
"wildcard_suffix"
2823+
]
2824+
}
2825+
],
26652826
"patternProperties": {
26662827
"^x-": {}
26672828
},
26682829
"properties": {
26692830
"domain": {
2670-
"description": "DNS name matched by the proxy.",
2831+
"anyOf": [
2832+
{
2833+
"description": "Expects an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing.",
2834+
"pattern": "^[^\\x00-\\x1f\\x7f`*]+$"
2835+
},
2836+
{
2837+
"const": "*"
2838+
}
2839+
],
2840+
"description": "Exact DNS name matched by the proxy. Mutually exclusive with wildcard_suffix.",
26712841
"examples": [
26722842
"shop.example.com"
26732843
],
@@ -2730,6 +2900,15 @@
27302900
"none"
27312901
],
27322902
"type": "string"
2903+
},
2904+
"wildcard_suffix": {
2905+
"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.",
2906+
"examples": [
2907+
"preview.example.com"
2908+
],
2909+
"maxLength": 253,
2910+
"pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$",
2911+
"type": "string"
27332912
}
27342913
},
27352914
"type": "object"

‎internal/app/constraints.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,21 @@ var (
108108
gRegistryUser = grammar{"registry username", regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@+-]*$`),
109109
"a username of letters, digits and . _ @ + -"}
110110

111+
gDNSProvider = grammar{"DNS challenge provider", regexp.MustCompile(`^[a-z][a-z0-9_-]*$`),
112+
"a lower-case Traefik DNS provider name such as cloudflare or route53"}
113+
114+
gDNSResolver = grammar{"DNS resolver", regexp.MustCompile(`^([a-z0-9]([a-z0-9.-]*[a-z0-9])?|\[[0-9A-Fa-f:.]+\]):[0-9]{1,5}$`),
115+
"a lower-case DNS name, IPv4 address, or bracketed IPv6 address followed by a port"}
116+
117+
// Exact route hosts predate strict hostname validation. Keep accepting their
118+
// established spellings (including upper-case and a trailing dot), while
119+
// excluding the characters that can escape Traefik's backtick literal.
120+
gRouteHost = grammar{"route host", regexp.MustCompile("^[^\\x00-\\x1f\\x7f`*]+$"),
121+
"an exact host with no wildcard, control character or backtick; use wildcard_suffix for wildcard routing"}
122+
123+
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])?)*$`),
124+
"a lower-case ASCII or Punycode DNS hostname whose labels contain 1 to 63 characters"}
125+
111126
gCalVer = grammar{"version", buildinfo.ReleaseVersionPattern,
112127
"a CalVer release such as v2026.8.0"}
113128

‎internal/app/generate.go‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -544,10 +544,14 @@ func (p *Spec) routeLabels(n Names, name string, w Workload) map[string]any {
544544
svcName := n.ProxyServiceFor(name, i)
545545
router := n.Router(name, i)
546546
kind := "http"
547-
rule := fmt.Sprintf("Host(`%s`)", r.Domain)
547+
rule := fmt.Sprintf("Host(`%s`)", r.HostPattern())
548+
if r.WildcardSuffix != "" {
549+
suffix := strings.ReplaceAll(r.WildcardSuffix, ".", `\.`)
550+
rule = fmt.Sprintf("HostRegexp(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.%s$`)", suffix)
551+
}
548552
if r.Protocol == "tcp" {
549553
kind = "tcp"
550-
rule = fmt.Sprintf("HostSNI(`%s`)", r.Domain)
554+
rule = fmt.Sprintf("HostSNI(`%s`)", r.HostPattern())
551555
} else if r.Path != "" && r.Path != "/" {
552556
rule += fmt.Sprintf(" && PathPrefix(`%s`)", r.Path)
553557
}
@@ -574,7 +578,12 @@ func (p *Spec) routeLabels(n Names, name string, w Workload) map[string]any {
574578
// authored project data. The generated static configuration defines
575579
// this same private identity.
576580
if p.Proxy.Managed && r.TLS == "terminate" {
577-
out[pre+"tls.certresolver"] = ManagedCertificateResolver
581+
resolver := ManagedCertificateResolver
582+
if r.WildcardSuffix != "" {
583+
resolver = ManagedWildcardCertificateResolver
584+
out[pre+"tls.domains[0].main"] = r.HostPattern()
585+
}
586+
out[pre+"tls.certresolver"] = resolver
578587
}
579588
}
580589
// Named explicitly: with more than one service defined on a container,

‎internal/app/generate_test.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,34 @@ func TestHasTerminatingTLSDistinguishesPassthrough(t *testing.T) {
362362
}
363363
}
364364

365+
func TestWildcardRouteRendersSafeHostRegexpAndDNSResolver(t *testing.T) {
366+
project := `api_version: onebox.run/v1
367+
app: preview
368+
environments: {production: {server: root@example.com}}
369+
workloads:
370+
web:
371+
image: nginx
372+
routes: [{wildcard_suffix: preview.example.com, port: 8080}]
373+
proxy:
374+
config: traefik
375+
dns_challenge: {provider: cloudflare}
376+
`
377+
out := string(render(t, project))
378+
if !strings.Contains(out, `HostRegexp(`+"`"+`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.preview\.example\.com$$`+"`"+`)`) {
379+
t.Fatalf("wildcard route missing single-label HostRegexp matcher:\n%s", out)
380+
}
381+
if strings.Contains(out, "Host(`*.preview.example.com`)") {
382+
t.Fatalf("wildcard route must not rely on Host wildcard semantics:\n%s", out)
383+
}
384+
if !strings.Contains(out, "tls.certresolver: "+ManagedWildcardCertificateResolver) ||
385+
!strings.Contains(out, "tls.domains[0].main: '*.preview.example.com'") {
386+
t.Fatalf("wildcard route lost managed TLS:\n%s", out)
387+
}
388+
if strings.Count(out, "tls.certresolver: "+ManagedCertificateResolver) != 0 {
389+
t.Fatalf("wildcard route must not change exact-route HTTP-01 issuance:\n%s", out)
390+
}
391+
}
392+
365393
// TestEveryDraftRenders runs generation over the real conversion drafts.
366394
func TestEveryDraftRenders(t *testing.T) {
367395
dir := filepath.Join("testdata", "corpus")

‎internal/app/jsonschema.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ var schemaConstraints = []struct {
344344
{[]string{"workloads", "*", "operator_run"}, enum(eJobOperatorRun)},
345345
{[]string{"workloads", "*", "data_effect"}, enum(eDataEffect)},
346346
{[]string{"workloads", "*", "compose"}, pattern(gComposeRef)},
347+
{[]string{"workloads", "*", "domain"}, pattern(gRouteHost)},
347348
{[]string{"workloads", "*", "port"}, portBounds()},
348349
{[]string{"workloads", "*", "working_dir"}, pattern(gAbsPath)},
349350
{[]string{"workloads", "*", "env_files", "items", "file"}, pattern(gRepoPath)},
@@ -386,6 +387,33 @@ var schemaConstraints = []struct {
386387
{[]string{"workloads", "*", "resources", "memory"}, pattern(gSize)},
387388
{[]string{"workloads", "*", "resources", "cpus"}, pattern(gCpus)},
388389
{[]string{"workloads", "*", "persistence", "mode"}, enum(ePersistence)},
390+
{[]string{"workloads", "*", "routes", "items"}, map[string]any{
391+
"oneOf": []any{
392+
map[string]any{"required": []any{"domain"}, "not": map[string]any{"required": []any{"wildcard_suffix"}}},
393+
map[string]any{"required": []any{"wildcard_suffix"}, "not": map[string]any{"required": []any{"domain"}}},
394+
},
395+
"allOf": []any{map[string]any{
396+
"if": map[string]any{
397+
"required": []any{"domain"},
398+
"properties": map[string]any{"domain": map[string]any{"const": "*"}},
399+
},
400+
"then": map[string]any{
401+
"required": []any{"protocol", "tls"},
402+
"properties": map[string]any{
403+
"protocol": map[string]any{"const": "tcp"},
404+
"tls": map[string]any{"enum": []any{"none", "passthrough"}},
405+
},
406+
},
407+
}},
408+
}},
409+
{[]string{"workloads", "*", "routes", "items", "domain"}, map[string]any{"anyOf": []any{
410+
pattern(gRouteHost),
411+
map[string]any{"const": "*"},
412+
}}},
413+
{[]string{"workloads", "*", "routes", "items", "wildcard_suffix"}, map[string]any{
414+
"pattern": gWildcardSuffix.pattern.String(),
415+
"maxLength": 253,
416+
}},
389417
{[]string{"workloads", "*", "routes", "items", "path"}, pattern(gURLPath)},
390418
{[]string{"workloads", "*", "routes", "items", "port"}, portBounds()},
391419
{[]string{"workloads", "*", "routes", "items", "protocol"}, enum(eRouteProtocol)},
@@ -477,6 +505,9 @@ var schemaConstraints = []struct {
477505
{[]string{"proxy", "kind"}, enum(eProxyKind)},
478506
{[]string{"proxy", "image"}, pattern(gImageRef)},
479507
{[]string{"proxy", "config"}, pattern(gRepoPath)},
508+
{[]string{"proxy", "dns_challenge", "provider"}, pattern(gDNSProvider)},
509+
{[]string{"proxy", "dns_challenge", "resolvers", "items"}, pattern(gDNSResolver)},
510+
{[]string{"proxy", "dns_challenge"}, map[string]any{"required": []any{"provider"}}},
480511
{[]string{"proxy", "entrypoints"}, propertyNames(gIdent)},
481512
{[]string{"proxy", "entrypoints", "*", "port"}, portBounds()},
482513
{[]string{"deployment", "migration_policy"}, enum(eMigrationPolicy)},

0 commit comments

Comments
 (0)