diff --git a/README.md b/README.md index fed7afd..862ee73 100644 --- a/README.md +++ b/README.md @@ -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. -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) @@ -104,6 +104,7 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po | Dovecot | Implemented | `Dovecot` | | Microsoft IIS | Implemented (local + WinRM) | `IIS` | | F5 BIG-IP | Beta | `F5` | +| SSH (Agentless) | Beta | `SSH` | ### Notifiers | Notifier | Status | Type | diff --git a/api/openapi.yaml b/api/openapi.yaml index fe2cd1a..89ae338 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2669,7 +2669,7 @@ components: # ─── Targets ───────────────────────────────────────────────────── TargetType: 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: type: object diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 5e4f9e3..fb6949c 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -31,6 +31,7 @@ import ( "github.com/shankar0123/certctl/internal/connector/target/caddy" "github.com/shankar0123/certctl/internal/connector/target/envoy" 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/haproxy" "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 + 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: return nil, fmt.Errorf("unsupported target type: %s", targetType) } diff --git a/docs/architecture.md b/docs/architecture.md index e97c592..a2452f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,7 @@ flowchart TB T9["Postfix/Dovecot\n(file + service reload)"] T2["F5 BIG-IP\n(proxy agent + iControl REST)"] T3["IIS\n(WinRM + local)"] + T10["SSH\n(SFTP + reload)"] end DASH --> API @@ -529,6 +530,7 @@ flowchart TB TI --> PO["Postfix/Dovecot"] TI --> IIS["IIS"] TI --> F5["F5 BIG-IP"] + TI --> SC["SSH"] end 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. -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 @@ -976,7 +978,7 @@ certctl is extensively tested across eight layers with CI-enforced coverage gate **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. -**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). Notifier connectors (Slack, Teams, PagerDuty, OpsGenie). +**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). **Scheduler tests** (`internal/scheduler/scheduler_test.go`) — Idempotency guards (`sync/atomic.Bool`), `WaitForCompletion` success and timeout paths, and multi-loop concurrency safety. diff --git a/docs/connectors.md b/docs/connectors.md index bc1b829..65594d1 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -26,6 +26,7 @@ Connectors extend certctl to integrate with external systems for certificate iss - [Built-in: Caddy](#built-in-caddy) - [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only) - [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode) + - [SSH (Agentless Deployment)](#ssh-agentless-deployment) 4. [Notifier Connector](#notifier-connector) - [Interface](#interface-2) 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: 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) 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` +### 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 connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations). diff --git a/go.mod b/go.mod index 718b2bc..e1db670 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,9 @@ 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 ) @@ -48,11 +50,11 @@ require ( github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // 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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.7 // 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/patternmatcher v0.6.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/shoenig/go-m1cpu v0.1.6 // 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/tklauser/go-sysconf v0.3.12 // 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/metric 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/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 ) diff --git a/go.sum b/go.sum index 969b12a..c9f88a5 100644 --- a/go.sum +++ b/go.sum @@ -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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= 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/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/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= 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/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +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/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= 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-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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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.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.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +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/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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-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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +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/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/connector/target/ssh/ssh.go b/internal/connector/target/ssh/ssh.go new file mode 100644 index 0000000..105ff04 --- /dev/null +++ b/internal/connector/target/ssh/ssh.go @@ -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 +} diff --git a/internal/connector/target/ssh/ssh_test.go b/internal/connector/target/ssh/ssh_test.go new file mode 100644 index 0000000..380e185 --- /dev/null +++ b/internal/connector/target/ssh/ssh_test.go @@ -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 +} diff --git a/internal/domain/connector.go b/internal/domain/connector.go index 6764816..219c071 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -97,4 +97,5 @@ const ( TargetTypeEnvoy TargetType = "Envoy" TargetTypePostfix TargetType = "Postfix" TargetTypeDovecot TargetType = "Dovecot" + TargetTypeSSH TargetType = "SSH" ) diff --git a/internal/service/target.go b/internal/service/target.go index 33518d1..a5c5571 100644 --- a/internal/service/target.go +++ b/internal/service/target.go @@ -24,6 +24,7 @@ var validTargetTypes = map[domain.TargetType]bool{ domain.TargetTypeEnvoy: true, domain.TargetTypePostfix: true, domain.TargetTypeDovecot: true, + domain.TargetTypeSSH: true, } // isValidTargetType checks if a type string is a known target type. diff --git a/web/src/pages/TargetDetailPage.tsx b/web/src/pages/TargetDetailPage.tsx index 9e5b189..9ceec25 100644 --- a/web/src/pages/TargetDetailPage.tsx +++ b/web/src/pages/TargetDetailPage.tsx @@ -11,16 +11,17 @@ import { formatDateTime } from '../api/utils'; import type { Job } from '../api/types'; const typeLabels: Record = { - nginx: 'NGINX', - apache: 'Apache', - haproxy: 'HAProxy', - traefik: 'Traefik', - caddy: 'Caddy', - f5_bigip: 'F5 BIG-IP', - iis: 'IIS', - envoy: 'Envoy', - postfix: 'Postfix', - dovecot: 'Dovecot', + NGINX: 'NGINX', + Apache: 'Apache', + HAProxy: 'HAProxy', + Traefik: 'Traefik', + Caddy: 'Caddy', + F5: 'F5 BIG-IP', + IIS: 'IIS', + Envoy: 'Envoy', + Postfix: 'Postfix', + Dovecot: 'Dovecot', + SSH: 'SSH', }; function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { diff --git a/web/src/pages/TargetsPage.tsx b/web/src/pages/TargetsPage.tsx index 3f90561..c6b5d23 100644 --- a/web/src/pages/TargetsPage.tsx +++ b/web/src/pages/TargetsPage.tsx @@ -11,83 +11,85 @@ import { formatDateTime } from '../api/utils'; import type { Target } from '../api/types'; const typeLabels: Record = { - nginx: 'NGINX', - apache: 'Apache', - haproxy: 'HAProxy', - traefik: 'Traefik', - caddy: 'Caddy', - envoy: 'Envoy', - postfix: 'Postfix', - dovecot: 'Dovecot', - f5_bigip: 'F5 BIG-IP', - iis: 'IIS', + NGINX: 'NGINX', + Apache: 'Apache', + HAProxy: 'HAProxy', + Traefik: 'Traefik', + Caddy: 'Caddy', + Envoy: 'Envoy', + Postfix: 'Postfix', + Dovecot: 'Dovecot', + F5: 'F5 BIG-IP', + IIS: 'IIS', + SSH: 'SSH', }; const TARGET_TYPES = [ - { 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: '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: '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: 'postfix', label: 'Postfix', description: 'Postfix MTA — file write + postfix 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: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' }, + { 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: '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: '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: 'Postfix', label: 'Postfix', description: 'Postfix MTA — file write + postfix reload' }, + { value: 'Dovecot', label: 'Dovecot', description: 'Dovecot IMAP/POP3 — file write + doveadm reload' }, + { 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: 'SSH', label: 'SSH', description: 'Agentless deployment via SSH/SFTP — deploy to any Linux/Unix server without installing an agent' }, ]; const CONFIG_FIELDS: Record = { - nginx: [ + NGINX: [ { 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: 'chain_path', label: 'Chain Path', placeholder: '/etc/nginx/ssl/chain.pem' }, { 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: '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: '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: 'reload_cmd', label: 'Reload Command', placeholder: 'systemctl reload haproxy' }, { 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_file', label: 'Certificate Filename', placeholder: 'cert.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: '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_file', label: 'Certificate Filename', placeholder: 'cert.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_filename', label: 'Certificate Filename', placeholder: 'cert.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: '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: '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: 'reload_command', label: 'Reload Command', placeholder: 'postfix reload' }, { key: 'validate_command', label: 'Validate Command', placeholder: 'postfix check' }, ], - dovecot: [ + Dovecot: [ { 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: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/dovecot/certs/chain.pem' }, { key: 'reload_command', label: 'Reload Command', placeholder: 'doveadm reload' }, { 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: 'port', label: 'Management Port', placeholder: '443' }, { key: 'username', label: 'Username', placeholder: 'admin', required: true }, @@ -97,7 +99,7 @@ const CONFIG_FIELDS: Record void; onSuccess: () => void }) {