Skill: netkr/mini-hids (version 1.2.0)

- # Mini-HIDS Stop brute-force IPs and suspicious web payloads on a small Linux server in minutes, without deploying a full SIEM or heavyweight EDR stack. ![Mini-HIDS flow](C:/Users/luxun/Documents/trae_projects/hids/images/mini-hids-flow.svg) ### [**GitHub profile**](https://github.com/netkr/mini-hids) Mini-HIDS is a lightweight Linux host intrusion detection tool built with the Python standard library. It focuses on three things that are easy to operationalize on small servers: - Detect repeated SSH login failures with a sliding time window - Detect obvious web attack payloads from access logs - Scan common script files for suspicious webshell patterns It also exposes both a JSON CLI and a minimal MCP server, so AI agents can inspect status, read alerts, query the blacklist, and trigger ban or unban actions through a standard tool interface. ## Why This Exists Most open-source security tools are optimized for human operators first. Mini-HIDS is intentionally small enough to understand quickly, script easily, and embed into agent workflows without a large control plane. This repository is a good fit if you want: - A single-host defensive tool for VPS or small Linux fleets - A JSON-first CLI for automation and agent usage - Simple, inspectable detection logic instead of opaque pipelines - A local MCP tool server that IDE agents can call directly This repository is not a good fit if you need: - Cross-host correlation or centralized SOC workflows - Kernel telemetry, eBPF, or endpoint prevention - High-fidelity detection engineering with low false positives ## Architecture - `mini_hids.py`: long-running daemon that tails logs, tracks attack windows, bans IPs, and rescans web roots - `hids_cli.py`: JSON-only control-plane CLI for operators and agents - `hids_common.py`: shared config loading, SQLite helpers, IP validation, and firewall backends - `mcp_server.py`: stdio MCP adapter that exposes Mini-HIDS actions as agent-callable tools - `config.json`: runtime configuration loaded by both the daemon and the CLI - `llms.txt`: LLM-oriented project map for AI search and coding assistants ## Quick Start ```bash git clone https://github.com/netkr/mini-hids.git cd mini-hids ``` Adjust `config.json`, then start the daemon: ```bash sudo python3 mini_hids.py ``` Use the JSON CLI: ```bash python3 hids_cli.py --action status python3 hids_cli.py --action get_alerts --lines 20 python3 hids_cli.py --action get_blacklist python3 hids_cli.py --action ban --ip 192.168.1.100 --reason "manual ban" python3 hids_cli.py --action unban --ip 192.168.1.100 ``` ## Use With AI Agents Mini-HIDS now ships with a local MCP server. That means tools like Cursor, Claude Desktop, and other MCP-compatible clients can call the project directly instead of shelling out ad hoc. Run the MCP server: ```bash python3 mcp_server.py ``` Example client config: ```json { "mcpServers": { "mini-hids": { "command": "python3", "args": ["/absolute/path/to/mini-hids/mcp_server.py"] } } } ``` A ready-to-copy sample is also included at [`examples/claude_desktop_mcp.json`](examples/claude_desktop_mcp.json). Available MCP tools: - `mini_hids_status` - `mini_hids_get_alerts` - `mini_hids_get_blacklist` - `mini_hids_ban_ip` - `mini_hids_unban_ip` This is the practical replacement for a fake "one-click deploy" button. Mini-HIDS needs local log access and firewall privileges, so local or server-side MCP integration is the correct deployment model. ## CLI Output All CLI commands return JSON. Example: ```json { "success": true, "data": { "is_running": true, "pid": 12345, "firewall_backend": "iptables" } } ``` ## Requirements - Python 3.6+ - Linux - Root privileges for firewall operations and protected log access - One supported firewall backend: - `iptables` - `nft` - `fail2ban-client` ## Configuration Edit `config.json` instead of modifying the Python files. ```json { "LOG_PATHS": { "auth": ["/var/log/auth.log", "/var/log/secure"], "web": ["/var/log/nginx/access.log", "/var/log/apache2/access.log"], "mysql": ["/var/log/mysql/mysql.log", "/var/log/mysql/error.log"] }, "BAN_TIME": 3600, "TRUSTED_IPS": ["127.0.0.1", "192.168.1.1"], "WEB_ROOT": ["/var/www/html", "/var/www"], "BLACKLIST_DB": "blacklist.db", "ALERT_LOG": "hids_alert.log", "PID_FILE": "mini_hids.pid", "MAX_FAILURES": 5, "WINDOW_SECONDS": 300, "CHECK_INTERVAL": 1, "WEBSHELL_SCAN_INTERVAL": 3600 } ``` Notes: - `BLACKLIST_DB`, `ALERT_LOG`, and `PID_FILE` can be absolute paths. If they are relative, they are created in the project directory. - `CHECK_INTERVAL` controls how often the daemon checks for expired bans. - `WEBSHELL_SCAN_INTERVAL` controls how often the daemon rescans web roots. - `TRUSTED_IPS` are never banned by the daemon or the CLI. ## Security Notes - Run the daemon as root if you need firewall enforcement or access to privileged logs. - Review `TRUSTED_IPS` carefully to avoid locking yourself out. - Web attack and webshell detection are heuristic. Treat alerts as signals, not final verdicts. - MCP clients should be treated as privileged local integrations, since they can trigger ban and unban operations. ## Limitations - Detection is regex-based and intentionally simple. - The project does not yet ship with automated tests or service packaging. - `nftables` support uses a dedicated `mini_hids` table and timeout-enabled sets, so existing firewall policies should still be reviewed before production use. ## v1.2 Release Notes - Unified runtime configuration loading from config.json with default merging - Added shared core module for config, firewall, IP validation, and blacklist persistence - Added SQLite-backed blacklist persistence with automatic recovery and expired-entry cleanup - Improved ban/unban idempotency and reduced risk of duplicate firewall rules - Fixed firewall backend detection, including proper nftables support - Improved daemon scheduling so ban expiry is checked on a short interval - Added incremental webshell scanning based on file modification time - Improved log tailing robustness with log rotation handling - Normalized runtime file paths for blacklist.db, hids_alert.log, and mini_hids.pid - Added JSON CLI for status, alerts, blacklist inspection, manual ban, and unban

path: config.json
content: {
  "LOG_PATHS": {
    "auth": ["/var/log/auth.log", "/var/log/secure"],
    "web": ["/var/log/nginx/access.log", "/var/log/apache2/access.log"],
    "mysql": ["/var/log/mysql/mysql.log", "/var/log/mysql/error.log"]
  },
  "BAN_TIME": 3600,
  "TRUSTED_IPS": ["127.0.0.1", "192.168.1.1"],
  "WEB_ROOT": ["/var/www/html", "/var/www"],
  "BLACKLIST_DB": "blacklist.db",
  "ALERT_LOG": "hids_alert.log",
  "PID_FILE": "mini_hids.pid",
  "MAX_FAILURES": 5,
  "WINDOW_SECONDS": 300,
  "CHECK_INTERVAL": 1,
  "WEBSHELL_SCAN_INTERVAL": 3600
}


path: examples/claude_desktop_mcp.json
content: {
  "mcpServers": {
    "mini-hids": {
      "command": "python3",
      "args": [
        "/absolute/path/to/mini-hids/mcp_server.py"
      ]
    }
  }
}


path: hids_cli.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
轻量级主机入侵检测与日志分析系统（Mini-HIDS）
版本：v1.1
功能：CLI 命令行工具，专供 Agent 调用
定位：控制面 / Agent 专属调用接口，无阻塞、无死循环，执行完立刻返回标准 JSON 字符串
"""

import argparse
import json
import os
import time

from hids_common import (
    FirewallManager,
    delete_blacklist_entry,
    detect_firewall,
    init_db,
    list_blacklist_rows,
    load_config,
    purge_expired_blacklist_entries,
    upsert_blacklist_entry,
    validate_ip,
)


CONFIG = load_config()
FIREWALL = FirewallManager()


def ensure_runtime():
    init_db(CONFIG["BLACKLIST_DB"])
    purge_expired_blacklist_entries(CONFIG["BLACKLIST_DB"])


def ban_ip(ip, reason):
    if not validate_ip(ip):
        return {"success": False, "message": f"无效的 IP 地址: {ip}"}

    if ip in CONFIG["TRUSTED_IPS"]:
        return {"success": False, "message": f"IP {ip} 在白名单中，拒绝封禁"}

    current_blacklist = {row[0]: row[1] for row in list_blacklist_rows(CONFIG["BLACKLIST_DB"])}
    if current_blacklist.get(ip, 0) > int(time.time()):
        return {"success": True, "message": f"IP {ip} 已在黑名单中"}

    expiry_time = int(time.time() + CONFIG["BAN_TIME"])
    try:
        FIREWALL.ban_ip(ip, CONFIG["BAN_TIME"])
        upsert_blacklist_entry(CONFIG["BLACKLIST_DB"], ip, expiry_time, reason)
    except Exception as exc:
        try:
            FIREWALL.unban_ip(ip)
        except Exception:
            pass
        return {"success": False, "message": f"封禁失败: {exc}"}

    return {
        "success": True,
        "message": f"IP {ip} 已成功封禁，原因: {reason}",
        "data": {"expiry_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(expiry_time))},
    }


def unban_ip(ip):
    if not validate_ip(ip):
        return {"success": False, "message": f"无效的 IP 地址: {ip}"}

    current_blacklist = {row[0]: row[1] for row in list_blacklist_rows(CONFIG["BLACKLIST_DB"])}
    if ip not in current_blacklist:
        delete_blacklist_entry(CONFIG["BLACKLIST_DB"], ip)
        return {"success": True, "message": f"IP {ip} 当前不在黑名单中"}

    try:
        FIREWALL.unban_ip(ip)
        delete_blacklist_entry(CONFIG["BLACKLIST_DB"], ip)
    except Exception as exc:
        return {"success": False, "message": f"解封失败: {exc}"}

    return {"success": True, "message": f"IP {ip} 已成功解封"}


def get_status():
    is_running = False
    pid = None

    if os.path.exists(CONFIG["PID_FILE"]):
        try:
            with open(CONFIG["PID_FILE"], "r", encoding="utf-8") as pid_file:
                pid = int(pid_file.read().strip())
            os.kill(pid, 0)
            is_running = True
        except Exception:
            pid = None

    load_avg = ""
    try:
        with open("/proc/loadavg", "r", encoding="utf-8") as load_file:
            load_avg = load_file.read().strip()
    except Exception:
        pass

    return {
        "success": True,
        "data": {
            "is_running": is_running,
            "pid": pid,
            "load_avg": load_avg,
            "firewall_backend": detect_firewall(),
            "blacklist_db": CONFIG["BLACKLIST_DB"],
            "alert_log": CONFIG["ALERT_LOG"],
            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
        },
    }


def get_alerts(lines=10):
    if lines <= 0:
        return {"success": False, "message": "lines 必须大于 0"}

    alerts = []
    try:
        if os.path.exists(CONFIG["ALERT_LOG"]):
            with open(CONFIG["ALERT_LOG"], "r", encoding="utf-8", errors="ignore") as alert_file:
                recent_lines = alert_file.readlines()[-lines:]
            alerts = [line.strip() for line in recent_lines if line.strip()]
    except Exception as exc:
        return {"success": False, "message": f"读取告警日志失败: {exc}"}

    return {
        "success": True,
        "data": {
            "alerts": alerts,
            "count": len(alerts),
            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
        },
    }


def get_blacklist():
    current_time = int(time.time())
    blacklist = []

    try:
        for ip, ban_time, reason in list_blacklist_rows(CONFIG["BLACKLIST_DB"]):
            if ban_time <= current_time:
                continue
            blacklist.append(
                {
                    "ip": ip,
                    "ban_time": ban_time,
                    "expiry_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ban_time)),
                    "reason": reason,
                    "time_remaining": ban_time - current_time,
                }
            )
    except Exception as exc:
        return {"success": False, "message": f"读取黑名单失败: {exc}"}

    return {
        "success": True,
        "data": {
            "blacklist": blacklist,
            "count": len(blacklist),
            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
        },
    }


def main():
    ensure_runtime()

    parser = argparse.ArgumentParser(description="Mini-HIDS CLI 工具")
    parser.add_argument(
        "--action",
        type=str,
        required=True,
        choices=["status", "get_alerts", "get_blacklist", "ban", "unban"],
        help="执行的操作",
    )
    parser.add_argument("--ip", type=str, help="IP 地址")
    parser.add_argument("--reason", type=str, help="封禁原因")
    parser.add_argument("--lines", type=int, default=10, help="获取告警日志的行数")
    args = parser.parse_args()

    try:
        if args.action == "status":
            result = get_status()
        elif args.action == "get_alerts":
            result = get_alerts(args.lines)
        elif args.action == "get_blacklist":
            result = get_blacklist()
        elif args.action == "ban":
            if not args.ip:
                result = {"success": False, "message": "缺少 IP 地址参数"}
            elif not args.reason:
                result = {"success": False, "message": "缺少封禁原因参数"}
            else:
                result = ban_ip(args.ip, args.reason)
        else:
            if not args.ip:
                result = {"success": False, "message": "缺少 IP 地址参数"}
            else:
                result = unban_ip(args.ip)
    except Exception as exc:
        result = {"success": False, "message": f"执行操作失败: {exc}"}

    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: hids_common.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Mini-HIDS shared helpers.
"""

import json
import os
import shutil
import socket
import sqlite3
import subprocess
import time


BASE_DIR = os.path.dirname(os.path.abspath(__file__))

DEFAULT_CONFIG = {
    "LOG_PATHS": {
        "auth": ["/var/log/auth.log", "/var/log/secure"],
        "web": ["/var/log/nginx/access.log", "/var/log/apache2/access.log"],
        "mysql": ["/var/log/mysql/mysql.log", "/var/log/mysql/error.log"],
    },
    "BAN_TIME": 3600,
    "TRUSTED_IPS": ["127.0.0.1", "192.168.1.1"],
    "WEB_ROOT": ["/var/www/html", "/var/www"],
    "BLACKLIST_DB": "blacklist.db",
    "ALERT_LOG": "hids_alert.log",
    "PID_FILE": "mini_hids.pid",
    "MAX_FAILURES": 5,
    "WINDOW_SECONDS": 300,
    "CHECK_INTERVAL": 1,
    "WEBSHELL_SCAN_INTERVAL": 3600,
}


def _deep_merge(defaults, overrides):
    merged = dict(defaults)
    for key, value in overrides.items():
        if isinstance(value, dict) and isinstance(merged.get(key), dict):
            merged[key] = _deep_merge(merged[key], value)
        else:
            merged[key] = value
    return merged


def resolve_path(path_value):
    if os.path.isabs(path_value):
        return path_value
    return os.path.join(BASE_DIR, path_value)


def load_config():
    config = dict(DEFAULT_CONFIG)
    config_path = os.path.join(BASE_DIR, "config.json")

    if os.path.exists(config_path):
        with open(config_path, "r", encoding="utf-8") as config_file:
            loaded = json.load(config_file)
        config = _deep_merge(config, loaded)

    for path_key in ("BLACKLIST_DB", "ALERT_LOG", "PID_FILE"):
        config[path_key] = resolve_path(config[path_key])

    return config


def validate_ip(ip):
    try:
        socket.inet_pton(socket.AF_INET, ip)
        return True
    except OSError:
        try:
            socket.inet_pton(socket.AF_INET6, ip)
            return True
        except OSError:
            return False


def is_ipv6(ip):
    try:
        socket.inet_pton(socket.AF_INET6, ip)
        return True
    except OSError:
        return False


def detect_firewall():
    if shutil.which("iptables"):
        return "iptables"
    if shutil.which("nft"):
        return "nftables"
    if shutil.which("fail2ban-client"):
        return "fail2ban"
    return None


def ensure_parent_dir(path_value):
    parent = os.path.dirname(path_value)
    if parent:
        os.makedirs(parent, exist_ok=True)


def init_db(db_path):
    ensure_parent_dir(db_path)
    with sqlite3.connect(db_path) as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS blacklist (
                ip TEXT PRIMARY KEY,
                ban_time INTEGER NOT NULL,
                reason TEXT NOT NULL
            )
            """
        )
        conn.commit()


def list_blacklist_rows(db_path):
    if not os.path.exists(db_path):
        return []

    with sqlite3.connect(db_path) as conn:
        return conn.execute(
            "SELECT ip, ban_time, reason FROM blacklist ORDER BY ban_time ASC"
        ).fetchall()


def upsert_blacklist_entry(db_path, ip, ban_time, reason):
    with sqlite3.connect(db_path) as conn:
        conn.execute(
            "INSERT OR REPLACE INTO blacklist (ip, ban_time, reason) VALUES (?, ?, ?)",
            (ip, ban_time, reason),
        )
        conn.commit()


def delete_blacklist_entry(db_path, ip):
    with sqlite3.connect(db_path) as conn:
        conn.execute("DELETE FROM blacklist WHERE ip = ?", (ip,))
        conn.commit()


def purge_expired_blacklist_entries(db_path, current_time=None):
    if not os.path.exists(db_path):
        return 0

    if current_time is None:
        current_time = int(time.time())

    with sqlite3.connect(db_path) as conn:
        cursor = conn.execute("DELETE FROM blacklist WHERE ban_time <= ?", (current_time,))
        conn.commit()
        return cursor.rowcount


class FirewallManager:
    def __init__(self, backend=None):
        self.backend = backend or detect_firewall()

    def _run(self, command, check=True):
        return subprocess.run(
            command,
            check=check,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )

    def _capture(self, command):
        return subprocess.run(
            command,
            check=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
        )

    def _iptables_binary(self, ip):
        if is_ipv6(ip):
            return "ip6tables" if shutil.which("ip6tables") else None
        return "iptables" if shutil.which("iptables") else None

    def _iptables_rule_exists(self, ip):
        binary = self._iptables_binary(ip)
        if not binary:
            return False
        result = self._run([binary, "-C", "INPUT", "-s", ip, "-j", "DROP"], check=False)
        return result.returncode == 0

    def _ensure_nft_structure(self, ip):
        family = "ip6" if is_ipv6(ip) else "ip"
        set_name = "blocked_ipv6" if is_ipv6(ip) else "blocked_ipv4"
        addr_type = "ipv6_addr" if is_ipv6(ip) else "ipv4_addr"
        ruleset = self._capture(["nft", "list", "table", family, "mini_hids"]).stdout

        self._run(["nft", "add", "table", family, "mini_hids"], check=False)
        if "chain input {" not in ruleset:
            self._run(
                [
                    "nft",
                    "add",
                    "chain",
                    family,
                    "mini_hids",
                    "input",
                    "{",
                    "type",
                    "filter",
                    "hook",
                    "input",
                    "priority",
                    "0",
                    ";",
                    "}",
                ],
                check=False,
            )
        if f"set {set_name} " not in ruleset:
            self._run(
                [
                    "nft",
                    "add",
                    "set",
                    family,
                    "mini_hids",
                    set_name,
                    "{",
                    "type",
                    addr_type,
                    ";",
                    "flags",
                    "timeout",
                    ";",
                    "}",
                ],
                check=False,
            )
        if f"{family} saddr @{set_name} drop" not in ruleset:
            rule = [family, "saddr", "@{}".format(set_name), "drop"]
            self._run(["nft", "add", "rule", family, "mini_hids", "input"] + rule, check=False)
        return family, set_name

    def ban_ip(self, ip, duration):
        if self.backend == "iptables":
            binary = self._iptables_binary(ip)
            if not binary:
                raise RuntimeError("iptables backend is unavailable for this IP family")
            if self._iptables_rule_exists(ip):
                return
            self._run([binary, "-A", "INPUT", "-s", ip, "-j", "DROP"])
            return

        if self.backend == "nftables":
            family, set_name = self._ensure_nft_structure(ip)
            timeout = "{}s".format(int(duration))
            self._run(
                [
                    "nft",
                    "add",
                    "element",
                    family,
                    "mini_hids",
                    set_name,
                    "{",
                    "{} timeout {}".format(ip, timeout),
                    "}",
                ]
            )
            return

        if self.backend == "fail2ban":
            self._run(["fail2ban-client", "set", "sshd", "banip", ip])
            return

        raise RuntimeError("no supported firewall backend found")

    def unban_ip(self, ip):
        if self.backend == "iptables":
            binary = self._iptables_binary(ip)
            if not binary:
                raise RuntimeError("iptables backend is unavailable for this IP family")
            while self._iptables_rule_exists(ip):
                self._run([binary, "-D", "INPUT", "-s", ip, "-j", "DROP"])
            return

        if self.backend == "nftables":
            family, set_name = self._ensure_nft_structure(ip)
            self._run(
                ["nft", "delete", "element", family, "mini_hids", set_name, "{", ip, "}"],
                check=False,
            )
            return

        if self.backend == "fail2ban":
            self._run(["fail2ban-client", "set", "sshd", "unbanip", ip], check=False)
            return

        raise RuntimeError("no supported firewall backend found")


path: images/mini-hids-flow-zh.svg
content: <svg width="1200" height="480" viewBox="0 0 1200 480" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
  <title id="title">Mini-HIDS 中文架构流程图</title>
  <desc id="desc">展示日志进入 Mini-HIDS，然后流向 SQLite、封禁动作以及 CLI 和 MCP 调用端的动画示意图。</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1200" y2="480" gradientUnits="userSpaceOnUse">
      <stop stop-color="#09131f"/>
      <stop offset="1" stop-color="#10273f"/>
    </linearGradient>
    <linearGradient id="panel" x1="0" y1="0" x2="1" y2="1">
      <stop stop-color="#12314f"/>
      <stop offset="1" stop-color="#0f2237"/>
    </linearGradient>
    <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
      <stop stop-color="#29c7ac"/>
      <stop offset="1" stop-color="#f3c969"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
      <feDropShadow dx="0" dy="10" stdDeviation="14" flood-color="#04101b" flood-opacity="0.35"/>
    </filter>
  </defs>

  <rect width="1200" height="480" rx="28" fill="url(#bg)"/>
  <circle cx="1030" cy="92" r="120" fill="#183455" opacity="0.18"/>
  <circle cx="182" cy="404" r="96" fill="#2AC4AF" opacity="0.08"/>

  <g filter="url(#shadow)">
    <rect x="70" y="116" width="212" height="248" rx="24" fill="url(#panel)" stroke="#29557E"/>
    <rect x="344" y="76" width="304" height="328" rx="28" fill="url(#panel)" stroke="#2D668D"/>
    <rect x="716" y="76" width="190" height="144" rx="24" fill="url(#panel)" stroke="#2D668D"/>
    <rect x="716" y="260" width="190" height="144" rx="24" fill="url(#panel)" stroke="#2D668D"/>
    <rect x="962" y="76" width="168" height="144" rx="24" fill="url(#panel)" stroke="#2D668D"/>
    <rect x="962" y="260" width="168" height="144" rx="24" fill="url(#panel)" stroke="#2D668D"/>
  </g>

  <text x="102" y="160" fill="#F4F7FB" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="28" font-weight="700">日志来源</text>
  <text x="102" y="202" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="20">auth.log</text>
  <text x="102" y="236" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="20">nginx access.log</text>
  <text x="102" y="270" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="20">apache access.log</text>
  <text x="102" y="304" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="20">Web 目录</text>

  <text x="382" y="134" fill="#F4F7FB" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="34" font-weight="700">Mini-HIDS</text>
  <text x="382" y="176" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="20">SSH 爆破滑动窗口检测</text>
  <text x="382" y="210" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="20">Web 攻击特征匹配</text>
  <text x="382" y="244" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="20">增量式 Webshell 扫描</text>
  <text x="382" y="278" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="20">SQLite 黑名单持久化</text>
  <text x="382" y="312" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="20">JSON CLI 与 MCP 工具</text>

  <rect x="382" y="338" width="228" height="24" rx="12" fill="#143E57"/>
  <rect x="382" y="338" width="170" height="24" rx="12" fill="url(#accent)">
    <animate attributeName="width" values="120;210;145;190;120" dur="4.5s" repeatCount="indefinite"/>
  </rect>
  <text x="382" y="386" fill="#7EDBC8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="16">面向小型服务器的防御工作流</text>

  <text x="748" y="128" fill="#F4F7FB" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="26" font-weight="700">SQLite 状态</text>
  <text x="748" y="170" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="18">blacklist.db</text>
  <text x="748" y="196" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="18">封禁到期时间</text>

  <text x="748" y="312" fill="#F4F7FB" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="26" font-weight="700">防火墙动作</text>
  <text x="748" y="354" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="18">iptables</text>
  <text x="748" y="380" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="18">nft / fail2ban</text>

  <text x="992" y="128" fill="#F4F7FB" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="24" font-weight="700">JSON CLI</text>
  <text x="992" y="170" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="18">状态查询</text>
  <text x="992" y="196" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="18">告警与黑名单</text>

  <text x="992" y="312" fill="#F4F7FB" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="24" font-weight="700">MCP 工具</text>
  <text x="992" y="354" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="18">Cursor / Claude</text>
  <text x="992" y="380" fill="#A9C4D8" font-family="'Microsoft YaHei', 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="18">可被 AI 调用的安全操作</text>

  <path d="M282 240H344" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
  <path d="M648 148H716" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
  <path d="M648 332H716" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
  <path d="M906 148H962" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
  <path d="M906 332H962" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
</svg>


path: images/mini-hids-flow.svg
content: <svg width="1200" height="480" viewBox="0 0 1200 480" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
  <title id="title">Mini-HIDS animated architecture overview</title>
  <desc id="desc">Animated diagram showing logs flowing into Mini-HIDS, then into SQLite, firewall actions, and MCP or CLI consumers.</desc>
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1200" y2="480" gradientUnits="userSpaceOnUse">
      <stop stop-color="#09131f"/>
      <stop offset="1" stop-color="#10273f"/>
    </linearGradient>
    <linearGradient id="panel" x1="0" y1="0" x2="1" y2="1">
      <stop stop-color="#12314f"/>
      <stop offset="1" stop-color="#0f2237"/>
    </linearGradient>
    <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
      <stop stop-color="#29c7ac"/>
      <stop offset="1" stop-color="#f3c969"/>
    </linearGradient>
    <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
      <feDropShadow dx="0" dy="10" stdDeviation="14" flood-color="#04101b" flood-opacity="0.35"/>
    </filter>
  </defs>

  <rect width="1200" height="480" rx="28" fill="url(#bg)"/>
  <circle cx="1030" cy="92" r="120" fill="#183455" opacity="0.18"/>
  <circle cx="182" cy="404" r="96" fill="#2AC4AF" opacity="0.08"/>

  <g filter="url(#shadow)">
    <rect x="70" y="116" width="212" height="248" rx="24" fill="url(#panel)" stroke="#29557E"/>
    <rect x="344" y="76" width="304" height="328" rx="28" fill="url(#panel)" stroke="#2D668D"/>
    <rect x="716" y="76" width="190" height="144" rx="24" fill="url(#panel)" stroke="#2D668D"/>
    <rect x="716" y="260" width="190" height="144" rx="24" fill="url(#panel)" stroke="#2D668D"/>
    <rect x="962" y="76" width="168" height="144" rx="24" fill="url(#panel)" stroke="#2D668D"/>
    <rect x="962" y="260" width="168" height="144" rx="24" fill="url(#panel)" stroke="#2D668D"/>
  </g>

  <text x="102" y="160" fill="#F4F7FB" font-family="Segoe UI, Arial, sans-serif" font-size="28" font-weight="700">Log Sources</text>
  <text x="102" y="202" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="20">auth.log</text>
  <text x="102" y="236" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="20">nginx access.log</text>
  <text x="102" y="270" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="20">apache access.log</text>
  <text x="102" y="304" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="20">web roots</text>

  <text x="382" y="134" fill="#F4F7FB" font-family="Segoe UI, Arial, sans-serif" font-size="34" font-weight="700">Mini-HIDS</text>
  <text x="382" y="176" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="20">Sliding-window SSH detection</text>
  <text x="382" y="210" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="20">Web attack pattern matching</text>
  <text x="382" y="244" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="20">Incremental webshell scan</text>
  <text x="382" y="278" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="20">SQLite-backed blacklist</text>
  <text x="382" y="312" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="20">JSON CLI + MCP tools</text>

  <rect x="382" y="338" width="228" height="24" rx="12" fill="#143E57"/>
  <rect x="382" y="338" width="170" height="24" rx="12" fill="url(#accent)">
    <animate attributeName="width" values="120;210;145;190;120" dur="4.5s" repeatCount="indefinite"/>
  </rect>
  <text x="382" y="386" fill="#7EDBC8" font-family="Segoe UI, Arial, sans-serif" font-size="16">small-server defensive workflow</text>

  <text x="748" y="128" fill="#F4F7FB" font-family="Segoe UI, Arial, sans-serif" font-size="26" font-weight="700">SQLite State</text>
  <text x="748" y="170" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="18">blacklist.db</text>
  <text x="748" y="196" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="18">expiry times</text>

  <text x="748" y="312" fill="#F4F7FB" font-family="Segoe UI, Arial, sans-serif" font-size="26" font-weight="700">Firewall</text>
  <text x="748" y="354" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="18">iptables</text>
  <text x="748" y="380" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="18">nft / fail2ban</text>

  <text x="992" y="128" fill="#F4F7FB" font-family="Segoe UI, Arial, sans-serif" font-size="24" font-weight="700">JSON CLI</text>
  <text x="992" y="170" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="18">status</text>
  <text x="992" y="196" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="18">alerts / blacklist</text>

  <text x="992" y="312" fill="#F4F7FB" font-family="Segoe UI, Arial, sans-serif" font-size="24" font-weight="700">MCP Tools</text>
  <text x="992" y="354" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="18">Cursor / Claude</text>
  <text x="992" y="380" fill="#A9C4D8" font-family="Segoe UI, Arial, sans-serif" font-size="18">AI-callable security ops</text>

  <path d="M282 240H344" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
  <path d="M648 148H716" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
  <path d="M648 332H716" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
  <path d="M906 148H962" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
  <path d="M906 332H962" stroke="url(#accent)" stroke-width="6" stroke-linecap="round" stroke-dasharray="14 16">
    <animate attributeName="stroke-dashoffset" from="0" to="-60" dur="2s" repeatCount="indefinite"/>
  </path>
</svg>


path: llms.txt
content: # Mini-HIDS

> Mini-HIDS is a lightweight Linux host intrusion detection tool for small servers. It monitors auth and web logs, tracks repeated failures in a sliding window, scans web roots for suspicious script patterns, stores ban state in SQLite, and exposes both a JSON CLI and an MCP tool server for agent workflows.

Mini-HIDS is optimized for local or single-host deployments where simple detection, understandable code, and scriptable operations matter more than large-platform features.

When using this repository, treat `mini_hids.py` as the data-plane daemon, `hids_cli.py` as the operator and agent control plane, and `mcp_server.py` as the interface layer for MCP-compatible AI clients.

Important behavior:

- Firewall actions require Linux plus a supported backend: `iptables`, `nft`, or `fail2ban-client`
- Runtime state is persisted in `blacklist.db`
- Relative runtime paths are resolved relative to the project directory
- Detection is heuristic and regex-based, not a full IDS or EDR platform
- MCP integrations should be treated as privileged local tools

## Key Files

- [README.md](README.md): English overview, quick start, MCP usage, and operational notes
- [README_zh.md](README_zh.md): Chinese overview and operator guidance
- [mini_hids.py](mini_hids.py): Long-running daemon for log monitoring, auto-ban, and webshell scanning
- [hids_cli.py](hids_cli.py): JSON CLI for status, alerts, blacklist, ban, and unban operations
- [hids_common.py](hids_common.py): Shared config loading, SQLite persistence, and firewall backends
- [config.json](config.json): Runtime configuration for log paths, trust list, scan intervals, and file locations
- [mcp_server.py](mcp_server.py): MCP stdio server exposing Mini-HIDS as AI-callable tools

## Core APIs

- `python3 mini_hids.py`: start the daemon
- `python3 hids_cli.py --action status`: return daemon and backend status as JSON
- `python3 hids_cli.py --action get_alerts --lines N`: return recent alerts as JSON
- `python3 hids_cli.py --action get_blacklist`: return active blacklist entries as JSON
- `python3 hids_cli.py --action ban --ip <IP> --reason "<TEXT>"`: ban an IP
- `python3 hids_cli.py --action unban --ip <IP>`: unban an IP
- `python3 mcp_server.py`: expose Mini-HIDS tools over stdio MCP

## Usage Scenarios

- Protecting a small VPS against brute-force SSH attempts
- Giving an AI coding assistant a safe, explicit interface for security checks on a Linux host
- Embedding lightweight host monitoring into an operations or incident-response workflow
- Demonstrating how to convert a simple CLI security project into an AI-readable and AI-callable repository

## Limitations

- Linux-only in practice because of log and firewall assumptions
- No built-in service unit, packaging, or automated tests yet
- No central dashboard, multi-host coordination, or advanced telemetry


path: mcp_server.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Minimal stdio MCP server for Mini-HIDS.
"""

import json
import sys
from typing import Any, Dict

from hids_cli import ban_ip, ensure_runtime, get_alerts, get_blacklist, get_status, unban_ip


SERVER_INFO = {"name": "mini-hids", "version": "1.2.0"}
PROTOCOL_VERSION = "2025-06-18"


TOOLS = [
    {
        "name": "mini_hids_status",
        "description": "Get daemon, load, firewall backend, and runtime file status.",
        "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
    },
    {
        "name": "mini_hids_get_alerts",
        "description": "Read recent alert log lines from Mini-HIDS.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "lines": {"type": "integer", "minimum": 1, "maximum": 500, "default": 10}
            },
            "additionalProperties": False,
        },
    },
    {
        "name": "mini_hids_get_blacklist",
        "description": "List currently active blacklist entries.",
        "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
    },
    {
        "name": "mini_hids_ban_ip",
        "description": "Ban an IP address using the configured firewall backend.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "ip": {"type": "string"},
                "reason": {"type": "string"},
            },
            "required": ["ip", "reason"],
            "additionalProperties": False,
        },
    },
    {
        "name": "mini_hids_unban_ip",
        "description": "Unban an IP address using the configured firewall backend.",
        "inputSchema": {
            "type": "object",
            "properties": {"ip": {"type": "string"}},
            "required": ["ip"],
            "additionalProperties": False,
        },
    },
]


def _response(request_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
    return {"jsonrpc": "2.0", "id": request_id, "result": result}


def _error(request_id: Any, code: int, message: str) -> Dict[str, Any]:
    return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}


def _tool_result(payload: Dict[str, Any], is_error: bool = False) -> Dict[str, Any]:
    return {
        "content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}],
        "structuredContent": payload,
        "isError": is_error,
    }


def _handle_tool_call(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
    if name == "mini_hids_status":
        return _tool_result(get_status())
    if name == "mini_hids_get_alerts":
        lines = arguments.get("lines", 10)
        return _tool_result(get_alerts(lines))
    if name == "mini_hids_get_blacklist":
        return _tool_result(get_blacklist())
    if name == "mini_hids_ban_ip":
        result = ban_ip(arguments["ip"], arguments["reason"])
        return _tool_result(result, is_error=not result.get("success", False))
    if name == "mini_hids_unban_ip":
        result = unban_ip(arguments["ip"])
        return _tool_result(result, is_error=not result.get("success", False))
    raise KeyError(name)


def _handle_request(message: Dict[str, Any]) -> Dict[str, Any]:
    request_id = message.get("id")
    method = message.get("method")
    params = message.get("params", {})

    if method == "initialize":
        return _response(
            request_id,
            {
                "protocolVersion": PROTOCOL_VERSION,
                "capabilities": {"tools": {"listChanged": False}},
                "serverInfo": SERVER_INFO,
            },
        )

    if method == "ping":
        return _response(request_id, {})

    if method == "tools/list":
        return _response(request_id, {"tools": TOOLS})

    if method == "tools/call":
        try:
            name = params["name"]
            arguments = params.get("arguments", {})
            return _response(request_id, _handle_tool_call(name, arguments))
        except KeyError as exc:
            return _error(request_id, -32602, "Unknown tool: {}".format(exc.args[0]))
        except Exception as exc:
            return _response(
                request_id,
                _tool_result({"success": False, "message": str(exc)}, is_error=True),
            )

    if request_id is None:
        return {}

    return _error(request_id, -32601, "Method not found: {}".format(method))


def main() -> None:
    ensure_runtime()

    for raw_line in sys.stdin:
        raw_line = raw_line.strip()
        if not raw_line:
            continue

        try:
            message = json.loads(raw_line)
        except json.JSONDecodeError:
            continue

        response = _handle_request(message)
        if response:
            sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
            sys.stdout.flush()


if __name__ == "__main__":
    main()


path: mini_hids.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
轻量级主机入侵检测与日志分析系统（Mini-HIDS）
版本：v1.1
功能：实时监控系统日志，检测暴力破解和 Webshell，自动封禁恶意 IP
定位：后台守护进程，负责 7x24 小时底层监控与自动防御
"""

import os
import re
import threading
import time
from collections import deque

from hids_common import (
    FirewallManager,
    delete_blacklist_entry,
    init_db,
    list_blacklist_rows,
    load_config,
    purge_expired_blacklist_entries,
    upsert_blacklist_entry,
    validate_ip,
)


CONFIG = load_config()
FIREWALL = FirewallManager()

WEBSHELL_PATTERNS = [
    r"eval\(base64_decode\(",
    r"proc_open\(",
    r"shell_exec\(",
    r"system\(",
    r"passthru\(",
    r"exec\(",
    r"popen\(",
    r"assert\(",
    r"create_function\(",
    r"array_map\(.*eval\(",
    r"\$\_GET\[.*\]\(.*\)",
    r"\$\_POST\[.*\]\(.*\)",
    r"\$\_REQUEST\[.*\]\(.*\)",
    r"file_put_contents\(.*\$\_",
    r"fwrite\(.*\$\_",
    r"\$\_FILES\[.*\]\['tmp_name'\]",
]
COMPILED_WEBSHELL_PATTERNS = [re.compile(pattern, re.IGNORECASE) for pattern in WEBSHELL_PATTERNS]

WEB_ATTACK_PATTERNS = [
    r"' OR\s+",
    r"UNION\s+SELECT",
    r"<script>",
    r"javascript:",
    r"\.\./",
]
COMPILED_WEB_ATTACK_PATTERNS = [re.compile(pattern, re.IGNORECASE) for pattern in WEB_ATTACK_PATTERNS]

SSH_FAILURE_PATTERN = re.compile(r"Failed password for .* from (\S+)")
IP_EXTRACT_PATTERN = re.compile(r"(\S+) - - \[")

state_lock = threading.RLock()
ban_times = {}
blacklist = set()
ip_failures = {}
file_modification_times = {}


def log_alert(message):
    timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
    alert_message = f"[{timestamp}] {message}\n"
    print(alert_message, end="")

    with open(CONFIG["ALERT_LOG"], "a", encoding="utf-8") as alert_file:
        alert_file.write(alert_message)


def is_trusted_ip(ip):
    return ip in CONFIG["TRUSTED_IPS"]


def setup_environment():
    init_db(CONFIG["BLACKLIST_DB"])
    os.makedirs(os.path.dirname(CONFIG["ALERT_LOG"]) or ".", exist_ok=True)
    os.makedirs("logs", mode=0o755, exist_ok=True)
    load_runtime_state()


def load_runtime_state():
    current_time = int(time.time())
    active_bans = {}
    active_blacklist = set()
    expired_ips = []

    for ip, ban_time, _reason in list_blacklist_rows(CONFIG["BLACKLIST_DB"]):
        if ban_time > current_time:
            active_bans[ip] = ban_time
            active_blacklist.add(ip)
        else:
            expired_ips.append(ip)

    for ip in expired_ips:
        delete_blacklist_entry(CONFIG["BLACKLIST_DB"], ip)

    with state_lock:
        ban_times.clear()
        ban_times.update(active_bans)
        blacklist.clear()
        blacklist.update(active_blacklist)

    log_alert(f"[状态加载] 从数据库加载了 {len(active_bans)} 个未过期的封禁")
    if expired_ips:
        log_alert(f"[状态清理] 清理了 {len(expired_ips)} 条过期封禁记录")


def ban_ip(ip, reason):
    if is_trusted_ip(ip) or not validate_ip(ip):
        return False

    with state_lock:
        if ip in blacklist:
            return False

    expiry_time = int(time.time() + CONFIG["BAN_TIME"])

    try:
        FIREWALL.ban_ip(ip, CONFIG["BAN_TIME"])
    except Exception as exc:
        log_alert(f"[错误] 执行封禁失败 {ip}: {exc}")
        return False

    try:
        upsert_blacklist_entry(CONFIG["BLACKLIST_DB"], ip, expiry_time, reason)
    except Exception as exc:
        try:
            FIREWALL.unban_ip(ip)
        except Exception:
            pass
        log_alert(f"[错误] 持久化封禁失败 {ip}: {exc}")
        return False

    with state_lock:
        blacklist.add(ip)
        ban_times[ip] = expiry_time
        ip_failures.pop(ip, None)

    log_alert(f"[封禁] IP {ip} 因 {reason} 被封禁")
    return True


def unban_ip(ip, reason="封禁到期自动解封"):
    if not validate_ip(ip):
        return False

    with state_lock:
        was_banned = ip in blacklist or ip in ban_times

    if not was_banned:
        delete_blacklist_entry(CONFIG["BLACKLIST_DB"], ip)
        return False

    try:
        FIREWALL.unban_ip(ip)
    except Exception as exc:
        log_alert(f"[错误] 执行解封失败 {ip}: {exc}")
        return False

    delete_blacklist_entry(CONFIG["BLACKLIST_DB"], ip)
    with state_lock:
        blacklist.discard(ip)
        ban_times.pop(ip, None)

    log_alert(f"[解封] IP {ip} 已解封，原因: {reason}")
    return True


def check_ban_expiry():
    current_time = int(time.time())
    with state_lock:
        expired_ips = [ip for ip, expiry in ban_times.items() if current_time >= expiry]

    for ip in expired_ips:
        unban_ip(ip)


def tail_log_file(log_path):
    while True:
        try:
            stat_info = os.stat(log_path)
            inode = stat_info.st_ino
            offset = stat_info.st_size

            with open(log_path, "r", encoding="utf-8", errors="ignore") as log_file:
                log_file.seek(offset)

                while True:
                    try:
                        new_stat = os.stat(log_path)
                    except FileNotFoundError:
                        log_alert(f"[日志轮转] {log_path} 暂时不可见，等待重新打开")
                        break

                    if new_stat.st_ino != inode or new_stat.st_size < log_file.tell():
                        log_alert(f"[日志轮转] {log_path} 发生轮转，重新打开")
                        break

                    line = log_file.readline()
                    if not line:
                        time.sleep(0.2)
                        continue

                    process_log_line(line, log_path)
        except FileNotFoundError:
            log_alert(f"[警告] 日志文件 {log_path} 不存在，5 秒后重试")
            time.sleep(5)
        except Exception as exc:
            log_alert(f"[错误] 监控日志文件 {log_path} 时出错: {exc}")
            time.sleep(5)


def process_log_line(line, log_path):
    if "auth.log" in log_path or "secure" in log_path:
        detect_ssh_brute_force(line)
    elif "access.log" in log_path:
        detect_web_attack(line)


def _register_failure(ip):
    current_time = time.time()
    with state_lock:
        if ip in blacklist:
            return False

        failures = ip_failures.setdefault(ip, deque())
        failures.append(current_time)

        while failures and current_time - failures[0] > CONFIG["WINDOW_SECONDS"]:
            failures.popleft()

        while len(failures) > CONFIG["MAX_FAILURES"]:
            failures.popleft()

        return len(failures) >= CONFIG["MAX_FAILURES"]


def detect_ssh_brute_force(line):
    match = SSH_FAILURE_PATTERN.search(line)
    if not match:
        return

    ip = match.group(1)
    if is_trusted_ip(ip) or not validate_ip(ip):
        return

    if _register_failure(ip):
        log_alert(f"[SSH暴力破解] 检测到来自 {ip} 的登录失败，已达到阈值")
        ban_ip(ip, "SSH暴力破解")


def detect_web_attack(line):
    for pattern in COMPILED_WEB_ATTACK_PATTERNS:
        if not pattern.search(line):
            continue

        ip_match = IP_EXTRACT_PATTERN.search(line)
        if not ip_match:
            return

        ip = ip_match.group(1)
        if is_trusted_ip(ip) or not validate_ip(ip):
            return

        if _register_failure(ip):
            log_alert(f"[Web攻击] 检测到来自 {ip} 的可能攻击: {pattern.pattern}，已达到阈值")
            ban_ip(ip, "Web攻击")
        return


def scan_webshell():
    scan_start_time = time.time()
    scanned_files = 0
    modified_files = 0

    for web_root in CONFIG["WEB_ROOT"]:
        if not os.path.exists(web_root):
            continue

        for root, _dirs, files in os.walk(web_root):
            for file_name in files:
                if not file_name.endswith((".php", ".py", ".sh", ".jsp", ".asp", ".aspx")):
                    continue

                file_path = os.path.join(root, file_name)
                try:
                    file_mtime = os.path.getmtime(file_path)
                    previous_mtime = file_modification_times.get(file_path)
                    scanned_files += 1

                    if previous_mtime is not None and file_mtime <= previous_mtime:
                        continue

                    modified_files += 1
                    with open(file_path, "r", encoding="utf-8", errors="ignore") as source_file:
                        content = source_file.read()

                    for pattern in COMPILED_WEBSHELL_PATTERNS:
                        if pattern.search(content):
                            log_alert(f"[Webshell] 检测到可疑文件: {file_path}")
                            break

                    file_modification_times[file_path] = file_mtime
                except Exception:
                    continue

    scan_duration = time.time() - scan_start_time
    log_alert(
        "[Webshell扫描] 完成扫描，共扫描 {} 个文件，其中 {} 个为新增或修改文件，耗时 {:.2f} 秒".format(
            scanned_files, modified_files, scan_duration
        )
    )


def main():
    if os.name != "posix":
        print("Mini-HIDS 仅支持 Linux 系统")
        return

    if os.path.exists(CONFIG["PID_FILE"]):
        try:
            with open(CONFIG["PID_FILE"], "r", encoding="utf-8") as pid_file:
                pid = int(pid_file.read().strip())
            os.kill(pid, 0)
            print("Mini-HIDS 已经在运行中")
            return
        except Exception:
            pass

    with open(CONFIG["PID_FILE"], "w", encoding="utf-8") as pid_file:
        pid_file.write(str(os.getpid()))

    try:
        setup_environment()
        purge_expired_blacklist_entries(CONFIG["BLACKLIST_DB"])

        for _log_type, paths in CONFIG["LOG_PATHS"].items():
            for path in paths:
                if os.path.exists(path):
                    thread = threading.Thread(target=tail_log_file, args=(path,), daemon=True)
                    thread.start()
                    log_alert(f"[监控启动] 开始监控 {path}")

        log_alert(f"[防火墙] 当前后端: {FIREWALL.backend or '未检测到'}")
        scan_webshell()
        next_webshell_scan = time.time() + CONFIG["WEBSHELL_SCAN_INTERVAL"]

        while True:
            check_ban_expiry()
            now = time.time()
            if now >= next_webshell_scan:
                scan_webshell()
                next_webshell_scan = now + CONFIG["WEBSHELL_SCAN_INTERVAL"]
            time.sleep(max(1, int(CONFIG["CHECK_INTERVAL"])))

    except KeyboardInterrupt:
        log_alert("[停止] Mini-HIDS 已停止")
    finally:
        if os.path.exists(CONFIG["PID_FILE"]):
            os.remove(CONFIG["PID_FILE"])


if __name__ == "__main__":
    main()


path: README.md
content: # Mini-HIDS

Stop brute-force IPs and suspicious web payloads on a small Linux server in minutes, without deploying a full SIEM or heavyweight EDR stack.

![Mini-HIDS flow](images/mini-hids-flow.svg)

[中文文档](README_zh.md)

Mini-HIDS is a lightweight Linux host intrusion detection tool built with the Python standard library. It focuses on three things that are easy to operationalize on small servers:

- Detect repeated SSH login failures with a sliding time window
- Detect obvious web attack payloads from access logs
- Scan common script files for suspicious webshell patterns

It also exposes both a JSON CLI and a minimal MCP server, so AI agents can inspect status, read alerts, query the blacklist, and trigger ban or unban actions through a standard tool interface.

## Why This Exists

Most open-source security tools are optimized for human operators first. Mini-HIDS is intentionally small enough to understand quickly, script easily, and embed into agent workflows without a large control plane.

This repository is a good fit if you want:

- A single-host defensive tool for VPS or small Linux fleets
- A JSON-first CLI for automation and agent usage
- Simple, inspectable detection logic instead of opaque pipelines
- A local MCP tool server that IDE agents can call directly

This repository is not a good fit if you need:

- Cross-host correlation or centralized SOC workflows
- Kernel telemetry, eBPF, or endpoint prevention
- High-fidelity detection engineering with low false positives

## Architecture

- `mini_hids.py`: long-running daemon that tails logs, tracks attack windows, bans IPs, and rescans web roots
- `hids_cli.py`: JSON-only control-plane CLI for operators and agents
- `hids_common.py`: shared config loading, SQLite helpers, IP validation, and firewall backends
- `mcp_server.py`: stdio MCP adapter that exposes Mini-HIDS actions as agent-callable tools
- `config.json`: runtime configuration loaded by both the daemon and the CLI
- `llms.txt`: LLM-oriented project map for AI search and coding assistants

## Quick Start

```bash
git clone https://github.com/netkr/mini-hids.git
cd mini-hids
```

Adjust `config.json`, then start the daemon:

```bash
sudo python3 mini_hids.py
```

Use the JSON CLI:

```bash
python3 hids_cli.py --action status
python3 hids_cli.py --action get_alerts --lines 20
python3 hids_cli.py --action get_blacklist
python3 hids_cli.py --action ban --ip 192.168.1.100 --reason "manual ban"
python3 hids_cli.py --action unban --ip 192.168.1.100
```

## Use With AI Agents

Mini-HIDS now ships with a local MCP server. That means tools like Cursor, Claude Desktop, and other MCP-compatible clients can call the project directly instead of shelling out ad hoc.

Run the MCP server:

```bash
python3 mcp_server.py
```

Example client config:

```json
{
  "mcpServers": {
    "mini-hids": {
      "command": "python3",
      "args": ["/absolute/path/to/mini-hids/mcp_server.py"]
    }
  }
}
```

A ready-to-copy sample is also included at [`examples/claude_desktop_mcp.json`](examples/claude_desktop_mcp.json).

Available MCP tools:

- `mini_hids_status`
- `mini_hids_get_alerts`
- `mini_hids_get_blacklist`
- `mini_hids_ban_ip`
- `mini_hids_unban_ip`

This is the practical replacement for a fake "one-click deploy" button. Mini-HIDS needs local log access and firewall privileges, so local or server-side MCP integration is the correct deployment model.

## CLI Output

All CLI commands return JSON. Example:

```json
{
  "success": true,
  "data": {
    "is_running": true,
    "pid": 12345,
    "firewall_backend": "iptables"
  }
}
```

## Requirements

- Python 3.6+
- Linux
- Root privileges for firewall operations and protected log access
- One supported firewall backend:
  - `iptables`
  - `nft`
  - `fail2ban-client`

## Configuration

Edit `config.json` instead of modifying the Python files.

```json
{
  "LOG_PATHS": {
    "auth": ["/var/log/auth.log", "/var/log/secure"],
    "web": ["/var/log/nginx/access.log", "/var/log/apache2/access.log"],
    "mysql": ["/var/log/mysql/mysql.log", "/var/log/mysql/error.log"]
  },
  "BAN_TIME": 3600,
  "TRUSTED_IPS": ["127.0.0.1", "192.168.1.1"],
  "WEB_ROOT": ["/var/www/html", "/var/www"],
  "BLACKLIST_DB": "blacklist.db",
  "ALERT_LOG": "hids_alert.log",
  "PID_FILE": "mini_hids.pid",
  "MAX_FAILURES": 5,
  "WINDOW_SECONDS": 300,
  "CHECK_INTERVAL": 1,
  "WEBSHELL_SCAN_INTERVAL": 3600
}
```

Notes:

- `BLACKLIST_DB`, `ALERT_LOG`, and `PID_FILE` can be absolute paths. If they are relative, they are created in the project directory.
- `CHECK_INTERVAL` controls how often the daemon checks for expired bans.
- `WEBSHELL_SCAN_INTERVAL` controls how often the daemon rescans web roots.
- `TRUSTED_IPS` are never banned by the daemon or the CLI.

## Security Notes

- Run the daemon as root if you need firewall enforcement or access to privileged logs.
- Review `TRUSTED_IPS` carefully to avoid locking yourself out.
- Web attack and webshell detection are heuristic. Treat alerts as signals, not final verdicts.
- MCP clients should be treated as privileged local integrations, since they can trigger ban and unban operations.

## Limitations

- Detection is regex-based and intentionally simple.
- The project does not yet ship with automated tests or service packaging.
- `nftables` support uses a dedicated `mini_hids` table and timeout-enabled sets, so existing firewall policies should still be reviewed before production use.



## v1.2 Release Notes

- Unified runtime configuration loading from config.json with default merging
- Added shared core module for config, firewall, IP validation, and blacklist persistence
- Added SQLite-backed blacklist persistence with automatic recovery and expired-entry cleanup
- Improved ban/unban idempotency and reduced risk of duplicate firewall rules
- Fixed firewall backend detection, including proper nftables support
- Improved daemon scheduling so ban expiry is checked on a short interval
- Added incremental webshell scanning based on file modification time
- Improved log tailing robustness with log rotation handling
- Normalized runtime file paths for blacklist.db, hids_alert.log, and mini_hids.pid
- Added JSON CLI for status, alerts, blacklist inspection, manual ban, and unban


path: README_zh.md
content: # Mini-HIDS

几分钟内就在小型 Linux 服务器上拦住 SSH 爆破和可疑 Web 攻击，不需要先上完整 SIEM 或沉重的 EDR。

![Mini-HIDS 流程图](images/mini-hids-flow-zh.svg)

Mini-HIDS 是一个基于 Python 标准库实现的轻量级 Linux 主机入侵检测工具，核心目标很直接：

- 用滑动时间窗口检测 SSH 登录失败爆破
- 从访问日志识别明显的 Web 攻击载荷
- 对常见脚本文件做增量式 Webshell 扫描

现在仓库同时提供 JSON CLI 和一个最小可用的 MCP 服务端，因此 Cursor、Claude Desktop 一类支持 MCP 的 Agent 客户端可以直接把它当成安全工具来调用。

## 这个项目适合谁

这个项目更像“可嵌入 Agent 工作流的单机安全工具”，不是“大而全的安全平台”。

适合的场景：

- VPS、小型业务机、个人运维节点
- 希望保留简单、可审计、可脚本化的检测逻辑
- 希望让 AI Agent 直接查询状态、读告警、查黑名单、做封禁动作

不适合的场景：

- 多主机关联分析
- 需要内核级遥测或高强度终端防护
- 对误报率和规则精细化要求很高的生产安全平台

## 架构

- `mini_hids.py`：后台守护进程，负责日志跟踪、滑动窗口统计、自动封禁和周期性 Webshell 扫描
- `hids_cli.py`：控制面 CLI，适合 Agent 或运维脚本调用，输出统一 JSON
- `hids_common.py`：共享配置、SQLite 持久化、IP 校验与防火墙后端逻辑
- `mcp_server.py`：基于 stdio 的 MCP 适配层，把 Mini-HIDS 能力暴露成标准工具
- `config.json`：daemon 和 CLI 共用的运行时配置文件
- `llms.txt`：面向 LLM 和 AI 搜索的项目总览文件

## 快速开始

```bash
git clone https://github.com/netkr/mini-hids.git
cd mini-hids
```

先调整 `config.json`，再启动 daemon：

```bash
sudo python3 mini_hids.py
```

CLI 用法：

```bash
python3 hids_cli.py --action status
python3 hids_cli.py --action get_alerts --lines 20
python3 hids_cli.py --action get_blacklist
python3 hids_cli.py --action ban --ip 192.168.1.100 --reason "手动封禁"
python3 hids_cli.py --action unban --ip 192.168.1.100
```

## Agent / MCP 集成

运行本地 MCP 服务：

```bash
python3 mcp_server.py
```

客户端配置示例：

```json
{
  "mcpServers": {
    "mini-hids": {
      "command": "python3",
      "args": ["/absolute/path/to/mini-hids/mcp_server.py"]
    }
  }
}
```

仓库里也附带了一个可直接改路径后使用的示例文件：[`examples/claude_desktop_mcp.json`](examples/claude_desktop_mcp.json)。

当前暴露的工具：

- `mini_hids_status`
- `mini_hids_get_alerts`
- `mini_hids_get_blacklist`
- `mini_hids_ban_ip`
- `mini_hids_unban_ip`

这比硬塞一个不合适的 `Vercel` 或 `Railway` 按钮更合理，因为 Mini-HIDS 依赖本地日志访问和防火墙权限，本地或服务器侧 MCP 才是对的接入方式。

## 配置说明

请直接修改 `config.json`，不要再去手改 Python 文件。

```json
{
  "LOG_PATHS": {
    "auth": ["/var/log/auth.log", "/var/log/secure"],
    "web": ["/var/log/nginx/access.log", "/var/log/apache2/access.log"],
    "mysql": ["/var/log/mysql/mysql.log", "/var/log/mysql/error.log"]
  },
  "BAN_TIME": 3600,
  "TRUSTED_IPS": ["127.0.0.1", "192.168.1.1"],
  "WEB_ROOT": ["/var/www/html", "/var/www"],
  "BLACKLIST_DB": "blacklist.db",
  "ALERT_LOG": "hids_alert.log",
  "PID_FILE": "mini_hids.pid",
  "MAX_FAILURES": 5,
  "WINDOW_SECONDS": 300,
  "CHECK_INTERVAL": 1,
  "WEBSHELL_SCAN_INTERVAL": 3600
}
```

补充说明：

- `BLACKLIST_DB`、`ALERT_LOG`、`PID_FILE` 可以配置为绝对路径；如果写相对路径，会自动落到项目目录下。
- `CHECK_INTERVAL` 控制 daemon 检查封禁过期的频率。
- `WEBSHELL_SCAN_INTERVAL` 控制 Web 目录重扫频率。
- `TRUSTED_IPS` 中的地址不会被 daemon 或 CLI 封禁。

## CLI 返回示例

所有 CLI 命令都返回 JSON，例如：

```json
{
  "success": true,
  "data": {
    "is_running": true,
    "pid": 12345,
    "firewall_backend": "iptables"
  }
}
```

## 安全提示

- 如果要真正执行防火墙封禁或读取受限日志，请用 root 权限运行 daemon。
- `TRUSTED_IPS` 要谨慎维护，避免把自己锁在服务器外面。
- Web 攻击和 Webshell 检测目前是启发式规则，告警更适合作为信号，而不是最终结论。
- MCP 客户端应被视为本地高权限集成，因为它们可以触发封禁和解封动作。

## v1.2 版本说明

- 统一从 config.json 加载运行时配置，并支持默认值合并
- 新增共享核心模块，统一处理配置、防火墙、IP 校验与黑名单持久化
- 新增基于 SQLite 的黑名单持久化，包含自动恢复和过期条目清理
- 改进封禁/解封的幂等性，降低重复防火墙规则风险
- 修复防火墙后端检测，包括完整的 nftables 支持
- 优化守护进程调度，更频繁检查封禁过期
- 新增基于文件修改时间的增量式 Webshell 扫描
- 增强日志跟踪鲁棒性，支持日志轮转
- 规范化运行时文件路径（blacklist.db、hids_alert.log、mini_hids.pid）
- 新增 JSON CLI，支持状态、告警、黑名单查看、手动封禁和解封



Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector:  ()