Compare commits

..

No commits in common. "codex/hn-production" and "main" have entirely different histories.

121 changed files with 266 additions and 13560 deletions

View File

@ -1,8 +1,7 @@
SITE_PROFILE=dpl
APP_NAME=XBoard APP_NAME=XBoard
APP_FULL_NAME="Deep Link" APP_FULL_NAME="Deep Link"
APP_ENV=production APP_ENV=production
APP_KEY= APP_KEY=base64:PZXk5vTuTinfeEVG5FpYv2l6WEhLsyvGpiWK7IgJJ60=
APP_DEBUG=false APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost

View File

@ -1,24 +0,0 @@
SITE_PROFILE=hn
SITE_THEME=EZ
APP_NAME=hardnerd
APP_FULL_NAME=hardnerd.net
APP_ENV=production
APP_DEBUG=false
APP_URL=https://hardnerd.net
APP_KEY=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=hn
DB_USERNAME=hn
DB_PASSWORD=
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=file
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
CACHE_PREFIX=hn_cache
REDIS_PREFIX=hn_database_
HORIZON_PREFIX=hn_horizon:
SESSION_COOKIE=hn_session
LOG_CHANNEL=stack

5
.gitignore vendored
View File

@ -48,8 +48,3 @@ compose.split.yaml
/macos-client/.swiftpm/ /macos-client/.swiftpm/
/storage/app/private/ /storage/app/private/
# hn loopback development marker
/storage/framework/ez-vite.hot*
/storage/framework/hardnerd-vite.hot*

3
.gitmodules vendored
View File

@ -1,6 +1,3 @@
[submodule "public/assets/admin"] [submodule "public/assets/admin"]
path = public/assets/admin path = public/assets/admin
url = https://github.com/cedar2025/xboard-admin-dist.git url = https://github.com/cedar2025/xboard-admin-dist.git
[submodule "ez-theme"]
path = frontends/ez-theme
url = https://github.com/EZTHEME/EZ_THEME.git

View File

@ -1,6 +1,6 @@
# dpl 项目维护 # dpl 项目维护
先读 [CoDocs 项目总览](/Users/hellcat/Documents/CoDocs/项目总览.md) 与首次会话的 [持续维护协议](/Users/hellcat/Documents/CoDocs/00-协作规范/维护协议.md),再阅读 [维护入口](docs/maintenance/README.md),再按任务范围阅读架构、业务约束、发布恢复与验收状态。用户最新明确要求优先;文档中的历史状态必须按当前代码和运行环境复核。 开始维护前阅读 [维护入口](docs/maintenance/README.md),再按任务范围阅读架构、业务约束、发布恢复与验收状态。用户最新明确要求优先;文档中的历史状态必须按当前代码和运行环境复核。
- 保留用户及其他任务既有修改,提交只包含本次范围;不要重置或清理无关文件。 - 保留用户及其他任务既有修改,提交只包含本次范围;不要重置或清理无关文件。
- 默认 Git 发布:提交、推送,再让服务器更新到明确版本并验证;未经明确要求不得打包覆盖。 - 默认 Git 发布:提交、推送,再让服务器更新到明确版本并验证;未经明确要求不得打包覆盖。
@ -8,19 +8,6 @@
- 修改功能时同步相应维护说明与验收状态。原有业务约束见 docs/maintenance/BUSINESS_RULES.md。 - 修改功能时同步相应维护说明与验收状态。原有业务约束见 docs/maintenance/BUSINESS_RULES.md。
- 文档放 docs/maintenance/;任务截图、日志与临时脚本放 codex/output/;密钥和私密配置不提交 Git。 - 文档放 docs/maintenance/;任务截图、日志与临时脚本放 codex/output/;密钥和私密配置不提交 Git。
- 本地开发优先 dev/热更新;构建用于发布或构建验证。相关检查与命令见 docs/maintenance/ARCHITECTURE.md。 - 本地开发优先 dev/热更新;构建用于发布或构建验证。相关检查与命令见 docs/maintenance/ARCHITECTURE.md。
- 后台界面功能变更仍实际验证显示、切换、保存与权限;HTTP 200 不代表界面可用。节点接入按用户2026-10-20长期要求,不再默认登录DPL或进行DPL页面验收;经白色机使用服务器侧正式脚本、应用模型/事务操作并回读线上数据,仍须完成订阅、联网/上报、权限、QA清理和旧资源回归。执行范围唯一入口见[服务器侧接入](docs/maintenance/NODE_BATCH_RUNTIME.md#服务器侧接入与验收)。测试、提交、发布和真实验收分别报告,未执行的步骤不能写成完成。 - 后台变更实际验证显示、切换、保存与权限;HTTP 200 不代表界面可用。测试、提交、发布和真实验收分别报告,未执行的步骤不能写成完成。
- 节点维护全流程(候选盘点、查重、DNS/中转配置、部署、验收和回退)优先在服务器侧完成,默认不打开线上网站;优先使用经白色机的SSH、已登记凭据及服务接口。第三方缺少可用接口或凭据且确需网页时,先说明具体缺口,仅按现有访问规则处理必要范围;不为补页面验收打开网站,不绕过强制验证。 具体流程见[服务器侧接入](docs/maintenance/NODE_BATCH_RUNTIME.md#服务器侧接入与验收)。
## 批量节点接入:默认启动一个子助手
后续新批次默认使用 [正式适配器入口](docs/maintenance/NODE_BATCH_RUNTIME.md),由 `configure-site.py` 固定计划,再由 `batch.py` 的 `ssh-site-v1` 运行;动态读取验收账号应得节点集合,不固定节点数量。没有有效验收账号时自动创建本任务受限测试用户并负责禁用收尾;SSH始终经已登记白色机。具体实现、证据续跑和原服务调查边界见该专题;旧冻结计划及其处理脚本不改摘要迁就新实现。仅维护脚本/文档不触发真实接入或线上创建测试用户。
用户要求执行批量节点接入(明确说批量,或同批接入两个及以上节点)时,主协调者必须默认启动一个子助手,无需再次询问是否使用助手:核查验收助手负责候选服务器、端口、中转与已有资源冲突核查,并独立审查脚本、测试并发/查重/恢复及执行接入验收。主协调者负责统一参数、展示编号、端口、唯一账本及生产写入。完整分工、执行关卡与消耗控制见 [批量接入协作流程](docs/maintenance/NODE_BATCH.md#多智能体协作流程)。
这项授权针对当前任务的子助手,不自动新建或分叉普通聊天,也不授权向已有聊天发消息。仅讨论方案、维护脚本/文档或单节点任务不自动触发。若子助手工具不可用、并发名额不足或助手失败,说明具体缺口并恢复/等待;未经用户明确允许,不静默省略角色或跳过验收。当前批次已有对应助手时复用,不重复启动。具体线上变更仍以用户本次接入授权为限;项目规则不启动无人值守接入。
- 新增候选按[容量筛选](docs/maintenance/NODE_BATCH_RUNTIME.md#候选容量筛选):物理内存小于1GB且已安装XrayR则排除,不以停服务绕过,不追溯停用已接入节点。
- 接入所需邮箱验证码,按用户2026-10-20长期授权,先用已登记的对应邮箱自行登录并只读取本次验证邮件;凭据仍走GemDocs统一字段入口,不改账号安全设置、不读取无关邮件。只有资料缺失、认证失败或必须人工完成的验证才请求用户协助;账号切换及网站访问边界继续适用。
本文件提供维护入口,不扩大当前任务授权,也不启动无人值守自动维护。 本文件提供维护入口,不扩大当前任务授权,也不启动无人值守自动维护。

View File

@ -217,7 +217,6 @@ class ConfigController extends Controller
public function save(ConfigSave $request) public function save(ConfigSave $request)
{ {
$data = $request->validated(); $data = $request->validated();
\App\Support\SiteProfile::assertSettingsWritable($data);
$templateKeys = [ $templateKeys = [
'subscribe_template_singbox' => 'singbox', 'subscribe_template_singbox' => 'singbox',

View File

@ -28,10 +28,6 @@ class SettingServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
// dpl keeps its existing environment and database settings.
if (\App\Support\SiteProfile::isHn()) {
config(['app.name' => config('site.name'), 'app.full_name' => config('site.full_name')]);
}
// App URL is forced per-request via middleware (Octane-safe). // App URL is forced per-request via middleware (Octane-safe).
} }
} }

View File

@ -15,7 +15,7 @@ class ThemeService
private const USER_THEME_DIR = '/storage/theme/'; private const USER_THEME_DIR = '/storage/theme/';
private const CONFIG_FILE = 'config.json'; private const CONFIG_FILE = 'config.json';
private const SETTING_PREFIX = 'theme_'; private const SETTING_PREFIX = 'theme_';
private const SYSTEM_THEMES = ['Xboard', 'v2board', 'EZ', 'Hardnerd']; private const SYSTEM_THEMES = ['Xboard', 'v2board'];
public function __construct() public function __construct()
{ {
@ -190,7 +190,6 @@ class ThemeService
return true; return true;
} }
\App\Support\SiteProfile::assertSettingsWritable(['frontend_theme' => $theme]);
$currentTheme = admin_setting('current_theme'); $currentTheme = admin_setting('current_theme');
try { try {

View File

@ -1,30 +0,0 @@
<?php
namespace App\Support;
use Illuminate\Http\Request;
use RuntimeException;
final class EzAssets
{
public static function scripts(Request $request): array
{
$origin = 'http://hn.loc/__hn_vite';
$marker = storage_path('framework/ez-vite.hot');
if (in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1', 'hn.loc'], true)
&& is_file($marker) && trim(file_get_contents($marker)) === $origin) {
$socket = @fsockopen('127.0.0.1', 5184, $error, $message, 0.1);
if ($socket) {
fclose($socket);
return ['scripts' => ['/__hn_vite/@vite/client', '/__hn_vite/main.js'], 'styles' => []];
}
}
$manifest = base_path('theme/EZ/assets/.vite/manifest.json');
if (!is_file($manifest)) {
throw new RuntimeException('EZ assets missing: run npm ci && npm run build:hn in frontends/.');
}
$entries = json_decode(file_get_contents($manifest), true, flags: JSON_THROW_ON_ERROR);
$entry = $entries['index.html'];
return ['scripts' => ['/theme/EZ/assets/'.$entry['file']], 'styles' => array_map(fn ($file) => '/theme/EZ/assets/'.$file, $entry['css'] ?? [])];
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Support;
use Illuminate\Http\Request;
use RuntimeException;
final class HardnerdAssets
{
public static function scripts(Request $request): array
{
$origin = 'http://127.0.0.1:5175';
$marker = storage_path('framework/hardnerd-vite.hot');
if (in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1', 'hn.loc'], true)
&& is_file($marker) && trim(file_get_contents($marker)) === $origin) {
$socket = @fsockopen('127.0.0.1', 5175, $error, $message, 0.1);
if ($socket) {
fclose($socket);
return ['scripts' => [$origin.'/@vite/client', $origin.'/src/main.js'], 'styles' => []];
}
}
$manifest = base_path('theme/Hardnerd/assets/.vite/manifest.json');
if (!is_file($manifest)) {
throw new RuntimeException('Hardnerd assets missing: run npm ci && npm run build:hardnerd in frontends/.');
}
$entries = json_decode(file_get_contents($manifest), true, flags: JSON_THROW_ON_ERROR);
$entry = $entries['index.html'];
return ['scripts' => ['/theme/Hardnerd/assets/'.$entry['file']], 'styles' => array_map(fn ($file) => '/theme/Hardnerd/assets/'.$file, $entry['css'] ?? [])];
}
}

View File

@ -123,9 +123,9 @@ class Setting
} }
} }
$this->loadedSettings = array_replace($settings, SiteProfile::publicSettings()); $this->loadedSettings = $settings;
} catch (\Throwable) { } catch (\Throwable) {
$this->loadedSettings = SiteProfile::publicSettings(); $this->loadedSettings = [];
} }
} }

View File

@ -1,32 +0,0 @@
<?php
namespace App\Support;
use Illuminate\Validation\ValidationException;
final class SiteProfile
{
public static function isHn(): bool
{
return config('site.profile', 'dpl') === 'hn';
}
public static function publicSettings(): array
{
return self::isHn() ? [
'frontend_theme' => config('site.theme', 'EZ'),
'app_name' => config('site.name'),
'logo' => config('site.logo'),
'app_description' => config('site.description'),
] : [];
}
public static function assertSettingsWritable(array $settings): void
{
foreach (self::publicSettings() as $key => $value) {
if (array_key_exists($key, $settings) && $settings[$key] !== $value) {
throw ValidationException::withMessages([$key => 'hn 品牌与主题由 SITE_PROFILE / SITE_* 部署配置管理。']);
}
}
}
}

View File

@ -102,6 +102,6 @@ return [
| |
*/ */
'prefix' => env('CACHE_PREFIX', (env('SITE_PROFILE', 'dpl') === 'hn' ? 'hn_cache' : Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache')), 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'),
]; ];

View File

@ -132,7 +132,7 @@ return [
'options' => [ 'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'), 'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', (env('SITE_PROFILE', 'dpl') === 'hn' ? 'hn_database_' : Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_')), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'),
], ],
'default' => [ 'default' => [

View File

@ -60,7 +60,7 @@ return [
'prefix' => env( 'prefix' => env(
'HORIZON_PREFIX', 'HORIZON_PREFIX',
(env('SITE_PROFILE', 'dpl') === 'hn' ? 'hn_horizon:' : Str::slug(env('APP_NAME', 'laravel'), '_') . '_horizon:') Str::slug(env('APP_NAME', 'laravel'), '_') . '_horizon:'
), ),
/* /*

View File

@ -126,7 +126,7 @@ return [
'cookie' => env( 'cookie' => env(
'SESSION_COOKIE', 'SESSION_COOKIE',
(env('SITE_PROFILE', 'dpl') === 'hn' ? 'hn_session' : Str::slug(env('APP_NAME', 'laravel'), '_') . '_session') Str::slug(env('APP_NAME', 'laravel'), '_') . '_session'
), ),
/* /*

View File

@ -1,20 +0,0 @@
<?php
$profile = env('SITE_PROFILE', 'dpl');
if (!in_array($profile, ['dpl', 'hn'], true)) {
throw new InvalidArgumentException('SITE_PROFILE must be dpl or hn.');
}
$theme = env('SITE_THEME', 'EZ');
if ($profile === 'hn' && !in_array($theme, ['Hardnerd', 'EZ'], true)) {
throw new InvalidArgumentException('hn SITE_THEME must be Hardnerd or EZ.');
}
return [
'profile' => $profile,
'theme' => $theme,
'name' => env('SITE_NAME', 'hardnerd'),
'full_name' => env('SITE_FULL_NAME', 'hardnerd.net'),
'logo' => env('SITE_LOGO', '/brand/hn/logo.svg'),
'description' => env('SITE_DESCRIPTION', 'hardnerd · 探索全球网络'),
];

View File

@ -10,10 +10,6 @@
- 后台 Blade:resources/views/admin.blade.php;自定义后台扩展的源文件 resources/js/admin/ 与运行副本 public/admin-custom/ 必须一致。 - 后台 Blade:resources/views/admin.blade.php;自定义后台扩展的源文件 resources/js/admin/ 与运行副本 public/admin-custom/ 必须一致。
- 当前后台入口配置 secure_path=dove,通过 routes/web.php 使用,不是硬编码固定路由。后台 API 也使用这个配置。后台路径不是身份认证的替代。 - 当前后台入口配置 secure_path=dove,通过 routes/web.php 使用,不是硬编码固定路由。后台 API 也使用这个配置。后台路径不是身份认证的替代。
## 多实例站点配置
`SITE_PROFILE` 默认 dpl 保留原有运行行为;hn 使用独立品牌与缓存/会话命名空间,2026-10-27 起默认 EZ,保留 Hardnerd 显式选项。实现、子模块、开发和发布边界见 [SITE_PROFILES](SITE_PROFILES.md)。部署实例须各用各的数据库、密钥、storage 和运行资源;参数不等于自动隔离错误复用的环境。当前纯前端设计预览另见 [THEME_DESIGNS](THEME_DESIGNS.md),不连接本后端。
## 主要业务入口 ## 主要业务入口
| 范围 | 入口 | | 范围 | 入口 |

View File

@ -1,14 +1,12 @@
# 发布与恢复 # 发布与恢复
HN 已于2026-10-27独立部署,主站 hardnerd.net、后台 admin.hardnerd.net;版本、服务、备份与验收唯一正文见 [HN_PRODUCTION](HN_PRODUCTION.md)。以下原生产描述属于 DPL,不能套用到 HN。
## 目标与连接 ## 目标与连接
本项目生产站点为 https://dp-link.net,后台 /dove;后端目录 /www/wwwroot/dp-link.net,服务器逻辑名 site-jp02。通过既定白色跳板连接;项目与连接入口参见 [dpl 档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md) 和 [私有资源索引](/Users/hellcat/Documents/CoDocs/30-私有资源/README.md),不把它们的内容提交到公开仓库。 本项目生产站点为 https://dp-link.net,后台 /dove;后端目录 /www/wwwroot/dp-link.net,服务器逻辑名 site-jp02。通过既定白色跳板连接;具体连接资料参见本机 /Users/hellcat/Documents/CoDocs/项目关系.md 与 vps连接资料.md,不把它们的内容提交到公开仓库。
本地 http://dpl.loc/dove 已配置到当前 Laravel public/。本机 Nginx 配置 /usr/local/etc/nginx/servers/dpl.loc.conf,PHP-FPM 使用现有 9000 端口。系统级服务重载可能需要 Mac 管理员授权;不持久保存系统密码。 本地 http://dpl.loc/dove 已配置到当前 Laravel public/。本机 Nginx 配置 /usr/local/etc/nginx/servers/dpl.loc.conf,PHP-FPM 使用现有 9000 端口。系统级服务重载可能需要 Mac 管理员授权;不持久保存系统密码。
本地开发与联调访问规则:本地站点仍使用 `http://dpl.loc`;本地访问线上项目域名必须查询 [CoDocs 反代表](/Users/hellcat/Documents/CoDocs/20-基础设施/访问与反代.md) 并走明确登记的白色机反代,dpl 主站入口为 `https://dpl.tomshell.com.de`。缺映射/用途不符时询问用户,不直接或变相访问源站。该访问约定不自动修改生产支付回调域名或生产配置。 本地开发与联调访问规则(用户于 2026-10-11 指定):本地不访问 tp-link.net,需要访问远端站点时使用 https://dpl.tomshell.com.de 代替;本地站点仍使用 http://dpl.loc。该访问约定不自动修改生产支付回调域名或生产配置。
## Git 发布顺序 ## Git 发布顺序

View File

@ -1,67 +0,0 @@
# HN 邮件与订阅网关
2026-10-27初始化,2026-10-28按用户要求将c2订阅域名迁至t34;所有凭据、数据库与运行实例独立,未向DPL发布。主站与后台见 [HN_PRODUCTION](HN_PRODUCTION.md)。本文是实现、验收和恢复的唯一正文。
## 域名与渠道
- `hardnerd.net` / `admin.hardnerd.net`:HN主站/后台,sitejp02。
- `hardnerd.dev`:系统邮件域名,保留Namecheap BasicDNS和Private Email MX/SPF、privateemail DKIM。用户实际建立的是 `mail@hardnerd.dev`,保留其名称。
- Resend:独立账号ACC-RESEND-HN,Tokyo,domain ID `db1cf9ac-3d57-41b5-9fd0-f567ab40b520`。`resend._domainkey` TXT、`rsend` CNAME到`rsend-apne1.forge.rmta.net`、`send` CNAME到`send.forge.rmta.net`均verified。Resend receiving关闭;企业收件走IMAP。
- 邮箱:Namecheap Private Email Launch,订阅4476265,1/1邮箱、5GB;后台已显示服务有效至2027-10-27、自动续费开启,不代表将来扣款成功。
- `hardnerd-nodes.com`:独立Cloudflare Free zone `1043df03492d50f87d92300a6729362e`,NS `bradley.ns.cloudflare.com` / `jewel.ns.cloudflare.com`,权威/公共解析及active状态已核实。root A与t34 A指向gateway-01,www CNAME根域,均橙云;原Namecheap转发MX/SPF保留,不作为HN业务邮箱。
- 正式订阅基址 `https://t34.hardnerd-nodes.com` 已写入仅HN的 `subscribe_url`,生成链接主机已回读。root/www跳到hardnerd.net;t34只开放 `/s/<token>` 和 `/api/v1/client/subscribe`,其他路径404,不提供公开管理端。
- Cloudflare完整(严格)在源站证书验证通过后保存;Cache Rule `d765537a713747faa2a6e9cd1009c8a9` 对所有请求绕过缓存。Bot Fight/Under Attack未开启,未关闭BIC或改账号安全设置。
## 2026-10-28 t34 域名迁移
用户明确将c2.hardnerd-nodes.com改为t34.hardnerd-nodes.com。新t34 A记录到相同gateway-01、橙云及自动TTL,旧c2记录已移除;不保留旧地址为正式订阅别名。HN subscribe_url已修改,生成链接主机回读t34。原全区缓存绕过和严格TLS保持,不改日志设置、认证、邮箱、DPL/FUC配置。
运维提交 `360c155fae5bbf86654fdcba1d0de33b80fb7de4` 先推送后部署;`scripts/hn/gateway/migrate-t34.py`分prepare/certificate/activate执行。新证书独立lineage t34,SAN=t34/root/www,有效至2027-01-26;宿主/容器证书路径、SNI、环回管理端模板和hn-gateway-cert.service已同步。仅重建HN两个容器以刷新只读挂载模板,保留原卷和管理员凭据;DPL/FUC四个原容器及Nginx哈希基线通过。旧c2证书只保留回退,不由现行专用timer续期。
经白色机实际测试:t34有效限时账号得到sing-box JSON和Clash YAML,invalid token403,非订阅/管理路径404,CF DYNAMIC、private/no-store及过滤头存在;源站证书校验、新lineage续期dry-run及专用HN reload hook均通过。QA用户ID4已禁用、额度清零、到期、令牌轮换、会话0。当前回读套餐0、节点1;节点来自既有/并行业务,本任务没有新增/改动节点,不把订阅格式验收写成节点联网验收。未做线上页面验收。
网关备份 `/root/codex-backups/hn-gateway/20261028-t34/` 保存本次保护基线、旧host/proxy/cert service、旧证书symlink和deployment metadata;HN应用设置备份 `/root/codex-backups/hn/20261028-t34/subscribe-before.json`。本次本地证据 `codex/output/hn-t34-20261028/`。回退先恢复旧c2橙云A记录及旧网关Git模板/备份vhost、proxy、证书symlink、专用service,nginx -t后仅重建HN容器并平滑reload,再恢复HN subscribe_url;保留新旧证书/卷和凭据,不用全局docker/Certbot操作,不重启DPL/FUC。
## HN 邮件运行配置
`/www/wwwroot/hardnerd.net/.env`、HN库的 `mail_flow` 设置;SMTP465 SSL与IMAP993 TLS均校验证书。旧Laravel读取 `MAIL_DRIVER`,已明确设smtp,不能只设MAIL_MAILER。发件名称hardnerd、地址及Reply-To均为自有邮箱,页脚主站hardnerd.net,空MAIL_LOGO_PATH避免沿用DPL图片。
验证码/登录链接/工单回复优先Resend,其他走企业SMTP;额度100/3000且使用官方用量与本地预占,规则沿 [BUSINESS_RULES](BUSINESS_RULES.md)。Resend密钥、IMAP密码与回调签名秘密在HN库加密;SMTP密码只在私有环境,未复制进普通后台可读设置。ACC-HN-MAIL与ACC-RESEND-HN私有字段按GemDocs读取,本文不存值。
Webhook `https://hardnerd.net/api/v1/guest/resend/webhook`,ID `dafe1822-894f-4aff-97c9-0dc669d416b8`,enabled;订阅sent/delivered/delivery_delayed/bounced/failed/complained/suppressed。未签名401,签名诊断200;真实测试sent和delivered均回调200,投递事件和时间已入库。
IMAP来信加密保存;来信自动回复启用,普通SMTP发送,按北京时间每天每发件人最多一次,既有过滤避免自身/退信/自动邮件循环。管理员运行汇报保持关闭,没有借用DPL master或Telegram目的地;HN尚无独立汇报路由,不能宣称已接入日报。外部普通来信自动回复未做额外真实发送验收。
只重启过HN的hn-fpm、hn-horizon;DPL邮件配置和工作进程未改。
## 独立网关
Git分支 `codex/hn-production`。首个网关安装器d8a5091,首启等待/安全续跑修复 `ad9a4fe5877f3f9741e79f8250257d707e768189`;后续文档提交以服务器HEAD为准。发布先推送后部署;原Git获取较慢最终成功,修复提交经已推送Git bundle导入,不使用源码打包覆盖。
- gateway-01 / VPS-006 -> `/opt/hn-gateway/provision`,实现 `scripts/hn/gateway/`,只经白色机SSH。
- 本地代码:HN独立工作树相同目录下的scripts;本地Web域名不适用(运维容器);线上订阅域名见上;白色机网站反代未建立。
- 容器 `hn-subscribe-gateway` / `hn-subscribe-admin`;宿主端口仅 `127.0.0.1:28080/28443/26444`。管理端只环回26444,无公网vhost。默认管理员按ACC-005建立,未改旧管理员。
- 网络hn_sgw_net,172.28.32.0/24;卷hn_sgw_config / hn_sgw_logs;状态 `/var/lib/hn-sgw`,admin.env600。管理后端只指向hardnerd.net。白色机IP104.168.30.40为运维验收白名单,其他继承独立过滤库与管理规则。
- gateway内存160MB/0.5CPU,admin128MB/0.5CPU;启动前可用内存538MB,端口/子网/磁盘/固定镜像均检查。既有DPL/FUC资源不停止、不复用HN卷。
- 宿主 `/etc/nginx/conf.d/hn-subscribe.conf`、独立Cloudflare可信IP include与proxy include。宿主验证容器证书,容器验证HN上游证书,均不跳过TLS;已知域名SNI固定。
- 初启镜像下载公开云IP列表再启动Nginx;安装器等待其初始化,失败后只允许相同凭据安全续跑,不重建身份或覆盖变更。
- 独立证书SAN含t34/root/www,到2027-01-26;目录 `/etc/hn-gateway-letsencrypt`,ACME账户从同宿主既有已接受条款的账户复制到独立目录,未创建新账户或运行全局hooks。
- hn-gateway-cert.timer专属续期,renew-gateway.sh仅重载两个HN容器和宿主Nginx;hn-gateway-cloudflare-ips.timer维护HN可信代理段。独立续期dry-run通过,未改DPL/FUC续期。
## 验收证据与边界
证据本地忽略目录 `codex/output/hn-mail-20261027/`;远端私有备份下保存配置/SQL/回调与测试收据,不能输出其中秘密。
- 两封SendEmailJob测试只发用户新建邮箱:Resend verification一封、conventional other一封;没有跨渠道重复发送。IMAP实收2封、SPF和DKIM均pass、HN正文/页脚正确、无DPL品牌;IMAP入库2封。Resend已投递且有时间;SMTP仅记submitted,真实收件另证,未伪造SMTP回执。
- 白色机实际HTTPS请求:HN主站和DPL主站200,订阅root404;源站TLS校验通过。正式入口有效受限用户输出sing-box JSON与Clash YAML结构,invalid token403,管理路径404,no-store与过滤响应头存在。
- 为保留审计同时避免令牌进入URL日志,真实有效令牌测试放GET JSON body,未持久改变原HN主站日志配置;非秘密invalid token验证了常规查询路径和短路径。没有对外展示或记录有效订阅URL。
- 限时非管理员QA用户ID2,example.invalid、1MB/30分钟、无权限组/无节点;结束已禁用、额度清零、到期、令牌轮换且会话0,未删除审计证据。套餐/节点为空,未声称实际代理联网通过。
- 四个原DPL/FUC容器ID、启动时间、重启次数与既有Nginx配置hash基线一致。HN主站外其他站点配置未改。未发布DPL功能代码。
- sitejp02整机启动时间为2026-10-27 19:50:52,五个原服务在19:50:59至19:51:07随系统启动;与18:39首发保护基线的启动时间不同,不能写成未重启。重启原因本任务未核实,未执行重启命令;DPL HEAD/既有Nginx配置hash未变,原服务当前均active,DPL公网200。此项与gateway-01四个旧容器基线保持一致是两台服务器的不同结论。
- 已完成服务器侧验收,未做线上页面验收。支付、真实节点联网及外部自动回复额外发送未验收。
## 备份与回退
邮件备份 `/root/codex-backups/hn/20261027-mail-setup/`:hn.sql、hn.env.before、applied.json、subscribe-url-before.json、邮件测试收据;resend-webhook.json含秘密只限root。恢复前先备份当前业务数据,不能用初次SQL覆盖后续运营数据。紧急停用HN mail_flow或恢复旧HN邮件环境/设置,仅重启HN FPM/Horizon;不要重置DPL或邮箱密码。回调可在HN Resend账号禁用,保留记录。
网关备份 `/root/codex-backups/hn-gateway/20261027-first-install/`:原容器/配置保护基线、HTTP-only vhost及证书日志。回退顺序:先恢复HN subscribe_url为原值,再只停HN两个timer及HN compose容器,将HN vhost恢复HTTP-only或退出,nginx -t后平滑reload;保留卷、证书、admin.env及备份。不得执行全局docker prune/down,不停旧容器、不撤销旧证书或重启共享服务。

View File

@ -1,41 +0,0 @@
# HN 生产部署与恢复
2026-10-27 用户授权部署到 sitejp02,并指定主域名 hardnerd.net。本页仅维护 HN;DPL 发布仍单独授权。
## 当前实例
- 主站:https://hardnerd.net/;www.hardnerd.net 301 到主站。
- 后台:https://admin.hardnerd.net/,根路径内部映射 HN 数据库内随机 secure_path;API 仍要求管理员认证。
- 服务器:site-jp02 / VPS-005,路径 `/www/wwwroot/hardnerd.net`,经白色机 SSH;连接资料按 GemDocs 凭据入口取用,不写入仓库。
- Git 发布分支:`codex/hn-production`;首发功能提交 `2e5cec6a05e4ba6bb4bad9ec814f8d099bcd2bfe`,合并当时本地 master;不是 DPL 的上线版本。后续邮件/订阅配置和安装器版本以本文及 [HN_MAIL_GATEWAY](HN_MAIL_GATEWAY.md) 为准;只更新HN专用分支。
- EZ 锁定源码 `2c0581386d86006315e6eba2f5970c923892caba`;后台子模块 `ef5f43da335092cbff8fdf0ad7ff9b4d92d7d0d7`;两者均正常 Git 初始化,未修改第三方构建源。
- `SITE_PROFILE=hn`、`SITE_THEME=EZ`、生产环境、debug 关闭、HTTPS 安全会话;独立 APP_KEY、数据库、SQL 用户、Redis 密码/前缀、storage、日志、会话与队列。未复制本地演示库或 DPL 业务数据。
- MariaDB 数据库/账号 hn(仅 hn.* 授权),Redis `127.0.0.1:6387`;Unix 用户 hn。服务 `hn-redis`、`hn-fpm`、`hn-horizon`,独立 FPM socket `/run/php/hn.sock`、独立主配置 `/etc/php/8.4/hn-fpm.conf`(不向 DPL 共用 FPM 池加载)。
- 新配置:`/etc/nginx/conf.d/hn.conf`、`/etc/hn/redis.conf`、`/etc/cron.d/hn-scheduler`,服务单元在 `/etc/systemd/system/`。HN FPM ondemand 最多2子进程,Horizon每组1进程,两个服务各 MemoryMax 640M,Redis上限128MB。
- 生产默认管理员按 ACC-005 约定创建为 ID1,管理员有效且未禁用;未覆盖已有账号。凭据只由 GemDocs 对应字段入口提供,不写本文或日志。
## DNS 与 TLS
Namecheap 中 hardnerd.net 已切到 `bradley.ns.cloudflare.com` / `jewel.ns.cloudflare.com`;Cloudflare 独立 zone `76ae2997fe3ed41820aee0f7a73f4271`,免费方案已激活。根域与 admin A 指向 sitejp02,www CNAME 根域,均已代理;原5条 Namecheap 邮件 MX 与 SPF 已保留,不代表邮件收发可用。
源站 Let's Encrypt 证书含根域、www、admin,有效期至 2027-01-25;Certbot webroot `/var/lib/hn/acme`,HTTP 仅开放 ACME 并将其他请求跳到 HTTPS。证书目录 `/etc/letsencrypt/live/hardnerd.net`,已安装仅处理本证书的 `hn-cert-renew.timer` / `hn-cert-renew.service`,每日03:30加最多1小时随机延迟检查,Persistent补跑;续期后专用 deploy hook 检查并平滑 reload Nginx。续期 `certbot renew --cert-name hardnerd.net --dry-run` 已通过(退出0),timer下一次触发时间已回读。Cloudflare 完整(严格)已保存并回读;先通过源站证书验证,再启用严格校验,没有降低 TLS 校验。
未建立新的白色机网站反代;本机不访问线上。服务器侧运维请求走已登记 SSH 路线。hardnerd.dev 已配置独立企业邮箱与Resend;hardnerd-nodes.com 已配置独立订阅网关,运行、DNS、验收与恢复见 [HN_MAIL_GATEWAY](HN_MAIL_GATEWAY.md)。
## 发布、权限与恢复
按 Git 提交、推送,再在 HN 路径更新明确提交;保留 DPL 原 checkout 和分支。依赖安装与 EZ 构建在 HN 目录内完成,运行私密配置不进 Git。备份用 umask077,Git checkout 用022;仅 HN storage/bootstrap缓存允许 hn 写,源代码和发布资源由 root 管理。
首发备份:`/root/codex-backups/hn/20261027-first-deploy/`,包括 DPL/原Nginx/原服务保护基线、HN 初始环境600、建管理员前 HN SQL600、HTTP-only Nginx备份、安装/构建/Certbot日志及服务器验收JSON。目录受 root 保护;恢复前核实当前数据,不盲跑首发脚本。
紧急停止 HN:只停 hn-horizon、hn-fpm、hn-redis、hn-cert-renew.timer,保留数据库/配置/备份;如需撤销入口,将 hn.conf 恢复为该备份中的 HTTP-only 配置,nginx -t 后平滑 reload。不要重启/停止 DPL FPM、DPL Horizon、共享 MariaDB 或原 Redis。DNS 可按登记回滚 Namecheap BasicDNS,旧停放配置留作历史而不作业务恢复。
首发初始化曾因 Git 不跟踪 storage/app 空目录而中断;从守卫续跑点补齐目录后继续迁移,保留已生成密钥和独立数据库,没有重新初始化。HTTPS reload 后首次瞬时探测失败,复查及后续所有源站/公网 TLS 均通过;未因此放宽证书校验。
## 验收与业务范围
- 站点配置回归14测试/31断言、EZ配置测试1项、旧草稿测试7项及正式构建通过;合并后再验证,服务器依赖安装/构建和数据库迁移成功。
- 经白色机在服务器侧验证:主站/后台 HTTPS200,www301,响应经 Cloudflare;公开配置 app_name=hardnerd,EZ主模块200,源站三个域名证书校验通过,.env访问403/404(www跳转后拒绝)。
- 后台HTML及8项资源200,真实默认管理员登录200、管理配置接口200、访客403;仅本次新建验收令牌已撤销。
- DPL HEAD `7e8fbf60bd62ed5c6093099d574e0f234994a088` 未变;全部原 Nginx 配置SHA256未变;DPL Horizon、共享FPM、Nginx主进程、MariaDB、原Redis均active且启动时间未变,DPL公网HTTP200。Nginx仅平滑加载新增HN虚拟主机。
- 已完成服务器侧验收,未做线上页面验收。HN现为独立新站,没有套餐/订单/节点;邮件及辅助域名随后已配置并完成服务器侧收发/投递回调/订阅鉴权验收,详见 [HN_MAIL_GATEWAY](HN_MAIL_GATEWAY.md)。唯一受限订阅测试用户ID2已禁用、轮换令牌且无会话;支付及实际节点联网未配置/验收。

View File

@ -1,9 +0,0 @@
# HN购买页周期显示
2026-10-28用户要求:套餐卡片里置灰的不可购买周期直接不显示。
仅在frontends/hn/vite.config.mjs的HN适配器中,对上游Shop.vue周期标签v-for数据过滤price===null;不修改锁定EZ源码或DPL主题/代码/数据。不改套餐价格、销售状态、周期定义或订单校验。
本地实际浏览器确认:标准与专业各只显示月付/季度/半年/一年,一次性只显示一次性,DOM中disabled周期标签0个。周期切换/分组正常,既有价格和介绍未变。截图在共享工作目录codex/output/hn-hide-periods-20261028/shop.png。
生产仅发布HN,Git提交/构建与验收收据在同任务忽略目录及服务器备份;部署完成记录由GemDocs HN项目档案维护。恢复时仅回退HN此适配器提交并重新构建,或从该次静态资源备份恢复;不变更DPL、数据库或共享EZ子仓库。

View File

@ -1,157 +0,0 @@
# 节点批量接入
本专题说明批量调度;单节点操作、append 的保护范围与验收仍以 [执行手册](NODE_RUNBOOK.md) 为准,展示编号以 [命名规则](NODE_ONBOARDING.md#节点命名与编号) 为准。
2026-10-16:新增标准库 Python 调度器、命令适配器和离线进程测试。没有部署、连接服务器或增加生产节点。调度器可以运行经过审阅的任务适配器;**没有内置 Cloudflare/中转后台 API,也没有替使用者实现各站点的 SSH 登录与现场检查。**生产任务必须先按下述协议配置命令,不能将离线测试通过视为生产全链路可用。
## 文件与运行方式
2026-10-17新增[正式站点适配器](NODE_BATCH_RUNTIME.md):后续新批次默认用`configure-site.py`生成`ssh-site-v1`计划,具备动态完整节点验收、受限测试账号、白色机SSH会话复用、证据续跑及阶段事件。已通过本地隔离测试,尚未部署或线上演练。本页下述外部命令适配器协议保留兼容,旧任务脚本和既有冻结摘要不修改;旧命令适配器不透传缓存证据,继续逐阶段现场probe。
| 文件 | 用途 |
| --- | --- |
| `scripts/node-onboarding/batch.py` | 参数校验、资源预留、并发、进程间锁、逐阶段账本与恢复 |
| `scripts/node-onboarding/command-adapter.py` | 调用经过摘要固定的任务命令;不经过 shell,缺命令返回等待 |
| `tests/node-onboarding/test_batch.py` | 临时目录模拟远端,验证并发、重复运行和回包丢失;不加载 Laravel/.env |
在仓库根目录运行,计划文件与状态目录用绝对路径,放在受保护任务目录。计划只含非秘密参数和引用;不要加入密码、令牌、私钥内容、含订阅令牌的完整 URL 或命令行凭据。
```sh
python3 scripts/node-onboarding/batch.py plan --plan /private/task/batch.json --state-root /private/task/ledger
python3 scripts/node-onboarding/batch.py run --plan /private/task/batch.json --state-root /private/task/ledger --jobs 4 --timeout 900 --execute
python3 scripts/node-onboarding/batch.py status --plan /private/task/batch.json --state-root /private/task/ledger
```
`plan` 仅做本地参数校验,不预留资源、不访问网络、不生成状态目录。`status` 只读账本,不触发命令或创建账本。`run --execute` 才可能执行写入。退出码 0 表示本次要求完成;2 表示校验失败、等待、冲突、未知或未完成,不应自动当成临时网络错误无限重试。默认并发 4,上限 16;timeout 是每次适配器调用的超时秒数,不是整个批次的时限。
## 参数格式
以下使用保留示例地址,不能直接运行生产任务。`provision_commit` 填已审阅、已推送的完整 Git 提交,bundle 与客户端二进制路径必须由前检核验。VPS ID 与实际地址从 CoDocs 对应资源读取,不能从展示编号推断。所有节点放在同一个 `nodes` 数组,SOCKS 端口各不相同。
```json
{
"version": 1,
"mode": "append",
"batch_id": "jp-batch-20261016",
"context": {
"panel_vps": "VPS-001",
"gateway_vps": "VPS-002",
"jump_vps": "VPS-003",
"app_root": "/absolute/panel/app",
"panel_url": "https://panel.example.invalid",
"subscription_url": "https://subscribe.example.invalid",
"provision_commit": "0000000000000000000000000000000000000000",
"bundle": "/private/task/reviewed.bundle",
"client_binary": "/absolute/gateway/sing-box"
},
"adapter": ["/absolute/python3", "/absolute/repo/scripts/node-onboarding/command-adapter.py"],
"command_profile": {
"files": {"/private/task/handler.py": "填写该文件的SHA256"},
"phases": {
"preflight": {
"probe": ["/absolute/python3", "/private/task/handler.py", "--plan", "{plan}", "--node", "{node}", "--phase", "{phase}", "--action", "{action}", "--state-dir", "{state_dir}"]
}
}
},
"nodes": [{
"key": "jp-new-a",
"country": "JP", "sequence": 2, "flag": "🇯🇵", "country_name": "日本",
"name": "🇯🇵 日本 JP - 02",
"vps_id": "VPS-011", "node_address": "192.0.2.11", "listen_port": 24443,
"relay_host": "relay.example.invalid", "relay_port": 47011,
"tls_domain": "jp-new-a.example.invalid", "formal_group_id": 1,
"expected_exit": "192.0.2.11", "socks_port": 19011
}]
}
```
示例只声明 preflight 命令,其他阶段必然等待。实际任务可为八个阶段逐一登记 `probe`,并在 stage/dns/provision/relay/publish 登记有权限执行的 `apply`。缺少 API 的阶段可以只提供读取实际状态的 probe,由操作者完成外部操作后续跑。不要使用“手工勾选成功”或静态回执替代现场 probe。
`files` 必须覆盖本地适配器、handler 及其加载的操作配置/脚本;不要只固定 Python 解释器。任一摘要变化停止,不接受在原批次中改命令后直接续跑。远端代码、安装包、二进制也由前检按明确 Git 提交/摘要核验。通用调度器不会自动检查命令是否遵守这些语义,也不会替命令构造 SSH 配置。`adapter` 亦可指向符合协议的专用程序,不必使用命令适配器。
## 调度与操作协议
所有候选节点先通过 preflight,才能开始任何 stage。每个节点依次执行以下流程;独立节点同时推进,共享写入由锁串行执行。
| 阶段 | 并发边界 | 任务命令必须核验的事实 |
| --- | --- | --- |
| preflight | 按 VPS 并发,全批屏障 | 当前代码/工具/系统依赖;VPS 与地址;空闲端口;现有机器/节点/组/名称/域名/中转冲突;目标正式组;保留资源基线 |
| stage | 面板串行 | 参数与 manifest 完整匹配;隔离 QA 和新节点确属本任务;创建后回读对象及私有传输文件 |
| dns | 全局串行 | 本次域名实际 DNS 指向;有权限的 API 才能 apply;无 API 则等待 |
| provision | 按 VPS 并发 | 已推送提交、安装包摘要、独立服务/配置归属、证书/SNI、旧服务保持;不能仅按文件存在判定完成 |
| relay | 全局串行 | 独立规则的入口和目标完全匹配;网关侧严格 TLS/SNI 连通;不得占用旧规则 |
| verify | 按 VPS 并发 | 从隔离网关获取真实完整 QA 订阅,用 `verify-node.py --run` 验证预期出口/国家并核对实际流量上报;报告绑定本请求 |
| publish | 面板串行 | `promote.php --mode=append` 先预览后应用;只开放本次新节点到指定已有组并禁用 QA;不能重写套餐、用户组或订阅地址 |
| audit | 按 VPS 并发 | 正式多节点订阅完整、旧节点/网关/套餐/用户关联/订阅地址符合基线、QA 禁用且订阅拒绝/不下发;新节点仍可用 |
preflight/verify/audit 仅调用 `probe`,没有 apply。verify 的 probe 会产生隔离测试流量,可以启动/停止本次临时客户端;不能修改正式配置。已经完成 audit 的节点,重跑只做 preflight 与完整 audit,避免重新激活 QA。尚未完成的节点会从第一阶段重新逐项 probe,确认后才越过。
适配器接受 `--plan FILE --node FILE --phase PHASE --action probe|apply --state-dir DIR`。node 文件包含 `request_sha256`,它绑定 context 与完整节点参数。标准输出必须只含一条 JSON,字段如下:
```json
{"key":"jp-new-a","phase":"stage","request_sha256":"从node文件原样读取","status":"absent","retry_safe":true}
```
| status | 含义与调度行为 |
| --- | --- |
| ready | 当前现场满足本阶段所有要求,可以继续;不能只依据上次成功日志 |
| absent | 已权威确认本阶段尚未执行;只有同时明确 `retry_safe: true` 才能调用 apply |
| waiting | 外部依赖未就绪或需人工处理,本节点停下;不占工作线程反复轮询 |
| unknown | 现场无法确认,不写入,不自动重试 apply |
| conflict | 资源占用、参数漂移或归属冲突;阻止该节点,通知其余任务停止继续写入 |
apply 返回成功后仍必须 probe 确认。退出码非零、输出损坏、身份不符、超时或 Ctrl-C 后,远端结果一律按未知处理;重跑仍从现场读取。apply 命令必须支持安全处理部分完成的现场;不能把“有进程/有目录但未完成”随意返回 absent。stage 的现有对象没有匹配 manifest、或事务结果与 manifest 不一致时应 conflict/unknown,人工核实,不能自动创建第二套对象。
命令适配器的占位符仅支持 plan/node/state_dir/key/phase/action;每个元素作为独立 argv 传递,不解释 shell。操作脚本需要参数时自行读取受保护的 JSON。禁止在 argv/标准输出加入凭据;密码与令牌由 handler 按 CoDocs 私有引用按需读取,SSH 保持经白色机跳转;本地 HTTP 继续使用明确登记的反代。不要为了批量化改变既有访问规则。
## 查重、锁与断点恢复
计划内拒绝重复的部署 key、展示名、国家编号、VPS ID、实际落地 IP、TLS 域名、中转入口域名+端口与网关 SOCKS 端口。展示名格式与旗帜/国家/两位编号必须匹配。**这些是本地候选查重,已在线资源的查重必须由 preflight/stage 实际查询。**同一物理地址使用不同 VPS 别名也会被拦下。
状态目录是唯一协调入口:SQLite 事务原子预留身份,同目录 fcntl 锁协调进程。VPS/IP/TLS 与中转入口预留跨面板;名称/编号/key 按面板应用划分;SOCKS 按网关划分。相同节点参数可重跑;不同参数不能借用同一批次或占用已有预留。预留不自动释放,不提供强制接管命令。
所有参与聊天/进程必须使用**同一台协调机、同一个状态目录**。另建账本、复制状态目录、在另一台主机启动独立协调器都不能获得跨机锁保证。对共享面板的非批量手工操作也必须协调暂停,不能指望本地锁约束后台人工修改。每次写入前的远端归属/参数检查仍必要。
中断后保留原计划、账本、远端 manifest 和操作日志。先用 status 查看,再以同一计划、同一状态目录运行相同 run 命令;不要删除账本、改 batch_id 或清除 manifest 来“解除报错”。冲突需先核对原始请求与现场,不能通过修改参数静默接管。未知结果宁可停住;恢复不保证所有断点都能无人干预推进。
SIGINT/SIGTERM 会停止派发后续写入,终止当前本地适配器进程组;**这不保证已发出的远端命令停止,也不自动回滚**。其他已开始的节点可能已完成步骤。已完成节点保留成果;未完成节点留在其最后安全阶段。需要回退时按执行手册逐字段预览与回退,不做全批删除。
### 原服务自行重启时的恢复
PID/启动时间漂移应先停止写入,不直接刷新基线。读取服务生命周期、退出原因、重启策略、内核OOM与本任务命令记录,区分本次变更影响和原服务自身故障。确认已恢复时,保留原基线只读副本及SHA256、事故日志、两次稳定采样、443监听归属与当前配置/二进制/unit哈希;没有历史哈希就明确不能证明前后相同。独立助手复核后,只更新本任务进程比对所需字段,保存diff、原因与证据路径;不修改原服务、不删除账本、不改变冻结代码/计划。再次漂移必须重新调查,不能滚动更新绕过保护。2026-10-17 jp03 QUIC sniffer自身panic的实际处理见 [STATUS](STATUS.md)。
## 多智能体协作流程
用户于 2026-10-16 明确授权:以后执行批量节点接入,默认启动一个子助手,按本节分工执行,不再重复询问是否需要助手。触发入口见项目根 [AGENTS.md](../../AGENTS.md)。这里的“启动”由主协调者使用子助手工具完成;`batch.py` 本身不调用模型或创建助手。明确要求批量,或同批接入两个及以上节点时触发;仅讨论、修改脚本/文档、单节点接入不自动触发。用户当次明确调整角色或数量时,以最新要求为准。
| 角色 | 固定职责 | 写入与协作边界 |
| --- | --- | --- |
| 主协调者 | 明确本批范围,统一参数、展示编号与端口,固定计划,持有唯一账本,安排生产写入并汇总结果 | 只有主协调者启动本批正式调度和共享资源写入;不以助手已派发代替工作完成 |
| 核查验收助手 | 接入前核查候选服务器身份、既有节点/机器、端口、中转、DNS 和归属冲突;独立审查脚本与参数、测试并发/查重/恢复;开放前后核对 QA、正式订阅和旧资源基线 | 分阶段完成两类职责,返回每节点结论、证据及缺口;不自行分配最终编号、创建节点、改后台或另起调度。真实联网验收的对象、端口和时机由主协调者统一安排 |
执行顺序:
1. 主协调者在生产写入前启动/复用一个核查验收助手,给出当前任务范围、必要资料入口、受保护资源、分阶段输出和完成标准。当前批次已存在对应子助手时继续使用,不在每个节点或每次续跑时重新创建。
2. 助手分阶段核查资源与审查方案;主协调者并行准备参数与调度。助手完成冲突核查和相关隔离测试/审查后,主协调者解决影响写入的缺口,固定同一参数单,再运行全批 preflight。检查发现变更时重新核对受影响项,不把旧回执沿用到不同参数。
3. 独立节点由脚本按 `--jobs` 并发,面板/DNS/中转按共享锁执行。正式开放前,核查验收助手核对本节点隔离 QA、严格 TLS、实际出口与上报证据;主协调者确认后执行 publish。助手不得另起同批调度、重复 stage、共用浏览器会话切换账户或绕开账本。
4. 正式开放后,核查验收助手按[影响范围](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)检查完整正式订阅、实际权限、QA 禁用/拒绝、旧资源基线及分层联网结果,并报告通过或具体缺口。轻量TLS不能写成出口/流量验收通过;影响不明或共享变化回到全量。主协调者核实后逐节点报告完成、等待或需用户操作;批次全部完成必须有对应实际验收,不能以子助手结束作为完成证明。
5. 子助手不可用、并发名额不足或失败时,主协调者明确报告,复用/恢复角色或等待名额;不自动追加更多助手、跳过必需角色或把未知写成通过。暂停和停止指令必须传达给子助手,并停止派发后续写入;已发出的远端操作仍按断点恢复规则回读。
主协调者与一个子助手分别消耗处理其输入和输出所需的 token,总量不是固定倍数。主协调者默认仅传必要背景、资料路径、节点参数与任务边界,避免复制整段历史;每项检查明确归属,汇报用结论、证据位置与缺口,不反复粘贴完整日志。独立验收保留必要的交叉核对,不为节省 token 省略验收。没有固定 token 配额或价格承诺。
该常驻授权仅允许启动当前任务的子助手;不自动新建/分叉侧边栏普通聊天,向已有聊天派消息仍需用户明确授权。节点并发数由脚本控制,助手数保持一个;不会为十个节点启动十个助手。项目规则不扩大实际生产变更、凭据、访问路线或后台自动化授权。
## 本地验证
本地测试:
```sh
python3 -m unittest discover -s tests/node-onboarding -p 'test_batch.py' -v
```
本次 11 项测试通过,覆盖:plan/status 无副作用,计划内重复与命名约束,全批前检屏障,独立节点重叠执行/面板串行,完整重跑不重复写,模拟写成后回包丢失,SIGTERM 中断后续跑,跨批预留/参数漂移,两进程竞争、错误身份回执、命令摘要变更及配置完整的适配器流程。测试没有验证生产命令的正确性、DNS/中转 API、真实 SSH/网络、现场负载或十节点耗时。生产批次的候选参数、命令 profile 与现场验收须在具体获授权的接入任务中另行核实。
2026-10-17追加面板隔离测试:`php vendor/bin/phpunit --no-configuration tests/node-onboarding/PanelChangeIsolatedTest.php`,9 tests / 65 assertions通过。真实Eloquent与SQLite内存库,无.env/Redis/队列/网络;覆盖append保护范围、QA禁用、幂等、事务失败/通知失败恢复和冲突拒绝。NodeSync为测试替身,生产通知与用户下发仍需现场验收。
2026-10-17:已在用户授权的三日本节点批次使用同一冻结计划/账本及一个核查验收助手完成生产接入与分阶段恢复;DNS/中转仍由注册后台界面保存后现场probe,无通用服务商API写入。精确版本、五节点订阅/QA/原资源验收及进程基线事故见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-17-三个日本节点批量接入)。本轮三节点结果不推导十节点耗时或无人值守能力。

View File

@ -1,179 +0,0 @@
# 批量接入正式适配器
本专题是后续批量接入的默认技术入口。适配器实现与隔离测试于2026-10-17完成,2026-10-18首次正式批次线上验收完成;部署、动态全量验收和例外范围见[本次节点记录](NODE_ONBOARDING.md#2026-10-18-后续五节点批量接入)。此前三节点批次使用冻结旧任务适配器,与本实现的生产证据分开。
`batch.py`保留原命令适配器兼容,新计划默认用`configure-site.py`生成`runtime.kind=ssh-site-v1`。旧计划、账本、任务handler一律保留,不修改源码摘要后直接续跑旧任务。生产操作仍须具体接入授权、Git运维部署及现场验收;批量按AGENTS启动独立核查助手,单节点按执行手册由操作者完成精确证据复核,不自动启动子助手,也不得把操作者审核写成独立助手审核。
## 服务器侧接入与验收
用户2026-10-20进一步指定:节点维护全流程(候选盘点、查重、DNS/中转配置、部署、验收和回退)优先在服务器侧完成,默认不打开线上网站;优先使用经白色机的SSH、已登记凭据及服务接口。第三方缺少可用接口或凭据且确需网页时,先说明具体缺口,仅按现有访问规则处理必要范围;不为补页面验收打开网站,不绕过强制验证。 线上业务操作、凭据读取、首次SSH信任和账号切换边界仍按全局规则执行。
用户2026-10-20长期指定:新增节点不再默认登录DPL管理页面或进行页面验收。通过白色机SSH在面板应用根目录运行正式stage/promote/readiness及验收脚本,使用应用模型、业务服务、事务和操作journal读写线上数据;保留写前基线、参数/身份校验、幂等恢复和精确回退,不临时绕过业务逻辑写裸SQL。此前批次的节点记录本就由这些服务器侧脚本写入,浏览器仅承担额外回读。
完成标准仍包括新增资源参数回读、隔离完整订阅联网/严格TLS/预期出口和双向上报、append正式组、动态正式订阅按影响范围验收、QA禁用403/下发排除、旧节点/套餐/用户关联/订阅设置/网关容器及原服务保护。DPL页面验收不再列为节点接入未完成项;未做的界面/手机/TUN测试仍按事实注明。此规则不覆盖真正的后台界面功能修改,不授权本机网站访问或第三方后台写入越界。
### DNS与中转的服务器侧路径
2026-10-20单节点任务首次实际使用白色机访问中转站自身接口(不是新建平台权限):正常登录`hw.lshan.eu.org/api/v1/user/login`,凭据从`ACC-RELAY-HW`统一读取,经私密stdin传给白色机认证组件;先按当前站点登录合同检查验证码要求,强制验证则停止求助。令牌只保留在该进程内存,不导出浏览器会话。
转发先`POST /api/v1/forward/list`核对全表,对名称、入口端口和目标查重;仅当均无冲突才`POST /api/v1/forward/create`新增独立规则,随后重新list回读平台ID、隧道、地址、状态及Proxy Protocol两端配置,并对照写前快照确认旧规则所核字段保持。存在完全匹配项只回读,结果未知先probe,不盲重发create。当前合同来自站点本身前端,未宣称为稳定公开OpenAPI;后续须按当时站点合同核实,不能猜接口或跳过认证/验证码。
gemcook.net为Namecheap BasicDNS,`ACC-002`目前只有已登记网页登录密码、无DNS API密钥。不得为避免网页自动开通新API权限或借用其他账号。本次新增A记录因此仅此步骤使用必要网页并回读旧记录;部署与验收继续服务器侧。后续有可用且获授权的DNS接口凭据时改走服务器侧。DNS运行值可从白色机核对当前NS和权威A应答;不能把超时、不完整或非权威应答记为通过。
## 文件与职责
| 文件 | 职责 |
| --- | --- |
| `configure-site.py` | 本地合并明确站点参数,固定解释器、Paramiko版本、源码与非秘密配置摘要;不联网、不写账本、不覆盖旧计划 |
| `site_adapter.py` | 复用正式stage/provision/promote流程;按国家、组、批次和路径参数执行,按需连接服务器,进行动态正式订阅验收 |
| `ssh_pool.py` | 线程内复用白色机及下游SSH会话;下游强制通过白色机direct-tcpip,不提供直连回退 |
| `panel-acceptance.php` | 开工前选择有效正式组账号;缺少时创建本批受限非管理员测试用户,记录归属并收尾禁用 |
| `acceptance.py` | 动态集合、逐节点结果、原始证据摘要、QA身份/禁用/拒绝/下发排除和时效的完整自动审计 |
## 候选容量筛选
用户2026-10-20指定:物理总内存小于1GB且已安装XrayR的服务器,不作为DPL新节点。两条件须同时成立;已装但未运行、inactive或masked同样计算,不能临时停止/卸载旧服务来绕过。采用`/proc/meminfo`的MemTotal乘1024,1GB=1,000,000,000 bytes;不把1GiB误当门槛,不使用MemAvailable、空闲内存或swap。容量或安装证据不明时暂停核查,不按未安装放行。该规则筛选新增候选,不自动迁移、停用已接入节点。
预选时先核实;正式适配器每次preflight在准备正式测试账号和stage前再次执行`capacity_gate`,本地600回执绑定请求、字节数、安装状态和采集时间。小内存且已装时拒绝进入接入阶段;不能为了凑批次数忽略。满足本规则只代表未被该条件排除,仍须通过余下资源、原服务、端口与容量检查。
### 无旧XrayR与发行版工具差异
2026-10-21本批ru01实际小于1GB但未安装XrayR,不命中两条件排除。正式适配器已有完整`preserved_service_overrides`:按现场实际unit、程序、配置和端口保护SSH等原服务,不创建虚假的XrayR文件或跳过原服务门禁;无原443监听及原22/53全监听另外留前后证据。各主机工具、续期timer和收尾采样也必须按实际选择,不能把Snap timer或XrayR写死后宣称通过。
Ubuntu20.04自带Certbot0.40缺少当前验收所需参数,不能以删掉dry-run/deploy-hook检查兼容。可在独立venv安装经过实际验证的官方版本并让部署命令与续期服务使用同一运行时,保留原unit/drop-in/入口存在性、原字节摘要和精确回退。Python3.8的本批组合为Certbot/ACME2.11.0、pyOpenSSL24.3.0、cryptography44.0.3、josepy1.15.0;本批兼容组合不等于最新版或未来兼容保证。验证依赖导入、pip check、实际插件registry参数解析和工具版本;隐藏参数不能仅凭help文本是否展示判断,`--help`提前退出也不替代真实参数解析。注册结果未知先回读已有账户,不重放注册;账户JSON并不保证包含status字段。
Debian官方Release可能带新版本共签。任务验证器仍须至少有对应发行版的必需官方签名,新增共签仅按官方精确指纹单独允许,不能让共签单独替代必需签名;异常/未知签名仍拒绝。保留Release→Packages→固定版本deb哈希链与无删除/无意外升级模拟。详见本批[NODE_ONBOARDING](NODE_ONBOARDING.md),旧冻结任务源码和计划不改摘要追认。
## 准备与Git部署
先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。
生产前先提交并推送源码,再生成、推送和部署独立运维Git版本:保留原安装脚本,加入`panel-acceptance.php`及其`operations.php`,还有stage/promote/rollback/readiness/verify-node入口;面板、网关和目标节点的对应运维目录必须为计划中完整`provision_commit`且无工作区差异。配置文件和凭据不入Git。独立快照由正式`bundle-ops.py`生成,保持基础运维树、工作目录和真实Git索引不变,仅复制10个明确的已提交运维文件,默认拒绝已有分支或bundle,不联网:
```sh
python3 scripts/node-onboarding/bundle-ops.py --source FULL_SOURCE_COMMIT --base FULL_OPS_BASE_COMMIT --branch codex/node-tools-BATCH --bundle /private/task/provision.bundle
```
同一运维分支的后续修订只有显式`--advance`才允许,且`--base`必须精确等于当前分支头;新提交以该base为父并通过旧值比较更新,仅快进,不强推或覆盖并行修改。bundle仍要求新路径。随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。
适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局执行钩子、普通/Snap目录钩子或目标证书额外执行钩子均停止。目标证书只允许单个精确专用`renew_hook`或`deploy_hook`(兼容连字符别名);重复及未知执行钩子停止。2026-10-18补齐字段兼容检查,仅用于后续新计划;已完成批次的实际钩子另行只读审查,冻结源码/计划/pin不重写。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。
Certbot准备不能只检查`command -v`:还须实际运行`certbot --version`。2026-10-17后续批次两台Debian10虽有Snap Certbot、renew timer和生成的profiles,但既有AppArmor加载服务缺失,证书命令报`missing profile`。项目提供`load-certbot-apparmor.sh`、`dpl-certbot-apparmor.service`及renew drop-in作为该情形的限定修复:只校验并加载已安装Certbot/core生成的profiles,保留其原有模式;不启用全局AppArmor服务、不关闭安全校验、不重启XrayR。仅在现场确认符合该缺口的主机部署到独立Git目录`/opt/dpl-certbot-security/provision`,先`--check`、再加载并验证工具;原续期命令前重检profiles,启动时也加载。安装前保存原单位/drop-in存在性及已加载profile清单;失败不签发,回退先确认无证书进程,再撤销本任务unit/drop-in并按原清单处理本次新加载项,不卸载其他任务profile。本段修复的实际部署及验收状态见[STATUS](STATUS.md)。
## 本地参数文件
全部放在权限700的任务目录,文件600。基础计划沿用[NODE_BATCH](NODE_BATCH.md#参数格式),包括本批任意数量、任意已支持国家的候选节点;不固定五节点、日本、组1或任务日期。新增站点配置JSON如下,示例路径必须换为实际已核实值:
```json
{
"ops_root": "/opt/dpl-batch/provision",
"node_root": "/opt/dpl-node/provision",
"batch_backup_root": "/root/codex-backups/dpl/BATCH",
"node_backup_root": "/root/codex-backups/dpl/BATCH-nodes",
"qa_root": "/var/lib/dpl-sgw/BATCH",
"provider_snapshot": "/private/task/providers.json",
"exit_registry": "/private/task/exits.json",
"ssh_config": "/private/task/ssh.json",
"preserved_service": {
"unit": "XrayR", "binary": "/usr/local/XrayR/XrayR",
"config": "/etc/XrayR/config.yml", "port": 443
},
"formal_socks_port": 19203,
"client_sha256": "填已核验官方客户端文件的64位SHA256",
"gateway_containers": ["dpl-subscribe-gateway", "dpl-subscribe-admin", "subscribe-gateway", "subscribe-admin"]
}
```
各主机原服务路径不同且已通过实际进程参数核实时,可在site配置增加 `preserved_service_overrides`,例如 `{"VPS-023":{"config":"/usr/local/XrayR/config.yml"}}`。只允许候选主机ID,值为单独config或完整unit/binary/config/port四字段;路径必须为安全绝对路径。默认preserved_service保持不变,按当前主机合并保护规格,不搬动、复制或改写旧服务配置以适配脚本。现场依赖准备及收尾采集也必须使用同一已核实规格,不能仍硬编码默认路径。
批次备份目录必须独立且固定,不跨实际批次复用旧资源基线。新节点端口、正式客户端端口均查重预留;共享网关客户端还使用同一账本内的进程锁。当前适配器用于独立AnyTLS节点及明确列出的既有服务;不自动把白色机、面板或网关当节点宿主。
`ssh.json`含`known_hosts`绝对路径及`hosts`映射,key为准确CoDocs VPS ID。每台登记`host`、`port`、`username`、用户已批准的`host_key_sha256`(OpenSSH SHA256格式)、可选`credential_ref`;白色机额外登记`role=white_jump`,ID须与`context.jump_vps`一致。候选IP必须与冻结计划相同。`credential_ref`只引用既定安全存储中的600文件。新计划统一设`credential_format=gemdocs-resource`及准确`credential_resource=VPS-xxx`,通过GemDocs `tools/credentials.py`的`get_field`按登记格式读取password,原样交给SSH组件,不自行剥标签或trim。configure同时固定credentials.py/resources.py摘要;格式或字段异常停止,不回退全文。旧JSON/codocs-password分支仅保留旧冻结版本兼容,不用于新计划;原始凭据不改写、不复制。内容不得进入计划、日志、argv或Git。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。
只从known-hosts文件选择与冻结配置中已批准指纹完全相符的公钥;找不到匹配时在读取/发送凭据前停止。全局known-hosts增加无关记录无需改变本批受信任指纹。不关闭主机密钥检查,不自动TOFU;首次信任先按访问规则核验/批准精确指纹,保存公开known-host条目。会话丢失只标记未知,不自动重发安装/开放命令;下次仍从现场probe恢复。
`exits.json`按SNI登记独立已知预期,例如:
```json
{"node.example.invalid":{"expected_exit":"192.0.2.11","country":"JP"}}
```
覆盖该账号当前应获得的全部旧节点与新候选,不能从本次实测结果反填预期来让检查通过。增加或改变节点时,按CoDocs/授权现场证据更新新任务的登记;遇到未知预期或不支持协议停止,不能静默省略。当前站点适配器针对IPv4 A记录、固定端口AnyTLS;端口范围或其他协议停止而非静默省略,扩展需另补验证能力。
`providers.json`是实际已登记后台UI或授权官方API的近期观察,最多900秒有效,不使用语言相关DOM字符串:
```json
{
"captured_at": 0,
"dns": [{"type":"A","name":"node.example.invalid","address":"192.0.2.11","proxy":false}],
"relays": [{"name":"dpl-new-anytls","host":"relay.example.invalid","port":47011,"target":"192.0.2.11","listen_port":24443,"proxy_protocol_in":false,"proxy_protocol_out":false}]
}
```
`captured_at`填实际采集Unix时间。观察文件可以刷新,不能把静态勾选当现场证据。DNS写入先按真实后台或获授权接口回读;后续只读续跑可由白色机查询当前NS并核对两台权威服务器的AA应答,刷新DNS运行观察,不能用普通递归缓存或只改时间戳代替。中转可以使用上述正常服务接口的实际list回读。资源存在但参数/归属不符停止;尚未创建的DNS/中转按服务器侧优先选择已获授权的正常接口,或确实必要的登记后台,保存后更新观察再续跑。调度器不自动创建平台API权限或扩大本次业务授权;本机访问路线仍按全局规则,不默认直连源站。
## 执行与等待
```sh
/path/to/reviewed/python scripts/node-onboarding/configure-site.py --plan /private/task/base.json --site /private/task/site.json --output /private/task/frozen.json
/path/to/reviewed/python scripts/node-onboarding/batch.py plan --plan /private/task/frozen.json --state-root /private/task/ledger
/path/to/reviewed/python scripts/node-onboarding/batch.py run --plan /private/task/frozen.json --state-root /private/task/ledger --jobs 3 --timeout 900 --execute --events
```
配置/plan只做本地工作,不创建线上测试用户。`run --execute`进入准备前检时才允许准备验收账号,随后全批前检屏障通过才stage。`--events`输出无秘密的阶段状态JSON行,最后一行为批次结果;不加此参数保持原单JSON输出协议。账本`events.jsonl`仅追加状态变化,等待时使用进程输出/事件,不每几秒重复查全部状态。只有有新依赖证据、外部DNS/中转变化或用户恢复指令时续跑,不能无限重试。
SSH会话仅在本次进程、同一工作线程内复用,线程不共用可变SSH客户端;进程退出关闭连接,不建立后台守护进程。每个阶段通过lazy连接只访问必要主机。Paramiko channel有远端命令超时;进程内模式不承诺`--timeout`能杀死已发送远端命令,停止后仍须核实实际结果。
## 动态验收和测试用户
准备阶段先按正式组、banned、额度和有效期选既有账号,优先管理员且最多检查20个候选,避免全表巡检客户凭据;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤;候选被实际节点下发过滤时不算可用。未找到可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。
新节点仍沿用各自隔离QA→严格TLS/出口/上报→按任务规模复核精确报告(批量独立助手、单节点操作者)→append开放并禁用QA。适配器直接生成`key-evidence.json`,包含原始报告字节SHA256、请求摘要、QA身份、报告时间及最新在线/双向上报状态,600原子保存,不再需要任务专用采集脚本。完成上述对应角色审核后,操作者或主协调者在同目录记录`key-reviewed.json`:`request_sha256`、`report_sha256`、`qa_user_id`、`verified_at`必须与该报告完全相同;未审核不得自动填批准文件。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。所有新旧节点均覆盖结构、权限和必要连通检查,完整联网测试范围按下节决定。
每组正式客户端串行运行,保留完整订阅DNS/路由,只给诊断目的地固定出站。新建测试用户可能等待节点拉取用户列表;只对通用客户端运行失败最多做3轮诊断读测试,间隔15秒且共用900秒预算。结构、TLS或出口断言错误不重试;不重放生产写入、不另建用户来绕过失败。证据绑定完整计划摘要、节点集合摘要、原始报告及QA拒绝文件SHA256、QA实际ID、403、banned、实际下发排除及900秒时效。任一缺项或摘要/集合变化失败。旧节点关键配置、全部套餐、原用户关联和订阅地址按批次原始基线核对,网关容器ID/启动时间/重启数再次核对。
正式验收成功或失败都禁用本任务新建的正式测试用户;既有账号不被禁用或改密码/权限。进程正常退出在finally收尾,即使等待或部分前检失败也尝试清理;遇到断网/SIGKILL无法保证立即清理,记录的归属、1小时有效期与额度是兜底,恢复后先核对旧journal。已关闭用户绝不重新启用;下一次需要用户时创建新受限租约,保留历史并在最终审计核对其403/下发排除。完成要求由服务端实际集合/订阅拒绝证明,不承诺立即踢断已经建立的客户端会话。
## 按影响范围验收(2026-10-20)
后续新计划分两阶段,避免把隔离测试与最终验收混成重复的全量联网:
1. 每个新节点在隔离QA阶段仍完整测试:完整订阅、严格TLS/SNI、独立预期出口/国家、实际在线及双向上报;独立复核通过后才append开放。
2. 全部开放后,每组获取一次正式账号完整订阅,对所有节点核对集合、端点、SNI、实际密码、严格TLS设置,并检查服务端实际下发权限、QA禁用/403/排除。未受影响节点只进行经中转的TCP+严格TLS握手,不重复启动客户端查询出口。此检查不证明正式账号实际鉴权或流量上报,不得标注为完整联网通过。
3. 受影响节点重新完整联网;共享网关/订阅生成、DNS/路由规则、中转公共配置等发生变化时,全组完整联网。影响未知、没有明确评审,或使用本任务新建正式测试账号时,保守全组完整联网(新账号需验证真实认证和用户下发传播)。旧资源、套餐/用户关联、应用版本及网关容器基线保护仍执行,异常停止,不能借轻量模式放行不符基线的变化。
站点JSON新增可选`acceptance_impact`,在冻结新计划前按实际变更评审填写;这不是生产操作授权:
```json
"acceptance_impact": {
"shared_changes": [],
"affected_snis": [],
"review": "task/impact-review.json 中的变更清单、基线对比及评审结论"
}
```
两个空列表仅表示已核实为独立append,不能用空列表代替调查。`affected_snis`列出需重测的准确SNI(不按国家或节点数量抽样);`shared_changes`任一非空项令全组完整联网。字段省略或null保持原全量策略,格式含糊/未知SNI停止。该对象随计划摘要固定;评审依据应保存在私有任务证据中,不能仅写“无影响”而无对比材料。出现新依赖变更,停止旧计划并重新评审,不修改已冻结摘要来继续。
`verify-node.py`的完整expectations清单仍覆盖所有AnyTLS出站;每条可增加`verification: "full" | "tls"`,省略为full。`--qa`只走full。tls仅握手、不启动sing-box;full沿用实际客户端出口/国家测试。审计绑定逐标签范围摘要、原始报告、QA证据及完整集合;不能用tls结果满足full要求,漏节点/证据过期仍失败。正式报告保存`verification_scope`,区分轻量结果与完整联网结果。缓存复核仍绑定相同范围和集合,不复用隔离账号报告冒充正式账号认证。
本次仅修改本地脚本与规则,不接入节点或访问线上。后续实际使用须先按Git流程生成并部署含新版`verify-node.py`的运维提交,再生成全新计划;旧冻结批次及原验收记录不改。上一批10节点完整联网结果仍按当时已执行范围保留。
## 续跑与原服务调查
preflight/publish/audit始终现场执行。只有先前状态为done、收到真实前检依赖摘要、请求/代码/config未变、证据未过期,才可复用stage/dns/provision/relay/verify。前驱依赖变化使后续缓存失效;running/unknown/conflict绝不跳过。旧适配器不提供这些证据时仍按原逐阶段probe逻辑执行。生产写入仍先probe、持久化意图、apply后probe,不为速度跳过归属检查。
原服务保存最初unit/程序/配置SHA256、监听、active及PID/启动时间/重启数。配置/程序/监听改变或不可用停止;仅进程变化分类为“需调查”,不宣称是DPL导致,也不自动刷新基线。记录观察后由操作者及助手核查生命周期、OOM、操作时间线和稳定采样;认可时在本节点私有备份登记`preserved-service-review.json`,绑定最初/本次观察摘要、原因及证据SHA256。原基线保持;再次重启不匹配旧审核,须重新调查。
隔离stage QA与正式验收租约分开:stage创建的QA为24小时,长时间中断后必须先核对实际有效期,不能用未禁用状态代替有效账号判断。本次曾在独立审查后对仍归本任务、未banned、未推广、原身份/隔离组/额度/设备/无订单全匹配的过期QA作单次1小时续期;仅改expired_at,写前私有journal+事务行锁,重新联网测试并重审,不复用旧报告。已有续期journal再次到期或任一归属变化则停止,不自动顺延或启用关闭账号;正式推广后仍须验证banned/403/实际下发排除。此为受限恢复步骤,不授权后台长期续期。
## 验证范围
新增Python测试覆盖动态1/2/7/23节点、集合变化/漏旧节点、出口/TLS/QA身份/摘要/时效、白色机跳转及严格密钥策略、线程会话复用/断线、依赖续跑及未知/过期状态、原服务精确调查回执、远端脚本与PHP构造。PHP隔离测试使用真实Eloquent/SQLite和ServerService,覆盖自动受限用户、既有管理员复用、真实下发、无效账号、journal恢复、关闭后新租约、归属漂移、插件排除及失败收尾;不加载.env/应用provider/Redis/HTTP。生产停机、失败回退、远端真实会话复用性能及十节点耗时尚未演练,不承诺固定提速倍数。
隔离回归入口(不使用项目默认PHPUnit配置):
```sh
python3 -B -m unittest discover -s tests/node-onboarding -p 'test_*.py' -q
php vendor/bin/phpunit --no-configuration tests/node-onboarding/PanelChangeIsolatedTest.php
php vendor/bin/phpunit --no-configuration tests/node-onboarding/AcceptanceUserIsolatedTest.php
```

View File

@ -1,127 +1,7 @@
# 节点与订阅网关接入 # 节点与订阅网关接入
后续接入的参数与命令唯一入口:[节点接入执行手册](NODE_RUNBOOK.md)。首次初始化与追加节点分别执行,不自动改订阅地址、套餐或用户关联。
首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。
## 2026-10-21 剩余候选三节点接入
| 展示名 | VPS名称 | 资源 | 节点/机器ID | 隔离QA | 中转端口 | 实测出口 |
| --- | --- | --- | --- | --- | --- | --- |
| 🇺🇸 美国 US - 04 | node-us02 | VPS-013 | 21/21 | 46 | 47102 | 107.172.62.233 |
| 🇭🇰 香港 HK - 07 | node-vn01 | VPS-034 | 22/22 | 47 | 47308 | 96.9.228.135 |
| 🇷🇺 俄罗斯 RU - 01 | node-ru01 | VPS-035 | 23/23 | 48 | 47501 | 45.140.169.230 |
用户要求接入剩余四台:us02、vn01、ru01符合组合容量门禁;hk02现场MemTotal为516,222,976 bytes且已安装运行XrayR,按既定规则排除,未部署或改旧服务。us02为1,023,815,680 bytes且已装XrayR,vn01为8,294,055,936 bytes且已装XrayR,ru01为484,175,872 bytes但未装XrayR,后者不命中“小于1GB且已安装”的两条件规则。vn01按用户登记及实测出口归HK,未改服务器key为hk或按vn字面设VN。
us02/ru01使用用户更新的独立SSH凭据成功认证。us02旧主机密钥冲突由用户明确批准接受本次精确新指纹,严格匹配后登录;旧指纹和信任基线保留,批准不等于独立核验服务商密钥来源。连接始终经白色机。旧临时SSH库被清空后本次从官方PyPI恢复隔离Paramiko5.0.0,未改变系统Python或补造旧冻结环境。
三台独立AnyTLS24443,中转5175/5176/5177分别47102/47308/47501,PP收发关闭;旧43条所核字段保持,当前46条。us02旧46102、vn01旧46601的443转发保持;ru01原本没有443转发,任务规则显式old_port=null且对baseline/current所有归属均要求不存在该目标443,不能借用其他旧规则。原7恢复查重测试及新增反例合计12项通过。中转一次SSH握手阶段失败后先只读probe确认候选absent才执行,不盲重放创建。
Namecheap仅必要DNS网页新增dpl-us02/vn01/ru01.gemcook.net三条A;正常登录及设备邮件验证,真人验证由用户手动完成。旧记录逐条比较、刷新保存和两权威NS/AA解析核验通过。其余接入/验收全在服务器侧,不登录DPL网页。
Debian11的Git使用隔离官方索引、发行版必需签名与精确可选共签,11项正反测试及真实gpgv状态复核通过,保留Release/Packages/deb哈希链及固定版本安装。原拒绝是合法Debian12共签不在任务白名单,未关闭签名校验;共签单独不能满足必需签名。us02新增3包、vn01新增9包,旧XrayR程序/配置/进程/443保持。
ru01无旧XrayR,完整保护规格按实际ssh unit、/usr/sbin/sshd、/etc/ssh/sshd_config、22设置,原DNS53与全监听另验收。Ubuntu20.04 Apt Certbot0.40缺必需参数,先装官方Certbot2.11隔离venv,固定兼容pyOpenSSL24.3.0/cryptography44.0.3/josepy1.15.0,依赖导入、pip check及真实插件registry参数解析通过;/usr/local/bin入口与certbot.service定向drop-in使用同一运行时,原certbot.timer enabled/active。原unit/drop-in字节摘要、入口存在性及精确回滚脚本保存;初次依赖前未采SSH unit SHA,后来只读元数据表明unit时间早于依赖,不将后来摘要写成先前采集。ACME正常注册后只读show_account确认,因账户JSON无status导致后置检查失败时未重放注册。vn01旧renewal字节相对昨日基线发生变化:mtime与原Snap timer于今日03:59执行及not_due日志一致,旧证书未签新版本;推断为自然续期元数据更新,因基线仅留hash不能重建精确字段差异,未回写覆盖。us02旧Snap续期服务本批前已failed,本次保留旧renewal,不声称修复;新三证书专用续期dry-run均通过。
三台隔离完整订阅联网、严格TLS/SNI、准确US/HK/RU出口及在线/上下行上报由唯一助手独立复核双端原始字节、请求/身份/时效后才开放;QA均已禁用,订阅403且下发排除。正式动态23节点完整结构/密码/权限与逐节点严格TLS通过,不代表23条重跑完整客户端出口/流量。旧节点、套餐、正式用户关联、订阅设置、应用版本及四容器基线保持。
当前有效登记24台,已接23台,未接的hk02为本次明确不符合条件的排除项,不作为等待接入的合格候选。证据codex/output/node-batch-remaining-20261020/(跨日恢复,实际完成2026-10-21),共享账本node-batch-five-20261017/ledger/remaining-20261020。服务器备份/root/codex-backups/dpl/node-batch-remaining-20261020、网关/var/lib/dpl-sgw/node-batch-remaining-20261020;回退按各promotion-journal逐字段预览,不恢复整库。GemDocs资源及hk02排除事实同步,本批15份资源与20份派生视图已更新;全库build/check仍报告无关fnb项目SEO.md失效链接,未更改该项目或声称全库通过。已完成服务器侧验收,未做线上页面验收;手机/TUN、多设备并发和生产回退演练未做。
## 2026-10-20 香港第二批HK04至06接入
| 展示名 | VPS名称 | 资源 | 节点/机器ID | 隔离QA | 中转端口 | 实测HK出口 |
| --- | --- | --- | --- | --- | --- | --- |
| 🇭🇰 香港 HK - 04 | node-hk01 | VPS-023 | 19/19 | 37 | 47301 | 192.131.142.21 |
| 🇭🇰 香港 HK - 05 | node-hk03 | VPS-025 | 20/20 | 38 | 47303 | 192.131.142.220 |
| 🇭🇰 香港 HK - 06 | node-hk07 | VPS-029 | 18/18 | 36 | 47307 | 85.113.71.90 |
三台容量/XrayR门禁通过,hk01实际Debian10、hk03/hk07 Debian11,原服务保留,新AnyTLS独立24443。中转规则5172/5173/5174对应47301/47303/47307,Proxy Protocol收发关闭;白色机接口精确新增/回读,原40条所核字段保持,总数43。新SNI为dpl-hk01/hk03/hk07.gemcook.net;仅Namecheap DNS新增使用必要网页,旧记录保持,两台权威NS回读通过。
本批发现hk01实际运行配置为/usr/local/XrayR/config.yml,另外两台为/etc/XrayR/config.yml,未搬动/复制/修改旧配置。源码66f3044增加按候选VPS覆盖保护规格,51项隔离回归通过并已推送;本批冻结preserved_service_overrides仅指定VPS023真实config。两台Git准备因附加签名缺钥导致gpgv整体exit2而暂停;已有官方发行VALIDSIG已独立核实,任务验证器逐签名拒绝坏/过期/撤销/不明状态,8项正反测试通过,保留Release/Packages/逐包SHA与元数据核验及固定版本安装,未关闭APT签名或ValidUntil。下载曾卡在官方CDN的IPv6 SYN连接,断链后两轮任务进程仍存活;独立核对PID/启动时刻/父命令精确源码并确认无apt/dpkg后仅TERM这4个任务进程,单次以IPv4 HTTPS及原签名哈希验证完成依赖。初轮部署两机在证书已签发、enable前断链,现场确认无残留命令/owner匹配后按原冻结probe恢复,以阶段边界SSH保活继续,未重签或手写成功凭据。后续只读回读再次遇到SSH会话丢失,先独立确认三台真实部署与原服务状态,未盲重放写入;白色机无重启或MaxStartups丢弃证据,根因未确认。最终采用单并发持续会话和阶段保活,旧QA原始字节/审核归档保留,三台均重新实际联网并获新独立审核,再在同一会话完成开放、动态审计与账号收尾。
三个隔离QA完整订阅联网、严格TLS/SNI、独立HK预期出口及在线/双向上报通过,由一个子助手独立核对原始字节、身份、摘要和时效后开放到组1。QA已禁用,实际订阅403并从下发用户排除。正式动态20节点完整订阅结构/密码/权限及逐条严格TLS通过;本批仅独立追加且无共享配置变化,正式范围tls,不代表20条重新测试客户端出口。旧节点、套餐、正式用户关联、订阅配置、应用版本和四网关容器基线保持。原XrayR进程/配置/程序/443及旧renewal配置经独立终审保持,新证书专用续期dry-run通过。hk01旧renew.service在开工前已failed,旧日志为ACME connection refused,未证实AppArmor问题;本次不修复或宣称旧证书续期成功。
当前有效登记24台、已接20台、剩4台未接:us02、hk02、vn01、ru01。us02有历史SSH密钥冲突;hk02/ru01登记512MB但尚未现场确认XrayR,不能仅凭登记断言最终排除;vn01按用户登记属HK,未改服务器代号。
证据codex/output/node-batch-hk01-hk03-hk07-20261020/;冻结计划与共享账本node-batch-five-20261017/ledger/hk01-hk03-hk07-20261020,不改旧批次摘要。服务器备份/root/codex-backups/dpl/node-batch-hk01-hk03-hk07-20261020,网关/var/lib/dpl-sgw/node-batch-hk01-hk03-hk07-20261020;回退按各promotion-journal逐字段预览,不恢复整库。已完成服务器侧验收,未做线上页面验收;手机客户端/TUN、多设备并发和生产回退演练未做。GemDocs本批实例/域名/中转规则与关联已同步,离线校验通过:12项目、435文档、3515链接、390资源对象及11岗位/13科室绑定;不代替线上业务验收,不自动更新人类版。
## 2026-10-20 三个香港节点服务器侧接入
| 展示名 | VPS名称 | 资源 | 节点/机器ID | 隔离QA | 中转端口 | 实测HK出口 |
| --- | --- | --- | --- | --- | --- | --- |
| 🇭🇰 香港 HK - 01 | node-hk04 | VPS-026 | 17/17 | 35 | 47304 | 45.128.220.145 |
| 🇭🇰 香港 HK - 02 | node-hk05 | VPS-027 | 16/16 | 34 | 47305 | 45.128.220.146 |
| 🇭🇰 香港 HK - 03 | node-hk06 | VPS-028 | 15/15 | 33 | 47306 | 85.113.71.89 |
三机均通过物理内存/XrayR容量门禁,hk04为Debian12,hk05/hk06为Debian11。经白色机以固定Git运维64c3b087部署,独立AnyTLS监听24443,TLS使用dpl-hk04/hk05/hk06.gemcook.net;中转规则5169/5170/5171对应47304/47305/47306,Proxy Protocol收发关闭。服务器侧正常接口查重、新增和精确回读,旧37条所核字段保持,总数40;原443转发不改。Namecheap无已登记DNS API凭据,只有DNS新增使用必要网页,保存后旧记录对比保持,两台权威NS验证新A解析。
三节点分别完成隔离有效订阅、严格TLS/SNI、独立预期HK出口、在线及双向流量上报;一个子助手核对原始字节摘要、请求和QA身份及时效后才开放到基础组1。QA随后禁用,实际订阅403并从下发用户排除。正式账号动态应得17节点,全量订阅结构/密码/权限和逐条严格TLS通过;本批无共享配置变化,正式范围为tls,没有重新测试17条客户端出口。原节点/套餐/正式用户关联/订阅配置及四个网关容器保持;生产应用版本及原工作区摘要保持。三机原XrayR程序/unit/配置/443进程及旧renewal配置经独立终审保持,新证书专用续期dry-run通过。
本次有效登记node服务器24台,接入由14增至17,剩7台未接:us02、hk01、hk02、hk03、hk07、vn01、ru01。此为登记差集,不代表7台均符合接入条件;us02有历史SSH密钥冲突,hk02/ru01登记512MB但尚未现场确认XrayR。已退役sg01不计入候选,vn01按用户登记地区不凭名称改类。
证据:codex/output/node-batch-hk04-hk06-20261020/;冻结计划/共享账本node-batch-five-20261017/ledger/hk04-hk06-20261020。服务器备份/root/codex-backups/dpl/node-batch-hk04-hk06-20261020,网关验收/var/lib/dpl-sgw/node-batch-hk04-hk06-20261020;回退沿各promotion-journal逐字段预览,不恢复整库。已完成服务器侧验收,未做线上页面验收;手机客户端/TUN、多设备并发及生产回退演练未做。GemDocs实例/域名/中转规则及关联已同步,离线检查通过(425文档、3336链接、380对象);不自动更新人类版。
## 2026-10-20 单节点服务器侧接入 sg05
新增展示新加坡 SG - 04,部署key/VPS为sg05/node-sg05(VPS-033),节点/机器ID为14/14,基础组1。现场MemTotal 8,294,055,936 bytes且已安装XrayR,容量门禁通过;Debian11/x86_64、SG出口45.128.222.176,独立AnyTLS 24443。SNI dpl-sg05.gemcook.net,中转规则5168(dpl-sg05-anytls)为haiwi.lshan.eu.org:47405 ->45.128.222.176:24443,Proxy Protocol收发关闭。
本次DPL数据经白色机SSH运行正式stage/promote及应用模型/事务写入,未登录DPL或进行线上页面验收。中转首次改为白色机正常登录站点自身接口、list查重、create新增再逐字段回读,原36条所核配置保持,总数37;无中转网页操作,认证令牌仅进程内存。Namecheap缺少已登记DNS API凭据,仅DNS新增使用必要网页,新增后旧记录逐项保持;再由白色机核对当前NS及两台权威A应答。未自动开通API权限。
隔离QA32完整订阅联网、严格TLS/SNI、独立预期SG出口、在线和双向上报通过;单节点由操作者按原始字节SHA256、请求、精确身份及900秒时效复核,无独立子助手。正式append后QA已禁用、订阅403并从实际下发用户排除。正式有效既有账号动态应得14节点,全量订阅结构、密码、权限及逐条严格TLS通过;本批无共享配置变化采用tls范围,没有重复14条完整出口联网。旧节点关键参数、套餐、正式用户关联、订阅地址和四个网关容器ID/启动时间/重启计数符合原基线。
原XrayR程序/unit/配置/443监听、PID/启动时间/重启数及旧证书/私钥/renewal摘要保持。新证书有效至Jan 18 08:39:40 2027 GMT,单个精确专用renew_hook、无全局/目录执行钩子,续期dry-run通过。缺少Git时用独立APT索引、Debian签名与精确包摘要校验安装9项固定依赖,未改全局源或升级旧XrayR。首次前检因观察文件权限不符停止,收紧600后现场probe通过,未绕过保护。
复用已推送运维64c3b087部署到新节点,面板/网关沿用同版本;生产应用7e8fbf6及原工作区摘要保持。服务器侧优先规则852b38c、9d153c7已推送,接口合同和DNS例外唯一正文见[NODE_BATCH_RUNTIME](NODE_BATCH_RUNTIME.md#服务器侧接入与验收)。证据:codex/output/node-single-sg05-20261020/;冻结计划和共享账本node-batch-five-20261017/ledger/sg05-20261020不改旧批次。面板/节点备份/root/codex-backups/dpl/node-single-sg05-20261020,网关/var/lib/dpl-sgw/node-single-sg05-20261020;回退沿promotion-journal逐字段预览,不恢复整库。未做页面、手机客户端/TUN、多设备并发或生产回退演练。GemDocs AI版已同步本实例/TLS域名/中转规则5168与项目、服务器、父域关系,离线结构检查通过;人类版未自动写入。
## 2026-10-20 三个新加坡节点批量接入
新增展示新加坡 SG - 01/02/03,分别部署于node-sg02/03/04(VPS-030/031/032),数据库节点/机器ID为11/13/12。三台MemTotal均8,294,055,936 bytes,已有XrayR,符合本批新增候选门禁:总内存小于1,000,000,000 bytes且已安装XrayR才排除;未知不放行。该规则不追溯移除已有节点。
新服务独立AnyTLS 24443;新部署TLS名称dpl-sg02/03/04.gemcook.net按全局域名规则使用gemcook.net,由Namecheap BasicDNS新增DNS only A记录,保留旧节点域名。中转新增dpl-sg02/03/04-anytls,haiwi.lshan.eu.org:47402/47403/47404分别转发45.128.222.173/174/175:24443,收发Proxy Protocol关闭;原443规则保持。
三个隔离QA29/31/30逐台完整订阅联网、严格TLS/SNI、SG独立预期出口、在线及双向上报通过,一个助手独立核对面板/网关原始报告字节、精确QA身份及有效期后批准;主协调统一append至基础组1。三个QA均已禁用、订阅403且在实际下发集合中排除。正式验收动态读取既有有效账号应得13节点(美国3、日本7、新加坡3),全量订阅结构、密码、权限及逐条严格TLS通过;本批独立append已评审为无共享变化,正式阶段使用tls范围,未重复13条完整出口联网,不能把此结果写成13节点全live。旧节点关键配置、全部套餐、正式用户关联及订阅地址、四个网关容器ID/启动时间/重启计数均与原基线一致。
源码d331f45已提交推送,50项Python离线测试通过;独立运维64c3b087已推送并以Git bundle精确部署到面板/网关运维目录及三新节点。生产应用7e8fbf6与原工作区摘要保持原值,未发布应用代码。本批Debian11官方索引引用的安全包池返回404,改用官方HTTPS归档取得相同版本包,仍验证Debian签名索引、Packages SHA256及每包版本/架构/大小/SHA256,再离线安装固定Git依赖;不关闭签名或有效期检查、不改全局APT源、不全量升级。
证据入口:codex/output/node-batch-three-20261020/,冻结计划frozen.json不变;共享查重账本在node-batch-five-20261017/ledger/sg02-sg04-20261020。面板和节点备份/root/codex-backups/dpl/node-batch-three-20261020,网关验收/var/lib/dpl-sgw/node-batch-three-20261020;推广回退按各promotion-journal逐字段预览,不恢复整库。用户本次仅批准Git推送,DPL管理页面验收明确暂不做;未执行本机/手机GUI、TUN或多设备并发验收。
三台证书有效期均至2027-01-18 UTC(精确时分秒见final-metadata.json)。各自单条精确专用renew_hook、无全局或目录执行钩子、专用续期dry-run成功;原XrayR程序/unit/配置/443监听/PID/启动时间/重启计数保持。VPS-032额外xray也逐字段独立核对保留。任务完成器复用冻结Coordinator.phase,先全批publish再统一audit,未改原计划或跳过证据时效。GemDocs AI版已同步三实例、三域名和服务器/项目/中转关联,离线结构检查通过;人类版未自动写入。
## 2026-10-18 后续五节点批量接入
新增日本 JP - 05/06/07(node-jp06/07/08)和美国 US - 02/03(node-us03/04),展示编号独立于VPS、部署key和数据库ID。均以独立AnyTLS 24443、DNS only节点证书名和专用中转规则接入;只把新节点append到基础节点组1,未改旧443中转。node-us02因既有SSH信任密钥不符停止且未发送目标凭据,按用户批准备用node-us04精确指纹接入。
五台均先在各自隔离组完成完整订阅、严格TLS/SNI、预期IPv4/国家、在线及上下行上报测试;一个助手独立复核面板与网关原始报告字节摘要及精确QA身份后,主协调者按append预览/应用开放。本批QA21/23/22/24/25分别属于jp06/jp07/jp08/us03/us04;节点/机器ID为6/8/7/9/10。等待超过24小时使QA到期后,事务内核对归属、未禁用、隔离、无订单及100MiB额度,仅续原账号1小时;没有重置身份或复活已禁用用户,旧报告全部重测并重新审核。
正式验收动态读取有效既有管理员实际授权集合,本次为美国01至03、日本01至07共10节点,逐条完整订阅联网、严格TLS及独立预期出口/国家一致。五个QA均已禁用,订阅403且不在节点实际用户下发集合;未为正式验收新增用户或更改管理员权限。全部旧节点关键配置、套餐、原用户组/套餐关联和订阅地址均符合原基线;四个网关容器ID、启动时间、重启数保持原值。管理后台节点/服务器列表切换、名称、端口、正式权限组及在线状态已回读;未额外保存编辑表单默认值。未进行本机/手机客户端GUI、TUN、多地区、多设备并发或生产回退演练。
5台节点的证书签发和专用续期dry-run均通过。两台美国节点既有Snap Certbot AppArmor加载缺口已用独立运维提交ebe12d3修复,目录/loader/启动及续期unit均保持生成profiles原模式;包含当前core Snap helper,不启用全局AppArmor服务、不关闭校验。原XrayR程序、unit、配置及443监听保持;日本3台在等待/验收前检期间发生既有xray-core v1.8.20 QUIC sniffer越界panic并自动恢复,逐台日志、无OOM及稳定采样独立审查后登记精确原/现摘要回执,原基线保留。此次没有修复或升级旧XrayR。
本地源码6b0ac99及独立运维2547ea25、Certbot安全修复ebe12d3均已提交推送;面板/网关和5台节点仅部署运维目录,DPL生产应用仍为7e8fbf6,原工作区摘要未变。Python34项回归及既有PHP隔离19项103断言已通过(具体执行日期以原测试记录为准);本轮恢复另对受限修复、取证与文档脚本做语法及独立审查,真实业务结果以本节现场验收为准。
本批曾因发布probe状态未知停止,经独立现场回读确认3台已推广、2台仍隔离,未盲重放;后两台重新测试并审核。收尾时另发生新SSH握手失败,白色机聚合日志无足够证据确定原因。最终使用独立审核的任务完成控制器(SHA256见任务finish-controller-reviewed.sha256),单工作线程、已认证连接保活及工作线程内清理,仍调用冻结Coordinator.phase;全批publish屏障后统一动态audit。未修改冻结适配器源码或跳过现场关卡,不把此任务控制器写成通用调度器已自动采用的策略。
收尾发现Certbot 5.8把专用命令持久化为`renew_hook`。本批已另行只读核对五台实际配置:均为单个精确专用命令,两个全局配置无执行钩子,普通/Snap目录钩子为空。随后本地适配器补齐`renew_hook`/`deploy_hook`及连字符别名、重复/未知钩子与目录钩子检查,回归测试覆盖实际生成的远端检查程序,收尾完整Python 39项测试通过。该修复用于后续新计划;本批冻结运行源码已按摘要归档于`frozen-runtime/`,原计划、账本及pin未重写,也未用新代码重放已完成批次。
证据入口:忽略目录codex/output/node-batch-five-20261017/。原frozen.json及旧账本摘要不改;GemDocs迁移仅更换SSH凭据引用根后,新resume.json保持context/nodes/owner不变并固定新runtime pin,恢复账本为ledger/jp06-jp08-us03-us04-20261018-resume。最终formal-group-1.json、final-proof原始报告/QA拒绝/集合/绑定、final-metadata.json及后台截图共同保存验收。面板和节点备份仍为/root/codex-backups/dpl/node-batch-five-20261017,网关验收为/var/lib/dpl-sgw/node-batch-five-20261017;回退按各节点promotion-journal逐字段预览,不恢复整库或覆盖共享资源。GemDocs AI版新增5实例/5域名及相关服务器、项目引用已同步;人类版未自动写入。
## 2026-10-17 三个日本节点批量接入
按用户授权新增日本 JP - 02/03/04,分别部署于 node-jp03/04/05;展示编号、VPS、部署key和数据库ID独立。三台均为本次登录核实的 Debian11/x86_64、JP IPv4出口;新建独立 AnyTLS 24443、DNS only A记录及中转规则 dpl-jp03/04/05-anytls,入口47203/47204/47205,总规则25→28。收发Proxy Protocol均关闭,未改原443规则。
三个隔离QA分别实测严格TLS/SNI、实际IPv4出口与国家、双向流量及在线心跳,经一个核查验收助手逐报告摘要复核后,主协调者以append预览/应用,只将新节点加入基础节点组1并禁用本次QA。QA账号19/20/18分别属于jp03/04/05;节点/机器ID分别4/5/3,不能据数据库ID推断展示顺序。
本次使用既有正式组有效管理员账号ID1,确认其在五节点实际下发集合中;未为正式验收另建账号或更改该账号权限/密码,不代表普通付费用户、购买或套餐流程验收。正式有效订阅实取后包含美国01和日本01至04共五个AnyTLS出站,官方sing-box 1.14.3逐一实际联网;五个端点均严格TLS,出口/国家分别与节点登记相符,完整订阅DNS/路由校验通过。三个QA订阅均403且不在节点实际用户下发集合;既有全部套餐、正式用户组/套餐关联、订阅地址和旧两节点关键配置逐字段符合基线。四个新旧网关容器ID、启动时间及重启计数保持原值;未部署生产应用代码、改价格/套餐或重启共享网关。
三张独立证书有效期至2027-01-14 UTC,沿用既有Snap Certbot调度,独立renewal与仅重启dpl-node的deploy hook;逐台续期dry-run成功,原XrayR 443保留。例外:jp03旧XrayR在DPL尚未安装时因既有QUIC sniffer越界panic,经systemd自动恢复(198435→215765);前检自动停止后保留原基线/哈希、事故日志、两次稳定采样和diff,经独立复核仅更新任务进程检查字段。其余两台旧PID保持。未修改旧XrayR配置、程序或服务;无原配置哈希,不宣称前后哈希一致。不能把此次结果写成三台原PID全程不变。
源码6af6764已提交并推送;独立运维提交b8d10fac963d63825c97d36d41ff7f525116bbfd已推送codex/node-batch-20261017并部署到面板/网关/三台新节点。PHP语法检查、9项/65断言面板隔离测试与11项批量进程测试通过;真实通知/用户下发由本次在线和实际用户集合补充验证,未演练生产故障回退。
唯一任务计划与账本:codex/output/node-batch-20261017/batch.json、ledger/jp03-jp04-jp05-20261017-reviewed;证据包括*-evidence.json、formal-evidence.json、final-metadata.json和后台截图,均不入Git。远端面板/节点私有备份为/root/codex-backups/dpl/node-onboarding-20261017-jp03/04/05;面板全批旧资源基线在/root/codex-backups/dpl/node-batch-20261017;网关正式验收在/var/lib/dpl-sgw/qa-batch-20261017。面板回退按各自promotion-journal逐字段预览,不恢复整库;DNS/中转/节点服务分别专项回退。
自动audit会检查三条新端点集合、报告摘要和QA403;冻结任务适配器未自动逐一核对旧两节点结果、每个出口IP及QA拒绝文件摘要/身份/时间,须由主协调与助手独立对照完整五节点实测报告及原始文件补齐,本次按此执行。三节点账本已全部complete。管理面板浏览器已验证节点/服务器列表切换、五台在线及保存后的名称、端口、基础节点组显示,未额外提交编辑表单默认值。未进行本机/手机客户端GUI、TUN、多地区或多设备并发测试;三节点调度验证不等于十节点耗时承诺。
## 2026-10-16 日本节点接入 ## 2026-10-16 日本节点接入
本轮选择 `node-jp02`;现场确认 IPv4 出口地区 JP、24443 空闲、无既有 DPL 服务,生产原来只有美国节点,故未重复接入。新节点运行接入及正式开放完成,管理面板浏览器验收已通过;下文首节点历史验收范围不自动扩大到本轮。 本轮选择 `node-jp02`;现场确认 IPv4 出口地区 JP、24443 空闲、无既有 DPL 服务,生产原来只有美国节点,故未重复接入。新节点运行接入及正式开放完成,管理面板浏览器验收已通过;下文首节点历史验收范围不自动扩大到本轮。
@ -143,62 +23,12 @@ ru01无旧XrayR,完整保护规格按实际ssh unit、/usr/sbin/sshd、/etc/ss
订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare 橙云 HTTPS)→ gateway-01 宿主 Nginx → dpl 独立 SubSieve → dpl 面板。 订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare 橙云 HTTPS)→ gateway-01 宿主 Nginx → dpl 独立 SubSieve → dpl 面板。
流量链路按部署key分列;旧十节点2026-10-18完整联网,本日三节点批次及sg05单节点均隔离完整联网;sg05追加后正式14节点动态集合及严格TLS复核通过: 流量:客户端 → haiwi.lshan.eu.org:47101 → node-us01 64.44.115.18:24443 → 目标网络。AnyTLS 使用 us01.deeplink-subscribe.com 的有效证书,订阅要求严格校验。普通 Cloudflare 橙云只用于订阅 HTTP,不用于原生 AnyTLS 流量。
| 部署 key | VPS(不代表展示编号) | 中转入口 | 落地 | TLS 名称 | 证据 | | 资源 | 本次新增 | 必须保留的原有资源 |
| --- | --- | --- | --- | --- | --- |
| us01 | node-us01 | haiwi.lshan.eu.org:47101 | 64.44.115.18:24443 | us01.deeplink-subscribe.com | 2026-10-11 首节点验收 |
| jp02 | node-jp02 | haiwi.lshan.eu.org:47202 | 193.111.31.52:24443 | jp02.deeplink-subscribe.com | 本文2026-10-16日本节点接入 |
| jp03 | node-jp03 | haiwi.lshan.eu.org:47203 | 185.200.65.70:24443 | jp03.deeplink-subscribe.com | 本文2026-10-17批量接入 |
| jp04 | node-jp04 | haiwi.lshan.eu.org:47204 | 85.113.70.174:24443 | jp04.deeplink-subscribe.com | 本文2026-10-17批量接入 |
| jp05 | node-jp05 | haiwi.lshan.eu.org:47205 | 45.153.246.171:24443 | jp05.deeplink-subscribe.com | 本文2026-10-17批量接入 |
| jp06 | node-jp06 | haiwi.lshan.eu.org:47206 | 45.153.246.172:24443 | jp06.deeplink-subscribe.com | 本文2026-10-18批量接入 |
| jp07 | node-jp07 | haiwi.lshan.eu.org:47207 | 45.153.246.173:24443 | jp07.deeplink-subscribe.com | 本文2026-10-18批量接入 |
| jp08 | node-jp08 | haiwi.lshan.eu.org:47208 | 172.93.221.4:24443 | jp08.deeplink-subscribe.com | 本文2026-10-18批量接入 |
| us03 | node-us03 | haiwi.lshan.eu.org:47103 | 107.175.24.202:24443 | us03.deeplink-subscribe.com | 本文2026-10-18批量接入 |
| us04 | node-us04 | haiwi.lshan.eu.org:47104 | 23.94.87.178:24443 | us04.deeplink-subscribe.com | 本文2026-10-18批量接入 |
| sg02 | node-sg02 | haiwi.lshan.eu.org:47402 | 45.128.222.173:24443 | dpl-sg02.gemcook.net | 本文2026-10-20批量接入 |
| sg03 | node-sg03 | haiwi.lshan.eu.org:47403 | 45.128.222.174:24443 | dpl-sg03.gemcook.net | 本文2026-10-20批量接入 |
| sg04 | node-sg04 | haiwi.lshan.eu.org:47404 | 45.128.222.175:24443 | dpl-sg04.gemcook.net | 本文2026-10-20批量接入 |
| sg05 | node-sg05 | haiwi.lshan.eu.org:47405 | 45.128.222.176:24443 | dpl-sg05.gemcook.net | 本文2026-10-20单节点接入 |
| hk04 | node-hk04 | haiwi.lshan.eu.org:47304 | 45.128.220.145:24443 | dpl-hk04.gemcook.net | 本文2026-10-20香港批次 |
| hk05 | node-hk05 | haiwi.lshan.eu.org:47305 | 45.128.220.146:24443 | dpl-hk05.gemcook.net | 本文2026-10-20香港批次 |
| hk06 | node-hk06 | haiwi.lshan.eu.org:47306 | 85.113.71.89:24443 | dpl-hk06.gemcook.net | 本文2026-10-20香港批次 |
| hk01 | node-hk01 | haiwi.lshan.eu.org:47301 | 192.131.142.21:24443 | dpl-hk01.gemcook.net | 本文2026-10-20香港第二批 |
| hk03 | node-hk03 | haiwi.lshan.eu.org:47303 | 192.131.142.220:24443 | dpl-hk03.gemcook.net | 本文2026-10-20香港第二批 |
| hk07 | node-hk07 | haiwi.lshan.eu.org:47307 | 85.113.71.90:24443 | dpl-hk07.gemcook.net | 本文2026-10-20香港第二批 |
| us02 | node-us02 | haiwi.lshan.eu.org:47102 | 107.172.62.233:24443 | dpl-us02.gemcook.net | 本文2026-10-21剩余候选批次 |
| vn01 | node-vn01 | haiwi.lshan.eu.org:47308 | 96.9.228.135:24443 | dpl-vn01.gemcook.net | 本文2026-10-21剩余候选批次 |
| ru01 | node-ru01 | haiwi.lshan.eu.org:47501 | 45.140.169.230:24443 | dpl-ru01.gemcook.net | 本文2026-10-21剩余候选批次 |
客户端 → 对应中转入口 → 对应落地 → 目标网络。以上链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。
| 资源 | DPL独立资源(各次接入) | 必须保留的原有资源 |
| --- | --- | --- | | --- | --- | --- |
| node-us01 | dpl-node,TCP 24443,机器/节点 ID 1(2026-10-11) | XrayR,TCP 443 | | node-us01 | dpl-node,TCP 24443,机器/节点 ID 1 | XrayR,TCP 443 |
| node-jp02 | dpl-node,TCP 24443,机器/节点 ID 2(2026-10-16) | XrayR,TCP 443;原中转 46202 → 443 | | 中转 | dpl-us01-anytls,47101 → 24443 | us01,46101 → 443;其他规则 |
| node-jp03 | dpl-node,TCP 24443,机器/节点 ID 4(2026-10-17) | XrayR,TCP 443;自身panic恢复例外见本次记录 |
| node-jp04 | dpl-node,TCP 24443,机器/节点 ID 5(2026-10-17) | XrayR,TCP 443 |
| node-jp05 | dpl-node,TCP 24443,机器/节点 ID 3(2026-10-17) | XrayR,TCP 443 |
| node-jp06 | dpl-node,TCP 24443,机器/节点 ID 6(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 |
| node-jp07 | dpl-node,TCP 24443,机器/节点 ID 8(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 |
| node-jp08 | dpl-node,TCP 24443,机器/节点 ID 7(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 |
| node-us03 | dpl-node,TCP 24443,机器/节点 ID 9(2026-10-18) | XrayR,TCP 443 |
| node-us04 | dpl-node,TCP 24443,机器/节点 ID 10(2026-10-18) | XrayR,TCP 443 |
| node-sg02 | dpl-node,TCP 24443,机器/节点 ID 11(2026-10-20) | XrayR,TCP 443 |
| node-sg03 | dpl-node,TCP 24443,机器/节点 ID 13(2026-10-20) | XrayR,TCP 443 |
| node-sg04 | dpl-node,TCP 24443,机器/节点 ID 12(2026-10-20) | XrayR,TCP 443;额外xray也保留 |
| node-sg05 | dpl-node,TCP 24443,机器/节点 ID 14(2026-10-20) | XrayR,TCP 443;旧证书保持 |
| node-hk04 | dpl-node,TCP 24443,机器/节点 ID 17/17(2026-10-20) | XrayR,TCP 443保留 |
| node-hk05 | dpl-node,TCP 24443,机器/节点 ID 16/16(2026-10-20) | XrayR,TCP 443保留 |
| node-hk06 | dpl-node,TCP 24443,机器/节点 ID 15/15(2026-10-20) | XrayR,TCP 443保留 |
| node-hk01 | dpl-node,TCP 24443,机器/节点 ID 19/19(2026-10-20) | 原XrayR 443保留 |
| node-hk03 | dpl-node,TCP 24443,机器/节点 ID 20/20(2026-10-20) | 原XrayR 443保留 |
| node-hk07 | dpl-node,TCP 24443,机器/节点 ID 18/18(2026-10-20) | 原XrayR 443保留 |
| node-us02 | dpl-node,TCP 24443,机器/节点 ID 21/21(2026-10-21) | 原XrayR 443保持 |
| node-vn01 | dpl-node,TCP 24443,机器/节点 ID 22/22(2026-10-21) | 原XrayR 443保持 |
| node-ru01 | dpl-node,TCP 24443,机器/节点 ID 23/23(2026-10-21) | 原SSH22/DNS53保持,开工无XrayR/443 |
| 中转 | 23条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 |
| gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin | | gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin |
| 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 | | 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 |
| Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs | | Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs |
@ -210,7 +40,7 @@ ru01无旧XrayR,完整保护规格按实际ssh unit、/usr/sbin/sshd、/etc/ss
## DNS、真实 IP 和证书 ## DNS、真实 IP 和证书
2026-10-11首节点记录:Namecheap 已委托至 bradley.ns.cloudflare.com、jewel.ns.cloudflare.com。导入的原有记录保留。c2 指向网关并启用代理;us01 指向节点,DNS only。两张源站证书有效期至 2027-01-09,公网订阅 HTTPS 可用。后续日本节点独立DNS与证书见各次接入记录。 Namecheap 已委托至 bradley.ns.cloudflare.com、jewel.ns.cloudflare.com。导入的原有记录保留。c2 指向网关并启用代理;us01 指向节点,DNS only。两张源站证书有效期至 2027-01-09,公网订阅 HTTPS 可用。
2026-10-11 本聊天通过正常浏览器访问直接复核:dpl 区域 SSL/TLS 为 Full (strict)。初次只读审查没有配置/源站/请求或响应标头转换/URL 改写/重定向规则,旧式 Page Rules 为零。后续按具体授权新增订阅路径 BIC 配置规则,未设置 SSL,严格模式保持。 2026-10-11 本聊天通过正常浏览器访问直接复核:dpl 区域 SSL/TLS 为 Full (strict)。初次只读审查没有配置/源站/请求或响应标头转换/URL 改写/重定向规则,旧式 Page Rules 为零。后续按具体授权新增订阅路径 BIC 配置规则,未设置 SSL,严格模式保持。
@ -260,20 +90,70 @@ VPS 代号、数据库节点/机器 ID、部署 key 与展示编号各自独立
## 新节点参数单 ## 新节点参数单
参数、冲突检查与模式选择统一见[执行手册](NODE_RUNBOOK.md#统一参数单与冲突检查),不沿用首节点示例作为后续默认值。命名仍以本文前一节为准。 | 参数 | 首次实例 | 后续填写规则 |
| --- | --- | --- |
| key | us01 | 唯一、稳定的部署标识;不用于推导展示序号 |
| name | 🇺🇸 美国 US - 01 | 按[节点命名与编号](#节点命名与编号)从生产清单分配,按国家从01递增,不沿用VPS序号 |
| server | node-us01 | 从 CoDocs 项目关系及连接资料确认 |
| listen-port | 24443 | 当场核对 TCP/UDP 和防火墙,不复用过期判断 |
| relay-host / relay-port | haiwi.lshan.eu.org / 47101 | 新增规则,不改原规则 |
| tls-domain | us01.deeplink-subscribe.com | DNS only,有效证书 |
| group / plans | 首次隔离组;套餐 1、6、7、8 | 当前真实 ID 和授权集合 |
| backup | /root/codex-backups/dpl/node-onboarding-20261011-us01 | 每节点独立时间/key 目录 |
## 重复执行步骤 ## 重复执行步骤
按[统一接入顺序](NODE_RUNBOOK.md#统一接入顺序)执行;先区分首次 initialize 与已有组 append。promote.php必须显式mode;append只修改新节点及本次QA,默认预览、apply才写入。2026-10-17已通过隔离测试并部署独立运维快照,具体版本与现场范围见[STATUS](STATUS.md);不能用未知版本旧脚本替代。 1. 读取根 AGENTS、维护入口和本文。经 CoDocs 白色机中转访问服务器;本地网页使用指定访问域名。记录原服务 PID/启动时间/端口、中转规则、Docker ID/启动时间、原订阅结果;审计资源、防火墙、所有证书钩子。受保护备份本次将修改的配置和 Git 差异,不输出私密内容。
2. 非敏感脚本和模板先 Git 提交/推送,再更新服务器到明确提交,保留生产原有差异。无法直接拉取时可传输已推送提交的 Git bundle 后 fetch/fast-forward,不能用压缩包覆盖。scripts/node-onboarding 可 subtree split 为独立运维仓库;面板 .env 不送到节点。
3. 在生产应用根目录按参数单运行 stage.php,创建机器、AnyTLS 节点、隔离组和 100MB/一天的临时用户。节点虽启用显示,正式用户没有隔离组权限。相同 manifest 重跑只报告已创建;同名资源存在却缺少 manifest 时停止,不自动接管。
依赖检查在创建面板资源前完成;证书演练、完整订阅验收、精确回退与现有脚本限制均在执行手册维护,不在多处复制命令。面板正式开放/精确回退与多节点验证入口的测试、部署及现场范围见执行手册和STATUS。远程服务/DNS/中转仍按专项恢复。 ```sh
php scripts/node-onboarding/stage.php \
--key=us01 --name='🇺🇸 美国 US - 01' \
--relay-host=haiwi.lshan.eu.org --relay-port=47101 \
--listen-port=24443 --tls-domain=us01.deeplink-subscribe.com \
--backup=/root/codex-backups/dpl/node-onboarding-20261011-us01
```
4. 将受保护备份的 node-config.yml、node-secret.env 经白机安装到节点 /etc/dpl-node/config.yml、secret.env,均 600;运维仓库 checkout 明确 Git 版本到 /opt/dpl-node/provision。首次安装使用下面命令。脚本检查参数、配置、端口、已有后端及 XrayR 状态,校验官方二进制并安装独立 unit,不自动启动。已有 dpl-node 时不能重跑覆盖;升级须单独复核。
```sh
bash /opt/dpl-node/provision/install-node.sh \
--listen-port=24443 --tls-domain=us01.deeplink-subscribe.com
```
5. 配置 DNS only、有效证书并审计/安装仅匹配新域名的续期钩子,再启用 dpl-node。在中转后台只新增 dpl-<key>-anytls,核对保存后的入口/目标和诊断结果。本次未启用收发 Proxy Protocol,以后启用前须同时确认入口和后端支持。
6. 后续 dpl 节点复用 c2 及当前网关,不再次更换 NS。另一个机场需要不同上游时才新增独立实例;不得改已有上游、运行原项目 setup/update/down 或复用旧卷。
7. 在网关创建本次验收目录(700),以 600 保存 stage.php 生成的 QA 私密文件和实际订阅;准备官方客户端并校验官方发布摘要。首轮已校验的 Linux 1.14.3 客户端保留于 /opt/dpl-gateway/acceptance/,下述命令可复用它。拉取临时用户完整订阅到私密文件;sing-box 使用正常版本 UA(如 sing-box/1.14.3),不要使用覆盖版本识别的裸 flag=sing-box。核验名称、入口、SNI、严格 TLS、缓存头和真实 IP;先官方客户端 check,再完整配置实际联网,核对出口、DNS、上下行计费与上报。
```sh
python3 /opt/dpl-gateway/provision/verify-node.py \
--qa /var/lib/dpl-sgw/qa/qa.json \
--binary /opt/dpl-gateway/acceptance/sing-box-1.14.3-linux-amd64/sing-box \
--subscription /var/lib/dpl-sgw/qa/subscription.json \
--expected-exit 64.44.115.18
```
helper 仅替换 TUN 等入站为临时本机 SOCKS,保留订阅 DNS、分流及出站,退出时停止客户端;失败日志留在同一私密目录。这不等于手机 GUI、系统 TUN、多地区、超额或多设备并发验收。某环境到入口不通时分段诊断并换环境核验,HTTP 200 不能替代联网。
8. 上述验收通过后,在生产根目录运行 readiness.php 和 promote.php,显式指定当前套餐集合。已有正式组时额外传 --group=<ID>。只关联所选尚无组的套餐及这些套餐现有无组用户,不改价格、周期、流量或设备限制。
```sh
php scripts/node-onboarding/readiness.php \
--backup=/root/codex-backups/dpl/node-onboarding-20261011-us01
php scripts/node-onboarding/promote.php \
--backup=/root/codex-backups/dpl/node-onboarding-20261011-us01 \
--plans=1,6,7,8
```
首次隔离组改为基础节点、订阅域名设置为 c2,以后复用正式组。脚本要求节点在线且临时用户已有流量,保存 before-promotion.json,最后禁用临时用户并写 promoted.json。清理本次客户端和私密传输副本,保留服务器备份、禁用用户与审计记录。
9. 回归原机场服务/规则/订阅,实际核对正式组权限、面板节点及订阅域名保存,确认测试用户禁用且不再下发。更新本文及 STATUS,分别记录提交、发布、真实验收及未完成项。
数据库默认 sing-box 模板已迁移新 DNS/路由格式,保留原分流和选择逻辑;现代本地 DoH 使用默认 dialer,不绕到空 direct 出站。仓库原默认资源和其他机场模板不改。apply-template.php 仅接受原默认模板,或与显式 expected-current-sha256 完全匹配的旧版本,逐次备份后只更新该模板/缓存。用户定制或未知模板须先审查,不能强行覆盖。 数据库默认 sing-box 模板已迁移新 DNS/路由格式,保留原分流和选择逻辑;现代本地 DoH 使用默认 dialer,不绕到空 direct 出站。仓库原默认资源和其他机场模板不改。apply-template.php 仅接受原默认模板,或与显式 expected-current-sha256 完全匹配的旧版本,逐次备份后只更新该模板/缓存。用户定制或未知模板须先审查,不能强行覆盖。
## 回退与排障 ## 回退与排障
节点变更先按[精确回退入口与证据契约](NODE_RUNBOOK.md#精确回退入口与证据契约)确定本次对象及 before/after。下列专项恢复说明继续适用;历史 before-promotion 不保证包含所有 after 值,不能盲目恢复。
- 单节点:停用本次节点/新增中转,停止新 dpl-node;保留 XrayR、原规则和审计数据。 - 单节点:停用本次节点/新增中转,停止新 dpl-node;保留 XrayR、原规则和审计数据。
- 正式关联:依据 before-promotion.json 精确恢复本次套餐/用户组及订阅域名,先确认当前值仍是本次写入值,避免覆盖后续业务。不恢复整库。 - 正式关联:依据 before-promotion.json 精确恢复本次套餐/用户组及订阅域名,先确认当前值仍是本次写入值,避免覆盖后续业务。不恢复整库。
- 模板:从对应私密模板备份恢复该条内容并清理模板缓存,不替换整个设置表。 - 模板:从对应私密模板备份恢复该条内容并清理模板缓存,不替换整个设置表。
@ -288,9 +168,6 @@ VPS 代号、数据库节点/机器 ID、部署 key 与展示编号各自独立
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| dpl | 🇺🇸 美国 US - 01 / node-us01 | AnyTLS | 24443 | haiwi.lshan.eu.org:47101 | 1 | 已上线;基础节点组,正式套餐 1/6/7/8 | | dpl | 🇺🇸 美国 US - 01 / node-us01 | AnyTLS | 24443 | haiwi.lshan.eu.org:47101 | 1 | 已上线;基础节点组,正式套餐 1/6/7/8 |
| dpl | 🇯🇵 日本 JP - 01 / node-jp02 | AnyTLS | 24443 | haiwi.lshan.eu.org:47202 | 2 | 2026-10-16 运行上线;基础节点组 1,正式套餐 1/6/7/8;浏览器验收通过 | | dpl | 🇯🇵 日本 JP - 01 / node-jp02 | AnyTLS | 24443 | haiwi.lshan.eu.org:47202 | 2 | 2026-10-16 运行上线;基础节点组 1,正式套餐 1/6/7/8;浏览器验收通过 |
| dpl | 🇯🇵 日本 JP - 02 / node-jp03 | AnyTLS | 24443 | haiwi.lshan.eu.org:47203 | 4 | 2026-10-17 append上线;组1,正式五节点订阅及QA403通过 |
| dpl | 🇯🇵 日本 JP - 03 / node-jp04 | AnyTLS | 24443 | haiwi.lshan.eu.org:47204 | 5 | 2026-10-17 append上线;组1,正式五节点订阅及QA403通过 |
| dpl | 🇯🇵 日本 JP - 04 / node-jp05 | AnyTLS | 24443 | haiwi.lshan.eu.org:47205 | 3 | 2026-10-17 append上线;组1,正式五节点订阅及QA403通过 |
已完成:独立服务、节点 TLS、新中转、新网关、DNS/橙云、真实 IP 比对、新旧订阅 vhost 官方 IP 自动同步、两张证书续期演练。官方 sing-box 1.14.3 的完整实际订阅(仅把入站替换为临时本机 SOCKS)经中转联网,出口 64.44.115.18、US、HTTPS TLSv1.3,严格节点证书校验;完整 DNS 和远程分流规则实际启动通过,面板已记录上下行流量。 已完成:独立服务、节点 TLS、新中转、新网关、DNS/橙云、真实 IP 比对、新旧订阅 vhost 官方 IP 自动同步、两张证书续期演练。官方 sing-box 1.14.3 的完整实际订阅(仅把入站替换为临时本机 SOCKS)经中转联网,出口 64.44.115.18、US、HTTPS TLSv1.3,严格节点证书校验;完整 DNS 和远程分流规则实际启动通过,面板已记录上下行流量。

View File

@ -1,261 +0,0 @@
# DPL 节点接入执行手册
入口:[节点关系与历史验收](NODE_ONBOARDING.md)。本手册定义后续授权任务的步骤。2026-10-16 已补齐本地脚本草稿;用户要求测试交给奶油,本轮未运行脚本、语法检查或功能测试,未触及线上。**新脚本必须先在隔离环境测试,再按Git流程审阅发布;本地存在不代表线上已更新。**已有节点展示命名按原入口的[节点命名与编号](NODE_ONBOARDING.md#节点命名与编号),此处不另建规则。
节点维护默认全流程采用[服务器侧方式](NODE_BATCH_RUNTIME.md#服务器侧接入与验收),不打开线上网站;包括DNS和中转也先检查可用服务器接口,网页仅用于确实无法服务器侧处理的必要例外。
## 先选择变更模式
| 模式 | 适用情形 | 允许修改的范围 |
| --- | --- | --- |
| initialize | 首次建立 DPL 网关、基础节点组及明确授权的套餐可见范围 | 本次参数单与授权清单逐项列明;订阅地址、套餐/用户关联必须分别确认 |
| append | 已有网关/正式节点组上增加节点 | 新机器、新节点、新独立中转、节点 DNS/证书和 QA 隔离资源;验收后只把新节点加入指定已有组并禁用 QA |
默认判断依据是现有资源与目标,不把“新节点”当作首次初始化。append 保持现有订阅地址、网关、套餐全部字段、正式用户分组及旧节点不变。扩展套餐覆盖另列变更任务。未知已有组/节点状态时先解决该依赖,不能通过重跑初始化猜测。
## 统一参数单与冲突检查
在本次服务器受保护任务目录保存参数,目录 700、文件 600;非秘密参数也不进公开日志。以下是待填写模板,没有可直接用于新节点的默认值。文件只使用赋值,不含执行命令;人工审阅后才加载。
```sh
MODE='' # initialize 或 append
KEY='' # 稳定部署 key
NODE_NAME='' # 按既有命名规则确定;与 VPS 代号独立
VPS_ID='' # CoDocs 稳定 ID
NODE_ADDRESS='' # 实际落地地址,按私有资源读取
LISTEN_PORT=''
RELAY_HOST=''
RELAY_PORT=''
TLS_DOMAIN=''
FORMAL_GROUP_ID='' # append 必须填已有正式组
PLAN_IDS='' # 仅 initialize 或另有明确套餐变更授权时填
BACKUP='' # 绝对路径,每次任务独立,不复用其他节点目录
QA_DIR='' # 网关本次独立验收目录
CLIENT_BINARY='' # 已校验摘要的官方客户端绝对路径
EXPECTED_EXIT=''
EXPECTED_COUNTRY=''
LOCAL_SOCKS_PORT='' # 网关上本次隔离客户端空闲端口
APP_ROOT='' # 面板应用路径,从项目档案核实
PROVISION_ROOT='' # 面板上本次已确认脚本路径
NODE_PROVISION='' # 节点上已确认的运维 Git 路径
GIT_COMMIT='' # 已推送且经过审阅的明确提交
PANEL_URL='' # 节点回连面板的明确HTTPS地址
SUBSCRIBE_URL='' # 仅initialize明确修改的订阅地址
QA_REPORT='' # 本次隔离QA的私有实时验收报告
FORMAL_EXPECTATIONS='' # 正式多节点预期的私有JSON文件
FORMAL_REPORT='' # 正式多节点验证报告
```
填写后逐项核对:key/展示名、机器名、面板 ID、VPS、落地监听端口、中转入口的域名+端口、源站目标、SNI、正式组、DNS 现值、备份路径和 QA 端口。相同 key 有 manifest 时,只回读已有对象并确认参数完全一致;新 stage.php 重跑要求 manifest 记录的完整参数一致,并核对节点、机器和中转端点;旧 manifest 没有 parameters 时停止,不能自动采纳。相同资源无 manifest、冲突端口或未授权的覆盖均停止,不自动接管。
机密字段单独沿用 stage.php 生成的私密文件;不把机器令牌、QA UUID、订阅令牌写进上述参数单、文档或命令行。参数按角色分发,不把面板 .env 复制到节点。
可在审阅参数文件后运行以下本地字段检查;它不验证线上冲突或资源归属:
```sh
set -eu
: "${MODE:?}" "${KEY:?}" "${NODE_NAME:?}" "${VPS_ID:?}" "${NODE_ADDRESS:?}"
: "${LISTEN_PORT:?}" "${RELAY_HOST:?}" "${RELAY_PORT:?}" "${TLS_DOMAIN:?}"
: "${BACKUP:?}" "${QA_DIR:?}" "${CLIENT_BINARY:?}" "${EXPECTED_EXIT:?}" "${EXPECTED_COUNTRY:?}"
: "${LOCAL_SOCKS_PORT:?}" "${APP_ROOT:?}" "${PROVISION_ROOT:?}" "${NODE_PROVISION:?}" "${GIT_COMMIT:?}"
: "${PANEL_URL:?}" "${QA_REPORT:?}"
case "$MODE" in
append) : "${FORMAL_GROUP_ID:?}"; test -z "$PLAN_IDS" ;;
initialize) : "${PLAN_IDS:?}" "${SUBSCRIBE_URL:?}" ;;
*) exit 1 ;;
esac
```
## 创建面板记录之前完成前置检查
只在具体接入任务授权后,通过 CoDocs 白色机跳板及登记的后台反代执行以下检查。本次文档维护没有执行这些动作。
| 位置 | 必须确认 | 未通过时 |
| --- | --- | --- |
| 面板 | Git 提交、当前组和节点清单、现有订阅设置、套餐/用户分组快照、应用运行环境、脚本实际版本 | 先补证据与备份,不创建机器/节点/QA 记录 |
| 节点 | OS/架构、内存/磁盘、时间同步、systemd、Git、curl、sha256sum、ss、证书工具,以及命令实际路径 | 先明确缺失工具和安装来源;安装属于接入任务变更,文档整理不安装 |
| 软件来源 | 发行版源可下载、包版本与依赖可满足;运维仓库和官方二进制来源/摘要可验证 | 不把 apt 索引存在当可安装;不自动换不明镜像或全量升级 |
| 证书 | 已有 renewal 配置、pre/post/deploy hooks及renew_hook别名、未知可执行hook、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 |
| 候选内存 | 按[容量筛选](NODE_BATCH_RUNTIME.md#候选容量筛选)核对物理MemTotal及XrayR是否已安装(不限active) | 小于1GB且已装XrayR则排除,未知先核查;不改动旧服务绕过 |
| 节点隔离 | 现有 XrayR 等服务的 PID/启动时间、TCP/UDP 监听、防火墙与新端口、旧证书和目录权限 | 冲突先处理;安装脚本只检查 TCP,不能代替全项前检 |
| 中转 | 后台/登录账户按 CoDocs 读取,当前规则快照、目标落地、隧道及入口端口、Proxy Protocol 两端设置 | 缺入口先问,不改原规则;创建后回读实际值 |
| 网关 | 容器 ID/启动时间/重启计数、CPU/内存、独立 QA 路径和端口、Python3、官方客户端及其摘要 | 补齐验收依赖后再 stage,不边创建业务记录边找工具 |
历史 jp02 接入曾因 Debian11 源包 404 临时使用 Debian 官方 HTTPS 归档安装 Git;这是该次处理记录,不是以后默认改源或安装命令。先核对目标当下发行版与来源,不在本手册固化过期包 URL。
前置完成后,保存本次允许改变的对象、每个对象的 before 值、预期 after 值和精确回退依据。不要打印整库快照或秘密。
## 脚本能力与边界
后续新批次按[正式适配器](NODE_BATCH_RUNTIME.md)准备和执行:动态账号授权集合验收、缺账号时自动创建受限用户、白色机SSH复用及证据有效期续跑。旧命令适配器继续兼容。下文运维提交为上一轮三节点已验收版本,新适配器与PHP验收用户入口尚未部署,不能把旧任务验收视为新版本线上通过。
2026-10-17:下列脚本已完成 PHP 语法检查、面板隔离测试(9 tests / 65 assertions)及批量进程测试(11 tests),源码提交并推送 `6af6764`;独立运维快照 `b8d10fac963d63825c97d36d41ff7f525116bbfd` 已部署到本次面板/网关的 `/opt/dpl-batch/provision` 与三台新节点 `/opt/dpl-node/provision`。生产应用代码未随之更新。各阶段现场结果以 [STATUS](STATUS.md) 和 [接入记录](NODE_ONBOARDING.md) 为准;隔离测试中的通知替身不证明生产通知成功。
| 工具 | 新行为 | 边界 |
| --- | --- | --- |
| stage.php | 明确panel-url、完整参数/对象重跑核对、端点冲突检查;受保护manifest可恢复传输文件 | 旧manifest不自动采纳;依赖与实际资源前检仍由接入任务完成 |
| promote.php | 强制mode;默认只预览;append只写新节点group_ids及QA banned;initialize显式plans/subscribe-url/可选group-name | 真正写入需apply及15分钟内匹配该QA、端点与SNI的实时验证报告;不根据节点名推导身份 |
| readiness.php | 只统计显式plans,缺省不统计套餐;可检查给定group的冲突套餐 | 只读快照,不代替真实订阅/计费/界面验收 |
| verify-node.py | 默认离线结构核对;显式run才启动客户端/联网;可按预期逐一验证多节点、SNI、密码、出口IP及国家 | 正式多节点需要完整预期清单;只证明实际执行的探针,不代替业务/手机/TUN/全路由验收 |
| rollback.php | 默认预览;基于新promotion-journal逐字段比较after再恢复before;保持QA禁用,隔离本次节点 | 冲突整次停止,不覆盖后续变化;只覆盖面板变更,不停止远程服务或修改DNS/中转;旧before-promotion需专项人工核对 |
| operations.php | 私有文件锁、原子写日志、事务内记录写前意图、提交后完成标记;中断后按before/after识别已应用或冲突 | 文件与数据库不是同一个事务;状态未知需依据日志恢复,不盲目重新stage |
| install-node.sh / renew-node.sh | 沿用已登记的独立安装与专用续期hook | 本次未更改,不代表前置工具/安装源/实际timer已经验证 |
新脚本不修改已有节点展示名、不自动分配编号。操作日志不包含密码/UUID/机器令牌;脚本不在错误输出中打印数据库异常全文或客户端配置。上述隔离测试覆盖事务、幂等、崩溃恢复与冲突拒绝;缓存/节点通知与用户下发仍按本次现场证据验收。
## 统一接入顺序
1. 完成参数单、模式确认、前检和恢复点;非秘密运维代码先提交/推送,再更新目标至明确 Git 提交。已有线上差异保留。拉取受限时只能传输已推送提交的 Git bundle 后 fetch/fast-forward,不用归档覆盖。
2. 在确认的面板应用根目录,用下列统一参数创建隔离资源;创建后把实际 node_id、machine_id、group_id、test_user_id 与 key/VPS 对应写回本次任务 manifest,不能从服务器名推导面板 ID。
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/stage.php" \
--key="$KEY" --name="$NODE_NAME" \
--relay-host="$RELAY_HOST" --relay-port="$RELAY_PORT" \
--listen-port="$LISTEN_PORT" --tls-domain="$TLS_DOMAIN" \
--panel-url="$PANEL_URL" --backup="$BACKUP"
```
3. 按既有受保护传输方式安装 node-config.yml 和 node-secret.env 至节点的独立目录,文件 600、父目录 700;实际运维 Git 路径使用 NODE_PROVISION,保留二进制固定版本/摘要核验。节点未安装本服务时才运行:
```sh
bash "$NODE_PROVISION/install-node.sh" \
--listen-port="$LISTEN_PORT" --tls-domain="$TLS_DOMAIN"
```
4. 配置本次 DNS only、证书和隔离续期策略,再启动新服务;中转只新增本次规则,保存后回读入口、目标及诊断结果。Proxy Protocol 默认保持本次参数单记录,不能单端打开。已有网关按 append 复用,不重建网关、NS、旧卷或订阅配置。
5. 按下节执行隔离验收;不因 service active、内核磁盘无 JSON 或 HTTP 200 单独判断成功/故障。Xboard-Node 可使用内存配置,须按实际版本、启动日志、证书和请求链路判断。
6. 验收通过后按下方“面板变更命令”预览,再对同一组参数明确apply。append只改新节点与QA;initialize才允许修改明确列出的套餐/用户分组及订阅地址。保存前后日志并回读。未通过隔离测试的新版本不能直接带到生产执行。
7. 回读正式订阅与原资源,检查 QA 禁用、订阅拒绝、下发集合无 QA,确认旧节点、旧中转、套餐/用户和订阅设置保持预期。同步 CoDocs 对象关系、节点登记及 STATUS;提交/推送/部署/现场验收分别报告。
## 证书钩子与演练
新证书只挂本项目专用 hook。历史已验证的调用方式为 `/bin/sh /opt/dpl-node/provision/renew-node.sh`;不能假定脚本有执行权限后直接执行。路径实际不同时按当前运维检出路径填写。renew-node.sh 依据 `/etc/dpl-node/certificate-domain` 和 RENEWED_LINEAGE 精确匹配,只操作 dpl-node。
目标实际 Certbot 命令路径记为 CERTBOT_BIN;先审计目录钩子、renewal 配置及 Snap/apt timer 的调用方式。经确认的演练入口:
```sh
: "${CERTBOT_BIN:?}"
"$CERTBOT_BIN" renew --cert-name "$TLS_DOMAIN" --dry-run \
--no-random-sleep-on-renew --run-deploy-hooks --no-directory-hooks
```
`--no-directory-hooks` 不替代审计 renewal 配置内的显式钩子。Certbot 5.8.0实测把`--deploy-hook`持久化为`renew_hook`;与`deploy_hook`按同一种部署钩子检查,只接受单个精确专用命令,不能把未识别字段当作无hook。全局cli.ini也须覆盖renew别名及其他可执行hook;目标出现额外pre/post或未知hook时停止。必须确认该证书配置仍明确调用专用 `/bin/sh …/renew-node.sh`,并检查演练后的服务状态及旧服务 PID/启动时间。关闭随机等待只用于本次演练可控等待,不等于修改常规定时调度。BrokenPipe/超时只记未确认,回查进程和最终结果后再决定是否重试,不重复发起签发。
续期完成条件包括:证书配置正确、调度 enabled/下一次触发可见、调度参数确实覆盖新证书及专用 hook、dry-run 退出状态和旧服务保护证据齐全。证书到期时间引用证书/域名对应记录,不把一次演练日期当作长期验收。
## 统一验收入口
每次在 QA_DIR 保存 700/600 的 QA 私密文件与完整实际订阅;文件名统一为 qa.json、subscription.json、验收结果和受保护日志,参数与 stage manifest 对照,不使用首节点默认路径。本地浏览器、服务器请求均按各自授权路线执行。
在已授权的网关环境,先用本次参数里的官方客户端检查完整配置,再运行完整隔离订阅:
```sh
"$CLIENT_BINARY" check -c "$QA_DIR/subscription.json"
python3 /opt/dpl-gateway/provision/verify-node.py \
--qa "$QA_DIR/qa.json" --subscription "$QA_DIR/subscription.json" \
--binary "$CLIENT_BINARY" --port "$LOCAL_SOCKS_PORT" \
--expected-exit "$EXPECTED_EXIT" --expected-country "$EXPECTED_COUNTRY" \
--report "$QA_REPORT" --run
```
上面脚本路径是既有网关检出约定,执行前核对实际 Git 版本与路径,若不同则使用本次确认路径,不复制临时代码替代。QA可按host/port唯一匹配本次节点,冲突时显式target-tag;正式多节点使用下方expectations。验证保留DNS/分流及全部出站,替换临时SOCKS入站,并仅为api.ipify.org与www.cloudflare.com两个诊断目的地添加指定出站规则,从而逐节点验证;不是原订阅所有路由均已验收。
| 验收层 | 必须保留的结果 |
| --- | --- |
| 参数一致 | 订阅包含本次入口/端口/SNI、严格 TLS 开启;机器/节点与 VPS 对应正确 |
| 协议/联网 | 官方客户端配置检查与完整启动结果、严格 TLS、目标 HTTPS、出口 IP 与 EXPECTED_EXIT 一致;脚本强制比对 EXPECTED_COUNTRY;仅结构核对不产生live_verified报告 |
| 订阅 | 有效用户标准/短路径按本次范围检查,JSON/gzip、private/no-store、无 HIT/Age/挑战;不要在日志保存令牌 |
| 上报 | readiness.php回读节点在线、last_check/last_push及QA上下行;按显式plans/group统计,缺省不选任何套餐 |
| 正式开放 | 正式有效用户订阅与完整expectations逐一对应,检查实际权限及QA清理;按[影响范围](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)决定完整联网或轻量TLS,不能把轻量结果称为出口/鉴权通过 |
| QA 清理 | QA banned、其订阅拒绝、实际下发用户集合无 QA;客户端退出、临时本地秘密副本清理,服务器审计文件保留 |
| 回归 | 原节点/中转、套餐字段/用户组、订阅 URL、网关容器 ID/启动时间/重启计数与 before 快照对照 |
| DPL数据回读 | 用户2026-10-20长期指定无需登录DPL或页面验收;经白色机正式脚本/应用模型回读节点、机器、组及保存值,并绑定实际链路证据。边界见[服务器侧接入](NODE_BATCH_RUNTIME.md#服务器侧接入与验收) |
面板回读命令入口如下;它只覆盖该脚本实际返回的节点/QA 字段,其他行须另留证据:
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/readiness.php" --backup="$BACKUP" --group="$FORMAL_GROUP_ID"
```
GUI/手机、TUN、多地区、并发及用户明确跳过的测试分别标为未执行或跳过。未确认结果不补成通过;统一表格不替代真实执行。
### 正式多节点验证
在私有文件 FORMAL_EXPECTATIONS 保存每个AnyTLS出站的明确预期,完整覆盖订阅中的全部AnyTLS标签。每条结构:`tag`、`host`、整数`port`、`sni`、`uuid`、`expected_exit`、`expected_country`;uuid是该正式用户的实际密码,不能复制进普通参数单或文档。标签必须与订阅精确一致;不重命名节点。 后续批量计划按[影响范围](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)生成每条`verification`:full为完整客户端联网,tls仅经中转严格TLS握手;省略保持full。完整清单和权限检查不缩减,新节点隔离QA仍必须full。
```sh
python3 /opt/dpl-gateway/provision/verify-node.py \
--subscription "$QA_DIR/formal-subscription.json" \
--expectations "$FORMAL_EXPECTATIONS" --binary "$CLIENT_BINARY" \
--port "$LOCAL_SOCKS_PORT" --report "$FORMAL_REPORT" --run
```
不加run时只读取私有JSON并核对结构,不启动客户端、不发网络请求;run每次会先将旧报告失效,避免失败后误用旧成功结果。实时报告记录具体结果与时间,面板正式开放仅接受匹配manifest QA及端点、15分钟内的实时QA报告,正式多节点报告不能代替QA报告。
### 面板变更命令
以下命令默认只预览数据库字段变化;确认输出范围后,对同一命令追加 `--verification="$QA_REPORT" --apply` 才写入。append禁止传plans、subscribe-url和group-name;它不会修改现有套餐/正式用户/订阅地址。initialize对应已有套餐中的无组用户关联也属于实际写入范围,必须先看预览。
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/promote.php" --backup="$BACKUP" --mode=append \
--group="$FORMAL_GROUP_ID"
```
首次初始化另用以下命令,按任务需要显式传group/group-name;没有这些参数时不自动改组名:
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/promote.php" --backup="$BACKUP" --mode=initialize \
--plans="$PLAN_IDS" --subscribe-url="$SUBSCRIBE_URL"
```
promotion-journal.json记录允许写入字段、before/after与manifest。相同日志/参数重跑会比较实际字段,已应用则只确认;参数变化、已启动回退、旧promoted.json无新日志或对象冲突时停止。prepared可能意味着数据库未提交、已提交但后续通知/缓存/落盘失败;只有完整before或完整after匹配才恢复,中间/未知状态先人工处理。不要删除日志重新执行。
## 精确回退入口与证据契约
触发失败先停止正式开放,隔离新节点。回退按本次 manifest 指定对象执行;不恢复整库、不删除原卷、不停止旧节点。执行前取回当前值,只有当前值仍等于本次 after 才恢复 before;存在后续变化时停止本次自动回退,不写任何字段;专项人工恢复另行核对。
| 对象 | 必需恢复证据 | 动作及回读 |
| --- | --- | --- |
| append 新节点正式组 | 该 node_id 的 before/after group_ids、enabled/show 及实际变动字段 | 新rollback.php按日志恢复本次字段并禁用/隐藏节点;不动已有套餐/用户/订阅地址 |
| initialize 正式关联 | 新promotion-journal.json、manifest.json;旧before-promotion/promoted仅用于历史专项回退 | 按 ID 逐条恢复本次变动的套餐/用户组、节点组、组名、订阅 URL;缺 after 不盲目回填,以实际变更证据核对 |
| QA | test_user_id 与实际禁用状态 | 始终保持本次测试账号禁用、订阅拒绝、不下发;不因回退恢复 QA 活跃 |
| 中转 | 新增规则的真实 ID、入口和目标 before/after | 只停用本次新增规则,再回读;原规则保持 |
| 节点服务 | 新服务/unit/配置及安装前状态、原服务 PID/启动时间 | 在已授权节点上执行下方停止入口,只停止新 dpl-node,回查旧服务;不删证书/审计目录 |
| DNS/证书续期 | 本次记录 ID/旧值/新值、renewal/hook/timer 原文件及权限 | 仅撤回本次增改;已共用证书/调度不能直接删除,恢复后核对下一次调度及原服务 |
| 网关/模板/CF | 对应专项恢复点 | append 不应修改;initialize 或专项变更按原[恢复专题](NODE_ONBOARDING.md#回退与排障)逐项执行 |
节点上的停止入口(确认本服务确为本次新增后):
```sh
systemctl stop dpl-node
systemctl is-active dpl-node || test "$?" -eq 3
```
新日志的面板回退统一入口(默认预览,加apply才执行):
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/rollback.php" --backup="$BACKUP"
```
回退逐字段核对当前值仍等于promotion记录的after,任何冲突整体停止;写回本次before、保持QA禁用,并禁用/隐藏本次节点。rollback-journal.json保存回退本身的意图与完成状态,重复执行也需匹配完整结果。旧日志不能自动补造after或升级成新日志。DNS、中转、远程服务、证书、网关/CF属于其他对象,仍按对应专项步骤和恢复证据处理;该面板脚本不包含跨服务器/服务商变更。
## 原交接测试清单与当前证据
2026-10-16编写时尚未测试或发布;2026-10-17已执行本节上方所列隔离测试、语法检查和独立运维快照部署,现场任务仍以STATUS区分完成范围。下列保留为完整验证清单,不表示每一项都已有独立用例或实际故障演练;未执行的项不能因部署而记为通过。
离线测试应在全新SQLite内存库/隔离应用容器中进行,禁止加载项目本地真实.env、默认数据库/Redis/队列或使用正式凭据:
1. append仅改变本次node.group_ids、QA.banned,现有订阅设置/全部套餐/正式用户/旧节点逐字段不变;拒绝append夹带plans/url/group-name。
2. initialize只修改明确套餐及其无组用户、指定订阅地址/可选组名;拒绝不存在ID、其他组冲突,验证原null订阅设置的创建及回退。
3. 默认预览无数据库写入;apply要求近期真实QA报告且QA身份、端点/SNI正确;结构报告、过期/错误QA报告不能通过。
4. 回读仅使用本次显式plans;无plans时不查询/统计隐含套餐集合。
5. 应用/回退幂等;数据库提交前失败、提交后通知/缓存/日志失败、prepared+完整before/after与混合冲突,不能重复创建或覆盖后续变化;QA始终不复活。
6. 多节点JSON用虚构身份;缺失/重复标签、端点/SNI/密码/严格TLS/国家/IP错误均失败。模拟子进程和传输,不发公网请求;默认无run绝不启动进程/开socket,失败后不能留下旧成功报告。
7. stage相同完整参数恢复缺失传输文件;参数变化、旧manifest、现有同名/同中转端点资源或已promote时拒绝接管;检查所有秘密文件权限。
8. 通过隔离测试之后,线上发布/验收仍需具体任务授权;测试本身不授权生产变更。节点命名按NODE_ONBOARDING执行,不因测试改名或分配编号。

View File

@ -4,10 +4,10 @@
## 接手顺序 ## 接手顺序
1. 按全局规则先读 CoDocs 项目总览、首次会话的持续维护协议与 dpl 档案,再读项目根目录 AGENTS.md、当前用户要求和本目录说明。用户最新明确边界优先,不扩大本次授权。 1. 阅读全局及项目根目录 AGENTS.md、当前用户要求和本目录说明。用户最新明确边界优先,不扩大本次授权。
2. 确认所在仓库、分支、工作区差异与远端;分别识别用户既有修改、本次任务与其他聊天修改。不要覆盖、清理或混入其他任务。 2. 确认所在仓库、分支、工作区差异与远端;分别识别用户既有修改、本次任务与其他聊天修改。不要覆盖、清理或混入其他任务。
3. 按 [架构与配置](ARCHITECTURE.md) 沿实际调用链检查问题;按 [业务约束](BUSINESS_RULES.md) 判断影响。 3. 按 [架构与配置](ARCHITECTURE.md) 沿实际调用链检查问题;按 [业务约束](BUSINESS_RULES.md) 判断影响。
4. 修改后运行与影响范围相关的检查;后台界面功能修改必须浏览器验收,包括页面显示、保存、切换、权限。节点接入按[服务器侧接入](NODE_BATCH_RUNTIME.md#服务器侧接入与验收)完成数据与实际链路验收,用户2026-10-20长期指定无需登录DPL或页面验收。HTTP 200 不代表前端可用。 4. 修改后运行与影响范围相关的检查;后台界面必须浏览器验收,包括页面显示、保存、切换、权限。HTTP 200 不代表前端可用。
5. 本地实现、提交、推送、部署、真实联调分别报告。发布按 [发布与恢复](DEPLOYMENT.md) 执行。 5. 本地实现、提交、推送、部署、真实联调分别报告。发布按 [发布与恢复](DEPLOYMENT.md) 执行。
6. 完成后同步本目录说明与 [验收状态](STATUS.md)。记录事实、证据、限制与下一步,不存密钥或用户原始邮件。 6. 完成后同步本目录说明与 [验收状态](STATUS.md)。记录事实、证据、限制与下一步,不存密钥或用户原始邮件。
@ -15,26 +15,15 @@
| 文件 | 用途 | | 文件 | 用途 |
| --- | --- | | --- | --- |
| HN_MAIL_GATEWAY.md | HN独立企业邮箱/Resend、辅助域名、订阅网关、验收及恢复 |
| HN_SHOP_PERIODS.md | HN购买页隐藏不可购买周期、隔离与验收 |
| HN_PRODUCTION.md | HN生产版本、主域名/后台、独立运行服务、TLS、服务器验收与回退 |
| SITE_PROFILES.md | dpl / hn 独立部署参数、主题选择、开发与发布隔离 |
| THEME_DESIGNS.md | tpl-a 等可复用设计方案、定稿与绑定状态、预览和验证边界 |
| XBOARD_THEME_SURVEY.md | Xboard 生态主题一手来源、同名项目区别与本地库存范围 |
| ARCHITECTURE.md | 服务入口、前后端边界、配置来源、开发与检查 | | ARCHITECTURE.md | 服务入口、前后端边界、配置来源、开发与检查 |
| BUSINESS_RULES.md | 邮件、提醒、账号与支付等不可随意改变的约束 | | BUSINESS_RULES.md | 邮件、提醒、账号与支付等不可随意改变的约束 |
| DEPLOYMENT.md | Git 发布、备份、运行依赖、恢复及故障排查 | | DEPLOYMENT.md | Git 发布、备份、运行依赖、恢复及故障排查 |
| TELEGRAM_TICKETS.md | Telegram 工单接入、绑定、权限及故障处理 | | TELEGRAM_TICKETS.md | Telegram 工单接入、绑定、权限及故障处理 |
| STATUS.md | 最近验证基线、已知限制与待验收项目 | | STATUS.md | 最近验证基线、已知限制与待验收项目 |
| CLOUD_FLARE_AUDIT.md | 两机场 Cloudflare 审查、已部署调优、域名轮换与回退及可选未实施项 | | CLOUD_FLARE_AUDIT.md | 两机场 Cloudflare 审查、已部署调优、域名轮换与回退及可选未实施项 |
| NODE_ONBOARDING.md | 五节点链路、命名规则、共享资源与验收 | | NODE_ONBOARDING.md | 共享资源隔离、订阅网关与新增节点流程及待验收状态 |
| NODE_RUNBOOK.md | initialize/append参数、脚本入口、前检/证书/验收与面板回退;隔离测试/运维版本与现场边界,含验证清单 |
| NODE_BATCH.md | 批量接入默认一个子助手的分工与关卡;append并发锁/查重/断点恢复、命令协议与测试边界 |
| NODE_BATCH_RUNTIME.md | 新批次默认正式适配器、动态集合验收、自动受限测试用户、白色机SSH复用、证据续跑及部署准备 |
| GATEWAY_ADMIN_PROXY.md | 两网关管理域名、白色机新旧区域反代、独立证书续期、恢复和验收边界 | | GATEWAY_ADMIN_PROXY.md | 两网关管理域名、白色机新旧区域反代、独立证书续期、恢复和验收边界 |
这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。 这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。
正式维护文档放在 docs/maintenance/,纳入 Git 并随相关代码一起更新。项目根目录 AGENTS.md 是 AI 自动接手入口;运行日志、截图、临时脚本与任务产物放在 codex/output/,继续默认忽略。受保护备份和私密配置按运行环境保存,不提交 Git。 正式维护文档放在 docs/maintenance/,纳入 Git 并随相关代码一起更新。项目根目录 AGENTS.md 是 AI 自动接手入口;运行日志、截图、临时脚本与任务产物放在 codex/output/,继续默认忽略。受保护备份和私密配置按运行环境保存,不提交 Git。
本机共享 wwwroot 权限已于 2026-10-13 收紧;策略、服务身份、限制和恢复入口见 [CoDocs 本地目录权限](/Users/hellcat/Documents/CoDocs/20-基础设施/本地目录权限.md)。仅本机适用,未变更本项目业务或生产权限。

View File

@ -1,118 +0,0 @@
# dpl / hn 独立实例与主题
2026-10-27 用户确定 hn 先使用 `EZ`。当前独立工作树的默认值与 `.env.hn.example` 已改为 EZ,旧 Hardnerd 功能草稿保留为显式选项;已批准的 `tpl-a` 继续独立保存、不绑定 hn,见 [THEME_DESIGNS](THEME_DESIGNS.md)。2026-10-27 已独立部署 HN 到 sitejp02,主域名 hardnerd.net;DPL 未发布。当前生产和回退见 [HN_PRODUCTION](HN_PRODUCTION.md)。
## 配置与边界
同一后端仓库、同一业务逻辑,分别部署到各自服务器/目录。`SITE_PROFILE` 为启动参数,不按访问域名切换租户,不共享数据库。
| 参数 | 行为 |
| --- | --- |
| 未设置或 `SITE_PROFILE=dpl` | 保持既有 APP_NAME、APP_FULL_NAME、数据库品牌设置与 frontend_theme(包括 Stellar),缓存/Redis/Horizon/session 默认命名规则不变 |
| `SITE_PROFILE=hn` | 默认 EZ,示例配置显式 `SITE_THEME=EZ`;旧 Hardnerd 草稿仅在显式 `SITE_THEME=Hardnerd` 时启用;默认 hardnerd / hardnerd.net;独立命名空间 hn_cache / hn_database_ / hn_horizon: / hn_session |
| 其他值 | 启动报错,防止拼写错误误选 dpl |
hn 可用 `SITE_NAME`、`SITE_FULL_NAME`、`SITE_DESCRIPTION`、`SITE_LOGO` 覆盖品牌;后台读到的主题/品牌与部署配置一致,不能在后台切换成另一站的主题。读取覆盖在缓存取出后、请求内合并,不写回原有设置,也不污染 Redis 中的配置。dpl 后台设置流程保持原状。
**选择 hn 不会自动创建数据库或隔离错误复制的私密环境。** 使用 `.env.hn.example` 建立 hn 自己的 `.env`,独立创建 hn 数据库和最小权限用户、APP_KEY、storage、日志及 worker/scheduler。不得复用 dpl 的实际 `.env`、业务数据库、storage 挂载或加密配置。显式 CACHE_PREFIX / REDIS_PREFIX / HORIZON_PREFIX / SESSION_COOKIE 优先于默认值,复制环境时必须核对。
邮件、支付商户、订阅域名、节点和通知配置由 hn 自己设置。共用功能不等于已有外部资源授权共用;新实例未配置这些服务时,不宣称能发信、收款或提供真实节点。
## EZ 来源与适配
原始源码:[EZTHEME/EZ_THEME](https://github.com/EZTHEME/EZ_THEME),package v0.4.0;Git 子模块 `frontends/ez-theme` 锁定 `2c0581386d86006315e6eba2f5970c923892caba`。上游源码不直接改写,hn 适配层在 `frontends/hn`,依赖和锁文件在 `frontends/`。后台资源与 Stellar 各自既有仓库边界保持不变。
上游 package.json 标注 ISC,当前锁定版本未包含单独 LICENSE 文件;保留作者/上游出处,公开再分发前复核授权声明,不能自行称作 MIT。
适配明确使用 Xboard API、当前站点的 `/api/v1`,删除演示 API、中间件地址和占位客户端链接。默认深色、绿色主题和 hn logo;禁用上游的随机外部背景、工单第三方图片托管、工单外部 IP 定位与 Crisp 用户数据传送。登录验证码仍由实例配置决定,不改变后端验证逻辑。客户端导入等 EZ 自带能力保留,下载卡片因未指定正式链接而隐藏。EZ 使用 hash 路由,无需修改 dpl Stellar history fallback。
后端服务、认证、用户、套餐、订单、支付、邮件与订阅仍使用同一现有实现;只接入站点配置和主题。浏览器令牌保存于本站 origin,部署应使用两个独立域名。
## 本地开发
在独立 hn checkout 使用自己的 `.env`、空数据库和独立 Redis。不要把 dpl 的本地运行目录改成 hn。
```bash
git submodule update --init frontends/ez-theme
cd frontends
npm ci --ignore-scripts
npm run dev:hn
```
固定开发入口为 `http://hn.loc/`(2026-10-27 已配置并在浏览器验收 EZ 首页)。hosts 仅映射 `127.0.0.1` / `::1`;前端 Nginx 的 `/usr/local/etc/nginx/servers/hn.loc.conf` 同样只监听双回环80。普通请求代理独立 HN PHP `127.0.0.1:8592`;`/__hn_vite/` 代理 EZ Vite `127.0.0.1:5184`,保留 WebSocket 升级。未让 root Nginx 直接读文稿、未扩大 GemCodes 书签或监听权限;此工作树仍在既有 Codex 路径,PHP CLI 直接提供 public。
Vite 标记文件 `storage/framework/ez-vite.hot` 内容为 `http://hn.loc/__hn_vite`;后端只在允许的本地 hostname、标记匹配且5184在线时加载该同源路径,公共 hostname 或失效标记仍回退正式资源。APP_URL 已设为 `http://hn.loc`,其余私密环境未变,数据库仍是 `database/hn-preview.sqlite`,Redis 仍为独立6387。未将 Laravel .env 注入前端。
本次三个开发进程已启动,未新增登录项;关闭进程或重启后需重新启动。以下命令各在独立终端运行(先确认专用端口未被占用,不结束其他项目进程):
```bash
# Redis:无持久化的本地演示缓存,不使用 dpl 的 Redis
/usr/local/bin/redis-server --bind 127.0.0.1 --port 6387 --protected-mode yes --save '' --appendonly no
# PHP:在本工作树的 public/ 下运行,使用实际 PHP 8.4,避免 Herd 占位二进制
cd public
/usr/local/bin/php -S 127.0.0.1:8592 -t . ../vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php
# Vite:在本工作树根运行
npm --prefix frontends run dev:hn
```
`npm run dev:hn` 的内部端口已由5174改为5184,避免与其他项目冲突;生产构建路径不变。不能把直接打开 Vite 或8591设计预览当作 hn.loc 的功能入口。
历史 EZ/功能预览使用工作树内部 `database/hn-preview.sqlite` 和临时 Redis `127.0.0.1:6387`;这些不是 dpl 数据。2026-10-14 当前 `http://127.0.0.1:8591` 已切为纯前端设计预览,不连接该 SQLite / Redis。启动区别见 [THEME_DESIGNS](THEME_DESIGNS.md)。
## 发布与恢复
2026-10-27 HN 首发已按 Git 提交、推送并在 sitejp02 更新到明确提交;实际版本、独立服务、TLS、备份和服务器验收见 [HN_PRODUCTION](HN_PRODUCTION.md)。后续发布按 Git:提交并推送共享版本,在指定 hn 服务器 checkout 更新明确提交,初始化锁定子模块、安装依赖并构建。版本升级前备份 hn 自己的数据库/配置;dpl 发布单独授权、单独验收。
```bash
git submodule update --init frontends/ez-theme
cd frontends
npm ci --ignore-scripts
npm run build:hn
cd ..
php scripts/hn/publish-assets.php
php artisan config:cache
```
构建输出 `theme/EZ/assets/` 默认忽略,不把下载依赖/私密环境入 Git;发布脚本仅允许 hn 配置,将 EZ 资源复制至当前实例 `public/theme/EZ`,不切换或删除其他主题。脚本需要 PHP >=8.2、Laravel dependencies 与 hn 自身运行资源。修改参数前需清理旧配置缓存,发布后重启 hn 自己的长驻 worker/Octane。首次实例初始化按已有安装流程执行迁移、创建独立管理员;不要通过拷贝 dpl 用户和订单完成初始化。
回滚恢复 hn 的原 Git 版本、原 lock/submodule 和相应构建资源,清配置缓存、重启 hn 进程;本次没有新增业务迁移。不要把已运行 hn 的参数切成 dpl 代替版本回滚。现有 dpl 部署无需安装或构建 EZ;保留原主题和环境即可。
## 验证
新增站点测试:`php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Feature/SiteProfile`。前台配置测试:在 frontends 运行 `npm run test:hn`。构建检查:`npm run build:hn`。
支付/邮件既有隔离套件和订单并发套件应在独立 PHP 进程运行:前者使用精简 user schema,Eloquent 的静态 guardable columns 缓存会影响后者,合并执行可产生缺 password 的测试夹具错误。不要据此修改业务模型。
当次实际验证与尚未验收事项见 [STATUS](STATUS.md)。不将本地主题验证当作服务器上线、真实付款或客户端节点连接验收。
## hn.loc 验收与回退(2026-10-27)
双栈解析仅回环;首页、Vite client/main.js、品牌SVG和公开站点配置接口均200,接口品牌 hardnerd。浏览器显示 EZ 的 hardnerd 深色首页,日志确认 `[vite] connected.`,未见警告/错误。原 dpl.loc 与 home.loc 首页均200。站点回归14项/31断言、EZ配置测试1项、PHP语法和差异检查通过。没有登录账户、创建业务记录、真实付款或访问线上。
备份及截图:主工作区 `codex/output/hn-local-domain-20261027/`(含私密.env备份,文件600,不提交);管理员安装备份与回执在 `/private/tmp/hn-loc-admin-20261027/`。撤回域名时只移除本次 `hn.loc.conf` 及 hosts 中带 HN 标记的两条精确映射,先 `nginx -t` 再向核实后的 master 平滑HUP;不恢复整份hosts覆盖其他项目新条目。开发文件按备份逐项比较恢复,APP_URL 改动也只恢复对应字段;不覆盖并行修改,不清理SQLite数据。域名和服务配置已本机启用,代码未提交、推送或生产发布。
## HN 本地默认管理员(2026-10-27)
用户要求补齐默认管理员。账号/密码按 GemDocs AI版 `ACC-005` 的统一字段契约取用,本文不保存凭据值。本次先检查发现约定账号不存在;SQLite在线备份后,使用项目官方 `XboardInstall::registerAdmin` 在 hn-preview.sqlite 中创建,用户ID3,is_admin=true、banned=false,密码哈希匹配。未覆盖旧管理员或用户、未触碰dpl/线上库。
管理入口现为 `http://admin.hn.loc/`(同日按用户要求新增);原 `http://hn.loc/hn-preview-admin` 继续兼容。浏览器已确认登录界面、邮箱与密码框正常显示。本次通过该实例正常HTTP登录API获得is_admin=true,并验证管理端plan/fetch返回200、访客同一路由403;接口返回的凭据和业务内容没有输出。唯一API验收会话已撤销,未在浏览器保存登录状态或密码,未改验证码/认证策略。
任务前数据库备份位于主工作区 `codex/output/hn-local-domain-20261027/hn-before-default-admin.sqlite`(600,含私有数据,不提交)。默认账号已经创建;如需撤销仅处理本次新建ID3并核对其后业务引用,不能整库覆盖丢失后续修改。后台登录页面截图同目录 `hn-admin-login.png`。各实例账号独立存在,此本地结果不代表HN生产管理员已经建立。
## EZ 购买套餐页精简(2026-10-27)
用户指定:中文“商店”改为“购买套餐”,移除进入页面自动弹出的用户须知,以及全球节点、极速体验、流媒体解锁、多设备支持四张宣传卡片。HN 适配层在 `runtime-config.mjs` 设置 `SHOP_CONFIG.popup.enabled=false`、`showPlanFeatureCards=false`;Vite 适配仅改写简体中文模块的“商店”文案,覆盖导航、页面标题与返回入口。上游锁定子模块保持原样,其他实例和语言不改;套餐/价格/库存/支付/下单二次确认逻辑未改。
本地浏览器已验证:导航与标题显示购买套餐,从仪表盘进入不再弹须知,四张宣传卡片不显示,套餐和购买按钮正常显示;一次性筛选空态及重置筛选可用,控制台无警告/错误。EZ同源配置测试及差异检查通过。未创建订单、未付款、未构建发布或修改dpl。截图和两文件修改前备份位于主工作区 `codex/output/hn-shop-cleanup-20261027/`。
## 独立本地后台域名(2026-10-27)
用户指定 `admin.hn.loc`。hosts 新增IPv4/IPv6回环映射;`/usr/local/etc/nginx/servers/admin.hn.loc.conf` 只监听127.0.0.1/[::1]:80,精确根 `/` 内部代理到既有PHP8592的 `/hn-preview-admin`,其他管理静态资源和API同源代理至同一PHP。浏览器地址保持 `http://admin.hn.loc/#/sign-in`,无需携带内部管理路径。未改app_url、secure_path、账户、认证中间件或前端构建产物;后台与hn.loc属于两个浏览器origin,登录状态分别保存,使用同一HN本地管理员。
验收:解析仅双回环;管理HTML与8项静态资源均200;浏览器实际显示品牌登录界面且无警告/错误;在新域名实际管理员登录/受限接口200、访客403,准确比对登录前会话ID后仅撤销本次新增验收会话。hn.loc/dpl.loc首页仍200,原hn.loc虚拟主机摘要未变。未访问线上、未改其他站点或放宽监听/认证。
安装候选、原hn.loc摘要及截图:主工作区 `codex/output/hn-admin-domain-20261027/`;管理员hosts备份/回执在 `/private/tmp/hn-admin-loc-20261027/`。回退只移除 `admin.hn.loc.conf` 与hosts中本次精确域名两行,保留hn.loc映射及并行配置;nginx配置检查通过后对核实的master平滑HUP。不覆盖整份hosts。
## 公告与购买须知的区别(2026-10-27代码核对)
购买套餐页须知仅由 `SHOP_CONFIG.popup.enabled` 控制,本次设false不涉及公告。网站公告由 `/api/v1/user/notice/fetch` 提供启用项,在仪表盘公告卡片展示标题/日期,查看详情打开公告弹窗;当前EZ自动弹窗只识别tags数组里的精确“弹窗”标签,选择返回列表中第一条符合者,并按公告ID在sessionStorage记录,当前标签页会话仅自动展示一次。后台独立popup字段不是该主题现有判断条件。未修改这套逻辑、未新建公告或做新的公告发布实测。

View File

@ -2,136 +2,6 @@
文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。
## HN 订阅域名改为 t34(2026-10-28,服务器侧验收完成)
用户指定后,新入口为t34.hardnerd-nodes.com,旧c2 DNS已移除;HN subscribe_url、生成链接、源站/容器TLS与SNI、独立续期同步。有效限时账号两种格式200、无效令牌403、非订阅404、CF DYNAMIC/no-store、证书续期及HN专用hook通过,QA ID4已禁用和轮换令牌。DPL/FUC原容器和配置基线保持,未改节点、邮箱或认证;节点联网不属于本次验收。实现、版本及回退见 [HN_MAIL_GATEWAY](HN_MAIL_GATEWAY.md)。未做线上页面验收。
## HN 首次线上部署(2026-10-27,服务器侧验收完成)
用户指定 sitejp02、主域名 hardnerd.net,已使用独立数据库和 EZ 上线;后台 admin.hardnerd.net,默认管理员已创建并验证。Git功能提交2e5cec6已推送HN专用分支并在服务器部署;Cloudflare代理/严格HTTPS、源站证书、前后台及权限通过,唯一验收会话已撤销。DPL提交/既有Nginx配置/原服务启动时间不变,DPL公网200。未做线上页面验收;套餐、节点及支付未配置。邮件及辅助域名随后已接通,见下节。具体服务、证据、恢复与限制见 [HN_PRODUCTION](HN_PRODUCTION.md)。下文“未推送/部署”是各历史阶段当时状态,不代表当前HN尚未部署。
## HN 邮件与独立订阅网关(2026-10-27,服务器侧验收完成)
mail@hardnerd.dev 的SMTP465/IMAP993及Resend独立账号已接通,两封实际测试邮件收件及SPF/DKIM通过;Resend实际回调200、已投递时间入库,IMAP入库2封,自身邮件不自动回复。来信自动回复已启用但未发送额外外部测试;管理员运行汇报未借用DPL目的地。c2.hardnerd-nodes.com 经Cloudflare严格TLS转发到独立HN网关,Clash/sing-box格式、鉴权、no-store及非订阅拒绝通过;限时QA账号已禁用、令牌轮换。证书续期模拟通过,原DPL/FUC容器及Nginx配置基线不变。没有可运营套餐/节点,未测真实付款、实际代理联网或线上页面。sitejp02收尾核实19:50:52发生过整机启动,原服务随系统重启,首发启动时间基线已变化;原因未核实,当前全active、DPL代码和Nginx配置不变且公网200。配置、证据、版本与回退见 [HN_MAIL_GATEWAY](HN_MAIL_GATEWAY.md)。
## admin.hn.loc 本地后台入口(2026-10-27,已启用)
按用户要求新增独立后台域名admin.hn.loc,根路径内部映射现有管理应用,资源与API同源,双回环监听。浏览器登录页显示正常、无警告错误;管理HTML/8项资源200,真实管理员接口登录及权限200、访客403,仅撤销本次新增验收会话。hn.loc/dpl.loc回归200,原hn.loc配置未变;未改账号、认证或线上。备份与回退见 [SITE_PROFILES](SITE_PROFILES.md)。公告与商城须知为独立逻辑,代码核对结果也归入该正文,未发布测试公告。
## HN 购买套餐页精简(2026-10-27,已本地验证)
中文商店入口/标题已改为购买套餐;进入须知弹窗及四张宣传卡片通过HN适配配置关闭。上游子模块与dpl未改。浏览器验证从仪表盘进入、套餐/购买按钮、一次性筛选与重置均正常,未见弹窗/四卡或控制台警告错误;EZ配置测试通过。没有创建订单或付款,未构建、提交、推送或部署。实现与备份入口见 [SITE_PROFILES](SITE_PROFILES.md)。
## hn 本地默认管理员(2026-10-27,已创建并验证)
按用户要求核对ACC-005字段与目标独立SQLite:默认账号原先不存在,在线备份后经项目官方注册方法创建为用户ID3、有效管理员,未覆盖原用户密码。正常HTTP登录200且is_admin=true;受限管理接口200、访客403;唯一验收会话已撤销。浏览器确认本地后台登录界面正常,未在浏览器登录/保存密码。管理员入口、凭据引用和备份在 [SITE_PROFILES](SITE_PROFILES.md) 维护。只操作hn本地实例,未改dpl、认证策略或线上数据。
## hn.loc 本地入口(2026-10-27,已启用)
按用户要求新增固定域名 `http://hn.loc/`,仅双回环hosts与Nginx入口,普通请求到独立PHP8592,EZ同源开发资源与HMR到5184,Redis6387/SQLite演示库保持独立。开发服务已运行,未新增开机登录项。PHP直接使用/usr/local/bin/php,避免artisan误选不可执行的Herd链接。root安装脚本在文稿路径被macOS拒绝时未改权限,改从私有临时目录通过正常管理员授权安装;未放宽TCC。
浏览器实际显示 hardnerd EZ 深色首页,Vite WebSocket connected,无警告/错误;解析仅127.0.0.1与::1,HTML、开发client/main.js、Logo、公开配置接口均200;既有dpl.loc/home.loc均200。14项站点回归/31断言与1项EZ配置测试通过。未登录或写入业务、未重建、未提交推送/部署。备份、启动和回退见 [SITE_PROFILES](SITE_PROFILES.md)。tpl-a未修改。
## hn 当前使用 EZ(2026-10-27)
用户决定 hn 先使用 EZ。独立工作树的 `config/site.php` 默认值、`SiteProfile` 和资源发布脚本回退值、`.env.hn.example` 均已改为 EZ;本地 `.env` 已是 `SITE_PROFILE=hn` 且未覆盖 `SITE_THEME`,因此沿用 EZ。未改私密环境、数据库或缓存;Hardnerd 功能草稿保留为显式选项,tpl-a 定稿未修改、未绑定 hn。
本次站点配置/权限/命名空间回归 14 测试、31 断言通过;EZ 同源接口配置测试 1 项通过,PHP 语法与 `git diff --check` 通过。本次没有修改界面或重新构建资源,没有启动/切换预览服务,也未做新的浏览器验收。未合入 dpl 主项目、未提交本次改动、未推送或部署;线上仍未登记可执行的 hn 部署目标。历史设计预览与旧功能验收见下文,不代表本次上线。
## tpl-a 设计定稿保留(2026-10-14,未绑定业务实例)
用户确认当前效果可作为定稿方案保留,命名 tpl-a,不限定用于 hn;仅保存纯前端设计,吉祥物暂缓。最新要求明确“机房网络运营商”定位、解除此前扁平 SVG 限制并重新设计。当前 `frontends/tpl-a/` 首页改为写实机柜通道主视觉;接入方案、控制台及其他服务门户页面已统一重做,旧卡通机架 / 格纹 / 厚框眼镜 / 终端装饰已退出当前视觉。默认暗色、可切浅色,减少无关小字的要求保留。端口 8591 继续为独立 Vite 静态前端。
当前版本已验证 1280px 明暗首页、接入方案与控制台;390px 七类门户页面无横向溢出,320px 首页、菜单和控制台无溢出。月 / 年付展示、方案选择、页面跳转、主题切换与模拟保存可用,浏览器没有运行错误。观察到的 11 个请求均留在本机 Vite,没有业务 API;源码无后端代理。构建与格式检查通过。机房影像为内置 imagegen 生成示意,价格、地区、线路、账户、流量和记录为演示,不据此声称实际机房资产或资源已验收。tpl-a 源码与素材已独立提交为 8b78424;未推送、部署,没有修改实际 dpl 环境或业务数据库。启动、源码、证据与限制见 [THEME_DESIGNS](THEME_DESIGNS.md)。
## Hardnerd 功能草稿(2026-10-14,暂缓接入与验收)
用户要求重新设计与 EZ / Stellar 均不同的 hn 专属主题。当前工作树切到 `codex/hn-custom-theme`,自有源码在 `frontends/hardnerd/`,主题包装 `theme/Hardnerd/`,独立热更新端口 5175。该历史草稿当时默认 Hardnerd;2026-10-27 已按用户要求改为默认 EZ,Hardnerd 保留为显式选项;dpl 选择不受该参数影响。当前改动尚未提交、推送或部署,不能视作正式发布。
已实际显示首页和用户中心,虚构本地用户登录、套餐列表/确认、创建一笔本地待支付测试订单(未付款、未开通)已验证。新增主题/隔离回归 14 测试 / 31 断言、前台金额/流量/接口凭据 7 测试通过,构建通过。其余页面交互、手机断点、正式资源验收和最终文档仍需继续。原生取消确认在浏览器验证时阻塞,已改为主题内 dialog,尚待验证。没有修改实际 dpl 环境和业务数据库。
以上记录属于此前功能草稿,不代表当前静态设计稿已经接入业务。用户已要求吉祥物先搁置,概念图没有批准或接入。后续须先确定视觉,再继续功能主题验收。
## hn / EZ 本地实现(2026-10-13)
独立工作树 `/Users/hellcat/Documents/Codex/2026-10-12/ji-cha/work/dpl-hn`,分支 `codex/hn-site-profile`,基于后端 `99f9b9ec927d924ce48f4bf6835a7edf604dcf46`。共享代码由 SITE_PROFILE 区分 dpl / hn,hn 使用锁定 EZ 源码和自有品牌,部署边界见 [SITE_PROFILES](SITE_PROFILES.md)。运行中的 dpl 后端、Stellar、实际 .env、数据库、Redis、队列、代理和生产服务器均未修改;主工作区既有修改保留。
本地隔离环境使用工作树 SQLite 和专用 Redis 6387,虚构用户、管理员和套餐。浏览器已验证 EZ 首页、登录、用户中心、套餐列表 ¥19/月及结算摘要;后台显示 EZ 为当前主题,切换 Xboard 返回部署配置限制,EZ 保持当前。已验证 dev 资源和正式构建资源加载;未提交真实订单、未付款、未发信、未连接实际节点。
验证:新增站点/权限/参数/命名空间 10 测试 / 26 断言通过;EZ 配置测试通过,npm build:hn 构建通过;既有支付/邮件 65 测试 / 386 断言,订单并发单独进程 4 测试 / 13 断言通过。最初合并运行订单套件遭遇精简 user schema 的 Eloquent 静态字段缓存干扰,分进程复核通过,业务模型未为测试改写。PHP 语法和 git diff --check 通过。
实现与文档当前仅在本地分支;未推送、未部署、未验证正式域名/白色机反代。hn 生产服务器、独立数据库、管理员、邮件、支付、订阅和节点资源尚未指定或验收,不能把本地完成解释为上线完成。无业务迁移新增,dpl 发布需单独授权和验收。
## 2026-10-21 剩余合格节点接入完成
新增us02/vn01/ru01展示US - 04、HK - 07、RU - 01,已接23台;hk02现场小于1GB且运行XrayR,按组合规则排除。三台完整隔离联网/各自出口/双向上报和助手独立审查通过,QA禁用403/排除;正式动态23节点结构/密码/权限及严格TLS通过。旧服务、既有业务及四容器保持,新三证书续期演练通过。RU实际保护SSH/DNS且使用已验证隔离Certbot2.11与既有certbot.timer,未假造XrayR/Snap。
GemDocs资源及排除项已同步,本批15份资源与20份派生视图已更新;全库build/check仍报告无关fnb项目SEO.md失效链接,未更改该项目或声称全库通过。服务器侧验收完成,未做DPL页面验收。工具兼容及签名修复范围、旧续期失败未修复与证据边界见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-21-剩余候选三节点接入)。
## 2026-10-20 香港HK04–06接入完成
node-hk01/hk03/hk07新增展示HK - 04/05/06。三台完整隔离联网/HK出口/双向上报和独立助手审查通过,QA禁用403/下发排除;正式动态20节点结构/密码/权限/严格TLS通过。原服务、旧renewal、旧业务及四容器保持,新证书续期演练通过。hk01旧证书续期在开工前已失败,未修改或声称修复。
按主机真实保护路径的源码66f3044已推送,51项隔离回归通过;任务签名验证器8项正反测试通过,没有关闭APT签名/时效校验。当前有效登记24、已接20、剩4(含低内存/SSH异常待核实)。GemDocs已同步;离线校验通过:12项目、435文档、3515链接、390资源对象及11岗位/13科室绑定;不代替线上业务验收。已完成服务器侧验收,未做线上页面验收。详情见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-20-香港第二批hk04至06接入)。
## 2026-10-20 香港HK01–03接入完成
node-hk04/hk05/hk06展示香港HK - 01/02/03,已加入基础组1。三台隔离完整订阅、严格TLS、HK出口、双向上报及一个助手独立审核通过,QA禁用403/下发排除;正式动态17节点结构/密码/权限/逐条严格TLS通过,不代表17条重测出口。原XrayR/443进程、旧renewal配置、旧资源与四容器保持,新证书续期演练通过。
有效登记24台、已接17台、剩7台未接,低内存及SSH异常需后续按实际门禁核实。DPL数据、中转和部署/验收均经白色机服务器侧;只有Namecheap DNS新增使用必要网页。已完成服务器侧验收,未做线上页面验收。GemDocs同步及离线检查通过(425文档、3336链接、380对象);详情见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-20-三个香港节点服务器侧接入)。
## 2026-10-20 sg05单节点接入与服务器侧优先
新加坡SG - 04(node-sg05,VPS033)已加入基础组1,节点/机器14,QA32完整订阅严格TLS/SG出口/双向上报通过,单节点操作者精确审核后开放,QA禁用403/下发排除。动态正式14节点结构/密码/权限/逐条严格TLS及旧资源基线通过,不代表14节点重新测出口。原XrayR及原证书保持,新证书专用续期演练通过。
节点维护全流程优先服务器侧规则已写入并推送;本次DPL写入、中转接口创建/查重/回读、部署及验收均服务器侧,只有无DNS API凭据的Namecheap新增使用必要网页,再以权威NS复核。已完成服务器侧验收,未做线上页面验收;不将其列为未完成项。具体证据与恢复见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-20-单节点服务器侧接入-sg05)。GemDocs已同步实例、域名、中转规则5168及关联;离线检查通过(415文档、3171链接、370对象),不替代上述现场验收。
## 2026-10-20 三个新加坡节点接入完成
新加坡SG - 01/02/03(node-sg02/03/04)已append基础组1;三个隔离QA完整联网、严格TLS/SG出口、在线与双向上报经一个助手独立审核,QA29/31/30已禁用、订阅403及下发排除。正式动态13节点订阅结构/密码/权限及逐条严格TLS通过,本批无共享变化采用分层tls范围,不表示13节点全部重新测出口;旧节点/套餐/用户关联/订阅地址、四网关容器、生产应用版本与工作区摘要保持。原XrayR和VPS032额外xray逐字段保留。三证书签发、专用钩子及续期演练通过。用户本次明确跳过DPL页面验收。
新增低内存且已装XrayR的候选排除门禁、GemDocs统一凭据读取及长期授权邮箱验证码处理规则已实现;50项Python离线回归通过。源码d331f45已推送,运维64c3b087以Git部署五台相关服务器运维目录。DNS采用新部署规则的gemcook.net/Namecheap,不迁移旧节点。精确证据、备份及未测范围见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-20-三个新加坡节点批量接入),分层策略唯一正文见[NODE_BATCH_RUNTIME](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)。GemDocs已同步三实例/三域名与关联;正式工具build及check_docs离线检查通过(412文档、3134链接、367对象),该检查不替代上述现场验收。
## 2026-10-18 后续五节点接入完成
日本JP - 05/06/07(node-jp06/07/08)及美国US - 02/03(node-us03/04)已append基础节点组1;五份新QA经独立原始字节审查后开放并禁用QA21–25。动态正式授权集合本次10节点全部完整订阅、严格TLS和预期出口/国家联网通过,QA403/实际下发排除、旧节点/套餐/用户关联/订阅地址及四网关容器基线审计通过;后台节点和服务器列表显示/切换/保存值回读通过。GemDocs AI版已登记5实例/5域名及项目和服务器关联。
面板、网关、5节点运维2547ea25,US两台Certbot安全修复ebe12d3已Git部署;源码6b0ac99已推送,生产应用仍7e8fbf6及原工作区摘要。所有新证书签发/专用续期dry-run通过。日本三台原XrayR既有QUIC sniffer panic自动恢复已独立调查并绑定精确回执,原程序/配置/unit/443保持;原基线不覆盖,未修复旧XrayR。GemDocs迁移后原冻结计划/账本不改,新resume计划保持context/nodes/owner并固定新SSH引用摘要。完整证据、边界、恢复及未测项目见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-18-后续五节点批量接入)。
收尾本地适配器补齐Certbot `renew_hook`及别名、重复/未知/目录执行钩子检查;Python完整39项回归通过。本批实际钩子已另行只读核对,冻结源码归档、原计划/pin不改;新修复仅用于后续新计划,无需重部署生产应用。
## 2026-10-17 批量接入流程优化(本地验证)
已实现正式`ssh-site-v1`适配器及`configure-site.py`入口,去除临时handler中的固定日期、日本、组1和节点数量。正式验收从真实账号授权集合生成快照,完整校验新旧节点、预期出口、严格TLS、原始证据摘要、QA身份/403/禁用/下发排除及时效;集合变化停止。没有有效正式组账号时自动创建100MiB/1小时/1设备非管理员测试用户,journal恢复同一身份,正常成功/失败/等待均清理本任务用户,关闭后不复活;异常中断依靠日志恢复和时限兜底。已批准指纹匹配后才读取凭据,SSH始终经白色机,按线程复用会话、按阶段延迟连接,不自动重放断线命令。
续跑保留全批前检及publish/audit现场关口,以请求/代码/当前依赖摘要和有效期复用已完成步骤,前驱变化向下游失效;事件输出替代重复状态轮询。原服务以unit/程序/配置/监听和进程信息区分实际冲突与待调查重启,原基线不覆盖。独立运维打包工具从已提交源码生成可追踪快照,保留工作区/真实索引及旧运维基础树;旧命令适配器及旧冻结任务文件不修改。
本轮Python 32项测试通过;PHP新增10项/38断言及既有9项/65断言隔离测试通过,均未加载真实.env或联网。CoDocs全库离线检查通过,前次.obsidian权限问题已由用户处理,本轮未读写其配置正文。新版本尚未部署至远端或进行真实SSH/客户端性能演练,不能把上一轮三节点验收复用为新实现线上通过,也不承诺十节点耗时。准备/部署/执行与边界见[正式适配器专题](NODE_BATCH_RUNTIME.md);后续新批次默认按该入口执行。实现源码已提交推送,独立运维分支`codex/node-runtime-20261017`已准备并经Git bundle校验;精确版本按该分支Git记录读取,仍未切换远端旧任务的运行目录。
## 2026-10-17 三节点批量接入完成
新增日本JP - 02/03/04(node-jp03/04/05),均已append正式组1、QA禁用。五节点正式完整订阅逐条严格TLS联网、预期IPv4出口/国家及三个QA订阅403通过;旧节点、套餐/用户关联、订阅设置与四个网关容器基线保持。续期演练逐台通过。源码6af6764、独立运维b8d10fac已提交推送并部署运维目录,生产应用代码未随之更新;9项/65断言面板隔离测试及11项批量进程测试通过。
jp03旧XrayR接入前自身QUIC sniffer panic自动恢复的基线例外、原证据与恢复边界,以及完整参数/验收/备份/自动audit不足的人工补验,见[本次接入记录](NODE_ONBOARDING.md#2026-10-17-三个日本节点批量接入)。GUI/TUN/多地区/多设备并发与生产回退演练未执行。下方2026-10-16未测试/未发布为当时历史,当前以本节为准。CoDocs新增三实例/三域名、服务器核实字段及关联已同步;resources.py check通过(166对象/299关系),check_docs.py全库检查发现6项.obsidian目录/文件权限不合要求;本任务未修改该目录,不能把全库检查写成通过。
## 2026-10-16 批量接入默认单子助手规则
按用户明确要求,在项目 AGENTS.md 登记批量接入触发规则,在 [批量专题](NODE_BATCH.md#多智能体协作流程) 维护唯一完整流程:主协调者统一参数/编号/端口/账本与写入;按用户为节省 token 的最新要求,只启动一个核查验收助手,分阶段核查候选及冲突、审查脚本、测试恢复并独立验收。规定复用、失败/暂停处理、写入及正式开放关卡、最小上下文与避免重复检查。此次仅更新文档,没有启动新接入任务或工作助手,没有生产变更;先前单个显示测试助手不算本批分工验收。规则本地落盘不代表自动执行或生产验证;尚未提交/推送。
## 2026-10-16 批量调度本地验证
新增 `batch.py`、`command-adapter.py` 与[批量专题](NODE_BATCH.md):append 模式,全批前检屏障,独立 VPS 并发,共享面板/DNS/中转锁,跨批资源预留,固定计划与逐阶段现场回读恢复。11 项独立进程离线测试通过,覆盖并发/串行边界、双进程去重、回包丢失与 SIGTERM 中断恢复、参数冲突、错误回执和命令摘要变更;没有加载 Laravel/.env 或访问网络。CoDocs 离线结构检查通过。该测试结果只覆盖新调度器与命令适配器,不覆盖下节其他聊天新写的 PHP 面板脚本及真实订阅验证器。
当前未提交/推送/部署,未新增线上节点。生产操作通过经过审阅的任务命令 profile 接入;没有内置 DNS/中转后台自动写入或 SSH 登录工具,缺少 handler 的阶段返回等待。真实生产命令、十节点耗时、远端现场及业务验收尚未验证,不能宣称已经支持现网一键接入。
## 2026-10-16 节点脚本本地草稿(未测试/未发布)
已在本地补promote.php的append/initialize模式隔离和默认预览,新增operations.php字段日志及rollback.php面板精确回退;readiness.php按显式套餐回读,verify-node.py支持完整多节点预期及IP/国家断言、默认不联网,stage.php移除固定面板地址并核对完整重跑参数。使用与限制见[NODE_RUNBOOK](NODE_RUNBOOK.md)。用户要求测试交给奶油,本次未运行测试/语法检查或应用命令,未改线上,未提交/推送/部署,不能视为已验证可用版本。节点命名未改;本次代码不更改展示名或分配编号。
## 2026-10-16 节点文档核对(仅离线)
核对两条既有节点链路并补 CoDocs 美国节点关联;新[执行手册](NODE_RUNBOOK.md)统一参数/前检/证书钩子与演练/验收及精确回退,首次初始化与追加模式分开。现有 promote.php 会写固定订阅地址并关联无组套餐/用户,不适合作为追加默认入口;readiness.php 套餐统计硬编码,verify-node.py 只接受单个 AnyTLS 出站,自动追加与统一回退工具仍缺失。文档已明确限制与现有管理界面操作入口,本次未改脚本或线上。以下运行结果均引用原任务证据,不是本轮复验;节点命名继续由原维护任务处理。
## 2026-10-16 日本节点 ## 2026-10-16 日本节点
`node-jp02` 已按既有 Git 运维版本独立部署并加入基础节点组 1,节点/机器 ID 2、AnyTLS、名称 `🇯🇵 日本 JP - 01`。完整隔离订阅经中转实际联网、严格 TLS、日本出口、DNS/路由、上下行上报均通过;证书续期演练及专用 deploy hook 通过。正式有效订阅同时包含美国、日本节点,测试账号禁用且订阅 403。未改价格/套餐限制;原 XrayR 与原中转保留。管理面板实际登录、节点/服务器列表切换、在线状态及已保存参数回读通过;未额外提交编辑表单默认值。参数、Git 版本、备份和边界见 [节点接入记录](NODE_ONBOARDING.md#2026-10-16-日本节点接入)。 `node-jp02` 已按既有 Git 运维版本独立部署并加入基础节点组 1,节点/机器 ID 2、AnyTLS、名称 `🇯🇵 日本 JP - 01`。完整隔离订阅经中转实际联网、严格 TLS、日本出口、DNS/路由、上下行上报均通过;证书续期演练及专用 deploy hook 通过。正式有效订阅同时包含美国、日本节点,测试账号禁用且订阅 403。未改价格/套餐限制;原 XrayR 与原中转保留。管理面板实际登录、节点/服务器列表切换、在线状态及已保存参数回读通过;未额外提交编辑表单默认值。参数、Git 版本、备份和边界见 [节点接入记录](NODE_ONBOARDING.md#2026-10-16-日本节点接入)。

View File

@ -1,45 +0,0 @@
# 自有主题设计方案与预览
## 当前范围与定稿
2026-10-14,用户确认当前效果可作为定稿方案保留,命名为 `tpl-a`,不限定用于 hn。当前独立源码在 `frontends/tpl-a/`,设计定稿版本 1.0.0;业务接入仍未完成。2026-10-27 用户确定 hn 先使用 EZ;tpl-a 仍不绑定 hn,吉祥物暂缓。此前接入业务接口的 `frontends/hardnerd/` 功能草稿,以及 EZ / Stellar 独立保留。
用户最新明确定位为“机房网络运营商”,解除此前 2D 扁平 / SVG 风格限制并要求重新设计。当前首页采用写实机柜通道主视觉、网络接入 / 套餐 / 技术服务入口与运营管理内容;接入方案、控制台、资源、账单、文档、工单和账户页面均重做为统一服务门户。使用深蓝、暖橙、自然空间影像与常规企业排版,不继续沿用卡通机架、格子衫 / 厚眼镜和终端装饰作为主视觉。保留减少无关小字的要求,只在全站一处注明影像和数据为示意。明暗双色保留,初次加载默认暗色。
所有价格、参数、地区、节点、账户、流量与订单均为演示,年付展示按演示月价 × 12 计算。机房主图通过内置 imagegen 生成,不是 hn 实际机房照片;不据此认定机房资产、实际覆盖或可用性承诺。表单与按钮只切换视觉状态和提示,不提交业务数据、不发邮件、不创建订单或工单。
设计源文件和素材已以本地 Git 提交 `8b78424` 保存;关联业务草稿与环境未纳入该提交。
## 开发入口
在独立工作树启动:
```bash
npm --prefix frontends/tpl-a run dev
```
预览:`http://127.0.0.1:8591/#/home`。该端口当前由 Vite 直接提供静态前端;原先占用该端口的 hn PHP 演示进程已停止。配置 `envDir:false`,没有后端代理,不加载 Laravel 配置或业务数据库。只监听本机回环地址。
主要源码:
- `src/NetworkLanding.vue`:写实机房首页、网络接入、套餐与服务入口。
- `src/App.vue`:导航、明暗切换,以及七类用户中心页面的模拟交互。
- `src/brand.mjs`:可替换的演示品牌;`template.json` 的 site_binding 为 null。
- `approved-files.sha256.json`:定稿源码与素材摘要;运行和定稿保护见方案 README。
- `src/operator.css`:完整企业官网和服务门户样式、明暗与响应式布局;旧 style.css 已退出使用并移除。
- `public/datacenter-hero-v1.png`:已落入项目的写实主视觉,原始生成文件另行保留。生成方式与完整提示词在 `codex/output/hn-datacenter-asset.md`。
构建验证:在仓库根运行 `npm --prefix frontends/tpl-a run build`,输出仅在 `frontends/tpl-a/dist/`。**该构建不会发布到 `theme/Hardnerd` 或运行中的 dpl;不能使用 hn 正式资源发布脚本将本设计稿误当作功能主题。**
## 本次验证与限制
- 首页、用户中心、套餐、节点、订单、说明、工单、账户均能前端跳转;保存偏好只显示模拟提示。
- 默认暗色与浅色切换已在浏览器验证。
- 当前机房运营商方向已在 1280px 宽屏验证明暗首页、接入方案和控制台;390px 七类门户页面逐页检查无横向溢出,320px 首页、菜单与控制台无溢出。临时浏览器尺寸已恢复。
- 月付 / 年付展示切换、方案选择和账户模拟保存已验证,仅作用于前端状态。
- 浏览器观察到的首页资源、页面跳转与模拟保存请求全部留在本机 Vite;未出现业务 API 请求。源码无 fetch / Axios / XHR 和后端 proxy。
- 构建与差异格式检查通过,浏览器没有运行错误。当前截图为 `codex/output/hn-dc-*.jpg`;`hn-hard-*` / `hn-operator-*` 为此前版本对照,均不作为定稿素材。
- 项目维护记录和 CoDocs hn/dpl 入口已同步。CoDocs 全库结构检查仍报告 13 项既有 Obsidian 主题目录/文件权限问题;本任务未改动这些无关配置,不能称全库检查通过。
- 设计已由用户批准保存为 tpl-a;未推送、部署,hn 已选 EZ,tpl-a 使用实例仍未定。业务接入、支付、邮件、订阅、节点连通与生产验收没有在本阶段执行。
后续明确使用实例后,再将 tpl-a 接入功能主题,分别进行业务接入和部署验收。实例隔离和历史功能验证见 [SITE_PROFILES](SITE_PROFILES.md) 与 [STATUS](STATUS.md)。

View File

@ -1,27 +0,0 @@
# Xboard 主题生态调查
核对日期:2026-10-14。仅依据本次读取的上游、作者仓库和作者文档;不是官方完整目录、购买建议或本项目适配验收。第三方主题的可购买性、授权和 Xboard 版本支持须在实际采用前核对。
## 已找到的主要项目
| 名称 / 作者 | 本次证据与获取状态 | 一手来源 |
| --- | --- | --- |
| Xboard 默认主题 | 官方 master 的 theme/ 目录内置 Xboard | [官方目录](https://github.com/cedar2025/Xboard/tree/master/theme) |
| EZ THEME / EZTHEME | 公开源码,作者声明适配 V2Board / XBoard / wyxBoard;本项目以前适配的是这个仓库 | [作者仓库](https://github.com/EZTHEME/EZ_THEME) |
| EZ-Theme / PangHu-Code | 与上一项名称相近的另一项目;Vue 3,README 标注 MIT,明确提供 Xboard API 配置 | [作者仓库](https://github.com/PangHu-Code/EZ-Theme) |
| Stellar Theme | 公开源码,README 明确支持 cedar2025/Xboard 与 wyx2685/v2board | [项目仓库](https://github.com/aklibk86-dev/stellar) |
| DK Theme | React / Vite / TypeScript,作者声明 XBoard / V2Board 适配,MIT | [作者仓库](https://github.com/Nice-Derrick/DK_Theme) |
| Aurora | 作者公开发布包(static、config.json、Blade);仓库含 Xboard 标签,README 主要面向 V2Board。具体授权与版本支持需确认 | [作者发布仓库](https://github.com/krsunm/Aurora) |
| AirRocket / Rocket | 作者 README 明确列 Xboard;仓库目前主要是介绍,提供授权购买入口,不能因仓库摘要写免费就认为当前产品免费开源 | [作者仓库](https://github.com/dc8683/v2board-theme-airrocket) |
| Zero Theme | 作者配置文档允许 v2board_version=xboard,含购买授权令牌字段 | [作者文档](https://linkis-organization.gitbook.io/v2boar-zero-theme/shi-yong-jiao-cheng/zhu-ti-pei-zhi) |
| Lucid | 作者频道介绍 Bento Grid 与商业域名授权,声明曾在 Xboard 测试;不代表本次已验证当前版本 | [作者频道](https://t.me/s/NEDEFINITA) |
| Angela | 作者公开介绍、响应式 / PWA / 扩展点;GitHub 仓库只有 README,含 Xboard 标签,具体交付与适配版本向作者确认 | [作者仓库](https://github.com/Miss-Tea2/Angela) |
| Lovesy | 作者介绍 Xboard 前后端分离主题;仓库主要是 README 与截图,未见完整源码 | [作者仓库](https://github.com/devlovesy/xboard-theme-lovesy) |
同名项目、分支和商业版不自动等价。公开 GitHub 页面可能只是介绍或编译产物,不能一律称为开源。这里只确认项目存在及作者声明;未购买、安装、登录演示后台或测试业务 API。
## 本地库存与 tpl-a
本次只读清点:运行中的 dpl 代码目录中,系统主题 Xboard、自定义主题 Stellar 的 config.json 和 dashboard.blade.php 均存在。隔离工作树另有 EZ 与旧 Hardnerd 功能草稿。SYSTEM_THEMES 里的保留名称不代表相应目录已经安装。
`tpl-a` 是本次用户批准保存的自主设计方案,独立源码 `frontends/tpl-a/`,未绑定 hn,不作为第三方生态项目或已经接入 Xboard 后台的主题计数。其状态与启动入口见 [THEME_DESIGNS](THEME_DESIGNS.md)。

View File

@ -1 +0,0 @@
/node_modules/

@ -1 +0,0 @@
Subproject commit 2c0581386d86006315e6eba2f5970c923892caba

View File

@ -1 +0,0 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>hardnerd</title><link rel="icon" href="/brand/hn/logo.svg"></head><body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html>

View File

@ -1,17 +0,0 @@
<script setup>
import {ref,watch} from 'vue';import {useRoute,useRouter} from 'vue-router';import Icon from './Icon.vue';import Confirm from './Confirm.vue';import {state,signOut,loadAccount,notify} from './state.mjs';
const route=useRoute(),router=useRouter(),open=ref(false);const brand=window.settings?.title||'hardnerd';const logo=window.settings?.logo||'/brand/hn/logo.svg';
const nav=[['/dashboard','grid','概览'],['/shop','bag','选择套餐'],['/nodes','network','连接与节点'],['/orders','receipt','我的订单'],['/docs','book','使用指南'],['/tickets','help','工单支持']];
watch(()=>route.path,()=>{open.value=false;if(route.meta.auth&&state.token&&!state.user)loadAccount().catch(error=>notify(error.message));},{immediate:true});
function skip(){const target=document.getElementById('main');target?.focus();target?.scrollIntoView();}
function logout(){signOut();router.push('/');}
</script>
<template>
<a class="skip-link" href="#main" @click.prevent="skip">跳到主要内容</a>
<div v-if="route.meta.auth" class="workspace">
<button class="mobile-nav" @click="open=!open" :aria-expanded="open" aria-label="打开导航"><Icon :name="open?'close':'menu'"/></button>
<aside class="sidebar" :class="{open}"><RouterLink class="brand side-brand" to="/"><img :src="logo" alt=""/><span>{{brand}}</span><span class="word-dot">NET</span></RouterLink><span class="nav-label">YOUR SPACE / 用户中心</span><nav><RouterLink v-for="[url,icon,label] in nav" :key="url" :to="url" :class="{'router-link-active':(url==='/shop'&&route.path==='/order-confirm')||(url==='/orders'&&route.path==='/payment')}"><Icon :name="icon"/><span>{{label}}</span><span class="nav-pointer">↗</span></RouterLink></nav><div class="side-bottom"><RouterLink class="invite-link" to="/invite"><Icon name="spark"/> 好连接,值得分享 <span>↗</span></RouterLink><RouterLink class="identity" to="/profile"><span class="avatar">{{(state.user?.email||'H').slice(0,1).toUpperCase()}}</span><span><b>我的账户</b><small>{{state.user?.email||'正在读取…'}}</small></span><Icon name="user"/></RouterLink><button class="logout" @click="logout"><Icon name="logout"/>退出登录</button><small class="side-footer">{{brand}} · CONNECT DIFFERENT.</small></div></aside>
<div class="work-main"><header class="work-top"><span class="mono">WORKSPACE <span class="slash">/</span> {{state.user?.email?.split('@')[0]||'hardnerd'}}</span><RouterLink class="top-help" to="/tickets"><span class="tiny-dot"></span>需要帮助?<Icon name="arrow"/></RouterLink></header><main id="main" tabindex="-1"><RouterView/></main><footer class="work-footer"><span>© {{new Date().getFullYear()}} {{brand}}</span><span>保持好奇。保持连接。</span><RouterLink to="/docs">使用指南 ↗</RouterLink></footer></div></div>
<RouterView v-else/>
<Confirm v-if="state.confirm"/><div v-if="state.toast" class="toast" role="status"><Icon name="help"/><span>{{state.toast}}</span><button @click="state.toast=''" aria-label="关闭提示"><Icon name="close"/></button></div>
</template>

View File

@ -1,9 +0,0 @@
<script setup>
import {ref,onMounted,onBeforeUnmount} from 'vue';import {state} from './state.mjs';
const container=ref(null),error=ref('');let widget=null,sdk=null,token='';
function loadScript(url){return new Promise((resolve,reject)=>{const existing=document.querySelector(`script[data-hn-captcha="${url}"]`);if(existing){if(existing.dataset.ready)return resolve();existing.addEventListener('load',resolve,{once:true});existing.addEventListener('error',()=>reject(new Error('验证服务未加载,请重试')),{once:true});return;}const script=document.createElement('script');script.src=url;script.async=true;script.dataset.hnCaptcha=url;script.onload=()=>{script.dataset.ready='true';resolve();};script.onerror=()=>reject(new Error('验证服务未加载,请重试'));document.head.append(script);});}
onMounted(async()=>{if(!state.config.is_captcha)return;try{const type=state.config.captcha_type;if(type==='turnstile'){await loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit');sdk=window.turnstile;widget=sdk.render(container.value,{sitekey:state.config.turnstile_site_key,callback:value=>token=value,'expired-callback':()=>token=''});}else if(type==='recaptcha'){await loadScript('https://www.google.com/recaptcha/api.js?render=explicit');await new Promise(resolve=>window.grecaptcha.ready(resolve));sdk=window.grecaptcha;widget=sdk.render(container.value,{sitekey:state.config.recaptcha_site_key,callback:value=>token=value,'expired-callback':()=>token=''});}else if(type==='recaptcha-v3'){await loadScript('https://www.google.com/recaptcha/api.js?render='+encodeURIComponent(state.config.recaptcha_v3_site_key));await new Promise(resolve=>window.grecaptcha.ready(resolve));sdk=window.grecaptcha;}else throw new Error('当前验证方式暂不支持,请联系支持');}catch(e){error.value=e.message;}});
async function payload(){if(!state.config.is_captcha)return {};if(error.value)throw new Error(error.value);const type=state.config.captcha_type;if(type==='recaptcha-v3'){if(!sdk)throw new Error('验证服务正在加载');token=await sdk.execute(state.config.recaptcha_v3_site_key,{action:'register'});}if(!token)throw new Error('请先完成人机验证');return {[type==='turnstile'?'turnstile_token':type==='recaptcha-v3'?'recaptcha_v3_token':'recaptcha_data']:token};}
function reset(){token='';if(sdk&&widget!==null)sdk.reset(widget);}
onBeforeUnmount(()=>{if(sdk&&widget!==null&&state.config.captcha_type==='turnstile')sdk.remove(widget);});defineExpose({payload,reset});
</script><template><div v-if="state.config.is_captcha" class="captcha"><div ref="container"></div><p v-if="error" role="alert">{{error}}</p></div></template>

View File

@ -1,6 +0,0 @@
<script setup>
import {ref,onMounted} from 'vue';import {state} from './state.mjs';
const dialog=ref(null);
function finish(value){const resolve=state.confirm?.resolve;state.confirm=null;resolve?.(value);}
onMounted(()=>dialog.value.showModal());
</script><template><dialog ref="dialog" class="confirm-dialog" aria-labelledby="hn-confirm-title" @cancel.prevent="finish(false)"><span class="eyebrow">BEFORE YOU CONTINUE</span><h2 id="hn-confirm-title">确认操作</h2><p>{{state.confirm?.message}}</p><div class="form-actions"><button class="button outline" @click="finish(false)" autofocus>返回</button><button class="button primary" @click="finish(true)">确认</button></div></dialog></template>

View File

@ -1,5 +0,0 @@
<script setup>
defineProps({name:String});
const paths={arrow:'M5 12h14m-6-6 6 6-6 6',grid:'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',network:'M12 3v6m-7 9v-5h14v5M12 13v5M9 3h6v4H9zM2 18h6v4H2zM9 18h6v4H9zM16 18h6v4h-6z',bag:'M5 8h14l1 13H4L5 8Zm3 0V6a4 4 0 0 1 8 0v2',receipt:'M5 3h14v18l-3-2-4 2-4-2-3 2V3Zm4 5h6M9 12h6',user:'M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM4 21v-3a8 8 0 0 1 16 0v3',help:'M21 11a9 9 0 0 1-9 9H4l-2 2V11a9 9 0 0 1 19 0ZM8 8a4 4 0 0 1 7 2c0 2-3 2-3 4m0 3h.01',book:'M3 4h7l2 2 2-2h7v16h-7l-2 1-2-1H3V4Zm9 2v15',logout:'M9 4H3v16h6m5-14 6 6-6 6m6-6H8',copy:'M8 8h12v12H8zM4 16H2V2h14v2',check:'m5 12 4 4L20 5',plus:'M12 4v16M4 12h16',refresh:'M20 8a8 8 0 1 0 1 7m-1-12v5h-5',menu:'M3 6h18M3 12h18M3 18h18',close:'m5 5 14 14M19 5 5 19',globe:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM3 12h18M12 3c-5 5-5 13 0 18 5-5 5-13 0-18',spark:'m12 2 3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7',shield:'m12 2 9 4v6c0 5-9 10-9 10S3 17 3 12V6l9-4Zm-4 10 3 3 5-6'};
</script>
<template><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="paths[name]||paths.arrow"/></svg></template>

View File

@ -1,8 +0,0 @@
export const periods={month_price:'月付',quarter_price:'季付',half_year_price:'半年付',year_price:'年付',two_year_price:'两年付',three_year_price:'三年付',onetime_price:'一次性',reset_price:'重置流量'};
export function money(cents=0){return (Number(cents||0)/100).toFixed(2);}
export function bytes(value=0){const size=Number(value||0);if(size===0)return '0 B';const index=Math.min(4,Math.floor(Math.log(Math.max(1,size))/Math.log(1024)));return `${(size/1024**index).toFixed(index?1:0)} ${['B','KB','GB','TB','PB'][index]}`;}
export function date(value){return value?new Date(Number(value)*1000).toLocaleDateString('zh-CN'):'—';}
export function planPeriods(plan){return Object.keys(periods).filter(key=>key!=='reset_price'&&plan[key]!=null&&Number.isFinite(Number(plan[key])));}
export function usage(subscription){const total=Number(subscription?.transfer_enable||0),used=Number(subscription?.u||0)+Number(subscription?.d||0);return {total,used,remaining:Math.max(0,total-used),percent:total>0?Math.min(100,Math.max(0,used/total*100)):0};}
export function safeLink(value,origin){try{const url=new URL(value,origin);return ['http:','https:'].includes(url.protocol)?url.href:null;}catch{return null;}}
export function statusLabel(status){return ['待支付','处理中','已取消','已完成','已折抵'][Number(status)]||'未知状态';}

View File

@ -1,18 +0,0 @@
import {createApp} from 'vue';
import {createRouter,createWebHashHistory} from 'vue-router';
import App from './App.vue';
import Home from './pages/Home.vue';
import Auth from './pages/Auth.vue';
import Dashboard from './pages/Dashboard.vue';
import Plans from './pages/Plans.vue';
import Orders from './pages/Orders.vue';
import Account from './pages/Account.vue';
import Resources from './pages/Resources.vue';
import {state,signOut,notify,api} from './state.mjs';
import './style.css';
const routes=[{path:'/',component:Home},{path:'/landing',redirect:'/'},...['login','register','forgot-password'].map(path=>({path:'/'+path,component:Auth})),{path:'/dashboard',component:Dashboard,meta:{auth:true}},{path:'/shop',component:Plans,meta:{auth:true}},{path:'/order-confirm',component:Plans,meta:{auth:true}},{path:'/payment',component:Orders,meta:{auth:true}},{path:'/orders',component:Orders,meta:{auth:true}},{path:'/profile',component:Account,meta:{auth:true}},...['nodes','tickets','docs','invite'].map(path=>({path:'/'+path,component:Resources,meta:{auth:true}})),{path:'/:pathMatch(.*)*',redirect:'/'}];
const router=createRouter({history:createWebHashHistory(),routes,scrollBehavior:()=>({top:0})});
router.beforeEach(to=>{if(to.meta.auth&&!state.token)return {path:'/login',query:{redirect:to.fullPath}};document.title=(to.path==='/'?'连接不止于此':({'order-confirm':'确认订单',payment:'订单支付','forgot-password':'找回密码',dashboard:'概览',shop:'套餐',orders:'订单',profile:'账户',tickets:'支持',docs:'指南',nodes:'节点',invite:'邀请',login:'登录',register:'注册'}[to.path.slice(1)]||'hardnerd'))+' · '+(window.settings?.title||'hardnerd');});
window.addEventListener('hardnerd-expired',()=>{notify('登录已过期,请重新登录');router.replace('/login');});
api('/guest/comm/config').then(config=>(state.config=config||{},state.configReady=true)).catch(error=>{state.configError=true;notify(error.message);});
createApp(App).use(router).mount('#app');

View File

@ -1,8 +0,0 @@
<script setup>
import {ref,onMounted} from 'vue';import Icon from '../Icon.vue';import {state,api,action,loadAccount,copy,askConfirmation} from '../state.mjs';import {money,date} from '../core.mjs';
const oldPassword=ref(''),newPassword=ref(''),again=ref(''),expire=ref(true),traffic=ref(true),sessions=ref([]),error=ref('');
async function load(){try{await loadAccount();expire.value=!!state.user.remind_expire;traffic.value=!!state.user.remind_traffic;sessions.value=await api('/user/getActiveSession')||[];}catch(e){error.value=e.message;}}onMounted(load);
async function save(){await action(()=>api('/user/update',{method:'POST',data:{remind_expire:expire.value?1:0,remind_traffic:traffic.value?1:0}}),'提醒偏好已保存');}
async function change(){await action(async()=>{if(newPassword.value!==again.value)throw new Error('两次新密码不一致');await api('/user/changePassword',{method:'POST',data:{old_password:oldPassword.value,new_password:newPassword.value}});oldPassword.value='';newPassword.value='';again.value='';await load();},'密码已更新');}
async function revoke(session){if(!await askConfirmation('确认退出这台设备?'))return;await action(async()=>{await api('/user/removeActiveSession',{method:'POST',data:{session_id:session.id}});await load();},'设备会话已退出');}
</script><template><section class="page-heading"><div><span class="eyebrow">04 / MAKE YOURSELF AT HOME</span><h1>你的空间,你作主<span class="lime-dot">.</span></h1><p>账户信息、提醒偏好与登录设备。</p></div><span class="number-tag">HN / ACCOUNT</span></section><p v-if="error" class="error-note" role="alert">{{error}}</p><div class="account-grid"><section class="panel account-profile"><span class="avatar large">{{(state.user?.email||'H').slice(0,1).toUpperCase()}}</span><h2>{{state.user?.email?.split('@')[0]||'探索者'}}</h2><p class="muted email">{{state.user?.email||'正在读取…'}}</p><span class="account-tag">HARDNERD MEMBER</span><div class="profile-detail"><span>加入时间</span><b>{{date(state.user?.created_at)}}</b></div><div class="profile-detail"><span>账户余额</span><b>¥{{money(state.user?.balance)}}</b></div><div class="profile-detail"><span>上次登录</span><b>{{date(state.user?.last_login_at)}}</b></div><RouterLink to="/invite" class="button outline full"><Icon name="spark"/>邀请好友</RouterLink></section><div class="account-settings"><section class="panel"><div class="panel-title"><h2>消息提醒</h2><span class="mono">PREFERENCES</span></div><form @submit.prevent="save"><label class="toggle-row"><span><b>套餐到期提醒</b><small>接收账户套餐到期相关提醒</small></span><input type="checkbox" role="switch" v-model="expire"/></label><label class="toggle-row"><span><b>流量使用提醒</b><small>接收流量用量相关提醒</small></span><input type="checkbox" role="switch" v-model="traffic"/></label><button class="button dark" :disabled="state.busy||!state.user">保存偏好<Icon name="check"/></button></form></section><section class="panel"><div class="panel-title"><h2>修改密码</h2><span class="mono">SECURITY</span></div><form @submit.prevent="change"><label>当前密码<input v-model="oldPassword" type="password" autocomplete="current-password" required/></label><div class="field-pair"><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="8" required/></label><label>确认新密码<input v-model="again" type="password" autocomplete="new-password" minlength="8" required/></label></div><button class="button dark" :disabled="state.busy">更新密码<Icon name="arrow"/></button></form></section><section class="panel"><div class="panel-title"><h2>登录设备</h2><span class="mono">SESSIONS</span></div><p v-if="!sessions.length" class="muted">暂无设备记录。</p><div v-for="session in sessions" :key="session.id" class="session-row"><div><b>{{session.device_info?.device||session.device_info?.browser||'登录设备'}}</b><small>{{date(session.last_used_at?Date.parse(session.last_used_at)/1000:0)}}</small></div><button @click="revoke(session)" class="text-button" :disabled="state.busy">退出此设备 ↗</button></div></section></div></div></template>

View File

@ -1,15 +0,0 @@
<script setup>
import {ref,computed,watch,onMounted} from 'vue';import {useRoute,useRouter} from 'vue-router';import Icon from '../Icon.vue';import Captcha from '../Captcha.vue';import {state,api,signIn,action,notify} from '../state.mjs';
const logo=window.settings?.logo||'/brand/hn/logo.svg';
const route=useRoute(),router=useRouter(),email=ref(''),password=ref(''),again=ref(''),code=ref(''),invite=ref(String(route.query.code||'')),remember=ref(false),agreed=ref(false),captcha=ref(null),sending=ref(false),countdown=ref(0);let interval;
const mode=computed(()=>route.path.slice(1)),isLogin=computed(()=>mode.value==='login'),isForgot=computed(()=>mode.value==='forgot-password');const title=computed(()=>isLogin.value?'回来,继续探索。':isForgot.value?'找回你的连接。':'很高兴,认识你。');
watch(()=>route.path,()=>{password.value='';again.value='';code.value='';captcha.value?.reset();});
import {onBeforeUnmount} from 'vue';onBeforeUnmount(()=>clearInterval(interval));
function destination(){const redirect=String(route.query.redirect||'');return redirect.startsWith('/')&&!redirect.startsWith('//')?redirect:'/dashboard';}
async function mailLogin(){if(!email.value){notify('请先填写邮箱');return;}await action(()=>api('/passport/auth/loginWithMailLink',{method:'POST',data:{email:email.value,redirect:'/dashboard'}}),'登录邮件已提交,请检查邮箱');}
onMounted(()=>{if(isLogin.value&&route.query.verify)action(async()=>{const data=await api('/passport/auth/token2Login?verify='+encodeURIComponent(route.query.verify));signIn(data.auth_data);router.replace(destination());});});
async function reconnect(){await action(async()=>{state.config=await api('/guest/comm/config')||{};state.configReady=true;state.configError=false;});}
async function sendCode(){if(!email.value){notify('请先填写邮箱');return;}if(sending.value||countdown.value)return;sending.value=true;try{await api('/passport/comm/sendEmailVerify',{method:'POST',data:{email:email.value,...await captcha.value?.payload()}});notify('验证码已发送,请检查邮箱');countdown.value=60;interval=setInterval(()=>{if(--countdown.value<=0)clearInterval(interval);},1000);}catch(error){notify(error.message);}finally{sending.value=false;captcha.value?.reset();}}
async function submit(){await action(async()=>{if(!isLogin.value&&password.value!==again.value)throw new Error('两次密码不一致');if(!isLogin.value&&!isForgot.value&&state.config.tos_url&&!agreed.value)throw new Error('请先阅读并同意服务条款');if(isLogin.value){const data=await api('/passport/auth/login',{method:'POST',data:{email:email.value,password:password.value}});signIn(data.auth_data,remember.value);router.push(destination());}else if(isForgot.value){await api('/passport/auth/forget',{method:'POST',data:{email:email.value,password:password.value,email_code:code.value}});notify('密码已更新,请登录');router.push('/login');}else{const data=await api('/passport/auth/register',{method:'POST',data:{email:email.value,password:password.value,email_code:code.value,invite_code:invite.value,...await captcha.value?.payload()}});signIn(data.auth_data);router.push('/dashboard');}});if(!isLogin.value)captcha.value?.reset();}
</script>
<template><div class="auth-layout"><aside class="auth-art"><RouterLink to="/" class="brand"><img :src="logo" alt=""/>hardnerd<sup>NET</sup></RouterLink><span class="eyebrow">A SPACE FOR THE CURIOUS.</span><div class="auth-slogan">世界很大。<br/>你的连接,<br/>可以<span>更远。</span></div><div class="auth-diagram" aria-hidden="true"><div class="diagram-ring"></div><span>HN</span><i></i><b>↗</b></div><div class="mono">STAY CURIOUS. / CONNECT DIFFERENT.</div></aside><main id="main" class="auth-main" tabindex="-1"><RouterLink to="/" class="auth-back">← 回到首页</RouterLink><div class="auth-form"><span class="eyebrow">{{isLogin?'WELCOME BACK':isForgot?'START AGAIN':'HELLO, HARDNERD.'}}</span><h1>{{title}}</h1><p class="muted">{{isLogin?'登录你的 hardnerd 空间。':isForgot?'通过邮箱验证码重设密码。':'用一个账户,开始新的连接。'}}</p><p v-if="state.configError" class="error-note">站点设置暂未加载。<button class="text-button" @click="reconnect">重新连接 ↗</button></p><form @submit.prevent="submit"><label>邮箱<input v-model="email" type="email" autocomplete="email" placeholder="you@example.com" required/></label><label>{{isForgot?'新密码':'密码'}}<input v-model="password" type="password" :autocomplete="isLogin?'current-password':'new-password'" :minlength="isLogin?1:8" placeholder="请输入密码" required/></label><label v-if="!isLogin">确认密码<input v-model="again" type="password" autocomplete="new-password" minlength="8" placeholder="再次输入密码" required/></label><label v-if="!isLogin&&(state.config.is_email_verify||isForgot)">邮箱验证码<div class="input-action"><input v-model="code" inputmode="numeric" maxlength="6" pattern="[0-9]{6}" required placeholder="6 位验证码"/><button type="button" :disabled="sending||countdown>0||!state.configReady" @click="sendCode">{{countdown?countdown+' 秒':sending?'发送中…':'发送验证码'}}</button></div></label><label v-if="!isLogin&&!isForgot">邀请码<span class="muted">{{state.config.is_invite_force?'(必填)':'(可选)'}}</span><input v-model="invite" :required="!!state.config.is_invite_force" placeholder="如果朋友邀请了你"/></label><Captcha v-if="!isLogin&&state.configReady" :key="mode" ref="captcha"/><div class="auth-options" v-if="isLogin"><label class="checkbox"><input v-model="remember" type="checkbox"/>记住这次登录</label><RouterLink to="/forgot-password">忘记密码?</RouterLink></div><label v-else-if="!isForgot&&state.config.tos_url" class="checkbox"><input v-model="agreed" type="checkbox" required/>我已阅读并同意<a v-if="state.config.tos_url" :href="state.config.tos_url" target="_blank" rel="noopener noreferrer">服务条款 ↗</a><span v-else>服务条款(尚未提供)</span></label><button class="button primary full" :disabled="state.busy||!state.configReady">{{state.busy?'正在处理…':!state.configReady?'正在连接…':isLogin?'进入我的空间':isForgot?'更新密码':'创建账户'}}<Icon name="arrow"/></button></form><button v-if="isLogin" class="text-button mail-login" @click="mailLogin" :disabled="state.busy||!state.configReady">使用邮件链接登录 ↗</button><p class="auth-switch">{{isLogin?'还没有账户?':'已经有账户?'}}<RouterLink :to="isLogin?'/register':'/login'">{{isLogin?'加入 hardnerd':'登录'}} ↗</RouterLink></p></div><footer class="mono">HARDNERD.NET / KEEP EXPLORING.</footer></main></div></template>

View File

@ -1,5 +0,0 @@
<script setup>
import {computed,ref,onMounted} from 'vue';import Icon from '../Icon.vue';import {state,api,notify,copy,loadAccount} from '../state.mjs';import {money,bytes,date,usage} from '../core.mjs';import DOMPurify from 'dompurify';
const metric=computed(()=>usage(state.subscription)),notices=ref([]),failed=ref(false),greeting=computed(()=>new Date().getHours()<12?'上午好':new Date().getHours()<18?'下午好':'晚上好');
onMounted(()=>api('/user/notice/fetch').then(data=>notices.value=data||[]).catch(()=>failed.value=true));
</script><template><section class="page-heading"><div><span class="eyebrow">01 / YOUR CONNECTION AT A GLANCE</span><h1>{{greeting}},探索者<span class="lime-dot">.</span></h1><p>你的连接近况,都在这里。</p></div><button class="icon-button" @click="loadAccount().catch(error=>notify(error.message))" aria-label="刷新账户数据"><Icon name="refresh"/></button></section><div v-if="!state.ready" class="loading-state" role="status">正在读取你的空间…</div><div v-else-if="!state.user" class="empty panel"><h2>暂时没有读到账户数据</h2><button class="button primary" @click="loadAccount().catch(error=>notify(error.message))">重新加载</button></div><template v-else><div class="dashboard-grid"><section class="connection-card"><div class="connection-top"><span class="eyebrow">MY CONNECTION / 我的连接</span><span class="status-pill" :class="{active:state.subscription?.plan_id}">{{state.subscription?.plan_id?'已有套餐':'尚未订阅'}}</span></div><h2>{{state.subscription?.plan?.name||'为下一次探索,\n选一条新连接。'}}</h2><p>{{state.subscription?.plan_id?'每一次好奇,都值得一个顺畅的开始。':'选择适合的套餐,把世界再打开一点。'}}</p><div class="connection-bottom"><RouterLink :to="state.subscription?.plan_id?'/nodes':'/shop'" class="button light">{{state.subscription?.plan_id?'开始连接':'查看套餐'}}<Icon name="arrow"/></RouterLink><span class="mono">HN / YOUR NEXT CHAPTER</span></div><div class="connection-orbit" aria-hidden="true"><span>↗</span></div></section><section class="usage-card panel"><div class="panel-title"><h2>本期流量</h2><span class="mono">DATA / 01</span></div><div class="usage-body"><div class="usage-ring" :style="{'--usage':metric.percent+'%'}"><div><strong>{{metric.percent.toFixed(0)}}<small>%</small></strong><span>已使用</span></div></div><div class="usage-numbers"><span>剩余流量</span><strong>{{bytes(metric.remaining)}}</strong><span>{{bytes(metric.used)}} / {{bytes(metric.total)}}</span></div></div><div class="usage-footer"><span>套餐到期</span><b>{{state.subscription?.expired_at===null&&state.subscription?.plan_id?'不限时':date(state.subscription?.expired_at)}}</b></div></section></div><div class="stat-strip"><div><span>账户余额</span><strong>¥{{money(state.user.balance)}}</strong><small>用于账户内消费</small></div><div><span>当前套餐</span><strong class="small-value">{{state.subscription?.plan?.name||'暂未订阅'}}</strong><small>{{state.subscription?.plan_id?'查看连接与节点':'选择一个适合你的套餐'}}</small></div><div><span>下次流量重置</span><strong class="small-value">{{date(state.subscription?.next_reset_at)}}</strong><small>以当前套餐规则为准</small></div></div><div class="lower-grid"><section class="panel quick-panel"><div class="panel-title"><h2>快捷连接</h2><span class="mono">ONE LESS STEP.</span></div><button class="quick-row" @click="copy(state.subscription?.subscribe_url)"><span class="square-icon"><Icon name="copy"/></span><div><b>复制订阅地址</b><p>导入你正在使用的客户端</p></div><Icon name="arrow"/></button><RouterLink class="quick-row" to="/docs"><span class="square-icon"><Icon name="book"/></span><div><b>第一次使用?从这里开始</b><p>查看配置和客户端使用指南</p></div><Icon name="arrow"/></RouterLink></section><section class="panel notices"><div class="panel-title"><h2>来自 hardnerd</h2><span class="mono">UPDATES</span></div><p v-if="failed" class="muted">公告暂未加载,稍后重试。</p><div v-else-if="!notices.length" class="quiet-state"><span class="mono">ALL QUIET HERE.</span><p>暂无新公告。继续你的探索吧。</p></div><details v-for="notice in notices" :key="notice.id"><summary>{{notice.title}}<span>{{date(notice.updated_at)}}</span></summary><div class="prose" v-html="DOMPurify.sanitize(notice.content||'')"></div></details></section></div></template></template>

View File

@ -1,11 +0,0 @@
<script setup>
import Icon from '../Icon.vue';import {state} from '../state.mjs';
function jump(id){document.getElementById(id)?.scrollIntoView({behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'});}
const logo=window.settings?.logo||'/brand/hn/logo.svg',brand=window.settings?.title||'hardnerd';
</script>
<template><div class="landing"><header class="landing-nav"><RouterLink to="/" class="brand"><img :src="logo" alt=""/>{{brand}}<sup>NET</sup></RouterLink><nav><a href="#idea" @click.prevent="jump('idea')">我们的主张</a><a href="#start" @click.prevent="jump('start')">开始连接</a></nav><RouterLink :to="state.token?'/dashboard':'/login'" class="nav-enter">{{state.token?'进入控制台':'登录账户'}} <Icon name="arrow"/></RouterLink></header>
<main id="main" tabindex="-1"><section class="hero"><div class="hero-copy"><span class="eyebrow"><span class="tiny-dot"></span> FOR THE CURIOUS. / 为好奇而连接</span><h1>连接<span class="outlined">不止</span><br/>于此<span class="lime-dot">.</span></h1><p>你的世界,不必只有眼前这一面。<br/>把连接交给 hardnerd,把好奇留给自己。</p><div class="hero-actions"><RouterLink :to="state.token?'/dashboard':'/register'" class="button primary">{{state.token?'打开我的空间':'开启你的连接'}}<Icon name="arrow"/></RouterLink><RouterLink to="/login" class="text-button">已有账户?登录 ↗</RouterLink></div><div class="hero-foot mono"><span>01 — STAY CURIOUS</span><span>NETWORK / WITHOUT THE NOISE</span></div></div>
<div class="hero-art" aria-hidden="true"><div class="art-caption mono">THE WORLD IS CLOSER<br/>THAN YOU THINK.</div><svg class="orbit-svg" viewBox="0 0 600 620"><defs><pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="1" fill="#8d9589"/></pattern></defs><rect x="0" y="0" width="600" height="620" fill="url(#dots)"/><g fill="none" stroke="#b9bdb0" stroke-width="1"><circle cx="300" cy="300" r="204"/><ellipse cx="300" cy="300" rx="204" ry="74"/><ellipse cx="300" cy="300" rx="100" ry="204"/><path d="M96 300h408M300 96v408M126 194h348M126 406h348"/></g><g fill="none" stroke="#171b1a" stroke-width="2"><path d="M112 335Q240 110 442 177M183 444Q412 437 442 177M112 335Q227 454 383 350"/></g><g fill="#b4f047" stroke="#171b1a" stroke-width="2"><circle cx="112" cy="335" r="9"/><circle cx="442" cy="177" r="9"/><circle cx="183" cy="444" r="9"/><circle cx="383" cy="350" r="9"/></g><g transform="translate(235 235) rotate(-8 65 65)"><rect width="130" height="130" rx="22" fill="#171b1a"/><path d="M28 26h18v78H28zm56 0h18v78H84" fill="#f4f3ee"/><path d="M20 51h34v25H28L20 68zm57 0h34v17l-8 8H77zM54 60h23" fill="#b4f047" stroke="#b4f047" stroke-width="5"/></g><text x="63" y="571" font-size="13" font-family="monospace">HN / 001</text><path d="M461 534h60m-10-10 10 10-10 10" fill="none" stroke="#171b1a" stroke-width="2"/></svg><span class="art-note mono">OPEN A NEW PERSPECTIVE ↗</span></div></section>
<div class="brand-band"><span>STAY CURIOUS.</span><span class="band-star">✳</span><span>CONNECT DIFFERENT.</span><span class="band-star">✳</span><span>HARDNERD.</span></div>
<section id="idea" class="manifesto"><div><span class="eyebrow">02 / LESS FRICTION. MORE WORLD.</span><h2>让连接简单。<br/>把空间留给探索。</h2></div><div class="principles"><article><span class="mono">01</span><div><h3>看得清楚,用得明白</h3><p>套餐、用量与到期时间,一眼就能找到。每一次选择,都有清楚的信息。</p></div></article><article><span class="mono">02</span><div><h3>从这里,开始连接</h3><p>订阅入口、节点列表与使用指南,集中在你的空间里。少一点寻找,多一点行动。</p></div></article><article><span class="mono">03</span><div><h3>遇到问题,有处可问</h3><p>把问题交给工单,让沟通有记录,让进展可追踪。</p></div></article></div></section>
<section id="start" class="start-panel"><span class="eyebrow">YOUR NEXT CONNECTION STARTS HERE.</span><h2>下一站,<br/>由你定义<span>.</span></h2><RouterLink :to="state.token?'/shop':'/register'" class="button light">{{state.token?'选择适合的套餐':'加入 hardnerd'}}<Icon name="arrow"/></RouterLink><span class="start-mark" aria-hidden="true">↗</span></section></main><footer class="landing-footer"><span class="brand">{{brand}}.net</span><span>© {{new Date().getFullYear()}} {{brand}} · 保持好奇。</span><RouterLink to="/login">用户登录 ↗</RouterLink></footer></div></template>

View File

@ -1,9 +0,0 @@
<script setup>
import {ref,onMounted,watch,computed,onBeforeUnmount} from 'vue';import {useRoute,useRouter} from 'vue-router';import QRCode from 'qrcode';import Icon from '../Icon.vue';import {api,state,action,notify,loadAccount,askConfirmation} from '../state.mjs';import {money,date,statusLabel,periods,safeLink} from '../core.mjs';
const route=useRoute(),router=useRouter(),orders=ref([]),detail=ref(null),methods=ref([]),method=ref(''),loading=ref(true),error=ref(''),paymentLink=ref(''),qr=ref('');let generation=0;const paying=computed(()=>route.path==='/payment');
async function load(){const n=++generation;loading.value=true;error.value='';paymentLink.value='';qr.value='';try{if(paying.value){const [order,payments]=await Promise.all([api('/user/order/detail?trade_no='+encodeURIComponent(route.query.trade_no||'')),api('/user/order/getPaymentMethod')]);if(n!==generation)return;detail.value=order;methods.value=payments||[];method.value=methods.value[0]?.id||'';}else{const value=await api('/user/order/fetch');if(n===generation)orders.value=value||[];}}catch(e){if(n===generation)error.value=e.message;}finally{if(n===generation)loading.value=false;}}
onMounted(load);watch(()=>route.fullPath,load);onBeforeUnmount(()=>generation++);
async function cancel(order){if(!await askConfirmation('确认取消这笔待支付订单?'))return;await action(async()=>{await api('/user/order/cancel',{method:'POST',data:{trade_no:order.trade_no}});await load();},'订单已取消');}
async function checkout(){await action(async()=>{const result=await api('/user/order/checkout',{method:'POST',data:{trade_no:detail.value.trade_no,method:method.value},raw:true});if(result.type===1){const link=safeLink(result.data,window.location.origin);if(!link)throw new Error('支付链接格式不正确,请联系支持');paymentLink.value=link;}else if(result.type===0){qr.value=await QRCode.toDataURL(String(result.data));}else{await load();await loadAccount();notify('订单状态已更新');}});}
async function refresh(){await action(async()=>{await api('/user/order/check?trade_no='+encodeURIComponent(detail.value.trade_no));await load();await loadAccount();},'已刷新订单状态');}
</script><template><section class="page-heading"><div><span class="eyebrow">03 / EVERY STEP, IN ONE PLACE</span><h1>{{paying?'完成这次连接':'你的每一次选择'}}<span class="lime-dot">.</span></h1><p>{{paying?'订单已创建。选择支付方式,完成开通。':'订单记录与处理进度,清楚可查。'}}</p></div><RouterLink v-if="paying" to="/orders" class="text-button">← 返回订单</RouterLink><button v-else class="icon-button" @click="load" aria-label="刷新订单"><Icon name="refresh"/></button></section><div v-if="loading" class="loading-state" role="status">正在读取订单…</div><div v-else-if="error" class="empty panel"><h2>暂时无法读取订单</h2><p>{{error}}</p><button class="button primary" @click="load">重试</button></div><div v-else-if="paying&&detail" class="checkout-grid"><section class="panel checkout-plan"><span class="status-pill" :class="{active:detail.status===3}">{{statusLabel(detail.status)}}</span><h2>{{detail.plan?.name||'套餐订单'}}</h2><p class="mono order-number">{{detail.trade_no}}</p><div class="summary-line"><span>计费周期</span><b>{{periods[detail.period]||detail.period}}</b></div><div class="summary-line"><span>创建时间</span><b>{{date(detail.created_at)}}</b></div><div class="summary-line"><span>订单金额</span><b>¥{{money(detail.total_amount)}}</b></div><div class="summary-line" v-if="detail.handling_amount"><span>手续费</span><b>¥{{money(detail.handling_amount)}}</b></div><div class="summary-total"><span>应付金额</span><strong>¥{{money(Number(detail.total_amount)+Number(detail.handling_amount||0))}}</strong></div><button class="text-button" v-if="detail.status===0" @click="cancel(detail)" :disabled="state.busy">取消订单</button></section><aside class="panel checkout-summary"><span class="eyebrow">NEXT STEP</span><h2>{{detail.status===0?'选择支付方式':'订单进度'}}</h2><template v-if="detail.status===0"><div v-if="!methods.length" class="quiet-state"><p>暂未配置可用支付方式。</p><RouterLink to="/tickets" class="text-button">联系支持 ↗</RouterLink></div><label class="payment-option" v-for="item in methods" :key="item.id"><input type="radio" v-model="method" :value="item.id"/>{{item.name}}<span v-if="item.handling_fee_percent||item.handling_fee_fixed" class="hint">手续费 {{item.handling_fee_percent||0}}% + ¥{{money(item.handling_fee_fixed)}}</span></label><button class="button primary full" v-if="methods.length" :disabled="state.busy||!method" @click="checkout">{{state.busy?'正在处理…':'获取支付入口'}}<Icon name="arrow"/></button><a v-if="paymentLink" class="button dark full" :href="paymentLink" target="_blank" rel="noopener noreferrer">前往支付页面 ↗</a><img v-if="qr" class="payment-qr" :src="qr" alt="订单支付二维码"/><p v-if="paymentLink||qr" class="hint">支付完成后,点击下方按钮刷新订单状态。</p></template><p v-else class="muted">{{detail.status===3?'订单已完成,可以开始使用连接。':detail.status===2?'这笔订单已取消。':'订单正在处理中,请稍后刷新。'}}</p><button class="button outline full" @click="refresh" :disabled="state.busy"><Icon name="refresh"/>刷新订单状态</button><RouterLink v-if="detail.status===3" to="/dashboard" class="text-button">返回我的空间 ↗</RouterLink></aside></div><div v-else-if="!orders.length" class="empty panel"><Icon name="receipt"/><span class="eyebrow">A FRESH START.</span><h2>第一笔连接,还在前方。</h2><p>你还没有订单。找到适合的套餐,开始新的探索。</p><RouterLink to="/shop" class="button primary">浏览套餐<Icon name="arrow"/></RouterLink></div><section v-else class="panel order-table"><div class="table-row table-head"><span>订单 / 套餐</span><span>金额</span><span>日期</span><span>状态</span><span></span></div><div class="table-row" v-for="order in orders" :key="order.trade_no"><div><b>{{order.plan?.name||'套餐订单'}}</b><small class="mono">{{order.trade_no}}</small></div><strong>¥{{money(order.total_amount)}}</strong><span>{{date(order.created_at)}}</span><span class="status-pill" :class="{active:order.status===3}">{{statusLabel(order.status)}}</span><button class="text-button" @click="router.push({path:'/payment',query:{trade_no:order.trade_no}})">{{order.status===0?'继续支付':'查看详情'}} ↗</button></div></section></template>

View File

@ -1,10 +0,0 @@
<script setup>
import {ref,computed,onMounted,watch} from 'vue';import {useRoute,useRouter} from 'vue-router';import Icon from '../Icon.vue';import {state,api,action,notify} from '../state.mjs';import {money,periods,planPeriods} from '../core.mjs';import DOMPurify from 'dompurify';
const route=useRoute(),router=useRouter(),plans=ref([]),loading=ref(true),error=ref(''),selections=ref({}),coupon=ref(''),couponInfo=ref(null);const selected=computed(()=>plans.value.find(p=>String(p.id)===String(route.query.id))),confirming=computed(()=>route.path==='/order-confirm');const period=computed(()=>selected.value?selections.value[selected.value.id]:'');const total=computed(()=>{let cents=Number(selected.value?.[period.value]||0);if(couponInfo.value?.type===1)cents-=Number(couponInfo.value.value||0);if(couponInfo.value?.type===2)cents*=Math.max(0,1-Number(couponInfo.value.value||0)/100);return Math.max(0,cents);});
async function load(){loading.value=true;error.value='';try{plans.value=await api('/user/plan/fetch')||[];for(const plan of plans.value){const options=planPeriods(plan);selections.value[plan.id]=options.includes(route.query.period)?route.query.period:options[0];}}catch(e){error.value=e.message;}finally{loading.value=false;}}
onMounted(load);watch(()=>route.fullPath,()=>{coupon.value='';couponInfo.value=null;if(selected.value&&planPeriods(selected.value).includes(route.query.period))selections.value[selected.value.id]=route.query.period;});watch(coupon,()=>couponInfo.value=null);
function choose(plan){router.push({path:'/order-confirm',query:{id:plan.id,period:selections.value[plan.id]}});}
async function verify(){await action(async()=>{couponInfo.value=await api('/user/coupon/check',{method:'POST',data:{code:coupon.value,plan_id:selected.value.id,period:period.value}});},'优惠码已验证');}
async function submit(){await action(async()=>{const trade=await api('/user/order/save',{method:'POST',data:{plan_id:selected.value.id,period:period.value,...(coupon.value?{coupon_code:coupon.value}:{})}});router.push({path:'/payment',query:{trade_no:trade}});});}
function content(plan){const limit=plan.display_device_limit??plan.device_limit;return DOMPurify.sanitize(String(plan.content||'').replaceAll('{{display_device_limit}}',limit==null||Number(limit)===0?'不限':String(limit)));}
</script><template><section class="page-heading"><div><span class="eyebrow">02 / FIND YOUR CONNECTION</span><h1>{{confirming?'确认你的选择':'给好奇,留点余量'}}<span class="lime-dot">.</span></h1><p>{{confirming?'核对套餐与周期,再创建订单。':'根据实际需求,找到适合的连接方式。'}}</p></div><RouterLink v-if="confirming" to="/shop" class="text-button">← 返回套餐列表</RouterLink><span v-else class="number-tag">HN / PLANS</span></section><div v-if="loading" class="loading-state" role="status">正在读取套餐…</div><div v-else-if="error" class="empty panel" role="alert"><h2>暂时无法读取套餐</h2><p>{{error}}</p><button class="button primary" @click="load">重试</button></div><template v-else-if="confirming"><div v-if="selected" class="checkout-grid"><section class="panel checkout-plan"><span class="eyebrow">YOUR SELECTION</span><h2>{{selected.name}}</h2><div class="prose" v-html="content(selected)"></div><h3>选择计费周期</h3><div class="period-grid"><button v-for="key in planPeriods(selected)" :key="key" :class="{selected:period===key}" @click="selections[selected.id]=key;couponInfo=null"><span>{{periods[key]}}</span><strong>¥{{money(selected[key])}}</strong></button></div><div class="plan-facts"><span>流量额度 <b>{{selected.transfer_enable}} GB</b></span><span>速率上限 <b>{{selected.speed_limit==null?'不限速':selected.speed_limit+' Mbps'}}</b></span></div></section><aside class="panel checkout-summary"><span class="eyebrow">ORDER SUMMARY</span><h2>订单摘要</h2><label>优惠码<div class="input-action"><input v-model="coupon" placeholder="如果你有优惠码"/><button @click="verify" :disabled="!coupon||state.busy">验证</button></div></label><div class="summary-line"><span>{{selected.name}}</span><b>{{periods[period]}}</b></div><div class="summary-line"><span>套餐金额</span><b>¥{{money(selected[period])}}</b></div><div v-if="couponInfo" class="summary-line"><span>优惠</span><b>−¥{{money(selected[period]-total)}}</b></div><div class="summary-total"><span>预计金额</span><strong>¥{{money(total)}}</strong></div><p class="hint">最终应付金额及手续费以创建后的订单为准。</p><button class="button primary full" @click="submit" :disabled="state.busy||!period">{{state.busy?'正在创建…':'创建订单'}}<Icon name="arrow"/></button></aside></div><div v-else class="empty panel"><h2>没有找到这个套餐</h2><RouterLink to="/shop" class="button primary">返回套餐列表</RouterLink></div></template><template v-else><div v-if="!plans.length" class="empty panel"><Icon name="bag"/><h2>套餐正在准备中</h2><p>目前暂无可购买套餐,请稍后回来看看。</p></div><div v-else class="plan-grid"><article class="plan-card" v-for="(plan,index) in plans" :key="plan.id"><div class="plan-top"><span class="mono">PLAN / {{String(index+1).padStart(2,'0')}}</span><Icon name="spark"/></div><h2>{{plan.name}}</h2><div class="plan-price"><span>¥</span><strong>{{money(plan[selections[plan.id]])}}</strong><small>/ {{periods[selections[plan.id]]}}</small></div><label class="period-select">计费周期<select v-model="selections[plan.id]"><option v-for="key in planPeriods(plan)" :key="key" :value="key">{{periods[key]}} · ¥{{money(plan[key])}}</option></select></label><div class="plan-facts"><span><Icon name="check"/>{{plan.transfer_enable}} GB 流量</span><span><Icon name="check"/>{{plan.speed_limit==null?'不限速':plan.speed_limit+' Mbps 速率上限'}}</span><span><Icon name="check"/>{{(plan.display_device_limit??plan.device_limit)?(plan.display_device_limit??plan.device_limit)+' 台设备':'设备数量不限'}}</span></div><div class="prose plan-description" v-html="content(plan)"></div><button class="button primary full" :disabled="!selections[plan.id]" @click="choose(plan)">选择这个套餐<Icon name="arrow"/></button></article></div><div class="help-strip"><span><Icon name="help"/>不确定怎么选?告诉我们你的使用需求。</span><RouterLink to="/tickets">联系支持 ↗</RouterLink></div></template></template>

View File

@ -1,23 +0,0 @@
<script setup>
import {ref,computed,onMounted,watch,onBeforeUnmount} from 'vue';import {useRoute,useRouter} from 'vue-router';import DOMPurify from 'dompurify';import {marked} from 'marked';import Icon from '../Icon.vue';import {state,api,action,copy,askConfirmation} from '../state.mjs';import {date,money,safeLink} from '../core.mjs';
const route=useRoute(),router=useRouter(),data=ref(null),loading=ref(true),error=ref(''),search=ref(''),creating=ref(false),subject=ref(''),message=ref(''),level=ref(0),reply=ref('');let generation=0;
const kind=computed(()=>route.path.slice(1)),heads={nodes:['05 / GET CONNECTED','把世界,接到眼前。','订阅地址、可用节点与连接入口。'],tickets:['06 / WE ARE HERE TO HELP','问题,交给我们。','新的工单、沟通记录和回复进展。'],docs:['07 / A LITTLE GUIDANCE','好连接,从懂得开始。','使用指南与常见问题。'],invite:['08 / GOOD THINGS TRAVEL','好连接,值得分享。','邀请好友,以及你的邀请记录。']};
const heading=computed(()=>heads[kind.value]);const filtered=computed(()=>{const values=Array.isArray(data.value)?data.value:[];return values.filter(item=>(item.name||item.subject||'').toLowerCase().includes(search.value.toLowerCase()));});
const documents=computed(()=>data.value&&kind.value==='docs'&&!route.query.id?Object.entries(data.value).flatMap(([category,items])=>(Array.isArray(items)?items:[]).map(item=>({...item,category}))):[]);const foundDocs=computed(()=>documents.value.filter(item=>(item.title||'').includes(search.value)));
function article(body){return DOMPurify.sanitize(marked.parse(String(body||'')));}
async function load(){const n=++generation;loading.value=true;error.value='';data.value=null;search.value='';try{let path=kind.value==='nodes'?'/user/server/fetch':kind.value==='tickets'?'/user/ticket/fetch':kind.value==='docs'?'/user/knowledge/fetch?language=zh-CN':'/user/invite/fetch';if(route.query.id)path+=(path.includes('?')?'&':'?')+'id='+encodeURIComponent(route.query.id);const value=await api(path);if(n===generation)data.value=value;}catch(e){if(n===generation)error.value=e.message;}finally{if(n===generation)loading.value=false;}}
onMounted(load);watch(()=>route.fullPath,()=>{creating.value=false;reply.value='';load();});onBeforeUnmount(()=>generation++);
async function createTicket(){await action(async()=>{await api('/user/ticket/save',{method:'POST',data:{subject:subject.value,message:message.value,level:level.value}});creating.value=false;subject.value='';message.value='';await load();},'工单已提交');}
async function sendReply(){await action(async()=>{await api('/user/ticket/reply',{method:'POST',data:{id:data.value.id,message:reply.value}});reply.value='';await load();},'回复已发送');}
async function closeTicket(){if(!await askConfirmation('确认关闭这张工单?'))return;await action(async()=>{await api('/user/ticket/close',{method:'POST',data:{id:data.value.id}});await load();},'工单已关闭');}
async function inviteCode(){await action(async()=>{await api('/user/invite/save');await load();},'邀请码已创建');}
function invitation(code){return window.location.origin+window.location.pathname+'#/register?code='+encodeURIComponent(code);}
const subscribeLink=computed(()=>safeLink(state.subscription?.subscribe_url,window.location.origin));
</script><template><section class="page-heading"><div><span class="eyebrow">{{heading[0]}}</span><h1>{{heading[1]}}</h1><p>{{heading[2]}}</p></div><button v-if="kind==='tickets'&&!route.query.id" class="button primary" @click="creating=!creating"><Icon name="plus"/>新建工单</button><RouterLink v-else-if="route.query.id" :to="'/'+kind" class="text-button">← 返回列表</RouterLink><span v-else class="number-tag">HN / {{kind.toUpperCase()}}</span></section>
<section v-if="kind==='nodes'" class="panel subscribe-panel"><div><span class="eyebrow">YOUR SUBSCRIPTION</span><h2>一个地址,把连接带走。</h2><p class="muted">复制订阅地址,在客户端中导入。订阅地址仅供你本人使用。</p></div><div class="subscription-actions"><button class="button primary" :disabled="!state.subscription?.subscribe_url" @click="copy(state.subscription?.subscribe_url)"><Icon name="copy"/>复制订阅地址</button><RouterLink to="/docs" class="button outline">查看使用指南<Icon name="arrow"/></RouterLink></div></section>
<form v-if="kind==='tickets'&&creating" class="panel ticket-form" @submit.prevent="createTicket"><div class="panel-title"><h2>把问题说清楚,我们更好帮到你。</h2><button type="button" class="icon-button" @click="creating=false" aria-label="关闭新工单"><Icon name="close"/></button></div><div class="field-pair"><label>问题主题<input v-model="subject" required maxlength="100" placeholder="例如:客户端无法导入订阅"/></label><label>优先级<select v-model="level"><option :value="0">一般</option><option :value="1">中等</option><option :value="2">紧急</option></select></label></div><label>问题描述<textarea v-model="message" required rows="5" placeholder="描述遇到的问题、使用的客户端及已经尝试的步骤。"></textarea></label><button class="button primary" :disabled="state.busy">提交工单<Icon name="arrow"/></button></form>
<div v-if="loading" class="loading-state" role="status">正在读取{{kind==='tickets'?'工单':kind==='nodes'?'节点':kind==='docs'?'指南':'邀请记录'}}…</div><div v-else-if="error" class="empty panel"><h2>暂时无法读取内容</h2><p>{{error}}</p><button class="button primary" @click="load">重试</button></div>
<template v-else-if="kind==='nodes'"><div class="list-toolbar"><h2>可用节点 <span class="count">{{Array.isArray(data)?data.length:0}}</span></h2><input v-model="search" aria-label="搜索节点" placeholder="搜索节点名称…"/></div><div v-if="!filtered.length" class="empty panel"><Icon name="globe"/><h2>{{state.subscription?.plan_id?'暂无可用节点':'你的第一条连接,还没开始。'}}</h2><p>{{state.subscription?.plan_id?'请稍后重试,或通过工单联系支持。':'购买套餐后,可在这里查看对应节点。'}}</p><RouterLink v-if="!state.subscription?.plan_id" to="/shop" class="button primary">查看套餐<Icon name="arrow"/></RouterLink></div><div v-else class="node-grid"><article class="panel node-card" v-for="node in filtered" :key="node.cache_key"><Icon name="globe"/><h3>{{node.name}}</h3><span class="status-pill" :class="{active:node.is_online}">{{node.is_online?'在线':'离线'}}</span><div class="node-meta"><span>{{node.type}}</span><span>{{node.rate}}× 倍率</span></div></article></div></template>
<template v-else-if="kind==='tickets'"><section v-if="route.query.id&&data" class="panel conversation"><div class="panel-title"><h2>{{data.subject}}</h2><span class="status-pill">{{data.status?'已关闭':'处理中'}}</span></div><article v-for="item in data.message||[]" :key="item.id" class="message" :class="{'from-me':item.is_me}"><span class="mono">{{item.is_me?'我':'hardnerd 支持'}} · {{date(item.created_at)}}</span><p>{{item.message}}</p></article><form v-if="!data.status" @submit.prevent="sendReply"><label>补充回复<textarea v-model="reply" required rows="4" placeholder="补充新的信息…"></textarea></label><div class="form-actions"><button class="button primary" :disabled="state.busy">发送回复<Icon name="arrow"/></button><button type="button" class="text-button" @click="closeTicket" :disabled="state.busy">关闭工单</button></div></form></section><div v-else-if="!filtered.length" class="empty panel"><Icon name="help"/><h2>这里很安静。</h2><p>你还没有工单。有问题时,我们就在这里。</p><button class="button primary" @click="creating=true">创建第一张工单<Icon name="arrow"/></button></div><section v-else class="panel ticket-list"><button class="ticket-row" v-for="ticket in filtered" :key="ticket.id" @click="router.push({path:'/tickets',query:{id:ticket.id}})"><span class="mono">#{{ticket.id}}</span><div><b>{{ticket.subject}}</b><small>{{date(ticket.updated_at)}}</small></div><span class="status-pill" :class="{active:ticket.reply_status===0&&!ticket.status}">{{ticket.status?'已关闭':ticket.reply_status===0?'待回复':'有新回复'}}</span><Icon name="arrow"/></button></section></template>
<template v-else-if="kind==='docs'"><article v-if="route.query.id&&data" class="panel article"><span class="eyebrow">{{data.category}}</span><h2>{{data.title}}</h2><div class="prose" v-html="article(data.body)"></div></article><template v-else><div class="list-toolbar"><h2>探索指南</h2><input v-model="search" aria-label="搜索指南" placeholder="搜索标题…"/></div><div v-if="!foundDocs.length" class="empty panel"><Icon name="book"/><h2>指南正在准备中。</h2><p>如果你需要帮助,可以先创建工单。</p><RouterLink to="/tickets" class="button outline">联系支持<Icon name="arrow"/></RouterLink></div><div v-else class="docs-grid"><RouterLink class="panel doc-card" v-for="doc in foundDocs" :key="doc.id" :to="{path:'/docs',query:{id:doc.id}}"><span class="eyebrow">{{doc.category}}</span><h2>{{doc.title}}</h2><span class="text-button">阅读指南<Icon name="arrow"/></span></RouterLink></div></template></template>
<template v-else-if="kind==='invite'"><section class="invite-hero"><div><span class="eyebrow">SHARE A GOOD CONNECTION.</span><h2>把好奇,<br/>带给朋友。</h2><p>创建你的专属邀请链接,分享给需要的人。</p><button class="button light" @click="inviteCode" :disabled="state.busy"><Icon name="plus"/>创建邀请码</button></div><span class="invite-art" aria-hidden="true">↗<br/>↗</span></section><div class="stat-strip"><div><span>已邀请用户</span><strong>{{data?.stat?.[0]||0}}</strong><small>注册人数</small></div><div><span>累计佣金</span><strong>¥{{money(data?.stat?.[1])}}</strong><small>以实际邀请记录为准</small></div><div><span>可用佣金</span><strong>¥{{money(data?.stat?.[4])}}</strong><small>奖励比例 {{data?.stat?.[3]||0}}%</small></div></div><section class="panel"><div class="panel-title"><h2>你的邀请链接</h2><span class="mono">PASS IT ON.</span></div><p v-if="!data?.codes?.length" class="muted">还没有邀请码,点击上方按钮创建。</p><div v-for="item in data?.codes||[]" :key="item.code" class="invite-row"><span class="mono">{{item.code}}</span><button class="button outline" @click="copy(invitation(item.code))"><Icon name="copy"/>复制邀请链接</button></div></section></template></template>

View File

@ -1,24 +0,0 @@
import {reactive} from 'vue';
const key='hardnerd.auth';
function stored(){try{return sessionStorage.getItem(key)||localStorage.getItem(key)||'';}catch{return '';}}
export const state=reactive({token:stored(),user:null,subscription:null,config:{},configReady:false,configError:false,confirm:null,toast:'',busy:false,ready:false});
let toastTimer;
export function notify(message){state.toast=message;clearTimeout(toastTimer);toastTimer=setTimeout(()=>state.toast='',5000);}
export function signIn(token,remember=false){if(typeof token!=='string'||!token.startsWith('Bearer '))throw new Error('登录凭据格式不正确');localStorage.removeItem(key);sessionStorage.removeItem(key);(remember?localStorage:sessionStorage).setItem(key,token);state.token=token;state.user=null;state.subscription=null;state.ready=false;}
export function signOut(){localStorage.removeItem(key);sessionStorage.removeItem(key);state.token='';state.user=null;state.subscription=null;state.ready=false;}
export async function api(path,{method='GET',data,raw=false}={}){
if(!path.startsWith('/')||path.startsWith('//'))throw new Error('接口地址无效');
const sentToken=state.token;const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),20000);
try{
const response=await fetch('/api/v1'+path,{method,credentials:'same-origin',redirect:'error',signal:controller.signal,headers:{Accept:'application/json',...(sentToken?{Authorization:sentToken}:{}),...(data?{'Content-Type':'application/json'}:{})},...(data?{body:JSON.stringify(data)}:{})});
let body;try{body=await response.json();}catch{throw new Error('服务暂时不可用,请稍后重试');}
if(state.token===sentToken&&(response.status===401||(response.status===403&&body.message==='Unauthorized'))){signOut();window.dispatchEvent(new Event('hardnerd-expired'));}
if(!response.ok){const detail=body.errors?Object.values(body.errors).flat()[0]:null;throw new Error(detail||body.message||'操作失败,请稍后重试');}
return raw?body:body.data;
}catch(error){if(error.name==='AbortError')throw new Error('请求超时,请检查网络后重试');throw error;}finally{clearTimeout(timer);}
}
export async function loadAccount(){const token=state.token;state.ready=false;try{const [user,subscription]=await Promise.all([api('/user/info'),api('/user/getSubscribe')]);if(state.token===token){state.user=user;state.subscription=subscription;}}finally{if(state.token===token)state.ready=true;}}
export async function action(callback,success){if(state.busy)return;state.busy=true;try{await callback();if(success)notify(success);}catch(error){notify(error.message);}finally{state.busy=false;}}
export async function copy(text){if(!text){notify('暂无可复制的内容');return;}try{await navigator.clipboard.writeText(text);notify('已复制');}catch{notify('浏览器未允许复制,请手动选择内容');}}
export function askConfirmation(message){if(state.confirm)state.confirm.resolve(false);return new Promise(resolve=>state.confirm={message,resolve});}

File diff suppressed because one or more lines are too long

View File

@ -1,7 +0,0 @@
import {test} from 'node:test';import assert from 'node:assert/strict';
const storage=()=>{const values=new Map();return {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};};
globalThis.sessionStorage=storage();globalThis.localStorage=storage();globalThis.window=new EventTarget();
const {api,state,signIn}=await import('../src/state.mjs');
test('API never sends credentials to an arbitrary origin',async()=>{let calls=0;globalThis.fetch=async()=>{calls++;throw new Error('unexpected HTTP');};await assert.rejects(api('//external.example/path'),/接口地址无效/);assert.equal(calls,0);});
test('authenticated requests use same-origin APIs and preserve structured errors',async()=>{signIn('Bearer local-test');globalThis.fetch=async(path,options)=>{assert.equal(path,'/api/v1/user/info');assert.equal(options.headers.Authorization,'Bearer local-test');assert.equal(options.redirect,'error');return {ok:false,status:422,json:async()=>({message:'invalid',errors:{email:['邮箱格式不正确']}})};};await assert.rejects(api('/user/info'),/邮箱格式不正确/);});
test('a stale expired request cannot sign out a newer session',async()=>{signIn('Bearer old-local-test');let resolve;globalThis.fetch=()=>new Promise(done=>resolve=done);const pending=api('/user/info');signIn('Bearer new-local-test');resolve({ok:false,status:401,json:async()=>({message:'Unauthorized'})});await assert.rejects(pending,/Unauthorized/);assert.equal(state.token,'Bearer new-local-test');});

View File

@ -1,5 +0,0 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {money,usage,planPeriods,safeLink,statusLabel} from '../src/core.mjs';
test('amounts stay in cents, including zero-price periods',()=>{assert.equal(money(1900),'19.00');assert.equal(money(0),'0.00');assert.deepEqual(planPeriods({month_price:0,year_price:1900,quarter_price:null}),['month_price','year_price']);});
test('traffic meters handle empty, exhausted and over-quota subscriptions',()=>{assert.deepEqual(usage(null),{total:0,used:0,remaining:0,percent:0});assert.equal(usage({transfer_enable:100,u:70,d:50}).remaining,0);assert.equal(usage({transfer_enable:100,u:70,d:50}).percent,100);assert.equal(usage({transfer_enable:100,u:20,d:5}).percent,25);});
test('payment links reject executable and malformed protocols',()=>{assert.equal(safeLink('javascript:alert(1)','https://hardnerd.net'),null);assert.equal(safeLink('data:text/html,payment','https://hardnerd.net'),null);assert.equal(safeLink('https://pay.example.test/order','https://hardnerd.net'),'https://pay.example.test/order');assert.equal(safeLink('/orders','https://hardnerd.net'),'https://hardnerd.net/orders');});
test('order labels preserve backend state meanings',()=>{assert.equal(statusLabel(0),'待支付');assert.equal(statusLabel(2),'已取消');assert.equal(statusLabel(3),'已完成');assert.equal(statusLabel(999),'未知状态');});

View File

@ -1,18 +0,0 @@
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root=path.dirname(fileURLToPath(import.meta.url));
const backend=path.resolve(root,'../..');
const marker=path.join(backend,'storage/framework/hardnerd-vite.hot');
export default defineConfig(({mode})=>({
root,envDir:false,publicDir:false,base:mode==='production'?'/theme/Hardnerd/assets/':'/',
plugins:[vue(),{name:'hardnerd-local-preview',configureServer(server){
server.httpServer?.once('listening',()=>{fs.mkdirSync(path.dirname(marker),{recursive:true});fs.writeFileSync(marker,'http://127.0.0.1:5175');});
server.httpServer?.once('close',()=>{if(fs.existsSync(marker))fs.unlinkSync(marker);});
}}],
resolve:{dedupe:['vue']},
build:{outDir:path.join(backend,'theme/Hardnerd/assets'),assetsDir:'chunks',manifest:true,emptyOutDir:true,sourcemap:false},
server:{host:'127.0.0.1',port:5175,strictPort:true,origin:'http://127.0.0.1:5175',cors:{origin:/^http:\/\/(127\.0\.0\.1|localhost|hn\.loc)(:\d+)?$/},fs:{allow:[root,path.join(backend,'frontends/node_modules')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']},proxy:{'/api':'http://127.0.0.1:8591','/brand':'http://127.0.0.1:8591'}}
}));

View File

@ -1 +0,0 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>hardnerd</title><link rel="icon" href="/brand/hn/logo.svg"></head><body><div id="app"></div><script type="module" src="/main.js"></script></body></html>

View File

@ -1 +0,0 @@
import '../ez-theme/src/main.js';

View File

@ -1,33 +0,0 @@
export function adaptConfig(original, settings, location) {
const config = structuredClone(original);
config.PANEL_TYPE = 'Xboard';
config.API_CONFIG = {urlMode: 'auto', showCheckBackend: false, staticBaseUrl: [], autoConfig: {useSameProtocol: true, appendApiPath: true, apiPath: '/api/v1'}};
config.API_MIDDLEWARE_ENABLED = false;
config.API_MIDDLEWARE_URL = '';
config.API_MIDDLEWARE_KEY = '';
config.SITE_CONFIG.siteName = settings.title || 'hardnerd';
config.SITE_CONFIG.siteDescription = settings.description || 'hardnerd · 探索全球网络';
config.SITE_CONFIG.copyright = `© ${new Date().getFullYear()} ${settings.full_name || 'hardnerd.net'}`;
config.SITE_CONFIG.customLandingPage = '';
config.DEFAULT_CONFIG.defaultTheme = 'dark';
config.DEFAULT_CONFIG.primaryColor = '#8bcf36';
config.AUTH_LAYOUT_CONFIG.splitLayout.leftContent.backgroundImage = '';
config.AUTHORIZED_DOMAINS = [location.hostname];
config.SHOP_CONFIG.showPlanFeatureCards = false;
config.SHOP_CONFIG.popup.enabled = false;
config.CLIENT_CONFIG.showDownloadCard = false;
config.CLIENT_CONFIG.clientLinks = {};
config.PROFILE_CONFIG.showRecentDevices = false;
config.CUSTOMER_SERVICE_CONFIG.enabled = false;
config.TICKET_CONFIG.isImageHosting = false;
config.TICKET_CONFIG.imggbKey = '';
config.MORE_PAGE_CONFIG.enableCustomCards = false;
config.MORE_PAGE_CONFIG.customCards = [];
const scrub = value => {
if (typeof value === 'string' && /ezdemo\.xyz|loliapi\.com|t\.me\/your_|github\.com\/xxx|store\/apps\/xxx|\/app\/xxx/.test(value)) return '';
if (Array.isArray(value)) return value.map(scrub);
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrub(item)]));
return value;
};
return scrub(config);
}

View File

@ -1,21 +0,0 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import {adaptConfig} from '../runtime-config.mjs';
const source=fs.readFileSync(new URL('../../ez-theme/src/config/index.js',import.meta.url),'utf8').replace('export const config','const config');
const window={}; vm.runInNewContext(source,{window});
test('HN uses same-origin Xboard API and never inherits EZ demo services',()=>{
const config=adaptConfig(window.EZ_CONFIG,{title:'hardnerd',full_name:'hardnerd.net'},{hostname:'127.0.0.1'});
assert.equal(config.PANEL_TYPE,'Xboard');
assert.equal(config.API_CONFIG.urlMode,'auto');
assert.deepEqual(config.API_CONFIG.staticBaseUrl,[]);
assert.equal(config.API_CONFIG.autoConfig.apiPath,'/api/v1');
assert.equal(config.SITE_CONFIG.siteName,'hardnerd');
assert.equal(config.CUSTOMER_SERVICE_CONFIG.enabled,false);
assert.equal(config.TICKET_CONFIG.isImageHosting,false);
assert.equal(config.CLIENT_CONFIG.showDownloadCard,false);
assert.doesNotMatch(JSON.stringify(config),/ezdemo\.xyz|loliapi\.com|t\.me\/your_|github\.com\/xxx/);
assert.equal(window.EZ_CONFIG.PANEL_TYPE,'Xiao-V2board');
});

View File

@ -1,40 +0,0 @@
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root = path.dirname(fileURLToPath(import.meta.url));
const backend = path.resolve(root, '../..');
const upstream = path.resolve(root, '../ez-theme');
const upstreamConfig = path.join(upstream, 'src/config/index.js');
const marker = path.join(backend, 'storage/framework/ez-vite.hot');
export default defineConfig(({mode}) => ({
root, envDir: false, base: mode === 'production' ? '/theme/EZ/assets/' : '/__hn_vite/', publicDir: false,
plugins: [{name:'hn-ez-adapter', enforce:'pre',
load(id) {
if (id === upstreamConfig) return `import {config as original} from ${JSON.stringify(upstreamConfig+'?hn-base')}; import {adaptConfig} from ${JSON.stringify(path.join(root,'runtime-config.mjs'))}; export const config=adaptConfig(original,window.settings||{},window.location);`;
},
transform(code, id) {
if (!id.startsWith(upstream + '/src/')) return;
code = code.replaceAll('src="/images/logo.png"', ':src="hnLogo"').replaceAll('/images/logo.png', '/brand/hn/logo.svg');
if (id === path.join(upstream,'src/views/shop/Shop.vue')) {
code = code.replace('v-for="(price, type) in getPlanPrices(plan)"', 'v-for="(price, type) in Object.fromEntries(Object.entries(getPlanPrices(plan)).filter(([, price]) => price !== null))"');
}
if (id === path.join(upstream,'src/i18n/locales/zh-CN.js')) code = code.replaceAll('商店', '购买套餐');
if (id === path.join(upstream,'src/appInit.js')) code = code.replace("app.mount('#app');", "app.config.globalProperties.hnLogo = window.settings?.logo || '/brand/hn/logo.svg'; app.mount('#app');");
if (id === path.join(upstream,'src/api/user.js')) code = code.replace(/export function getIpLocationInfo\(\)\s*\{[\s\S]*?\n\}/, 'export function getIpLocationInfo() { return Promise.resolve({data: null}); }');
return code;
},
configureServer(server) {
server.httpServer?.once('listening', () => {fs.mkdirSync(path.dirname(marker), {recursive:true}); fs.writeFileSync(marker,'http://hn.loc/__hn_vite');});
server.httpServer?.once('close', () => {if(fs.existsSync(marker))fs.unlinkSync(marker);});
}
}, vue({template:{transformAssetUrls:{includeAbsolute:false}}})],
resolve:{alias:{'@':path.join(upstream,'src')}, dedupe:['vue']},
define:{'process.env':{NODE_ENV:mode,VUE_APP_CONFIGJS:'false',VUE_APP_DEBUGGING:'false'},__VUE_OPTIONS_API__:true,__VUE_PROD_DEVTOOLS__:false,__VUE_PROD_HYDRATION_MISMATCH_DETAILS__:false},
css:{preprocessorOptions:{scss:{additionalData:'@use "@/assets/styles/base/variables.scss" as *;'}}},
optimizeDeps:{include:['axios','vue','vue-router','pinia','vue-i18n']},
build:{outDir:path.join(backend,'theme/EZ/assets'),assetsDir:'chunks',manifest:true,emptyOutDir:true,sourcemap:false},
server:{host:'127.0.0.1',port:5184,strictPort:true,origin:'http://hn.loc',allowedHosts:['hn.loc'],hmr:{host:'hn.loc',clientPort:80},cors:{origin:/^http:\/\/(127\.0\.0\.1|localhost|hn\.loc)(:\d+)?$/},
fs:{allow:[path.resolve(root,'..')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']},proxy:{'/api':'http://127.0.0.1:8592','/brand':'http://127.0.0.1:8592'}}
}));

File diff suppressed because it is too large Load Diff

View File

@ -1,72 +0,0 @@
{
"name": "dpl-shared-frontends",
"version": "v0.4.0",
"private": true,
"scripts": {
"dev:hn": "vite --config hn/vite.config.mjs",
"build:hn": "vite build --config hn/vite.config.mjs",
"test:hn": "node --test hn/tests/*.test.mjs",
"dev:hardnerd": "vite --config hardnerd/vite.config.mjs",
"build:hardnerd": "vite build --config hardnerd/vite.config.mjs",
"test:hardnerd": "node --test hardnerd/tests/*.test.mjs",
"dev:design": "npm run dev:tpl-a",
"build:design": "npm run build:tpl-a",
"dev:tpl-a": "npm --prefix tpl-a run dev",
"build:tpl-a": "npm --prefix tpl-a run build"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@originjs/crypto-js-wasm": "^1.1.1",
"@tabler/icons-vue": "^3.30.0",
"@vue/compat": "^3.5.13",
"@vue/runtime-core": "^3.5.13",
"@vue/runtime-dom": "^3.5.13",
"@vue/shared": "^3.5.13",
"@vueuse/core": "^13.0.0",
"@vueuse/motion": "^3.0.3",
"aieditor": "^1.4.1",
"axios": "^0.27.2",
"chart.js": "^3.9.1",
"core-js": "^3.25.0",
"crisp": "^1.0.0",
"crisp-sdk-web": "^1.0.25",
"crypto-js": "^4.2.0",
"disable-devtool": "^0.3.9",
"dompurify": "^3.2.4",
"echarts": "^5.6.0",
"jsencrypt": "^3.3.2",
"markdown-it": "^14.1.0",
"marked": "^15.0.7",
"nprogress": "^0.2.0",
"pinia": "^3.0.3",
"postscribe": "^2.0.8",
"qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0",
"shepherd.js": "^14.5.0",
"vue-confetti": "^2.3.0",
"vue-confetti-explosion": "^1.0.2",
"vue-i18n": "^9.2.2",
"vue-markdown-render": "^2.2.1",
"vue-router": "^4.5.0",
"vue-shepherd": "^5.0.1",
"vue3-qrcode": "^0.0.2"
},
"devDependencies": {
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/compiler-sfc": "^3.2.37",
"autoprefixer": "^10.4.21",
"javascript-obfuscator": "^4.1.1",
"postcss": "^8.5.3",
"postcss-import": "^16.1.0",
"sass": "^1.69.5",
"tailwindcss": "^3.4.17",
"terser": "^5.47.1",
"vite": "^6.3.5",
"vitest": "^3.1.4",
"vue": "^3.5.18"
}
}

View File

@ -1,2 +0,0 @@
/dist/
/node_modules/

View File

@ -1,10 +0,0 @@
# tpl-a 机房主视觉素材
- 方式:内置 imagegen 工具。
- 用途:本地纯前端设计预览首页;属于 AI 生成示意素材,不是 hn 实际机房照片。
- 项目素材:[datacenter-hero-v1.png](public/datacenter-hero-v1.png)。原始文件另保留在 Codex generated_images,未覆盖或删除。
- 消费位置:`src/operator.css` 中的 `.dc-hero`。
## 最终生成提示词
Use case: ads-marketing. Asset type: wide website hero background for a serious data-center network operator, HARDNERD. Primary request: architectural photograph of a modern working data center, real physical infrastructure and professional scale. Long rows of black enterprise server cabinets, fine perforated metal doors, neatly installed network equipment, restrained blue and amber status lights, overhead cable trays, clean cold aisle, polished dark floor. Photorealistic commercial architectural photography, realistic materials, strong depth and precise perspective, natural cool-white ceiling lighting, subtle cool blue reflections, no sci-fi. Composition: wide horizontal 16:9, server cabinets most prominent in right two-thirds, left third darker and less detailed to support white HTML headline overlay. Camera at human eye level, sophisticated industrial documentary mood. No people, no logos, no text, no watermark, no drawn diagrams, no cartoons, no neon laser trails. This is illustrative stock-style imagery, not an identified actual facility.

View File

@ -1,28 +0,0 @@
# tpl-a
版本:1.0.0。用户于 2026-10-14 确认当前效果可作为定稿方案保留,命名为 `tpl-a`,不限定用于 hn。
这是独立的机房网络运营商前端设计方案:写实机房首页、明暗主题、接入方案、服务控制台、网络资源、账单、文档、技术工单和账户页。设计已批准保存;业务代码接入与生产发布尚未完成。不得把设计定稿解释为可以直接上传到 Xboard 后台使用的完整主题包。
## 运行
依赖使用父级 `frontends/package.json` / lockfile 中的 Vue、Vite 与插件。父级依赖安装完成后:
```bash
npm --prefix frontends/tpl-a run dev
npm --prefix frontends/tpl-a run build
```
预览地址 `http://127.0.0.1:8591/#/home`,只监听本机,构建输出 `dist/` 不纳入 Git。构建不会调用 Laravel,也不会发布到任何业务站点。
## 品牌与绑定
`template.json` 登记方案名、版本与批准状态,`site_binding:null` 表示尚未绑定 hn 或其他实例。当前 hardnerd.net / HN 文案为保留视觉效果的演示品牌;替换 `src/brand.mjs` 和 `public/logo.svg` 即可使用其他品牌。实例启动配置、数据库与 `SITE_THEME` 没有因保存本设计而切换。
所有价格、节点、流量和订单为演示;年付按演示月价 × 12 展示,不提交任何业务数据。AI 机房图为示意素材,生成来源与完整提示词见 [ASSETS](ASSETS.md)。
## 定稿保护
- 当前视觉作为 `tpl-a` v1.0.0 保存。新方向另建方案或版本,避免直接覆盖本定稿。
- `approved-files.sha256.json` 记录本次运行源码与素材摘要,供恢复和核对;这不是业务测试或生产验收。
- 本地 Git 提交保存源文件和素材;未推送、未部署。其他功能草稿与运行中的 dpl 独立保留。

View File

@ -1,18 +0,0 @@
{
"template": "tpl-a",
"version": "1.0.0",
"approved_on": "2026-10-14",
"sha256": {
"index.html": "78eb443588cd1913b307132c77b3d75c1760b160a86538f565caa7e466796a25",
"vite.config.mjs": "12553e80844906567b9494b54c74f9711a5e4cbd8474b6ac4e98d945513f0411",
"package.json": "16c48a0e420149ae3068b477ed519e5056eedaed16ddf5eb297d2ce9bfd3c2dc",
"template.json": "c964ca32de98070e6a6271dc4764bde0dbd863456eb52cfdb72a0ce64812220a",
"src/App.vue": "a3eede460b8bb5af3a866c4b969d5a6e33b918e21ea51c24eb8945cf64a59c50",
"src/NetworkLanding.vue": "9583744cb19ac97a47352536dcdf05ad1b626dcfb94ad663a74d20a639f8c212",
"src/brand.mjs": "5929fc76144d3f638db021f097d9cfedfcb9637a4f20558cff178d0a519fb48c",
"src/main.js": "c47b1a723188ca2ad18ab33f4b2c982b3113cc8a616772eb206196ed133d311d",
"src/operator.css": "1037e5edbb19be5db3fb66a9f1efdc09f5da800fc63f088294472632eb8d7d3d",
"public/datacenter-hero-v1.png": "0294026e45c0a3eff894c67e27bc8e2a393cb2ce6c6a15668c167ab93eda07ca",
"public/logo.svg": "a5412a612aab7bb416e63dea3d1a636a6b307adf73e89ec961d45f958f652711"
}
}

View File

@ -1 +0,0 @@
<!doctype html><html lang="zh-CN" data-theme="dark" data-template="tpl-a"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="color-scheme" content="dark light"><title>hardnerd / 全球连接</title><link rel="icon" href="/logo.svg"></head><body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html>

View File

@ -1,9 +0,0 @@
{
"name": "tpl-a",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite --config vite.config.mjs",
"build": "vite build --config vite.config.mjs"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M0 0h52l12 12v52H0z" fill="#f29656"/><text x="32" y="42" text-anchor="middle" font-family="Arial,sans-serif" font-weight="900" font-size="29" letter-spacing="-2" fill="#101d29">HN</text></svg>

Before

Width:  |  Height:  |  Size: 262 B

View File

@ -1,67 +0,0 @@
<script setup>
import {ref,computed,onMounted,onBeforeUnmount,watch} from 'vue';
import NetworkLanding from './NetworkLanding.vue';
import brand from './brand.mjs';
const menu=[['dashboard','控制台'],['shop','接入方案'],['nodes','网络资源'],['orders','订单账单'],['docs','接入指南'],['tickets','技术工单'],['profile','账户设置']];
const getView=()=>{const v=location.hash.replace(/^#\/?/,'');return menu.some(item=>item[0]===v)?v:'home';};
const page=ref(getView()),theme=ref('dark'),mobileMenu=ref(false),notice=ref(''),selected=ref('STANDARD'),period=ref('month'),saved=ref(false),question=ref('');
const title=computed(()=>menu.find(item=>item[0]===page.value)?.[1]||'机房网络运营商');
const plans=[{id:'ENTRY',title:'基础接入',price:12,quota:60,speed:100,devices:2},{id:'STANDARD',title:'标准接入',price:24,quota:200,speed:300,devices:3},{id:'EXTENDED',title:'专业接入',price:48,quota:500,speed:500,devices:5}];
const nodes=[['日本 · 东京','JP-TYO','AnyTLS','1.0×'],['新加坡','SG-SIN','Trojan','1.0×'],['美国 · 西海岸','US-WEST','AnyTLS','1.5×']];
let timer;
function sync(){page.value=getView();mobileMenu.value=false;window.scrollTo(0,0);}
function changeTheme(){theme.value=theme.value==='dark'?'light':'dark';document.documentElement.dataset.theme=theme.value;}
function inform(message='演示页面,不执行实际操作。'){notice.value=message;clearTimeout(timer);timer=setTimeout(()=>notice.value='',4000);}
watch(page,()=>document.title=`${brand.name} / ${title.value}`,{immediate:true});
onMounted(()=>{document.documentElement.dataset.theme='dark';window.addEventListener('hashchange',sync);});
onBeforeUnmount(()=>{window.removeEventListener('hashchange',sync);clearTimeout(timer);});
</script>
<template>
<div class="preview-notice"><div class="content-width">设计预览 · 机房影像与方案数据均为示意</div></div>
<header class="site-header"><div class="content-width header-inner">
<a href="#/home" class="brand" :aria-label="brand.domain+' 首页'"><span class="brand-symbol">{{brand.symbol}}</span><span class="brand-text"><strong>{{brand.name}}<span>{{brand.domainSuffix}}</span></strong><small>{{brand.description}}</small></span></a>
<button class="mobile-menu" aria-label="网站菜单" :aria-expanded="mobileMenu" @click="mobileMenu=!mobileMenu">{{mobileMenu?'关闭 ×':'菜单 ☰'}}</button>
<nav class="public-nav" :class="{expanded:mobileMenu}" aria-label="主导航"><a href="#/home" :class="{active:page==='home'}">首页</a><a href="#/nodes" :class="{active:page==='nodes'}">网络服务</a><a href="#/shop" :class="{active:page==='shop'}">接入方案</a><a href="#/docs" :class="{active:page==='docs'}">服务文档</a><a href="#/dashboard" class="mobile-console">控制台 →</a></nav>
<div class="header-tools"><button class="theme-button" :aria-label="theme==='dark'?'切换浅色主题':'切换暗色主题'" @click="changeTheme">{{theme==='dark'?'☼':'☾'}}</button><a class="console-link" href="#/dashboard">控制台 <span>→</span></a></div>
</div></header>
<main>
<NetworkLanding v-if="page==='home'" :plans="plans"/>
<template v-else>
<div class="portal-top"><div class="content-width"><h1>{{title}}</h1><p>{{page==='shop'?'比较接入规格,选择适合的使用规模。':page==='nodes'?'节点、协议与接入配置。':page==='profile'?'管理账户信息与登录安全。':'服务、资源与账户的统一管理入口。'}}</p></div></div>
<div class="portal-layout content-width">
<aside class="portal-nav" aria-label="账户导航"><a v-for="item in menu" :key="item[0]" :href="'#/'+item[0]" :class="{active:page===item[0]}">{{item[1]}}<span>→</span></a></aside>
<div class="portal-content">
<template v-if="page==='dashboard'">
<div class="metrics-grid"><section><span>剩余流量</span><strong>132.6 <small>GB</small></strong><p>本期总额 200 GB</p></section><section><span>套餐到期</span><strong class="date-value">2026.11.14</strong><p>标准接入 · 月付</p></section><section><span>账户余额</span><strong><small>¥</small> 24.00</strong><a href="#/orders">查看账单 →</a></section></div>
<section class="panel"><header><h2>接入服务</h2><a href="#/shop">管理方案 →</a></header><div class="service-detail"><div><span class="service-badge">标准接入</span><h3>网络接入服务</h3><p>200 GB / 月 · 300 Mbps · 3 台设备</p></div><button class="button primary" @click="inform()">复制订阅配置</button></div><div class="usage-detail"><div><span>本期已用 67.4 GB</span><b>34%</b></div><div class="usage-track" role="img" aria-label="演示流量用量 34%"><span></span></div></div></section>
<section class="panel"><header><h2>常用操作</h2></header><div class="action-grid"><a href="#/nodes"><b>网络资源</b><span>选择节点与查看协议 →</span></a><a href="#/docs"><b>接入指南</b><span>客户端与配置步骤 →</span></a><a href="#/tickets"><b>技术工单</b><span>提交问题与跟进处理 →</span></a></div></section>
</template>
<template v-else-if="page==='shop'">
<div class="plan-toolbar"><h2>流量接入方案</h2><div class="period-control" aria-label="计费周期"><button :class="{active:period==='month'}" @click="period='month'" :aria-pressed="period==='month'">月付</button><button :class="{active:period==='year'}" @click="period='year'" :aria-pressed="period==='year'">年付</button></div></div>
<div class="portal-plan-grid"><article v-for="plan in plans" :key="plan.id" :class="{selected:selected===plan.id}"><h2>{{plan.title}}</h2><div class="price"><span>¥</span><strong>{{plan.price*(period==='year'?12:1)}}</strong><small>/ {{period==='year'?'年':'月'}}</small></div><dl><div><dt>每月流量</dt><dd>{{plan.quota}} GB</dd></div><div><dt>速率规格</dt><dd>{{plan.speed}} Mbps</dd></div><div><dt>设备额度</dt><dd>{{plan.devices}} 台</dd></div></dl><button class="button" :class="selected===plan.id?'primary':'secondary'" @click="selected=plan.id;inform('已选择方案(演示),未创建订单。')">{{selected===plan.id?'已选择此方案':'选择方案'}}</button></article></div>
<section class="panel configuration-note"><h3>选择方案前</h3><p>确认月流量、计费周期和设备额度。具体节点协议与倍率可在网络资源中查看。</p><a class="text-link" href="#/nodes">查看网络资源 →</a></section>
</template>
<template v-else-if="page==='nodes'">
<section class="panel"><header><h2>订阅配置</h2></header><div class="service-detail"><div><h3>导入配置,连接网络。</h3><p>将订阅配置导入客户端后选择接入节点。</p></div><button class="button primary" @click="inform()">复制订阅配置</button></div></section>
<section class="panel"><header><h2>网络资源</h2><span class="resource-count">3 个节点</span></header><div class="resource-table"><div class="resource-row table-heading"><span>地区</span><span>节点</span><span>协议</span><span>倍率</span></div><div class="resource-row" v-for="node in nodes" :key="node[1]"><b>{{node[0]}}</b><span>{{node[1]}}</span><span class="protocol">{{node[2]}}</span><span>{{node[3]}}</span></div></div></section>
</template>
<template v-else-if="page==='orders'">
<section class="panel"><header><h2>订单记录</h2></header><div class="billing-row table-heading"><span>方案 / 订单</span><span>金额</span><span>日期</span><span>详情</span></div><div class="billing-row" v-for="(plan,index) in plans.slice(0,2)" :key="plan.id"><div><b>{{plan.title}}</b><small>HN-SAMPLE-000{{index+1}}</small></div><b>¥ {{plan.price.toFixed(2)}}</b><span>2026.10.14</span><button class="text-link" @click="inform()">查看 →</button></div></section>
</template>
<template v-else-if="page==='profile'">
<section class="panel"><header><h2>账户信息</h2></header><form class="account-form" @submit.prevent="saved=true;inform('已保存(演示),不会提交数据。')"><div class="form-grid"><label>显示名称<input value="nerd_0001"/></label><label>邮箱地址<input type="email" value="demo@hardnerd.test"/></label></div><div class="check-row"><label><input type="checkbox" checked/>套餐到期提醒</label><label><input type="checkbox" checked/>流量不足提醒</label></div><button class="button primary">{{saved?'已保存':'保存设置'}}</button></form></section>
<section class="panel"><header><h2>登录安全</h2></header><div class="service-detail"><div><h3>密码与登录设备</h3><p>管理账户凭据和设备访问。</p></div><button class="button secondary" @click="inform()">安全设置</button></div></section>
</template>
<template v-else-if="page==='docs'">
<section class="panel guide-panel"><header><h2>客户端接入指南</h2></header><ol><li><h3>选择接入方案</h3><p>确认月流量、使用周期与设备需求。</p></li><li><h3>导入订阅配置</h3><p>在客户端订阅管理中添加配置,更新节点列表。</p></li><li><h3>选择节点连接</h3><p>核对地区与协议后发起连接;遇到问题可提交技术工单。</p></li></ol><a class="text-link" href="#/tickets">联系技术支持 →</a></section>
</template>
<template v-else-if="page==='tickets'">
<section class="panel"><header><h2>提交技术工单</h2></header><form class="ticket-form" @submit.prevent="inform('已展示提交效果,未发送工单。')"><label>问题标题<input placeholder="概述需要协助的问题" required/></label><label>详细描述<textarea v-model="question" rows="6" placeholder="客户端、错误提示、发生时间及已尝试的操作" required/></label><button class="button primary">提交工单</button></form></section>
</template>
</div>
</div>
</template>
</main>
<footer class="site-footer"><div class="content-width"><div class="footer-brand"><strong>{{brand.domain}}</strong><span>{{brand.description}}</span></div><div class="footer-links"><a href="#/nodes">网络服务</a><a href="#/docs">服务文档</a><a href="#/tickets">技术支持</a></div><span class="copyright">© 2026 {{brand.copyrightName}}</span></div></footer>
<div class="toast" v-if="notice" role="status"><span>{{notice}}</span><button aria-label="关闭提示" @click="notice=''">×</button></div>
</template>

View File

@ -1,27 +0,0 @@
<script setup>
defineProps({plans:{type:Array,required:true}});
const coverage=[['亚太','东京 · 香港 · 新加坡'],['美洲','洛杉矶 · 西雅图'],['欧洲','法兰克福 · 伦敦']];
</script>
<template>
<section class="dc-hero">
<div class="content-width dc-hero-content">
<h1>机房网络。<br/>连接业务的底座。</h1>
<p>线路接入、流量管理、技术支持。<br/>从选择方案到日常管理,在一个平台完成。</p>
<div class="hero-actions"><a class="button primary" href="#/shop">查看接入方案 <span>→</span></a><a class="button hero-secondary" href="#/dashboard">进入控制台</a></div>
</div>
</section>
<section class="service-overview content-width" aria-label="服务入口">
<a href="#/nodes"><span class="service-icon">↗</span><div><h2>网络接入</h2><p>地区、节点与连接协议</p></div><span class="service-arrow">→</span></a>
<a href="#/shop"><span class="service-icon">▤</span><div><h2>套餐与规格</h2><p>流量、周期与设备额度</p></div><span class="service-arrow">→</span></a>
<a href="#/tickets"><span class="service-icon">+</span><div><h2>技术服务</h2><p>接入指南与工单支持</p></div><span class="service-arrow">→</span></a>
</section>
<section class="dc-section content-width">
<header class="section-heading"><div><h2>连接网络,管理每一项资源。</h2><p>接入信息集中呈现,日常操作清晰可控。</p></div><a href="#/nodes">查看网络资源 →</a></header>
<div class="coverage-layout"><div class="coverage-intro"><h3>跨区域网络接入</h3><p>按地区选择节点,查看协议与计费倍率,把连接配置带入你的客户端。</p><a class="text-link" href="#/docs">阅读接入指南 →</a></div><div class="coverage-list"><a v-for="region in coverage" :key="region[0]" href="#/nodes"><h3>{{region[0]}}</h3><p>{{region[1]}}</p><span>→</span></a></div></div>
</section>
<section class="dc-section dc-plan-section"><div class="content-width">
<header class="section-heading"><div><h2>按需求选择接入方案。</h2><p>统一规格,直接比较。适配不同规模的网络使用。</p></div><a href="#/shop">查看所有方案 →</a></header>
<div class="dc-plan-grid"><article v-for="plan in plans" :key="plan.id" :class="{featured:plan.id==='STANDARD'}"><h3>{{plan.title}}</h3><div class="price"><span>¥</span><strong>{{plan.price}}</strong><small>/ 月</small></div><dl><div><dt>月流量</dt><dd>{{plan.quota}} GB</dd></div><div><dt>速率规格</dt><dd>{{plan.speed}} Mbps</dd></div><div><dt>设备数量</dt><dd>{{plan.devices}} 台</dd></div></dl><a class="button" :class="plan.id==='STANDARD'?'primary':'secondary'" href="#/shop">查看方案 →</a></article></div>
</div></section>
<section class="dc-section content-width operational-section"><div><h2>服务交付之后,<br/>管理同样重要。</h2><p>订阅配置、流量使用、订单账单与技术工单,<br/>在统一控制台里完成。</p><a class="button primary" href="#/dashboard">打开控制台 →</a></div><div class="operations-panel"><a href="#/dashboard"><b>服务与流量</b><span>查看套餐周期与剩余额度</span><i>→</i></a><a href="#/orders"><b>订单与账单</b><span>核对购买记录和订单详情</span><i>→</i></a><a href="#/tickets"><b>技术支持</b><span>提交问题与跟进处理记录</span><i>→</i></a></div></section>
</template>

View File

@ -1 +0,0 @@
export default {name:'hardnerd',domain:'hardnerd.net',domainSuffix:'.net',symbol:'HN',description:'机房网络运营商',copyrightName:'Hardnerd'};

View File

@ -1,4 +0,0 @@
import {createApp} from 'vue';
import App from './App.vue';
import './operator.css';
createApp(App).mount('#app');

View File

@ -1,20 +0,0 @@
:root{color-scheme:dark;--bg:#0b1420;--surface:#111f2e;--panel:#152536;--raised:#1c3042;--ink:#e9eef4;--muted:#9bacbe;--line:#293d50;--accent:#f29759;--primary:#ef914e;--primary-ink:#121c26;--input:#101c2a;--soft:#15283b;--shadow:0 12px 40px #02071133;font-family:'Helvetica Neue','PingFang SC','Microsoft YaHei',Arial,sans-serif;color:var(--ink);background:var(--bg);font-synthesis:none}
html[data-theme=light]{color-scheme:light;--bg:#f4f7fa;--surface:#fff;--panel:#fff;--raised:#edf3f8;--ink:#142a40;--muted:#617489;--line:#dce5ec;--accent:#bd4e19;--primary:#c1531c;--primary-ink:#fff;--input:#f8fafc;--soft:#edf3f8;--shadow:0 12px 40px #163a6110}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-width:320px}h1,h2,h3,p{margin:0}a{color:inherit;text-decoration:none}button,input,textarea{font:inherit;color:inherit}button{cursor:pointer;background:none;border:0}button,a{-webkit-tap-highlight-color:transparent}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:4px}::selection{background:var(--primary);color:var(--primary-ink)}.content-width{width:calc(100% - 96px);max-width:1320px;margin-inline:auto}.preview-notice{background:var(--bg);color:var(--muted);font-size:11px;padding-block:9px;border-bottom:1px solid var(--line)}
.site-header{background:var(--surface);border-bottom:1px solid var(--line)}.header-inner{display:flex;align-items:center;gap:38px;min-height:98px}.brand{display:flex;align-items:center;gap:13px;flex-shrink:0}.brand-symbol{display:flex;align-items:center;justify-content:center;width:49px;height:49px;border-radius:4px;background:var(--primary);color:var(--primary-ink);font-family:Arial,sans-serif;font-size:21px;letter-spacing:-1px;font-weight:900;clip-path:polygon(0 0,85% 0,100% 15%,100% 100%,0 100%)}.brand-text{display:flex;flex-direction:column;gap:7px}.brand-text strong{font-size:28px;letter-spacing:-1px;font-weight:800;line-height:1}.brand-text strong>span{font-size:15px;font-weight:600;letter-spacing:-.2px}.brand-text small{font-size:11px;letter-spacing:1px;color:var(--muted)}.public-nav{margin-left:auto;display:flex;align-items:center;gap:33px}.public-nav>a{font-size:14px;font-weight:500;color:var(--muted);padding-block:16px;white-space:nowrap;border-bottom:2px solid transparent}.public-nav>a:hover,.public-nav>a.active{color:var(--ink);border-bottom-color:var(--accent)}.header-tools{display:flex;align-items:center;gap:22px}.theme-button{width:28px;height:32px;font-size:23px;line-height:1;color:var(--muted);padding:0}.console-link{display:flex;align-items:center;gap:24px;white-space:nowrap;background:var(--soft);border:1px solid var(--line);padding:13px 18px;border-radius:5px;font-size:13px;font-weight:600}.mobile-menu,.mobile-console{display:none}
.button{display:inline-flex;align-items:center;justify-content:center;gap:25px;border:1px solid transparent;border-radius:5px;min-height:49px;padding:14px 23px;font-size:14px;font-weight:600;line-height:1.4;transition:filter .15s,background .15s}.button:hover{filter:brightness(1.1)}.button.primary{background:var(--primary);color:var(--primary-ink);border-color:var(--primary)}.button.secondary{background:var(--soft);border-color:var(--line);color:var(--ink)}.text-link{color:var(--accent);font-size:13px;line-height:1.7;padding:0}
.dc-hero{position:relative;background:#091422;background-image:linear-gradient(90deg,#061321ec 0%,#061321c2 35%,#06132130 76%,#0613210a 100%),linear-gradient(0deg,#071420dd 0%,transparent 45%),url('/datacenter-hero-v1.png');background-size:cover;background-position:center 52%;color:#fff;min-height:620px;display:flex;align-items:center}.dc-hero-content{padding-block:80px 100px}.dc-hero h1{font-size:clamp(44px,4.5vw,68px);font-weight:650;line-height:1.32;letter-spacing:-1.8px;max-width:760px}.dc-hero p{font-size:17px;line-height:1.9;color:#c4d1df;margin-block:28px 33px}.hero-actions{display:flex;align-items:center;gap:17px;flex-wrap:wrap}.hero-actions .primary{background:#f29656;border-color:#f29656;color:#101d29}.hero-secondary{background:#ffffff0d;border-color:#ffffff61;color:#fff}.hero-secondary:hover{background:#ffffff16}
.service-overview{position:relative;margin-top:-36px;display:grid;grid-template-columns:repeat(3,1fr);background:var(--surface);border:1px solid var(--line);border-radius:7px;box-shadow:var(--shadow);z-index:1}.service-overview>a{display:flex;align-items:center;gap:21px;padding:33px 28px;border-right:1px solid var(--line)}.service-overview>a:last-child{border:0}.service-overview>a:hover{background:var(--raised)}.service-icon{display:flex;align-items:center;justify-content:center;width:43px;height:43px;border:1px solid var(--line);color:var(--accent);font-size:25px;border-radius:6px;flex-shrink:0}.service-overview h2{font-size:21px;font-weight:600;margin-bottom:10px}.service-overview p{font-size:12px;color:var(--muted);line-height:1.5}.service-arrow{margin-left:auto;font-size:20px;color:var(--muted)}
.dc-section{padding-block:90px}.section-heading{display:flex;justify-content:space-between;align-items:flex-end;gap:35px;margin-bottom:38px}.section-heading h2{font-size:34px;line-height:1.45;letter-spacing:-.7px;font-weight:600}.section-heading p{font-size:14px;color:var(--muted);line-height:1.9;margin-top:16px}.section-heading>a{font-size:13px;color:var(--accent);white-space:nowrap;padding-bottom:5px}.coverage-layout{display:grid;grid-template-columns:1fr 1.4fr;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:var(--surface)}.coverage-intro{padding:40px 37px;background:linear-gradient(135deg,var(--panel),var(--surface));border-right:1px solid var(--line)}.coverage-intro h3{font-size:26px;font-weight:600;margin-bottom:19px}.coverage-intro p{font-size:14px;color:var(--muted);line-height:2;max-width:355px;margin-bottom:26px}.coverage-list{display:grid;grid-template-columns:1fr}.coverage-list>a{display:grid;grid-template-columns:100px 1fr 20px;align-items:center;gap:25px;padding:27px 35px;border-bottom:1px solid var(--line)}.coverage-list>a:last-child{border:0}.coverage-list>a:hover{background:var(--raised)}.coverage-list h3{font-size:20px;font-weight:600}.coverage-list p{font-size:13px;color:var(--muted);line-height:1.8}.coverage-list span{color:var(--accent);font-size:19px}
.dc-plan-section{background:var(--surface);border-block:1px solid var(--line)}.dc-plan-grid,.portal-plan-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.dc-plan-grid>article,.portal-plan-grid>article{padding:35px 31px;background:var(--panel);border:1px solid var(--line);border-radius:7px;position:relative}.dc-plan-grid>article.featured,.portal-plan-grid>article.selected{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}.dc-plan-grid h3,.portal-plan-grid h2{font-size:23px;font-weight:600}.price{display:flex;align-items:baseline;gap:8px;margin-block:30px;color:var(--ink)}.price>span{font-size:20px}.price strong{font-family:Arial,sans-serif;font-size:58px;line-height:1;letter-spacing:-2px;font-weight:600}.price small{font-size:12px;color:var(--muted);margin-left:4px}.dc-plan-grid dl,.portal-plan-grid dl{margin:0 0 28px}.dc-plan-grid dl>div,.portal-plan-grid dl>div{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:15px 0;border-top:1px solid var(--line);font-size:13px}.dc-plan-grid dt,.portal-plan-grid dt{color:var(--muted)}.dc-plan-grid dd,.portal-plan-grid dd{margin:0;font-weight:500}.dc-plan-grid .button,.portal-plan-grid .button{width:100%}
.operational-section{display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:100px}.operational-section h2{font-size:34px;line-height:1.5;font-weight:600;letter-spacing:-.5px}.operational-section p{font-size:14px;line-height:2;color:var(--muted);margin-block:22px 28px}.operations-panel{border:1px solid var(--line);background:var(--surface);border-radius:7px;overflow:hidden}.operations-panel>a{display:grid;grid-template-columns:1fr 20px;gap:14px 25px;padding:27px 30px;border-bottom:1px solid var(--line)}.operations-panel>a:last-child{border:0}.operations-panel>a:hover{background:var(--raised)}.operations-panel b{font-size:19px;font-weight:600}.operations-panel span{font-size:13px;color:var(--muted);grid-column:1}.operations-panel i{grid-column:2;grid-row:1 / span 2;align-self:center;font-style:normal;color:var(--accent);font-size:20px}
.site-footer{background:var(--surface);border-top:1px solid var(--line);padding-block:42px}.site-footer>.content-width{display:flex;align-items:center;justify-content:space-between;gap:35px}.footer-brand{display:flex;flex-direction:column;gap:12px}.footer-brand strong{font-size:23px;font-weight:700;letter-spacing:-.5px}.footer-brand span{font-size:12px;color:var(--muted)}.footer-links{display:flex;align-items:center;gap:30px;font-size:13px;color:var(--muted)}.copyright{font-size:11px;color:var(--muted)}
/* Unified service portal. */
.portal-top{background:var(--surface);border-bottom:1px solid var(--line);padding-block:38px 32px}.portal-top h1{font-size:32px;line-height:1.4;font-weight:600;letter-spacing:-.5px}.portal-top p{font-size:13px;color:var(--muted);line-height:1.8;margin-top:12px}.portal-layout{display:grid;grid-template-columns:198px minmax(0,1fr);gap:32px;padding-block:35px 70px;min-height:620px;align-items:start}.portal-nav{border:1px solid var(--line);background:var(--surface);border-radius:6px;overflow:hidden}.portal-nav>a{display:flex;justify-content:space-between;align-items:center;gap:18px;padding:19px 22px;font-size:14px;color:var(--muted);border-left:3px solid transparent}.portal-nav>a span{font-size:13px;opacity:0}.portal-nav>a.active{color:var(--accent);border-left-color:var(--accent);background:var(--soft);font-weight:600}.portal-nav>a.active span{opacity:1}.portal-nav>a:hover{background:var(--raised)}.portal-content{min-width:0}.panel{border:1px solid var(--line);background:var(--panel);border-radius:7px;overflow:hidden;margin-bottom:26px}.panel:last-child{margin-bottom:0}.panel>header{display:flex;align-items:center;justify-content:space-between;gap:25px;padding:22px 27px;border-bottom:1px solid var(--line)}.panel>header h2{font-size:18px;font-weight:600}.panel>header a{font-size:12px;color:var(--accent)}.resource-count{font-size:12px;color:var(--muted)}.metrics-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-bottom:26px}.metrics-grid>section{padding:25px;border:1px solid var(--line);border-radius:7px;background:var(--panel)}.metrics-grid>section>span{font-size:12px;color:var(--muted)}.metrics-grid strong{display:block;font-family:Arial,sans-serif;font-size:34px;font-weight:600;line-height:1.3;letter-spacing:-.8px;margin-block:18px 13px;white-space:nowrap}.metrics-grid strong small{font-size:15px;letter-spacing:0;font-weight:400;color:var(--muted)}.metrics-grid .date-value{font-size:24px;letter-spacing:-.7px;margin-block:25px 17px}.metrics-grid p,.metrics-grid a{font-size:11px;color:var(--muted)}.metrics-grid a{color:var(--accent)}.service-detail{display:flex;align-items:center;justify-content:space-between;gap:30px;padding:30px 27px}.service-detail h3{font-size:22px;line-height:1.5;font-weight:600}.service-detail p{font-size:13px;line-height:1.8;color:var(--muted);margin-top:12px}.service-detail .button{font-size:13px;white-space:nowrap}.service-badge{font-size:11px;display:inline-block;background:var(--soft);border:1px solid var(--line);border-radius:4px;padding:6px 9px;color:var(--accent);margin-bottom:14px}.usage-detail{padding:0 27px 28px}.usage-detail>div:first-child{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--muted);margin-bottom:12px}.usage-detail b{font-weight:500}.usage-track{height:7px;border-radius:4px;background:var(--raised);overflow:hidden}.usage-track span{display:block;width:34%;height:100%;background:var(--accent)}.action-grid{display:grid;grid-template-columns:repeat(3,1fr)}.action-grid>a{padding:28px 22px;border-right:1px solid var(--line)}.action-grid>a:last-child{border:0}.action-grid b{font-size:15px;font-weight:600;display:block;margin-bottom:15px}.action-grid span{font-size:11px;color:var(--muted);line-height:1.8}.action-grid a:hover{background:var(--raised)}
.plan-toolbar{display:flex;align-items:center;justify-content:space-between;gap:25px;margin-bottom:26px}.plan-toolbar h2{font-size:19px;font-weight:600}.period-control{display:flex;background:var(--surface);border:1px solid var(--line);border-radius:5px;padding:4px;gap:3px}.period-control button{padding:9px 20px;font-size:12px;border-radius:3px;color:var(--muted)}.period-control button.active{background:var(--primary);color:var(--primary-ink);font-weight:600}.portal-plan-grid{gap:18px;margin-bottom:28px}.portal-plan-grid>article{padding:29px 23px}.portal-plan-grid h2{font-size:21px}.portal-plan-grid .price strong{font-size:49px}.portal-plan-grid .price>span{font-size:16px}.portal-plan-grid .price small{font-size:11px}.portal-plan-grid dl>div{font-size:12px;gap:15px}.portal-plan-grid .button{font-size:12px;padding-inline:12px}.configuration-note{padding:27px}.configuration-note h3{font-size:16px;font-weight:600}.configuration-note p{font-size:13px;color:var(--muted);line-height:1.9;margin-block:13px}.resource-row{display:grid;grid-template-columns:1.5fr 1fr 1fr .5fr;align-items:center;gap:25px;padding:24px 27px;border-bottom:1px solid var(--line);font-size:13px}.resource-row:last-child{border:0}.resource-row b{font-weight:500}.resource-row>span:nth-child(2){font-size:12px;color:var(--muted)}.resource-row .protocol{font-size:12px;color:var(--ink)}.table-heading{background:var(--soft);font-size:11px!important;color:var(--muted);padding-block:14px!important}.billing-row{display:grid;grid-template-columns:1.8fr .8fr 1fr .5fr;align-items:center;gap:25px;padding:24px 27px;border-bottom:1px solid var(--line);font-size:12px}.billing-row:last-child{border:0}.billing-row b{font-size:13px;font-weight:500}.billing-row small{display:block;font-size:10px;color:var(--muted);margin-top:9px}.account-form,.ticket-form{padding:30px 27px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:26px}label{display:block;font-size:13px;line-height:1.7}input,textarea{display:block;width:100%;min-width:0;padding:13px 14px;border:1px solid var(--line);background:var(--input);border-radius:5px;margin-top:11px;font-size:14px}input::placeholder,textarea::placeholder{color:var(--muted)}textarea{resize:vertical;line-height:1.9}.check-row{display:flex;align-items:center;gap:35px;margin-block:29px}.check-row label{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--muted)}.check-row input{width:14px;height:14px;padding:0;margin:0;accent-color:var(--primary)}.ticket-form label{margin-bottom:25px}.guide-panel ol{margin:0;padding:30px 35px 5px 54px}.guide-panel li{padding-left:12px;margin-bottom:28px;color:var(--accent)}.guide-panel li h3{color:var(--ink);font-size:17px;font-weight:600}.guide-panel li p{color:var(--muted);font-size:13px;line-height:2;margin-top:11px}.guide-panel>.text-link{display:inline-block;margin:0 27px 30px}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);max-width:calc(100vw - 40px);background:var(--surface);border:1px solid var(--accent);border-radius:6px;box-shadow:var(--shadow);padding:16px 20px;display:flex;align-items:center;gap:25px;font-size:13px;line-height:1.7;z-index:10}.toast button{font-size:23px;color:var(--muted);padding:0}
@media(max-width:1180px){.content-width{width:calc(100% - 64px)}.header-inner{gap:26px}.public-nav{gap:24px}.brand-text strong{font-size:25px}.brand-symbol{width:43px;height:43px;font-size:19px}.header-tools{gap:16px}.console-link{padding-inline:14px;gap:18px}.dc-hero{min-height:590px}.dc-hero h1{font-size:5.3vw}.service-overview>a{padding:28px 22px;gap:16px}.service-overview h2{font-size:19px}.service-overview p{font-size:11px}.service-icon{width:36px;height:36px;font-size:23px}.service-arrow{font-size:16px}.operational-section{gap:55px}.portal-layout{grid-template-columns:175px minmax(0,1fr);gap:25px}.metrics-grid{gap:17px}.metrics-grid>section{padding:23px 18px}.metrics-grid strong{font-size:29px}.metrics-grid .date-value{font-size:22px}.portal-plan-grid{gap:15px}.portal-plan-grid>article{padding:28px 19px}.portal-plan-grid .price strong{font-size:44px}.portal-plan-grid .price small{margin-left:0}}
@media(max-width:960px){.header-inner{flex-wrap:wrap;padding-block:21px 15px;gap:19px}.public-nav{order:3;width:100%;margin:0;gap:34px}.public-nav>a{padding-block:9px;font-size:13px}.header-tools{margin-left:auto}.dc-hero{min-height:550px;background-position:60% center}.dc-hero-content{padding-block:68px 88px}.dc-hero h1{font-size:5.6vw;max-width:650px}.dc-hero p{font-size:15px}.service-overview>a{padding:26px 20px;gap:14px}.service-overview h2{font-size:18px}.service-arrow{display:none}.service-icon{width:31px;height:35px;font-size:21px}.dc-section{padding-block:70px}.section-heading h2{font-size:29px}.section-heading p{font-size:13px}.coverage-layout{grid-template-columns:1fr 1.3fr}.coverage-intro{padding:31px 27px}.coverage-intro h3{font-size:23px}.coverage-list>a{grid-template-columns:65px 1fr 15px;gap:18px;padding:26px}.coverage-list h3{font-size:19px}.coverage-list p{font-size:12px}.dc-plan-grid{gap:18px}.dc-plan-grid>article{padding:29px 23px}.dc-plan-grid h3{font-size:21px}.price strong{font-size:50px}.dc-plan-grid dl>div{font-size:12px;gap:13px}.operational-section{gap:35px}.operational-section h2{font-size:29px}.operations-panel>a{padding:25px}.operations-panel span{font-size:12px}.footer-links{gap:22px;font-size:12px}.copyright{font-size:10px}.portal-layout{grid-template-columns:150px minmax(0,1fr);gap:22px}.portal-nav>a{font-size:13px;padding:18px 17px}.metrics-grid{grid-template-columns:1fr;gap:15px}.metrics-grid>section{display:grid;grid-template-columns:1fr 1fr;gap:12px 20px;padding:22px}.metrics-grid>section>span{align-self:center}.metrics-grid strong{grid-column:2;grid-row:1 / span 2;align-self:center;justify-self:end;margin:0;font-size:34px}.metrics-grid .date-value{margin:0;font-size:26px}.service-detail{flex-wrap:wrap;padding:27px}.action-grid{grid-template-columns:1fr}.action-grid>a{border-right:0;border-bottom:1px solid var(--line);padding:24px 27px}.action-grid b{margin-bottom:11px}.portal-plan-grid{grid-template-columns:1fr}.portal-plan-grid>article{display:grid;grid-template-columns:1fr 1fr;gap:0 30px;padding:29px}.portal-plan-grid h2{grid-column:1}.portal-plan-grid .price{grid-column:1;margin-block:25px}.portal-plan-grid .price strong{font-size:48px}.portal-plan-grid dl{grid-column:2;grid-row:1 / span 2;align-self:center;margin:0}.portal-plan-grid .button{grid-column:1 / span 2;margin-top:20px}.form-grid{grid-template-columns:1fr}.check-row{flex-direction:column;align-items:flex-start;gap:18px}.resource-row{grid-template-columns:1.2fr 1fr;gap:15px;padding:23px 25px}.resource-row.table-heading{display:none}.billing-row{grid-template-columns:1.5fr 1fr;gap:20px}.billing-row.table-heading{display:none}.resource-row>span:last-child{font-size:12px;color:var(--muted)}}
@media(max-width:680px){.content-width{width:calc(100% - 40px)}.preview-notice{font-size:9px;padding-block:8px}.header-inner{padding-block:20px;min-height:84px;gap:15px}.brand{gap:10px}.brand-symbol{width:39px;height:39px;font-size:18px}.brand-text strong{font-size:26px}.brand-text small{font-size:10px;letter-spacing:.5px}.brand-text strong>span{font-size:13px}.header-tools{margin-left:0}.theme-button{font-size:21px;width:23px}.console-link{display:none}.mobile-menu{display:block;order:2;margin-left:auto;font-size:12px;color:var(--muted);padding:0;white-space:nowrap}.header-tools{order:1;margin-left:auto}.public-nav{display:none;order:3;gap:15px;padding-top:5px}.public-nav.expanded{display:grid;grid-template-columns:repeat(4,1fr)}.public-nav>a{font-size:12px;padding:8px 0}.public-nav .mobile-console{display:block;grid-column:1 / span 4;border-top:1px solid var(--line);padding-top:15px;color:var(--accent)}.dc-hero{min-height:535px;background-position:65% center;background-image:linear-gradient(90deg,#061321e3,#0613218f),linear-gradient(0deg,#061321c9,transparent),url('/datacenter-hero-v1.png')}.dc-hero-content{padding-block:55px 80px}.dc-hero h1{font-size:clamp(36px,9vw,55px);letter-spacing:-1px;line-height:1.4;max-width:100%}.dc-hero p{font-size:14px;line-height:1.9;margin-block:23px 28px}.hero-actions{gap:13px}.hero-actions .button{font-size:13px;padding:14px 17px;gap:18px}.service-overview{grid-template-columns:1fr;margin-top:-28px}.service-overview>a{padding:24px;border-right:0;border-bottom:1px solid var(--line);gap:19px}.service-overview h2{font-size:19px;margin-bottom:8px}.service-overview p{font-size:12px}.service-icon{width:38px;height:38px;font-size:23px}.service-arrow{display:block;font-size:18px}.dc-section{padding-block:50px}.section-heading{display:block;margin-bottom:28px}.section-heading h2{font-size:27px;line-height:1.5;letter-spacing:-.5px}.section-heading p{font-size:13px;margin-top:13px}.section-heading>a{display:inline-block;margin-top:20px;padding:0;font-size:12px}.coverage-layout{grid-template-columns:1fr}.coverage-intro{padding:29px;border-right:0;border-bottom:1px solid var(--line)}.coverage-intro h3{font-size:23px}.coverage-intro p{font-size:13px;line-height:2;max-width:100%;margin-bottom:21px}.coverage-list>a{padding:23px 27px;grid-template-columns:63px 1fr 15px;gap:17px}.coverage-list h3{font-size:19px}.coverage-list p{font-size:12px}.dc-plan-grid{grid-template-columns:1fr;gap:22px}.dc-plan-grid>article{padding:31px}.dc-plan-grid h3{font-size:24px}.price strong{font-size:58px}.dc-plan-grid dl>div{font-size:14px}.operational-section{display:block}.operational-section h2{font-size:28px}.operational-section p{font-size:13px}.operations-panel{margin-top:32px}.operations-panel>a{padding:25px 27px}.operations-panel b{font-size:19px}.operations-panel span{font-size:13px}.site-footer{padding-block:33px}.site-footer>.content-width{flex-wrap:wrap;gap:30px}.footer-brand{width:100%}.footer-links{gap:25px;font-size:12px}.copyright{font-size:10px}.portal-top{padding-block:29px 25px}.portal-top h1{font-size:29px}.portal-top p{font-size:12px;margin-top:10px}.portal-layout{display:block;padding-block:24px 45px;min-height:0}.portal-nav{display:flex;overflow:auto;border:0;border-bottom:1px solid var(--line);border-radius:0;margin-bottom:25px;padding-bottom:13px;gap:24px;background:none}.portal-nav>a{padding:6px 0;white-space:nowrap;border:0!important;font-size:13px;color:var(--muted)}.portal-nav>a.active{background:none}.portal-nav>a span{display:none}.panel{margin-bottom:23px}.panel>header{padding:20px 23px;gap:18px}.panel>header h2{font-size:17px}.metrics-grid>section{padding:23px}.metrics-grid strong{font-size:33px}.metrics-grid .date-value{font-size:25px}.service-detail{padding:26px 23px;gap:25px}.service-detail h3{font-size:22px}.service-detail p{font-size:13px;line-height:1.9}.service-detail .button{font-size:13px}.usage-detail{padding:0 23px 27px}.action-grid>a{padding:25px 23px}.action-grid b{font-size:16px}.action-grid span{font-size:12px}.plan-toolbar{gap:20px;flex-wrap:wrap}.plan-toolbar h2{font-size:18px}.period-control button{padding:9px 18px;font-size:12px}.portal-plan-grid>article{display:block;padding:30px}.portal-plan-grid h2{font-size:24px}.portal-plan-grid .price strong{font-size:55px}.portal-plan-grid .price{margin-block:29px}.portal-plan-grid dl{margin-bottom:25px}.portal-plan-grid dl>div{font-size:14px}.portal-plan-grid .button{font-size:14px;margin:0}.configuration-note{padding:25px}.configuration-note p{font-size:13px}.resource-row{padding:24px;gap:17px;font-size:12px}.billing-row{padding:24px;gap:18px;font-size:12px}.account-form,.ticket-form{padding:26px 23px}.form-grid{gap:24px}label{font-size:13px}.check-row label{font-size:12px}.guide-panel ol{padding:28px 25px 0 40px}.guide-panel li{padding-left:9px}.guide-panel li h3{font-size:17px}.guide-panel li p{font-size:13px}.toast{width:calc(100vw - 40px);font-size:12px;padding:15px 18px;gap:18px}}
@media(max-width:370px){.content-width{width:calc(100% - 32px)}.brand-text strong{font-size:23px}.brand-text small{font-size:9px}.brand-symbol{width:35px;height:35px;font-size:17px}.header-inner{gap:11px}.brand{gap:8px}.mobile-menu{font-size:11px}.theme-button{width:21px;font-size:19px}.dc-hero h1{font-size:32px;letter-spacing:-.8px}.dc-hero p{font-size:13px}.hero-actions .button{font-size:12px;padding-inline:15px}.public-nav.expanded{gap:12px}.public-nav>a{font-size:11px}.section-heading h2{font-size:25px}.coverage-list>a{padding-inline:22px;gap:13px}.coverage-list p{font-size:11px}.footer-links{gap:19px}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.button{transition:none}}

View File

@ -1,15 +0,0 @@
{
"name": "tpl-a",
"version": "1.0.0",
"design_status": "approved",
"approved_on": "2026-10-14",
"scope": "frontend design prototype",
"site_binding": null,
"demo_brand": "hardnerd.net",
"default_color_mode": "dark",
"color_modes": [
"dark",
"light"
],
"business_integration": false
}

View File

@ -1,6 +0,0 @@
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root=path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({root,envDir:false,plugins:[vue()],build:{outDir:'dist',emptyOutDir:true,sourcemap:false},server:{host:'127.0.0.1',port:8591,strictPort:true,fs:{allow:[root,path.resolve(root,'../node_modules')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']}}});

View File

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216 216" role="img"><title>hardnerd</title> <g >
<rect width="216" height="216" rx="48" fill="#171B1A"/>
<!-- The two vertical stems form H; the glasses are its crossbar. -->
<path d="M48 48H76V168H48ZM140 48H168V168H140Z" fill="#F4F3EE"/>
<path d="M36 86H91V129H49L36 114ZM125 86H180V114L167 129H125Z" fill="#B4F047" stroke="#171B1A" stroke-width="8" stroke-linejoin="round"/>
<path d="M91 99H125" fill="none" stroke="#B4F047" stroke-width="12"/>
<path d="M55 99H70M144 99H159" stroke="#171B1A" stroke-width="7" stroke-linecap="square"/>
</g></svg>

Before

Width:  |  Height:  |  Size: 630 B

View File

@ -1,17 +0,0 @@
# HN independent subscription gateway
This deployment reuses the fixed, already-running DPL SubSieve images while keeping HN configuration, credentials, volumes, network, ports, certificates and upstream independent. It does not update or restart DPL/FUC containers.
Target: registered gateway-01 via the white SSH jump host. Public subscription host: t34.hardnerd-nodes.com. Upstream: https://hardnerd.net. Local management: https://127.0.0.1:26444 only; no public management virtual host is installed.
Publish the repository through Git before running the exact checked-out release. Run install.py prepare, then install.py certificate after public DNS/ACME readiness, then install.py start with an authorized administrator JSON object on stdin. Never place credentials in arguments or this repository. Each phase has state guards; inspect partial state before resuming rather than rerunning initialization.
Preflight checks free RAM/disk, fixed image IDs, all three loopback ports and the bridge subnet. Configuration uses ports 28080/28443/26444, hn_sgw_config / hn_sgw_logs, hn_sgw_net (172.28.32.0/24), and /var/lib/hn-sgw. Gateway/admin limits are 160MB/128MB and 0.5 CPU each. The state backup is /root/codex-backups/hn-gateway/20261027-first-install. Certificate files and registered account copy are isolated in /etc/hn-gateway-letsencrypt; no global renewal hooks run. Renewal reloads only the two HN containers and gracefully reloads host Nginx.
Public paths are limited to /s/{token} and /api/v1/client/subscribe. Host access logs are disabled; short paths become canonical API paths before entering the container, whose access log excludes query strings. TLS verification stays enabled both from host to container and from container to HN. Only Cloudflare ranges are trusted for CF-Connecting-IP; only the bridge gateway is trusted inside the container. The documented white host is allowlisted for server-side acceptance. Root and www hosts redirect to the HN main website.
Before changing HN's application subscription URL, verify a valid restricted QA token in supported client formats plus invalid/disabled tokens, no-store response headers, and the original-instance baseline. Empty HN node lists must be reported as empty; they do not constitute node connectivity acceptance. Cloudflare cache bypass is required for this dedicated subscription zone; preserve platform protections unless a specific compatibility change is authorized.
Rollback: restore the backed-up HN HTTP-only host file, nginx -t then graceful reload; stop only HN Compose services and HN timers, preserving HN volumes, credentials and backups for investigation. Do not run compose down on DPL/FUC or restart shared services. Current production evidence is maintained in docs/maintenance/HN_PRODUCTION.md and STATUS.md.
2026-10-28 endpoint migration: publish first, then run migrate-t34.py prepare, certificate, activate in sequence. The old c2 certificate is retained for rollback; the active certificate, SNI and isolated renewal service use t34. Retire old DNS only after new endpoint and application URL acceptance. Backup: /root/codex-backups/hn-gateway/20261028-t34.

View File

@ -1,29 +0,0 @@
user nginx;
worker_processes 1;
pid /var/run/nginx.pid;
events { worker_connections 256; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
server {
listen 64444 ssl;
ssl_certificate /etc/hn-gateway-letsencrypt/live/t34.hardnerd-nodes.com/fullchain.pem;
ssl_certificate_key /etc/hn-gateway-letsencrypt/live/t34.hardnerd-nodes.com/privkey.pem;
root /var/www/html;
index index.php;
location / { try_files $uri /index.php$is_args$args; }
location ~ ^/api/[a-z_]+\.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass 127.0.0.1:9000;
}
location ~ \.php$ {
try_files $uri /index.php$is_args$args;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass 127.0.0.1:9000;
}
location ~ /\. { deny all; }
}
}

View File

@ -1,37 +0,0 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/subscribe/real_ip.conf;
# Do not record subscription credentials in access logs.
log_format main '$remote_addr [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent "$http_user_agent"';
access_log /var/log/subscribe/access.log main;
error_log /var/log/subscribe/error.log crit;
sendfile on;
keepalive_timeout 65;
server_tokens off;
resolver 1.1.1.1 valid=60s ipv6=off;
resolver_timeout 5s;
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
include /etc/nginx/subscribe/cloud_geo.conf;
include /etc/nginx/subscribe/whitelist.conf;
include /etc/nginx/subscribe/ua_custom.conf;
include /etc/nginx/subscribe/ua_whitelist.conf;
server { listen 80 default_server; return 444; }
server {
listen 443 ssl;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_verify on;
proxy_ssl_verify_depth 3;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
include /etc/nginx/subscribe/blacklist.conf;
include /etc/nginx/subscribe/protect.conf;
location / { return 404; }
}
}

View File

@ -1,27 +0,0 @@
location ^~ /api/v1/client/subscribe {
if ($is_cloud_ip = 1) { set $block_reason "cloud"; }
if ($bad_subscribe_ua = 1) { set $block_reason "ua"; }
if ($is_custom_bad_ua = 1) { set $block_reason "ua"; }
if ($is_ua_whitelisted = 1) { set $block_reason ""; }
if ($whitelist_ip = 1) { set $block_reason ""; }
if ($block_reason = "cloud") { return 403 "Forbidden: Cloud IP"; }
if ($block_reason = "ua") { return 403 "Forbidden: Invalid Client"; }
limit_req zone=subscribe_limit burst=5 nodelay;
limit_req_status 429;
set $upstream_backend ${V2B_BACKEND};
proxy_pass $upstream_backend;
proxy_set_header Host ${V2B_HOST};
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_ssl_server_name on;
proxy_ssl_name ${V2B_HOST};
proxy_ssl_verify on;
proxy_ssl_verify_depth 3;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_http_version 1.1;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
add_header Cache-Control "private, no-store" always;
add_header X-Subscribe-Filter "active" always;
}

View File

@ -1,53 +0,0 @@
name: hn-sgw
services:
gateway:
image: sha256:d8807a5b2442bc25ce4fbc1312354b9dcbb0f538d75181ef735f4ea980760e70
pull_policy: never
container_name: hn-subscribe-gateway
restart: unless-stopped
mem_limit: 160m
cpus: 0.5
ports:
- "127.0.0.1:28080:80"
- "127.0.0.1:28443:443"
environment:
V2B_BACKEND: https://hardnerd.net
V2B_HOST: hardnerd.net
REAL_IP_HEADER: X-Real-IP
AXISNOW_TRUSTED_IPS: 172.28.32.1
volumes:
- config:/etc/nginx/subscribe
- logs:/var/log/subscribe
- /etc/hn-gateway-letsencrypt:/etc/hn-gateway-letsencrypt:ro
- /var/lib/hn-sgw/ssl:/etc/nginx/ssl:ro
- ./gateway-nginx.conf:/etc/nginx/templates-src/nginx.conf:ro
- ./gateway-protect.conf:/etc/nginx/templates-src/subscribe_protect.conf.template:ro
networks: [hn]
logging: {driver: json-file, options: {max-size: "5m", max-file: "2"}}
admin:
image: sha256:31ff8ea3e8f958a835edbd1579709017da75b742ff3a5984ea59d12ea0775577
pull_policy: never
container_name: hn-subscribe-admin
restart: unless-stopped
mem_limit: 128m
cpus: 0.5
ports:
- "127.0.0.1:26444:64444"
env_file:
- path: /var/lib/hn-sgw/admin.env
format: raw
volumes:
- config:/etc/nginx/subscribe
- logs:/var/log/subscribe
- /etc/hn-gateway-letsencrypt:/etc/hn-gateway-letsencrypt:ro
- ./gateway-admin.conf:/etc/nginx/nginx.conf:ro
networks: [hn]
logging: {driver: json-file, options: {max-size: "5m", max-file: "2"}}
volumes:
config: {name: hn_sgw_config}
logs: {name: hn_sgw_logs}
networks:
hn:
name: hn_sgw_net
ipam:
config: [{subnet: 172.28.32.0/24}]

View File

@ -1,5 +0,0 @@
[Unit]
Description=Renew isolated HN subscription gateway certificate
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --config-dir /etc/hn-gateway-letsencrypt --work-dir /var/lib/hn-sgw/certbot --logs-dir /var/log/hn-gateway-certbot --cert-name t34.hardnerd-nodes.com --no-directory-hooks --no-random-sleep-on-renew --quiet --deploy-hook /opt/hn-gateway/provision/scripts/hn/gateway/renew-gateway.sh

View File

@ -1,8 +0,0 @@
[Unit]
Description=Renew HN subscription certificate
[Timer]
OnCalendar=*-*-* 04:30:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -1,5 +0,0 @@
[Unit]
Description=Refresh HN gateway Cloudflare trusted ranges
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/hn-gateway/provision/scripts/hn/gateway/update-cloudflare-ips.py

View File

@ -1,8 +0,0 @@
[Unit]
Description=Refresh HN Cloudflare trust ranges
[Timer]
OnCalendar=*-*-* 04:30:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -1,39 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name t34.hardnerd-nodes.com hardnerd-nodes.com www.hardnerd-nodes.com;
access_log off;
location ^~ /.well-known/acme-challenge/ { root /var/lib/hn-sgw/acme; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name t34.hardnerd-nodes.com;
include /etc/nginx/hn-cloudflare-realip.conf;
ssl_certificate /etc/hn-gateway-letsencrypt/live/t34.hardnerd-nodes.com/fullchain.pem;
ssl_certificate_key /etc/hn-gateway-letsencrypt/live/t34.hardnerd-nodes.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
access_log off;
error_log /var/log/nginx/hn-subscribe-error.log crit;
location ~ ^/s/([^/]+)/?$ {
rewrite ^/s/([^/]+)/?$ /api/v1/client/subscribe?token=$1 break;
proxy_pass https://127.0.0.1:28443;
include /etc/nginx/hn-subscribe-proxy.conf;
}
location = /api/v1/client/subscribe {
proxy_pass https://127.0.0.1:28443;
include /etc/nginx/hn-subscribe-proxy.conf;
}
location / { return 404; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name hardnerd-nodes.com www.hardnerd-nodes.com;
ssl_certificate /etc/hn-gateway-letsencrypt/live/t34.hardnerd-nodes.com/fullchain.pem;
ssl_certificate_key /etc/hn-gateway-letsencrypt/live/t34.hardnerd-nodes.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
access_log off;
return 301 https://hardnerd.net$request_uri;
}

View File

@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""Guarded HN gateway first deployment; never operates existing DPL/FUC containers."""
from pathlib import Path
import argparse,hashlib,ipaddress,json,os,shutil,socket,subprocess,sys,time
BASE=Path(__file__).resolve().parent
STATE=Path('/var/lib/hn-sgw')
BACKUP=Path('/root/codex-backups/hn-gateway/20261027-first-install')
CERT=Path('/etc/hn-gateway-letsencrypt')
HOST=Path('/etc/nginx/conf.d/hn-subscribe.conf')
ORIGINAL=['dpl-subscribe-gateway','dpl-subscribe-admin','subscribe-gateway','subscribe-admin']
NEW=['hn-subscribe-gateway','hn-subscribe-admin']
def run(args,**kwargs):return subprocess.run(args,check=True,**kwargs)
def output(args):return subprocess.check_output(args,text=True)
def write(path,data,mode=0o644):
p=Path(path);assert not p.exists(),str(p);p.parent.mkdir(parents=True,exist_ok=True);p.write_text(data);p.chmod(mode)
def original_state():
containers=json.loads(output(['docker','inspect',*ORIGINAL]))
return {'containers':{x['Name']:{'id':x['Id'],'started':x['State']['StartedAt'],'restarts':x['RestartCount'],'running':x['State']['Running']}for x in containers},'nginx':{str(p):hashlib.sha256(p.read_bytes()).hexdigest()for p in Path('/etc/nginx/conf.d').glob('*.conf')if p!=HOST}}
def verify_original():
before=json.loads((BACKUP/'protected-baseline.json').read_text());now=original_state()
assert now['containers']==before['containers'],'Original containers changed; inspect before proceeding'
assert all(now['nginx'].get(p)==h for p,h in before['nginx'].items()),'Original Nginx configuration changed'
print('DPL/FUC original container IDs, start times and Nginx configurations unchanged.')
def compose(*args):run(['docker','compose','-f',str(BASE/'gateway.compose.yml'),*args],cwd=BASE)
def prepare():
assert not STATE.exists() and not CERT.exists() and not HOST.exists()
assert not BACKUP.exists()
for name in NEW:
assert subprocess.run(['docker','container','inspect',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode!=0
for port in [28080,28443,26444]:
with socket.socket()as s:s.bind(('127.0.0.1',port))
available=int(next(line.split()[1]for line in Path('/proc/meminfo').read_text().splitlines()if line.startswith('MemAvailable:')))
assert available>=300*1024,'Insufficient available memory for two bounded HN containers'
assert shutil.disk_usage('/').free>=1024**3
ids=output(['docker','network','ls','-q']).split()
networks=json.loads(output(['docker','network','inspect',*ids]))
proposed=ipaddress.ip_network('172.28.32.0/24')
for n in networks:
for c in n.get('IPAM',{}).get('Config')or[]:
if c.get('Subnet'):
existing=ipaddress.ip_network(c['Subnet']);assert existing.version!=4 or not proposed.overlaps(existing),'HN subnet collision'
for image in ['sha256:d8807a5b2442bc25ce4fbc1312354b9dcbb0f538d75181ef735f4ea980760e70','sha256:31ff8ea3e8f958a835edbd1579709017da75b742ff3a5984ea59d12ea0775577']:
run(['docker','image','inspect',image],stdout=subprocess.DEVNULL)
BACKUP.mkdir(parents=True,mode=0o700);BACKUP.chmod(0o700)
write(BACKUP/'protected-baseline.json',json.dumps(original_state(),indent=2),0o600)
for folder in [STATE,STATE/'acme',STATE/'ssl',STATE/'certbot',Path('/var/log/hn-gateway-certbot')]:folder.mkdir(mode=0o755,parents=True,exist_ok=False)
CERT.mkdir(mode=0o700)
# Reuse the same host's existing registered ACME account; never copy or run global renewal hooks.
shutil.copytree('/etc/dpl-letsencrypt/accounts',CERT/'accounts')
for parent,dirs,files in os.walk(CERT/'accounts'):
os.chmod(parent,0o700)
for f in files:os.chmod(Path(parent)/f,0o600)
write('/etc/nginx/hn-cloudflare-realip.conf',Path('/etc/nginx/dpl-cloudflare-realip.conf').read_text())
write('/etc/nginx/hn-subscribe-proxy.conf',(BASE/'proxy.conf').read_text())
http=(BASE/'host.conf').read_text().split('\nserver {',1)[0]+'\n'
write(HOST,http);run(['nginx','-t']);run(['systemctl','reload','nginx']);verify_original()
print('HN HTTP ACME route prepared; existing services untouched.')
def certificate():
assert STATE.is_dir() and CERT.is_dir()
verify_original()
args=['certbot','certonly','--webroot','-w',str(STATE/'acme'),'--config-dir',str(CERT),'--work-dir',str(STATE/'certbot'),'--logs-dir','/var/log/hn-gateway-certbot','--cert-name','t34.hardnerd-nodes.com','-d','t34.hardnerd-nodes.com','-d','hardnerd-nodes.com','-d','www.hardnerd-nodes.com','--non-interactive','--no-directory-hooks']
with (BACKUP/'certificate.log').open('w')as log:run(args,stdout=log,stderr=subprocess.STDOUT)
print(output(['openssl','x509','-in',str(CERT/'live/t34.hardnerd-nodes.com/fullchain.pem'),'-noout','-dates','-ext','subjectAltName']))
def start():
verify_original();assert (CERT/'live/t34.hardnerd-nodes.com/fullchain.pem').is_file()
secret=json.load(sys.stdin)
assert isinstance(secret.get('username'),str)and isinstance(secret.get('password'),str)
assert all('\n' not in secret[k]for k in ['username','password'])
environment='ADMIN_USER='+secret['username']+'\nADMIN_PASS='+secret['password']+'\nV2B_BACKEND=https://hardnerd.net\nV2B_HOST=hardnerd.net\n'
if (STATE/'admin.env').exists():assert (STATE/'admin.env').read_text()==environment,'Existing HN credentials differ; stop for inspection'
else:write(STATE/'admin.env',environment,0o600)
for name,target in [('cert.pem',CERT/'live/t34.hardnerd-nodes.com/fullchain.pem'),('key.pem',CERT/'live/t34.hardnerd-nodes.com/privkey.pem')]:
link=STATE/'ssl'/name
if link.is_symlink():assert link.resolve()==target.resolve()
else:link.symlink_to(target)
run(['docker','volume','create','hn_sgw_config'],stdout=subprocess.DEVNULL)
directory=Path(output(['docker','volume','inspect','-f','{{.Mountpoint}}','hn_sgw_config']).strip())
if not (directory/'whitelist_ips.txt').exists():write(directory/'whitelist_ips.txt','104.168.30.40\n')
else:assert (directory/'whitelist_ips.txt').read_text()=='104.168.30.40\n'
compose('config','--quiet');compose('up','-d')
# The image downloads its public IP list on first start before creating cloud_geo.conf.
deadline=time.monotonic()+55
for name in NEW:
while subprocess.run(['docker','exec',name,'nginx','-t'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode:
assert time.monotonic()<deadline,'HN initialization timeout; inspect and safely resume start'
time.sleep(2)
run(['docker','exec',name,'nginx','-t'])
old=HOST.read_text();write(BACKUP/'hn-http-only.conf',old,0o600)
HOST.write_text((BASE/'host.conf').read_text())
try:run(['nginx','-t']);run(['systemctl','reload','nginx'])
except Exception:HOST.write_text(old);run(['nginx','-t']);raise
for name in ['hn-gateway-cert.service','hn-gateway-cert.timer','hn-gateway-cloudflare-ips.service','hn-gateway-cloudflare-ips.timer']:
write(Path('/etc/systemd/system')/name,(BASE/name).read_text())
run(['systemctl','daemon-reload']);run(['systemctl','enable','--now','hn-gateway-cert.timer','hn-gateway-cloudflare-ips.timer'])
write(STATE/'deployment.json',json.dumps({'commit':output(['git','-C',str(BASE),'rev-parse','HEAD']).strip(),'host':'t34.hardnerd-nodes.com','upstream':'https://hardnerd.net','containers':NEW,'admin_address':'https://127.0.0.1:26444','public_admin':False},indent=2),0o600)
verify_original();print('HN isolated gateway started; valid-token acceptance still required.')
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('phase',choices=['prepare','certificate','start','verify-original']);args=parser.parse_args()
assert os.geteuid()==0,'Run on the registered gateway host as root'
{'prepare':prepare,'certificate':certificate,'start':start,'verify-original':verify_original}[args.phase]()

View File

@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""One-time, guarded HN endpoint migration; no DPL/FUC mutations."""
from pathlib import Path
import argparse,json,os,shutil,subprocess,sys,time
sys.dont_write_bytecode=True
sys.path.insert(0,str(Path(__file__).resolve().parent))
import install as deployment
BASE=Path(__file__).resolve().parent
BACKUP=Path('/root/codex-backups/hn-gateway/20261028-t34')
STATE=deployment.STATE
OLD='c2.hardnerd-nodes.com'
NEW='t34.hardnerd-nodes.com'
HOST=deployment.HOST
PROXY=Path('/etc/nginx/hn-subscribe-proxy.conf')
UNIT=Path('/etc/systemd/system/hn-gateway-cert.service')
def run(args,**kw):return subprocess.run(args,check=True,**kw)
def protected():
before=json.loads((BACKUP/'protected.json').read_text());now=deployment.original_state()
assert now['containers']==before['containers'],'Original containers changed'
assert all(now['nginx'].get(k)==v for k,v in before['nginx'].items()),'Original virtual hosts changed'
print('DPL/FUC container and original Nginx baseline preserved.')
def prepare():
assert not BACKUP.exists() and OLD in HOST.read_text()
assert json.loads((STATE/'deployment.json').read_text())['host']==OLD
BACKUP.mkdir(mode=0o700,parents=True)
(BACKUP/'protected.json').write_text(json.dumps(deployment.original_state()));(BACKUP/'protected.json').chmod(0o600)
for source,name in [(HOST,'host.conf'),(PROXY,'proxy.conf'),(UNIT,'cert.service'),(STATE/'deployment.json','deployment.json')]:
shutil.copy2(source,BACKUP/name);(BACKUP/name).chmod(0o600)
(BACKUP/'symlinks.json').write_text(json.dumps({name:os.readlink(STATE/'ssl'/name) for name in ['cert.pem','key.pem']}));(BACKUP/'symlinks.json').chmod(0o600)
http='server_name '+OLD+' hardnerd-nodes.com www.hardnerd-nodes.com;'
s=HOST.read_text();assert s.count(http)==1
HOST.write_text(s.replace(http,'server_name '+OLD+' '+NEW+' hardnerd-nodes.com www.hardnerd-nodes.com;',1))
try:run(['nginx','-t']);run(['systemctl','reload','nginx'])
except Exception:shutil.copy2(BACKUP/'host.conf',HOST);raise
protected();print('HN t34 ACME route ready; c2 subscription still active.')
def certificate():
protected()
args=['certbot','certonly','--webroot','-w',str(STATE/'acme'),'--config-dir',str(deployment.CERT),'--work-dir',str(STATE/'certbot'),'--logs-dir','/var/log/hn-gateway-certbot','--cert-name',NEW,'-d',NEW,'-d','hardnerd-nodes.com','-d','www.hardnerd-nodes.com','--non-interactive','--no-directory-hooks']
with (BACKUP/'certificate.log').open('w')as log:run(args,stdout=log,stderr=subprocess.STDOUT)
run(['openssl','x509','-in',str(deployment.CERT/'live'/NEW/'fullchain.pem'),'-noout','-dates','-ext','subjectAltName'])
def activate():
protected();assert not (BACKUP/'activated.json').exists()
assert (deployment.CERT/'live'/NEW/'fullchain.pem').is_file()
for name,file in [('cert.pem','fullchain.pem'),('key.pem','privkey.pem')]:
temp=STATE/'ssl'/(name+'.new');assert not temp.exists();temp.symlink_to(deployment.CERT/'live'/NEW/file);temp.replace(STATE/'ssl'/name)
PROXY.write_text((BASE/'proxy.conf').read_text());HOST.write_text((BASE/'host.conf').read_text());UNIT.write_text((BASE/'hn-gateway-cert.service').read_text())
# Recreate only HN so bind-mounted templates pick up their new inode, retaining volumes and credentials.
deployment.compose('up','-d','--force-recreate','gateway','admin')
deadline=time.monotonic()+55
for name in deployment.NEW:
while subprocess.run(['docker','exec',name,'nginx','-t'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode:
assert time.monotonic()<deadline,'HN readiness timed out; inspect and restore backup'
time.sleep(2)
run(['docker','exec',name,'nginx','-t'])
run(['nginx','-t']);run(['systemctl','reload','nginx']);run(['systemctl','daemon-reload'])
data=json.loads((STATE/'deployment.json').read_text());data['host']=NEW;data['commit']=subprocess.check_output(['git','-C',str(BASE),'rev-parse','HEAD'],text=True).strip()
(STATE/'deployment.json').write_text(json.dumps(data,indent=2));(STATE/'deployment.json').chmod(0o600)
protected();(BACKUP/'activated.json').write_text(json.dumps({'host':NEW,'commit':data['commit']}));(BACKUP/'activated.json').chmod(0o600)
print('HN t34 gateway activated. Application URL and DNS retirement require final acceptance.')
if __name__=='__main__':
assert os.geteuid()==0
parser=argparse.ArgumentParser();parser.add_argument('phase',choices=['prepare','certificate','activate','verify-original']);args=parser.parse_args()
{'prepare':prepare,'certificate':certificate,'activate':activate,'verify-original':protected}[args.phase]()

View File

@ -1,16 +0,0 @@
proxy_set_header Host t34.hardnerd-nodes.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_ssl_name t34.hardnerd-nodes.com;
proxy_ssl_verify on;
proxy_ssl_verify_depth 3;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_hide_header Cache-Control;
add_header Cache-Control "private, no-store" always;
proxy_no_cache 1;
proxy_cache_bypass 1;

View File

@ -1,9 +0,0 @@
#!/bin/sh
set -eu
[ "${RENEWED_LINEAGE:-}" = /etc/hn-gateway-letsencrypt/live/t34.hardnerd-nodes.com ] || exit 0
nginx -t
for container in hn-subscribe-gateway hn-subscribe-admin; do
docker exec "$container" nginx -t
docker exec "$container" nginx -s reload
done
systemctl reload nginx

View File

@ -1,40 +0,0 @@
#!/usr/bin/env python3
"""Update the gateway vhosts' trusted ranges, retaining the last valid configuration on failure."""
import ipaddress
import os
import pathlib
import subprocess
target = pathlib.Path("/etc/nginx/hn-cloudflare-realip.conf")
ranges = []
for version in (4, 6):
result = subprocess.run(["curl", "-4", "--fail", "--silent", "--show-error", "--max-time", "30",
f"https://www.cloudflare.com/ips-v{version}"], check=True, capture_output=True, text=True)
nets = [ipaddress.ip_network(line.strip(), strict=True) for line in result.stdout.splitlines() if line.strip()]
assert 5 <= len(nets) <= 100 if version == 4 else 2 <= len(nets) <= 100, "Unexpected range count"
assert all(n.version == version and n.prefixlen >= (8 if version == 4 else 16) for n in nets), "Invalid trust range"
ranges.extend(str(n) for n in nets)
assert len(ranges) == len(set(ranges)), "Duplicate ranges"
data = ("# Subscription vhosts: synchronized from Cloudflare official lists.\n" +
"".join(f"set_real_ip_from {n};\n" for n in ranges) +
"real_ip_header CF-Connecting-IP;\nreal_ip_recursive off;\n").encode()
old = target.read_bytes() if target.exists() else None
if old == data:
print("Cloudflare trust ranges unchanged")
raise SystemExit
temporary = target.with_suffix(".conf.new")
temporary.write_bytes(data)
temporary.chmod(0o644)
os.replace(temporary, target)
try:
subprocess.run(["nginx", "-t"], check=True)
subprocess.run(["systemctl", "reload", "nginx"], check=True)
except Exception:
if old is None:
target.unlink()
else:
temporary.write_bytes(old)
temporary.chmod(0o644)
os.replace(temporary, target)
raise
print(f"HN Cloudflare trust ranges updated: {len(ranges)} ranges")

View File

@ -1,18 +0,0 @@
<?php
// Run only after building the selected hn theme, within the hn deployment checkout.
require __DIR__.'/../../vendor/autoload.php';
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if (!App\Support\SiteProfile::isHn()) {
throw new RuntimeException('hn asset publication requires SITE_PROFILE=hn.');
}
$theme = config('site.theme', 'EZ');
$build = $theme === 'EZ' ? 'build:hn' : 'build:hardnerd';
if (!is_file(base_path('theme/'.$theme.'/assets/.vite/manifest.json'))) {
throw new RuntimeException('Build '.$theme.' first: cd frontends && npm ci && npm run '.$build);
}
if (!Illuminate\Support\Facades\File::copyDirectory(base_path('theme/'.$theme), public_path('theme/'.$theme))) {
throw new RuntimeException('Failed to publish '.$theme.' assets.');
}
echo $theme." assets published for hn.\n";

View File

@ -1,134 +0,0 @@
"""Token-free dynamic evidence validation shared by the reusable site adapter."""
import hashlib
import ipaddress
import re
import json
import time
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest()
def snapshot(nodes):
if not isinstance(nodes, list) or not nodes:
raise ValueError('Empty authorized node set')
fields = ('id', 'type', 'tag', 'relay', 'relay_port', 'sni', 'expected_exit', 'country')
result = []
for node in nodes:
if node.get('type') != 'anytls' or any(node.get(k) in (None, '') for k in fields):
raise ValueError('Unsupported protocol or incomplete independently registered expectations')
if type(node['id']) is not int or node['id']<1 or type(node['relay_port']) is not int or not 1<=node['relay_port']<=65535 or not isinstance(node['country'],str) or not re.fullmatch('[A-Z]{2}',node['country']):
raise ValueError('Invalid authorized node identity or expectation')
address=ipaddress.ip_address(node['expected_exit'])
if getattr(address,'scope_id',None):raise ValueError('Scoped exit expectation unsupported')
result.append({k: node[k] for k in fields})
for key in ('id', 'tag'):
if len({n[key] for n in result}) != len(result):
raise ValueError('Duplicate authorized node identity')
if len({(n['relay'], n['relay_port'], n['sni']) for n in result}) != len(result):
raise ValueError('Ambiguous authorized endpoint')
return sorted(result, key=lambda n: n['id'])
def candidate_eligibility(total_memory_bytes, xrayr_installed):
"""Use physical MemTotal, excluding swap and currently free memory. 1 GB = 10**9 bytes."""
if type(total_memory_bytes) is not int or total_memory_bytes <= 0 or type(xrayr_installed) is not bool:
raise ValueError('Unknown candidate memory or XrayR installation')
return not (total_memory_bytes < 1_000_000_000 and xrayr_installed)
def validate_impact(impact):
if impact is None:
return
if not isinstance(impact, dict) or set(impact) != {'shared_changes', 'affected_snis', 'review'}:
raise ValueError('Explicit reviewed impact required')
if not isinstance(impact['review'], str) or not impact['review'].strip():
raise ValueError('Impact review reference required')
for key in ('shared_changes', 'affected_snis'):
if not isinstance(impact[key], list) or any(not isinstance(x, str) or not x.strip() for x in impact[key]) or len(set(impact[key])) != len(impact[key]):
raise ValueError('Invalid impact inventory')
def verification_scope(nodes, impact=None):
"""Unknown/shared impact keeps full tests; reviewed append uses light TLS probes."""
nodes = snapshot(nodes)
if impact is None:
return {n['tag']: 'full' for n in nodes}
validate_impact(impact)
if not set(impact['affected_snis']).issubset({n['sni'] for n in nodes}):
raise ValueError('Affected node absent from current authorization set')
return {n['tag']: 'full' if impact['shared_changes'] or n['sni'] in impact['affected_snis'] else 'tls' for n in nodes}
def audit(before, after, report_bytes, qa_bytes, binding, plan_sha, expected_qas, now=None, ttl=900, scope=None):
now = time.time() if now is None else now
before, after = snapshot(before), snapshot(after)
if before != after or binding.get('snapshot_sha256') != digest(before):
raise ValueError('Authorized node set changed; regenerate and verify the complete set')
if binding.get('plan_sha256') != plan_sha:
raise ValueError('Wrong plan binding')
for label, raw in [('report', report_bytes), ('qa_rejections', qa_bytes)]:
if binding.get(label + '_sha256') != hashlib.sha256(raw).hexdigest():
raise ValueError('Evidence bytes changed')
report, qas = json.loads(report_bytes), json.loads(qa_bytes)
for stamp in (binding.get('verified_at'), report.get('verified_at'), qas.get('captured_at')):
if type(stamp) not in (int, float) or not 0 <= now-stamp <= ttl:
raise ValueError('Evidence expired or future dated')
modes = {n['tag']: 'full' for n in before} if scope is None else scope
if set(modes) != {n['tag'] for n in before} or any(v not in ('full', 'tls') for v in modes.values()):
raise ValueError('Incomplete verification scope')
if scope is not None and binding.get('scope_sha256') != digest(modes):
raise ValueError('Verification scope binding changed')
if report.get('state') != 'live_verified' or len(report.get('results', [])) != len(before):
raise ValueError('Incomplete live verification')
actual = {}
for result in report['results']:
tag = result.get('tag')
if tag in actual or tag not in modes:
raise ValueError('Unexpected or duplicate verification result')
actual[tag] = result
for node in before:
result = actual.get(node['tag'], {})
if result.get('live') is not True or result.get('strict_tls') is not True or result.get('verification', 'full') != modes[node['tag']]:
raise ValueError('Required verification mode or strict TLS missing')
if any(result.get(k) != node[k] for k in ('relay', 'relay_port', 'sni')):
raise ValueError('Node endpoint mismatch')
if modes[node['tag']] == 'full' and (result.get('exit_ip') != node['expected_exit'] or result.get('country') != node['country']):
raise ValueError('Node exit/country mismatch')
if qas.get('plan_sha256') != plan_sha or set(qas.get('users', {})) != set(expected_qas):
raise ValueError('QA evidence identity mismatch')
for key, identity in expected_qas.items():
q = qas['users'][key]
if q.get('id') != identity or q.get('http_status') != 403 or q.get('banned') is not True or q.get('excluded') is not True:
raise ValueError('QA account not fully revoked')
return {'nodes': len(before), 'snapshot_sha256': digest(before), 'verified_at': now}
def service_change(before, current):
"""Process movement is investigatory; never silently accept/rebase it."""
required = ('unit_sha256', 'binary_sha256', 'config_sha256', 'listeners')
if any(not before.get(k) or not current.get(k) for k in required):
return 'unknown'
if any(before[k] != current[k] for k in required) or current.get('active') is not True:
return 'conflict'
if any(before.get(k) != current.get(k) for k in ('pid', 'started', 'restarts')):
return 'investigate_restart'
return 'ready'
def provider_state(observed, node, now=None):
"""Canonical observations from the registered provider UI/API, never HTML substrings."""
now=time.time() if now is None else now
stamp=observed.get('captured_at')
if type(stamp) not in (int,float) or not 0<=now-stamp<=900:
raise ValueError('Provider observation expired')
if not isinstance(observed.get('dns'),list) or not isinstance(observed.get('relays'),list):
raise ValueError('Canonical DNS and relay observations required')
dns=[d for d in observed['dns'] if d.get('name')==node['tls_domain']]
relays=[r for r in observed['relays'] if r.get('host')==node['relay_host'] and r.get('port')==node['relay_port']]
if dns and (len(dns)!=1 or dns[0].get('type')!='A' or dns[0].get('address')!=node['node_address'] or dns[0].get('proxy') is not False):
raise ValueError('DNS provider resource conflict')
if relays and (len(relays)!=1 or relays[0].get('target')!=node['node_address'] or relays[0].get('listen_port')!=node['listen_port'] or relays[0].get('name')!='dpl-'+node['key']+'-anytls' or relays[0].get('proxy_protocol_in') is not False or relays[0].get('proxy_protocol_out') is not False):
raise ValueError('Relay provider resource conflict')
return {'dns':dns,'relays':relays}

View File

@ -1,461 +0,0 @@
#!/usr/bin/env python3
"""DPL append-only batch coordinator. Adapters must probe before and after writes."""
import argparse
import concurrent.futures
import contextlib
import fcntl
import hashlib
import ipaddress
import importlib.util
import json
import os
from pathlib import Path
import re
import signal
import sqlite3
import subprocess
import sys
import threading
import time
PHASES = ('stage', 'dns', 'provision', 'relay', 'verify', 'publish', 'audit')
SHARED = {'stage': 'panel', 'dns': 'dns', 'relay': 'relay', 'publish': 'panel'}
STOP = threading.Event()
EVENTS = False
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(',', ':')).encode()).hexdigest()
def read_json(path):
return json.loads(Path(path).read_text())
def atomic_json(path, value):
path = Path(path)
tmp = path.with_name(path.name + '.tmp-' + str(os.getpid()) + '-' + str(threading.get_ident()))
with tmp.open('x') as f:
os.chmod(tmp, 0o600)
json.dump(value, f, ensure_ascii=False, sort_keys=True)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def validate(plan):
if plan.get('version') != 1 or plan.get('mode') != 'append':
raise ValueError('Only version=1, mode=append is supported')
if not re.fullmatch(r'[a-z0-9-]{1,64}', plan.get('batch_id', '')):
raise ValueError('Invalid batch_id')
context = plan.get('context', {})
required = ('panel_vps', 'gateway_vps', 'jump_vps', 'app_root', 'panel_url',
'subscription_url', 'provision_commit', 'bundle', 'client_binary')
if any(not context.get(k) for k in required):
raise ValueError('Missing explicit context fields')
if not re.fullmatch(r'[0-9a-f]{40}', context['provision_commit']):
raise ValueError('A full reviewed, pushed Git commit is required')
for k in ('panel_url', 'subscription_url'):
if not re.fullmatch(r'https://[a-z0-9.-]+', context[k]):
raise ValueError('HTTPS origin without token/path required: ' + k)
adapter = plan.get('adapter')
if not isinstance(adapter, list) or not adapter or not all(isinstance(x, str) for x in adapter):
raise ValueError('adapter must be an argv array, never a shell string')
if not plan.get('nodes') or not isinstance(plan['nodes'], list):
raise ValueError('A nonempty node list is required')
seen = {k: set() for k in ('key', 'name', 'vps_id', 'node_address', 'tls_domain', 'relay', 'socks_port', 'number')}
for n in plan['nodes']:
if any(k not in n for k in ('key', 'country', 'sequence', 'flag', 'country_name', 'name',
'vps_id', 'node_address', 'listen_port', 'relay_host',
'relay_port', 'tls_domain', 'formal_group_id',
'expected_exit', 'socks_port')):
raise ValueError('Incomplete node specification')
if not re.fullmatch(r'[a-z0-9-]{1,48}', n['key']):
raise ValueError('Invalid deployment key')
if not re.fullmatch(r'[A-Z]{2}', n['country']):
raise ValueError('Country must be a two-letter code')
if type(n['sequence']) is not int or not 1 <= n['sequence'] <= 99:
raise ValueError('Display sequence must be 01..99')
expected = f"{n['flag']} {n['country_name']} {n['country']} - {n['sequence']:02d}"
flag = ''.join(chr(0x1F1E6 + ord(x) - ord('A')) for x in n['country'])
if n['name'] != expected or n['flag'] != flag:
raise ValueError('Display name/flag does not match country and sequence')
for k in ('listen_port', 'relay_port', 'socks_port'):
if type(n[k]) is not int or not 1024 <= n[k] <= 65535:
raise ValueError('Invalid port: ' + k)
if type(n['formal_group_id']) is not int or n['formal_group_id'] < 1:
raise ValueError('An existing formal group is required')
ipaddress.ip_address(n['node_address'])
exit_address=ipaddress.ip_address(n['expected_exit'])
if getattr(exit_address,'scope_id',None):raise ValueError('Scoped/link-local exit addresses are not supported')
for k in ('relay_host', 'tls_domain'):
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}', n[k]):
raise ValueError('Invalid DNS name: ' + k)
for k in ('vps_id',):
if not re.fullmatch(r'VPS-[0-9]{3}', n[k]):
raise ValueError('Use the exact CoDocs VPS ID')
identities = {k: n[k] for k in ('key', 'name', 'vps_id', 'node_address', 'tls_domain', 'socks_port')}
identities.update(relay=(n['relay_host'], n['relay_port']), number=(n['country'], n['sequence']))
for k, value in identities.items():
if value in seen[k]:
raise ValueError('Duplicate batch identity: ' + k)
seen[k].add(value)
# The plan carries references, never secret values. Adapters load credentials themselves.
def check(value):
if isinstance(value, dict):
for k, v in value.items():
if re.search(r'password|secret|token|private_key', k, re.I):
raise ValueError('Secret fields are not allowed in a batch plan')
check(v)
elif isinstance(value, list):
for v in value:
check(v)
check(plan)
return plan
@contextlib.contextmanager
def lock(root, name):
path = root / ('lock-' + hashlib.sha256(name.encode()).hexdigest())
with path.open('a+') as f:
os.chmod(path, 0o600)
while True:
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if STOP.wait(.1):
raise RuntimeError('Stopped while waiting for resource lock')
try:
yield
finally:
fcntl.flock(f, fcntl.LOCK_UN)
class Ledger:
def __init__(self, root):
self.root = Path(root).resolve()
if self.root == Path('/'):
raise ValueError('Invalid state root')
self.root.mkdir(parents=True, mode=0o700, exist_ok=True)
os.chmod(self.root, 0o700)
self.path = self.root / 'registry.sqlite3'
with self.db() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS batches(id TEXT PRIMARY KEY, digest TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS claims(namespace TEXT, kind TEXT, value TEXT,
owner TEXT NOT NULL, PRIMARY KEY(namespace,kind,value));
CREATE TABLE IF NOT EXISTS evidence(batch TEXT,node TEXT,phase TEXT,
dependency TEXT,expires REAL,request TEXT,PRIMARY KEY(batch,node,phase));
CREATE TABLE IF NOT EXISTS phases(batch TEXT, node TEXT, phase TEXT,
status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
updated REAL NOT NULL, PRIMARY KEY(batch,node,phase));
''')
os.chmod(self.path, 0o600)
@contextlib.contextmanager
def db(self):
c = sqlite3.connect(self.path, timeout=30)
try:
c.execute('PRAGMA synchronous=FULL')
with c: yield c
finally: c.close()
def reserve(self, plan):
namespace = plan['context']['panel_vps'] + ':' + plan['context']['app_root']
with self.db() as c:
c.execute('BEGIN IMMEDIATE')
prior = c.execute('SELECT digest FROM batches WHERE id=?', (plan['batch_id'],)).fetchone()
if prior and prior[0] != digest(plan):
raise ValueError('Batch plan changed; resume requires the original exact plan')
for n in plan['nodes']:
owner = digest({'context': plan['context'], 'node': n})
claims = {k: n[k] for k in ('key', 'name', 'vps_id', 'node_address', 'tls_domain')}
claims['relay'] = [n['relay_host'], n['relay_port']]
claims['number'] = [n['country'], n['sequence']]
for kind, value in claims.items():
scope = namespace
if kind == 'relay':
scope = 'relay:' + n['relay_host']
elif kind in ('vps_id', 'node_address', 'tls_domain'):
scope = 'infrastructure'
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True)
existing = c.execute('SELECT owner FROM claims WHERE namespace=? AND kind=? AND value=?',
(scope, kind, encoded)).fetchone()
if existing and existing[0] != owner:
raise ValueError('Another batch reserved identity: ' + kind)
c.execute('INSERT OR IGNORE INTO claims VALUES(?,?,?,?)', (scope, kind, encoded, owner))
# SOCKS listeners are shared by all batches using this gateway.
scope, value = 'gateway:' + plan['context']['gateway_vps'], str(n['socks_port'])
old = c.execute('SELECT owner FROM claims WHERE namespace=? AND kind=? AND value=?',
(scope, 'socks_port', value)).fetchone()
if old and old[0] != owner:
raise ValueError('Another batch reserved the gateway SOCKS port')
c.execute('INSERT OR IGNORE INTO claims VALUES(?,?,?,?)', (scope, 'socks_port', value, owner))
formal_port=plan['context'].get('formal_socks_port')
if formal_port is not None:
if type(formal_port) is not int or not 1024<=formal_port<=65535 or any(n['socks_port']==formal_port for n in plan['nodes']):
raise ValueError('A distinct formal SOCKS port is required')
scope,value='gateway:'+plan['context']['gateway_vps'],str(formal_port)
owner=digest({'context':plan['context'],'formal_port':formal_port})
old=c.execute('SELECT owner FROM claims WHERE namespace=? AND kind=? AND value=?',(scope,'socks_port',value)).fetchone()
if old and old[0]!=owner:raise ValueError('Formal SOCKS port already reserved')
c.execute('INSERT OR IGNORE INTO claims VALUES(?,?,?,?)',(scope,'socks_port',value,owner))
c.execute('INSERT OR IGNORE INTO batches VALUES(?,?)', (plan['batch_id'], digest(plan)))
def state(self, batch, key, phase):
with self.db() as c:
row = c.execute('SELECT status FROM phases WHERE batch=? AND node=? AND phase=?',
(batch, key, phase)).fetchone()
return row[0] if row else 'pending'
def reusable(self, batch, key, phase, dependency, request):
if not dependency or self.state(batch, key, phase) != 'done':
return False
with self.db() as c:
row = c.execute('SELECT dependency,expires,request FROM evidence WHERE batch=? AND node=? AND phase=?', (batch,key,phase)).fetchone()
return bool(row and row[0] == dependency and time.time() <= row[1] <= time.time()+900 and row[2] == request)
def proof(self, batch, key, phase, dependency, expires, request):
with self.db() as c:
c.execute('INSERT OR REPLACE INTO evidence VALUES(?,?,?,?,?,?)',(batch,key,phase,dependency,expires,request))
def mark(self, batch, key, phase, status, attempt=False):
previous=self.state(batch,key,phase)
with self.db() as c:
c.execute('''INSERT INTO phases VALUES(?,?,?,?,?,?) ON CONFLICT(batch,node,phase)
DO UPDATE SET status=excluded.status,attempts=phases.attempts+?,updated=excluded.updated''',
(batch, key, phase, status, int(attempt), time.time(), int(attempt)))
if previous!=status or attempt:
fd=os.open(self.root/'events.jsonl',os.O_CREAT|os.O_WRONLY|os.O_APPEND,0o600)
try:
os.fchmod(fd,0o600)
event=json.dumps({'batch':batch,'node':key,'phase':phase,'status':status,'time':time.time()})+'\n'
os.write(fd,event.encode())
if EVENTS:sys.stdout.write(event);sys.stdout.flush()
finally:os.close(fd)
class Coordinator:
def __init__(self, plan, root, jobs=4, timeout=900):
self.plan = validate(plan)
self.ledger = Ledger(root)
self.jobs = jobs
self.timeout = timeout
self.dependencies = {}
self.runtime = None
self.ledger.reserve(plan)
self.dir = self.ledger.root / plan['batch_id']
self.dir.mkdir(mode=0o700, exist_ok=True)
self.plan_file = self.dir / 'plan.json'
atomic_json(self.plan_file, plan)
if plan.get('runtime'):
self.check_runtime()
directory = str(Path(__file__).resolve().parent)
if directory not in sys.path: sys.path.insert(0, directory)
from site_adapter import SiteAdapter
self.runtime = SiteAdapter(plan, self.dir, atomic_writer=atomic_json, resource_lock=lock)
def check_runtime(self):
runtime = self.plan['runtime']
if runtime.get('kind') != 'ssh-site-v1': raise ValueError('Unknown runtime')
if runtime.get('python') != str(Path(sys.executable).resolve()):raise ValueError('Use the frozen runtime interpreter')
import paramiko
if paramiko.__version__ != runtime.get('paramiko_version'):raise ValueError('Runtime dependency version changed')
base = Path(__file__).resolve().parent
required = [base/n for n in ('batch.py','site_adapter.py','ssh_pool.py','acceptance.py')]
required += [Path(self.plan['context'][k]) for k in ('ssh_config','exit_registry')]
files = runtime.get('files', {})
if any(str(f) not in files for f in required): raise ValueError('Runtime source pin missing')
for f, sha in files.items():
if not Path(f).is_absolute() or hashlib.sha256(Path(f).read_bytes()).hexdigest() != sha:
raise ValueError('Runtime code/config changed')
def call(self, n, phase, action):
fingerprint = digest({'context': self.plan['context'], 'node': n})
path = self.dir / (n['key'] + '.json')
atomic_json(path, dict(n, request_sha256=fingerprint))
argv = self.plan['adapter'] + ['--plan', str(self.plan_file), '--node', str(path),
'--phase', phase, '--action', action, '--state-dir', str(self.dir)]
if self.runtime:
self.check_runtime()
r = self.runtime.call(dict(n, request_sha256=fingerprint), phase, action)
if (r.get('key'),r.get('phase'),r.get('request_sha256')) != (n['key'],phase,fingerprint):
raise RuntimeError('Runtime identity mismatch')
if r.get('status') not in ('ready','absent','waiting','unknown','conflict'):
raise RuntimeError('Runtime status invalid')
return r
with subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
start_new_session=True) as p:
deadline = time.monotonic() + self.timeout
while True:
try:
output, _ = p.communicate(timeout=.2)
break
except subprocess.TimeoutExpired:
if STOP.is_set() or time.monotonic() >= deadline:
os.killpg(p.pid, signal.SIGTERM)
try:
p.communicate(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL)
p.communicate()
raise RuntimeError('Adapter interrupted; remote outcome must be probed')
if p.returncode or len(output) > 65536:
raise RuntimeError('Adapter failed; no stdout/stderr persisted')
r = json.loads(output)
if (r.get('key'), r.get('phase'), r.get('request_sha256')) != (n['key'], phase, fingerprint):
raise RuntimeError('Adapter receipt identity mismatch')
if r.get('status') not in ('ready', 'absent', 'waiting', 'unknown', 'conflict'):
raise RuntimeError('Invalid adapter status')
return r
def phase(self, n, phase):
try:
return self._phase(n, phase)
except Exception:
self.ledger.mark(self.plan['batch_id'], n['key'], phase, 'unknown')
return False
def _phase(self, n, phase):
batch, key = self.plan['batch_id'], n['key']
resource = SHARED.get(phase, n['vps_id'])
# Common panel/dns/relay lock names across batches and processes.
namespace = self.plan['context']['panel_vps']
if resource in ('dns', 'relay'):
namespace = 'dpl-external'
with lock(self.ledger.root, namespace + ':' + resource):
dependency = self.dependencies.get(key, {}).get(phase)
request = digest({'plan': self.plan, 'node': n})
# Publish/audit/preflight are always live; unknown or running phases cannot use a cache.
if phase in ('stage','dns','provision','relay','verify') and self.ledger.reusable(batch,key,phase,dependency,request):
if self.runtime: self.check_runtime()
return True
r = self.call(n, phase, 'probe')
if phase == 'preflight' and r['status'] == 'ready':
deps = r.get('resume_dependencies', {})
clean = {k:v for k,v in deps.items() if k in PHASES and isinstance(v,str) and re.fullmatch('[0-9a-f]{64}',v)}
self.dependencies[key] = {k:digest({p:clean.get(p) for p in PHASES[:PHASES.index(k)+1]}) for k in clean}
if r['status'] == 'ready':
if dependency and type(r.get('valid_until')) in (int,float) and time.time() < r['valid_until'] <= time.time()+900:
self.ledger.proof(batch,key,phase,dependency,r['valid_until'],request)
self.ledger.mark(batch, key, phase, 'done')
return True
if r['status'] == 'conflict':
STOP.set()
if phase in ('preflight', 'verify', 'audit') or r['status'] != 'absent' or r.get('retry_safe') is not True:
self.ledger.mark(batch, key, phase, r['status'])
return False
if STOP.is_set():
return False
# Persist intent before a write; never infer success from the command exit code.
self.ledger.mark(batch, key, phase, 'running', attempt=True)
applied = self.call(n, phase, 'apply')
if applied['status'] == 'conflict':
self.ledger.mark(batch, key, phase, 'conflict')
STOP.set()
return False
r = self.call(n, phase, 'probe')
self.ledger.mark(batch, key, phase, 'done' if r['status'] == 'ready' else r['status'])
if r['status'] == 'ready' and dependency and type(r.get('valid_until')) in (int,float) and time.time()<r['valid_until']<=time.time()+900:
self.ledger.proof(batch,key,phase,dependency,r['valid_until'],request)
if r['status'] == 'conflict':
STOP.set()
return r['status'] == 'ready'
def worker(self, n):
try:
with lock(self.ledger.root, 'node:' + self.plan['context']['panel_vps'] + ':' + n['key']):
self.ledger.mark(self.plan['batch_id'], n['key'], 'coordinator', 'running')
# Already finished batches are re-audited; disabled QA need not be re-enabled.
if self.ledger.state(self.plan['batch_id'], n['key'], 'audit') == 'done':
success = self.phase(n, 'audit')
else:
success = True
for phase in PHASES:
if STOP.is_set() or not self.phase(n, phase):
success = False
break
self.ledger.mark(self.plan['batch_id'], n['key'], 'coordinator', 'done' if success else 'waiting')
return success
except Exception:
# Never print adapter payloads, commands, credentials or exception contents.
self.ledger.mark(self.plan['batch_id'], n['key'], 'coordinator', 'unknown')
return False
def run(self):
# Whole-batch barrier: no staging until every candidate passed current preflight.
with concurrent.futures.ThreadPoolExecutor(max_workers=self.jobs) as pool:
def check(n):
try:
return self.phase(n, 'preflight')
except Exception:
self.ledger.mark(self.plan['batch_id'], n['key'], 'preflight', 'unknown')
return False
checks = list(pool.map(check, self.plan['nodes']))
if not all(checks):
return False
return all(list(pool.map(self.worker, self.plan['nodes'])))
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('command', choices=('plan', 'run', 'status'))
p.add_argument('--plan', required=True)
p.add_argument('--state-root', required=True)
p.add_argument('--jobs', type=int, default=4)
p.add_argument('--timeout', type=int, default=900)
p.add_argument('--events', action='store_true', help='Emit token-free phase transition JSON lines; final result is last line')
p.add_argument('--execute', action='store_true', help='Required for a run that may write remotely')
a = p.parse_args()
global EVENTS
EVENTS = a.events
os.umask(0o077)
if not 1 <= a.jobs <= 16 or not 1 <= a.timeout <= 3600:
p.error('jobs must be 1..16; timeout 1..3600')
try:
plan = validate(read_json(a.plan))
if a.command == 'plan':
print(json.dumps({'batch_id': plan['batch_id'], 'nodes': len(plan['nodes']),
'plan_sha256': digest(plan), 'writes': False}))
return 0
if a.command == 'run' and not a.execute:
p.error('run requires --execute; plan does not perform remote requests')
if a.command == 'status':
path = Path(a.state_root).resolve() / 'registry.sqlite3'
rows = {}
if path.exists():
with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as c:
prior = c.execute('SELECT digest FROM batches WHERE id=?', (plan['batch_id'],)).fetchone()
if prior and prior[0] != digest(plan):
raise ValueError('Batch plan differs from its reserved plan')
rows = {(key, phase): status for key, phase, status in c.execute(
'SELECT node,phase,status FROM phases WHERE batch=?', (plan['batch_id'],))}
print(json.dumps({n['key']: {phase: rows.get((n['key'], phase), 'pending')
for phase in ('preflight',) + PHASES + ('coordinator',)}
for n in plan['nodes']}))
return 0
runner = Coordinator(plan, a.state_root, a.jobs, a.timeout)
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, lambda *_: STOP.set())
try:
success = runner.run()
finally:
if runner.runtime:
try:runner.runtime.close()
except Exception:
for node in plan['nodes']:runner.ledger.mark(plan['batch_id'],node['key'],'coordinator','unknown')
raise
print(json.dumps({'batch_id': plan['batch_id'], 'complete': success,
'next': None if success else 'Inspect status, resolve waiting/conflict, rerun the exact plan'}))
return 0 if success else 2
except Exception as exc:
# Validation errors have no adapter/credential material.
print(json.dumps({'error': str(exc) if isinstance(exc, ValueError) else 'Coordinator failed; inspect protected state'}))
return 2
if __name__ == '__main__':
sys.exit(main())

View File

@ -1,60 +0,0 @@
#!/usr/bin/env python3
"""Build a versioned flat operations bundle from committed files, without touching the worktree/index."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
FILES=('stage.php','promote.php','operations.php','rollback.php','readiness.php',
'verify-node.py','panel-acceptance.php','install-node.sh','renew-node.sh','dpl-node.service')
def build(repo,source,base,branch,bundle,advance=False):
if not all(re.fullmatch('[0-9a-f]{40}',r) for r in (source,base)) or not re.fullmatch('codex/[A-Za-z0-9_/-]+',branch) or '..' in branch:
raise ValueError('Explicit full commits and a new codex branch required')
destination=Path(bundle)
if not destination.is_absolute() or destination.exists() or destination.is_symlink():raise ValueError('New absolute bundle path required')
def git(*args,input=None,env=None):
r=subprocess.run(['git','-C',str(repo),*args],input=input,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,env=env)
if r.returncode:raise ValueError('Git source/base/ref check failed')
return r.stdout
git('cat-file','-e',source+'^{commit}');git('cat-file','-e',base+'^{commit}');git('check-ref-format','refs/heads/'+branch)
exists=subprocess.run(['git','-C',str(repo),'show-ref','--verify','--quiet','refs/heads/'+branch]).returncode
expected_old='0'*40
if exists==0 and advance:
expected_old=git('rev-parse','refs/heads/'+branch).decode().strip()
if expected_old!=base:raise ValueError('Advance requires the exact current branch head as reviewed base')
elif exists!=1:raise ValueError('Branch exists or outcome unknown; inspect it, do not overwrite')
with tempfile.TemporaryDirectory(prefix='dpl-ops-index-',dir=destination.parent) as temp:
env=dict(os.environ,GIT_INDEX_FILE=str(Path(temp)/'index'))
git('read-tree',base,env=env)
for name in FILES:
entry=git('ls-tree',source,'--','scripts/node-onboarding/'+name).decode().strip()
if not entry:raise ValueError('Required committed operations file missing')
mode,kind,rest=entry.split(' ',2);sha,_=rest.split('\t',1)
if kind!='blob' or mode not in ('100644','100755'):raise ValueError('Regular committed operations file required')
git('update-index','--add','--cacheinfo',mode+','+sha+','+name,env=env)
tree=git('write-tree',env=env).decode().strip()
commit=git('commit-tree',tree,'-p',base,input=('DPL operations snapshot\n\nSource commit: '+source+'\n').encode()).decode().strip()
git('update-ref','refs/heads/'+branch,commit,expected_old)
git('bundle','create',str(destination),'refs/heads/'+branch)
destination.chmod(0o600)
return {'commit':commit,'source_commit':source,'branch':branch,'bundle_sha256':hashlib.sha256(destination.read_bytes()).hexdigest(),'files':len(FILES),'network':False}
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--repo',default=str(Path(__file__).resolve().parents[2]))
for k in ('source','base','branch','bundle'):p.add_argument('--'+k,required=True)
p.add_argument('--advance',action='store_true',help='Explicit fast-forward only from the exact --base current branch head')
a=p.parse_args()
try:
print(json.dumps(build(a.repo,a.source,a.base,a.branch,a.bundle,a.advance)));return 0
except Exception:
print(json.dumps({'state':'blocked','reason':'Inspect source/base, branch and bundle outcome before retry; no network performed'}));return 2
if __name__=='__main__':raise SystemExit(main())

View File

@ -1,66 +0,0 @@
#!/usr/bin/env python3
"""Run reviewed, pinned site-specific commands for batch.py without a shell.
Missing integration is a waiting gate, never an assumed successful operation.
Handlers receive paths to the frozen request, not passwords on the command line.
"""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import sys
def main():
p = argparse.ArgumentParser(description=__doc__)
for field in ('plan', 'node', 'phase', 'action', 'state-dir'):
p.add_argument('--' + field, required=True)
a = p.parse_args()
node = json.loads(Path(a.node).read_text())
plan = json.loads(Path(a.plan).read_text())
identity = {'key': node['key'], 'phase': a.phase, 'request_sha256': node['request_sha256']}
try:
profile = plan['command_profile']
# Every runnable local script/config must be pinned; pinning the interpreter
# alone does not pin scripts, remote code or the commands' dependencies.
files = profile['files']
if not files:
raise ValueError('No pinned handler files')
for filename, expected in files.items():
if not Path(filename).is_absolute():
raise ValueError('Absolute handler paths required')
if hashlib.sha256(Path(filename).read_bytes()).hexdigest() != expected:
raise ValueError('Handler changed')
command = profile['phases'].get(a.phase, {}).get(a.action)
if command is None:
result = dict(identity, status='waiting', reason='handler_not_configured')
else:
if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command):
raise ValueError('Command must be argv')
values = dict(plan=a.plan, node=a.node, state_dir=a.state_dir,
key=node['key'], phase=a.phase, action=a.action)
argv = [x.format_map(values) for x in command]
if not Path(argv[0]).is_absolute():
raise ValueError('Absolute executable required')
completed = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
if completed.returncode or len(completed.stdout) > 65536:
raise ValueError('Handler failed')
result = json.loads(completed.stdout)
if any(result.get(k) != v for k, v in identity.items()):
raise ValueError('Wrong handler receipt')
if result.get('status') not in ('ready', 'absent', 'waiting', 'unknown', 'conflict'):
raise ValueError('Wrong handler status')
# Only structured nonsecret receipt fields are allowed through to the runner.
safe = {**identity, 'status': result['status']}
if result.get('retry_safe') is True:
safe['retry_safe'] = True
print(json.dumps(safe))
return 0
except Exception:
print(json.dumps(dict(identity, status='unknown')))
return 2
if __name__ == '__main__':
sys.exit(main())

View File

@ -1,46 +0,0 @@
#!/usr/bin/env python3
"""Freeze a reusable SSH-site plan locally. No network, credentials or ledger writes."""
import argparse
import hashlib
import json
import importlib
import sys
from pathlib import Path
from batch import validate,atomic_json
from site_adapter import SiteAdapter
def configure(plan,site):
allowed={'ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','preserved_service','preserved_service_overrides','formal_socks_port','gateway_containers','client_sha256','acceptance_impact'}
if set(site)-allowed:raise ValueError('Unexpected site configuration fields')
library=importlib.import_module('paramiko')
if library.__version__!='5.0.0':raise ValueError('Use the reviewed Paramiko 5.0.0 environment')
plan=dict(plan,context={**plan['context'],**site})
validate(plan)
adapter=SiteAdapter(plan,Path('/private/tmp/dpl-validation-only')) # Creates no directory or connections.
adapter.pool.close()
base=Path(__file__).resolve().parent
plan.pop('command_profile',None)
plan['adapter']=[str(Path(sys.executable).resolve()),str(base/'command-adapter.py')]
paths=[base/x for x in ('batch.py','site_adapter.py','ssh_pool.py','acceptance.py')]+[Path(site[k]) for k in ('ssh_config','exit_registry')]
if any(h.get('credential_format')=='gemdocs-resource' for h in adapter.pool.config['hosts'].values()):
paths += [Path('/Users/hellcat/Documents/GemDocs/AI版/tools')/name for name in ('credentials.py','resources.py')]
plan['runtime']={'kind':'ssh-site-v1','python':str(Path(sys.executable).resolve()),'paramiko_version':library.__version__,'files':{str(p):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}}
return plan
def main():
p=argparse.ArgumentParser(description=__doc__)
for k in ('plan','site','output'):p.add_argument('--'+k,required=True)
a=p.parse_args()
try:
output=Path(a.output)
if not output.is_absolute() or output.exists() or output.is_symlink():raise ValueError('New absolute output file required; never mutate a frozen plan')
plan=configure(json.loads(Path(a.plan).read_text()),json.loads(Path(a.site).read_text()))
atomic_json(output,plan)
print(json.dumps({'state':'configured','nodes':len(plan['nodes']),'network':False,'ledger_writes':False}))
return 0
except Exception:
print(json.dumps({'state':'blocked','reason':'Inspect explicit site paths, registry and plan; no network performed'}));return 2
if __name__=='__main__':raise SystemExit(main())

View File

@ -1,7 +0,0 @@
[Unit]
Requires=dpl-certbot-apparmor.service
After=dpl-certbot-apparmor.service
[Service]
# Recheck generated profiles after a snap refresh, before the existing renewal command.
ExecStartPre=/bin/bash /opt/dpl-certbot-security/provision/load-certbot-apparmor.sh --load

View File

@ -1,14 +0,0 @@
[Unit]
Description=Load existing Certbot AppArmor profiles for DPL certificate operations
After=local-fs.target
RequiresMountsFor=/snap/core/current /snap/certbot/current /var/lib/snapd/apparmor/profiles
Before=snap.certbot.renew.service
ConditionPathExists=/var/lib/snapd/apparmor/profiles/snap.certbot.certbot
[Service]
Type=oneshot
ExecStart=/bin/bash /opt/dpl-certbot-security/provision/load-certbot-apparmor.sh --load
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

View File

@ -1,30 +0,0 @@
#!/bin/bash
set -euo pipefail
# Restore only profiles already generated by the installed Certbot/core snaps.
# This does not enable the global AppArmor service or change profile modes.
[[ $(id -u) == 0 ]] || exit 1
mode=${1:---load}
[[ $# -le 1 && ( $mode == --load || $mode == --check ) ]] || exit 2
[[ $(cat /sys/module/apparmor/parameters/enabled) == Y ]] || exit 3
directory=/var/lib/snapd/apparmor/profiles
[[ -d $directory && ! -L $directory && $(stat -c %u "$directory") == 0 ]] || exit 4
[[ -z $(find "$directory" -maxdepth 0 -perm /022 -print) ]] || exit 4
[[ -f $directory/snap.certbot.certbot && -f $directory/snap.certbot.renew ]] || exit 5
shopt -s nullglob
core=$(readlink -f /snap/core/current)
[[ $core =~ ^/(var/lib/snapd/)?snap/core/([0-9]+)$ ]] || exit 5
revision=${BASH_REMATCH[2]}
profiles=("$directory"/snap.certbot.* "$directory"/snap-update-ns.certbot "$directory/snap-confine.core.$revision")
(( ${#profiles[@]} >= 4 )) || exit 5
# Validate every input before loading any profile. Never use complain/disable flags.
for profile in "${profiles[@]}"; do
[[ -f $profile && ! -L $profile && $(stat -c %u "$profile") == 0 ]] || exit 6
[[ -z $(find "$profile" -maxdepth 0 -perm /022 -print) ]] || exit 6
/usr/sbin/apparmor_parser --skip-kernel-load --skip-cache "$profile"
done
if [[ $mode == --load ]]; then
for profile in "${profiles[@]}"; do
/usr/sbin/apparmor_parser --replace --skip-cache "$profile"
done
fi
printf 'Certbot/core profiles checked: %s; mode: %s\n' "${#profiles[@]}" "$mode"

View File

@ -1,332 +0,0 @@
<?php
/** Shared CLI helpers; loading this file does not bootstrap Laravel or open a connection. */
namespace DplOnboarding;
use App\Models\{Server, ServerGroup, User, Plan, Setting};
use Illuminate\Support\Facades\{DB, Cache};
use RuntimeException;
function ids(string $value): array
{
if (!preg_match('/^[1-9][0-9]*(,[1-9][0-9]*)*$/D', $value)) {
throw new RuntimeException('Expected explicit comma-separated positive IDs');
}
$ids = array_map('intval', explode(',', $value));
if (count($ids) !== count(array_unique($ids))) throw new RuntimeException('Duplicate IDs');
return $ids;
}
function readJson(string $path): array
{
if (!is_file($path) || is_link($path)) throw new RuntimeException('Required regular journal/manifest missing');
$data = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data)) throw new RuntimeException('Invalid JSON document');
return $data;
}
function writeJson(string $path, array $data): void
{
if (is_link($path)) throw new RuntimeException('Refusing journal symlink');
$tmp = tempnam(dirname($path), '.onboarding-');
if ($tmp === false) throw new RuntimeException('Cannot create journal');
try {
chmod($tmp, 0600);
$body = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
if (file_put_contents($tmp, $body) !== strlen($body) || !rename($tmp, $path)) {
throw new RuntimeException('Cannot persist journal');
}
} finally {
if (is_file($tmp)) unlink($tmp);
}
}
function manifest(string $dir): array
{
$m = readJson($dir . '/manifest.json');
foreach (['node_id','machine_id','group_id','test_user_id'] as $field) {
if (!isset($m[$field]) || !preg_match('/^[1-9][0-9]*$/D', (string)$m[$field])) {
throw new RuntimeException('Manifest missing positive object ID');
}
$m[$field] = (int)$m[$field];
}
if (!isset($m['key']) || !preg_match('/^[a-z0-9-]+$/D', $m['key'])) throw new RuntimeException('Invalid manifest key');
return $m;
}
function locked(string $dir, callable $fn): mixed
{
if (!is_dir($dir) || is_link($dir) || (fileperms($dir)&0077)!==0) throw new RuntimeException('Existing private backup directory required');
$path = $dir . '/operation.lock';
if (is_link($path)) throw new RuntimeException('Refusing lock symlink');
$h = fopen($path, 'c');
if ($h === false) throw new RuntimeException('Cannot open operation lock');
chmod($path, 0600);
try {
if (!flock($h, LOCK_EX | LOCK_NB)) throw new RuntimeException('Another operation is active');
return $fn();
} finally { flock($h, LOCK_UN); fclose($h); }
}
function boot(): void
{
require getcwd() . '/vendor/autoload.php';
$app = require getcwd() . '/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
}
final class PanelChange
{
private const MODELS = ['node'=>Server::class,'qa'=>User::class,'user'=>User::class,
'plan'=>Plan::class,'group'=>ServerGroup::class,'setting'=>Setting::class];
private const FIELDS = ['node'=>['group_ids'],'qa'=>['banned'],'user'=>['group_id'],
'plan'=>['group_id'],'group'=>['name'],'setting'=>['value']];
private static function normalize(string $field, mixed $value): mixed
{
return match ($field) {
'group_ids' => array_map('strval', $value ?? []),
'group_id' => $value === null ? null : (int)$value,
'banned','enabled','show' => (bool)$value,
default => $value,
};
}
private static function object(string $kind, int|string $id): mixed
{
$class = self::MODELS[$kind] ?? throw new RuntimeException('Unsupported journal object');
return $class::where($kind === 'setting' ? 'name' : 'id', $id)->lockForUpdate()->first();
}
private static function value(mixed $model, string $field): mixed
{
return $model === null ? null : self::normalize($field, $model->getAttribute($field));
}
private static function operation(string $kind, mixed $model, string $field, mixed $after, int|string|null $id = null): ?array
{
if (!in_array($field, self::FIELDS[$kind] ?? [], true)) throw new RuntimeException('Unsupported journal field');
$before = self::value($model, $field);
$after = self::normalize($field, $after);
if ($model !== null && $before === $after) return null;
return ['kind'=>$kind,'id'=>$id ?? $model->getKey(),'field'=>$field,
'before_exists'=>$model !== null,'before'=>$before,'after'=>$after];
}
private static function context(array $m): array
{
$node = self::object('node', $m['node_id']) ?? throw new RuntimeException('Manifest node missing');
$qa = self::object('qa', $m['test_user_id']) ?? throw new RuntimeException('Manifest QA missing');
if ((int)$node->machine_id !== $m['machine_id'] || $node->type !== 'anytls') {
throw new RuntimeException('Manifest node/machine mismatch');
}
if ($qa->is_admin || (int)$qa->group_id !== $m['group_id'] ||
!str_starts_with($qa->email, 'node-qa-' . $m['key'] . '-') ||
!str_ends_with($qa->email, '@example.invalid')) {
throw new RuntimeException('Manifest does not identify an isolated QA user');
}
return [$node, $qa];
}
private static function checkJournal(array $j, array $m): void
{
if (($j['version'] ?? null) !== 1 || ($j['manifest'] ?? null) !== $m || !is_array($j['operations'] ?? null)) {
throw new RuntimeException('Journal/manifest mismatch or unsupported version');
}
foreach ($j['operations'] as $op) {
if (!in_array($op['field'], self::FIELDS[$op['kind']] ?? [], true)) throw new RuntimeException('Invalid journal operation');
if ($op['kind']==='node' && $op['id']!==$m['node_id']) throw new RuntimeException('Wrong node in journal');
if ($op['kind']==='qa' && $op['id']!==$m['test_user_id']) throw new RuntimeException('Wrong QA in journal');
if ($op['kind']==='setting' && $op['id']!=='subscribe_url') throw new RuntimeException('Wrong setting in journal');
if ($j['request']['mode']==='append' && !in_array($op['kind'], ['node','qa'], true)) {
throw new RuntimeException('Append journal contains shared-resource writes');
}
}
}
private static function current(array $op): array
{
$model = self::object($op['kind'], $op['id']);
return [$model, $model !== null, self::value($model, $op['field'])];
}
private static function applyOperation(array $op): void
{
[$model, $exists, $value] = self::current($op);
if ($exists !== $op['before_exists'] || $value !== $op['before']) throw new RuntimeException('Object changed before application');
if (!$exists) {
if ($op['kind'] !== 'setting') throw new RuntimeException('Missing target');
$model = new Setting(['name'=>$op['id']]);
}
$model->setAttribute($op['field'], $op['after']);
// Keep model observers for node full-sync notifications; QA does not send notifications.
$op['kind'] === 'qa' ? $model->saveQuietly() : $model->save();
}
private static function flushSettings(array $ops): void
{
if (array_filter($ops, fn($op) => $op['kind']==='setting')) {
// Called after commit; clearing request-local state also makes retry recovery safe.
Cache::store('redis')->forget(\App\Support\Setting::CACHE_KEY);
app()->forgetInstance(\App\Support\Setting::class);
}
}
private static function notifyCommitted(array $m): void
{
// Explicitly repeat idempotent invalidations after commit. If an observer's
// after-commit callback failed, prepared+after recovery must still notify.
\App\Services\NodeSyncService::notifyFullSync($m['node_id']);
\App\Services\NodeSyncService::notifyMachineNodesChanged($m['machine_id']);
}
private static function verifyEvidence(array $request, array $m, mixed $node): void
{
$report = readJson($request['verification'] ?? throw new RuntimeException('Missing --verification live QA report'));
$at = $report['verified_at'] ?? 0;
if (($report['state'] ?? '') !== 'live_verified' || ($report['qa_user_id'] ?? null) !== $m['test_user_id'] ||
!is_int($at) || $at > time() || time()-$at > 900) {
throw new RuntimeException('Recent live verification for this manifest QA required');
}
$sni = $node->protocol_settings['tls']['server_name'] ?? null;
$targets = array_filter($report['results'] ?? [], fn($row) =>
($row['live'] ?? false) === true && ($row['strict_tls'] ?? false) === true &&
($row['relay'] ?? null) === $node->host && (int)($row['relay_port'] ?? 0) === (int)$node->port &&
($row['sni'] ?? null) === $sni);
if (count($targets)!==1) throw new RuntimeException('Live evidence endpoint/SNI mismatch');
}
public static function promote(string $dir, array $request, bool $apply): array
{
$mode = $request['mode'] ?? '';
if (!in_array($mode, ['append','initialize'], true)) throw new RuntimeException('Explicit append/initialize mode required');
$request['group'] = isset($request['group']) ? (int)$request['group'] : null;
if ($mode==='append' && (!$request['group'] || isset($request['plans']) || isset($request['subscribe-url']) || isset($request['group-name']))) {
throw new RuntimeException('Append requires group and forbids plans, subscribe-url and group-name');
}
if ($mode==='initialize') {
$request['plan_ids'] = ids($request['plans'] ?? '');
$url = $request['subscribe-url'] ?? '';
$parts = parse_url($url);
if (!is_array($parts) || ($parts['scheme'] ?? '')!=='https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) {
throw new RuntimeException('Initialize requires an explicit HTTPS subscribe-url without credentials/query');
}
}
ksort($request);
$m = manifest($dir); $path = $dir . '/promotion-journal.json';
$result = DB::transaction(function () use ($dir,$request,$mode,$m,$path,$apply) {
[$node,$qa] = self::context($m);
if (is_file($path)) {
$j = readJson($path); self::checkJournal($j,$m);
if ($j['request'] !== $request) throw new RuntimeException('Existing operation has different parameters');
if (is_file($dir.'/rollback-journal.json')) throw new RuntimeException('Rollback started; use a new reviewed task');
$before = $after = true;
foreach ($j['operations'] as $op) {
[, $exists,$value] = self::current($op);
$before = $before && $exists===$op['before_exists'] && $value===$op['before'];
$after = $after && $exists && $value===$op['after'];
}
if (!$after && (!$before || $j['state']!=='prepared')) throw new RuntimeException('Journal conflict; inspect before retrying');
if (!$apply) return ['state'=>$after?'already_applied':'preview','journal'=>$j];
if (!$after) {
self::verifyEvidence($request,$m,$node);
if (!$node->is_online || (int)$qa->u+(int)$qa->d<=0 || $qa->banned) throw new RuntimeException('QA evidence no longer ready');
foreach ($j['operations'] as $op) self::applyOperation($op);
}
return ['state'=>'applied','journal'=>$j];
}
if (is_file($dir.'/promoted.json')) throw new RuntimeException('Legacy promotion exists without new journal; do not adopt automatically');
if (!$node->enabled || !$node->show || !$node->is_online || (int)$qa->u + (int)$qa->d <= 0 || $qa->banned) throw new RuntimeException('Fresh online/active QA traffic evidence missing');
if (self::value($node,'group_ids') !== [(string)$m['group_id']]) throw new RuntimeException('Node no longer in its isolated stage group');
$groupId = $request['group'] ?? $m['group_id'];
$group = self::object('group',$groupId) ?? throw new RuntimeException('Target group missing');
if ($mode==='append' && $groupId===$m['group_id']) throw new RuntimeException('Append requires an existing formal group');
$ops = [self::operation('node',$node,'group_ids',[(string)$groupId]), self::operation('qa',$qa,'banned',true)];
if ($mode==='initialize') {
$plans = Plan::whereIn('id',$request['plan_ids'])->orderBy('id')->lockForUpdate()->get();
if ($plans->count()!==count($request['plan_ids'])) throw new RuntimeException('Plan IDs do not all exist');
foreach ($plans as $plan) {
if ($plan->group_id !== null && (int)$plan->group_id!==$groupId) throw new RuntimeException('Plan belongs to another group');
$ops[] = self::operation('plan',$plan,'group_id',$groupId);
}
// Only the explicitly selected plans' unassigned users can change in initialize mode.
foreach (User::whereIn('plan_id',$request['plan_ids'])->whereNull('group_id')->orderBy('id')->lockForUpdate()->get() as $user) {
$ops[] = self::operation('user',$user,'group_id',$groupId);
}
if (isset($request['group-name'])) $ops[] = self::operation('group',$group,'name',$request['group-name']);
$setting = self::object('setting','subscribe_url');
$ops[] = self::operation('setting',$setting,'value',$request['subscribe-url'],'subscribe_url');
}
if ($apply) self::verifyEvidence($request,$m,$node);
$j = ['version'=>1,'state'=>'prepared','manifest'=>$m,'request'=>$request,
'operations'=>array_values(array_filter($ops)),
'node_guard'=>['enabled'=>(bool)$node->enabled,'show'=>(bool)$node->show]];
if (!$apply) return ['state'=>'preview','journal'=>$j];
writeJson($path,$j); // Durable intent before any DB write; recover prepared+after on retry.
foreach ($j['operations'] as $op) self::applyOperation($op);
return ['state'=>'applied','journal'=>$j];
},1);
if ($apply) {
self::flushSettings($result['journal']['operations']);
self::notifyCommitted($m);
$result['journal']['state']='applied'; writeJson($path,$result['journal']);
}
return $result;
}
public static function rollback(string $dir, bool $apply): array
{
$m = manifest($dir); $j = readJson($dir.'/promotion-journal.json'); self::checkJournal($j,$m);
$rollbackPath=$dir.'/rollback-journal.json';
$result=DB::transaction(function () use ($m,$j,$apply,$rollbackPath) {
[$node,$qa]=self::context($m);
if (!$qa->banned) throw new RuntimeException('QA must remain disabled during rollback');
if (is_file($rollbackPath)) {
$r=readJson($rollbackPath);
if (($r['promotion']??null)!==$j) throw new RuntimeException('Rollback journal mismatch');
} else {
// Rollback itself has a durable before/after journal, including node containment.
$undo=[];
foreach (array_reverse($j['operations']) as $op) {
if ($op['kind']==='qa') continue; // Never reactivate test users.
[$model,$exists,$value]=self::current($op);
if (!$exists || $value!==$op['after']) throw new RuntimeException('Rollback conflict: current value differs from recorded after');
$undo[]=['kind'=>$op['kind'],'id'=>$op['id'],'field'=>$op['field'],
'before_exists'=>true,'before'=>$op['after'],'after'=>$op['before'],
'delete'=>!$op['before_exists']];
}
foreach (['enabled','show'] as $field) {
if ((bool)$node->$field!==$j['node_guard'][$field]) throw new RuntimeException('Node containment field changed after promotion');
if ($node->$field) $undo[]=['kind'=>'node','id'=>$m['node_id'],'field'=>$field,
'before_exists'=>true,'before'=>true,'after'=>false,'delete'=>false];
}
if (!$qa->banned) throw new RuntimeException('QA reactivated after promotion; resolve conflict before rollback');
$r=['version'=>1,'state'=>'prepared','promotion'=>$j,'operations'=>$undo];
}
$before=$after=true;
foreach ($r['operations'] as $op) {
// Only rollback containment adds these two node fields.
if (!in_array($op['field'],self::FIELDS[$op['kind']]??[],true) && !($op['kind']==='node' && $op['id']===$m['node_id'] && in_array($op['field'],['enabled','show'],true))) {
throw new RuntimeException('Invalid rollback field');
}
[, $exists,$value]=self::current($op);
$before=$before && $exists && $value===$op['before'];
$after=$after && ($op['delete'] ? !$exists : ($exists && $value===$op['after']));
}
if (!$after && (!$before || $r['state']!=='prepared')) throw new RuntimeException('Rollback conflicts; no objects restored');
if (!$apply) return ['state'=>$after?'already_rolled_back':'preview','journal'=>$r];
if (!is_file($rollbackPath)) writeJson($rollbackPath,$r);
if (!$after) foreach ($r['operations'] as $op) {
[$model]=self::current($op);
if ($op['delete']) $model->delete();
else { $model->setAttribute($op['field'],$op['after']); $model->save(); }
}
return ['state'=>'rolled_back','journal'=>$r];
},1);
if ($apply) {
self::flushSettings($j['operations']);
self::notifyCommitted($m);
$result['journal']['state']='rolled_back';writeJson($rollbackPath,$result['journal']);
}
return $result;
}
}

Some files were not shown because too many files have changed in this diff Show More