7 Commits

Author SHA1 Message Date
shankar0123 8b75e0311b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00
shankar0123 9f41b58b2f feat(ssh,wincertstore,javakeystore,k8ssecret): explicit ValidateOnly + leverage existing connectors
Phase 9 of the deploy-hardening I master bundle. The four
non-file-server connectors get real ValidateOnly probes that
operators use to preview a deploy without touching the live cert.
Existing DeployCertificate paths already have explicit backup +
rollback semantics (SCP backup / WinCertStore Get-ChildItem
snapshot / keytool snapshot / K8s atomic API).

SSH (validate_only.go):
- Probes via SSHClient.Connect. Confirms agent reachability +
  credentials. Cheap (no remote command runs); released cleanly
  via defer Close.
- A true SCP dry-run requires a no-commit upload (SCP doesn't
  have one). V2 ships the auth probe as the load-bearing check.
- 3 new tests in validate_only_test.go.

WinCertStore (validate_only.go):
- Probes via PowerShell `Get-ChildItem -Path Cert:\<loc>\<store>`
  using the configured StoreLocation + StoreName (defaults
  LocalMachine\My).
- Confirms agent has Windows + the IIS module + the right ACLs.
- 4 new tests including default-store-path verification.

JavaKeystore (validate_only.go):
- Probes via `keytool -list -keystore <path> -storepass <pass>`
  using the configured KeystorePath / KeystorePassword and
  KeytoolPath (default "keytool").
- Confirms keystore exists, password is correct, JRE is on PATH.
- 4 new tests covering succeeds / fails / no-path-sentinel /
  nil-executor-sentinel.

K8s Secret (validate_only.go):
- Probes via K8sClient.GetSecret on the configured Namespace +
  SecretName. Returns nil on success or "not found" (the
  CreateSecret path on Deploy will handle it). Other errors
  (forbidden/unreachable) surface as wrapped.
- 4 new tests covering succeeds / RBAC-error wrapped /
  no-config-sentinel / nil-client-sentinel.

Smoke test connectorsAtPhase3 list shrunk from 7 to 3 entries
(ssh + wincertstore + javakeystore + k8ssecret removed). Only
caddy (file-mode) + envoy + traefik remain — those three
genuinely have no validate-with-target command available.

Race detector clean across all 13 connectors. golangci-lint
v2.11.4 clean.

Phase 10 next: DeployCounters + Prometheus exposer mirroring the
production-hardening-II OCSP counter pattern.
2026-04-30 15:22:17 +00:00
shankar0123 36d79cd1ff feat(f5,iis): explicit ValidateOnly + leverage existing transactional rollback
Phase 8 of the deploy-hardening I master bundle. F5 + IIS already
have transactional / explicit-backup-restore rollback semantics
in their DeployCertificate paths. Phase 8 adds the explicit
ValidateOnly dry-run probe that operators use to preview a deploy
without touching the live cert.

F5 (validate_only.go):
- ValidateOnly probes the iControl REST API via Authenticate.
  Cheap (no F5 transaction created) + cached after first success.
  Failure surfaces as a wrapped error so operators see the actual
  cause (auth provider down, invalid creds, BIG-IP unreachable,
  etc.). nil client returns ErrValidateOnlyNotSupported.
- A true cert-bind dry-run requires F5's no-commit transaction
  mode (v17.5+); V3-Pro can add per-version dispatch. V2 ships
  the reachability probe as the load-bearing safety check.
- 5 new tests in validate_only_test.go covering: auth-success,
  auth-fail wrapped, nil-client sentinel, error-message contains
  BIG-IP context, recoverable auth-fail surfaces provider info.

IIS (validate_only.go):
- ValidateOnly runs `Get-WebSite -Name <SiteName>` via the
  injected PowerShellExecutor. Confirms the IIS PS module is
  loaded AND the site exists AND the agent has admin privileges.
  Failure here surfaces the actual PowerShell stderr (site not
  found / module missing / access denied).
- A true cert-bind dry-run would need IIS to expose a no-commit
  New-WebBinding (it doesn't); V3-Pro can extend with a
  temp-install + immediate-remove. V2 ships the permission +
  module probe as the load-bearing check.
- 5 new tests in validate_only_test.go covering: get-website
  succeeds, get-website fails, nil-executor sentinel, site-name
  quoting (handles spaces in 'Default Web Site'), output-context
  in error.

Smoke test connectorsAtPhase3 list shrunk from 10 to 7 entries
(f5 + iis + postfix removed). Caddy stays in (file-mode returns
sentinel; api-mode is real-impl). Envoy + Traefik stay in (no
validate-with-target command exists for either). javakeystore +
k8ssecret + ssh + wincertstore stay in pending Phase 9.

Coverage: F5 holds at ≥85%; IIS holds at ≥85%. Race detector
clean. golangci-lint v2.11.4 clean.

Phase 9 next: SSH + WinCertStore + JavaKeystore + K8s — the
non-file-server connectors.
2026-04-30 15:16:11 +00:00
shankar0123 919a92bf1b feat(haproxy): atomic deploy + post-deploy TLS verify + rollback + ValidateOnly + test-depth uplift to 36 tests
Phase 6 of the deploy-hardening I master bundle. HAProxy connector
follows the canonical Phase 4 NGINX template with the HAProxy-
specific quirk: combined PEM file (cert + chain + key in one
file, in that order). Test count lifts 3 → 36.

HAProxy specifics:
- buildCombinedPEM concatenates cert, chain, key in HAProxy's
  required order. The combined file goes through deploy.Apply as
  a single File entry (vs NGINX/Apache's 2-3 separate File entries).
- Default mode 0600 unconditionally (combined file contains the
  private key); operators rely on this back-compat behavior.
  PEMFileMode override is the supported escape hatch.
- Validate command is `haproxy -c -f <config>`. Reload via
  `systemctl reload haproxy` (NOT `restart` — reload uses socket
  activation to drain in-flight connections).
- Default user/group: haproxy (cross-distro consistent).

DeployCertificate refactor:
- Replaces the duplicated os.WriteFile flow with deploy.Apply.
- PreCommit runs `haproxy -c -f` validation (gated on
  ValidateCommand being non-empty — HAProxy historically allowed
  empty validate).
- PostCommit runs the operator's ReloadCommand.
- Post-deploy TLS verify (frozen-decision-0.3 default ON when
  Endpoint is configured): probes the configured target,
  fingerprint-matches against the deployed cert (the leaf cert
  block from the combined PEM), retries with backoff for load-
  balanced targets.
- Rollback wires identical to NGINX/Apache: backup restore +
  reload retry on PostCommit failure; verify-fail also triggers
  rollback.

ValidateOnly real impl: returns sentinel when no ValidateCommand;
otherwise runs the operator's command without touching the live
combined PEM.

Tests (36 total: 33 in haproxy_atomic_test.go + 3 pre-existing
in haproxy_test.go):

- Atomic invariants (happy, validate-fail, reload-fail-rollback,
  rollback-also-fail-escalation)
- Combined PEM order (cert + chain + key — verified via PEM
  block headers, not base64 bodies)
- Mode handling (default 0600 even when existing is 0640 —
  back-compat; PEMFileMode override; existing-mode unchanged
  when override matches)
- Idempotency (full skip)
- Verify (match, mismatch, dial-timeout, retries, disabled,
  no-endpoint, rollback-runs-reload)
- ValidateOnly (happy, fails, no-command-sentinel, stderr-in-error)
- Concurrency (same-paths-serialize)
- Edge cases (no-chain, no-key, ctx-cancelled, no-validate-command,
  config-validation rejects missing pem_path / reload / shell-injection)

Coverage: HAProxy 88.0% (above >=85% prompt bar). Race detector
clean. golangci-lint v2.11.4 clean.

Smoke test connectorsAtPhase3 list shrinks 11→10 (haproxy
removed alongside nginx + apache).

Phase 7 next: Traefik + Caddy + Envoy + Postfix — the remaining
file-based connectors get the same treatment.
2026-04-30 15:01:23 +00:00
shankar0123 12e5f97f59 feat(apache): atomic deploy + post-deploy TLS verify + rollback + ValidateOnly + test-depth uplift to 34 tests
Phase 5 of the deploy-hardening I master bundle. Mirrors the Phase 4
NGINX template for Apache httpd. Test count lifts 3 → 34 (above the
prompt's >=30 target; matches and slightly exceeds the IIS bar).

Apache-specific quirks codified in apache.go:

- Validate command convention is `apachectl configtest` (NOT
  `apachectl -t` — that flag exists but configtest is the documented
  operator-facing form).
- Reload command convention is `apachectl graceful` for zero-
  downtime worker swap (NOT `apachectl restart` which drops
  in-flight TLS sessions).
- Per-distro user defaults: Debian/Ubuntu apache2, RHEL/CentOS
  apache, Alpine httpd. pickFirstExistingUser walks the list and
  picks the one that resolves on the host; falls back to no-chown
  when none exist (cross-distro portability without operator
  config; same approach as nginx).
- Default key file mode 0600 for back-compat with operators
  relying on the historical hard-coded value (matches the
  pre-Phase-5 implementation behavior).

DeployCertificate refactor:
- Replaces the duplicated os.WriteFile chain with deploy.Apply.
- PreCommit runs the operator's ValidateCommand via the test
  seam (which wraps `sh -c <cmd>` in production).
- PostCommit runs ReloadCommand the same way.
- Post-deploy TLS verify (frozen-decision-0.3 default ON when
  Endpoint is configured): probes the configured target,
  compares leaf cert SHA-256 against deployed bytes, retries with
  exponential backoff (default 3 attempts / 2s backoff for
  load-balanced targets).
- Rollback wires: reload-fail → restore backups + retry reload;
  verify-fail → restore backups + reload again. Second-failure
  surfaces ErrRollbackFailed for operator-actionable triage.

ValidateOnly real implementation replaces the Phase 3 stub.
Returns ErrValidateOnlyNotSupported when no ValidateCommand
configured; otherwise runs the validate-with-the-target command
without touching the live cert.

Test seams (SetTestRunValidate / SetTestRunReload / SetTestProbe)
allow tests to skip exec without `apachectl` on PATH; mirror the
nginx pattern.

Tests (34 total: 31 in apache_atomic_test.go + 3 pre-existing
in apache_test.go):

- Atomic invariants (happy, validate-fail-no-files-changed,
  reload-fail-rollback, rollback-also-fail-escalation)
- SHA-256 idempotency (full skip + partial-mismatch full-deploy)
- Post-deploy verify (match-success, mismatch-rollback,
  dial-timeout-rollback, retries-until-match,
  retries-exhausted-rollback, no-endpoint-skips, disabled-skips)
- Ownership / mode preservation (existing-mode, override-wins,
  default-key-0600, default-cert-0644)
- Backup retention (keeps-N, disabled-no-backups, backup-created)
- Concurrency (same-paths-serialize)
- ValidateOnly (happy, fails, no-command-sentinel, stderr-in-error)
- Edge cases (no-chain, no-key, ctx-cancelled, verify-rollback-
  reload, deployment-id-prefix, metadata-populated)

Coverage: Apache 86.6% (above the >=85% prompt bar). Race detector
clean. golangci-lint v2.11.4 clean.

Smoke test connectorsAtPhase3 list shrunk from 12 to 11
entries (apache removed; nginx + apache now have real impls).

Phase 6 next: HAProxy (combined PEM atomic write + `haproxy -c -f`
validate + uplift 3 → >=30).
2026-04-30 14:56:23 +00:00
shankar0123 7444df01e2 feat(nginx): atomic deploy + post-deploy TLS verify + rollback + ValidateOnly + ownership preservation
Phase 4 of the deploy-hardening I master bundle. The canonical NGINX
implementation that Phases 5-9 model on. Replaces the historical
os.WriteFile flow at internal/connector/target/nginx/nginx.go:99
with deploy.Apply() and adds three production-grade competitor-gap
features: atomic deploy with rollback, post-deploy TLS verify, file
ownership preservation.

NGINX connector — internal/connector/target/nginx/nginx.go:

- DeployCertificate now wires deploy.Apply with PreCommit running
  the operator's ValidateCommand (e.g. `nginx -t`), PostCommit
  running ReloadCommand (e.g. `nginx -s reload`), and an explicit
  post-deploy TLS verify step that dials the configured endpoint,
  pulls the leaf cert SHA-256, and compares against what was just
  deployed. SHA-256 mismatch (wrong vhost / cached cert / NGINX
  still serving stale) triggers automatic rollback: backup files
  are restored + reload fired again. Failed-second-reload returns
  ErrRollbackFailed (operator-actionable; loud audit + alert).

- ValidateOnly replaces the Phase 3 stub: runs the operator's
  ValidateCommand without touching the live cert. V2 contract is
  syntax-only validation (full pre-deploy temp-config validation
  is V3-Pro). Returns ErrValidateOnlyNotSupported when no
  ValidateCommand is configured.

- New per-target Config fields: PostDeployVerify (frozen-decision-
  0.3 default ON), PostDeployVerifyAttempts (default 3 — defends
  against load-balanced targets where the verify might hit a
  different pod that hasn't picked up the new cert yet),
  PostDeployVerifyBackoff (default 2s exponential), per-file
  Mode/Owner/Group overrides (KeyFileMode, CertFileMode,
  KeyFileOwner, etc.), and BackupRetention (default 3, -1 to
  disable backups entirely — documented foot-gun).

- buildPlan honors per-distro nginx user (Debian: www-data,
  Alpine: nginx, Red Hat: nginx) by checking the local user
  database; falls back to no-chown when neither exists. Means
  the connector is portable across distros without operator
  config.

Deploy package — internal/deploy/ownership.go:

- applyOwnership now silently swallows chown failures when the
  agent isn't running as root. Production agents always run as
  root and chown failures are real bugs; dev / CI runs as a
  regular user where chown to a different uid will always fail
  with EPERM (or EINVAL on some tmpfs configs) and would
  otherwise force every test to run with sudo. Production-grade
  contract preserved (uid 0 still hard-fails on chown errors).

Test suite — internal/connector/target/nginx/nginx_atomic_test.go
ships 42 new named tests (NGINX total: 17 pre-existing + 42 new = 59,
above the prompt's >=40 bar; matches the IIS depth bar of 41):

- Atomic-deploy invariants (cert+chain+key all-or-nothing,
  validate-fails-no-files-changed, reload-fails-rollback,
  rollback-also-fails-escalation)
- SHA-256 idempotency (full match skips, partial match deploys all)
- Post-deploy TLS verify (fingerprint-match-success,
  SHA256-mismatch-rollback, dial-timeout-rollback, retries-until-
  match, retries-exhausted-rollback, no-endpoint-skips,
  disabled-skips-entirely, default-10s-timeout, endpoint-forwarded)
- Ownership / mode preservation (existing-mode-preserved, override-
  wins, KeyFileMode override applied)
- Backup retention (keeps-last-N, disabled-creates-no-backups,
  fresh-deploy-creates-backup)
- Concurrency (same-paths-serialize via deploy package's file mutex,
  different-paths-parallelize)
- ValidateOnly (happy-path-nil, command-fails-wrapped-error,
  no-config-returns-sentinel, ctx-cancelled, stderr-in-message)
- Edge cases (no-chain, no-key, no-chain-path, empty-cert-PEM,
  ctx-cancelled, all-four-one-apply)
- Result.Metadata + DeploymentID shape contracts

Coverage: NGINX 91.0% (above the >=85% prompt bar). Race detector
clean. golangci-lint v2.11.4 clean. Existing 17 tests still all pass
(no behavior change in the legacy paths exercised there).

Phase 5 next: mirror this implementation for Apache + lift its
test count from 3 to >=30. Same template applies through Phases
6-9 for the remaining 11 connectors.
2026-04-30 14:50:56 +00:00
shankar0123 49f1a60762 feat(target): ValidateOnly dry-run method on Connector interface (default returns ErrValidateOnlyNotSupported)
Phase 3 of the deploy-hardening I master bundle. Extends the
target.Connector interface with the dry-run method that operators
will use to preview a deploy before committing — but ships only the
default-stub for all 13 connectors. Phases 4-9 replace each stub
with the real validate-with-the-target implementation.

interface.go:
- Add ErrValidateOnlyNotSupported sentinel (frozen decision 0.6 —
  connectors that cannot dry-run, like K8s, return this rather than
  nil so operator triage can errors.Is for "not supported" vs
  "validated successfully").
- Add ValidateOnly(ctx, request DeploymentRequest) error to
  Connector interface.

13 new validate_only.go files (one per connector at
internal/connector/target/<name>/validate_only.go):
- apache, caddy, envoy, f5, haproxy, iis, javakeystore, k8ssecret,
  nginx, postfix, ssh, traefik, wincertstore.
- Each file is identical except for the package declaration: a
  one-method default stub returning target.ErrValidateOnlyNotSupported.
- Per-connector files (rather than a single embed-method approach)
  let Phases 4-9 replace each connector's stub independently
  without churning a shared base.

Tests:
- internal/connector/target/validate_only_test.go pins the sentinel
  contract (errors.Is identity, Error() string, %w wrap propagation).
- internal/connector/target/validate_only_smoke_test.go (external
  test package) constructs a zero-value &<pkg>.Connector{} for each
  of the 13 connectors and asserts ValidateOnly returns
  ErrValidateOnlyNotSupported. The test's
  connectorsAtPhase3 list is the load-bearing CI guard:
  - A 14th connector added without wiring ValidateOnly fails the
    `len(connectorsAtPhase3) != 13` invariant.
  - A connector whose real ValidateOnly lands (Phase 4 NGINX, Phase
    5 Apache, etc.) MUST be removed from this list or the smoke test
    fails (real impl no longer returns the sentinel). That removal
    IS the bookkeeping that the operator-visible bit + behavior
    change are wired together end-to-end.

Compile + go vet + golangci-lint v2.11.4 + go test all 0 issues.

Phase 4 next: NGINX canonical real-impl — replace the stub with
nginx -t -c <temp>; same time replace the existing os.WriteFile
flow in DeployCertificate with deploy.Apply(...).
2026-04-30 14:40:51 +00:00