From f2d2d75063e265bc44eff01782b81708047c8f41 Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 14 Oct 2026 17:20:52 +0300 Subject: [PATCH] feat: add flake with static build, release app and NixOS module packages.static is a static musl binary so a low-power deploy target can fetch a prebuilt one rather than compile Rust; apps.release-static publishes it to a Forgejo release, guarded on HEAD carrying the version's tag. fumi-core is a path dependency on ../fumi, which escapes the flake source, so fumi is vendored as its own input and re-laid beside the source tree in the store. Over https, not a local path: a flake importing this one has to resolve this input too when it locks. services.smolboid runs as a real user rather than DynamicUser, because admit, block and the fumi bootstrap are operator commands against the same master and store and need a stable uid to sudo into. --- README.md | 39 ++++++ flake.lock | 78 ++++++++++++ flake.nix | 365 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 482 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/README.md b/README.md index 4ad258a..559a446 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,45 @@ smolboid block --config smolboid.toml tobias@smol.place # withdraw the token smolboid check /srv/smolboid/data/feeds/tobias.txt # verify a feed's records, nonzero on forged ones ``` +## Deploying with Nix + +The flake exposes `packages.default`, `packages.static` (a static musl binary, +for deploy targets that should not compile Rust) and `nixosModules.default`: + +```nix +{ + inputs.smolboid.url = "git+https://code.randogoth.com/randogoth/smolboid.git"; + + # in your nixosSystem modules: + imports = [ inputs.smolboid.nixosModules.default ]; + + services.smolboid = { + enable = true; + listen = "127.0.0.1:8098"; + baseUrl = "https://smol.place"; + smolmailHost = "smol.place"; + masterFile = "/run/smolboid/master.key"; # 32 raw bytes, provisioned out of band + }; +} +``` + +The module runs the daemon as a real `smolboid` user rather than under +`DynamicUser`, because `admit`, `block` and the fumi bootstrap are operator +commands against the same master key and store and need a stable uid to +`sudo -u` into. It does not generate or manage the master. + +`nix run .#release-static` builds the static binary and publishes it as a +Forgejo release on this repo, tagged from `daemon/Cargo.toml`'s version; it +refuses unless HEAD carries that tag. Needs `FORGEJO_TOKEN` with +`write:repository`. + +`fumi-core` is a path dependency on a sibling `../fumi` checkout, which escapes +this repo and so cannot be reached from a flake `src`. The flake vendors fumi +as its own input and re-lays it beside the source tree in the store, the way +bunshin vendors microReticulum. One consequence: `nix build` compiles against +the **locked** fumi rev while `devbox run build` uses the sibling worktree, so +the two can disagree — the lock is the one that ships. + ## The author's side Post by sending the twt as the mail body, from any smolmail client. Fetch the daemon's welcome mail first — it carries the accept token that moves your posts out of the requests tier. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fc040b0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,78 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1791914995, + "narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "fumi": { + "flake": false, + "locked": { + "lastModified": 1790794441, + "narHash": "sha256-De7oD/xpNGE//ZBK+TGMa9XC9qf4Clo0vGhB9U4xoO8=", + "ref": "refs/heads/main", + "rev": "8872ec6c98c072ffd16b94b4f72ad501b7b56d16", + "revCount": 25, + "type": "git", + "url": "https://code.randogoth.com/randogoth/fumi.git" + }, + "original": { + "type": "git", + "url": "https://code.randogoth.com/randogoth/fumi.git" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1791734076, + "narHash": "sha256-i5qk1kLiit98rRbh1AXmWBNT9QXC/y4l5Wk/u5EdpDQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7fd4004d0511991fff3a0524856a9be09e28c5f0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "fumi": "fumi", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..efbbcf6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,365 @@ +{ + description = "smolboid - signed twtxt feeds published over smolmail"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + + # fumi-core is a path dependency at ../../fumi/core, which escapes this + # repo and so cannot be reached from a flake `src`. Vendored as its own + # input and re-laid as a sibling of the source tree below, the same way + # bunshin vendors microReticulum. + # + # Fetched over https rather than ssh or a local path, because a flake + # importing this one has to resolve this input too when it locks: a + # `git+file://` here would be unresolvable on any machine but the + # maintainer's, and ssh would need a deploy key present at evaluation + # time. The repo is public, so https needs no credentials. Keep it public + # or this input breaks every downstream lock. + # + # Note this pins fumi to the locked rev, while `devbox run build` compiles + # against the sibling ../fumi worktree. They can disagree; the lock is the + # one that ships. + fumi = { + url = "git+https://code.randogoth.com/randogoth/fumi.git"; + flake = false; + }; + }; + + outputs = { self, nixpkgs, flake-utils, fumi }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + inherit (pkgs) lib; + + # The workspace root carries no [package]; the binary's version is the + # single source of the release version. + version = (lib.importTOML ./daemon/Cargo.toml).package.version; + + # Cargo resolves `path = "../../fumi/core"` relative to core/Cargo.toml + # and daemon/Cargo.toml, so fumi must sit beside this checkout, not + # inside it. Rebuild that layout in the store and point sourceRoot at + # the smolboid half. + composedSrc = pkgs.runCommandLocal "smolboid-src" { } '' + mkdir -p $out/smolboid $out/fumi + cp -r ${lib.cleanSource ./.}/. $out/smolboid/ + cp -r ${fumi}/. $out/fumi/ + chmod -R u+w $out + ''; + + smolboid = { static ? false }: + let p = if static then pkgs.pkgsStatic else pkgs; + in p.rustPlatform.buildRustPackage { + pname = "smolboid"; + inherit version; + src = composedSrc; + sourceRoot = "smolboid-src/smolboid"; + cargoLock.lockFile = ./Cargo.lock; + nativeBuildInputs = [ p.pkg-config ]; + # fumi-core's default features include bundled-sqlite, which + # compiles SQLite from source, so no system copy is linked. + buildInputs = [ ]; + # The rig, stress and pentest suites dial a live bunshin and bind + # sockets; the sandbox has neither. + doCheck = false; + }; + in + { + packages.default = smolboid { }; + + # Static musl build, so a low-power deploy target can fetch a prebuilt + # binary rather than compile Rust. See apps.release-static. + packages.static = smolboid { static = true; }; + + apps.default = flake-utils.lib.mkApp { + drv = smolboid { }; + name = "smolboid"; + }; + + # Builds packages.static and publishes it as a Forgejo release on this + # repo, tagged v, creating the release if it + # does not exist and replacing the asset if it does, so it is safe to + # rerun. Refuses unless HEAD carries that tag, so a release always + # matches a bumped version. Needs FORGEJO_TOKEN (write:repository) in + # the environment or a .env beside this flake; run from a checkout so + # `git rev-parse` and the `.` flake ref resolve here. + apps.release-static = flake-utils.lib.mkApp { + drv = pkgs.writeShellApplication { + name = "smolboid-release-static"; + runtimeInputs = [ pkgs.nix pkgs.curl pkgs.git pkgs.jq ]; + text = '' + if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a + fi + : "''${FORGEJO_TOKEN:?set FORGEJO_TOKEN (env or .env) to a Forgejo token with write:repository scope}" + + tag="v${version}" + if [ "$(git rev-parse -q --verify "$tag^{commit}" || true)" != "$(git rev-parse HEAD)" ]; then + echo "HEAD is not tagged $tag; bump daemon/Cargo.toml, then tag the release commit" >&2 + exit 1 + fi + sha=$(git rev-parse HEAD) + out=$(nix build .#static --no-link --print-out-paths) + bin="$out/bin/smolboid" + + api="https://code.randogoth.com/api/v1/repos/randogoth/smolboid" + auth=(-H "Authorization: token ''${FORGEJO_TOKEN}") + + release_id=$(curl -sS "''${auth[@]}" "$api/releases/tags/$tag" | jq -r '.id // empty') + if [ -z "$release_id" ]; then + release_id=$(curl -sSf "''${auth[@]}" -H "Content-Type: application/json" \ + -d "$(jq -n --arg tag "$tag" --arg sha "$sha" \ + '{tag_name:$tag, target_commitish:$sha, name:$tag, body:"Static musl build.", draft:false, prerelease:false}')" \ + "$api/releases" | jq -r '.id') + fi + + asset_id=$(curl -sSf "''${auth[@]}" "$api/releases/$release_id/assets" | jq -r '.[] | select(.name=="smolboid") | .id' | head -1) + if [ -n "$asset_id" ]; then + curl -sSf -X DELETE "''${auth[@]}" "$api/releases/$release_id/assets/$asset_id" >/dev/null + fi + curl -sSf "''${auth[@]}" -F "attachment=@$bin;filename=smolboid" "$api/releases/$release_id/assets?name=smolboid" >/dev/null + + echo "released: https://code.randogoth.com/randogoth/smolboid/releases/tag/$tag" + ''; + }; + }; + + devShells.default = pkgs.mkShell { + packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config ]; + }; + }) // { + nixosModules.default = { config, lib, pkgs, ... }: + let + cfg = config.services.smolboid; + inherit (lib) mkEnableOption mkOption mkIf types; + + # Not TOML despite the conventional filename: daemon/src/config.rs + # parses one flat `key = value` per line, `#` comments, and + # rejects any key it does not know. + configFile = pkgs.writeText "smolboid.conf" '' + listen = ${cfg.listen} + base_url = ${cfg.baseUrl} + smolmail_host = ${cfg.smolmailHost} + mailbox = ${cfg.mailbox} + master = ${toString cfg.masterFile} + data = ${cfg.dataDir} + posts_per_day = ${toString cfg.postsPerDay} + reposts_per_day = ${toString cfg.repostsPerDay} + cap_bytes = ${toString cfg.capBytes} + poll_secs = ${toString cfg.pollSecs} + timeout = ${toString cfg.timeout} + ''; + in + { + options.services.smolboid = { + enable = mkEnableOption "the smolboid twtxt feed daemon"; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + description = "smolboid package to run."; + }; + + listen = mkOption { + type = types.str; + default = "127.0.0.1:8080"; + example = "127.0.0.1:8098"; + description = '' + Address the feed server listens on. Plain HTTP with no TLS + of its own, so keep it on loopback and terminate TLS in the + reverse proxy that serves `baseUrl`. + ''; + }; + + baseUrl = mkOption { + type = types.str; + example = "https://smol.place"; + description = '' + Canonical origin the feeds' `# url` metadata carries, with + no trailing slash. Readers resolve `user@domain` to + `/twtxt/.txt`, so this must be the origin + that actually serves the feeds. + ''; + }; + + smolmailHost = mkOption { + type = types.str; + example = "smol.place"; + description = '' + Host serving the daemon's own smolmail mailbox. The server's + key is pinned in `/mail.db` by the out-of-band + `fumi trust` bootstrap, not by this module, and fumi pins by + host and port — so this must be the name the pin was taken + under, even when the mail server runs on this same machine. + ''; + }; + + mailbox = mkOption { + type = types.str; + default = "twtxt"; + description = '' + Local part of the daemon's mailbox. Authors post by writing + to `@`. + ''; + }; + + masterFile = mkOption { + type = types.path; + description = '' + Path to the daemon's 32-byte smolmail master secret. + Provisioned out of band and readable by the service user; + this module does not generate it. With sops-nix, set the + secret's `owner` to the `user` below, because the one-off + `fumi trust` and `fumi register` bootstrap has to read the + same file as the service. + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/smolboid"; + description = "Directory holding state.json, mail.db and feeds/."; + }; + + user = mkOption { + type = types.str; + default = "smolboid"; + description = '' + Service user. A real user rather than DynamicUser on + purpose: `smolboid admit` and the fumi bootstrap are + operator commands run against the same master key and + mail.db as the service, which needs a stable uid to + `sudo -u` into. + ''; + }; + + postsPerDay = mkOption { + type = types.ints.positive; + default = 50; + description = "Per-feed daily post quota."; + }; + + repostsPerDay = mkOption { + type = types.ints.positive; + default = 50; + description = "Per-feed daily repost quota."; + }; + + capBytes = mkOption { + type = types.ints.positive; + default = 1048576; + description = "Per-feed size cap, in bytes."; + }; + + pollSecs = mkOption { + type = types.ints.positive; + default = 60; + description = "Seconds between mailbox fetches."; + }; + + timeout = mkOption { + type = types.ints.positive; + default = 30; + description = "Network timeout for smolmail operations, in seconds."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Open the `listen` port in the firewall. Off by default and + normally left off: the listener speaks plain HTTP and is + meant to sit behind a TLS-terminating proxy. + ''; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = !(lib.hasSuffix "/" cfg.baseUrl); + message = "services.smolboid.baseUrl must not end in a slash; it is concatenated with /twtxt/.txt."; + } + { + assertion = lib.hasInfix ":" cfg.listen; + message = "services.smolboid.listen must be host:port, e.g. 127.0.0.1:8098."; + } + ]; + + warnings = lib.optional (lib.hasPrefix "http://" cfg.baseUrl) + ''services.smolboid.baseUrl is http://; feeds are public data but a network attacker can substitute them in transit. Terminate TLS in front of the daemon.''; + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.user; + home = cfg.dataDir; + }; + users.groups.${cfg.user} = { }; + + # Created here rather than with StateDirectory so a dataDir + # outside /var/lib still works, and so the bootstrap can write + # mail.db before the unit has ever started. + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.user} - -" + ]; + + systemd.services.smolboid = { + description = "smolboid twtxt feed daemon"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.user; + ExecStart = "${cfg.package}/bin/smolboid serve --config ${configFile}"; + + # The daemon polls its mailbox on a timer and a failed dial + # is retried on the next tick, so the common failure needs + # no restart; this covers the uncommon one. + Restart = "on-failure"; + RestartSec = 5; + + ReadWritePaths = [ cfg.dataDir ]; + + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectClock = true; + ProtectHostname = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; + # smolmailHost is a name, so the resolver is reached over + # AF_UNIX as well as AF_INET. + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + UMask = "0077"; + }; + }; + + # `smolboid admit
` is a required operator action for + # every new author, not an occasional debugging aid, so the CLI + # belongs on PATH wherever the daemon runs. + environment.systemPackages = [ cfg.package ]; + + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall + [ (lib.toInt (lib.last (lib.splitString ":" cfg.listen))) ]; + }; + }; + }; +}