package main import ( "bufio" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "os/user" "path/filepath" "strconv" "strings" "time" ) const defaultEnvFile = "/etc/rustproxy/docker-agent.env" // requestedEnvFile is handled before other flags so CLI calls can load the same // configuration as systemd. Both --env-file=X and --env-file X are supported. func requestedEnvFile(args []string) (string, error) { path := defaultEnvFile for i := 0; i < len(args); i++ { if args[i] == "--env-file" || args[i] == "-env-file" { if i+1 >= len(args) { return "", errors.New("--env-file requires a path") } path = args[i+1] i++ } else if strings.HasPrefix(args[i], "--env-file=") || strings.HasPrefix(args[i], "-env-file=") { _, path, _ = strings.Cut(args[i], "=") } } if path == "" { return "", errors.New("--env-file cannot be empty") } return path, nil } // Read a restricted, plain KEY=VALUE EnvironmentFile. Never execute it as shell. func readEnvironmentFile(path string) (map[string]string, error) { result := make(map[string]string) f, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { return result, nil } if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } line = strings.TrimPrefix(line, "export ") key, value, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(key) if strings.HasPrefix(key, "RUSTPROXY_DOCKER_") { value = strings.TrimSpace(value) if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { decoded, err := strconv.Unquote(value) if err != nil { return nil, fmt.Errorf("invalid quoted environment value for %s: %w", key, err) } value = decoded } else if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { value = value[1 : len(value)-1] } result[key] = value } } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } return result, nil } func makeToken() (string, error) { data := make([]byte, 32) if _, err := rand.Read(data); err != nil { return "", err } return hex.EncodeToString(data), nil } func provisionToken(path string, rotate bool) (string, bool, error) { // Reject symlinks, devices and directories before opening anything. stat, err := os.Lstat(path) var raw []byte if err == nil { if !stat.Mode().IsRegular() { return "", false, fmt.Errorf("refusing non-regular environment file %s", path) } if stat.Size() > 1<<20 { return "", false, fmt.Errorf("environment file too large: %s", path) } raw, err = os.ReadFile(path) if err != nil { return "", false, err } } else if !errors.Is(err, os.ErrNotExist) { return "", false, err } values, err := readEnvironmentFile(path) if err != nil { return "", false, err } token := values["RUSTPROXY_DOCKER_AGENT_TOKEN"] needsToken := token == "" || strings.HasPrefix(token, "replace-with-") if !rotate && !needsToken { if len(token) < 24 { return "", false, errors.New("configured Bearer token is shorter than 24 characters; use rotate-token") } return token, false, nil } token, err = makeToken() if err != nil { return "", false, err } if len(raw) == 0 { raw = []byte("# RustProxy Docker Agent: edit the listen address and firewall as needed.\n" + "RUSTPROXY_DOCKER_AGENT_LISTEN=0.0.0.0:9876\n" + "RUSTPROXY_DOCKER_AGENT_TLS=true\n") } // Preserve non-secret settings/comments, remove duplicate token assignments. lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") filtered := make([]string, 0, len(lines)+1) for _, line := range lines { normalized := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "export ")) key, _, ok := strings.Cut(normalized, "=") if ok && strings.TrimSpace(key) == "RUSTPROXY_DOCKER_AGENT_TOKEN" { continue } filtered = append(filtered, line) } filtered = append(filtered, "RUSTPROXY_DOCKER_AGENT_TOKEN="+token) if err := atomicWrite(path, []byte(strings.Join(filtered, "\n")+"\n"), 0600); err != nil { return "", false, err } return token, true, nil } // Generated keys must be readable by the unprivileged systemd service even // when 'init' is invoked via sudo during DEB/RPM or manual installation. func setServiceCertificateOwnership(certFile, keyFile string) error { if os.Geteuid() != 0 { return nil } serviceUser, err := user.Lookup("rustproxy-docker-agent") if err != nil { return nil } // Standalone CLI; warn operators to run init as service user. uid, err := strconv.Atoi(serviceUser.Uid) if err != nil { return err } gid, err := strconv.Atoi(serviceUser.Gid) if err != nil { return err } if err := os.Chown(keyFile, uid, gid); err != nil { return err } if err := os.Chown(certFile, uid, gid); err != nil { return err } // Parent generated via atomicWrite must also be traversable by the service. dir := filepath.Dir(keyFile) if dir == filepath.Dir(certFile) { if err := os.Chown(dir, uid, gid); err != nil { return err } } return nil } type certDetails struct { Path string `json:"path"` KeyPath string `json:"key_path"` SHA256 string `json:"sha256"` Subject string `json:"subject"` Issuer string `json:"issuer"` Serial string `json:"serial"` NotBefore string `json:"not_before"` NotAfter string `json:"not_after"` DNSNames []string `json:"dns_names"` IPAddresses []string `json:"ip_addresses"` SignatureAlgorithm string `json:"signature_algorithm"` PublicKeyAlgorithm string `json:"public_key_algorithm"` ValidNow bool `json:"valid_now"` } func describeCertificate(path string) (certDetails, error) { cert, err := readCertificate(path) if err != nil { return certDetails{}, err } now := time.Now() result := certDetails{ Path: path, SHA256: certificateFingerprintDER(cert.Raw), Subject: cert.Subject.String(), Issuer: cert.Issuer.String(), Serial: cert.SerialNumber.Text(16), NotBefore: cert.NotBefore.UTC().Format(time.RFC3339), NotAfter: cert.NotAfter.UTC().Format(time.RFC3339), DNSNames: cert.DNSNames, SignatureAlgorithm: cert.SignatureAlgorithm.String(), PublicKeyAlgorithm: cert.PublicKeyAlgorithm.String(), ValidNow: !now.Before(cert.NotBefore) && !now.After(cert.NotAfter), } for _, ip := range cert.IPAddresses { result.IPAddresses = append(result.IPAddresses, ip.String()) } return result, nil } type agentDetails struct { Version string `json:"version"` Listen string `json:"listen"` TLS bool `json:"tls"` Hostname string `json:"hostname"` DockerSocket string `json:"docker_socket"` EnvironmentFile string `json:"environment_file"` TokenConfigured bool `json:"token_configured"` TokenLength int `json:"token_length"` Token string `json:"token,omitempty"` Certificate *certDetails `json:"certificate,omitempty"` CertificateStatus string `json:"certificate_status"` } func printAgentInfo(w io.Writer, cfg config, envFile string, showToken, asJSON bool) error { info := agentDetails{Version: version, Listen: cfg.Listen, TLS: cfg.TLS, Hostname: cfg.Hostname, DockerSocket: cfg.DockerSock, EnvironmentFile: envFile, TokenConfigured: validToken(cfg.Token), TokenLength: len(cfg.Token), CertificateStatus: "disabled"} if showToken { info.Token = cfg.Token } if cfg.TLS { info.CertificateStatus = "missing" if _, err := os.Stat(cfg.CertFile); err == nil { certificate, err := describeCertificate(cfg.CertFile) if err != nil { return err } certificate.KeyPath = cfg.KeyFile info.Certificate = &certificate info.CertificateStatus = "present" } else if !errors.Is(err, os.ErrNotExist) { return err } } if asJSON { enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(info) } fmt.Fprintf(w, "RustProxy Docker Agent %s\nListen: %s://%s\nHostname: %s\nDocker socket: %s\nConfiguration: %s\n", info.Version, map[bool]string{true: "https", false: "http"}[cfg.TLS], cfg.Listen, cfg.Hostname, cfg.DockerSock, envFile) fmt.Fprintf(w, "Bearer token: configured=%t length=%d", info.TokenConfigured, info.TokenLength) if showToken { fmt.Fprintf(w, " value=%s", cfg.Token) } fmt.Fprintln(w) fmt.Fprintf(w, "TLS certificate: %s\n", info.CertificateStatus) if c := info.Certificate; c != nil { fmt.Fprintf(w, " Certificate: %s\n Private key: %s (contents never displayed)\n SHA-256: %s\n Subject: %s\n Issuer: %s\n Serial: %s\n Valid from: %s\n Valid until: %s\n Valid now: %t\n DNS SAN: %s\n IP SAN: %s\n Signature algorithm: %s\n Public key algorithm: %s\n", c.Path, c.KeyPath, c.SHA256, c.Subject, c.Issuer, c.Serial, c.NotBefore, c.NotAfter, c.ValidNow, strings.Join(c.DNSNames, ", "), strings.Join(c.IPAddresses, ", "), c.SignatureAlgorithm, c.PublicKeyAlgorithm) } if !showToken { fmt.Fprintln(w, "Token redacted. Use 'sudo rustproxy-docker-agent info --show-token' or 'show-token' to reveal it.") } return nil } // Compile-time reference: readCertificate returns an x509 parsed certificate.