add custom tunnel labels

This commit is contained in:
Leopere 2026-07-06 10:14:56 -04:00
parent 531b3e841e
commit e5e99abab4
16 changed files with 806 additions and 60 deletions

View File

@ -129,9 +129,98 @@ docker run -d \
| `TUNNEL_KEY` | SSH private key — file path or raw PEM **(required)** | `/keys/id_ed25519` | | `TUNNEL_KEY` | SSH private key — file path or raw PEM **(required)** | `/keys/id_ed25519` |
| `TUNNEL_AUTH_USER` | Optional HTTP Basic auth username (tunnel ingress) | (none) | | `TUNNEL_AUTH_USER` | Optional HTTP Basic auth username (tunnel ingress) | (none) |
| `TUNNEL_AUTH_PASS` | Optional HTTP Basic auth password | (none) | | `TUNNEL_AUTH_PASS` | Optional HTTP Basic auth password | (none) |
| `TUNNEL_LABELS` | Optional custom Traefik labels for this tunnel, as JSON or newline/semicolon-separated `key=value` entries | (none) |
| `TUNNEL_LABELS_FILE` | Optional file containing custom labels in the same format as `TUNNEL_LABELS`; env labels override file labels | (none) |
Set `TUNNEL_AUTH_USER` / `TUNNEL_AUTH_PASS` via an env file; never commit passwords. Set `TUNNEL_AUTH_USER` / `TUNNEL_AUTH_PASS` via an env file; never commit passwords.
### Custom client labels
Clients can request extra Traefik labels for their own generated router, service,
or tunnel-scoped middlewares. Existing clients remain compatible: if
`TUNNEL_LABELS` and `TUNNEL_LABELS_FILE` are unset, the client sends the same
metadata as before.
Use placeholders so clients do not need to know the sanitized tunnel key:
| Placeholder | Expands to |
|---|---|
| `{router}` | Generated router name, e.g. `tunnel-myapp-example-com-router` |
| `{service}` | Generated service name, e.g. `tunnel-myapp-example-com-service` |
| `{middleware}` | Generated Basic Auth middleware name, e.g. `tunnel-myapp-example-com-auth` |
| `{tunKey}` | Sanitized tunnel key, e.g. `myapp-example-com` |
| `{domain}` | Requested `TUNNEL_DOMAIN` |
| `{port}` | Allocated tunnel-server port |
Example Compose block scalar:
```yaml
environment:
TUNNEL_LABELS: |
traefik.http.routers.{router}.priority=100
traefik.http.middlewares.tunnel-{tunKey}-headers.headers.customrequestheaders.X-Tunnel-Domain={domain}
traefik.http.routers.{router}.middlewares=tunnel-{tunKey}-headers
```
Example JSON:
```bash
TUNNEL_LABELS='{"traefik.http.routers.{router}.priority":"100"}'
```
Attach an existing middleware already available to Traefik, such as an Authentik
forward-auth middleware defined on ingress:
```yaml
environment:
TUNNEL_LABELS: |
traefik.http.routers.{router}.middlewares=authentik@docker
```
If the middleware is defined by the file provider instead, use its provider
suffix:
```yaml
environment:
TUNNEL_LABELS: |
traefik.http.routers.{router}.middlewares=authentik@file
```
Define a tunnel-scoped middleware by naming it `tunnel-{tunKey}-...`. For
example, to use the Trackleware Traefik plugin from `../trackleware` and inject
Notomo tracking into proxied HTML:
```yaml
environment:
TUNNEL_LABELS: |
traefik.http.routers.{router}.middlewares=tunnel-{tunKey}-trackleware
traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.notomoBaseURL=https://notomo.colinknapp.com
traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.siteIDMode=host
traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.siteIDPrefix=tw-
```
Use a fixed Trackleware site ID when a host-derived ID is not appropriate:
```yaml
environment:
TUNNEL_LABELS: |
traefik.http.routers.{router}.middlewares=tunnel-{tunKey}-trackleware
traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.notomoBaseURL=https://notomo.colinknapp.com
traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.siteIDMode=static
traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.siteID=my-static-site-id
```
Traefik must already have the Trackleware plugin installed/enabled on the ingress
side; `TUNNEL_LABELS` only attaches and configures middleware labels for the
client's generated tunnel route.
Custom labels are limited to this tunnel's generated router, generated service,
or middleware names beginning with `tunnel-{tunKey}-`. The server rejects custom
labels that try to replace managed route labels such as `rule`, `entrypoints`,
`tls`, `service`, or `loadbalancer.server.port`. If Basic Auth is enabled and a
custom `router.middlewares` label is supplied, the server keeps the auth
middleware and appends the requested middlewares.
### Credentials and .env ### Credentials and .env
For WebDAV stacks (macmini, logos) and any client using HTTP Basic Auth at the tunnel: copy `.env.example` to `.env`, set `TUNNEL_AUTH_USER` and `TUNNEL_AUTH_PASS`, and **never commit `.env`**. Compose files reference `${TUNNEL_AUTH_USER}` and `${TUNNEL_AUTH_PASS}`; Docker Compose reads `.env` from the project directory automatically. For WebDAV stacks (macmini, logos) and any client using HTTP Basic Auth at the tunnel: copy `.env.example` to `.env`, set `TUNNEL_AUTH_USER` and `TUNNEL_AUTH_PASS`, and **never commit `.env`**. Compose files reference `${TUNNEL_AUTH_USER}` and `${TUNNEL_AUTH_PASS}`; Docker Compose reads `.env` from the project directory automatically.
@ -270,6 +359,14 @@ without requiring a Dockerfile change.
If you intentionally need to clear every managed Traefik tunnel route, start the If you intentionally need to clear every managed Traefik tunnel route, start the
server with `PURGE_STALE_LABELS_ON_START=true` once, then disable it again. server with `PURGE_STALE_LABELS_ON_START=true` once, then disable it again.
### Split DNS: HTTP on `sms.taylor-co.com`, tunnel on `ingress.nixc.us`
If public DNS for a name points at the **SMS** host but Traefik and tunnel-server
run on **ingress**, browsers get 404 on the public URL. Use the same Pi keys; see
[deploy/sms/README.md](deploy/sms/README.md) for an optional SSH TCP relay
(`TUNNEL_SERVER=sms.taylor-co.com:2222`) and a small Caddy edge that proxies HTTPS
to `ingress` so `Host` rules still match.
### "unable to authenticate, attempted methods [none publickey]" ### "unable to authenticate, attempted methods [none publickey]"
This means the tunnel-server rejected the client's key. There are exactly two causes: This means the tunnel-server rejected the client's key. There are exactly two causes:

View File

@ -3,10 +3,10 @@ services:
build: build:
context: . context: .
target: server target: server
image: git.nixc.us/colin/better-argo-tunnels:production image: ghcr.io/leopere/better-argo-tunnels:production
tunnel-client: tunnel-client:
build: build:
context: . context: .
target: client target: client
image: git.nixc.us/colin/better-argo-tunnels:client-production image: ghcr.io/leopere/better-argo-tunnels:client-production

View File

@ -4,7 +4,7 @@ networks:
services: services:
tunnel-server: tunnel-server:
image: git.nixc.us/colin/better-argo-tunnels:production image: ghcr.io/leopere/better-argo-tunnels:production
networks: networks:
- traefik - traefik
environment: environment:

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -28,6 +29,88 @@ func envOr(key, fallback string) string {
return fallback return fallback
} }
func parseLabels(raw string) (map[string]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if strings.HasPrefix(raw, "{") {
labels := make(map[string]string)
if err := json.Unmarshal([]byte(raw), &labels); err != nil {
return nil, fmt.Errorf("parse JSON labels: %w", err)
}
return cleanLabels(labels), nil
}
labels := make(map[string]string)
for _, line := range strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == ';'
}) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("label %q must be key=value", line)
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" {
return nil, fmt.Errorf("label %q has an empty key", line)
}
labels[key] = value
}
return labels, nil
}
func cleanLabels(labels map[string]string) map[string]string {
cleaned := make(map[string]string, len(labels))
for key, value := range labels {
cleanKey := strings.TrimSpace(key)
cleanValue := strings.TrimSpace(value)
if cleanKey != "" {
cleaned[cleanKey] = cleanValue
}
}
if len(cleaned) == 0 {
return nil
}
return cleaned
}
func loadCustomLabels() (map[string]string, error) {
labels := make(map[string]string)
if path := cleanHost(os.Getenv("TUNNEL_LABELS_FILE")); path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read TUNNEL_LABELS_FILE %q: %w", path, err)
}
fileLabels, err := parseLabels(string(data))
if err != nil {
return nil, fmt.Errorf("parse TUNNEL_LABELS_FILE %q: %w", path, err)
}
for key, value := range fileLabels {
labels[key] = value
}
}
envLabels, err := parseLabels(os.Getenv("TUNNEL_LABELS"))
if err != nil {
return nil, fmt.Errorf("parse TUNNEL_LABELS: %w", err)
}
for key, value := range envLabels {
labels[key] = value
}
if len(labels) == 0 {
return nil, nil
}
return labels, nil
}
// cleanHost strips BOM, NULs, and all Unicode whitespace (including NBSP) from ends. // cleanHost strips BOM, NULs, and all Unicode whitespace (including NBSP) from ends.
func cleanHost(s string) string { func cleanHost(s string) string {
s = strings.ReplaceAll(s, "\x00", "") s = strings.ReplaceAll(s, "\x00", "")
@ -37,10 +120,10 @@ func cleanHost(s string) string {
} }
// resolveBackendHost picks backend hostname. Priority: // resolveBackendHost picks backend hostname. Priority:
// 1) TUNNEL_HOST_FILE (path to a file containing hostname, e.g. Docker secret mount) // 1. TUNNEL_HOST_FILE (path to a file containing hostname, e.g. Docker secret mount)
// 2) TUNNEL_HOST // 2. TUNNEL_HOST
// 3) TUNNEL_BACKEND_HOST (alias for stacks that reserve TUNNEL_HOST) // 3. TUNNEL_BACKEND_HOST (alias for stacks that reserve TUNNEL_HOST)
// 4) 127.0.0.1 // 4. 127.0.0.1
func resolveBackendHost() (host string, source string) { func resolveBackendHost() (host string, source string) {
path := cleanHost(os.Getenv("TUNNEL_HOST_FILE")) path := cleanHost(os.Getenv("TUNNEL_HOST_FILE"))
if path != "" { if path != "" {
@ -78,6 +161,13 @@ func main() {
// Optional HTTP Basic Auth credentials for Traefik middleware. // Optional HTTP Basic Auth credentials for Traefik middleware.
authUser := envOr("TUNNEL_AUTH_USER", "") authUser := envOr("TUNNEL_AUTH_USER", "")
authPass := envOr("TUNNEL_AUTH_PASS", "") authPass := envOr("TUNNEL_AUTH_PASS", "")
customLabels, err := loadCustomLabels()
if err != nil {
log.Fatalf("Invalid custom labels: %v", err)
}
if len(customLabels) > 0 {
log.Printf("Loaded %d custom tunnel label(s)", len(customLabels))
}
localHost, hostSrc := resolveBackendHost() localHost, hostSrc := resolveBackendHost()
localPortStr := cleanHost(os.Getenv("TUNNEL_PORT")) localPortStr := cleanHost(os.Getenv("TUNNEL_PORT"))
@ -124,7 +214,7 @@ func main() {
log.Printf("Connected to %s", serverAddr) log.Printf("Connected to %s", serverAddr)
// Set up the reverse tunnel (blocks until disconnected). // Set up the reverse tunnel (blocks until disconnected).
if err := client.SetupTunnel(sshClient, domain, localHost, localPort, authUser, authPass); err != nil { if err := client.SetupTunnel(sshClient, domain, localHost, localPort, authUser, authPass, customLabels); err != nil {
log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff) log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff)
} }

38
cmd/client/main_test.go Normal file
View File

@ -0,0 +1,38 @@
package main
import "testing"
func TestParseLabelsFromLines(t *testing.T) {
labels, err := parseLabels(`
traefik.http.routers.{router}.priority=100
# comment
traefik.http.routers.{router}.middlewares=tunnel-{tunKey}-headers
`)
if err != nil {
t.Fatalf("parseLabels: %v", err)
}
if labels["traefik.http.routers.{router}.priority"] != "100" {
t.Fatalf("expected priority label, got %#v", labels)
}
if labels["traefik.http.routers.{router}.middlewares"] != "tunnel-{tunKey}-headers" {
t.Fatalf("expected middlewares label, got %#v", labels)
}
}
func TestParseLabelsFromJSON(t *testing.T) {
labels, err := parseLabels(`{" traefik.http.routers.{router}.priority ":" 100 "}`)
if err != nil {
t.Fatalf("parseLabels: %v", err)
}
if labels["traefik.http.routers.{router}.priority"] != "100" {
t.Fatalf("expected trimmed JSON label, got %#v", labels)
}
}
func TestParseLabelsRejectsInvalidLine(t *testing.T) {
if _, err := parseLabels("not-a-label"); err == nil {
t.Fatalf("expected invalid label line to fail")
}
}

View File

@ -0,0 +1,20 @@
# Copy to Caddyfile and set INGRESS in http-edge.compose.yml (or export before run).
# Terminates TLS on the SMS host (ACME for your public hostnames) and forwards to
# Traefik on ingress, preserving Host so routes like king73.taylor-co.com still match.
#
# Duplicate a block for each Taylor hostname that resolves to the SMS public IP, or
# add more server blocks. Wildcards need DNS-01; see Caddy docs.
{
# email your@taylor-co.com
}
king73.taylor-co.com {
reverse_proxy https://{$INGRESS} {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}
transport http {
tls_insecure_skip_verify
}
}
}

67
deploy/sms/README.md Normal file
View File

@ -0,0 +1,67 @@
# Taylor `sms.taylor-co.com` (split DNS) layout
If HTTP DNS for a hostname points at the **SMS** public IP (e.g. 134.209.x.x) but the
`better-argo-tunnels` **tunnel-server** and **Traefik** live on `ingress.nixc.us`, you get
HTTP 404 on the public URL while the tunnel and routes are actually healthy on ingress.
## Install onto `sms` (one shot)
From this repo, with SSH to **ingress** and **sms**:
```bash
cd deploy/sms
export SMS_SSH=root@sms.taylor-co.com
export INGRESS_SSH=root@ingress.nixc.us
./bootstrap-to-sms.sh
```
This will:
- `scp` the tunnel `authorized_keys` from `ingress` (`/home/tunnel/.ssh/authorized_keys` by default).
- `scp` the compose files and `Caddyfile.example` to `/opt/taylor-sms-edge/`.
- **Append** (deduped) those public keys to `/root/.ssh/authorized_keys` on SMS, with a
timestamped backup of the previous file if one existed. Override with `SMS_KEYS_USER=…`.
To use a local file instead of pulling from ingress: `export AUTHORIZED_KEYS_LOCAL=/path/to/keys`.
To keep **the same Raspberry Pi tunnel clients and keys** and still use SMS in front:
## 1. SSH relay (optional)
Run `ssh-relay.compose.yml` on the SMS host so Pis can set:
```bash
TUNNEL_SERVER=sms.taylor-co.com:2222
```
Traffic is forwarded to the real `tunnel-server` (default `ingress.nixc.us:2222`).
- Authorize the **same** client public key on **ingress** (`authorized_keys` for the tunnel
user) — the relay is TCP-only, not another SSH key step.
## 2. HTTPS edge (this fixes 404 for names that resolve to SMS)
Run `http-edge.compose.yml` with a `Caddyfile` (see `Caddyfile.example`):
- Set `INGRESS` to the IP or stable hostname of the Traefik host (`ingress` public IP/hostname).
- Add one Caddy `server` block per Taylor hostname (e.g. `king73.taylor-co.com`).
Caddy gets Lets Encrypt certs for those names on the **SMS** machine and reverse-proxies to
`https://$INGRESS`, preserving `Host:`, so Traefik on ingress still matches `Host(\`...\`)` rules.
**Security note:** the proxy uses `tls_insecure_skip_verify` to the origin because the
connection is often IP-based. Restrict network path (same DC/VPC) or pin to a private IP if
you can.
## 3. Simpler alternative
Point the Taylor hostnames **A/AAAA** at `ingress.nixc.us` and skip the SMS HTTP edge.
## Files
| File | Role |
|------|------|
| `bootstrap-to-sms.sh` | `scp` keys from ingress + these files to `/opt/taylor-sms-edge`, merge keys on SMS |
| `ssh-relay.compose.yml` | TCP :2222 → upstream tunnel-server |
| `http-edge.compose.yml` | Caddy on :80 / :443 → Traefik (HTTPS upstream) |
| `Caddyfile.example` | Template for one hostname; duplicate blocks as needed |

88
deploy/sms/bootstrap-to-sms.sh Executable file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Copy tunnel authorized_keys from ingress to sms.taylor-co.com and install
# this directory's compose/Caddy example into /opt/taylor-sms-edge (or $INSTALL_DIR).
#
# export SMS_SSH=root@sms.taylor-co.com
# export INGRESS_SSH=root@ingress.nixc.us
# ./bootstrap-to-sms.sh
#
# First-time SSH: you may need to connect once to accept host keys, or set
# export SSH_EXTRA_OPTS='-o StrictHostKeyChecking=accept-new'
#
# Prereq: your SSH key can reach INGRESS and SMS as the given users.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
: "${INGRESS_SSH:=root@ingress.nixc.us}"
: "${INGRESS_AUTHORIZED_KEYS:=/home/tunnel/.ssh/authorized_keys}"
: "${SMS_SSH:=root@sms.taylor-co.com}"
# User on SMS whose ~/.ssh/authorized_keys receives the tunnel pubkeys (append, deduped)
: "${SMS_KEYS_USER:=root}"
: "${INSTALL_DIR:=/opt/taylor-sms-edge}"
# Optional: use a local file instead of scp from ingress
: "${AUTHORIZED_KEYS_LOCAL:=}"
SSH_OPTS=(${SSH_EXTRA_OPTS-})
tmp=
cleanup() { [[ -n "${tmp:-}" && -f "$tmp" ]] && rm -f "$tmp"; }
trap cleanup EXIT
if [[ -n "$AUTHORIZED_KEYS_LOCAL" ]]; then
tmp="$AUTHORIZED_KEYS_LOCAL"
[[ -f "$tmp" ]] || { echo "missing AUTHORIZED_KEYS_LOCAL=$tmp" >&2; exit 1; }
else
tmp="$(mktemp)"
echo "-> scp $INGRESS_SSH:$INGRESS_AUTHORIZED_KEYS"
scp "${SSH_OPTS[@]}" "$INGRESS_SSH:$INGRESS_AUTHORIZED_KEYS" "$tmp"
fi
echo "-> scp stack/Caddy to $SMS_SSH:$INSTALL_DIR"
ssh "${SSH_OPTS[@]}" "$SMS_SSH" "mkdir -p '$INSTALL_DIR'"
akeys_name="authorized_keys.from-ingress"
scp "${SSH_OPTS[@]}" \
"$SCRIPT_DIR/ssh-relay.compose.yml" \
"$SCRIPT_DIR/http-edge.compose.yml" \
"$SCRIPT_DIR/Caddyfile.example" \
"$SCRIPT_DIR/README.md" \
"$SMS_SSH:$INSTALL_DIR/"
scp "${SSH_OPTS[@]}" "$tmp" "$SMS_SSH:$INSTALL_DIR/$akeys_name"
echo "-> merge $akeys_name into $SMS_KEYS_USER on $SMS_SSH"
ssh "${SSH_OPTS[@]}" "$SMS_SSH" bash -s -- "$INSTALL_DIR" "$akeys_name" "$SMS_KEYS_USER" <<'REM'
set -euo pipefail
INST="$1"
F="$2"
USERX="$3"
H="$(getent passwd "$USERX" | cut -d: -f6)"
[[ -n "$H" ]] || { echo "no such user: $USERX" >&2; exit 1; }
install -d -m 700 "$H/.ssh"
KEYFILE="$H/.ssh/authorized_keys"
if [[ -f "$KEYFILE" ]]; then
cp -a "$KEYFILE" "$KEYFILE.bak.$(date +%Y%m%d%H%M%S)"
fi
touch "$KEYFILE"
chmod 600 "$KEYFILE"
# Append lines that are not already present
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" || "$line" == \#* ]] && continue
if ! grep -qxF -- "$line" "$KEYFILE" 2>/dev/null; then
echo "$line" >> "$KEYFILE"
fi
done < "$INST/$F"
# ownership
U="$(id -u "$USERX")" G="$(id -g "$USERX")"
chown -R "$U:$G" "$H/.ssh"
echo "-> installed keys for $USERX; backup may exist beside $KEYFILE"
REM
echo "-> done. On SMS:"
echo " cd $INSTALL_DIR && cp -n Caddyfile.example Caddyfile && \${EDITOR:-vi} Caddyfile"
echo " export INGRESS=138.197.167.216 RELAY_UPSTREAM=ingress.nixc.us:2222"
echo " docker compose -f http-edge.compose.yml up -d"
echo " docker compose -f ssh-relay.compose.yml up -d"
echo

View File

@ -0,0 +1,29 @@
# HTTPS edge on sms.taylor-co.com: forward public traffic to Traefik on ingress
# (same place tunnel-server registers Host() routes). Stops 404s when A/AAAA for
# those names point at SMS while the tunnel and labels remain on ingress.
#
# Prereq: Caddyfile (start from Caddyfile.example) lists each hostname.
# export INGRESS=138.197.167.216
# cp Caddyfile.example Caddyfile
# docker compose -f http-edge.compose.yml up -d
#
# ACME: Caddy will obtain public certs for hostnames in Caddyfile (HTTP-01 to this host).
services:
sms-http-edge:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
INGRESS: ${INGRESS:-138.197.167.216}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:

View File

@ -0,0 +1,27 @@
# SSH relay: expose tunnel-server on sms.taylor-co.com:2222 while the real
# tunnel-server stays on ingress (or another host). Same Pi keys and
# authorized_keys as today — only TUNNEL_SERVER changes.
#
# Usage on the SMS host:
# export RELAY_UPSTREAM=ingress.nixc.us:2222
# docker compose -f ssh-relay.compose.yml up -d
#
# Pis: TUNNEL_SERVER=sms.taylor-co.com:2222
services:
tunnel-ssh-relay:
image: alpine:3.21
restart: unless-stopped
command:
- /bin/sh
- -c
- |
set -e
apk add --no-cache socat
UP="$${RELAY_UPSTREAM:-ingress.nixc.us:2222}"
echo "relay: 0.0.0.0:2222 -> $$UP"
exec socat TCP-LISTEN:2222,fork,reuseaddr TCP:$$UP
ports:
- "2222:2222"
environment:
RELAY_UPSTREAM: ${RELAY_UPSTREAM:-ingress.nixc.us:2222}

View File

@ -18,6 +18,7 @@ type TunnelRequest struct {
Domain string `json:"domain"` Domain string `json:"domain"`
AuthUser string `json:"auth_user,omitempty"` // optional HTTP Basic Auth username AuthUser string `json:"auth_user,omitempty"` // optional HTTP Basic Auth username
AuthPass string `json:"auth_pass,omitempty"` // optional HTTP Basic Auth password AuthPass string `json:"auth_pass,omitempty"` // optional HTTP Basic Auth password
Labels map[string]string `json:"labels,omitempty"` // optional custom Traefik labels
} }
// SetupTunnel sends domain metadata and establishes a reverse port forward. // SetupTunnel sends domain metadata and establishes a reverse port forward.
@ -26,9 +27,16 @@ type TunnelRequest struct {
// Backend TLS is detected dynamically: TLS is tried first; on failure, plain TCP is used. // Backend TLS is detected dynamically: TLS is tried first; on failure, plain TCP is used.
// authUser and authPass are optional; if both are non-empty, the server will // authUser and authPass are optional; if both are non-empty, the server will
// add a Traefik basicauth middleware in front of this tunnel. // add a Traefik basicauth middleware in front of this tunnel.
func SetupTunnel(client *ssh.Client, domain string, localHost string, localPort int, authUser, authPass string) error { func SetupTunnel(
client *ssh.Client,
domain string,
localHost string,
localPort int,
authUser, authPass string,
labels map[string]string,
) error {
// Step 1: Open custom channel to send domain metadata. // Step 1: Open custom channel to send domain metadata.
if err := sendMetadata(client, domain, authUser, authPass); err != nil { if err := sendMetadata(client, domain, authUser, authPass, labels); err != nil {
return fmt.Errorf("send metadata: %w", err) return fmt.Errorf("send metadata: %w", err)
} }
@ -53,13 +61,13 @@ func SetupTunnel(client *ssh.Client, domain string, localHost string, localPort
} }
// sendMetadata opens a custom channel and sends the tunnel request JSON. // sendMetadata opens a custom channel and sends the tunnel request JSON.
func sendMetadata(client *ssh.Client, domain, authUser, authPass string) error { func sendMetadata(client *ssh.Client, domain, authUser, authPass string, labels map[string]string) error {
ch, _, err := client.OpenChannel("tunnel-request", nil) ch, _, err := client.OpenChannel("tunnel-request", nil)
if err != nil { if err != nil {
return fmt.Errorf("open tunnel-request channel: %w", err) return fmt.Errorf("open tunnel-request channel: %w", err)
} }
req := TunnelRequest{Domain: domain, AuthUser: authUser, AuthPass: authPass} req := TunnelRequest{Domain: domain, AuthUser: authUser, AuthPass: authPass, Labels: labels}
data, err := json.Marshal(req) data, err := json.Marshal(req)
if err != nil { if err != nil {
ch.Close() ch.Close()
@ -76,6 +84,9 @@ func sendMetadata(client *ssh.Client, domain, authUser, authPass string) error {
} else { } else {
log.Printf("Sent tunnel metadata: domain=%s", domain) log.Printf("Sent tunnel metadata: domain=%s", domain)
} }
if len(labels) > 0 {
log.Printf("Sent %d custom tunnel label(s) for %s", len(labels), domain)
}
// Keep the channel open in a goroutine for disconnect detection. // Keep the channel open in a goroutine for disconnect detection.
go func() { go func() {

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"sort"
"strings" "strings"
"sync" "sync"
@ -23,6 +24,7 @@ type LabelManager struct {
certResolver string // e.g. "letsencryptresolver" certResolver string // e.g. "letsencryptresolver"
labels map[string]bool // track which tunnel keys we've added labels map[string]bool // track which tunnel keys we've added
authLabels map[string]bool // track which tunnel keys have auth middleware authLabels map[string]bool // track which tunnel keys have auth middleware
customLabels map[string][]string // track extra label keys added per tunnel
} }
// NewLabelManager creates a label manager that updates Swarm service labels via SSH. // NewLabelManager creates a label manager that updates Swarm service labels via SSH.
@ -41,6 +43,7 @@ func NewLabelManager(
certResolver: certResolver, certResolver: certResolver,
labels: make(map[string]bool), labels: make(map[string]bool),
authLabels: make(map[string]bool), authLabels: make(map[string]bool),
customLabels: make(map[string][]string),
} }
// Verify we can reach the Swarm manager and the service exists. // Verify we can reach the Swarm manager and the service exists.
@ -103,7 +106,7 @@ func (lm *LabelManager) PurgeAllTunnelLabels() error {
} }
rmCmd := fmt.Sprintf("docker service update --label-rm %s %s", rmCmd := fmt.Sprintf("docker service update --label-rm %s %s",
strings.Join(rmLabels, " --label-rm "), lm.serviceName) labelRmArgs(rmLabels), shellQuote(lm.serviceName))
if err := lm.runRemote(rmCmd); err != nil { if err := lm.runRemote(rmCmd); err != nil {
return fmt.Errorf("purge tunnel labels: %w", err) return fmt.Errorf("purge tunnel labels: %w", err)
} }
@ -193,7 +196,12 @@ func tunnelKeyFromLabel(label, prefix, suffix string) (string, bool) {
// Add adds Traefik routing labels to the Swarm service for a tunnel. // Add adds Traefik routing labels to the Swarm service for a tunnel.
// If authUser and authPass are non-empty, a basicauth middleware is also added. // If authUser and authPass are non-empty, a basicauth middleware is also added.
func (lm *LabelManager) Add(tunKey, domain string, port int, authUser, authPass string) error { func (lm *LabelManager) Add(
tunKey, domain string,
port int,
authUser, authPass string,
custom map[string]string,
) error {
lm.mu.Lock() lm.mu.Lock()
defer lm.mu.Unlock() defer lm.mu.Unlock()
@ -201,50 +209,93 @@ func (lm *LabelManager) Add(tunKey, domain string, port int, authUser, authPass
serviceName := fmt.Sprintf("tunnel-%s-service", tunKey) serviceName := fmt.Sprintf("tunnel-%s-service", tunKey)
middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey) middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey)
// Build the label-add flags for docker service update. routerPrefix := fmt.Sprintf("traefik.http.routers.%s.", routerName)
labelArgs := []string{ servicePrefix := fmt.Sprintf("traefik.http.services.%s.", serviceName)
labelFlag(fmt.Sprintf("traefik.http.routers.%s.rule", routerName), labels := map[string]string{
fmt.Sprintf("Host(`%s`)", domain)), routerPrefix + "rule": fmt.Sprintf("Host(`%s`)", domain),
labelFlag(fmt.Sprintf("traefik.http.routers.%s.entrypoints", routerName), routerPrefix + "entrypoints": lm.entrypoint,
lm.entrypoint), routerPrefix + "tls": "true",
labelFlag(fmt.Sprintf("traefik.http.routers.%s.tls", routerName), routerPrefix + "tls.certresolver": lm.certResolver,
"true"), routerPrefix + "service": serviceName,
labelFlag(fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", routerName), servicePrefix + "loadbalancer.server.port": fmt.Sprintf("%d", port),
lm.certResolver),
labelFlag(fmt.Sprintf("traefik.http.routers.%s.service", routerName),
serviceName),
labelFlag(fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", serviceName),
fmt.Sprintf("%d", port)),
} }
customKeys := make([]string, 0, len(custom))
// If auth credentials are provided, add basicauth middleware labels. // If auth credentials are provided, add basicauth middleware labels.
if authUser != "" && authPass != "" { authEnabled := authUser != "" && authPass != ""
if authEnabled {
htpasswd, err := generateHTPasswd(authUser, authPass) htpasswd, err := generateHTPasswd(authUser, authPass)
if err != nil { if err != nil {
return fmt.Errorf("generate htpasswd for %s: %w", domain, err) return fmt.Errorf("generate htpasswd for %s: %w", domain, err)
} }
labelArgs = append(labelArgs, labels[fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName)] = htpasswd
labelFlag( labels[routerPrefix+"middlewares"] = middlewareName
fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName),
htpasswd,
),
labelFlag(
fmt.Sprintf("traefik.http.routers.%s.middlewares", routerName),
middlewareName,
),
)
lm.authLabels[tunKey] = true
log.Printf("BasicAuth middleware %s added for %s", middlewareName, domain) log.Printf("BasicAuth middleware %s added for %s", middlewareName, domain)
} }
cmd := fmt.Sprintf("docker service update --label-add %s %s", renderedCustom, err := renderCustomLabels(tunKey, domain, port, routerName, serviceName, middlewareName, custom)
strings.Join(labelArgs, " --label-add "), lm.serviceName) if err != nil {
return err
}
for key, value := range renderedCustom {
if !isSafeLabelKey(key) {
return fmt.Errorf("custom label %q contains unsupported characters", key)
}
if !isAllowedCustomLabel(key, tunKey, routerName, serviceName) {
return fmt.Errorf("custom label %q is outside tunnel %s", key, tunKey)
}
if isProtectedCustomLabel(key, routerName, serviceName) {
return fmt.Errorf("custom label %q would replace a managed tunnel label", key)
}
if key == routerPrefix+"middlewares" && labels[key] != "" {
value = mergeMiddlewares(labels[key], value)
}
labels[key] = value
customKeys = append(customKeys, key)
}
staleLabels := staleLabelKeys(lm.customLabels[tunKey], labels)
if lm.authLabels[tunKey] && !authEnabled {
staleLabels = append(staleLabels, fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName))
if _, hasMiddlewares := labels[routerPrefix+"middlewares"]; !hasMiddlewares {
staleLabels = append(staleLabels, routerPrefix+"middlewares")
}
}
staleLabels = uniqueStrings(staleLabels)
sort.Strings(staleLabels)
labelArgs := make([]string, 0, len(labels))
for _, key := range sortedKeys(labels) {
labelArgs = append(labelArgs, labelFlag(key, labels[key]))
}
cmdParts := []string{"docker service update"}
for _, key := range staleLabels {
cmdParts = append(cmdParts, "--label-rm "+shellQuote(key))
}
for _, arg := range labelArgs {
cmdParts = append(cmdParts, "--label-add "+arg)
}
cmdParts = append(cmdParts, shellQuote(lm.serviceName))
cmd := strings.Join(cmdParts, " ")
if err := lm.runRemote(cmd); err != nil { if err := lm.runRemote(cmd); err != nil {
return fmt.Errorf("add labels for %s: %w", domain, err) return fmt.Errorf("add labels for %s: %w", domain, err)
} }
lm.labels[tunKey] = true lm.labels[tunKey] = true
if authEnabled {
lm.authLabels[tunKey] = true
} else {
delete(lm.authLabels, tunKey)
}
if len(customKeys) > 0 {
sort.Strings(customKeys)
lm.customLabels[tunKey] = customKeys
log.Printf("Added %d custom label(s) for %s", len(customKeys), domain)
} else {
delete(lm.customLabels, tunKey)
}
log.Printf("Added Swarm labels: %s -> %s:%d", domain, lm.serviceName, port) log.Printf("Added Swarm labels: %s -> %s:%d", domain, lm.serviceName, port)
return nil return nil
} }
@ -282,9 +333,12 @@ func (lm *LabelManager) Remove(tunKey string) error {
delete(lm.authLabels, tunKey) delete(lm.authLabels, tunKey)
log.Printf("Removing BasicAuth middleware %s", middlewareName) log.Printf("Removing BasicAuth middleware %s", middlewareName)
} }
rmLabels = append(rmLabels, lm.customLabels[tunKey]...)
delete(lm.customLabels, tunKey)
rmLabels = uniqueStrings(rmLabels)
cmd := fmt.Sprintf("docker service update --label-rm %s %s", cmd := fmt.Sprintf("docker service update --label-rm %s %s",
strings.Join(rmLabels, " --label-rm "), lm.serviceName) labelRmArgs(rmLabels), shellQuote(lm.serviceName))
if err := lm.runRemote(cmd); err != nil { if err := lm.runRemote(cmd); err != nil {
return fmt.Errorf("remove labels for %s: %w", tunKey, err) return fmt.Errorf("remove labels for %s: %w", tunKey, err)
@ -309,7 +363,134 @@ func generateHTPasswd(user, pass string) (string, error) {
// labelFlag formats a --label-add value, quoting properly for shell. // labelFlag formats a --label-add value, quoting properly for shell.
func labelFlag(key, value string) string { func labelFlag(key, value string) string {
return fmt.Sprintf("'%s=%s'", key, value) return shellQuote(fmt.Sprintf("%s=%s", key, value))
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func labelRmArgs(keys []string) string {
args := make([]string, 0, len(keys))
for _, key := range keys {
args = append(args, shellQuote(key))
}
return strings.Join(args, " --label-rm ")
}
func renderCustomLabels(
tunKey, domain string,
port int,
routerName, serviceName, middlewareName string,
custom map[string]string,
) (map[string]string, error) {
if len(custom) == 0 {
return nil, nil
}
replacer := strings.NewReplacer(
"{tunKey}", tunKey,
"{domain}", domain,
"{port}", fmt.Sprintf("%d", port),
"{router}", routerName,
"{service}", serviceName,
"{middleware}", middlewareName,
)
rendered := make(map[string]string, len(custom))
for rawKey, rawValue := range custom {
key := strings.TrimSpace(replacer.Replace(rawKey))
if key == "" {
return nil, fmt.Errorf("custom label has an empty key")
}
rendered[key] = strings.TrimSpace(replacer.Replace(rawValue))
}
return rendered, nil
}
func isAllowedCustomLabel(key, tunKey, routerName, serviceName string) bool {
return strings.HasPrefix(key, fmt.Sprintf("traefik.http.routers.%s.", routerName)) ||
strings.HasPrefix(key, fmt.Sprintf("traefik.http.services.%s.", serviceName)) ||
strings.HasPrefix(key, fmt.Sprintf("traefik.http.middlewares.tunnel-%s-", tunKey))
}
func isProtectedCustomLabel(key, routerName, serviceName string) bool {
protected := map[string]bool{
fmt.Sprintf("traefik.http.routers.%s.rule", routerName): true,
fmt.Sprintf("traefik.http.routers.%s.entrypoints", routerName): true,
fmt.Sprintf("traefik.http.routers.%s.tls", routerName): true,
fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", routerName): true,
fmt.Sprintf("traefik.http.routers.%s.service", routerName): true,
fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", serviceName): true,
}
return protected[key]
}
func isSafeLabelKey(key string) bool {
for _, r := range key {
if r >= 'a' && r <= 'z' {
continue
}
if r >= 'A' && r <= 'Z' {
continue
}
if r >= '0' && r <= '9' {
continue
}
if r == '.' || r == '-' || r == '_' {
continue
}
return false
}
return key != ""
}
func mergeMiddlewares(existing, extra string) string {
seen := make(map[string]bool)
var merged []string
for _, list := range []string{existing, extra} {
for _, item := range strings.Split(list, ",") {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
merged = append(merged, item)
}
}
return strings.Join(merged, ",")
}
func sortedKeys(values map[string]string) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
unique := make([]string, 0, len(values))
for _, value := range values {
if seen[value] {
continue
}
seen[value] = true
unique = append(unique, value)
}
return unique
}
func staleLabelKeys(oldKeys []string, currentLabels map[string]string) []string {
var stale []string
for _, key := range oldKeys {
if _, stillPresent := currentLabels[key]; !stillPresent {
stale = append(stale, key)
}
}
sort.Strings(stale)
return stale
} }
// runRemote executes a command on the Swarm manager via SSH. // runRemote executes a command on the Swarm manager via SSH.

View File

@ -37,3 +37,84 @@ func TestTunnelKeyFromLabelRejectsInvalidLabels(t *testing.T) {
} }
} }
} }
func TestRenderCustomLabelsReplacesPlaceholders(t *testing.T) {
labels, err := renderCustomLabels(
"app-example-com",
"app.example.com",
10042,
"tunnel-app-example-com-router",
"tunnel-app-example-com-service",
"tunnel-app-example-com-auth",
map[string]string{
"traefik.http.routers.{router}.priority": "100",
"traefik.http.middlewares.tunnel-{tunKey}-headers.headers.customrequestheaders.X-Tunnel-Domain": "{domain}",
},
)
if err != nil {
t.Fatalf("renderCustomLabels: %v", err)
}
if labels["traefik.http.routers.tunnel-app-example-com-router.priority"] != "100" {
t.Fatalf("expected router placeholder replacement, got %#v", labels)
}
if labels["traefik.http.middlewares.tunnel-app-example-com-headers.headers.customrequestheaders.X-Tunnel-Domain"] != "app.example.com" {
t.Fatalf("expected middleware/domain placeholder replacement, got %#v", labels)
}
}
func TestCustomLabelValidation(t *testing.T) {
tunKey := "app-example-com"
routerName := "tunnel-app-example-com-router"
serviceName := "tunnel-app-example-com-service"
allowed := []string{
"traefik.http.routers.tunnel-app-example-com-router.priority",
"traefik.http.services.tunnel-app-example-com-service.loadbalancer.passhostheader",
"traefik.http.middlewares.tunnel-app-example-com-headers.headers.customrequestheaders.X-Test",
}
for _, label := range allowed {
if !isAllowedCustomLabel(label, tunKey, routerName, serviceName) {
t.Fatalf("expected %q to be allowed", label)
}
}
if isAllowedCustomLabel("traefik.http.routers.other-router.priority", tunKey, routerName, serviceName) {
t.Fatalf("expected other router label to be rejected")
}
if !isProtectedCustomLabel("traefik.http.routers.tunnel-app-example-com-router.rule", routerName, serviceName) {
t.Fatalf("expected route rule to be protected")
}
if isProtectedCustomLabel("traefik.http.routers.tunnel-app-example-com-router.priority", routerName, serviceName) {
t.Fatalf("expected router priority to be customizable")
}
if isSafeLabelKey("traefik.http.routers.tunnel-app-example-com-router.priority;bad") {
t.Fatalf("expected shell metacharacter in label key to be rejected")
}
}
func TestMergeMiddlewaresDeduplicates(t *testing.T) {
got := mergeMiddlewares("auth,headers", "headers,compress")
want := "auth,headers,compress"
if got != want {
t.Fatalf("mergeMiddlewares() = %q, want %q", got, want)
}
}
func TestLabelFlagShellQuotesSingleQuotes(t *testing.T) {
got := labelFlag("test.label", "value'withquote")
want := "'test.label=value'\\''withquote'"
if got != want {
t.Fatalf("labelFlag() = %q, want %q", got, want)
}
}
func TestStaleLabelKeys(t *testing.T) {
got := staleLabelKeys(
[]string{"traefik.http.routers.tunnel-a-router.priority", "traefik.http.routers.tunnel-a-router.middlewares"},
map[string]string{"traefik.http.routers.tunnel-a-router.middlewares": "auth"},
)
if len(got) != 1 || got[0] != "traefik.http.routers.tunnel-a-router.priority" {
t.Fatalf("unexpected stale keys: %#v", got)
}
}

View File

@ -18,6 +18,7 @@ type TunnelRequest struct {
Domain string `json:"domain"` Domain string `json:"domain"`
AuthUser string `json:"auth_user,omitempty"` AuthUser string `json:"auth_user,omitempty"`
AuthPass string `json:"auth_pass,omitempty"` AuthPass string `json:"auth_pass,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
} }
// SSHServer handles incoming SSH connections and sets up reverse tunnels. // SSHServer handles incoming SSH connections and sets up reverse tunnels.
@ -37,6 +38,7 @@ type activeTunnel struct {
connKey string // tracks which SSH connection owns this tunnel connKey string // tracks which SSH connection owns this tunnel
authUser string // optional HTTP Basic Auth username authUser string // optional HTTP Basic Auth username
authPass string // optional HTTP Basic Auth password authPass string // optional HTTP Basic Auth password
labels map[string]string // optional custom Traefik labels
} }
// NewSSHServer creates a new SSH server with host key and authorized keys. // NewSSHServer creates a new SSH server with host key and authorized keys.
@ -172,7 +174,7 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string)
return return
} }
buf := make([]byte, 4096) buf := make([]byte, 64*1024)
n, err := ch.Read(buf) n, err := ch.Read(buf)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
log.Printf("failed to read tunnel metadata: %v", err) log.Printf("failed to read tunnel metadata: %v", err)
@ -197,6 +199,9 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string)
if req.AuthUser != "" && req.AuthPass != "" { if req.AuthUser != "" && req.AuthPass != "" {
log.Printf("Tunnel metadata includes basicauth for domain=%s", req.Domain) log.Printf("Tunnel metadata includes basicauth for domain=%s", req.Domain)
} }
if len(req.Labels) > 0 {
log.Printf("Tunnel metadata includes %d custom label(s) for domain=%s", len(req.Labels), req.Domain)
}
// Store domain mapping for this connection so forward handler can use it. // Store domain mapping for this connection so forward handler can use it.
s.mu.Lock() s.mu.Lock()
@ -205,6 +210,7 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string)
connKey: connKey, connKey: connKey,
authUser: req.AuthUser, authUser: req.AuthUser,
authPass: req.AuthPass, authPass: req.AuthPass,
labels: req.Labels,
} }
s.mu.Unlock() s.mu.Unlock()

View File

@ -143,11 +143,13 @@ func (s *SSHServer) handleForwardRequest(
// Look up the metadata channel first, fall back to bind address. // Look up the metadata channel first, fall back to bind address.
domain := fwdReq.BindAddr domain := fwdReq.BindAddr
var authUser, authPass string var authUser, authPass string
var customLabels map[string]string
s.mu.Lock() s.mu.Lock()
if meta, ok := s.activeTuns[connKey+"-meta"]; ok { if meta, ok := s.activeTuns[connKey+"-meta"]; ok {
domain = meta.domain domain = meta.domain
authUser = meta.authUser authUser = meta.authUser
authPass = meta.authPass authPass = meta.authPass
customLabels = meta.labels
} }
s.mu.Unlock() s.mu.Unlock()
@ -194,6 +196,7 @@ func (s *SSHServer) handleForwardRequest(
connKey: connKey, connKey: connKey,
authUser: authUser, authUser: authUser,
authPass: authPass, authPass: authPass,
labels: customLabels,
} }
// If a previous tunnel exists for this domain (reconnect), tear it down // If a previous tunnel exists for this domain (reconnect), tear it down
@ -208,7 +211,7 @@ func (s *SSHServer) handleForwardRequest(
s.mu.Unlock() s.mu.Unlock()
// Register Traefik labels (with optional basicauth middleware). // Register Traefik labels (with optional basicauth middleware).
if err := s.labels.Add(tunKey, domain, port, authUser, authPass); err != nil { if err := s.labels.Add(tunKey, domain, port, authUser, authPass, customLabels); err != nil {
log.Printf("WARN: failed to add Traefik labels for %s: %v", domain, err) log.Printf("WARN: failed to add Traefik labels for %s: %v", domain, err)
} else { } else {
log.Printf("Traefik labels added for %s -> port %d", domain, port) log.Printf("Traefik labels added for %s -> port %d", domain, port)

View File

@ -1,6 +1,8 @@
# Copy to /etc/tunnel-client.env and set values. # Copy to /etc/tunnel-client.env and set values.
# Required: # Required:
TUNNEL_SERVER=ingress.nixc.us:2222 TUNNEL_SERVER=ingress.nixc.us:2222
# Optional: if you run deploy/sms/ssh-relay on SMS, use the same public key in
# authorized_keys on ingress and: TUNNEL_SERVER=sms.taylor-co.com:2222
TUNNEL_DOMAIN=myapp.example.com TUNNEL_DOMAIN=myapp.example.com
TUNNEL_KEY=/etc/tunnel-client/id_ed25519 TUNNEL_KEY=/etc/tunnel-client/id_ed25519
@ -10,3 +12,9 @@ TUNNEL_KEY=/etc/tunnel-client/id_ed25519
# TUNNEL_PORT=8080 # TUNNEL_PORT=8080
# TUNNEL_AUTH_USER= # TUNNEL_AUTH_USER=
# TUNNEL_AUTH_PASS= # TUNNEL_AUTH_PASS=
# TUNNEL_LABELS=
# TUNNEL_LABELS_FILE=
#
# Custom labels can use placeholders such as {router}, {service}, {tunKey},
# {domain}, and {port}. Example:
# TUNNEL_LABELS=traefik.http.routers.{router}.priority=100