Revert "Switch from Swarm labels to Traefik file provider for routing"
ci/woodpecker/push/woodpecker Pipeline was successful Details

This reverts commit 64347ce8a5.
This commit is contained in:
Leopere 2026-02-09 15:07:34 -05:00
parent 64347ce8a5
commit fd31e40285
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
2 changed files with 98 additions and 78 deletions

View File

@ -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,

View File

@ -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)
// Build optional middleware section.
var middlewareYAML string
var routerMiddleware string
if authUser != "" && authPass != "" {
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)
}
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, ":") {