diff --git a/admin_2fa_setup.php b/admin_2fa_setup.php index 08a5edd..e108d9b 100644 --- a/admin_2fa_setup.php +++ b/admin_2fa_setup.php @@ -21,7 +21,10 @@ $boot = secBootSessionActive(); // 无 install.lock)时,本页先让位给数据库安装页;装完它自删,再回来配 2FA/白名单/令牌。 // 引导模式下 secGatePage 已先行 302,这里兜住「已引导 / 本机直连」两种入口; // 已引导站点带 ?token= 过 install.php 的第三层门禁(令牌为空时 secTokenUrl 原样返回)。 -if (secInstallPending()) { +// ★ 0.0.0.0 应急时让位反向失效:install.php 属「其它任何页面」,会被 secEmergencyGate() +// 302 回本页 —— 若本页再 302 去 install.php 就成了两页互跳死循环。 +// 应急的语义就是「直达引导配置页把白名单改回来」,故应急优先于装库让位。 +if (secInstallPending() && !secIsEmergency()) { header('Location: ' . secTokenUrl('install.php')); exit; } @@ -34,8 +37,17 @@ $needInit = !secAdminPassConfigured(); // 说明:本页只依赖 security.php(不加载 core.php), // isAdminLoggedIn() 是 core 的函数,这里用等价的内联判定(admin_auth + 安全纪元)。 $loggedIn = !empty($_SESSION['admin_auth']) && secSessionEpochOk(); +$isEmergency = false; if ($boot) { $mode = 'boot'; +} elseif (secIsEmergency()) { + // ★ 0.0.0.0 应急(2026-10-09):boot / 访问令牌全丢时的最后通道 —— 按 boot + // 引导模式免密放行(本分支必须在 session 之前:应急时若 session 开着 2FA, + // 会被送去 admin_2fa.php,而那页又被全局应急路由 302 回本页 → 死循环)。 + // 引导动作:删掉白名单里的 0.0.0.0 → 保存 → 重新生成令牌,即恢复常态。 + $mode = 'boot'; + $isEmergency = true; + secLog('emergency_setup_enter', 'ip=' . secClientIp()); } elseif ($loggedIn) { // 已登录:若 2FA 已启用但本会话未验证 → 先去验证页 if (defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED && empty($_SESSION['admin_2fa_ok'])) { @@ -346,7 +358,8 @@ $wlAllowsMe = secIpAllowed() || secIpInWhitelist($myIp, $whitelist);

🔐 后台安全配置

- 引导模式(boot 令牌) + 引导模式(boot 令牌) + 🚨 应急模式(0.0.0.0) 首次初始化 ❌ ✅ + +
+ 🚨 应急模式生效中:白名单里存在条目 0.0.0.0,全站其它页面已全部锁定并跳转到本页。
+ 恢复步骤(做完再离开): + ① 在下方「IP 白名单」里删掉 0.0.0.0 并点「保存白名单」(确保你的 IP 在列表里); + ② 点「重新生成令牌」重置全部令牌(原令牌视为已泄露); + ③ 刷新本页确认 🚨 徽标消失。 +
+ + @@ -447,6 +470,8 @@ $wlAllowsMe = secIpAllowed() || secIpInWhitelist($myIp, $whitelist); 0): ?>
+
当前访问 IP: + —— 白名单里必须包含它,否则保存后你会把自己挡在门外(含 0.0.0.0 应急条目时本页是唯一入口,改完记得删掉它)。
diff --git a/config.php b/config.php index 11c3d5d..4957b25 100644 --- a/config.php +++ b/config.php @@ -116,5 +116,10 @@ define('CAPTCHA_PROVIDER', 'CF'); // ← 只改这个值:'CF' 用 Cloudflare // 被锁在外面时的恢复方法:宝塔/SSH 查看本文件补全后的 SEC_BOOT_TOKEN,访问 // https://你的域名/plugin/admin_2fa_setup.php?boot=<令牌> // 进入配置页把自己当前 IP 加入白名单(boot 令牌本身也是配置页入口)。 +// ★ 令牌全丢的最后手段(2026-10-09):宝塔/SSH 手工给下面 SEC-UPDATE 区的 +// ADMIN_IP_WHITELIST 数组加一条字面量 '0.0.0.0' —— 应急模式立即生效: +// 除服务器本机外全站所有页面/API 一律 302 锁回 admin_2fa_setup.php(免密进入), +// 按该页红色横幅指引删掉 0.0.0.0 + 「重新生成令牌」即恢复常态。 +// ⚠️ 0.0.0.0 只是应急门票(等价全网可进引导页),用完必须立刻删除。 // >>>SEC-UPDATE-BEGIN 本区段由 admin_2fa_setup.php 自动维护,请勿手工改动标记行 // >>>SEC-UPDATE-END diff --git a/install.php b/install.php index 7e0170e..df2d724 100644 --- a/install.php +++ b/install.php @@ -23,8 +23,9 @@ require_once __DIR__ . '/security.php'; // ① 已装过 → 与文件不存在完全一致的表现(本页也可能还没来得及自删) if (is_file(__DIR__ . '/install.lock')) secNginx404(); -// ② IP 白名单 / boot 引导令牌 / 未引导放行 -secGatePage(true); +// ② 不做 IP 白名单验证(2026-10-09 需求):全新站根本没有白名单可言,拿白名单 +// 挡安装页只会把部署者锁在门外;已引导站点由下面 ③ 的令牌层把关(?token= / ?boot=)。 +// 原 secGatePage(true) 在「已引导但没装库」时会把非白名单 IP 挡成 404。 // ③ 动态访问令牌(config 里有 SEC_ACCESS_TOKEN 时才生效;全新部署为空 → 放行) secTokenGate(true); secEnsureSession(); @@ -35,6 +36,7 @@ define('INSTALL_MD', defined('SQL_MD_PATH') ? SQL_MD_PATH : __DIR__ . '/sql.md $cur = sqlMdDbConfig(); // static 缓存,此刻读的是「写入前」的旧配置,仅用于回显 $err = ''; $ok = ''; +$saved = null; // 本次 POST 成功写入的配置 → 完成页回显供截图/手抄 // CSRF:会话级随机数(不依赖 SEC_ACCESS_TOKEN,全新部署时它还是空的) if (empty($_SESSION['inst_csrf'])) { @@ -383,14 +385,19 @@ $val = function ($k, $d = '') use ($cur, $err) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && $err !== '' && isset($_POST[$k])) { return htmlspecialchars((string)$_POST[$k], ENT_QUOTES, 'UTF-8'); } - if ($k === 'dir') return htmlspecialchars($cur['dir'], ENT_QUOTES, 'UTF-8'); + $cur = is_array($cur) ? $cur : array(); + if ($k === 'dir') return htmlspecialchars(isset($cur['dir']) ? (string)$cur['dir'] : '', ENT_QUOTES, 'UTF-8'); if ($k === 'mode') return ''; if (preg_match('/^(java|web)_(host|port|name|user|pass)$/', $k, $m)) { - $e = $cur[$m[1]]; + // ★ 全新站 sql.md 配置区账密全空 → sqlMdDbConfig 的 java/web 槽是 null + // ("字段不全 → null → 该侧兜底 db"),直接 $e[...] 在 PHP 8 会每个输入框 + // 报一条 "Trying to access array offset on null"(实录:test12 第 391 行)。 + $e = (isset($cur[$m[1]]) && is_array($cur[$m[1]])) ? $cur[$m[1]] : array(); $map = array('host' => 'host', 'port' => 'port', 'name' => 'name', 'user' => 'user', 'pass' => 'pass'); - return htmlspecialchars((string)$e[$map[$m[2]]], ENT_QUOTES, 'UTF-8'); + $rv = isset($e[$map[$m[2]]]) ? (string)$e[$map[$m[2]]] : ''; + return htmlspecialchars($rv, ENT_QUOTES, 'UTF-8'); } - return htmlspecialchars($d, ENT_QUOTES, 'UTF-8'); + return htmlspecialchars((string)$d, ENT_QUOTES, 'UTF-8'); }; ?> @@ -447,12 +454,40 @@ ul{margin:6px 0;padding-left:20px}
  • 配置已写入 sql.md 并回读校验通过。
  • 已生成 install.lock,本页再次访问将返回 404。
  • -
  • install.php 已自我删除。
  • +
  • install.php 已自我删除(后台静默完成,页面照常停留供你截图留档)。
+ + +
+
📋 本次写入的配置 —— 请截图或手抄留档:
+ + + + + + + + + + + + 'Java 侧(sync.php · Java.db)', 'web' => 'PHP 侧(web.db)') as $slot => $label): ?> + + + + + + + +
运行模式
存储位置
(空 → 自动回退 db 文件)
+
本页已随安装完成而删除 —— 这是最后一处能整份查看该配置的页面;完整配置仍保存在 sql.md。
+
+ + 下一步:
    -
  • db 模式 → 直接访问 后台登录页。
  • -
  • mysql 模式 → 后台首访会自动建表(已在本页实连并验证过建表权限)。
  • +
  • 先去 安全配置引导页:把你当前的 IP 加入白名单、保存访问令牌、设置 2FA。
  • +
  • mysql 模式 → 配完安全项首次进后台会自动建表(已在本页实连并验证过建表权限);db 模式 → 配完即可登录。
  • 记得让 Java 端(api/sync.php 所在的独立站点)也用同一份配置:改 sync.php 时两处都要传。
diff --git a/security.php b/security.php index cb86afe..9fc93d0 100644 --- a/security.php +++ b/security.php @@ -273,6 +273,25 @@ function secCidrMatch($ip, $cidr) { * 规则:127.0.0.1/::1 永远放行(服务器本机逃生舱); * 白名单为空 → 除本机外任何人一律拒绝(含 IP 缺失的异常请求)。 */ +/** + * 0.0.0.0 应急模式(2026-10-09):白名单里出现字面条目 `0.0.0.0`。 + * + * 场景:boot 引导令牌与后台访问令牌全丢、又没有别的入口 —— 只要能上服务器 + * (宝塔/SSH)把 config.php 的 ADMIN_IP_WHITELIST 加上一条 0.0.0.0,即可临时恢复: + * - 全站其它页面/API 一律被 security.php 尾部的全局应急路由 302 回 + * admin_2fa_setup.php(应急条目只买得到这一张门票,用来删掉它自己 + 重置令牌); + * - admin_2fa_setup.php 以 boot 引导模式免密放行(见该页 $isEmergency 分支); + * - 服务器本机 127.0.0.1 / ::1 不受应急路由限制(宝塔/SSH 永远有救)。 + * 用完必须立刻删掉 0.0.0.0 —— 它等价于"全网都能进引导页"。 + */ +function secIsEmergency() { + if (!defined('ADMIN_IP_WHITELIST') || !is_array(ADMIN_IP_WHITELIST)) return false; + foreach (ADMIN_IP_WHITELIST as $e) { + if (trim((string)$e) === '0.0.0.0') return true; + } + return false; +} + function secIpAllowed() { $ip = secClientIp(); if ($ip === '') { @@ -284,6 +303,9 @@ function secIpAllowed() { foreach (ADMIN_IP_WHITELIST as $entry) { $entry = trim((string)$entry); if ($entry === '') continue; + // ★ 0.0.0.0 = 应急全放行条目:IP 层直接开门,但非引导页根本到不了门禁 —— + // security.php 尾部的全局应急路由会在 require 阶段就把它们 302 回引导页。 + if ($entry === '0.0.0.0') return true; if ($entry === $ip) return true; if (strpos($entry, '/') !== false && secCidrMatch($ip, $entry)) return true; } @@ -617,6 +639,9 @@ function secGatePage($allowBoot = false) { // → 先放行,让部署者完成首次配置;后台主入口改跳配置页,这就是"引导"的落点。 if (!secIsBootstrapped()) { secLog('ip_gate_open_bootstrap', 'path=' . (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-')); + // ★ 应急模式下直接放行:全局钩子只放 admin_2fa_setup.php 走到这里, + // 不能再把它转送去 install(那是常规首跑的顺序,应急时以自救为先)。 + if (secIsEmergency()) return; $script = basename(isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : ''); // ★ 首跑顺序(2026-10-09):装库先于安全配置 —— install.php 还没跑完 // (文件在场、无 install.lock)时,任何引导入口一律先 302 到自毁式安装页; @@ -1457,6 +1482,27 @@ function secRateTrusted() { return false; } +/** + * 全局应急路由(2026-10-09):include security.php 时自动执行一次(在限流层之前)。 + * + * 白名单含 0.0.0.0 时,除 admin_2fa_setup.php 与服务器本机外,任何加载本文件的 + * 入口(admin 系 + core 系全部页面/API)一律 302 送回应急引导页 —— 0.0.0.0 只放行 + * 到那一页,进去删掉应急条目 + 重置令牌即恢复常态。 + * 用 302 不用 301:301 会被浏览器永久缓存,改回白名单后客户端仍会继续乱跳。 + * 无 REMOTE_ADDR(CLI/内部调用)不参与。 + */ +function secEmergencyGate() { + if (empty($_SERVER['REMOTE_ADDR'])) return; + if (!secIsEmergency()) return; + $script = basename(isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : ''); + if ($script === 'admin_2fa_setup.php') return; // 唯一放行页(引导用户自救) + if (secIsLocalIp(secClientIp())) return; // 本机恒通:宝塔/SSH 永远有救 + secLog('emergency_route', 'path=' . (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-')); + header('Location: admin_2fa_setup.php', true, 302); + exit; +} +secEmergencyGate(); + /** 第四层入口:include security.php 时自动执行一次。 */ function secRateLimitGate() { if (PHP_SAPI === 'cli') return; // 命令行脚本不参与限流