Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 184 additions & 5 deletions docs/onebox.run-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
],
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
],
Expand Down Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions internal/app/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down
15 changes: 12 additions & 3 deletions internal/app/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions internal/app/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
31 changes: 31 additions & 0 deletions internal/app/jsonschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand Down Expand Up @@ -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)},
Expand Down Expand Up @@ -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)},
Expand Down
Loading