mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 22:59:01 +00:00
8b75e0311b
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.
346 lines
13 KiB
Go
346 lines
13 KiB
Go
// Package traefik implements the Traefik file-provider target
|
|
// connector. Bundle 4 of the 2026-05-02 deployment-target audit:
|
|
// upgraded from two separate deploy.AtomicWriteFile calls (cert,
|
|
// key) to a single deploy.Apply Plan with all-files atomicity.
|
|
// Traefik has no PreCommit (no `nginx -t` equivalent) and no
|
|
// PostCommit (file watcher auto-reloads on rename). Post-deploy
|
|
// TLS verify (optional) confirms the watcher picked up the new
|
|
// cert; on verify failure, restoreFromBackups rewrites every File
|
|
// path from its backup so the watcher reloads to the prior state.
|
|
package traefik
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/target"
|
|
"github.com/certctl-io/certctl/internal/deploy"
|
|
"github.com/certctl-io/certctl/internal/tlsprobe"
|
|
)
|
|
|
|
type Config struct {
|
|
CertDir string `json:"cert_dir"`
|
|
CertFile string `json:"cert_file"`
|
|
KeyFile string `json:"key_file"`
|
|
|
|
// Phase 7: per-file mode/owner overrides + post-deploy verify
|
|
// + backup retention.
|
|
CertFileMode os.FileMode `json:"cert_file_mode,omitempty"`
|
|
KeyFileMode os.FileMode `json:"key_file_mode,omitempty"`
|
|
CertFileOwner string `json:"cert_file_owner,omitempty"`
|
|
CertFileGroup string `json:"cert_file_group,omitempty"`
|
|
KeyFileOwner string `json:"key_file_owner,omitempty"`
|
|
KeyFileGroup string `json:"key_file_group,omitempty"`
|
|
PostDeployVerify *PostDeployVerifyConfig `json:"post_deploy_verify,omitempty"`
|
|
PostDeployVerifyAttempts int `json:"post_deploy_verify_attempts,omitempty"`
|
|
PostDeployVerifyBackoff time.Duration `json:"post_deploy_verify_backoff,omitempty"`
|
|
PostDeployVerifyMaxBackoff time.Duration `json:"post_deploy_verify_max_backoff,omitempty"`
|
|
BackupRetention int `json:"backup_retention,omitempty"`
|
|
}
|
|
|
|
type PostDeployVerifyConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Endpoint string `json:"endpoint,omitempty"`
|
|
Timeout time.Duration `json:"timeout,omitempty"`
|
|
}
|
|
|
|
type Connector struct {
|
|
config *Config
|
|
logger *slog.Logger
|
|
probe func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult
|
|
}
|
|
|
|
func New(config *Config, logger *slog.Logger) *Connector {
|
|
return &Connector{config: config, logger: logger, probe: tlsprobe.ProbeTLS}
|
|
}
|
|
|
|
func (c *Connector) SetTestProbe(fn func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult) {
|
|
c.probe = fn
|
|
}
|
|
|
|
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 Traefik config: %w", err)
|
|
}
|
|
if cfg.CertDir == "" {
|
|
return fmt.Errorf("Traefik cert_dir is required")
|
|
}
|
|
if cfg.CertFile == "" {
|
|
cfg.CertFile = "cert.pem"
|
|
}
|
|
if cfg.KeyFile == "" {
|
|
cfg.KeyFile = "key.pem"
|
|
}
|
|
c.logger.Info("validating Traefik configuration", "cert_dir", cfg.CertDir,
|
|
"cert_file", cfg.CertFile, "key_file", cfg.KeyFile)
|
|
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
|
|
return fmt.Errorf("Traefik cert directory does not exist: %s", cfg.CertDir)
|
|
}
|
|
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
|
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
|
return fmt.Errorf("Traefik cert directory is not writable: %s (%w)", cfg.CertDir, err)
|
|
}
|
|
os.Remove(testFile)
|
|
c.config = &cfg
|
|
c.logger.Info("Traefik configuration validated")
|
|
return nil
|
|
}
|
|
|
|
// DeployCertificate writes cert + chain (combined) and key via a
|
|
// single deploy.Apply Plan. Bundle 4 of the 2026-05-02 deployment-
|
|
// target audit replaced the prior two-AtomicWriteFile approach,
|
|
// which broke all-files atomicity (a key-write failure after a
|
|
// successful cert write left an orphaned cert and the dedicated
|
|
// rollback helper only restored the cert).
|
|
//
|
|
// Traefik has no PreCommit (no validate-with-target command) and
|
|
// no PostCommit (inotify watcher auto-reloads on rename). Apply
|
|
// gives us SHA-256 idempotency over both files and rollback
|
|
// semantics if the rename loop ever fails mid-stream.
|
|
//
|
|
// Post-deploy TLS verify (optional via PostDeployVerify) confirms
|
|
// the watcher picked up the new bytes; on mismatch, restoreFromBackups
|
|
// rewrites both files from their backups and the watcher will
|
|
// auto-reload to the prior state on its next tick.
|
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
|
c.logger.Info("deploying certificate to Traefik",
|
|
"cert_dir", c.config.CertDir, "cert_file", c.config.CertFile, "key_file", c.config.KeyFile)
|
|
startTime := time.Now()
|
|
|
|
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
|
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
|
|
|
plan := c.buildPlan(request, certPath, keyPath)
|
|
|
|
res, err := deploy.Apply(ctx, plan)
|
|
if err != nil {
|
|
return c.failureResult(certPath, "deploy.Apply", err, startTime), err
|
|
}
|
|
|
|
// Post-deploy TLS verify (skip when nothing changed).
|
|
if !res.SkippedAsIdempotent {
|
|
if vErr := c.runPostDeployVerify(ctx, request.CertPEM); vErr != nil {
|
|
c.logger.Error("post-deploy TLS verify failed; rolling back", "error", vErr)
|
|
rbErr := c.restoreFromBackups(ctx, res.BackupPaths)
|
|
if rbErr != nil {
|
|
return c.failureResult(certPath, "verify+rollback both failed",
|
|
fmt.Errorf("verify: %w; rollback: %v", vErr, rbErr), startTime), rbErr
|
|
}
|
|
return c.failureResult(certPath, "post-deploy verify failed; rolled back", vErr, startTime), vErr
|
|
}
|
|
}
|
|
|
|
dur := time.Since(startTime)
|
|
idemNote := ""
|
|
if res.SkippedAsIdempotent {
|
|
idemNote = " (idempotent skip — bytes unchanged)"
|
|
}
|
|
c.logger.Info("certificate deployed to Traefik successfully",
|
|
"duration", dur.String(), "cert_path", certPath, "idempotent", res.SkippedAsIdempotent)
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: certPath,
|
|
DeploymentID: fmt.Sprintf("traefik-%d", time.Now().Unix()),
|
|
Message: "Certificate deployed to Traefik (file watcher will auto-reload)" + idemNote,
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"cert_path": certPath, "key_path": keyPath,
|
|
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
|
|
"idempotent": fmt.Sprintf("%t", res.SkippedAsIdempotent),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// buildPlan assembles the deploy.Plan for one cert+(optional)key
|
|
// deployment. Cert and key are separate Files in the same Plan so
|
|
// Apply runs SHA-256 idempotency all-files and produces all-or-
|
|
// nothing atomicity. The combined-PEM (cert + "\n" + chain + "\n")
|
|
// shape is preserved for byte-equal compatibility with pre-Bundle-4
|
|
// deploys.
|
|
func (c *Connector) buildPlan(request target.DeploymentRequest, certPath, keyPath string) deploy.Plan {
|
|
combined := request.CertPEM + "\n"
|
|
if request.ChainPEM != "" {
|
|
combined = combined + request.ChainPEM + "\n"
|
|
}
|
|
certMode := c.config.CertFileMode
|
|
if certMode == 0 {
|
|
certMode = 0644
|
|
}
|
|
keyMode := c.config.KeyFileMode
|
|
if keyMode == 0 {
|
|
keyMode = 0600
|
|
}
|
|
|
|
files := []deploy.File{{
|
|
Path: certPath,
|
|
Bytes: []byte(combined),
|
|
Mode: certMode,
|
|
Owner: c.config.CertFileOwner,
|
|
Group: c.config.CertFileGroup,
|
|
}}
|
|
if request.KeyPEM != "" {
|
|
files = append(files, deploy.File{
|
|
Path: keyPath,
|
|
Bytes: []byte(request.KeyPEM),
|
|
Mode: keyMode,
|
|
Owner: c.config.KeyFileOwner,
|
|
Group: c.config.KeyFileGroup,
|
|
})
|
|
}
|
|
return deploy.Plan{
|
|
Files: files,
|
|
BackupRetention: c.config.BackupRetention,
|
|
}
|
|
}
|
|
|
|
// ValidateOnly returns ErrValidateOnlyNotSupported. Traefik has no
|
|
// validate-with-the-target command (the file watcher just picks up
|
|
// changes); there is no way to dry-run a cert deploy without
|
|
// touching the live files.
|
|
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
|
return target.ErrValidateOnlyNotSupported
|
|
}
|
|
|
|
func (c *Connector) runPostDeployVerify(ctx context.Context, deployedCertPEM string) error {
|
|
verify := c.config.PostDeployVerify
|
|
if verify == nil || !verify.Enabled || verify.Endpoint == "" {
|
|
return nil
|
|
}
|
|
timeout := verify.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 10 * time.Second
|
|
}
|
|
want, err := certPEMToFingerprint(deployedCertPEM)
|
|
if err != nil {
|
|
return fmt.Errorf("compute fingerprint: %w", err)
|
|
}
|
|
|
|
retryCfg := tlsprobe.RetryConfig{
|
|
Attempts: c.config.PostDeployVerifyAttempts,
|
|
InitialBackoff: c.config.PostDeployVerifyBackoff,
|
|
MaxBackoff: c.config.PostDeployVerifyMaxBackoff,
|
|
}
|
|
|
|
probe := func(probectx context.Context) error {
|
|
res := c.probe(probectx, verify.Endpoint, timeout)
|
|
if !res.Success {
|
|
return fmt.Errorf("TLS probe failed: %s", res.Error)
|
|
}
|
|
if !strings.EqualFold(res.Fingerprint, want) {
|
|
return fmt.Errorf("post-deploy TLS verify SHA-256 mismatch: got %s, want %s", res.Fingerprint, want)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return tlsprobe.VerifyWithExponentialBackoff(ctx, retryCfg, probe)
|
|
}
|
|
|
|
// restoreFromBackups iterates the BackupPaths returned by deploy.Apply
|
|
// and rewrites every destination from its backup via AtomicWriteFile
|
|
// {SkipIdempotent:true, BackupRetention:-1}. The -1 prevents
|
|
// backup-of-the-backup pollution when a rollback fires.
|
|
//
|
|
// For files that did not exist before the deploy (BackupPath == ""),
|
|
// restore = remove. Mirrors nginx.go::rollbackToBackups (L487-515)
|
|
// with the reload step elided — Traefik's inotify watcher will
|
|
// pick up the restored bytes on its next tick.
|
|
//
|
|
// Bundle 4 of the 2026-05-02 deployment-target audit. Replaces the
|
|
// pre-fix rollbackCertAndKey helper which only restored the cert
|
|
// (key was orphaned on verify failure).
|
|
func (c *Connector) restoreFromBackups(ctx context.Context, backupPaths map[string]string) error {
|
|
for finalPath, backupPath := range backupPaths {
|
|
if backupPath == "" {
|
|
// File didn't exist pre-deploy → restore = remove.
|
|
if err := os.Remove(finalPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("rollback remove %s: %w", finalPath, err)
|
|
}
|
|
continue
|
|
}
|
|
bytes, err := os.ReadFile(backupPath)
|
|
if err != nil {
|
|
return fmt.Errorf("rollback read backup %s: %w", backupPath, err)
|
|
}
|
|
if _, err := deploy.AtomicWriteFile(ctx, finalPath, bytes, deploy.WriteOptions{
|
|
SkipIdempotent: true,
|
|
BackupRetention: -1, // don't backup the rollback
|
|
}); err != nil {
|
|
return fmt.Errorf("rollback write %s: %w", finalPath, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Connector) failureResult(addr, stage string, err error, startTime time.Time) *target.DeploymentResult {
|
|
return &target.DeploymentResult{
|
|
Success: false, TargetAddress: addr,
|
|
Message: fmt.Sprintf("%s: %v", stage, err), DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"stage": stage, "duration_ms": fmt.Sprintf("%d", time.Since(startTime).Milliseconds()),
|
|
},
|
|
}
|
|
}
|
|
|
|
func certPEMToFingerprint(pemBytes string) (string, error) {
|
|
begin := "-----BEGIN CERTIFICATE-----"
|
|
end := "-----END CERTIFICATE-----"
|
|
beginIdx := strings.Index(pemBytes, begin)
|
|
if beginIdx < 0 {
|
|
return "", fmt.Errorf("no CERTIFICATE PEM block")
|
|
}
|
|
rest := pemBytes[beginIdx+len(begin):]
|
|
endIdx := strings.Index(rest, end)
|
|
if endIdx < 0 {
|
|
return "", fmt.Errorf("PEM not terminated")
|
|
}
|
|
body := strings.TrimSpace(rest[:endIdx])
|
|
body = strings.ReplaceAll(body, "\n", "")
|
|
body = strings.ReplaceAll(body, "\r", "")
|
|
body = strings.ReplaceAll(body, " ", "")
|
|
der, err := base64.StdEncoding.DecodeString(body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("base64: %w", err)
|
|
}
|
|
h := sha256.Sum256(der)
|
|
return hex.EncodeToString(h[:]), nil
|
|
}
|
|
|
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
|
c.logger.Info("validating Traefik deployment", "certificate_id", request.CertificateID, "serial", request.Serial)
|
|
startTime := time.Now()
|
|
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
|
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
|
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
|
return &target.ValidationResult{
|
|
Valid: false, Serial: request.Serial, TargetAddress: certPath,
|
|
Message: fmt.Sprintf("certificate file not found: %s", certPath), ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("certificate file not found: %s", certPath)
|
|
}
|
|
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
|
return &target.ValidationResult{
|
|
Valid: false, Serial: request.Serial, TargetAddress: keyPath,
|
|
Message: fmt.Sprintf("private key file not found: %s", keyPath), ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("private key file not found: %s", keyPath)
|
|
}
|
|
dur := time.Since(startTime)
|
|
return &target.ValidationResult{
|
|
Valid: true, Serial: request.Serial, TargetAddress: certPath,
|
|
Message: "Certificate and key files accessible", ValidatedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"cert_path": certPath, "key_path": keyPath,
|
|
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|