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_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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
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() {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue