From fd31e402859fe9e8ef53331925baf1fbba91e7aa Mon Sep 17 00:00:00 2001 From: Leopere Date: Mon, 9 Feb 2026 15:07:34 -0500 Subject: [PATCH] Revert "Switch from Swarm labels to Traefik file provider for routing" This reverts commit 64347ce8a5e439a34ad699b62e2dcae329b55786. --- cmd/server/main.go | 6 +- internal/server/labels.go | 170 +++++++++++++++++++++----------------- 2 files changed, 98 insertions(+), 78 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index a84b98b..91c4794 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -49,7 +49,7 @@ func main() { portStart := envInt("PORT_RANGE_START", 10000) portEnd := envInt("PORT_RANGE_END", 10100) - // Traefik host SSH config (for writing dynamic config files). + // Swarm manager SSH config (for updating service labels). traefikHost := envRequired("TRAEFIK_SSH_HOST") traefikUser := envOr("TRAEFIK_SSH_USER", "root") traefikKey := envRequired("TRAEFIK_SSH_KEY") @@ -62,13 +62,13 @@ func main() { if err != nil { log.Fatalf("Failed to load Traefik SSH key: %v", err) } - log.Printf("Loaded Traefik host SSH key") + 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 (Traefik file provider via SSH). + // Initialize label manager (Swarm service update via SSH). labels, err := server.NewLabelManager( traefikHost, traefikUser, traefikSigner, serviceName, entrypoint, certResolver, diff --git a/internal/server/labels.go b/internal/server/labels.go index 045f1f6..bb9cd83 100644 --- a/internal/server/labels.go +++ b/internal/server/labels.go @@ -10,30 +10,26 @@ import ( "golang.org/x/crypto/ssh" ) -// LabelManager manages Traefik dynamic configuration by writing YAML config -// files to the Traefik host via SSH. This uses the Traefik file provider -// (--providers.file.directory + --providers.file.watch=true) and avoids -// docker service update which restarts the server container. +// 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 // Traefik host, e.g. "ingress.nixc.us:65522" + remoteHost string // Swarm manager, e.g. "ingress.nixc.us" remoteUser string // SSH user signer ssh.Signer - serviceName string // Swarm service name (used for backend URL) + serviceName string // Swarm service name, e.g. "better-argo-tunnels_tunnel-server" entrypoint string // e.g. "websecure" certResolver string // e.g. "letsencryptresolver" - configDir string // remote dir for Traefik dynamic configs - configs map[string]bool + 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 manager that writes Traefik file-provider configs -// to the remote host via SSH. +// 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) { - configDir := "/root/traefik/dynamic" lm := &LabelManager{ remoteHost: remoteHost, @@ -42,113 +38,132 @@ func NewLabelManager( serviceName: serviceName, entrypoint: entrypoint, certResolver: certResolver, - configDir: configDir, - configs: make(map[string]bool), + labels: make(map[string]bool), + authLabels: make(map[string]bool), } - // Ensure the config directory exists. - cmd := fmt.Sprintf("mkdir -p %s", configDir) + // 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 ensure config dir %s: %v", configDir, err) + 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, dir=%s)", - remoteHost, serviceName, entrypoint, certResolver, configDir) + log.Printf("Label manager ready (host=%s, service=%s, ep=%s, resolver=%s)", + remoteHost, serviceName, entrypoint, certResolver) return lm, nil } -// Add writes a Traefik dynamic config YAML to the remote host for a tunnel. -// If authUser and authPass are non-empty, a basicauth middleware is included. +// 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) - svcName := fmt.Sprintf("tunnel-%s-service", tunKey) + serviceName := fmt.Sprintf("tunnel-%s-service", tunKey) + middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey) - // Build optional middleware section. - var middlewareYAML string - var routerMiddleware string + // 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 != "" { - middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey) htpasswd, err := generateHTPasswd(authUser, authPass) if err != nil { return fmt.Errorf("generate htpasswd for %s: %w", domain, err) } - middlewareYAML = fmt.Sprintf( - " middlewares:\n %s:\n basicAuth:\n users:\n - %q\n", - middlewareName, htpasswd) - routerMiddleware = fmt.Sprintf( - "\n middlewares:\n - %s", middlewareName) - log.Printf("BasicAuth middleware %s configured for %s", middlewareName, domain) + 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) } - yaml := fmt.Sprintf( - "http:\n"+ - " routers:\n"+ - " %s:\n"+ - " rule: \"Host(`%s`)\"\n"+ - " entryPoints:\n"+ - " - %s\n"+ - " tls:\n"+ - " certResolver: %s\n"+ - " service: %s%s\n"+ - " services:\n"+ - " %s:\n"+ - " loadBalancer:\n"+ - " servers:\n"+ - " - url: \"http://%s:%d\"\n"+ - "%s", - routerName, - domain, - lm.entrypoint, - lm.certResolver, - svcName, - routerMiddleware, - svcName, - lm.serviceName, - port, - middlewareYAML, - ) - - filePath := fmt.Sprintf("%s/tunnel-%s.yml", lm.configDir, tunKey) - - // Write via heredoc so we don't need to escape anything. - cmd := fmt.Sprintf("cat > '%s' << 'TRAEFIKEOF'\n%sTRAEFIKEOF", filePath, yaml) + 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("write config for %s: %w", domain, err) + return fmt.Errorf("add labels for %s: %w", domain, err) } - lm.configs[tunKey] = true - log.Printf("Wrote Traefik config: %s -> %s:%d (%s)", domain, lm.serviceName, port, filePath) + lm.labels[tunKey] = true + log.Printf("Added Swarm labels: %s -> %s:%d", domain, lm.serviceName, port) return nil } -// Remove deletes the Traefik dynamic config file for a tunnel. +// 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.configs[tunKey] { - return nil + if !lm.labels[tunKey] { + return nil // nothing to remove } - filePath := fmt.Sprintf("%s/tunnel-%s.yml", lm.configDir, tunKey) - cmd := fmt.Sprintf("rm -f '%s'", filePath) + 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 config for %s: %w", tunKey, err) + return fmt.Errorf("remove labels for %s: %w", tunKey, err) } - delete(lm.configs, tunKey) - log.Printf("Removed Traefik config: %s", filePath) + 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 { @@ -157,7 +172,12 @@ func generateHTPasswd(user, pass string) (string, error) { return fmt.Sprintf("%s:%s", user, string(hash)), nil } -// runRemote executes a command on the Traefik host via SSH. +// 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, ":") {