setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->exec('PRAGMA journal_mode=WAL;'); db_init($pdo); } catch (Throwable $e) { http_response_code(500); die('数据库初始化失败:' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); } return $pdo; } /** 若旧表缺少某列则补充(SQLite 兼容迁移) */ function db_ensure_column(PDO $pdo, string $table, string $column, string $definition): void { $cols = $pdo->query('PRAGMA table_info(' . $table . ')')->fetchAll(); foreach ($cols as $col) { if (strtolower((string) $col['name']) === strtolower($column)) { return; } } $pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $definition); } function db_init(PDO $pdo): void { $pdo->exec('CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, must_change_pwd INTEGER NOT NULL DEFAULT 0 )'); $pdo->exec('CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT )'); $pdo->exec('CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE NOT NULL, name TEXT NOT NULL, icon TEXT DEFAULT \'\', description TEXT DEFAULT \'\', sort INTEGER DEFAULT 0 )'); $pdo->exec('CREATE TABLE IF NOT EXISTS nav_blocks ( id INTEGER PRIMARY KEY AUTOINCREMENT, cat_id INTEGER NOT NULL, title TEXT NOT NULL, sort INTEGER DEFAULT 0 )'); $pdo->exec('CREATE TABLE IF NOT EXISTS nav_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, block_id INTEGER NOT NULL, label TEXT NOT NULL, url TEXT NOT NULL, note TEXT DEFAULT \'\', sort INTEGER DEFAULT 0 )'); $pdo->exec('CREATE TABLE IF NOT EXISTS func_tools ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, icon TEXT DEFAULT \'\', note TEXT DEFAULT \'\', description TEXT DEFAULT \'\', url TEXT NOT NULL DEFAULT \'\', is_external INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0 )'); $pdo->exec('CREATE TABLE IF NOT EXISTS quick_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL DEFAULT \'\', icon TEXT DEFAULT \'\', note TEXT DEFAULT \'\', enabled INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0 )'); $pdo->exec('CREATE TABLE IF NOT EXISTS ext_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, icon TEXT DEFAULT \'\', url TEXT NOT NULL DEFAULT \'\', description TEXT DEFAULT \'\', grp TEXT DEFAULT \'\', cat_keys TEXT DEFAULT \'\', enabled INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0 )'); $pdo->exec('CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, part TEXT NOT NULL DEFAULT \'未分类\', title TEXT NOT NULL, summary TEXT DEFAULT \'\', markdown TEXT DEFAULT \'\', pinned INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0, board_cats TEXT DEFAULT \'\', created_at TEXT DEFAULT \'\', updated_at TEXT DEFAULT \'\' )'); // ---------- 更新公告(多条记录:保留历史、可编辑、支持超链接) ---------- $pdo->exec('CREATE TABLE IF NOT EXISTS notices ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT \'\', content TEXT DEFAULT \'\', pinned INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT \'\', updated_at TEXT DEFAULT \'\' )'); // ---------- IP 黑名单 ---------- $pdo->exec('CREATE TABLE IF NOT EXISTS ip_blacklist ( id INTEGER PRIMARY KEY AUTOINCREMENT, rule TEXT NOT NULL, note TEXT DEFAULT \'\', created_at TEXT NOT NULL DEFAULT \'\', source TEXT NOT NULL DEFAULT \'manual\' )'); // ---------- 自动风控计数(登录失败 / 404 次数 / 每分钟请求频率) ---------- $pdo->exec('CREATE TABLE IF NOT EXISTS ip_risk ( ip TEXT PRIMARY KEY, login_fail INTEGER NOT NULL DEFAULT 0, not_found INTEGER NOT NULL DEFAULT 0, rate_ts INTEGER NOT NULL DEFAULT 0, rate_count INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT \'\' )'); // ---------- 登录日志(成功 / 失败均记录:时间 / 用户名 / IP / UA / 设备) ---------- $pdo->exec('CREATE TABLE IF NOT EXISTS login_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT DEFAULT \'\', ip TEXT DEFAULT \'\', ua TEXT DEFAULT \'\', device TEXT DEFAULT \'\', status TEXT NOT NULL DEFAULT \'ok\', created_at TEXT NOT NULL DEFAULT \'\' )'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_status ON login_log (status, created_at)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_ip ON login_log (ip)'); // 兼容旧库:登录日志补 device 列 db_ensure_column($pdo, 'login_log', 'device', "TEXT DEFAULT ''"); // 兼容旧库:users 补 must_change_pwd 列(初始/重置密码强制修改标志) db_ensure_column($pdo, 'users', 'must_change_pwd', 'INTEGER NOT NULL DEFAULT 0'); // 兼容旧库:ip_blacklist 补 source 列;categories 补充 icon 列 db_ensure_column($pdo, 'ip_blacklist', 'source', "TEXT NOT NULL DEFAULT 'manual'"); db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''"); // 兼容旧库:quick_links / func_tools 若缺列一并补齐 db_ensure_column($pdo, 'quick_links', 'note', "TEXT DEFAULT ''"); db_ensure_column($pdo, 'func_tools', 'note', "TEXT DEFAULT ''"); // 兼容旧库:工具与文章的“库首页分发范围”(board_cats,逗号分隔库 key;空=通用) db_ensure_column($pdo, 'func_tools', 'board_cats', "TEXT DEFAULT ''"); db_ensure_column($pdo, 'articles', 'board_cats', "TEXT DEFAULT ''"); // 兼容旧库:外部工具的“分区/栏目”(grp,空=不分组平铺) db_ensure_column($pdo, 'ext_links', 'grp', "TEXT DEFAULT ''"); // ---------- 默认分类种子 ---------- $count = (int) $pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn(); if ($count === 0) { $seedCats = [ ['popular', '科普库', '📖', 1, '面向大众与初学者的计算机科学、网络安全与前沿技术科普,帮你建立体系化的安全认知。'], ['red', '红队库', '⚔️', 2, '面向渗透测试与攻防演练的资源集,汇聚漏洞利用、武器化、情报收集与红队基础设施相关的高质量工具与文档。'], ['blue', '蓝队库', '🛡️', 3, '面向防御侧的资源集,汇集应急响应、威胁狩猎、流量分析与取证溯源相关的工具与知识。'], ['tool', '工具库', '🧰', 4, '日常效率工具与安全实用小工具都在这里,点击直达功能区。'], ]; $st = $pdo->prepare('INSERT INTO categories (key, name, icon, sort, description) VALUES (?,?,?,?,?)'); foreach ($seedCats as $c) { $st->execute($c); } } // ---------- 文章库板块分类(第五大板块,旧库缺失时追加) ---------- $hasArticleCat = (int) $pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn(); if ($hasArticleCat === 0) { $stC = $pdo->prepare('INSERT INTO categories (key, name, icon, description, sort) VALUES (?,?,?,?,?)'); $stC->execute(['article', '文章库', '📚', '站长原创与整理收录的图文与 Markdown 文章合集。', 5]); } // ---------- 默认设置种子 ---------- $hasSettings = (int) $pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn(); if ($hasSettings === 0) { $defaults = [ 'site_name' => 'SecHome', 'site_slogan' => '汇聚科普、攻防与效率工具的实用首页', 'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg 'icp_no' => '京ICP备00000000号-1', 'gongan_no' => '京公网安备11000000000000号', 'gongan_link' => '', // 公安备案号链接(可空则不显示为链接) 'copyright' => 'Copyright © 2026 SecHome 版权所有。本站内容仅供学习研究使用。', 'footer_text' => '', 'search_engines' => '', // 搜索引擎列表 JSON;空 = 使用默认 4 个 'last_updated' => '', 'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认 'hero_mode' => 'weather', // 首页顶部组件模式:weather=天气(默认) / info=信息 / blank=精简 / red=渗透测试 / blue=安全巡检 / posture=安全态势 / trend=访问趋势 / clock=大字时钟 / notice=公告滚动 / slogan=打字机标语 'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表 'article_note' => '', // 文章区首页顶部注要(Markdown 风格,纯文本) ]; $st = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?,?)'); foreach ($defaults as $k => $v) { $st->execute([$k, $v]); } } // ---------- 默认管理员 ---------- $userCount = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(); if ($userCount === 0) { // 密码算法:PHP 内置 password_hash(bcrypt/argon2);默认管理员首次登录强制改密 $st = $pdo->prepare('INSERT INTO users (username, password_hash, must_change_pwd) VALUES (?,?,1)'); $st->execute(['admin', password_hash('admin123', PASSWORD_DEFAULT)]); } // ---------- 功能区工具默认种子 ---------- $toolCount = (int) $pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn(); if ($toolCount === 0) { $seedTools = [ ['编码 / 加解密', '🔐', 'Base64 / Base32 / URL / Unicode / MD5 / SHA 系列', 'codec.html', 0], ['随机密码生成', '🔑', '自定义字符集与长度,一次生成多组', 'password.html', 0], ['二维码生成器', '▦', '任意内容生成二维码,可放大并下载 PNG', 'qrcode.html', 0], ['杀软识别', '🛡', '粘贴 tasklist 输出,匹配进程对应的安全软件', 'av.html', 0], ]; $st = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,?,1,?)'); $sort = 0; foreach ($seedTools as $t) { $st->execute([$t[0], $t[1], $t[2], $t[3], $t[4], ++$sort]); } } // ---------- 后期新增功能页默认登记(按 url 去重,允许后台再停用/删除) ---------- $extraTools = [ ['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密(SM1 为不公开硬件算法)', 'gmcodec.html'], ['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.html'], ['辅助维权', '🛠️', '反弹 Shell 构建 / Windows SAM·mimikatz·票据提取 / Linux 自启服务(systemd 与 init.d)', 'persist.html'], ['Navicat 密码解密', '🔓', 'Navicat 数据库连接密码本地解密 / 加密(11 及以前 Blowfish / 12 及以后 AES-128-CBC)', 'navicat.html'], ]; $chkUrl = $pdo->prepare('SELECT COUNT(*) FROM func_tools WHERE url = ?'); $insTool = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,0,1,?)'); foreach ($extraTools as $t) { $chkUrl->execute([$t[3]]); if ((int) $chkUrl->fetchColumn() === 0) { $mx = (int) $pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn(); $insTool->execute([$t[0], $t[1], $t[2], $t[3], $mx + 1]); } } // ---------- 库首页“外部工具区”默认种子(cat_keys 为空=展示于所有库首页;grp=分区/栏目,留空不分) ---------- $extCount = (int) $pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn(); if ($extCount === 0) { $seedExt = [ // [分区/栏目, 名称, 网址, 简介] ['在线检测', 'VirusTotal', 'https://www.virustotal.com', '多引擎文件与 URL 检测'], ['在线检测', 'Shodan', 'https://www.shodan.io', '互联网设备与端口测绘'], ['在线检测', 'URLScan', 'https://urlscan.io', '网站访问行为分析'], ['情报与数据', 'CyberChef', 'https://gchq.github.io/CyberChef/', '数据加工与编解码台'], ['情报与数据', 'Exploit-DB', 'https://www.exploit-db.com', '公开漏洞利用库'], ['情报与数据', 'Have I Been Pwned', 'https://haveibeenpwned.com', '泄露账户与密码查询'], ]; $stExt = $pdo->prepare('INSERT INTO ext_links (name, url, description, grp, cat_keys, enabled, sort) VALUES (?,?,?,?,?,1,?)'); $extSort = 0; foreach ($seedExt as $e) { $stExt->execute([$e[1], $e[2], $e[3], $e[0], '', ++$extSort]); } } // ---------- 文章库示例文章(首次建库时提供一篇使用说明) ---------- $artCount = (int) $pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn(); if ($artCount === 0) { $sampleMd = <<prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, created_at, updated_at) VALUES (?,?,?,?,1,1,1,?,?)'); $stA->execute(['使用说明', '文章库使用说明', '了解分区、三栏阅读与 Markdown 图片引用的基本用法。', $sampleMd, $now, $now]); } // ---------- 功能区工具登记:旧 PHP 外壳 → 静态 HTML(.php 已改为 302 重定向,入口自动同步迁移) ---------- $toolHtmlMap = [ 'codec.php' => 'codec.html', 'password.php' => 'password.html', 'qrcode.php' => 'qrcode.html', 'av.php' => 'av.html', 'ip.php' => 'ip.html', 'persist.php' => 'persist.html', 'gmcodec.php' => 'gmcodec.html', ]; $updUrl = $pdo->prepare('UPDATE func_tools SET url = ? WHERE url = ?'); foreach ($toolHtmlMap as $oldUrl => $newUrl) { $updUrl->execute([$newUrl, $oldUrl]); } // ---------- 公告数据迁移:旧版单条公告(settings.update_notice)→ notices 表首条 ---------- $noticeCount = (int) $pdo->query('SELECT COUNT(*) FROM notices')->fetchColumn(); if ($noticeCount === 0) { $legacy = trim((string) $pdo->query("SELECT value FROM settings WHERE key = 'update_notice'")->fetchColumn()); if ($legacy !== '') { $nowN = date('Y-m-d H:i:s'); $stN = $pdo->prepare('INSERT INTO notices (title, content, pinned, enabled, created_at, updated_at) VALUES (?,?,0,1,?,?)'); $stN->execute(['', $legacy, $nowN, $nowN]); } } } /** 标记内容已更新(首页展示"上次更新时间") */ function touch_last_updated(): void { try { $st = db()->prepare('INSERT INTO settings (key, value) VALUES (\'last_updated\', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'); $st->execute([date('Y-m-d H:i:s')]); } catch (Throwable $e) { // 不阻断主流程 } } /** 读取一条设置 */ function setting_get(string $key, string $default = ''): string { static $cache = null; if ($cache === null) { $cache = []; foreach (db()->query('SELECT key, value FROM settings') as $row) { $cache[$row['key']] = (string) $row['value']; } } return array_key_exists($key, $cache) ? $cache[$key] : $default; } // ============================================================ // 访问日志 + IP 黑名单(自动拦截 / 自动记录) // ============================================================ /** 访问日志文件路径:data/vistors.log(追加写入、完整保留,可自行归档 / 删除) */ const ACCESS_LOG_FILE = DATA_DIR . '/vistors.log'; /** 当前客户端 IP(REMOTE_ADDR,忽略转发头以免被伪造) */ function client_ip(): string { static $cached = null; if ($cached !== null) { return $cached; } $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '')); if (!filter_var($ip, FILTER_VALIDATE_IP)) { $ip = ''; } $cached = $ip; return $ip; } /** IPv4 转无符号整数;失败返回 -1 */ function ip4_int(string $ip): int { $n = ip2long($ip); if ($n === false) { return -1; } return $n < 0 ? $n + 4294967296 : $n; } /** * 校验并解析黑名单规则,返回 [ok, net(掩码后网络号), prefix, 错误说明] * 支持:单 IP(203.0.113.7)、CIDR(203.0.113.0/24)、星号通配(203.0.113.* / 192.168.*.*) */ function blacklist_parse_rule(string $raw): array { $rule = trim($raw); if ($rule === '') { return [false, 0, 0, '规则不能为空。']; } // 单 IPv4 if (filter_var($rule, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return [true, ip4_int($rule), 32, '']; } // CIDR 网段 if (preg_match('#^(\d{1,3}(?:\.\d{1,3}){3})/(\d{1,2})$#', $rule, $m)) { $prefix = (int) $m[2]; if ($prefix > 32 || !filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return [false, 0, 0, '网段格式不正确,掩码需在 0-32 之间。']; } $mask = $prefix === 0 ? 0 : ((0xFFFFFFFF << (32 - $prefix)) & 0xFFFFFFFF); return [true, ip4_int($m[1]) & $mask, $prefix, '']; } // 星号通配段:* 只能连续出现在末尾 if (strpos($rule, '*') !== false) { $parts = explode('.', $rule); if (count($parts) !== 4) { return [false, 0, 0, '仅支持标准 IPv4 / CIDR 网段 / 星号通配段。']; } $bytes = [0, 0, 0, 0]; $prefix = 0; $seen = false; foreach ($parts as $i => $p) { if ($p === '*') { if (!$seen) { $seen = true; $prefix = $i * 8; } continue; } if ($seen || $p === '' || !ctype_digit($p) || (int) $p > 255) { return [false, 0, 0, '通配段格式不正确,* 只能连续出现在末尾。']; } $bytes[$i] = (int) $p; } if (!$seen) { return [false, 0, 0, '规则格式不正确。']; } $net = (($bytes[0] << 24) | ($bytes[1] << 16) | ($bytes[2] << 8) | $bytes[3]) & 0xFFFFFFFF; return [true, $net, $prefix, '']; } return [false, 0, 0, '仅支持标准 IPv4、CIDR 网段或星号通配段。']; } /** 读取全部黑名单规则(仅返回解析成功的记录,静态缓存避免单次请求重复查询) */ function blacklist_rules(bool $refresh = false): array { static $cache = null; if ($cache !== null && !$refresh) { return $cache; } $cache = []; try { foreach (db()->query('SELECT id, rule, note, created_at FROM ip_blacklist ORDER BY id ASC') as $row) { [$ok, $net, $prefix] = blacklist_parse_rule((string) $row['rule']); if (!$ok) { continue; } $cache[] = [ 'id' => (int) $row['id'], 'rule' => (string) $row['rule'], 'note' => (string) $row['note'], 'created_at' => (string) $row['created_at'], 'net' => $net, 'prefix' => $prefix, ]; } } catch (Throwable $e) { $cache = []; } return $cache; } /** 当前 IPv4 是否命中黑名单;未命中或非 IPv4 返回 null,命中返回规则记录 */ function blacklist_ip_hit(string $ip): ?array { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return null; } $int = ip4_int($ip); foreach (blacklist_rules() as $r) { if ($r['prefix'] === 32) { if ($r['net'] === $int) { return $r; } continue; } $mask = $r['prefix'] === 0 ? 0 : ((0xFFFFFFFF << (32 - $r['prefix'])) & 0xFFFFFFFF); if (($int & $mask) === $r['net']) { return $r; } } return null; } /** 请求结束统一追加写入一条访问日志(含被黑名单拦截的 403) */ function access_log_flush(): void { try { $ip = client_ip(); if ($ip === '') { return; } $method = (string) ($_SERVER['REQUEST_METHOD'] ?? ''); $method = $method !== '' ? strtoupper($method) : 'GET'; $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); // 登录口令、CSRF 等敏感 query 参数脱敏后再写日志 $uri = preg_replace('/([?&](?:password|passwd|old_pwd|new_pwd|new_pwd2|csrf|token|secret)[^=]*=)[^&]*/i', '$1***', $uri); $uri = substr((string) $uri, 0, 600); $code = http_response_code(); if (!is_int($code) || $code < 100 || $code > 599) { $code = 200; } // 每行:时间 / IP / 请求方式 / 响应码 / 访问 URL(制表符分隔) $line = date('Y-m-d H:i:s') . "\t" . $ip . "\t" . $method . "\t" . $code . "\t" . $uri . "\n"; @file_put_contents(ACCESS_LOG_FILE, $line, FILE_APPEND | LOCK_EX); // 自动风控:前台 404 响应计入累计(是否达到阈值触发封禁见 risk_count_not_found / access_boot 自检) $isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0; if (!$isAdmin && $code === 404) { risk_count_not_found($ip); } } catch (Throwable $e) { // 日志写入失败不影响页面主流程 } } /** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“风控管理”页使用 */ function visitors_entries(): array { $out = []; if (!is_file(ACCESS_LOG_FILE)) { return $out; } $fh = @fopen(ACCESS_LOG_FILE, 'rb'); if (!$fh) { return $out; } while (($line = fgets($fh)) !== false) { $line = rtrim($line, "\r\n"); if ($line === '') { continue; } $p = explode("\t", $line); if (count($p) < 5) { continue; } $out[] = [ 'created_at' => (string) $p[0], 'ip' => (string) $p[1], 'method' => (string) $p[2], 'status' => (int) $p[3], 'url' => (string) $p[4], ]; } fclose($fh); return array_reverse($out); // 新→旧 } /** * 首页顶部组件「安全态势 / 访问趋势」所需的汇总数据(一次遍历访问日志 + 两条轻量查询)。 * 返回:今日/累计访问、今日/累计独立 IP、拦截次数、黑名单条数、近 N 天每日访问、最近一次成功登录。 */ function home_security_stats(int $days = 7): array { $entries = visitors_entries(); $total = count($entries); $today = 0; $allIps = []; $todayIps = []; $blockedTotal = 0; $blockedToday = 0; $todayStart = date('Y-m-d 00:00:00'); // 近 N 天日期桶(含今天,由旧到新) $buckets = []; $order = []; for ($i = $days - 1; $i >= 0; $i--) { $d = date('Y-m-d', strtotime('-' . $i . ' day')); $buckets[$d] = 0; $order[] = $d; } foreach ($entries as $e) { $allIps[$e['ip']] = 1; if ((int) $e['status'] === 403) { $blockedTotal++; } $day = substr((string) $e['created_at'], 0, 10); if (isset($buckets[$day])) { $buckets[$day]++; } if ((string) $e['created_at'] >= $todayStart) { $today++; $todayIps[$e['ip']] = 1; if ((int) $e['status'] === 403) { $blockedToday++; } } } $daily = []; foreach ($order as $d) { $daily[] = ['day' => $d, 'cnt' => $buckets[$d]]; } $ban = (int) db()->query('SELECT COUNT(*) FROM ip_blacklist')->fetchColumn(); $lastLogin = null; $row = db()->query("SELECT username, ip, created_at FROM login_log WHERE status = 'ok' ORDER BY created_at DESC, id DESC LIMIT 1")->fetch(); if ($row) { $lastLogin = [ 'username' => (string) ($row['username'] ?? ''), 'ip' => (string) ($row['ip'] ?? ''), 'time' => (string) ($row['created_at'] ?? ''), ]; } return [ 'total' => $total, 'today' => $today, 'ips_total' => count($allIps), 'ips_today' => count($todayIps), 'blocked_total' => $blockedTotal, 'blocked_today' => $blockedToday, 'ban' => $ban, 'daily' => $daily, 'last_login' => $lastLogin, ]; } /** 自动启动:注册访问日志;黑名单对所有请求(前台与后台登录页)统一拦截;自动风控自检仅针对前台 */ function access_boot(): void { if (PHP_SAPI === 'cli') { return; } register_shutdown_function('access_log_flush'); $ip = client_ip(); if ($ip === '') { return; } $isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0; // 1) 已在黑名单(手动 / 自动):前台与后台一致拦截,统一返回风控提示页 if (blacklist_ip_hit($ip) !== null) { risk_block_page(); } // 2) 自动风控自检仅针对前台(后台正常维护不计入 404 / 频率,避免管理员被自身操作误伤) if (!$isAdmin) { $cfg = risk_rule_cfg(); // 2a) 请求频率:先累计本请求,超过阈值即封禁 if ($cfg['rate']['on'] && risk_bump_rate($ip, (int) $cfg['rate']['n'])) { risk_ban($ip, '请求频率超过阈值(60 秒内 ' . (int) $cfg['rate']['n'] . ' 次)'); risk_block_page(); } // 2b) 404 累计已达标(若上一请求刚触发封禁,这里会因命中黑名单直接拦截,此分支为兑底) if ($cfg['not_found']['on']) { $row = risk_row($ip); if ((int) $row['not_found'] >= (int) $cfg['not_found']['n']) { risk_ban($ip, '404 累计次数达阈值(' . (int) $cfg['not_found']['n'] . ' 次)'); risk_block_page(); } } } } // ============================================================ // 自动风控:登录失败 / 404 次数 / 每分钟请求频率(可启停、可设阈值) // ============================================================ /** 读取 settings 键值全表(规避 setting_get 的静态缓存;供风控配置与拦截提示使用) */ function risk_settings_map(): array { $map = []; try { foreach (db()->query('SELECT key, value FROM settings') as $r) { $map[(string) $r['key']] = (string) $r['value']; } } catch (Throwable $e) { $map = []; } return $map; } /** * 站长自定义拦截提示:main=主提示 sub=次行说明(留空回落默认文案); * 另附 raw_main / raw_sub 原始保存值(空串表示未自定义)。 */ function risk_block_tip(): array { $map = risk_settings_map(); $rawMain = trim((string) ($map['risk_tip_main'] ?? '')); $rawSub = trim((string) ($map['risk_tip_sub'] ?? '')); $main = $rawMain !== '' ? $rawMain : '您触发了本站风控,请稍后再访问'; $sub = $rawSub !== '' ? $rawSub : '如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。'; return ['main' => $main, 'sub' => $sub, 'raw_main' => $rawMain, 'raw_sub' => $rawSub]; } /** * 读取风控规则配置(始终直查 settings 表,避免缓存旧值)。 * 返回:login_fail / not_found / rate,每项含 on(启用) n(阈值) tip(说明)。 */ function risk_rule_cfg(): array { $defs = [ 'login_fail' => [1, 10, '后台登录失败累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'], 'not_found' => [1, 100, '前台访问返回 404 累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'], 'rate' => [0, 120, '60 秒内请求次数超过阈值后,该 IP 被自动加入黑名单(永久封禁);多人共享同一出口 IP 时建议关闭。'], ]; $map = risk_settings_map(); $out = []; // 存储键别名:后台保存使用 risk_login_* / risk_404_* / risk_rate_* $alias = ['login_fail' => 'login', 'not_found' => '404', 'rate' => 'rate']; foreach ($defs as $k => $d) { $pre = $alias[$k]; $out[$k] = [ 'on' => (($map['risk_' . $pre . '_on'] ?? (string) $d[0]) === '1'), 'n' => max(1, min(1000000, (int) ($map['risk_' . $pre . '_n'] ?? (string) $d[1]))), 'tip' => $d[2], ]; } // 拦截提示(含原始保存值,供后台编辑回显与占位提示) $tip = risk_block_tip(); $out['_tip'] = [ 'main' => $tip['main'], 'sub' => $tip['sub'], 'raw_main' => $tip['raw_main'], 'raw_sub' => $tip['raw_sub'], ]; return $out; } /** 读取某 IP 的风控计数行(不存在时返回全 0) */ function risk_row(string $ip): array { $empty = ['ip' => $ip, 'login_fail' => 0, 'not_found' => 0, 'rate_ts' => 0, 'rate_count' => 0, 'updated_at' => '']; if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return $empty; } try { $st = db()->prepare('SELECT ip, login_fail, not_found, rate_ts, rate_count, updated_at FROM ip_risk WHERE ip = ? LIMIT 1'); $st->execute([$ip]); $r = $st->fetch(); return $r ? $r : $empty; } catch (Throwable $e) { return $empty; } } /** 写入某 IP 的风控计数 */ function risk_upsert(string $ip, int $loginFail, int $notFound, int $rateTs, int $rateCount): void { try { $st = db()->prepare('INSERT INTO ip_risk (ip, login_fail, not_found, rate_ts, rate_count, updated_at) VALUES (?,?,?,?,?,?) ON CONFLICT(ip) DO UPDATE SET login_fail=excluded.login_fail, not_found=excluded.not_found, rate_ts=excluded.rate_ts, rate_count=excluded.rate_count, updated_at=excluded.updated_at'); $st->execute([$ip, $loginFail, $notFound, $rateTs, $rateCount, date('Y-m-d H:i:s')]); } catch (Throwable $e) { // 风控写入失败不影响页面主流程 } } /** 将某 IPv4 永久封禁(幂等:已存在该单 IP 规则则不再重复添加),并刷新黑名单缓存 */ function risk_ban(string $ip, string $reason): void { $ip = trim($ip); if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return; } $int = ip4_int($ip); foreach (blacklist_rules() as $r) { if ($r['prefix'] === 32 && $r['net'] === $int) { return; // 已存在 } } try { $st = db()->prepare('INSERT INTO ip_blacklist (rule, note, created_at, source) VALUES (?,?,?,?)'); $st->execute([$ip, '风控自动封禁:' . $reason, date('Y-m-d H:i:s'), 'auto']); blacklist_rules(true); } catch (Throwable $e) { // 忽略:避免风控本身拖垮请求 } } /** 后台登录失败累计;规则启用且达到阈值时自动封禁。返回 [当前失败次数, 本次是否触发封禁, 阈值] */ function risk_login_fail(string $ip): array { $empty = [0, false, 0]; if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return $empty; } $cfg = risk_rule_cfg(); $row = risk_row($ip); $lf = (int) $row['login_fail'] + 1; $n = (int) $cfg['login_fail']['n']; risk_upsert($ip, $lf, (int) $row['not_found'], (int) $row['rate_ts'], (int) $row['rate_count']); $banned = $cfg['login_fail']['on'] && $lf >= $n; if ($banned) { risk_ban($ip, '后台登录失败累计达阈值(' . $n . ' 次)'); } return [$lf, $banned, $n]; } /** 请求频率(每分钟窗口)累计;返回本次累计后是否达到阈值 */ function risk_bump_rate(string $ip, int $threshold): bool { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return false; } $win = (int) floor(time() / 60); $row = risk_row($ip); $cnt = ((int) $row['rate_ts'] === $win) ? ((int) $row['rate_count'] + 1) : 1; risk_upsert($ip, (int) $row['login_fail'], (int) $row['not_found'], $win, $cnt); return $cnt >= $threshold; } /** 前台 404 响应累计;规则启用且达到阈值时自动封禁(本响应结束后生效,下一请求即被拦截) */ function risk_count_not_found(string $ip): void { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return; } $cfg = risk_rule_cfg(); $row = risk_row($ip); $nf = (int) $row['not_found'] + 1; risk_upsert($ip, (int) $row['login_fail'], $nf, (int) $row['rate_ts'], (int) $row['rate_count']); if ($cfg['not_found']['on'] && $nf >= (int) $cfg['not_found']['n']) { risk_ban($ip, '404 累计次数达阈值(' . (int) $cfg['not_found']['n'] . ' 次)'); } } /** 清除某 IP 的风控计数(解封单 IP 黑名单时调用) */ function risk_clear_for_ip(string $ip): void { $ip = trim($ip); if ($ip === '') { return; } try { $st = db()->prepare('DELETE FROM ip_risk WHERE ip = ?'); $st->execute([$ip]); } catch (Throwable $e) { // ignore } } /** 清空全部风控计数(不影响黑名单本身) */ function risk_clear_all(): void { try { db()->exec('DELETE FROM ip_risk'); } catch (Throwable $e) { // ignore } } /** 命中黑名单 / 触发风控时的统一提示页(403) */ function risk_block_page(): void { if (!headers_sent()) { http_response_code(403); header('Content-Type: text/html; charset=utf-8'); } $site = '本站'; try { $site = setting_get('site_name', 'SecHome'); } catch (Throwable $e) { // ignore } $tip = risk_block_tip(); $s = htmlspecialchars($site, ENT_QUOTES, 'UTF-8'); $m = htmlspecialchars($tip['main'], ENT_QUOTES, 'UTF-8'); $sb = htmlspecialchars($tip['sub'], ENT_QUOTES, 'UTF-8'); echo '' . "\n" . '' . '' . '访问受限 - ' . $s . '' . '' . '
403
' . '
' . $m . '
' . '
' . $sb . '
' . '' . $s . '' . '
'; exit; } /** 由 UA 串解析浏览器与操作系统(内部复用):返回 [浏览器, 操作系统] */ function ua_parts(string $ua): array { $ua = trim($ua); if ($ua === '') { return ['未知浏览器', '']; } if (strpos($ua, 'MicroMessenger') !== false) { $browser = '微信内置浏览器'; } elseif (preg_match('/Edg\/([\d.]+)/', $ua, $m)) { $browser = 'Edge ' . $m[1]; } elseif (preg_match('/OPR\/([\d.]+)/', $ua, $m)) { $browser = 'Opera ' . $m[1]; } elseif (preg_match('/Firefox\/([\d.]+)/', $ua, $m)) { $browser = 'Firefox ' . $m[1]; } elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) { $browser = 'Chrome ' . $m[1]; } elseif (preg_match('/Version\/([\d.]+).*Safari/', $ua, $m)) { $browser = 'Safari ' . $m[1]; } elseif (stripos($ua, 'curl') !== false) { $browser = 'curl'; } elseif (preg_match('/python|wget|Go-http|Java|okhttp/i', $ua)) { $browser = '脚本客户端'; } else { $browser = '未知浏览器'; } if (strpos($ua, 'Windows NT 10.0') !== false) { $os = 'Windows 10/11'; } elseif (strpos($ua, 'Windows NT 6.3') !== false) { $os = 'Windows 8.1'; } elseif (strpos($ua, 'Windows NT 6.1') !== false) { $os = 'Windows 7'; } elseif (stripos($ua, 'Windows') !== false) { $os = 'Windows'; } elseif (strpos($ua, 'Android') !== false) { $os = 'Android'; } elseif (preg_match('/iPhone|iPad|iPod/', $ua)) { $os = 'iOS'; } elseif (strpos($ua, 'Mac OS X') !== false) { $os = 'macOS'; } elseif (strpos($ua, 'Linux') !== false) { $os = 'Linux'; } else { $os = ''; } return [$browser, $os]; } /** 由 UA 串解析“浏览器 / 操作系统”(简洁展示;容错返回“未知设备”) */ function login_ua_brief(string $ua): string { if (trim($ua) === '') { return '未知设备'; } list($browser, $os) = ua_parts($ua); return $os !== '' ? ($browser . ' / ' . $os) : $browser; } /** * 设备信息描述(手机型号 / 操作系统 / 浏览器 / 设备类型 + 屏幕、语言等): * 客户端采集 JSON($clientJson,见 common.js collectLoginDevice)优先,服务端 UA 回退;能获取到什么就写什么。 */ function login_device_brief(string $clientJson, string $ua): string { $ua = trim($ua); $ci = json_decode($clientJson, true); if (!is_array($ci)) { $ci = []; } $uch = (isset($ci['uch']) && is_array($ci['uch'])) ? $ci['uch'] : []; list($browser, $os) = ua_parts($ua); // 系统:客户端高熵值优先(可拿到 Windows/Android 具体版本) if (!empty($uch['platform'])) { $os2 = trim((string) $uch['platform']); if (!empty($uch['platformVersion'])) { $os2 .= ' ' . trim((string) $uch['platformVersion']); } if ($os2 !== '') { $os = $os2; } } // 型号:客户端 model 优先,其次从 Android UA 提取,iOS 只能得到机型系列 $model = ''; if (!empty($uch['model'])) { $model = trim((string) $uch['model']); } elseif (preg_match('/Android[\s\d.]+;\s*([^;)]+?)(?:\s+Build|\))/i', $ua, $m)) { $model = trim($m[1]); } elseif (preg_match('/(iPhone|iPad|iPod)/i', $ua, $m)) { $model = 'Apple ' . $m[1]; } $mobile = (!empty($ci['touch'])) || (stripos($ua, 'Mobile') !== false) || (isset($uch['mobile']) && $uch['mobile']); $seg = []; if ($model !== '') { $seg[] = $model; } if ($os !== '') { $seg[] = $os; } if ($browser !== '') { $seg[] = $browser; } $seg[] = $mobile ? '移动设备' : '桌面设备'; $extra = []; if (!empty($uch['architecture'])) { $extra[] = '架构 ' . trim((string) $uch['architecture']); } if (!empty($ci['screen'])) { $extra[] = '屏幕 ' . trim((string) $ci['screen']); } if (!empty($ci['lang'])) { $extra[] = trim((string) $ci['lang']); } $text = implode(' · ', $seg); if ($extra) { $text .= '(' . implode(',', $extra) . ')'; } return function_exists('mb_substr') ? mb_substr($text, 0, 300, 'UTF-8') : substr($text, 0, 300); } /** 写入一条登录日志(status:ok=成功 / fail=失败);同时保存客户端设备信息($deviceClient 为前端采集 JSON) */ function login_log_write(string $username, string $status, string $deviceClient = ''): void { $status = ($status === 'fail') ? 'fail' : 'ok'; $uaRaw = (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''); $ua = function_exists('mb_substr') ? mb_substr($uaRaw, 0, 300, 'UTF-8') : substr($uaRaw, 0, 300); $device = login_device_brief(substr($deviceClient, 0, 2000), $uaRaw); try { $st = db()->prepare('INSERT INTO login_log (username, ip, ua, device, status, created_at) VALUES (?,?,?,?,?,?)'); $st->execute([$username, client_ip(), $ua, $device, $status, date('Y-m-d H:i:s')]); } catch (Throwable $e) { // 登录日志写入失败不影响登录流程 } } /** 最近的成功登录记录(新→旧) */ function login_log_recent_ok(int $limit = 15): array { $limit = max(1, min(200, $limit)); try { $st = db()->prepare("SELECT username, ip, ua, device, created_at FROM login_log WHERE status = 'ok' ORDER BY id DESC LIMIT ?"); $st->bindValue(1, $limit, PDO::PARAM_INT); $st->execute(); return $st->fetchAll(); } catch (Throwable $e) { return []; } } /** 登录失败日志:按「IP × 日期」聚合(次数 / 最近时间 / 最近设备),支持 IP 模糊筛选 */ function login_fail_daily_stats(string $ipFilter = '', int $limit = 200): array { $limit = max(1, min(1000, $limit)); try { $sql = "SELECT ip, substr(created_at,1,10) AS day, COUNT(*) AS cnt, MAX(created_at) AS last_at, (SELECT ua FROM login_log l2 WHERE l2.ip = l1.ip AND l2.status = 'fail' ORDER BY l2.id DESC LIMIT 1) AS ua, (SELECT device FROM login_log l3 WHERE l3.ip = l1.ip AND l3.status = 'fail' ORDER BY l3.id DESC LIMIT 1) AS device FROM login_log l1 WHERE status = 'fail'"; $args = []; if ($ipFilter !== '') { $sql .= ' AND ip LIKE ?'; $args[] = '%' . $ipFilter . '%'; } $sql .= ' GROUP BY ip, day ORDER BY last_at DESC LIMIT ' . $limit; $st = db()->prepare($sql); $st->execute($args); return $st->fetchAll(); } catch (Throwable $e) { return []; } } /** 登录日志统计:今日成功 / 今日失败 / 失败涉及 IP 数 / 总记录数 */ function login_log_stat(): array { $out = ['ok_today' => 0, 'fail_today' => 0, 'fail_ips' => 0, 'total' => 0]; try { $today = db()->quote(date('Y-m-d')); $out['ok_today'] = (int) db()->query("SELECT COUNT(*) FROM login_log WHERE status = 'ok' AND substr(created_at,1,10) = " . $today)->fetchColumn(); $out['fail_today'] = (int) db()->query("SELECT COUNT(*) FROM login_log WHERE status = 'fail' AND substr(created_at,1,10) = " . $today)->fetchColumn(); $out['fail_ips'] = (int) db()->query("SELECT COUNT(DISTINCT ip) FROM login_log WHERE status = 'fail'")->fetchColumn(); $out['total'] = (int) db()->query('SELECT COUNT(*) FROM login_log')->fetchColumn(); } catch (Throwable $e) { // ignore } return $out; } /** 清空登录日志(成功 + 失败) */ function login_log_clear(): void { try { db()->exec('DELETE FROM login_log'); } catch (Throwable $e) { // ignore } } access_boot();