2026-10-14 17:20:52 +03:00
|
|
|
{
|
|
|
|
|
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}
|
2026-10-15 13:59:39 +03:00
|
|
|
max_post_chars = ${toString cfg.maxPostChars}
|
2026-10-14 17:20:52 +03:00
|
|
|
cap_bytes = ${toString cfg.capBytes}
|
|
|
|
|
poll_secs = ${toString cfg.pollSecs}
|
|
|
|
|
timeout = ${toString cfg.timeout}
|
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
|
|
|
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}
|
2026-10-14 17:20:52 +03:00
|
|
|
'';
|
|
|
|
|
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.";
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-15 13:59:39 +03:00
|
|
|
maxPostChars = mkOption {
|
|
|
|
|
type = types.ints.positive;
|
|
|
|
|
default = 288;
|
|
|
|
|
description = "Longest post or repost text, in Unicode code points.";
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-14 17:20:52 +03:00
|
|
|
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.";
|
|
|
|
|
};
|
|
|
|
|
|
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
|
|
|
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.
|
|
|
|
|
'';
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-14 17:20:52 +03:00
|
|
|
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))) ];
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|