add custom tunnel labels
This commit is contained in:
parent
531b3e841e
commit
e5e99abab4
97
README.md
97
README.md
|
|
@ -129,9 +129,98 @@ docker run -d \
|
|||
| `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_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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
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]"
|
||||
|
||||
This means the tunnel-server rejected the client's key. There are exactly two causes:
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ services:
|
|||
build:
|
||||
context: .
|
||||
target: server
|
||||
image: git.nixc.us/colin/better-argo-tunnels:production
|
||||
image: ghcr.io/leopere/better-argo-tunnels:production
|
||||
|
||||
tunnel-client:
|
||||
build:
|
||||
context: .
|
||||
target: client
|
||||
image: git.nixc.us/colin/better-argo-tunnels:client-production
|
||||
image: ghcr.io/leopere/better-argo-tunnels:client-production
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ networks:
|
|||
|
||||
services:
|
||||
tunnel-server:
|
||||
image: git.nixc.us/colin/better-argo-tunnels:production
|
||||
image: ghcr.io/leopere/better-argo-tunnels:production
|
||||
networks:
|
||||
- traefik
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -28,6 +29,88 @@ func envOr(key, fallback string) string {
|
|||
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.
|
||||
func cleanHost(s string) string {
|
||||
s = strings.ReplaceAll(s, "\x00", "")
|
||||
|
|
@ -37,10 +120,10 @@ func cleanHost(s string) string {
|
|||
}
|
||||
|
||||
// resolveBackendHost picks backend hostname. Priority:
|
||||
// 1) TUNNEL_HOST_FILE (path to a file containing hostname, e.g. Docker secret mount)
|
||||
// 2) TUNNEL_HOST
|
||||
// 3) TUNNEL_BACKEND_HOST (alias for stacks that reserve TUNNEL_HOST)
|
||||
// 4) 127.0.0.1
|
||||
// 1. TUNNEL_HOST_FILE (path to a file containing hostname, e.g. Docker secret mount)
|
||||
// 2. TUNNEL_HOST
|
||||
// 3. TUNNEL_BACKEND_HOST (alias for stacks that reserve TUNNEL_HOST)
|
||||
// 4. 127.0.0.1
|
||||
func resolveBackendHost() (host string, source string) {
|
||||
path := cleanHost(os.Getenv("TUNNEL_HOST_FILE"))
|
||||
if path != "" {
|
||||
|
|
@ -78,6 +161,13 @@ func main() {
|
|||
// Optional HTTP Basic Auth credentials for Traefik middleware.
|
||||
authUser := envOr("TUNNEL_AUTH_USER", "")
|
||||
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()
|
||||
localPortStr := cleanHost(os.Getenv("TUNNEL_PORT"))
|
||||
|
|
@ -124,7 +214,7 @@ func main() {
|
|||
log.Printf("Connected to %s", serverAddr)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 Let’s 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 |
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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}
|
||||
|
|
@ -18,6 +18,7 @@ type TunnelRequest struct {
|
|||
Domain string `json:"domain"`
|
||||
AuthUser string `json:"auth_user,omitempty"` // optional HTTP Basic Auth username
|
||||
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.
|
||||
|
|
@ -26,9 +27,16 @@ type TunnelRequest struct {
|
|||
// 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
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
ch.Close()
|
||||
|
|
@ -76,6 +84,9 @@ func sendMetadata(client *ssh.Client, domain, authUser, authPass string) error {
|
|||
} else {
|
||||
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.
|
||||
go func() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ type LabelManager struct {
|
|||
certResolver string // e.g. "letsencryptresolver"
|
||||
labels map[string]bool // track which tunnel keys we've added
|
||||
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.
|
||||
|
|
@ -41,6 +43,7 @@ func NewLabelManager(
|
|||
certResolver: certResolver,
|
||||
labels: 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.
|
||||
|
|
@ -103,7 +106,7 @@ func (lm *LabelManager) PurgeAllTunnelLabels() error {
|
|||
}
|
||||
|
||||
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 {
|
||||
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.
|
||||
// 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()
|
||||
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)
|
||||
middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey)
|
||||
|
||||
// Build the label-add flags for docker service update.
|
||||
labelArgs := []string{
|
||||
labelFlag(fmt.Sprintf("traefik.http.routers.%s.rule", routerName),
|
||||
fmt.Sprintf("Host(`%s`)", domain)),
|
||||
labelFlag(fmt.Sprintf("traefik.http.routers.%s.entrypoints", routerName),
|
||||
lm.entrypoint),
|
||||
labelFlag(fmt.Sprintf("traefik.http.routers.%s.tls", routerName),
|
||||
"true"),
|
||||
labelFlag(fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", routerName),
|
||||
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)),
|
||||
routerPrefix := fmt.Sprintf("traefik.http.routers.%s.", routerName)
|
||||
servicePrefix := fmt.Sprintf("traefik.http.services.%s.", serviceName)
|
||||
labels := map[string]string{
|
||||
routerPrefix + "rule": fmt.Sprintf("Host(`%s`)", domain),
|
||||
routerPrefix + "entrypoints": lm.entrypoint,
|
||||
routerPrefix + "tls": "true",
|
||||
routerPrefix + "tls.certresolver": lm.certResolver,
|
||||
routerPrefix + "service": serviceName,
|
||||
servicePrefix + "loadbalancer.server.port": fmt.Sprintf("%d", port),
|
||||
}
|
||||
customKeys := make([]string, 0, len(custom))
|
||||
|
||||
// If auth credentials are provided, add basicauth middleware labels.
|
||||
if authUser != "" && authPass != "" {
|
||||
authEnabled := authUser != "" && authPass != ""
|
||||
if authEnabled {
|
||||
htpasswd, err := generateHTPasswd(authUser, authPass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate htpasswd for %s: %w", domain, err)
|
||||
}
|
||||
labelArgs = append(labelArgs,
|
||||
labelFlag(
|
||||
fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName),
|
||||
htpasswd,
|
||||
),
|
||||
labelFlag(
|
||||
fmt.Sprintf("traefik.http.routers.%s.middlewares", routerName),
|
||||
middlewareName,
|
||||
),
|
||||
)
|
||||
lm.authLabels[tunKey] = true
|
||||
labels[fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName)] = htpasswd
|
||||
labels[routerPrefix+"middlewares"] = middlewareName
|
||||
log.Printf("BasicAuth middleware %s added for %s", middlewareName, domain)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("docker service update --label-add %s %s",
|
||||
strings.Join(labelArgs, " --label-add "), lm.serviceName)
|
||||
renderedCustom, err := renderCustomLabels(tunKey, domain, port, routerName, serviceName, middlewareName, custom)
|
||||
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 {
|
||||
return fmt.Errorf("add labels for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -282,9 +333,12 @@ func (lm *LabelManager) Remove(tunKey string) error {
|
|||
delete(lm.authLabels, tunKey)
|
||||
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",
|
||||
strings.Join(rmLabels, " --label-rm "), lm.serviceName)
|
||||
labelRmArgs(rmLabels), shellQuote(lm.serviceName))
|
||||
|
||||
if err := lm.runRemote(cmd); err != nil {
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type TunnelRequest struct {
|
|||
Domain string `json:"domain"`
|
||||
AuthUser string `json:"auth_user,omitempty"`
|
||||
AuthPass string `json:"auth_pass,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
// 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
|
||||
authUser string // optional HTTP Basic Auth username
|
||||
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.
|
||||
|
|
@ -172,7 +174,7 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string)
|
|||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
buf := make([]byte, 64*1024)
|
||||
n, err := ch.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
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 != "" {
|
||||
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.
|
||||
s.mu.Lock()
|
||||
|
|
@ -205,6 +210,7 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string)
|
|||
connKey: connKey,
|
||||
authUser: req.AuthUser,
|
||||
authPass: req.AuthPass,
|
||||
labels: req.Labels,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
|
|
|
|||
|
|
@ -143,11 +143,13 @@ func (s *SSHServer) handleForwardRequest(
|
|||
// Look up the metadata channel first, fall back to bind address.
|
||||
domain := fwdReq.BindAddr
|
||||
var authUser, authPass string
|
||||
var customLabels map[string]string
|
||||
s.mu.Lock()
|
||||
if meta, ok := s.activeTuns[connKey+"-meta"]; ok {
|
||||
domain = meta.domain
|
||||
authUser = meta.authUser
|
||||
authPass = meta.authPass
|
||||
customLabels = meta.labels
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
|
|
@ -194,6 +196,7 @@ func (s *SSHServer) handleForwardRequest(
|
|||
connKey: connKey,
|
||||
authUser: authUser,
|
||||
authPass: authPass,
|
||||
labels: customLabels,
|
||||
}
|
||||
|
||||
// If a previous tunnel exists for this domain (reconnect), tear it down
|
||||
|
|
@ -208,7 +211,7 @@ func (s *SSHServer) handleForwardRequest(
|
|||
s.mu.Unlock()
|
||||
|
||||
// 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)
|
||||
} else {
|
||||
log.Printf("Traefik labels added for %s -> port %d", domain, port)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Copy to /etc/tunnel-client.env and set values.
|
||||
# Required:
|
||||
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_KEY=/etc/tunnel-client/id_ed25519
|
||||
|
||||
|
|
@ -10,3 +12,9 @@ TUNNEL_KEY=/etc/tunnel-client/id_ed25519
|
|||
# TUNNEL_PORT=8080
|
||||
# TUNNEL_AUTH_USER=
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue