mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 11:11:30 +00:00
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:
@@ -144,7 +144,7 @@ flowchart TB
|
||||
| `renewal_policies` | Renewal window, auto-renew settings, retry config, alert thresholds |
|
||||
| `issuers` | CA configurations (Local CA, ACME, etc.) |
|
||||
| `deployment_targets` | Target systems (NGINX, F5, IIS) with agent assignments |
|
||||
| `agents` | Registered agents with heartbeat tracking |
|
||||
| `agents` | Registered agents with heartbeat tracking, OS/arch/IP metadata |
|
||||
| `jobs` | Issuance, renewal, deployment, and validation jobs |
|
||||
| `teams` | Organizational groups for certificate ownership |
|
||||
| `owners` | Individual owners with email for notifications |
|
||||
@@ -288,6 +288,8 @@ GET /ready Readiness check
|
||||
| Target | Status | Type |
|
||||
|--------|--------|------|
|
||||
| NGINX | Implemented | `NGINX` |
|
||||
| Apache httpd | Implemented | `Apache` |
|
||||
| HAProxy | Implemented | `HAProxy` |
|
||||
| F5 BIG-IP | Interface only (V2) | `F5` |
|
||||
| Microsoft IIS | Interface only (V2) | `IIS` |
|
||||
| Kubernetes Secrets | Planned | — |
|
||||
@@ -350,10 +352,14 @@ make docker-clean # Stop + remove volumes
|
||||
All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 11 views wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 220+ tests total: 170+ Go tests across service, handler, integration, and connector layers, plus 53 frontend Vitest tests covering API client functions and utility helpers. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
|
||||
|
||||
### V2: Operational Maturity
|
||||
- **V2.0: Operational Workflows** — ACME DNS-01 challenges (wildcard certs, custom validation scripts), step-ca, ADCS, and OpenSSL/custom CA issuer connectors, F5 BIG-IP, IIS, Apache httpd, and HAProxy target connector implementations, agent metadata collection (OS, platform, IP, hostname via heartbeat), dynamic device grouping for policy-based targeting, crypto policy enforcement, certificate ownership tracking, renewal approval UI, bulk cert operations, deployment timeline, real-time updates (SSE/WebSocket), target config wizard
|
||||
- **V2.1: Team Adoption** — OIDC/SSO, RBAC, CLI tool, Slack/Teams/PagerDuty/OpsGenie notifiers, bulk cert import
|
||||
- **V2.2: Observability** — expiration calendar, health scores, compliance scoring, Prometheus metrics, deployment rollback
|
||||
- **V2.3: Integrations & Distribution** — MCP server (OpenClaw/Claude/Cursor), CT Log monitoring, DigiCert issuer connector, filesystem cert discovery
|
||||
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
|
||||
- **M11: Policy + Ownership** — crypto policy enforcement (key algo/size validation), certificate ownership tracking, dynamic device grouping, renewal approval UI
|
||||
- **M12: DNS-01 + step-ca** — ACME DNS-01 challenges (wildcard certs, Cloudflare/Route53 adapters), step-ca issuer connector
|
||||
- **M13: GUI Operations** — bulk cert operations, deployment timeline, inline policy editor, target config wizard, audit export
|
||||
- **M14: Enterprise Connectors** — SSE/WebSocket real-time updates, F5 BIG-IP, IIS, ADCS, OpenSSL/Custom CA implementations
|
||||
- **M15: Team Adoption** — OIDC/SSO, RBAC, CLI tool, Slack/Teams/PagerDuty/OpsGenie notifiers, bulk cert import
|
||||
- **M16: Observability** — expiration calendar, health scores, compliance scoring, Prometheus metrics, deployment rollback
|
||||
- **M17: Integrations** — MCP server (OpenClaw/Claude/Cursor), CT Log monitoring, DigiCert issuer, filesystem cert discovery
|
||||
|
||||
### V3: Discovery, Visibility & Cloud
|
||||
Discovery engine (passive/active scanning, cert chain validation, Nmap/Qualys import, unknown cert detection, triage workflows), cloud targets (AWS ALB, Azure Key Vault, Palo Alto, FortiGate, Citrix ADC, Kubernetes Secrets), extended issuers (Entrust, GlobalSign, Google CAS, EJBCA, Vault PKI), ServiceNow integration, Ansible module, compliance mapping docs
|
||||
|
||||
+39
-3
@@ -14,16 +14,20 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
||||
)
|
||||
@@ -139,15 +143,29 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to the control plane.
|
||||
// getOutboundIP returns the preferred outbound IP address of this machine.
|
||||
func getOutboundIP() string {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer conn.Close()
|
||||
localAddr := conn.LocalAddr().(*net.UDPAddr)
|
||||
return localAddr.IP.String()
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to the control plane with agent metadata.
|
||||
// POST /api/v1/agents/{agentID}/heartbeat
|
||||
func (a *Agent) sendHeartbeat(ctx context.Context) {
|
||||
a.logger.Debug("sending heartbeat", "agent_id", a.config.AgentID)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/heartbeat", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, path, map[string]string{
|
||||
"version": "1.0.0",
|
||||
"hostname": a.config.Hostname,
|
||||
"version": "1.0.0",
|
||||
"hostname": a.config.Hostname,
|
||||
"os": runtime.GOOS,
|
||||
"architecture": runtime.GOARCH,
|
||||
"ip_address": getOutboundIP(),
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error("heartbeat failed", "error", err)
|
||||
@@ -489,6 +507,24 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
||||
}
|
||||
return nginx.New(&cfg, a.logger), nil
|
||||
|
||||
case "Apache":
|
||||
var cfg apache.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Apache config: %w", err)
|
||||
}
|
||||
}
|
||||
return apache.New(&cfg, a.logger), nil
|
||||
|
||||
case "HAProxy":
|
||||
var cfg haproxy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid HAProxy config: %w", err)
|
||||
}
|
||||
}
|
||||
return haproxy.New(&cfg, a.logger), nil
|
||||
|
||||
case "F5":
|
||||
var cfg f5.Config
|
||||
if len(configJSON) > 0 {
|
||||
|
||||
@@ -77,11 +77,11 @@ The server exposes a REST API under `/api/v1/` and optionally serves the web das
|
||||
|
||||
### Agents
|
||||
|
||||
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX fully implemented; Apache httpd, HAProxy planned for V2; F5 BIG-IP, IIS interface only with V2 implementations planned) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
||||
Lightweight Go processes that run on or near your infrastructure. Agents generate ECDSA P-256 private keys locally, create CSRs, and submit them to the control plane for signing — private keys never leave agent infrastructure. Agents also handle certificate deployment to target systems (NGINX, Apache httpd, HAProxy fully implemented; F5 BIG-IP, IIS interface only with V2 implementations planned) and report job status. They communicate with the control plane via HTTP and authenticate with API keys.
|
||||
|
||||
The agent runs two background loops: a heartbeat (every 60 seconds) to signal it's alive, and a work poll (every 30 seconds) to check for actionable jobs via `GET /api/v1/agents/{id}/work`. Jobs may be `AwaitingCSR` (agent needs to generate key + submit CSR) or `Deployment` (agent needs to deploy a certificate). Private keys are stored in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`) with 0600 permissions.
|
||||
|
||||
**Planned (V2):** Agent metadata collection — agents will report OS, platform, architecture, IP address, and hostname via heartbeat using `runtime.GOOS`, `runtime.GOARCH`, and `net` stdlib. This metadata enables dynamic device grouping, allowing policies to be scoped by agent criteria (e.g., all Ubuntu agents, all agents in a specific subnet) rather than requiring manual per-certificate assignment.
|
||||
**Agent metadata (M10):** Agents report OS, architecture, IP address, hostname, and version via heartbeat using `runtime.GOOS`, `runtime.GOARCH`, and `net` stdlib. This metadata is stored on the `agents` table and displayed in the GUI (agent list shows OS/Arch column, detail page shows full system info). This metadata enables future dynamic device grouping, allowing policies to be scoped by agent criteria (e.g., all Ubuntu agents, all agents in a specific subnet).
|
||||
|
||||
### Web Dashboard
|
||||
|
||||
@@ -425,9 +425,9 @@ type Connector interface {
|
||||
|
||||
The `DeploymentRequest` struct carries the full material needed by the target system: the signed certificate, the CA chain, the agent-generated private key, target-specific configuration, and arbitrary metadata. The key field is populated by the agent from its local key store (`CERTCTL_KEY_DIR`) — it never originates from the control plane.
|
||||
|
||||
Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **F5 BIG-IP** (interface only — iControl REST flow mapped, implementation planned), **IIS** (interface only — WinRM/PowerShell flow mapped, implementation planned).
|
||||
Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx -t`, reloads), **Apache httpd** (writes cert/chain/key files, validates with `apachectl configtest`, graceful reload), **HAProxy** (combined PEM file with cert+chain+key, validates config, reloads via systemctl/signal), **F5 BIG-IP** (interface only — iControl REST flow mapped, implementation planned V2), **IIS** (interface only — WinRM/PowerShell flow mapped, implementation planned V2).
|
||||
|
||||
**Planned targets (V2):** Apache httpd (file write, `apachectl configtest`, graceful reload), HAProxy (combined PEM file write, reload via socket/signal). **Planned targets (V3):** AWS ALB/CloudFront, Azure Key Vault, Palo Alto, FortiGate, Citrix ADC, Kubernetes Secrets.
|
||||
**Planned targets (V3):** AWS ALB/CloudFront, Azure Key Vault, Palo Alto, FortiGate, Citrix ADC, Kubernetes Secrets.
|
||||
|
||||
### Notifier Connector
|
||||
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@ Think of it like a notarized ID badge for a website. The badge says "I am api.ex
|
||||
|
||||
Every certificate has an expiration date, typically 90 days for Let's Encrypt or up to 1 year for commercial CAs. This isn't a bug — it's a security feature. Short lifetimes limit the damage if a private key is compromised, and they force organizations to prove they still control their domains.
|
||||
|
||||
The problem? When you have 5 certificates, tracking expiry dates is trivial. When you have 500 certificates spread across NGINX servers, F5 load balancers, and IIS boxes in three environments, it becomes a ticking time bomb. One missed renewal means a production outage — your site goes down, your API returns errors, and your customers see scary browser warnings.
|
||||
The problem? When you have 5 certificates, tracking expiry dates is trivial. When you have 500 certificates spread across NGINX servers, Apache instances, HAProxy load balancers, F5 appliances, and IIS boxes in three environments, it becomes a ticking time bomb. One missed renewal means a production outage — your site goes down, your API returns errors, and your customers see scary browser warnings.
|
||||
|
||||
**This is the core problem certctl solves**: automated tracking, renewal, and deployment of certificates across your entire infrastructure.
|
||||
|
||||
@@ -72,9 +72,11 @@ The flow looks like this:
|
||||
|
||||
At no point does the private key leave the agent. This is a fundamental security property.
|
||||
|
||||
Agents also report **metadata** about themselves — their operating system, CPU architecture, IP address, hostname, and version — with every heartbeat. This gives ops teams fleet-wide visibility (e.g., "how many agents are running on ARM?", "which agents are still on v1.0.0?") and is the foundation for future features like dynamic device grouping, where policies can be scoped to specific agent criteria like OS type or network subnet.
|
||||
|
||||
### Deployment Targets
|
||||
|
||||
Targets are the systems where certificates actually get installed — NGINX web servers, F5 BIG-IP load balancers, Microsoft IIS servers. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX config, calling the F5 REST API, running PowerShell commands on IIS via WinRM).
|
||||
Targets are the systems where certificates actually get installed — NGINX web servers, Apache httpd servers, HAProxy load balancers, F5 BIG-IP appliances, Microsoft IIS servers. Each target type has a **connector** that knows how to deploy certificates to that specific system (e.g., writing files and reloading NGINX or Apache config, building a combined PEM for HAProxy, calling the F5 REST API, running PowerShell commands on IIS via WinRM).
|
||||
|
||||
## The Certificate Lifecycle
|
||||
|
||||
|
||||
+39
-3
@@ -7,7 +7,7 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
Three types of connectors:
|
||||
|
||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA, ACME implemented; step-ca, ADCS, OpenSSL planned V2; DigiCert, Entrust, GlobalSign, EJBCA, Vault PKI, Google CAS planned V3)
|
||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX implemented; F5, IIS interface only; Apache httpd, HAProxy planned V2; AWS ALB, Azure Key Vault, Palo Alto, FortiGate, Citrix ADC, Kubernetes Secrets planned V3)
|
||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy implemented; F5, IIS interface only; AWS ALB, Azure Key Vault, Palo Alto, FortiGate, Citrix ADC, Kubernetes Secrets planned V3)
|
||||
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks; Slack, Teams, PagerDuty, OpsGenie planned V2.1)
|
||||
|
||||
All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents.
|
||||
@@ -280,7 +280,43 @@ The `reload_command` defaults to `systemctl reload nginx` but can be overridden
|
||||
|
||||
Location: `internal/connector/target/nginx/nginx.go`
|
||||
|
||||
### Planned: F5 BIG-IP (Interface Only)
|
||||
### Built-in: Apache httpd
|
||||
|
||||
The Apache httpd connector follows the same pattern as NGINX: it writes separate certificate, chain, and key files to disk, validates the Apache configuration with `apachectl configtest`, and performs a graceful reload. The key difference is that private keys are written with 0600 permissions (owner-only read) for security, while cert and chain files use 0644.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
"cert_path": "/etc/apache2/ssl/cert.pem",
|
||||
"chain_path": "/etc/apache2/ssl/chain.pem",
|
||||
"key_path": "/etc/apache2/ssl/key.pem",
|
||||
"reload_command": "apachectl graceful",
|
||||
"validate_command": "apachectl configtest"
|
||||
}
|
||||
```
|
||||
|
||||
The `reload_command` can be customized for different environments (e.g., `systemctl reload apache2` for systemd, `httpd -k graceful` for RHEL/CentOS). Validation output is captured and included in error messages for debugging.
|
||||
|
||||
Location: `internal/connector/target/apache/apache.go`
|
||||
|
||||
### Built-in: HAProxy
|
||||
|
||||
The HAProxy connector differs from NGINX and Apache because HAProxy expects all TLS material in a single combined PEM file (certificate + chain + private key concatenated). The connector builds this combined file, writes it with 0600 permissions (since it contains the private key), optionally validates the HAProxy configuration, and reloads.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
"pem_path": "/etc/haproxy/certs/site.pem",
|
||||
"reload_command": "systemctl reload haproxy",
|
||||
"validate_command": "haproxy -c -f /etc/haproxy/haproxy.cfg"
|
||||
}
|
||||
```
|
||||
|
||||
The combined PEM is built in this order: server certificate, intermediate/chain certificates, private key. The `validate_command` is optional — if omitted, the connector skips config validation and goes straight to reload.
|
||||
|
||||
Location: `internal/connector/target/haproxy/haproxy.go`
|
||||
|
||||
### Planned: F5 BIG-IP (V2, Interface Only)
|
||||
|
||||
The F5 BIG-IP target connector interface is built with the iControl REST flow mapped out, but the actual API calls are not yet implemented. The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status. Implementation is planned for V2.
|
||||
|
||||
@@ -297,7 +333,7 @@ Configuration (defined, not yet functional):
|
||||
|
||||
Location: `internal/connector/target/f5/f5.go`
|
||||
|
||||
### Planned: IIS (Interface Only)
|
||||
### Planned: IIS (V2, Interface Only)
|
||||
|
||||
The IIS target connector interface is built with the WinRM/PowerShell flow mapped out, but the actual remote execution is not yet implemented. The planned flow is: transfer a PFX bundle to the Windows server via WinRM, run `Import-PfxCertificate` to install it into the certificate store, and run `Set-WebBinding` to bind the certificate to the IIS site. Implementation is planned for V2.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type MockAgentService struct {
|
||||
ListAgentsFn func(page, perPage int) ([]domain.Agent, int64, error)
|
||||
GetAgentFn func(id string) (*domain.Agent, error)
|
||||
RegisterAgentFn func(agent domain.Agent) (*domain.Agent, error)
|
||||
HeartbeatFn func(agentID string) error
|
||||
HeartbeatFn func(agentID string, metadata *domain.AgentMetadata) error
|
||||
CSRSubmitFn func(agentID string, csrPEM string) (string, error)
|
||||
CSRSubmitForCertFn func(agentID string, certID string, csrPEM string) (string, error)
|
||||
CertificatePickupFn func(agentID, certID string) (string, error)
|
||||
@@ -46,9 +46,9 @@ func (m *MockAgentService) RegisterAgent(agent domain.Agent) (*domain.Agent, err
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockAgentService) Heartbeat(agentID string) error {
|
||||
func (m *MockAgentService) Heartbeat(agentID string, metadata *domain.AgentMetadata) error {
|
||||
if m.HeartbeatFn != nil {
|
||||
return m.HeartbeatFn(agentID)
|
||||
return m.HeartbeatFn(agentID, metadata)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func TestRegisterAgent_InvalidBody(t *testing.T) {
|
||||
// Test Heartbeat - success case
|
||||
func TestHeartbeat_Success(t *testing.T) {
|
||||
mock := &MockAgentService{
|
||||
HeartbeatFn: func(agentID string) error {
|
||||
HeartbeatFn: func(agentID string, metadata *domain.AgentMetadata) error {
|
||||
if agentID == "a-prod-001" {
|
||||
return nil
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func TestHeartbeat_Success(t *testing.T) {
|
||||
// Test Heartbeat - service error
|
||||
func TestHeartbeat_ServiceError(t *testing.T) {
|
||||
mock := &MockAgentService{
|
||||
HeartbeatFn: func(agentID string) error {
|
||||
HeartbeatFn: func(agentID string, metadata *domain.AgentMetadata) error {
|
||||
return ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type AgentService interface {
|
||||
ListAgents(page, perPage int) ([]domain.Agent, int64, error)
|
||||
GetAgent(id string) (*domain.Agent, error)
|
||||
RegisterAgent(agent domain.Agent) (*domain.Agent, error)
|
||||
Heartbeat(agentID string) error
|
||||
Heartbeat(agentID string, metadata *domain.AgentMetadata) error
|
||||
CSRSubmit(agentID string, csrPEM string) (string, error)
|
||||
CSRSubmitForCert(agentID string, certID string, csrPEM string) (string, error)
|
||||
CertificatePickup(agentID, certID string) (string, error)
|
||||
@@ -159,7 +159,30 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
agentID := parts[0]
|
||||
|
||||
if err := h.svc.Heartbeat(agentID); err != nil {
|
||||
// Parse optional metadata from request body
|
||||
var metadata *domain.AgentMetadata
|
||||
if r.Body != nil {
|
||||
var body struct {
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err == nil {
|
||||
if body.Version != "" || body.Hostname != "" || body.OS != "" || body.Architecture != "" || body.IPAddress != "" {
|
||||
metadata = &domain.AgentMetadata{
|
||||
Version: body.Version,
|
||||
Hostname: body.Hostname,
|
||||
OS: body.OS,
|
||||
Architecture: body.Architecture,
|
||||
IPAddress: body.IPAddress,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.Heartbeat(agentID, metadata); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package apache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the Apache httpd deployment target configuration.
|
||||
// This configuration is used on the agent side to deploy certificates to Apache.
|
||||
type Config struct {
|
||||
CertPath string `json:"cert_path"` // Path where cert will be written (e.g., /etc/apache2/ssl/cert.pem)
|
||||
KeyPath string `json:"key_path"` // Path where private key will be written
|
||||
ChainPath string `json:"chain_path"` // Path where CA chain will be written
|
||||
ReloadCommand string `json:"reload_command"` // Command to reload Apache (e.g., "apachectl graceful" or "systemctl reload apache2")
|
||||
ValidateCommand string `json:"validate_command"` // Command to validate Apache config (e.g., "apachectl configtest")
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Apache httpd servers.
|
||||
// This connector runs on the AGENT side and handles local certificate deployment.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Apache 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 Apache config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.CertPath == "" || cfg.ChainPath == "" {
|
||||
return fmt.Errorf("Apache cert_path and chain_path are required")
|
||||
}
|
||||
|
||||
if cfg.ReloadCommand == "" || cfg.ValidateCommand == "" {
|
||||
return fmt.Errorf("Apache reload_command and validate_command are required")
|
||||
}
|
||||
|
||||
c.logger.Info("validating Apache configuration",
|
||||
"cert_path", cfg.CertPath,
|
||||
"chain_path", cfg.ChainPath)
|
||||
|
||||
// Verify parent directory exists
|
||||
certDir := filepath.Dir(cfg.CertPath)
|
||||
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("Apache cert directory does not exist: %s", certDir)
|
||||
}
|
||||
|
||||
// Verify validate command works
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.logger.Warn("Apache config validation failed during config check",
|
||||
"error", err,
|
||||
"validate_command", cfg.ValidateCommand)
|
||||
// Don't fail; Apache might not be installed yet
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Apache configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate writes the certificate, key, and chain to configured paths
|
||||
// and reloads Apache to pick up the new certificates.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Write certificate to cert_path with mode 0644
|
||||
// 2. Write private key to key_path with mode 0600 (owner-only read)
|
||||
// 3. Write chain to chain_path with mode 0644
|
||||
// 4. Validate Apache configuration with configtest
|
||||
// 5. Execute graceful reload command
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to Apache httpd",
|
||||
"cert_path", c.config.CertPath,
|
||||
"chain_path", c.config.ChainPath)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Write certificate (0644: rw-r--r--)
|
||||
if err := os.WriteFile(c.config.CertPath, []byte(request.CertPEM), 0644); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
|
||||
c.logger.Error("certificate deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Write private key with secure permissions (0600: rw-------)
|
||||
if c.config.KeyPath != "" && request.KeyPEM != "" {
|
||||
if err := os.WriteFile(c.config.KeyPath, []byte(request.KeyPEM), 0600); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
||||
c.logger.Error("key deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.KeyPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Write chain (0644: rw-r--r--)
|
||||
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
||||
c.logger.Error("chain deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.ChainPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Validate Apache configuration before reload
|
||||
c.logger.Debug("validating Apache 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("Apache config validation failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("Apache validation failed", "error", err, "output", string(output))
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Graceful reload
|
||||
c.logger.Debug("reloading Apache", "reload_command", c.config.ReloadCommand)
|
||||
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||
if output, err := reloadCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("Apache reload failed: %v (output: %s)", err, string(output))
|
||||
c.logger.Error("Apache reload failed", "error", err, "output", string(output))
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
c.logger.Info("certificate deployed to Apache successfully",
|
||||
"duration", deploymentDuration.String(),
|
||||
"cert_path", c.config.CertPath)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: c.config.CertPath,
|
||||
DeploymentID: fmt.Sprintf("apache-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployed and Apache reloaded successfully",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"cert_path": c.config.CertPath,
|
||||
"chain_path": c.config.ChainPath,
|
||||
"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 Apache deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Validate Apache configuration
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
if output, err := validateCmd.CombinedOutput(); err != nil {
|
||||
errMsg := fmt.Sprintf("Apache 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.CertPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Verify certificate file exists and is readable
|
||||
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
c.logger.Info("Apache deployment validated successfully",
|
||||
"duration", validationDuration.String())
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: "Apache configuration valid and certificate accessible",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"validate_command": c.config.ValidateCommand,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package apache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||
)
|
||||
|
||||
func TestApacheConnector_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) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := apache.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "echo reload",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing cert_path", func(t *testing.T) {
|
||||
cfg := apache.Config{
|
||||
ChainPath: "/tmp/chain.pem",
|
||||
ReloadCommand: "echo reload",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert_path")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing reload_command", func(t *testing.T) {
|
||||
cfg := apache.Config{
|
||||
CertPath: "/tmp/cert.pem",
|
||||
ChainPath: "/tmp/chain.pem",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.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 := apache.New(&apache.Config{}, logger)
|
||||
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApacheConnector_DeployCertificate(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("successful deployment", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &apache.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "echo reload",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN EC PRIVATE KEY-----\ntest\n-----END EC PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
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 files were written
|
||||
certData, err := os.ReadFile(cfg.CertPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
}
|
||||
if string(certData) != req.CertPEM {
|
||||
t.Errorf("cert content mismatch")
|
||||
}
|
||||
|
||||
// Verify key has secure permissions
|
||||
info, err := os.Stat(cfg.KeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat key file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected key permissions 0600, got %v", info.Mode().Perm())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validate command fails", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &apache.Config{
|
||||
CertPath: filepath.Join(tmpDir, "cert.pem"),
|
||||
KeyPath: filepath.Join(tmpDir, "key.pem"),
|
||||
ChainPath: filepath.Join(tmpDir, "chain.pem"),
|
||||
ReloadCommand: "echo reload",
|
||||
ValidateCommand: "false", // always fails
|
||||
}
|
||||
|
||||
connector := apache.New(cfg, logger)
|
||||
|
||||
req := target.DeploymentRequest{
|
||||
CertPEM: "cert",
|
||||
ChainPEM: "chain",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when validate command fails")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApacheConnector_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()
|
||||
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||
os.WriteFile(certPath, []byte("cert"), 0644)
|
||||
|
||||
cfg := &apache.Config{
|
||||
CertPath: certPath,
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.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 cert file", func(t *testing.T) {
|
||||
cfg := &apache.Config{
|
||||
CertPath: "/nonexistent/cert.pem",
|
||||
ValidateCommand: "echo ok",
|
||||
}
|
||||
|
||||
connector := apache.New(cfg, logger)
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert file")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid result")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -37,6 +37,19 @@ type Agent struct {
|
||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
APIKeyHash string `json:"api_key_hash"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AgentMetadata contains runtime metadata reported by agents via heartbeat.
|
||||
type AgentMetadata struct {
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AgentStatus represents the operational status of an agent.
|
||||
@@ -60,7 +73,9 @@ const (
|
||||
type TargetType string
|
||||
|
||||
const (
|
||||
TargetTypeNGINX TargetType = "NGINX"
|
||||
TargetTypeF5 TargetType = "F5"
|
||||
TargetTypeIIS TargetType = "IIS"
|
||||
TargetTypeNGINX TargetType = "NGINX"
|
||||
TargetTypeApache TargetType = "Apache"
|
||||
TargetTypeHAProxy TargetType = "HAProxy"
|
||||
TargetTypeF5 TargetType = "F5"
|
||||
TargetTypeIIS TargetType = "IIS"
|
||||
)
|
||||
|
||||
@@ -684,7 +684,7 @@ func (m *mockAgentRepository) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentRepository) UpdateHeartbeat(ctx context.Context, id string) error {
|
||||
func (m *mockAgentRepository) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
|
||||
agent, ok := m.agents[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("agent not found")
|
||||
|
||||
@@ -69,8 +69,8 @@ type AgentRepository interface {
|
||||
Update(ctx context.Context, agent *domain.Agent) error
|
||||
// Delete removes an agent.
|
||||
Delete(ctx context.Context, id string) error
|
||||
// UpdateHeartbeat updates the agent's last heartbeat timestamp.
|
||||
UpdateHeartbeat(ctx context.Context, id string) error
|
||||
// UpdateHeartbeat updates the agent's last heartbeat timestamp and metadata.
|
||||
UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error
|
||||
// GetByAPIKey retrieves an agent by hashed API key.
|
||||
GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ func NewAgentRepository(db *sql.DB) *AgentRepository {
|
||||
// List returns all agents
|
||||
func (r *AgentRepository) List(ctx context.Context) ([]*domain.Agent, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash,
|
||||
os, architecture, ip_address, version
|
||||
FROM agents
|
||||
ORDER BY registered_at DESC
|
||||
`)
|
||||
@@ -52,7 +53,8 @@ func (r *AgentRepository) List(ctx context.Context) ([]*domain.Agent, error) {
|
||||
// Get retrieves an agent by ID
|
||||
func (r *AgentRepository) Get(ctx context.Context, id string) (*domain.Agent, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash,
|
||||
os, architecture, ip_address, version
|
||||
FROM agents
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
@@ -75,11 +77,13 @@ func (r *AgentRepository) Create(ctx context.Context, agent *domain.Agent) error
|
||||
}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash,
|
||||
os, architecture, ip_address, version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`, agent.ID, agent.Name, agent.Hostname, agent.Status, agent.LastHeartbeatAt,
|
||||
agent.RegisteredAt, agent.APIKeyHash).Scan(&agent.ID)
|
||||
agent.RegisteredAt, agent.APIKeyHash,
|
||||
agent.OS, agent.Architecture, agent.IPAddress, agent.Version).Scan(&agent.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create agent: %w", err)
|
||||
@@ -96,9 +100,14 @@ func (r *AgentRepository) Update(ctx context.Context, agent *domain.Agent) error
|
||||
hostname = $2,
|
||||
status = $3,
|
||||
last_heartbeat_at = $4,
|
||||
api_key_hash = $5
|
||||
WHERE id = $6
|
||||
`, agent.Name, agent.Hostname, agent.Status, agent.LastHeartbeatAt, agent.APIKeyHash, agent.ID)
|
||||
api_key_hash = $5,
|
||||
os = $6,
|
||||
architecture = $7,
|
||||
ip_address = $8,
|
||||
version = $9
|
||||
WHERE id = $10
|
||||
`, agent.Name, agent.Hostname, agent.Status, agent.LastHeartbeatAt, agent.APIKeyHash,
|
||||
agent.OS, agent.Architecture, agent.IPAddress, agent.Version, agent.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update agent: %w", err)
|
||||
@@ -136,11 +145,27 @@ func (r *AgentRepository) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateHeartbeat updates the agent's last heartbeat timestamp
|
||||
func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE agents SET last_heartbeat_at = $1 WHERE id = $2
|
||||
`, time.Now(), id)
|
||||
// UpdateHeartbeat updates the agent's last heartbeat timestamp and metadata
|
||||
func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
|
||||
var result sql.Result
|
||||
var err error
|
||||
|
||||
if metadata != nil {
|
||||
result, err = r.db.ExecContext(ctx, `
|
||||
UPDATE agents SET
|
||||
last_heartbeat_at = $1,
|
||||
hostname = CASE WHEN $3 = '' THEN hostname ELSE $3 END,
|
||||
os = CASE WHEN $4 = '' THEN os ELSE $4 END,
|
||||
architecture = CASE WHEN $5 = '' THEN architecture ELSE $5 END,
|
||||
ip_address = CASE WHEN $6 = '' THEN ip_address ELSE $6 END,
|
||||
version = CASE WHEN $7 = '' THEN version ELSE $7 END
|
||||
WHERE id = $2
|
||||
`, time.Now(), id, metadata.Hostname, metadata.OS, metadata.Architecture, metadata.IPAddress, metadata.Version)
|
||||
} else {
|
||||
result, err = r.db.ExecContext(ctx, `
|
||||
UPDATE agents SET last_heartbeat_at = $1 WHERE id = $2
|
||||
`, time.Now(), id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update heartbeat: %w", err)
|
||||
@@ -161,7 +186,8 @@ func (r *AgentRepository) UpdateHeartbeat(ctx context.Context, id string) error
|
||||
// GetByAPIKey retrieves an agent by hashed API key
|
||||
func (r *AgentRepository) GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash
|
||||
SELECT id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash,
|
||||
os, architecture, ip_address, version
|
||||
FROM agents
|
||||
WHERE api_key_hash = $1
|
||||
`, keyHash)
|
||||
@@ -183,7 +209,8 @@ func scanAgent(scanner interface {
|
||||
}) (*domain.Agent, error) {
|
||||
var agent domain.Agent
|
||||
err := scanner.Scan(&agent.ID, &agent.Name, &agent.Hostname, &agent.Status,
|
||||
&agent.LastHeartbeatAt, &agent.RegisteredAt, &agent.APIKeyHash)
|
||||
&agent.LastHeartbeatAt, &agent.RegisteredAt, &agent.APIKeyHash,
|
||||
&agent.OS, &agent.Architecture, &agent.IPAddress, &agent.Version)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan agent: %w", err)
|
||||
|
||||
@@ -81,15 +81,15 @@ func (s *AgentService) Register(ctx context.Context, name string, hostname strin
|
||||
return agent, apiKey, nil
|
||||
}
|
||||
|
||||
// HeartbeatWithContext updates an agent's last seen time and status.
|
||||
func (s *AgentService) HeartbeatWithContext(ctx context.Context, agentID string) error {
|
||||
// HeartbeatWithContext updates an agent's last seen time, status, and metadata.
|
||||
func (s *AgentService) HeartbeatWithContext(ctx context.Context, agentID string, metadata *domain.AgentMetadata) error {
|
||||
agent, err := s.agentRepo.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
// Update heartbeat
|
||||
if err := s.agentRepo.UpdateHeartbeat(ctx, agentID); err != nil {
|
||||
// Update heartbeat and metadata
|
||||
if err := s.agentRepo.UpdateHeartbeat(ctx, agentID, metadata); err != nil {
|
||||
return fmt.Errorf("failed to update heartbeat: %w", err)
|
||||
}
|
||||
|
||||
@@ -105,8 +105,8 @@ func (s *AgentService) HeartbeatWithContext(ctx context.Context, agentID string)
|
||||
}
|
||||
|
||||
// Heartbeat updates agent heartbeat (handler interface method).
|
||||
func (s *AgentService) Heartbeat(agentID string) error {
|
||||
return s.HeartbeatWithContext(context.Background(), agentID)
|
||||
func (s *AgentService) Heartbeat(agentID string, metadata *domain.AgentMetadata) error {
|
||||
return s.HeartbeatWithContext(context.Background(), agentID, metadata)
|
||||
}
|
||||
|
||||
// SubmitCSR validates and processes a Certificate Signing Request from an agent.
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestHeartbeat(t *testing.T) {
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||
|
||||
err := agentService.HeartbeatWithContext(ctx, "agent-001")
|
||||
err := agentService.HeartbeatWithContext(ctx, "agent-001", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Heartbeat failed: %v", err)
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func TestHeartbeat_NotFound(t *testing.T) {
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||
|
||||
err := agentService.HeartbeatWithContext(ctx, "nonexistent")
|
||||
err := agentService.HeartbeatWithContext(ctx, "nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent agent")
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@ func (m *mockAgentRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentRepo) UpdateHeartbeat(ctx context.Context, id string) error {
|
||||
func (m *mockAgentRepo) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
|
||||
if m.UpdateHeartbeatErr != nil {
|
||||
return m.UpdateHeartbeatErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Rollback: remove agent metadata columns
|
||||
|
||||
DROP INDEX IF EXISTS idx_agents_os;
|
||||
DROP INDEX IF EXISTS idx_agents_architecture;
|
||||
|
||||
ALTER TABLE agents DROP COLUMN IF EXISTS os;
|
||||
ALTER TABLE agents DROP COLUMN IF EXISTS architecture;
|
||||
ALTER TABLE agents DROP COLUMN IF EXISTS ip_address;
|
||||
ALTER TABLE agents DROP COLUMN IF EXISTS version;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Add agent metadata columns for M10: Agent Metadata + Targets
|
||||
-- Agents report OS, platform, architecture, and IP address via heartbeat
|
||||
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS os VARCHAR(100) DEFAULT '';
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS architecture VARCHAR(100) DEFAULT '';
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS ip_address VARCHAR(45) DEFAULT '';
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS version VARCHAR(50) DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_os ON agents(os);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_architecture ON agents(architecture);
|
||||
@@ -36,12 +36,12 @@ INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VA
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Agents
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash) VALUES
|
||||
('ag-web-prod', 'web-prod-agent', 'web-prod-01.internal', 'online', NOW() - INTERVAL '30 seconds', NOW() - INTERVAL '90 days', 'demo_hash_1'),
|
||||
('ag-web-staging', 'web-staging-agent', 'web-stg-01.internal', 'online', NOW() - INTERVAL '45 seconds', NOW() - INTERVAL '60 days', 'demo_hash_2'),
|
||||
('ag-lb-prod', 'lb-prod-agent', 'f5-prod-01.internal', 'online', NOW() - INTERVAL '15 seconds', NOW() - INTERVAL '120 days', 'demo_hash_3'),
|
||||
('ag-iis-prod', 'iis-prod-agent', 'iis-prod-01.internal', 'offline', NOW() - INTERVAL '3 hours', NOW() - INTERVAL '30 days', 'demo_hash_4'),
|
||||
('ag-data-prod', 'data-prod-agent', 'data-prod-01.internal', 'online', NOW() - INTERVAL '20 seconds', NOW() - INTERVAL '45 days', 'demo_hash_5')
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
|
||||
('ag-web-prod', 'web-prod-agent', 'web-prod-01.internal', 'online', NOW() - INTERVAL '30 seconds', NOW() - INTERVAL '90 days', 'demo_hash_1', 'linux', 'amd64', '10.0.1.10', '1.0.0'),
|
||||
('ag-web-staging', 'web-staging-agent', 'web-stg-01.internal', 'online', NOW() - INTERVAL '45 seconds', NOW() - INTERVAL '60 days', 'demo_hash_2', 'linux', 'amd64', '10.0.2.20', '1.0.0'),
|
||||
('ag-lb-prod', 'lb-prod-agent', 'f5-prod-01.internal', 'online', NOW() - INTERVAL '15 seconds', NOW() - INTERVAL '120 days', 'demo_hash_3', 'linux', 'amd64', '10.0.1.50', '1.0.0'),
|
||||
('ag-iis-prod', 'iis-prod-agent', 'iis-prod-01.internal', 'offline', NOW() - INTERVAL '3 hours', NOW() - INTERVAL '30 days', 'demo_hash_4', 'windows', 'amd64', '10.0.3.15', '1.0.0'),
|
||||
('ag-data-prod', 'data-prod-agent', 'data-prod-01.internal', 'online', NOW() - INTERVAL '20 seconds', NOW() - INTERVAL '45 days', 'demo_hash_5', 'linux', 'arm64', '10.0.4.30', '1.0.0')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Deployment Targets
|
||||
|
||||
@@ -39,11 +39,15 @@ export interface Agent {
|
||||
name: string;
|
||||
hostname: string;
|
||||
ip_address: string;
|
||||
os: string;
|
||||
architecture: string;
|
||||
status: string;
|
||||
version: string;
|
||||
last_heartbeat: string;
|
||||
last_heartbeat_at: string;
|
||||
capabilities: string[];
|
||||
tags: Record<string, string>;
|
||||
registered_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -93,11 +93,15 @@ export default function AgentDetailPage() {
|
||||
<InfoRow label="Updated" value={formatDateTime(agent.updated_at)} />
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
{/* System Info */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Capabilities & Tags</h3>
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">System Information</h3>
|
||||
<InfoRow label="Operating System" value={agent.os || '—'} />
|
||||
<InfoRow label="Architecture" value={agent.architecture || '—'} />
|
||||
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
|
||||
<InfoRow label="Agent Version" value={agent.version || '—'} />
|
||||
{agent.capabilities?.length ? (
|
||||
<div className="mb-4">
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-slate-400 mb-2">Capabilities</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agent.capabilities.map((c) => (
|
||||
@@ -105,11 +109,9 @@ export default function AgentDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 mb-4">No capabilities reported</p>
|
||||
)}
|
||||
) : null}
|
||||
{agent.tags && Object.keys(agent.tags).length > 0 ? (
|
||||
<div>
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-slate-400 mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(agent.tags).map(([k, v]) => (
|
||||
@@ -117,9 +119,7 @@ export default function AgentDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No tags</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function AgentsPage() {
|
||||
render: (a) => <StatusBadge status={a.status || heartbeatStatus(a.last_heartbeat)} />,
|
||||
},
|
||||
{ key: 'hostname', label: 'Hostname', render: (a) => <span className="text-slate-300 font-mono text-xs">{a.hostname || '—'}</span> },
|
||||
{ key: 'os', label: 'OS / Arch', render: (a) => <span className="text-slate-400 text-xs">{a.os && a.architecture ? `${a.os}/${a.architecture}` : a.os || '—'}</span> },
|
||||
{ key: 'ip', label: 'IP Address', render: (a) => <span className="text-slate-400 font-mono text-xs">{a.ip_address || '—'}</span> },
|
||||
{ key: 'version', label: 'Version', render: (a) => <span className="text-slate-400 text-xs">{a.version || '—'}</span> },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user