* 才算"这个文件存在",否则一律返回 nginx 原生 404 页(与文件不存在时
* 的表现完全一致,让探测者分不清是文件不在还是自己请求错了)。
* 第四层:全局限流(2026-09-30)—— 每个客户端 IP 每 60 秒最多 20 次请求,
* 玩家端与管理端统一生效。白名单 IP(维护者/管理员)、携带正确
* SECRET_KEY 的 Java/服务端调用、以及登录轮询等机器接口不计数;
* 超限请求**不返回任何数据**,直接挂起连接直到 nginx/浏览器超时
* 自动断开(挂起槽有并发上限,避免把 PHP-FPM worker 占满)。
* 应急关闭:配置 define('SEC_RATE_LIMIT', false); 或建 db/ratelimit.off。
*
* 引导令牌(防自锁逃生舱):
* 忘记密码之外的"被锁在外面"场景,用宝塔/SSH 打开 config.php 查看 SEC_BOOT_TOKEN,
* 访问 admin_2fa_setup.php?boot=<令牌> 即可进入配置页把自己 IP 加入白名单。
*
* 依赖:config.php(本文件自动加载)
* 使用方:core.php(会话增强)、admin.php、admin_login.php、
* admin_2fa.php、admin_2fa_setup.php、api/admin.php
*
* 注意:本文件必须以 ,避免任何意外输出污染页面/JSON。
*/
require_once __DIR__ . '/config.php';
/* ============================================================================
* sql.md 数据库配置解析(2026-10-09 双库切分重构)
*
* 为什么放 security.php 而不是 config.php:
* 1) security.php 是全站唯一的公共加载点 —— core.php、admin_2fa_setup.php、
* poller_online.php、minecraft_auth.php 全部会 require 它,任何入口都拿得到
* sqlDbFile();而 config.php 只有部分入口直接加载。
* 2) 线上 config.php 不随部署覆盖(deploy 的 EXCLUDE 永不上传它,它只由
* admin_2fa_setup.php 就地改写 SEC-UPDATE 区)—— 放 config.php 线上根本不会生效。
* ========================================================================== */
// ===== sql.md 运行配置(2026-10-09 大重构:配置文件 + 说明书二合一)=====
// ★ sql.md 整个文档就是数据库配置文件:只读「配置区开始」哨兵所在的那个 json 代码块,
// 说明区表格 / 常见问题一律忽略 —— 文档里不管有多少个代码块,都只认这一个。
// ★ 软依赖(全部回退 db 兜底,绝不因配置问题让站点 500):
// sql.md 缺失 / json 解码失败 / 运行模式非法 / MySQL 地址任一字段为空 → 一律按 db 运行。
// ★ 运行模式四个取值不区分大小写:true|db → db(SQLite);false|mysql → mysql。
// ★ 归属决定地址给谁用:web|php → PHP 专属库;java|plugin → Java 专属库;
// 归属留空时按模板出现顺序分配(第 1 个给 Java,第 2 个给 PHP)。
// ★ 可用 define('SQL_MD_PATH', '/path/sql.md') 覆盖路径(仅测试用,与 PAY_MD_PATH 同规矩)。
if (!function_exists('sqlModeNorm')) {
/**
* 运行模式归一化:true/db → 'db';false/mysql → 'mysql';未知值 → 'db' 兜底。
* 兼容 json 里的布尔 true/false 与字符串 "true"/"DB"/"MySQL" 等写法。
*/
function sqlModeNorm($v) {
if (is_bool($v)) return $v ? 'db' : 'mysql';
if (is_int($v) || is_float($v)) return ((int)$v) ? 'db' : 'mysql';
$s = strtolower(trim((string)$v));
if ($s === 'true' || $s === 'db') return 'db';
if ($s === 'false' || $s === 'mysql') return 'mysql';
return 'db';
}
}
if (!function_exists('sqlMySqlEntry')) {
/**
* 解析 sql.md 里的一个 MySQL 地址条目(一对 {} = 一个地址)。
* 软依赖:地址 / 端口 / 库名 / 账号 / 密码 任意一项为空 → 返回 null(该归属回退 db)。
* @return array|null ['host','port','name','user','pass'] 或 null
*/
function sqlMySqlEntry($el) {
$g = function ($k, $d = '') use ($el) {
return array_key_exists($k, $el) ? (string)$el[$k] : $d;
};
$host = trim($g('数据库地址'));
$port = trim($g('端口'));
$name = trim($g('数据库名'));
$user = trim($g('数据库账号'));
$pass = $g('数据库密码');
if ($host === '' || $port === '' || $name === '' || $user === '' || $pass === '') return null;
if (!ctype_digit($port)) return null;
$p = (int)$port;
if ($p < 1 || $p > 65535) return null;
return array('host' => $host, 'port' => $p, 'name' => $name, 'user' => $user, 'pass' => $pass);
}
}
if (!function_exists('sqlMdDbConfig')) {
/**
* 读 sql.md 配置模块的 json,返回运行配置(static 缓存,一次请求只读一次盘)。
*
* @return array {
* 'mode' => 'db'|'mysql', 运行模式(已归一化)
* 'dir' => string, db 模式的存储位置(未配置 = '')
* 'java' => array|null, Java 专属 MySQL 连接参数(null = 该侧兜底 db)
* 'web' => array|null, PHP 专属 MySQL 连接参数(null = 该侧兜底 db)
* 'raw' => array|null, 解析出的原始 json(install 页回显用)
* 'ok' => bool, 是否成功读到配置模块
* }
*/
function sqlMdDbConfig() {
static $cfg = null;
if ($cfg !== null) return $cfg;
$cfg = array(
'mode' => 'db', 'dir' => '', 'java' => null, 'web' => null, 'raw' => null, 'ok' => false,
);
$path = defined('SQL_MD_PATH') ? SQL_MD_PATH : __DIR__ . '/sql.md';
if (!is_file($path)) return $cfg;
$md = @file_get_contents($path);
if (!is_string($md) || $md === '') return $cfg;
if (!preg_match_all('/```(?:json|JSON)?\s*(.*?)```/s', $md, $mm) || empty($mm[1])) return $cfg;
// 只认含「配置区开始」哨兵的代码块;没找到哨兵时退而取第一个可解码的块(老格式兜底)
$arr = null;
foreach ($mm[1] as $body) {
$a = json_decode(trim($body), true);
if (!is_array($a)) continue; // 解码失败的块直接跳过,不影响下一个块
$hit = false;
foreach ($a as $el) {
if (is_array($el) && isset($el['text'])
&& strpos((string)$el['text'], '配置区开始') !== false) {
$hit = true;
break;
}
}
if ($hit) { $arr = $a; break; }
if ($arr === null) $arr = $a;
}
if (!is_array($arr)) return $cfg; // 整体解码失败 → 走 db 兜底
$cfg['ok'] = true;
$cfg['raw'] = $arr;
$slotSeq = 0; // 归属留空时按出现顺序分配槽位
foreach ($arr as $el) {
if (!is_array($el)) continue;
// 模式条目:{运行模式, 数据库存储位置}
if (array_key_exists('运行模式', $el)) {
$cfg['mode'] = sqlModeNorm($el['运行模式']);
if (array_key_exists('数据库存储位置', $el)) {
$cfg['dir'] = trim((string)$el['数据库存储位置']);
}
continue;
}
// 地址条目:含 MySQL 字段才算(一对 {} = 一个地址)
if (!array_key_exists('数据库地址', $el) && !array_key_exists('数据库名', $el)) continue;
$own = strtolower(trim(isset($el['归属']) ? (string)$el['归属'] : ''));
if ($own !== '' && (strpos($own, 'java') !== false || strpos($own, 'plugin') !== false)) {
$slot = 'java';
} elseif ($own !== '' && (strpos($own, 'web') !== false || strpos($own, 'php') !== false)) {
$slot = 'web';
} else {
$slot = ($slotSeq === 0) ? 'java' : 'web';
}
$slotSeq++;
$conn = sqlMySqlEntry($el); // 字段不全 → null → 该侧兜底 db
if ($conn !== null && $cfg[$slot] === null) $cfg[$slot] = $conn;
}
return $cfg;
}
}
if (!function_exists('sqlDbDir')) {
/**
* 解析 db 模式的存储位置:
* /xxx → 绝对路径(从根目录开始)
* ./xxx → 相对当前目录(Web 根)
* ../xxx → 相对上层目录
* 空 → 默认 __DIR__/db(与改造前 DB_PATH 完全一致,老部署零变化)
* @return string 已归一为 / 分隔的绝对路径
*/
function sqlDbDir() {
$p = trim(sqlMdDbConfig()['dir']);
if ($p === '') return str_replace('\\', '/', __DIR__ . '/db');
$p = str_replace('\\', '/', $p);
$base = str_replace('\\', '/', __DIR__);
while (strpos($p, '../') === 0) { $base = dirname($base); $p = substr($p, 3); }
if (strpos($p, './') === 0) $p = substr($p, 2);
if ($p !== '' && $p[0] === '/') return rtrim($p, '/'); // 绝对路径
if ($p === '') return $base;
return rtrim($base, '/') . '/' . trim($p, '/'); // 相对当前目录
}
}
if (!function_exists('sqlDbFile')) {
/**
* 取某侧的 SQLite 文件路径(db 模式 / mysql 模式下该侧兜底时都用它)。
* 'web' → web.db(PHP 专属);未配置存储位置时沿用 config.php 的 DB_PATH,老部署零变化
* 'java' → Java.db(Java 专属)
*/
function sqlDbFile($side) {
$c = sqlMdDbConfig();
if ($side === 'web') {
if ($c['dir'] === '' && defined('DB_PATH') && DB_PATH !== '') {
return str_replace('\\', '/', DB_PATH);
}
return sqlDbDir() . '/web.db';
}
return sqlDbDir() . '/Java.db';
}
}
// ======================================================================
// 基础:客户端 IP
// ======================================================================
/** 客户端真实 IP。只信 REMOTE_ADDR(不取 X-Forwarded-For,防伪造)。 */
function secClientIp() {
$ip = isset($_SERVER['REMOTE_ADDR']) ? trim((string)$_SERVER['REMOTE_ADDR']) : '';
if (strncmp($ip, '::ffff:', 7) === 0) $ip = substr($ip, 7); // IPv4-mapped IPv6 归一
// ★ Cloudflare 回源场景:nginx 未配 real_ip 时 REMOTE_ADDR 是每次变化的 CF 节点 IP,
// 真实客户端 IP 在 CF-Connecting-IP 头里。仅当 REMOTE_ADDR 确属 CF 官方网段
// 才信任该头 —— 攻击者直连源站伪造该头时不属于 CF 网段,伪造无效;
// 经 CF 访问时 CF 会覆盖客户端传入的同名头,亦无法伪造。
if ($ip !== '' && secIsCloudflareIp($ip)) {
foreach (array('HTTP_CF_CONNECTING_IP', 'HTTP_TRUE_CLIENT_IP') as $h) {
if (!empty($_SERVER[$h])) {
$c = trim((string)$_SERVER[$h]);
if (filter_var($c, FILTER_VALIDATE_IP)) {
if (strncmp($c, '::ffff:', 7) === 0) $c = substr($c, 7);
return $c;
}
}
}
}
return $ip;
}
/** IP 是否属于 Cloudflare 官方回源网段(developers.cloudflare.com/ips,IPv4)。 */
function secIsCloudflareIp($ip) {
static $nets = array(
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
'104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
);
foreach ($nets as $cidr) {
if (secCidrMatch($ip, $cidr)) return true;
}
return false;
}
function secIsLocalIp($ip) {
return $ip === '127.0.0.1' || $ip === '::1';
}
/** IPv4 CIDR 匹配,支持 "1.2.3.4/24" 写法(家用动态 IP 段适用)。 */
function secCidrMatch($ip, $cidr) {
$parts = explode('/', $cidr, 2);
if (count($parts) !== 2 || !is_numeric($parts[1])) return false;
$bits = (int)$parts[1];
if ($bits < 0 || $bits > 32) return false;
$ipLong = ip2long($ip);
$snLong = ip2long($parts[0]);
if ($ipLong === false || $snLong === false) return false;
$mask = $bits === 0 ? 0 : ((-1 << (32 - $bits)) & 0xFFFFFFFF);
return ($ipLong & $mask) === ($snLong & $mask);
}
/**
* IP 白名单判定(第二层)。
* 规则: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 === '') {
secLog('ip_unknown', 'REMOTE_ADDR 缺失,按拒绝处理');
return false;
}
if (secIsLocalIp($ip)) return true;
if (!defined('ADMIN_IP_WHITELIST') || !is_array(ADMIN_IP_WHITELIST)) return false;
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;
}
return false;
}
// ======================================================================
// 审计日志(入侵排查用)
// ======================================================================
function secLog($event, $detail = '') {
$dir = __DIR__ . '/db';
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
$line = '[' . date('Y-m-d H:i:s') . '] ip=' . secClientIp() . ' event=' . $event
. ($detail !== '' ? ' | ' . $detail : '') . "\n";
@file_put_contents($dir . '/security.log', $line, FILE_APPEND | LOCK_EX);
}
// ======================================================================
// boot 引导令牌(防自锁:读得到 config.php 的管理员 = 服务器管理员)
// ======================================================================
function secBootToken() {
return defined('SEC_BOOT_TOKEN') ? (string)SEC_BOOT_TOKEN : '';
}
function secEnsureSession() {
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
}
/** boot 会话是否有效(令牌正确 且 与建立会话时的 IP 一致)。 */
function secBootSessionActive() {
if (secBootToken() === '') return false;
if (empty($_SESSION['sec_boot_ok'])) return false;
return !empty($_SESSION['sec_boot_ip']) && $_SESSION['sec_boot_ip'] === secClientIp();
}
/**
* 尝试用 URL/POST 的 boot 参数解锁(成功则建立 boot 会话);
* 已有有效 boot 会话也返回 true。
*/
function secBootAuthenticate() {
if (secBootToken() === '') return false;
// ★ 先看"有没有凭据可验",再决定要不要开会话(2026-10-05)。
// secGatePage / secTokenGate 现在会为所有被白名单挡住的请求调用本函数做恢复路由,
// 若无条件 session_start(),扫目录的匿名流量会每次刷出一个 session 文件。
// URL 没带 boot、浏览器也没带会话 cookie、当前也没有活动会话 → 无凭据可验,直接失败。
$hasParam = isset($_GET['boot']) || isset($_POST['boot']);
if (!$hasParam && session_status() !== PHP_SESSION_ACTIVE && empty($_COOKIE[session_name()])) {
return false;
}
secEnsureSession();
$t = '';
if (isset($_GET['boot'])) $t = (string)$_GET['boot'];
elseif (isset($_POST['boot'])) $t = (string)$_POST['boot'];
if ($t !== '' && hash_equals(secBootToken(), $t)) {
$_SESSION['sec_boot_ok'] = 1;
$_SESSION['sec_boot_ip'] = secClientIp();
secLog('boot_unlock', 'boot 令牌解锁配置页');
return true;
}
if ($t !== '') {
secLog('boot_fail', 'boot 令牌不匹配');
}
return secBootSessionActive();
}
// ======================================================================
// 动态访问令牌(第三层:admin.php?token= 放行,否则 nginx 原生 404)
// ======================================================================
/** 后台入口令牌(config.php 的 SEC_ACCESS_TOKEN;为空字符串 = 本层未启用)。 */
function secToken() {
return defined('SEC_ACCESS_TOKEN') ? trim((string)SEC_ACCESS_TOKEN) : '';
}
/** 生成新令牌(32 位十六进制,128 bit 随机)。 */
function secNewToken() {
return bin2hex(random_bytes(16));
}
/**
* 复刻 nginx 默认 404 页面(原样字节)。
* 目的:不给探测者任何"服务器上有这个文件"的线索 —— 拿不到令牌时的 admin.php
* 与一个根本不存在的 .php 文件表现完全相同。
*/
function secNginx404() {
while (ob_get_level() > 0) { @ob_end_clean(); }
http_response_code(404);
header('Content-Type: text/html');
header('X-Content-Type-Options: nosniff');
echo "\r\n"
. "
404 Not Found\r\n"
. "\r\n"
. "404 Not Found
\r\n"
. "
nginx\r\n"
. "\r\n"
. "\r\n";
exit;
}
/**
* 第三层闸门:只有 admin.php?token= 才放行。
* - 令牌未配置(SEC_ACCESS_TOKEN 为空/不存在)→ 直接放行,向后兼容;
* - 尚未引导(全新部署第一次访问,见 secIsBootstrapped)→ 本层整体不生效,
* 否则部署者连配置页都进不去,首次引导无从完成;
* - $allowBoot=true 时 ?boot= 也可放行(配置页逃生舱,
* boot 令牌本身就是"读得到 config.php 的服务器管理员"凭据);
* - 其余一律 nginx 404(不是 403 —— 403 会暴露文件确实存在)。
*/
function secTokenGate($allowBoot = false) {
// 引导模式:config 里还没有任何令牌、web.db 里也没有引导标记 → 本层先不生效。
if (!secIsBootstrapped()) return;
$want = secToken();
if ($want === '') return;
$got = '';
if (isset($_GET['token'])) $got = (string)$_GET['token'];
elseif (isset($_POST['token'])) $got = (string)$_POST['token'];
if ($got !== '' && hash_equals($want, $got)) return;
if ($allowBoot && secBootAuthenticate()) return;
if (function_exists('secLog')) {
secLog('token_block', 'path=' . (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-'));
}
secNginx404();
}
/** 拼出带令牌的后台入口 URL(页面内跳转/展示用;服务器端持有令牌,故可直接拼)。 */
function secTokenUrl($page = 'admin.php', $extra = '') {
$t = secToken();
if ($t === '') return $page . $extra;
return $page . (strpos($page, '?') === false ? '?' : '&') . 'token=' . rawurlencode($t) . $extra;
}
// ======================================================================
// 引导标记(bootstrap marker)—— 判断"这站是否已经用过后台配置页"
// ======================================================================
//
// 判据(任一成立 = 已引导,闸门转为强制模式):
// 1) config.php 的 SEC-UPDATE 区已有 SEC_BOOT_TOKEN / SEC_ACCESS_TOKEN
// (线上既有站点早就写过 → 改动上线即刻生效,不存在暴露窗口);
// 2) web.db 的 sec_bootstrap 标记表有行 —— admin_2fa_setup.php 每次保存都由
// secWriteConfig() 顺手写入(Java 插件产生的业务数据不算数)。
//
// 两者皆空 = 全新部署的第一次访问 → 引导模式:先放行,让部署者把自己的
// IP 白名单 / 访问令牌 / 2FA 配齐,配置页一保存即自动转强制。
// 读库异常(文件损坏、被锁死、打不开)→ 从严按"已引导"处理:宁可把本机之外的
// 访问挡成 404,也不把闸门敞开;127.0.0.1 与 boot 令牌永远不受影响。
//
// 为什么不用"库里有没有数据"当判据:Java 插件建站第一天就会往 web.db 写业务
// 数据,按数据量判断会把还没配置完的部署者直接锁死在门外,故用显式标记。
/** 打开 web.db(仅供引导标记读写)。$write=true 时允许新建文件;失败返回 null。 */
function secBootstrapDb($write = false) {
if (!class_exists('SQLite3')) return null;
if (!defined('DB_PATH') || DB_PATH === '') return null;
$path = sqlDbFile('web');
if ($write) {
$dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
$flags = SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE;
} else {
if (!file_exists($path)) return null;
$flags = SQLITE3_OPEN_READWRITE;
}
try {
$db = new SQLite3($path, $flags);
// ★ 刻意不调用 enableExceptions():PHP 8.4+ 该方法只保留异常模式,传 false
// 会触发 E_DEPRECATED —— 本函数跑在闸门判定里,任何提前输出都会把后面
// secNginx404() 的状态行挤掉(headers already sent → 404 变 200)。
// 两种版本差异由调用方兜住:老版本返回 false、新版本抛异常,
// 而下面所有查询都带 @ 并处在 try/catch 里。
@$db->exec('PRAGMA busy_timeout=3000');
return $db;
} catch (Throwable $e) {
secLog('bootstrap_db_open_fail', $e->getMessage());
return null;
}
}
/**
* 写入引导标记(secWriteConfig 成功后调用)。
* 失败只记日志、不抛错 —— 判据 1(config 已有令牌)足以兜底,不能因为写标记
* 失败就把一次好好的配置保存变成报错。
*/
function secMarkBootstrapped($note = '') {
$db = secBootstrapDb(true);
if ($db === null) {
secLog('bootstrap_mark_fail', 'open failed path=' . (function_exists('sqlDbFile') ? sqlDbFile('web') : '(no sqlDbFile)'));
return false;
}
try {
$ok = $db->exec('CREATE TABLE IF NOT EXISTS sec_bootstrap ('
. 'id INTEGER PRIMARY KEY CHECK (id = 1),'
. 'marked_at INTEGER NOT NULL,'
. 'note TEXT)');
if ($ok === false) {
secLog('bootstrap_mark_fail', 'create table: ' . $db->lastErrorMsg());
$db->close();
return false;
}
$t = (string)time();
$n = str_replace("'", "''", substr((string)$note, 0, 200));
$ok = $db->exec("INSERT OR REPLACE INTO sec_bootstrap (id, marked_at, note) VALUES (1, {$t}, '{$n}')");
if ($ok === false) {
secLog('bootstrap_mark_fail', 'insert: ' . $db->lastErrorMsg());
$db->close();
return false;
}
$db->close();
secLog('bootstrap_marked', 'note=' . $note);
return true;
} catch (Throwable $e) {
secLog('bootstrap_mark_fail', $e->getMessage());
return false;
}
}
/** 是否已引导(双判据说明见本段注释)。结果在单次请求内缓存。 */
function secIsBootstrapped() {
static $cached = null;
if ($cached !== null) return $cached;
// 判据 1:config 里已经有引导 / 访问令牌(分发版是空的,线上早就写过)
if (secBootToken() !== '' || secToken() !== '') {
$cached = true;
return true;
}
// 判据 2:web.db 还没建 = 全新部署第一次访问 → 引导模式
if (!function_exists('sqlDbFile') || !file_exists(sqlDbFile('web'))) {
$cached = false;
return false;
}
$db = secBootstrapDb(false);
if ($db === null) {
$cached = true; // 有库却打不开 → 从严按已引导
return true;
}
try {
$m = @$db->query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sec_bootstrap' LIMIT 1");
if ($m === false) {
$db->close();
$cached = true; // 连 sqlite_master 都读不了 → 从严
return true;
}
if ($m->fetchArray() === false) {
$db->close();
$cached = false; // 标记表还没建 = 没人保存过配置 → 引导模式
return false;
}
$r = @$db->query('SELECT marked_at FROM sec_bootstrap LIMIT 1');
if ($r === false) {
$db->close();
$cached = true; // 从严
return true;
}
$has = ($r->fetchArray() !== false);
$db->close();
$cached = $has;
return $cached;
} catch (Throwable $e) {
secLog('bootstrap_read_fail', $e->getMessage());
$cached = true; // 读库异常 → 从严按已引导
return true;
}
}
// ======================================================================
// 页面 / API 闸门(第二层 IP 白名单的挂载点)
// ======================================================================
/**
* boot 恢复模式的落点(2026-10-05)。
*
* 背景(线上 db/security.log 实录):运维被锁在外面 → 拿 boot 令牌解锁成功
* (boot_unlock)→ 紧接着去开 admin.php?token=... → 依旧 ip_block_page 404。
* 也就是说旧逻辑只在 admin_2fa_setup.php 这一个点上豁免了白名单,从其它任何
* 管理页进来的 boot 持有者都会撞 404 死胡同,**根本到不了那个唯一豁免页**,
* 结果还是被锁在外面(当天 09:42 只能靠宝塔手工改 config.php 才脱困)。
*
* 现在:持有有效 boot 凭据(?boot= 令牌 或 已建立的 boot 会话)却仍不在白名单
* → 不丢 404,一律 302 送回引导配置页(唯一能把自己 IP 加白的页面)。
*
* 信息透露边界不变:没 boot 凭据的试探者照旧拿到与"文件不存在"完全一致的
* nginx 404,本函数根本不会被调用;能走到这里的人本就读得到 config.php
* (= 已经握有 ADMIN_PASS / SECRET_KEY / 2FA 密钥),路由给他没有新增暴露面。
*/
function secBootRecoveryRedirect() {
$path = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-';
secLog('boot_route', 'path=' . $path);
// URL 上还带着 ?boot= 就原样带上:会话一旦失效(换网络/清 cookie)还能自愈,
// 不必让运维再手抄一次令牌。
$q = '';
if (isset($_GET['boot'])) {
$t = (string)$_GET['boot'];
if ($t !== '' && hash_equals(secBootToken(), $t)) {
$q = '?boot=' . rawurlencode($t);
}
}
header('Location: admin_2fa_setup.php' . $q);
exit;
}
/**
* 数据库是否还没装完(2026-10-09「首跑顺序」判据)。
* 判据 = 自毁式安装页还在场上:install.php 存在 且 install.lock 不存在。
* 装完后 install.php 自删 + lock 落地 → 恒为 false;
* 线上老站根本没有 install.php → 同样恒为 false,行为零变化。
*/
function secInstallPending() {
return is_file(__DIR__ . '/install.php') && !is_file(__DIR__ . '/install.lock');
}
/**
* 页面级闸门。$allowBoot=true 时接受 boot 令牌解锁(引导配置页 / 2FA 页使用)。
* - 已引导 且 不在 IP 白名单 → nginx 原生 404(不是 403:403 会告诉探测者
* "这个文件确实存在",与最小化信息透露原则冲突);
* - 【boot 恢复模式(2026-10-05)】不在白名单 但 持有效 boot 凭据 → 不返回 404,
* 改 302 到引导配置页把自己加白(详见 secBootRecoveryRedirect 注释);
* - 未引导(全新部署第一次访问)→ 放行;后台主入口 admin.php 不直接渲染,
* 改跳到 admin_2fa_setup.php 完成白名单 / 令牌 / 2FA 的首次配置。
*/
function secGatePage($allowBoot = false) {
// ★ boot 必须先于 IP 判定:IP 已在白名单时原逻辑直接 return,
// 会导致 admin_2fa_setup.php?boot=... 在"自己 IP 已加白"的情况下进不了引导模式。
if ($allowBoot && secBootAuthenticate()) return;
if (secIpAllowed()) return;
// 引导模式:全新部署的第一次访问(config 无令牌、web.db 无引导标记)
// → 先放行,让部署者完成首次配置;后台主入口改跳配置页,这就是"引导"的落点。
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 到自毁式安装页;
// 装完它自删 + lock 落地,下一次进来才轮到 admin_2fa_setup.php。
// 修复实录:全新测试站直接进了 2FA 引导页,数据库安装页根本没露面。
if ($script !== 'install.php' && secInstallPending()) {
secLog('route_install_first', 'from=' . $script);
header('Location: install.php');
exit;
}
if ($script === 'admin.php') {
header('Location: admin_2fa_setup.php');
exit;
}
return;
}
// ★ boot 恢复模式:$allowBoot=false 的页面(admin.php / admin_login.php 等)
// 不渲染,但持有 boot 凭据的人不能被丢去 404 —— 送到唯一豁免页去加白名单。
if (secBootAuthenticate()) {
secBootRecoveryRedirect();
}
// 已引导且不在白名单 → nginx 原生 404(绝不回 403:403 等于承认文件存在)。
// 维护者不需要提示页 —— 服务器在他手上,他知道该查 db/security.log;
// 试探者只能看到与"文件不存在"完全一致的表现。恢复入口写在 config.php 注释里
// (boot 引导令牌访问 admin_2fa_setup.php)。
secLog('ip_block_page', 'path=' . (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-'));
secNginx404();
}
/**
* API 级闸门。与页面闸门同规则:已引导且不在白名单 → nginx 原生 404;
* 引导模式 → 放行(各动作自己仍有登录 / 口令 / 人机验证把关)。
* 不再回 JSON 403 —— 403 等于告诉探测者"这个接口确实存在",
* 而 404 与"服务器上根本没有这个文件"表现完全一致。
*/
function secGateApi() {
if (secIpAllowed()) return;
if (!secIsBootstrapped()) {
secLog('api_gate_open_bootstrap', 'action=' . (isset($_GET['action']) ? $_GET['action'] : '-'));
return;
}
secLog('ip_block_api', 'action=' . (isset($_GET['action']) ? $_GET['action'] : '-'));
secNginx404();
}
// ======================================================================
// 会话增强(core.php 的 requireAdminSession / isAdminLoggedIn 调用)
// ======================================================================
function secEpoch() {
return defined('SEC_EPOCH') ? (int)SEC_EPOCH : 0;
}
/** 会话是否晚于安全纪元(部署加固时把之前的活会话全部作废,踢掉入侵者)。 */
function secSessionEpochOk() {
$t = isset($_SESSION['admin_login_time']) ? (int)$_SESSION['admin_login_time'] : 0;
return $t >= secEpoch();
}
/** 当前管理会话是否欠二次验证(已启用 2FA 且尚未通过)。 */
function secSessionNeed2FA() {
if (!defined('SEC_2FA_ENABLED') || !SEC_2FA_ENABLED) return false;
if (empty($_SESSION['admin_auth'])) return false;
return empty($_SESSION['admin_2fa_ok']);
}
// ======================================================================
// TOTP(RFC 6238 / SHA1 / 30 秒 / 6 位)
// ======================================================================
/**
* Base32 解码(RFC 4648)。
* 兼容手工录入的常见脏字符:空格/制表符/连字符/下划线/填充 '=' 一律剔除;
* 手机键盘最容易敲错的 0/1/8/9 按等价字符归一(0→O、1→I、8→B、9→G),
* 否则这些字符会被静默丢弃导致密钥变短、之后所有动态码永远对不上。
*/
function secB32Decode($s) {
$s = strtoupper((string)$s);
$s = str_replace(array('0', '1', '8', '9'), array('O', 'I', 'B', 'G'), $s);
$s = preg_replace('/[^A-Z2-7]/', '', $s);
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$buf = 0; $bits = 0; $out = '';
for ($i = 0; $i < strlen($s); $i++) {
$v = strpos($alphabet, $s[$i]);
if ($v === false) continue;
$buf = ($buf << 5) | $v;
$bits += 5;
if ($bits >= 8) {
$bits -= 8;
$out .= chr(($buf >> $bits) & 0xFF);
}
}
return $out;
}
function secB32Encode($data) {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$out = ''; $buf = 0; $bits = 0;
for ($i = 0; $i < strlen($data); $i++) {
$buf = ($buf << 8) | ord($data[$i]);
$bits += 8;
while ($bits >= 5) {
$bits -= 5;
$out .= $alphabet[($buf >> $bits) & 31];
}
}
if ($bits > 0) $out .= $alphabet[($buf << (5 - $bits)) & 31];
return $out;
}
/** 生成 20 字节随机密钥(Base32 无填充,认证器通用格式)。 */
function secNewSecret() {
return secB32Encode(random_bytes(20));
}
/** 计算某一时刻的 6 位动态码。 */
function secTotp($secret, $time = null, $step = 30, $digits = 6) {
$key = secB32Decode($secret);
if ($key === '') return '';
$t = ($time === null) ? time() : (int)$time;
$counter = (int)floor($t / $step);
$bin = '';
for ($i = 7; $i >= 0; $i--) {
$bin .= chr(($counter >> ($i * 8)) & 0xFF);
}
$hash = hash_hmac('sha1', $bin, $key, true);
$offset = ord($hash[19]) & 0x0F;
$code = ((ord($hash[$offset]) & 0x7F) << 24)
| (ord($hash[$offset + 1]) << 16)
| (ord($hash[$offset + 2]) << 8)
| (ord($hash[$offset + 3]));
$code = $code % (int)pow(10, $digits);
return str_pad((string)$code, $digits, '0', STR_PAD_LEFT);
}
/**
* 校验动态码。
* 默认 window=3 → 接受 [now-90s, now+90s] 共 7 个时间步(210 秒跨度),
* 容忍手机与服务器之间 ±90 秒的时钟漂移;限速(5 次/15 分钟)仍在,
* 窗口放大不会带来可利用的暴力破解空间。
*/
function secTotpVerify($secret, $code, $window = 3) {
$code = trim((string)$code);
if (!preg_match('/^\d{6}$/', $code)) return false;
if (!$secret) return false;
$now = time();
for ($i = -$window; $i <= $window; $i++) {
if (hash_equals(secTotp($secret, $now + $i * 30), $code)) return true;
}
return false;
}
/**
* 诊断:在 ±$range 个时间步内找这个码到底对应哪个时刻。
* @return int|null 该码相对服务器当前时间的偏移秒数(30 的倍数);完全无匹配返回 null。
*
* 用途 —— 验证失败时把"密钥录错了"和"设备时钟不对"这两种原因区分开:
* 返回非 null → 认证器密钥是对的,只是设备时钟偏了(提示快/慢多少秒);
* 返回 null → 码根本不属于这个密钥(手工录入密钥出错 / 绑定了旧密钥),
* 应引导用户回到配置页重新扫码绑定。
*/
function secTotpDiagnose($secret, $code, $range = 20) {
$code = trim((string)$code);
if (!preg_match('/^\d{6}$/', $code)) return null;
if (!$secret) return null;
$now = time();
for ($i = -$range; $i <= $range; $i++) {
if (hash_equals(secTotp($secret, $now + $i * 30), $code)) return $i * 30;
}
return null;
}
/**
* 把诊断结果转成给管理员看的一句话。
* @param int|null $off secTotpDiagnose 的返回值
*/
function secTotpDiagnoseMsg($off) {
if ($off === null) {
return '这个 6 位码不属于本后台的密钥 —— 多半是手工输入 Base32 密钥时敲错了字符,'
. '或认证器里添加的是旧密钥。请到「安全配置」页重新生成密钥并扫码绑定。';
}
$n = abs($off);
$dir = $off > 0 ? '快' : '慢';
return '认证器密钥是对的,但你的设备时钟比服务器' . $dir . '了约 ' . $n . ' 秒(已超出 ±90 秒容错范围)。'
. '请到手机「设置 → 系统 → 日期和时间」打开「自动设置/网络提供时间」,'
. '等它校准到与服务器一致后(本页会显示服务器时间,可直接比对)再重试。';
}
/** 供认证器手动录入的 otpauth 链接(不外联、不生成第三方二维码)。 */
function secOtpauthUri($secret) {
$label = rawurlencode('SDF1-Admin');
$issuer = rawurlencode('SDF1');
return 'otpauth://totp/' . $label . '?secret=' . $secret
. '&issuer=' . $issuer . '&algorithm=SHA1&digits=6&period=30';
}
// ======================================================================
// 限速(db/sec_state.json,文件锁;防 6 位码/密码暴力破解)
// ======================================================================
function secStateFile() {
return __DIR__ . '/db/sec_state.json';
}
/**
* 记录一次失败并返回当前锁定状态。
* @return int 剩余锁定秒数(0 = 未锁定)
*/
function secThrottleFail($bucket, $key, $max, $win) {
$f = secStateFile();
if (!is_dir(dirname($f))) { @mkdir(dirname($f), 0755, true); }
$fp = @fopen($f, 'c+');
if (!$fp) return 0;
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$state = json_decode($raw, true);
if (!is_array($state)) $state = array();
$now = time();
$k = $bucket . '|' . $key;
$arr = isset($state[$k]) && is_array($state[$k]) ? $state[$k] : array();
$arr[] = $now;
// 顺手清理窗口外的旧记录
$fresh = array();
foreach ($arr as $ts) { if ((int)$ts > $now - $win) $fresh[] = (int)$ts; }
$state[$k] = $fresh;
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($state, JSON_UNESCAPED_UNICODE));
fflush($fp); flock($fp, LOCK_UN); fclose($fp);
if (count($fresh) >= $max) {
return max(1, ($fresh[0] + $win) - $now);
}
return 0;
}
/** 只读查询:当前是否被锁定,返回剩余秒数(0 = 未锁定)。 */
function secThrottleLocked($bucket, $key, $max, $win) {
$f = secStateFile();
if (!file_exists($f)) return 0;
$fp = @fopen($f, 'r');
if (!$fp) return 0;
flock($fp, LOCK_SH);
$raw = stream_get_contents($fp);
flock($fp, LOCK_UN); fclose($fp);
$state = json_decode($raw, true);
if (!is_array($state)) return 0;
$k = $bucket . '|' . $key;
if (!isset($state[$k]) || !is_array($state[$k])) return 0;
$now = time();
$fresh = array();
foreach ($state[$k] as $ts) { if ((int)$ts > $now - $win) $fresh[] = (int)$ts; }
if (count($fresh) >= $max) {
return max(1, ($fresh[0] + $win) - $now);
}
return 0;
}
/** 成功后清除该 key 的失败计数。 */
function secThrottleReset($bucket, $key) {
$f = secStateFile();
if (!file_exists($f)) return;
$fp = @fopen($f, 'r+');
if (!$fp) return;
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$state = json_decode($raw, true);
if (is_array($state)) {
$k = $bucket . '|' . $key;
if (isset($state[$k])) {
unset($state[$k]);
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($state, JSON_UNESCAPED_UNICODE));
fflush($fp);
}
}
flock($fp, LOCK_UN); fclose($fp);
}
// ======================================================================
// config.php 标记区回写(2FA 与白名单的持久化)
// ======================================================================
function secConfigPath() {
return __DIR__ . '/config.php';
}
/** 值导出为单行 PHP 字面量。 */
function secExportVal($v) {
if (is_bool($v)) return $v ? 'true' : 'false';
if (is_int($v) || is_float($v)) return (string)$v;
if (is_array($v)) {
$items = array();
foreach ($v as $x) $items[] = secExportVal($x);
return 'array(' . implode(', ', $items) . ')';
}
return var_export((string)$v, true);
}
/**
* 读取 config.php 的 SEC-UPDATE 区当前各 define 原始字面量(写前快照 / 写后自检用)。
* @return array|null 键=常量名,值=define 第二参数的原文;区段缺失或不可读返回 null
*/
function secReadUpdateBlock() {
$path = secConfigPath();
$src = @file_get_contents($path);
if ($src === false) return null;
$bPos = strpos($src, 'SEC-UPDATE-BEGIN');
$ePos = strpos($src, 'SEC-UPDATE-END');
if ($bPos === false || $ePos === false || $ePos < $bPos) return null;
$blk = substr($src, $bPos, $ePos - $bPos);
$out = array();
foreach (explode("\n", $blk) as $line) {
if (preg_match('/^define\(\'([A-Z0-9_]+)\',\s*(.*)\);\s*$/', trim($line), $m)) {
$out[$m[1]] = $m[2];
}
}
return $out;
}
/**
* 二次验证状态的语义指纹:'on|set' / 'off|empty' / null(读不到)。
* 用语义而不是字面量比对,避免 true/1 这类等价写法误判。
*/
function secTwoFaState($blk) {
if (!is_array($blk)) return null;
if (!array_key_exists('SEC_2FA_ENABLED', $blk)) return null;
$on = trim($blk['SEC_2FA_ENABLED']);
$enabled = ($on === 'true' || $on === '1'
|| ((strncmp($on, '\'', 1) === 0 || strncmp($on, '"', 1) === 0) && trim($on, '\'"') !== ''));
$sec = array_key_exists('SEC_2FA_SECRET', $blk) ? trim($blk['SEC_2FA_SECRET']) : '';
$hasSecret = !($sec === '' || $sec === "''" || $sec === '""' || $sec === 'null');
return ($enabled ? 'on' : 'off') . '|' . ($hasSecret ? 'set' : 'empty');
}
/**
* 还原 config.php 到写入前的最近一次自动备份(secWriteConfig 每次写前必备份)。
* @return bool 是否还原成功
*/
function secRestoreLastConfigBackup($op) {
$baks = glob(__DIR__ . '/db/config_bak/config.*.php');
if (!is_array($baks) || count($baks) === 0) return false;
sort($baks);
$last = $baks[count($baks) - 1];
$src = @file_get_contents($last);
if ($src === false) return false;
$path = secConfigPath();
if (@file_put_contents($path, $src, LOCK_EX) === false) return false;
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
secLog('config_rollback', $op . ' restored from ' . basename($last));
return true;
}
/**
* 二次验证护栏:给「本轮操作本来就不该动 2FA」的动作(重置访问令牌、改白名单)用。
* 用写入前的快照和写入后的磁盘实际状态比对,一旦 2FA 被打开/关闭或密钥被清空,
* 立即从写前备份回滚并抛错 —— 结构上保证"重置访问密钥绝不会顺带关掉二次验证"。
*
* @param array|null $before 写入前 secReadUpdateBlock() 的快照
* @param string $op 操作名(日志用)
* @throws Exception 检测到 2FA 状态被改动时(此时已尝试回滚)
*/
function secGuardTwoFaUnchanged($before, $op) {
$b = secTwoFaState($before);
if ($b === null) return; // 快照不可得:护栏不拦,避免误伤
$a = secTwoFaState(secReadUpdateBlock());
if ($a === null) return;
if ($b === $a) {
secLog('twofa_guard_ok', $op . ' 2FA=' . $a . ' 未变');
return;
}
$restored = secRestoreLastConfigBackup($op);
secLog('twofa_guard_trip', $op . ' 2FA ' . $b . ' -> ' . $a . ' | rollback=' . ($restored ? 'ok' : 'FAIL'));
throw new Exception('安全护栏触发:' . $op . ' 意外改动了二次验证配置,已'
. ($restored ? '自动回滚,二次验证未被关闭。' : '回滚失败,请立刻用引导令牌进本页检查 2FA 状态。'));
}
// ======================================================================
// 管理员密码:首次由部署者自己设置(引导初始化)
// ======================================================================
//
// 分发版 config.php 里 ADMIN_PASS 是占位符 REPLACE_ME_ADMIN_PASSWORD —— 它不是密码,
// 只是"还没配"的标记。旧流程的死结:配置页在会话模式下每次写操作都要重输管理密码,
// 可部署者手上根本没有可用密码,于是 IP 白名单永远提交不进去。
//
// 现在:密码还是占位符 → admin_2fa_setup.php 直接渲染初始化卡片,让部署者第一次就
// 把自己的密码设好(白名单预填当前 IP),设完自动登录、门禁随即转强制。
// 已经配过真密码的站点(线上 /plugin 一直是真密码)完全不受影响,照旧走登录流程。
/** 分发版占位符:出现这个值 = 密码尚未配置。 */
function secAdminPassPlaceholder() {
return 'REPLACE_ME_ADMIN_PASSWORD';
}
/** 管理员密码是否已真正配置(非空且不是占位符)。 */
function secAdminPassConfigured() {
if (!defined('ADMIN_PASS')) return false;
$p = trim((string)ADMIN_PASS);
if ($p === '' || $p === secAdminPassPlaceholder()) return false;
return true;
}
/** 新密码强度校验:通过返回 null,否则返回中文错误信息。 */
function secValidateNewPassword($pw, $pw2 = null) {
$pw = (string)$pw;
if ($pw === secAdminPassPlaceholder()) return '不能把默认占位符当密码';
if (strlen($pw) < 6) return '密码至少 6 位';
if (strlen($pw) > 128) return '密码过长(最多 128 位)';
if (trim($pw) !== $pw) return '密码首尾不能有空格';
if ($pw2 !== null && (string)$pw2 !== $pw) return '两次输入的密码不一致';
return null;
}
/**
* 解析 IP 白名单文本(一行一个 IP / CIDR,去空行去重)。
* 初始化卡片与白名单保存共用同一套校验,避免两处规则漂移。
* @throws Exception 含非法条目时抛出(调用方给用户回显)
*/
function secParseWhitelistText($text) {
$items = array();
foreach (preg_split('/\r\n|\r|\n/', (string)$text) as $L) {
$L = trim($L);
if ($L === '') continue;
if (filter_var($L, FILTER_VALIDATE_IP)) {
$items[] = $L;
} elseif (preg_match('#^(\d{1,3}\.){3}\d{1,3}/(\d{1,2})$#', $L, $mm)
&& (int)$mm[2] <= 32
&& filter_var(explode('/', $L)[0], FILTER_VALIDATE_IP)) {
$items[] = $L;
} else {
throw new Exception('无效的 IP / CIDR:' . htmlspecialchars($L, ENT_QUOTES, 'UTF-8'));
}
}
return array_values(array_unique($items));
}
/**
* IP 是否命中白名单(支持精确 IP 与 IPv4 CIDR;本机回环恒命中)。
* 用途:首次初始化时确认"部署者自己不会被刚保存的白名单挡在门外"。
*/
function secIpInWhitelist($ip, $items) {
if ($ip === '') return true;
if ($ip === '127.0.0.1' || $ip === '::1') return true;
$v4 = (string)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
foreach ((array)$items as $it) {
$it = trim((string)$it);
if ($it === '') continue;
if (strpos($it, '/') === false) {
if ($it === $ip) return true;
continue;
}
if ($v4 !== $ip) continue; // CIDR 解析只做 IPv4
list($net, $bits) = explode('/', $it, 2);
$n = ip2long($net);
$b = (int)$bits;
if ($n === false || $b < 0 || $b > 32) continue;
$m = ($b === 0) ? 0 : (-1 << (32 - $b));
if ((ip2long($ip) & $m) === ($n & $m)) return true;
}
return false;
}
/**
* 整段重写 config.php 的 SEC-UPDATE 标记区。
* - 未在 $kv 中出现的既有 define 一律按旧值保留(防丢 SEC_EPOCH / SEC_BOOT_TOKEN)
* - 写前备份到 db/config_bak/(保留最近 5 份),tmp+rename 原子替换
* @throws Exception 失败时抛出(调用方给用户回显)
*/
function secWriteConfig(array $kv) {
$path = secConfigPath();
$src = @file_get_contents($path);
if ($src === false) throw new Exception('读取 config.php 失败');
$bPos = strpos($src, 'SEC-UPDATE-BEGIN');
$ePos = strpos($src, 'SEC-UPDATE-END');
if ($bPos === false || $ePos === false || $ePos < $bPos) {
throw new Exception('config.php 缺少 SEC-UPDATE 标记区,拒绝写入');
}
$bStart = strrpos(substr($src, 0, $bPos), "\n");
$bStart = ($bStart === false) ? 0 : $bStart + 1;
$eEnd = strpos($src, "\n", $ePos);
$eEnd = ($eEnd === false) ? strlen($src) : $eEnd + 1;
// 解析旧区既有 define(单行约定)
$old = substr($src, $bStart, $eEnd - $bStart);
$kept = array();
foreach (explode("\n", $old) as $line) {
if (preg_match('/^define\(\'([A-Z0-9_]+)\',\s*(.*)\);\s*$/', trim($line), $m)) {
$kept[$m[1]] = $m[2]; // 保留原始字面量文本
}
}
foreach ($kv as $k => $v) {
$kept[$k] = secExportVal($v);
}
// 固定输出顺序(可读性),未知 key 追加在后
$order = array('SEC_BOOT_TOKEN', 'SEC_ACCESS_TOKEN', 'SEC_EPOCH', 'ADMIN_IP_WHITELIST', 'SEC_2FA_ENABLED', 'SEC_2FA_SECRET');
$lines = array('// >>>SEC-UPDATE-BEGIN 本区段由 admin_2fa_setup.php 自动维护,请勿手工改动标记行');
foreach ($order as $k) {
if (isset($kept[$k])) { $lines[] = 'define(\'' . $k . '\', ' . $kept[$k] . ');'; unset($kept[$k]); }
}
foreach ($kept as $k => $lit) {
$lines[] = 'define(\'' . $k . '\', ' . $lit . ');';
}
$lines[] = '// >>>SEC-UPDATE-END';
$newBlock = implode("\n", $lines) . "\n";
$new = substr($src, 0, $bStart) . $newBlock . substr($src, $eEnd);
secReplaceConfigFile($src, $new, 'keys=' . implode(',', array_keys($kv)));
// ★ 引导标记:配置一落盘 = "这站已经用过后台面板",secIsBootstrapped() 的判据 2
// 从此成立(判据 1 是 config 里已有令牌,两者互为兜底)。
secMarkBootstrapped('secWriteConfig:' . implode(',', array_keys($kv)));
return true;
}
/**
* 备份 config.php(保留最近 5 份)并原子替换 —— secWriteConfig / secWriteAdminPass 共用。
* @param string $src 替换前的原文(用于备份)
* @param string $new 替换后的全文
* @param string $op 操作标识(写进 security.log)
* @throws Exception 备份目录不可写 / 临时文件写不进 / rename 失败
*/
function secReplaceConfigFile($src, $new, $op) {
$path = secConfigPath();
// 备份(保留最近 5 份)
$bakDir = __DIR__ . '/db/config_bak';
if (!is_dir($bakDir)) { @mkdir($bakDir, 0755, true); }
$bak = $bakDir . '/config.' . date('YmdHis') . '.php';
@file_put_contents($bak, $src, LOCK_EX);
$baks = glob($bakDir . '/config.*.php');
if (is_array($baks) && count($baks) > 5) {
sort($baks);
$extra = count($baks) - 5;
for ($i = 0; $i < $extra; $i++) { @unlink($baks[$i]); }
}
// 原子替换
$tmp = $path . '.tmp.' . getmypid();
if (@file_put_contents($tmp, $new, LOCK_EX) === false) {
throw new Exception('写入临时文件失败(目录不可写?)');
}
if (!@rename($tmp, $path)) {
@unlink($tmp);
throw new Exception('替换 config.php 失败');
}
if (function_exists('opcache_invalidate')) { @opcache_invalidate($path, true); }
secLog('config_write', $op);
return true;
}
/**
* 首次初始化:把管理密码写进 config.php 的 `define('ADMIN_PASS', ...)`。
*
* 注意两点:
* - ADMIN_PASS 定义在 SEC-UPDATE 区**之外**(config.php 第 32 行),而 PHP 重复
* define 只认第一次、之后的会告警并被忽略 —— 所以只能就地改写那一行,不能往
* SEC-UPDATE 区里再塞一个同名 define。
* - 就地改写用逐行匹配而不是正则,避免密码里带 $ / 引号 时被当替换模式或引用吃掉。
*
* @throws Exception 读写 config.php 失败
*/
function secWriteAdminPass($pw) {
$path = secConfigPath();
$src = @file_get_contents($path);
if ($src === false) throw new Exception('读取 config.php 失败');
$line = 'define(\'ADMIN_PASS\', ' . secExportVal((string)$pw) . '); // 管理员密码(引导初始化写入)';
$lines = explode("\n", $src);
$idx = -1;
foreach ($lines as $i => $L) {
if (strncmp(ltrim($L), 'define(\'ADMIN_PASS\'', 19) === 0) { $idx = $i; break; }
}
if ($idx >= 0) {
if (trim($lines[$idx]) === trim($line)) return true; // 值没变,别白备份一次
$lines[$idx] = $line;
} else {
// 模板里没有这一行:插到 SEC-UPDATE 区之前。必须在任何既有定义之前才生效。
$bPos = -1;
foreach ($lines as $i => $L) {
if (strpos($L, 'SEC-UPDATE-BEGIN') !== false) { $bPos = $i; break; }
}
if ($bPos < 0) throw new Exception('config.php 缺少 SEC-UPDATE 标记区,拒绝写入');
array_splice($lines, $bPos, 0, array($line));
}
secReplaceConfigFile($src, implode("\n", $lines), 'admin_pass');
secLog('admin_pass_written', 'len=' . strlen((string)$pw));
return true;
}
// ======================================================================
// 第四层:全局限流(每 IP 每 60 秒最多 20 次;白名单 IP 不限)
// 本文件被任何入口 require 时自动执行一次,玩家端与管理端统一生效。
// ======================================================================
/** 应急开关:config 里 SEC_RATE_LIMIT===false,或存在 db/ratelimit.off 时停用。 */
function secRateLimitOff() {
if (defined('SEC_RATE_LIMIT') && SEC_RATE_LIMIT === false) return true;
return file_exists(__DIR__ . '/db/ratelimit.off');
}
/** 当前脚本名(api/sync.php → sync.php)。 */
function secRateScript() {
$s = '';
if (!empty($_SERVER['SCRIPT_NAME'])) $s = (string)$_SERVER['SCRIPT_NAME'];
elseif (!empty($_SERVER['PHP_SELF'])) $s = (string)$_SERVER['PHP_SELF'];
elseif (!empty($_SERVER['REQUEST_URI'])) $s = (string)parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$s = str_replace('\\', '/', (string)$s);
return basename($s);
}
/**
* 轮询/机器接口豁免表:脚本名 => 免计数的 action 列表。
* 这些是前端每 0.5~5 秒一次的登录轮询与支付补单器,超过 20 次/分钟是正常行为,
* 若也计数会让玩家无法登录、支付无法补单。
* 数组里放空字符串 '' 表示整个脚本豁免。
*/
function secRateExemptMap() {
static $map = array(
'sync.php' => array('check_web_login_result', 'web_access_check'),
'pay.php' => array('query_order'),
'land_api.php' => array('get_add_visitor_status', 'get_visitor_status', 'add_visitor_status'),
'minecraft_auth.php' => array('check_session'),
'poller_online.php' => array(''), // 支付补单器:只被服务端/Java 触发,自带进程锁
);
return $map;
}
/** 当前请求是否命中轮询豁免表。 */
function secRateExempt() {
$map = secRateExemptMap();
$script = secRateScript();
if (!isset($map[$script])) return false;
$list = $map[$script];
if (count($list) === 1 && $list[0] === '') return true;
$action = '';
if (isset($_REQUEST['action'])) $action = trim((string)$_REQUEST['action']);
return $action !== '' && in_array($action, $list, true);
}
/**
* 是否携带了正确的 SECRET_KEY(Java/服务端内部调用,不计数)。
* 取值顺序与各接口一致:GET / POST / JSON 请求体。
*/
function secRateHasSecret() {
if (!defined('SECRET_KEY') || SECRET_KEY === '') return false;
$key = (string)SECRET_KEY;
$cands = array();
if (isset($_GET['secret'])) $cands[] = (string)$_GET['secret'];
if (isset($_POST['secret'])) $cands[] = (string)$_POST['secret'];
if (isset($_REQUEST['secret'])) $cands[] = (string)$_REQUEST['secret'];
foreach ($cands as $c) {
if ($c !== '' && hash_equals($key, $c)) return true;
}
// 个别接口把 secret 放在 JSON 请求体里(Content-Type: application/json)
$ct = isset($_SERVER['CONTENT_TYPE']) ? (string)$_SERVER['CONTENT_TYPE'] : '';
if ($ct !== '' && stripos($ct, 'json') !== false && empty($_POST)) {
$len = isset($_SERVER['CONTENT_LENGTH']) ? (int)$_SERVER['CONTENT_LENGTH'] : 0;
if ($len > 0 && $len <= 262144) {
$raw = file_get_contents('php://input');
if (is_string($raw) && $raw !== '') {
$j = json_decode($raw, true);
if (is_array($j)) {
foreach (array('secret', 'key') as $k) {
if (isset($j[$k]) && is_string($j[$k]) && $j[$k] !== ''
&& hash_equals($key, $j[$k])) return true;
}
}
}
}
}
return false;
}
/**
* 计一次数,返回该 IP 当前 60 秒窗口内的请求数(含本次)。
* 计数与判断放在同一把文件锁里,避免“读到没超、写入后已超”的竞态。
*/
function secRateCount($ip, $max, $win) {
$f = secStateFile();
if (!is_dir(dirname($f))) { @mkdir(dirname($f), 0755, true); }
$fp = @fopen($f, 'c+');
if (!$fp) return 0;
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$state = json_decode($raw, true);
if (!is_array($state)) $state = array();
$now = time();
$k = 'rate|' . $ip;
$arr = (isset($state[$k]) && is_array($state[$k])) ? $state[$k] : array();
$arr[] = $now;
$fresh = array();
foreach ($arr as $ts) { if ((int)$ts > $now - $win) $fresh[] = (int)$ts; }
// 单 IP 最多留 60 条:即便被刷爆,也只按窗口内的条数判超限,不撑爆文件
if (count($fresh) > 60) $fresh = array_slice($fresh, -60);
$state[$k] = $fresh;
// 文件过大时顺手清掉其它已过期 IP 的桶,防止长期堆积
if (strlen((string)$raw) > 262144) {
foreach ($state as $kk => $vv) {
if (!is_array($vv)) { unset($state[$kk]); continue; }
$alive = false;
foreach ($vv as $ts) { if ((int)$ts > $now - $win) { $alive = true; break; } }
if (!$alive) unset($state[$kk]);
}
}
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($state, JSON_UNESCAPED_UNICODE));
fflush($fp); flock($fp, LOCK_UN); fclose($fp);
return count($fresh);
}
/** 挂起槽文件(记录当前正在被挂起的连接,防止占满 PHP-FPM worker)。 */
function secHangFile() {
return __DIR__ . '/db/sec_hang.json';
}
/**
* 申请一个挂起槽:每 IP 最多 2 个、全局最多 4 个。
* 返回槽 id(成功)或 ''(槽满)。槽位带 300 秒过期自愈,进程被强杀也不会永久泄漏。
*/
function secHangAcquire($ip) {
$f = secHangFile();
if (!is_dir(dirname($f))) { @mkdir(dirname($f), 0755, true); }
$fp = @fopen($f, 'c+');
if (!$fp) return '';
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$list = json_decode($raw, true);
if (!is_array($list)) $list = array();
$now = time();
$mine = 0; $total = 0; $keep = array();
foreach ($list as $e) {
if (!is_array($e) || !isset($e['ip'], $e['t'])) continue;
if ((int)$e['t'] < $now - 300) continue; // 过期自愈
$keep[] = $e;
$total++;
if ((string)$e['ip'] === (string)$ip) $mine++;
}
$id = '';
if ($mine < 2 && $total < 4) {
$id = uniqid('h', true);
$keep[] = array('ip' => (string)$ip, 't' => $now, 'id' => $id);
}
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($keep, JSON_UNESCAPED_UNICODE));
fflush($fp); flock($fp, LOCK_UN); fclose($fp);
return $id;
}
/** 释放挂起槽。 */
function secHangRelease($id) {
if (!is_string($id) || $id === '') return;
$f = secHangFile();
if (!file_exists($f)) return;
$fp = @fopen($f, 'r+');
if (!$fp) return;
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$list = json_decode($raw, true);
if (is_array($list)) {
$out = array();
foreach ($list as $e) {
if (is_array($e) && isset($e['id']) && (string)$e['id'] === $id) continue;
$out[] = $e;
}
if (count($out) !== count($list)) {
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($out, JSON_UNESCAPED_UNICODE));
fflush($fp);
}
}
flock($fp, LOCK_UN); fclose($fp);
}
/**
* 超限处理:一个字节都不返回,挂起连接直到超时/对端断开。
* - 挂起槽满时立即结束(同样不输出任何内容),避免把 worker 占满导致全站不可用;
* - 挂起期间关闭 session 写锁,否则同用户的下一个请求会被 session 文件锁卡死;
* - 上限 90 秒:nginx fastcgi_read_timeout 默认 60 秒会先返回 504 断开。
*/
function secRateSuspend($ip) {
$id = secHangAcquire($ip);
if ($id === '') { exit; }
@set_time_limit(0);
if (session_status() === PHP_SESSION_ACTIVE) { @session_write_close(); }
$start = time();
while (time() - $start < 90) {
if (connection_aborted()) break;
@sleep(1);
}
secHangRelease($id);
exit;
}
/**
* 该请求是否持有"不该被限流挂死"的凭据。本层的定位是拦陌生流量,
* 不能反过来把自己人(部署者 / 已认证管理员)挂成白屏。
*
* 四类放行:
* 1) 引导模式(config 无令牌、web.db 无引导标记 = 全新部署还没配置)——
* 此时 IP 白名单层与令牌层闸门本来就整体放行(secGatePage / secTokenGate),
* 限流若照常生效就等于"唯一还开着的闸只拦自己人":后台页面 10 秒一轮询,
* 轻松打满 20 次/分钟 → secRateSuspend() 一个字节都不返回 → 整站白屏,
* 连配置页的白名单都提交不进去(2026-10-02 /test12 实测:200 + 0 字节,
* 带 token 与不带 token 表现完全一样 —— 因为限流先于令牌闸门执行)。
* 2) 持有效后台入口令牌(第三层已凭据,secTokenGate 反正会放行);
* 3) 持有效 boot 会话 / ?boot= 参数匹配(配置页逃生舱凭据);
* 4) 已登录后台的会话(admin.php 的轮询子请求只带 cookie 不带 token,
* 不认它的话管理员会看着自己的后台被第四层挂死)。
*
* 反向边界:以上都拿不到的普通访客流量照旧计数 —— 限流对陌生人一点没松。
*/
function secRateTrusted() {
// 1) 引导模式:还没配任何安全项,先把配置做完,配置一保存闸门自动转强制
if (!secIsBootstrapped()) return true;
// 2) 后台入口令牌(GET / POST 与 secTokenGate 取值顺序一致)
$t = secToken();
if ($t !== '') {
$got = '';
if (isset($_GET['token'])) $got = (string)$_GET['token'];
elseif (isset($_POST['token'])) $got = (string)$_POST['token'];
if ($got !== '' && hash_equals($t, $got)) return true;
}
// 3) boot 令牌 / 4) 后台会话:只有请求本身就带着 boot 参数或会话 cookie 时
// 才去开 session —— 否则给每一个路过的访客都平白 session_start() 一次。
// (没有会话 cookie 就不可能存在有效会话,直接跳过是安全的。)
$hasBootParam = isset($_GET['boot']) || isset($_POST['boot']);
if ($hasBootParam || !empty($_COOKIE[session_name()])) {
secEnsureSession();
if ($hasBootParam && secBootAuthenticate()) return true; // ?boot= 或已建立的 boot 会话
if (!empty($_SESSION['admin_auth'])) return true; // 后台登录会话(伪造 cookie 无效)
if (secBootSessionActive()) return true;
}
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; // 命令行脚本不参与限流
if (empty($_SERVER['REMOTE_ADDR'])) return; // 无来源地址(CLI/内部)不参与
if (secRateLimitOff()) return; // 应急开关
$ip = secClientIp();
if ($ip === '') return;
if (secIpAllowed()) return; // 第二层白名单 IP = 维护者/管理员,不限
if (secRateHasSecret()) return; // Java/服务端调用(带正确 SECRET_KEY)
if (secRateExempt()) return; // 登录轮询、支付补单等机器接口
if (secRateTrusted()) return; // 引导模式 / 令牌 / boot / 后台会话
$n = secRateCount($ip, 20, 60);
if ($n <= 20) return;
if ($n === 21) { secLog('rate_blocked', '超过 20 次/分钟,开始挂起连接不返回数据'); }
secRateSuspend($ip);
}
secRateLimitGate();