mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
feat(M38): SSH target connector for agentless deployment via SSH/SFTP
Adds a new target connector enabling certificate deployment to any Linux/Unix server without installing the certctl agent binary. Uses the proxy agent pattern — a single agent in the same network zone deploys certs to remote servers over SSH/SFTP. Key additions: - SSH/SFTP connector with key auth (file/inline) + password auth - Injectable SSHClient interface for cross-platform testing (25 tests) - Shell injection prevention via validation.ValidateShellCommand() - Configurable cert/key/chain paths with octal permissions - GUI: 11 SSH config fields in target create wizard Also fixes pre-existing frontend bug where all target type strings (nginx, apache, etc.) were sent as lowercase but the backend expects proper-case (NGINX, Apache, etc.), breaking GUI-created targets. Adds missing TargetTypeSSH to validTargetTypes service map. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+63
-1
@@ -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).
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -97,4 +97,5 @@ const (
|
||||
TargetTypeEnvoy TargetType = "Envoy"
|
||||
TargetTypePostfix TargetType = "Postfix"
|
||||
TargetTypeDovecot TargetType = "Dovecot"
|
||||
TargetTypeSSH TargetType = "SSH"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,16 +11,17 @@ import { formatDateTime } from '../api/utils';
|
||||
import type { Job } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
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 }) {
|
||||
|
||||
@@ -11,83 +11,85 @@ import { formatDateTime } from '../api/utils';
|
||||
import type { Target } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
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<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: '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<string, { key: string; label: string; placeholder: s
|
||||
{ key: 'insecure', label: 'Skip TLS Verify', placeholder: 'true (default)' },
|
||||
{ key: 'timeout', label: 'Timeout (seconds)', placeholder: '30' },
|
||||
],
|
||||
iis: [
|
||||
IIS: [
|
||||
{ key: 'site_name', label: 'IIS Site Name', placeholder: 'Default Web Site', required: true },
|
||||
{ key: 'cert_store', label: 'Certificate Store', placeholder: 'My', required: true },
|
||||
{ key: 'port', label: 'HTTPS Port', placeholder: '443' },
|
||||
@@ -112,6 +114,19 @@ 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_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 }) {
|
||||
|
||||
Reference in New Issue
Block a user