Files
RustProxy/docker-agent/credentials.go
T
2026-10-19 22:38:17 +02:00

279 lines
9.2 KiB
Go

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.