Skip to content

feat(san-cert): import certificates from a Traefik acme.json - #91

Merged
mhenrixon merged 3 commits into
dashfrom
feature/traefik-cert-import
Aug 9, 2026
Merged

feat(san-cert): import certificates from a Traefik acme.json#91
mhenrixon merged 3 commits into
dashfrom
feature/traefik-cert-import

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • New offline CLI: kamal-proxy import certs --traefik-acme /path/acme.json [--resolver name] [--data-dir dir] (internal/cmd/import.go) — runs against the data dir before first boot, no RPC socket needed
  • server.ImportTraefikCertificates (internal/server/traefik_import.go) parses Traefik's acme.json (per-resolver Certificates[] with base64-wrapped PEM), writes certs into the SAN store layout (certs/<id>/cert.pem|key.pem) and merges domain→cert mappings + expiry into acme.state atomically (tmp+rename)
  • Extracted writeManagerStateFile from SANCertManager.writeState so the running manager and the offline import share one atomic state writer
  • Imported certs are ordinary managed certs afterwards: loadState() picks them up on boot and the renewal manager rotates them under whichever challenge the proxy is configured for; SAN groupings from the source are preserved until natural renewal regroups them
  • The ACME account is not imported — a fresh registration on first boot is cheap
  • Summary printed (imported / skipped-expired / skipped-duplicate / failed-to-parse); exit non-zero only on I/O or wholly unparseable input, never on individual skips

Closes #87

Test plan

  • TestImportTraefikCertificates_* (10 tests): happy path incl. SAN grouping preservation and atomicity, state loads into a fresh SANCertManager, expired/not-yet-valid skips, longer-lived incumbent kept vs shorter-lived replaced (on disk and in state), resolver selection incl. unknown-resolver error, cross-resolver last-writer-wins with warning, wildcard→DNS-01 warning, per-entry parse failures counted not fatal, missing/unparseable file errors, corrupt existing acme.state refused rather than clobbered
  • TestImportCertsCommand_* (4 tests): --traefik-acme required, --data-dir wiring, summary output, error propagation
  • make test, go vet, gofmt -l clean, go test -race ./internal/server/ clean, golangci-lint 0 issues

Deviations & judgment calls

  • Deviation (Phase 0): this clone has no upstream remote (only origin), so the /lfg upstream sync step was skipped; branched feature/traefik-cert-import straight off a clean, current dash.
  • Judgment call: expiry threshold is exactly what the issue says — skip only expired or not-yet-valid certs. The in-tree legacy importer (san_cert_import.go) skips anything within 24h of expiry, but for a cutover even a nearly-expired cert serves handshakes while the renewer re-orders it, which is the point of this feature.
  • Judgment call: JSON []byte auto-base64 would fail the whole file on one bad entry; certificate/key are parsed as strings and base64-decoded per entry so a single corrupt entry counts as failed-to-parse instead of aborting the import.
  • Judgment call: resolver iteration is sorted by name (Go maps are unordered) so "last-writer-wins" is deterministic: the lexicographically last resolver wins a contested domain, with a warning naming both resolvers.
  • Judgment call: the imported domain set is the leaf certificate's DNSNames (same trust decision as importLegacyHTTP01Cache), not the acme.json domain.main/domain.sans metadata — the leaf is what will actually serve.
  • Judgment call: asymmetric duplicate handling, per the issue's wording — vs pre-existing acme.state the longer-lived cert wins; within one import run the last writer wins (cross-resolver case).
  • Judgment call: a corrupt pre-existing acme.state aborts the import (exit non-zero) rather than being clobbered.

Summary by cubic

Adds an offline CLI to import certificates from a Traefik acme.json into the SAN store so cutovers serve TLS immediately. Imported certs become managed; state and cert files are written atomically, and the data dir is created if missing.

  • New Features

    • Command: kamal-proxy import certs --traefik-acme /path/acme.json [--resolver name] [--data-dir dir] (runs offline, no RPC).
    • Parses resolver blocks and base64-wrapped PEM; writes to certs/<id>/cert.pem|key.pem and merges domain→cert + expiry into acme.state.
    • Resolver handling: imports all by default in sorted order; last-writer-wins per domain with a warning; limit with --resolver.
    • Safety: skips expired/not-yet-valid; keeps longer-lived existing mappings; wildcard imports warn that renewal needs DNS-01; per-entry parse failures counted, not fatal; ACME account is not imported.
    • Hardening: creates missing --data-dir; writes cert.pem/key.pem via tmp+rename; refuses an existing acme.state that parses as JSON but isn’t valid manager state.
    • Refactor: shared atomic state writer extracted as writeManagerStateFile.
  • Migration

    • Run kamal-proxy import certs --traefik-acme /path/to/acme.json against the target --data-dir before first boot.
    • Use --resolver to import a single resolver; otherwise all resolvers import (last-writer-wins per domain).
    • Ensure a DNS-01 provider is configured if importing wildcards.

Written for commit 1bec1f0. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added an import certs command for importing certificates from Traefik acme.json files.
    • Supports selecting a specific resolver and configuring certificate data locations.
    • Imports valid certificates while skipping expired, invalid, or incomplete entries.
    • Preserves existing certificates when they have longer validity and reports import results, skips, failures, and warnings.
  • Bug Fixes
    • Added validation and clear errors for missing files, invalid input, unreadable state, and unknown resolvers.

## Summary
Offline `kamal-proxy import certs --traefik-acme /path/acme.json` seeds the
SAN certificate store from a Traefik estate before first boot, so a cutover
serves TLS immediately instead of hard-failing handshakes while re-issuing
every certificate under the issuance rate limit. Imported certs land in the
manager's own layout (certs/<id>/{cert,key}.pem + merged acme.state, atomic
tmp+rename) and become ordinary managed certificates the renewer rotates.

Semantics: skip expired/not-yet-valid entries; a domain already mapped in an
existing state file keeps its cert when it outlives the imported one; without
--resolver all resolver blocks import in sorted order, last writer wins per
domain with a warning; wildcard imports warn that renewal needs DNS-01;
per-entry parse failures are counted, never fatal. The ACME account is not
imported.

Closes #87

## Test Coverage
- TestImportTraefikCertificates_*: happy path, state loads into the
  SANCertManager, expired/not-yet-valid skips, longer-lived incumbent kept,
  shorter-lived incumbent replaced on disk and in state, resolver selection,
  cross-resolver last-writer-wins, wildcard warning, per-entry parse
  failures, missing/unparseable input, corrupt existing state not clobbered
- TestImportCertsCommand_*: required flag, data-dir wiring, summary output,
  error exits

## Verification
- [x] gofmt -l internal/ cmd/ clean
- [x] make test passes
- [x] go vet ./... clean
- [x] go test -race ./internal/server/ clean
- [x] make lint (golangci-lint) 0 issues
@cubic-dev-ai

cubic-dev-ai Bot commented Aug 9, 2026

Copy link
Copy Markdown

We've triggered an ultrareview automatically — This import introduces a new offline path that writes certificates and merges domain mappings into acme.state, so a subtle parsing, expiry, or atomicity bug could corrupt the SAN store or break TLS handshakes across a fleet cutover.. I'll post findings when complete.

An ultrareview is cubic's deepest review, catching hard-to-find bugs in the most critical PRs. It runs a longer, multi-pass analysis using cubic's most capable review models, and typically takes around 30 minutes. It consumes your team's reviewed-lines allowance at 3× the standard rate.

Automated ultrareviews are disabled by default. We triggered this run as part of your trial. Want cubic to do this for every high-risk PR? Enable auto-ultrareview in your settings.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an offline import certs command for Traefik acme.json files. The importer validates certificates, merges resolver data, writes SAN certificates and state, reports skips and warnings, and supports resolver and data-directory options.

Changes

Traefik certificate import

Layer / File(s) Summary
Import workflow and certificate persistence
internal/server/traefik_import.go, internal/server/san_cert_manager.go
The importer parses resolver data, validates certificate entries, filters validity and duplicates, merges domain claims, writes certificate files, and atomically persists managed state.
Import behavior validation
internal/server/traefik_import_test.go
Tests cover valid imports, state loading, validity filtering, duplicate replacement, resolver selection, ordering, wildcard warnings, malformed entries, and fatal input errors.
CLI command integration
internal/cmd/import.go, internal/cmd/root.go, internal/cmd/import_test.go
The root command registers import certs. The command accepts Traefik ACME, resolver, and data-directory options, then prints summaries and warnings. Command tests cover required arguments, directory creation, and import errors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant ImportCerts
  participant ImportTraefikCertificates
  participant TraefikAcmeJSON
  participant SANCertificateStore
  participant AcmeState
  Operator->>ImportCerts: provide ACME path and options
  ImportCerts->>ImportTraefikCertificates: invoke import
  ImportTraefikCertificates->>TraefikAcmeJSON: read resolver certificates
  ImportTraefikCertificates->>SANCertificateStore: write valid certificates
  ImportTraefikCertificates->>AcmeState: persist domain mappings
  ImportTraefikCertificates-->>ImportCerts: return counts and warnings
  ImportCerts-->>Operator: print import summary
Loading

Possibly related issues

  • #90: The reusable certificate-store state writing and import certs workflow align with the issue’s certificate import and restore objectives.

Poem

I hop through certificates, tidy and bright,
Resolver by resolver, I set them right.
PEM files nest safely, state settles down,
Wildcards raise warnings throughout the town.
A carrot for imports—what a fine little crown! 🐇

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation covers issue #87, including offline import, resolver handling, state updates, filtering, warnings, summaries, and error handling.
Out of Scope Changes check ✅ Passed All changes support the requested Traefik certificate import feature, including command wiring, persistence reuse, implementation, and focused tests.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: importing certificates from a Traefik acme.json file.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
internal/server/traefik_import.go (1)

101-124: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Persist imp.state instead of relying on shared map aliasing.

imp receives a copy of state. The import works only because the two copies share the same Certificates and DomainMap maps. If a later change reassigns either map inside traefikImport, the write at Line 122 silently drops those entries. Hold a pointer, or write imp.state.

♻️ Proposed change
-	state.SavedAt = time.Now()
-	if err := writeManagerStateFile(opts.StatePath, state); err != nil {
+	imp.state.SavedAt = time.Now()
+	if err := writeManagerStateFile(opts.StatePath, imp.state); err != nil {
 		return summary, fmt.Errorf("failed to write certificate state: %w", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/traefik_import.go` around lines 101 - 124, Update the
traefikImport initialization and final persistence flow to retain and write the
mutated import state directly rather than the original state copy. Ensure
imp.state is a pointer or otherwise use imp.state when setting SavedAt and
calling writeManagerStateFile, preserving all changes even if Certificates or
DomainMap are reassigned.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cmd/import_test.go`:
- Line 20: Update the test helper containing runImportCerts to save the existing
globalConfig value before assigning server.Config{} and register t.Cleanup to
restore it afterward, ensuring package-level configuration does not leak into
later tests.

In `@internal/server/traefik_import.go`:
- Around line 158-183: Update importEntry in internal/server/traefik_import.go
(lines 158-183) to compute certID and ManagedCert.Domains from winners rather
than the full domains slice, keeping the stored certificate consistent with
DomainMap ownership. Add a test case in internal/server/traefik_import_test.go
(lines 170-244) where a two-domain entry overlaps an existing longer-lived
certificate on one domain, asserting the resulting Domains and DomainMap contain
only the domains actually won.
- Around line 250-261: Update writeCertificateFiles to write cert.pem and
key.pem to temporary files in the certificate directory, then rename them into
place using the established writeManagerStateFile pattern. Ensure both files are
fully written before replacing the existing pair, and clean up temporary files
on failure so an existing certificate/key pair is not left mismatched.

---

Nitpick comments:
In `@internal/server/traefik_import.go`:
- Around line 101-124: Update the traefikImport initialization and final
persistence flow to retain and write the mutated import state directly rather
than the original state copy. Ensure imp.state is a pointer or otherwise use
imp.state when setting SavedAt and calling writeManagerStateFile, preserving all
changes even if Certificates or DomainMap are reassigned.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6008f067-ec35-4e40-94bd-52e9fd1e0592

📥 Commits

Reviewing files that changed from the base of the PR and between 581162a and 85fbd22.

📒 Files selected for processing (6)
  • internal/cmd/import.go
  • internal/cmd/import_test.go
  • internal/cmd/root.go
  • internal/server/san_cert_manager.go
  • internal/server/traefik_import.go
  • internal/server/traefik_import_test.go

Comment thread internal/cmd/import_test.go
Comment thread internal/server/traefik_import.go
Comment thread internal/server/traefik_import.go

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultrareview completed in 26m 33s

All reported issues were addressed across 6 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread internal/server/traefik_import.go
Comment thread internal/server/traefik_import.go Outdated
Comment thread internal/cmd/import.go
Comment thread internal/server/traefik_import.go
Comment thread internal/server/traefik_import_test.go
Review fixes for #91:

- Create a missing --data-dir before writing state, as `run` does — an
  import with zero qualifying certificates still writes acme.state, and
  previously failed on a fresh directory
- Write cert.pem/key.pem via tmp+rename so replacing a shorter-lived
  certificate can never leave a torn file behind
- Refuse a state file that parses as JSON but is not manager state
  (null / missing maps), extending the no-clobber safeguard
- Restore globalConfig after the import command test helper runs
- Extract adoptExistingCert test helper; add coverage for the
  partial-overlap case (two-domain entry vs one-domain incumbent) and
  for schema-invalid state
- Document why nearly-expired (<24h) certificates still import

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 4 files (changes from recent commits).

Confidence score: 2/5

  • In internal/server/traefik_import.go, import validation still accepts null certificate entries and dangling domain mappings, which can be persisted and later panic SANCertManager.loadState; this is the highest regression risk because it can take certificate loading down at runtime — reject structurally invalid state before writing and validate all referenced certificate/version records.
  • In internal/server/traefik_import.go, certificate replacement is non-atomic (cert.pem can be renamed before key.pem), so an interrupted run can leave a mapped cert without a key and cause TLS handshake failures — switch to an atomic pair update/rollback strategy so both files move together or not at all.
  • In internal/server/traefik_import.go (writeCertificateFiles), the comment says loadState fails on mismatched pairs, but SANCertManager.loadState() behavior does not match that claim; this can hide real failure modes during future fixes — align the comment with actual loader behavior (or adjust loader behavior to match).
  • In internal/cmd/import.go (importCertsCommand.run), data-directory bootstrap logic is duplicated from internal/cmd/run.go, increasing drift risk between import and serve paths — extract shared initialization into one helper to keep behavior consistent.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/cmd/import.go">

<violation number="1" location="internal/cmd/import.go:62">
P2: Custom agent: **Enforce Strict Maintainability Standards**

The `importCertsCommand.run` function duplicates the data-directory bootstrap logic already present in the serve `run` command (`internal/cmd/run.go:110-112`). The comment explicitly acknowledges this as a mirror of `run`. Instead of repeating `os.MkdirAll` with identical error wrapping in both commands, extract a single shared helper in `internal/cmd` (or add a method to `globalConfig`) so the bootstrap layer owns the concept in one place.</violation>
</file>

<file name="internal/server/traefik_import.go">

<violation number="1" location="internal/server/traefik_import.go:254">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

The `writeCertificateFiles` comment claims that `loadState` "simply fails to load the mismatched pair," but `SANCertManager.loadState()` does not fail on a torn cert/key pair. It silently ignores `tls.LoadX509KeyPair` errors via `if err == nil { cert.Certificate = &tlsCert }`, leaving `cert.Certificate` as `nil` while still loading the domain mappings and metadata into memory. This misrepresents a safety property and could mislead maintainers into believing torn pairs are rejected at boot when they are actually silently accepted with nil certificates.</violation>

<violation number="2" location="internal/server/traefik_import.go:278">
P2: Replacing an existing certificate can destroy the usable pair if execution stops after the `cert.pem` rename but before the `key.pem` rename. The loader then keeps a mapped certificate with no key pair, causing TLS failure and reissuance; a recoverable pair-level staging/swap or rollback would retain the incumbent certificate.</violation>

<violation number="3" location="internal/server/traefik_import.go:308">
P1: Structurally corrupt state with null certificate records or dangling domain mappings is still accepted and rewritten, and a null record can later panic `SANCertManager.loadState`. Extending validation to certificate values and every `DomainMap` reference would preserve the promised refusal behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread internal/server/traefik_import.go
Comment thread internal/cmd/import.go Outdated
Comment thread internal/server/traefik_import.go Outdated
Comment thread internal/server/traefik_import.go Outdated
@mhenrixon mhenrixon self-assigned this Aug 9, 2026
…re rename

Second review round for #91:

- Refuse a state file with null certificate records or dangling
  DomainMap references — a healthy manager never persists either
  (removeCertificate unmaps in the same critical section), and a null
  record can panic loadState when its pair files exist
- Stage both cert.pem.tmp and key.pem.tmp before renaming either, so a
  failed write can no longer leave a replaced pair mismatched; the
  remaining crash-between-renames window degrades to a re-order
- Correct the writeCertificateFiles comment: loadState leaves a torn
  pair unloaded (nil Certificate), it does not fail
- Extract ensureDataDir shared by `run` and `import certs`
@mhenrixon
mhenrixon enabled auto-merge (squash) August 9, 2026 17:16
@mhenrixon
mhenrixon disabled auto-merge August 9, 2026 17:32
@mhenrixon
mhenrixon merged commit 80b2d01 into dash Aug 9, 2026
2 checks passed
@mhenrixon
mhenrixon deleted the feature/traefik-cert-import branch August 9, 2026 17:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Import certificates from a Traefik acme.json

1 participant