Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
b81a83b
feat(store,ca): phase 0 of spec 0017 — schema rewrite for multiple CA…
nofuturekid Aug 7, 2026
ded9322
feat(tests,spec): phase 1a of spec 0017 — test-driven contract for mu…
nofuturekid Aug 7, 2026
464706d
feat(ca): phase 1b of spec 0017 — implement multiple CA hierarchies
nofuturekid Aug 7, 2026
003a8e0
fix(test,ca): catch renew_in_place mutation — verify key reuse and se…
nofuturekid Aug 7, 2026
e863acd
spec(0022): native HTTPS support architecture and requirements
nofuturekid Aug 7, 2026
3980796
spec(0018): per-issuer permissions architecture and requirements
nofuturekid Aug 7, 2026
ddd42cf
fix(store): enforce SQLite foreign keys, fix ACME order insert ordering
nofuturekid Aug 7, 2026
1371728
spec(0018): fix ruff format on embedded Python code fence
nofuturekid Aug 7, 2026
9182244
docs(adr): record the TLS environment-variable deviation, close two s…
nofuturekid Aug 7, 2026
ce551ed
feat(server): phase 0 of spec 0022 -- runtime skeleton and live test …
nofuturekid Aug 7, 2026
c58c8f9
test(0022): phase 1 -- the contract for native HTTPS, written before …
nofuturekid Aug 7, 2026
157d3a3
feat(tls): phase 2 of spec 0022 -- native HTTPS, implemented against …
nofuturekid Aug 7, 2026
0a32137
fix(tls): implement FR-17's retire guard, and make the shutdown test …
nofuturekid Aug 8, 2026
eb91281
spec(0018): correct three things the world changed underneath it
nofuturekid Aug 8, 2026
18d0e5d
feat(store): phase 0 of spec 0018 -- the grant tables
nofuturekid Aug 8, 2026
670a13d
test(0018): the contract for per-issuer permissions, written before t…
nofuturekid Aug 8, 2026
7e34362
feat(grants): phase 1 of spec 0018 -- per-issuer permissions, enforce…
nofuturekid Aug 8, 2026
a93feb4
spec(0019): ACME per issuer, and what it does and does not close
nofuturekid Aug 8, 2026
59c1a18
feat(acme): spec 0019 -- an ACME account belongs to one issuer
nofuturekid Aug 8, 2026
9291d09
test(0019): pin the ACME helper's signature, which was the only thing…
nofuturekid Aug 8, 2026
224cbb4
spec(0020): name constraints, enforced by cabin rather than hoped for
nofuturekid Aug 8, 2026
250f58c
test(0020): the contract for name constraints, written before the code
nofuturekid Aug 8, 2026
15abdd6
feat(ca): spec 0020 -- name constraints, enforced before signing
nofuturekid Aug 8, 2026
c56f250
spec(0021): cross-signing, for the devices that cannot be reached
nofuturekid Aug 8, 2026
cac20ca
feat(ca): spec 0021 -- cross-signing, and the expired path that must …
nofuturekid Aug 8, 2026
f7932da
test(cross-signing): pin load_cross's own public-key comparison
nofuturekid Aug 8, 2026
0fd5584
chore(release): close the changelog for 0.2.0
nofuturekid Aug 8, 2026
16acd82
test: stop asserting on the prose that openssl prints
nofuturekid Aug 8, 2026
ded1b44
docs(readme): describe the cabin that 0.2.0 actually ships
nofuturekid Aug 8, 2026
8b7a96b
spec(0023): split the CA page, which four specifications had grown in…
nofuturekid Aug 8, 2026
4d60f65
test(0023): the contract for the split CA pages, written before the code
nofuturekid Aug 8, 2026
825fa54
feat(web): spec 0023 -- split the CA page into four
nofuturekid Aug 8, 2026
21d980e
spec(0024): stop appending to names, split root from intermediate, fi…
nofuturekid Aug 8, 2026
dc6a6ea
test(0024): the contract for verbatim names and separated creation
nofuturekid Aug 8, 2026
265c79a
feat(ca): spec 0024 -- verbatim names, root and intermediate as two s…
nofuturekid Aug 8, 2026
a623eca
spec(0025): one place for everything that moves material in or out
nofuturekid Aug 8, 2026
b15fe61
test(0025): the contract for the transfer area, written before the code
nofuturekid Aug 8, 2026
971bf99
feat(web): spec 0025 -- one place for everything that moves material …
nofuturekid Aug 8, 2026
a8515fb
fix(web): give the hierarchy row controls their own spacing
nofuturekid Aug 8, 2026
57d024e
test(layout): make the overflow probe reach the selects it claims to …
nofuturekid Aug 8, 2026
15ff343
fix(web): report a missing issuer per hierarchy, not per instance
nofuturekid Aug 8, 2026
93963c0
test: guard three behaviours the suite could not notice being broken
nofuturekid Aug 9, 2026
4a03c9c
docs(changelog): record specs 0023 to 0025
nofuturekid Aug 9, 2026
c8a1921
docs(spec): 0026 -- the hierarchy page becomes a list, its rows get p…
nofuturekid Aug 9, 2026
ebb8845
feat(web): spec 0026 -- the hierarchy page lists its rows, the rows g…
nofuturekid Aug 9, 2026
36e79d4
docs(spec): fill in the index between 0014 and 0026
nofuturekid Aug 9, 2026
c455922
fix(ca): refuse to renew a retired CA row
nofuturekid Aug 9, 2026
09b4142
docs(spec): 0027 -- the shell, the tokens and the vocabulary
nofuturekid Aug 9, 2026
433b570
docs(spec): correct five defects in 0027 found by writing its tests
nofuturekid Aug 9, 2026
ee56f1d
test(0027): the contract for the shell and the tokens, before the code
nofuturekid Aug 9, 2026
f8d5ae1
feat(web): spec 0027 -- the design's shell, palette and vocabulary
nofuturekid Aug 9, 2026
051b006
docs(spec): 0028 -- the detail pages take the design's arrangement
nofuturekid Aug 9, 2026
ebcd5e6
docs(spec): correct 0028's width argument, which measurement disproved
nofuturekid Aug 9, 2026
457c6f1
test(0028): the contract for the detail pages, before the code
nofuturekid Aug 9, 2026
4cc5885
feat(web): spec 0028 -- the detail pages take the design's arrangement
nofuturekid Aug 9, 2026
b1f5631
docs(spec): 0029 -- the form pages and the first interactivity
nofuturekid Aug 9, 2026
7446282
docs(spec): correct four unsatisfiable criteria in 0029, and record t…
nofuturekid Aug 9, 2026
45ea2c0
feat(web): spec 0029 -- the form pages and the first htmx in this pro…
nofuturekid Aug 9, 2026
964a208
docs(spec): 0030 -- the remaining pages, the flash and the 403 page
nofuturekid Aug 9, 2026
59fac9a
docs(spec): correct fourteen defects in 0030, two of them its own rule
nofuturekid Aug 10, 2026
6b184bb
feat(web): spec 0030 -- the remaining pages, the flash and the refusa…
nofuturekid Aug 10, 2026
5603280
fix(tests): stop asking the browser which colour scheme it prefers
nofuturekid Aug 10, 2026
c43c69a
docs(design): record every deliberate departure from the design
nofuturekid Aug 10, 2026
5e86cf4
chore(release): close the changelog for 0.2.0
nofuturekid Aug 10, 2026
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
559 changes: 555 additions & 4 deletions CHANGELOG.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ USER 65532:65532

# stdlib only -- the image has no curl and no wget, and adding one just for
# the healthcheck would grow it for nothing.
#
# Spec 0022 FR-15: $PORT speaks TLS once CABIN_TLS is on, so the probe has to
# pick its scheme from the same flag cabin itself reads, with verification
# off -- stage 1 is self-signed by definition and this is a liveness check
# against 127.0.0.1, not a trust decision.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["python", "-c", "import os,urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:%s/healthz' % os.environ.get('PORT','8080'), timeout=3).status == 200 else 1)"]
CMD ["python", "-c", "import os,ssl,urllib.request,sys; tls=os.environ.get('CABIN_TLS','').strip().lower() in ('true','1'); scheme='https' if tls else 'http'; ctx=ssl._create_unverified_context() if tls else None; sys.exit(0 if urllib.request.urlopen('%s://127.0.0.1:%s/healthz' % (scheme, os.environ.get('PORT','8080')), timeout=3, context=ctx).status == 200 else 1)"]

ENTRYPOINT ["cabin"]
117 changes: 96 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,69 @@ server, an MCP server, direct issuance, CSR signing, revocation and a CRL.
first-run wizard creates the superadmin, then walks you through creating a
root + intermediate CA (ECDSA P-256/P-384, RSA-4096 or Ed25519) or
importing an existing one. Roles: superadmin, admin, viewer.
- **Multiple CA hierarchies** — root and intermediate CAs run side by side
rather than one of each. Adding a new intermediate under an existing root
and retiring the old one is how rotation works, with nothing already
issued invalidated. Which issuer signs is explicit on every request and
defaults only when exactly one is active; with several active, leaving it
out is refused rather than guessed. A root chooses its `path_length` at
creation — 1 to 4, default 1 — and it never changes afterwards; see
Cross-signing below for why that matters.
- **Per-issuer permissions** — grants, not just roles, decide who may issue
or revoke on which CA, held by users and API tokens alike and checked
fresh on every request, so withdrawing one takes effect on the next
request rather than the next login. They do not bind ACME unless
`acme_require_eab` is on: with it off, any account still registers at any
issuer's directory and obtains a certificate regardless of grants, so
"cabin has per-issuer permissions" is only true for ACME once that
setting is on.
- **Name constraints** — an intermediate can be restricted, at creation, to
the DNS names and IP ranges it may sign. cabin checks its own
constraints before every issuance, ACME included, rather than trusting a
client to respect them. Roots take none, and renewal carries an
intermediate's constraints over unchanged.
- **Cross-signing** — a root can sign another root, or import a
cross-certificate produced elsewhere, so devices that still trust the old
root have a path to certificates issued under the new one. It only works
if the signing root was created with a `path_length` of at least 2 —
cabin's default is 1, renewal carries that forward unchanged, and there
is no way to repair it afterwards, so this has to be planned a root
generation ahead. For anything else, running two hierarchies side by
side (above) is the simpler transition.
- **Certificates** — issue `server`/`client` leaves with a server-generated
key or by signing a pasted CSR, browse and search the inventory, download
the leaf, the chain, the private key or a password-protected **PKCS#12**
bundle.
- **REST API** — `/api/v1`, bearer-token authentication, OpenAPI docs at
[`/api/v1/docs`](http://localhost:8080/api/v1/docs).
- **ACME v2 server** (RFC 8555, own implementation) — directory at
`/acme/directory`, all three challenge types (`http-01`, `dns-01`,
`tls-alpn-01`) and External Account Binding. Verified against certbot and
acme.sh, not just unit tests.
- **ACME v2 server** (RFC 8555, own implementation) — one directory per CA,
at `/acme/ca/<issuer id>/directory`, all three challenge types (`http-01`,
`dns-01`, `tls-alpn-01`) and External Account Binding. An account key
registers at one issuer's directory and stays bound to that issuer for
life, so obtaining certificates from two hierarchies needs two account
keys. With EAB required, an account can only register with a key issued
for that CA, so an identity holding no grant on it obtains no certificate
over ACME; with EAB not required, anyone who can reach the port may
register and order from any issuer's directory. Verified against certbot
and acme.sh, not just unit tests. Two things to know before pointing a
client at it: `http-01` always dials port 80 on the resolved address
(RFC 8555 8.3) — never the port cabin listens on, and not one the client
gets to pick — so a host where port 80 is taken or unreachable from cabin
just fails the challenge, with nothing in the error saying why. And
loopback identifiers (`localhost`, `127.0.0.1`) are refused
unconditionally as an SSRF guard. There is a setting that widens what
validation may reach (`allow_private_validation_targets`, on by default),
but it only covers RFC 1918 addresses — loopback stays blocked either
way. Test with a name that resolves elsewhere instead, e.g. wildcard DNS
to a LAN address (`*.example.test` → `192.168.1.20`).
- **MCP server** — `/mcp` (streamable HTTP), six tools so an AI assistant can
look at the CA and issue, sign or revoke certificates under an API token's
role.
- **Revocation and CRL** — reason codes, a monotonic CRL number and a public
`/crl` (DER) / `/crl.pem` that regenerates itself when stale.
look at the CAs and issue, sign or revoke certificates under an API
token's role and issuer grants.
- **Revocation and CRL** — reason codes, a monotonic CRL number per issuer,
and a public `/crl/<issuer id>` (DER) / `/crl/<issuer id>.pem` per
intermediate that regenerates itself when stale. `/ca/<id>.cer` serves
any row's own certificate, root or intermediate, so a client can complete
a chain from a leaf's AIA URL.
- **Audit log** — who did what, from which front door (UI, API, ACME, MCP).
- **Secrets encrypted at rest** — AES-256-GCM, master key in
`/data/secret.key`, optionally wrapped with a passphrase-derived KEK.
Expand Down Expand Up @@ -61,18 +109,23 @@ open the WebUI, complete the wizard.

## Configuration

Five environment variables, all optional. Everything else — CA, profiles,
Seven environment variables, all optional. Everything else — CA, profiles,
base URL, ACME, MCP, users, tokens — is configured in the UI and stored in
the database. Flags (`--port`, `--data-dir`) beat environment variables,
which beat the defaults.

| Variable | Default | What it does |
| ------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
| `PORT` | `8080` | Listen port. One port serves the UI, the API, ACME, MCP and the CRL. |
| `DATA_DIR` | `data` (`/data` in the image) | Holds `cabin.db` and `secret.key`. |
| `CABIN_DB_URL` | `sqlite:///$DATA_DIR/cabin.db` | SQLAlchemy URL. SQLite is the tested default; PostgreSQL needs a driver the image does not bundle yet. |
| `COOKIE_SECURE` | `false` | Send session cookies only over HTTPS. Turn on behind a TLS proxy. |
| `CABIN_MASTER_PASSPHRASE` | unset | Wraps the master key in `secret.key` with a scrypt-derived KEK. Set it **before** the first start. |
which beat the defaults. Two of the seven exist only for native TLS
(`CABIN_TLS`, `CABIN_HTTP_PORT`) — see
[`docs/adr/0002-tls-environment-variables.md`](docs/adr/0002-tls-environment-variables.md)
for why they had to leave the database and land here instead.

| Variable | Default | What it does |
| ------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `PORT` | `8080` | Listen port. Serves the UI, the API, ACME, MCP and the CRL — over HTTPS instead of HTTP when TLS is on. |
| `DATA_DIR` | `data` (`/data` in the image) | Holds `cabin.db`, `secret.key` and, with TLS on, `tls/`. |
| `CABIN_DB_URL` | `sqlite:///$DATA_DIR/cabin.db` | SQLAlchemy URL. SQLite is the tested default; PostgreSQL needs a driver the image does not bundle yet. |
| `COOKIE_SECURE` | `false` | Send session cookies only over HTTPS. Turn on behind a TLS proxy; forced on automatically when TLS is on. |
| `CABIN_MASTER_PASSPHRASE` | unset | Wraps the master key in `secret.key` with a scrypt-derived KEK. Set it **before** the first start. |
| `CABIN_TLS` | `false` | Terminate TLS in cabin itself instead of behind a reverse proxy. See Security notes below. |
| `CABIN_HTTP_PORT` | `8081` | Port of the plaintext listener serving only the CRL and CA-certificate routes. Used only when `CABIN_TLS` is on. |

The version cabin reports at `/healthz` and in the UI footer is the version of
the installed wheel: the release build stamps the git tag into it
Expand All @@ -86,9 +139,31 @@ the installed wheel: the release build stamps the git tag into it
- **`CABIN_MASTER_PASSPHRASE`** adds a passphrase-derived KEK around the
master key, so a stolen `secret.key` alone is not enough. It cannot be
added or changed after the first start without discarding the sealed keys.
- **Put TLS in front.** cabin speaks plain HTTP; terminate TLS in a reverse
proxy and set `COOKIE_SECURE=true`. ACME `http-01` validation is outbound,
so it works either way.
- **Terminate TLS — in a reverse proxy, or in cabin itself.** The default is
still plain HTTP behind a reverse proxy: terminate TLS there and set
`COOKIE_SECURE=true`. ACME `http-01` validation is outbound, so it works
either way.

Set `CABIN_TLS=true` and cabin terminates TLS itself, in three stages, no
restart between them: it serves a self-signed certificate the moment it
starts (expect one browser warning — the UI says so before and after it
happens); the instant a CA exists it issues and serves a certificate from
it, hot-swapped into the running listener; once you install cabin's root
certificate in your trust store, the warning is gone for good and `curl`
without `--cacert` fails, which is how you know the trust is real.
`COOKIE_SECURE` is forced on automatically the moment TLS is on — there is
no deployment where an explicit `COOKIE_SECURE=false` should win.
Certificates need a plaintext CRL/CA-certificate listener to stay
fetchable (spec 0022 FR-10): cabin binds a second port
(`CABIN_HTTP_PORT`, default `8081`) that serves only those two routes and
nothing else. If `base_url` names this host, publish that port on host
port **80** — that is where every certificate cabin issues says its CRL
and CA certificate are, and `base_url` must not name any other explicit
port while TLS is on, or those URLs would point at the TLS listener
instead and be unfetchable. cabin's own TLS private key is sealed at rest
exactly like every other key it holds — `DATA_DIR/tls/cabin.key.sealed`,
same treatment as `secret.key`, no exception carved out for it.

- **Do not expose it to the internet.** An internal CA is trusted by every
machine that installs its root; treat the admin UI accordingly.

Expand Down
30 changes: 30 additions & 0 deletions deploy/unraid/cabin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,34 @@
Display="advanced"
Required="false"
Mask="true"/>
<Config
Name="CABIN_TLS"
Target="CABIN_TLS"
Default=""
Mode=""
Description="Set to true to have cabin terminate TLS itself: it issues its own certificate (self-signed at first start, then from its own CA) and forces COOKIE_SECURE on automatically. Needs the plaintext PKI port below published too."
Type="Variable"
Display="advanced"
Required="false"
Mask="false"/>
<Config
Name="CABIN_HTTP_PORT"
Target="CABIN_HTTP_PORT"
Default="8081"
Mode=""
Description="Port cabin listens on for the plaintext CRL/CA-certificate routes. Only used when CABIN_TLS is true; must match the container-side port of the mapping below."
Type="Variable"
Display="advanced"
Required="false"
Mask="false">8081</Config>
<Config
Name="Plaintext PKI port"
Target="8081"
Default="8081"
Mode="tcp"
Description="Host port for the plaintext CRL/CA-certificate listener. Only bound when CABIN_TLS is true. If base_url names this host, publish it on host port 80 — that is where every certificate cabin issues says its CRL and CA certificate are."
Type="Port"
Display="advanced"
Required="false"
Mask="false">8081</Config>
</Container>
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ services:
restart: unless-stopped
ports:
- "8080:8080"
# Only needed with CABIN_TLS on -- see below. Spec 0022 FR-13: publish
# it on host port 80 if base_url names this host, because that is
# where every certificate cabin issues says its CRL and CA
# certificate are.
# - "80:8081"
# cabin binds a port above 1024 as a nonroot user and touches nothing but
# /data, so it needs no capabilities at all. Dropping them also defuses
# the eight setuid-root binaries (su, mount, passwd, …) that come with
Expand All @@ -39,6 +44,15 @@ services:
# CABIN_MASTER_PASSPHRASE: "change-me"
# # Database URL. Defaults to sqlite:///$DATA_DIR/cabin.db.
# CABIN_DB_URL: "postgresql+psycopg://cabin:cabin@db:5432/cabin"
# # Terminate TLS in cabin itself instead of behind a reverse proxy.
# # cabin issues its own certificate (self-signed at first start, then
# # from its own CA) and forces COOKIE_SECURE on automatically. Needs
# # the second published port above.
# CABIN_TLS: "true"
# # Port of the plaintext listener that serves only the CRL and CA
# # certificate routes -- what the second published port above maps
# # to. Only used when CABIN_TLS is on.
# CABIN_HTTP_PORT: "8081"

# Optional PostgreSQL instead of the bundled SQLite file: uncomment this
# service, uncomment CABIN_DB_URL above and add `depends_on: [db]` to cabin.
Expand Down
Loading