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:
parent
21c7cbe4ed
commit
f2d2d75063
3 changed files with 482 additions and 0 deletions
39
README.md
39
README.md
|
|
@ -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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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
78
flake.lock
generated
Normal 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
365
flake.nix
Normal 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))) ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue