feat: M10 — agent metadata collection, Apache httpd + HAProxy target connectors

Agents now report OS, architecture, IP address, hostname, and version
via heartbeat using runtime.GOOS, runtime.GOARCH, and net.Dial. New
migration adds columns to agents table. Heartbeat handler, service,
and repository updated to accept and persist metadata. GUI shows
OS/Arch in agent list and full system info in agent detail page.

Apache httpd connector: separate cert/chain/key files, apachectl
configtest validation, graceful reload. HAProxy connector: combined
PEM file (cert+chain+key), optional config validation, reload.
Both wired into agent binary's target connector switch.

14 tests for new connectors. All existing tests updated for new
Heartbeat/UpdateHeartbeat signatures. Docs updated across README,
architecture, concepts, and connectors guides.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-20 02:19:28 -04:00
parent e06ea310a8
commit 07275bf92f
24 changed files with 1087 additions and 70 deletions
@@ -0,0 +1,214 @@
package haproxy
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Config represents the HAProxy deployment target configuration.
// HAProxy expects a combined PEM file containing the certificate, chain, and private key
// concatenated in a single file.
type Config struct {
PEMPath string `json:"pem_path"` // Path for combined PEM (cert + chain + key)
ReloadCommand string `json:"reload_command"` // Command to reload HAProxy (e.g., "systemctl reload haproxy")
ValidateCommand string `json:"validate_command"` // Command to validate config (e.g., "haproxy -c -f /etc/haproxy/haproxy.cfg")
}
// Connector implements the target.Connector interface for HAProxy servers.
// This connector runs on the AGENT side and handles local certificate deployment.
// HAProxy uses a combined PEM file (cert + chain + key) unlike NGINX/Apache which use
// separate files.
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new HAProxy target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that all required configuration paths and commands are valid.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid HAProxy config: %w", err)
}
if cfg.PEMPath == "" {
return fmt.Errorf("HAProxy pem_path is required")
}
if cfg.ReloadCommand == "" {
return fmt.Errorf("HAProxy reload_command is required")
}
c.logger.Info("validating HAProxy configuration",
"pem_path", cfg.PEMPath)
// Verify validate command works if provided
if cfg.ValidateCommand != "" {
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
if err := cmd.Run(); err != nil {
c.logger.Warn("HAProxy config validation failed during config check",
"error", err,
"validate_command", cfg.ValidateCommand)
// Don't fail; HAProxy might not be installed yet
}
}
c.config = &cfg
c.logger.Info("HAProxy configuration validated")
return nil
}
// DeployCertificate creates a combined PEM file (cert + chain + key) and reloads HAProxy.
//
// HAProxy requires all TLS material in a single file, concatenated in this order:
// 1. Server certificate
// 2. Intermediate/chain certificates
// 3. Private key
//
// Steps:
// 1. Build combined PEM (cert + chain + key)
// 2. Write to pem_path with mode 0600 (contains private key)
// 3. Optionally validate HAProxy configuration
// 4. Execute reload command
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to HAProxy",
"pem_path", c.config.PEMPath)
startTime := time.Now()
// Build combined PEM: cert + chain + key
combinedPEM := request.CertPEM + "\n"
if request.ChainPEM != "" {
combinedPEM += request.ChainPEM + "\n"
}
if request.KeyPEM != "" {
combinedPEM += request.KeyPEM + "\n"
}
// Write combined PEM with secure permissions (0600: contains private key)
if err := os.WriteFile(c.config.PEMPath, []byte(combinedPEM), 0600); err != nil {
errMsg := fmt.Sprintf("failed to write combined PEM: %v", err)
c.logger.Error("PEM deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.PEMPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
// Validate HAProxy configuration if validate command is configured
if c.config.ValidateCommand != "" {
c.logger.Debug("validating HAProxy configuration", "validate_command", c.config.ValidateCommand)
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
if output, err := validateCmd.CombinedOutput(); err != nil {
errMsg := fmt.Sprintf("HAProxy config validation failed: %v (output: %s)", err, string(output))
c.logger.Error("HAProxy validation failed", "error", err, "output", string(output))
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.PEMPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
}
// Reload HAProxy
c.logger.Debug("reloading HAProxy", "reload_command", c.config.ReloadCommand)
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
if output, err := reloadCmd.CombinedOutput(); err != nil {
errMsg := fmt.Sprintf("HAProxy reload failed: %v (output: %s)", err, string(output))
c.logger.Error("HAProxy reload failed", "error", err, "output", string(output))
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.PEMPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
deploymentDuration := time.Since(startTime)
c.logger.Info("certificate deployed to HAProxy successfully",
"duration", deploymentDuration.String(),
"pem_path", c.config.PEMPath)
return &target.DeploymentResult{
Success: true,
TargetAddress: c.config.PEMPath,
DeploymentID: fmt.Sprintf("haproxy-%d", time.Now().Unix()),
Message: "Combined PEM deployed and HAProxy reloaded successfully",
DeployedAt: time.Now(),
Metadata: map[string]string{
"pem_path": c.config.PEMPath,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating HAProxy deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial)
startTime := time.Now()
// Validate HAProxy configuration if command provided
if c.config.ValidateCommand != "" {
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
if output, err := validateCmd.CombinedOutput(); err != nil {
errMsg := fmt.Sprintf("HAProxy config validation failed: %v (output: %s)", err, string(output))
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.PEMPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
}
// Verify combined PEM file exists and is readable
if _, err := os.Stat(c.config.PEMPath); os.IsNotExist(err) {
errMsg := fmt.Sprintf("combined PEM file not found: %s", c.config.PEMPath)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.PEMPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
validationDuration := time.Since(startTime)
c.logger.Info("HAProxy deployment validated successfully",
"duration", validationDuration.String())
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: c.config.PEMPath,
Message: "HAProxy configuration valid and PEM accessible",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"pem_path": c.config.PEMPath,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}
@@ -0,0 +1,203 @@
package haproxy_test
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
)
func TestHAProxyConnector_ValidateConfig(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("valid config", func(t *testing.T) {
cfg := haproxy.Config{
PEMPath: "/tmp/haproxy/cert.pem",
ReloadCommand: "echo reload",
}
connector := haproxy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("missing pem_path", func(t *testing.T) {
cfg := haproxy.Config{
ReloadCommand: "echo reload",
}
connector := haproxy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("expected error for missing pem_path")
}
})
t.Run("missing reload_command", func(t *testing.T) {
cfg := haproxy.Config{
PEMPath: "/tmp/cert.pem",
}
connector := haproxy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("expected error for missing reload_command")
}
})
t.Run("invalid JSON", func(t *testing.T) {
connector := haproxy.New(&haproxy.Config{}, logger)
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
})
}
func TestHAProxyConnector_DeployCertificate(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("successful deployment with combined PEM", func(t *testing.T) {
tmpDir := t.TempDir()
pemPath := filepath.Join(tmpDir, "combined.pem")
cfg := &haproxy.Config{
PEMPath: pemPath,
ReloadCommand: "echo reload",
}
connector := haproxy.New(cfg, logger)
certPEM := "-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----"
chainPEM := "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----"
keyPEM := "-----BEGIN EC PRIVATE KEY-----\nkey\n-----END EC PRIVATE KEY-----"
req := target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
ChainPEM: chainPEM,
}
result, err := connector.DeployCertificate(ctx, req)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("expected success, got: %s", result.Message)
}
// Verify combined PEM was written
data, err := os.ReadFile(pemPath)
if err != nil {
t.Fatalf("failed to read PEM file: %v", err)
}
content := string(data)
if !strings.Contains(content, "cert") {
t.Error("combined PEM missing certificate")
}
if !strings.Contains(content, "chain") {
t.Error("combined PEM missing chain")
}
if !strings.Contains(content, "key") {
t.Error("combined PEM missing key")
}
// Verify secure permissions (contains private key)
info, err := os.Stat(pemPath)
if err != nil {
t.Fatalf("failed to stat PEM file: %v", err)
}
if info.Mode().Perm() != 0600 {
t.Errorf("expected PEM permissions 0600, got %v", info.Mode().Perm())
}
})
t.Run("reload command fails", func(t *testing.T) {
tmpDir := t.TempDir()
pemPath := filepath.Join(tmpDir, "combined.pem")
cfg := &haproxy.Config{
PEMPath: pemPath,
ReloadCommand: "false", // always fails
}
connector := haproxy.New(cfg, logger)
req := target.DeploymentRequest{
CertPEM: "cert",
}
result, err := connector.DeployCertificate(ctx, req)
if err == nil {
t.Fatal("expected error when reload command fails")
}
if result.Success {
t.Fatal("expected failure result")
}
})
}
func TestHAProxyConnector_ValidateDeployment(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("valid deployment", func(t *testing.T) {
tmpDir := t.TempDir()
pemPath := filepath.Join(tmpDir, "combined.pem")
os.WriteFile(pemPath, []byte("combined-pem-content"), 0600)
cfg := &haproxy.Config{
PEMPath: pemPath,
ReloadCommand: "echo reload",
ValidateCommand: "echo ok",
}
connector := haproxy.New(cfg, logger)
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123",
})
if err != nil {
t.Fatalf("ValidateDeployment failed: %v", err)
}
if !result.Valid {
t.Fatal("expected valid deployment")
}
})
t.Run("missing PEM file", func(t *testing.T) {
cfg := &haproxy.Config{
PEMPath: "/nonexistent/combined.pem",
ReloadCommand: "echo reload",
}
connector := haproxy.New(cfg, logger)
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123",
})
if err == nil {
t.Fatal("expected error for missing PEM file")
}
if result.Valid {
t.Fatal("expected invalid result")
}
})
}