Clean repo: env-based auth, no credentials in compose or history
ci/woodpecker/push/woodpecker Pipeline was successful
Details
ci/woodpecker/push/woodpecker Pipeline was successful
Details
Made-with: Cursor
This commit is contained in:
commit
fd81852ea5
|
|
@ -0,0 +1,5 @@
|
|||
# Copy to .env, set values, and never commit .env.
|
||||
# Used by docker-compose-macmini.yml and docker-compose-logos.yml for tunnel HTTP Basic auth.
|
||||
|
||||
TUNNEL_AUTH_USER=
|
||||
TUNNEL_AUTH_PASS=
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Keys (never commit secrets)
|
||||
keys/
|
||||
|
||||
# Local credentials for compose (TUNNEL_AUTH_*, etc.)
|
||||
.env
|
||||
|
||||
# Local dev override (mounts real SSH keys)
|
||||
docker-compose.override.yml
|
||||
|
||||
# Macmini WebDAV (local key + data)
|
||||
macmini-tunnel
|
||||
macmini-tunnel.pub
|
||||
webdav-data/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
# Woodpecker CI Configuration for better-argo-tunnels
|
||||
#
|
||||
# SYNTAX NOTES:
|
||||
# - Environment variables from secrets MUST use $${VAR} syntax (double dollar)
|
||||
# - Single $ will be interpreted literally and won't expand variables
|
||||
|
||||
labels:
|
||||
location: manager
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
partial: false
|
||||
depth: 1
|
||||
|
||||
steps:
|
||||
# Build and test Go binaries
|
||||
test:
|
||||
name: test
|
||||
image: golang:1.24-alpine
|
||||
commands:
|
||||
- go version | cat
|
||||
- go vet ./...
|
||||
- go build ./cmd/server/
|
||||
- go build ./cmd/client/
|
||||
- echo "Build and vet passed"
|
||||
when:
|
||||
branch: main
|
||||
event: [push, pull_request]
|
||||
|
||||
# Build and Push Docker images for production (x86)
|
||||
build-push-production:
|
||||
name: build-push-production
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on: ["test"]
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USER
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- HOSTNAME=$(docker info --format "{{.Name}}")
|
||||
- echo "Building on $HOSTNAME"
|
||||
- echo "$${REGISTRY_PASSWORD}" | docker login -u "$${REGISTRY_USER}" --password-stdin git.nixc.us
|
||||
- apk add --no-cache git || true
|
||||
- export GIT_COMMIT=$${CI_COMMIT_SHA}
|
||||
- echo "Building GIT_COMMIT=$GIT_COMMIT"
|
||||
# Build server image
|
||||
- docker build --target server -t git.nixc.us/colin/better-argo-tunnels:production .
|
||||
- docker push git.nixc.us/colin/better-argo-tunnels:production
|
||||
# Build client image
|
||||
- docker build --target client -t git.nixc.us/colin/better-argo-tunnels:client-production .
|
||||
- docker push git.nixc.us/colin/better-argo-tunnels:client-production
|
||||
when:
|
||||
branch: main
|
||||
event: [push, cron]
|
||||
|
||||
# Smoke test - verify server binary starts
|
||||
smoke-production:
|
||||
name: smoke-production
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on: ["build-push-production"]
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USER
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- echo "$${REGISTRY_PASSWORD}" | docker login git.nixc.us -u "$${REGISTRY_USER}" --password-stdin
|
||||
- docker pull git.nixc.us/colin/better-argo-tunnels:production
|
||||
- docker rm -f tunnel-smoke || true
|
||||
# Smoke: verify the binary runs and prints startup log
|
||||
- mkdir -p /tmp/smoke-keys
|
||||
- ssh-keygen -t ed25519 -f /tmp/smoke-keys/host_key -N "" -q
|
||||
- ssh-keygen -t ed25519 -f /tmp/smoke-keys/client_key -N "" -q
|
||||
- cat /tmp/smoke-keys/client_key.pub > /tmp/smoke-keys/authorized_keys
|
||||
- |
|
||||
docker run -d --name tunnel-smoke \
|
||||
-e SSH_PORT=2222 \
|
||||
-e SSH_HOST_KEY=/keys/host_key \
|
||||
-e AUTHORIZED_KEYS=/keys/authorized_keys \
|
||||
-e TRAEFIK_SSH_HOST=127.0.0.1 \
|
||||
-e TRAEFIK_SSH_KEY=/keys/host_key \
|
||||
-e SWARM_SERVICE_NAME=smoke-test \
|
||||
-v /tmp/smoke-keys:/keys:ro \
|
||||
git.nixc.us/colin/better-argo-tunnels:production
|
||||
- sleep 3
|
||||
- docker logs tunnel-smoke 2>&1 | head -20
|
||||
- docker rm -f tunnel-smoke || true
|
||||
- rm -rf /tmp/smoke-keys
|
||||
- echo "Smoke test passed"
|
||||
when:
|
||||
branch: main
|
||||
event: [push, cron]
|
||||
|
||||
# Deploy to Swarm on ingress.nixc.us
|
||||
deploy-production:
|
||||
name: deploy-production
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
depends_on: ["test", "build-push-production", "smoke-production"]
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USER
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||
- HOSTNAME=$(docker info --format "{{.Name}}")
|
||||
- echo "Deploying on $HOSTNAME"
|
||||
- echo "$${REGISTRY_PASSWORD}" | docker login -u "$${REGISTRY_USER}" --password-stdin git.nixc.us
|
||||
# Clean up any leftover Docker secrets from previous deployments
|
||||
- docker secret rm tunnel_ssh_host_key 2>/dev/null || true
|
||||
- docker secret rm tunnel_authorized_keys 2>/dev/null || true
|
||||
- docker secret rm tunnel_traefik_deploy_key 2>/dev/null || true
|
||||
# Remove old stack
|
||||
- echo "Removing old stack..."
|
||||
- docker stack rm better-argo-tunnels || true
|
||||
- sleep 10
|
||||
# Verify host key files exist on the node
|
||||
- |
|
||||
echo "Verifying host key files..."; \
|
||||
docker run --rm -v /root/.ssh:/check:ro alpine sh -c \
|
||||
'ls -la /check/tunnel_host_key /check/authorized_keys /check/ca-userkey /check/ca-userkey-cert.pub' \
|
||||
|| { echo "ERROR: Required key files missing from /root/.ssh/ on host"; exit 1; }
|
||||
# Deploy stack (keys are bind-mounted from /root/.ssh/ on the host)
|
||||
- echo "Deploying stack..."
|
||||
- docker stack deploy --with-registry-auth -c ./stack.production.yml better-argo-tunnels
|
||||
when:
|
||||
branch: main
|
||||
event: [push, cron]
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build both binaries as static.
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /tunnel-server ./cmd/server/
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /tunnel-client ./cmd/client/
|
||||
|
||||
# --- Server image ---
|
||||
FROM alpine:3.21 AS server
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
COPY --from=builder /tunnel-server /usr/local/bin/tunnel-server
|
||||
|
||||
RUN mkdir -p /keys /etc/traefik/dynamic
|
||||
|
||||
EXPOSE 2222
|
||||
EXPOSE 10000-10100
|
||||
|
||||
ENTRYPOINT ["tunnel-server"]
|
||||
|
||||
# --- Client image ---
|
||||
FROM alpine:3.21 AS client
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
COPY --from=builder /tunnel-client /usr/local/bin/tunnel-client
|
||||
|
||||
RUN mkdir -p /keys
|
||||
|
||||
ENTRYPOINT ["tunnel-client"]
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
# Reverse SSH Tunnel Server for Traefik
|
||||
|
||||
A lightweight Go system that lets remote Docker hosts expose HTTP services through a central Traefik reverse proxy via SSH tunnels. The server SSHs into the Traefik ingress host to dynamically manage route configuration.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Remote Host Tunnel Server Traefik Host (ingress.nixc.us)
|
||||
+--------------+ +---------------------+ +---------------------+
|
||||
| tunnel-client| ---SSH tunnel---> | tunnel-server | | Traefik |
|
||||
| TUNNEL_KEY | | allocates port, | | watches dynamic/ |
|
||||
| TUNNEL_DOMAIN| | SSHs into Traefik |--->| routes HTTPS to |
|
||||
| TUNNEL_PORT | | host to write config | | tunnel-server ports |
|
||||
+--------------+ +---------------------+ +---------------------+
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
1. Client connects to tunnel-server via SSH using a private key
|
||||
2. Client sends domain metadata (`{"domain":"myapp.example.com"}`) over a custom channel
|
||||
3. Server allocates a port from the pool and sets up a reverse port forward
|
||||
4. Server SSHs into `ingress.nixc.us` and writes a Traefik dynamic config file
|
||||
5. Traefik detects the new config and routes HTTPS traffic to `tunnel-server:<port>`
|
||||
6. Traffic flows: Internet -> Traefik -> tunnel-server:port -> SSH tunnel -> client -> local service
|
||||
7. When the client disconnects, the config file is removed and the port is freed
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Generate SSH Keys
|
||||
|
||||
```bash
|
||||
mkdir -p keys
|
||||
|
||||
# Server host key (for the SSH server that clients connect to)
|
||||
ssh-keygen -t ed25519 -f keys/host_key -N ""
|
||||
|
||||
# Client key (for tunnel clients)
|
||||
ssh-keygen -t ed25519 -f keys/id_ed25519 -N ""
|
||||
|
||||
# Authorize the client (install this file as the tunnel user's authorized_keys on ingress.nixc.us)
|
||||
cat keys/id_ed25519.pub > keys/authorized_keys
|
||||
|
||||
# Deploy key for SSHing into the Traefik host
|
||||
# (use an existing key that has root access to ingress.nixc.us,
|
||||
# or generate one and add its .pub to the Traefik host's authorized_keys)
|
||||
cp ~/.ssh/ingress_deploy_key keys/traefik_deploy_key
|
||||
```
|
||||
|
||||
### 2. Enable Traefik File Provider
|
||||
|
||||
On `ingress.nixc.us`, ensure Traefik has the file provider enabled.
|
||||
If Traefik is run via Docker stack with CLI args, add:
|
||||
|
||||
```
|
||||
--providers.file.directory=/root/traefik/dynamic
|
||||
--providers.file.watch=true
|
||||
```
|
||||
|
||||
Or via SSH (non-interactive):
|
||||
|
||||
```bash
|
||||
ssh root@ingress.nixc.us "mkdir -p /root/traefik/dynamic"
|
||||
```
|
||||
|
||||
Then update your Traefik stack/service to mount the directory and add the CLI args.
|
||||
|
||||
### 3. Start the Tunnel Server
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 4. Run a Client
|
||||
|
||||
On any remote host where your service is running:
|
||||
|
||||
**Using a mounted key file:**
|
||||
```bash
|
||||
docker run -d \
|
||||
--name tunnel-client \
|
||||
-e TUNNEL_SERVER=ingress.nixc.us:2222 \
|
||||
-e TUNNEL_DOMAIN=myapp.example.com \
|
||||
-e TUNNEL_PORT=8080 \
|
||||
-e TUNNEL_KEY=/keys/id_ed25519 \
|
||||
-v /path/to/keys:/keys:ro \
|
||||
--network host \
|
||||
tunnel-client
|
||||
```
|
||||
|
||||
**Using raw PEM key content in an envvar:**
|
||||
```bash
|
||||
docker run -d \
|
||||
--name tunnel-client \
|
||||
-e TUNNEL_SERVER=ingress.nixc.us:2222 \
|
||||
-e TUNNEL_DOMAIN=myapp.example.com \
|
||||
-e TUNNEL_PORT=8080 \
|
||||
-e "TUNNEL_KEY=$(cat /path/to/id_ed25519)" \
|
||||
--network host \
|
||||
tunnel-client
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `SSH_PORT` | SSH listen port for tunnel clients | `2222` |
|
||||
| `PORT_RANGE_START` | First allocatable tunnel port | `10000` |
|
||||
| `PORT_RANGE_END` | Last allocatable tunnel port | `10100` |
|
||||
| `SSH_HOST_KEY` | Path to SSH host private key | `/keys/host_key` |
|
||||
| `AUTHORIZED_KEYS` | Path to authorized_keys file (tunnel user’s keys from ingress.nixc.us) | `/keys/authorized_keys` |
|
||||
| `TRAEFIK_SSH_HOST` | Traefik host to SSH into **(required)** | - |
|
||||
| `TRAEFIK_SSH_USER` | SSH user on the Traefik host | `root` |
|
||||
| `TRAEFIK_SSH_KEY` | SSH key for Traefik host (path or PEM) **(required)** | - |
|
||||
| `TRAEFIK_CONFIG_DIR` | Remote path for dynamic configs | `/root/traefik/dynamic` |
|
||||
| `TRAEFIK_ENTRYPOINT` | Traefik entrypoint name | `websecure` |
|
||||
| `TRAEFIK_CERT_RESOLVER` | Traefik TLS cert resolver | `letsencryptresolver` |
|
||||
|
||||
### Client
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `TUNNEL_SERVER` | Server host:port **(required)** | - |
|
||||
| `TUNNEL_DOMAIN` | Public domain to expose **(required)** | - |
|
||||
| `TUNNEL_PORT` | Local port of your service | `8080` |
|
||||
| `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) |
|
||||
|
||||
Set `TUNNEL_AUTH_USER` / `TUNNEL_AUTH_PASS` via an env file; never commit passwords.
|
||||
|
||||
### 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.
|
||||
|
||||
#### WebDAV stacks (macmini / logos)
|
||||
|
||||
- **Macmini:** `docker compose -f docker-compose-macmini.yml up -d --build` — ensure `.env` has tunnel auth set.
|
||||
- **Logos:** `docker compose -f docker-compose-logos.yml -p logos up -d --build` — same `.env` or separate; images/SVG-only uploads.
|
||||
|
||||
## Generated Traefik Config
|
||||
|
||||
When a client connects requesting `myapp.example.com`, the server writes this to the Traefik host:
|
||||
|
||||
```yaml
|
||||
# /root/traefik/dynamic/tunnel-myapp-example-com.yml
|
||||
http:
|
||||
routers:
|
||||
tunnel-myapp-example-com-router:
|
||||
rule: "Host(`myapp.example.com`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencryptresolver
|
||||
service: tunnel-myapp-example-com-service
|
||||
services:
|
||||
tunnel-myapp-example-com-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://tunnel-server:10042"
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Build both binaries locally (commit and push these for remote deployment)
|
||||
go build -o tunnel-server ./cmd/server/
|
||||
go build -o tunnel-client ./cmd/client/
|
||||
|
||||
# Build Docker images
|
||||
docker compose build # server image
|
||||
docker build --target client -t tunnel-client . # client image
|
||||
```
|
||||
|
||||
## Binaries and systemd (bare metal)
|
||||
|
||||
The repo ships compiled `tunnel-server` and `tunnel-client` for remote hosts that run without Docker. Use the included systemd units under `systemd/`.
|
||||
|
||||
**Keys:** Do not reuse the host’s SSH keys or share one key between hosts or tunnels. Generate a dedicated ed25519 key per tunnel (or per host). Add that key’s **public** half to the **tunnel** user’s `authorized_keys` on **ingress.nixc.us** (the reverse tunnel server).
|
||||
|
||||
**Multiple tunnels:** Run one systemd instance per tunnel (different env file and optional unit name), e.g. `tunnel-client@app1.service` and `tunnel-client@app2.service` each with their own env and key.
|
||||
|
||||
### Install from git.nixc.us (HTTPS raw)
|
||||
|
||||
From a host with curl, install the binary and systemd unit directly from the repo (replace `main` with your branch if needed):
|
||||
|
||||
```bash
|
||||
REPO=https://git.nixc.us/colin/better-argo-tunnels/raw/branch/main
|
||||
sudo curl -o /usr/local/bin/tunnel-client -L "$REPO/client"
|
||||
sudo chmod +x /usr/local/bin/tunnel-client
|
||||
sudo curl -o /etc/systemd/system/tunnel-client.service -L "$REPO/systemd/tunnel-client.service"
|
||||
```
|
||||
|
||||
Then add a dedicated key for this tunnel, env file, and authorize on the server:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/tunnel-client
|
||||
sudo ssh-keygen -t ed25519 -f /etc/tunnel-client/id_ed25519 -N ""
|
||||
# Add the public key to the tunnel user's authorized_keys on ingress.nixc.us:
|
||||
sudo cat /etc/tunnel-client/id_ed25519.pub
|
||||
# On ingress.nixc.us as tunnel user: append that line to ~tunnel/.ssh/authorized_keys
|
||||
sudo cp systemd/tunnel-client.env.example /etc/tunnel-client.env # or curl from $REPO/systemd/tunnel-client.env.example
|
||||
sudo edit /etc/tunnel-client.env # set TUNNEL_SERVER, TUNNEL_DOMAIN, TUNNEL_KEY=/etc/tunnel-client/id_ed25519
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now tunnel-client
|
||||
```
|
||||
|
||||
### Client on a remote host (clone or copy)
|
||||
|
||||
1. Install the binary (from repo clone or raw URL above; repo has `client`/`server`):
|
||||
|
||||
```bash
|
||||
sudo cp client /usr/local/bin/tunnel-client && sudo chmod +x /usr/local/bin/tunnel-client
|
||||
```
|
||||
|
||||
2. Copy the systemd unit and create env file:
|
||||
|
||||
```bash
|
||||
sudo cp systemd/tunnel-client.service /etc/systemd/system/
|
||||
sudo cp systemd/tunnel-client.env.example /etc/tunnel-client.env
|
||||
sudo edit /etc/tunnel-client.env # set TUNNEL_SERVER, TUNNEL_DOMAIN, TUNNEL_KEY
|
||||
```
|
||||
|
||||
3. Use a **dedicated** ed25519 key for this tunnel (not the host’s keys). Put the private key on the host (e.g. `/etc/tunnel-client/id_ed25519`) and set `TUNNEL_KEY` in env. Add the matching **public** key to the **tunnel** user’s `~/.ssh/authorized_keys` on **ingress.nixc.us**.
|
||||
|
||||
4. Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now tunnel-client
|
||||
sudo journalctl -u tunnel-client -f
|
||||
```
|
||||
|
||||
For a second tunnel on the same host, use a separate env and key (e.g. `/etc/tunnel-client-app2.env`, `/etc/tunnel-client/app2_id_ed25519`) and a second unit (e.g. copy to `tunnel-client-app2.service` with `EnvironmentFile=/etc/tunnel-client-app2.env`).
|
||||
|
||||
### Server (optional, bare metal)
|
||||
|
||||
If you run the tunnel server without Docker:
|
||||
|
||||
1. Install binary and keys under e.g. `/etc/tunnel-server/` (host_key, authorized_keys from the tunnel user on ingress.nixc.us, traefik_deploy_key).
|
||||
2. Copy `systemd/tunnel-server.service` to `/etc/systemd/system/` and `systemd/tunnel-server.env.example` to `/etc/tunnel-server.env`. Set `TRAEFIK_SSH_HOST`, `TRAEFIK_SSH_KEY`, and paths to keys.
|
||||
3. `systemctl enable --now tunnel-server`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "unable to authenticate, attempted methods [none publickey]"
|
||||
|
||||
This means the tunnel-server rejected the client's key. There are exactly two causes:
|
||||
|
||||
**1. The client's public key is not in `authorized_keys`.**
|
||||
|
||||
The server reads `/home/tunnel/.ssh/authorized_keys` on `ingress.nixc.us`. Every client key must have its `.pub` contents in that file. To check and fix:
|
||||
|
||||
```bash
|
||||
# See what keys the server knows about:
|
||||
ssh root@ingress.nixc.us "cat /home/tunnel/.ssh/authorized_keys"
|
||||
|
||||
# Add a missing client key:
|
||||
ssh root@ingress.nixc.us "cat >> /home/tunnel/.ssh/authorized_keys" < YOUR_CLIENT_KEY.pub
|
||||
```
|
||||
|
||||
**2. The tunnel-server was not restarted after adding the key.**
|
||||
|
||||
The tunnel-server loads `authorized_keys` into memory **once at startup**. Editing the file on disk does nothing until the container restarts. After adding a key, always restart:
|
||||
|
||||
```bash
|
||||
ssh root@ingress.nixc.us "docker service update --force better-argo-tunnels_tunnel-server"
|
||||
```
|
||||
|
||||
Then restart the client so it reconnects immediately instead of waiting for its backoff timer.
|
||||
|
||||
### Checklist for a new tunnel client
|
||||
|
||||
1. Generate an ed25519 key (or reuse an existing one).
|
||||
2. Append the `.pub` to `/home/tunnel/.ssh/authorized_keys` on `ingress.nixc.us`.
|
||||
3. Restart the tunnel-server: `docker service update --force better-argo-tunnels_tunnel-server`.
|
||||
4. Start the client container with the private key mounted and `TUNNEL_KEY` pointing at it.
|
||||
|
||||
If all four steps happen, the tunnel will connect. If any step is skipped, it won't.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Only clients whose public keys are in the tunnel user’s `authorized_keys` on ingress.nixc.us can connect
|
||||
- The server uses a stable host key for client verification
|
||||
- SSH tunnels encrypt all traffic between client and server
|
||||
- The server authenticates to the Traefik host with a separate deploy key
|
||||
- Traefik handles TLS termination with automatic Let's Encrypt certificates
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/nixc/reverse-ssh-traefik/internal/client"
|
||||
)
|
||||
|
||||
func envRequired(key string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
log.Fatalf("Required environment variable %s is not set", key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Println("tunnel-client starting")
|
||||
|
||||
serverAddr := envRequired("TUNNEL_SERVER")
|
||||
domain := envRequired("TUNNEL_DOMAIN")
|
||||
keyPath := envOr("TUNNEL_KEY", "/keys/id_ed25519")
|
||||
|
||||
// Optional HTTP Basic Auth credentials for Traefik middleware.
|
||||
authUser := envOr("TUNNEL_AUTH_USER", "")
|
||||
authPass := envOr("TUNNEL_AUTH_PASS", "")
|
||||
|
||||
localHost := envOr("TUNNEL_HOST", "127.0.0.1")
|
||||
localPortStr := envOr("TUNNEL_PORT", "8080")
|
||||
localPort, err := strconv.Atoi(localPortStr)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid TUNNEL_PORT=%q: %v", localPortStr, err)
|
||||
}
|
||||
|
||||
// Load the private key.
|
||||
signer, err := client.LoadPrivateKey(keyPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load private key: %v", err)
|
||||
}
|
||||
log.Printf("Loaded key from %s", keyPath)
|
||||
|
||||
// Reconnect loop.
|
||||
backoff := time.Second
|
||||
maxBackoff := 30 * time.Second
|
||||
|
||||
for {
|
||||
if authUser != "" {
|
||||
log.Printf("Connecting to %s (domain=%s, local=%s:%d, basicauth=enabled)", serverAddr, domain, localHost, localPort)
|
||||
} else {
|
||||
log.Printf("Connecting to %s (domain=%s, local=%s:%d)", serverAddr, domain, localHost, localPort)
|
||||
}
|
||||
|
||||
sshClient, err := client.Connect(serverAddr, signer)
|
||||
if err != nil {
|
||||
log.Printf("Connection failed: %v (retry in %s)", err, backoff)
|
||||
time.Sleep(backoff)
|
||||
backoff = min(backoff*2, maxBackoff)
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset backoff on successful connection.
|
||||
backoff = time.Second
|
||||
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 {
|
||||
log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff)
|
||||
}
|
||||
|
||||
sshClient.Close()
|
||||
time.Sleep(backoff)
|
||||
backoff = min(backoff*2, maxBackoff)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"github.com/nixc/reverse-ssh-traefik/internal/server"
|
||||
)
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envRequired(key string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
log.Fatalf("Required environment variable %s is not set", key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
log.Printf("WARN: invalid %s=%q, using default %d", key, v, fallback)
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Println("tunnel-server starting")
|
||||
|
||||
// SSH server config (for accepting tunnel clients).
|
||||
sshPort := envOr("SSH_PORT", "2222")
|
||||
hostKeyPath := envOr("SSH_HOST_KEY", "/keys/host_key")
|
||||
authKeysPath := envOr("AUTHORIZED_KEYS", "/keys/authorized_keys")
|
||||
portStart := envInt("PORT_RANGE_START", 10000)
|
||||
portEnd := envInt("PORT_RANGE_END", 10100)
|
||||
|
||||
// Swarm manager SSH config (for updating service labels).
|
||||
traefikHost := envRequired("TRAEFIK_SSH_HOST")
|
||||
traefikUser := envOr("TRAEFIK_SSH_USER", "root")
|
||||
traefikKey := envRequired("TRAEFIK_SSH_KEY")
|
||||
serviceName := envOr("SWARM_SERVICE_NAME", "better-argo-tunnels_tunnel-server")
|
||||
entrypoint := envOr("TRAEFIK_ENTRYPOINT", "websecure")
|
||||
certResolver := envOr("TRAEFIK_CERT_RESOLVER", "letsencryptresolver")
|
||||
|
||||
// Load the SSH key for connecting to the Swarm manager.
|
||||
traefikSigner, err := server.LoadSigner(traefikKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load Traefik SSH key: %v", err)
|
||||
}
|
||||
log.Printf("Loaded Swarm manager SSH key")
|
||||
|
||||
// Initialize port pool.
|
||||
pool := server.NewPortPool(portStart, portEnd)
|
||||
log.Printf("Port pool: %d-%d (%d ports)", portStart, portEnd, portEnd-portStart+1)
|
||||
|
||||
// Initialize label manager (Swarm service update via SSH).
|
||||
labels, err := server.NewLabelManager(
|
||||
traefikHost, traefikUser, traefikSigner,
|
||||
serviceName, entrypoint, certResolver,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to init label manager: %v", err)
|
||||
}
|
||||
defer labels.Close()
|
||||
|
||||
// Initialize SSH server for tunnel clients.
|
||||
sshSrv, err := server.NewSSHServer(hostKeyPath, authKeysPath, pool, labels)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to init SSH server: %v", err)
|
||||
}
|
||||
|
||||
// Handle graceful shutdown.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
log.Printf("Received signal %s, shutting down", sig)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// Start SSH server.
|
||||
addr := "0.0.0.0:" + sshPort
|
||||
if err := sshSrv.ListenAndServe(addr); err != nil {
|
||||
log.Fatalf("SSH server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
// errImagesOnly is returned when upload is rejected by image-only filter.
|
||||
var errImagesOnly = errors.New("webdav: only image and SVG files allowed (.svg, .png, .jpg, .jpeg, .gif, .webp, .ico, .bmp, .avif)")
|
||||
|
||||
// allowedImageExtensions are the only extensions permitted for upload when images-only mode is on.
|
||||
var allowedImageExtensions = map[string]bool{
|
||||
".svg": true, ".png": true, ".jpg": true, ".jpeg": true,
|
||||
".gif": true, ".webp": true, ".ico": true, ".bmp": true, ".avif": true,
|
||||
}
|
||||
|
||||
// imageOnlyFS wraps a webdav.FileSystem and rejects PUT/copy of files that are not images or SVG.
|
||||
type imageOnlyFS struct {
|
||||
webdav.FileSystem
|
||||
}
|
||||
|
||||
func (f *imageOnlyFS) OpenFile(ctx context.Context, name string, flags int, perm os.FileMode) (webdav.File, error) {
|
||||
if flags&(os.O_CREATE|os.O_WRONLY|os.O_RDWR) != 0 {
|
||||
ext := strings.ToLower(path.Ext(path.Clean(name)))
|
||||
if ext == "" || !allowedImageExtensions[ext] {
|
||||
return nil, errImagesOnly
|
||||
}
|
||||
}
|
||||
return f.FileSystem.OpenFile(ctx, name, flags, perm)
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
const (
|
||||
rootDir = "/data"
|
||||
addr = ":80"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := os.MkdirAll(rootDir, 0o777); err != nil {
|
||||
log.Fatalf("mkdir %s: %v", rootDir, err)
|
||||
}
|
||||
abs, err := filepath.Abs(rootDir)
|
||||
if err != nil {
|
||||
log.Fatalf("abs %s: %v", rootDir, err)
|
||||
}
|
||||
|
||||
var fs webdav.FileSystem = webdav.Dir(abs)
|
||||
if os.Getenv("WEBDAV_IMAGES_ONLY") == "1" {
|
||||
fs = &imageOnlyFS{FileSystem: fs}
|
||||
log.Print("WebDAV images-only mode: only image and SVG uploads allowed")
|
||||
}
|
||||
|
||||
h := &webdav.Handler{
|
||||
FileSystem: fs,
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
}
|
||||
// Wrap so GET / returns a simple page instead of 403 for directory
|
||||
http.Handle("/", &rootGETWrapper{Handler: h})
|
||||
|
||||
log.Printf("WebDAV listening on %s (root %s)", addr, abs)
|
||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// rootGETWrapper serves a minimal HTML page for GET / and passes everything else to WebDAV.
|
||||
type rootGETWrapper struct {
|
||||
*webdav.Handler
|
||||
}
|
||||
|
||||
func (w *rootGETWrapper) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && (r.URL.Path == "" || r.URL.Path == "/") {
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
host := r.Host
|
||||
rw.Write([]byte(`<!DOCTYPE html><html><head><title>WebDAV</title></head><body><h1>WebDAV</h1>
|
||||
<h2>Mac</h2><p>Finder → Go → Connect to Server… (⌘K) → <code>https://` + host + `</code></p>
|
||||
<h2>Windows</h2><p>File Explorer → right-click This PC → Map network drive → Choose a drive letter → Folder: <code>https://` + host + `</code> → Finish (use the same login when prompted).</p>
|
||||
<p>Or: File Explorer → This PC → Computer tab → Map network drive.</p></body></html>`))
|
||||
return
|
||||
}
|
||||
w.Handler.ServeHTTP(rw, r)
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Logos WebDAV stack — https://logos.nixc.us (images/SVG uploads only).
|
||||
# Server (ingress) must be configured for this domain and key.
|
||||
#
|
||||
# Auth: set TUNNEL_AUTH_USER and TUNNEL_AUTH_PASS in .env (see .env.example); never commit .env.
|
||||
# Deploy (separate from macmini): docker compose -f docker-compose-logos.yml -p logos up -d --build
|
||||
#
|
||||
# Connect in Finder: Go → Connect to Server → https://logos.nixc.us
|
||||
# Only image and SVG files can be uploaded (company logos).
|
||||
#
|
||||
services:
|
||||
webdav:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: webdav/Dockerfile
|
||||
restart: always
|
||||
environment:
|
||||
WEBDAV_IMAGES_ONLY: "1"
|
||||
volumes:
|
||||
- ${HOME}/dev/logos:/data
|
||||
|
||||
tunnel-client:
|
||||
image: git.nixc.us/colin/better-argo-tunnels:client-production-arm64
|
||||
restart: always
|
||||
environment:
|
||||
TUNNEL_SERVER: "ingress.nixc.us:2222"
|
||||
TUNNEL_DOMAIN: "logos.nixc.us"
|
||||
TUNNEL_PORT: "80"
|
||||
TUNNEL_KEY: "/keys/client_key"
|
||||
TUNNEL_AUTH_USER: "${TUNNEL_AUTH_USER}"
|
||||
TUNNEL_AUTH_PASS: "${TUNNEL_AUTH_PASS}"
|
||||
volumes:
|
||||
- ~/.ssh/ca-userkey:/keys/client_key:ro
|
||||
depends_on:
|
||||
- webdav
|
||||
network_mode: "service:webdav"
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Macmini WebDAV stack — exposes https://macmini.nixc.us (auth at tunnel).
|
||||
# Server (ingress) is assumed configured for this domain and key.
|
||||
#
|
||||
# Auth: HTTP Basic at the tunnel. Set TUNNEL_AUTH_USER and TUNNEL_AUTH_PASS in .env (see .env.example); never commit .env.
|
||||
# Connect in Finder: Go → Connect to Server → https://macmini.nixc.us
|
||||
#
|
||||
# What works:
|
||||
# - WebDAV: no auth in app; uploads go to ~/dev/piconfigurator/bin. Rebuild after app changes: --build.
|
||||
# - Tunnel: uses ~/.ssh/ca-userkey (same key as all other tunnel clients).
|
||||
# - network_mode: service:webdav so tunnel forwards to localhost:80 inside the webdav container.
|
||||
#
|
||||
services:
|
||||
webdav:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: webdav/Dockerfile
|
||||
restart: always
|
||||
volumes:
|
||||
- ${HOME}/dev/piconfigurator/bin:/data
|
||||
|
||||
tunnel-client:
|
||||
image: git.nixc.us/colin/better-argo-tunnels:client-production-arm64
|
||||
restart: always
|
||||
environment:
|
||||
TUNNEL_SERVER: "ingress.nixc.us:2222"
|
||||
TUNNEL_DOMAIN: "macmini.nixc.us"
|
||||
TUNNEL_PORT: "80"
|
||||
TUNNEL_KEY: "/keys/client_key"
|
||||
TUNNEL_AUTH_USER: "${TUNNEL_AUTH_USER}"
|
||||
TUNNEL_AUTH_PASS: "${TUNNEL_AUTH_PASS}"
|
||||
volumes:
|
||||
- ~/.ssh/ca-userkey:/keys/client_key:ro
|
||||
depends_on:
|
||||
- webdav
|
||||
network_mode: "service:webdav"
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
services:
|
||||
tunnel-server:
|
||||
build:
|
||||
context: .
|
||||
target: server
|
||||
image: git.nixc.us/colin/better-argo-tunnels:production
|
||||
|
||||
tunnel-client:
|
||||
build:
|
||||
context: .
|
||||
target: client
|
||||
image: git.nixc.us/colin/better-argo-tunnels:client-production
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Test stack: nginx HTTP server + tunnel client (local CI only).
|
||||
# Usage: docker compose -f docker-compose.test.yml up --build
|
||||
# Exposes: testrst.nixc.us -> nginx. Auth defaults to test/test if .env not set.
|
||||
services:
|
||||
test-http:
|
||||
image: nginx:alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- ./test-index.html:/usr/share/nginx/html/index.html:ro
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
test-tunnel-client:
|
||||
build:
|
||||
context: .
|
||||
target: client
|
||||
restart: always
|
||||
environment:
|
||||
TUNNEL_SERVER: "ingress.nixc.us:2222"
|
||||
TUNNEL_DOMAIN: "testrst.nixc.us"
|
||||
TUNNEL_PORT: "80"
|
||||
TUNNEL_KEY: "/keys/client_key"
|
||||
TUNNEL_AUTH_USER: "${TUNNEL_AUTH_USER:-test}"
|
||||
TUNNEL_AUTH_PASS: "${TUNNEL_AUTH_PASS:-test}"
|
||||
volumes:
|
||||
- ~/.ssh/ca-userkey:/keys/client_key:ro
|
||||
depends_on:
|
||||
- test-http
|
||||
# Share network namespace with test-http so 127.0.0.1:80 reaches nginx
|
||||
network_mode: "service:test-http"
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
services:
|
||||
tunnel-server:
|
||||
build:
|
||||
context: .
|
||||
target: server
|
||||
image: git.nixc.us/colin/better-argo-tunnels:production
|
||||
|
||||
tunnel-client:
|
||||
build:
|
||||
context: .
|
||||
target: client
|
||||
image: git.nixc.us/colin/better-argo-tunnels:client-production
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
module github.com/nixc/reverse-ssh-traefik
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.13
|
||||
|
||||
require golang.org/x/crypto v0.48.0
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// LoadPrivateKey loads an SSH private key from a file path or raw PEM content.
|
||||
func LoadPrivateKey(keyOrPath string) (ssh.Signer, error) {
|
||||
var keyBytes []byte
|
||||
|
||||
if isFilePath(keyOrPath) {
|
||||
data, err := os.ReadFile(keyOrPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read key file %s: %w", keyOrPath, err)
|
||||
}
|
||||
keyBytes = data
|
||||
} else {
|
||||
keyBytes = []byte(keyOrPath)
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func isFilePath(v string) bool {
|
||||
if strings.HasPrefix(v, "/") || strings.HasPrefix(v, "./") || strings.HasPrefix(v, "~") {
|
||||
return true
|
||||
}
|
||||
return !strings.Contains(v, "-----BEGIN")
|
||||
}
|
||||
|
||||
// Connect establishes an SSH connection to the tunnel server.
|
||||
func Connect(addr string, signer ssh.Signer) (*ssh.Client, error) {
|
||||
config := &ssh.ClientConfig{
|
||||
User: "tunnel",
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// TunnelRequest is the metadata sent to the server on the tunnel-request channel.
|
||||
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
|
||||
}
|
||||
|
||||
// SetupTunnel sends domain metadata and establishes a reverse port forward.
|
||||
// The server will allocate a port and register Traefik routes for the domain.
|
||||
// localHost and localPort are the backend address (e.g. "127.0.0.1" or "192.168.0.2", 11001).
|
||||
// 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 {
|
||||
// Step 1: Open custom channel to send domain metadata.
|
||||
if err := sendMetadata(client, domain, authUser, authPass); err != nil {
|
||||
return fmt.Errorf("send metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Request reverse port forward.
|
||||
// We use the domain as the bind address so the server can associate it.
|
||||
listener, err := client.Listen("tcp", fmt.Sprintf("%s:0", domain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reverse listen: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
log.Printf("Reverse tunnel established: %s -> %s:%d", domain, localHost, localPort)
|
||||
|
||||
// Step 3: Accept connections from the server and forward to local service.
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return fmt.Errorf("tunnel accept: %w", err)
|
||||
}
|
||||
go forwardToLocal(conn, localHost, localPort)
|
||||
}
|
||||
}
|
||||
|
||||
// sendMetadata opens a custom channel and sends the tunnel request JSON.
|
||||
func sendMetadata(client *ssh.Client, domain, authUser, authPass 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}
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
ch.Close()
|
||||
return fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
if _, err := ch.Write(data); err != nil {
|
||||
ch.Close()
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
|
||||
if authUser != "" {
|
||||
log.Printf("Sent tunnel metadata: domain=%s (with basicauth)", domain)
|
||||
} else {
|
||||
log.Printf("Sent tunnel metadata: domain=%s", domain)
|
||||
}
|
||||
|
||||
// Keep the channel open in a goroutine for disconnect detection.
|
||||
go func() {
|
||||
io.Copy(io.Discard, ch)
|
||||
log.Printf("Metadata channel closed for %s", domain)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// forwardToLocal connects an incoming tunnel connection to the backend.
|
||||
// It tries TLS first; if the backend does not speak TLS, it falls back to plain TCP.
|
||||
func forwardToLocal(remoteConn net.Conn, localHost string, localPort int) {
|
||||
defer remoteConn.Close()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", localHost, localPort)
|
||||
localConn, _ := dialBackend(addr, localHost)
|
||||
if localConn == nil {
|
||||
return
|
||||
}
|
||||
defer localConn.Close()
|
||||
|
||||
// Bidirectional copy.
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(localConn, remoteConn)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(remoteConn, localConn)
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// dialBackend tries TLS first; on failure (e.g. backend is plain HTTP), uses plain TCP.
|
||||
// Returns (conn, true) if TLS was used, (conn, false) if plain, (nil, false) on error.
|
||||
func dialBackend(addr, serverName string) (net.Conn, bool) {
|
||||
tlsConn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", addr, &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
if err == nil {
|
||||
return tlsConn, true
|
||||
}
|
||||
// Fall back to plain TCP (backend is HTTP or not TLS).
|
||||
plainConn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("failed to connect to backend at %s: %v", addr, err)
|
||||
return nil, false
|
||||
}
|
||||
return plainConn, false
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// LoadSigner loads an SSH private key from a file path or raw PEM content.
|
||||
// If <path>-cert.pub exists alongside the key, openssh-style cert auth is used.
|
||||
func LoadSigner(keyOrPath string) (ssh.Signer, error) {
|
||||
var keyBytes []byte
|
||||
|
||||
if isFilePath(keyOrPath) {
|
||||
data, err := os.ReadFile(keyOrPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read key file %s: %w", keyOrPath, err)
|
||||
}
|
||||
keyBytes = data
|
||||
} else {
|
||||
keyBytes = []byte(keyOrPath)
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
// If a companion -cert.pub exists, use it (same as openssh auto-loading).
|
||||
if isFilePath(keyOrPath) {
|
||||
certPath := keyOrPath + "-cert.pub"
|
||||
if certData, err := os.ReadFile(certPath); err == nil {
|
||||
pub, _, _, _, err := ssh.ParseAuthorizedKey(certData)
|
||||
if err == nil {
|
||||
if cert, ok := pub.(*ssh.Certificate); ok {
|
||||
if cs, err := ssh.NewCertSigner(cert, signer); err == nil {
|
||||
log.Printf("Auto-loaded %s", certPath)
|
||||
return cs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func isFilePath(v string) bool {
|
||||
if strings.HasPrefix(v, "/") || strings.HasPrefix(v, "./") || strings.HasPrefix(v, "~") {
|
||||
return true
|
||||
}
|
||||
return !strings.Contains(v, "-----BEGIN")
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// LabelManager manages Traefik routing labels on its own Swarm service
|
||||
// by SSHing into the Swarm manager and running docker service update.
|
||||
type LabelManager struct {
|
||||
mu sync.Mutex
|
||||
remoteHost string // Swarm manager, e.g. "ingress.nixc.us"
|
||||
remoteUser string // SSH user
|
||||
signer ssh.Signer
|
||||
serviceName string // Swarm service name, e.g. "better-argo-tunnels_tunnel-server"
|
||||
entrypoint string // e.g. "websecure"
|
||||
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
|
||||
}
|
||||
|
||||
// NewLabelManager creates a label manager that updates Swarm service labels via SSH.
|
||||
func NewLabelManager(
|
||||
remoteHost, remoteUser string,
|
||||
signer ssh.Signer,
|
||||
serviceName, entrypoint, certResolver string,
|
||||
) (*LabelManager, error) {
|
||||
|
||||
lm := &LabelManager{
|
||||
remoteHost: remoteHost,
|
||||
remoteUser: remoteUser,
|
||||
signer: signer,
|
||||
serviceName: serviceName,
|
||||
entrypoint: entrypoint,
|
||||
certResolver: certResolver,
|
||||
labels: make(map[string]bool),
|
||||
authLabels: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Verify we can reach the Swarm manager and the service exists.
|
||||
cmd := fmt.Sprintf("docker service inspect --format '{{.Spec.Name}}' %s", serviceName)
|
||||
if err := lm.runRemote(cmd); err != nil {
|
||||
log.Printf("WARN: could not verify service %s (may not exist yet): %v", serviceName, err)
|
||||
} else {
|
||||
log.Printf("Verified Swarm service: %s", serviceName)
|
||||
}
|
||||
|
||||
log.Printf("Label manager ready (host=%s, service=%s, ep=%s, resolver=%s)",
|
||||
remoteHost, serviceName, entrypoint, certResolver)
|
||||
|
||||
return lm, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
lm.mu.Lock()
|
||||
defer lm.mu.Unlock()
|
||||
|
||||
routerName := fmt.Sprintf("tunnel-%s-router", tunKey)
|
||||
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)),
|
||||
}
|
||||
|
||||
// If auth credentials are provided, add basicauth middleware labels.
|
||||
if authUser != "" && authPass != "" {
|
||||
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
|
||||
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)
|
||||
|
||||
if err := lm.runRemote(cmd); err != nil {
|
||||
return fmt.Errorf("add labels for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
lm.labels[tunKey] = true
|
||||
log.Printf("Added Swarm labels: %s -> %s:%d", domain, lm.serviceName, port)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes Traefik routing labels from the Swarm service for a tunnel.
|
||||
func (lm *LabelManager) Remove(tunKey string) error {
|
||||
lm.mu.Lock()
|
||||
defer lm.mu.Unlock()
|
||||
|
||||
if !lm.labels[tunKey] {
|
||||
return nil // nothing to remove
|
||||
}
|
||||
|
||||
routerName := fmt.Sprintf("tunnel-%s-router", tunKey)
|
||||
serviceName := fmt.Sprintf("tunnel-%s-service", tunKey)
|
||||
|
||||
middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey)
|
||||
|
||||
// Build the label-rm flags.
|
||||
rmLabels := []string{
|
||||
fmt.Sprintf("traefik.http.routers.%s.rule", routerName),
|
||||
fmt.Sprintf("traefik.http.routers.%s.entrypoints", routerName),
|
||||
fmt.Sprintf("traefik.http.routers.%s.tls", routerName),
|
||||
fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", routerName),
|
||||
fmt.Sprintf("traefik.http.routers.%s.service", routerName),
|
||||
fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", serviceName),
|
||||
}
|
||||
|
||||
// Remove auth middleware labels if they were added.
|
||||
if lm.authLabels[tunKey] {
|
||||
rmLabels = append(rmLabels,
|
||||
fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName),
|
||||
fmt.Sprintf("traefik.http.routers.%s.middlewares", routerName),
|
||||
)
|
||||
delete(lm.authLabels, tunKey)
|
||||
log.Printf("Removing BasicAuth middleware %s", middlewareName)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("docker service update --label-rm %s %s",
|
||||
strings.Join(rmLabels, " --label-rm "), lm.serviceName)
|
||||
|
||||
if err := lm.runRemote(cmd); err != nil {
|
||||
return fmt.Errorf("remove labels for %s: %w", tunKey, err)
|
||||
}
|
||||
|
||||
delete(lm.labels, tunKey)
|
||||
log.Printf("Removed Swarm labels for tunnel: %s", tunKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateHTPasswd creates a bcrypt-hashed htpasswd entry for Traefik basicauth.
|
||||
// The output format is user:$hash. Dollar signs are NOT doubled here because
|
||||
// we pass labels via docker service update with single-quoted values, which
|
||||
// preserves them literally. Doubling is only needed in compose files.
|
||||
func generateHTPasswd(user, pass string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bcrypt hash: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", user, string(hash)), nil
|
||||
}
|
||||
|
||||
// labelFlag formats a --label-add value, quoting properly for shell.
|
||||
func labelFlag(key, value string) string {
|
||||
return fmt.Sprintf("'%s=%s'", key, value)
|
||||
}
|
||||
|
||||
// runRemote executes a command on the Swarm manager via SSH.
|
||||
func (lm *LabelManager) runRemote(cmd string) error {
|
||||
addr := lm.remoteHost
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr = addr + ":22"
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: lm.remoteUser,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(lm.signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH dial %s: %w", addr, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.CombinedOutput(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote cmd failed: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close is a no-op — SSH connections are opened/closed per operation.
|
||||
func (lm *LabelManager) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// TunnelRequest is the metadata a client sends when opening a tunnel channel.
|
||||
type TunnelRequest struct {
|
||||
Domain string `json:"domain"`
|
||||
AuthUser string `json:"auth_user,omitempty"`
|
||||
AuthPass string `json:"auth_pass,omitempty"`
|
||||
}
|
||||
|
||||
// SSHServer handles incoming SSH connections and sets up reverse tunnels.
|
||||
type SSHServer struct {
|
||||
config *ssh.ServerConfig
|
||||
pool *PortPool
|
||||
labels *LabelManager
|
||||
mu sync.Mutex
|
||||
activeTuns map[string]*activeTunnel // keyed by sanitized domain
|
||||
}
|
||||
|
||||
type activeTunnel struct {
|
||||
domain string
|
||||
port int
|
||||
listener net.Listener
|
||||
done chan struct{}
|
||||
connKey string // tracks which SSH connection owns this tunnel
|
||||
authUser string // optional HTTP Basic Auth username
|
||||
authPass string // optional HTTP Basic Auth password
|
||||
}
|
||||
|
||||
// NewSSHServer creates a new SSH server with host key and authorized keys.
|
||||
func NewSSHServer(
|
||||
hostKeyPath, authorizedKeysPath string,
|
||||
pool *PortPool,
|
||||
labels *LabelManager,
|
||||
) (*SSHServer, error) {
|
||||
s := &SSHServer{
|
||||
pool: pool,
|
||||
labels: labels,
|
||||
activeTuns: make(map[string]*activeTunnel),
|
||||
}
|
||||
|
||||
config := &ssh.ServerConfig{
|
||||
PublicKeyCallback: s.buildAuthCallback(authorizedKeysPath),
|
||||
}
|
||||
|
||||
hostKeyBytes, err := os.ReadFile(hostKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read host key: %w", err)
|
||||
}
|
||||
|
||||
hostKey, err := ssh.ParsePrivateKey(hostKeyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse host key: %w", err)
|
||||
}
|
||||
config.AddHostKey(hostKey)
|
||||
|
||||
s.config = config
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// loadAuthorizedKeys reads the authorized_keys file and returns a set of
|
||||
// allowed public key fingerprints. Called on every auth attempt so that
|
||||
// newly-added keys take effect without restarting the server.
|
||||
func loadAuthorizedKeys(path string) map[string]bool {
|
||||
allowed := make(map[string]bool)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("WARN: cannot read authorized_keys at %s: %v", path, err)
|
||||
return allowed
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
pubKey, _, _, _, parseErr := ssh.ParseAuthorizedKey([]byte(line))
|
||||
if parseErr != nil {
|
||||
log.Printf("WARN: skipping bad authorized key: %v", parseErr)
|
||||
continue
|
||||
}
|
||||
allowed[string(pubKey.Marshal())] = true
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// buildAuthCallback returns a public key callback that re-reads the
|
||||
// authorized_keys file on every connection so new keys work immediately.
|
||||
func (s *SSHServer) buildAuthCallback(
|
||||
path string,
|
||||
) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
initial := loadAuthorizedKeys(path)
|
||||
log.Printf("Loaded %d authorized key(s)", len(initial))
|
||||
|
||||
return func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
allowed := loadAuthorizedKeys(path)
|
||||
if allowed[string(key.Marshal())] {
|
||||
return &ssh.Permissions{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown public key")
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe starts the SSH server on the given address.
|
||||
func (s *SSHServer) ListenAndServe(addr string) error {
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", addr, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
log.Printf("SSH server listening on %s", addr)
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
log.Printf("accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConn performs the SSH handshake and processes channels.
|
||||
func (s *SSHServer) handleConn(netConn net.Conn) {
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(netConn, s.config)
|
||||
if err != nil {
|
||||
log.Printf("SSH handshake failed from %s: %v", netConn.RemoteAddr(), err)
|
||||
netConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
connKey := sshConn.RemoteAddr().String()
|
||||
log.Printf("SSH connection from %s (%s)", connKey, sshConn.User())
|
||||
|
||||
go s.handleGlobalRequests(reqs, sshConn, connKey)
|
||||
|
||||
for newChan := range chans {
|
||||
switch newChan.ChannelType() {
|
||||
case "tunnel-request":
|
||||
go s.handleTunnelChannel(newChan, connKey)
|
||||
case "session":
|
||||
ch, _, chErr := newChan.Accept()
|
||||
if chErr == nil {
|
||||
ch.Close()
|
||||
}
|
||||
default:
|
||||
newChan.Reject(ssh.UnknownChannelType, "unsupported channel type")
|
||||
}
|
||||
}
|
||||
|
||||
s.cleanupConnection(connKey)
|
||||
log.Printf("SSH connection closed from %s", connKey)
|
||||
}
|
||||
|
||||
// handleTunnelChannel reads tunnel metadata from the custom channel.
|
||||
func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string) {
|
||||
ch, _, err := newChan.Accept()
|
||||
if err != nil {
|
||||
log.Printf("failed to accept tunnel channel: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
n, err := ch.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
log.Printf("failed to read tunnel metadata: %v", err)
|
||||
ch.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var req TunnelRequest
|
||||
if err := json.Unmarshal(buf[:n], &req); err != nil {
|
||||
log.Printf("invalid tunnel metadata: %v", err)
|
||||
ch.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if req.Domain == "" {
|
||||
ch.Close()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Tunnel metadata received: domain=%s (conn=%s)", req.Domain, connKey)
|
||||
|
||||
if req.AuthUser != "" && req.AuthPass != "" {
|
||||
log.Printf("Tunnel metadata includes basicauth for domain=%s", req.Domain)
|
||||
}
|
||||
|
||||
// Store domain mapping for this connection so forward handler can use it.
|
||||
s.mu.Lock()
|
||||
s.activeTuns[connKey+"-meta"] = &activeTunnel{
|
||||
domain: req.Domain,
|
||||
connKey: connKey,
|
||||
authUser: req.AuthUser,
|
||||
authPass: req.AuthPass,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Keep channel open as heartbeat / disconnect signal.
|
||||
io.Copy(io.Discard, ch)
|
||||
}
|
||||
|
||||
// SanitizeDomain converts a domain name into a safe label key.
|
||||
func SanitizeDomain(domain string) string {
|
||||
r := strings.NewReplacer(".", "-", ":", "-", "/", "-")
|
||||
return strings.ToLower(r.Replace(strings.TrimSpace(domain)))
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// PortPool manages a pool of available ports for reverse tunnels.
|
||||
type PortPool struct {
|
||||
mu sync.Mutex
|
||||
available map[int]bool
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
// NewPortPool creates a port pool for the given range [start, end].
|
||||
func NewPortPool(start, end int) *PortPool {
|
||||
available := make(map[int]bool, end-start+1)
|
||||
for p := start; p <= end; p++ {
|
||||
available[p] = true
|
||||
}
|
||||
return &PortPool{
|
||||
available: available,
|
||||
start: start,
|
||||
end: end,
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate claims an available port from the pool.
|
||||
func (pp *PortPool) Allocate() (int, error) {
|
||||
pp.mu.Lock()
|
||||
defer pp.mu.Unlock()
|
||||
|
||||
for port, free := range pp.available {
|
||||
if free {
|
||||
pp.available[port] = false
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no ports available in range %d-%d", pp.start, pp.end)
|
||||
}
|
||||
|
||||
// Release returns a port to the pool.
|
||||
func (pp *PortPool) Release(port int) {
|
||||
pp.mu.Lock()
|
||||
defer pp.mu.Unlock()
|
||||
pp.available[port] = true
|
||||
}
|
||||
|
||||
// tcpipForwardRequest matches the SSH tcpip-forward request payload.
|
||||
type tcpipForwardRequest struct {
|
||||
BindAddr string
|
||||
BindPort uint32
|
||||
}
|
||||
|
||||
// tcpipForwardResponse matches the SSH tcpip-forward response payload.
|
||||
type tcpipForwardResponse struct {
|
||||
BoundPort uint32
|
||||
}
|
||||
|
||||
// forwardedTCPPayload matches the SSH forwarded-tcpip channel data.
|
||||
type forwardedTCPPayload struct {
|
||||
Addr string
|
||||
Port uint32
|
||||
OriginAddr string
|
||||
OriginPort uint32
|
||||
}
|
||||
|
||||
// handleGlobalRequests processes SSH global requests (tcpip-forward).
|
||||
func (s *SSHServer) handleGlobalRequests(
|
||||
reqs <-chan *ssh.Request,
|
||||
sshConn *ssh.ServerConn,
|
||||
connKey string,
|
||||
) {
|
||||
for req := range reqs {
|
||||
switch req.Type {
|
||||
case "tcpip-forward":
|
||||
s.handleForwardRequest(req, sshConn, connKey)
|
||||
default:
|
||||
if req.WantReply {
|
||||
req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleForwardRequest handles a tcpip-forward global request.
|
||||
func (s *SSHServer) handleForwardRequest(
|
||||
req *ssh.Request,
|
||||
sshConn *ssh.ServerConn,
|
||||
connKey string,
|
||||
) {
|
||||
var fwdReq tcpipForwardRequest
|
||||
if err := ssh.Unmarshal(req.Payload, &fwdReq); err != nil {
|
||||
log.Printf("invalid tcpip-forward payload: %v", err)
|
||||
req.Reply(false, nil)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("tcpip-forward request: bind=%s:%d from %s", fwdReq.BindAddr, fwdReq.BindPort, connKey)
|
||||
|
||||
// Allocate a port from the pool.
|
||||
port, err := s.pool.Allocate()
|
||||
if err != nil {
|
||||
log.Printf("port allocation failed: %v", err)
|
||||
req.Reply(false, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Start a listener on the allocated port.
|
||||
listenAddr := fmt.Sprintf("0.0.0.0:%d", port)
|
||||
listener, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
log.Printf("failed to listen on %s: %v", listenAddr, err)
|
||||
s.pool.Release(port)
|
||||
req.Reply(false, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Reply with the bound port.
|
||||
resp := tcpipForwardResponse{BoundPort: uint32(port)}
|
||||
req.Reply(true, ssh.Marshal(&resp))
|
||||
log.Printf("Allocated port %d for forwarding (conn=%s)", port, connKey)
|
||||
|
||||
// Accept connections on the allocated port and forward through SSH.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
acceptForwardedConnections(listener, sshConn, fwdReq.BindAddr, uint32(port))
|
||||
}()
|
||||
|
||||
// Determine the domain for Traefik label registration.
|
||||
// Look up the metadata channel first, fall back to bind address.
|
||||
domain := fwdReq.BindAddr
|
||||
var authUser, authPass string
|
||||
s.mu.Lock()
|
||||
if meta, ok := s.activeTuns[connKey+"-meta"]; ok {
|
||||
domain = meta.domain
|
||||
authUser = meta.authUser
|
||||
authPass = meta.authPass
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
tunKey := SanitizeDomain(domain)
|
||||
if tunKey == "" {
|
||||
tunKey = fmt.Sprintf("port-%d", port)
|
||||
}
|
||||
|
||||
tun := &activeTunnel{
|
||||
domain: domain,
|
||||
port: port,
|
||||
listener: listener,
|
||||
done: done,
|
||||
connKey: connKey,
|
||||
authUser: authUser,
|
||||
authPass: authPass,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.activeTuns[tunKey] = tun
|
||||
s.mu.Unlock()
|
||||
|
||||
// Register Traefik labels (with optional basicauth middleware).
|
||||
if err := s.labels.Add(tunKey, domain, port, authUser, authPass); 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)
|
||||
}
|
||||
}
|
||||
|
||||
// acceptForwardedConnections accepts TCP connections and opens SSH channels.
|
||||
func acceptForwardedConnections(
|
||||
listener net.Listener,
|
||||
sshConn *ssh.ServerConn,
|
||||
bindAddr string,
|
||||
bindPort uint32,
|
||||
) {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return // listener closed
|
||||
}
|
||||
go forwardConnection(conn, sshConn, bindAddr, bindPort)
|
||||
}
|
||||
}
|
||||
|
||||
// forwardConnection forwards a single TCP connection through the SSH channel.
|
||||
func forwardConnection(
|
||||
conn net.Conn,
|
||||
sshConn *ssh.ServerConn,
|
||||
bindAddr string,
|
||||
bindPort uint32,
|
||||
) {
|
||||
defer conn.Close()
|
||||
|
||||
originAddr, originPortStr, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
var originPort int
|
||||
fmt.Sscanf(originPortStr, "%d", &originPort)
|
||||
|
||||
payload := forwardedTCPPayload{
|
||||
Addr: bindAddr,
|
||||
Port: bindPort,
|
||||
OriginAddr: originAddr,
|
||||
OriginPort: uint32(originPort),
|
||||
}
|
||||
|
||||
ch, reqs, err := sshConn.OpenChannel("forwarded-tcpip", ssh.Marshal(&payload))
|
||||
if err != nil {
|
||||
log.Printf("failed to open forwarded-tcpip channel: %v", err)
|
||||
return
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
// Bidirectional copy.
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(ch, conn)
|
||||
ch.CloseWrite()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(conn, ch)
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// cleanupConnection removes all tunnels associated with a closed SSH connection.
|
||||
func (s *SSHServer) cleanupConnection(connKey string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for key, tun := range s.activeTuns {
|
||||
if tun.connKey != connKey {
|
||||
continue
|
||||
}
|
||||
|
||||
if tun.listener != nil {
|
||||
tun.listener.Close()
|
||||
s.pool.Release(tun.port)
|
||||
}
|
||||
|
||||
if err := s.labels.Remove(key); err != nil {
|
||||
log.Printf("WARN: failed to remove labels for %s: %v", key, err)
|
||||
}
|
||||
|
||||
log.Printf("Cleaned up tunnel %s (port %d, conn=%s)", key, tun.port, connKey)
|
||||
delete(s.activeTuns, key)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
services:
|
||||
tunnel-server:
|
||||
image: git.nixc.us/colin/better-argo-tunnels:production
|
||||
networks:
|
||||
- traefik
|
||||
environment:
|
||||
SSH_PORT: "2222"
|
||||
PORT_RANGE_START: "10000"
|
||||
PORT_RANGE_END: "10100"
|
||||
TRAEFIK_SSH_HOST: "ingress.nixc.us:65522"
|
||||
TRAEFIK_SSH_USER: "root"
|
||||
TRAEFIK_SSH_KEY: "/keys/deploy_key"
|
||||
SWARM_SERVICE_NAME: "better-argo-tunnels_tunnel-server"
|
||||
TRAEFIK_ENTRYPOINT: "websecure"
|
||||
TRAEFIK_CERT_RESOLVER: "letsencryptresolver"
|
||||
HOSTNAME: "{{.Node.Hostname}}"
|
||||
NODE_ID: "{{.Node.ID}}"
|
||||
SERVICE_NAME: "{{.Service.Name}}"
|
||||
TASK_ID: "{{.Task.ID}}"
|
||||
ENVIRONMENT: "production"
|
||||
volumes:
|
||||
# Tunnel user's keys on ingress.nixc.us (clients connect here; authorized_keys = tunnel user's)
|
||||
- /home/tunnel/.ssh/tunnel_host_key:/keys/host_key:ro
|
||||
- /home/tunnel/.ssh/authorized_keys:/keys/authorized_keys:ro
|
||||
- /home/tunnel/.ssh/ca-userkey:/keys/deploy_key:ro
|
||||
- /home/tunnel/.ssh/ca-userkey-cert.pub:/keys/deploy_key-cert.pub:ro
|
||||
ports:
|
||||
- target: 2222
|
||||
published: 2222
|
||||
protocol: tcp
|
||||
mode: host
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == ingress.nixc.us
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: "traefik"
|
||||
# Dynamic tunnel labels are added at runtime via docker service update.
|
||||
update_config:
|
||||
order: stop-first
|
||||
failure_action: rollback
|
||||
delay: 0s
|
||||
parallelism: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Copy to /etc/tunnel-client.env and set values.
|
||||
# Required:
|
||||
TUNNEL_SERVER=ingress.nixc.us:2222
|
||||
TUNNEL_DOMAIN=myapp.example.com
|
||||
TUNNEL_KEY=/etc/tunnel-client/id_ed25519
|
||||
|
||||
# Add this key's public half to the tunnel user's ~/.ssh/authorized_keys on ingress.nixc.us.
|
||||
|
||||
# Optional (defaults shown):
|
||||
# TUNNEL_PORT=8080
|
||||
# TUNNEL_AUTH_USER=
|
||||
# TUNNEL_AUTH_PASS=
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
[Unit]
|
||||
Description=Reverse SSH tunnel client for Traefik
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Load env from same directory as this unit, or override with drop-in
|
||||
EnvironmentFile=-/etc/tunnel-client.env
|
||||
ExecStart=/usr/local/bin/tunnel-client
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# Copy to /etc/tunnel-server.env and set values.
|
||||
# Required:
|
||||
TRAEFIK_SSH_HOST=ingress.example.com
|
||||
TRAEFIK_SSH_KEY=/etc/tunnel-server/traefik_deploy_key
|
||||
|
||||
# Optional (defaults shown):
|
||||
# SSH_PORT=2222
|
||||
# SSH_HOST_KEY=/etc/tunnel-server/host_key
|
||||
# AUTHORIZED_KEYS=/etc/tunnel-server/authorized_keys (tunnel user's authorized_keys from ingress.nixc.us)
|
||||
# PORT_RANGE_START=10000
|
||||
# PORT_RANGE_END=10100
|
||||
# TRAEFIK_SSH_USER=root
|
||||
# SWARM_SERVICE_NAME=better-argo-tunnels_tunnel-server
|
||||
# TRAEFIK_ENTRYPOINT=websecure
|
||||
# TRAEFIK_CERT_RESOLVER=letsencryptresolver
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
[Unit]
|
||||
Description=Reverse SSH tunnel server for Traefik
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=-/etc/tunnel-server.env
|
||||
ExecStart=/usr/local/bin/tunnel-server
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Tunnel Test</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:system-ui,-apple-system,sans-serif;display:flex;
|
||||
align-items:center;justify-content:center;min-height:100vh;
|
||||
background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff}
|
||||
.card{background:rgba(255,255,255,.15);backdrop-filter:blur(12px);
|
||||
border-radius:16px;padding:3rem 4rem;text-align:center;
|
||||
box-shadow:0 8px 32px rgba(0,0,0,.25)}
|
||||
h1{font-size:2rem;margin-bottom:.5rem}
|
||||
p{opacity:.85;font-size:1.1rem}
|
||||
.status{margin-top:1.5rem;padding:.75rem 1.5rem;background:rgba(255,255,255,.2);
|
||||
border-radius:8px;font-family:monospace;font-size:.95rem}
|
||||
.ok{color:#6eff6e}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Reverse SSH Tunnel Test</h1>
|
||||
<p>If you can see this, the tunnel to <strong>testrst.nixc.us</strong> is working.</p>
|
||||
<div class="status"><span class="ok">✓</span> Tunnel active — served via nginx</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# Reference Traefik stack configuration for Docker Swarm.
|
||||
# This is NOT deployed by this project — it documents the Traefik setup
|
||||
# that the tunnel-server depends on for dynamic routing via Swarm labels.
|
||||
#
|
||||
# Key requirements:
|
||||
# - Docker Swarm provider enabled (--providers.docker.swarmMode=true)
|
||||
# - Exposed by default OFF (--providers.docker.exposedbydefault=false)
|
||||
# - A shared overlay network (e.g. "traefik")
|
||||
# - An ACME cert resolver for automatic TLS
|
||||
# - NO file providers — all routing is done via Docker service labels
|
||||
#
|
||||
# Deploy: docker stack deploy -c traefik-reference.yml traefik
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
services:
|
||||
traefik-http:
|
||||
image: traefik:v2
|
||||
command:
|
||||
- --providers.docker.endpoint=unix:///var/run/docker.sock
|
||||
- --providers.docker.swarmMode=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=traefik
|
||||
- --serverstransport.insecureskipverify=true
|
||||
- --log.level=ERROR
|
||||
- --global.checknewversion=false
|
||||
- --global.sendanonymoususage=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entryPoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entryPoint.scheme=https
|
||||
- --entrypoints.web.http.redirections.entryPoint.permanent=true
|
||||
- --entryPoints.websecure.forwardedHeaders.insecure=true
|
||||
- --entryPoints.websecure.transport.respondingTimeouts.idleTimeout=600s
|
||||
- --entryPoints.websecure.transport.respondingTimeouts.readTimeout=600s
|
||||
- --entryPoints.websecure.transport.respondingTimeouts.writeTimeout=600s
|
||||
- --certificatesresolvers.letsencryptresolver.acme.httpchallenge=true
|
||||
- --certificatesresolvers.letsencryptresolver.acme.httpchallenge.entrypoint=web
|
||||
- --certificatesresolvers.letsencryptresolver.acme.email=admin@example.com
|
||||
- --certificatesresolvers.letsencryptresolver.acme.storage=/letsencrypt/acme.json
|
||||
- --api.dashboard=true
|
||||
ports:
|
||||
- target: 80
|
||||
published: 80
|
||||
protocol: tcp
|
||||
mode: host
|
||||
- target: 443
|
||||
published: 443
|
||||
protocol: tcp
|
||||
mode: host
|
||||
- target: 443
|
||||
published: 443
|
||||
protocol: udp
|
||||
mode: host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik-letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- traefik
|
||||
deploy:
|
||||
endpoint_mode: dnsrr
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == manager
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: "traefik"
|
||||
traefik.http.routers.traefik-dashboard.rule: "Host(`traefik.example.com`)"
|
||||
traefik.http.routers.traefik-dashboard.entrypoints: "websecure"
|
||||
traefik.http.routers.traefik-dashboard.tls: "true"
|
||||
traefik.http.routers.traefik-dashboard.tls.certresolver: "letsencryptresolver"
|
||||
traefik.http.routers.traefik-dashboard.service: "api@internal"
|
||||
traefik.http.services.traefik-dashboard.loadbalancer.server.port: "888"
|
||||
update_config:
|
||||
order: stop-first
|
||||
failure_action: rollback
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
volumes:
|
||||
traefik-letsencrypt:
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# Build from repo root so go.mod and cmd/webdav are available (build context: .).
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go build -o /webdav ./cmd/webdav
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY webdav/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
COPY --from=build /webdav /webdav
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
mkdir -p /data
|
||||
chmod 1777 /data
|
||||
chmod -R a+rwX /data 2>/dev/null || true
|
||||
umask 0000
|
||||
exec /webdav
|
||||
Loading…
Reference in New Issue