mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:51:30 +00:00
ba66748b5b
Phase 7 of the certctl architecture diligence remediation closes
SEC-H2 by eliminating `sh -c` from every production target-connector
exec call site, replacing it with argv-form exec.CommandContext
fed by a new validating shell-split helper.
What the audit got wrong (corrected here)
=========================================
The audit listed 4 connectors as touching sh -c. Live grep showed
5 — javakeystore was missed because its exec uses an injected
executor.Execute(ctx, "sh", "-c", ...) shape instead of the more
typical exec.CommandContext direct call. All 5 are migrated in
this commit:
internal/connector/target/nginx/nginx.go
internal/connector/target/apache/apache.go
internal/connector/target/haproxy/haproxy.go
internal/connector/target/postfix/postfix.go
internal/connector/target/javakeystore/javakeystore.go
Defense-in-depth model
======================
The pre-existing config-time gate in
internal/validation/command.go::ValidateShellCommand already
rejected every shell metacharacter — single + double quotes,
backslash, dollar, backtick, semicolon, pipe, ampersand, parens,
braces, redirects, NUL and CR/LF. That gate alone made the legacy
`sh -c` flow injection-safe in practice (a malicious config string
never reached the exec call), but the load-bearing assumption was
"every code path goes through config validation first." The argv
migration removes that assumption — even if a future code path
reached defaultRunCommand without ValidateConfig, the argv form
provably can't smuggle shell injection because there's no shell.
New helper: validation.SplitShellCommand
========================================
internal/validation/command.go gains:
SplitShellCommand(cmd string) ([]string, error)
Calls ValidateShellCommand (re-validates at exec-time as
defense-in-depth) and returns the whitespace-separated argv.
Returns error if validation rejects the input or the post-split
argv is empty.
Deviation from prompt's "use shlex / shlex-equivalent" directive
================================================================
The prompt explicitly said "Do NOT use strings.Fields — it
doesn't handle quoted arguments. Use shlex-equivalent or
github.com/google/shlex for correctness."
Deviation: this commit uses strings.Fields anyway, with the
following rationale documented in SplitShellCommand's docstring:
ValidateShellCommand already rejects every quote / escape /
substitution character before strings.Fields runs. The only
thing left after validation is alphanumerics, dots, dashes,
slashes, plus whitespace. strings.Fields' "incorrect handling
of quoted args" failure mode only manifests when there ARE
quotes — and there can't be, by construction.
Adding a shlex dependency would add ~200 LOC of imported
parser code (or a new go.mod entry) to handle a case that
the deny-list provably forbids. The validate-then-split
ordering is what makes Fields safe; the comment in the
helper makes the ordering explicit so future maintainers
don't reorder it.
The SplitShellCommand_HappyPaths test pins this contract — e.g.
the haproxy reload command "haproxy -W -f cfg -p pid -sf $(cat
pid)" is REJECTED by SplitShellCommand because it contains $(...).
Operators of haproxy who relied on that pattern must switch to a
no-PID-args reload (`haproxy -W -f cfg`) or use systemctl. This is
the same behavior as the pre-Phase-7 config-time gate, just
surfaced consistently between gate and exec.
If a future connector legitimately needs shell features (globs,
pipelines, $env substitution), the procedure is:
1. Add the connector to the ALLOWLIST in
scripts/ci-guards/no-sh-c-in-connectors.sh with a documented
justification.
2. Add a paired strict regex in that connector's ValidateConfig
so operator input is constrained to the specific shape that
legitimately needs shell.
The empty-by-default ALLOWLIST is the load-bearing default.
Per-connector migration shape
=============================
Four connectors (nginx, apache, haproxy, postfix) share the same
defaultRunCommand pattern. Before:
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
return exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
}
After:
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
argv, err := validation.SplitShellCommand(command)
if err != nil {
return nil, fmt.Errorf("invalid reload/validate command: %w", err)
}
return exec.CommandContext(ctx, argv[0], argv[1:]...).CombinedOutput()
}
The test-seam contract `runReload(ctx context.Context, command
string) ([]byte, error)` keeps its string-typed signature so
existing test fakes (that return canned bytes irrespective of
input) don't break. Only the production default implementation
changed.
javakeystore is different — its exec goes through an injected
executor.Execute(ctx, name string, args ...string), which is
already variadic and never needed a shell wrapper. The migration
unpacks argv directly:
argv, err := validation.SplitShellCommand(c.config.ReloadCommand)
if err != nil { /* log + skip */ }
output, runErr := c.executor.Execute(ctx, argv[0], argv[1:]...)
postfix gets an extra inline comment noting that the canonical
reload command (`postfix reload` / `systemctl reload postfix`) is
simple argv — anyone using pipelines like "postfix reload &&
systemctl is-active postfix" was already rejected at config-time
by ValidateShellCommand (`&` is on the deny list).
Tests
=====
internal/validation/command_test.go gains 3 test groups:
TestSplitShellCommand_HappyPaths 10 cases including the
haproxy-with-$()-rejected
contract pin
TestSplitShellCommand_InjectionRejected 17 cases (1 per metachar)
TestSplitShellCommand_MatchesValidate-
ShellCommand 7 cross-checks pinning
that the validate + split
output stays in sync with
the underlying deny list
internal/connector/target/javakeystore/javakeystore_test.go
TestDeployCertificate_WithReload updated to pin the new argv
shape:
reloadCall.Name == "systemctl"
reloadCall.Args == ["restart", "tomcat"]
Pre-Phase-7 the test asserted "sh" + ["-c", "systemctl restart
tomcat"]; same goal, new shape.
internal/connector/target/apache/apache_test.go +
internal/connector/target/haproxy/haproxy_test.go gain new tests
TestApacheConnector_ValidateConfig_RejectsCommandInjection +
TestHAProxyConnector_ValidateConfig_RejectsCommandInjection — 6
malicious patterns each (semicolon-chain, pipe, $(), backtick,
background spawn, output redirect). Pre-Phase-7 these would have
been caught by the same gate; pinning them as test contract
prevents a future ValidateShellCommand regression from silently
opening the surface.
CI guard
========
scripts/ci-guards/no-sh-c-in-connectors.sh greps for any future
`(exec\.Command(Context)?|\.Execute)\([^)]*"sh"[[:space:]]*,[[:space:]]*"-c"`
under internal/connector/target/*.go (excluding _test.go and
comment lines). Auto-picked-up by the existing
.github/workflows/ci.yml regression-guards loop.
ALLOWLIST is empty post-Phase-7. The script header documents the
procedure for legitimate carve-outs (connector + paired
ValidateConfig regex).
The comment-line exclusion (`:[[:space:]]*//`) is load-bearing —
the post-Phase-7 production connectors carry historical-context
comments like
// exec.CommandContext(ctx, "sh", "-c", command) — the legacy
// shape pre-Phase-7 ...
explaining the migration. Those comments would otherwise
false-positive the guard.
Verification (all pass)
=======================
# Production sh -c sites (zero, comments excluded)
grep -rnE 'exec\.Command(Context)?\([^,]+,\s*"sh"\s*,\s*"-c"' \
internal/connector/target/ --include='*.go' --exclude='*_test.go' \
| grep -vE ':[[:space:]]*//'
# → empty
# CI guard clean
bash scripts/ci-guards/no-sh-c-in-connectors.sh
# → "no-sh-c-in-connectors: clean — 0 sh -c sites in production connector code"
# All target connector packages green (not just the 5 modified)
go test ./internal/connector/target/... -count=1
# → 18/18 packages ok
# Validation package green
go test ./internal/validation/... -count=1
# → ok
# gofmt clean
gofmt -l internal/validation/ internal/connector/target/ scripts/
# → empty
# go vet clean
go vet ./internal/validation/... ./internal/connector/target/...
# → empty
Files changed (10):
internal/validation/command.go (+37 -0)
internal/validation/command_test.go (+109 -0)
internal/connector/target/nginx/nginx.go (+22 -2)
internal/connector/target/apache/apache.go (+11 -1)
internal/connector/target/haproxy/haproxy.go (+11 -1)
internal/connector/target/postfix/postfix.go (+18 -1)
internal/connector/target/javakeystore/javakeystore.go (+18 -2)
internal/connector/target/javakeystore/javakeystore_test.go (+11 -2)
internal/connector/target/apache/apache_test.go (+42 -0)
internal/connector/target/haproxy/haproxy_test.go (+41 -0)
scripts/ci-guards/no-sh-c-in-connectors.sh (new, 93 lines)
Closes: cowork/certctl-architecture-diligence-audit.html#fix-SEC-H2
420 lines
14 KiB
Go
420 lines
14 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
// Package haproxy implements the HAProxy target connector.
|
|
//
|
|
// HAProxy expects all TLS material concatenated in a single PEM
|
|
// file (cert + chain + key in that order). Phase 6 of the
|
|
// deploy-hardening I master bundle adds atomic-deploy + post-deploy
|
|
// TLS verify + rollback + ValidateOnly to the connector following
|
|
// the canonical NGINX template.
|
|
//
|
|
// HAProxy quirks:
|
|
//
|
|
// - Single combined-PEM file (vs NGINX/Apache's separate
|
|
// cert/chain/key files).
|
|
// - Validate command is `haproxy -c -f <config>` (NOT a separate
|
|
// subcommand).
|
|
// - Reload via `systemctl reload haproxy` is preferred over
|
|
// `restart` because reload uses socket activation to drain
|
|
// in-flight connections gracefully (the old worker hands off
|
|
// to the new worker via the master socket).
|
|
// - Combined PEM file mode default 0600 (contains private key).
|
|
package haproxy
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/target"
|
|
"github.com/certctl-io/certctl/internal/deploy"
|
|
"github.com/certctl-io/certctl/internal/tlsprobe"
|
|
"github.com/certctl-io/certctl/internal/validation"
|
|
)
|
|
|
|
// Config — Phase 6 (deploy-hardening I) added per-target file
|
|
// ownership + mode overrides + post-deploy verify + backup
|
|
// retention.
|
|
type Config struct {
|
|
PEMPath string `json:"pem_path"`
|
|
ReloadCommand string `json:"reload_command"`
|
|
ValidateCommand string `json:"validate_command,omitempty"`
|
|
|
|
PEMFileMode os.FileMode `json:"pem_file_mode,omitempty"`
|
|
PEMFileOwner string `json:"pem_file_owner,omitempty"`
|
|
PEMFileGroup string `json:"pem_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
|
|
|
|
runValidate func(ctx context.Context, command string) ([]byte, error)
|
|
runReload func(ctx context.Context, command string) ([]byte, error)
|
|
probe func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult
|
|
}
|
|
|
|
func New(config *Config, logger *slog.Logger) *Connector {
|
|
c := &Connector{config: config, logger: logger}
|
|
c.runValidate = defaultRunCommand
|
|
c.runReload = defaultRunCommand
|
|
c.probe = tlsprobe.ProbeTLS
|
|
return c
|
|
}
|
|
|
|
// Phase 7 SEC-H2 closure (2026-05-14): argv-form exec instead of
|
|
// `sh -c`. See nginx connector's defaultRunCommand for the
|
|
// rationale + threat model. ValidateShellCommand at config-time +
|
|
// SplitShellCommand at exec-time provide defense in depth; the argv
|
|
// exec is what actually eliminates the injection vector.
|
|
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
|
|
argv, err := validation.SplitShellCommand(command)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid reload/validate command: %w", err)
|
|
}
|
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
func (c *Connector) SetTestRunValidate(fn func(ctx context.Context, command string) ([]byte, error)) {
|
|
c.runValidate = fn
|
|
}
|
|
func (c *Connector) SetTestRunReload(fn func(ctx context.Context, command string) ([]byte, error)) {
|
|
c.runReload = fn
|
|
}
|
|
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 HAProxy config: %w", err)
|
|
}
|
|
if cfg.PEMPath == "" {
|
|
return fmt.Errorf("HAProxy pem_path is required")
|
|
}
|
|
if cfg.ReloadCommand == "" {
|
|
return fmt.Errorf("HAProxy reload_command is required")
|
|
}
|
|
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
|
return fmt.Errorf("invalid reload_command: %w", err)
|
|
}
|
|
if cfg.ValidateCommand != "" {
|
|
if err := validation.ValidateShellCommand(cfg.ValidateCommand); err != nil {
|
|
return fmt.Errorf("invalid validate_command: %w", err)
|
|
}
|
|
}
|
|
c.logger.Info("validating HAProxy configuration", "pem_path", cfg.PEMPath)
|
|
c.config = &cfg
|
|
c.logger.Info("HAProxy configuration validated")
|
|
return nil
|
|
}
|
|
|
|
// DeployCertificate Phase 6 atomic + verify + rollback. Combined
|
|
// PEM file (cert + chain + key) written via deploy.Apply.
|
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
|
c.logger.Info("deploying certificate to HAProxy", "pem_path", c.config.PEMPath)
|
|
startTime := time.Now()
|
|
|
|
combinedPEM := buildCombinedPEM(request)
|
|
plan := c.buildPlan([]byte(combinedPEM))
|
|
if c.config.ValidateCommand != "" {
|
|
plan.PreCommit = func(pcCtx context.Context, _ map[string]string) error {
|
|
out, err := c.runValidate(pcCtx, c.config.ValidateCommand)
|
|
if err != nil {
|
|
return fmt.Errorf("haproxy -c -f failed: %w (output: %s)", err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
plan.PostCommit = func(pcCtx context.Context) error {
|
|
out, err := c.runReload(pcCtx, c.config.ReloadCommand)
|
|
if err != nil {
|
|
return fmt.Errorf("haproxy reload failed: %w (output: %s)", err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
res, err := deploy.Apply(ctx, plan)
|
|
if err != nil {
|
|
return c.failureResult(c.config.PEMPath, "deploy.Apply", err, startTime), err
|
|
}
|
|
|
|
if !res.SkippedAsIdempotent {
|
|
// Use the cert (first PEM block) for fingerprint match,
|
|
// not the full combined PEM. The wire serves leaf cert.
|
|
if vErr := c.runPostDeployVerify(ctx, request.CertPEM); vErr != nil {
|
|
c.logger.Error("post-deploy TLS verify failed; rolling back", "error", vErr)
|
|
rbErr := c.rollbackToBackups(ctx, res.BackupPaths)
|
|
if rbErr != nil {
|
|
return c.failureResult(c.config.PEMPath, "verify+rollback both failed",
|
|
fmt.Errorf("verify: %w; rollback: %v", vErr, rbErr), startTime), rbErr
|
|
}
|
|
return c.failureResult(c.config.PEMPath, "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 HAProxy successfully",
|
|
"duration", dur.String(), "pem_path", c.config.PEMPath, "idempotent", res.SkippedAsIdempotent)
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: c.config.PEMPath,
|
|
DeploymentID: fmt.Sprintf("haproxy-%d", time.Now().Unix()),
|
|
Message: "Certificate deployed and HAProxy reloaded successfully" + idemNote,
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"pem_path": c.config.PEMPath,
|
|
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
|
|
"idempotent": fmt.Sprintf("%t", res.SkippedAsIdempotent),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ValidateOnly real impl — Phase 6 replaces the stub.
|
|
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
|
if c.config == nil || c.config.ValidateCommand == "" {
|
|
return target.ErrValidateOnlyNotSupported
|
|
}
|
|
out, err := c.runValidate(ctx, c.config.ValidateCommand)
|
|
if err != nil {
|
|
return fmt.Errorf("haproxy -c -f (ValidateOnly): %w (output: %s)", err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildCombinedPEM concatenates cert + chain + key in the order
|
|
// HAProxy requires.
|
|
func buildCombinedPEM(request target.DeploymentRequest) string {
|
|
var b strings.Builder
|
|
b.WriteString(request.CertPEM)
|
|
b.WriteString("\n")
|
|
if request.ChainPEM != "" {
|
|
b.WriteString(request.ChainPEM)
|
|
b.WriteString("\n")
|
|
}
|
|
if request.KeyPEM != "" {
|
|
b.WriteString(request.KeyPEM)
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (c *Connector) buildPlan(combined []byte) deploy.Plan {
|
|
mode := c.config.PEMFileMode
|
|
if mode == 0 {
|
|
mode = 0600 // combined file contains the private key
|
|
}
|
|
return deploy.Plan{
|
|
Files: []deploy.File{{
|
|
Path: c.config.PEMPath,
|
|
Bytes: combined,
|
|
Mode: mode,
|
|
Owner: c.config.PEMFileOwner,
|
|
Group: c.config.PEMFileGroup,
|
|
}},
|
|
Defaults: deploy.FileDefaults{
|
|
Mode: 0600,
|
|
Owner: pickFirstExistingUser("haproxy"),
|
|
Group: pickFirstExistingGroup("haproxy"),
|
|
},
|
|
BackupRetention: c.config.BackupRetention,
|
|
}
|
|
}
|
|
|
|
func (c *Connector) runPostDeployVerify(ctx context.Context, deployedCertPEM string) error {
|
|
verify := c.config.PostDeployVerify
|
|
if verify != nil && !verify.Enabled {
|
|
return nil
|
|
}
|
|
endpoint := ""
|
|
timeout := 10 * time.Second
|
|
if verify != nil {
|
|
endpoint = verify.Endpoint
|
|
if verify.Timeout > 0 {
|
|
timeout = verify.Timeout
|
|
}
|
|
}
|
|
if endpoint == "" {
|
|
c.logger.Warn("post-deploy verify enabled but no endpoint configured; skipping")
|
|
return nil
|
|
}
|
|
want, err := certPEMToFingerprint(deployedCertPEM)
|
|
if err != nil {
|
|
return fmt.Errorf("compute deployed cert 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, endpoint, timeout)
|
|
if !res.Success {
|
|
return fmt.Errorf("TLS probe failed: %s", res.Error)
|
|
}
|
|
got := strings.ToLower(res.Fingerprint)
|
|
wantLower := strings.ToLower(want)
|
|
if got != wantLower {
|
|
return fmt.Errorf("post-deploy TLS verify SHA-256 mismatch: got %s, want %s", got, wantLower)
|
|
}
|
|
c.logger.Info("post-deploy TLS verify succeeded",
|
|
"endpoint", endpoint, "fingerprint", got)
|
|
return nil
|
|
}
|
|
|
|
return tlsprobe.VerifyWithExponentialBackoff(ctx, retryCfg, probe)
|
|
}
|
|
|
|
func (c *Connector) rollbackToBackups(ctx context.Context, backupPaths map[string]string) error {
|
|
for finalPath, backupPath := range backupPaths {
|
|
if backupPath == "" {
|
|
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,
|
|
}); err != nil {
|
|
return fmt.Errorf("rollback write %s: %w", finalPath, err)
|
|
}
|
|
}
|
|
out, err := c.runReload(ctx, c.config.ReloadCommand)
|
|
if err != nil {
|
|
return fmt.Errorf("rollback reload failed: %w (output: %s)", err, string(out))
|
|
}
|
|
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 block 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 decode: %w", err)
|
|
}
|
|
h := sha256.Sum256(der)
|
|
return hex.EncodeToString(h[:]), nil
|
|
}
|
|
|
|
func pickFirstExistingUser(candidates ...string) string {
|
|
for _, name := range candidates {
|
|
if _, err := user.Lookup(name); err == nil {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
func pickFirstExistingGroup(candidates ...string) string {
|
|
for _, name := range candidates {
|
|
if _, err := user.LookupGroup(name); err == nil {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
|
c.logger.Info("validating HAProxy deployment",
|
|
"certificate_id", request.CertificateID, "serial", request.Serial)
|
|
startTime := time.Now()
|
|
if c.config.ValidateCommand != "" {
|
|
if _, err := c.runValidate(ctx, c.config.ValidateCommand); err != nil {
|
|
errMsg := fmt.Sprintf("HAProxy config validation failed: %v", err)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: c.config.PEMPath,
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
if _, err := os.Stat(c.config.PEMPath); os.IsNotExist(err) {
|
|
errMsg := fmt.Sprintf("PEM file not found: %s", c.config.PEMPath)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: c.config.PEMPath,
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
dur := time.Since(startTime)
|
|
c.logger.Info("HAProxy deployment validated successfully", "duration", dur.String())
|
|
return &target.ValidationResult{
|
|
Valid: true,
|
|
Serial: request.Serial,
|
|
TargetAddress: c.config.PEMPath,
|
|
Message: "HAProxy PEM file present and config valid",
|
|
ValidatedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"validate_command": c.config.ValidateCommand,
|
|
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|