smolboid/flake.nix
randogoth ff289671a5 feat: add the registration config keys and their module options
Six keys, and the parser's first list, bool and word-valued ones:
registration, registrations_per_hour, info_replies_per_hour, admins,
admin_notify, register_hosts.

Lists drop empty items, because `admins =` is exactly what the Nix module
renders for an unset option, and addresses are validated at load rather than
at first use — a typo would otherwise pass the start and then silently never
match an admin command. Validated values are normalised through
Address::short(), so a spelling carrying the default port collapses to the one
the daemon will compare against.

registration defaults to silent, so adding these keys changes nothing for
anyone who writes in.

Verified the generator and the parser agree by feeding the module's rendered
output to the binary, in both the fully-set and all-defaults shapes — which
matters because config.rs hard-errors on an unknown key.

The bucur half waits for the lock: that host pins this flake at the rev before
these options existed, so setting them there now would break its eval. It
lands with the deploy.
2026-10-14 19:11:12 +03:00

446 lines
19 KiB
Nix

{
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<daemon/Cargo.toml version>, 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}
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
`<baseUrl>/twtxt/<user>.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 `<dataDir>/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 `<mailbox>@<smolmailHost>`.
'';
};
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.";
};
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/<user>.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 <address>` 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))) ];
};
};
};
}