{ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/master"; utils.url = "github:numtide/flake-utils"; }; description = "A very basic flake"; outputs = { self, nixpkgs, utils }: utils.lib.eachDefaultSystem(system: let pkgs = import nixpkgs { inherit system; }; pname = "pounce"; version = "3.1"; in { packages = { default = pkgs.stdenv.mkDerivation { inherit pname version; src = pkgs.fetchzip { url = "https://git.causal.agency/pounce/snapshot/pounce-${version}.tar.gz"; sha256 = "sha256-6PGiaU5sOwqO4V2PKJgIi3kI2jXsBOldEH51D7Sx9tg="; }; buildInputs = with pkgs; [ libressl libxcrypt curl sqlite ]; nativeBuildInputs = [ pkgs.pkg-config ]; configureFlags = [ "--enable-notify" "--enable-palaver" ]; buildFlags = [ "all" ]; makeFlags = [ "PREFIX=$(out)" ]; meta = with nixpkgs.lib; { homepage = "https://code.causal.agency/june/pounce"; description = "Simple multi-client TLS-only IRC bouncer"; license = licenses.gpl3; }; }; }; }) // { nixosModule = {config, lib, pkgs, ...}: with lib; let cfg = config.services.pounce; pkg = self.packages.${pkgs.system}.default; defaultUser = "pounce"; settingsFormat = { type = types.attrsOf (types.nullOr (types.oneOf [ types.bool types.int types.str ])); generate = name: value: let mkKeyValue = k: v: if true == v then k else if false == v then "#${k}" else lib.generators.mkKeyValueDefault {} " = " k v; mkKeyValueList = values: lib.generators.toKeyValue { inherit mkKeyValue; } values; in pkgs.writeText name (mkKeyValueList value); }; sharedNotifyOpts = { insecure = mkOption { type = types.bool; default = false; description = lib.mdDoc '' Disable certificate validation for connecting to the Pounce instance. Overrides {option}`services.pounce.networks..notify.trust-cert`. ''; }; trust-cert = mkOption { type = types.nullOr types.str; default = ""; example = "/etc/letsencrypt/live/libera.irc.example.org/fullchain.pem"; description = lib.mdDoc '' Pounce certificate for the pounce-notify client to trust. This is required if Pounce is using a self-signed certificate. If left blank, pounce-notify will use the appropriate certificate in {option}`services.pounce.fullChain`. Set to `null` to disable certificate pinning. ''; }; client-cert = mkOption { type = types.str; default = ""; description = lib.mdDoc '' Client certificate to use if Pounce is configured to require certificate authentication. If the relevant private key is stored in a separate file, load it with {option}`services.pounce.networks..notify.client-priv`. ''; }; client-priv = mkOption { type = types.str; default = ""; description = lib.mdDoc '' Private key to use if Pounce is configured to require certificate authentication. If the certificate provided in {option}`services.pounce.networks..notify.client-cert` has an embedded private key, this option can be left empty. ''; }; user = mkOption { type = types.str; default = "pounce-notify"; description = lib.mdDoc '' Username to present to Pounce when connecting. ''; }; }; hardeningFlags = { CapabilityBoundingSet = [ "" ]; NoNewPrivileges = true; PrivateDevices = true; PrivateMounts = true; PrivateTmp = true; PrivateUsers = true; ProtectClock = true; ProtectControlGroups = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectProc = "invisible"; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; RestrictNamespaces = true; RestrictSUIDSGID = true; SystemCallArchitectures = "native"; SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; }; in { options.services.pounce = { enable = mkEnableOption (lib.mdDoc "the Pounce IRC bouncer and Calico dispatcher"); user = mkOption { type = types.str; default = defaultUser; description = lib.mdDoc '' User account under which Pounce runs. If not specified, a default user will be created. ''; }; socketDir = mkOption { type = types.str; default = "/run/pounce"; description = lib.mdDoc '' Directory where each Pounce instance's UNIX-domain socket is stored for Calico to route to. ''; }; bindHost = mkOption { type = types.str; default = "localhost"; description = lib.mdDoc '' The IP or host for Calico to bind to. ''; }; port = mkOption { type = types.port; default = 6697; description = lib.mdDoc "Port for Calico to listen on."; }; timeout = mkOption { type = types.ints.positive; default = 1000; description = lib.mdDoc '' Timeout parameter (in milliseconds) for Calico to close a connection if no `ClientHello` message is sent. ''; }; networks = mkOption { type = types.attrsOf (types.submodule { options = { fullChain = mkOption { type = types.str; description = lib.mdDoc '' Certificate chain for TLS connections. ''; }; privKey = mkOption { type = types.str; description = lib.mdDoc '' Private key for TLS connections. ''; }; config = mkOption { type = settingsFormat.type; default = {}; example = { irc.libera.chat = { host = "irc.libera.chat"; port = 6697; sasl-plain = "testname:password"; join = "#nixos,#nixos-dev"; }; }; description = lib.mdDoc '' Pounce configuration to user for the network. For information on what options Pounce accepts, see the [pounce(1)](https://git.causal.agency/pounce/about/pounce.1) manual page. ''; }; notify = mkOption { type = types.submodule { options = sharedNotifyOpts // { script = mkOption { type = types.lines; default = ""; example = '' # Pushover example if [ -z "$NOTIFY_CHANNEL" ]; then TITLE="Private Message" CONTEXT="$NOTIFY_NICK" else TITLE="Mention" CONTEXT="$NOTIFY_CHANNEL" fi $${pkgs.curl}/bin/curl \ -X POST \ --form-string token="API_TOKEN" \ --form-string user="USER_KEY" \ --form-string title="(libera/$CONTEXT) $TITLE" \ --form-string timestamp="$NOTIFY_TIME" \ --form-string message="$NOTIFY_NICK: $NOTIFY_MESSAGE" \ https://api.pushover.net/1/messages.json ''; description = lib.mdDoc '' Commands to run when a private message or a mention occurs. See [pounce-notify(1)](https://git.causal.agency/pounce/about/extra/notify/pounce-notify.1) for a list of environment variables containing information about the notification event. ''; }; command = mkOption { type = types.str; default = ""; example = "/var/lib/pounce/notify.sh"; description = lib.mdDoc '' Command to run when a private message or a mention occurs. Overrides {option}`services.pounce.notify.networks..notify.script`. See [pounce-notify(1)](https://git.causal.agency/pounce/about/extra/notify/pounce-notify.1) for a list of environment variables containing information about the notification event. ''; }; }; }; default = {}; example = { script = "/var/lib/pounce/notify.sh"; }; description = lib.mdDoc '' Configuration for pounce-notify. A notification client will be spawned if either {option}`services.pounce.networks..notify.script` or {option}`services.pounce.networks..notify.command` is set. ''; }; palaver = mkOption { type = types.submodule { options = sharedNotifyOpts // { enable = mkEnableOption (lib.mdDoc "the palaver notification client"); noPreviews = mkOption { type = types.bool; default = false; description = "Never send message previews, regardless of app preferences."; }; noPrivateMessagePreviews = mkOption { type = types.bool; default = false; description = "Never send message previews for private messages."; }; dbPath = mkOption { type = types.string; default = ""; description = "Set the path to the database file used to store notification preferences."; }; caseSensitive = mkOption { type = types.bool; default = false; description = "Match nick and keywords case-sensitively, despite the specification."; }; verbose = mkOption { type = types.bool; default = false; description = "Log IRC protocol, SQL and HTTP to standard error."; }; }; }; default = { enable = false; }; example = { script = "/var/lib/pounce/notify.sh"; }; description = lib.mdDoc '' Configuration for pounce-palaver. A notification client will be spawned if {option}`services.pounce.networks..palaver.enable` is true. ''; }; }; }); default = {}; description = lib.mdDoc "Attribute set of IRC networks to connect to."; }; }; config = mkIf cfg.enable { systemd.tmpfiles.rules = [ "d ${cfg.socketDir} 0700 ${cfg.user} ${cfg.user} -" ]; systemd.services = mkMerge ( [ { calico = { wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; description = "Calico dispatcher for Pounce IRC bouncer."; serviceConfig = { User = cfg.user; Group = cfg.user; ExecStart = '' ${pkg}/bin/calico \ -H ${cfg.bindHost} -P ${toString cfg.port} \ -t ${toString cfg.timeout} ${cfg.socketDir} ''; Restart = "on-failure"; } // hardeningFlags; }; } ] ++ (mapAttrsToList (name: value: mkMerge [ { "pounce-${name}" = { wantedBy = [ "calico.service" ]; after = [ "network.target" ]; before = [ "calico.service" ]; description = "Pounce IRC bouncer for the ${name} network."; serviceConfig = { User = cfg.user; Group = cfg.user; ExecStart = '' ${pkg}/bin/pounce \ -C ${value.fullChain} \ -K ${value.privKey} \ -U ${cfg.socketDir} -H ${name} \ ${settingsFormat.generate "${name}.cfg" value.config} ''; Restart = "on-failure"; } // hardeningFlags; }; } (mkIf (value.notify.command != "" || value.notify.script != "") { "pounce-${name}-notify" = { wantedBy = [ "multi-user.target" ]; requires = [ "calico.service" "pounce-${name}.service" ]; after = [ "calico.service" "pounce-${name}.service" ]; description = "Pounce notification client for the ${name} network."; serviceConfig = { User = cfg.user; Group = cfg.user; Environment = "SHELL=${pkgs.bash}/bin/bash"; ExecStart = '' ${pkg}/bin/pounce-notify \ ${if value.notify.insecure then "-!" else if value.notify.trust-cert == "" then "-t ${value.fullChain}" else if value.notify.trust-cert != null then "-t ${value.notify.trust-cert}" else ""} \ ${if value.notify.client-cert != "" then "-c ${value.notify.client-cert}" else ""} \ ${if value.notify.client-priv != "" then "-k ${value.notify.client-priv}" else ""} \ -p ${toString cfg.port} \ -u ${value.notify.user} \ ${if cfg.networks.${name}.config ? local-pass then "-w ${cfg.networks.${name}.config.local-pass}" else ""} \ ${name} \ ${if value.notify.command != "" then "\"${value.notify.command}\"" else pkgs.writeShellScript "pounce-${name}-notify-script" value.notify.script} ''; Restart = "on-failure"; # pounce will refuse all connections before it's connected to the # IRC network, but there's no easy way for systemd to know when # that's happened. The best I've come up with is starting # pounce-notify anyways and retrying with a fairly long delay. # This value works for me, hopefully it works for you too. RestartSec = "15s"; } // hardeningFlags; }; }) (mkIf (value.palaver.enable) { "pounce-${name}-palaver" = { wantedBy = [ "multi-user.target" ]; requires = [ "calico.service" "pounce-${name}.service" ]; after = [ "calico.service" "pounce-${name}.service" ]; description = "Pounce palaver notification client for the ${name} network."; serviceConfig = { User = cfg.user; Group = cfg.user; Environment = "SHELL=${pkgs.bash}/bin/bash"; ExecStart = '' ${pkg}/bin/pounce-palaver \ ${if value.palaver.insecure then "-!" else if value.palaver.trust-cert == "" then "-t ${value.fullChain}" else if value.palaver.trust-cert != null then "-t ${value.palaver.trust-cert}" else ""} \ ${if value.palaver.client-cert != "" then "-c ${value.notify.client-cert}" else ""} \ ${if value.palaver.client-priv != "" then "-k ${value.notify.client-priv}" else ""} \ -p ${toString cfg.port} \ -u ${value.palaver.user} \ ${if cfg.networks.${name}.config ? local-pass then "-w ${cfg.networks.${name}.config.local-pass}" else ""} \ ${name} \ ${if value.palaver.noPreviews then "-N" else ""} \ ${if value.palaver.noPrivateMessagePreviews then "-N" else ""} \ ${if value.palaver.dbPath != "" then "-d ${value.palaver.dbPath}" else ""} \ ${if value.palaver.caseSensitive then "-s" else "" } \ ${if value.palaver.verbose then "-v" else ""} ''; Restart = "on-failure"; # pounce will refuse all connections before it's connected to the # IRC network, but there's no easy way for systemd to know when # that's happened. The best I've come up with is starting # pounce-palaver anyways and retrying with a fairly long delay. # This value works for me, hopefully it works for you too. RestartSec = "15s"; } // hardeningFlags; }; }) ]) cfg.networks) ); users = optionalAttrs (cfg.user == defaultUser) { users.${defaultUser} = { group = defaultUser; isSystemUser = true; }; groups.${defaultUser} = { }; }; }; }; }; }