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.9" 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) } }