mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 08:48:58 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 697c0be9f3 | |||
| 8f146e08d6 | |||
| e6088c79a3 | |||
| e19b8c95fe | |||
| 995b72df05 |
@@ -36,7 +36,7 @@ gantt
|
|||||||
47 days :crit, 2020-01-01, 47d
|
47 days :crit, 2020-01-01, 47d
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs 1,554+ tests with race detection, static analysis, and vulnerability scanning on every commit.
|
> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs the full test suite with race detection, static analysis, and vulnerability scanning on every commit.
|
||||||
|
|
||||||
## Why certctl Exists
|
## Why certctl Exists
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Certificate lifecycle tooling today falls into two camps: expensive enterprise p
|
|||||||
|
|
||||||
certctl fills that gap. It's **CA-agnostic** — plug in any certificate authority: Let's Encrypt via ACME, Smallstep step-ca, HashiCorp Vault PKI, DigiCert CertCentral, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. Run multiple issuers simultaneously for different certificate types.
|
certctl fills that gap. It's **CA-agnostic** — plug in any certificate authority: Let's Encrypt via ACME, Smallstep step-ca, HashiCorp Vault PKI, DigiCert CertCentral, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. Run multiple issuers simultaneously for different certificate types.
|
||||||
|
|
||||||
It's **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, and IIS (local PowerShell or remote WinRM) — all using the same pluggable connector model. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
It's **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (local PowerShell or remote WinRM), F5 BIG-IP (proxy agent), and any Linux/Unix server via SSH/SFTP — all using the same pluggable connector model. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||||
|
|
||||||
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [Why certctl?](docs/why-certctl.md)
|
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [Why certctl?](docs/why-certctl.md)
|
||||||
|
|
||||||
@@ -104,6 +104,7 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po
|
|||||||
| Dovecot | Implemented | `Dovecot` |
|
| Dovecot | Implemented | `Dovecot` |
|
||||||
| Microsoft IIS | Implemented (local + WinRM) | `IIS` |
|
| Microsoft IIS | Implemented (local + WinRM) | `IIS` |
|
||||||
| F5 BIG-IP | Beta | `F5` |
|
| F5 BIG-IP | Beta | `F5` |
|
||||||
|
| SSH (Agentless) | Beta | `SSH` |
|
||||||
|
|
||||||
### Notifiers
|
### Notifiers
|
||||||
| Notifier | Status | Type |
|
| Notifier | Status | Type |
|
||||||
@@ -294,7 +295,7 @@ CI runs on every push: `go vet`, `go test -race`, `golangci-lint`, `govulncheck`
|
|||||||
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
||||||
|
|
||||||
### V2: Operational Maturity — Shipped
|
### V2: Operational Maturity — Shipped
|
||||||
30+ milestones, 1,554+ tests. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01, step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS targets. RFC 5280 revocation with CRL + OCSP. Certificate profiles, ownership tracking, approval workflows. Filesystem and network certificate discovery. Prometheus metrics, dashboard charts, agent fleet overview. EST server (RFC 7030), ACME ARI (RFC 9702), certificate export, S/MIME support, Helm chart, MCP server, CLI, scheduled digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). See the [Feature Inventory](docs/features.md) for details.
|
30+ milestones, extensively tested with CI-enforced coverage gates. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01, step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS targets. RFC 5280 revocation with CRL + OCSP. Certificate profiles, ownership tracking, approval workflows. Filesystem and network certificate discovery. Prometheus metrics, dashboard charts, agent fleet overview. EST server (RFC 7030), ACME ARI (RFC 9702), certificate export, S/MIME support, Helm chart, MCP server, CLI, scheduled digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). See the [Feature Inventory](docs/features.md) for details.
|
||||||
|
|
||||||
**Coming in v2.1.0:** Dynamic issuer and target configuration via GUI (no env var restarts), first-run onboarding wizard.
|
**Coming in v2.1.0:** Dynamic issuer and target configuration via GUI (no env var restarts), first-run onboarding wizard.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2669,7 +2669,7 @@ components:
|
|||||||
# ─── Targets ─────────────────────────────────────────────────────
|
# ─── Targets ─────────────────────────────────────────────────────
|
||||||
TargetType:
|
TargetType:
|
||||||
type: string
|
type: string
|
||||||
enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5]
|
enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5, SSH]
|
||||||
|
|
||||||
DeploymentTarget:
|
DeploymentTarget:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
||||||
pf "github.com/shankar0123/certctl/internal/connector/target/postfix"
|
pf "github.com/shankar0123/certctl/internal/connector/target/postfix"
|
||||||
|
sshconn "github.com/shankar0123/certctl/internal/connector/target/ssh"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
||||||
@@ -647,6 +648,15 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
|
|||||||
}
|
}
|
||||||
return pf.New(&cfg, a.logger), nil
|
return pf.New(&cfg, a.logger), nil
|
||||||
|
|
||||||
|
case "SSH":
|
||||||
|
var cfg sshconn.Config
|
||||||
|
if len(configJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid SSH config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sshconn.New(&cfg, a.logger)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-164
@@ -16,15 +16,8 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/api/router"
|
"github.com/shankar0123/certctl/internal/api/router"
|
||||||
"github.com/shankar0123/certctl/internal/config"
|
"github.com/shankar0123/certctl/internal/config"
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
|
||||||
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
|
||||||
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
|
||||||
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
|
||||||
googlecasissuer "github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
|
||||||
sectigoissuer "github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
|
|
||||||
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
|
||||||
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
||||||
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
||||||
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
||||||
@@ -85,143 +78,18 @@ func main() {
|
|||||||
ownerRepo := postgres.NewOwnerRepository(db)
|
ownerRepo := postgres.NewOwnerRepository(db)
|
||||||
logger.Info("initialized all repositories")
|
logger.Info("initialized all repositories")
|
||||||
|
|
||||||
// Initialize Local CA issuer connector.
|
// Initialize dynamic issuer registry.
|
||||||
// In sub-CA mode (CERTCTL_CA_CERT_PATH + CERTCTL_CA_KEY_PATH set), loads a pre-signed
|
// Issuers are loaded from the database (with AES-GCM encrypted config).
|
||||||
// CA cert+key from disk. All issued certs chain to the upstream root (e.g., ADCS).
|
// On first boot with an empty database, env var issuers are seeded automatically.
|
||||||
// Otherwise, generates an ephemeral self-signed CA for development/demo.
|
var encryptionKey []byte
|
||||||
localCAConfig := &local.Config{}
|
if cfg.Encryption.ConfigEncryptionKey != "" {
|
||||||
if cfg.CA.CertPath != "" && cfg.CA.KeyPath != "" {
|
encryptionKey = crypto.DeriveKey(cfg.Encryption.ConfigEncryptionKey)
|
||||||
localCAConfig.CACertPath = cfg.CA.CertPath
|
logger.Info("config encryption enabled (AES-256-GCM)")
|
||||||
localCAConfig.CAKeyPath = cfg.CA.KeyPath
|
|
||||||
logger.Info("Local CA configured in sub-CA mode",
|
|
||||||
"cert_path", cfg.CA.CertPath,
|
|
||||||
"key_path", cfg.CA.KeyPath)
|
|
||||||
} else {
|
} else {
|
||||||
logger.Info("Local CA configured in self-signed mode (ephemeral)")
|
logger.Warn("CERTCTL_CONFIG_ENCRYPTION_KEY not set — issuer configs stored in plaintext (not recommended for production)")
|
||||||
}
|
|
||||||
localCA := local.New(localCAConfig, logger)
|
|
||||||
logger.Info("initialized Local CA issuer connector")
|
|
||||||
|
|
||||||
// Initialize ACME issuer connector (for Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, etc.)
|
|
||||||
// Supports HTTP-01 (default), DNS-01 (for wildcards), and DNS-PERSIST-01 (standing record) challenge types.
|
|
||||||
// EAB (External Account Binding) required by ZeroSSL, Google Trust Services, SSL.com.
|
|
||||||
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
|
||||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
|
||||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
|
||||||
EABKid: os.Getenv("CERTCTL_ACME_EAB_KID"),
|
|
||||||
EABHmac: os.Getenv("CERTCTL_ACME_EAB_HMAC"),
|
|
||||||
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
|
||||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
|
||||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
|
||||||
DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"),
|
|
||||||
Insecure: cfg.ACME.Insecure,
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized ACME issuer connector")
|
|
||||||
|
|
||||||
// Initialize step-ca issuer connector (for Smallstep private CA).
|
|
||||||
// Uses the native /sign API with JWK provisioner authentication.
|
|
||||||
stepcaConnector := stepcaissuer.New(&stepcaissuer.Config{
|
|
||||||
CAURL: os.Getenv("CERTCTL_STEPCA_URL"),
|
|
||||||
RootCertPath: os.Getenv("CERTCTL_STEPCA_ROOT_CERT"),
|
|
||||||
ProvisionerName: os.Getenv("CERTCTL_STEPCA_PROVISIONER"),
|
|
||||||
ProvisionerKeyPath: os.Getenv("CERTCTL_STEPCA_KEY_PATH"),
|
|
||||||
ProvisionerPassword: os.Getenv("CERTCTL_STEPCA_PASSWORD"),
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized step-ca issuer connector")
|
|
||||||
|
|
||||||
// Initialize OpenSSL/Custom CA issuer connector (for script-based CA integrations).
|
|
||||||
// Delegates certificate signing to user-provided scripts.
|
|
||||||
opensslConnector := opensslissuer.New(&opensslissuer.Config{
|
|
||||||
SignScript: os.Getenv("CERTCTL_OPENSSL_SIGN_SCRIPT"),
|
|
||||||
RevokeScript: os.Getenv("CERTCTL_OPENSSL_REVOKE_SCRIPT"),
|
|
||||||
CRLScript: os.Getenv("CERTCTL_OPENSSL_CRL_SCRIPT"),
|
|
||||||
TimeoutSeconds: getEnvIntDefault(os.Getenv("CERTCTL_OPENSSL_TIMEOUT_SECONDS"), 30),
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized OpenSSL/Custom CA issuer connector")
|
|
||||||
|
|
||||||
// Initialize Vault PKI issuer connector (for HashiCorp Vault internal PKI).
|
|
||||||
// Uses the Vault HTTP API with token authentication.
|
|
||||||
vaultConnector := vaultissuer.New(&vaultissuer.Config{
|
|
||||||
Addr: os.Getenv("CERTCTL_VAULT_ADDR"),
|
|
||||||
Token: os.Getenv("CERTCTL_VAULT_TOKEN"),
|
|
||||||
Mount: getEnvDefault("CERTCTL_VAULT_MOUNT", "pki"),
|
|
||||||
Role: os.Getenv("CERTCTL_VAULT_ROLE"),
|
|
||||||
TTL: getEnvDefault("CERTCTL_VAULT_TTL", "8760h"),
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized Vault PKI issuer connector")
|
|
||||||
|
|
||||||
// Initialize DigiCert CertCentral issuer connector (for enterprise public CA).
|
|
||||||
// Uses the DigiCert REST API with async order model.
|
|
||||||
digicertConnector := digicertissuer.New(&digicertissuer.Config{
|
|
||||||
APIKey: os.Getenv("CERTCTL_DIGICERT_API_KEY"),
|
|
||||||
OrgID: os.Getenv("CERTCTL_DIGICERT_ORG_ID"),
|
|
||||||
ProductType: getEnvDefault("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
|
|
||||||
BaseURL: getEnvDefault("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized DigiCert CertCentral issuer connector")
|
|
||||||
|
|
||||||
// Initialize Sectigo SCM issuer connector (for enterprise public CA).
|
|
||||||
// Uses the Sectigo SCM REST API with async order model.
|
|
||||||
sectigoConnector := sectigoissuer.New(§igoissuer.Config{
|
|
||||||
CustomerURI: cfg.Sectigo.CustomerURI,
|
|
||||||
Login: cfg.Sectigo.Login,
|
|
||||||
Password: cfg.Sectigo.Password,
|
|
||||||
OrgID: cfg.Sectigo.OrgID,
|
|
||||||
CertType: cfg.Sectigo.CertType,
|
|
||||||
Term: cfg.Sectigo.Term,
|
|
||||||
BaseURL: cfg.Sectigo.BaseURL,
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized Sectigo SCM issuer connector")
|
|
||||||
|
|
||||||
// Initialize Google CAS issuer connector (for GCP private CA).
|
|
||||||
// Uses the Google CAS REST API with OAuth2 service account auth.
|
|
||||||
googlecasConnector := googlecasissuer.New(&googlecasissuer.Config{
|
|
||||||
Project: cfg.GoogleCAS.Project,
|
|
||||||
Location: cfg.GoogleCAS.Location,
|
|
||||||
CAPool: cfg.GoogleCAS.CAPool,
|
|
||||||
Credentials: cfg.GoogleCAS.Credentials,
|
|
||||||
TTL: cfg.GoogleCAS.TTL,
|
|
||||||
}, logger)
|
|
||||||
logger.Info("initialized Google CAS issuer connector")
|
|
||||||
|
|
||||||
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
|
||||||
// "iss-local" matches the seed data issuer ID for the Local CA.
|
|
||||||
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
|
||||||
// "iss-stepca" is the step-ca private CA connector.
|
|
||||||
// "iss-openssl" is the custom CA/OpenSSL connector.
|
|
||||||
issuerRegistry := map[string]service.IssuerConnector{
|
|
||||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
|
||||||
"iss-acme-staging": service.NewIssuerConnectorAdapter(acmeConnector),
|
|
||||||
"iss-acme-prod": service.NewIssuerConnectorAdapter(acmeConnector),
|
|
||||||
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
|
|
||||||
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conditionally register Vault PKI (only if CERTCTL_VAULT_ADDR is set)
|
issuerRegistry := service.NewIssuerRegistry(logger)
|
||||||
if os.Getenv("CERTCTL_VAULT_ADDR") != "" {
|
|
||||||
issuerRegistry["iss-vault"] = service.NewIssuerConnectorAdapter(vaultConnector)
|
|
||||||
logger.Info("Vault PKI issuer registered", "id", "iss-vault")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conditionally register DigiCert (only if CERTCTL_DIGICERT_API_KEY is set)
|
|
||||||
if os.Getenv("CERTCTL_DIGICERT_API_KEY") != "" {
|
|
||||||
issuerRegistry["iss-digicert"] = service.NewIssuerConnectorAdapter(digicertConnector)
|
|
||||||
logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conditionally register Sectigo SCM (only if all 3 auth credentials are set)
|
|
||||||
if cfg.Sectigo.CustomerURI != "" && cfg.Sectigo.Login != "" && cfg.Sectigo.Password != "" {
|
|
||||||
issuerRegistry["iss-sectigo"] = service.NewIssuerConnectorAdapter(sectigoConnector)
|
|
||||||
logger.Info("Sectigo SCM issuer registered", "id", "iss-sectigo")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conditionally register Google CAS (only if project and credentials are set)
|
|
||||||
if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" {
|
|
||||||
issuerRegistry["iss-googlecas"] = service.NewIssuerConnectorAdapter(googlecasConnector)
|
|
||||||
logger.Info("Google CAS issuer registered", "id", "iss-googlecas")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
|
||||||
|
|
||||||
// Initialize revocation repository
|
// Initialize revocation repository
|
||||||
revocationRepo := postgres.NewRevocationRepository(db)
|
revocationRepo := postgres.NewRevocationRepository(db)
|
||||||
@@ -309,8 +177,15 @@ func main() {
|
|||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
agentService.SetProfileRepo(profileRepo)
|
agentService.SetProfileRepo(profileRepo)
|
||||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger)
|
||||||
targetService := service.NewTargetService(targetRepo, auditService)
|
|
||||||
|
// Seed issuers from env vars on first boot (empty database only), then build registry
|
||||||
|
issuerService.SeedFromEnvVars(context.Background(), cfg)
|
||||||
|
if err := issuerService.BuildRegistry(context.Background()); err != nil {
|
||||||
|
logger.Error("failed to build issuer registry from database", "error", err)
|
||||||
|
}
|
||||||
|
logger.Info("issuer registry loaded", "issuers", issuerRegistry.Len())
|
||||||
|
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
|
||||||
profileService := service.NewProfileService(profileRepo, auditService)
|
profileService := service.NewProfileService(profileRepo, auditService)
|
||||||
teamService := service.NewTeamService(teamRepo, auditService)
|
teamService := service.NewTeamService(teamRepo, auditService)
|
||||||
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
||||||
@@ -447,7 +322,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
// Register EST (RFC 7030) handlers if enabled
|
// Register EST (RFC 7030) handlers if enabled
|
||||||
if cfg.EST.Enabled {
|
if cfg.EST.Enabled {
|
||||||
issuerConn, ok := issuerRegistry[cfg.EST.IssuerID]
|
issuerConn, ok := issuerRegistry.Get(cfg.EST.IssuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
|
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -645,22 +520,3 @@ func main() {
|
|||||||
logger.Info("certctl server stopped")
|
logger.Info("certctl server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEnvDefault reads an environment variable with a default fallback.
|
|
||||||
func getEnvDefault(key, defaultVal string) string {
|
|
||||||
if val := os.Getenv(key); val != "" {
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// getEnvIntDefault parses an integer from a string with a default fallback.
|
|
||||||
func getEnvIntDefault(s string, defaultVal int) int {
|
|
||||||
if s == "" {
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
val, err := strconv.Atoi(s)
|
|
||||||
if err != nil {
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Demo mode: pre-populated dashboard with 15 certificates, 5 agents, issuers, etc.
|
||||||
|
# Use this to showcase certctl's dashboard with realistic data.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
||||||
|
#
|
||||||
|
# To start fresh (wipe previous data):
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.demo.yml down -v
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
volumes:
|
||||||
|
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/030_seed_demo.sql
|
||||||
@@ -45,8 +45,10 @@ services:
|
|||||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
||||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
||||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
||||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
|
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
||||||
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/015_seed_test.sql
|
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
||||||
|
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
||||||
|
- ../migrations/seed_test.sql:/docker-entrypoint-initdb.d/025_seed_test.sql
|
||||||
# No seed_demo.sql — start with a clean database for real testing
|
# No seed_demo.sql — start with a clean database for real testing
|
||||||
networks:
|
networks:
|
||||||
certctl-test:
|
certctl-test:
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ services:
|
|||||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
||||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
||||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
||||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
|
- ../migrations/000009_issuer_config.up.sql:/docker-entrypoint-initdb.d/009_issuer_config.sql
|
||||||
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql
|
- ../migrations/000010_target_config.up.sql:/docker-entrypoint-initdb.d/010_target_config.sql
|
||||||
|
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/020_seed.sql
|
||||||
networks:
|
networks:
|
||||||
- certctl-network
|
- certctl-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
+13
-13
@@ -94,6 +94,7 @@ flowchart TB
|
|||||||
T9["Postfix/Dovecot\n(file + service reload)"]
|
T9["Postfix/Dovecot\n(file + service reload)"]
|
||||||
T2["F5 BIG-IP\n(proxy agent + iControl REST)"]
|
T2["F5 BIG-IP\n(proxy agent + iControl REST)"]
|
||||||
T3["IIS\n(WinRM + local)"]
|
T3["IIS\n(WinRM + local)"]
|
||||||
|
T10["SSH\n(SFTP + reload)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
DASH --> API
|
DASH --> API
|
||||||
@@ -529,6 +530,7 @@ flowchart TB
|
|||||||
TI --> PO["Postfix/Dovecot"]
|
TI --> PO["Postfix/Dovecot"]
|
||||||
TI --> IIS["IIS"]
|
TI --> IIS["IIS"]
|
||||||
TI --> F5["F5 BIG-IP"]
|
TI --> F5["F5 BIG-IP"]
|
||||||
|
TI --> SC["SSH"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Notifier Connectors"
|
subgraph "Notifier Connectors"
|
||||||
@@ -604,7 +606,7 @@ Built-in targets: **NGINX** (writes cert/chain/key files, validates with `nginx
|
|||||||
|
|
||||||
After deployment, agents can perform **post-deployment TLS verification**: the agent probes the live TLS endpoint using `crypto/tls.DialWithDialer` and compares the SHA-256 fingerprint of the served certificate against what was deployed. Results are reported via `POST /api/v1/jobs/{id}/verify` and stored on the job record. Verification is best-effort — failures don't block or rollback deployments.
|
After deployment, agents can perform **post-deployment TLS verification**: the agent probes the live TLS endpoint using `crypto/tls.DialWithDialer` and compares the SHA-256 fingerprint of the served certificate against what was deployed. Results are reported via `POST /api/v1/jobs/{id}/verify` and stored on the job record. Verification is best-effort — failures don't block or rollback deployments.
|
||||||
|
|
||||||
Additional cloud, network, and Kubernetes target connectors are planned for future releases.
|
The SSH connector enables agentless deployment to any Linux/Unix server via SSH/SFTP, using the proxy agent pattern. Additional cloud, network, and Kubernetes target connectors are planned for future releases.
|
||||||
|
|
||||||
### Notifier Connector
|
### Notifier Connector
|
||||||
|
|
||||||
@@ -964,27 +966,25 @@ This data flow is pull-based and non-blocking. Agents discover at their own pace
|
|||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
||||||
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 1050+ tests across six layers (service, handler, integration, connector, frontend, and scheduler). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
|
certctl is extensively tested across eight layers with CI-enforced coverage gates that act as regression floors. The goal is high-confidence regression prevention at the service and handler layers (where the most complex business logic lives), combined with integration tests that exercise the full request path from HTTP to database.
|
||||||
|
|
||||||
**Service layer unit tests** (`internal/service/*_test.go`) — ~238 test functions across 15 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, certificate revocation (success, already-revoked, archived, invalid reason, all RFC 5280 reason codes, issuer notification, notification service integration, OCSP/CRL generation), agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers including revocation). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
|
**Service layer unit tests** (`internal/service/*_test.go`) — Mock-based tests across all service files covering certificate CRUD, revocation (all RFC 5280 reason codes, OCSP/CRL generation), agent lifecycle, job state machine, policy evaluation, renewal/issuance flow (both keygen modes), notification deduplication, team/owner/agent group CRUD, issuer service CRUD with connection testing, and the issuer connector adapter. Mock repositories are simple structs with function fields — no heavy mocking frameworks.
|
||||||
|
|
||||||
**Handler layer tests** (`internal/api/handler/*_test.go`) — ~257 test functions across 11 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (50 tests including revocation, DER CRL, and OCSP), agents (28 tests), jobs (21 tests including approve/reject), notifications (11 tests), policies (19 tests), profiles (18 tests), issuers (17 tests), targets (17 tests), agent groups (12 tests), teams (26 tests), and owners (21 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs, name length limits), error propagation from the service layer, method-not-allowed responses, and pagination parameters.
|
**Handler layer tests** (`internal/api/handler/*_test.go`) — Every handler file has a corresponding test file using Go's `httptest` package: certificates (including revocation, DER CRL, OCSP), agents, jobs (including approve/reject), notifications, policies, profiles, issuers, targets, agent groups, teams, owners, discovery, network scan, verification, export, EST, digest, stats, and metrics. Tests cover the happy path, input validation, error propagation, method-not-allowed, and pagination.
|
||||||
|
|
||||||
**Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths, 19 M11b endpoint tests, and 8 revocation endpoint tests (M15a+M15b): nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, expired certificate lifecycle, team/owner/agent group CRUD validation, revocation success, already-revoked rejection, not-found revocation, JSON CRL retrieval, DER CRL retrieval, OCSP response retrieval, and short-lived cert exemption. Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector. A third file, `e2e_test.go`, contains 8 cross-milestone test functions with 48+ subtests that exercise features across milestones end-to-end: M10 agent metadata via heartbeat, M11 profiles/teams/owners/agent-groups CRUD, M12 issuer registry verification, M13 GUI operation endpoints, M14 stats and metrics, M15 revocation and CRL, M16 notification channels, and M20 enhanced query API (sorting, cursor pagination, sparse fields, time-range filters).
|
**Integration tests** (`internal/integration/`) — Three test files exercising the full stack from HTTP request through router, handler, service, and repository layers. `lifecycle_test.go` covers the complete certificate lifecycle (team/owner creation through deployment and status reporting). `negative_test.go` covers error paths, endpoint validation, and revocation scenarios. `e2e_test.go` exercises cross-milestone features end-to-end (agent metadata, profiles, issuer registry, GUI operations, stats, revocation, notifications, enhanced query API).
|
||||||
|
|
||||||
**Frontend tests** (`web/src/api/client.test.ts`, `web/src/api/utils.test.ts`) — 86 Vitest tests covering the API client, stats/metrics endpoints, and utility functions. The API client tests mock `globalThis.fetch` and verify all endpoint functions (certificates, agents, jobs, policies, issuers, targets, notifications, audit, stats, metrics, health) send correct HTTP methods, URLs, headers, and request bodies. They also test API key management (store/retrieve/clear), auth header propagation, 401 event dispatching, and error handling (server messages, error fields, status text fallback). The stats/metrics endpoint tests verify correct query parameter handling and response shape validation. The utility tests use `vi.useFakeTimers()` for deterministic date testing and cover `formatDate`, `formatDateTime`, `timeAgo`, `daysUntil`, and `expiryColor`. The test environment uses jsdom with `@testing-library/jest-dom` matchers.
|
**Go integration tests** (`deploy/test/integration_test.go`) — Runs against the live Docker Compose test environment with real CA backends (Local CA, Pebble ACME, step-ca). Covers health checks, agent heartbeat, issuance, renewal, revocation, CRL/OCSP, EST enrollment, S/MIME, discovery, network scanning, and deployment verification using `crypto/x509` for cert parsing and `crypto/tls` for live TLS verification.
|
||||||
|
|
||||||
**CLI tests** (`internal/cli/client_test.go`) — 14 tests covering all 10 CLI subcommands with httptest mock servers, PEM parsing for bulk import, auth header verification, and JSON/table output formatting.
|
**Frontend tests** (`web/src/api/`) — Vitest tests covering the full API client (all endpoint functions with fetch mocking), stats/metrics endpoints, utility functions, and auth flows. Test environment uses jsdom with `@testing-library/jest-dom` matchers.
|
||||||
|
|
||||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, race detection, static analysis, vulnerability scanning, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs `go test -race` on service, handler, middleware, and scheduler packages to catch data races. It runs `golangci-lint` with 11 linters (errcheck, govet, staticcheck, unused, gosimple, ineffassign, typecheck, gocritic, gosec, bodyclose, noctx) configured in `.golangci.yml`. It runs `govulncheck ./...` to scan dependencies for known CVEs. Coverage thresholds are enforced per-layer: service 60%, handler 60%, domain 40%, middleware 50%. These thresholds act as regression floors — they can only go up. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, HAProxy, Traefik, and Caddy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
**Connector tests** (`internal/connector/`) — Issuer connectors (Local CA self-signed/sub-CA modes, ACME DNS-01/DNS-PERSIST-01, step-ca, OpenSSL, Vault PKI, DigiCert, Sectigo, Google CAS — all with httptest mock servers). Target connectors (NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS with mock PowerShell executor, F5 BIG-IP with mock iControl client, Postfix/Dovecot, SSH with mock SSH client). Notifier connectors (Slack, Teams, PagerDuty, OpsGenie).
|
||||||
|
|
||||||
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 10 tests for script-based DNS-01 and DNS-PERSIST-01 challenges (6 DNS-01 tests + 4 DNS-PERSIST-01 tests covering `PresentPersist` success, no-script error, script failure, and wildcard domain handling). The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Traefik and Caddy connectors have tests covering file-based deployment and (for Caddy) dual-mode API/file configuration. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
**Scheduler tests** (`internal/scheduler/scheduler_test.go`) — Idempotency guards (`sync/atomic.Bool`), `WaitForCompletion` success and timeout paths, and multi-loop concurrency safety.
|
||||||
|
|
||||||
**Scheduler tests** (`internal/scheduler/scheduler_test.go`) — Tests for idempotency guards (`sync/atomic.Bool` CompareAndSwap prevents concurrent loop ticks), `WaitForCompletion` success and timeout paths, and multi-loop idempotency.
|
**Fuzz tests** (`internal/validation/`, `internal/domain/`) — Go native fuzz tests for command validation (`ValidateShellCommand`, `ValidateDomainName`, `ValidateACMEToken`) and revocation domain parsing.
|
||||||
|
|
||||||
**Fuzz tests** (`internal/validation/command_fuzz_test.go`, `internal/domain/revocation_fuzz_test.go`) — Go native fuzz tests (`testing/fuzz`) for command validation functions and revocation domain parsing. These exercise `ValidateShellCommand`, `ValidateDomainName`, and `ValidateACMEToken` with random inputs to discover edge cases.
|
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs. Go: build, vet, `go test -race`, `golangci-lint` (11 linters), `govulncheck`, test with coverage, per-layer coverage threshold enforcement (service 60%, handler 60%, domain 40%, middleware 50%). Frontend: TypeScript type check, Vitest, Vite production build.
|
||||||
|
|
||||||
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests — a `testcontainers-go` scaffolding for isolated PostgreSQL instances is planned. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for V3). The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|
||||||
|
|||||||
+63
-1
@@ -26,6 +26,7 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
|||||||
- [Built-in: Caddy](#built-in-caddy)
|
- [Built-in: Caddy](#built-in-caddy)
|
||||||
- [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only)
|
- [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only)
|
||||||
- [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode)
|
- [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode)
|
||||||
|
- [SSH (Agentless Deployment)](#ssh-agentless-deployment)
|
||||||
4. [Notifier Connector](#notifier-connector)
|
4. [Notifier Connector](#notifier-connector)
|
||||||
- [Interface](#interface-2)
|
- [Interface](#interface-2)
|
||||||
5. [Registering a Connector](#registering-a-connector)
|
5. [Registering a Connector](#registering-a-connector)
|
||||||
@@ -54,7 +55,7 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
|||||||
Three types of connectors:
|
Three types of connectors:
|
||||||
|
|
||||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA, Vault PKI, DigiCert implemented; additional CA integrations planned)
|
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA, Vault PKI, DigiCert implemented; additional CA integrations planned)
|
||||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS implemented; F5 via proxy agent planned; additional cloud and network targets planned)
|
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5, SSH implemented; additional cloud and network targets planned)
|
||||||
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
||||||
|
|
||||||
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. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections.
|
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. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections.
|
||||||
@@ -809,6 +810,67 @@ The IIS target connector supports two deployment modes — agent-local (recommen
|
|||||||
|
|
||||||
Location: `internal/connector/target/iis/iis.go`, `internal/connector/target/iis/winrm.go`
|
Location: `internal/connector/target/iis/iis.go`, `internal/connector/target/iis/winrm.go`
|
||||||
|
|
||||||
|
### SSH (Agentless Deployment)
|
||||||
|
|
||||||
|
The SSH target connector enables agentless certificate deployment to any Linux/Unix server via SSH/SFTP. Instead of installing the certctl agent binary on every target, a single "proxy agent" in the same network zone deploys certificates to remote servers over SSH. This is ideal for environments where installing agents on every server is impractical.
|
||||||
|
|
||||||
|
**Key authentication (recommended):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "web-server.internal",
|
||||||
|
"port": 22,
|
||||||
|
"user": "certctl",
|
||||||
|
"auth_method": "key",
|
||||||
|
"private_key_path": "/home/certctl/.ssh/id_ed25519",
|
||||||
|
"cert_path": "/etc/ssl/certs/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/private/key.pem",
|
||||||
|
"chain_path": "/etc/ssl/certs/chain.pem",
|
||||||
|
"reload_command": "systemctl reload nginx",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Password authentication:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "legacy-server.internal",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "password",
|
||||||
|
"password": "s3cret",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
"reload_command": "systemctl reload apache2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `host` | string | *(required)* | SSH hostname or IP address |
|
||||||
|
| `port` | number | 22 | SSH port |
|
||||||
|
| `user` | string | *(required)* | SSH username |
|
||||||
|
| `auth_method` | string | `"key"` | `"key"` or `"password"` |
|
||||||
|
| `private_key_path` | string | | Path to SSH private key file (key auth) |
|
||||||
|
| `private_key` | string | | Inline SSH private key PEM (alternative to path) |
|
||||||
|
| `password` | string | | SSH password (password auth) |
|
||||||
|
| `passphrase` | string | | Passphrase for encrypted private keys |
|
||||||
|
| `cert_path` | string | *(required)* | Remote path for certificate file |
|
||||||
|
| `key_path` | string | *(required)* | Remote path for private key file |
|
||||||
|
| `chain_path` | string | | Remote path for chain file (if empty, chain appended to cert) |
|
||||||
|
| `cert_mode` | string | `"0644"` | File permissions for cert (octal) |
|
||||||
|
| `key_mode` | string | `"0600"` | File permissions for private key (octal) |
|
||||||
|
| `reload_command` | string | | Command to execute after deployment |
|
||||||
|
| `timeout` | number | 30 | SSH connection timeout in seconds |
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- Key-based authentication is recommended over password authentication
|
||||||
|
- Reload commands are validated against shell injection (same validation as Postfix/Dovecot connectors)
|
||||||
|
- Host field is regex-validated to prevent shell metacharacters
|
||||||
|
- Private keys are written with 0600 permissions by default
|
||||||
|
- Host key verification is intentionally skipped (same rationale as network scanner and F5 connector — deploying to known, operator-configured infrastructure)
|
||||||
|
- Encrypted private keys supported via passphrase
|
||||||
|
|
||||||
|
Location: `internal/connector/target/ssh/ssh.go`
|
||||||
|
|
||||||
## Notifier Connector
|
## Notifier Connector
|
||||||
|
|
||||||
Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations).
|
Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations).
|
||||||
|
|||||||
+5
-5
@@ -1286,11 +1286,11 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
- **Docker Tags** — `:latest`, `:v{version}` (`shankar0123.docker.scarf.sh/certctl-server`, `shankar0123.docker.scarf.sh/certctl-agent`)
|
- **Docker Tags** — `:latest`, `:v{version}` (`shankar0123.docker.scarf.sh/certctl-server`, `shankar0123.docker.scarf.sh/certctl-agent`)
|
||||||
|
|
||||||
### Test Suite
|
### Test Suite
|
||||||
- **Unit Tests** — 1,088+ test functions across service, handler, middleware, domain layers
|
- **Unit Tests** — Extensive coverage across service, handler, middleware, domain, and connector layers
|
||||||
- **Integration Tests** — End-to-end workflows (issuance→renewal→deployment)
|
- **Integration Tests** — End-to-end workflows (issuance→renewal→deployment) against live Docker Compose environment
|
||||||
- **Negative Tests** — Malformed input, nonexistent resources, error conditions
|
- **Negative Tests** — Malformed input, nonexistent resources, error conditions
|
||||||
- **Frontend Tests** — 211 Vitest tests (API client, utilities, stats/metrics, full endpoint coverage)
|
- **Frontend Tests** — Vitest suite covering API client, utilities, stats/metrics, and full endpoint coverage
|
||||||
- **Total Coverage** — 1,554+ tests (Go + frontend combined)
|
- **CI Gates** — Per-layer coverage thresholds (service 60%, handler 60%, domain 40%, middleware 50%), race detection, static analysis, vulnerability scanning
|
||||||
|
|
||||||
### Licensing
|
### Licensing
|
||||||
- **License** — Business Source License 1.1 (BSL 1.1)
|
- **License** — Business Source License 1.1 (BSL 1.1)
|
||||||
@@ -1492,6 +1492,6 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
|||||||
| **MCP Tools** | 76 (16 resource domains) |
|
| **MCP Tools** | 76 (16 resource domains) |
|
||||||
| **CLI Subcommands** | 10 |
|
| **CLI Subcommands** | 10 |
|
||||||
| **Database Tables** | 19 |
|
| **Database Tables** | 19 |
|
||||||
| **Test Suite** | 1,554+ tests (Go backend + frontend) |
|
| **Test Suite** | Extensively tested with CI-enforced coverage gates |
|
||||||
| **Environment Variables** | 41+ configuration options |
|
| **Environment Variables** | 41+ configuration options |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ The three differentiators above get the headlines, but the feature surface is wi
|
|||||||
|
|
||||||
**Full REST API** — 97 OpenAPI 3.1-documented operations. CLI tool with 10 subcommands. Helm chart for Kubernetes deployment. Scheduled certificate digest emails. Certificate export in PEM and PKCS#12. S/MIME support with EKU-aware issuance.
|
**Full REST API** — 97 OpenAPI 3.1-documented operations. CLI tool with 10 subcommands. Helm chart for Kubernetes deployment. Scheduled certificate digest emails. Certificate export in PEM and PKCS#12. S/MIME support with EKU-aware issuance.
|
||||||
|
|
||||||
**1,554 tests** — Go backend with race detection, static analysis (golangci-lint), and vulnerability scanning (govulncheck) on every commit. Frontend test suite. CI runs on every push.
|
**Extensively tested** — Go backend with race detection, static analysis (golangci-lint), and vulnerability scanning (govulncheck) on every commit. CI-enforced per-layer coverage thresholds. Frontend test suite. Every push is gated.
|
||||||
|
|
||||||
## How certctl Compares
|
## How certctl Compares
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/crypto v0.31.0
|
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
||||||
|
github.com/pkg/sftp v1.13.10
|
||||||
|
golang.org/x/crypto v0.41.0
|
||||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,11 +50,11 @@ require (
|
|||||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
|
github.com/kr/fs v0.1.0 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
|
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
|
||||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 // indirect
|
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
github.com/moby/sys/sequential v0.5.0 // indirect
|
github.com/moby/sys/sequential v0.5.0 // indirect
|
||||||
@@ -69,7 +71,7 @@ require (
|
|||||||
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
|
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/stretchr/testify v1.9.0 // indirect
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect
|
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
@@ -79,9 +81,9 @@ require (
|
|||||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||||
golang.org/x/net v0.23.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc
|
|||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
|
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||||
@@ -87,6 +89,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
|
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -121,6 +125,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
|
|||||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||||
|
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
@@ -150,8 +156,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
|
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
|
||||||
github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
|
github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
|
||||||
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0=
|
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0=
|
||||||
@@ -188,8 +194,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
@@ -202,8 +208,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -230,14 +236,14 @@ golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import (
|
|||||||
|
|
||||||
// MockTargetService is a mock implementation of TargetService interface.
|
// MockTargetService is a mock implementation of TargetService interface.
|
||||||
type MockTargetService struct {
|
type MockTargetService struct {
|
||||||
ListTargetsFn func(page, perPage int) ([]domain.DeploymentTarget, int64, error)
|
ListTargetsFn func(page, perPage int) ([]domain.DeploymentTarget, int64, error)
|
||||||
GetTargetFn func(id string) (*domain.DeploymentTarget, error)
|
GetTargetFn func(id string) (*domain.DeploymentTarget, error)
|
||||||
CreateTargetFn func(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
CreateTargetFn func(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||||
UpdateTargetFn func(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
UpdateTargetFn func(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||||
DeleteTargetFn func(id string) error
|
DeleteTargetFn func(id string) error
|
||||||
|
TestTargetConnectionFn func(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
func (m *MockTargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
||||||
@@ -55,6 +56,13 @@ func (m *MockTargetService) DeleteTarget(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockTargetService) TestTargetConnection(id string) error {
|
||||||
|
if m.TestTargetConnectionFn != nil {
|
||||||
|
return m.TestTargetConnectionFn(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestListTargets_Success(t *testing.T) {
|
func TestListTargets_Success(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
t1 := domain.DeploymentTarget{
|
t1 := domain.DeploymentTarget{
|
||||||
@@ -419,3 +427,69 @@ func TestDeleteTarget_EmptyID(t *testing.T) {
|
|||||||
t.Fatalf("expected status 400, got %d", w.Code)
|
t.Fatalf("expected status 400, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTestTargetConnection_Success(t *testing.T) {
|
||||||
|
mock := &MockTargetService{
|
||||||
|
TestTargetConnectionFn: func(id string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewTargetHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
|
||||||
|
req = req.WithContext(contextWithRequestID())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.TestTargetConnection(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["status"] != "success" {
|
||||||
|
t.Errorf("expected status 'success', got %v", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestTargetConnection_Failed(t *testing.T) {
|
||||||
|
mock := &MockTargetService{
|
||||||
|
TestTargetConnectionFn: func(id string) error {
|
||||||
|
return ErrMockServiceFailed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewTargetHandler(mock)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
|
||||||
|
req = req.WithContext(contextWithRequestID())
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.TestTargetConnection(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["status"] != "failed" {
|
||||||
|
t.Errorf("expected status 'failed', got %v", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestTargetConnection_MethodNotAllowed(t *testing.T) {
|
||||||
|
handler := NewTargetHandler(&MockTargetService{})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets/t-nginx-01/test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.TestTargetConnection(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("expected status 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type TargetService interface {
|
|||||||
CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||||
UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||||
DeleteTarget(id string) error
|
DeleteTarget(id string) error
|
||||||
|
TestTargetConnection(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// TargetHandler handles HTTP requests for deployment target operations.
|
// TargetHandler handles HTTP requests for deployment target operations.
|
||||||
@@ -189,3 +190,36 @@ func (h TargetHandler) DeleteTarget(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTargetConnection tests target connectivity by checking the assigned agent's heartbeat.
|
||||||
|
// POST /api/v1/targets/{id}/test
|
||||||
|
func (h TargetHandler) TestTargetConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
|
||||||
|
// Extract target ID from path: /api/v1/targets/{id}/test
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) < 2 || parts[0] == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := parts[0]
|
||||||
|
|
||||||
|
if err := h.svc.TestTargetConnection(id); err != nil {
|
||||||
|
JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Agent is online and reachable",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
|||||||
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget))
|
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget))
|
||||||
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget))
|
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget))
|
||||||
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget))
|
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget))
|
||||||
|
r.Register("POST /api/v1/targets/{id}/test", http.HandlerFunc(reg.Targets.TestTargetConnection))
|
||||||
|
|
||||||
// Agents routes: /api/v1/agents
|
// Agents routes: /api/v1/agents
|
||||||
r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents))
|
r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents))
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ type Config struct {
|
|||||||
Sectigo SectigoConfig
|
Sectigo SectigoConfig
|
||||||
GoogleCAS GoogleCASConfig
|
GoogleCAS GoogleCASConfig
|
||||||
Digest DigestConfig
|
Digest DigestConfig
|
||||||
|
Encryption EncryptionConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptionConfig contains configuration for encrypting sensitive data at rest.
|
||||||
|
type EncryptionConfig struct {
|
||||||
|
// ConfigEncryptionKey is the passphrase used to derive AES-256-GCM keys for encrypting
|
||||||
|
// issuer config secrets in the database. If empty, configs are stored in plaintext (development only).
|
||||||
|
ConfigEncryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifierConfig contains configuration for notification connectors.
|
// NotifierConfig contains configuration for notification connectors.
|
||||||
@@ -598,6 +606,9 @@ func Load() (*Config, error) {
|
|||||||
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
|
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
|
||||||
Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil),
|
Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil),
|
||||||
},
|
},
|
||||||
|
Encryption: EncryptionConfig{
|
||||||
|
ConfigEncryptionKey: getEnv("CERTCTL_CONFIG_ENCRYPTION_KEY", ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package issuer
|
||||||
|
|
||||||
|
// Factory has been moved to internal/connector/issuerfactory to avoid import cycles.
|
||||||
|
// See issuerfactory.NewFromConfig().
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package issuer
|
||||||
|
|
||||||
|
// Factory tests have been moved to internal/connector/issuerfactory.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package issuerfactory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewFromConfig instantiates an issuer connector from its type string and config JSON.
|
||||||
|
// The config JSON keys use snake_case matching the connector Config struct json tags.
|
||||||
|
// This replaces the manual wiring in cmd/server/main.go.
|
||||||
|
func NewFromConfig(issuerType string, configJSON json.RawMessage, logger *slog.Logger) (issuer.Connector, error) {
|
||||||
|
if len(configJSON) == 0 {
|
||||||
|
configJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch issuerType {
|
||||||
|
case "local", "GenericCA":
|
||||||
|
var cfg local.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Local CA config: %w", err)
|
||||||
|
}
|
||||||
|
return local.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "ACME":
|
||||||
|
var cfg acme.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid ACME config: %w", err)
|
||||||
|
}
|
||||||
|
return acme.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "StepCA":
|
||||||
|
var cfg stepca.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid step-ca config: %w", err)
|
||||||
|
}
|
||||||
|
return stepca.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "OpenSSL":
|
||||||
|
var cfg openssl.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid OpenSSL config: %w", err)
|
||||||
|
}
|
||||||
|
return openssl.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "VaultPKI":
|
||||||
|
var cfg vault.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Vault PKI config: %w", err)
|
||||||
|
}
|
||||||
|
return vault.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "DigiCert":
|
||||||
|
var cfg digicert.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid DigiCert config: %w", err)
|
||||||
|
}
|
||||||
|
return digicert.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "Sectigo":
|
||||||
|
var cfg sectigo.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Sectigo config: %w", err)
|
||||||
|
}
|
||||||
|
return sectigo.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
case "GoogleCAS":
|
||||||
|
var cfg googlecas.Config
|
||||||
|
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Google CAS config: %w", err)
|
||||||
|
}
|
||||||
|
return googlecas.New(&cfg, logger), nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown issuer type: %q", issuerType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package issuerfactory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_LocalCA(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"ca_common_name":"Test CA"}`)
|
||||||
|
conn, err := NewFromConfig("local", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(local) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_GenericCA_Alias(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{}`)
|
||||||
|
conn, err := NewFromConfig("GenericCA", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(GenericCA) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_ACME(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"directory_url":"https://acme-staging-v02.api.letsencrypt.org/directory","email":"test@example.com"}`)
|
||||||
|
conn, err := NewFromConfig("ACME", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(ACME) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_StepCA(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"ca_url":"https://ca.internal:9000","provisioner_name":"test"}`)
|
||||||
|
conn, err := NewFromConfig("StepCA", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(StepCA) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_OpenSSL(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"sign_script":"/path/to/sign.sh"}`)
|
||||||
|
conn, err := NewFromConfig("OpenSSL", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(OpenSSL) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_VaultPKI(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"addr":"https://vault:8200","token":"hvs.test","mount":"pki","role":"web","ttl":"8760h"}`)
|
||||||
|
conn, err := NewFromConfig("VaultPKI", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(VaultPKI) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_DigiCert(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"api_key":"test-key","org_id":"123","product_type":"ssl_basic"}`)
|
||||||
|
conn, err := NewFromConfig("DigiCert", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(DigiCert) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_Sectigo(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"customer_uri":"test-org","login":"api-user","password":"secret","org_id":1}`)
|
||||||
|
conn, err := NewFromConfig("Sectigo", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(Sectigo) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_GoogleCAS(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{"project":"my-project","location":"us-central1","ca_pool":"my-pool","credentials":"/path/to/creds.json"}`)
|
||||||
|
conn, err := NewFromConfig("GoogleCAS", cfg, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig(GoogleCAS) failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_UnknownType(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{}`)
|
||||||
|
_, err := NewFromConfig("UnknownCA", cfg, testLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_MalformedJSON(t *testing.T) {
|
||||||
|
cfg := json.RawMessage(`{invalid json}`)
|
||||||
|
_, err := NewFromConfig("ACME", cfg, testLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for malformed JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromConfig_EmptyConfig(t *testing.T) {
|
||||||
|
// Empty config should work — connectors have defaults
|
||||||
|
conn, err := NewFromConfig("local", nil, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFromConfig with nil config failed: %v", err)
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
// Package ssh implements a target.Connector for agentless certificate deployment
|
||||||
|
// via SSH/SFTP. This enables the "proxy agent" pattern — a certctl agent in the
|
||||||
|
// same network zone deploys certificates to remote servers without requiring the
|
||||||
|
// certctl agent binary on every target host.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
"github.com/shankar0123/certctl/internal/validation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the SSH deployment target configuration.
|
||||||
|
// Supports key-based and password-based authentication for agentless
|
||||||
|
// certificate deployment to any Linux/Unix server.
|
||||||
|
type Config struct {
|
||||||
|
Host string `json:"host"` // Required. SSH hostname or IP.
|
||||||
|
Port int `json:"port"` // Default: 22.
|
||||||
|
User string `json:"user"` // Required. SSH username.
|
||||||
|
AuthMethod string `json:"auth_method"` // "key" (default) or "password".
|
||||||
|
PrivateKeyPath string `json:"private_key_path"` // Path to SSH private key file (when auth_method="key").
|
||||||
|
PrivateKey string `json:"private_key"` // Inline SSH private key PEM (alternative to path).
|
||||||
|
Password string `json:"password"` // SSH password (when auth_method="password").
|
||||||
|
Passphrase string `json:"passphrase"` // Optional passphrase for encrypted private keys.
|
||||||
|
CertPath string `json:"cert_path"` // Required. Remote path for certificate file.
|
||||||
|
KeyPath string `json:"key_path"` // Required. Remote path for private key file.
|
||||||
|
ChainPath string `json:"chain_path"` // Optional. Remote path for chain file.
|
||||||
|
CertMode string `json:"cert_mode"` // File permissions for cert (default: "0644").
|
||||||
|
KeyMode string `json:"key_mode"` // File permissions for key (default: "0600").
|
||||||
|
ReloadCommand string `json:"reload_command"` // Optional. Command to run after deployment.
|
||||||
|
Timeout int `json:"timeout"` // SSH connection timeout in seconds (default: 30).
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHClient abstracts SSH/SFTP operations for testability.
|
||||||
|
// The real implementation uses golang.org/x/crypto/ssh + github.com/pkg/sftp.
|
||||||
|
// Tests inject a mock to verify behavior without a real SSH server.
|
||||||
|
type SSHClient interface {
|
||||||
|
// Connect establishes an SSH connection to the remote host.
|
||||||
|
Connect(ctx context.Context) error
|
||||||
|
// WriteFile writes data to a remote path with the given permissions.
|
||||||
|
WriteFile(remotePath string, data []byte, mode os.FileMode) error
|
||||||
|
// Execute runs a command on the remote server and returns combined output.
|
||||||
|
Execute(ctx context.Context, command string) (string, error)
|
||||||
|
// StatFile checks if a remote file exists and returns its size.
|
||||||
|
StatFile(remotePath string) (int64, error)
|
||||||
|
// Close closes the SSH connection.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the target.Connector interface for SSH/SFTP deployment.
|
||||||
|
// This connector runs on the AGENT side and handles remote certificate deployment
|
||||||
|
// to Linux/Unix servers without requiring the certctl agent binary on each target.
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
client SSHClient
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostRegex validates SSH hostnames (no shell metacharacters).
|
||||||
|
var hostRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||||
|
|
||||||
|
// permRegex validates octal permission strings like "0644" or "0600".
|
||||||
|
var permRegex = regexp.MustCompile(`^0[0-7]{3}$`)
|
||||||
|
|
||||||
|
// New creates a new SSH target connector with the given configuration and logger.
|
||||||
|
// Returns an error if the configuration is invalid.
|
||||||
|
func New(cfg *Config, logger *slog.Logger) (*Connector, error) {
|
||||||
|
applyDefaults(cfg)
|
||||||
|
client := &realSSHClient{config: cfg}
|
||||||
|
return &Connector{
|
||||||
|
config: cfg,
|
||||||
|
client: client,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithClient creates a new SSH target connector with an injectable SSH client.
|
||||||
|
// Used in tests to mock SSH/SFTP operations.
|
||||||
|
func NewWithClient(cfg *Config, client SSHClient, logger *slog.Logger) *Connector {
|
||||||
|
applyDefaults(cfg)
|
||||||
|
return &Connector{
|
||||||
|
config: cfg,
|
||||||
|
client: client,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDefaults fills in default values for unset config fields.
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 22
|
||||||
|
}
|
||||||
|
if cfg.AuthMethod == "" {
|
||||||
|
cfg.AuthMethod = "key"
|
||||||
|
}
|
||||||
|
if cfg.CertMode == "" {
|
||||||
|
cfg.CertMode = "0644"
|
||||||
|
}
|
||||||
|
if cfg.KeyMode == "" {
|
||||||
|
cfg.KeyMode = "0600"
|
||||||
|
}
|
||||||
|
if cfg.Timeout == 0 {
|
||||||
|
cfg.Timeout = 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig validates the SSH deployment target configuration.
|
||||||
|
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 SSH config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaults(&cfg)
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
if cfg.Host == "" {
|
||||||
|
return fmt.Errorf("SSH host is required")
|
||||||
|
}
|
||||||
|
if cfg.User == "" {
|
||||||
|
return fmt.Errorf("SSH user is required")
|
||||||
|
}
|
||||||
|
if cfg.CertPath == "" {
|
||||||
|
return fmt.Errorf("SSH cert_path is required")
|
||||||
|
}
|
||||||
|
if cfg.KeyPath == "" {
|
||||||
|
return fmt.Errorf("SSH key_path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate host (no shell metacharacters)
|
||||||
|
if !hostRegex.MatchString(cfg.Host) {
|
||||||
|
return fmt.Errorf("SSH host contains invalid characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth method validation
|
||||||
|
if cfg.AuthMethod != "key" && cfg.AuthMethod != "password" {
|
||||||
|
return fmt.Errorf("SSH auth_method must be \"key\" or \"password\", got %q", cfg.AuthMethod)
|
||||||
|
}
|
||||||
|
if cfg.AuthMethod == "key" {
|
||||||
|
if cfg.PrivateKeyPath == "" && cfg.PrivateKey == "" {
|
||||||
|
return fmt.Errorf("SSH key auth requires private_key_path or private_key")
|
||||||
|
}
|
||||||
|
// If path specified, verify file exists locally
|
||||||
|
if cfg.PrivateKeyPath != "" {
|
||||||
|
if _, err := os.Stat(cfg.PrivateKeyPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("SSH private key file not found: %s", cfg.PrivateKeyPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.AuthMethod == "password" && cfg.Password == "" {
|
||||||
|
return fmt.Errorf("SSH password auth requires password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file permissions
|
||||||
|
if !permRegex.MatchString(cfg.CertMode) {
|
||||||
|
return fmt.Errorf("SSH cert_mode must be octal (e.g., \"0644\"), got %q", cfg.CertMode)
|
||||||
|
}
|
||||||
|
if !permRegex.MatchString(cfg.KeyMode) {
|
||||||
|
return fmt.Errorf("SSH key_mode must be octal (e.g., \"0600\"), got %q", cfg.KeyMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reload command (if set) against shell injection
|
||||||
|
if cfg.ReloadCommand != "" {
|
||||||
|
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
||||||
|
return fmt.Errorf("SSH invalid reload_command: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
c.logger.Info("SSH configuration validated",
|
||||||
|
"host", cfg.Host,
|
||||||
|
"port", cfg.Port,
|
||||||
|
"user", cfg.User,
|
||||||
|
"auth_method", cfg.AuthMethod,
|
||||||
|
"cert_path", cfg.CertPath,
|
||||||
|
"key_path", cfg.KeyPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployCertificate deploys a certificate to the remote server via SSH/SFTP.
|
||||||
|
//
|
||||||
|
// Steps:
|
||||||
|
// 1. Connect to remote host via SSH
|
||||||
|
// 2. Write certificate (+ chain if chain_path not set) to cert_path
|
||||||
|
// 3. Write private key to key_path with restricted permissions
|
||||||
|
// 4. If chain_path is set and chain provided, write chain separately
|
||||||
|
// 5. If reload_command is set, execute it via SSH
|
||||||
|
// 6. Close connection
|
||||||
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||||
|
c.logger.Info("deploying certificate via SSH",
|
||||||
|
"host", c.config.Host,
|
||||||
|
"port", c.config.Port,
|
||||||
|
"cert_path", c.config.CertPath,
|
||||||
|
"key_path", c.config.KeyPath)
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
if err := c.client.Connect(ctx); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("SSH connection failed: %v", err)
|
||||||
|
c.logger.Error("SSH connection failed", "error", err, "host", c.config.Host)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
defer c.client.Close()
|
||||||
|
|
||||||
|
// Parse file permissions
|
||||||
|
certMode, _ := parsePermissions(c.config.CertMode)
|
||||||
|
keyMode, _ := parsePermissions(c.config.KeyMode)
|
||||||
|
|
||||||
|
// Build cert data: if chain_path not set, append chain to cert (fullchain)
|
||||||
|
certData := request.CertPEM
|
||||||
|
if request.ChainPEM != "" && c.config.ChainPath == "" {
|
||||||
|
certData += "\n" + request.ChainPEM
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write certificate
|
||||||
|
if err := c.client.WriteFile(c.config.CertPath, []byte(certData), certMode); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
|
||||||
|
c.logger.Error("certificate write failed", "error", err, "path", c.config.CertPath)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write private key (must have KeyPEM)
|
||||||
|
if request.KeyPEM == "" {
|
||||||
|
errMsg := "SSH deployment requires private key (KeyPEM)"
|
||||||
|
c.logger.Error("missing private key")
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
if err := c.client.WriteFile(c.config.KeyPath, []byte(request.KeyPEM), keyMode); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
||||||
|
c.logger.Error("key write failed", "error", err, "path", c.config.KeyPath)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write chain separately if chain_path configured
|
||||||
|
if c.config.ChainPath != "" && request.ChainPEM != "" {
|
||||||
|
if err := c.client.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), certMode); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
||||||
|
c.logger.Error("chain write failed", "error", err, "path", c.config.ChainPath)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute reload command if configured
|
||||||
|
if c.config.ReloadCommand != "" {
|
||||||
|
c.logger.Debug("executing reload command", "command", c.config.ReloadCommand)
|
||||||
|
output, err := c.client.Execute(ctx, c.config.ReloadCommand)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("reload command failed: %v (output: %s)", err, output)
|
||||||
|
c.logger.Error("reload command failed", "error", err, "output", output)
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentDuration := time.Since(startTime)
|
||||||
|
c.logger.Info("certificate deployed via SSH successfully",
|
||||||
|
"host", c.config.Host,
|
||||||
|
"duration", deploymentDuration.String(),
|
||||||
|
"cert_path", c.config.CertPath)
|
||||||
|
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: true,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
DeploymentID: fmt.Sprintf("ssh-%s-%d", c.config.Host, time.Now().Unix()),
|
||||||
|
Message: fmt.Sprintf("Certificate deployed via SSH to %s", c.config.Host),
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"host": c.config.Host,
|
||||||
|
"cert_path": c.config.CertPath,
|
||||||
|
"key_path": c.config.KeyPath,
|
||||||
|
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDeployment verifies that the deployed certificate files exist on the remote server.
|
||||||
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||||
|
c.logger.Info("validating SSH deployment",
|
||||||
|
"host", c.config.Host,
|
||||||
|
"certificate_id", request.CertificateID,
|
||||||
|
"serial", request.Serial)
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
if err := c.client.Connect(ctx); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("SSH connection failed during validation: %v", err)
|
||||||
|
c.logger.Error("SSH connection failed", "error", err)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
defer c.client.Close()
|
||||||
|
|
||||||
|
// Verify cert file exists
|
||||||
|
if _, err := c.client.StatFile(c.config.CertPath); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("certificate file not found on remote: %s (%v)", c.config.CertPath, err)
|
||||||
|
c.logger.Error("validation failed", "error", err)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify key file exists
|
||||||
|
if _, err := c.client.StatFile(c.config.KeyPath); err != nil {
|
||||||
|
errMsg := fmt.Sprintf("key file not found on remote: %s (%v)", c.config.KeyPath, err)
|
||||||
|
c.logger.Error("validation failed", "error", err)
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: false,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: errMsg,
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
validationDuration := time.Since(startTime)
|
||||||
|
c.logger.Info("SSH deployment validated successfully",
|
||||||
|
"host", c.config.Host,
|
||||||
|
"duration", validationDuration.String())
|
||||||
|
|
||||||
|
return &target.ValidationResult{
|
||||||
|
Valid: true,
|
||||||
|
Serial: request.Serial,
|
||||||
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||||
|
Message: "Certificate and key files accessible on remote server",
|
||||||
|
ValidatedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"host": c.config.Host,
|
||||||
|
"cert_path": c.config.CertPath,
|
||||||
|
"key_path": c.config.KeyPath,
|
||||||
|
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePermissions converts an octal permission string like "0644" to os.FileMode.
|
||||||
|
func parsePermissions(s string) (os.FileMode, error) {
|
||||||
|
var mode uint32
|
||||||
|
_, err := fmt.Sscanf(s, "%o", &mode)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid permission string %q: %w", s, err)
|
||||||
|
}
|
||||||
|
return os.FileMode(mode), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Real SSH client implementation ---
|
||||||
|
|
||||||
|
// realSSHClient implements SSHClient using golang.org/x/crypto/ssh + github.com/pkg/sftp.
|
||||||
|
type realSSHClient struct {
|
||||||
|
config *Config
|
||||||
|
sshClient *ssh.Client
|
||||||
|
sftpClient *sftp.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect establishes an SSH connection to the remote host.
|
||||||
|
func (c *realSSHClient) Connect(ctx context.Context) error {
|
||||||
|
authMethods, err := c.buildAuthMethods()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to build SSH auth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConfig := &ssh.ClientConfig{
|
||||||
|
User: c.config.User,
|
||||||
|
Auth: authMethods,
|
||||||
|
Timeout: time.Duration(c.config.Timeout) * time.Second,
|
||||||
|
// InsecureIgnoreHostKey is used intentionally: certctl deploys to known
|
||||||
|
// infrastructure (the operator explicitly configures each target host).
|
||||||
|
// This is the same security rationale as network scanner's InsecureSkipVerify
|
||||||
|
// and F5 connector's insecure flag. Host key verification would require
|
||||||
|
// an additional known_hosts management layer that is out of scope.
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", c.config.Port))
|
||||||
|
|
||||||
|
// Use net.DialTimeout for context-aware connection (context cancellation
|
||||||
|
// is handled by the timeout on the SSH client config)
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, sshConfig.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("TCP connection to %s failed: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("SSH handshake with %s failed: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sshClient = ssh.NewClient(sshConn, chans, reqs)
|
||||||
|
|
||||||
|
// Open SFTP session
|
||||||
|
c.sftpClient, err = sftp.NewClient(c.sshClient)
|
||||||
|
if err != nil {
|
||||||
|
c.sshClient.Close()
|
||||||
|
c.sshClient = nil
|
||||||
|
return fmt.Errorf("SFTP session failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAuthMethods constructs SSH auth methods from the config.
|
||||||
|
func (c *realSSHClient) buildAuthMethods() ([]ssh.AuthMethod, error) {
|
||||||
|
switch c.config.AuthMethod {
|
||||||
|
case "password":
|
||||||
|
return []ssh.AuthMethod{ssh.Password(c.config.Password)}, nil
|
||||||
|
|
||||||
|
case "key":
|
||||||
|
var keyData []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if c.config.PrivateKey != "" {
|
||||||
|
keyData = []byte(c.config.PrivateKey)
|
||||||
|
} else if c.config.PrivateKeyPath != "" {
|
||||||
|
keyData, err = os.ReadFile(c.config.PrivateKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read private key %s: %w", c.config.PrivateKeyPath, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("key auth requires private_key or private_key_path")
|
||||||
|
}
|
||||||
|
|
||||||
|
var signer ssh.Signer
|
||||||
|
if c.config.Passphrase != "" {
|
||||||
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(c.config.Passphrase))
|
||||||
|
} else {
|
||||||
|
signer, err = ssh.ParsePrivateKey(keyData)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported auth method: %s", c.config.AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile writes data to a remote path via SFTP with the given permissions.
|
||||||
|
func (c *realSSHClient) WriteFile(remotePath string, data []byte, mode os.FileMode) error {
|
||||||
|
if c.sftpClient == nil {
|
||||||
|
return fmt.Errorf("SFTP client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := c.sftpClient.Create(remotePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create remote file %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return fmt.Errorf("failed to write remote file %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close remote file %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set file permissions
|
||||||
|
if err := c.sftpClient.Chmod(remotePath, mode); err != nil {
|
||||||
|
return fmt.Errorf("failed to set permissions on %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs a command on the remote server and returns combined output.
|
||||||
|
func (c *realSSHClient) Execute(ctx context.Context, command string) (string, error) {
|
||||||
|
if c.sshClient == nil {
|
||||||
|
return "", fmt.Errorf("SSH client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := c.sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create SSH session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
output, err := session.CombinedOutput(command)
|
||||||
|
return string(output), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatFile checks if a remote file exists and returns its size.
|
||||||
|
func (c *realSSHClient) StatFile(remotePath string) (int64, error) {
|
||||||
|
if c.sftpClient == nil {
|
||||||
|
return 0, fmt.Errorf("SFTP client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := c.sftpClient.Stat(remotePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to stat remote file %s: %w", remotePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the SFTP and SSH connections.
|
||||||
|
func (c *realSSHClient) Close() error {
|
||||||
|
if c.sftpClient != nil {
|
||||||
|
c.sftpClient.Close()
|
||||||
|
c.sftpClient = nil
|
||||||
|
}
|
||||||
|
if c.sshClient != nil {
|
||||||
|
c.sshClient.Close()
|
||||||
|
c.sshClient = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testLogger returns a slog.Logger for test output.
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock SSH Client ---
|
||||||
|
|
||||||
|
// mockSSHClient records all calls and returns configurable results.
|
||||||
|
type mockSSHClient struct {
|
||||||
|
connectCalls int
|
||||||
|
connectErr error
|
||||||
|
writeFileCalls []writeFileCall
|
||||||
|
writeFileErr error
|
||||||
|
executeCalls []string
|
||||||
|
executeOutput string
|
||||||
|
executeErr error
|
||||||
|
statFileCalls []string
|
||||||
|
statFileSize int64
|
||||||
|
statFileErr error
|
||||||
|
closeCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeFileCall struct {
|
||||||
|
Path string
|
||||||
|
Data []byte
|
||||||
|
Mode os.FileMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) Connect(ctx context.Context) error {
|
||||||
|
m.connectCalls++
|
||||||
|
return m.connectErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) WriteFile(remotePath string, data []byte, mode os.FileMode) error {
|
||||||
|
m.writeFileCalls = append(m.writeFileCalls, writeFileCall{Path: remotePath, Data: data, Mode: mode})
|
||||||
|
return m.writeFileErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) Execute(ctx context.Context, command string) (string, error) {
|
||||||
|
m.executeCalls = append(m.executeCalls, command)
|
||||||
|
return m.executeOutput, m.executeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) StatFile(remotePath string) (int64, error) {
|
||||||
|
m.statFileCalls = append(m.statFileCalls, remotePath)
|
||||||
|
return m.statFileSize, m.statFileErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSSHClient) Close() error {
|
||||||
|
m.closeCalls++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateConfig tests ---
|
||||||
|
|
||||||
|
func TestValidateConfig_Success_KeyAuth(t *testing.T) {
|
||||||
|
// Create a temporary key file
|
||||||
|
keyFile := createTempKeyFile(t)
|
||||||
|
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.example.com",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "key",
|
||||||
|
"private_key_path": keyFile,
|
||||||
|
"cert_path": "/etc/ssl/certs/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/private/key.pem",
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.Port != 22 {
|
||||||
|
t.Errorf("expected default port 22, got %d", c.config.Port)
|
||||||
|
}
|
||||||
|
if c.config.CertMode != "0644" {
|
||||||
|
t.Errorf("expected default cert_mode 0644, got %s", c.config.CertMode)
|
||||||
|
}
|
||||||
|
if c.config.KeyMode != "0600" {
|
||||||
|
t.Errorf("expected default key_mode 0600, got %s", c.config.KeyMode)
|
||||||
|
}
|
||||||
|
if c.config.Timeout != 30 {
|
||||||
|
t.Errorf("expected default timeout 30, got %d", c.config.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_Success_InlineKey(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "10.0.0.5",
|
||||||
|
"user": "root",
|
||||||
|
"auth_method": "key",
|
||||||
|
"private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_Success_PasswordAuth(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "password",
|
||||||
|
"password": "s3cret",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidJSON(t *testing.T) {
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
err := c.ValidateConfig(context.Background(), json.RawMessage(`{invalid`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingHost(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"user": "deploy",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingUser(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingCertPath(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing cert_path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_MissingKeyPath(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing key_path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_KeyAuth_MissingKey(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "key",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for key auth missing both private_key and private_key_path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_PasswordAuth_MissingPassword(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "password",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for password auth missing password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidHost(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server;rm -rf /",
|
||||||
|
"user": "deploy",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
"private_key": "fake",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for host with shell metacharacters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidPermissions(t *testing.T) {
|
||||||
|
keyFile := createTempKeyFile(t)
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"private_key_path": keyFile,
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
"cert_mode": "999",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid cert_mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_ReloadCommandInjection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
command string
|
||||||
|
}{
|
||||||
|
{"semicolon", "systemctl reload nginx; rm -rf /"},
|
||||||
|
{"pipe", "systemctl reload nginx | cat"},
|
||||||
|
{"backtick", "systemctl reload `malicious`"},
|
||||||
|
{"command substitution", "systemctl reload $(evil)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
keyFile := createTempKeyFile(t)
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"private_key_path": keyFile,
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
"reload_command": tc.command,
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for reload command injection: %q", tc.command)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_InvalidAuthMethod(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "kerberos",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid auth method")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig_KeyFileNotFound(t *testing.T) {
|
||||||
|
cfg := map[string]interface{}{
|
||||||
|
"host": "server.local",
|
||||||
|
"user": "deploy",
|
||||||
|
"auth_method": "key",
|
||||||
|
"private_key_path": "/nonexistent/key.pem",
|
||||||
|
"cert_path": "/etc/ssl/cert.pem",
|
||||||
|
"key_path": "/etc/ssl/key.pem",
|
||||||
|
}
|
||||||
|
c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger())
|
||||||
|
raw, _ := json.Marshal(cfg)
|
||||||
|
err := c.ValidateConfig(context.Background(), raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent key file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DeployCertificate tests ---
|
||||||
|
|
||||||
|
func TestDeployCertificate_Success_NoChainPath(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{statFileSize: 1024}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----",
|
||||||
|
KeyPEM: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----",
|
||||||
|
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 2 writes (cert with chain appended, key)
|
||||||
|
if len(mock.writeFileCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 write calls, got %d", len(mock.writeFileCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cert should include chain (fullchain)
|
||||||
|
certWrite := mock.writeFileCalls[0]
|
||||||
|
if certWrite.Path != "/etc/ssl/cert.pem" {
|
||||||
|
t.Errorf("expected cert path /etc/ssl/cert.pem, got %s", certWrite.Path)
|
||||||
|
}
|
||||||
|
if certWrite.Mode != 0644 {
|
||||||
|
t.Errorf("expected cert mode 0644, got %v", certWrite.Mode)
|
||||||
|
}
|
||||||
|
certContent := string(certWrite.Data)
|
||||||
|
if len(certContent) == 0 {
|
||||||
|
t.Error("cert data should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key write
|
||||||
|
keyWrite := mock.writeFileCalls[1]
|
||||||
|
if keyWrite.Path != "/etc/ssl/key.pem" {
|
||||||
|
t.Errorf("expected key path /etc/ssl/key.pem, got %s", keyWrite.Path)
|
||||||
|
}
|
||||||
|
if keyWrite.Mode != 0600 {
|
||||||
|
t.Errorf("expected key mode 0600, got %v", keyWrite.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
if result.Metadata["host"] != "server.local" {
|
||||||
|
t.Errorf("expected host metadata server.local, got %s", result.Metadata["host"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_Success_SeparateChain(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
ChainPath: "/etc/ssl/chain.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert-data",
|
||||||
|
KeyPEM: "key-data",
|
||||||
|
ChainPEM: "chain-data",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 3 writes (cert, key, chain)
|
||||||
|
if len(mock.writeFileCalls) != 3 {
|
||||||
|
t.Fatalf("expected 3 write calls, got %d", len(mock.writeFileCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain should be separate
|
||||||
|
chainWrite := mock.writeFileCalls[2]
|
||||||
|
if chainWrite.Path != "/etc/ssl/chain.pem" {
|
||||||
|
t.Errorf("expected chain path /etc/ssl/chain.pem, got %s", chainWrite.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_Success_WithReload(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{executeOutput: "ok"}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
ReloadCommand: "systemctl reload nginx",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
t.Fatalf("expected success, got %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have executed reload command
|
||||||
|
if len(mock.executeCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 execute call, got %d", len(mock.executeCalls))
|
||||||
|
}
|
||||||
|
if mock.executeCalls[0] != "systemctl reload nginx" {
|
||||||
|
t.Errorf("expected reload command, got %s", mock.executeCalls[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_MissingKeyPEM(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "", // Missing
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing KeyPEM")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_ConnectionFailure(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{connectErr: fmt.Errorf("connection refused")}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "unreachable.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for connection failure")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_WriteFailure(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{writeFileErr: fmt.Errorf("permission denied")}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for write failure")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeployCertificate_ReloadFailure(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{executeErr: fmt.Errorf("reload failed: exit status 1"), executeOutput: "error"}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
ReloadCommand: "systemctl reload nginx",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.DeploymentRequest{
|
||||||
|
CertPEM: "cert",
|
||||||
|
KeyPEM: "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.DeployCertificate(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for reload failure")
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
t.Fatal("expected failure result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ValidateDeployment tests ---
|
||||||
|
|
||||||
|
func TestValidateDeployment_Success(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{statFileSize: 2048}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.ValidationRequest{
|
||||||
|
CertificateID: "mc-test",
|
||||||
|
Serial: "ABC123",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !result.Valid {
|
||||||
|
t.Fatalf("expected valid, got %s", result.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have stat'd both files
|
||||||
|
if len(mock.statFileCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 stat calls, got %d", len(mock.statFileCalls))
|
||||||
|
}
|
||||||
|
if mock.statFileCalls[0] != "/etc/ssl/cert.pem" {
|
||||||
|
t.Errorf("expected cert path, got %s", mock.statFileCalls[0])
|
||||||
|
}
|
||||||
|
if mock.statFileCalls[1] != "/etc/ssl/key.pem" {
|
||||||
|
t.Errorf("expected key path, got %s", mock.statFileCalls[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeployment_CertNotFound(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{statFileErr: fmt.Errorf("file not found")}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "server.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.ValidationRequest{
|
||||||
|
CertificateID: "mc-test",
|
||||||
|
Serial: "ABC123",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing cert")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeployment_ConnectionFailure(t *testing.T) {
|
||||||
|
mock := &mockSSHClient{connectErr: fmt.Errorf("connection refused")}
|
||||||
|
cfg := &Config{
|
||||||
|
Host: "unreachable.local",
|
||||||
|
Port: 22,
|
||||||
|
CertPath: "/etc/ssl/cert.pem",
|
||||||
|
KeyPath: "/etc/ssl/key.pem",
|
||||||
|
CertMode: "0644",
|
||||||
|
KeyMode: "0600",
|
||||||
|
}
|
||||||
|
c := NewWithClient(cfg, mock, testLogger())
|
||||||
|
|
||||||
|
req := target.ValidationRequest{
|
||||||
|
CertificateID: "mc-test",
|
||||||
|
Serial: "ABC123",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.ValidateDeployment(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for connection failure")
|
||||||
|
}
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatal("expected invalid result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper tests ---
|
||||||
|
|
||||||
|
func TestParsePermissions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected os.FileMode
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"0644", 0644, false},
|
||||||
|
{"0600", 0600, false},
|
||||||
|
{"0755", 0755, false},
|
||||||
|
{"invalid", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
mode, err := parsePermissions(tc.input)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !tc.wantErr && mode != tc.expected {
|
||||||
|
t.Errorf("expected %v, got %v", tc.expected, mode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDefaults(t *testing.T) {
|
||||||
|
cfg := &Config{}
|
||||||
|
applyDefaults(cfg)
|
||||||
|
|
||||||
|
if cfg.Port != 22 {
|
||||||
|
t.Errorf("expected port 22, got %d", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.AuthMethod != "key" {
|
||||||
|
t.Errorf("expected auth_method key, got %s", cfg.AuthMethod)
|
||||||
|
}
|
||||||
|
if cfg.CertMode != "0644" {
|
||||||
|
t.Errorf("expected cert_mode 0644, got %s", cfg.CertMode)
|
||||||
|
}
|
||||||
|
if cfg.KeyMode != "0600" {
|
||||||
|
t.Errorf("expected key_mode 0600, got %s", cfg.KeyMode)
|
||||||
|
}
|
||||||
|
if cfg.Timeout != 30 {
|
||||||
|
t.Errorf("expected timeout 30, got %d", cfg.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
// createTempKeyFile creates a temporary file that simulates an SSH private key.
|
||||||
|
func createTempKeyFile(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyFile := dir + "/id_rsa"
|
||||||
|
if err := os.WriteFile(keyFile, []byte("fake-key-data"), 0600); err != nil {
|
||||||
|
t.Fatalf("failed to create temp key file: %v", err)
|
||||||
|
}
|
||||||
|
return keyFile
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package crypto provides AES-256-GCM encryption for sensitive configuration data.
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encrypt encrypts plaintext using AES-256-GCM with a random 12-byte nonce prepended to the output.
|
||||||
|
// The key must be exactly 32 bytes (AES-256). Returns [12-byte nonce][ciphertext+tag].
|
||||||
|
func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts ciphertext that was encrypted with Encrypt.
|
||||||
|
// Expects format: [12-byte nonce][ciphertext+tag]. Key must be exactly 32 bytes.
|
||||||
|
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(ciphertext) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("ciphertext too short: %d bytes", len(ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertextBody := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertextBody, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveKey derives a 32-byte AES-256 key from a passphrase using PBKDF2-SHA256.
|
||||||
|
// Uses a fixed application-specific salt and 100,000 iterations for resistance
|
||||||
|
// to brute-force attacks on weak passphrases.
|
||||||
|
func DeriveKey(passphrase string) []byte {
|
||||||
|
// Fixed salt is acceptable here because:
|
||||||
|
// 1. Each certctl instance has its own passphrase
|
||||||
|
// 2. The salt prevents generic rainbow table attacks
|
||||||
|
// 3. Per-user salts are unnecessary (single server key, not user passwords)
|
||||||
|
salt := []byte("certctl-config-encryption-v1")
|
||||||
|
return pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptIfKeySet encrypts plaintext if a key is provided, otherwise returns plaintext unchanged.
|
||||||
|
// This supports the development/demo fallback where encryption isn't configured.
|
||||||
|
func EncryptIfKeySet(plaintext []byte, key []byte) ([]byte, bool, error) {
|
||||||
|
if len(key) == 0 {
|
||||||
|
return plaintext, false, nil
|
||||||
|
}
|
||||||
|
encrypted, err := Encrypt(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return encrypted, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptIfKeySet decrypts ciphertext if a key is provided, otherwise returns ciphertext unchanged.
|
||||||
|
func DecryptIfKeySet(ciphertext []byte, key []byte) ([]byte, error) {
|
||||||
|
if len(key) == 0 {
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
return Decrypt(ciphertext, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||||
|
key := DeriveKey("test-passphrase")
|
||||||
|
plaintext := []byte(`{"api_key":"secret123","org_id":"456"}`)
|
||||||
|
|
||||||
|
encrypted, err := Encrypt(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.Equal(encrypted, plaintext) {
|
||||||
|
t.Fatal("encrypted data should differ from plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := Decrypt(encrypted, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(decrypted, plaintext) {
|
||||||
|
t.Fatalf("round-trip failed: got %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptWrongKey(t *testing.T) {
|
||||||
|
key1 := DeriveKey("key-one")
|
||||||
|
key2 := DeriveKey("key-two")
|
||||||
|
plaintext := []byte("sensitive config data")
|
||||||
|
|
||||||
|
encrypted, err := Encrypt(plaintext, key1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Decrypt(encrypted, key2)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when decrypting with wrong key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptTamperedCiphertext(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
plaintext := []byte("important data")
|
||||||
|
|
||||||
|
encrypted, err := Encrypt(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamper with the ciphertext (flip a byte after the nonce)
|
||||||
|
if len(encrypted) > 13 {
|
||||||
|
encrypted[13] ^= 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Decrypt(encrypted, key)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when decrypting tampered ciphertext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptEmptyPlaintext(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
plaintext := []byte{}
|
||||||
|
|
||||||
|
encrypted, err := Encrypt(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt empty plaintext failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := Decrypt(encrypted, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt empty plaintext failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(decrypted, plaintext) {
|
||||||
|
t.Fatalf("empty plaintext round-trip failed: got %q", decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptInvalidKeyLength(t *testing.T) {
|
||||||
|
_, err := Encrypt([]byte("data"), []byte("short-key"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid key length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptInvalidKeyLength(t *testing.T) {
|
||||||
|
_, err := Decrypt([]byte("some-ciphertext-data"), []byte("short-key"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid key length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptTooShortCiphertext(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
_, err := Decrypt([]byte("short"), key)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for too-short ciphertext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveKeyDeterministic(t *testing.T) {
|
||||||
|
key1 := DeriveKey("same-passphrase")
|
||||||
|
key2 := DeriveKey("same-passphrase")
|
||||||
|
if !bytes.Equal(key1, key2) {
|
||||||
|
t.Fatal("DeriveKey should be deterministic")
|
||||||
|
}
|
||||||
|
if len(key1) != 32 {
|
||||||
|
t.Fatalf("DeriveKey should return 32 bytes, got %d", len(key1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveKeyDifferentPassphrases(t *testing.T) {
|
||||||
|
key1 := DeriveKey("passphrase-one")
|
||||||
|
key2 := DeriveKey("passphrase-two")
|
||||||
|
if bytes.Equal(key1, key2) {
|
||||||
|
t.Fatal("different passphrases should produce different keys")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptIfKeySet_WithKey(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
plaintext := []byte("config data")
|
||||||
|
|
||||||
|
result, wasEncrypted, err := EncryptIfKeySet(plaintext, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||||
|
}
|
||||||
|
if !wasEncrypted {
|
||||||
|
t.Fatal("expected wasEncrypted=true when key provided")
|
||||||
|
}
|
||||||
|
if bytes.Equal(result, plaintext) {
|
||||||
|
t.Fatal("result should be encrypted")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := DecryptIfKeySet(result, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptIfKeySet failed: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(decrypted, plaintext) {
|
||||||
|
t.Fatalf("round-trip failed: got %q", decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptIfKeySet_NilKey(t *testing.T) {
|
||||||
|
plaintext := []byte("config data")
|
||||||
|
|
||||||
|
result, wasEncrypted, err := EncryptIfKeySet(plaintext, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptIfKeySet with nil key failed: %v", err)
|
||||||
|
}
|
||||||
|
if wasEncrypted {
|
||||||
|
t.Fatal("expected wasEncrypted=false when key is nil")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(result, plaintext) {
|
||||||
|
t.Fatal("result should be unchanged plaintext when key is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptIfKeySet_NilKey(t *testing.T) {
|
||||||
|
data := []byte("plaintext config data")
|
||||||
|
|
||||||
|
result, err := DecryptIfKeySet(data, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptIfKeySet with nil key failed: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(result, data) {
|
||||||
|
t.Fatal("result should be unchanged when key is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||||
|
key := DeriveKey("test-key")
|
||||||
|
plaintext := []byte("same data")
|
||||||
|
|
||||||
|
enc1, _ := Encrypt(plaintext, key)
|
||||||
|
enc2, _ := Encrypt(plaintext, key)
|
||||||
|
|
||||||
|
if bytes.Equal(enc1, enc2) {
|
||||||
|
t.Fatal("encrypting same plaintext twice should produce different ciphertexts (random nonce)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,25 +7,33 @@ import (
|
|||||||
|
|
||||||
// Issuer represents a certificate authority or ACME provider.
|
// Issuer represents a certificate authority or ACME provider.
|
||||||
type Issuer struct {
|
type Issuer struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type IssuerType `json:"type"`
|
Type IssuerType `json:"type"`
|
||||||
Config json.RawMessage `json:"config"`
|
Config json.RawMessage `json:"config"`
|
||||||
Enabled bool `json:"enabled"`
|
EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Enabled bool `json:"enabled"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||||
|
TestStatus string `json:"test_status,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeploymentTarget represents a target system where certificates are deployed.
|
// DeploymentTarget represents a target system where certificates are deployed.
|
||||||
type DeploymentTarget struct {
|
type DeploymentTarget struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type TargetType `json:"type"`
|
Type TargetType `json:"type"`
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
Config json.RawMessage `json:"config"`
|
Config json.RawMessage `json:"config"`
|
||||||
Enabled bool `json:"enabled"`
|
EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Enabled bool `json:"enabled"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||||
|
TestStatus string `json:"test_status,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent represents an agent running on a target system.
|
// Agent represents an agent running on a target system.
|
||||||
@@ -89,4 +97,5 @@ const (
|
|||||||
TargetTypeEnvoy TargetType = "Envoy"
|
TargetTypeEnvoy TargetType = "Envoy"
|
||||||
TargetTypePostfix TargetType = "Postfix"
|
TargetTypePostfix TargetType = "Postfix"
|
||||||
TargetTypeDovecot TargetType = "Dovecot"
|
TargetTypeDovecot TargetType = "Dovecot"
|
||||||
|
TargetTypeSSH TargetType = "SSH"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,9 +43,8 @@ func TestCertificateLifecycle(t *testing.T) {
|
|||||||
localCA := local.New(nil, logger)
|
localCA := local.New(nil, logger)
|
||||||
|
|
||||||
// Build issuer registry with adapter
|
// Build issuer registry with adapter
|
||||||
issuerRegistry := map[string]service.IssuerConnector{
|
issuerRegistry := service.NewIssuerRegistry(logger)
|
||||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA))
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize services (following dependency graph)
|
// Initialize services (following dependency graph)
|
||||||
auditService := service.NewAuditService(auditRepo)
|
auditService := service.NewAuditService(auditRepo)
|
||||||
@@ -67,7 +66,7 @@ func TestCertificateLifecycle(t *testing.T) {
|
|||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, nil, slog.Default())
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
certificateHandler := handler.NewCertificateHandler(certificateService)
|
certificateHandler := handler.NewCertificateHandler(certificateService)
|
||||||
@@ -90,7 +89,8 @@ func TestCertificateLifecycle(t *testing.T) {
|
|||||||
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
||||||
|
|
||||||
// EST handler — uses real Local CA issuer via ESTService
|
// EST handler — uses real Local CA issuer via ESTService
|
||||||
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
localCAConnector, _ := issuerRegistry.Get("iss-local")
|
||||||
|
estService := service.NewESTService("iss-local", localCAConnector, auditService, logger)
|
||||||
estHandler := handler.NewESTHandler(estService)
|
estHandler := handler.NewESTHandler(estService)
|
||||||
|
|
||||||
// Create router and register handlers
|
// Create router and register handlers
|
||||||
@@ -786,6 +786,14 @@ func (m *mockTargetRepository) Create(ctx context.Context, target *domain.Deploy
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
||||||
|
if _, exists := m.targets[target.ID]; exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.targets[target.ID] = target
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockTargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
func (m *mockTargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
||||||
m.targets[target.ID] = target
|
m.targets[target.ID] = target
|
||||||
return nil
|
return nil
|
||||||
@@ -954,6 +962,14 @@ func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
|
||||||
|
if _, exists := m.issuers[issuer.ID]; exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.issuers[issuer.ID] = issuer
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
|
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
|
||||||
delete(m.issuers, id)
|
delete(m.issuers, id)
|
||||||
return nil
|
return nil
|
||||||
@@ -1001,6 +1017,10 @@ func (m *mockTargetService) DeleteTarget(id string) error {
|
|||||||
return m.targetRepo.Delete(context.Background(), id)
|
return m.targetRepo.Delete(context.Background(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTargetService) TestTargetConnection(id string) error {
|
||||||
|
return nil // No-op for integration tests
|
||||||
|
}
|
||||||
|
|
||||||
type mockTeamService struct{}
|
type mockTeamService struct{}
|
||||||
|
|
||||||
func (m *mockTeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error) {
|
func (m *mockTeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error) {
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
|||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
localCA := local.New(nil, logger)
|
localCA := local.New(nil, logger)
|
||||||
|
|
||||||
issuerRegistry := map[string]service.IssuerConnector{
|
issuerRegistry := service.NewIssuerRegistry(logger)
|
||||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA))
|
||||||
}
|
|
||||||
|
|
||||||
revocationRepo := newMockRevocationRepository()
|
revocationRepo := newMockRevocationRepository()
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
|||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, nil, logger)
|
||||||
|
|
||||||
certificateHandler := handler.NewCertificateHandler(certificateService)
|
certificateHandler := handler.NewCertificateHandler(certificateService)
|
||||||
issuerHandler := handler.NewIssuerHandler(issuerService)
|
issuerHandler := handler.NewIssuerHandler(issuerService)
|
||||||
@@ -81,7 +80,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
|||||||
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
||||||
|
|
||||||
// EST handler — uses real Local CA issuer via ESTService
|
// EST handler — uses real Local CA issuer via ESTService
|
||||||
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
localCAConnector, _ := issuerRegistry.Get("iss-local")
|
||||||
|
estService := service.NewESTService("iss-local", localCAConnector, auditService, logger)
|
||||||
estHandler := handler.NewESTHandler(estService)
|
estHandler := handler.NewESTHandler(estService)
|
||||||
|
|
||||||
r := router.New()
|
r := router.New()
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ type IssuerRepository interface {
|
|||||||
Get(ctx context.Context, id string) (*domain.Issuer, error)
|
Get(ctx context.Context, id string) (*domain.Issuer, error)
|
||||||
// Create stores a new issuer.
|
// Create stores a new issuer.
|
||||||
Create(ctx context.Context, issuer *domain.Issuer) error
|
Create(ctx context.Context, issuer *domain.Issuer) error
|
||||||
|
// CreateIfNotExists creates an issuer only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
|
||||||
|
// Returns true if created, false if already existed.
|
||||||
|
CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error)
|
||||||
// Update modifies an existing issuer.
|
// Update modifies an existing issuer.
|
||||||
Update(ctx context.Context, issuer *domain.Issuer) error
|
Update(ctx context.Context, issuer *domain.Issuer) error
|
||||||
// Delete removes an issuer.
|
// Delete removes an issuer.
|
||||||
@@ -65,6 +68,9 @@ type TargetRepository interface {
|
|||||||
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
|
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
|
||||||
// Create stores a new target.
|
// Create stores a new target.
|
||||||
Create(ctx context.Context, target *domain.DeploymentTarget) error
|
Create(ctx context.Context, target *domain.DeploymentTarget) error
|
||||||
|
// CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
|
||||||
|
// Returns true if created, false if already existed.
|
||||||
|
CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error)
|
||||||
// Update modifies an existing target.
|
// Update modifies an existing target.
|
||||||
Update(ctx context.Context, target *domain.DeploymentTarget) error
|
Update(ctx context.Context, target *domain.DeploymentTarget) error
|
||||||
// Delete removes a target.
|
// Delete removes a target.
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ func NewIssuerRepository(db *sql.DB) *IssuerRepository {
|
|||||||
// List returns all issuers
|
// List returns all issuers
|
||||||
func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, name, type, config, enabled, created_at, updated_at
|
SELECT id, name, type, config, COALESCE(encrypted_config, NULL), enabled,
|
||||||
|
last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'),
|
||||||
|
created_at, updated_at
|
||||||
FROM issuers
|
FROM issuers
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`)
|
`)
|
||||||
@@ -36,7 +38,9 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var issuer domain.Issuer
|
var issuer domain.Issuer
|
||||||
if err := rows.Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config,
|
if err := rows.Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config,
|
||||||
&issuer.Enabled, &issuer.CreatedAt, &issuer.UpdatedAt); err != nil {
|
&issuer.EncryptedConfig, &issuer.Enabled,
|
||||||
|
&issuer.LastTestedAt, &issuer.TestStatus, &issuer.Source,
|
||||||
|
&issuer.CreatedAt, &issuer.UpdatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan issuer: %w", err)
|
return nil, fmt.Errorf("failed to scan issuer: %w", err)
|
||||||
}
|
}
|
||||||
issuers = append(issuers, &issuer)
|
issuers = append(issuers, &issuer)
|
||||||
@@ -53,11 +57,15 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
|||||||
func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
||||||
var issuer domain.Issuer
|
var issuer domain.Issuer
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, name, type, config, enabled, created_at, updated_at
|
SELECT id, name, type, config, COALESCE(encrypted_config, NULL), enabled,
|
||||||
|
last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'),
|
||||||
|
created_at, updated_at
|
||||||
FROM issuers
|
FROM issuers
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, id).Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config,
|
`, id).Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config,
|
||||||
&issuer.Enabled, &issuer.CreatedAt, &issuer.UpdatedAt)
|
&issuer.EncryptedConfig, &issuer.Enabled,
|
||||||
|
&issuer.LastTestedAt, &issuer.TestStatus, &issuer.Source,
|
||||||
|
&issuer.CreatedAt, &issuer.UpdatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -75,11 +83,22 @@ func (r *IssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) er
|
|||||||
issuer.ID = uuid.New().String()
|
issuer.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
source := issuer.Source
|
||||||
|
if source == "" {
|
||||||
|
source = "database"
|
||||||
|
}
|
||||||
|
testStatus := issuer.TestStatus
|
||||||
|
if testStatus == "" {
|
||||||
|
testStatus = "untested"
|
||||||
|
}
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at)
|
INSERT INTO issuers (id, name, type, config, encrypted_config, enabled,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
last_tested_at, test_status, source, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, issuer.ID, issuer.Name, issuer.Type, issuer.Config, issuer.Enabled,
|
`, issuer.ID, issuer.Name, issuer.Type, issuer.Config, issuer.EncryptedConfig,
|
||||||
|
issuer.Enabled, issuer.LastTestedAt, testStatus, source,
|
||||||
issuer.CreatedAt, issuer.UpdatedAt).Scan(&issuer.ID)
|
issuer.CreatedAt, issuer.UpdatedAt).Scan(&issuer.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,6 +108,40 @@ func (r *IssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateIfNotExists creates an issuer only if the ID doesn't already exist.
|
||||||
|
// Used for env var seeding on first boot. Returns true if created, false if already existed.
|
||||||
|
func (r *IssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
|
||||||
|
source := issuer.Source
|
||||||
|
if source == "" {
|
||||||
|
source = "env"
|
||||||
|
}
|
||||||
|
testStatus := issuer.TestStatus
|
||||||
|
if testStatus == "" {
|
||||||
|
testStatus = "untested"
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO issuers (id, name, type, config, encrypted_config, enabled,
|
||||||
|
last_tested_at, test_status, source, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
`, issuer.ID, issuer.Name, issuer.Type, issuer.Config, issuer.EncryptedConfig,
|
||||||
|
issuer.Enabled, issuer.LastTestedAt, testStatus, source,
|
||||||
|
issuer.CreatedAt, issuer.UpdatedAt).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// ON CONFLICT DO NOTHING — row already existed
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("failed to create issuer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update modifies an existing issuer
|
// Update modifies an existing issuer
|
||||||
func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error {
|
func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error {
|
||||||
result, err := r.db.ExecContext(ctx, `
|
result, err := r.db.ExecContext(ctx, `
|
||||||
@@ -96,10 +149,15 @@ func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) er
|
|||||||
name = $1,
|
name = $1,
|
||||||
type = $2,
|
type = $2,
|
||||||
config = $3,
|
config = $3,
|
||||||
enabled = $4,
|
encrypted_config = $4,
|
||||||
updated_at = $5
|
enabled = $5,
|
||||||
WHERE id = $6
|
last_tested_at = $6,
|
||||||
`, issuer.Name, issuer.Type, issuer.Config, issuer.Enabled, issuer.UpdatedAt, issuer.ID)
|
test_status = $7,
|
||||||
|
updated_at = $8
|
||||||
|
WHERE id = $9
|
||||||
|
`, issuer.Name, issuer.Type, issuer.Config, issuer.EncryptedConfig,
|
||||||
|
issuer.Enabled, issuer.LastTestedAt, issuer.TestStatus,
|
||||||
|
issuer.UpdatedAt, issuer.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update issuer: %w", err)
|
return fmt.Errorf("failed to update issuer: %w", err)
|
||||||
|
|||||||
@@ -19,10 +19,40 @@ func NewTargetRepository(db *sql.DB) *TargetRepository {
|
|||||||
return &TargetRepository{db: db}
|
return &TargetRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanTarget scans a target row including optional M35 columns (encrypted_config, last_tested_at, test_status, source).
|
||||||
|
func scanTarget(scanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}, target *domain.DeploymentTarget) error {
|
||||||
|
var lastTestedAt sql.NullTime
|
||||||
|
var testStatus sql.NullString
|
||||||
|
var source sql.NullString
|
||||||
|
if err := scanner.Scan(
|
||||||
|
&target.ID, &target.Name, &target.Type, &target.AgentID,
|
||||||
|
&target.Config, &target.EncryptedConfig, &target.Enabled,
|
||||||
|
&lastTestedAt, &testStatus, &source,
|
||||||
|
&target.CreatedAt, &target.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lastTestedAt.Valid {
|
||||||
|
target.LastTestedAt = &lastTestedAt.Time
|
||||||
|
}
|
||||||
|
if testStatus.Valid {
|
||||||
|
target.TestStatus = testStatus.String
|
||||||
|
}
|
||||||
|
if source.Valid {
|
||||||
|
target.Source = source.String
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetSelectColumns is the standard column list for target queries.
|
||||||
|
const targetSelectColumns = `id, name, type, agent_id, config, COALESCE(encrypted_config, ''::bytea), enabled, last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'), created_at, updated_at`
|
||||||
|
|
||||||
// List returns all targets
|
// List returns all targets
|
||||||
func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
|
func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, name, type, agent_id, config, enabled, created_at, updated_at
|
SELECT `+targetSelectColumns+`
|
||||||
FROM deployment_targets
|
FROM deployment_targets
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`)
|
`)
|
||||||
@@ -35,8 +65,7 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
|
|||||||
var targets []*domain.DeploymentTarget
|
var targets []*domain.DeploymentTarget
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var target domain.DeploymentTarget
|
var target domain.DeploymentTarget
|
||||||
if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
|
if err := scanTarget(rows, &target); err != nil {
|
||||||
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan target: %w", err)
|
return nil, fmt.Errorf("failed to scan target: %w", err)
|
||||||
}
|
}
|
||||||
targets = append(targets, &target)
|
targets = append(targets, &target)
|
||||||
@@ -52,12 +81,11 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
|
|||||||
// Get retrieves a target by ID
|
// Get retrieves a target by ID
|
||||||
func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
||||||
var target domain.DeploymentTarget
|
var target domain.DeploymentTarget
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := scanTarget(r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, name, type, agent_id, config, enabled, created_at, updated_at
|
SELECT `+targetSelectColumns+`
|
||||||
FROM deployment_targets
|
FROM deployment_targets
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, id).Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
|
`, id), &target)
|
||||||
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -76,10 +104,11 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at)
|
INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.Enabled,
|
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
|
||||||
|
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
|
||||||
target.CreatedAt, target.UpdatedAt).Scan(&target.ID)
|
target.CreatedAt, target.UpdatedAt).Scan(&target.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,6 +118,33 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
|
||||||
|
// Returns true if created, false if already existed.
|
||||||
|
func (r *TargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
||||||
|
if target.ID == "" {
|
||||||
|
target.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
|
||||||
|
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
|
||||||
|
target.CreatedAt, target.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to create target: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get rows affected: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update modifies an existing target
|
// Update modifies an existing target
|
||||||
func (r *TargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
func (r *TargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
||||||
result, err := r.db.ExecContext(ctx, `
|
result, err := r.db.ExecContext(ctx, `
|
||||||
@@ -97,10 +153,16 @@ func (r *TargetRepository) Update(ctx context.Context, target *domain.Deployment
|
|||||||
type = $2,
|
type = $2,
|
||||||
agent_id = $3,
|
agent_id = $3,
|
||||||
config = $4,
|
config = $4,
|
||||||
enabled = $5,
|
encrypted_config = $5,
|
||||||
updated_at = $6
|
enabled = $6,
|
||||||
WHERE id = $7
|
last_tested_at = $7,
|
||||||
`, target.Name, target.Type, target.AgentID, target.Config, target.Enabled, target.UpdatedAt, target.ID)
|
test_status = $8,
|
||||||
|
source = $9,
|
||||||
|
updated_at = $10
|
||||||
|
WHERE id = $11
|
||||||
|
`, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
|
||||||
|
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
|
||||||
|
target.UpdatedAt, target.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update target: %w", err)
|
return fmt.Errorf("failed to update target: %w", err)
|
||||||
@@ -141,7 +203,7 @@ func (r *TargetRepository) Delete(ctx context.Context, id string) error {
|
|||||||
// ListByCertificate returns all targets for a given certificate
|
// ListByCertificate returns all targets for a given certificate
|
||||||
func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
|
func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, dt.enabled, dt.created_at, dt.updated_at
|
SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, COALESCE(dt.encrypted_config, ''::bytea), dt.enabled, dt.last_tested_at, COALESCE(dt.test_status, 'untested'), COALESCE(dt.source, 'database'), dt.created_at, dt.updated_at
|
||||||
FROM deployment_targets dt
|
FROM deployment_targets dt
|
||||||
INNER JOIN certificate_target_mappings ctm ON dt.id = ctm.target_id
|
INNER JOIN certificate_target_mappings ctm ON dt.id = ctm.target_id
|
||||||
WHERE ctm.certificate_id = $1
|
WHERE ctm.certificate_id = $1
|
||||||
@@ -156,8 +218,7 @@ func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string)
|
|||||||
var targets []*domain.DeploymentTarget
|
var targets []*domain.DeploymentTarget
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var target domain.DeploymentTarget
|
var target domain.DeploymentTarget
|
||||||
if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
|
if err := scanTarget(rows, &target); err != nil {
|
||||||
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan target: %w", err)
|
return nil, fmt.Errorf("failed to scan target: %w", err)
|
||||||
}
|
}
|
||||||
targets = append(targets, &target)
|
targets = append(targets, &target)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type AgentService struct {
|
|||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
profileRepo repository.CertificateProfileRepository
|
profileRepo repository.CertificateProfileRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry *IssuerRegistry
|
||||||
renewalService *RenewalService
|
renewalService *RenewalService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ func NewAgentService(
|
|||||||
jobRepo repository.JobRepository,
|
jobRepo repository.JobRepository,
|
||||||
targetRepo repository.TargetRepository,
|
targetRepo repository.TargetRepository,
|
||||||
auditService *AuditService,
|
auditService *AuditService,
|
||||||
issuerRegistry map[string]IssuerConnector,
|
issuerRegistry *IssuerRegistry,
|
||||||
renewalService *RenewalService,
|
renewalService *RenewalService,
|
||||||
) *AgentService {
|
) *AgentService {
|
||||||
return &AgentService{
|
return &AgentService{
|
||||||
@@ -163,7 +163,7 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission)
|
// Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission)
|
||||||
connector, ok := s.issuerRegistry[cert.IssuerID]
|
connector, ok := s.issuerRegistry.Get(cert.IssuerID)
|
||||||
if ok {
|
if ok {
|
||||||
// Resolve EKUs from the certificate profile if available
|
// Resolve EKUs from the certificate profile if available
|
||||||
var ekus []string
|
var ekus []string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ func TestRegisterAgent(t *testing.T) {
|
|||||||
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ func TestHeartbeat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ func TestHeartbeat_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -217,7 +218,8 @@ func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) {
|
|||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
auditService := NewAuditService(&mockAuditRepo{})
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
// Agent A should only see its job
|
// Agent A should only see its job
|
||||||
jobsA, err := agentService.GetPendingWork(ctx, agentA)
|
jobsA, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
@@ -268,7 +270,8 @@ func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
|
|||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
auditService := NewAuditService(&mockAuditRepo{})
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,7 +305,8 @@ func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
|
|||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
auditService := NewAuditService(&mockAuditRepo{})
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -350,7 +354,7 @@ func TestReportJobStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -409,7 +413,7 @@ func TestMarkStaleAgentsOffline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -475,7 +479,8 @@ func TestSubmitCSR(t *testing.T) {
|
|||||||
NotAfter: now.AddDate(1, 0, 0),
|
NotAfter: now.AddDate(1, 0, 0),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
issuerRegistry := map[string]IssuerConnector{"iss-local": issuerConnector}
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
issuerRegistry.Set("iss-local", issuerConnector)
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -524,7 +529,7 @@ func TestSubmitCSR_EmptyCSR(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
@@ -572,7 +577,7 @@ func TestListAgents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
issuerRegistry := make(map[string]IssuerConnector)
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type CAOperationsSvc struct {
|
|||||||
revocationRepo repository.RevocationRepository
|
revocationRepo repository.RevocationRepository
|
||||||
certRepo repository.CertificateRepository
|
certRepo repository.CertificateRepository
|
||||||
profileRepo repository.CertificateProfileRepository
|
profileRepo repository.CertificateProfileRepository
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry *IssuerRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCAOperationsSvc creates a new CA operations service.
|
// NewCAOperationsSvc creates a new CA operations service.
|
||||||
@@ -35,7 +35,7 @@ func NewCAOperationsSvc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetIssuerRegistry sets the issuer registry for CRL and OCSP operations.
|
// SetIssuerRegistry sets the issuer registry for CRL and OCSP operations.
|
||||||
func (s *CAOperationsSvc) SetIssuerRegistry(registry map[string]IssuerConnector) {
|
func (s *CAOperationsSvc) SetIssuerRegistry(registry *IssuerRegistry) {
|
||||||
s.issuerRegistry = registry
|
s.issuerRegistry = registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func (s *CAOperationsSvc) GenerateDERCRL(issuerID string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("issuer registry not configured")
|
return nil, fmt.Errorf("issuer registry not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuerConn, ok := s.issuerRegistry[issuerID]
|
issuerConn, ok := s.issuerRegistry.Get(issuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ func (s *CAOperationsSvc) GetOCSPResponse(issuerID string, serialHex string) ([]
|
|||||||
return nil, fmt.Errorf("issuer registry not configured")
|
return nil, fmt.Errorf("issuer registry not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuerConn, ok := s.issuerRegistry[issuerID]
|
issuerConn, ok := s.issuerRegistry.Get(issuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
return nil, fmt.Errorf("issuer not found: %s", issuerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,9 +17,9 @@ func newCAOperationsSvcTest() (*CAOperationsSvc, *mockRevocationRepo, *mockCertR
|
|||||||
profileRepo := newMockProfileRepository()
|
profileRepo := newMockProfileRepository()
|
||||||
|
|
||||||
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
||||||
caSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": &mockIssuerConnector{},
|
registry.Set("iss-local", &mockIssuerConnector{})
|
||||||
})
|
caSvc.SetIssuerRegistry(registry)
|
||||||
|
|
||||||
return caSvc, revocationRepo, certRepo
|
return caSvc, revocationRepo, certRepo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -130,13 +132,14 @@ func TestConcurrentAgentHeartbeats(t *testing.T) {
|
|||||||
mockAgentRepo.AddAgent(agent)
|
mockAgentRepo.AddAgent(agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
agentSvc := NewAgentService(
|
agentSvc := NewAgentService(
|
||||||
mockAgentRepo,
|
mockAgentRepo,
|
||||||
nil, // certRepo
|
nil, // certRepo
|
||||||
nil, // jobRepo
|
nil, // jobRepo
|
||||||
nil, // targetRepo
|
nil, // targetRepo
|
||||||
nil, // auditService
|
nil, // auditService
|
||||||
make(map[string]IssuerConnector),
|
issuerRegistry,
|
||||||
nil, // renewalService
|
nil, // renewalService
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,7 +194,7 @@ func TestConcurrentTargetCRUD(t *testing.T) {
|
|||||||
Targets: make(map[string]*domain.DeploymentTarget),
|
Targets: make(map[string]*domain.DeploymentTarget),
|
||||||
}
|
}
|
||||||
|
|
||||||
targetSvc := NewTargetService(mockTargetRepo, nil)
|
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
createdTargets := make([]string, 0)
|
createdTargets := make([]string, 0)
|
||||||
@@ -400,7 +403,7 @@ func TestConcurrentMixedOperations(t *testing.T) {
|
|||||||
// Setup services
|
// Setup services
|
||||||
auditSvc := &AuditService{auditRepo: mockAuditRepo}
|
auditSvc := &AuditService{auditRepo: mockAuditRepo}
|
||||||
certSvc := NewCertificateService(mockCertRepo, nil, auditSvc)
|
certSvc := NewCertificateService(mockCertRepo, nil, auditSvc)
|
||||||
targetSvc := NewTargetService(mockTargetRepo, auditSvc)
|
targetSvc := NewTargetService(mockTargetRepo, auditSvc, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errChan := make(chan error, 30)
|
errChan := make(chan error, 30)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sensitiveKeys are config key substrings that should be redacted in API responses.
|
||||||
|
var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"}
|
||||||
|
|
||||||
|
// isSensitiveConfigKey checks if a config key contains sensitive substrings.
|
||||||
|
func isSensitiveConfigKey(key string) bool {
|
||||||
|
lower := strings.ToLower(key)
|
||||||
|
for _, s := range sensitiveKeys {
|
||||||
|
if strings.Contains(lower, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactConfigJSON replaces sensitive values in a JSON config with "********".
|
||||||
|
func redactConfigJSON(configJSON json.RawMessage) json.RawMessage {
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal(configJSON, &m); err != nil {
|
||||||
|
return configJSON // Not a JSON object, return as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range m {
|
||||||
|
if isSensitiveConfigKey(k) {
|
||||||
|
if str, ok := v.(string); ok && str != "" {
|
||||||
|
m[k] = "********"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redacted, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return configJSON
|
||||||
|
}
|
||||||
|
return json.RawMessage(redacted)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ func TestRenewalService_ProcessWithCancelledContext(t *testing.T) {
|
|||||||
notifierRegistry: make(map[string]Notifier),
|
notifierRegistry: make(map[string]Notifier),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
renewalSvc := NewRenewalService(
|
renewalSvc := NewRenewalService(
|
||||||
mockCertRepo,
|
mockCertRepo,
|
||||||
mockJobRepo,
|
mockJobRepo,
|
||||||
@@ -73,7 +76,7 @@ func TestRenewalService_ProcessWithCancelledContext(t *testing.T) {
|
|||||||
mockProfileRepo,
|
mockProfileRepo,
|
||||||
mockAuditSvc,
|
mockAuditSvc,
|
||||||
mockNotifSvc,
|
mockNotifSvc,
|
||||||
make(map[string]IssuerConnector),
|
issuerRegistry,
|
||||||
"agent",
|
"agent",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,7 +142,7 @@ func TestTargetService_ListWithCancelledContext(t *testing.T) {
|
|||||||
mockTargetRepo := &mockTargetRepo{
|
mockTargetRepo := &mockTargetRepo{
|
||||||
Targets: make(map[string]*domain.DeploymentTarget),
|
Targets: make(map[string]*domain.DeploymentTarget),
|
||||||
}
|
}
|
||||||
targetSvc := NewTargetService(mockTargetRepo, nil)
|
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
|
||||||
_, _, err := targetSvc.List(ctx, 1, 50)
|
_, _, err := targetSvc.List(ctx, 1, 50)
|
||||||
|
|
||||||
@@ -162,13 +165,14 @@ func TestAgentService_HeartbeatWithCancelledContext(t *testing.T) {
|
|||||||
Hostname: "localhost",
|
Hostname: "localhost",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
agentSvc := NewAgentService(
|
agentSvc := NewAgentService(
|
||||||
mockAgentRepo,
|
mockAgentRepo,
|
||||||
nil, // certRepo
|
nil, // certRepo
|
||||||
nil, // jobRepo
|
nil, // jobRepo
|
||||||
nil, // targetRepo
|
nil, // targetRepo
|
||||||
nil, // auditService
|
nil, // auditService
|
||||||
make(map[string]IssuerConnector),
|
issuerRegistry,
|
||||||
nil, // renewalService
|
nil, // renewalService
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,13 +216,14 @@ func TestAgentService_HeartbeatWithDeadlineExceeded(t *testing.T) {
|
|||||||
Hostname: "localhost",
|
Hostname: "localhost",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
agentSvc := NewAgentService(
|
agentSvc := NewAgentService(
|
||||||
mockAgentRepo,
|
mockAgentRepo,
|
||||||
nil, // certRepo
|
nil, // certRepo
|
||||||
nil, // jobRepo
|
nil, // jobRepo
|
||||||
nil, // targetRepo
|
nil, // targetRepo
|
||||||
nil, // auditService
|
nil, // auditService
|
||||||
make(map[string]IssuerConnector),
|
issuerRegistry,
|
||||||
nil, // renewalService
|
nil, // renewalService
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,9 +29,8 @@ func newTestRenewalServiceForCSR(issuerErr error) *RenewalService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
issuerConnector := &mockIssuerConnector{Err: issuerErr}
|
issuerConnector := &mockIssuerConnector{Err: issuerErr}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": issuerConnector,
|
issuerRegistry.Set("iss-local", issuerConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent")
|
||||||
return svc
|
return svc
|
||||||
|
|||||||
+548
-42
@@ -2,31 +2,50 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/config"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuerfactory"
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IssuerService provides business logic for certificate issuer management.
|
// IssuerService provides business logic for certificate issuer management.
|
||||||
type IssuerService struct {
|
type IssuerService struct {
|
||||||
issuerRepo repository.IssuerRepository
|
issuerRepo repository.IssuerRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
|
registry *IssuerRegistry
|
||||||
|
encryptionKey []byte
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIssuerService creates a new issuer service.
|
// NewIssuerService creates a new issuer service.
|
||||||
func NewIssuerService(
|
func NewIssuerService(
|
||||||
issuerRepo repository.IssuerRepository,
|
issuerRepo repository.IssuerRepository,
|
||||||
auditService *AuditService,
|
auditService *AuditService,
|
||||||
|
registry *IssuerRegistry,
|
||||||
|
encryptionKey []byte,
|
||||||
|
logger *slog.Logger,
|
||||||
) *IssuerService {
|
) *IssuerService {
|
||||||
return &IssuerService{
|
return &IssuerService{
|
||||||
issuerRepo: issuerRepo,
|
issuerRepo: issuerRepo,
|
||||||
auditService: auditService,
|
auditService: auditService,
|
||||||
|
registry: registry,
|
||||||
|
encryptionKey: encryptionKey,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRegistry returns the dynamic issuer registry.
|
||||||
|
func (s *IssuerService) GetRegistry() *IssuerRegistry {
|
||||||
|
return s.registry
|
||||||
|
}
|
||||||
|
|
||||||
// List returns a paginated list of issuers.
|
// List returns a paginated list of issuers.
|
||||||
func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) {
|
func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -61,49 +80,112 @@ func (s *IssuerService) Get(ctx context.Context, id string) (*domain.Issuer, err
|
|||||||
return issuer, nil
|
return issuer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create validates and stores a new issuer.
|
// validIssuerTypes is the set of allowed issuer types for validation.
|
||||||
func (s *IssuerService) Create(ctx context.Context, issuer *domain.Issuer, actor string) error {
|
var validIssuerTypes = map[domain.IssuerType]bool{
|
||||||
if issuer.Name == "" {
|
domain.IssuerTypeACME: true,
|
||||||
|
domain.IssuerTypeGenericCA: true,
|
||||||
|
domain.IssuerTypeStepCA: true,
|
||||||
|
domain.IssuerTypeOpenSSL: true,
|
||||||
|
domain.IssuerTypeVault: true,
|
||||||
|
domain.IssuerTypeDigiCert: true,
|
||||||
|
domain.IssuerTypeSectigo: true,
|
||||||
|
domain.IssuerTypeGoogleCAS: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidIssuerType checks if a type string is a known issuer type.
|
||||||
|
func isValidIssuerType(t domain.IssuerType) bool {
|
||||||
|
return validIssuerTypes[t]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create validates and stores a new issuer, encrypting sensitive config.
|
||||||
|
func (s *IssuerService) Create(ctx context.Context, iss *domain.Issuer, actor string) error {
|
||||||
|
if iss.Name == "" {
|
||||||
return fmt.Errorf("issuer name is required")
|
return fmt.Errorf("issuer name is required")
|
||||||
}
|
}
|
||||||
|
if !isValidIssuerType(iss.Type) {
|
||||||
|
return fmt.Errorf("unsupported issuer type: %s", iss.Type)
|
||||||
|
}
|
||||||
|
|
||||||
if issuer.ID == "" {
|
if iss.ID == "" {
|
||||||
issuer.ID = generateID("issuer")
|
iss.ID = generateID("issuer")
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if issuer.CreatedAt.IsZero() {
|
if iss.CreatedAt.IsZero() {
|
||||||
issuer.CreatedAt = now
|
iss.CreatedAt = now
|
||||||
}
|
}
|
||||||
if issuer.UpdatedAt.IsZero() {
|
if iss.UpdatedAt.IsZero() {
|
||||||
issuer.UpdatedAt = now
|
iss.UpdatedAt = now
|
||||||
}
|
}
|
||||||
if err := s.issuerRepo.Create(ctx, issuer); err != nil {
|
if iss.TestStatus == "" {
|
||||||
|
iss.TestStatus = "untested"
|
||||||
|
}
|
||||||
|
if iss.Source == "" {
|
||||||
|
iss.Source = "database"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the full config and store redacted version in config column
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
encrypted, _, err := crypto.EncryptIfKeySet([]byte(iss.Config), s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt config: %w", err)
|
||||||
|
}
|
||||||
|
iss.EncryptedConfig = encrypted
|
||||||
|
iss.Config = redactConfigJSON(iss.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Create(ctx, iss); err != nil {
|
||||||
return fmt.Errorf("failed to create issuer: %w", err)
|
return fmt.Errorf("failed to create issuer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to dynamic registry
|
||||||
|
if iss.Enabled {
|
||||||
|
s.rebuildRegistryQuiet(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", issuer.ID, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", iss.ID, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update modifies an existing issuer.
|
// Update modifies an existing issuer. Handles "********" preservation for sensitive fields.
|
||||||
func (s *IssuerService) Update(ctx context.Context, id string, issuer *domain.Issuer, actor string) error {
|
func (s *IssuerService) Update(ctx context.Context, id string, iss *domain.Issuer, actor string) error {
|
||||||
if issuer.Name == "" {
|
if iss.Name == "" {
|
||||||
return fmt.Errorf("issuer name is required")
|
return fmt.Errorf("issuer name is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer.ID = id
|
iss.ID = id
|
||||||
if err := s.issuerRepo.Update(ctx, issuer); err != nil {
|
iss.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// If config contains "********" values, merge with existing decrypted config
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
mergedConfig, err := s.mergeRedactedConfig(ctx, id, iss.Config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to merge config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the merged config
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||||
|
}
|
||||||
|
iss.EncryptedConfig = encrypted
|
||||||
|
iss.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Update(ctx, iss); err != nil {
|
||||||
return fmt.Errorf("failed to update issuer %s: %w", id, err)
|
return fmt.Errorf("failed to update issuer %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild registry after update
|
||||||
|
s.rebuildRegistryQuiet(ctx)
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_issuer", "issuer", id, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_issuer", "issuer", id, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,27 +198,48 @@ func (s *IssuerService) Delete(ctx context.Context, id string, actor string) err
|
|||||||
return fmt.Errorf("failed to delete issuer %s: %w", id, err)
|
return fmt.Errorf("failed to delete issuer %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove from registry
|
||||||
|
if s.registry != nil {
|
||||||
|
s.registry.Remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_issuer", "issuer", id, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_issuer", "issuer", id, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnectionWithContext verifies the issuer connection with context.
|
// TestConnectionWithContext tests the connection to an issuer by instantiating a throwaway
|
||||||
|
// connector and calling ValidateConfig. Records the result in the database.
|
||||||
func (s *IssuerService) TestConnectionWithContext(ctx context.Context, id string) error {
|
func (s *IssuerService) TestConnectionWithContext(ctx context.Context, id string) error {
|
||||||
issuer, err := s.issuerRepo.Get(ctx, id)
|
iss, err := s.issuerRepo.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("issuer not found: %w", err)
|
return fmt.Errorf("issuer not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement actual connection test based on issuer type
|
// Get the decrypted config
|
||||||
if issuer == nil {
|
configJSON, err := s.getDecryptedConfig(iss)
|
||||||
return fmt.Errorf("issuer not found")
|
if err != nil {
|
||||||
|
s.updateTestStatus(ctx, iss, "failed")
|
||||||
|
return fmt.Errorf("failed to decrypt config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Instantiate a throwaway connector and validate
|
||||||
|
connector, err := issuerfactory.NewFromConfig(string(iss.Type), configJSON, s.logger)
|
||||||
|
if err != nil {
|
||||||
|
s.updateTestStatus(ctx, iss, "failed")
|
||||||
|
return fmt.Errorf("failed to create connector: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := connector.ValidateConfig(ctx, configJSON); err != nil {
|
||||||
|
s.updateTestStatus(ctx, iss, "failed")
|
||||||
|
return fmt.Errorf("connection test failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.updateTestStatus(ctx, iss, "success")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +248,241 @@ func (s *IssuerService) TestConnection(id string) error {
|
|||||||
return s.TestConnectionWithContext(context.Background(), id)
|
return s.TestConnectionWithContext(context.Background(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildRegistry loads all enabled issuers from the database and rebuilds the dynamic registry.
|
||||||
|
// Called at server startup. Partial failures (individual issuers failing to load) are logged
|
||||||
|
// as warnings but don't prevent the server from starting.
|
||||||
|
func (s *IssuerService) BuildRegistry(ctx context.Context) error {
|
||||||
|
issuers, err := s.issuerRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load issuers from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.registry.Rebuild(issuers, s.encryptionKey); err != nil {
|
||||||
|
// Log the error but don't fail — some issuers loaded successfully.
|
||||||
|
s.logger.Warn("issuer registry rebuilt with errors", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("issuer registry built from database", "total_issuers", len(issuers), "registry_size", s.registry.Len())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedFromEnvVars creates issuer records from environment variables if the database is empty.
|
||||||
|
// Uses ON CONFLICT DO NOTHING so GUI-created configs are never overwritten.
|
||||||
|
func (s *IssuerService) SeedFromEnvVars(ctx context.Context, cfg *config.Config) {
|
||||||
|
// Check if any issuers already exist
|
||||||
|
existing, err := s.issuerRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to check existing issuers for env var seeding", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(existing) > 0 {
|
||||||
|
s.logger.Info("issuers already exist in database, skipping env var seeding", "count", len(existing))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("no issuers in database, seeding from environment variables")
|
||||||
|
|
||||||
|
seeds := s.buildEnvVarSeeds(cfg)
|
||||||
|
seeded := 0
|
||||||
|
for _, seed := range seeds {
|
||||||
|
// Encrypt the config if key is set
|
||||||
|
if len(seed.Config) > 0 {
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet([]byte(seed.Config), s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
s.logger.Error("failed to encrypt seed config", "id", seed.ID, "error", encErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seed.EncryptedConfig = encrypted
|
||||||
|
seed.Config = redactConfigJSON(seed.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Create(ctx, seed); err != nil {
|
||||||
|
s.logger.Warn("failed to seed issuer from env var", "id", seed.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seeded++
|
||||||
|
s.logger.Info("seeded issuer from env vars", "id", seed.ID, "type", seed.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("env var seeding complete", "seeded", seeded, "total_seeds", len(seeds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnvVarSeeds constructs issuer domain objects from the config's env var values.
|
||||||
|
func (s *IssuerService) buildEnvVarSeeds(cfg *config.Config) []*domain.Issuer {
|
||||||
|
now := time.Now()
|
||||||
|
var seeds []*domain.Issuer
|
||||||
|
|
||||||
|
// Local CA (always seeded)
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-local",
|
||||||
|
Name: "Local CA",
|
||||||
|
Type: domain.IssuerTypeGenericCA,
|
||||||
|
Config: mustJSON(map[string]interface{}{"ca_cert_path": cfg.CA.CertPath, "ca_key_path": cfg.CA.KeyPath}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ACME (always seeded — even with empty directory URL, for demo mode)
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-acme-staging",
|
||||||
|
Name: "ACME Staging",
|
||||||
|
Type: domain.IssuerTypeACME,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"directory_url": cfg.ACME.DirectoryURL,
|
||||||
|
"email": cfg.ACME.Email,
|
||||||
|
"challenge_type": cfg.ACME.ChallengeType,
|
||||||
|
"insecure": cfg.ACME.Insecure,
|
||||||
|
"ari_enabled": cfg.ACME.ARIEnabled,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ACME prod (same config, different ID for backward compat)
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-acme-prod",
|
||||||
|
Name: "ACME Production",
|
||||||
|
Type: domain.IssuerTypeACME,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"directory_url": cfg.ACME.DirectoryURL,
|
||||||
|
"email": cfg.ACME.Email,
|
||||||
|
"challenge_type": cfg.ACME.ChallengeType,
|
||||||
|
"insecure": cfg.ACME.Insecure,
|
||||||
|
"ari_enabled": cfg.ACME.ARIEnabled,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Conditional: step-ca — only seed if CERTCTL_STEPCA_URL is set
|
||||||
|
if stepcaURL := getEnvForSeed("CERTCTL_STEPCA_URL"); stepcaURL != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-stepca",
|
||||||
|
Name: "step-ca",
|
||||||
|
Type: domain.IssuerTypeStepCA,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"ca_url": stepcaURL,
|
||||||
|
"root_cert_path": getEnvForSeed("CERTCTL_STEPCA_ROOT_CERT"),
|
||||||
|
"provisioner_name": getEnvForSeed("CERTCTL_STEPCA_PROVISIONER"),
|
||||||
|
"provisioner_key_path": getEnvForSeed("CERTCTL_STEPCA_KEY_PATH"),
|
||||||
|
"provisioner_password": getEnvForSeed("CERTCTL_STEPCA_PASSWORD"),
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: OpenSSL — only seed if sign script is set
|
||||||
|
if signScript := getEnvForSeed("CERTCTL_OPENSSL_SIGN_SCRIPT"); signScript != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-openssl",
|
||||||
|
Name: "OpenSSL/Custom CA",
|
||||||
|
Type: domain.IssuerTypeOpenSSL,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"sign_script": signScript,
|
||||||
|
"revoke_script": getEnvForSeed("CERTCTL_OPENSSL_REVOKE_SCRIPT"),
|
||||||
|
"crl_script": getEnvForSeed("CERTCTL_OPENSSL_CRL_SCRIPT"),
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: Vault PKI
|
||||||
|
if cfg.Vault.Addr != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-vault",
|
||||||
|
Name: "Vault PKI",
|
||||||
|
Type: domain.IssuerTypeVault,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"addr": cfg.Vault.Addr,
|
||||||
|
"token": cfg.Vault.Token,
|
||||||
|
"mount": cfg.Vault.Mount,
|
||||||
|
"role": cfg.Vault.Role,
|
||||||
|
"ttl": cfg.Vault.TTL,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: DigiCert
|
||||||
|
if cfg.DigiCert.APIKey != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-digicert",
|
||||||
|
Name: "DigiCert CertCentral",
|
||||||
|
Type: domain.IssuerTypeDigiCert,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"api_key": cfg.DigiCert.APIKey,
|
||||||
|
"org_id": cfg.DigiCert.OrgID,
|
||||||
|
"product_type": cfg.DigiCert.ProductType,
|
||||||
|
"base_url": cfg.DigiCert.BaseURL,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: Sectigo
|
||||||
|
if cfg.Sectigo.CustomerURI != "" && cfg.Sectigo.Login != "" && cfg.Sectigo.Password != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-sectigo",
|
||||||
|
Name: "Sectigo SCM",
|
||||||
|
Type: domain.IssuerTypeSectigo,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"customer_uri": cfg.Sectigo.CustomerURI,
|
||||||
|
"login": cfg.Sectigo.Login,
|
||||||
|
"password": cfg.Sectigo.Password,
|
||||||
|
"org_id": cfg.Sectigo.OrgID,
|
||||||
|
"cert_type": cfg.Sectigo.CertType,
|
||||||
|
"term": cfg.Sectigo.Term,
|
||||||
|
"base_url": cfg.Sectigo.BaseURL,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional: Google CAS
|
||||||
|
if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" {
|
||||||
|
seeds = append(seeds, &domain.Issuer{
|
||||||
|
ID: "iss-googlecas",
|
||||||
|
Name: "Google CAS",
|
||||||
|
Type: domain.IssuerTypeGoogleCAS,
|
||||||
|
Config: mustJSON(map[string]interface{}{
|
||||||
|
"project": cfg.GoogleCAS.Project,
|
||||||
|
"location": cfg.GoogleCAS.Location,
|
||||||
|
"ca_pool": cfg.GoogleCAS.CAPool,
|
||||||
|
"credentials": cfg.GoogleCAS.Credentials,
|
||||||
|
"ttl": cfg.GoogleCAS.TTL,
|
||||||
|
}),
|
||||||
|
Enabled: true,
|
||||||
|
Source: "env",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return seeds
|
||||||
|
}
|
||||||
|
|
||||||
// ListIssuers returns paginated issuers (handler interface method).
|
// ListIssuers returns paginated issuers (handler interface method).
|
||||||
func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64, error) {
|
func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -176,33 +514,201 @@ func (s *IssuerService) GetIssuer(id string) (*domain.Issuer, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateIssuer creates a new issuer (handler interface method).
|
// CreateIssuer creates a new issuer (handler interface method).
|
||||||
func (s *IssuerService) CreateIssuer(issuer domain.Issuer) (*domain.Issuer, error) {
|
func (s *IssuerService) CreateIssuer(iss domain.Issuer) (*domain.Issuer, error) {
|
||||||
if issuer.ID == "" {
|
if !isValidIssuerType(iss.Type) {
|
||||||
issuer.ID = generateID("issuer")
|
return nil, fmt.Errorf("unsupported issuer type: %s", iss.Type)
|
||||||
|
}
|
||||||
|
if iss.ID == "" {
|
||||||
|
iss.ID = generateID("issuer")
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if issuer.CreatedAt.IsZero() {
|
if iss.CreatedAt.IsZero() {
|
||||||
issuer.CreatedAt = now
|
iss.CreatedAt = now
|
||||||
}
|
}
|
||||||
if issuer.UpdatedAt.IsZero() {
|
if iss.UpdatedAt.IsZero() {
|
||||||
issuer.UpdatedAt = now
|
iss.UpdatedAt = now
|
||||||
}
|
}
|
||||||
if err := s.issuerRepo.Create(context.Background(), &issuer); err != nil {
|
if iss.TestStatus == "" {
|
||||||
|
iss.TestStatus = "untested"
|
||||||
|
}
|
||||||
|
if iss.Source == "" {
|
||||||
|
iss.Source = "database"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt config
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
encrypted, _, err := crypto.EncryptIfKeySet([]byte(iss.Config), s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt config: %w", err)
|
||||||
|
}
|
||||||
|
iss.EncryptedConfig = encrypted
|
||||||
|
iss.Config = redactConfigJSON(iss.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Create(context.Background(), &iss); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create issuer: %w", err)
|
return nil, fmt.Errorf("failed to create issuer: %w", err)
|
||||||
}
|
}
|
||||||
return &issuer, nil
|
|
||||||
|
// Rebuild registry
|
||||||
|
if iss.Enabled {
|
||||||
|
s.rebuildRegistryQuiet(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &iss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateIssuer modifies an issuer (handler interface method).
|
// UpdateIssuer modifies an issuer (handler interface method).
|
||||||
func (s *IssuerService) UpdateIssuer(id string, issuer domain.Issuer) (*domain.Issuer, error) {
|
func (s *IssuerService) UpdateIssuer(id string, iss domain.Issuer) (*domain.Issuer, error) {
|
||||||
issuer.ID = id
|
iss.ID = id
|
||||||
if err := s.issuerRepo.Update(context.Background(), &issuer); err != nil {
|
iss.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// Merge redacted fields with existing config
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
mergedConfig, err := s.mergeRedactedConfig(context.Background(), id, iss.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to merge config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||||
|
}
|
||||||
|
iss.EncryptedConfig = encrypted
|
||||||
|
iss.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.issuerRepo.Update(context.Background(), &iss); err != nil {
|
||||||
return nil, fmt.Errorf("failed to update issuer: %w", err)
|
return nil, fmt.Errorf("failed to update issuer: %w", err)
|
||||||
}
|
}
|
||||||
return &issuer, nil
|
|
||||||
|
s.rebuildRegistryQuiet(context.Background())
|
||||||
|
|
||||||
|
return &iss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteIssuer removes an issuer (handler interface method).
|
// DeleteIssuer removes an issuer (handler interface method).
|
||||||
func (s *IssuerService) DeleteIssuer(id string) error {
|
func (s *IssuerService) DeleteIssuer(id string) error {
|
||||||
return s.issuerRepo.Delete(context.Background(), id)
|
if err := s.issuerRepo.Delete(context.Background(), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.registry != nil {
|
||||||
|
s.registry.Remove(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers ---
|
||||||
|
|
||||||
|
// rebuildRegistryQuiet rebuilds the registry, logging errors instead of returning them.
|
||||||
|
func (s *IssuerService) rebuildRegistryQuiet(ctx context.Context) {
|
||||||
|
if s.registry == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.BuildRegistry(ctx); err != nil {
|
||||||
|
s.logger.Error("failed to rebuild issuer registry after change", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDecryptedConfig returns the decrypted config JSON for an issuer.
|
||||||
|
func (s *IssuerService) getDecryptedConfig(iss *domain.Issuer) (json.RawMessage, error) {
|
||||||
|
if len(iss.EncryptedConfig) > 0 {
|
||||||
|
decrypted, err := crypto.DecryptIfKeySet(iss.EncryptedConfig, s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.RawMessage(decrypted), nil
|
||||||
|
}
|
||||||
|
if len(iss.Config) > 0 {
|
||||||
|
return iss.Config, nil
|
||||||
|
}
|
||||||
|
return json.RawMessage("{}"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRedactedConfig merges incoming config (which may have "********" values)
|
||||||
|
// with the existing decrypted config so sensitive fields are preserved.
|
||||||
|
func (s *IssuerService) mergeRedactedConfig(ctx context.Context, id string, incoming json.RawMessage) ([]byte, error) {
|
||||||
|
// Parse incoming config
|
||||||
|
var incomingMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(incoming, &incomingMap); err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: incoming config is not a JSON object, using as-is", "issuer", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any values are "********"
|
||||||
|
hasRedacted := false
|
||||||
|
for _, v := range incomingMap {
|
||||||
|
if str, ok := v.(string); ok && str == "********" {
|
||||||
|
hasRedacted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasRedacted {
|
||||||
|
return incoming, nil // No redacted values, use incoming as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing config to get real values
|
||||||
|
existing, err := s.issuerRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: could not load existing issuer, redacted values will be lost", "issuer", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existingConfig, err := s.getDecryptedConfig(existing)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: could not decrypt existing config, redacted values will be lost", "issuer", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(existingConfig, &existingMap); err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: existing config is not a JSON object, redacted values will be lost", "issuer", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: for each "********" value in incoming, use existing value
|
||||||
|
for k, v := range incomingMap {
|
||||||
|
if str, ok := v.(string); ok && str == "********" {
|
||||||
|
if existingVal, exists := existingMap[k]; exists {
|
||||||
|
incomingMap[k] = existingVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(incomingMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTestStatus updates the test_status and last_tested_at fields in the database
|
||||||
|
// and records an audit event.
|
||||||
|
func (s *IssuerService) updateTestStatus(ctx context.Context, iss *domain.Issuer, status string) {
|
||||||
|
now := time.Now()
|
||||||
|
iss.TestStatus = status
|
||||||
|
iss.LastTestedAt = &now
|
||||||
|
iss.UpdatedAt = now
|
||||||
|
if err := s.issuerRepo.Update(ctx, iss); err != nil {
|
||||||
|
s.logger.Error("failed to update test status", "issuer", iss.ID, "status", status, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record audit event for connection test
|
||||||
|
if s.auditService != nil {
|
||||||
|
action := "issuer_test_connection_" + status
|
||||||
|
details := map[string]interface{}{"issuer_type": string(iss.Type), "result": status}
|
||||||
|
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, action, "issuer", iss.ID, details); auditErr != nil {
|
||||||
|
s.logger.Error("failed to record test connection audit event", "error", auditErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnvForSeed reads an environment variable for seed data construction.
|
||||||
|
func getEnvForSeed(key string) string {
|
||||||
|
return os.Getenv(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustJSON marshals a value to json.RawMessage, panicking on error (for seed data only).
|
||||||
|
func mustJSON(v interface{}) json.RawMessage {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("mustJSON: %v", err))
|
||||||
|
}
|
||||||
|
return json.RawMessage(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuerfactory"
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssuerRegistry is a thread-safe registry of issuer connectors.
|
||||||
|
// It replaces the static map[string]IssuerConnector that was built at startup.
|
||||||
|
// Consumers call Get() to look up a connector by issuer ID.
|
||||||
|
type IssuerRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
issuers map[string]IssuerConnector
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIssuerRegistry creates a new empty issuer registry.
|
||||||
|
func NewIssuerRegistry(logger *slog.Logger) *IssuerRegistry {
|
||||||
|
return &IssuerRegistry{
|
||||||
|
issuers: make(map[string]IssuerConnector),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the issuer connector for the given ID and whether it exists.
|
||||||
|
func (r *IssuerRegistry) Get(id string) (IssuerConnector, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
conn, ok := r.issuers[id]
|
||||||
|
return conn, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set adds or replaces an issuer connector in the registry.
|
||||||
|
func (r *IssuerRegistry) Set(id string, conn IssuerConnector) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.issuers[id] = conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes an issuer connector from the registry.
|
||||||
|
func (r *IssuerRegistry) Remove(id string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
delete(r.issuers, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a copy of all registered issuers.
|
||||||
|
func (r *IssuerRegistry) List() map[string]IssuerConnector {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make(map[string]IssuerConnector, len(r.issuers))
|
||||||
|
for k, v := range r.issuers {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of registered issuers.
|
||||||
|
func (r *IssuerRegistry) Len() int {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return len(r.issuers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild reconstructs the registry from a list of issuer configs.
|
||||||
|
// For each enabled issuer, it decrypts the config (if encryption key is set),
|
||||||
|
// instantiates a connector via the factory, wraps it in an adapter, and
|
||||||
|
// atomically swaps the entire map.
|
||||||
|
func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey []byte) error {
|
||||||
|
newIssuers := make(map[string]IssuerConnector)
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
r.logger.Debug("skipping disabled issuer", "id", cfg.ID, "type", cfg.Type)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the config JSON to use for connector instantiation.
|
||||||
|
// Prefer encrypted_config (decrypted) if available; fall back to config.
|
||||||
|
var configJSON json.RawMessage
|
||||||
|
if len(cfg.EncryptedConfig) > 0 {
|
||||||
|
decrypted, err := crypto.DecryptIfKeySet(cfg.EncryptedConfig, encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("issuer %s: decrypt failed: %v", cfg.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configJSON = json.RawMessage(decrypted)
|
||||||
|
} else if len(cfg.Config) > 0 {
|
||||||
|
configJSON = cfg.Config
|
||||||
|
} else {
|
||||||
|
configJSON = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
connector, err := issuerfactory.NewFromConfig(string(cfg.Type), configJSON, r.logger)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("issuer %s: factory error: %v", cfg.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newIssuers[cfg.ID] = NewIssuerConnectorAdapter(connector)
|
||||||
|
r.logger.Info("issuer loaded into registry", "id", cfg.ID, "type", cfg.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic swap
|
||||||
|
r.mu.Lock()
|
||||||
|
old := r.issuers
|
||||||
|
r.issuers = newIssuers
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Log changes
|
||||||
|
for id := range newIssuers {
|
||||||
|
if _, existed := old[id]; !existed {
|
||||||
|
r.logger.Info("issuer added to registry", "id", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range old {
|
||||||
|
if _, exists := newIssuers[id]; !exists {
|
||||||
|
r.logger.Info("issuer removed from registry", "id", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("issuer registry rebuilt", "loaded", len(newIssuers), "failed", len(errors))
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
for _, e := range errors {
|
||||||
|
r.logger.Warn("issuer load failure", "detail", e)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%d issuer(s) failed to load: %s", len(errors), errors[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registryTestLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_GetSet(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
mock := &mockIssuerConnector{}
|
||||||
|
reg.Set("iss-test", mock)
|
||||||
|
|
||||||
|
conn, ok := reg.Get("iss-test")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected to find iss-test in registry")
|
||||||
|
}
|
||||||
|
if conn == nil {
|
||||||
|
t.Fatal("expected non-nil connector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_GetNotFound(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
_, ok := reg.Get("nonexistent")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected not to find nonexistent issuer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Remove(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
reg.Set("iss-test", &mockIssuerConnector{})
|
||||||
|
reg.Remove("iss-test")
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-test")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected issuer to be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_List(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
reg.Set("iss-a", &mockIssuerConnector{})
|
||||||
|
reg.Set("iss-b", &mockIssuerConnector{})
|
||||||
|
|
||||||
|
list := reg.List()
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("expected 2 issuers, got %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify List returns a copy (modifying it doesn't affect registry)
|
||||||
|
delete(list, "iss-a")
|
||||||
|
if reg.Len() != 2 {
|
||||||
|
t.Fatal("deleting from List() copy should not affect registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Len(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
if reg.Len() != 0 {
|
||||||
|
t.Fatalf("expected empty registry, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.Set("iss-a", &mockIssuerConnector{})
|
||||||
|
if reg.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 issuer, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_Enabled(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-local",
|
||||||
|
Name: "Local CA",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "iss-disabled",
|
||||||
|
Name: "Disabled",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := reg.Rebuild(configs, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 enabled issuer, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-local")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected iss-local in registry")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok = reg.Get("iss-disabled")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("disabled issuer should not be in registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_WithEncryption(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
key := crypto.DeriveKey("test-key")
|
||||||
|
configJSON := []byte(`{"ca_common_name":"Encrypted CA"}`)
|
||||||
|
encrypted, err := crypto.Encrypt(configJSON, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-encrypted",
|
||||||
|
Name: "Encrypted Local CA",
|
||||||
|
Type: "local",
|
||||||
|
EncryptedConfig: encrypted,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = reg.Rebuild(configs, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild with encryption failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-encrypted")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected iss-encrypted in registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_NilKeyFallback(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-plain",
|
||||||
|
Name: "Plain Config",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// nil key should work — falls back to config column
|
||||||
|
err := reg.Rebuild(configs, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild with nil key failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-plain")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected iss-plain in registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_InvalidConfig(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-bad",
|
||||||
|
Name: "Bad Config",
|
||||||
|
Type: "UnknownType",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "iss-good",
|
||||||
|
Name: "Good Config",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return an error indicating partial failure, but still load valid issuers
|
||||||
|
err := reg.Rebuild(configs, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Rebuild should return error when some issuers fail to load")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Despite the error, valid issuers should be loaded
|
||||||
|
if reg.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 valid issuer, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-good")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected iss-good in registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_ReplacesExisting(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
// Set up initial state
|
||||||
|
reg.Set("iss-old", &mockIssuerConnector{})
|
||||||
|
|
||||||
|
configs := []*domain.Issuer{
|
||||||
|
{
|
||||||
|
ID: "iss-new",
|
||||||
|
Name: "New Issuer",
|
||||||
|
Type: "local",
|
||||||
|
Config: json.RawMessage(`{}`),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := reg.Rebuild(configs, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := reg.Get("iss-old")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("old issuer should have been replaced")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok = reg.Get("iss-new")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("new issuer should be present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_ConcurrentAccess(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(3)
|
||||||
|
id := "iss-concurrent"
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
reg.Set(id, &mockIssuerConnector{})
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
reg.Get(id)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
reg.List()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
// No race detector panics = success
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerRegistry_Rebuild_Empty(t *testing.T) {
|
||||||
|
reg := NewIssuerRegistry(registryTestLogger())
|
||||||
|
|
||||||
|
reg.Set("iss-existing", &mockIssuerConnector{})
|
||||||
|
|
||||||
|
err := reg.Rebuild([]*domain.Issuer{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rebuild with empty configs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg.Len() != 0 {
|
||||||
|
t.Fatalf("expected empty registry after rebuild with no configs, got %d", reg.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ func TestIssuerService_List(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
issuers, total, err := service.List(ctx, 1, 2)
|
issuers, total, err := service.List(ctx, 1, 2)
|
||||||
|
|
||||||
@@ -85,7 +86,8 @@ func TestIssuerService_List_DefaultPagination(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
// Call with invalid page and perPage
|
// Call with invalid page and perPage
|
||||||
issuers, total, err := service.List(ctx, 0, 0)
|
issuers, total, err := service.List(ctx, 0, 0)
|
||||||
@@ -113,7 +115,7 @@ func TestIssuerService_List_RepositoryError(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
_, _, err := service.List(ctx, 1, 50)
|
_, _, err := service.List(ctx, 1, 50)
|
||||||
|
|
||||||
@@ -134,7 +136,8 @@ func TestIssuerService_List_EmptyResult(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
issuers, total, err := service.List(ctx, 1, 50)
|
issuers, total, err := service.List(ctx, 1, 50)
|
||||||
|
|
||||||
@@ -170,7 +173,7 @@ func TestIssuerService_Get(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
retrieved, err := service.Get(ctx, "iss-acme-prod")
|
retrieved, err := service.Get(ctx, "iss-acme-prod")
|
||||||
|
|
||||||
@@ -195,7 +198,8 @@ func TestIssuerService_Get_NotFound(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
_, err := service.Get(ctx, "nonexistent-issuer")
|
_, err := service.Get(ctx, "nonexistent-issuer")
|
||||||
|
|
||||||
@@ -212,7 +216,8 @@ func TestIssuerService_Create(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
config := map[string]interface{}{"endpoint": "https://acme.example.com/v2/new-account"}
|
config := map[string]interface{}{"endpoint": "https://acme.example.com/v2/new-account"}
|
||||||
configJSON, _ := json.Marshal(config)
|
configJSON, _ := json.Marshal(config)
|
||||||
@@ -274,7 +279,8 @@ func TestIssuerService_Create_EmptyName(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
issuer := &domain.Issuer{
|
issuer := &domain.Issuer{
|
||||||
Name: "",
|
Name: "",
|
||||||
@@ -308,7 +314,7 @@ func TestIssuerService_Create_RepositoryError(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
issuer := &domain.Issuer{
|
issuer := &domain.Issuer{
|
||||||
Name: "Test Issuer",
|
Name: "Test Issuer",
|
||||||
@@ -335,7 +341,8 @@ func TestIssuerService_Update(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
config := map[string]interface{}{"endpoint": "https://acme.example.com"}
|
config := map[string]interface{}{"endpoint": "https://acme.example.com"}
|
||||||
configJSON, _ := json.Marshal(config)
|
configJSON, _ := json.Marshal(config)
|
||||||
@@ -379,7 +386,8 @@ func TestIssuerService_Update_EmptyName(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
issuer := &domain.Issuer{
|
issuer := &domain.Issuer{
|
||||||
Name: "",
|
Name: "",
|
||||||
@@ -406,7 +414,8 @@ func TestIssuerService_Delete(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
err := service.Delete(ctx, "iss-to-delete", "user-frank")
|
err := service.Delete(ctx, "iss-to-delete", "user-frank")
|
||||||
|
|
||||||
@@ -438,7 +447,7 @@ func TestIssuerService_Delete_RepositoryError(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
err := service.Delete(ctx, "iss-bad-id", "user-grace")
|
err := service.Delete(ctx, "iss-bad-id", "user-grace")
|
||||||
|
|
||||||
@@ -455,24 +464,27 @@ func TestIssuerService_Delete_RepositoryError(t *testing.T) {
|
|||||||
func TestIssuerService_TestConnection_Success(t *testing.T) {
|
func TestIssuerService_TestConnection_Success(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
issuer := &domain.Issuer{
|
// Use GenericCA (Local CA) type because it has no required config fields,
|
||||||
|
// so ValidateConfig succeeds with empty config.
|
||||||
|
iss := &domain.Issuer{
|
||||||
ID: "iss-test-conn",
|
ID: "iss-test-conn",
|
||||||
Name: "Test Connection",
|
Name: "Test Connection",
|
||||||
Type: domain.IssuerTypeACME,
|
Type: domain.IssuerTypeGenericCA,
|
||||||
|
Config: json.RawMessage(`{"validity_days":365}`),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
repo := newMockIssuerRepository()
|
repo := newMockIssuerRepository()
|
||||||
repo.AddIssuer(issuer)
|
repo.AddIssuer(iss)
|
||||||
|
|
||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
svc := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
err := service.TestConnectionWithContext(ctx, "iss-test-conn")
|
err := svc.TestConnectionWithContext(ctx, "iss-test-conn")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("TestConnectionWithContext failed: %v", err)
|
t.Fatalf("TestConnectionWithContext failed: %v", err)
|
||||||
@@ -487,7 +499,8 @@ func TestIssuerService_TestConnection_NotFound(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
err := service.TestConnectionWithContext(ctx, "nonexistent-issuer")
|
err := service.TestConnectionWithContext(ctx, "nonexistent-issuer")
|
||||||
|
|
||||||
@@ -527,7 +540,7 @@ func TestIssuerService_ListIssuers_HandlerInterface(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||||
|
|
||||||
issuers, total, err := service.ListIssuers(1, 50)
|
issuers, total, err := service.ListIssuers(1, 50)
|
||||||
|
|
||||||
@@ -554,7 +567,8 @@ func TestIssuerService_CreateIssuer_HandlerInterface(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
config := map[string]interface{}{"url": "https://example.com"}
|
config := map[string]interface{}{"url": "https://example.com"}
|
||||||
configJSON, _ := json.Marshal(config)
|
configJSON, _ := json.Marshal(config)
|
||||||
@@ -591,7 +605,8 @@ func TestIssuerService_DeleteIssuer_HandlerInterface(t *testing.T) {
|
|||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
|
|
||||||
service := NewIssuerService(repo, auditService)
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
|
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||||
|
|
||||||
err := service.DeleteIssuer("iss-handler-delete")
|
err := service.DeleteIssuer("iss-handler-delete")
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ func newTestJobService(jobRepo *mockJobRepo) *JobService {
|
|||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||||
|
|
||||||
renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notifService, make(map[string]IssuerConnector), "server")
|
issuerRegistry := NewIssuerRegistry(logger)
|
||||||
|
renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notifService, issuerRegistry, "server")
|
||||||
deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService)
|
deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService)
|
||||||
|
|
||||||
return NewJobService(jobRepo, renewalService, deploymentService, logger)
|
return NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type RenewalService struct {
|
|||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
notificationSvc *NotificationService
|
notificationSvc *NotificationService
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry *IssuerRegistry
|
||||||
keygenMode string // "agent" (default) or "server" (demo only)
|
keygenMode string // "agent" (default) or "server" (demo only)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ func NewRenewalService(
|
|||||||
profileRepo repository.CertificateProfileRepository,
|
profileRepo repository.CertificateProfileRepository,
|
||||||
auditService *AuditService,
|
auditService *AuditService,
|
||||||
notificationSvc *NotificationService,
|
notificationSvc *NotificationService,
|
||||||
issuerRegistry map[string]IssuerConnector,
|
issuerRegistry *IssuerRegistry,
|
||||||
keygenMode string,
|
keygenMode string,
|
||||||
) *RenewalService {
|
) *RenewalService {
|
||||||
if keygenMode == "" {
|
if keygenMode == "" {
|
||||||
@@ -169,7 +169,7 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
|||||||
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
||||||
|
|
||||||
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
||||||
connector, hasIssuer := s.issuerRegistry[cert.IssuerID]
|
connector, hasIssuer := s.issuerRegistry.Get(cert.IssuerID)
|
||||||
if !hasIssuer {
|
if !hasIssuer {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,7 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
|||||||
return fmt.Errorf("certificate has no issuer assigned")
|
return fmt.Errorf("certificate has no issuer assigned")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, ok := s.issuerRegistry[issuerID]
|
_, ok := s.issuerRegistry.Get(issuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", issuerID))
|
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", issuerID))
|
||||||
return fmt.Errorf("issuer connector not found for %s", issuerID)
|
return fmt.Errorf("issuer connector not found for %s", issuerID)
|
||||||
@@ -390,7 +390,7 @@ func (s *RenewalService) processRenewalAgentKeygen(ctx context.Context, job *dom
|
|||||||
// private key in the cert version so agents can retrieve it for deployment.
|
// private key in the cert version so agents can retrieve it for deployment.
|
||||||
// WARNING: Private keys touch the control plane. Use only for development/demo.
|
// WARNING: Private keys touch the control plane. Use only for development/demo.
|
||||||
func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate) error {
|
func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate) error {
|
||||||
connector := s.issuerRegistry[cert.IssuerID]
|
connector, _ := s.issuerRegistry.Get(cert.IssuerID)
|
||||||
|
|
||||||
// Generate server-side RSA key + CSR
|
// Generate server-side RSA key + CSR
|
||||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
@@ -524,7 +524,7 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do
|
|||||||
// It signs the CSR via the issuer connector, stores the cert version (without private key),
|
// It signs the CSR via the issuer connector, stores the cert version (without private key),
|
||||||
// completes the renewal job, and creates deployment jobs.
|
// completes the renewal job, and creates deployment jobs.
|
||||||
func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error {
|
func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error {
|
||||||
connector, ok := s.issuerRegistry[cert.IssuerID]
|
connector, ok := s.issuerRegistry.Get(cert.IssuerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", cert.IssuerID))
|
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", cert.IssuerID))
|
||||||
return fmt.Errorf("issuer connector not found for %s", cert.IssuerID)
|
return fmt.Errorf("issuer connector not found for %s", cert.IssuerID)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,9 +27,8 @@ func TestCheckExpiringCertificates_SendsThresholdAlerts(t *testing.T) {
|
|||||||
"Email": notifier,
|
"Email": notifier,
|
||||||
})
|
})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -108,9 +108,8 @@ func TestCheckExpiringCertificates_DeduplicatesAlerts(t *testing.T) {
|
|||||||
"Email": notifier,
|
"Email": notifier,
|
||||||
})
|
})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -188,9 +187,8 @@ func TestCheckExpiringCertificates_SkipsRenewalInProgress(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -253,9 +251,8 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpiring(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -315,9 +312,8 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpired(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -377,9 +373,8 @@ func TestCheckExpiringCertificates_CreatesRenewalJob(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -445,7 +440,7 @@ func TestCheckExpiringCertificates_SkipsWithoutIssuer(t *testing.T) {
|
|||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
// Empty issuer registry
|
// Empty issuer registry
|
||||||
issuerRegistry := map[string]IssuerConnector{}
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -505,9 +500,8 @@ func TestCheckExpiringCertificates_SkipsDuplicateJobs(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -589,9 +583,8 @@ func TestProcessRenewalJob(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
issuerConnector := &mockIssuerConnector{}
|
issuerConnector := &mockIssuerConnector{}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": issuerConnector,
|
issuerRegistry.Set("iss-test", issuerConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -685,9 +678,8 @@ func TestProcessRenewalJob_IssuerFailure(t *testing.T) {
|
|||||||
Err: fmt.Errorf("issuer service unavailable"),
|
Err: fmt.Errorf("issuer service unavailable"),
|
||||||
}
|
}
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": issuerConnector,
|
issuerRegistry.Set("iss-test", issuerConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -767,9 +759,8 @@ func TestRetryFailedJobs(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -832,9 +823,8 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
|
|||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -885,9 +875,8 @@ func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
|
|||||||
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
|
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-acme": ariConnector,
|
issuerRegistry.Set("iss-acme", ariConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -958,9 +947,8 @@ func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) {
|
|||||||
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
|
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-acme": ariConnector,
|
issuerRegistry.Set("iss-acme", ariConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -1021,9 +1009,8 @@ func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) {
|
|||||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
|
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-local", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
@@ -1090,9 +1077,8 @@ func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
|
|||||||
ariConnector := &mockIssuerConnector{
|
ariConnector := &mockIssuerConnector{
|
||||||
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
|
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
|
||||||
}
|
}
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-acme": ariConnector,
|
issuerRegistry.Set("iss-acme", ariConnector)
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type RevocationSvc struct {
|
|||||||
revocationRepo repository.RevocationRepository
|
revocationRepo repository.RevocationRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
notificationSvc *NotificationService
|
notificationSvc *NotificationService
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry *IssuerRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRevocationSvc creates a new revocation service.
|
// NewRevocationSvc creates a new revocation service.
|
||||||
@@ -39,7 +39,7 @@ func (s *RevocationSvc) SetNotificationService(svc *NotificationService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetIssuerRegistry sets the issuer registry for issuer-level revocation.
|
// SetIssuerRegistry sets the issuer registry for issuer-level revocation.
|
||||||
func (s *RevocationSvc) SetIssuerRegistry(registry map[string]IssuerConnector) {
|
func (s *RevocationSvc) SetIssuerRegistry(registry *IssuerRegistry) {
|
||||||
s.issuerRegistry = registry
|
s.issuerRegistry = registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ func (s *RevocationSvc) RevokeCertificateWithActor(ctx context.Context, certID s
|
|||||||
|
|
||||||
// 5. Notify the issuer connector (best-effort)
|
// 5. Notify the issuer connector (best-effort)
|
||||||
if s.issuerRegistry != nil {
|
if s.issuerRegistry != nil {
|
||||||
if issuerConn, ok := s.issuerRegistry[cert.IssuerID]; ok {
|
if issuerConn, ok := s.issuerRegistry.Get(cert.IssuerID); ok {
|
||||||
if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil {
|
if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil {
|
||||||
slog.Error("failed to notify issuer of revocation",
|
slog.Error("failed to notify issuer of revocation",
|
||||||
"error", err,
|
"error", err,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,9 +19,9 @@ func newRevocationSvcTest() (*RevocationSvc, *mockCertRepo, *mockRevocationRepo,
|
|||||||
|
|
||||||
auditService := NewAuditService(auditRepo)
|
auditService := NewAuditService(auditRepo)
|
||||||
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
||||||
revSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": &mockIssuerConnector{},
|
registry.Set("iss-local", &mockIssuerConnector{})
|
||||||
})
|
revSvc.SetIssuerRegistry(registry)
|
||||||
|
|
||||||
return revSvc, certRepo, revocationRepo, auditRepo
|
return revSvc, certRepo, revocationRepo, auditRepo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,15 +22,13 @@ func newRevocationTestService() (*CertificateService, *mockCertRepo, *mockRevoca
|
|||||||
|
|
||||||
// Create RevocationSvc
|
// Create RevocationSvc
|
||||||
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
|
||||||
revSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": &mockIssuerConnector{},
|
registry.Set("iss-local", &mockIssuerConnector{})
|
||||||
})
|
revSvc.SetIssuerRegistry(registry)
|
||||||
|
|
||||||
// Create CAOperationsSvc
|
// Create CAOperationsSvc
|
||||||
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
|
||||||
caSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
caSvc.SetIssuerRegistry(registry)
|
||||||
"iss-local": &mockIssuerConnector{},
|
|
||||||
})
|
|
||||||
|
|
||||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||||
certService.SetRevocationSvc(revSvc)
|
certService.SetRevocationSvc(revSvc)
|
||||||
@@ -243,9 +242,9 @@ func TestRevokeCertificate_WithIssuerNotification(t *testing.T) {
|
|||||||
|
|
||||||
// Wire up issuer registry on RevocationSvc with mock
|
// Wire up issuer registry on RevocationSvc with mock
|
||||||
mockIssuer := &mockIssuerConnector{}
|
mockIssuer := &mockIssuerConnector{}
|
||||||
svc.revSvc.SetIssuerRegistry(map[string]IssuerConnector{
|
registry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-local": mockIssuer,
|
registry.Set("iss-local", mockIssuer)
|
||||||
})
|
svc.revSvc.SetIssuerRegistry(registry)
|
||||||
|
|
||||||
cert := &domain.ManagedCertificate{
|
cert := &domain.ManagedCertificate{
|
||||||
ID: "cert-7",
|
ID: "cert-7",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,9 +19,8 @@ func setupShortLivedTestService(
|
|||||||
) *RenewalService {
|
) *RenewalService {
|
||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
|
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(
|
svc := NewRenewalService(
|
||||||
certRepo,
|
certRepo,
|
||||||
@@ -137,9 +137,8 @@ func TestExpireShortLivedCertificates_ListError(t *testing.T) {
|
|||||||
|
|
||||||
// Create the service manually to use our custom cert repo
|
// Create the service manually to use our custom cert repo
|
||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(
|
svc := NewRenewalService(
|
||||||
customCertRepo,
|
customCertRepo,
|
||||||
@@ -385,9 +384,8 @@ func TestExpireShortLivedCertificates_NoProfileRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
issuerRegistry := map[string]IssuerConnector{
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
||||||
"iss-test": &mockIssuerConnector{},
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewRenewalService(
|
svc := NewRenewalService(
|
||||||
certRepo,
|
certRepo,
|
||||||
|
|||||||
+249
-9
@@ -2,28 +2,59 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/crypto"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// validTargetTypes is the set of allowed target types for validation.
|
||||||
|
var validTargetTypes = map[domain.TargetType]bool{
|
||||||
|
domain.TargetTypeNGINX: true,
|
||||||
|
domain.TargetTypeApache: true,
|
||||||
|
domain.TargetTypeHAProxy: true,
|
||||||
|
domain.TargetTypeF5: true,
|
||||||
|
domain.TargetTypeIIS: true,
|
||||||
|
domain.TargetTypeTraefik: true,
|
||||||
|
domain.TargetTypeCaddy: true,
|
||||||
|
domain.TargetTypeEnvoy: true,
|
||||||
|
domain.TargetTypePostfix: true,
|
||||||
|
domain.TargetTypeDovecot: true,
|
||||||
|
domain.TargetTypeSSH: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidTargetType checks if a type string is a known target type.
|
||||||
|
func isValidTargetType(t domain.TargetType) bool {
|
||||||
|
return validTargetTypes[t]
|
||||||
|
}
|
||||||
|
|
||||||
// TargetService provides business logic for deployment target management.
|
// TargetService provides business logic for deployment target management.
|
||||||
type TargetService struct {
|
type TargetService struct {
|
||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
auditService *AuditService
|
agentRepo repository.AgentRepository
|
||||||
|
auditService *AuditService
|
||||||
|
encryptionKey []byte
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTargetService creates a new target service.
|
// NewTargetService creates a new target service.
|
||||||
func NewTargetService(
|
func NewTargetService(
|
||||||
targetRepo repository.TargetRepository,
|
targetRepo repository.TargetRepository,
|
||||||
auditService *AuditService,
|
auditService *AuditService,
|
||||||
|
agentRepo repository.AgentRepository,
|
||||||
|
encryptionKey []byte,
|
||||||
|
logger *slog.Logger,
|
||||||
) *TargetService {
|
) *TargetService {
|
||||||
return &TargetService{
|
return &TargetService{
|
||||||
targetRepo: targetRepo,
|
targetRepo: targetRepo,
|
||||||
auditService: auditService,
|
agentRepo: agentRepo,
|
||||||
|
auditService: auditService,
|
||||||
|
encryptionKey: encryptionKey,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +92,14 @@ func (s *TargetService) Get(ctx context.Context, id string) (*domain.DeploymentT
|
|||||||
return target, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create validates and stores a new deployment target.
|
// Create validates and stores a new deployment target, encrypting sensitive config.
|
||||||
func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTarget, actor string) error {
|
func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTarget, actor string) error {
|
||||||
if target.Name == "" {
|
if target.Name == "" {
|
||||||
return fmt.Errorf("target name is required")
|
return fmt.Errorf("target name is required")
|
||||||
}
|
}
|
||||||
|
if !isValidTargetType(target.Type) {
|
||||||
|
return fmt.Errorf("unsupported target type: %s", target.Type)
|
||||||
|
}
|
||||||
|
|
||||||
if target.ID == "" {
|
if target.ID == "" {
|
||||||
target.ID = generateID("target")
|
target.ID = generateID("target")
|
||||||
@@ -77,33 +111,68 @@ func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTar
|
|||||||
if target.UpdatedAt.IsZero() {
|
if target.UpdatedAt.IsZero() {
|
||||||
target.UpdatedAt = now
|
target.UpdatedAt = now
|
||||||
}
|
}
|
||||||
|
if target.TestStatus == "" {
|
||||||
|
target.TestStatus = "untested"
|
||||||
|
}
|
||||||
|
if target.Source == "" {
|
||||||
|
target.Source = "database"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the full config and store redacted version in config column
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt config: %w", err)
|
||||||
|
}
|
||||||
|
target.EncryptedConfig = encrypted
|
||||||
|
target.Config = redactConfigJSON(target.Config)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.targetRepo.Create(ctx, target); err != nil {
|
if err := s.targetRepo.Create(ctx, target); err != nil {
|
||||||
return fmt.Errorf("failed to create target: %w", err)
|
return fmt.Errorf("failed to create target: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_target", "target", target.ID, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_target", "target", target.ID, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update modifies an existing deployment target.
|
// Update modifies an existing deployment target. Handles "********" preservation for sensitive fields.
|
||||||
func (s *TargetService) Update(ctx context.Context, id string, target *domain.DeploymentTarget, actor string) error {
|
func (s *TargetService) Update(ctx context.Context, id string, target *domain.DeploymentTarget, actor string) error {
|
||||||
if target.Name == "" {
|
if target.Name == "" {
|
||||||
return fmt.Errorf("target name is required")
|
return fmt.Errorf("target name is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
target.ID = id
|
target.ID = id
|
||||||
|
target.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// If config contains "********" values, merge with existing decrypted config
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
mergedConfig, err := s.mergeRedactedConfig(ctx, id, target.Config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to merge config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the merged config
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||||
|
}
|
||||||
|
target.EncryptedConfig = encrypted
|
||||||
|
target.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.targetRepo.Update(ctx, target); err != nil {
|
if err := s.targetRepo.Update(ctx, target); err != nil {
|
||||||
return fmt.Errorf("failed to update target %s: %w", id, err)
|
return fmt.Errorf("failed to update target %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_target", "target", id, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_target", "target", id, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,13 +187,50 @@ func (s *TargetService) Delete(ctx context.Context, id string, actor string) err
|
|||||||
|
|
||||||
if s.auditService != nil {
|
if s.auditService != nil {
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_target", "target", id, nil); auditErr != nil {
|
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_target", "target", id, nil); auditErr != nil {
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConnection tests a target's connectivity by checking the assigned agent's heartbeat status.
|
||||||
|
// Target connectors run on agents, not on the server, so we can't instantiate a connector here.
|
||||||
|
// Instead, we verify the agent is online and reachable.
|
||||||
|
func (s *TargetService) TestConnection(ctx context.Context, id string) error {
|
||||||
|
target, err := s.targetRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("target not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if target.AgentID == "" {
|
||||||
|
s.updateTestStatus(ctx, target, "failed")
|
||||||
|
return fmt.Errorf("target has no assigned agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := s.agentRepo.Get(ctx, target.AgentID)
|
||||||
|
if err != nil {
|
||||||
|
s.updateTestStatus(ctx, target, "failed")
|
||||||
|
return fmt.Errorf("assigned agent not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent.Status != domain.AgentStatusOnline {
|
||||||
|
s.updateTestStatus(ctx, target, "failed")
|
||||||
|
return fmt.Errorf("assigned agent %s is %s (expected Online)", agent.ID, agent.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check heartbeat freshness (agent must have heartbeated within the last 5 minutes)
|
||||||
|
if agent.LastHeartbeatAt != nil {
|
||||||
|
if time.Since(*agent.LastHeartbeatAt) > 5*time.Minute {
|
||||||
|
s.updateTestStatus(ctx, target, "failed")
|
||||||
|
return fmt.Errorf("assigned agent %s last heartbeat was %s ago (stale)", agent.ID, time.Since(*agent.LastHeartbeatAt).Round(time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.updateTestStatus(ctx, target, "success")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListTargets returns paginated targets (handler interface method).
|
// ListTargets returns paginated targets (handler interface method).
|
||||||
func (s *TargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
func (s *TargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -157,6 +263,9 @@ func (s *TargetService) GetTarget(id string) (*domain.DeploymentTarget, error) {
|
|||||||
|
|
||||||
// CreateTarget creates a new target (handler interface method).
|
// CreateTarget creates a new target (handler interface method).
|
||||||
func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
||||||
|
if !isValidTargetType(target.Type) {
|
||||||
|
return nil, fmt.Errorf("unsupported target type: %s", target.Type)
|
||||||
|
}
|
||||||
if target.ID == "" {
|
if target.ID == "" {
|
||||||
target.ID = generateID("target")
|
target.ID = generateID("target")
|
||||||
}
|
}
|
||||||
@@ -167,6 +276,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De
|
|||||||
if target.UpdatedAt.IsZero() {
|
if target.UpdatedAt.IsZero() {
|
||||||
target.UpdatedAt = now
|
target.UpdatedAt = now
|
||||||
}
|
}
|
||||||
|
if target.TestStatus == "" {
|
||||||
|
target.TestStatus = "untested"
|
||||||
|
}
|
||||||
|
if target.Source == "" {
|
||||||
|
target.Source = "database"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt config
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt config: %w", err)
|
||||||
|
}
|
||||||
|
target.EncryptedConfig = encrypted
|
||||||
|
target.Config = redactConfigJSON(target.Config)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.targetRepo.Create(context.Background(), &target); err != nil {
|
if err := s.targetRepo.Create(context.Background(), &target); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create target: %w", err)
|
return nil, fmt.Errorf("failed to create target: %w", err)
|
||||||
}
|
}
|
||||||
@@ -176,6 +302,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De
|
|||||||
// UpdateTarget modifies a target (handler interface method).
|
// UpdateTarget modifies a target (handler interface method).
|
||||||
func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
||||||
target.ID = id
|
target.ID = id
|
||||||
|
target.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// Merge redacted fields with existing config
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
mergedConfig, err := s.mergeRedactedConfig(context.Background(), id, target.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to merge config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||||
|
}
|
||||||
|
target.EncryptedConfig = encrypted
|
||||||
|
target.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.targetRepo.Update(context.Background(), &target); err != nil {
|
if err := s.targetRepo.Update(context.Background(), &target); err != nil {
|
||||||
return nil, fmt.Errorf("failed to update target: %w", err)
|
return nil, fmt.Errorf("failed to update target: %w", err)
|
||||||
}
|
}
|
||||||
@@ -186,3 +329,100 @@ func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget)
|
|||||||
func (s *TargetService) DeleteTarget(id string) error {
|
func (s *TargetService) DeleteTarget(id string) error {
|
||||||
return s.targetRepo.Delete(context.Background(), id)
|
return s.targetRepo.Delete(context.Background(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTargetConnection tests target connectivity (handler interface method).
|
||||||
|
func (s *TargetService) TestTargetConnection(id string) error {
|
||||||
|
return s.TestConnection(context.Background(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers ---
|
||||||
|
|
||||||
|
// getDecryptedConfig returns the decrypted config JSON for a target.
|
||||||
|
func (s *TargetService) getDecryptedConfig(target *domain.DeploymentTarget) (json.RawMessage, error) {
|
||||||
|
if len(target.EncryptedConfig) > 0 {
|
||||||
|
decrypted, err := crypto.DecryptIfKeySet(target.EncryptedConfig, s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.RawMessage(decrypted), nil
|
||||||
|
}
|
||||||
|
if len(target.Config) > 0 {
|
||||||
|
return target.Config, nil
|
||||||
|
}
|
||||||
|
return json.RawMessage("{}"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRedactedConfig merges incoming config (which may have "********" values)
|
||||||
|
// with the existing decrypted config so sensitive fields are preserved.
|
||||||
|
func (s *TargetService) mergeRedactedConfig(ctx context.Context, id string, incoming json.RawMessage) ([]byte, error) {
|
||||||
|
// Parse incoming config
|
||||||
|
var incomingMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(incoming, &incomingMap); err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: incoming config is not a JSON object, using as-is", "target", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any values are "********"
|
||||||
|
hasRedacted := false
|
||||||
|
for _, v := range incomingMap {
|
||||||
|
if str, ok := v.(string); ok && str == "********" {
|
||||||
|
hasRedacted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasRedacted {
|
||||||
|
return incoming, nil // No redacted values, use incoming as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing target to get real values
|
||||||
|
existing, err := s.targetRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: could not load existing target, redacted values will be lost", "target", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existingConfig, err := s.getDecryptedConfig(existing)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: could not decrypt existing config, redacted values will be lost", "target", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(existingConfig, &existingMap); err != nil {
|
||||||
|
s.logger.Warn("mergeRedactedConfig: existing config is not a JSON object, redacted values will be lost", "target", id, "error", err)
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: for each "********" value in incoming, use existing value
|
||||||
|
for k, v := range incomingMap {
|
||||||
|
if str, ok := v.(string); ok && str == "********" {
|
||||||
|
if existingVal, exists := existingMap[k]; exists {
|
||||||
|
incomingMap[k] = existingVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(incomingMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTestStatus updates the test_status and last_tested_at fields in the database
|
||||||
|
// and records an audit event.
|
||||||
|
func (s *TargetService) updateTestStatus(ctx context.Context, target *domain.DeploymentTarget, status string) {
|
||||||
|
now := time.Now()
|
||||||
|
target.TestStatus = status
|
||||||
|
target.LastTestedAt = &now
|
||||||
|
target.UpdatedAt = now
|
||||||
|
if err := s.targetRepo.Update(ctx, target); err != nil {
|
||||||
|
s.logger.Error("failed to update test status", "target", target.ID, "status", status, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record audit event for connection test
|
||||||
|
if s.auditService != nil {
|
||||||
|
action := "target_test_connection_" + status
|
||||||
|
details := map[string]interface{}{"target_type": string(target.Type), "result": status, "agent_id": target.AgentID}
|
||||||
|
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, action, "target", target.ID, details); auditErr != nil {
|
||||||
|
s.logger.Error("failed to record test connection audit event", "error", auditErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+189
-20
@@ -3,21 +3,26 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestTargetService creates a TargetService with mock repositories for testing.
|
// newTestTargetService creates a TargetService with mock repositories for testing.
|
||||||
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo) {
|
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo, *mockAgentRepo) {
|
||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
auditRepo := newMockAuditRepository()
|
auditRepo := newMockAuditRepository()
|
||||||
auditSvc := NewAuditService(auditRepo)
|
auditSvc := NewAuditService(auditRepo)
|
||||||
return NewTargetService(targetRepo, auditSvc), targetRepo, auditRepo
|
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent), HeartbeatUpdates: make(map[string]time.Time)}
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
return NewTargetService(targetRepo, auditSvc, agentRepo, nil, logger), targetRepo, auditRepo, agentRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_List_Success(t *testing.T) {
|
func TestTargetService_List_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Add 3 targets
|
// Add 3 targets
|
||||||
@@ -44,7 +49,7 @@ func TestTargetService_List_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_List_DefaultPagination(t *testing.T) {
|
func TestTargetService_List_DefaultPagination(t *testing.T) {
|
||||||
svc, _, _ := newTestTargetService()
|
svc, _, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Call with invalid pagination (page=0, perPage=0)
|
// Call with invalid pagination (page=0, perPage=0)
|
||||||
@@ -60,7 +65,7 @@ func TestTargetService_List_DefaultPagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_List_EmptyPage(t *testing.T) {
|
func TestTargetService_List_EmptyPage(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Add 3 targets
|
// Add 3 targets
|
||||||
@@ -87,7 +92,7 @@ func TestTargetService_List_EmptyPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_List_RepoError(t *testing.T) {
|
func TestTargetService_List_RepoError(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Set repo to return error
|
// Set repo to return error
|
||||||
@@ -104,7 +109,7 @@ func TestTargetService_List_RepoError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Get_Success(t *testing.T) {
|
func TestTargetService_Get_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||||
@@ -121,7 +126,7 @@ func TestTargetService_Get_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Get_NotFound(t *testing.T) {
|
func TestTargetService_Get_NotFound(t *testing.T) {
|
||||||
svc, _, _ := newTestTargetService()
|
svc, _, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
result, err := svc.Get(ctx, "nonexistent")
|
result, err := svc.Get(ctx, "nonexistent")
|
||||||
@@ -135,7 +140,7 @@ func TestTargetService_Get_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Create_Success(t *testing.T) {
|
func TestTargetService_Create_Success(t *testing.T) {
|
||||||
svc, targetRepo, auditRepo := newTestTargetService()
|
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{
|
target := &domain.DeploymentTarget{
|
||||||
@@ -168,6 +173,14 @@ func TestTargetService_Create_Success(t *testing.T) {
|
|||||||
t.Errorf("expected timestamps to be set, CreatedAt=%v, UpdatedAt=%v", target.CreatedAt, target.UpdatedAt)
|
t.Errorf("expected timestamps to be set, CreatedAt=%v, UpdatedAt=%v", target.CreatedAt, target.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify test status and source defaults
|
||||||
|
if target.TestStatus != "untested" {
|
||||||
|
t.Errorf("expected test_status 'untested', got %s", target.TestStatus)
|
||||||
|
}
|
||||||
|
if target.Source != "database" {
|
||||||
|
t.Errorf("expected source 'database', got %s", target.Source)
|
||||||
|
}
|
||||||
|
|
||||||
// Verify audit event
|
// Verify audit event
|
||||||
if len(auditRepo.Events) == 0 {
|
if len(auditRepo.Events) == 0 {
|
||||||
t.Fatalf("expected audit event, got none")
|
t.Fatalf("expected audit event, got none")
|
||||||
@@ -184,7 +197,7 @@ func TestTargetService_Create_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Create_MissingName(t *testing.T) {
|
func TestTargetService_Create_MissingName(t *testing.T) {
|
||||||
svc, _, _ := newTestTargetService()
|
svc, _, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{
|
target := &domain.DeploymentTarget{
|
||||||
@@ -197,8 +210,23 @@ func TestTargetService_Create_MissingName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetService_Create_InvalidType(t *testing.T) {
|
||||||
|
svc, _, _, _ := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
Name: "Bad Target",
|
||||||
|
Type: domain.TargetType("InvalidType"),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.Create(ctx, target, "test-actor")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for invalid type, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTargetService_Create_RepoError(t *testing.T) {
|
func TestTargetService_Create_RepoError(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
targetRepo.CreateErr = errNotFound
|
targetRepo.CreateErr = errNotFound
|
||||||
@@ -215,7 +243,7 @@ func TestTargetService_Create_RepoError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Update_Success(t *testing.T) {
|
func TestTargetService_Update_Success(t *testing.T) {
|
||||||
svc, targetRepo, auditRepo := newTestTargetService()
|
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create initial target
|
// Create initial target
|
||||||
@@ -251,7 +279,7 @@ func TestTargetService_Update_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Update_MissingName(t *testing.T) {
|
func TestTargetService_Update_MissingName(t *testing.T) {
|
||||||
svc, _, _ := newTestTargetService()
|
svc, _, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{
|
target := &domain.DeploymentTarget{
|
||||||
@@ -265,7 +293,7 @@ func TestTargetService_Update_MissingName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Delete_Success(t *testing.T) {
|
func TestTargetService_Delete_Success(t *testing.T) {
|
||||||
svc, targetRepo, auditRepo := newTestTargetService()
|
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create initial target
|
// Create initial target
|
||||||
@@ -295,7 +323,7 @@ func TestTargetService_Delete_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_Delete_RepoError(t *testing.T) {
|
func TestTargetService_Delete_RepoError(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
targetRepo.DeleteErr = errNotFound
|
targetRepo.DeleteErr = errNotFound
|
||||||
@@ -307,7 +335,7 @@ func TestTargetService_Delete_RepoError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_ListTargets_Success(t *testing.T) {
|
func TestTargetService_ListTargets_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
// Add targets
|
// Add targets
|
||||||
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||||
@@ -331,7 +359,7 @@ func TestTargetService_ListTargets_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_GetTarget_Success(t *testing.T) {
|
func TestTargetService_GetTarget_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||||
targetRepo.AddTarget(target)
|
targetRepo.AddTarget(target)
|
||||||
@@ -347,7 +375,7 @@ func TestTargetService_GetTarget_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_CreateTarget_Success(t *testing.T) {
|
func TestTargetService_CreateTarget_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
target := domain.DeploymentTarget{
|
target := domain.DeploymentTarget{
|
||||||
Name: "New Target",
|
Name: "New Target",
|
||||||
@@ -369,8 +397,22 @@ func TestTargetService_CreateTarget_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetService_CreateTarget_InvalidType(t *testing.T) {
|
||||||
|
svc, _, _, _ := newTestTargetService()
|
||||||
|
|
||||||
|
target := domain.DeploymentTarget{
|
||||||
|
Name: "Bad Target",
|
||||||
|
Type: domain.TargetType("Unknown"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.CreateTarget(target)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for invalid type, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
// Create initial target
|
// Create initial target
|
||||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
|
||||||
@@ -393,7 +435,7 @@ func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
||||||
svc, targetRepo, _ := newTestTargetService()
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
|
||||||
// Create initial target
|
// Create initial target
|
||||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
|
||||||
@@ -410,3 +452,130 @@ func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
|||||||
t.Errorf("target should be deleted from repo")
|
t.Errorf("target should be deleted from repo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_AgentOnline(t *testing.T) {
|
||||||
|
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Set up agent
|
||||||
|
heartbeat := time.Now()
|
||||||
|
agent := &domain.Agent{
|
||||||
|
ID: "agent-1",
|
||||||
|
Name: "Test Agent",
|
||||||
|
Status: domain.AgentStatusOnline,
|
||||||
|
LastHeartbeatAt: &heartbeat,
|
||||||
|
}
|
||||||
|
agentRepo.Create(ctx, agent)
|
||||||
|
|
||||||
|
// Set up target assigned to agent
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "t-1",
|
||||||
|
Name: "Test Target",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "agent-1",
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
// Test connection should succeed
|
||||||
|
err := svc.TestConnection(ctx, "t-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify test status was updated
|
||||||
|
stored := targetRepo.Targets["t-1"]
|
||||||
|
if stored.TestStatus != "success" {
|
||||||
|
t.Errorf("expected test_status 'success', got %s", stored.TestStatus)
|
||||||
|
}
|
||||||
|
if stored.LastTestedAt == nil {
|
||||||
|
t.Error("expected last_tested_at to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_AgentOffline(t *testing.T) {
|
||||||
|
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Set up offline agent
|
||||||
|
agent := &domain.Agent{
|
||||||
|
ID: "agent-1",
|
||||||
|
Name: "Offline Agent",
|
||||||
|
Status: domain.AgentStatusOffline,
|
||||||
|
}
|
||||||
|
agentRepo.Create(ctx, agent)
|
||||||
|
|
||||||
|
// Set up target
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "t-1",
|
||||||
|
Name: "Test Target",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "agent-1",
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
err := svc.TestConnection(ctx, "t-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for offline agent, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := targetRepo.Targets["t-1"]
|
||||||
|
if stored.TestStatus != "failed" {
|
||||||
|
t.Errorf("expected test_status 'failed', got %s", stored.TestStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_NoAgent(t *testing.T) {
|
||||||
|
svc, targetRepo, _, _ := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "t-1",
|
||||||
|
Name: "Test Target",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "",
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
err := svc.TestConnection(ctx, "t-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing agent, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_TargetNotFound(t *testing.T) {
|
||||||
|
svc, _, _, _ := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := svc.TestConnection(ctx, "nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent target, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetService_TestConnection_StaleHeartbeat(t *testing.T) {
|
||||||
|
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Set up agent with stale heartbeat (10 minutes ago)
|
||||||
|
staleTime := time.Now().Add(-10 * time.Minute)
|
||||||
|
agent := &domain.Agent{
|
||||||
|
ID: "agent-1",
|
||||||
|
Name: "Stale Agent",
|
||||||
|
Status: domain.AgentStatusOnline,
|
||||||
|
LastHeartbeatAt: &staleTime,
|
||||||
|
}
|
||||||
|
agentRepo.Create(ctx, agent)
|
||||||
|
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "t-1",
|
||||||
|
Name: "Test Target",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "agent-1",
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
err := svc.TestConnection(ctx, "t-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for stale heartbeat, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -637,6 +637,19 @@ func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTa
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTargetRepo) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.CreateErr != nil {
|
||||||
|
return false, m.CreateErr
|
||||||
|
}
|
||||||
|
if _, exists := m.Targets[target.ID]; exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.Targets[target.ID] = target
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -856,6 +869,17 @@ func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
|
||||||
|
if m.CreateErr != nil {
|
||||||
|
return false, m.CreateErr
|
||||||
|
}
|
||||||
|
if _, exists := m.issuers[issuer.ID]; exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.issuers[issuer.ID] = issuer
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
|
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
|
||||||
if m.DeleteErr != nil {
|
if m.DeleteErr != nil {
|
||||||
return m.DeleteErr
|
return m.DeleteErr
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Rollback migration 000009: Remove dynamic issuer configuration columns
|
||||||
|
ALTER TABLE issuers DROP COLUMN IF EXISTS encrypted_config;
|
||||||
|
ALTER TABLE issuers DROP COLUMN IF EXISTS last_tested_at;
|
||||||
|
ALTER TABLE issuers DROP COLUMN IF EXISTS test_status;
|
||||||
|
ALTER TABLE issuers DROP COLUMN IF EXISTS source;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration 000009: Add dynamic issuer configuration columns
|
||||||
|
-- Supports M34: Dynamic Issuer Configuration (GUI)
|
||||||
|
|
||||||
|
-- encrypted_config stores AES-GCM encrypted config blob containing all fields including secrets.
|
||||||
|
-- The existing `config` JSONB column is retained for backward compatibility and holds a redacted copy.
|
||||||
|
ALTER TABLE issuers ADD COLUMN IF NOT EXISTS encrypted_config BYTEA;
|
||||||
|
|
||||||
|
-- last_tested_at tracks when the issuer connection was last successfully tested.
|
||||||
|
ALTER TABLE issuers ADD COLUMN IF NOT EXISTS last_tested_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- test_status tracks the latest connection test result.
|
||||||
|
ALTER TABLE issuers ADD COLUMN IF NOT EXISTS test_status TEXT NOT NULL DEFAULT 'untested';
|
||||||
|
|
||||||
|
-- source tracks where the issuer configuration originated from.
|
||||||
|
-- 'database' = created via GUI, 'env' = seeded from environment variables.
|
||||||
|
ALTER TABLE issuers ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'database';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Rollback migration 000010: Remove dynamic target configuration columns
|
||||||
|
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS encrypted_config;
|
||||||
|
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS last_tested_at;
|
||||||
|
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS test_status;
|
||||||
|
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS source;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration 000010: Add dynamic target configuration columns
|
||||||
|
-- Supports M35: Dynamic Target Configuration (GUI)
|
||||||
|
|
||||||
|
-- encrypted_config stores AES-GCM encrypted config blob containing all fields including secrets.
|
||||||
|
-- The existing `config` JSONB column is retained for backward compatibility and holds a redacted copy.
|
||||||
|
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS encrypted_config BYTEA;
|
||||||
|
|
||||||
|
-- last_tested_at tracks when the target connection was last tested (agent heartbeat check).
|
||||||
|
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS last_tested_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- test_status tracks the latest connection test result.
|
||||||
|
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS test_status TEXT NOT NULL DEFAULT 'untested';
|
||||||
|
|
||||||
|
-- source tracks where the target configuration originated from.
|
||||||
|
-- 'database' = created via GUI, 'env' = seeded from environment variables.
|
||||||
|
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'database';
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
getTargets,
|
getTargets,
|
||||||
createTarget,
|
createTarget,
|
||||||
deleteTarget,
|
deleteTarget,
|
||||||
|
testTargetConnection,
|
||||||
getProfiles,
|
getProfiles,
|
||||||
getProfile,
|
getProfile,
|
||||||
createProfile,
|
createProfile,
|
||||||
@@ -425,6 +426,14 @@ describe('API Client', () => {
|
|||||||
expect(url).toBe('/api/v1/targets/t-nginx');
|
expect(url).toBe('/api/v1/targets/t-nginx');
|
||||||
expect(init.method).toBe('DELETE');
|
expect(init.method).toBe('DELETE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('testTargetConnection sends POST', async () => {
|
||||||
|
mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'success', message: 'Agent is online' }));
|
||||||
|
await testTargetConnection('t-nginx');
|
||||||
|
const [url, init] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/v1/targets/t-nginx/test');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Approval ──────────────────────────────────────
|
// ─── Approval ──────────────────────────────────────
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ export const updateTarget = (id: string, data: Partial<Target>) =>
|
|||||||
export const deleteTarget = (id: string) =>
|
export const deleteTarget = (id: string) =>
|
||||||
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
export const testTargetConnection = (id: string) =>
|
||||||
|
fetchJSON<{ status: string; message: string }>(`${BASE}/targets/${id}/test`, { method: 'POST' });
|
||||||
|
|
||||||
// Profiles
|
// Profiles
|
||||||
export const getProfiles = (params: Record<string, string> = {}) => {
|
export const getProfiles = (params: Record<string, string> = {}) => {
|
||||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
|||||||
+10
-2
@@ -142,6 +142,12 @@ export interface Issuer {
|
|||||||
status: string;
|
status: string;
|
||||||
/** Backend returns enabled boolean; status is derived from this */
|
/** Backend returns enabled boolean; status is derived from this */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** Timestamp of last connection test */
|
||||||
|
last_tested_at?: string;
|
||||||
|
/** Result of last connection test: "untested", "success", or "failed" */
|
||||||
|
test_status?: string;
|
||||||
|
/** Config source: "database" (GUI-created) or "env" (env var seeded) */
|
||||||
|
source?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@@ -150,10 +156,12 @@ export interface Target {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
hostname: string;
|
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
status: string;
|
enabled: boolean;
|
||||||
|
last_tested_at?: string;
|
||||||
|
test_status?: string;
|
||||||
|
source?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -660,7 +660,7 @@ export default function CertificateDetailPage() {
|
|||||||
>
|
>
|
||||||
<option value="">Choose a target...</option>
|
<option value="">Choose a target...</option>
|
||||||
{targets?.data?.map(t => (
|
{targets?.data?.map(t => (
|
||||||
<option key={t.id} value={t.id}>{t.name} ({t.type} — {t.hostname})</option>
|
<option key={t.id} value={t.id}>{t.name} ({t.type})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
getCertificates, getAgents, getJobs, getNotifications, getHealth,
|
getCertificates, getAgents, getJobs, getNotifications, getHealth,
|
||||||
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
|
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
|
||||||
getJobTrends, getIssuanceRate, previewDigest, sendDigest,
|
getJobTrends, getIssuanceRate, previewDigest, sendDigest, getIssuers,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import { daysUntil, expiryColor, formatDate } from '../api/utils';
|
import { daysUntil, expiryColor, formatDate } from '../api/utils';
|
||||||
|
import OnboardingWizard from './OnboardingWizard';
|
||||||
|
|
||||||
// Convert PascalCase status like "RenewalInProgress" to "Renewal In Progress"
|
// Convert PascalCase status like "RenewalInProgress" to "Renewal In Progress"
|
||||||
const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2');
|
const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
@@ -162,8 +163,17 @@ function DigestCard() {
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Onboarding wizard state: once shown, stays shown until explicitly dismissed.
|
||||||
|
// Uses a ref to "latch" the first-run detection so query refetches don't yank the wizard away.
|
||||||
|
const [onboardingDismissed, setOnboardingDismissed] = useState(() => {
|
||||||
|
try { return localStorage.getItem('certctl:onboarding-dismissed') === 'true'; } catch { return false; }
|
||||||
|
});
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
|
||||||
|
// All hooks must be called unconditionally (React rules of hooks — no hooks after early returns)
|
||||||
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
|
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
|
||||||
const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 });
|
const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 });
|
||||||
|
const { data: issuersData } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() });
|
||||||
const { data: statusCounts } = useQuery({ queryKey: ['certs-by-status'], queryFn: getCertificatesByStatus, refetchInterval: 30000 });
|
const { data: statusCounts } = useQuery({ queryKey: ['certs-by-status'], queryFn: getCertificatesByStatus, refetchInterval: 30000 });
|
||||||
const { data: expirationTimeline } = useQuery({ queryKey: ['expiration-timeline'], queryFn: () => getExpirationTimeline(90), refetchInterval: 60000 });
|
const { data: expirationTimeline } = useQuery({ queryKey: ['expiration-timeline'], queryFn: () => getExpirationTimeline(90), refetchInterval: 60000 });
|
||||||
const { data: jobTrends } = useQuery({ queryKey: ['job-trends'], queryFn: () => getJobTrends(30), refetchInterval: 30000 });
|
const { data: jobTrends } = useQuery({ queryKey: ['job-trends'], queryFn: () => getJobTrends(30), refetchInterval: 30000 });
|
||||||
@@ -171,6 +181,30 @@ export default function DashboardPage() {
|
|||||||
const { data: certs } = useQuery({ queryKey: ['certificates', {}], queryFn: () => getCertificates(), refetchInterval: 30000 });
|
const { data: certs } = useQuery({ queryKey: ['certificates', {}], queryFn: () => getCertificates(), refetchInterval: 30000 });
|
||||||
const { data: jobs } = useQuery({ queryKey: ['jobs', {}], queryFn: () => getJobs(), refetchInterval: 10000 });
|
const { data: jobs } = useQuery({ queryKey: ['jobs', {}], queryFn: () => getJobs(), refetchInterval: 10000 });
|
||||||
|
|
||||||
|
// Detect first-run ONCE: no user-configured issuers AND no certificates.
|
||||||
|
// Auto-seeded env var issuers (source="env") don't count — they exist on every fresh boot.
|
||||||
|
// Once showWizard latches true, it stays true until the user dismisses.
|
||||||
|
const userConfiguredIssuers = (issuersData?.data ?? []).filter((i: { source?: string }) => i.source !== 'env');
|
||||||
|
const isFirstRun = !onboardingDismissed &&
|
||||||
|
summary !== undefined && issuersData !== undefined &&
|
||||||
|
summary.total_certificates === 0 &&
|
||||||
|
userConfiguredIssuers.length === 0;
|
||||||
|
|
||||||
|
if (isFirstRun && !showWizard) {
|
||||||
|
// Can't call setState during render — use a microtask
|
||||||
|
setTimeout(() => setShowWizard(true), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showWizard && !onboardingDismissed) {
|
||||||
|
return (
|
||||||
|
<OnboardingWizard onDismiss={() => {
|
||||||
|
try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ }
|
||||||
|
setOnboardingDismissed(true);
|
||||||
|
setShowWizard(false);
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const totalCerts = summary?.total_certificates || 0;
|
const totalCerts = summary?.total_certificates || 0;
|
||||||
const expiringSoon = summary?.expiring_certificates || 0;
|
const expiringSoon = summary?.expiring_certificates || 0;
|
||||||
const expired = summary?.expired_certificates || 0;
|
const expired = summary?.expired_certificates || 0;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function IssuerDetailPage() {
|
|||||||
|
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: () => testIssuerConnection(id!),
|
mutationFn: () => testIssuerConnection(id!),
|
||||||
|
onSuccess: () => refetch(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -128,6 +129,22 @@ export default function IssuerDetailPage() {
|
|||||||
<InfoRow label="Name" value={issuer.name} />
|
<InfoRow label="Name" value={issuer.name} />
|
||||||
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
|
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
|
||||||
<InfoRow label="Status" value={<StatusBadge status={issuerStatus(issuer)} />} />
|
<InfoRow label="Status" value={<StatusBadge status={issuerStatus(issuer)} />} />
|
||||||
|
<InfoRow label="Source" value={
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
issuer.source === 'env' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
|
||||||
|
}`}>
|
||||||
|
{issuer.source === 'env' ? 'Environment Variable' : 'GUI Configured'}
|
||||||
|
</span>
|
||||||
|
} />
|
||||||
|
<InfoRow label="Connection Test" value={
|
||||||
|
issuer.test_status === 'success' ? (
|
||||||
|
<span className="text-xs text-emerald-600 font-medium">Passed {issuer.last_tested_at ? formatDateTime(issuer.last_tested_at) : ''}</span>
|
||||||
|
) : issuer.test_status === 'failed' ? (
|
||||||
|
<span className="text-xs text-red-600 font-medium">Failed {issuer.last_tested_at ? formatDateTime(issuer.last_tested_at) : ''}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-ink-faint">Not tested</span>
|
||||||
|
)
|
||||||
|
} />
|
||||||
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
|
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,692 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
getIssuers, getAgents, getProfiles,
|
||||||
|
createIssuer, testIssuerConnection,
|
||||||
|
createCertificate, triggerRenewal,
|
||||||
|
getApiKey,
|
||||||
|
} from '../api/client';
|
||||||
|
import { issuerTypes, type IssuerTypeConfig } from '../config/issuerTypes';
|
||||||
|
import ConfigForm from '../components/issuer/ConfigForm';
|
||||||
|
import type { Issuer, Agent } from '../api/types';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────
|
||||||
|
|
||||||
|
type WizardStep = 'issuer' | 'agent' | 'certificate' | 'complete';
|
||||||
|
|
||||||
|
const STEPS: { key: WizardStep; label: string }[] = [
|
||||||
|
{ key: 'issuer', label: 'Connect a CA' },
|
||||||
|
{ key: 'agent', label: 'Deploy Agent' },
|
||||||
|
{ key: 'certificate', label: 'Add Certificate' },
|
||||||
|
{ key: 'complete', label: 'Done' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function CodeBlock({ code, label }: { code: string; label?: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{label && <div className="text-xs text-ink-muted mb-1 font-medium">{label}</div>}
|
||||||
|
<pre className="bg-gray-900 text-gray-100 rounded p-4 text-sm font-mono overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{code}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => { navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
|
||||||
|
className="absolute top-2 right-2 px-2 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ steps, current }: { steps: typeof STEPS; current: WizardStep }) {
|
||||||
|
const currentIdx = steps.findIndex(s => s.key === current);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-8">
|
||||||
|
{steps.map((s, i) => {
|
||||||
|
const isCompleted = i < currentIdx;
|
||||||
|
const isCurrent = s.key === current;
|
||||||
|
return (
|
||||||
|
<div key={s.key} className="flex items-center gap-2">
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-colors ${
|
||||||
|
isCompleted ? 'bg-emerald-500 text-white' :
|
||||||
|
isCurrent ? 'bg-accent text-white' :
|
||||||
|
'bg-surface-border text-ink-muted'
|
||||||
|
}`}>
|
||||||
|
{isCompleted ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : i + 1}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium hidden sm:inline ${isCurrent ? 'text-ink' : 'text-ink-muted'}`}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
{i < steps.length - 1 && (
|
||||||
|
<div className={`w-8 h-0.5 ${i < currentIdx ? 'bg-emerald-500' : 'bg-surface-border'}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WizardFooter({ onSkip, onNext, nextLabel, nextDisabled, showSkip = true }: {
|
||||||
|
onSkip?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
nextLabel?: string;
|
||||||
|
nextDisabled?: boolean;
|
||||||
|
showSkip?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center pt-6 border-t border-surface-border mt-6">
|
||||||
|
<div>
|
||||||
|
{showSkip && onSkip && (
|
||||||
|
<button onClick={onSkip} className="text-sm text-ink-muted hover:text-ink transition-colors">
|
||||||
|
Skip this step
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onNext && (
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={nextDisabled}
|
||||||
|
className="btn btn-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{nextLabel || 'Continue'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 1: Connect a CA ────────────────────────────
|
||||||
|
|
||||||
|
function IssuerStep({ onNext, onSkip, onIssuerCreated }: {
|
||||||
|
onNext: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onIssuerCreated: (issuer: Issuer) => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||||
|
const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [issuerName, setIssuerName] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [createdIssuer, setCreatedIssuer] = useState<Issuer | null>(null);
|
||||||
|
|
||||||
|
const typeConfig = selectedType ? issuerTypes.find(t => t.id === selectedType) : null;
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createIssuer({
|
||||||
|
name: issuerName || `${typeConfig?.name || selectedType} Issuer`,
|
||||||
|
type: selectedType!,
|
||||||
|
config: configValues as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
onSuccess: (issuer) => {
|
||||||
|
setCreatedIssuer(issuer);
|
||||||
|
onIssuerCreated(issuer);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['issuers'] });
|
||||||
|
setError('');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => setError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: () => testIssuerConnection(createdIssuer!.id),
|
||||||
|
onSuccess: () => setTestResult({ ok: true, msg: 'Connection successful' }),
|
||||||
|
onError: (err: Error) => setTestResult({ ok: false, msg: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// After issuer is created successfully
|
||||||
|
if (createdIssuer) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-2">CA Connected</h2>
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded p-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-emerald-700">
|
||||||
|
{createdIssuer.name} ({typeConfig?.name}) created successfully
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!testResult && (
|
||||||
|
<button
|
||||||
|
onClick={() => testMutation.mutate()}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
className="btn btn-secondary text-sm mb-4"
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{testResult?.ok && (
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded p-3 mb-4 text-sm text-emerald-700">
|
||||||
|
Connection test passed.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testResult && !testResult.ok && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded p-3 mb-4 text-sm text-red-700">
|
||||||
|
Connection test failed: {testResult.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WizardFooter onNext={onNext} nextLabel="Next: Deploy Agent" showSkip={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type selection
|
||||||
|
if (!selectedType) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-1">Connect a Certificate Authority</h2>
|
||||||
|
<p className="text-sm text-ink-muted mb-6">
|
||||||
|
Choose a CA to issue and manage certificates. You can add more later from the Issuers page.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{issuerTypes.filter(t => !t.comingSoon).map((type: IssuerTypeConfig) => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => setSelectedType(type.id)}
|
||||||
|
className="p-4 border border-surface-border rounded-lg hover:border-brand-500 hover:bg-surface-muted transition-all text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{type.icon}</span>
|
||||||
|
<span className="font-medium text-ink">{type.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted mt-1">{type.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<WizardFooter onSkip={onSkip} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config form for selected type
|
||||||
|
const requiredFields = typeConfig?.configFields.filter(f => f.required) || [];
|
||||||
|
const allRequiredFilled = requiredFields.every(f => configValues[f.key]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<button onClick={() => { setSelectedType(null); setConfigValues({}); setError(''); }}
|
||||||
|
className="text-ink-muted hover:text-ink transition-colors">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2 className="text-lg font-semibold text-ink">
|
||||||
|
Configure {typeConfig?.name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-ink-muted mb-6">{typeConfig?.description}</p>
|
||||||
|
|
||||||
|
<div className="mb-5">
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={issuerName}
|
||||||
|
onChange={e => setIssuerName(e.target.value)}
|
||||||
|
placeholder={`${typeConfig?.name || ''} Issuer`}
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfigForm
|
||||||
|
fields={typeConfig?.configFields || []}
|
||||||
|
values={configValues}
|
||||||
|
onChange={(key, val) => setConfigValues(prev => ({ ...prev, [key]: val }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WizardFooter
|
||||||
|
onSkip={onSkip}
|
||||||
|
onNext={() => createMutation.mutate()}
|
||||||
|
nextLabel={createMutation.isPending ? 'Creating...' : 'Create Issuer'}
|
||||||
|
nextDisabled={!allRequiredFilled || createMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 2: Deploy an Agent ─────────────────────────
|
||||||
|
|
||||||
|
function AgentStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'linux' | 'macos' | 'docker'>('linux');
|
||||||
|
|
||||||
|
const apiKey = getApiKey() || '<your-api-key>';
|
||||||
|
const serverUrl = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8443` : 'http://localhost:8443';
|
||||||
|
|
||||||
|
// Poll for agents every 5s
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: ['agents'],
|
||||||
|
queryFn: () => getAgents(),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentList = agents?.data || [];
|
||||||
|
const hasAgents = agentList.length > 0;
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'linux' as const, label: 'Linux' },
|
||||||
|
{ key: 'macos' as const, label: 'macOS' },
|
||||||
|
{ key: 'docker' as const, label: 'Docker' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commands: Record<string, { code: string; label: string }> = {
|
||||||
|
linux: {
|
||||||
|
label: 'Install via shell script (systemd service)',
|
||||||
|
code: `curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||||
|
|
||||||
|
# Then configure:
|
||||||
|
sudo systemctl edit certctl-agent
|
||||||
|
# Add:
|
||||||
|
# [Service]
|
||||||
|
# Environment="CERTCTL_SERVER_URL=${serverUrl}"
|
||||||
|
# Environment="CERTCTL_API_KEY=${apiKey}"
|
||||||
|
|
||||||
|
sudo systemctl restart certctl-agent`,
|
||||||
|
},
|
||||||
|
macos: {
|
||||||
|
label: 'Install via shell script (launchd service)',
|
||||||
|
code: `curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||||
|
|
||||||
|
# Then configure:
|
||||||
|
# Edit /Library/LaunchDaemons/com.certctl.agent.plist
|
||||||
|
# Set CERTCTL_SERVER_URL to ${serverUrl}
|
||||||
|
# Set CERTCTL_API_KEY to ${apiKey}
|
||||||
|
|
||||||
|
sudo launchctl unload /Library/LaunchDaemons/com.certctl.agent.plist
|
||||||
|
sudo launchctl load /Library/LaunchDaemons/com.certctl.agent.plist`,
|
||||||
|
},
|
||||||
|
docker: {
|
||||||
|
label: 'Run as Docker container',
|
||||||
|
code: `docker run -d --name certctl-agent \\
|
||||||
|
-e CERTCTL_SERVER_URL=${serverUrl} \\
|
||||||
|
-e CERTCTL_API_KEY=${apiKey} \\
|
||||||
|
ghcr.io/shankar0123/certctl-agent:latest`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-1">Deploy a certctl Agent</h2>
|
||||||
|
<p className="text-sm text-ink-muted mb-6">
|
||||||
|
Agents run on your infrastructure to manage certificates, generate keys, and deploy to targets.
|
||||||
|
Install one now or skip to do it later.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* OS Tabs */}
|
||||||
|
<div className="flex gap-1 mb-4 bg-surface-border/30 rounded-lg p-1 w-fit">
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setActiveTab(t.key)}
|
||||||
|
className={`px-4 py-1.5 text-sm rounded-md transition-colors ${
|
||||||
|
activeTab === t.key
|
||||||
|
? 'bg-surface text-ink font-medium shadow-sm'
|
||||||
|
: 'text-ink-muted hover:text-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeBlock code={commands[activeTab].code} label={commands[activeTab].label} />
|
||||||
|
|
||||||
|
{/* Agent detection */}
|
||||||
|
<div className="mt-6 p-4 border border-surface-border rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{hasAgents ? (
|
||||||
|
<>
|
||||||
|
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-emerald-700">
|
||||||
|
{agentList.length} agent{agentList.length !== 1 ? 's' : ''} detected
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted mt-0.5">
|
||||||
|
{agentList.slice(0, 3).map(a => a.name || a.id).join(', ')}
|
||||||
|
{agentList.length > 3 && ` and ${agentList.length - 3} more`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-3 h-3 rounded-full bg-amber-400 animate-pulse" />
|
||||||
|
<div className="text-sm text-ink-muted">
|
||||||
|
Waiting for an agent to connect... <span className="text-xs">(polling every 5s)</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WizardFooter
|
||||||
|
onSkip={onSkip}
|
||||||
|
onNext={onNext}
|
||||||
|
nextLabel={hasAgents ? 'Next: Add Certificate' : 'Next: Add Certificate'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 3: Add a Certificate ───────────────────────
|
||||||
|
|
||||||
|
function CertificateStep({ onNext, onSkip, createdIssuerId }: {
|
||||||
|
onNext: (certName?: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
createdIssuerId: string | null;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [commonName, setCommonName] = useState('');
|
||||||
|
const [sans, setSans] = useState('');
|
||||||
|
const [issuerId, setIssuerId] = useState(createdIssuerId || '');
|
||||||
|
const [profileId, setProfileId] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [created, setCreated] = useState(false);
|
||||||
|
|
||||||
|
const { data: issuers } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() });
|
||||||
|
const { data: profiles } = useQuery({ queryKey: ['profiles'], queryFn: () => getProfiles() });
|
||||||
|
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() });
|
||||||
|
|
||||||
|
const hasAgents = (agents?.data?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const sanList = sans.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const cert = await createCertificate({
|
||||||
|
common_name: commonName,
|
||||||
|
sans: sanList,
|
||||||
|
issuer_id: issuerId,
|
||||||
|
certificate_profile_id: profileId || undefined,
|
||||||
|
environment: 'production',
|
||||||
|
});
|
||||||
|
// Trigger issuance
|
||||||
|
await triggerRenewal(cert.id);
|
||||||
|
return cert;
|
||||||
|
},
|
||||||
|
onSuccess: (cert) => {
|
||||||
|
setCreated(true);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
|
||||||
|
setTimeout(() => onNext(cert.common_name), 1500);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => setError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-2">Certificate Requested</h2>
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-emerald-700">
|
||||||
|
Certificate for {commonName} has been requested. Moving to summary...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-1">Add a Certificate</h2>
|
||||||
|
<p className="text-sm text-ink-muted mb-6">
|
||||||
|
Issue your first certificate, or skip this step and explore the dashboard.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">
|
||||||
|
Common Name <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={commonName}
|
||||||
|
onChange={e => setCommonName(e.target.value)}
|
||||||
|
placeholder="example.com"
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">
|
||||||
|
Subject Alternative Names <span className="text-xs text-ink-muted font-normal">(comma-separated)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sans}
|
||||||
|
onChange={e => setSans(e.target.value)}
|
||||||
|
placeholder="www.example.com, api.example.com"
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">
|
||||||
|
Issuer <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={issuerId}
|
||||||
|
onChange={e => setIssuerId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">Select issuer...</option>
|
||||||
|
{issuers?.data?.map(iss => (
|
||||||
|
<option key={iss.id} value={iss.id}>{iss.name} ({iss.type})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">
|
||||||
|
Profile <span className="text-xs text-ink-muted font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={profileId}
|
||||||
|
onChange={e => setProfileId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">Default</option>
|
||||||
|
{profiles?.data?.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discovery hint */}
|
||||||
|
{hasAgents && (
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded text-sm text-blue-700">
|
||||||
|
<span className="font-medium">Already have certificates on disk?</span>{' '}
|
||||||
|
Visit the <Link to="/discovery" className="underline hover:text-blue-900">Discovery page</Link> to
|
||||||
|
import and manage existing certificates found by your agents.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasAgents && (
|
||||||
|
<div className="mt-6 p-4 bg-gray-50 border border-gray-200 rounded text-sm text-ink-muted">
|
||||||
|
<span className="font-medium">Tip:</span> Deploy an agent with{' '}
|
||||||
|
<code className="bg-gray-200 px-1 rounded text-xs">CERTCTL_DISCOVERY_DIRS=/etc/ssl/certs</code>{' '}
|
||||||
|
to automatically discover existing certificates on your infrastructure.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WizardFooter
|
||||||
|
onSkip={onSkip}
|
||||||
|
onNext={() => createMutation.mutate()}
|
||||||
|
nextLabel={createMutation.isPending ? 'Creating...' : 'Issue Certificate'}
|
||||||
|
nextDisabled={!commonName || !issuerId || createMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 4: Complete ────────────────────────────────
|
||||||
|
|
||||||
|
function CompleteStep({ onFinish, issuerName, certName }: {
|
||||||
|
onFinish: () => void;
|
||||||
|
issuerName: string | null;
|
||||||
|
certName: string | null;
|
||||||
|
}) {
|
||||||
|
const { data: issuers } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() });
|
||||||
|
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents() });
|
||||||
|
|
||||||
|
const issuerCount = issuers?.data?.length ?? 0;
|
||||||
|
const agentCount = agents?.data?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-6 bg-emerald-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold text-ink mb-2">You're all set!</h2>
|
||||||
|
<p className="text-sm text-ink-muted mb-8 max-w-md mx-auto">
|
||||||
|
certctl is ready to manage your certificate lifecycle. Here's what's configured:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="max-w-sm mx-auto mb-8 space-y-3 text-left">
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-surface border border-surface-border rounded">
|
||||||
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${issuerCount > 0 ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-100 text-gray-400'}`}>
|
||||||
|
{issuerCount > 0 ? (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||||
|
) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium text-ink">
|
||||||
|
{issuerCount > 0 ? `${issuerCount} issuer${issuerCount !== 1 ? 's' : ''} configured` : 'No issuers configured'}
|
||||||
|
</span>
|
||||||
|
{issuerName && <span className="text-ink-muted ml-1">({issuerName})</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-surface border border-surface-border rounded">
|
||||||
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${agentCount > 0 ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-100 text-gray-400'}`}>
|
||||||
|
{agentCount > 0 ? (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||||
|
) : '—'}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-ink">
|
||||||
|
{agentCount > 0 ? `${agentCount} agent${agentCount !== 1 ? 's' : ''} connected` : 'No agents deployed yet'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-surface border border-surface-border rounded">
|
||||||
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${certName ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-100 text-gray-400'}`}>
|
||||||
|
{certName ? (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||||
|
) : '—'}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-ink">
|
||||||
|
{certName ? `Certificate requested: ${certName}` : 'No certificates added yet'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={onFinish} className="btn btn-primary text-sm px-8 mb-6">
|
||||||
|
Go to Dashboard
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-6 text-xs">
|
||||||
|
<a href="https://github.com/shankar0123/certctl/blob/master/docs/quickstart.md" target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent-bright">Quickstart Guide</a>
|
||||||
|
<a href="https://github.com/shankar0123/certctl/blob/master/docs/architecture.md" target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent-bright">Architecture</a>
|
||||||
|
<a href="https://github.com/shankar0123/certctl/blob/master/docs/connectors.md" target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent-bright">Connectors</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Wizard ─────────────────────────────────────
|
||||||
|
|
||||||
|
export default function OnboardingWizard({ onDismiss }: { onDismiss: () => void }) {
|
||||||
|
const [step, setStep] = useState<WizardStep>('issuer');
|
||||||
|
const [createdIssuerId, setCreatedIssuerId] = useState<string | null>(null);
|
||||||
|
const [issuerName, setIssuerName] = useState<string | null>(null);
|
||||||
|
const [certName, setCertName] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const goTo = (s: WizardStep) => setStep(s);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between px-6 pt-5 pb-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-ink">Welcome to certctl</h1>
|
||||||
|
<p className="text-sm text-ink-muted mt-0.5">Let's set up your certificate lifecycle management</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
|
>
|
||||||
|
Skip setup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<StepIndicator steps={STEPS} current={step} />
|
||||||
|
|
||||||
|
<div className="bg-surface border border-surface-border rounded-lg p-6 shadow-sm">
|
||||||
|
{step === 'issuer' && (
|
||||||
|
<IssuerStep
|
||||||
|
onNext={() => goTo('agent')}
|
||||||
|
onSkip={() => goTo('agent')}
|
||||||
|
onIssuerCreated={(iss) => { setCreatedIssuerId(iss.id); setIssuerName(iss.name); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'agent' && (
|
||||||
|
<AgentStep
|
||||||
|
onNext={() => goTo('certificate')}
|
||||||
|
onSkip={() => goTo('certificate')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'certificate' && (
|
||||||
|
<CertificateStep
|
||||||
|
onNext={(name) => { if (name) setCertName(name); goTo('complete'); }}
|
||||||
|
onSkip={() => goTo('complete')}
|
||||||
|
createdIssuerId={createdIssuerId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'complete' && (
|
||||||
|
<CompleteStep
|
||||||
|
onFinish={() => { onDismiss(); navigate('/'); }}
|
||||||
|
issuerName={issuerName}
|
||||||
|
certName={certName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getTarget, getJobs, updateTarget } from '../api/client';
|
import { getTarget, getJobs, updateTarget, testTargetConnection } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
@@ -11,13 +11,17 @@ import { formatDateTime } from '../api/utils';
|
|||||||
import type { Job } from '../api/types';
|
import type { Job } from '../api/types';
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
nginx: 'NGINX',
|
NGINX: 'NGINX',
|
||||||
apache: 'Apache',
|
Apache: 'Apache',
|
||||||
haproxy: 'HAProxy',
|
HAProxy: 'HAProxy',
|
||||||
traefik: 'Traefik',
|
Traefik: 'Traefik',
|
||||||
caddy: 'Caddy',
|
Caddy: 'Caddy',
|
||||||
f5_bigip: 'F5 BIG-IP',
|
F5: 'F5 BIG-IP',
|
||||||
iis: 'IIS',
|
IIS: 'IIS',
|
||||||
|
Envoy: 'Envoy',
|
||||||
|
Postfix: 'Postfix',
|
||||||
|
Dovecot: 'Dovecot',
|
||||||
|
SSH: 'SSH',
|
||||||
};
|
};
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
@@ -29,21 +33,59 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TestStatusIndicator({ status, testedAt }: { status?: string; testedAt?: string }) {
|
||||||
|
if (!status || status === 'untested') {
|
||||||
|
return <span className="text-xs text-ink-faint">Not tested</span>;
|
||||||
|
}
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
success: 'bg-emerald-100 text-emerald-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
success: 'Connected',
|
||||||
|
failed: 'Failed',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</span>
|
||||||
|
{testedAt && <span className="text-xs text-ink-faint">{formatDateTime(testedAt)}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceBadge({ source }: { source?: string }) {
|
||||||
|
if (!source || source === 'database') {
|
||||||
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">GUI</span>;
|
||||||
|
}
|
||||||
|
if (source === 'env') {
|
||||||
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">Env Var</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-xs text-ink-faint">{source}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TargetDetailPage() {
|
export default function TargetDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editHostname, setEditHostname] = useState('');
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (data: Partial<{ name: string; hostname: string }>) => updateTarget(id!, data),
|
mutationFn: (data: Partial<{ name: string }>) => updateTarget(id!, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['target', id] });
|
queryClient.invalidateQueries({ queryKey: ['target', id] });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: () => testTargetConnection(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['target', id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { data: target, isLoading, error, refetch } = useQuery({
|
const { data: target, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['target', id],
|
queryKey: ['target', id],
|
||||||
queryFn: () => getTarget(id!),
|
queryFn: () => getTarget(id!),
|
||||||
@@ -126,19 +168,39 @@ export default function TargetDetailPage() {
|
|||||||
title={target.name}
|
title={target.name}
|
||||||
subtitle={typeLabels[target.type] || target.type}
|
subtitle={typeLabels[target.type] || target.type}
|
||||||
action={
|
action={
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={() => {
|
<button
|
||||||
setEditName(target.name);
|
onClick={() => testMutation.mutate()}
|
||||||
setEditHostname(target.hostname || '');
|
disabled={testMutation.isPending}
|
||||||
setIsEditing(true);
|
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium disabled:opacity-50"
|
||||||
}}
|
>
|
||||||
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
|
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||||
>
|
</button>
|
||||||
Edit
|
<button
|
||||||
</button>
|
onClick={() => {
|
||||||
|
setEditName(target.name);
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Test connection result banner */}
|
||||||
|
{testMutation.isSuccess && (
|
||||||
|
<div className="mx-6 mt-2 p-3 bg-emerald-50 border border-emerald-200 rounded text-sm text-emerald-700">
|
||||||
|
Agent connection test passed — agent is online and responsive.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testMutation.isError && (
|
||||||
|
<div className="mx-6 mt-2 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||||
|
Connection test failed: {(testMutation.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Target info */}
|
{/* Target info */}
|
||||||
@@ -147,8 +209,9 @@ export default function TargetDetailPage() {
|
|||||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{target.id}</span>} />
|
<InfoRow label="ID" value={<span className="font-mono text-xs">{target.id}</span>} />
|
||||||
<InfoRow label="Name" value={target.name} />
|
<InfoRow label="Name" value={target.name} />
|
||||||
<InfoRow label="Type" value={typeLabels[target.type] || target.type} />
|
<InfoRow label="Type" value={typeLabels[target.type] || target.type} />
|
||||||
<InfoRow label="Hostname" value={target.hostname || '—'} />
|
<InfoRow label="Enabled" value={<StatusBadge status={target.enabled ? 'Enabled' : 'Disabled'} />} />
|
||||||
<InfoRow label="Status" value={<StatusBadge status={target.status} />} />
|
<InfoRow label="Source" value={<SourceBadge source={target.source} />} />
|
||||||
|
<InfoRow label="Test Status" value={<TestStatusIndicator status={target.test_status} testedAt={target.last_tested_at} />} />
|
||||||
{target.agent_id && (
|
{target.agent_id && (
|
||||||
<InfoRow label="Agent" value={
|
<InfoRow label="Agent" value={
|
||||||
<Link to={`/agents/${target.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
<Link to={`/agents/${target.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||||
@@ -157,6 +220,7 @@ export default function TargetDetailPage() {
|
|||||||
} />
|
} />
|
||||||
)}
|
)}
|
||||||
<InfoRow label="Created" value={formatDateTime(target.created_at)} />
|
<InfoRow label="Created" value={formatDateTime(target.created_at)} />
|
||||||
|
{target.updated_at && <InfoRow label="Updated" value={formatDateTime(target.updated_at)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config */}
|
{/* Config */}
|
||||||
@@ -205,15 +269,11 @@ export default function TargetDetailPage() {
|
|||||||
{(updateMutation.error as Error).message}
|
{(updateMutation.error as Error).message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={e => { e.preventDefault(); updateMutation.mutate({ name: editName, hostname: editHostname }); }} className="space-y-4">
|
<form onSubmit={e => { e.preventDefault(); updateMutation.mutate({ name: editName }); }} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-ink mb-1">Name</label>
|
<label className="block text-sm font-medium text-ink mb-1">Name</label>
|
||||||
<input value={editName} onChange={e => setEditName(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
<input value={editName} onChange={e => setEditName(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-ink mb-1">Hostname</label>
|
|
||||||
<input value={editHostname} onChange={e => setEditHostname(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<button type="submit" disabled={updateMutation.isPending} className="flex-1 btn btn-primary disabled:opacity-50">
|
<button type="submit" disabled={updateMutation.isPending} className="flex-1 btn btn-primary disabled:opacity-50">
|
||||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
|||||||
@@ -11,83 +11,85 @@ import { formatDateTime } from '../api/utils';
|
|||||||
import type { Target } from '../api/types';
|
import type { Target } from '../api/types';
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
nginx: 'NGINX',
|
NGINX: 'NGINX',
|
||||||
apache: 'Apache',
|
Apache: 'Apache',
|
||||||
haproxy: 'HAProxy',
|
HAProxy: 'HAProxy',
|
||||||
traefik: 'Traefik',
|
Traefik: 'Traefik',
|
||||||
caddy: 'Caddy',
|
Caddy: 'Caddy',
|
||||||
envoy: 'Envoy',
|
Envoy: 'Envoy',
|
||||||
postfix: 'Postfix',
|
Postfix: 'Postfix',
|
||||||
dovecot: 'Dovecot',
|
Dovecot: 'Dovecot',
|
||||||
f5_bigip: 'F5 BIG-IP',
|
F5: 'F5 BIG-IP',
|
||||||
iis: 'IIS',
|
IIS: 'IIS',
|
||||||
|
SSH: 'SSH',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TARGET_TYPES = [
|
const TARGET_TYPES = [
|
||||||
{ value: 'nginx', label: 'NGINX', description: 'Deploy to NGINX web server via file write + config validation + reload' },
|
{ value: 'NGINX', label: 'NGINX', description: 'Deploy to NGINX web server via file write + config validation + reload' },
|
||||||
{ value: 'apache', label: 'Apache httpd', description: 'Separate cert/chain/key files, apachectl configtest, graceful reload' },
|
{ value: 'Apache', label: 'Apache httpd', description: 'Separate cert/chain/key files, apachectl configtest, graceful reload' },
|
||||||
{ value: 'haproxy', label: 'HAProxy', description: 'Combined PEM file (cert+chain+key), optional validate, reload' },
|
{ value: 'HAProxy', label: 'HAProxy', description: 'Combined PEM file (cert+chain+key), optional validate, reload' },
|
||||||
{ value: 'traefik', label: 'Traefik', description: 'File provider deployment — writes cert/key to watched directory, auto-reload' },
|
{ value: 'Traefik', label: 'Traefik', description: 'File provider deployment — writes cert/key to watched directory, auto-reload' },
|
||||||
{ value: 'caddy', label: 'Caddy', description: 'Admin API hot-reload or file-based deployment with configurable mode' },
|
{ value: 'Caddy', label: 'Caddy', description: 'Admin API hot-reload or file-based deployment with configurable mode' },
|
||||||
{ value: 'envoy', label: 'Envoy', description: 'File-based deployment — writes cert/key to watched directory. Optional SDS file generation.' },
|
{ value: 'Envoy', label: 'Envoy', description: 'File-based deployment — writes cert/key to watched directory. Optional SDS file generation.' },
|
||||||
{ value: 'postfix', label: 'Postfix', description: 'Postfix MTA — file write + postfix reload' },
|
{ value: 'Postfix', label: 'Postfix', description: 'Postfix MTA — file write + postfix reload' },
|
||||||
{ value: 'dovecot', label: 'Dovecot', description: 'Dovecot IMAP/POP3 — file write + doveadm reload' },
|
{ value: 'Dovecot', label: 'Dovecot', description: 'Dovecot IMAP/POP3 — file write + doveadm reload' },
|
||||||
{ value: 'f5_bigip', label: 'F5 BIG-IP', description: 'iControl REST — cert upload, SSL profile update via proxy agent' },
|
{ value: 'F5', label: 'F5 BIG-IP', description: 'iControl REST — cert upload, SSL profile update via proxy agent' },
|
||||||
{ value: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' },
|
{ value: 'IIS', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' },
|
||||||
|
{ value: 'SSH', label: 'SSH', description: 'Agentless deployment via SSH/SFTP — deploy to any Linux/Unix server without installing an agent' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: string; required?: boolean }[]> = {
|
const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: string; required?: boolean }[]> = {
|
||||||
nginx: [
|
NGINX: [
|
||||||
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/nginx/ssl/cert.pem', required: true },
|
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/nginx/ssl/cert.pem', required: true },
|
||||||
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/nginx/ssl/key.pem', required: true },
|
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/nginx/ssl/key.pem', required: true },
|
||||||
{ key: 'chain_path', label: 'Chain Path', placeholder: '/etc/nginx/ssl/chain.pem' },
|
{ key: 'chain_path', label: 'Chain Path', placeholder: '/etc/nginx/ssl/chain.pem' },
|
||||||
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'nginx -t && systemctl reload nginx' },
|
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'nginx -t && systemctl reload nginx' },
|
||||||
],
|
],
|
||||||
apache: [
|
Apache: [
|
||||||
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/apache2/ssl/cert.pem', required: true },
|
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/apache2/ssl/cert.pem', required: true },
|
||||||
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/apache2/ssl/key.pem', required: true },
|
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/apache2/ssl/key.pem', required: true },
|
||||||
{ key: 'chain_path', label: 'Chain Path', placeholder: '/etc/apache2/ssl/chain.pem' },
|
{ key: 'chain_path', label: 'Chain Path', placeholder: '/etc/apache2/ssl/chain.pem' },
|
||||||
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'apachectl configtest && apachectl graceful' },
|
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'apachectl configtest && apachectl graceful' },
|
||||||
],
|
],
|
||||||
haproxy: [
|
HAProxy: [
|
||||||
{ key: 'pem_path', label: 'Combined PEM Path', placeholder: '/etc/haproxy/certs/combined.pem', required: true },
|
{ key: 'pem_path', label: 'Combined PEM Path', placeholder: '/etc/haproxy/certs/combined.pem', required: true },
|
||||||
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'systemctl reload haproxy' },
|
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'systemctl reload haproxy' },
|
||||||
{ key: 'validate_cmd', label: 'Validate Command (optional)', placeholder: 'haproxy -c -f /etc/haproxy/haproxy.cfg' },
|
{ key: 'validate_cmd', label: 'Validate Command (optional)', placeholder: 'haproxy -c -f /etc/haproxy/haproxy.cfg' },
|
||||||
],
|
],
|
||||||
traefik: [
|
Traefik: [
|
||||||
{ key: 'cert_dir', label: 'Certificate Directory', placeholder: '/etc/traefik/certs', required: true },
|
{ key: 'cert_dir', label: 'Certificate Directory', placeholder: '/etc/traefik/certs', required: true },
|
||||||
{ key: 'cert_file', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
|
{ key: 'cert_file', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
|
||||||
{ key: 'key_file', label: 'Key Filename', placeholder: 'key.pem (default)' },
|
{ key: 'key_file', label: 'Key Filename', placeholder: 'key.pem (default)' },
|
||||||
],
|
],
|
||||||
caddy: [
|
Caddy: [
|
||||||
{ key: 'mode', label: 'Deployment Mode', placeholder: 'api (default) or file', required: true },
|
{ key: 'mode', label: 'Deployment Mode', placeholder: 'api (default) or file', required: true },
|
||||||
{ key: 'admin_api', label: 'Admin API URL', placeholder: 'http://localhost:2019 (default)' },
|
{ key: 'admin_api', label: 'Admin API URL', placeholder: 'http://localhost:2019 (default)' },
|
||||||
{ key: 'cert_dir', label: 'Certificate Directory (file mode)', placeholder: '/etc/caddy/certs' },
|
{ key: 'cert_dir', label: 'Certificate Directory (file mode)', placeholder: '/etc/caddy/certs' },
|
||||||
{ key: 'cert_file', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
|
{ key: 'cert_file', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
|
||||||
{ key: 'key_file', label: 'Key Filename', placeholder: 'key.pem (default)' },
|
{ key: 'key_file', label: 'Key Filename', placeholder: 'key.pem (default)' },
|
||||||
],
|
],
|
||||||
envoy: [
|
Envoy: [
|
||||||
{ key: 'cert_dir', label: 'Certificate Directory', placeholder: '/etc/envoy/certs', required: true },
|
{ key: 'cert_dir', label: 'Certificate Directory', placeholder: '/etc/envoy/certs', required: true },
|
||||||
{ key: 'cert_filename', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
|
{ key: 'cert_filename', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
|
||||||
{ key: 'key_filename', label: 'Key Filename', placeholder: 'key.pem (default)' },
|
{ key: 'key_filename', label: 'Key Filename', placeholder: 'key.pem (default)' },
|
||||||
{ key: 'chain_filename', label: 'Chain Filename (optional)', placeholder: 'chain.pem (leave empty to append to cert)' },
|
{ key: 'chain_filename', label: 'Chain Filename (optional)', placeholder: 'chain.pem (leave empty to append to cert)' },
|
||||||
{ key: 'sds_config', label: 'Generate SDS Config', placeholder: 'true or false' },
|
{ key: 'sds_config', label: 'Generate SDS Config', placeholder: 'true or false' },
|
||||||
],
|
],
|
||||||
postfix: [
|
Postfix: [
|
||||||
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/postfix/certs/cert.pem' },
|
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/postfix/certs/cert.pem' },
|
||||||
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/postfix/certs/key.pem' },
|
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/postfix/certs/key.pem' },
|
||||||
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/postfix/certs/chain.pem' },
|
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/postfix/certs/chain.pem' },
|
||||||
{ key: 'reload_command', label: 'Reload Command', placeholder: 'postfix reload' },
|
{ key: 'reload_command', label: 'Reload Command', placeholder: 'postfix reload' },
|
||||||
{ key: 'validate_command', label: 'Validate Command', placeholder: 'postfix check' },
|
{ key: 'validate_command', label: 'Validate Command', placeholder: 'postfix check' },
|
||||||
],
|
],
|
||||||
dovecot: [
|
Dovecot: [
|
||||||
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/dovecot/certs/cert.pem' },
|
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/dovecot/certs/cert.pem' },
|
||||||
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/dovecot/certs/key.pem' },
|
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/dovecot/certs/key.pem' },
|
||||||
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/dovecot/certs/chain.pem' },
|
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/dovecot/certs/chain.pem' },
|
||||||
{ key: 'reload_command', label: 'Reload Command', placeholder: 'doveadm reload' },
|
{ key: 'reload_command', label: 'Reload Command', placeholder: 'doveadm reload' },
|
||||||
{ key: 'validate_command', label: 'Validate Command', placeholder: 'doveconf -n' },
|
{ key: 'validate_command', label: 'Validate Command', placeholder: 'doveconf -n' },
|
||||||
],
|
],
|
||||||
f5_bigip: [
|
F5: [
|
||||||
{ key: 'host', label: 'Management Host', placeholder: 'f5.internal.example.com', required: true },
|
{ key: 'host', label: 'Management Host', placeholder: 'f5.internal.example.com', required: true },
|
||||||
{ key: 'port', label: 'Management Port', placeholder: '443' },
|
{ key: 'port', label: 'Management Port', placeholder: '443' },
|
||||||
{ key: 'username', label: 'Username', placeholder: 'admin', required: true },
|
{ key: 'username', label: 'Username', placeholder: 'admin', required: true },
|
||||||
@@ -97,7 +99,7 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
|||||||
{ key: 'insecure', label: 'Skip TLS Verify', placeholder: 'true (default)' },
|
{ key: 'insecure', label: 'Skip TLS Verify', placeholder: 'true (default)' },
|
||||||
{ key: 'timeout', label: 'Timeout (seconds)', placeholder: '30' },
|
{ key: 'timeout', label: 'Timeout (seconds)', placeholder: '30' },
|
||||||
],
|
],
|
||||||
iis: [
|
IIS: [
|
||||||
{ key: 'site_name', label: 'IIS Site Name', placeholder: 'Default Web Site', required: true },
|
{ key: 'site_name', label: 'IIS Site Name', placeholder: 'Default Web Site', required: true },
|
||||||
{ key: 'cert_store', label: 'Certificate Store', placeholder: 'My', required: true },
|
{ key: 'cert_store', label: 'Certificate Store', placeholder: 'My', required: true },
|
||||||
{ key: 'port', label: 'HTTPS Port', placeholder: '443' },
|
{ key: 'port', label: 'HTTPS Port', placeholder: '443' },
|
||||||
@@ -112,13 +114,25 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
|
|||||||
{ key: 'winrm.winrm_https', label: 'WinRM Use HTTPS', placeholder: 'true or false' },
|
{ key: 'winrm.winrm_https', label: 'WinRM Use HTTPS', placeholder: 'true or false' },
|
||||||
{ key: 'winrm.winrm_insecure', label: 'WinRM Skip TLS Verify', placeholder: 'false' },
|
{ key: 'winrm.winrm_insecure', label: 'WinRM Skip TLS Verify', placeholder: 'false' },
|
||||||
],
|
],
|
||||||
|
SSH: [
|
||||||
|
{ key: 'host', label: 'SSH Host', placeholder: '192.168.1.100 or server.example.com', required: true },
|
||||||
|
{ key: 'port', label: 'SSH Port', placeholder: '22 (default)' },
|
||||||
|
{ key: 'user', label: 'SSH Username', placeholder: 'root or certctl', required: true },
|
||||||
|
{ key: 'auth_method', label: 'Auth Method', placeholder: 'key (default) or password' },
|
||||||
|
{ key: 'private_key_path', label: 'Private Key Path', placeholder: '/home/certctl/.ssh/id_ed25519' },
|
||||||
|
{ key: 'password', label: 'SSH Password', placeholder: 'Leave empty for key auth' },
|
||||||
|
{ key: 'cert_path', label: 'Remote Certificate Path', placeholder: '/etc/ssl/certs/cert.pem', required: true },
|
||||||
|
{ key: 'key_path', label: 'Remote Key Path', placeholder: '/etc/ssl/private/key.pem', required: true },
|
||||||
|
{ key: 'chain_path', label: 'Remote Chain Path (optional)', placeholder: '/etc/ssl/certs/chain.pem' },
|
||||||
|
{ key: 'reload_command', label: 'Reload Command (optional)', placeholder: 'systemctl reload nginx' },
|
||||||
|
{ key: 'timeout', label: 'Connection Timeout (seconds)', placeholder: '30 (default)' },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||||
const [step, setStep] = useState<'type' | 'config' | 'review'>('type');
|
const [step, setStep] = useState<'type' | 'config' | 'review'>('type');
|
||||||
const [targetType, setTargetType] = useState('');
|
const [targetType, setTargetType] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [hostname, setHostname] = useState('');
|
|
||||||
const [agentId, setAgentId] = useState('');
|
const [agentId, setAgentId] = useState('');
|
||||||
const [config, setConfig] = useState<Record<string, string>>({});
|
const [config, setConfig] = useState<Record<string, string>>({});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -127,7 +141,6 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
|||||||
mutationFn: () => createTarget({
|
mutationFn: () => createTarget({
|
||||||
name,
|
name,
|
||||||
type: targetType,
|
type: targetType,
|
||||||
hostname,
|
|
||||||
agent_id: agentId,
|
agent_id: agentId,
|
||||||
config: Object.fromEntries(Object.entries(config).filter(([, v]) => v)),
|
config: Object.fromEntries(Object.entries(config).filter(([, v]) => v)),
|
||||||
}),
|
}),
|
||||||
@@ -205,19 +218,11 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
|||||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
placeholder="web-server-1" />
|
placeholder="web-server-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div>
|
||||||
<div>
|
<label className="text-xs text-ink-muted block mb-1">Agent ID</label>
|
||||||
<label className="text-xs text-ink-muted block mb-1">Hostname</label>
|
<input value={agentId} onChange={e => setAgentId(e.target.value)}
|
||||||
<input value={hostname} onChange={e => setHostname(e.target.value)}
|
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
placeholder="agent-web1" />
|
||||||
placeholder="web1.example.com" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-ink-muted block mb-1">Agent ID</label>
|
|
||||||
<input value={agentId} onChange={e => setAgentId(e.target.value)}
|
|
||||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
|
||||||
placeholder="agent-web1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{fields.map(f => (
|
{fields.map(f => (
|
||||||
<div key={f.key}>
|
<div key={f.key}>
|
||||||
@@ -252,12 +257,6 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
|||||||
<span className="text-ink-muted">Type</span>
|
<span className="text-ink-muted">Type</span>
|
||||||
<span className="text-ink">{typeLabels[targetType] || targetType}</span>
|
<span className="text-ink">{typeLabels[targetType] || targetType}</span>
|
||||||
</div>
|
</div>
|
||||||
{hostname && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-ink-muted">Hostname</span>
|
|
||||||
<span className="text-ink font-mono text-xs">{hostname}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{agentId && (
|
{agentId && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-ink-muted">Agent</span>
|
<span className="text-ink-muted">Agent</span>
|
||||||
@@ -322,20 +321,23 @@ export default function TargetsPage() {
|
|||||||
<span className="badge badge-neutral">{typeLabels[t.type] || t.type}</span>
|
<span className="badge badge-neutral">{typeLabels[t.type] || t.type}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'hostname',
|
|
||||||
label: 'Hostname',
|
|
||||||
render: (t) => <span className="text-ink font-mono text-xs">{t.hostname || '\u2014'}</span>,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'agent',
|
key: 'agent',
|
||||||
label: 'Agent',
|
label: 'Agent',
|
||||||
render: (t) => <span className="text-xs text-ink-muted font-mono">{t.agent_id || '\u2014'}</span>,
|
render: (t) => <span className="text-xs text-ink-muted font-mono">{t.agent_id || '\u2014'}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'enabled',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
render: (t) => <StatusBadge status={t.status} />,
|
render: (t) => <StatusBadge status={t.enabled ? 'Enabled' : 'Disabled'} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'test_status',
|
||||||
|
label: 'Connection',
|
||||||
|
render: (t) => {
|
||||||
|
if (!t.test_status || t.test_status === 'untested') return <span className="text-xs text-ink-faint">—</span>;
|
||||||
|
return <StatusBadge status={t.test_status === 'success' ? 'Connected' : 'Failed'} />;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'created',
|
key: 'created',
|
||||||
|
|||||||
Reference in New Issue
Block a user