diff --git a/.devcontainer/.gitignore b/.devcontainer/.gitignore
new file mode 100644
index 0000000..879075b
--- /dev/null
+++ b/.devcontainer/.gitignore
@@ -0,0 +1 @@
+.*Marker
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..94f430c
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,54 @@
+FROM docker.io/rust:1-slim-trixie
+
+# Install development dependencies
+RUN apt-get update \
+ && DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
+ build-essential pkg-config musl-tools ca-certificates man-db \
+ neovim less git curl wget unzip ripgrep locales file procps net-tools \
+ nix-bin \
+ bubblewrap socat \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+# Locale setup
+RUN echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \
+ && locale-gen en_US.UTF-8 \
+ && update-locale LANG=en_US.UTF-8
+ENV LANG=en_US.UTF-8
+ENV LC_ALL=en_US.UTF-8
+
+# Create non-root user
+RUN groupadd -r dev -g 1000 \
+ && useradd -r -g dev -u 1000 -d /home/dev -s /bin/bash dev \
+ && mkdir /home/dev && chown dev:dev /home/dev
+
+# Create directories with user ownership
+# Note: VS Code Dev Containers will automatically adjust UID/GID via updateRemoteUserUID
+RUN mkdir -p /code && chown dev:dev /code
+RUN mkdir -p /nix && chown dev:dev /nix
+
+# Switch to non-root user
+USER dev
+
+# Install Rust dev components
+RUN rustup component add rustfmt clippy
+RUN cargo install cargo-insta cargo-audit
+
+# Create persistent config directory with default ownership
+RUN mkdir -p ~/.config-persistent/
+
+# Configure Nix
+RUN mkdir -p ~/.config/nix \
+ && printf 'store = local?root=/\nexperimental-features = nix-command flakes' > ~/.config/nix/nix.conf
+
+# Install Nix packages
+ENV PATH="${PATH}:/home/dev/.nix-profile/bin"
+RUN nix profile install nixpkgs#alejandra
+
+# Create Claude Code directories with default ownership
+RUN mkdir -p ~/.config/claude/
+
+# Create OpenCode directories with default ownership
+RUN mkdir -p ~/.config/opencode/ \
+ && mkdir -p ~/.local/share/opencode/
+
+ENTRYPOINT ["/bin/bash"]
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..c45c120
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,123 @@
+{
+ "name": "XC Bot Devcontainer",
+ "$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json",
+
+ "build": {
+ "dockerfile": "Dockerfile"
+ },
+
+ // Container runtime options
+ "runArgs": [
+ // Basic security: Limited capabilities
+ "--cap-drop=ALL",
+ "--cap-add=CHOWN",
+ "--cap-add=SETGID",
+ ],
+
+ // Run as non-root user
+ "remoteUser": "dev",
+
+ // Automatically update container user UID/GID to match host user
+ "updateRemoteUserUID": true,
+
+ // Set working directory (devcontainer mounts workspace here)
+ "workspaceMount": "source=${localWorkspaceFolder},target=/code,type=bind,consistency=cached",
+ "workspaceFolder": "/code",
+
+ // Commands run after creating devcontainer
+ "initializeCommand": {
+ "create-claude-files": "mkdir -p ${HOME}/.config/claude/commands && for f in settings.json .credentials.json .claude.json; do [ -f ${HOME}/.config/claude/$f ] || echo '{}' > ${HOME}/.config/claude/$f; done && touch ${HOME}/.config/claude/CLAUDE.md",
+ "create-opencode-config-dir": "mkdir -p ${HOME}/.config/opencode",
+ "create-opencode-auth-file": "mkdir -p ${HOME}/.local/share/opencode && touch ${HOME}/.local/share/opencode/auth.json",
+ },
+
+ // Mounts
+ "mounts": [
+ // Persistent config (project-specific named volume for bash history etc.)
+ "source=devcontainer--${localWorkspaceFolderBasename}--config-persistent,target=/home/dev/.config-persistent,type=volume",
+ // Claude Code state (project-specific named volume for sessions, todos, statsig, etc.)
+ "source=devcontainer--${localWorkspaceFolderBasename}--claude-data,target=/home/dev/.config/claude,type=volume",
+ // Claude Code config and auth (shared across projects, bind-mounted on top of volume)
+ "source=${localEnv:HOME}/.config/claude/settings.json,target=/home/dev/.config/claude/settings.json,type=bind,consistency=cached",
+ "source=${localEnv:HOME}/.config/claude/CLAUDE.md,target=/home/dev/.config/claude/CLAUDE.md,type=bind,consistency=cached",
+ "source=${localEnv:HOME}/.config/claude/commands,target=/home/dev/.config/claude/commands,type=bind,consistency=cached",
+ "source=${localEnv:HOME}/.config/claude/.credentials.json,target=/home/dev/.config/claude/.credentials.json,type=bind,consistency=cached",
+ "source=${localEnv:HOME}/.config/claude/.claude.json,target=/home/dev/.config/claude/.claude.json,type=bind,consistency=cached",
+ // OpenCode config (shared across projects)
+ "source=${localEnv:HOME}/.config/opencode,target=/home/dev/.config/opencode,type=bind,consistency=cached",
+ // OpenCode data storage (project-specific named volume for sessions, messages, etc.)
+ "source=devcontainer--${localWorkspaceFolderBasename}--opencode-data,target=/home/dev/.local/share/opencode,type=volume",
+ // OpenCode credentials (shared from host, overlays on top of the volume)
+ "source=${localEnv:HOME}/.local/share/opencode/auth.json,target=/home/dev/.local/share/opencode/auth.json,type=bind,consistency=cached",
+ // Nix store
+ "source=devcontainer--${localWorkspaceFolderBasename}--nix,target=/nix,type=volume",
+ ],
+
+ // Container environment variables
+ "containerEnv": {
+ // Bash history
+ "HISTFILE": "/home/dev/.config-persistent/.bash_history",
+ // Claude Code
+ "CLAUDE_CONFIG_DIR": "/home/dev/.config/claude/",
+ },
+
+ // Features to install
+ "features": {
+ "./features/claude-code": {
+ "version": "latest"
+ },
+ "./features/opencode": {
+ "version": "latest",
+ "binaryType": "glibc"
+ },
+ },
+
+ // Forward ports
+ "forwardPorts": [],
+ "appPort": [], // Compat with CLI, see https://github.com/devcontainers/cli/issues/22
+
+ // After creating container
+ "postCreateCommand": "echo 'export PATH=\"$PATH:/home/dev/.local/bin\"' >> /home/dev/.bashrc",
+
+ // After first start
+ "postStartCommand": "",
+
+ // Customizations
+ "customizations": {
+ "vscode": {
+ "settings": {
+ // General
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "files.insertFinalNewline": true,
+ "editor.formatOnSave": true,
+ // Extensions
+ "rewrap.wrappingColumn": 110,
+ // JSON
+ "[jsonc]": {
+ "json.format.keepLines": true
+ },
+ // Rust
+ "[rust]": {
+ "editor.defaultFormatter": "rust-lang.rust-analyzer"
+ },
+ "rust-analyzer.check.command": "clippy"
+ // Nix
+ "[nix]": {
+ "editor.defaultFormatter": "kamadorueda.alejandra"
+ },
+ },
+ "extensions": [
+ // General
+ "dnut.rewrap-revived",
+ "esbenp.prettier-vscode",
+ // Rust
+ "rust-lang.rust-analyzer",
+ "tamasfe.even-better-toml",
+ // Nix
+ "kamadorueda.alejandra",
+ "bbenoist.nix",
+ ]
+ }
+ },
+
+}
diff --git a/.devcontainer/features/claude-code/claudecode-install.sh b/.devcontainer/features/claude-code/claudecode-install.sh
new file mode 100644
index 0000000..6250ad5
--- /dev/null
+++ b/.devcontainer/features/claude-code/claudecode-install.sh
@@ -0,0 +1,160 @@
+#!/bin/bash
+
+# Source: https://claude.ai/install.sh
+
+set -e
+
+# Parse command line arguments
+TARGET="$1" # Optional target parameter
+
+# Validate target if provided
+if [[ -n "$TARGET" ]] && [[ ! "$TARGET" =~ ^(stable|latest|[0-9]+\.[0-9]+\.[0-9]+(-[^[:space:]]+)?)$ ]]; then
+ echo "Usage: $0 [stable|latest|VERSION]" >&2
+ exit 1
+fi
+
+GCS_BUCKET="https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases"
+DOWNLOAD_DIR="$HOME/.claude/downloads"
+
+# Check for required dependencies
+DOWNLOADER=""
+if command -v curl >/dev/null 2>&1; then
+ DOWNLOADER="curl"
+elif command -v wget >/dev/null 2>&1; then
+ DOWNLOADER="wget"
+else
+ echo "Either curl or wget is required but neither is installed" >&2
+ exit 1
+fi
+
+# Check if jq is available (optional)
+HAS_JQ=false
+if command -v jq >/dev/null 2>&1; then
+ HAS_JQ=true
+fi
+
+# Download function that works with both curl and wget
+download_file() {
+ local url="$1"
+ local output="$2"
+
+ if [ "$DOWNLOADER" = "curl" ]; then
+ if [ -n "$output" ]; then
+ curl -fsSL -o "$output" "$url"
+ else
+ curl -fsSL "$url"
+ fi
+ elif [ "$DOWNLOADER" = "wget" ]; then
+ if [ -n "$output" ]; then
+ wget -q -O "$output" "$url"
+ else
+ wget -q -O - "$url"
+ fi
+ else
+ return 1
+ fi
+}
+
+# Simple JSON parser for extracting checksum when jq is not available
+get_checksum_from_manifest() {
+ local json="$1"
+ local platform="$2"
+
+ # Normalize JSON to single line and extract checksum
+ json=$(echo "$json" | tr -d '\n\r\t' | sed 's/ \+/ /g')
+
+ # Extract checksum for platform using bash regex
+ if [[ $json =~ \"$platform\"[^}]*\"checksum\"[[:space:]]*:[[:space:]]*\"([a-f0-9]{64})\" ]]; then
+ echo "${BASH_REMATCH[1]}"
+ return 0
+ fi
+
+ return 1
+}
+
+# Detect platform
+case "$(uname -s)" in
+ Darwin) os="darwin" ;;
+ Linux) os="linux" ;;
+ MINGW*|MSYS*|CYGWIN*) echo "Windows is not supported by this script. See https://code.claude.com/docs for installation options." >&2; exit 1 ;;
+ *) echo "Unsupported operating system: $(uname -s). See https://code.claude.com/docs for supported platforms." >&2; exit 1 ;;
+esac
+
+case "$(uname -m)" in
+ x86_64|amd64) arch="x64" ;;
+ arm64|aarch64) arch="arm64" ;;
+ *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
+esac
+
+# Detect Rosetta 2 on macOS: if the shell is running as x64 under Rosetta on an ARM Mac,
+# download the native arm64 binary instead of the x64 one
+if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
+ if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]; then
+ arch="arm64"
+ fi
+fi
+
+# Check for musl on Linux and adjust platform accordingly
+if [ "$os" = "linux" ]; then
+ if [ -f /lib/libc.musl-x86_64.so.1 ] || [ -f /lib/libc.musl-aarch64.so.1 ] || ldd /bin/ls 2>&1 | grep -q musl; then
+ platform="linux-${arch}-musl"
+ else
+ platform="linux-${arch}"
+ fi
+else
+ platform="${os}-${arch}"
+fi
+mkdir -p "$DOWNLOAD_DIR"
+
+# Always download latest version (which has the most up-to-date installer)
+version=$(download_file "$GCS_BUCKET/latest")
+
+# Download manifest and extract checksum
+manifest_json=$(download_file "$GCS_BUCKET/$version/manifest.json")
+
+# Use jq if available, otherwise fall back to pure bash parsing
+if [ "$HAS_JQ" = true ]; then
+ checksum=$(echo "$manifest_json" | jq -r ".platforms[\"$platform\"].checksum // empty")
+else
+ checksum=$(get_checksum_from_manifest "$manifest_json" "$platform")
+fi
+
+# Validate checksum format (SHA256 = 64 hex characters)
+if [ -z "$checksum" ] || [[ ! "$checksum" =~ ^[a-f0-9]{64}$ ]]; then
+ echo "Platform $platform not found in manifest" >&2
+ exit 1
+fi
+
+# Download and verify
+binary_path="$DOWNLOAD_DIR/claude-$version-$platform"
+if ! download_file "$GCS_BUCKET/$version/$platform/claude" "$binary_path"; then
+ echo "Download failed" >&2
+ rm -f "$binary_path"
+ exit 1
+fi
+
+# Pick the right checksum tool
+if [ "$os" = "darwin" ]; then
+ actual=$(shasum -a 256 "$binary_path" | cut -d' ' -f1)
+else
+ actual=$(sha256sum "$binary_path" | cut -d' ' -f1)
+fi
+
+if [ "$actual" != "$checksum" ]; then
+ echo "Checksum verification failed" >&2
+ rm -f "$binary_path"
+ exit 1
+fi
+
+chmod +x "$binary_path"
+
+# Run claude install to set up launcher and shell integration
+echo "Setting up Claude Code..."
+"$binary_path" install ${TARGET:+"$TARGET"}
+
+# Clean up downloaded file
+rm -f "$binary_path"
+
+echo ""
+echo "✅ Installation complete!"
+echo ""
diff --git a/.devcontainer/features/claude-code/devcontainer-feature.json b/.devcontainer/features/claude-code/devcontainer-feature.json
new file mode 100644
index 0000000..54a264d
--- /dev/null
+++ b/.devcontainer/features/claude-code/devcontainer-feature.json
@@ -0,0 +1,17 @@
+{
+ "id": "claude-code",
+ "version": "1.0.0",
+ "name": "Claude Code",
+ "description": "Installs Claude Code CLI agent tool",
+ "documentationURL": "https://code.claude.com/docs/en/setup",
+ "options": {
+ "version": {
+ "type": "string",
+ "default": "latest",
+ "description": "Version of Claude Code to install (e.g., '1.0.58', 'stable' or 'latest')"
+ }
+ },
+ "installsAfter": [
+ "ghcr.io/devcontainers/features/common-utils"
+ ]
+}
diff --git a/.devcontainer/features/claude-code/install.sh b/.devcontainer/features/claude-code/install.sh
new file mode 100644
index 0000000..c9d8666
--- /dev/null
+++ b/.devcontainer/features/claude-code/install.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Feature options
+CLAUDE_CODE_VERSION="${VERSION:-latest}"
+
+echo "Installing Claude Code as non-root user..."
+su -s /bin/bash "$_REMOTE_USER" -c "bash $(pwd)/claudecode-install.sh $CLAUDE_CODE_VERSION"
diff --git a/.devcontainer/features/opencode/devcontainer-feature.json b/.devcontainer/features/opencode/devcontainer-feature.json
new file mode 100644
index 0000000..2710c29
--- /dev/null
+++ b/.devcontainer/features/opencode/devcontainer-feature.json
@@ -0,0 +1,32 @@
+{
+ "id": "opencode",
+ "version": "1.0.0",
+ "name": "OpenCode",
+ "description": "Installs OpenCode CLI agent tool",
+ "documentationURL": "https://github.com/sst/opencode",
+ "options": {
+ "version": {
+ "type": "string",
+ "default": "latest",
+ "description": "Version of OpenCode to install (e.g., '1.0.115' or 'latest')"
+ },
+ "architecture": {
+ "type": "string",
+ "enum": ["auto", "x64", "arm64"],
+ "default": "auto",
+ "description": "Target architecture (auto will detect automatically)"
+ },
+ "binaryType": {
+ "type": "string",
+ "enum": [
+ "glibc",
+ "musl"
+ ],
+ "default": "glibc",
+ "description": "Binary type (musl for Alpine/static linking, glibc for standard Linux)"
+ }
+ },
+ "installsAfter": [
+ "ghcr.io/devcontainers/features/common-utils"
+ ]
+}
diff --git a/.devcontainer/features/opencode/install.sh b/.devcontainer/features/opencode/install.sh
new file mode 100644
index 0000000..372db7e
--- /dev/null
+++ b/.devcontainer/features/opencode/install.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Feature options
+OPENCODE_VERSION="${VERSION:-latest}"
+OPENCODE_ARCH="${ARCHITECTURE:-auto}"
+OPENCODE_BINARY_TYPE="${BINARYTYPE:-musl}"
+
+echo "Installing OpenCode..."
+
+# Determine architecture
+if [ "$OPENCODE_ARCH" = "auto" ]; then
+ case "$(uname -m)" in
+ x86_64)
+ OPENCODE_ARCH="x64"
+ ;;
+ aarch64 | arm64)
+ OPENCODE_ARCH="arm64"
+ ;;
+ *)
+ echo "Unsupported architecture: $(uname -m)"
+ exit 1
+ ;;
+ esac
+fi
+echo "Detected/selected architecture: $OPENCODE_ARCH"
+
+# Determine binary type suffix
+if [ "$OPENCODE_BINARY_TYPE" = "musl" ]; then
+ BINARY_SUFFIX="-musl"
+else
+ BINARY_SUFFIX=""
+fi
+echo "Binary type: $OPENCODE_BINARY_TYPE"
+
+# Resolve latest version if needed
+if [ "$OPENCODE_VERSION" = "latest" ]; then
+ echo "Fetching latest version..."
+ OPENCODE_VERSION=$(curl --proto '=https' --tlsv1.3 -LSsf "https://api.github.com/repos/sst/opencode/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
+ if [ -z "$OPENCODE_VERSION" ]; then
+ echo "Failed to determine latest version"
+ exit 1
+ fi
+fi
+echo "Installing OpenCode version: $OPENCODE_VERSION"
+
+# Download and install
+TARBALL_NAME="opencode-linux-${OPENCODE_ARCH}${BINARY_SUFFIX}.tar.gz"
+DOWNLOAD_URL="https://github.com/sst/opencode/releases/download/v${OPENCODE_VERSION}/${TARBALL_NAME}"
+echo "Downloading from: $DOWNLOAD_URL"
+
+curl --proto '=https' --tlsv1.3 -OLSsf "$DOWNLOAD_URL" \
+ && tar xfvz "$TARBALL_NAME" -C /usr/local/bin/ \
+ && rm -f "$TARBALL_NAME"
+
+# Verify installation
+if command -v opencode &> /dev/null; then
+ echo "OpenCode installed successfully!"
+ opencode --version || true
+else
+ echo "OpenCode installation failed"
+ exit 1
+fi
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 80814ab..fa4be3b 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: cd nix && 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: cd nix && 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..3edf4e2 100644
--- a/README.md
+++ b/README.md
@@ -55,11 +55,19 @@ 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.
+
+### Build and Evaluate NixOS Module
+
+ cd nix && nix flake check
+
## License
Licensed under the AGPL version 3 or later. See `LICENSE.md` file.
- Copyright (C) 2021–2025 Danilo Bargen
+ Copyright (C) 2021–2026 Danilo Bargen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
@@ -75,5 +83,6 @@ Licensed under the AGPL version 3 or later. See `LICENSE.md` file.
along with this program. If not, see .
+
[ci]: https://github.com/dbrgn/xc-bot/actions/workflows/ci.yml
[ci-badge]: https://github.com/dbrgn/xc-bot/actions/workflows/ci.yml/badge.svg
diff --git a/nix/flake.lock b/nix/flake.lock
new file mode 100644
index 0000000..6380d49
--- /dev/null
+++ b/nix/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1767480499,
+ "narHash": "sha256-8IQQUorUGiSmFaPnLSo2+T+rjHtiNWc+OAzeHck7N48=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "30a3c519afcf3f99e2c6df3b359aec5692054d92",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-25.11",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "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
+}
diff --git a/nix/flake.nix b/nix/flake.nix
new file mode 100644
index 0000000..c3ef5b0
--- /dev/null
+++ b/nix/flake.nix
@@ -0,0 +1,73 @@
+{
+ description = "A chat bot that notifies about new paragliding cross-country flights published on XContest";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
+ flake-utils.url = "github:numtide/flake-utils";
+ };
+
+ outputs = {
+ self,
+ nixpkgs,
+ flake-utils,
+ }:
+ flake-utils.lib.eachDefaultSystem (
+ system: let
+ pkgs = nixpkgs.legacyPackages.${system};
+ in {
+ packages.default = pkgs.callPackage ./package.nix {};
+
+ # Formatter for `nix fmt`
+ formatter = pkgs.alejandra;
+
+ # Checks run by `nix flake check`
+ checks = {
+ # Verify the package builds
+ package = self.packages.${system}.default;
+
+ # Evaluate the NixOS module to catch configuration errors
+ module =
+ (nixpkgs.lib.nixosSystem {
+ inherit system;
+ modules = [
+ self.nixosModules.default
+ {
+ # Minimal config to evaluate the module
+ services.xc-bot = {
+ enable = true;
+ threema = {
+ gatewayId = "*TESTID";
+ gatewaySecretFile = "/run/secrets/gateway-secret";
+ privateKeyFile = "/run/secrets/private-key";
+ };
+ };
+
+ # Required stub options for evaluation
+ nixpkgs.hostPlatform = system;
+ boot.loader.grub.enable = false;
+ fileSystems."/".device = "nodev";
+ system.stateVersion = "25.11";
+ }
+ ];
+ })
+ .config
+ .system
+ .build
+ .toplevel;
+ };
+ }
+ )
+ // {
+ # NixOS module (not system-specific)
+ # Includes the overlay so pkgs.xc-bot is available
+ nixosModules.default = {
+ imports = [./module.nix];
+ nixpkgs.overlays = [self.overlays.default];
+ };
+
+ # Overlay for adding the package to pkgs
+ overlays.default = final: prev: {
+ xc-bot = final.callPackage ./package.nix {};
+ };
+ };
+}
diff --git a/nix/module.nix b/nix/module.nix
new file mode 100644
index 0000000..83e59e1
--- /dev/null
+++ b/nix/module.nix
@@ -0,0 +1,206 @@
+{
+ 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 "Threema ID 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";
+ }
+ ];
+
+ # 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
+ tomlFormat = pkgs.formats.toml {};
+ configFile = tomlFormat.generate "xc-bot-config.toml" 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/nix/package.nix b/nix/package.nix
new file mode 100644
index 0000000..011daec
--- /dev/null
+++ b/nix/package.nix
@@ -0,0 +1,11 @@
+{
+ rustPlatform,
+ bind,
+ ...
+}:
+rustPlatform.buildRustPackage {
+ pname = "xc-bot";
+ version = "0.3.3";
+ src = ../.;
+ cargoLock.lockFile = ../Cargo.lock;
+}
diff --git a/nix/result b/nix/result
new file mode 120000
index 0000000..f896a89
--- /dev/null
+++ b/nix/result
@@ -0,0 +1 @@
+/nix/store/g5j086i77gbys9mvpbgh76j7kf2x9fzn-xc-bot-0.3.3
\ No newline at end of file
diff --git a/src/config.rs b/src/config.rs
index ab0ccc9..330d9e1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -18,7 +18,7 @@ pub struct ThreemaConfig {
pub gateway_secret: String,
/// The hex-encoded private key
pub private_key: String,
- /// Identity of the admin
+ /// Threema ID of the admin
pub admin_id: Option,
}