279 lines
9.2 KiB
Go
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.
|