{ lib, pkgs, config, ... }: let cfg = config.services.streamplace-bot; inherit (lib) types mkOption mkEnableOption; toEnvString = v: if builtins.isBool v then (if v then "true" else "false") else toString v; # Everything that isn't a secret goes here and gets baked into a plain # EnvironmentFile. Secrets (BOT_PASSWORD) stay out of the Nix store via # cfg.environmentFiles instead. generatedSettings = cfg.extraEnvironment // { PUBLIC_BOT_IDENTIFIER = cfg.botIdentifier; PUBLIC_JETSTREAM_URL = cfg.jetstreamUrl; PUBLIC_CDN_URL = cfg.cdnUrl; PUBLIC_FOLLOWER_MODE = cfg.followerMode; PUBLIC_BACKEND_PORT = cfg.port; }; generatedEnvFile = pkgs.writeText "streamplace-bot.env" ( lib.concatStrings ( lib.mapAttrsToList (name: value: "${name}=${toEnvString value}\n") generatedSettings ) ); in { _class = "nixos"; options.services.streamplace-bot = { enable = mkEnableOption "streamplace-bot: a streaming bot for Streamplace"; package = mkOption { type = types.package; default = pkgs.callPackage ./default.nix { }; defaultText = lib.literalExpression "pkgs.callPackage ./default.nix { }"; description = "The streamplace-bot backend package to run."; }; user = mkOption { type = types.str; default = "streamplace-bot"; description = "User under which streamplace-bot runs."; }; group = mkOption { type = types.str; default = "streamplace-bot"; description = "Group under which streamplace-bot runs."; }; dataDir = mkOption { type = types.str; default = "/var/lib/streamplace-bot"; description = "Working/state directory for streamplace-bot."; }; environmentFiles = mkOption { type = types.listOf types.path; default = [ ]; description = '' Files to load secret environment variables from (outside the Nix store), layered on top of the generated non-secret settings below. Use this for at least `BOT_PASSWORD`. ''; }; botIdentifier = mkOption { type = types.str; example = "bot.timtinkers.online"; description = "Handle or DID of the bot account (PUBLIC_BOT_IDENTIFIER)."; }; jetstreamUrl = mkOption { type = types.str; default = "wss://jetstream1.us-east.bsky.network/subscribe"; description = "Jetstream instance URL (PUBLIC_JETSTREAM_URL)."; }; cdnUrl = mkOption { type = types.str; default = "https://cdn.bsky.app/img"; description = "CDN URL used to display images (PUBLIC_CDN_URL)."; }; followerMode = mkOption { type = types.bool; default = false; description = '' Whether the bot should also operate in the Streamplace chats of the bot account's followers (FOLLOWER_MODE). ''; }; port = mkOption { type = types.port; default = 8765; description = "Port the websocket/XRPC backend listens on (PUBLIC_BACKEND_PORT)."; }; extraEnvironment = mkOption { type = types.attrsOf ( types.oneOf [ types.str types.int types.bool ] ); default = { }; description = "Additional non-secret environment variables, merged in as-is."; }; frontend = { enable = mkEnableOption "building/serving the Svelte frontend" // { default = true; }; backendUrl = mkOption { type = types.str; default = ""; example = "https://bot.example.com"; description = '' Backend URL baked into the frontend build. Leave empty to assume same-origin (i.e. you're using `nginx.enable` or your own reverse proxy in front of both). Only takes effect when `package` is left at its default - if you supply your own package this is ignored, since the value has to be baked in at build time. ''; }; package = mkOption { type = types.nullOr types.package; default = pkgs.callPackage ./frontend.nix { inherit (cfg.frontend) backendUrl; backendPort = cfg.port; inherit (cfg) botIdentifier cdnUrl; }; defaultText = lib.literalExpression "pkgs.callPackage ./frontend.nix { }"; description = "Static frontend package to serve. Set to null for a backend-only deployment."; }; }; nginx = { enable = mkEnableOption '' an nginx virtual host that serves the frontend and reverse-proxies the backend, mirroring what vite.config.ts's dev-server proxy does for /api/websocket. Leave disabled if you're fronting this with your own nginx/Caddy/whatever config instead ''; hostName = mkOption { type = types.str; example = "bot.example.com"; description = "Virtual host name to serve the frontend and proxy on."; }; proxyPaths = mkOption { type = types.listOf types.str; default = [ "/api" "/xrpc" ]; description = "URL path prefixes reverse-proxied to the backend."; }; enableACME = mkOption { type = types.bool; default = true; description = '' Request an ACME certificate and force HTTPS for this vhost. Turn off if you're fronting `hostName` with your own certs/proxy, or testing locally without a resolvable domain. ''; }; }; }; config = lib.mkIf cfg.enable ( lib.mkMerge [ { users.users.${cfg.user} = { isSystemUser = true; inherit (cfg) group; home = cfg.dataDir; }; users.groups.${cfg.group} = { }; systemd.tmpfiles.settings."streamplace-bot" = { ${cfg.dataDir}.d = { mode = "0750"; inherit (cfg) user group; }; }; systemd.services.streamplace-bot = { description = "streamplace-bot: a streaming bot for Streamplace"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { User = cfg.user; Group = cfg.group; ExecStart = lib.getExe cfg.package; Restart = "on-failure"; RestartSec = 5; WorkingDirectory = cfg.dataDir; StateDirectory = "streamplace-bot"; EnvironmentFile = [ generatedEnvFile ] ++ cfg.environmentFiles; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; PrivateUsers = true; ProtectHostname = true; ProtectClock = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectKernelLogs = true; ProtectControlGroups = true; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; RestrictNamespaces = true; LockPersonality = true; # Must stay false: V8's JIT needs W^X page transitions to # generate machine code at isolate startup, and this seccomp # filter blocks that outright, crashing node before it even # gets to user code (V8_Fatal in OS::SetPermissions). MemoryDenyWriteExecute = false; RestrictRealtime = true; RestrictSUIDSGID = true; RemoveIPC = true; PrivateMounts = true; SystemCallFilter = [ "@system-service" "~@privileged @resources" ]; SystemCallArchitectures = "native"; }; }; } (lib.mkIf cfg.nginx.enable { services.nginx = { enable = true; recommendedProxySettings = true; virtualHosts.${cfg.nginx.hostName} = { inherit (cfg.nginx) enableACME; forceSSL = cfg.nginx.enableACME; locations = (lib.listToAttrs ( map (path: { name = path; value = { proxyPass = "http://127.0.0.1:${toString cfg.port}"; proxyWebsockets = true; }; }) cfg.nginx.proxyPaths )) // (lib.optionalAttrs (cfg.frontend.enable && cfg.frontend.package != null) { "/" = { root = cfg.frontend.package; tryFiles = "$uri $uri/ /index.html"; }; }); }; }; }) ] ); }