Declarative fleet control-plane for NixOS. Proof of concept - contributors wanted.
  • Go 82.8%
  • HTML 9.4%
  • Nix 2.6%
  • Rust 2.3%
  • Shell 1.6%
  • Other 1.2%
Find a file
Bram Buijs ad98444bb1
Some checks are pending
ci / test (push) Waiting to run
docs(audit): close H3, proved by a login on real hardware
Staff passwords crossed the cluster network in the clear on a simple bind. The
module has refused plain LDAP since db23306 and the fleet document moved to
ldaps on 8 August, but no device had taken it, so the finding stayed open on
the only thing that counts.

Measured on e2e5 before and after, on the same machine within the hour:
ldap://10.43.76.5 with reqcert never and
ldap_auth_disable_tls_never_use_in_production set to True, became
ldaps://10.43.76.5:636 with reqcert demand and the CA path, and that flag gone.

Then the two things a rendered configuration cannot promise. Resolution: id
bbuijs returned UID 10001 from the directory, which proves the TLS connection,
verification against our own CA under demand, the service bind and the search.
Authentication: a directory login over the new transport, which is a separate
bind with the user's own password. Both, because either alone would have been
a comfortable half-answer.

Promoted as one commit on its own ring, split from the core bump that was
waiting behind it, so a failure would have been attributable rather than
merely observed. The recovery path prepared beforehand was a git push, not
device access - config as data means the fix is at the source, and that is
worth knowing before you need it rather than after.

Enforcement on the directory does not follow automatically. Both live devices
are off port 389, but the fleet still carries test devices silent for 10 to 25
days and one is inside the inactivity window; ssf=128 locks those out if they
return. Recorded as a decision to take rather than a step to assume.

Code AI-assisted (Claude Opus 5); testing, review and integration by a human.
2026-08-11 14:40:36 +02:00
.forgejo fix(ci): only release from the forge that holds the registry 2026-08-10 16:09:52 +02:00
agent feat(agent): report a failed deployment instead of staying quiet 2026-08-10 17:38:21 +02:00
cmd erasure: wire it, because a service nobody calls is the bug I keep finding 2026-08-07 13:25:16 +02:00
deploy release 0.86.0: personal data stops accumulating, and can be erased on request 2026-08-07 15:07:12 +02:00
docs docs(audit): close H3, proved by a login on real hardware 2026-08-11 14:40:36 +02:00
examples/overlay overlay: write catalog.json the way its generator writes it 2026-07-30 14:47:30 +02:00
internal test(web): a missing translation is a word on the screen, not an error 2026-08-10 09:36:07 +02:00
nix catalog: label the options the newer core brought in 2026-08-06 21:55:43 +02:00
scripts fix(scripts): stop reporting created when the API refused 2026-08-10 13:45:06 +02:00
vendor feat(directory): LDAP group browse behind a Directory port 2026-07-10 17:39:36 +02:00
.dockerignore audit batch 1: token authz hardening, loud rollbacks, CI gates 2026-07-16 10:08:03 +02:00
.env.example Scaffold: hexagonal skeleton, platform layer, quality gates 2026-07-09 18:09:40 +02:00
.gitattributes Stop tracking Rust build artefacts; fix forge language stats 2026-07-13 14:48:14 +02:00
.gitignore ci: undo the TMPDIR move - wrong hypothesis, and it broke a test 2026-08-03 22:56:56 +02:00
.golangci.yml ci: exclude catalog.go from misspell (Dutch translations trip the English dict) 2026-07-15 03:10:48 +02:00
.govulncheck-exceptions ci: vulnerability gate on the pinned toolchain, with audited exceptions 2026-07-16 21:06:53 +02:00
CODE_OF_CONDUCT.md docs: the files a stranger looks for before they trust a project 2026-08-04 01:52:06 +02:00
CONTRIBUTING.md docs(adr): run Codeberg as a public project, forgejo as the workshop 2026-08-10 12:33:32 +02:00
docker-compose.yml Scaffold: hexagonal skeleton, platform layer, quality gates 2026-07-09 18:09:40 +02:00
Dockerfile build: the control plane image stops shipping a fleet simulator 2026-07-30 01:43:33 +02:00
Dockerfile.docs docs: name the handbook image after the handbook 2026-08-04 13:50:43 +02:00
Dockerfile.gate-runner gate: pin nix-eval-jobs to the fleet's own nixpkgs, and skip instantiation 2026-08-03 15:51:15 +02:00
flake.lock ci: vulnerability gate on the pinned toolchain, with audited exceptions 2026-07-16 21:06:53 +02:00
flake.nix feat(supply-chain): an SBOM and a CVE report for what the fleet runs 2026-08-08 14:30:39 +02:00
fleetsim rollout: expedited runs - short soak, full evidence 2026-07-18 00:19:39 +02:00
go.mod harden: complete pre-publication audit (feedback loop, atomicity, coverage) 2026-07-13 13:46:48 +02:00
go.sum harden: complete pre-publication audit (feedback loop, atomicity, coverage) 2026-07-13 13:46:48 +02:00
justfile tests: raise the floor to 80, starting with the claims we made in writing 2026-08-07 17:07:46 +02:00
licence_test.go supply chain: check the licences instead of having been lucky 2026-08-07 14:55:48 +02:00
LICENSE chore: add EUPL-1.2 licence 2026-07-10 17:34:40 +02:00
README.md docs: move the public front door to Codeberg, retire GitHub 2026-08-10 09:17:31 +02:00
SECURITY.md docs: the files a stranger looks for before they trust a project 2026-08-04 01:52:06 +02:00
THIRD-PARTY-NOTICES.md chore: drop unused htmx bundle + dead metrics accessor 2026-07-12 10:56:39 +02:00

Sextant

Manage a fleet of NixOS workstations the way you manage code.

license: EUPL-1.2 status: beta go: 1.25 docs: docs.sextantfleet.com

Documentation · Quickstart · Decision records · Contributing · Security


Every device's configuration is data in git. Nix builds it, a gate proves it compiles before anyone can merge it, and the fleet rolls forward in rings you control. The console shows you what each machine actually runs, not what you hoped it would.

The fleet overview: devices, compliance and live check-ins

Quickstart

Two commands and a browser. No database, no cluster, no account.

git clone https://codeberg.org/DAWO/DAWO-Sextant.git && cd DAWO-Sextant
just demo          # builds, seeds an example fleet, serves on :8080

Then open http://127.0.0.1:8080. You get a working console on the example fleet in examples/overlay: enroll a device, change a setting, watch the change become a git commit.

No just?
go build -o sextant ./cmd/sextant

# the config plane is a git working tree, so give the demo one
cp -r examples/overlay /tmp/sextant-demo
git -C /tmp/sextant-demo init -q -b main
git -C /tmp/sextant-demo add -A
git -C /tmp/sextant-demo -c user.name=demo -c user.email=demo@localhost commit -qm "example fleet"

./sextant --repo /tmp/sextant-demo --dev-auth --gate none --allow-unvalidated --write

--dev-auth mints a synthetic owner session and only works on loopback; --gate none skips Nix validation, which is why it makes you say --allow-unvalidated out loud. Neither belongs anywhere near a real fleet.

Why this exists

Public organisations are told to modernise their workplace and to stay in control of their own infrastructure, and the tools on offer make you pick one. The mature fleet managers are excellent and they are somebody else's cloud: your device inventory, your policies and your compliance evidence live where you cannot see them and cannot leave.

Sextant is the other option. It is a control plane you run yourself, over NixOS, where the fleet's configuration is a document in your own git repository. You can read it, diff it, review it, and hand it to an auditor. No agent phones a vendor. Devices pull their configuration and the console never pushes to them, so there is no remote command channel to abuse - not by us, not by anyone who gets in.

That is the conviction: a fleet you can explain is a fleet you control.

How a change reaches a device

flowchart LR
    A["Operator<br/>edits a setting"] --> B["Nix gate<br/>does it build?"]
    B -->|rejected| A
    B -->|proved| C["git commit<br/>in your repo"]
    C --> D["Ring 1<br/>soak + health"]
    D --> E["Ring 2"]
    E --> F["Rest of fleet"]
    D -.->|"device pulls"| G["Device converges<br/>nixos-rebuild"]
    E -.-> G
    F -.-> G

Nothing is pushed. A ring's branch moves only after the change builds and the previous ring stayed healthy through its soak, and each device picks up its own ring's revision on its own schedule.

What it does

Configuration - settings that resolve org → group → device, with locks
  • Settings resolve along organisation → group → device, with locks so a higher scope can hold a value a lower one may not weaken.
  • Policies are the layer above: a name and a reason an auditor can read, enforcement, and drift that gets re-checked rather than written once.
  • Policies also carry conditions about a device's observed state - free disk space, how long since it checked in. Those cannot be enforced, only checked, and the console says so instead of pretending the fleet will converge them away. A device that reports no measurement is never accused of failing one.
  • Every option your overlay publishes appears in the console by itself. There is no second list to keep in step.
Change and rollout - a gate that must pass, then rings
  • Submit a change, a gate builds it, and nobody merges what does not compile. Four-eyes approval when you want it.
  • Rollouts run in rings: soak times, health thresholds, device caps, pins, optional auto-flow.
  • A wave that stops making progress becomes an action item naming the devices holding it up, instead of waiting silently forever.
  • High-risk changes ask for an explicit extra confirmation.
Devices - imaging, intents rather than remote control, secrets
  • Imaging from a provisioning station, installing the revision that device's ring is pinned to - not whatever main happens to be that afternoon.
  • Remote intents, never remote control: lock a session, collect diagnostics, crypto-wipe a lost machine. A wipe needs the device armed, and reports back when it refuses or does not finish.
  • Secrets with agenix, where a newly imaged device's host key is registered as a recipient automatically - otherwise the classic silent failure.
  • Disk-encryption recovery keys escrowed, every reveal in the audit log.
Fleet health - one board of things that need a person
  • One board of action items: never checked in, offline, errored, running an unrecognised configuration, failing a policy condition.
  • A configuration that lags is a warning. A system that lags becomes a real issue once it persists. Reporting them identically teaches people to ignore both.
  • Devices read as matching or not matching. Revision hashes are there for the operator who asks, not for everyone who looks.
Integrations - mesh, directory, endpoint security, as ordinary settings
  • NetBird mesh, directory login over LDAP/LDAPS with SSSD, Wazuh endpoint security, OpenBao, and any SMTP server for notifications.
  • Endpoint controls: USB device control with an allowlist, printing, and per-capability user rights - so somebody can join a WiFi network or approve a dock without anyone handing out an administrator password.
Evidence - the auditor's cross-reference, and scoped access
  • Audit log of who changed what and when, an evidence export, CSV exports, and per-policy BIO/ISO control annotations: the auditor's cross-reference from a framework to the thing that actually enforces it.
  • Access is scoped, so an operator responsible for a few groups sees those groups.

The device inventory, with status, baseline and hardware per device

Running it for real

The quickstart above is a demo on your laptop: no database, no cluster, and validation switched off. A real instance needs four things, and it is worth knowing that before you invest an afternoon:

An overlay repository A git repo that consumes a NixOS core flake and holds your fleet.json. One per organisation. This is the same repo the devices follow, so it is the product's actual source of truth - not a copy of one.
Postgres The observed plane: check-ins, tokens, image jobs, preferences, notifications. A single instance beside the console is enough.
An OIDC identity provider Console login, mapped to roles by directory group. LDAP optionally supplies the group picker.
A validation gate The nix evaluation that proves a change builds before it can be committed. In production this runs out-of-process in a small gate-runner, fail-closed, because the console image deliberately ships no nix.

Deployment is one Helm release plus a secret (deploy/helm), or the NixOS module, or a plain container. The devices need DAWO-NixOS or your own core flake, and they pull with comin - the console never connects to a device.

The full walk-through, including the values that matter and the ones that bite, is Install and configure Sextant.

Platforms. The flake builds for x86_64-linux and aarch64-linux, but the released container images are single-architecture: they are built on an x86_64 runner with no multi-arch manifest, so on arm you build from the flake. Managed devices are NixOS. Nothing here targets macOS or Windows, now or planned - the configuration model is Nix, and that is the point rather than a gap to fill in later.

Who this is for

Written for public bodies running managed NixOS workstations, and useful to anyone who has ever wondered what a laptop in the field is really running. If you have a handful of machines, plain NixOS and a git repo already serve you well - Sextant starts paying off when a person has to answer for what the fleet is doing.

Status: Beta

Feature-complete for its first production use and being prepared for one. The fleet this is developed against runs it - imaging, rollouts in rings, directory login, endpoint security and disk-encryption escrow, on real hardware.

Beta means the shape is settled and the remaining work is proving it rather than designing it. Expect the APIs and the fleet document schema to stay put. Expect rough edges where the first fleet has not pushed yet, and expect us to say which those are rather than pretend otherwise.

Help wanted: developers, testers and maintainers. To collaborate, or just to ask whether this fits what you are doing, contact Bram Buijs at b.buijs@bb-open.com.

Contributing

We would genuinely like the company. This is a small project doing something ambitious, and the useful work is not all deep in the domain model.

Good places to start

  • Hardware profiles. Every laptop model needs a disk layout and imaging notes. If you have a machine we do not, that is a self-contained contribution with an obvious test: image it.
  • Translations. The console ships English and Dutch. Adding a language is one map in internal/http/web/catalog.go.
  • Integrations. They are ordinary fleet settings: a NixOS module that publishes options, with no console change needed. The how-to is Build your own integration.
  • Run it against your own fleet and tell us what broke. Honestly the most valuable thing anyone can do. The rough edges we know about are named in the status section above; the ones we do not are the point.
  • Documentation. If a page assumed knowledge you did not have, that is a bug and we would like the report.

How we work. Small commits that explain why rather than what. Tests that assert a behaviour somebody could plausibly get wrong, not coverage for its own sake. Decisions that shape the product go in an ADR, and we would rather argue about a design in writing than discover the disagreement in code review.

See CONTRIBUTING.md for the mechanics, CODE_OF_CONDUCT.md for how we talk to each other, and SECURITY.md if what you found should not be a public issue.

The ADRs in docs/adr/ are worth reading even if you never run this: they are where the arguments are, including the ones we lost.

Where this repository lives, and where it is built

codeberg.org/DAWO/DAWO-Sextant Where to read it, clone it, and take part today. Issues and pull requests here.
code.overheid.nl/MinBZK/DAWO-Sextant Canonical, and where it is published as Dutch government open source. No public accounts yet.
forgejo.bb-open.com BB Open's own Forgejo. Where CI runs today.

Every push goes to code.overheid.nl and to BB Open's Forgejo at the same time, and Forgejo mirrors on to Codeberg.

Codeberg is the public front door because it is a European non-profit forge rather than a company's platform, which is the same reasoning that put the canonical copy on code.overheid.nl. There was a GitHub mirror; it is being retired rather than kept as a third address nobody maintains.

A mirror can lag, and it does not announce it. Codeberg is pushed by Forgejo on its own schedule, so for a while after a change it serves the previous commit while claiming nothing. If you are about to depend on what you are reading - a line number, a version, a security fix - compare against code.overheid.nl, which is canonical by definition rather than by timing. Measured on 2026-08-08 Codeberg was two commits behind, and it had caught up without intervention by the 10th - so a mirror that looks stale is usually a schedule rather than a fault. Wait, or check canonical; do not conclude the push failed.

CI runs on BB Open's internal Forgejo, on a self-hosted runner, and that is worth saying plainly rather than leaving a contributor to wonder why a pull request shows no checks. Neither Codeberg nor the canonical forge runs our workflows yet, so for now the green tick lives somewhere you cannot see. If you open a pull request, we run it and report back; .forgejo/workflows/ci.yml is in this repository, so you can also read exactly what it does and run the same checks locally with just ci. Moving the pipeline to Codeberg is the next step and is not done.

The end state is that all of it happens on code.overheid.nl - the code, the issues, the pull requests and the pipeline. Everything above is scaffolding until the canonical repository is open and can build. We would rather describe that honestly than present a temporary arrangement as the design.

Architecture

Hexagonal: pure domain, use-case services, ports, adapters, thin transport.

internal/domain    pure model + scope/policy resolution (no I/O)
internal/app       use-case services
internal/ports     interfaces the app depends on
internal/adapters  git, nix, postgres, ldap, oidc, integrations
internal/http      SSR web (html/template, form-POST) and /api/v1 JSON
internal/platform  config, logging, metrics, server lifecycle

Server-rendered HTML and form posts. No framework, no build step for the front end, and the console works without JavaScript. The design is in docs/architecture.md; how it holds up at fleet scale, with the measurements, is in docs/architecture/scale.md.

License

EUPL 1.2, and that is settled - see LICENSE. BB Open is the steward, not the owner: the licence is what makes this yours to run, fork and keep running if we disappear.

What the EUPL requires is worth being precise about, because people assume either more or less than it says. It is copyleft on DISTRIBUTION: ship a modified Sextant to somebody and they get the source under the same terms. Running it as a service is not distribution, so an organisation operating its own console owes nobody anything. That is deliberate. A control plane you cannot run privately is not sovereign.

So the whole product is here. There is no crippled edition, no feature held back, and nothing in this repository stops at a paywall. If you want to run a fleet on it yourself, everything you need is in this repository and you never have to talk to us.

One honest gap: some vendor components come under licences that forbid us redistributing them. DisplayLink docks are the clearest case - the fleet supports them, this repository cannot carry them. That is the vendor's restriction rather than ours, and where it applies we say so instead of quietly leaving a hole.

If you are weighing this up for a public body, the question worth asking is what happens if the supplier goes away. Here the answer is that you keep the code, the licence, the data in your own git repository, and a fleet that keeps converging without us.