diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80814ab..816322b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,3 +128,23 @@ jobs: tags: | ghcr.io/dbrgn/xc-bot:${{ steps.version.outputs.branch }} ghcr.io/dbrgn/xc-bot:${{ steps.version.outputs.version }} + + nix-flake-check: + name: Check nix flake + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - run: nix flake check + + nix-build: + name: Build nix package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: cachix/install-nix-action@v31 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - run: nix build diff --git a/.gitignore b/.gitignore index a698104..9944406 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target data.db* config.toml +/result diff --git a/README.md b/README.md index d087dce..9f5913b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ You'll probably want to mount both files into the container. Note: This container runs as default user by default. If you use podman, you can run the container as non-root. +## Nix MOdule + +This repository also includes a Nix package and NixOS module. + ## License Licensed under the AGPL version 3 or later. See `LICENSE.md` file. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..483d60b --- /dev/null +++ b/flake.lock @@ -0,0 +1,26 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1762498405, + "narHash": "sha256-Zg/SCgCaAioc0/SVZQJxuECGPJy+OAeBcGeA5okdYDc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6faeb062ee4cf4f105989d490831713cc5a43ee1", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-25.05", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..dacf9cf --- /dev/null +++ b/flake.nix @@ -0,0 +1,39 @@ +{ + description = "A chat bot that notifies about new paragliding cross-country flights published on XContest"; + + inputs = { + nixpkgs.url = "nixpkgs/nixos-25.05"; + }; + + outputs = { + self, + nixpkgs, + }: let + # Supported target systems + allSystems = ["x86_64-linux"]; + + # Helper to build a package for all supported systems above + forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {pkgs = import nixpkgs {inherit system;};}); + + mkPackage = pkgs: pkgs.callPackage ./package.nix {}; + in { + # NixOS Module + nixosModules.default = import ./nixos-module.nix self; + + # Package + overlays.default = final: _prev: {xc-bot = mkPackage final;}; + packages = forAllSystems ( + {pkgs}: { + default = mkPackage pkgs; + } + ); + + # Tests + checks = forAllSystems ({pkgs}: { + test-module = pkgs.nixosTest (import ./nixos-tests/test-module.nix { + inherit pkgs; + modules = [self.nixosModules.default]; + }); + }); + }; +} diff --git a/nixos-module.nix b/nixos-module.nix new file mode 100644 index 0000000..a818edc --- /dev/null +++ b/nixos-module.nix @@ -0,0 +1,209 @@ +self: { + config, + pkgs, + lib, + ... +}: +with lib; let + cfg = config.services.xc-bot; +in { + # Define the options that can be set for this module + options.services.xc-bot = { + enable = mkEnableOption "xc-bot"; + package = mkPackageOption pkgs "xc-bot" {}; + + # Threema configuration + threema = mkOption { + type = types.submodule { + options = { + gatewayId = mkOption { + type = types.str; + description = lib.mdDoc "The Threema Gateway ID (starts with a *)"; + example = "*EXAMPLE"; + }; + gatewaySecretFile = mkOption { + type = types.path; + description = lib.mdDoc "Path to file containing the Threema Gateway secret"; + example = "/run/secrets/threema-gateway-secret"; + }; + privateKeyFile = mkOption { + type = types.path; + description = lib.mdDoc "Path to file containing the hex-encoded private key"; + example = "/run/secrets/threema-private-key"; + }; + adminId = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc "Identity of the admin"; + example = "ADMIN123"; + }; + }; + }; + description = lib.mdDoc "Threema Gateway configuration"; + }; + + # XContest configuration + xcontest = mkOption { + type = types.submodule { + options = { + intervalSeconds = mkOption { + type = types.nullOr types.int; + default = 180; + description = lib.mdDoc "The query interval in seconds"; + example = 300; + }; + }; + }; + default = {}; + description = lib.mdDoc "XContest configuration"; + }; + + # Server configuration + server = mkOption { + type = types.submodule { + options = { + listen = mkOption { + type = types.str; + default = "127.0.0.1:3000"; + description = lib.mdDoc "The HTTP server listening host:port string"; + example = "0.0.0.0:8080"; + }; + }; + }; + default = {}; + description = lib.mdDoc "Server configuration"; + }; + + # Logging configuration + logging = mkOption { + type = types.submodule { + options = { + filter = mkOption { + type = types.nullOr types.str; + default = "info,sqlx::query=warn"; + description = lib.mdDoc "The log filter (tracing syntax)"; + example = "debug,sqlx::query=warn"; + }; + }; + }; + default = {}; + description = lib.mdDoc "Logging configuration"; + }; + }; + + # Config if a user enabled this module + config = mkIf cfg.enable { + assertions = [ + { + assertion = lib.hasPrefix "*" cfg.threema.gatewayId; + message = "services.xc-bot.threema.gatewayId must start with '*'"; + } + { + assertion = cfg.xcontest.intervalSeconds == null || cfg.xcontest.intervalSeconds > 0; + message = "services.xc-bot.xcontest.intervalSeconds must be positive"; + } + { + assertion = lib.match ".*:[0-9]+" cfg.server.listen != null; + message = "services.xc-bot.server.listen must be in 'host:port' format"; + } + ]; + + nixpkgs.overlays = [self.overlays.default]; + + # Generate the TOML config file with placeholders for secrets + systemd.services.xc-bot = let + # Build the config structure + configData = { + threema = + { + gateway_id = cfg.threema.gatewayId; + gateway_secret = "@GATEWAY_SECRET@"; + private_key = "@PRIVATE_KEY@"; + } + // optionalAttrs (cfg.threema.adminId != null) { + admin_id = cfg.threema.adminId; + }; + + xcontest = optionalAttrs (cfg.xcontest.intervalSeconds != null) { + interval_seconds = cfg.xcontest.intervalSeconds; + }; + + server = { + listen = cfg.server.listen; + }; + + logging = optionalAttrs (cfg.logging.filter != null) { + filter = cfg.logging.filter; + }; + }; + + # Generate TOML config file + configFile = pkgs.writeText "xc-bot-config.toml" ( + generators.toTOML {} configData + ); + + # Create a script that substitutes secrets and runs xc-bot + startScript = pkgs.writeShellScript "xc-bot-start" '' + set -euo pipefail + + # Read secrets + GATEWAY_SECRET=$(cat "$CREDENTIALS_DIRECTORY/threema-gateway-secret") + PRIVATE_KEY=$(cat "$CREDENTIALS_DIRECTORY/threema-private-key") + + # Create runtime config with substituted secrets + RUNTIME_CONFIG=$(mktemp) + trap "rm -f $RUNTIME_CONFIG" EXIT + + sed -e "s|@GATEWAY_SECRET@|$GATEWAY_SECRET|g" \ + -e "s|@PRIVATE_KEY@|$PRIVATE_KEY|g" \ + ${configFile} > "$RUNTIME_CONFIG" + + # Run xc-bot with the runtime config + exec ${cfg.package}/bin/xc-bot -c "$RUNTIME_CONFIG" + ''; + in { + description = "A chat bot that notifies about new paragliding cross-country flights published on XContest"; + wantedBy = ["multi-user.target"]; + wants = ["network-online.target"]; + after = ["network-online.target"]; + + serviceConfig = { + ExecStart = startScript; + + # Secrets + LoadCredential = [ + "threema-gateway-secret:${cfg.threema.gatewaySecretFile}" + "threema-private-key:${cfg.threema.privateKeyFile}" + ]; + + # User and state config + DynamicUser = true; + StateDirectory = "xc-bot"; + WorkingDirectory = "/var/lib/xc-bot"; + + # Restart policy + Restart = "on-failure"; + RestartSec = "30s"; + + # Security hardening + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + ReadWritePaths = []; + RestrictAddressFamilies = ["AF_INET" "AF_INET6"]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallFilter = ["@system-service" "~@privileged"]; + }; + }; + }; +} diff --git a/nixos-tests/test-module.nix b/nixos-tests/test-module.nix new file mode 100644 index 0000000..f388558 --- /dev/null +++ b/nixos-tests/test-module.nix @@ -0,0 +1,29 @@ +{ + pkgs, + modules, + ... +}: { + name = "xc-bot module"; + nodes.machine = {pkgs, ...}: { + imports = modules; + + # Config being tested + services.xc-bot = { + enable = true; + threema = { + gatewayId = "*ABCDEFG"; + gatewaySecretFile = pkgs.writeText "gateway-secret" "mysecret"; + privateKeyFile = pkgs.writeText "private-key" "privkey"; + }; + server = { + listen = "127.0.0.1:3000"; + }; + }; + }; + + testScript = '' + machine.start(allow_reboot = True) + + machine.wait_for_unit("multi-user.target") + ''; +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..42ec1b5 --- /dev/null +++ b/package.nix @@ -0,0 +1,11 @@ +{ + rustPlatform, + bind, + ... +}: +rustPlatform.buildRustPackage { + pname = "xc-bot"; + version = "0.3.3"; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; +}