Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions gearbox-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,19 @@ HAPROXY_AGENT_LOG_LEVEL=info # debug, info, warn, error
# TLS (optional - uses self-signed if not set)
HAPROXY_AGENT_TLS_CERT=/etc/haproxy/certs/sarg3.net.fullchain.crt
HAPROXY_AGENT_TLS_KEY=/etc/haproxy/certs/sarg3.net.key

# Extra SANs for the auto-generated self-signed cert. Comma-separated list
# of hostnames and/or IPs that clients will use to reach this agent. Ignored
# when a custom cert is configured above. Loopback (localhost / 127.0.0.1 /
# ::1) is always covered automatically.
HAPROXY_AGENT_TLS_HOSTS=mjolnir,172.16.2.3,agent.example.com
```

> [!NOTE]
> The agent regenerates the self-signed cert automatically when
> `HAPROXY_AGENT_TLS_HOSTS` changes (adding a SAN that the existing cert
> does not cover). No manual cleanup required.

### Git Sync

```bash
Expand Down
20 changes: 12 additions & 8 deletions gearbox-agent/cmd/gearbox-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,20 +219,24 @@ func main() {
}
logger.Info("TLS: Using custom certificate", "path", tlsCfg.CertPath)
} else {
// Use self-signed certs (generate if needed)
hosts := []string{"localhost"}
if hostname, err := os.Hostname(); err == nil {
hosts = append(hosts, hostname)
}

// Use self-signed certs (generate if needed). The generator
// always covers loopback (localhost / 127.0.0.1 / ::1); anything
// else clients will dial — a static container IP, an FQDN, a
// LAN hostname — must come from HAPROXY_AGENT_TLS_HOSTS.
//
// We deliberately do NOT add os.Hostname() here: in a container
// it's a random short ID that changes on every recreation,
// which would force a cert regen each restart for no value.
// Operators who want a specific hostname pin it explicitly.
var isNewCert bool
tlsCfg, isNewCert, err = crypto.LoadOrCreateTLSCert(cfg.TLSCert, cfg.TLSKey, hosts)
tlsCfg, isNewCert, err = crypto.LoadOrCreateTLSCert(cfg.TLSCert, cfg.TLSKey, cfg.TLSHosts)
if err != nil {
logger.Error("Failed to initialize TLS", "error", err)
os.Exit(1)
}
if isNewCert {
logger.Info("TLS: Generated new self-signed certificate (valid for 1 year)")
logger.Info("TLS: Generated new self-signed certificate (valid for 1 year)",
"extra_sans", cfg.TLSHosts)
} else {
logger.Info("TLS: Using existing self-signed certificate", "path", tlsCfg.CertPath)
}
Expand Down
14 changes: 13 additions & 1 deletion gearbox-agent/docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,19 @@ docker run -d \

#### Option 1: Self-signed (Default)

The agent automatically generates self-signed certificates on first run. No configuration needed.
The agent automatically generates self-signed certificates on first run. The cert covers loopback (`localhost`, `127.0.0.1`, `::1`) out of the box, which is enough when clients reach the agent over `localhost` (e.g. host-network containers, or the host itself).

If clients dial the agent by a static container IP, an FQDN, or a LAN hostname, add those as extra SANs via `HAPROXY_AGENT_TLS_HOSTS`:

```yaml
services:
gearbox-agent:
environment:
# Comma-separated list of hostnames and/or IPs to include as SANs.
- HAPROXY_AGENT_TLS_HOSTS=mjolnir,172.16.2.3,agent.example.com
```

The agent regenerates the self-signed cert automatically when this list changes (adding a SAN the existing cert does not cover).

#### Option 2: Custom Certificates

Expand Down
34 changes: 34 additions & 0 deletions gearbox-agent/internal/framework/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ type Config struct {
TLSKey string
TLSCustom bool // True if user provided custom TLS cert paths

// TLSHosts are additional hostnames / IPs to include as SANs in the
// auto-generated self-signed certificate. Ignored when TLSCustom is
// true (the user-provided cert is used verbatim).
//
// The agent already includes "localhost", "127.0.0.1", "::1", and the
// container's os.Hostname() — TLSHosts is for the address the operator
// will actually point clients at (e.g. a static container IP, an FQDN
// behind a reverse proxy, or both).
Comment thread
sarg3nt marked this conversation as resolved.
Outdated
//
// Parsed from HAPROXY_AGENT_TLS_HOSTS as a comma-separated list;
// whitespace around each entry is trimmed, empty entries are dropped.
TLSHosts []string

// API key settings
APIKeyPath string

Expand Down Expand Up @@ -155,6 +168,7 @@ func Load() (*Config, error) {
} else {
cfg.TLSKey = cfg.DataDir + "/tls/server.key"
}
cfg.TLSHosts = parseCommaList(os.Getenv("HAPROXY_AGENT_TLS_HOSTS"))
cfg.APIKeyPath = getEnvOrDefault("HAPROXY_AGENT_API_KEY_PATH", cfg.DataDir+"/api-key")

// Logging
Expand Down Expand Up @@ -278,6 +292,26 @@ func (c *Config) Validate() error {
return nil
}

// parseCommaList splits a comma-separated env value into a clean slice:
// each entry is trimmed of surrounding whitespace and empty entries are
// dropped. Returns nil for an empty/whitespace-only input.
func parseCommaList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if v := strings.TrimSpace(p); v != "" {
out = append(out, v)
}
}
if len(out) == 0 {
return nil
}
return out
}

func getEnvOrDefault(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
Expand Down
49 changes: 49 additions & 0 deletions gearbox-agent/internal/framework/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,55 @@ func TestLoad_CustomTLS(t *testing.T) {
}
}

func TestLoad_TLSHosts(t *testing.T) {
tests := []struct {
name string
envValue string
want []string
}{
{"unset", "", nil},
{"whitespace only", " ", nil},
{"single host", "mjolnir", []string{"mjolnir"}},
{"comma separated", "mjolnir,10.0.0.1,agent.local", []string{"mjolnir", "10.0.0.1", "agent.local"}},
{"trims surrounding whitespace", " mjolnir , 10.0.0.1 ", []string{"mjolnir", "10.0.0.1"}},
{"drops empty entries", "mjolnir,,10.0.0.1,", []string{"mjolnir", "10.0.0.1"}},
}

saved := os.Getenv("HAPROXY_AGENT_TLS_HOSTS")
defer func() {
if saved != "" {
os.Setenv("HAPROXY_AGENT_TLS_HOSTS", saved)
} else {
os.Unsetenv("HAPROXY_AGENT_TLS_HOSTS")
}
}()

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue == "" {
os.Unsetenv("HAPROXY_AGENT_TLS_HOSTS")
} else {
os.Setenv("HAPROXY_AGENT_TLS_HOSTS", tt.envValue)
}

cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}

if len(cfg.TLSHosts) != len(tt.want) {
t.Fatalf("TLSHosts = %v (len %d), want %v (len %d)",
cfg.TLSHosts, len(cfg.TLSHosts), tt.want, len(tt.want))
}
for i, v := range tt.want {
if cfg.TLSHosts[i] != v {
t.Errorf("TLSHosts[%d] = %q, want %q", i, cfg.TLSHosts[i], v)
}
}
})
}
}

func TestValidate(t *testing.T) {
tests := []struct {
name string
Expand Down
112 changes: 109 additions & 3 deletions gearbox-agent/internal/framework/crypto/crypto_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package crypto

import (
"crypto/x509"
"encoding/pem"
"net"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -386,8 +389,111 @@ func TestGenerateSelfSignedCert_IncludesHosts(t *testing.T) {
t.Fatalf("LoadOrCreateTLSCert() error = %v", err)
}

// Verify the cert can be parsed (basic validation)
if err := verifyCertReadable(certPath); err != nil {
t.Errorf("Generated cert is not readable: %v", err)
cert := loadCertForTest(t, certPath)

// Requested SANs must be present.
if !containsString(cert.DNSNames, "example.com") {
t.Errorf("cert DNSNames missing example.com: %v", cert.DNSNames)
}
if !containsIPString(cert.IPAddresses, "192.168.1.1") {
t.Errorf("cert IPAddresses missing 192.168.1.1: %v", cert.IPAddresses)
}

// Loopback SANs are always added.
if !containsString(cert.DNSNames, "localhost") {
t.Errorf("cert DNSNames missing localhost: %v", cert.DNSNames)
}
if !containsIPString(cert.IPAddresses, "127.0.0.1") {
t.Errorf("cert IPAddresses missing 127.0.0.1: %v", cert.IPAddresses)
}
if !containsIPString(cert.IPAddresses, "::1") {
t.Errorf("cert IPAddresses missing ::1: %v", cert.IPAddresses)
}
}

// TestLoadOrCreateTLSCert_RegenOnMissingSAN verifies that a previously
// generated cert is regenerated when LoadOrCreateTLSCert is called with a
// host that the existing cert does not cover. Without this behaviour an
// operator who adds a new entry to HAPROXY_AGENT_TLS_HOSTS would have to
// manually wipe the cert file before the change takes effect.
func TestLoadOrCreateTLSCert_RegenOnMissingSAN(t *testing.T) {
tmpDir := t.TempDir()
certPath := filepath.Join(tmpDir, "server.crt")
keyPath := filepath.Join(tmpDir, "server.key")

// Initial cert covers example.com only.
_, isNew, err := LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com"})
if err != nil {
t.Fatalf("initial LoadOrCreateTLSCert() error = %v", err)
}
if !isNew {
t.Fatal("initial call should report isNew = true")
}
originalSerial := loadCertForTest(t, certPath).SerialNumber.String()

// Same hosts → reuse, no regen.
_, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com"})
if err != nil {
t.Fatalf("second LoadOrCreateTLSCert() error = %v", err)
}
if isNew {
t.Error("call with same hosts should report isNew = false")
}
if loadCertForTest(t, certPath).SerialNumber.String() != originalSerial {
t.Error("cert serial changed despite same hosts (unexpected regeneration)")
}

// New host added → regenerate.
_, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com", "172.16.2.3"})
if err != nil {
t.Fatalf("third LoadOrCreateTLSCert() error = %v", err)
}
if !isNew {
t.Fatal("call with added host should report isNew = true")
}
regenerated := loadCertForTest(t, certPath)
if regenerated.SerialNumber.String() == originalSerial {
t.Error("cert serial unchanged after adding new host (regeneration did not happen)")
}
if !containsIPString(regenerated.IPAddresses, "172.16.2.3") {
t.Errorf("regenerated cert missing 172.16.2.3 SAN: %v", regenerated.IPAddresses)
}
if !containsString(regenerated.DNSNames, "example.com") {
t.Errorf("regenerated cert lost example.com SAN: %v", regenerated.DNSNames)
}
}

func loadCertForTest(t *testing.T, certPath string) *x509.Certificate {
t.Helper()
data, err := os.ReadFile(certPath)
if err != nil {
t.Fatalf("read cert: %v", err)
}
block, _ := pem.Decode(data)
if block == nil {
t.Fatal("failed to decode cert PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse cert: %v", err)
}
return cert
}

func containsString(haystack []string, needle string) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}

func containsIPString(haystack []net.IP, needle string) bool {
for _, ip := range haystack {
if ip.String() == needle {
return true
}
}
return false
}
54 changes: 51 additions & 3 deletions gearbox-agent/internal/framework/crypto/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,22 @@ func LoadTLSCert(certPath, keyPath string) (*TLSConfig, error) {

// LoadOrCreateTLSCert loads existing TLS certificates or generates self-signed ones.
// Returns paths to cert and key files, and a boolean indicating if new certs were created.
//
// A cert is considered still valid (and reused) when it exists, parses, is not
// near expiry, AND covers every host in `hosts` as a SAN. If a previously
// generated cert is missing a host (e.g. the operator added a new entry to
// HAPROXY_AGENT_TLS_HOSTS) it is regenerated — otherwise the new env value
// would silently never take effect.
func LoadOrCreateTLSCert(certPath, keyPath string, hosts []string) (*TLSConfig, bool, error) {
// Check if both files exist and are valid
if fileExists(certPath) && fileExists(keyPath) {
// Verify the cert is still valid
if err := verifyCert(certPath); err == nil {
return &TLSConfig{CertPath: certPath, KeyPath: keyPath}, false, nil
if err := verifyCertCoversHosts(certPath, hosts); err == nil {
return &TLSConfig{CertPath: certPath, KeyPath: keyPath}, false, nil
}
// SAN coverage gap — fall through to regenerate.
}
// Cert is expired or invalid, regenerate
// Cert is expired, invalid, or missing required SANs — regenerate.
}

// Generate new self-signed certificate
Expand Down Expand Up @@ -119,6 +127,46 @@ func verifyCert(certPath string) error {
return nil
}

// verifyCertCoversHosts loads the cert at certPath and confirms every entry
// in `hosts` is present as a SAN (DNS name for hostnames, IPAddresses entry
// for IPs). Returns nil if all hosts are covered.
func verifyCertCoversHosts(certPath string, hosts []string) error {
data, err := os.ReadFile(certPath)
if err != nil {
return fmt.Errorf("failed to read certificate file: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return fmt.Errorf("failed to decode PEM block")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("failed to parse certificate: %w", err)
}

dnsSet := make(map[string]struct{}, len(cert.DNSNames))
for _, d := range cert.DNSNames {
dnsSet[d] = struct{}{}
}
ipSet := make(map[string]struct{}, len(cert.IPAddresses))
for _, ip := range cert.IPAddresses {
ipSet[ip.String()] = struct{}{}
}

for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
if _, ok := ipSet[ip.String()]; !ok {
return fmt.Errorf("certificate missing IP SAN %s", ip.String())
}
continue
}
if _, ok := dnsSet[h]; !ok {
return fmt.Errorf("certificate missing DNS SAN %s", h)
}
Comment thread
sarg3nt marked this conversation as resolved.
}
return nil
}

// generateSelfSignedCert creates a new self-signed TLS certificate and key.
// The certificate is valid for 1 year and includes the specified hosts as SANs.
func generateSelfSignedCert(certPath, keyPath string, hosts []string) error {
Expand Down
Loading