From ff3f6fe05bf972681214eb8c02ef0c3535e8ba64 Mon Sep 17 00:00:00 2001 From: tocmo0nlord Date: Fri, 17 Apr 2026 22:16:05 -0400 Subject: [PATCH] Add Debian .deb package support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - debian/control, changelog, conffiles — package metadata - debian/postinst — creates irc-bot system user, installs Python venv, symlinks runtime dirs, enables systemd services - debian/prerm/postrm — clean stop/uninstall with purge support - debian/systemd/ — hardened systemd units for bot and portal - build_deb.sh — assembles and builds the .deb via dpkg-deb - Path resolver in irc_client.py, memory.py, config_manager.py, portal/app.py: uses /etc/irc-bot + /var/lib/irc-bot when installed as .deb, relative paths otherwise (Docker/venv unchanged) Co-Authored-By: Claude Sonnet 4.6 --- bot/irc_client.py | 29 +++++++--- bot/memory.py | 6 +- build_deb.sh | 80 +++++++++++++++++++++++++++ debian/changelog | 5 ++ debian/conffiles | 2 + debian/control | 15 +++++ debian/postinst | 64 +++++++++++++++++++++ debian/postrm | 25 +++++++++ debian/prerm | 14 +++++ debian/systemd/irc-bot-portal.service | 27 +++++++++ debian/systemd/irc-bot.service | 27 +++++++++ portal/app.py | 15 +++-- portal/config_manager.py | 11 +++- 13 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 build_deb.sh create mode 100644 debian/changelog create mode 100644 debian/conffiles create mode 100644 debian/control create mode 100644 debian/postinst create mode 100644 debian/postrm create mode 100644 debian/prerm create mode 100644 debian/systemd/irc-bot-portal.service create mode 100644 debian/systemd/irc-bot.service diff --git a/bot/irc_client.py b/bot/irc_client.py index bcec6db..cefe68f 100644 --- a/bot/irc_client.py +++ b/bot/irc_client.py @@ -18,16 +18,27 @@ from pathlib import Path from dotenv import load_dotenv -load_dotenv() +# ── Path resolution: deb install vs Docker/venv ──────────────────────────── +# If /etc/irc-bot exists we're running from a .deb package install. +if os.path.isdir("/etc/irc-bot"): + _CONFIG_BASE = "/etc/irc-bot" + _DATA_BASE = "/var/lib/irc-bot" + _LOG_BASE = "/var/log/irc-bot" + load_dotenv("/etc/irc-bot/.env") +else: + _CONFIG_BASE = "config" + _DATA_BASE = "data" + _LOG_BASE = "logs" + load_dotenv() + +os.makedirs(_LOG_BASE, exist_ok=True) +os.makedirs(_DATA_BASE, exist_ok=True) +os.makedirs(_CONFIG_BASE, exist_ok=True) # ── Logging ──────────────────────────────────────────────────────────────── -os.makedirs("logs", exist_ok=True) -os.makedirs("data", exist_ok=True) -os.makedirs("config", exist_ok=True) - handler = logging.handlers.RotatingFileHandler( - "logs/bot.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8" + os.path.join(_LOG_BASE, "bot.log"), maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8" ) handler.setFormatter( logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") @@ -43,9 +54,9 @@ from bot.message_handler import handle_privmsg # ── Config ───────────────────────────────────────────────────────────────── -CONFIG_PATH = "config/config.json" -PID_PATH = "data/ircbot.pid" -SOCK_PATH = "data/ircbot.sock" +CONFIG_PATH = os.path.join(_CONFIG_BASE, "config.json") +PID_PATH = os.path.join(_DATA_BASE, "ircbot.pid") +SOCK_PATH = os.path.join(_DATA_BASE, "ircbot.sock") _config: dict = {} _config_lock = threading.Lock() diff --git a/bot/memory.py b/bot/memory.py index 7fdd9c4..ab3660c 100644 --- a/bot/memory.py +++ b/bot/memory.py @@ -5,7 +5,11 @@ import re logger = logging.getLogger(__name__) -HISTORY_DIR = "data/history" +HISTORY_DIR = ( + "/var/lib/irc-bot/history" + if os.path.isdir("/var/lib/irc-bot") + else "data/history" +) def _sanitize_channel(channel: str) -> str: diff --git a/build_deb.sh b/build_deb.sh new file mode 100644 index 0000000..4d252f5 --- /dev/null +++ b/build_deb.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Build a .deb package for irc-bot. +# Run on any Debian/Ubuntu host: bash build_deb.sh +set -euo pipefail + +PACKAGE=irc-bot +VERSION=1.0.0 +ARCH=all +DEB_NAME="${PACKAGE}_${VERSION}_${ARCH}.deb" +BUILD_DIR="$(mktemp -d)" +PKG_ROOT="${BUILD_DIR}/${PACKAGE}" + +echo "==> Building ${DEB_NAME} in ${BUILD_DIR}" + +# ── Directory layout inside the .deb ───────────────────────────────────────── +install -d "${PKG_ROOT}/DEBIAN" +install -d "${PKG_ROOT}/opt/irc-bot/bot" +install -d "${PKG_ROOT}/opt/irc-bot/portal/templates" +install -d "${PKG_ROOT}/opt/irc-bot/portal/static" +install -d "${PKG_ROOT}/opt/irc-bot/config" +install -d "${PKG_ROOT}/lib/systemd/system" +# Runtime dirs are created by postinst, not shipped in the package +# (avoids dpkg owning /var/lib/irc-bot with wrong permissions before user exists) + +# ── Application source ──────────────────────────────────────────────────────── +cp bot/__init__.py "${PKG_ROOT}/opt/irc-bot/bot/" +cp bot/irc_client.py "${PKG_ROOT}/opt/irc-bot/bot/" +cp bot/llm_client.py "${PKG_ROOT}/opt/irc-bot/bot/" +cp bot/memory.py "${PKG_ROOT}/opt/irc-bot/bot/" +cp bot/message_handler.py "${PKG_ROOT}/opt/irc-bot/bot/" + +cp portal/__init__.py "${PKG_ROOT}/opt/irc-bot/portal/" +cp portal/app.py "${PKG_ROOT}/opt/irc-bot/portal/" +cp portal/config_manager.py "${PKG_ROOT}/opt/irc-bot/portal/" +cp -r portal/templates/ "${PKG_ROOT}/opt/irc-bot/portal/" +cp -r portal/static/ "${PKG_ROOT}/opt/irc-bot/portal/" + +cp requirements.txt "${PKG_ROOT}/opt/irc-bot/" +cp .env.example "${PKG_ROOT}/opt/irc-bot/" +cp README.md "${PKG_ROOT}/opt/irc-bot/" + +# Default config.json — marked as conffile so dpkg won't overwrite on upgrade +install -d "${PKG_ROOT}/etc/irc-bot" +cp config/config.json "${PKG_ROOT}/etc/irc-bot/config.json" + +# ── Systemd units ───────────────────────────────────────────────────────────── +cp debian/systemd/irc-bot.service "${PKG_ROOT}/lib/systemd/system/" +cp debian/systemd/irc-bot-portal.service "${PKG_ROOT}/lib/systemd/system/" + +# ── DEBIAN control files ────────────────────────────────────────────────────── +cp debian/control "${PKG_ROOT}/DEBIAN/control" +cp debian/conffiles "${PKG_ROOT}/DEBIAN/conffiles" +cp debian/postinst "${PKG_ROOT}/DEBIAN/postinst" +cp debian/prerm "${PKG_ROOT}/DEBIAN/prerm" +cp debian/postrm "${PKG_ROOT}/DEBIAN/postrm" + +chmod 755 "${PKG_ROOT}/DEBIAN/postinst" +chmod 755 "${PKG_ROOT}/DEBIAN/prerm" +chmod 755 "${PKG_ROOT}/DEBIAN/postrm" + +# ── Installed-Size (approximate, in KB) ────────────────────────────────────── +SIZE_KB=$(du -sk "${PKG_ROOT}" | cut -f1) +sed -i "s/^Installed-Size:.*/Installed-Size: ${SIZE_KB}/" "${PKG_ROOT}/DEBIAN/control" 2>/dev/null || true +printf "\nInstalled-Size: ${SIZE_KB}\n" >> "${PKG_ROOT}/DEBIAN/control" + +# ── Build ───────────────────────────────────────────────────────────────────── +dpkg-deb --build --root-owner-group "${PKG_ROOT}" "${DEB_NAME}" + +echo "" +echo "==> Built: ${DEB_NAME}" +echo "" +echo "Install with:" +echo " sudo dpkg -i ${DEB_NAME}" +echo " sudo apt-get install -f # resolve any missing deps" +echo "" +echo "Then edit /etc/irc-bot/.env and start:" +echo " sudo systemctl start irc-bot irc-bot-portal" + +# Cleanup +rm -rf "${BUILD_DIR}" diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..b33a642 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +irc-bot (1.0.0) stable; urgency=low + + * Initial release. + + -- tocmo0nlord Thu, 17 Apr 2026 00:00:00 +0000 diff --git a/debian/conffiles b/debian/conffiles new file mode 100644 index 0000000..47795ae --- /dev/null +++ b/debian/conffiles @@ -0,0 +1,2 @@ +/etc/irc-bot/config.json +/etc/irc-bot/.env diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..cb22204 --- /dev/null +++ b/debian/control @@ -0,0 +1,15 @@ +Package: irc-bot +Version: 1.0.0 +Section: net +Priority: optional +Architecture: all +Installed-Size: 0 +Depends: python3 (>= 3.11), python3-pip, python3-venv, adduser +Recommends: sqlite3 +Maintainer: tocmo0nlord +Description: Active Blue IRC LLM Bot + An IRC bot that connects to a ZNC bouncer, joins configured channels, + and responds to users via a locally hosted Ollama LLM. Includes a + Flask-based web admin portal for live configuration. Conversation + history is persisted per user per channel in SQLite. +Homepage: http://192.168.1.64:3000/tocmo0nlord/irc-bot diff --git a/debian/postinst b/debian/postinst new file mode 100644 index 0000000..7aaa517 --- /dev/null +++ b/debian/postinst @@ -0,0 +1,64 @@ +#!/bin/bash +set -e + +APP_DIR=/opt/irc-bot +DATA_DIR=/var/lib/irc-bot +LOG_DIR=/var/log/irc-bot +CONF_DIR=/etc/irc-bot +USER=irc-bot +GROUP=irc-bot + +case "$1" in + configure) + # Create system user/group if they don't exist + if ! getent group "$GROUP" > /dev/null 2>&1; then + addgroup --system "$GROUP" + fi + if ! getent passwd "$USER" > /dev/null 2>&1; then + adduser --system --ingroup "$GROUP" --no-create-home \ + --home "$DATA_DIR" --shell /usr/sbin/nologin "$USER" + fi + + # Runtime directories + install -d -m 750 -o "$USER" -g "$GROUP" "$DATA_DIR" + install -d -m 750 -o "$USER" -g "$GROUP" "$DATA_DIR/history" + install -d -m 750 -o "$USER" -g "$GROUP" "$LOG_DIR" + + # Config directory — preserve existing .env if present + install -d -m 750 -o "$USER" -g "$GROUP" "$CONF_DIR" + if [ ! -f "$CONF_DIR/.env" ]; then + cp "$APP_DIR/.env.example" "$CONF_DIR/.env" + chown "$USER:$GROUP" "$CONF_DIR/.env" + chmod 640 "$CONF_DIR/.env" + echo "" + echo " ┌─────────────────────────────────────────────────────┐" + echo " │ irc-bot: edit /etc/irc-bot/.env before starting │" + echo " │ Set ZNC_USER, ZNC_PASSWORD, ZNC_NETWORK at minimum │" + echo " └─────────────────────────────────────────────────────┘" + echo "" + fi + + # Symlink runtime directories into app dir so relative paths work + ln -sfn "$DATA_DIR" "$APP_DIR/data" + ln -sfn "$LOG_DIR" "$APP_DIR/logs" + ln -sfn "$CONF_DIR" "$APP_DIR/config" + + # Install Python venv with dependencies + if [ ! -d "$APP_DIR/venv" ]; then + echo "irc-bot: creating Python venv and installing dependencies..." + python3 -m venv "$APP_DIR/venv" + "$APP_DIR/venv/bin/pip" install --quiet --no-cache-dir -r "$APP_DIR/requirements.txt" + chown -R "$USER:$GROUP" "$APP_DIR/venv" + fi + + # Enable and start systemd services + if [ -d /run/systemd/system ]; then + systemctl daemon-reload + systemctl enable irc-bot.service irc-bot-portal.service + echo "irc-bot: services enabled. Start with:" + echo " systemctl start irc-bot irc-bot-portal" + fi + ;; +esac + +exit 0 diff --git a/debian/postrm b/debian/postrm new file mode 100644 index 0000000..e1be443 --- /dev/null +++ b/debian/postrm @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +case "$1" in + purge) + # Remove data, logs, config only on purge (not plain remove) + rm -rf /var/lib/irc-bot /var/log/irc-bot /etc/irc-bot + # Remove system user + if getent passwd irc-bot > /dev/null 2>&1; then + deluser --system irc-bot 2>/dev/null || true + fi + if getent group irc-bot > /dev/null 2>&1; then + delgroup --system irc-bot 2>/dev/null || true + fi + if [ -d /run/systemd/system ]; then + systemctl daemon-reload + fi + ;; + remove) + # Remove the venv on plain remove (can be rebuilt on reinstall) + rm -rf /opt/irc-bot/venv + ;; +esac + +exit 0 diff --git a/debian/prerm b/debian/prerm new file mode 100644 index 0000000..d381c8b --- /dev/null +++ b/debian/prerm @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +case "$1" in + remove|upgrade|deconfigure) + if [ -d /run/systemd/system ]; then + systemctl stop irc-bot-portal.service 2>/dev/null || true + systemctl stop irc-bot.service 2>/dev/null || true + systemctl disable irc-bot.service irc-bot-portal.service 2>/dev/null || true + fi + ;; +esac + +exit 0 diff --git a/debian/systemd/irc-bot-portal.service b/debian/systemd/irc-bot-portal.service new file mode 100644 index 0000000..89eed8b --- /dev/null +++ b/debian/systemd/irc-bot-portal.service @@ -0,0 +1,27 @@ +[Unit] +Description=Active Blue IRC Bot Web Portal +Documentation=http://192.168.1.64:3000/tocmo0nlord/irc-bot +After=network-online.target irc-bot.service +Wants=network-online.target +BindsTo=irc-bot.service + +[Service] +Type=simple +User=irc-bot +Group=irc-bot +WorkingDirectory=/opt/irc-bot +Environment="PYTHONPATH=/opt/irc-bot" +EnvironmentFile=/etc/irc-bot/.env +ExecStart=/opt/irc-bot/venv/bin/python -m portal.app +Restart=on-failure +RestartSec=10 +StandardOutput=append:/var/log/irc-bot/portal.log +StandardError=append:/var/log/irc-bot/portal.log + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/etc/irc-bot /var/lib/irc-bot /var/log/irc-bot + +[Install] +WantedBy=multi-user.target diff --git a/debian/systemd/irc-bot.service b/debian/systemd/irc-bot.service new file mode 100644 index 0000000..4f106c0 --- /dev/null +++ b/debian/systemd/irc-bot.service @@ -0,0 +1,27 @@ +[Unit] +Description=Active Blue IRC LLM Bot +Documentation=http://192.168.1.64:3000/tocmo0nlord/irc-bot +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=irc-bot +Group=irc-bot +WorkingDirectory=/opt/irc-bot +Environment="PYTHONPATH=/opt/irc-bot" +EnvironmentFile=/etc/irc-bot/.env +ExecStart=/opt/irc-bot/venv/bin/python -m bot.irc_client +Restart=on-failure +RestartSec=10 +StandardOutput=append:/var/log/irc-bot/bot.log +StandardError=append:/var/log/irc-bot/bot.log + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/etc/irc-bot /var/lib/irc-bot /var/log/irc-bot + +[Install] +WantedBy=multi-user.target diff --git a/portal/app.py b/portal/app.py index b9c73a8..3f23ef0 100644 --- a/portal/app.py +++ b/portal/app.py @@ -7,7 +7,12 @@ from dotenv import load_dotenv from flask import Flask, abort, jsonify, redirect, render_template, request, url_for, send_file import io -load_dotenv() +if os.path.isdir("/etc/irc-bot"): + load_dotenv("/etc/irc-bot/.env") + _LOG_PATH = "/var/log/irc-bot/bot.log" +else: + load_dotenv() + _LOG_PATH = _LOG_PATH app = Flask(__name__, template_folder="templates", static_folder="static") app.secret_key = os.getenv("PORTAL_SECRET_KEY", "changeme") @@ -66,7 +71,7 @@ def action_reconnect(): @app.route("/action/clear_log", methods=["POST"]) def action_clear_log(): try: - open("logs/bot.log", "w").close() + open(_LOG_PATH, "w").close() except Exception: pass return redirect(url_for("logs")) @@ -164,7 +169,7 @@ def bot(): @app.route("/logs") def logs(): lines = [] - log_path = "logs/bot.log" + log_path = _LOG_PATH if os.path.exists(log_path): with open(log_path, "r", encoding="utf-8", errors="replace") as f: all_lines = f.readlines() @@ -174,7 +179,7 @@ def logs(): @app.route("/logs/download") def logs_download(): - log_path = "logs/bot.log" + log_path = _LOG_PATH if not os.path.exists(log_path): abort(404) return send_file(log_path, as_attachment=True, download_name="bot.log") @@ -183,7 +188,7 @@ def logs_download(): @app.route("/api/logs") def api_logs(): lines = [] - log_path = "logs/bot.log" + log_path = _LOG_PATH if os.path.exists(log_path): with open(log_path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines()[-200:] diff --git a/portal/config_manager.py b/portal/config_manager.py index bd80be4..3a10d38 100644 --- a/portal/config_manager.py +++ b/portal/config_manager.py @@ -7,9 +7,14 @@ import sys logger = logging.getLogger(__name__) -CONFIG_PATH = "config/config.json" -PID_PATH = "data/ircbot.pid" -SOCK_PATH = "data/ircbot.sock" +if os.path.isdir("/etc/irc-bot"): + CONFIG_PATH = "/etc/irc-bot/config.json" + PID_PATH = "/var/lib/irc-bot/ircbot.pid" + SOCK_PATH = "/var/lib/irc-bot/ircbot.sock" +else: + CONFIG_PATH = "config/config.json" + PID_PATH = "data/ircbot.pid" + SOCK_PATH = "data/ircbot.sock" def load_config() -> dict: