Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/target
data.db*
config.toml
/result
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -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];
});
});
};
}
209 changes: 209 additions & 0 deletions nixos-module.nix
Original file line number Diff line number Diff line change
@@ -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"];
};
};
};
}
29 changes: 29 additions & 0 deletions nixos-tests/test-module.nix
Original file line number Diff line number Diff line change
@@ -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")
'';
}
11 changes: 11 additions & 0 deletions package.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
rustPlatform,
bind,
...
}:
rustPlatform.buildRustPackage {
pname = "xc-bot";
version = "0.3.3";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
}
Loading