smolboid/flake.nix

454 lines
19 KiB
Nix
Raw Normal View History

{
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}
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
`<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.";
};
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/<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))) ];
};
};
};
}