Skip to content

Commit 0799f82

Browse files
sarg3ntclaude
andauthored
feat(agent): HAPROXY_AGENT_TLS_HOSTS for self-signed cert SANs (#110)
* feat(agent): HAPROXY_AGENT_TLS_HOSTS for self-signed cert SANs The agent auto-generates a self-signed cert covering only localhost, 127.0.0.1, ::1 and a random container hostname. Clients reaching the agent by a static container IP (e.g. the homelab gearbox-agent at 172.16.2.3) hit cert-verification failures — the previous workaround was disabling TLS verification on the dashboard side. HAPROXY_AGENT_TLS_HOSTS accepts a comma-separated list of extra SANs that get baked into the auto-generated cert. LoadOrCreateTLSCert now also regenerates the cert when an existing one is missing a requested SAN, so adding a host to the env var takes effect on the next restart with no manual cleanup. Dropped the os.Hostname() auto-SAN — in a container it's a random short ID that changes per recreation and would trigger needless regen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address Copilot review: stale doc comment + case-insensitive DNS SAN match - config.go: TLSHosts doc no longer claims os.Hostname() is auto-added; explains why we explicitly skip it (random container ID forces regen). - tls.go: verifyCertCoversHosts now lowercases DNS SANs and strips a trailing dot on both sides before comparison. Per RFC 6125 §6.4 DNS host matching is case-insensitive, so "Example.COM." and "example.com" must compare equal — otherwise a casing-only env-var edit would force a spurious cert regeneration. IP comparison is unchanged. - crypto_test.go: extends TestLoadOrCreateTLSCert_RegenOnMissingSAN with two new assertions covering the casing and trailing-dot equivalences. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a8a1af8 commit 0799f82

7 files changed

Lines changed: 325 additions & 15 deletions

File tree

gearbox-agent/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,19 @@ HAPROXY_AGENT_LOG_LEVEL=info # debug, info, warn, error
178178
# TLS (optional - uses self-signed if not set)
179179
HAPROXY_AGENT_TLS_CERT=/etc/haproxy/certs/sarg3.net.fullchain.crt
180180
HAPROXY_AGENT_TLS_KEY=/etc/haproxy/certs/sarg3.net.key
181+
182+
# Extra SANs for the auto-generated self-signed cert. Comma-separated list
183+
# of hostnames and/or IPs that clients will use to reach this agent. Ignored
184+
# when a custom cert is configured above. Loopback (localhost / 127.0.0.1 /
185+
# ::1) is always covered automatically.
186+
HAPROXY_AGENT_TLS_HOSTS=mjolnir,172.16.2.3,agent.example.com
181187
```
182188

189+
> [!NOTE]
190+
> The agent regenerates the self-signed cert automatically when
191+
> `HAPROXY_AGENT_TLS_HOSTS` changes (adding a SAN that the existing cert
192+
> does not cover). No manual cleanup required.
193+
183194
### Git Sync
184195

185196
```bash

gearbox-agent/cmd/gearbox-agent/main.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -219,20 +219,24 @@ func main() {
219219
}
220220
logger.Info("TLS: Using custom certificate", "path", tlsCfg.CertPath)
221221
} else {
222-
// Use self-signed certs (generate if needed)
223-
hosts := []string{"localhost"}
224-
if hostname, err := os.Hostname(); err == nil {
225-
hosts = append(hosts, hostname)
226-
}
227-
222+
// Use self-signed certs (generate if needed). The generator
223+
// always covers loopback (localhost / 127.0.0.1 / ::1); anything
224+
// else clients will dial — a static container IP, an FQDN, a
225+
// LAN hostname — must come from HAPROXY_AGENT_TLS_HOSTS.
226+
//
227+
// We deliberately do NOT add os.Hostname() here: in a container
228+
// it's a random short ID that changes on every recreation,
229+
// which would force a cert regen each restart for no value.
230+
// Operators who want a specific hostname pin it explicitly.
228231
var isNewCert bool
229-
tlsCfg, isNewCert, err = crypto.LoadOrCreateTLSCert(cfg.TLSCert, cfg.TLSKey, hosts)
232+
tlsCfg, isNewCert, err = crypto.LoadOrCreateTLSCert(cfg.TLSCert, cfg.TLSKey, cfg.TLSHosts)
230233
if err != nil {
231234
logger.Error("Failed to initialize TLS", "error", err)
232235
os.Exit(1)
233236
}
234237
if isNewCert {
235-
logger.Info("TLS: Generated new self-signed certificate (valid for 1 year)")
238+
logger.Info("TLS: Generated new self-signed certificate (valid for 1 year)",
239+
"extra_sans", cfg.TLSHosts)
236240
} else {
237241
logger.Info("TLS: Using existing self-signed certificate", "path", tlsCfg.CertPath)
238242
}

gearbox-agent/docs/docker.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,19 @@ docker run -d \
240240

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

243-
The agent automatically generates self-signed certificates on first run. No configuration needed.
243+
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).
244+
245+
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`:
246+
247+
```yaml
248+
services:
249+
gearbox-agent:
250+
environment:
251+
# Comma-separated list of hostnames and/or IPs to include as SANs.
252+
- HAPROXY_AGENT_TLS_HOSTS=mjolnir,172.16.2.3,agent.example.com
253+
```
254+
255+
The agent regenerates the self-signed cert automatically when this list changes (adding a SAN the existing cert does not cover).
244256
245257
#### Option 2: Custom Certificates
246258

gearbox-agent/internal/framework/config/config.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ type Config struct {
1818
TLSKey string
1919
TLSCustom bool // True if user provided custom TLS cert paths
2020

21+
// TLSHosts are additional hostnames / IPs to include as SANs in the
22+
// auto-generated self-signed certificate. Ignored when TLSCustom is
23+
// true (the user-provided cert is used verbatim).
24+
//
25+
// The cert generator always covers "localhost", "127.0.0.1", and
26+
// "::1"; TLSHosts is for any other address clients will actually
27+
// dial (e.g. a static container IP, an FQDN behind a reverse proxy,
28+
// or both). The container's os.Hostname() is deliberately NOT added
29+
// automatically — in a container it's a random short ID that
30+
// changes per recreation, which would force needless cert
31+
// regeneration since LoadOrCreateTLSCert regenerates when the
32+
// existing cert is missing a requested SAN.
33+
//
34+
// Parsed from HAPROXY_AGENT_TLS_HOSTS as a comma-separated list;
35+
// whitespace around each entry is trimmed, empty entries are dropped.
36+
TLSHosts []string
37+
2138
// API key settings
2239
APIKeyPath string
2340

@@ -155,6 +172,7 @@ func Load() (*Config, error) {
155172
} else {
156173
cfg.TLSKey = cfg.DataDir + "/tls/server.key"
157174
}
175+
cfg.TLSHosts = parseCommaList(os.Getenv("HAPROXY_AGENT_TLS_HOSTS"))
158176
cfg.APIKeyPath = getEnvOrDefault("HAPROXY_AGENT_API_KEY_PATH", cfg.DataDir+"/api-key")
159177

160178
// Logging
@@ -278,6 +296,26 @@ func (c *Config) Validate() error {
278296
return nil
279297
}
280298

299+
// parseCommaList splits a comma-separated env value into a clean slice:
300+
// each entry is trimmed of surrounding whitespace and empty entries are
301+
// dropped. Returns nil for an empty/whitespace-only input.
302+
func parseCommaList(raw string) []string {
303+
if strings.TrimSpace(raw) == "" {
304+
return nil
305+
}
306+
parts := strings.Split(raw, ",")
307+
out := make([]string, 0, len(parts))
308+
for _, p := range parts {
309+
if v := strings.TrimSpace(p); v != "" {
310+
out = append(out, v)
311+
}
312+
}
313+
if len(out) == 0 {
314+
return nil
315+
}
316+
return out
317+
}
318+
281319
func getEnvOrDefault(key, defaultValue string) string {
282320
if v := os.Getenv(key); v != "" {
283321
return v

gearbox-agent/internal/framework/config/config_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,55 @@ func TestLoad_CustomTLS(t *testing.T) {
220220
}
221221
}
222222

223+
func TestLoad_TLSHosts(t *testing.T) {
224+
tests := []struct {
225+
name string
226+
envValue string
227+
want []string
228+
}{
229+
{"unset", "", nil},
230+
{"whitespace only", " ", nil},
231+
{"single host", "mjolnir", []string{"mjolnir"}},
232+
{"comma separated", "mjolnir,10.0.0.1,agent.local", []string{"mjolnir", "10.0.0.1", "agent.local"}},
233+
{"trims surrounding whitespace", " mjolnir , 10.0.0.1 ", []string{"mjolnir", "10.0.0.1"}},
234+
{"drops empty entries", "mjolnir,,10.0.0.1,", []string{"mjolnir", "10.0.0.1"}},
235+
}
236+
237+
saved := os.Getenv("HAPROXY_AGENT_TLS_HOSTS")
238+
defer func() {
239+
if saved != "" {
240+
os.Setenv("HAPROXY_AGENT_TLS_HOSTS", saved)
241+
} else {
242+
os.Unsetenv("HAPROXY_AGENT_TLS_HOSTS")
243+
}
244+
}()
245+
246+
for _, tt := range tests {
247+
t.Run(tt.name, func(t *testing.T) {
248+
if tt.envValue == "" {
249+
os.Unsetenv("HAPROXY_AGENT_TLS_HOSTS")
250+
} else {
251+
os.Setenv("HAPROXY_AGENT_TLS_HOSTS", tt.envValue)
252+
}
253+
254+
cfg, err := Load()
255+
if err != nil {
256+
t.Fatalf("Load() error = %v", err)
257+
}
258+
259+
if len(cfg.TLSHosts) != len(tt.want) {
260+
t.Fatalf("TLSHosts = %v (len %d), want %v (len %d)",
261+
cfg.TLSHosts, len(cfg.TLSHosts), tt.want, len(tt.want))
262+
}
263+
for i, v := range tt.want {
264+
if cfg.TLSHosts[i] != v {
265+
t.Errorf("TLSHosts[%d] = %q, want %q", i, cfg.TLSHosts[i], v)
266+
}
267+
}
268+
})
269+
}
270+
}
271+
223272
func TestValidate(t *testing.T) {
224273
tests := []struct {
225274
name string

gearbox-agent/internal/framework/crypto/crypto_test.go

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package crypto
22

33
import (
4+
"crypto/x509"
5+
"encoding/pem"
6+
"net"
47
"os"
58
"path/filepath"
69
"testing"
@@ -386,8 +389,134 @@ func TestGenerateSelfSignedCert_IncludesHosts(t *testing.T) {
386389
t.Fatalf("LoadOrCreateTLSCert() error = %v", err)
387390
}
388391

389-
// Verify the cert can be parsed (basic validation)
390-
if err := verifyCertReadable(certPath); err != nil {
391-
t.Errorf("Generated cert is not readable: %v", err)
392+
cert := loadCertForTest(t, certPath)
393+
394+
// Requested SANs must be present.
395+
if !containsString(cert.DNSNames, "example.com") {
396+
t.Errorf("cert DNSNames missing example.com: %v", cert.DNSNames)
397+
}
398+
if !containsIPString(cert.IPAddresses, "192.168.1.1") {
399+
t.Errorf("cert IPAddresses missing 192.168.1.1: %v", cert.IPAddresses)
400+
}
401+
402+
// Loopback SANs are always added.
403+
if !containsString(cert.DNSNames, "localhost") {
404+
t.Errorf("cert DNSNames missing localhost: %v", cert.DNSNames)
405+
}
406+
if !containsIPString(cert.IPAddresses, "127.0.0.1") {
407+
t.Errorf("cert IPAddresses missing 127.0.0.1: %v", cert.IPAddresses)
408+
}
409+
if !containsIPString(cert.IPAddresses, "::1") {
410+
t.Errorf("cert IPAddresses missing ::1: %v", cert.IPAddresses)
411+
}
412+
}
413+
414+
// TestLoadOrCreateTLSCert_RegenOnMissingSAN verifies that a previously
415+
// generated cert is regenerated when LoadOrCreateTLSCert is called with a
416+
// host that the existing cert does not cover. Without this behaviour an
417+
// operator who adds a new entry to HAPROXY_AGENT_TLS_HOSTS would have to
418+
// manually wipe the cert file before the change takes effect.
419+
func TestLoadOrCreateTLSCert_RegenOnMissingSAN(t *testing.T) {
420+
tmpDir := t.TempDir()
421+
certPath := filepath.Join(tmpDir, "server.crt")
422+
keyPath := filepath.Join(tmpDir, "server.key")
423+
424+
// Initial cert covers example.com only.
425+
_, isNew, err := LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com"})
426+
if err != nil {
427+
t.Fatalf("initial LoadOrCreateTLSCert() error = %v", err)
428+
}
429+
if !isNew {
430+
t.Fatal("initial call should report isNew = true")
431+
}
432+
originalSerial := loadCertForTest(t, certPath).SerialNumber.String()
433+
434+
// Same hosts → reuse, no regen.
435+
_, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com"})
436+
if err != nil {
437+
t.Fatalf("second LoadOrCreateTLSCert() error = %v", err)
438+
}
439+
if isNew {
440+
t.Error("call with same hosts should report isNew = false")
441+
}
442+
if loadCertForTest(t, certPath).SerialNumber.String() != originalSerial {
443+
t.Error("cert serial changed despite same hosts (unexpected regeneration)")
444+
}
445+
446+
// New host added → regenerate.
447+
_, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com", "172.16.2.3"})
448+
if err != nil {
449+
t.Fatalf("third LoadOrCreateTLSCert() error = %v", err)
450+
}
451+
if !isNew {
452+
t.Fatal("call with added host should report isNew = true")
453+
}
454+
regenerated := loadCertForTest(t, certPath)
455+
if regenerated.SerialNumber.String() == originalSerial {
456+
t.Error("cert serial unchanged after adding new host (regeneration did not happen)")
457+
}
458+
if !containsIPString(regenerated.IPAddresses, "172.16.2.3") {
459+
t.Errorf("regenerated cert missing 172.16.2.3 SAN: %v", regenerated.IPAddresses)
460+
}
461+
if !containsString(regenerated.DNSNames, "example.com") {
462+
t.Errorf("regenerated cert lost example.com SAN: %v", regenerated.DNSNames)
463+
}
464+
regeneratedSerial := regenerated.SerialNumber.String()
465+
466+
// Casing change on the same DNS name → reuse, no spurious regen
467+
// (RFC 6125 §6.4: DNS host matching is case-insensitive).
468+
_, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"EXAMPLE.com", "172.16.2.3"})
469+
if err != nil {
470+
t.Fatalf("fourth LoadOrCreateTLSCert() error = %v", err)
471+
}
472+
if isNew {
473+
t.Error("call differing only in DNS casing should not regenerate")
474+
}
475+
if loadCertForTest(t, certPath).SerialNumber.String() != regeneratedSerial {
476+
t.Error("cert serial changed across casing-only difference (unexpected regeneration)")
477+
}
478+
479+
// Trailing-dot equivalence (example.com. == example.com per DNS).
480+
_, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com.", "172.16.2.3"})
481+
if err != nil {
482+
t.Fatalf("fifth LoadOrCreateTLSCert() error = %v", err)
483+
}
484+
if isNew {
485+
t.Error("trailing-dot variant should not regenerate")
486+
}
487+
}
488+
489+
func loadCertForTest(t *testing.T, certPath string) *x509.Certificate {
490+
t.Helper()
491+
data, err := os.ReadFile(certPath)
492+
if err != nil {
493+
t.Fatalf("read cert: %v", err)
494+
}
495+
block, _ := pem.Decode(data)
496+
if block == nil {
497+
t.Fatal("failed to decode cert PEM")
498+
}
499+
cert, err := x509.ParseCertificate(block.Bytes)
500+
if err != nil {
501+
t.Fatalf("parse cert: %v", err)
502+
}
503+
return cert
504+
}
505+
506+
func containsString(haystack []string, needle string) bool {
507+
for _, v := range haystack {
508+
if v == needle {
509+
return true
510+
}
511+
}
512+
return false
513+
}
514+
515+
func containsIPString(haystack []net.IP, needle string) bool {
516+
for _, ip := range haystack {
517+
if ip.String() == needle {
518+
return true
519+
}
392520
}
521+
return false
393522
}

0 commit comments

Comments
 (0)