Files
RustProxy/docker-agent/main.go
T
2026-10-03 10:05:46 +02:00

702 lines
21 KiB
Go

package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"log"
"math/big"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const version = "0.24.7"
type config struct {
Listen string
Token string
DockerSock string
AdvertiseIP string
PublicIPURL string
Hostname string
TLS bool
CertFile string
KeyFile string
Fingerprint string
}
type dockerPort struct {
IP string `json:"IP"`
PrivatePort uint16 `json:"PrivatePort"`
PublicPort uint16 `json:"PublicPort"`
Type string `json:"Type"`
}
type dockerContainer struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
Labels map[string]string `json:"Labels"`
Ports []dockerPort `json:"Ports"`
}
type binding struct {
BindIP string `json:"bind_ip"`
PublicPort uint16 `json:"public_port"`
}
type service struct {
Key string `json:"key"`
TechnicalName string `json:"technical_name"`
ContainerID string `json:"container_id"`
ContainerName string `json:"container_name"`
Image string `json:"image"`
PrivatePort uint16 `json:"private_port"`
Protocol string `json:"protocol"`
Bindings []binding `json:"bindings"`
}
type hostAddress struct {
IP string `json:"ip"`
Source string `json:"source"`
Interface string `json:"interface,omitempty"`
Scope string `json:"scope"`
}
type inventory struct {
AgentVersion string `json:"agent_version"`
Hostname string `json:"hostname"`
PublicIP string `json:"public_ip"`
PublicIPSource string `json:"public_ip_source"`
CertificateSHA256 string `json:"certificate_sha256"`
Addresses []hostAddress `json:"addresses"`
ObservedAt string `json:"observed_at"`
Services []service `json:"services"`
}
type unixRoundTripper struct{ sock string }
func (u unixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", u.sock)
},
DisableKeepAlives: false,
}
clone := req.Clone(req.Context())
clone.URL.Scheme = "http"
clone.URL.Host = "docker"
return transport.RoundTrip(clone)
}
func dockerContainers(ctx context.Context, sock string) ([]dockerContainer, error) {
client := &http.Client{Transport: unixRoundTripper{sock: sock}, Timeout: 4 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/containers/json?all=0", nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("docker API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("docker API returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var items []dockerContainer
dec := json.NewDecoder(io.LimitReader(resp.Body, 4<<20))
if err := dec.Decode(&items); err != nil {
return nil, fmt.Errorf("decode Docker response: %w", err)
}
return items, nil
}
func cleanName(names []string) string {
if len(names) == 0 {
return "container"
}
name := strings.TrimPrefix(names[0], "/")
if name == "" {
return "container"
}
return name
}
func dnsLabel(value string) string {
value = strings.ToLower(value)
var b strings.Builder
dash := false
for _, r := range value {
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if ok {
b.WriteRune(r)
dash = false
} else if b.Len() > 0 && !dash {
b.WriteByte('-')
dash = true
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
out = "app"
}
if len(out) > 48 {
out = strings.Trim(out[:48], "-")
}
return out
}
func serviceIdentity(c dockerContainer, p dockerPort) (key, technical string) {
name := cleanName(c.Names)
project := c.Labels["com.docker.compose.project"]
composeService := c.Labels["com.docker.compose.service"]
replica := c.Labels["com.docker.compose.container-number"]
suffix := strconv.Itoa(int(p.PrivatePort))
if project != "" && composeService != "" {
if replica == "" {
replica = name
}
key = fmt.Sprintf("compose:%s/%s/%s:%d/%s", project, composeService, replica, p.PrivatePort, p.Type)
technicalBase := project + "-" + composeService
if replica != "1" {
technicalBase += "-" + replica
}
technical = dnsLabel(technicalBase + "-" + suffix)
} else {
key = fmt.Sprintf("container:%s:%d/%s", name, p.PrivatePort, p.Type)
technical = dnsLabel(name + "-" + suffix)
}
return key, technical
}
func validIP(raw string) string {
ip := net.ParseIP(strings.TrimSpace(raw))
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() {
return ""
}
return ip.String()
}
func servicesFrom(containers []dockerContainer) []service {
byKey := make(map[string]*service)
bindingSeen := make(map[string]map[string]struct{})
for _, c := range containers {
name := cleanName(c.Names)
for _, p := range c.Ports {
if p.PublicPort == 0 || p.Type != "tcp" {
continue
}
if bind := net.ParseIP(strings.TrimSpace(p.IP)); bind != nil && bind.IsLoopback() {
continue
}
key, technical := serviceIdentity(c, p)
item := byKey[key]
if item == nil {
id := c.ID
if len(id) > 12 {
id = id[:12]
}
item = &service{
Key: key, TechnicalName: technical, ContainerID: id, ContainerName: name,
Image: c.Image, PrivatePort: p.PrivatePort, Protocol: p.Type,
}
byKey[key] = item
bindingSeen[key] = make(map[string]struct{})
}
bindIP := strings.TrimSpace(p.IP)
dedupe := bindIP + "|" + strconv.Itoa(int(p.PublicPort))
if _, ok := bindingSeen[key][dedupe]; ok {
continue
}
bindingSeen[key][dedupe] = struct{}{}
item.Bindings = append(item.Bindings, binding{BindIP: bindIP, PublicPort: p.PublicPort})
}
}
out := make([]service, 0, len(byKey))
for _, item := range byKey {
sort.Slice(item.Bindings, func(i, j int) bool {
left := net.ParseIP(strings.TrimSpace(item.Bindings[i].BindIP))
right := net.ParseIP(strings.TrimSpace(item.Bindings[j].BindIP))
leftWildcard := left == nil || left.IsUnspecified()
rightWildcard := right == nil || right.IsUnspecified()
if leftWildcard != rightWildcard {
return !leftWildcard
}
if item.Bindings[i].BindIP != item.Bindings[j].BindIP {
return item.Bindings[i].BindIP < item.Bindings[j].BindIP
}
return item.Bindings[i].PublicPort < item.Bindings[j].PublicPort
})
out = append(out, *item)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
func publicIPFromURL(ctx context.Context, rawURL string) string {
if rawURL == "" {
return ""
}
client := &http.Client{Timeout: 2 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return ""
}
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 128))
if err != nil {
return ""
}
return validIP(string(body))
}
func routeIP() string {
conn, err := net.DialTimeout("udp", "1.1.1.1:53", time.Second)
if err != nil {
return ""
}
defer conn.Close()
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
return validIP(addr.IP.String())
}
return ""
}
func ipScope(ip net.IP) string {
switch {
case ip.IsPrivate():
return "private"
case ip.IsLinkLocalUnicast():
return "link_local"
default:
return "public"
}
}
func skipInterface(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
return name == "docker0" || strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "veth") || strings.HasPrefix(name, "br-") || strings.HasPrefix(name, "virbr")
}
func localHostAddresses() []hostAddress {
interfaces, err := net.Interfaces()
if err != nil {
return nil
}
out := make([]hostAddress, 0)
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 || skipInterface(iface.Name) {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
raw := addr.String()
if host, _, err := net.ParseCIDR(raw); err == nil {
raw = host.String()
} else if host, _, err := net.SplitHostPort(raw); err == nil {
raw = host
}
clean := validIP(raw)
if clean == "" {
continue
}
ip := net.ParseIP(clean)
out = append(out, hostAddress{IP: clean, Source: "interface", Interface: iface.Name, Scope: ipScope(ip)})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Interface != out[j].Interface {
return out[i].Interface < out[j].Interface
}
return out[i].IP < out[j].IP
})
return out
}
type ipDetector struct {
mu sync.Mutex
publicIP string
expires time.Time
}
func (d *ipDetector) public(ctx context.Context, rawURL string) string {
if rawURL == "" {
return ""
}
d.mu.Lock()
defer d.mu.Unlock()
if time.Now().Before(d.expires) {
return d.publicIP
}
d.publicIP = publicIPFromURL(ctx, rawURL)
d.expires = time.Now().Add(5 * time.Minute)
if d.publicIP == "" {
d.expires = time.Now().Add(30 * time.Second)
}
return d.publicIP
}
func mergeHostAddress(out *[]hostAddress, index map[string]int, candidate hostAddress) {
candidate.IP = validIP(candidate.IP)
if candidate.IP == "" {
return
}
if candidate.Scope == "" {
candidate.Scope = ipScope(net.ParseIP(candidate.IP))
}
if pos, ok := index[candidate.IP]; ok {
existing := &(*out)[pos]
if candidate.Interface != "" && existing.Interface == "" {
existing.Interface = candidate.Interface
}
if candidate.Source != "" && !strings.Contains(existing.Source, candidate.Source) {
if existing.Source == "" {
existing.Source = candidate.Source
} else {
existing.Source += "," + candidate.Source
}
}
return
}
index[candidate.IP] = len(*out)
*out = append(*out, candidate)
}
func detectedAddresses(ctx context.Context, cfg config, detector *ipDetector) ([]hostAddress, string, string) {
out := make([]hostAddress, 0, 8)
index := make(map[string]int)
for _, item := range localHostAddresses() {
mergeHostAddress(&out, index, item)
}
routed := routeIP()
if routed != "" {
mergeHostAddress(&out, index, hostAddress{IP: routed, Source: "route"})
}
public := detector.public(ctx, cfg.PublicIPURL)
if public != "" {
mergeHostAddress(&out, index, hostAddress{IP: public, Source: "public_api"})
}
advertised := validIP(cfg.AdvertiseIP)
if advertised != "" {
mergeHostAddress(&out, index, hostAddress{IP: advertised, Source: "override"})
}
switch {
case advertised != "":
return out, advertised, "override"
case routed != "":
return out, routed, "route"
case public != "":
return out, public, "public_api"
case len(out) > 0:
return out, out[0].IP, out[0].Source
default:
return out, "", "unavailable"
}
}
func certificateFingerprintDER(der []byte) string {
sum := sha256.Sum256(der)
return hex.EncodeToString(sum[:])
}
func certificateFingerprint(certFile string) (string, error) {
raw, err := os.ReadFile(certFile)
if err != nil {
return "", err
}
block, _ := pem.Decode(raw)
if block == nil || block.Type != "CERTIFICATE" {
return "", fmt.Errorf("%s does not contain a PEM certificate", certFile)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("parse certificate: %w", err)
}
return certificateFingerprintDER(cert.Raw), nil
}
func atomicWrite(path string, data []byte, mode os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".rustproxy-agent-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
func ensureSelfSignedCertificate(certFile, keyFile, hostname string) (string, bool, error) {
certInfo, certErr := os.Stat(certFile)
keyInfo, keyErr := os.Stat(keyFile)
if certErr == nil && keyErr == nil {
if certInfo.IsDir() || keyInfo.IsDir() {
return "", false, errors.New("TLS certificate/key path points to a directory")
}
if _, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil {
return "", false, fmt.Errorf("load TLS certificate/key: %w", err)
}
fp, err := certificateFingerprint(certFile)
return fp, false, err
}
if (certErr == nil) != (keyErr == nil) {
return "", false, errors.New("only one TLS certificate/key file exists; refusing to replace the other")
}
if certErr != nil && !os.IsNotExist(certErr) {
return "", false, certErr
}
if keyErr != nil && !os.IsNotExist(keyErr) {
return "", false, keyErr
}
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", false, fmt.Errorf("generate TLS key: %w", err)
}
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, serialLimit)
if err != nil {
return "", false, fmt.Errorf("generate TLS serial: %w", err)
}
now := time.Now().UTC()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: hostname, Organization: []string{"RustProxy Docker Agent"}},
NotBefore: now.Add(-5 * time.Minute),
NotAfter: now.AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
if hostname != "" {
if ip := net.ParseIP(hostname); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, hostname)
}
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
return "", false, fmt.Errorf("create TLS certificate: %w", err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return "", false, fmt.Errorf("marshal TLS key: %w", err)
}
if err := atomicWrite(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0600); err != nil {
return "", false, fmt.Errorf("write TLS key: %w", err)
}
if err := atomicWrite(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil {
_ = os.Remove(keyFile)
return "", false, fmt.Errorf("write TLS certificate: %w", err)
}
return certificateFingerprintDER(der), true, nil
}
func tokenOK(got, expected string) bool {
if expected == "" || len(got) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
}
func handler(cfg config) http.Handler {
mux := http.NewServeMux()
detector := &ipDetector{}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if cfg.Fingerprint != "" {
w.Header().Set("X-RustProxy-Agent-Certificate-SHA256", cfg.Fingerprint)
}
_, _ = io.WriteString(w, `{"status":"ok"}`)
})
mux.HandleFunc("/v1/inventory", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
auth := strings.TrimSpace(r.Header.Get("Authorization"))
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) || !tokenOK(strings.TrimSpace(strings.TrimPrefix(auth, prefix)), cfg.Token) {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
containers, err := dockerContainers(r.Context(), cfg.DockerSock)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
addresses, primaryIP, primarySource := detectedAddresses(r.Context(), cfg, detector)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if cfg.Fingerprint != "" {
w.Header().Set("X-RustProxy-Agent-Certificate-SHA256", cfg.Fingerprint)
}
_ = json.NewEncoder(w).Encode(inventory{
AgentVersion: version, Hostname: cfg.Hostname, PublicIP: primaryIP, PublicIPSource: primarySource, CertificateSHA256: cfg.Fingerprint,
Addresses: addresses, ObservedAt: time.Now().UTC().Format(time.RFC3339), Services: servicesFrom(containers),
})
})
return mux
}
func envOr(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func envBool(name string, fallback bool) bool {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
value, err := strconv.ParseBool(raw)
if err != nil {
return fallback
}
return value
}
func main() {
hostname, _ := os.Hostname()
cfg := config{}
flag.StringVar(&cfg.Listen, "listen", envOr("RUSTPROXY_DOCKER_AGENT_LISTEN", "127.0.0.1:9876"), "listen address")
flag.StringVar(&cfg.Token, "token", envOr("RUSTPROXY_DOCKER_AGENT_TOKEN", ""), "Bearer token (prefer environment variable)")
flag.StringVar(&cfg.DockerSock, "docker-socket", envOr("RUSTPROXY_DOCKER_SOCKET", "/var/run/docker.sock"), "Docker Engine unix socket")
flag.StringVar(&cfg.AdvertiseIP, "advertise-ip", envOr("RUSTPROXY_DOCKER_ADVERTISE_IP", ""), "optional preferred host IP; all usable host addresses are still reported")
flag.StringVar(&cfg.PublicIPURL, "public-ip-url", envOr("RUSTPROXY_DOCKER_PUBLIC_IP_URL", "https://api.ipify.org"), "public IP lookup URL; empty disables")
flag.StringVar(&cfg.Hostname, "hostname", envOr("RUSTPROXY_DOCKER_HOSTNAME", hostname), "reported host name")
flag.BoolVar(&cfg.TLS, "tls", envBool("RUSTPROXY_DOCKER_AGENT_TLS", true), "serve HTTPS; enabled by default")
flag.StringVar(&cfg.CertFile, "cert-file", envOr("RUSTPROXY_DOCKER_AGENT_CERT_FILE", "/var/lib/rustproxy-docker-agent/agent.crt"), "TLS certificate file")
flag.StringVar(&cfg.KeyFile, "key-file", envOr("RUSTPROXY_DOCKER_AGENT_KEY_FILE", "/var/lib/rustproxy-docker-agent/agent.key"), "TLS private key file")
initCert := flag.Bool("init-cert", false, "create the self-signed TLS certificate if missing and exit")
printFingerprint := flag.Bool("print-fingerprint", false, "print SHA-256 certificate fingerprint and exit")
flag.Parse()
if *initCert {
fingerprint, created, err := ensureSelfSignedCertificate(cfg.CertFile, cfg.KeyFile, cfg.Hostname)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s\n", fingerprint)
if created {
log.Printf("created self-signed certificate %s", cfg.CertFile)
}
return
}
if *printFingerprint {
fingerprint, err := certificateFingerprint(cfg.CertFile)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s\n", fingerprint)
return
}
if len(cfg.Token) < 24 {
log.Fatal("RUSTPROXY_DOCKER_AGENT_TOKEN must contain at least 24 characters")
}
if _, err := os.Stat(cfg.DockerSock); err != nil {
log.Fatalf("Docker socket unavailable: %v", err)
}
if _, _, err := net.SplitHostPort(cfg.Listen); err != nil {
log.Fatalf("invalid listen address: %v", err)
}
if cfg.TLS {
fingerprint, created, err := ensureSelfSignedCertificate(cfg.CertFile, cfg.KeyFile, cfg.Hostname)
if err != nil {
log.Fatalf("TLS certificate: %v", err)
}
cfg.Fingerprint = fingerprint
if created {
log.Printf("generated self-signed TLS certificate; SHA-256 %s", fingerprint)
}
}
server := &http.Server{Addr: cfg.Listen, Handler: handler(cfg), ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdown)
}()
scheme := "http"
if cfg.TLS {
scheme = "https"
}
log.Printf("RustProxy Docker Agent %s listening on %s://%s", version, scheme, cfg.Listen)
if cfg.Fingerprint != "" {
log.Printf("TLS certificate SHA-256: %s", cfg.Fingerprint)
}
var err error
if cfg.TLS {
err = server.ListenAndServeTLS(cfg.CertFile, cfg.KeyFile)
} else {
err = server.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}