Add optional HTTP Basic Auth support for tunnel clients
ci/woodpecker/push/woodpecker Pipeline failed Details

Clients can now set TUNNEL_AUTH_USER and TUNNEL_AUTH_PASS to have the
server add a Traefik basicauth middleware in front of the tunnel route.
Credentials are sent as tunnel metadata over the SSH channel and the
server generates a bcrypt htpasswd entry for Traefik's Docker labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leopere 2026-02-09 14:40:58 -05:00
parent 4a9a210aed
commit 37081ab53e
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
6 changed files with 94 additions and 17 deletions

View File

@ -32,6 +32,10 @@ func main() {
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", "")
localPortStr := envOr("TUNNEL_PORT", "8080")
localPort, err := strconv.Atoi(localPortStr)
if err != nil {
@ -50,7 +54,11 @@ func main() {
maxBackoff := 30 * time.Second
for {
if authUser != "" {
log.Printf("Connecting to %s (domain=%s, local_port=%d, basicauth=enabled)", serverAddr, domain, localPort)
} else {
log.Printf("Connecting to %s (domain=%s, local_port=%d)", serverAddr, domain, localPort)
}
sshClient, err := client.Connect(serverAddr, signer)
if err != nil {
@ -65,7 +73,7 @@ func main() {
log.Printf("Connected to %s", serverAddr)
// Set up the reverse tunnel (blocks until disconnected).
if err := client.SetupTunnel(sshClient, domain, localPort); err != nil {
if err := client.SetupTunnel(sshClient, domain, localPort, authUser, authPass); err != nil {
log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff)
}

View File

@ -14,14 +14,18 @@ import (
// 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.
// localPort is the port of the service running on the client side.
func SetupTunnel(client *ssh.Client, domain string, localPort int) error {
// 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, localPort int, authUser, authPass string) error {
// Step 1: Open custom channel to send domain metadata.
if err := sendMetadata(client, domain); err != nil {
if err := sendMetadata(client, domain, authUser, authPass); err != nil {
return fmt.Errorf("send metadata: %w", err)
}
@ -46,13 +50,13 @@ func SetupTunnel(client *ssh.Client, domain string, localPort int) error {
}
// sendMetadata opens a custom channel and sends the tunnel request JSON.
func sendMetadata(client *ssh.Client, domain string) error {
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}
req := TunnelRequest{Domain: domain, AuthUser: authUser, AuthPass: authPass}
data, err := json.Marshal(req)
if err != nil {
ch.Close()
@ -64,7 +68,11 @@ func sendMetadata(client *ssh.Client, domain string) error {
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() {

View File

@ -6,6 +6,7 @@ import (
"strings"
"sync"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh"
)
@ -20,6 +21,7 @@ type LabelManager struct {
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.
@ -37,6 +39,7 @@ func NewLabelManager(
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.
@ -54,12 +57,14 @@ func NewLabelManager(
}
// Add adds Traefik routing labels to the Swarm service for a tunnel.
func (lm *LabelManager) Add(tunKey, domain string, port int) error {
// 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{
@ -77,6 +82,26 @@ func (lm *LabelManager) Add(tunKey, domain string, port int) error {
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)
@ -101,6 +126,8 @@ func (lm *LabelManager) Remove(tunKey string) error {
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),
@ -111,6 +138,16 @@ func (lm *LabelManager) Remove(tunKey string) error {
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)
@ -123,6 +160,18 @@ func (lm *LabelManager) Remove(tunKey string) error {
return nil
}
// generateHTPasswd creates a bcrypt-hashed htpasswd entry for Traefik basicauth.
// The output format is user:$$hash (with $ escaped for Docker label values).
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)
}
// Traefik in Docker labels requires dollar signs to be doubled.
escaped := strings.ReplaceAll(string(hash), "$", "$$")
return fmt.Sprintf("%s:%s", user, escaped), nil
}
// labelFlag formats a --label-add value, quoting properly for shell.
func labelFlag(key, value string) string {
return fmt.Sprintf("'%s=%s'", key, value)

View File

@ -16,6 +16,8 @@ import (
// 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.
@ -33,6 +35,8 @@ type activeTunnel struct {
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.
@ -185,11 +189,17 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string)
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()

View File

@ -137,9 +137,12 @@ func (s *SSHServer) handleForwardRequest(
// 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()
@ -154,14 +157,16 @@ func (s *SSHServer) handleForwardRequest(
listener: listener,
done: done,
connKey: connKey,
authUser: authUser,
authPass: authPass,
}
s.mu.Lock()
s.activeTuns[tunKey] = tun
s.mu.Unlock()
// Register Traefik labels.
if err := s.labels.Add(tunKey, domain, port); err != nil {
// 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)

View File

@ -11,8 +11,6 @@ services:
SSH_PORT: "2222"
PORT_RANGE_START: "10000"
PORT_RANGE_END: "10100"
SSH_HOST_KEY: "/keys/host_key"
AUTHORIZED_KEYS: "/keys/authorized_keys"
TRAEFIK_SSH_HOST: "ingress.nixc.us:65522"
TRAEFIK_SSH_USER: "root"
TRAEFIK_SSH_KEY: "/keys/deploy_key"
@ -43,7 +41,6 @@ services:
traefik.enable: "true"
traefik.docker.network: "traefik"
# Dynamic tunnel labels are added at runtime via docker service update.
# The base labels below just enable Traefik discovery.
update_config:
order: stop-first
failure_action: rollback