162 lines
4.7 KiB
Go
162 lines
4.7 KiB
Go
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
|
|
}
|