Add optional HTTP Basic Auth support for tunnel clients
ci/woodpecker/push/woodpecker Pipeline failed
Details
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:
parent
4a9a210aed
commit
37081ab53e
|
|
@ -32,6 +32,10 @@ func main() {
|
||||||
domain := envRequired("TUNNEL_DOMAIN")
|
domain := envRequired("TUNNEL_DOMAIN")
|
||||||
keyPath := envOr("TUNNEL_KEY", "/keys/id_ed25519")
|
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")
|
localPortStr := envOr("TUNNEL_PORT", "8080")
|
||||||
localPort, err := strconv.Atoi(localPortStr)
|
localPort, err := strconv.Atoi(localPortStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -50,7 +54,11 @@ func main() {
|
||||||
maxBackoff := 30 * time.Second
|
maxBackoff := 30 * time.Second
|
||||||
|
|
||||||
for {
|
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)
|
log.Printf("Connecting to %s (domain=%s, local_port=%d)", serverAddr, domain, localPort)
|
||||||
|
}
|
||||||
|
|
||||||
sshClient, err := client.Connect(serverAddr, signer)
|
sshClient, err := client.Connect(serverAddr, signer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -65,7 +73,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, localPort); err != nil {
|
if err := client.SetupTunnel(sshClient, domain, localPort, authUser, authPass); err != nil {
|
||||||
log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff)
|
log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,18 @@ import (
|
||||||
// TunnelRequest is the metadata sent to the server on the tunnel-request channel.
|
// TunnelRequest is the metadata sent to the server on the tunnel-request channel.
|
||||||
type TunnelRequest struct {
|
type TunnelRequest struct {
|
||||||
Domain string `json:"domain"`
|
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.
|
// SetupTunnel sends domain metadata and establishes a reverse port forward.
|
||||||
// The server will allocate a port and register Traefik routes for the domain.
|
// 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.
|
// 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.
|
// 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)
|
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.
|
// 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)
|
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}
|
req := TunnelRequest{Domain: domain, AuthUser: authUser, AuthPass: authPass}
|
||||||
data, err := json.Marshal(req)
|
data, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch.Close()
|
ch.Close()
|
||||||
|
|
@ -64,7 +68,11 @@ func sendMetadata(client *ssh.Client, domain string) error {
|
||||||
return fmt.Errorf("write metadata: %w", err)
|
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)
|
log.Printf("Sent tunnel metadata: domain=%s", 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() {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ type LabelManager struct {
|
||||||
entrypoint string // e.g. "websecure"
|
entrypoint string // e.g. "websecure"
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLabelManager creates a label manager that updates Swarm service labels via SSH.
|
// NewLabelManager creates a label manager that updates Swarm service labels via SSH.
|
||||||
|
|
@ -37,6 +39,7 @@ func NewLabelManager(
|
||||||
entrypoint: entrypoint,
|
entrypoint: entrypoint,
|
||||||
certResolver: certResolver,
|
certResolver: certResolver,
|
||||||
labels: make(map[string]bool),
|
labels: make(map[string]bool),
|
||||||
|
authLabels: make(map[string]bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify we can reach the Swarm manager and the service exists.
|
// 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.
|
// 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()
|
lm.mu.Lock()
|
||||||
defer lm.mu.Unlock()
|
defer lm.mu.Unlock()
|
||||||
|
|
||||||
routerName := fmt.Sprintf("tunnel-%s-router", tunKey)
|
routerName := fmt.Sprintf("tunnel-%s-router", tunKey)
|
||||||
serviceName := fmt.Sprintf("tunnel-%s-service", tunKey)
|
serviceName := fmt.Sprintf("tunnel-%s-service", tunKey)
|
||||||
|
middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey)
|
||||||
|
|
||||||
// Build the label-add flags for docker service update.
|
// Build the label-add flags for docker service update.
|
||||||
labelArgs := []string{
|
labelArgs := []string{
|
||||||
|
|
@ -77,6 +82,26 @@ func (lm *LabelManager) Add(tunKey, domain string, port int) error {
|
||||||
fmt.Sprintf("%d", port)),
|
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",
|
cmd := fmt.Sprintf("docker service update --label-add %s %s",
|
||||||
strings.Join(labelArgs, " --label-add "), lm.serviceName)
|
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)
|
routerName := fmt.Sprintf("tunnel-%s-router", tunKey)
|
||||||
serviceName := fmt.Sprintf("tunnel-%s-service", tunKey)
|
serviceName := fmt.Sprintf("tunnel-%s-service", tunKey)
|
||||||
|
|
||||||
|
middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey)
|
||||||
|
|
||||||
// Build the label-rm flags.
|
// Build the label-rm flags.
|
||||||
rmLabels := []string{
|
rmLabels := []string{
|
||||||
fmt.Sprintf("traefik.http.routers.%s.rule", routerName),
|
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),
|
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",
|
cmd := fmt.Sprintf("docker service update --label-rm %s %s",
|
||||||
strings.Join(rmLabels, " --label-rm "), lm.serviceName)
|
strings.Join(rmLabels, " --label-rm "), lm.serviceName)
|
||||||
|
|
||||||
|
|
@ -123,6 +160,18 @@ func (lm *LabelManager) Remove(tunKey string) error {
|
||||||
return nil
|
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.
|
// 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 fmt.Sprintf("'%s=%s'", key, value)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import (
|
||||||
// TunnelRequest is the metadata a client sends when opening a tunnel channel.
|
// TunnelRequest is the metadata a client sends when opening a tunnel channel.
|
||||||
type TunnelRequest struct {
|
type TunnelRequest struct {
|
||||||
Domain string `json:"domain"`
|
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.
|
// SSHServer handles incoming SSH connections and sets up reverse tunnels.
|
||||||
|
|
@ -33,6 +35,8 @@ type activeTunnel struct {
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
done chan struct{}
|
done chan 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
|
||||||
|
authPass string // optional HTTP Basic Auth password
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSSHServer creates a new SSH server with host key and authorized keys.
|
// 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)
|
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.
|
// Store domain mapping for this connection so forward handler can use it.
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.activeTuns[connKey+"-meta"] = &activeTunnel{
|
s.activeTuns[connKey+"-meta"] = &activeTunnel{
|
||||||
domain: req.Domain,
|
domain: req.Domain,
|
||||||
connKey: connKey,
|
connKey: connKey,
|
||||||
|
authUser: req.AuthUser,
|
||||||
|
authPass: req.AuthPass,
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,9 +137,12 @@ func (s *SSHServer) handleForwardRequest(
|
||||||
// Determine the domain for Traefik label registration.
|
// Determine the domain for Traefik label registration.
|
||||||
// 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
|
||||||
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
|
||||||
|
authPass = meta.authPass
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
|
@ -154,14 +157,16 @@ func (s *SSHServer) handleForwardRequest(
|
||||||
listener: listener,
|
listener: listener,
|
||||||
done: done,
|
done: done,
|
||||||
connKey: connKey,
|
connKey: connKey,
|
||||||
|
authUser: authUser,
|
||||||
|
authPass: authPass,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.activeTuns[tunKey] = tun
|
s.activeTuns[tunKey] = tun
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
// Register Traefik labels.
|
// Register Traefik labels (with optional basicauth middleware).
|
||||||
if err := s.labels.Add(tunKey, domain, port); err != nil {
|
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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ services:
|
||||||
SSH_PORT: "2222"
|
SSH_PORT: "2222"
|
||||||
PORT_RANGE_START: "10000"
|
PORT_RANGE_START: "10000"
|
||||||
PORT_RANGE_END: "10100"
|
PORT_RANGE_END: "10100"
|
||||||
SSH_HOST_KEY: "/keys/host_key"
|
|
||||||
AUTHORIZED_KEYS: "/keys/authorized_keys"
|
|
||||||
TRAEFIK_SSH_HOST: "ingress.nixc.us:65522"
|
TRAEFIK_SSH_HOST: "ingress.nixc.us:65522"
|
||||||
TRAEFIK_SSH_USER: "root"
|
TRAEFIK_SSH_USER: "root"
|
||||||
TRAEFIK_SSH_KEY: "/keys/deploy_key"
|
TRAEFIK_SSH_KEY: "/keys/deploy_key"
|
||||||
|
|
@ -43,7 +41,6 @@ services:
|
||||||
traefik.enable: "true"
|
traefik.enable: "true"
|
||||||
traefik.docker.network: "traefik"
|
traefik.docker.network: "traefik"
|
||||||
# Dynamic tunnel labels are added at runtime via docker service update.
|
# Dynamic tunnel labels are added at runtime via docker service update.
|
||||||
# The base labels below just enable Traefik discovery.
|
|
||||||
update_config:
|
update_config:
|
||||||
order: stop-first
|
order: stop-first
|
||||||
failure_action: rollback
|
failure_action: rollback
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue