Skip to content

Commit 5c00907

Browse files
author
Timothy Dodd
committed
feat: enhance subdomain assignment and add cert-manager configuration for wildcard TLS
1 parent e26ba1f commit 5c00907

9 files changed

Lines changed: 198 additions & 30 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@ from the .NET version).
1919
role, analogous to ngrok's cloud). Runs three listeners:
2020
- a WebSocket server (default `:8001`) that tunnel clients dial into. Clients
2121
**authenticate with a bearer API token** on the handshake; the host maps the
22-
token to a user and **assigns a subdomain dynamically** — the client's
23-
requested name (`Ntunl-Subdomain` header / `desiredSubdomain` config) if it's
24-
free, otherwise a random `word+number` — then pushes back the public URL.
25-
Subdomains are first-come-first-served per live connection; nothing is
26-
pre-reserved.
22+
token to a user and **assigns a subdomain** (`assignSubdomain` in tunnel.go),
23+
then pushes back the public URL. Two modes, set by `clientDomain.subDomains`:
24+
- **Pool** (list non-empty): assign a free name from that fixed list (honoring
25+
a requested one if it's a free pool member), reject when exhausted. Route a
26+
handful of subdomains to the host — no wildcard DNS needed.
27+
- **Fully dynamic** (empty list): requested name if free, else a random
28+
`word+number` (needs wildcard DNS to be routable).
29+
Either way it's first-come-first-served per live connection; nothing is
30+
pre-reserved per user.
2731
- a public HTTP server (default `:9200`) that receives outside traffic, maps the
2832
request's subdomain (`apple.domain.com` → client `apple`) to a connected
2933
client, forwards the request over the socket, relays the response, and counts

deploy/k3s/README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@ storage provisioner (for the SQLite volume) and **Traefik** (ingress).
2929
and `ingress.yaml` (the three hosts).
3030
3. **Set the admin password** in `secret.yaml` (or delete the Secret to fall back
3131
to `admin`/`admin`). It only applies on first run with an empty database.
32-
4. **DNS**: point `*.<domain>`, `tunnel.<domain>`, and `portal.<domain>` at your
33-
k3s ingress IP.
32+
4. **DNS**: one **DNS-only** wildcard A record covers everything (it also matches
33+
`portal.`/`tunnel.`):
34+
```
35+
*.<domain> A <traefik external IP> # kubectl -n kube-system get svc traefik -o wide
36+
```
37+
A records can't specify a port, so clients reach 443/80 and Traefik routes by
38+
hostname to ports 8002/8001/9200.
3439

3540
## Apply
3641

@@ -58,10 +63,10 @@ kubectl -n ntunl logs deploy/ntunl-host
5863
## TLS / HTTPS
5964

6065
Public URLs are `https://<sub>.<domain>` and clients use `wss://`, so you need a
61-
**wildcard certificate** at the ingress. The simplest path is cert-manager with a
62-
DNS-01 issuer producing `*.<domain>`; put it in `ntunl-wildcard-tls` and uncomment
63-
the `tls:` block in `ingress.yaml`. Until then, traffic is plain HTTP and clients
64-
must use `sslEnabled: false` against `tunnel.<domain>:80`.
66+
**wildcard certificate** at the ingress (the `tls:` block in `ingress.yaml` already
67+
references `ntunl-wildcard-tls`). Issue it with cert-manager + Cloudflare DNS-01 —
68+
see [`cert-manager/`](./cert-manager). Until the cert is issued, traffic is plain
69+
HTTP and clients must use `sslEnabled: false` against `<domain>:80`.
6570

6671
## Notes
6772

deploy/k3s/cert-manager/README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Wildcard TLS for the NTunl host (direct-to-IP, no Cloudflare proxy)
2+
3+
When you route `*.<domain>` straight at the k3s ingress IP with a **DNS-only**
4+
wildcard A record (instead of through a Cloudflare proxied tunnel), Cloudflare's
5+
edge certificate no longer applies — you must terminate TLS yourself. These
6+
manifests issue a Let's Encrypt **wildcard** cert via cert-manager + Cloudflare
7+
DNS-01 and hand it to the Ingress.
8+
9+
These are intentionally **not** part of the top-level `kustomization.yaml`: they
10+
depend on cert-manager's CRDs, which must be installed first.
11+
12+
## Steps
13+
14+
1. **Install cert-manager** (one-time, cluster-wide):
15+
```bash
16+
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
17+
```
18+
19+
2. **DNS** — point a wildcard at your ingress IP, **DNS-only / grey cloud**
20+
(proxied wildcards are Enterprise-only and won't work here):
21+
```
22+
*.example.com A <traefik external IP>
23+
```
24+
Find the IP: `kubectl -n kube-system get svc traefik -o wide`.
25+
26+
3. **Cloudflare API token** — create one (Zone:DNS:Edit + Zone:Zone:Read on your
27+
zone), put it in `cloudflare-api-token-secret.yaml`, and apply it into the
28+
`cert-manager` namespace.
29+
30+
4. **Edit the domain/email** in `clusterissuer.yaml` and `certificate.yaml`
31+
(replace `example.com` and the email). Tip: use the Let's Encrypt **staging**
32+
server first to avoid rate limits, then switch to prod.
33+
34+
5. **Apply**:
35+
```bash
36+
kubectl apply -f deploy/k3s/cert-manager/cloudflare-api-token-secret.yaml
37+
kubectl apply -f deploy/k3s/cert-manager/clusterissuer.yaml
38+
kubectl apply -f deploy/k3s/cert-manager/certificate.yaml
39+
```
40+
41+
6. **Verify** the cert was issued into the `ntunl-wildcard-tls` secret that the
42+
Ingress references:
43+
```bash
44+
kubectl -n ntunl get certificate ntunl-wildcard
45+
kubectl -n ntunl describe certificate ntunl-wildcard # watch for "Issued"
46+
```
47+
48+
The Ingress (`../ingress.yaml`) already references `secretName: ntunl-wildcard-tls`,
49+
so once the cert is `Ready`, `https://<sub>.example.com` and `wss://tunnel...`
50+
serve a valid certificate.
51+
52+
## Client config for this path
53+
54+
Because TLS is now terminated at Traefik on 443:
55+
56+
```jsonc
57+
{
58+
"sslEnabled": true,
59+
"allowInvalidCertificates": false, // real Let's Encrypt cert
60+
"ntunlAddress": "tunnel.example.com:443"
61+
}
62+
```
63+
64+
## Trade-off vs. Cloudflare Tunnel
65+
66+
Direct-to-IP exposes your node's public IP and you manage TLS yourself, but it's
67+
the only way to get wildcard/dynamic subdomains without a Cloudflare Enterprise
68+
plan. The portal and tunnel-handshake hostnames could still go through a
69+
Cloudflare proxied tunnel if you prefer to hide those two — but the wildcard
70+
proxy must be direct.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Wildcard certificate for the NTunl host. cert-manager writes the issued cert
2+
# into the ntunl-wildcard-tls secret, which the Ingress references.
3+
# The wildcard covers every client subdomain AND portal./tunnel. hostnames.
4+
apiVersion: cert-manager.io/v1
5+
kind: Certificate
6+
metadata:
7+
name: ntunl-wildcard
8+
namespace: ntunl
9+
spec:
10+
secretName: ntunl-wildcard-tls
11+
issuerRef:
12+
name: letsencrypt-cloudflare
13+
kind: ClusterIssuer
14+
commonName: "*.example.com" # CHANGE to your domain
15+
dnsNames:
16+
- "*.example.com" # CHANGE
17+
- "example.com" # CHANGE (apex, optional)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Cloudflare API token used by cert-manager's DNS-01 solver to prove ownership of
2+
# *.example.com (it creates temporary _acme-challenge TXT records).
3+
#
4+
# For a ClusterIssuer, cert-manager reads this secret from ITS OWN namespace
5+
# (default: cert-manager), NOT the ntunl namespace.
6+
#
7+
# Create the token in Cloudflare → My Profile → API Tokens with:
8+
# Permissions: Zone : DNS : Edit + Zone : Zone : Read
9+
# Zone Resources: Include your zone (example.com)
10+
apiVersion: v1
11+
kind: Secret
12+
metadata:
13+
name: cloudflare-api-token
14+
namespace: cert-manager
15+
type: Opaque
16+
stringData:
17+
api-token: <CLOUDFLARE_API_TOKEN>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Let's Encrypt issuer using Cloudflare DNS-01 (required for wildcard certs —
2+
# HTTP-01 cannot validate a wildcard). Switch the server to the staging URL while
3+
# testing to avoid rate limits:
4+
# https://acme-staging-v02.api.letsencrypt.org/directory
5+
apiVersion: cert-manager.io/v1
6+
kind: ClusterIssuer
7+
metadata:
8+
name: letsencrypt-cloudflare
9+
spec:
10+
acme:
11+
server: https://acme-v02.api.letsencrypt.org/directory
12+
email: you@example.com # CHANGE — Let's Encrypt notifications
13+
privateKeySecretRef:
14+
name: letsencrypt-cloudflare-account
15+
solvers:
16+
- dns01:
17+
cloudflare:
18+
apiTokenSecretRef:
19+
name: cloudflare-api-token
20+
key: api-token

deploy/k3s/configmap.yaml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@ metadata:
55
namespace: ntunl
66
data:
77
# Mounted at /etc/ntunl/host.json; the container reads it via NTUNL_CONFIG.
8-
# Set clientDomain.domain to YOUR domain — it is used to render public URLs and
9-
# must match the wildcard DNS / ingress host below.
8+
# Set clientDomain.domain to YOUR domain — used to render public URLs.
9+
#
10+
# clientDomain.subDomains controls subdomain assignment:
11+
# - Non-empty list -> POOL mode: the host hands out a free name from this list.
12+
# Route each one (apple/banana/cherry...) to the host; no wildcard DNS needed.
13+
# This is the easy path for Cloudflare Tunnel (one public hostname per name).
14+
# - Empty list -> fully dynamic random names (needs wildcard DNS / *.domain).
1015
host.json: |
1116
{
1217
"tunnelHost": {
1318
"hostName": "*",
1419
"port": 8001,
1520
"clientDomain": {
1621
"domain": "example.com",
17-
"subDomains": []
22+
"subDomains": ["apple", "banana", "cherry", "elderberry"]
1823
},
1924
"ssl": {
2025
"enabled": false,

deploy/k3s/ingress.yaml

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# Traefik ingress (bundled with k3s). Replace example.com with your domain.
22
#
3-
# DNS required:
4-
# *.example.com A/CNAME -> your k3s ingress IP (client tunnels + proxy)
5-
# tunnel.example.com A/CNAME -> your k3s ingress IP (client WebSocket)
6-
# portal.example.com A/CNAME -> your k3s ingress IP (admin/user portal)
3+
# DNS: a single DNS-only (grey-cloud) wildcard A record covers everything —
4+
# *.example.com A <k3s ingress/Traefik external IP>
5+
# It resolves portal./tunnel./ and every client subdomain. A records can't carry
6+
# a port, so clients use 443 (https/wss) / 80 (http); Traefik listens on those and
7+
# routes to ports 8002/8001/9200 by hostname. Find the IP with:
8+
# kubectl -n kube-system get svc traefik -o wide
79
#
810
# Note: the wildcard also matches tunnel./portal., but exact host rules take
911
# priority in Traefik. "tunnel" and "portal" route to those ports, not the proxy,
@@ -56,11 +58,10 @@ spec:
5658
name: ntunl-host
5759
port:
5860
number: 9200
59-
# Uncomment once you have a wildcard TLS cert in the secret below
60-
# (e.g. issued by cert-manager). Required for wss:// and https:// URLs.
61-
# tls:
62-
# - hosts:
63-
# - "*.example.com"
64-
# - portal.example.com
65-
# - tunnel.example.com
66-
# secretName: ntunl-wildcard-tls
61+
# Wildcard TLS issued by cert-manager (see cert-manager/). The wildcard cert
62+
# covers portal./tunnel./ and every client subdomain. Required for wss:// and
63+
# https:// URLs.
64+
tls:
65+
- hosts:
66+
- "*.example.com"
67+
secretName: ntunl-wildcard-tls

internal/host/tunnel.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,14 +175,43 @@ func (h *TunnelHost) handleConn(w http.ResponseWriter, r *http.Request) {
175175
h.readPump(ctx, client)
176176
}
177177

178-
// assignSubdomain picks a free subdomain. If the client requested one and it is
179-
// not currently in use, it gets it; otherwise a random free name is generated.
180-
// The chosen name is marked live via a placeholder so two connections can't claim
181-
// it concurrently.
178+
// assignSubdomain picks a free subdomain and marks it live via a placeholder so
179+
// two connections can't claim it concurrently. Behavior depends on whether a
180+
// fixed pool is configured (clientDomain.subDomains):
181+
//
182+
// - Pool mode: only names in the pool are routable (each is DNS-routed to the
183+
// host). A requested name is honored if it's a free pool member; otherwise the
184+
// first free pool member is assigned. Rejected when the pool is exhausted.
185+
// - Fully dynamic (empty pool): a requested name is used if free; otherwise a
186+
// random word+number is generated (requires wildcard DNS to be routable).
182187
func (h *TunnelHost) assignSubdomain(requested string) (string, error) {
183188
h.mu.Lock()
184189
defer h.mu.Unlock()
185190

191+
pool := h.settings.ClientDomain.SubDomains
192+
if len(pool) > 0 {
193+
// Prefer the requested name if it is a free member of the pool.
194+
if requested != "" {
195+
for _, name := range pool {
196+
if lower(name) == lower(requested) {
197+
if _, live := h.clients[lower(name)]; !live {
198+
h.clients[lower(name)] = nil // placeholder
199+
return name, nil
200+
}
201+
break // in pool but taken — fall through to next free member
202+
}
203+
}
204+
}
205+
for _, name := range pool {
206+
if _, live := h.clients[lower(name)]; !live {
207+
h.clients[lower(name)] = nil // placeholder
208+
return name, nil
209+
}
210+
}
211+
return "", errors.New("no subdomains available")
212+
}
213+
214+
// Fully dynamic: requested-if-free, else a random word+number.
186215
if requested != "" {
187216
if _, live := h.clients[lower(requested)]; live {
188217
return "", errors.New("requested subdomain already in use")

0 commit comments

Comments
 (0)