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, }