Skip to content

Commit d39e4d6

Browse files
committed
wildcard domains support
1 parent 114aeed commit d39e4d6

14 files changed

Lines changed: 400 additions & 46 deletions

File tree

ansible/roles/traefik/tasks/main.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@
9292
- "{{ terrarium_traefik_config_dir }}/bootstrap-certs"
9393
- /var/lib/traefik
9494

95+
- name: Create Traefik secret directory
96+
ansible.builtin.file:
97+
path: /etc/terrarium/secrets
98+
state: directory
99+
owner: root
100+
group: root
101+
mode: "0700"
102+
95103
- name: Determine whether bootstrap TLS is required
96104
ansible.builtin.set_fact:
97105
terrarium_bootstrap_tls_enabled: "{{ terrarium_idp_mode == 'local' and (terrarium_auth_domain | default('') | length > 0) }}"
@@ -184,6 +192,23 @@
184192
state: touch
185193
mode: "0600"
186194

195+
- name: Render Traefik DNS challenge credential environment
196+
ansible.builtin.template:
197+
src: traefik-dns.env.j2
198+
dest: /etc/terrarium/secrets/traefik-dns.env
199+
owner: root
200+
group: root
201+
mode: "0600"
202+
notify: restart traefik
203+
when: terrarium_acme_dns_provider | default('') | length > 0
204+
205+
- name: Remove Traefik DNS challenge credential environment when DNS-01 is disabled
206+
ansible.builtin.file:
207+
path: /etc/terrarium/secrets/traefik-dns.env
208+
state: absent
209+
notify: restart traefik
210+
when: terrarium_acme_dns_provider | default('') | length == 0
211+
187212
- name: Render Traefik static config
188213
ansible.builtin.template:
189214
src: traefik.yml.j2
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{% for item in (terrarium_acme_dns_env | default({}) | dictsort) %}
2+
{{ item.0 }}={{ item.1 | to_json }}
3+
{% endfor %}

ansible/roles/traefik/templates/traefik.service.j2

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Wants=network-online.target
55

66
[Service]
77
Type=simple
8+
EnvironmentFile=-/etc/terrarium/secrets/traefik-dns.env
89
ExecStart=/usr/local/bin/traefik --configFile={{ terrarium_traefik_config_dir }}/traefik.yml --entrypoints.bootstrapweb.address=127.0.0.1:18080
910
Restart=on-failure
1011
RestartSec=5s

ansible/roles/traefik/templates/traefik.yml.j2

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ certificatesResolvers:
1818
acme:
1919
email: {{ terrarium_acme_email }}
2020
storage: /var/lib/traefik/acme.json
21+
{% if terrarium_acme_dns_provider | default('') | length > 0 %}
22+
dnsChallenge:
23+
provider: {{ terrarium_acme_dns_provider }}
24+
{% else %}
2125
httpChallenge:
2226
entryPoint: web
27+
{% endif %}
2328

2429
log:
2530
level: INFO

docs/getting-started/domains-and-auth.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Container apps become public only when you add a `user.proxy` label to the LXD i
3535
For normal web apps, use this format:
3636

3737
```text
38-
https://<public-host>[:container-port][/path][@auth[:group[,group...]]]
38+
https://<public-host>[:container-port][/path][@auth[:group[,group...]][~callback-host]]
3939
```
4040

4141
Examples:
@@ -63,8 +63,30 @@ What each part means:
6363
- `/path` is optional and is matched as a path prefix.
6464
- `@auth` requires a successful OIDC login before traffic reaches the app.
6565
- `@auth:admins,devops` also requires membership in one of those groups.
66+
- `~auth.example.com` is required only for wildcard auth routes and gives Terrarium the concrete oauth2-proxy callback host.
6667

67-
Use `https://` for normal public routes. Terrarium will request a Let's Encrypt certificate and redirect plain HTTP to HTTPS. Query strings and URL fragments are not part of route labels. Wildcard route hosts such as `*.example.com` are not supported; each published hostname needs its own explicit label.
68+
Use `https://` for normal public routes. Terrarium will request a Let's Encrypt certificate and redirect plain HTTP to HTTPS. Query strings and URL fragments are not part of route labels.
69+
70+
Wildcard HTTPS routes are supported after DNS-01 ACME is configured:
71+
72+
```bash
73+
terrariumctl set dns provider cloudflare CF_DNS_API_TOKEN:your-token
74+
lxc config set my-app user.proxy "https://*.example.com:8080"
75+
terrariumctl proxy sync
76+
```
77+
78+
DNS credentials map directly to lego's provider environment variable names. See the [lego DNS provider list](https://go-acme.github.io/lego/dns/index.html) for supported providers and required variables. To disable DNS-01 and return to HTTP-01 certificate validation, run:
79+
80+
```bash
81+
terrariumctl set dns provider
82+
```
83+
84+
Wildcard auth routes need a concrete callback host under the same base domain:
85+
86+
```bash
87+
lxc config set admin-ui user.proxy "https://*.example.com:3000@auth:admins~auth.example.com"
88+
terrariumctl proxy sync
89+
```
6890

6991
You can publish more than one route from the same container by separating routes with commas or newlines:
7092

docs/reference/terrariumctl.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,21 +137,32 @@ terrariumctl proxy sync
137137
HTTP(S) route format:
138138

139139
```text
140-
https://<public-host>[:container-port][/path][@auth[:group[,group...]]]
141-
http://<public-host>[:container-port][/path][@auth[:group[,group...]]]
140+
https://<public-host>[:container-port][/path][@auth[:group[,group...]][~callback-host]]
141+
http://<public-host>[:container-port][/path][@auth[:group[,group...]][~callback-host]]
142142
```
143143

144144
Use `https://` for normal public routes. It creates a HTTPS router with Let's Encrypt and redirects plain HTTP to HTTPS. Use `http://` only when you intentionally want a plain HTTP public route. The port is the port inside the container; the public listener is still 80/443. If the port is omitted, Terrarium targets container port `80`. A path, when present, is matched as a prefix. Query strings and fragments are not supported.
145145

146+
Wildcard HTTPS hosts such as `https://*.example.com:8080` require DNS-01 ACME. Configure the single Traefik DNS provider with lego environment variable names:
147+
148+
```bash
149+
terrariumctl set dns provider cloudflare CF_DNS_API_TOKEN:your-token
150+
terrariumctl set dns provider route53 AWS_ACCESS_KEY_ID:your-key AWS_SECRET_ACCESS_KEY:your-secret AWS_REGION:us-east-1
151+
terrariumctl set dns provider
152+
```
153+
154+
The last command disables DNS-01 and returns Traefik to HTTP-01. Traefik supports one DNS challenge provider per instance; see the [lego DNS provider list](https://go-acme.github.io/lego/dns/index.html) for provider codes and required environment variables.
155+
146156
Authentication suffixes are supported only on HTTP(S) routes:
147157

148158
```bash
149159
lxc config set grafana user.proxy "https://grafana.example.com:3000@auth"
150160
lxc config set admin-tool user.proxy "https://admin.example.com:8080@auth:admins,devops"
161+
lxc config set wildcard-admin user.proxy "https://*.example.com:8080@auth:admins~auth.example.com"
151162
terrariumctl proxy sync
152163
```
153164

154-
`@auth` allows any authenticated user. `@auth:admins,devops` allows users in any listed group. Group names may contain letters, numbers, dots, underscores, and hyphens. External OIDC providers must emit a `groups` claim for group-restricted routes.
165+
`@auth` allows any authenticated user. `@auth:admins,devops` allows users in any listed group. Group names may contain letters, numbers, dots, underscores, and hyphens. Wildcard auth routes must add `~callback-host`; Terrarium always uses HTTPS and oauth2-proxy's callback path for that host. External OIDC providers must emit a `groups` claim for group-restricted routes.
155166

156167
TCP and UDP route formats:
157168

scripts/ctl/completion.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe("terrariumctl completion", () => {
1515
expect(script).toContain("--skip-reconfigure");
1616
expect(script).toContain("--storage-source");
1717
expect(script).toContain("local oidc");
18+
expect(script).toContain("provider");
1819
});
1920

2021
test("completes root command prefixes", () => {
@@ -30,8 +31,10 @@ describe("terrariumctl completion", () => {
3031
expect(zsh).toContain("#compdef terrariumctl trm");
3132
expect(zsh).toContain("update) opts=(--ref --skip-reconfigure --non-interactive)");
3233
expect(zsh).toContain("compadd local oidc");
34+
expect(zsh).toContain("compadd provider");
3335
expect(fish).toContain("complete -c terrariumctl");
3436
expect(fish).toContain("complete -c trm");
37+
expect(fish).toContain("__fish_seen_subcommand_from dns");
3538
expect(fish).toContain("-l oidc-client");
3639
expect(fish).toContain("-s p");
3740
});

scripts/ctl/completion.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const actions: Record<string, string[]> = {
2323
proxy: ["sync"],
2424
mount: ["add", "attach", "remove", "list"],
2525
idp: ["sync", "status", "logs", "backup", "restore"],
26-
set: ["domains", "emails", "idp", "s3", "syncoid"],
26+
set: ["domains", "emails", "idp", "dns", "s3", "syncoid"],
2727
completion: ["bash", "zsh", "fish"]
2828
};
2929

@@ -197,6 +197,8 @@ _terrariumctl_complete() {
197197
3)
198198
if [[ "\${command}" == "set" && "\${COMP_WORDS[2]}" == "idp" ]]; then
199199
COMPREPLY=( $(compgen -W "local oidc" -- "\${cur}") )
200+
elif [[ "\${command}" == "set" && "\${COMP_WORDS[2]}" == "dns" ]]; then
201+
COMPREPLY=( $(compgen -W "provider" -- "\${cur}") )
200202
elif [[ "\${command}" == "mount" && "\${COMP_WORDS[2]}" == "add" ]]; then
201203
COMPREPLY=( $(compgen -W "smb cifs" -- "\${cur}") )
202204
elif [[ "\${command}" == "cluster" && "\${COMP_WORDS[2]}" == "ovn" ]]; then
@@ -249,6 +251,8 @@ _terrariumctl() {
249251
250252
if [[ "$words[2]" == "set" && "$words[3]" == "idp" ]]; then
251253
compadd local oidc
254+
elif [[ "$words[2]" == "set" && "$words[3]" == "dns" ]]; then
255+
compadd provider
252256
elif [[ "$words[2]" == "mount" && "$words[3]" == "add" ]]; then
253257
compadd smb cifs
254258
elif [[ "$words[2]" == "cluster" && "$words[3]" == "ovn" ]]; then
@@ -277,6 +281,7 @@ function fishCompletion(): string {
277281
}
278282
}
279283
lines.push(`complete -c ${command} -f -n "__fish_seen_subcommand_from set; and __fish_seen_subcommand_from idp" -a "local oidc"`);
284+
lines.push(`complete -c ${command} -f -n "__fish_seen_subcommand_from set; and __fish_seen_subcommand_from dns" -a "provider"`);
280285
lines.push(`complete -c ${command} -f -n "__fish_seen_subcommand_from mount; and __fish_seen_subcommand_from add" -a "smb cifs"`);
281286
lines.push(`complete -c ${command} -f -n "__fish_seen_subcommand_from cluster; and __fish_seen_subcommand_from ovn" -a "configure"`);
282287
}

scripts/ctl/config.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
22
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5-
import { applySetIdpConfig, parseSetCommandOptions, runReconcileActions, type ReconcileActions } from "./config";
5+
import { applySetDnsProviderConfig, applySetIdpConfig, parseSetCommandOptions, runReconcileActions, type ReconcileActions } from "./config";
66

77
function recordActions(calls: string[], outputs: string[] = [""]): ReconcileActions {
88
return {
@@ -233,4 +233,45 @@ describe("terrariumctl config reconciliation", () => {
233233
expect(parsed.idp.lxdOidcSecret).toBeUndefined();
234234
expect(parsed.s3.s3SecretKey).toBeUndefined();
235235
});
236+
237+
test("stores DNS provider credentials as exact lego environment variables", () => {
238+
const config: Record<string, unknown> = {};
239+
240+
const summary = applySetDnsProviderConfig(config, {
241+
provider: "Cloudflare",
242+
credentials: ["CF_API_KEY:key:with:colon", "CF_DNS_API_TOKEN:token"]
243+
});
244+
245+
expect(summary).toBe("Enabled DNS-01 ACME provider cloudflare");
246+
expect(config.terrarium_acme_dns_provider).toBe("cloudflare");
247+
expect(config.terrarium_acme_dns_env).toEqual({
248+
CF_API_KEY: "key:with:colon",
249+
CF_DNS_API_TOKEN: "token"
250+
});
251+
});
252+
253+
test("clears DNS provider and DNS credentials together", () => {
254+
const config: Record<string, unknown> = {
255+
terrarium_acme_dns_provider: "cloudflare",
256+
terrarium_acme_dns_env: { CF_DNS_API_TOKEN: "token" }
257+
};
258+
259+
const summary = applySetDnsProviderConfig(config, { provider: undefined, credentials: [] });
260+
261+
expect(summary).toBe("Disabled DNS-01 ACME");
262+
expect(config.terrarium_acme_dns_provider).toBe("");
263+
expect(config.terrarium_acme_dns_env).toEqual({});
264+
});
265+
266+
test("rejects DNS credentials that are not environment variable assignments", () => {
267+
expect(() =>
268+
applySetDnsProviderConfig(
269+
{},
270+
{
271+
provider: "cloudflare",
272+
credentials: ["cf-token:secret"]
273+
}
274+
)
275+
).toThrow("uppercase lego environment variable");
276+
});
236277
});

scripts/ctl/config.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ export type SetSyncoidOptions = {
7979
syncoidSshKey?: string;
8080
};
8181

82+
/** Reusable option bag for `set dns provider`. */
83+
export type SetDnsProviderOptions = {
84+
provider?: string;
85+
credentials: string[];
86+
};
87+
8288
type SetIdpPlan = {
8389
summary: string;
8490
verifyOidc?: OidcVerificationOptions;
@@ -149,6 +155,59 @@ async function persistAndReconcile(config: MutableConfig, summary: string, actio
149155
console.log(success(summary));
150156
}
151157

158+
function validateLegoDnsProvider(provider: string): string {
159+
const normalized = provider.trim().toLowerCase();
160+
if (!normalized) {
161+
return "";
162+
}
163+
if (!/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
164+
throw new Error("DNS provider must be a lego provider code such as cloudflare, hetzner, route53, or acme-dns");
165+
}
166+
return normalized;
167+
}
168+
169+
function parseDnsCredentials(credentials: string[]): Record<string, string> {
170+
const env: Record<string, string> = {};
171+
for (const credential of credentials) {
172+
const separator = credential.indexOf(":");
173+
if (separator <= 0) {
174+
throw new Error(`DNS credential must use KEY:VALUE form: ${credential}`);
175+
}
176+
const key = credential.slice(0, separator);
177+
const value = credential.slice(separator + 1);
178+
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
179+
throw new Error(`DNS credential key must be an uppercase lego environment variable name: ${key}`);
180+
}
181+
if (/[\r\n]/.test(value)) {
182+
throw new Error(`DNS credential value for ${key} must be a single line`);
183+
}
184+
if (value) {
185+
env[key] = value;
186+
}
187+
}
188+
return env;
189+
}
190+
191+
/** Enables or disables Traefik DNS-01 ACME using lego provider env names. */
192+
export function applySetDnsProviderConfig(config: MutableConfig, options: SetDnsProviderOptions): string {
193+
const provider = validateLegoDnsProvider(options.provider ?? "");
194+
if (!provider) {
195+
setConfigValue(config, "terrarium_acme_dns_provider", "");
196+
setConfigValue(config, "terrarium_acme_dns_env", {});
197+
return "Disabled DNS-01 ACME";
198+
}
199+
200+
setConfigValue(config, "terrarium_acme_dns_provider", provider);
201+
setConfigValue(config, "terrarium_acme_dns_env", parseDnsCredentials(options.credentials));
202+
return `Enabled DNS-01 ACME provider ${provider}`;
203+
}
204+
205+
export async function setDnsProviderCmd(options: SetDnsProviderOptions, actions: ReconcileActions): Promise<void> {
206+
const config = loadMutableConfig();
207+
const summary = applySetDnsProviderConfig(config, options);
208+
await persistAndReconcile(config, summary, actions);
209+
}
210+
152211
/** Imports the local YAML export into the dqlite-backed LXD project store. */
153212
export function configImportCmd(): void {
154213
importConfigFileToClusterStore(CONFIG_PATH, "terrariumctl config import");

0 commit comments

Comments
 (0)