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.
This commit is contained in:
randogoth 2026-10-14 17:20:52 +03:00
parent 21c7cbe4ed
commit f2d2d75063
3 changed files with 482 additions and 0 deletions

View file

@ -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 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 ## 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. 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.

78
flake.lock generated Normal file
View file

@ -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
}

365
flake.nix Normal file
View file

@ -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<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}
'';
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.";
};
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))) ];
};
};
};
}