453 lines
19 KiB
Nix
453 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}
|
|
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))) ];
|
|
};
|
|
};
|
|
};
|
|
}
|