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