package server import ( "fmt" "log" "strings" "sync" "golang.org/x/crypto/ssh" ) // LabelManager manages Traefik dynamic config on a remote host via SSH. // It SSHs into the Traefik host and writes per-tunnel YAML config files // into the Traefik file provider directory. type LabelManager struct { mu sync.Mutex remoteHost string // e.g. "ingress.nixc.us" or "ingress.nixc.us:22" remoteUser string // SSH user on the Traefik host signer ssh.Signer configDir string // remote path where Traefik watches for file provider entrypoint string // e.g. "websecure" certResolver string // e.g. "letsencryptresolver" } // NewLabelManager creates a label manager that writes Traefik config via SSH. func NewLabelManager( remoteHost, remoteUser string, signer ssh.Signer, configDir, entrypoint, certResolver string, ) (*LabelManager, error) { lm := &LabelManager{ remoteHost: remoteHost, remoteUser: remoteUser, signer: signer, configDir: configDir, entrypoint: entrypoint, certResolver: certResolver, } // Ensure the remote config directory exists. if err := lm.runRemote(fmt.Sprintf("mkdir -p %s", configDir)); err != nil { return nil, fmt.Errorf("ensure remote config dir: %w", err) } log.Printf("Label manager ready (host=%s, dir=%s, ep=%s, resolver=%s)", remoteHost, configDir, entrypoint, certResolver) return lm, nil } // Add writes a Traefik dynamic config file on the remote host for a tunnel. func (lm *LabelManager) Add(tunKey, domain string, port int) error { lm.mu.Lock() defer lm.mu.Unlock() routerName := fmt.Sprintf("tunnel-%s-router", tunKey) serviceName := fmt.Sprintf("tunnel-%s-service", tunKey) cfg := buildRouteConfig(routerName, serviceName, domain, port, lm.entrypoint, lm.certResolver) remotePath := fmt.Sprintf("%s/tunnel-%s.yml", lm.configDir, tunKey) // Write the config file via SSH using cat heredoc. cmd := fmt.Sprintf("cat > %s << 'TRAEFIKEOF'\n%sTRAEFIKEOF", remotePath, cfg) if err := lm.runRemote(cmd); err != nil { return fmt.Errorf("write remote config %s: %w", remotePath, err) } log.Printf("Wrote remote Traefik config: %s (domain=%s port=%d)", remotePath, domain, port) return nil } // Remove deletes the Traefik dynamic config file on the remote host. func (lm *LabelManager) Remove(tunKey string) error { lm.mu.Lock() defer lm.mu.Unlock() remotePath := fmt.Sprintf("%s/tunnel-%s.yml", lm.configDir, tunKey) cmd := fmt.Sprintf("rm -f %s", remotePath) if err := lm.runRemote(cmd); err != nil { return fmt.Errorf("remove remote config %s: %w", remotePath, err) } log.Printf("Removed remote Traefik config: %s", remotePath) return nil } // runRemote executes a command on the remote Traefik host via SSH. func (lm *LabelManager) runRemote(cmd string) error { addr := lm.remoteHost if !strings.Contains(addr, ":") { addr = addr + ":22" } config := &ssh.ClientConfig{ User: lm.remoteUser, Auth: []ssh.AuthMethod{ ssh.PublicKeys(lm.signer), }, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } client, err := ssh.Dial("tcp", addr, config) if err != nil { return fmt.Errorf("SSH dial %s: %w", addr, err) } defer client.Close() session, err := client.NewSession() if err != nil { return fmt.Errorf("SSH session: %w", err) } defer session.Close() output, err := session.CombinedOutput(cmd) if err != nil { return fmt.Errorf("remote cmd failed: %w (output: %s)", err, string(output)) } return nil } // buildRouteConfig generates Traefik dynamic config YAML for one tunnel. func buildRouteConfig( routerName, serviceName, domain string, port int, entrypoint, certResolver string, ) string { var b strings.Builder b.WriteString("# Auto-generated by tunnel-server. Do not edit.\n") b.WriteString("http:\n") // Router b.WriteString(" routers:\n") b.WriteString(fmt.Sprintf(" %s:\n", routerName)) b.WriteString(fmt.Sprintf(" rule: \"Host(`%s`)\"\n", domain)) b.WriteString(" entryPoints:\n") b.WriteString(fmt.Sprintf(" - %s\n", entrypoint)) b.WriteString(" tls:\n") b.WriteString(fmt.Sprintf(" certResolver: %s\n", certResolver)) b.WriteString(fmt.Sprintf(" service: %s\n", serviceName)) // Service — points to the tunnel-server's allocated port. // The tunnel-server container is on the same network as Traefik, // so Traefik can reach it by container name or IP. b.WriteString(" services:\n") b.WriteString(fmt.Sprintf(" %s:\n", serviceName)) b.WriteString(" loadBalancer:\n") b.WriteString(" servers:\n") b.WriteString(fmt.Sprintf(" - url: \"http://tunnel-server:%d\"\n", port)) return b.String() } // Close is a no-op — SSH connections are opened/closed per operation. func (lm *LabelManager) Close() error { return nil }