{ 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} max_post_chars = ${toString cfg.maxPostChars} cap_bytes = ${toString cfg.capBytes} poll_secs = ${toString cfg.pollSecs} timeout = ${toString cfg.timeout} registration = ${cfg.registration} registrations_per_hour = ${toString cfg.registrationsPerHour} info_replies_per_hour = ${toString cfg.infoRepliesPerHour} admins = ${lib.concatStringsSep ", " cfg.admins} admin_notify = ${if cfg.adminNotify then "on" else "off"} register_hosts = ${lib.concatStringsSep ", " cfg.registerHosts} ''; 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."; }; maxPostChars = mkOption { type = types.ints.positive; default = 288; description = "Longest post or repost text, in Unicode code points."; }; 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."; }; registration = mkOption { type = types.enum [ "silent" "open" "closed" ]; default = "silent"; description = '' Whether strangers may register themselves by mail. `silent` drops an unknown sender without a reply, which is how every version before self-service behaved — so upgrading never starts mailing strangers on its own. `open` lets them register; `closed` tells them registration is shut. Only the initial value: an admin's `Twt-Registration` is persisted in the daemon's state and outranks it, so the mode can change by mail without a redeploy. ''; }; registrationsPerHour = mkOption { type = types.ints.positive; default = 10; description = '' Daemon-wide cap on registration attempts that reach the network, per hour. Attempts rather than successes: one that fails its key check costs the same outbound dial. This is the cap that matters — identity keys are free to mint, so a per-sender budget does not bind an attacker. ''; }; infoRepliesPerHour = mkOption { type = types.ints.positive; default = 30; description = "Daemon-wide cap on info replies to unknown senders, per hour."; }; admins = mkOption { type = types.listOf types.str; default = [ ]; example = [ "tobias@smol.place" ]; description = '' Addresses allowed to send `Twt-Admit`, `Twt-Block` and `Twt-Registration`, in `user@host` short form. Each is validated when the daemon loads its config, so a typo fails the start rather than silently never matching. Admit your own admin identity once with the CLI so the daemon pins your key and hands you a token; until it holds one, its acks land in your requests tier. ''; }; adminNotify = mkOption { type = types.bool; default = true; description = '' Mail every admin when a feed is created, with the author's address and key fingerprint — and for a remote author the unpinned server key their host presented. ''; }; registerHosts = mkOption { type = types.listOf types.str; default = [ ]; example = [ "smol.place" ]; description = '' Hosts whose users may register. Empty means any host. Note what empty allows: the daemon will dial a `host:port` an unknown sender chose, a mild port-scan primitive with timing feedback from inside this machine's network. Bounded by `registrationsPerHour`, closed entirely by listing hosts here. ''; }; 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))) ]; }; }; }; }