Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,26 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
material is in date starts having asked nothing of `OpenBao` or of the
CA, and its certificates and keys survive the restart byte-identically.
An already-expired leaf is repaired at start, before the endpoint's TLS
material loads, rather than at the first renewal tick.
material loads, rather than at the first renewal tick. Both leaves are
then kept valid on the daemon's own loop, under the cadence, lead time
and retry settings of the bootroot-internal profile: a leaf is replaced
when it falls inside lead time or stops chaining to `[trust]
ca_bundle_path`, and the replacement is issued off to one side and
fully validated — its key, its name, and for the endpoint leaf an
anchor the endpoint pin file already names — before anything live is
written. A publication that fails restores every file it reached, and
the endpoint keeps serving what it was serving. A successful one
exchanges the whole active TLS configuration at once, so the next
handshake presents the renewed leaf and accepts callers under the
renewed trust anchors with no restart, no signal, no socket rebind and
no dropped connection. The endpoint pin file is never rewritten and
never gains a leaf fingerprint. A caller reloads per dial: it rereads
the pair every time and, because the two files are published by
separate renames, retries a momentarily mismatched pair up to five
reads before failing rather than presenting it. A host that enables the
endpoint but cannot arm that loop — no usable internal agent
configuration, or a leaf on disk that no longer parses — now fails to
start and says so, instead of serving certificates nothing would renew.
- `bootroot-agent` now rotates `OpenBao`'s file audit device on a host
whose registrar endpoint is enabled, so the deployment no longer needs
an external rotator against it. Every 60 seconds the daemon renames the
Expand Down Expand Up @@ -365,6 +384,20 @@ byte for byte.

### Fixed

- Two of the daemon's own loops writing the CA bundle at the same moment
can no longer lose one of them. Up to three profiles issue
concurrently by default, and each publication reads
`[trust] ca_bundle_path`, merges its issued chain into it and writes
the result back; the fast-poll loop replaces the same file wholesale
when a trust update arrives. Nothing serialised them, so a merge
computed from bytes another writer had already replaced overwrote that
writer's anchor, leaving the host serving and validating against a
bundle missing a CA it had been told to trust, with nothing recording
that it went missing. Every in-process writer now holds one lock per
bundle path across its whole read-merge-write span. Hosts are
otherwise unaffected: the file's contents, mode and owner are what
they were, and a writer waits only for another writer of the same
bundle.
- Prometheus can now scrape step-ca. The bundled monitoring stack has
always declared a `step-ca` scrape target, but step-ca serves metrics
only when its `ca.json` carries a top-level `metricsAddress`, nothing
Expand Down
8 changes: 8 additions & 0 deletions docs/en/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ root that consumers need to terminate the chain — overwriting with the
chain alone would strip the root and break default-config TLS clients
(`Node tls.connect`, `openssl verify` without `-partial_chain`).

The bundle is shared by everything in the daemon that writes it — every
profile's renewal, the fast-poll trust apply, and the registrar surface's
own renewal — so each of them holds it exclusively for the whole
read-merge-write, and one writer's anchors can never be overwritten by
another's merge of bytes it had already replaced. A writer waits only for
another writer of the same bundle; readers are never blocked, because
every write is published by an atomic rename.

## CSR (Certificate Signing Request)

A **CSR** is generated by the party that wants to obtain a certificate from the
Expand Down
73 changes: 73 additions & 0 deletions docs/en/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -2009,6 +2009,79 @@ None of the four certificate paths is reloadable. A `SIGHUP` that changes
that changed, and the running daemon is left as it is. Only the
*contents* at those paths can change under a running daemon.

#### Renewal, and the reload contract on both sides

Both leaves expire, and the daemon renews both itself. Where the endpoint is
enabled it runs one renewal pass immediately at start and then one per interval,
under the cadence, lead time and issuance-retry settings of the rendered
internal agent configuration's single profile — the same values that govern the
bootroot-internal credential's own renewal. A leaf is renewed when it falls
inside lead time, or when it no longer chains to `trust.ca_bundle_path`, which
is what a trust-anchor rotation under a running daemon leaves behind. Where the
endpoint is disabled nothing of this exists: no pass runs and nothing is asked
of OpenBao or the CA.

Arming that loop is part of starting. An enabled endpoint whose renewal cannot
be prepared — a rendered internal agent configuration that is missing or no
longer loads, a deployment state file that cannot be resolved, a leaf
certificate on disk that no longer parses — fails the daemon start with a named
error instead of serving on two certificates nothing would renew.

A renewal is published as a transaction. The replacement is issued, validated
and turned into a complete next TLS configuration **before** any live file is
written: its key must be the leaf's, a client replacement must carry the same
instance, host and domain it already had, and a server replacement must carry
the exact endpoint SAN and chain to an anchor the endpoint pin file already
names. The pin file is never rewritten and never gains a leaf fingerprint, so a
server replacement no pinned caller would accept is discarded rather than
published. Only then are the merged CA bundle, the certificate and the key
written; if any of those writes fails, every path the publication reached is
restored from a snapshot of its bytes, mode and ownership, the endpoint keeps
serving what it was serving, and the next pass tries again. A rollback that
itself fails is logged as exactly that — both errors, and no claim that the
files were put back.

**The server side reloads with no restart.** Once every write has landed, the
daemon exchanges the whole active TLS configuration at once: the certificate the
endpoint presents *and* the verifier every client certificate is checked
against. Replacing only the presented certificate would leave the old verifier
deciding who may connect, so a trust-anchor rotation would not take effect until
a restart. The next handshake uses the new configuration; the socket is not
rebound, its pathname and inode are unchanged, no signal is sent and no
connection or handshake already in flight is dropped.

**The caller side reloads per dial, and must ride out the torn pair.** The
certificate and the key are published by two separate renames, so neither file
is ever half-written but the *pair* is not replaced atomically: a caller reading
in between sees the new certificate beside the old key, or the old certificate
beside the new one. Presenting that pair fails the handshake with a signature
error that says nothing about what happened, so a caller must not present it.
The contract this repository's own client implements, and the behaviour the
deployed registrar is expected to match, is:

- **Re-read both files on every dial.** No caching, and no configuration reload
or process restart to pick up a renewal — the dial after the publication
presents the new pair.
- **Check that the key is the leaf's** before presenting anything.
- **On a mismatch, re-read.** Five reads in total, with waits of 1 ms, 2 ms,
4 ms and 8 ms after the first four mismatches. A pair that matches on any of
the five is used.
- **After the fifth mismatch, fail with a typed error** naming both paths and
the number of reads. That is a misconfiguration — two files that are not each
other's — rather than a race, and it is reported instead of being retried
further or presented anyway.

This is a fixed local policy of the reader's and is deliberately not the
daemon's issuance-retry backoff: it is measured against one pair of renames on
the same host, not against a CA that is down. Any other failure — an absent
file, an unreadable one, a certificate PEM the parser refuses — is returned at
once and is not re-read.

The reference implementation is this repository's in-repo registrar endpoint
client (`src/registrar/endpoint/client.rs`). It has no production consumer here;
it exists so the caller behaviour this endpoint expects is written down as
running code.

#### Installing the units

Both units are checked in under `systemd/` in this repository. Copy them
Expand Down
8 changes: 8 additions & 0 deletions docs/ko/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ bootroot-agent는 PEM 블록을 순서대로 파싱해 **첫 블록은 리프**
`-partial_chain` 없는 `openssl verify`)가 체인을 끝까지 검증할 수 있습니다.
체인만으로 덮어쓰면 루트가 사라져 검증이 실패합니다.

이 번들은 데몬 안에서 이를 기록하는 모든 주체가 공유합니다. 각 프로파일의
갱신, fast-poll 신뢰 갱신 적용, 레지스트라 표면 자체의 갱신이 모두
같은 파일을 씁니다. 그래서 각 주체는 읽기-병합-쓰기 전 구간 동안 번들을
배타적으로 점유하며, 이미 다른 주체가 교체한 바이트를 기준으로 계산된
병합이 그 주체의 신뢰 앵커를 덮어쓰는 일은 발생하지 않습니다. 대기는 같은
번들을 기록하는 다른 주체에 대해서만 발생하고, 모든 쓰기는 원자적
이름 바꾸기로 게시되므로 읽는 쪽은 대기하지 않습니다.

## CSR (Certificate Signing Request)

CSR은 CA로부터 인증서를 발급받으려는 주체가 생성하는 요청서입니다.
Expand Down
69 changes: 69 additions & 0 deletions docs/ko/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,75 @@ step-ca로 나가는 ACME 경로를 통해서입니다. 프로비저닝 도구
남습니다. 실행 중인 데몬 아래에서 바뀔 수 있는 것은 그 경로들의 *내용*
뿐입니다.

#### 갱신, 그리고 양쪽의 리로드 계약 {#renewal-and-the-reload-contract-on-both-sides}

두 리프 모두 만료되며, 데몬이 두 리프를 직접 갱신합니다. 엔드포인트가 활성화된
호스트에서는 기동 직후 한 번 갱신 패스를 돌린 뒤 주기마다 한 번씩 돌리고, 그
주기·리드 타임·발급 재시도 설정은 렌더링된 내부 에이전트 설정의 단 하나뿐인
프로필에서 가져옵니다. bootroot 내부 자격 증명 자신의 갱신을 지배하는 바로 그
값들입니다. 리프는 리드 타임 안에 들어왔을 때, 또는 `trust.ca_bundle_path`까지
체인이 더 이상 이어지지 않을 때 갱신됩니다. 후자는 실행 중인 데몬 아래에서 신뢰
앵커 로테이션이 일어났을 때 남는 상태입니다. 엔드포인트가 비활성화된 호스트에는
이 중 아무것도 존재하지 않습니다. 패스는 돌지 않고, OpenBao와 CA에 아무것도
요청하지 않습니다.

이 루프를 준비하는 일은 기동의 일부입니다. 엔드포인트가 활성화되어 있는데 갱신을
준비할 수 없는 호스트는 — 렌더링된 내부 에이전트 설정이 없거나 더 이상 읽히지
않을 때, 배포 상태 파일을 해석할 수 없을 때, 디스크의 리프 인증서가 더 이상
파싱되지 않을 때 — 아무것도 갱신하지 않을 인증서 두 장으로 서비스를 시작하는
대신, 이름이 붙은 오류와 함께 데몬 기동을 실패시킵니다.

갱신은 하나의 트랜잭션으로 발행됩니다. 대체 자재는 **살아 있는 파일을 하나라도
쓰기 전에** 발급되고 검증되어 완전한 다음 TLS 설정으로 조립됩니다. 키는 그
리프의 키여야 하고, 클라이언트 대체본은 원래 갖고 있던 인스턴스·호스트·도메인을
그대로 유지해야 하며, 서버 대체본은 정확한 엔드포인트 SAN을 갖고 엔드포인트 핀
파일이 이미 지명한 앵커까지 체인이 이어져야 합니다. 핀 파일은 다시 쓰이지 않고
리프 지문이 추가되지도 않으므로, 핀을 건 호출자라면 아무도 받아들이지 않을 서버
대체본은 발행되지 않고 폐기됩니다. 그 다음에야 병합된 CA 번들, 인증서, 키가
기록됩니다. 이 중 어느 쓰기라도 실패하면, 발행이 닿은 모든 경로가
바이트·모드·소유권 스냅숏에서 복원되고, 엔드포인트는 서비스하던 것을 그대로
계속 서비스하며, 다음 패스가 다시 시도합니다. 롤백 자체가 실패하면 정확히
그렇게 기록됩니다. 두 오류가 함께 남고, 파일이 되돌려졌다는 주장은 하지
않습니다.

**서버 쪽은 재시작 없이 리로드합니다.** 모든 쓰기가 끝난 뒤 데몬은 활성 TLS
설정 전체를 한 번에 교체합니다. 엔드포인트가 제시하는 인증서 *와* 모든
클라이언트 인증서를 검사하는 검증기를 함께 바꿉니다. 제시하는 인증서만 바꾸면
예전 검증기가 계속 누가 접속할 수 있는지를 결정하므로, 신뢰 앵커 로테이션이
재시작 전까지 반영되지 않습니다. 다음 핸드셰이크부터 새 설정이 쓰이고, 소켓은
다시 바인딩되지 않으며, 경로명과 아이노드도 그대로이고, 시그널도 보내지 않으며,
이미 진행 중인 연결이나 핸드셰이크는 하나도 끊기지 않습니다.

**호출자 쪽은 다이얼마다 리로드하며, 찢어진 쌍을 견뎌야 합니다.** 인증서와 키는
별개의 rename 두 번으로 발행되므로, 두 파일 어느 쪽도 절반만 쓰인 상태로
보이지는 않지만 *쌍* 자체는 원자적으로 교체되지 않습니다. 그 사이에 읽은
호출자는 새 인증서 옆의 옛 키, 또는 옛 인증서 옆의 새 키를 보게 됩니다. 그 쌍을
제시하면 무슨 일이 있었는지 아무것도 말해 주지 않는 서명 오류로 핸드셰이크가
실패하므로, 호출자는 그것을 제시해서는 안 됩니다. 이 저장소의 클라이언트가
구현하고 있고 배포된 레지스트라가 따라야 하는 계약은 다음과 같습니다.

- **다이얼마다 두 파일을 다시 읽습니다.** 캐시하지 않고, 갱신을 반영하기 위한
설정 리로드나 프로세스 재시작도 필요 없습니다. 발행 직후의 다이얼이 새 쌍을
제시합니다.
- 무엇이든 제시하기 전에 **키가 그 리프의 키인지 확인합니다.**
- **맞지 않으면 다시 읽습니다.** 총 다섯 번 읽고, 처음 네 번의 불일치 뒤에 각각
1 ms·2 ms·4 ms·8 ms를 기다립니다. 다섯 번 중 어느 한 번이라도 맞는 쌍을
찾으면 그것을 씁니다.
- **다섯 번째 불일치 뒤에는 타입이 있는 오류로 실패합니다.** 두 경로와 읽은
횟수를 밝힙니다. 이는 경합이 아니라 서로의 짝이 아닌 두 파일, 즉 잘못된
설정이므로, 더 재시도하거나 그대로 제시하지 않고 보고합니다.

이는 읽는 쪽의 고정된 로컬 정책이며, 데몬의 발급 재시도 백오프와는 의도적으로
다릅니다. 같은 호스트에서 일어나는 rename 한 쌍을 기준으로 잡은 값이지, CA가
죽어 있는 상황을 기준으로 잡은 값이 아닙니다. 파일이 없거나 읽을 수 없거나
인증서 PEM을 파서가 거부하는 등 그 밖의 실패는 즉시 반환되고 다시 읽지
않습니다.

참조 구현은 이 저장소의 인레포 레지스트라 엔드포인트
클라이언트(`src/registrar/endpoint/client.rs`)입니다. 여기에는 프로덕션
소비자가 없습니다. 이 엔드포인트가 기대하는 호출자 동작을 실행되는 코드로 적어
두기 위해 존재합니다.

#### 유닛 설치

두 유닛 모두 이 저장소의 `systemd/` 아래에 포함되어 있습니다.
Expand Down
9 changes: 8 additions & 1 deletion src/acme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ pub(crate) mod types;
// surface issuance, which is inside this crate; only the ordinary
// `issue_certificate` is reached from the binary crates.
pub(crate) use flow::{
CsrShape, IssuanceOptions, LeafPublication, issue_certificate_with_bootstrap,
CsrShape, IssuanceOptions, IssuedMaterial, LeafPublication, issue_certificate_material,
issue_certificate_with_bootstrap,
};
pub use flow::{build_registrar_client_csr_params, issue_certificate};
// The two publication helpers a renewal stages its merged CA bundle
// with, so the staged bytes are computed by the same code the ordinary
// publication merges with rather than by a second rule. Their only
// consumer is the renewal adapter, which exists on Linux alone.
#[cfg(target_os = "linux")]
pub(crate) use flow::{merge_ca_bundle, verify_chain_fingerprints};
Loading
Loading