From ddc0a9996bc87552b1c6c40ca908a0aecc1f1e18 Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Thu, 23 Apr 2026 16:01:48 +0000 Subject: [PATCH] docs: prepare for delta.rocks Some of the docs were generated by Claude Opus, but they still should be correct. Wireguard example and explanation is based on mesh example written by me: --- --- a/README.adoc +++ b/README.adoc @@ -125,12 +125,12 @@ }; services.gitlab = let secrets = config.secrets; in { enable = true; - initialRootPasswordFile = secrets.gitlab-initial-root.secretPath; + initialRootPasswordFile = secrets.gitlab-initial-root.secret.path; secrets = { - secretFile = secrets.gitlab-secret.secretPath; - otpFile = secrets.gitlab-otp.secretPath; - dbFile = secrets.gitlab-db.secretPath; - jwsFile = secrets.gitlab-jws.secretPath; + secretFile = secrets.gitlab-secret.secret.path; + otpFile = secrets.gitlab-otp.secret.path; + dbFile = secrets.gitlab-db.secret.path; + jwsFile = secrets.gitlab-jws.secret.path; }; }; } @@ -200,7 +200,7 @@ set -eu export OPENSSL_CONF=${openssl-conf} # Yay, using secrets to generate secrets! - HSM_PIN=$(cat ${config.secrets.hsm-pin.secretPath}) + HSM_PIN=$(cat ${config.secrets.hsm-pin.secret.path}) exec ${pkgs.openssl}/bin/openssl "$@" -keyform=engine -CAkeyform=engine -engine=pkcs11 -passin=pass:"$HSM_PIN" ''; }) @@ -208,7 +208,7 @@ name = "hsmt"; text = '' set -eu - HSM_PIN=$(cat ${config.secrets.hsm-pin.secretPath}) + HSM_PIN=$(cat ${config.secrets.hsm-pin.secret.path}) exec ${pkgs.opensc}/bin/pkcs11-tool -l --pin="$HSM_PIN" "$@" ''; }) --- /dev/null +++ b/docs/examples/_section.adoc @@ -0,0 +1,2 @@ += Examples +:order: 2 --- /dev/null +++ b/docs/examples/wireguard.adoc @@ -0,0 +1,282 @@ += WireGuard Mesh Network +:order: 0 + +Full-mesh WireGuard VPN with automatic key generation, cross-host peer wiring, and hash-derived IPv6 ULA addresses. + +== Directory Structure + +---- +. +├── flake.nix +└── wireguard/ + └── default.nix +---- + +== flake.nix + +[source,nix] +---- +{...}: { + outputs = + inputs: + inputs.flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ + inputs.fleet.flakeModule + ]; + fleetConfigurations.default = { + imports = [ + (import ./wireguard { iface = "testwg"; }) + ]; + cluster.wireguard.testwg = { + hosts = [ + "bernard" + "edgeworth" + ]; + port = 51825; + }; + hosts.bernard = { + system = "x86_64-linux"; + network.externalIps = [ + "78.11.11.11" + ]; + }; + hosts.edgeworth = { + system = "aarch64-linux"; + }; + }; + }; +} +---- + +Module is imported with a parameterized interface name (`testwg`), then configured declaratively via `cluster.wireguard.testwg`. +Hosts without `network.externalIps` (like `edgeworth`) are treated as NAT/roaming — no endpoint is set for them, peers connect outbound only. + +== wireguard/default.nix + +[source,nix] +---- +{ iface }: +{ config, lib, fleetLib, ... }: +let + inherit (lib.attrsets) genAttrs optionalAttrs; + inherit (lib.strings) substring; + inherit (lib.lists) + filter + sort + unique + length + elemAt + ; + inherit (lib.options) mkOption; + inherit (lib.types) listOf str int; + inherit (lib) hashString lessThan; + + cfg = config.cluster.wireguard.${iface}; + hostNames = sort lessThan cfg.hosts; + hostHash = name: hashString "sha256" (name + iface); + hostSubnet = + name: + let + h = hostHash name; + in + "fd00:${substring 0 4 h}:${substring 4 4 h}:${substring 8 4 h}"; + hostIPv6 = name: "${hostSubnet name}::1"; +in +{ + options.cluster.wireguard.${iface} = { + hosts = mkOption { + description = "Hostnames participating in this wireguard mesh"; + type = listOf str; + }; + port = mkOption { + description = "WireGuard listen port"; + type = int; + default = 51824; + }; + }; + + config = { + assertions = [ + { + assertion = length (unique (map hostSubnet hostNames)) == length hostNames; + message = "${iface}: hostname-derived IPv6 subnet collision detected"; + } + ]; + secrets."${iface}-psk" = { + expectedOwners = hostNames; + generator = fleetLib.mkBase64Bytes { count = 32; }; + }; + + hosts = genAttrs hostNames (name: { + nixos = + { + config, + nixosHosts, + hosts, + ... + }: + let + peerNames = filter (n: n != name) hostNames; + in + { + secrets."${iface}-key" = { + generator = fleetLib.mkX25519 { encoding = "base64"; }; + }; + secrets."${iface}-psk" = { + generator = "shared"; + }; + + networking.wireguard.interfaces.${iface} = { + ips = [ "${hostIPv6 name}/64" ]; + listenPort = cfg.port; + privateKeyFile = config.secrets."${iface}-key".secret.path; + + peers = map ( + peerName: + { + publicKey = nixosHosts.${peerName}.secrets."${iface}-key".public.data; + presharedKeyFile = config.secrets."${iface}-psk".secret.path; + allowedIPs = [ "${hostSubnet peerName}::/64" ]; + } + // optionalAttrs (hosts.${peerName}.network.externalIps or [ ] != [ ]) { + endpoint = "${elemAt hosts.${peerName}.network.externalIps 0}:${toString cfg.port}"; + } + ) peerNames; + }; + + networking.firewall.allowedUDPPorts = [ cfg.port ]; + }; + }); + }; + _file = ./default.nix; +} +---- + +== How It Works + +=== Multi-Instance Module + +The outer function `{ iface }:` makes the module parameterizable — import it multiple times with different interface names to create independent WireGuard meshes with separate keys and subnets: + +[source,nix] +---- +imports = [ + (import ./wireguard { iface = "internal"; }) + (import ./wireguard { iface = "management"; }) +]; +---- + +=== IPv6 ULA Addressing + +Each host gets a unique `fd00::/64` subnet derived from `sha256(hostname + iface)`. +No manual IP assignment needed — deterministic, collision-checked via assertion. + +For example, `bernard` on interface `testwg` gets `fd00:1c86:78d4:dcef::1/64`. + +=== Secrets + +`${iface}-key`:: +Per-host X25519 keypair via `fleetLib.mkX25519 { encoding = "base64"; }`. +Private key encrypted in fleet state, public key plaintext — readable by other hosts at build time through `nixosHosts.${peerName}.secrets."${iface}-key".public.data`. + +`${iface}-psk`:: +Shared preshared key via `fleetLib.mkBase64Bytes { count = 32; }`. +Defined at fleet level with `expectedOwners`, each host claims with `generator = "shared"`. + +=== NAT Handling + +Endpoint is set only for peers that have `network.externalIps`: + +[source,nix] +---- +// optionalAttrs (hosts.${peerName}.network.externalIps or [ ] != [ ]) { + endpoint = "..."; +} +---- + +Hosts behind NAT initiate connections outward. Add `persistentKeepalive = 25;` if needed to maintain NAT mappings. + +== Deployment + +Single command generates all secrets and deploys: + +[source,shell] +---- +fleet --only bernard --only edgeworth deploy switch +---- + +No separate key generation step — fleet generates and provisions secrets automatically. + +=== Fleet State After Deploy + +The fleet state file records encryption keys, per-host keypairs (separate distributions), and shared PSK: + +[source,nix] +---- +{ + hosts = { + bernard.encryptionKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ25i6..."; + edgeworth.encryptionKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIELc1m..."; + }; + secrets = { + testwg-key = [ + { + owners = [ "bernard" ]; + createdAt = "2026-04-23T16:39:29.532714437Z"; + public.raw = "T7F6A+6jU87kz4c8GftttyU+xB0KhHIaplDV65psb3M="; + secret.raw = "<ENCRYPTED><BASE64-ENCODED>..."; + } + { + owners = [ "edgeworth" ]; + createdAt = "2026-04-23T16:39:43.337788671Z"; + public.raw = "<PLAINTEXT>QgERRQnE+blQaVNMNiQZ+uk+HEOFKVTmvg4O+MqQ0hA="; + secret.raw = "<ENCRYPTED><BASE64-ENCODED>..."; + } + ]; + testwg-psk = { + owners = [ "bernard" "edgeworth" ]; + createdAt = "2026-04-23T16:39:30.126077659Z"; + secret.raw = "<ENCRYPTED><BASE64-ENCODED>..."; + }; + }; +} +---- + +Note how `testwg-key` is a list — each host has its own distribution with a unique keypair. +`testwg-psk` is a single distribution shared by both hosts. + +=== Verification + +`wg show` on `edgeworth`: + +---- +interface: testwg + public key: QgERRQnE+blQaVNMNiQZ+uk+HEOFKVTmvg4O+MqQ0hA= + private key: (hidden) + listening port: 51825 + +peer: T7F6A+6jU87kz4c8GftttyU+xB0KhHIaplDV65psb3M= + preshared key: (hidden) + endpoint: 78.11.11.11:51825 + allowed ips: fd00:1c86:78d4:dcef::/64 + latest handshake: 1 second ago + transfer: 616 B received, 760 B sent +---- + +`wg show` on `bernard`: + +---- +interface: testwg + public key: T7F6A+6jU87kz4c8GftttyU+xB0KhHIaplDV65psb3M= + private key: (hidden) + listening port: 51825 + +peer: QgERRQnE+blQaVNMNiQZ+uk+HEOFKVTmvg4O+MqQ0hA= + preshared key: (hidden) + endpoint: 109.11.11.11:51825 + allowed ips: fd00:cac2:10cb:6ee9::/64 + latest handshake: 7 minutes, 18 seconds ago + transfer: 468 B received, 380 B sent +---- + +Public keys match between state file and `wg show`. `edgeworth` has no external IP in fleet config, yet `bernard` sees its endpoint — `edgeworth` initiated the connection outward through NAT. --- /dev/null +++ b/docs/features/_section.adoc @@ -0,0 +1,2 @@ += Features +:order: 1 --- /dev/null +++ b/docs/features/modules.adoc @@ -0,0 +1,51 @@ += Multi-host Modules +:order: 0 + +Fleet modules configure multiple hosts simultaneously. Unlike standard NixOS modules which configure a single machine, fleet modules have access to all hosts in the cluster. + +== Basic Module + +[source,nix] +---- +# wireguard/default.nix +{ config, ... }: { + hosts = builtins.mapAttrs (name: host: { + nixos.networking.wireguard.interfaces.wg0 = { + ips = [ host.config.wireguardIp ]; + peers = builtins.filter (p: p.name != name) + (builtins.attrValues config.hosts); + }; + }) config.hosts; +} +---- + +== Using Modules + +Modules are imported in the `fleetConfigurations` block: + +[source,nix] +---- +fleetConfigurations.default = { + imports = [ + ./wireguard + ./monitoring + ]; + + hosts.node-1 = { ... }; + hosts.node-2 = { ... }; +}; +---- + +== Multi-instance Modules + +Modules can be parameterized and imported multiple times: + +[source,nix] +---- +fleetConfigurations.default = { + imports = [ + (import ./kubernetes { hosts = ["cp-1" "cp-2"]; }) + (import ./kubernetes { hosts = ["cp-3" "cp-4"]; }) + ]; +}; +---- --- /dev/null +++ b/docs/features/rollback.adoc @@ -0,0 +1,29 @@ + += Automatic Rollback +:order: 2 + +Fleet automatically rolls back deployments that fail, preventing bricked machines. + +== How It Works + +When deploying a new system configuration: + +1. Fleet activates the new configuration +2. A health check verifies the system booted successfully +3. If the check fails, the previous configuration is restored + +Rollback works as long as the system passes the initrd stage. Be careful with changes to root filesystem mounts — those are harder to recover from. + +== Deployment Process + +[source,shell] +---- +# Deploy to all hosts +fleet deploy + +# Deploy to specific host +fleet deploy web-1 + +# Deploy without rollback (not recommended) +fleet deploy --no-rollback +---- --- /dev/null +++ b/docs/features/secrets.adoc @@ -0,0 +1,30 @@ += Fleet Secrets Management System +:order: 1 + +== Overview + +Secret management system is a built-in way to deploy secrets to remote hosts, similar to agenix and other systems. + +Secrets are encrypted using system's host ssh key (/etc/ssh/ssh_host_ed25519_key), which is not required to build the +remote system/add secret to fleet configuration, fleet users are encrypting secrets using received public key instead, +they don't need the root access to receive the public encryption key. + +== Example + +[source,nix] +---- +{ + secrets = { + "my-secret" = { + expectedOwners = [ "host1" "host2" ]; + regenerateOnOwnerAdded = true; + generator = {mkImpureSecretGenerator}: + mkImpureSecretGenerator { + script = '' + echo "secret content" | gh private -o $out/secret + ''; + }; + }; + } +} +---- --- /dev/null +++ b/docs/getting-started/_section.adoc @@ -0,0 +1,2 @@ += Getting Started +:order: 0 --- /dev/null +++ b/docs/getting-started/hosts.adoc @@ -0,0 +1,58 @@ + += Configuring Hosts +:order: 1 + +Each host in a fleet configuration represents a machine in your cluster. + +== Defining Hosts + +[source,nix] +---- +fleetConfigurations.default = { + hosts.web-1 = { + system = "x86_64-linux"; + nixos = { + imports = [ + ./web-1/hardware-configuration.nix + ./web-1/configuration.nix + ]; + }; + }; + + hosts.web-2 = { + system = "x86_64-linux"; + nixos = { + imports = [ + ./web-2/hardware-configuration.nix + ./web-2/configuration.nix + ]; + }; + }; +}; +---- + +== Host Systems + +Every host must specify a `system` attribute. Supported systems: + +- `x86_64-linux` +- `aarch64-linux` +- `armv7l-linux` +- `armv6l-linux` + +== Inline NixOS Configuration + +NixOS configuration can be specified inline: + +[source,nix] +---- +hosts.monitoring = { + system = "x86_64-linux"; + nixos = { + imports = [ ./monitoring/hardware-configuration.nix ]; + services.prometheus.enable = true; + services.grafana.enable = true; + networking.firewall.allowedTCPPorts = [ 3000 9090 ]; + }; +}; +---- --- /dev/null +++ b/docs/getting-started/index.adoc @@ -0,0 +1,77 @@ + += Fleet +:order: 0 + +An NixOS cluster deployment tool. + +== Advantages over existing configuration systems (NixOps/Morph) + +- Modules can configure multiple hosts at once (i.e. for wireguard/kubernetes installation) +- Secrets can be securely stored in Git (no one except target hosts can decrypt them), automatically regenerated, reencrypted, etc. +- Automatic rollback on deployment failure, which will work as long as system is passing initrd stage + +== Flake example + +[source,nix] +---- +{ + description = "My cluster configuration"; + inputs = { + nixpkgs.url = "github:nixos/nixpkgs"; + fleet = { + url = "github:CertainLach/fleet"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + flake-parts.url = "github:hercules-ci/flake-parts"; + }; + outputs = inputs: + inputs.flake-parts.lib.mkFlake {inherit inputs;} { + imports = [inputs.fleet.flakeModules.default]; + + fleetConfigurations.default = { + nixos = { + # Shared NixOS configuration for all hosts + }; + + imports = [ + ./wireguard + ]; + + hosts.controlplane-1 = { + system = "x86_64-linux"; + nixos = { + imports = [ + ./controlplane-1/hardware-configuration.nix + ./controlplane-1/configuration.nix + ]; + }; + }; + }; + }; +} +---- + +== Secret generator example + +[source,nix] +---- +{config, ...}: { + secrets = { + gitlab-initial-root = { + generator = {mkPassword}: mkPassword {}; + owner = "gitlab"; + group = "gitlab"; + }; + gitlab-secret = { + generator = {mkPassword}: mkPassword {}; + owner = "gitlab"; + group = "gitlab"; + }; + }; + services.gitlab = { + enable = true; + initialRootPasswordFile = config.secrets.gitlab-initial-root.secret.path; + secrets.secretFile = config.secrets.gitlab-secret.secret.path; + }; +} +---- --- a/docs/index.adoc +++ /dev/null @@ -1,78 +0,0 @@ - -= Fleet -:slug: index -:order: 0 - -An NixOS cluster deployment tool. - -== Advantages over existing configuration systems (NixOps/Morph) - -- Modules can configure multiple hosts at once (i.e. for wireguard/kubernetes installation) -- Secrets can be securely stored in Git (no one except target hosts can decrypt them), automatically regenerated, reencrypted, etc. -- Automatic rollback on deployment failure, which will work as long as system is passing initrd stage - -== Flake example - -[source,nix] ----- -{ - description = "My cluster configuration"; - inputs = { - nixpkgs.url = "github:nixos/nixpkgs"; - fleet = { - url = "github:CertainLach/fleet"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - flake-parts.url = "github:hercules-ci/flake-parts"; - }; - outputs = inputs: - inputs.flake-parts.lib.mkFlake {inherit inputs;} { - imports = [inputs.fleet.flakeModules.default]; - - fleetConfigurations.default = { - nixos = { - # Shared NixOS configuration for all hosts - }; - - imports = [ - ./wireguard - ]; - - hosts.controlplane-1 = { - system = "x86_64-linux"; - nixos = { - imports = [ - ./controlplane-1/hardware-configuration.nix - ./controlplane-1/configuration.nix - ]; - }; - }; - }; - }; -} ----- - -== Secret generator example - -[source,nix] ----- -{config, ...}: { - secrets = { - gitlab-initial-root = { - generator = {mkPassword}: mkPassword {}; - owner = "gitlab"; - group = "gitlab"; - }; - gitlab-secret = { - generator = {mkPassword}: mkPassword {}; - owner = "gitlab"; - group = "gitlab"; - }; - }; - services.gitlab = { - enable = true; - initialRootPasswordFile = config.secrets.gitlab-initial-root.secretPath; - secrets.secretFile = config.secrets.gitlab-secret.secretPath; - }; -} ----- --- a/docs/migration.adoc +++ /dev/null @@ -1,42 +0,0 @@ - -= Migration Guide -:slug: migration -:order: 2 - -== fleet.nix <unset> => 0.1.0 - -Add version field:: -Set it to 0.1.0; This field specifies which version of fleet do you use for cluster management, breaking changes will also break this value to make sure you read this guide. - -Move every secret part:: -Before it was only public and private, now it can be any number of parts. - -In your fleet.nix file, look at every record like this: -[source,nix] ----- -gitlab-initial-root = { - createdAt = "2024-03-01T15:54:32.983358495Z"; - public = "example"; - secret = "vp%d6wO#0#D2.../dgCA+v4Gf:YG"; -}; ----- - -And modify it as following: -[source,nix] ----- -gitlab-initial-root = { - createdAt = "2024-03-01T15:54:32.983358495Z"; - public.raw = "<PLAINTEXT>example"; - secret.raw = '' - <ENCRYPTED><Z85-ENCODED> - vp%d6wO#0#D2.../dgCA+v4Gf:YG - ''; -}; ----- - -Default encoding was also changed from `Z85` to `base64`. This conversion will be done by fleet automatically. - -Update references to secrets in fleet/nixos configurations:: -Instead of `config.secrets.secret-name.secretPath` use `config.secrets.secret-name.secret.path`, -instead of `config.secrets.secret-name.stableSecretPath` use `config.secrets.secret-name.secret.stablePath`, -instead of `config.secrets.secret-name.public` use `config.secrets.secret-name.public.data`. --- /dev/null +++ b/docs/reference/_section.adoc @@ -0,0 +1,2 @@ += Reference +:order: 2 --- /dev/null +++ b/docs/reference/migration.adoc @@ -0,0 +1,41 @@ + += Migration Guide +:order: 2 + +== fleet.nix <unset> => 0.1.0 + +Add version field:: +Set it to 0.1.0; This field specifies which version of fleet do you use for cluster management, breaking changes will also break this value to make sure you read this guide. + +Move every secret part:: +Before it was only public and private, now it can be any number of parts. + +In your fleet.nix file, look at every record like this: +[source,nix] +---- +gitlab-initial-root = { + createdAt = "2024-03-01T15:54:32.983358495Z"; + public = "example"; + secret = "vp%d6wO#0#D2.../dgCA+v4Gf:YG"; +}; +---- + +And modify it as following: +[source,nix] +---- +gitlab-initial-root = { + createdAt = "2024-03-01T15:54:32.983358495Z"; + public.raw = "<PLAINTEXT>example"; + secret.raw = '' + <ENCRYPTED><Z85-ENCODED> + vp%d6wO#0#D2.../dgCA+v4Gf:YG + ''; +}; +---- + +Default encoding was also changed from `Z85` to `base64`. This conversion will be done by fleet automatically. + +Update references to secrets in fleet/nixos configurations:: +Instead of `config.secrets.secret-name.secretPath` use `config.secrets.secret-name.secret.path`, +instead of `config.secrets.secret-name.stableSecretPath` use `config.secrets.secret-name.secret.stablePath`, +instead of `config.secrets.secret-name.public` use `config.secrets.secret-name.public.data`. --- a/docs/secrets.adoc +++ /dev/null @@ -1,37 +0,0 @@ -= Fleet Secrets Management System -:slug: secrets -:order: 1 - -== Overview - -Secret management system is a built-in way to deploy secrets to remote systems, similar to agenix and other similar systems. - -Secrets are encrypted using system's host ssh key (/etc/ssh/ssh_host_ed25519_key), which is not required to build the -remote system/add secret to fleet configuration, fleet users are encrypting secrets using received public key instead, -they don't need the root access to receive the public encryption key. - -== Example - -[source,nix] ----- -{ - fleet.secrets = { - "my-secret" = { - expectedOwners = [ "host1" "host2" ]; - regenerateOnOwnerAdded = true; - generator = {mkImpureSecretGenerator}: - mkImpureSecretGenerator { - script = '' - echo "secret content" | gh private -o $out/secret - ''; - }; - }; - } -} ----- - -== Limitations and Future Improvements - -- Pure secret generators are currently disabled -- Support for other secret management systems (e.g systemd-creds has planned asymmetric encryption support) - --- a/lib/default.nix +++ b/lib/default.nix @@ -160,7 +160,6 @@ mkImpureSecretGenerator, }: mkImpureSecretGenerator { - # TODO: Escape prompt/part (preferrably just use env) to prevent shell injection script = '' mkdir $out ${kdePackages.kdialog}/bin/kdialog --inputbox "${prompt}" | gh private -o $out/${part} -- gitstuff