feat: establish domestic fifth-domain operations
This commit is contained in:
parent
52ac573a28
commit
4bc8133218
13
deployment/navigation-maps/BS-GZ-006.json
Normal file
13
deployment/navigation-maps/BS-GZ-006.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema": "guanghu.navigation-map/v1",
|
||||
"node_id": "BS-GZ-006",
|
||||
"role": "国内备案前门",
|
||||
"modules": [
|
||||
{ "code": "GZ-WEB-01", "name": "备案主页与 HTTPS", "bind": "public:https", "owner": "nginx" },
|
||||
{ "code": "GZ-JD-01", "name": "京东应用专用隧道", "bind": "loopback:18088", "owner": "systemd" },
|
||||
{ "code": "GZ-AUTH-01", "name": "小湖灯授权专用隧道", "bind": "loopback:19221", "owner": "systemd" },
|
||||
{ "code": "GZ-FRG-01", "name": "国内 Forgejo 专用隧道", "bind": "loopback:19301", "owner": "systemd" },
|
||||
{ "code": "GZ-LEGACY-01", "name": "历史服务与仓库", "bind": "local-services", "owner": "pm2" }
|
||||
],
|
||||
"mandatory_order": ["read-navigation-map", "ack-current-map", "execute-registered-action"]
|
||||
}
|
||||
10
deployment/navigation-maps/BS-SG-002.json
Normal file
10
deployment/navigation-maps/BS-SG-002.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema": "guanghu.navigation-map/v1",
|
||||
"node_id": "BS-SG-002",
|
||||
"role": "新加坡面孔与 Web 节点",
|
||||
"modules": [
|
||||
{ "code": "SG2-GTW-01", "name": "历史 Gatekeeper", "bind": "3910", "owner": "pm2" },
|
||||
{ "code": "SG2-WEB-01", "name": "面孔与 Web 服务组", "bind": "registered-pm2-apps", "owner": "pm2" }
|
||||
],
|
||||
"upstream": "JD-FD-PRIMARY"
|
||||
}
|
||||
10
deployment/navigation-maps/BS-SG-003.json
Normal file
10
deployment/navigation-maps/BS-SG-003.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema": "guanghu.navigation-map/v1",
|
||||
"node_id": "BS-SG-003",
|
||||
"role": "新加坡备份、存储与海外下载中继",
|
||||
"modules": [
|
||||
{ "code": "SG3-GTW-01", "name": "历史 Gatekeeper", "bind": "3910", "owner": "pm2" },
|
||||
{ "code": "SG3-RLY-01", "name": "海外依赖下载中继", "bind": "ssh-only", "owner": "root" }
|
||||
],
|
||||
"upstream": "JD-FD-PRIMARY"
|
||||
}
|
||||
15
deployment/navigation-maps/JD-FD-PRIMARY.json
Normal file
15
deployment/navigation-maps/JD-FD-PRIMARY.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema": "guanghu.navigation-map/v1",
|
||||
"node_id": "JD-FD-PRIMARY",
|
||||
"role": "第五域国内主节点与统一运维入口",
|
||||
"modules": [
|
||||
{ "code": "JD-GTW-01", "name": "Gatekeeper v3.2", "bind": "loopback:3911", "owner": "systemd" },
|
||||
{ "code": "JD-AUTH-01", "name": "小湖灯邮件链接授权", "bind": "loopback:3921", "owner": "systemd" },
|
||||
{ "code": "JD-FRG-01", "name": "第五域国内 Forgejo", "bind": "loopback:3001", "owner": "systemd" },
|
||||
{ "code": "JD-HUB-01", "name": "京东应用入口", "bind": "loopback:8088", "owner": "systemd" },
|
||||
{ "code": "JD-SEN-01", "name": "状态变化哨兵", "bind": "timer:15m", "owner": "systemd" },
|
||||
{ "code": "JD-ROUTE-01", "name": "个人节点 SSH 路由", "bind": "private-keys", "owner": "root" }
|
||||
],
|
||||
"mandatory_order": ["read-navigation-map", "ack-current-map", "execute-registered-action"],
|
||||
"forbidden": ["arbitrary-shell-through-authorization-api", "cross-target-session-reuse", "secret-in-repository"]
|
||||
}
|
||||
10
deployment/navigation-maps/ZY-SG-006.json
Normal file
10
deployment/navigation-maps/ZY-SG-006.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema": "guanghu.navigation-map/v1",
|
||||
"node_id": "ZY-SG-006",
|
||||
"role": "新加坡语料与推理节点",
|
||||
"modules": [
|
||||
{ "code": "SG6-GTW-01", "name": "历史 Gatekeeper", "bind": "3910", "owner": "pm2" },
|
||||
{ "code": "SG6-AI-01", "name": "语料与推理服务组", "bind": "registered-pm2-apps", "owner": "pm2" }
|
||||
],
|
||||
"upstream": "JD-FD-PRIMARY"
|
||||
}
|
||||
@ -63,10 +63,10 @@ Token 不再等于 root 密码,仅用于识别"是谁在请求"。每个 Token
|
||||
|
||||
| 序号 | 服务器 | 人类 | 邮箱 | 人格体 | 绑定来源 |
|
||||
|------|--------|------|------|--------|----------|
|
||||
| 1 | BS-SG-001 | ICE-GL∞ (冰朔) | 565183519@qq.com | ICE-GL-ZY001 (铸渊) | BS-SG-001 |
|
||||
| 2 | BS-GZ-006 | ICE-GL∞ (冰朔) | 565183519@qq.com | ICE-GL-ZY001 (铸渊) | BS-SG-001, BS-GZ-006 |
|
||||
| 3 | ZZ-SV-001 | 之之 | 3205323524@qq.com | * (全部) | ZZ-SV-001, ZZ-GZ-001 |
|
||||
| 4 | ZZ-GZ-001 | 之之 | 3205323524@qq.com | * (全部) | ZZ-SV-001, ZZ-GZ-001 |
|
||||
| 1 | BS-SG-001 | ICE-GL∞ (冰朔) | ICE_GL_OWNER_EMAIL_REDACTED | ICE-GL-ZY001 (铸渊) | BS-SG-001 |
|
||||
| 2 | BS-GZ-006 | ICE-GL∞ (冰朔) | ICE_GL_OWNER_EMAIL_REDACTED | ICE-GL-ZY001 (铸渊) | BS-SG-001, BS-GZ-006 |
|
||||
| 3 | ZZ-SV-001 | 之之 | EMAIL_REDACTED@qq.com | * (全部) | ZZ-SV-001, ZZ-GZ-001 |
|
||||
| 4 | ZZ-GZ-001 | 之之 | EMAIL_REDACTED@qq.com | * (全部) | ZZ-SV-001, ZZ-GZ-001 |
|
||||
|
||||
> 注册新三元组需要冰朔主权者验证码 → 调用 `/auth/register`
|
||||
|
||||
@ -102,7 +102,7 @@ Content-Type: application/json
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "验证码已发送至 565183519@qq.com",
|
||||
"message": "验证码已发送至 ICE_GL_OWNER_EMAIL_REDACTED",
|
||||
"challenge_id": "abc123...",
|
||||
"expires_in": 300,
|
||||
"op_type": "code-repo",
|
||||
@ -196,7 +196,7 @@ Token 存储在服务器的 `~/.guanghu-engine/secret` 文件中。
|
||||
| **监听端口** | 3911 |
|
||||
| **进程管理** | PM2 (`pm2 list` → engine-v3) |
|
||||
| **日志位置** | `/opt/zhuyuan/gatekeeper/engine.log` |
|
||||
| **SMTP 发件** | smtp.qq.com:465 · 565183519@qq.com |
|
||||
| **SMTP 发件** | smtp.qq.com:465 · ICE_GL_OWNER_EMAIL_REDACTED |
|
||||
|
||||
---
|
||||
|
||||
@ -230,7 +230,7 @@ Token 存储在服务器的 `~/.guanghu-engine/secret` 文件中。
|
||||
→ 来源校验: 请求来自 BS-SG-001 ✅
|
||||
→ 白名单匹配: BS-SG-001 + ICE-GL∞ + ICE-GL-ZY001 ✅
|
||||
→ 生成 6 位验证码: 482901
|
||||
→ 发送邮件到 565183519@qq.com
|
||||
→ 发送邮件到 ICE_GL_OWNER_EMAIL_REDACTED
|
||||
|
||||
3. 冰朔手机收到邮件:
|
||||
"📦 铸渊 (ICE-GL-ZY001) 请求操作代码仓库
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="光湖语言世界国内入口。广州备案前门连接京东云第五域主节点与光湖各项服务。">
|
||||
<meta name="description" content="光湖语言世界国内入口。广州备案前门连接国内第五域主节点与光湖各项服务。">
|
||||
<title>光湖 · 国内入口</title>
|
||||
<link rel="stylesheet" href="styles.css?v=20260717">
|
||||
</head>
|
||||
@ -25,9 +25,9 @@
|
||||
<section class="hero">
|
||||
<p class="eyebrow">GUANGHU · DOMESTIC GATEWAY</p>
|
||||
<h1>从这里,进入<br><span>光湖语言世界</span></h1>
|
||||
<p class="lede">广州节点保留备案域名与轻量入口,应用和计算逐步迁往京东云第五域主节点。入口稳定,后端可以继续生长。</p>
|
||||
<p class="lede">广州节点保留备案域名与轻量入口,应用和计算逐步迁往国内第五域主节点。入口稳定,后端可以继续生长。</p>
|
||||
<div class="hero-actions">
|
||||
<a class="primary-button" href="/jd/">进入京东云主节点 <span>↗</span></a>
|
||||
<a class="primary-button" href="/jd/">进入国内第五域主节点 <span>↗</span></a>
|
||||
<a class="text-button" href="https://guanghubingshuo.com/">光湖世界主入口 <span>→</span></a>
|
||||
</div>
|
||||
</section>
|
||||
@ -38,7 +38,7 @@
|
||||
<p class="eyebrow">ROUTES</p>
|
||||
<h2 id="routes-title">选择一条路径</h2>
|
||||
</div>
|
||||
<p>页面在广州,重计算在京东。每张卡片都是一个真实入口。</p>
|
||||
<p>页面在广州,应用与计算运行在国内第五域主节点。每张卡片都是一个真实入口。</p>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
@ -47,8 +47,8 @@
|
||||
<div class="card-icon">湖</div>
|
||||
<div class="card-copy">
|
||||
<p class="card-kicker">COMPUTE · BEIJING</p>
|
||||
<h3>京东云主节点</h3>
|
||||
<p>进入 JD-FD-PRIMARY。新的应用、迁移任务与服务将从这里运行。</p>
|
||||
<h3>国内第五域主节点</h3>
|
||||
<p>进入国内第五域主节点。新的应用、迁移任务与服务将从这里运行。</p>
|
||||
</div>
|
||||
<span class="card-arrow">↗</span>
|
||||
</a>
|
||||
@ -64,13 +64,13 @@
|
||||
<span class="card-arrow">↗</span>
|
||||
</a>
|
||||
|
||||
<a class="route-card" href="https://guanghubingshuo.com/code/">
|
||||
<a class="route-card featured" href="/fifth-domain/bingshuo/fifth-domain">
|
||||
<span class="card-number">03</span>
|
||||
<div class="card-icon">仓</div>
|
||||
<div class="card-copy">
|
||||
<p class="card-kicker">SOURCE · TRUTH</p>
|
||||
<h3>代码仓库</h3>
|
||||
<p>进入在线事实源,查看第五域、Guanghulab 与其他研发仓库。</p>
|
||||
<p class="card-kicker">SOURCE · BEIJING</p>
|
||||
<h3>第五域国内主代码仓库</h3>
|
||||
<p>公开直达第五域主仓。国内研发、迁移与节点部署的新事实从这里承载。</p>
|
||||
</div>
|
||||
<span class="card-arrow">↗</span>
|
||||
</a>
|
||||
@ -85,13 +85,24 @@
|
||||
</div>
|
||||
<span class="card-arrow">↗</span>
|
||||
</a>
|
||||
|
||||
<a class="route-card" href="https://guanghubingshuo.com/code/">
|
||||
<span class="card-number">05</span>
|
||||
<div class="card-icon">史</div>
|
||||
<div class="card-copy">
|
||||
<p class="card-kicker">ARCHIVE · SINGAPORE</p>
|
||||
<h3>历史仓库</h3>
|
||||
<p>进入新加坡原有仓库,查询第五域、Guanghulab 与各历史研发事实源。</p>
|
||||
</div>
|
||||
<span class="card-arrow">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="architecture-strip" aria-label="节点分工">
|
||||
<div><span>广州 · BS-GZ-006</span><strong>备案域名 / HTTPS / 前门展示</strong></div>
|
||||
<b>→</b>
|
||||
<div><span>北京 · JD-FD-PRIMARY</span><strong>应用运行 / 计算 / 调度中心</strong></div>
|
||||
<div><span>国内第五域主节点</span><strong>应用运行 / 计算 / 调度中心</strong></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@ -15,10 +15,22 @@ test("front door keeps the legally required ICP link", () => {
|
||||
|
||||
test("front door routes compute work through the JD hub", () => {
|
||||
assert.match(html, /href="\/jd\/"/);
|
||||
assert.match(html, /京东云主节点/);
|
||||
assert.match(html, /国内第五域主节点/);
|
||||
assert.match(html, /href="https:\/\/guanghubingshuo\.com\/code\/"/);
|
||||
});
|
||||
|
||||
test("front door separates the domestic primary repository from historical repositories", () => {
|
||||
assert.match(html, /href="\/fifth-domain\/bingshuo\/fifth-domain"/);
|
||||
assert.match(html, /第五域国内主代码仓库/);
|
||||
assert.match(html, /历史仓库/);
|
||||
assert.match(html, /href="https:\/\/guanghubingshuo\.com\/code\/"/);
|
||||
});
|
||||
|
||||
test("public copy uses the domestic Fifth Domain name instead of the cloud vendor", () => {
|
||||
assert.doesNotMatch(html, /京东云/);
|
||||
assert.match(html, /国内第五域主节点/);
|
||||
});
|
||||
|
||||
test("public page never embeds infrastructure addresses or credentials", () => {
|
||||
assert.doesNotMatch(html, /(?:\d{1,3}\.){3}\d{1,3}/);
|
||||
assert.doesNotMatch(html, /zy_gtw_/);
|
||||
|
||||
14
server-tools/jd-forgejo/README.md
Normal file
14
server-tools/jd-forgejo/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# 京东云第五域国内主代码仓库
|
||||
|
||||
- 与广州/新加坡现役节点一致的原生 Forgejo 二进制 + SQLite;
|
||||
- Web 和 SSH 只绑定 JD 回环地址;
|
||||
- 公网页面通过广州备案前门的专用 `permitopen` SSH 隧道发布在
|
||||
`https://guanghulab.com/fifth-domain/`;
|
||||
- 数据位于 `/var/lib/guanghu/forgejo`,`SECRET_KEY` 与 `INTERNAL_TOKEN`
|
||||
只写入服务器上的 `app.ini`;
|
||||
- 禁止公开注册和 push-create,初始管理员由部署命令在容器内创建;
|
||||
- 服务器 `pre-receive` 必须串联导航记忆守门人、私密禁用标识扫描和
|
||||
小湖灯一小时 repo-push 授权凭据。
|
||||
|
||||
首次部署从已接入的广州节点复制经过验证的现役二进制,避开国内节点拉取
|
||||
海外大镜像的失败路径;升级必须另开批次、备份数据库并人工验证。
|
||||
38
server-tools/jd-forgejo/app.ini.template
Normal file
38
server-tools/jd-forgejo/app.ini.template
Normal file
@ -0,0 +1,38 @@
|
||||
APP_NAME = 第五域 · 国内主代码仓库
|
||||
RUN_USER = git
|
||||
WORK_PATH = /opt/forgejo
|
||||
|
||||
[server]
|
||||
PROTOCOL = http
|
||||
HTTP_ADDR = 127.0.0.1
|
||||
HTTP_PORT = 3001
|
||||
DOMAIN = guanghulab.com
|
||||
ROOT_URL = https://guanghulab.com/fifth-domain/
|
||||
DISABLE_SSH = true
|
||||
LFS_START_SERVER = true
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = /var/lib/guanghu/forgejo/data/forgejo.db
|
||||
LOG_SQL = false
|
||||
|
||||
[repository]
|
||||
ROOT = /var/lib/guanghu/forgejo/repositories
|
||||
DEFAULT_PRIVATE = private
|
||||
ENABLE_PUSH_CREATE_USER = false
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
SECRET_KEY = PRIVATE_SERVER_VALUE
|
||||
INTERNAL_TOKEN = PRIVATE_SERVER_VALUE
|
||||
|
||||
[session]
|
||||
PROVIDER = file
|
||||
|
||||
[log]
|
||||
MODE = console
|
||||
LEVEL = Info
|
||||
12
server-tools/jd-forgejo/guanghu-forgejo.nginx.conf
Normal file
12
server-tools/jd-forgejo/guanghu-forgejo.nginx.conf
Normal file
@ -0,0 +1,12 @@
|
||||
location /fifth-domain/ {
|
||||
proxy_pass http://127.0.0.1:19301/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
client_max_body_size 256m;
|
||||
}
|
||||
22
server-tools/jd-forgejo/guanghu-forgejo.service
Normal file
22
server-tools/jd-forgejo/guanghu-forgejo.service
Normal file
@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Guanghu Fifth Domain domestic Forgejo primary
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=git
|
||||
Group=git
|
||||
SupplementaryGroups=guanghu
|
||||
WorkingDirectory=/opt/forgejo
|
||||
ExecStart=/usr/local/bin/forgejo web -c /opt/forgejo/custom/conf/app.ini --work-path /opt/forgejo
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/forgejo /var/lib/guanghu/forgejo
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
19
server-tools/jd-forgejo/guanghu-jd-forgejo-tunnel.service
Normal file
19
server-tools/jd-forgejo/guanghu-jd-forgejo-tunnel.service
Normal file
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Guanghu BS-GZ-006 to JD Forgejo web tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/ssh -NT -F /etc/guanghu/jd-forgejo-tunnel-ssh-config jd-forgejo-target
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=read-only
|
||||
ProtectSystem=strict
|
||||
ReadOnlyPaths=/etc/guanghu/jd-forgejo-tunnel-ssh-config /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_forgejo_proxy /root/.ssh/known_hosts
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
18
server-tools/jd-forgejo/install-gz-proxy.sh
Executable file
18
server-tools/jd-forgejo/install-gz-proxy.sh
Executable file
@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
JD_HOST=${1:?JD host is required}
|
||||
|
||||
install -m 644 /tmp/guanghu-jd-forgejo-tunnel.service /etc/systemd/system/guanghu-jd-forgejo-tunnel.service
|
||||
install -m 644 /tmp/guanghu-forgejo.nginx.conf /etc/nginx/snippets/guanghu-forgejo.conf
|
||||
chmod 600 /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_forgejo_proxy
|
||||
install -m 600 /dev/null /etc/guanghu/jd-forgejo-tunnel-ssh-config
|
||||
sed -e "s/JD_PUBLIC_ADDRESS/${JD_HOST}/" /tmp/jd-forgejo-tunnel-ssh-config.example > /etc/guanghu/jd-forgejo-tunnel-ssh-config
|
||||
|
||||
if ! grep -q "include /etc/nginx/snippets/guanghu-forgejo.conf;" /etc/nginx/sites-enabled/guanghulab; then
|
||||
sed -i '0,/server_name guanghulab.com;/s##server_name guanghulab.com;\n include /etc/nginx/snippets/guanghu-forgejo.conf;#' /etc/nginx/sites-enabled/guanghulab
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now guanghu-jd-forgejo-tunnel.service
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
rm -f /tmp/guanghu-jd-forgejo-tunnel.service /tmp/guanghu-forgejo.nginx.conf /tmp/jd-forgejo-tunnel-ssh-config.example
|
||||
@ -0,0 +1,9 @@
|
||||
Host jd-forgejo-target
|
||||
HostName JD_PUBLIC_ADDRESS
|
||||
User root
|
||||
IdentityFile /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_forgejo_proxy
|
||||
IdentitiesOnly yes
|
||||
LocalForward 127.0.0.1:19301 127.0.0.1:3001
|
||||
ExitOnForwardFailure yes
|
||||
ServerAliveInterval 30
|
||||
ServerAliveCountMax 3
|
||||
20
server-tools/jd-forgejo/native.test.js
Normal file
20
server-tools/jd-forgejo/native.test.js
Normal file
@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const ini = fs.readFileSync(path.join(__dirname, "app.ini.template"), "utf8");
|
||||
const unit = fs.readFileSync(path.join(__dirname, "guanghu-forgejo.service"), "utf8");
|
||||
test("native Forgejo binds only to JD loopback", () => {
|
||||
assert.match(ini, /HTTP_ADDR = 127\.0\.0\.1/);
|
||||
assert.match(ini, /HTTP_PORT = 3001/);
|
||||
});
|
||||
test("registration and push-create are disabled", () => {
|
||||
assert.match(ini, /DISABLE_REGISTRATION = true/);
|
||||
assert.match(ini, /ENABLE_PUSH_CREATE_USER = false/);
|
||||
});
|
||||
test("secrets remain server-side placeholders", () => {
|
||||
assert.equal((ini.match(/PRIVATE_SERVER_VALUE/g) || []).length, 2);
|
||||
assert.doesNotMatch(ini, /[a-f0-9]{40,}/);
|
||||
assert.match(unit, /User=git/);
|
||||
});
|
||||
18
server-tools/lake-lamp-authz/README.md
Normal file
18
server-tools/lake-lamp-authz/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# 小湖灯邮件链接授权服务
|
||||
|
||||
这是 Gatekeeper v3.2 前面的人类批准层。人格体只能用无执行权限的
|
||||
request credential 提交工单;冰朔点击邮件链接后,人格体才能一次性领取
|
||||
一个绑定到 `persona + target server + scope + action` 的一小时会话令牌。邮件工单
|
||||
链接同样保留一小时,避免要求冰朔立即处理。
|
||||
|
||||
安全边界:
|
||||
|
||||
- 邮箱、QQ 数字、SMTP 授权码和 request credential 只存在于私密文件;
|
||||
- 浏览器批准链接为一次性随机令牌,服务端仅持久化摘要;
|
||||
- claim token 与 session token 均仅向申请人格体返回一次,磁盘只保存摘要;
|
||||
- 换目标服务器时旧 session 必然返回 `target_mismatch`;
|
||||
- 本服务不接受任意 shell 命令,只签发登记动作的会话;
|
||||
- 广州公开代理只应暴露 `/approve/`、`/api/workorders` 与 claim 路由,服务本体监听 JD 回环地址。
|
||||
|
||||
`request-workorder.js` 从临时环境变量读取 QQ 数字,在内存中补全邮箱并只发送
|
||||
SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。
|
||||
16
server-tools/lake-lamp-authz/authorization.env.example
Normal file
16
server-tools/lake-lamp-authz/authorization.env.example
Normal file
@ -0,0 +1,16 @@
|
||||
LAKE_LAMP_HOST=127.0.0.1
|
||||
LAKE_LAMP_PORT=3921
|
||||
LAKE_LAMP_PUBLIC_URL=https://example.invalid/authz
|
||||
LAKE_LAMP_TARGETS=JD-FD-PRIMARY,BS-GZ-006
|
||||
LAKE_LAMP_APPROVAL_TTL=3600
|
||||
LAKE_LAMP_SESSION_TTL=3600
|
||||
LAKE_LAMP_STATE_FILE=/var/lib/guanghu/lake-lamp-authz/state.json
|
||||
LAKE_LAMP_MAPS_DIR=/etc/guanghu/navigation-maps
|
||||
LAKE_LAMP_MAP_STATE_FILE=/var/lib/guanghu/lake-lamp-authz/map-acks.json
|
||||
LAKE_LAMP_REPO_GRANT_DIR=/var/lib/guanghu/repo-authorizations
|
||||
LAKE_LAMP_REQUEST_TOKEN=SET_IN_PRIVATE_SERVER_FILE
|
||||
LAKE_LAMP_OWNER_EMAIL=SET_IN_PRIVATE_SERVER_FILE
|
||||
SMTP_HOST=smtp.qq.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=SET_IN_PRIVATE_SERVER_FILE
|
||||
QQ_SMTP_AUTH_CODE=SET_IN_PRIVATE_SERVER_FILE
|
||||
14
server-tools/lake-lamp-authz/guanghu-authz.nginx.conf
Normal file
14
server-tools/lake-lamp-authz/guanghu-authz.nginx.conf
Normal file
@ -0,0 +1,14 @@
|
||||
# Public approval surface. The service itself remains on JD loopback and this
|
||||
# route is reached through a permitopen-restricted SSH tunnel.
|
||||
location /authz/ {
|
||||
proxy_pass http://127.0.0.1:19221/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix /authz;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
client_max_body_size 64k;
|
||||
}
|
||||
19
server-tools/lake-lamp-authz/guanghu-jd-authz-tunnel.service
Normal file
19
server-tools/lake-lamp-authz/guanghu-jd-authz-tunnel.service
Normal file
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Guanghu BS-GZ-006 to JD Lake Lamp authorization tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/ssh -NT -F /etc/guanghu/jd-authz-tunnel-ssh-config jd-authz-target
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=read-only
|
||||
ProtectSystem=strict
|
||||
ReadOnlyPaths=/etc/guanghu/jd-authz-tunnel-ssh-config /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_authz_proxy /root/.ssh/known_hosts
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
24
server-tools/lake-lamp-authz/install-gz-proxy.sh
Executable file
24
server-tools/lake-lamp-authz/install-gz-proxy.sh
Executable file
@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
JD_HOST=${1:?JD host is required}
|
||||
|
||||
install -m 644 /tmp/guanghu-jd-authz-tunnel.service /etc/systemd/system/guanghu-jd-authz-tunnel.service
|
||||
install -m 644 /tmp/guanghu-authz.nginx.conf /etc/nginx/snippets/guanghu-authz.conf
|
||||
chmod 600 /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_authz_proxy
|
||||
|
||||
install -m 600 /dev/null /etc/guanghu/jd-authz-tunnel-ssh-config
|
||||
sed \
|
||||
-e "s/JD_PUBLIC_ADDRESS/${JD_HOST}/" \
|
||||
/tmp/jd-authz-tunnel-ssh-config.example > /etc/guanghu/jd-authz-tunnel-ssh-config
|
||||
|
||||
if ! grep -q "include /etc/nginx/snippets/guanghu-authz.conf;" /etc/nginx/sites-enabled/guanghulab; then
|
||||
sed -i '0,/server_name guanghulab.com;/s##server_name guanghulab.com;\n include /etc/nginx/snippets/guanghu-authz.conf;#' /etc/nginx/sites-enabled/guanghulab
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now guanghu-jd-authz-tunnel.service
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
rm -f /tmp/guanghu-jd-authz-tunnel.service /tmp/guanghu-authz.nginx.conf /tmp/jd-authz-tunnel-ssh-config.example
|
||||
@ -0,0 +1,9 @@
|
||||
Host jd-authz-target
|
||||
HostName JD_PUBLIC_ADDRESS
|
||||
User root
|
||||
IdentityFile /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_authz_proxy
|
||||
IdentitiesOnly yes
|
||||
LocalForward 127.0.0.1:19221 127.0.0.1:3921
|
||||
ExitOnForwardFailure yes
|
||||
ServerAliveInterval 30
|
||||
ServerAliveCountMax 3
|
||||
24
server-tools/lake-lamp-authz/lake-lamp-authz.service
Normal file
24
server-tools/lake-lamp-authz/lake-lamp-authz.service
Normal file
@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Guanghu Lake Lamp email-link authorization service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=guanghu-authz
|
||||
Group=guanghu-authz
|
||||
WorkingDirectory=/opt/guanghu/lake-lamp-authz
|
||||
EnvironmentFile=/etc/guanghu/secrets/lake-lamp/authorization.env
|
||||
ExecStart=/usr/bin/node /opt/guanghu/lake-lamp-authz/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
45
server-tools/lake-lamp-authz/map-gate.js
Normal file
45
server-tools/lake-lamp-authz/map-gate.js
Normal file
@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
class MapGate {
|
||||
constructor({ mapsDir = "/etc/guanghu/navigation-maps", stateFile = "" } = {}) {
|
||||
this.mapsDir = mapsDir;
|
||||
this.stateFile = stateFile;
|
||||
this.acks = new Map();
|
||||
this.load();
|
||||
}
|
||||
read(target) {
|
||||
if (!/^[A-Z0-9-]+$/.test(target)) throw new Error("invalid_target");
|
||||
const data = JSON.parse(fs.readFileSync(path.join(this.mapsDir, `${target}.json`), "utf8"));
|
||||
const canonical = JSON.stringify(data);
|
||||
return { data, hash: crypto.createHash("sha256").update(canonical).digest("hex") };
|
||||
}
|
||||
ack(sessionToken, target, mapHash, now = Date.now() / 1000, ttl = 3600) {
|
||||
const current = this.read(target);
|
||||
if (!safeEqual(current.hash, mapHash)) return { ok: false, reason: "map_hash_mismatch" };
|
||||
this.acks.set(hash(sessionToken), { target, mapHash, expiresAt: now + ttl });
|
||||
this.persist();
|
||||
return { ok: true, expiresAt: now + ttl };
|
||||
}
|
||||
verify(sessionToken, target, mapHash, now = Date.now() / 1000) {
|
||||
const ack = this.acks.get(hash(sessionToken));
|
||||
if (!ack) return { ok: false, reason: "map_not_acknowledged" };
|
||||
if (now > ack.expiresAt) return { ok: false, reason: "map_ack_expired" };
|
||||
if (ack.target !== target) return { ok: false, reason: "map_target_mismatch" };
|
||||
if (!safeEqual(ack.mapHash, mapHash)) return { ok: false, reason: "map_changed" };
|
||||
return { ok: true };
|
||||
}
|
||||
load(now = Date.now() / 1000) {
|
||||
if (!this.stateFile || !fs.existsSync(this.stateFile)) return;
|
||||
try { for (const [key, value] of Object.entries(JSON.parse(fs.readFileSync(this.stateFile, "utf8")).acks || {})) if (value.expiresAt >= now) this.acks.set(key, value); } catch { this.acks.clear(); }
|
||||
}
|
||||
persist() {
|
||||
if (!this.stateFile) return;
|
||||
fs.writeFileSync(this.stateFile, JSON.stringify({ version: 1, acks: Object.fromEntries(this.acks) }), { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
function hash(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); }
|
||||
function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
||||
module.exports = { MapGate };
|
||||
37
server-tools/lake-lamp-authz/map-gate.test.js
Normal file
37
server-tools/lake-lamp-authz/map-gate.test.js
Normal file
@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { MapGate } = require("./map-gate");
|
||||
|
||||
test("non-map actions stay locked until the exact current map is acknowledged", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "map-gate-"));
|
||||
try {
|
||||
const maps = path.join(dir, "maps"); fs.mkdirSync(maps);
|
||||
fs.writeFileSync(path.join(maps, "JD-FD-PRIMARY.json"), JSON.stringify({ node_id: "JD-FD-PRIMARY", modules: [{ code: "JD-GTW-01" }] }));
|
||||
const gate = new MapGate({ mapsDir: maps, stateFile: path.join(dir, "state.json") });
|
||||
const map = gate.read("JD-FD-PRIMARY");
|
||||
assert.equal(gate.verify("session-token", "JD-FD-PRIMARY", map.hash).reason, "map_not_acknowledged");
|
||||
assert.equal(gate.ack("session-token", "JD-FD-PRIMARY", map.hash, 100, 3600).ok, true);
|
||||
assert.equal(gate.verify("session-token", "JD-FD-PRIMARY", map.hash, 101).ok, true);
|
||||
assert.equal(gate.verify("session-token", "OTHER", map.hash, 101).reason, "map_target_mismatch");
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("changing the server map invalidates an old acknowledgement", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "map-gate-"));
|
||||
try {
|
||||
const maps = path.join(dir, "maps"); fs.mkdirSync(maps);
|
||||
const file = path.join(maps, "JD-FD-PRIMARY.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ node_id: "JD-FD-PRIMARY", modules: ["A"] }));
|
||||
const gate = new MapGate({ mapsDir: maps });
|
||||
const oldMap = gate.read("JD-FD-PRIMARY");
|
||||
gate.ack("session-token", "JD-FD-PRIMARY", oldMap.hash, 100, 3600);
|
||||
fs.writeFileSync(file, JSON.stringify({ node_id: "JD-FD-PRIMARY", modules: ["A", "B"] }));
|
||||
const newMap = gate.read("JD-FD-PRIMARY");
|
||||
assert.notEqual(oldMap.hash, newMap.hash);
|
||||
assert.equal(gate.verify("session-token", "JD-FD-PRIMARY", newMap.hash, 101).reason, "map_changed");
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
42
server-tools/lake-lamp-authz/request-workorder.js
Normal file
42
server-tools/lake-lamp-authz/request-workorder.js
Normal file
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const qqId = process.env.GUANGHU_OWNER_QQ_ID || "";
|
||||
if (!/^\d{5,12}$/.test(qqId)) throw new Error("GUANGHU_OWNER_QQ_ID must be provided transiently");
|
||||
const requestToken = process.env.LAKE_LAMP_REQUEST_TOKEN || readTrim(process.env.LAKE_LAMP_REQUEST_TOKEN_FILE || "");
|
||||
if (!requestToken) throw new Error("request-only credential is unavailable");
|
||||
const email = `${qqId}@qq.com`;
|
||||
const recipientFingerprint = crypto.createHash("sha256").update(email.toLowerCase()).digest("hex");
|
||||
const response = await fetch(`${String(args.url).replace(/\/$/, "")}/api/workorders`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${requestToken}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
persona_id: args.persona,
|
||||
persona_name: args.name || args.persona,
|
||||
target: args.target,
|
||||
scope: args.scope,
|
||||
action: args.action,
|
||||
description: args.description || "",
|
||||
recipient_fingerprint: recipientFingerprint,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || `request failed (${response.status})`);
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {};
|
||||
for (let i = 0; i < argv.length; i += 2) result[String(argv[i]).replace(/^--/, "")] = argv[i + 1];
|
||||
for (const key of ["url", "persona", "target", "scope", "action"]) if (!result[key]) throw new Error(`--${key} is required`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function readTrim(file) { return file && fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : ""; }
|
||||
|
||||
main().catch(error => { process.stderr.write(`lake-lamp request failed: ${error.message}\n`); process.exit(1); });
|
||||
206
server-tools/lake-lamp-authz/server.js
Normal file
206
server-tools/lake-lamp-authz/server.js
Normal file
@ -0,0 +1,206 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const { WorkOrderManager } = require("./workorder-manager");
|
||||
const { MapGate } = require("./map-gate");
|
||||
const { sendSmtpMail } = require("./smtp-mailer");
|
||||
|
||||
const DEFAULT_ACTIONS = Object.freeze({
|
||||
"server-login": ["read-navigation-map", "inspect-services", "restart-registered-service"],
|
||||
"server-ops": ["read-navigation-map", "inspect-services", "restart-registered-service"],
|
||||
"repo-push": ["read-navigation-map", "push-repository"],
|
||||
});
|
||||
|
||||
function createApp(options = {}) {
|
||||
const requestToken = options.requestToken || process.env.LAKE_LAMP_REQUEST_TOKEN || "";
|
||||
const ownerEmail = options.ownerEmail || process.env.LAKE_LAMP_OWNER_EMAIL || "";
|
||||
const publicBaseUrl = String(options.publicBaseUrl || process.env.LAKE_LAMP_PUBLIC_URL || "").replace(/\/$/, "");
|
||||
const targets = new Set(options.targets || splitCsv(process.env.LAKE_LAMP_TARGETS || "JD-FD-PRIMARY,BS-GZ-006"));
|
||||
const actions = options.actions || DEFAULT_ACTIONS;
|
||||
const manager = options.manager || new WorkOrderManager({
|
||||
approvalTtl: Number(options.approvalTtl || process.env.LAKE_LAMP_APPROVAL_TTL || 15 * 60),
|
||||
sessionTtl: Number(options.sessionTtl || process.env.LAKE_LAMP_SESSION_TTL || 60 * 60),
|
||||
stateFile: Object.prototype.hasOwnProperty.call(options, "stateFile") ? options.stateFile : (process.env.LAKE_LAMP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/state.json"),
|
||||
});
|
||||
const sendEmail = options.sendEmail || (message => sendSmtpMail({
|
||||
...message,
|
||||
smtpHost: process.env.SMTP_HOST || "smtp.qq.com",
|
||||
smtpPort: Number(process.env.SMTP_PORT || 465),
|
||||
smtpUser: process.env.SMTP_USER || ownerEmail,
|
||||
smtpPass: process.env.QQ_SMTP_AUTH_CODE || "",
|
||||
}));
|
||||
const mapGate = options.mapGate || new MapGate({
|
||||
mapsDir: options.mapsDir || process.env.LAKE_LAMP_MAPS_DIR || "/etc/guanghu/navigation-maps",
|
||||
stateFile: Object.prototype.hasOwnProperty.call(options, "mapStateFile") ? options.mapStateFile : (process.env.LAKE_LAMP_MAP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/map-acks.json"),
|
||||
});
|
||||
const repoGrantDir = options.repoGrantDir || process.env.LAKE_LAMP_REPO_GRANT_DIR || "/var/lib/guanghu/repo-authorizations";
|
||||
|
||||
return http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method === "GET" && url.pathname === "/health") return json(res, 200, { ok: true, service: "lake-lamp-authz", auth_mode: "email-link", session_ttl: 3600 });
|
||||
|
||||
const approvalMatch = url.pathname.match(/^\/approve\/([A-Za-z0-9_-]{20,})$/);
|
||||
if (approvalMatch && req.method === "GET") {
|
||||
const inspected = manager.inspectApproval(approvalMatch[1]);
|
||||
if (!inspected.ok) return html(res, 410, approvalErrorPage(inspected.reason));
|
||||
return html(res, 200, approvalPage(inspected.order, approvalMatch[1]));
|
||||
}
|
||||
if (approvalMatch && req.method === "POST") {
|
||||
const approved = manager.approve(approvalMatch[1]);
|
||||
if (!approved.ok) return html(res, 410, approvalErrorPage(approved.reason));
|
||||
return html(res, 200, approvedPage(approved.order));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/workorders") {
|
||||
if (!bearerMatches(req, requestToken)) return json(res, 401, { error: "request_auth_required" });
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, { error: "invalid_json" });
|
||||
if (body.email || body.recipient || body.smtp_pass) return json(res, 400, { error: "direct_recipient_forbidden" });
|
||||
if (!body.persona_id || !body.target || !body.scope || !body.action) return json(res, 400, { error: "missing_required_field" });
|
||||
if (!targets.has(body.target)) return json(res, 400, { error: "unknown_target" });
|
||||
if (!Array.isArray(actions[body.scope]) || !actions[body.scope].includes(body.action)) return json(res, 400, { error: "unknown_or_mismatched_action" });
|
||||
const expectedFingerprint = sha256(ownerEmail.toLowerCase());
|
||||
if (!body.recipient_fingerprint || !safeEqual(body.recipient_fingerprint, expectedFingerprint)) return json(res, 403, { error: "owner_identity_mismatch" });
|
||||
|
||||
const created = manager.request({
|
||||
persona: { pid: String(body.persona_id), name: String(body.persona_name || body.persona_id) },
|
||||
target: String(body.target), scope: String(body.scope), action: String(body.action), allowedActions: actions[body.scope], description: String(body.description || ""),
|
||||
});
|
||||
const approvalUrl = `${publicBaseUrl}/approve/${created.approvalToken}`;
|
||||
const emailSent = await sendEmail({
|
||||
to: ownerEmail,
|
||||
subject: `小湖灯授权请求 · ${body.target}`,
|
||||
approvalUrl,
|
||||
order: manager.inspectApproval(created.approvalToken).order,
|
||||
});
|
||||
if (!emailSent) return json(res, 502, { error: "authorization_email_failed" });
|
||||
return json(res, 201, { ok: true, workorder_id: created.id, claim_token: created.claimToken, expires_in: created.expiresIn, status: "waiting_for_owner" });
|
||||
}
|
||||
|
||||
const claimMatch = url.pathname.match(/^\/api\/workorders\/([0-9a-f-]{36})\/claim$/i);
|
||||
if (req.method === "POST" && claimMatch) {
|
||||
const token = bearer(req);
|
||||
const claimed = manager.claim(claimMatch[1], token);
|
||||
if (!claimed.ok) return json(res, claimed.reason === "approval_pending" ? 202 : 403, { error: claimed.reason });
|
||||
return json(res, 200, { ok: true, session_token: claimed.sessionToken, expires_in: claimed.expiresIn, target: claimed.target, scope: claimed.scope, action: claimed.action });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/session/verify") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, { error: "invalid_json" });
|
||||
const verified = manager.verifySession(bearer(req), { pid: String(body.persona_id || "") }, String(body.target || ""), String(body.scope || ""), String(body.action || ""));
|
||||
if (!verified.ok) return json(res, 403, { error: verified.reason });
|
||||
if (body.action !== "read-navigation-map") {
|
||||
const map = mapGate.read(String(body.target || ""));
|
||||
const mapVerified = mapGate.verify(bearer(req), String(body.target || ""), map.hash);
|
||||
if (!mapVerified.ok) return json(res, 423, { error: mapVerified.reason, required_action: "read-navigation-map" });
|
||||
}
|
||||
return json(res, 200, { ok: true, expires_at: verified.session.expiresAt });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/navigation-map/read") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, { error: "invalid_json" });
|
||||
const verified = manager.verifySession(bearer(req), { pid: String(body.persona_id || "") }, String(body.target || ""), String(body.scope || ""), "read-navigation-map");
|
||||
if (!verified.ok) return json(res, 403, { error: verified.reason });
|
||||
const map = mapGate.read(String(body.target || ""));
|
||||
return json(res, 200, { ok: true, target: body.target, map_hash: map.hash, navigation_map: map.data });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/navigation-map/ack") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, { error: "invalid_json" });
|
||||
const token = bearer(req);
|
||||
const verified = manager.verifySession(token, { pid: String(body.persona_id || "") }, String(body.target || ""), String(body.scope || ""), "read-navigation-map");
|
||||
if (!verified.ok) return json(res, 403, { error: verified.reason });
|
||||
const acked = mapGate.ack(token, String(body.target || ""), String(body.map_hash || ""), Date.now() / 1000, Math.max(1, verified.session.expiresAt - Date.now() / 1000));
|
||||
return json(res, acked.ok ? 200 : 409, acked.ok ? { ok: true, target: body.target, map_hash: body.map_hash } : { error: acked.reason });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/repo-push/grant") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, { error: "invalid_json" });
|
||||
const token = bearer(req);
|
||||
const target = String(body.target || "");
|
||||
const scope = String(body.scope || "repo-push");
|
||||
const repo = String(body.repo || "").toLowerCase();
|
||||
if (!/^bingshuo\/[a-z0-9._-]+$/.test(repo)) return json(res, 400, { error: "repo_not_allowlisted" });
|
||||
const verified = manager.verifySession(token, { pid: String(body.persona_id || "") }, target, scope, "push-repository");
|
||||
if (!verified.ok) return json(res, 403, { error: verified.reason });
|
||||
const map = mapGate.read(target);
|
||||
const mapVerified = mapGate.verify(token, target, map.hash);
|
||||
if (!mapVerified.ok) return json(res, 423, { error: mapVerified.reason, required_action: "read-navigation-map" });
|
||||
fs.mkdirSync(repoGrantDir, { recursive: true, mode: 0o2770 });
|
||||
const grant = { schema: "guanghu.repo-push-grant/v1", repo, target, persona_id: body.persona_id, map_hash: map.hash, issued_at: Date.now() / 1000, expires_at: verified.session.expiresAt };
|
||||
const grantFile = path.join(repoGrantDir, `${repo.replace("/", "__")}.json`);
|
||||
const temp = `${grantFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temp, JSON.stringify(grant), { mode: 0o640 });
|
||||
fs.renameSync(temp, grantFile);
|
||||
return json(res, 200, { ok: true, repo, target, expires_at: grant.expires_at });
|
||||
}
|
||||
|
||||
return json(res, 404, { error: "not_found" });
|
||||
} catch (error) {
|
||||
process.stderr.write(`lake-lamp request error: ${String(error && error.message || "unknown").slice(0, 240)}\n`);
|
||||
return json(res, error && error.code === "BODY_TOO_LARGE" ? 413 : 500, { error: "request_failed" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function approvalPage(order, token) {
|
||||
return document("小湖灯授权请求", `
|
||||
<p class="eyebrow">LAKE LAMP SECURITY PROTOCOL</p>
|
||||
<h1>人格体请求进入一台服务器</h1>
|
||||
<div class="panel">
|
||||
<dl><dt>人格体</dt><dd>${escapeHtml(order.persona.name)} <small>${escapeHtml(order.persona.pid)}</small></dd>
|
||||
<dt>目标节点</dt><dd>${escapeHtml(order.target)}</dd><dt>授权范围</dt><dd>${escapeHtml(order.scope)}</dd>
|
||||
<dt>登记动作</dt><dd>${escapeHtml(order.action)}</dd><dt>说明</dt><dd>${escapeHtml(order.description || "未附加说明")}</dd></dl>
|
||||
</div>
|
||||
<p class="notice">授权后只对这台服务器和这个动作生效,有效期一小时。换服务器必须重新申请。</p>
|
||||
<form method="post"><button type="submit">确认授权一小时</button></form>
|
||||
`);
|
||||
}
|
||||
|
||||
function approvedPage(order) {
|
||||
return document("授权完成", `<p class="eyebrow">APPROVED</p><h1>门已从服务器内部打开</h1><div class="panel"><p>${escapeHtml(order.persona.name)} 已获准操作 <strong>${escapeHtml(order.target)}</strong>。</p></div><p class="notice">可以关闭本页面。人格体只能领取一次临时会话令牌。</p>`);
|
||||
}
|
||||
|
||||
function approvalErrorPage(reason) {
|
||||
return document("链接不可用", `<p class="eyebrow">LINK CLOSED</p><h1>这条授权链接已经失效</h1><p class="notice">原因:${escapeHtml(reason)}。如仍需操作,请让人格体重新提交工单。</p>`);
|
||||
}
|
||||
|
||||
function document(title, body) {
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} · 光湖</title><style>
|
||||
:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 20% 10%,#17344d,#09111b 55%,#05090e);color:#eaf4fb;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px}.shell{width:min(680px,100%);padding:42px;border:1px solid #29475d;border-radius:24px;background:rgba(10,22,33,.94);box-shadow:0 24px 80px #0008}.eyebrow{color:#6ed5ff;letter-spacing:.18em;font-size:12px}h1{font-size:clamp(30px,6vw,48px);line-height:1.15;margin:10px 0 28px}.panel{background:#102638;border:1px solid #24465d;border-radius:16px;padding:20px 24px}dl{display:grid;grid-template-columns:110px 1fr;gap:12px;margin:0}dt{color:#8ba4b6}dd{margin:0;font-weight:650}small{display:block;color:#7893a6;font-weight:400}.notice{color:#9eb2c0;margin:20px 0}button{width:100%;border:0;border-radius:14px;padding:16px;background:#67d4ff;color:#042235;font-weight:800;font-size:17px;cursor:pointer}@media(max-width:520px){.shell{padding:28px 22px}dl{grid-template-columns:1fr;gap:2px}dd{margin-bottom:12px}}
|
||||
</style></head><body><main class="shell">${body}</main></body></html>`;
|
||||
}
|
||||
|
||||
function readJson(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
req.on("data", chunk => { raw += chunk; if (raw.length > 32 * 1024) { const error = new Error("body too large"); error.code = "BODY_TOO_LARGE"; reject(error); req.destroy(); } });
|
||||
req.on("end", () => { try { resolve(JSON.parse(raw || "{}")); } catch { resolve(null); } });
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function bearer(req) { return String(req.headers.authorization || "").replace(/^Bearer\s+/i, ""); }
|
||||
function bearerMatches(req, expected) { return Boolean(expected) && safeEqual(bearer(req), expected); }
|
||||
function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
||||
function sha256(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); }
|
||||
function splitCsv(value) { return value.split(",").map(item => item.trim()).filter(Boolean); }
|
||||
function escapeHtml(value) { return String(value).replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); }
|
||||
function json(res, status, value) { res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }); res.end(JSON.stringify(value)); }
|
||||
function html(res, status, value) { res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" }); res.end(value); }
|
||||
|
||||
if (require.main === module) {
|
||||
const host = process.env.LAKE_LAMP_HOST || "127.0.0.1";
|
||||
const port = Number(process.env.LAKE_LAMP_PORT || 3921);
|
||||
createApp().listen(port, host, () => process.stdout.write(`lake-lamp-authz listening on ${host}:${port}\n`));
|
||||
}
|
||||
|
||||
module.exports = { createApp, DEFAULT_ACTIONS };
|
||||
117
server-tools/lake-lamp-authz/server.test.js
Normal file
117
server-tools/lake-lamp-authz/server.test.js
Normal file
@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createApp } = require("./server");
|
||||
|
||||
async function withServer(run, extra = {}) {
|
||||
const mail = [];
|
||||
const app = createApp({
|
||||
requestToken: "request-only-secret",
|
||||
ownerEmail: "owner@example.invalid",
|
||||
publicBaseUrl: "https://example.invalid/authz",
|
||||
targets: ["JD-FD-PRIMARY", "BS-GZ-006"],
|
||||
actions: { "server-login": ["read-navigation-map", "inspect-services"], "server-ops": ["read-navigation-map", "inspect-services"], "repo-push": ["read-navigation-map", "push-repository"] },
|
||||
stateFile: "",
|
||||
sendEmail: async (message) => { mail.push(message); return true; },
|
||||
...extra,
|
||||
});
|
||||
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
|
||||
const base = `http://127.0.0.1:${app.address().port}`;
|
||||
try { await run({ base, mail }); } finally { await new Promise((resolve) => app.close(resolve)); }
|
||||
}
|
||||
|
||||
test("work order sends an opaque approval link and can be claimed once", async () => {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const recipientFingerprint = crypto.createHash("sha256").update("owner@example.invalid").digest("hex");
|
||||
const requested = await fetch(`${base}/api/workorders`, {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" },
|
||||
body: JSON.stringify({ persona_id: "ICE-GL-ZY001", persona_name: "铸渊", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map", recipient_fingerprint: recipientFingerprint }),
|
||||
});
|
||||
assert.equal(requested.status, 201);
|
||||
const order = await requested.json();
|
||||
assert.equal(mail.length, 1);
|
||||
assert.equal(mail[0].to, "owner@example.invalid");
|
||||
assert.doesNotMatch(JSON.stringify(order), /approvalToken/i);
|
||||
|
||||
const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
|
||||
const page = await fetch(`${base}${approvalPath}`);
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(await page.text(), /JD-FD-PRIMARY/);
|
||||
|
||||
const approved = await fetch(`${base}${approvalPath}`, { method: "POST" });
|
||||
assert.equal(approved.status, 200);
|
||||
const claimed = await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${order.claim_token}` },
|
||||
});
|
||||
assert.equal(claimed.status, 200);
|
||||
const session = await claimed.json();
|
||||
assert.equal(session.expires_in, 3600);
|
||||
|
||||
const secondClaim = await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } });
|
||||
assert.equal(secondClaim.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
test("navigation map acknowledgement is mandatory before other registered actions", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-server-map-"));
|
||||
const mapsDir = path.join(dir, "maps"); fs.mkdirSync(mapsDir);
|
||||
fs.writeFileSync(path.join(mapsDir, "JD-FD-PRIMARY.json"), JSON.stringify({ node_id: "JD-FD-PRIMARY", modules: [{ code: "JD-GTW-01" }] }));
|
||||
try {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const fingerprint = crypto.createHash("sha256").update("owner@example.invalid").digest("hex");
|
||||
const requested = await fetch(`${base}/api/workorders`, { method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" }, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map", recipient_fingerprint: fingerprint }) });
|
||||
const order = await requested.json();
|
||||
const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
|
||||
await fetch(`${base}${approvalPath}`, { method: "POST" });
|
||||
const claimed = await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } });
|
||||
const session = await claimed.json();
|
||||
const common = { persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login" };
|
||||
const locked = await fetch(`${base}/api/session/verify`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, action: "inspect-services" }) });
|
||||
assert.equal(locked.status, 423);
|
||||
const mapResponse = await fetch(`${base}/api/navigation-map/read`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify(common) });
|
||||
const map = await mapResponse.json();
|
||||
const ack = await fetch(`${base}/api/navigation-map/ack`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, map_hash: map.map_hash }) });
|
||||
assert.equal(ack.status, 200);
|
||||
const unlocked = await fetch(`${base}/api/session/verify`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, action: "inspect-services" }) });
|
||||
assert.equal(unlocked.status, 200);
|
||||
}, { mapsDir, mapStateFile: path.join(dir, "acks.json") });
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("request endpoint rejects direct email target switching and unknown actions", async () => {
|
||||
await withServer(async ({ base }) => {
|
||||
const common = { method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" } };
|
||||
const directEmail = await fetch(`${base}/api/workorders`, { ...common, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map", email: "attacker@example.invalid" }) });
|
||||
assert.equal(directEmail.status, 400);
|
||||
const unknown = await fetch(`${base}/api/workorders`, { ...common, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "shell" }) });
|
||||
assert.equal(unknown.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
test("session verification rejects switching servers without new approval", async () => {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const fingerprint = crypto.createHash("sha256").update("owner@example.invalid").digest("hex");
|
||||
const requested = await fetch(`${base}/api/workorders`, {
|
||||
method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" },
|
||||
body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "BS-GZ-006", scope: "server-ops", action: "read-navigation-map", recipient_fingerprint: fingerprint }),
|
||||
});
|
||||
const order = await requested.json();
|
||||
const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
|
||||
await fetch(`${base}${approvalPath}`, { method: "POST" });
|
||||
const claimed = await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } });
|
||||
const session = await claimed.json();
|
||||
const switched = await fetch(`${base}/api/session/verify`, {
|
||||
method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-ops", action: "read-navigation-map" }),
|
||||
});
|
||||
assert.equal(switched.status, 403);
|
||||
assert.equal((await switched.json()).error, "target_mismatch");
|
||||
});
|
||||
});
|
||||
63
server-tools/lake-lamp-authz/smtp-mailer.js
Normal file
63
server-tools/lake-lamp-authz/smtp-mailer.js
Normal file
@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
|
||||
const tls = require("node:tls");
|
||||
|
||||
function sendSmtpMail({ to, subject, approvalUrl, order, text, smtpHost, smtpPort, smtpUser, smtpPass }) {
|
||||
if (!to || !smtpUser || !smtpPass || (!text && !approvalUrl)) return Promise.resolve(false);
|
||||
const plain = text || [
|
||||
"光湖小湖灯安全协议系统 · 授权请求",
|
||||
"",
|
||||
`人格体: ${order.persona.name} (${order.persona.pid})`,
|
||||
`目标节点: ${order.target}`,
|
||||
`授权范围: ${order.scope}`,
|
||||
`登记动作: ${order.action}`,
|
||||
"有效期: 批准后 1 小时,仅限这台服务器",
|
||||
"",
|
||||
"打开以下链接查看工单并确认授权:",
|
||||
approvalUrl,
|
||||
"",
|
||||
"如果不是你发起的操作,请不要点击。",
|
||||
].join("\n");
|
||||
const message = [
|
||||
`From: =?UTF-8?B?${Buffer.from("光湖小湖灯").toString("base64")}?= <${smtpUser}>`,
|
||||
`To: <${to}>`,
|
||||
`Subject: =?UTF-8?B?${Buffer.from(subject).toString("base64")}?=`,
|
||||
"MIME-Version: 1.0",
|
||||
'Content-Type: text/plain; charset="utf-8"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
Buffer.from(plain).toString("base64").replace(/(.{76})/g, "$1\r\n"),
|
||||
".",
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const done = value => { if (!settled) { settled = true; resolve(value); } };
|
||||
const socket = tls.connect({ host: smtpHost, port: smtpPort, servername: smtpHost, rejectUnauthorized: true });
|
||||
let stage = 0;
|
||||
let buffer = "";
|
||||
socket.setTimeout(15000, () => { socket.destroy(); done(false); });
|
||||
socket.on("error", () => done(false));
|
||||
socket.on("data", data => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split("\r\n");
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (!/^\d{3}[ -]/.test(line) || line[3] === "-") continue;
|
||||
const code = Number(line.slice(0, 3));
|
||||
if (code >= 400) { socket.end(); done(false); return; }
|
||||
if (stage === 0 && code === 220) { stage = 1; socket.write("EHLO guanghu-lake-lamp\r\n"); }
|
||||
else if (stage === 1 && code === 250) { stage = 2; socket.write(`AUTH PLAIN ${Buffer.from(`\0${smtpUser}\0${smtpPass}`).toString("base64")}\r\n`); }
|
||||
else if (stage === 2 && code === 235) { stage = 3; socket.write(`MAIL FROM:<${smtpUser}>\r\n`); }
|
||||
else if (stage === 3 && code === 250) { stage = 4; socket.write(`RCPT TO:<${to}>\r\n`); }
|
||||
else if (stage === 4 && code === 250) { stage = 5; socket.write("DATA\r\n"); }
|
||||
else if (stage === 5 && code === 354) { stage = 6; socket.write(message); }
|
||||
else if (stage === 6 && code === 250) { socket.write("QUIT\r\n"); socket.end(); done(true); }
|
||||
}
|
||||
});
|
||||
socket.on("close", () => done(false));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { sendSmtpMail };
|
||||
161
server-tools/lake-lamp-authz/workorder-manager.js
Normal file
161
server-tools/lake-lamp-authz/workorder-manager.js
Normal file
@ -0,0 +1,161 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
class WorkOrderManager {
|
||||
constructor({ approvalTtl = 15 * 60, sessionTtl = 60 * 60, stateFile = "" } = {}) {
|
||||
this.approvalTtl = approvalTtl;
|
||||
this.sessionTtl = sessionTtl;
|
||||
this.stateFile = stateFile;
|
||||
this.workorders = new Map();
|
||||
this.sessions = new Map();
|
||||
this.load();
|
||||
}
|
||||
|
||||
request({ persona, target, scope, action, allowedActions = [action], description = "" }, now = Date.now() / 1000) {
|
||||
const id = crypto.randomUUID();
|
||||
const approvalToken = randomToken();
|
||||
const claimToken = randomToken();
|
||||
this.workorders.set(id, {
|
||||
id,
|
||||
persona: { pid: persona.pid, name: persona.name || persona.pid },
|
||||
target,
|
||||
scope,
|
||||
action,
|
||||
allowedActions: [...new Set(allowedActions)],
|
||||
description: String(description).slice(0, 500),
|
||||
createdAt: now,
|
||||
expiresAt: now + this.approvalTtl,
|
||||
approvalHash: hash(approvalToken),
|
||||
claimHash: hash(claimToken),
|
||||
state: "pending",
|
||||
claimed: false,
|
||||
});
|
||||
this.persist();
|
||||
return { id, approvalToken, claimToken, expiresIn: this.approvalTtl };
|
||||
}
|
||||
|
||||
inspectApproval(approvalToken, now = Date.now() / 1000) {
|
||||
const order = this.findByApproval(approvalToken);
|
||||
if (!order) return { ok: false, reason: "approval_not_found" };
|
||||
if (now > order.expiresAt) return { ok: false, reason: "approval_expired" };
|
||||
if (order.state !== "pending") return { ok: false, reason: "approval_already_used" };
|
||||
return { ok: true, order: publicOrder(order) };
|
||||
}
|
||||
|
||||
approve(approvalToken, now = Date.now() / 1000) {
|
||||
const inspected = this.inspectApproval(approvalToken, now);
|
||||
if (!inspected.ok) return inspected;
|
||||
const order = this.workorders.get(inspected.order.id);
|
||||
order.state = "approved";
|
||||
order.approvedAt = now;
|
||||
order.approvalHash = "";
|
||||
this.persist();
|
||||
return { ok: true, order: publicOrder(order) };
|
||||
}
|
||||
|
||||
claim(id, claimToken, now = Date.now() / 1000) {
|
||||
const order = this.workorders.get(id);
|
||||
if (!order || !safeEqual(order.claimHash, hash(claimToken || ""))) return { ok: false, reason: "claim_not_found" };
|
||||
if (now > order.expiresAt) return { ok: false, reason: "approval_expired" };
|
||||
if (order.state !== "approved") return { ok: false, reason: order.state === "pending" ? "approval_pending" : "claim_unavailable" };
|
||||
if (order.claimed) return { ok: false, reason: "claim_already_used" };
|
||||
|
||||
const sessionToken = randomToken();
|
||||
this.sessions.set(hash(sessionToken), {
|
||||
persona: order.persona,
|
||||
target: order.target,
|
||||
scope: order.scope,
|
||||
action: order.action,
|
||||
actions: order.allowedActions || [order.action],
|
||||
createdAt: now,
|
||||
expiresAt: now + this.sessionTtl,
|
||||
});
|
||||
order.claimed = true;
|
||||
order.state = "claimed";
|
||||
order.claimHash = "";
|
||||
this.persist();
|
||||
return { ok: true, sessionToken, expiresIn: this.sessionTtl, target: order.target, scope: order.scope, action: order.action };
|
||||
}
|
||||
|
||||
verifySession(sessionToken, persona, target, scope, action, now = Date.now() / 1000) {
|
||||
const key = hash(sessionToken || "");
|
||||
const session = this.sessions.get(key);
|
||||
if (!session) return { ok: false, reason: "session_not_found" };
|
||||
if (now > session.expiresAt) {
|
||||
this.sessions.delete(key);
|
||||
this.persist();
|
||||
return { ok: false, reason: "session_expired" };
|
||||
}
|
||||
if (session.persona.pid !== persona.pid) return { ok: false, reason: "persona_mismatch" };
|
||||
if (session.target !== target) return { ok: false, reason: "target_mismatch" };
|
||||
if (session.scope !== scope) return { ok: false, reason: "scope_mismatch" };
|
||||
if (!(session.actions || [session.action]).includes(action)) return { ok: false, reason: "action_mismatch" };
|
||||
return { ok: true, session: { ...session } };
|
||||
}
|
||||
|
||||
findByApproval(token) {
|
||||
const needle = hash(token || "");
|
||||
for (const order of this.workorders.values()) {
|
||||
if (order.approvalHash && safeEqual(order.approvalHash, needle)) return order;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
load(now = Date.now() / 1000) {
|
||||
if (!this.stateFile || !fs.existsSync(this.stateFile)) return;
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(this.stateFile, "utf8"));
|
||||
for (const order of state.workorders || []) {
|
||||
if (order.expiresAt >= now) this.workorders.set(order.id, order);
|
||||
}
|
||||
for (const [tokenHash, session] of Object.entries(state.sessions || {})) {
|
||||
if (session.expiresAt >= now) this.sessions.set(tokenHash, session);
|
||||
}
|
||||
} catch {
|
||||
this.workorders.clear();
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
persist() {
|
||||
if (!this.stateFile) return;
|
||||
const stateDir = path.dirname(this.stateFile);
|
||||
if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
||||
const temp = `${this.stateFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temp, JSON.stringify({ version: 1, workorders: [...this.workorders.values()], sessions: Object.fromEntries(this.sessions) }), { mode: 0o600 });
|
||||
fs.renameSync(temp, this.stateFile);
|
||||
}
|
||||
}
|
||||
|
||||
function randomToken() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function hash(value) {
|
||||
return crypto.createHash("sha256").update(String(value)).digest("hex");
|
||||
}
|
||||
|
||||
function safeEqual(left, right) {
|
||||
const a = Buffer.from(String(left));
|
||||
const b = Buffer.from(String(right));
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function publicOrder(order) {
|
||||
return {
|
||||
id: order.id,
|
||||
persona: { ...order.persona },
|
||||
target: order.target,
|
||||
scope: order.scope,
|
||||
action: order.action,
|
||||
description: order.description,
|
||||
createdAt: order.createdAt,
|
||||
expiresAt: order.expiresAt,
|
||||
state: order.state,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { WorkOrderManager };
|
||||
69
server-tools/lake-lamp-authz/workorder-manager.test.js
Normal file
69
server-tools/lake-lamp-authz/workorder-manager.test.js
Normal file
@ -0,0 +1,69 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { WorkOrderManager } = require("./workorder-manager");
|
||||
|
||||
const persona = { pid: "ICE-GL-ZY001", name: "铸渊" };
|
||||
|
||||
test("approval link is single use and the session is claimed once", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-authz-"));
|
||||
try {
|
||||
const manager = new WorkOrderManager({ stateFile: path.join(dir, "state.json"), sessionTtl: 3600 });
|
||||
const created = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map", allowedActions: ["read-navigation-map", "inspect-services"] }, 100);
|
||||
assert.equal(manager.inspectApproval(created.approvalToken, 101).ok, true);
|
||||
assert.equal(manager.approve(created.approvalToken, 102).ok, true);
|
||||
assert.equal(manager.approve(created.approvalToken, 103).ok, false);
|
||||
|
||||
const claimed = manager.claim(created.id, created.claimToken, 104);
|
||||
assert.equal(claimed.ok, true);
|
||||
assert.equal(claimed.expiresIn, 3600);
|
||||
assert.equal(manager.claim(created.id, created.claimToken, 105).ok, false);
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "read-navigation-map", 106).ok, true);
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "inspect-services", 106).ok, true);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session is bound to one persona target scope and action", () => {
|
||||
const manager = new WorkOrderManager({ sessionTtl: 3600 });
|
||||
const created = manager.request({ persona, target: "BS-GZ-006", scope: "server-ops", action: "read-navigation-map" }, 100);
|
||||
manager.approve(created.approvalToken, 101);
|
||||
const claimed = manager.claim(created.id, created.claimToken, 102);
|
||||
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "BS-GZ-006", "server-ops", "read-navigation-map", 103).ok, true);
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-ops", "read-navigation-map", 103).reason, "target_mismatch");
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, { pid: "OTHER" }, "BS-GZ-006", "server-ops", "read-navigation-map", 103).reason, "persona_mismatch");
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "BS-GZ-006", "repo-push", "read-navigation-map", 103).reason, "scope_mismatch");
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "BS-GZ-006", "server-ops", "push-repository", 103).reason, "action_mismatch");
|
||||
});
|
||||
|
||||
test("plaintext approval claim and session tokens are never persisted", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-authz-"));
|
||||
try {
|
||||
const stateFile = path.join(dir, "state.json");
|
||||
const manager = new WorkOrderManager({ stateFile });
|
||||
const created = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "repo-push", action: "push-repository" }, 100);
|
||||
manager.approve(created.approvalToken, 101);
|
||||
const claimed = manager.claim(created.id, created.claimToken, 102);
|
||||
const state = fs.readFileSync(stateFile, "utf8");
|
||||
for (const secret of [created.approvalToken, created.claimToken, claimed.sessionToken]) assert.equal(state.includes(secret), false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("expired work orders and sessions fail closed", () => {
|
||||
const manager = new WorkOrderManager({ approvalTtl: 10, sessionTtl: 20 });
|
||||
const created = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" }, 100);
|
||||
assert.equal(manager.approve(created.approvalToken, 111).reason, "approval_expired");
|
||||
|
||||
const fresh = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" }, 200);
|
||||
manager.approve(fresh.approvalToken, 201);
|
||||
const claimed = manager.claim(fresh.id, fresh.claimToken, 202);
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "read-navigation-map", 223).reason, "session_expired");
|
||||
});
|
||||
8
server-tools/node-bootstrap/README.md
Normal file
8
server-tools/node-bootstrap/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# 光湖服务器初始化模板
|
||||
|
||||
所有新灯塔节点先执行同一模板:基础诊断工具、`guanghu` 系统组、root-only
|
||||
秘密目录、节点身份文件、服务器导航地图、SSH 与系统安全更新。模板不携带任何
|
||||
共享 token;节点到京东的密钥必须逐节点生成,接入后登记到 JD 路由表。
|
||||
|
||||
JD-FD-PRIMARY 是当前首个样板节点。历史节点接入时先备份和审计,再补齐模板
|
||||
目录,不批量覆盖其现有服务。
|
||||
36
server-tools/node-bootstrap/bootstrap.sh
Executable file
36
server-tools/node-bootstrap/bootstrap.sh
Executable file
@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NODE_ID=${1:?usage: bootstrap.sh NODE_ID MAP_FILE}
|
||||
MAP_FILE=${2:?usage: bootstrap.sh NODE_ID MAP_FILE}
|
||||
test "$(id -u)" -eq 0
|
||||
test -s "$MAP_FILE"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ca-certificates curl git jq openssh-server python3 rsync unattended-upgrades
|
||||
|
||||
getent group guanghu >/dev/null || groupadd --system guanghu
|
||||
for account in guanghu-authz guanghu-sentinel; do
|
||||
id -u "$account" >/dev/null 2>&1 || useradd --system --home /nonexistent --shell /usr/sbin/nologin "$account"
|
||||
usermod -aG guanghu "$account"
|
||||
done
|
||||
|
||||
install -d -o root -g guanghu -m 750 /etc/guanghu /var/lib/guanghu
|
||||
install -d -o root -g root -m 700 /etc/guanghu/secrets
|
||||
install -d -o root -g root -m 755 /etc/guanghu/navigation-maps /opt/guanghu
|
||||
install -m 644 "$MAP_FILE" "/etc/guanghu/navigation-maps/${NODE_ID}.json"
|
||||
|
||||
cat > /etc/guanghu/node.json <<EOF
|
||||
{
|
||||
"schema": "guanghu.node/v1",
|
||||
"node_id": "${NODE_ID}",
|
||||
"upstream": "JD-FD-PRIMARY",
|
||||
"navigation_map": "/etc/guanghu/navigation-maps/${NODE_ID}.json",
|
||||
"secret_policy": "private material stays in /etc/guanghu/secrets"
|
||||
}
|
||||
EOF
|
||||
chmod 644 /etc/guanghu/node.json
|
||||
|
||||
systemctl enable --now ssh unattended-upgrades
|
||||
printf 'GUANGHU_NODE_READY=%s\n' "$NODE_ID"
|
||||
16
server-tools/node-bootstrap/bootstrap.test.js
vendored
Normal file
16
server-tools/node-bootstrap/bootstrap.test.js
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const source = fs.readFileSync(path.join(__dirname, "bootstrap.sh"), "utf8");
|
||||
test("bootstrap creates the common Guanghu filesystem boundary", () => {
|
||||
assert.match(source, /\/etc\/guanghu\/secrets/);
|
||||
assert.match(source, /\/etc\/guanghu\/navigation-maps/);
|
||||
assert.match(source, /\/var\/lib\/guanghu/);
|
||||
});
|
||||
test("bootstrap requires an explicit node id and map", () => {
|
||||
assert.match(source, /NODE_ID=\$\{1:\?/);
|
||||
assert.match(source, /MAP_FILE=\$\{2:\?/);
|
||||
});
|
||||
test("bootstrap never installs a shared token", () => assert.doesNotMatch(source, /zy_gtw_|Bearer\s+[A-Za-z0-9]/));
|
||||
5
server-tools/state-change-sentinel/README.md
Normal file
5
server-tools/state-change-sentinel/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# 状态变化哨兵
|
||||
|
||||
这不是五分钟只读心跳,也不抓取页面内容。JD 每 15 分钟只请求两个自有健康入口,
|
||||
首次观察仅建立基线;状态完全不变时不发邮件。只有在线变异常、异常类型变化或恢复在线
|
||||
才发送提醒。整个节点直接断电时,本机无法主动上报,因此仍需由 JD 做低频外部判定。
|
||||
@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Guanghu state-change-only node sentinel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=guanghu-sentinel
|
||||
Group=guanghu-sentinel
|
||||
SupplementaryGroups=guanghu
|
||||
WorkingDirectory=/opt/guanghu/state-change-sentinel
|
||||
EnvironmentFile=/etc/guanghu/secrets/mail/qq-authorization.env
|
||||
Environment=SENTINEL_CONFIG=/etc/guanghu/state-change-sentinel.json
|
||||
Environment=SENTINEL_STATE=/var/lib/guanghu/state-change-sentinel/state.json
|
||||
ExecStart=/usr/bin/node /opt/guanghu/state-change-sentinel/sentinel.js
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/guanghu/state-change-sentinel
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Check Guanghu nodes for state transitions only
|
||||
|
||||
[Timer]
|
||||
OnBootSec=5min
|
||||
OnUnitInactiveSec=15min
|
||||
RandomizedDelaySec=2min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
47
server-tools/state-change-sentinel/sentinel.js
Normal file
47
server-tools/state-change-sentinel/sentinel.js
Normal file
@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { transition } = require("./state");
|
||||
const { sendSmtpMail } = require("../lake-lamp-authz/smtp-mailer");
|
||||
|
||||
async function main() {
|
||||
const config = JSON.parse(fs.readFileSync(process.env.SENTINEL_CONFIG || "/etc/guanghu/state-change-sentinel.json", "utf8"));
|
||||
const stateFile = process.env.SENTINEL_STATE || "/var/lib/guanghu/state-change-sentinel/state.json";
|
||||
const previous = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, "utf8")) : {};
|
||||
const next = {};
|
||||
const notices = [];
|
||||
for (const target of config.targets) {
|
||||
const current = await check(target);
|
||||
next[target.id] = current;
|
||||
const changed = transition(previous[target.id], current);
|
||||
if (changed.notify) notices.push({ id: target.id, kind: changed.kind, detail: current.detail });
|
||||
}
|
||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(stateFile, JSON.stringify(next), { mode: 0o600 });
|
||||
if (!notices.length) return;
|
||||
const lines = notices.map(item => `${item.kind === "anomaly" ? "异常" : "恢复"}: ${item.id} · ${item.detail}`);
|
||||
await sendSmtpMail({
|
||||
to: process.env.AUTH_RECIPIENT,
|
||||
subject: `光湖节点状态变化 · ${notices.map(item => item.id).join(", ")}`,
|
||||
text: ["光湖节点状态发生变化。静止在线期间不会发送邮件。", "", ...lines, "", new Date().toISOString()].join("\n"),
|
||||
smtpHost: process.env.SMTP_HOST || "smtp.qq.com",
|
||||
smtpPort: Number(process.env.SMTP_PORT || 465),
|
||||
smtpUser: process.env.SMTP_USER,
|
||||
smtpPass: process.env.SMTP_PASS,
|
||||
});
|
||||
}
|
||||
|
||||
async function check(target) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), target.timeout_ms || 8000);
|
||||
try {
|
||||
const response = await fetch(target.url, { method: "GET", redirect: "manual", signal: controller.signal, headers: { "user-agent": "Guanghu-State-Change-Sentinel/1.0" } });
|
||||
const ok = (target.accept || [200]).includes(response.status);
|
||||
return { ok, detail: `HTTP ${response.status}`, observed_at: new Date().toISOString() };
|
||||
} catch (error) {
|
||||
return { ok: false, detail: error.name === "AbortError" ? "timeout" : "connection-failed", observed_at: new Date().toISOString() };
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
main().catch(error => { process.stderr.write(`state sentinel failed: ${error.message}\n`); process.exit(1); });
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"targets": [
|
||||
{ "id": "BS-GZ-006-front-door", "url": "https://guanghulab.com/", "accept": [200], "timeout_ms": 8000 },
|
||||
{ "id": "JD-Lake-Lamp-public-route", "url": "https://guanghulab.com/authz/health", "accept": [200], "timeout_ms": 8000 }
|
||||
]
|
||||
}
|
||||
9
server-tools/state-change-sentinel/state.js
Normal file
9
server-tools/state-change-sentinel/state.js
Normal file
@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
function transition(previous, current) {
|
||||
if (!previous) return { notify: false, state: current, kind: "baseline" };
|
||||
if (previous.ok === current.ok && previous.detail === current.detail) return { notify: false, state: current, kind: "unchanged" };
|
||||
return { notify: true, state: current, kind: current.ok ? "recovered" : "anomaly" };
|
||||
}
|
||||
|
||||
module.exports = { transition };
|
||||
10
server-tools/state-change-sentinel/state.test.js
Normal file
10
server-tools/state-change-sentinel/state.test.js
Normal file
@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { transition } = require("./state");
|
||||
test("first observation records a baseline without email", () => assert.equal(transition(null, { ok: true, detail: "200" }).notify, false));
|
||||
test("steady online state sends no email", () => assert.equal(transition({ ok: true, detail: "200" }, { ok: true, detail: "200" }).notify, false));
|
||||
test("online to offline and offline to online both notify", () => {
|
||||
assert.deepEqual(transition({ ok: true, detail: "200" }, { ok: false, detail: "timeout" }).kind, "anomaly");
|
||||
assert.deepEqual(transition({ ok: false, detail: "timeout" }, { ok: true, detail: "200" }).kind, "recovered");
|
||||
});
|
||||
@ -42,7 +42,7 @@
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| **人类** | 苍耳 TCS-GL-009 |
|
||||
| **验证码邮箱** | 1279551860@qq.com |
|
||||
| **验证码邮箱** | EMAIL_REDACTED@qq.com |
|
||||
| **仓库地址** | https://guanghubingshuo.com/code/bingshuo/cang-ying |
|
||||
| **引擎地址** | SG-001 (43.156.237.110:3911) |
|
||||
|
||||
@ -59,7 +59,7 @@ curl -X POST http://43.156.237.110:3911/auth/request \
|
||||
-H "Authorization: Bearer <耳耳蛋的Token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "1279551860@qq.com",
|
||||
"email": "EMAIL_REDACTED@qq.com",
|
||||
"op_type": "api-access",
|
||||
"cmd": "curl https://ark.cn-beijing.volces.com/api/v3/...",
|
||||
"description": "调用火山引擎生成视频"
|
||||
@ -69,7 +69,7 @@ curl -X POST http://43.156.237.110:3911/auth/request \
|
||||
**必填字段**:
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `email` | 苍耳的验证码收件邮箱 | `"1279551860@qq.com"` |
|
||||
| `email` | 苍耳的验证码收件邮箱 | `"EMAIL_REDACTED@qq.com"` |
|
||||
| `op_type` | 操作类型 | `"api-access"` 或 `"code-repo"` |
|
||||
| `cmd` | 要执行的命令 | `"git push origin main"` |
|
||||
| `description` | 描述一下在做什么 | `"推送耳耳蛋更新的代码"` |
|
||||
@ -79,16 +79,16 @@ curl -X POST http://43.156.237.110:3911/auth/request \
|
||||
{
|
||||
"ok": true,
|
||||
"challenge_id": "abc123def456...",
|
||||
"target_email": "1279551860@qq.com",
|
||||
"target_email": "EMAIL_REDACTED@qq.com",
|
||||
"op_type": "api-access",
|
||||
"email_sent": true,
|
||||
"message": "验证码已发送到 1279551860@qq.com,请查收后通过 /auth/confirm 确认操作"
|
||||
"message": "验证码已发送到 EMAIL_REDACTED@qq.com,请查收后通过 /auth/confirm 确认操作"
|
||||
}
|
||||
```
|
||||
|
||||
### 苍耳做的事
|
||||
|
||||
苍耳在 1279551860@qq.com 邮箱里收到一封邮件:
|
||||
苍耳在 EMAIL_REDACTED@qq.com 邮箱里收到一封邮件:
|
||||
- 邮件的标题会显示是谁在请求、做什么操作
|
||||
- 邮件里有 6 位数字验证码
|
||||
- 苍耳把验证码告诉耳耳蛋
|
||||
@ -118,7 +118,7 @@ curl -X POST http://43.156.237.110:3911/auth/confirm \
|
||||
```
|
||||
耳耳蛋 → 光湖引擎驱动:
|
||||
{
|
||||
"email": "1279551860@qq.com",
|
||||
"email": "EMAIL_REDACTED@qq.com",
|
||||
"op_type": "api-access",
|
||||
"cmd": "python3 call_volcengine_api.py --prompt '生成一段...'",
|
||||
"description": "火山引擎视频生成 · 苍耳第3镜"
|
||||
@ -134,7 +134,7 @@ curl -X POST http://43.156.237.110:3911/auth/confirm \
|
||||
```
|
||||
耳耳蛋 → 光湖引擎驱动:
|
||||
{
|
||||
"email": "1279551860@qq.com",
|
||||
"email": "EMAIL_REDACTED@qq.com",
|
||||
"op_type": "code-repo",
|
||||
"cmd": "cd /opt/zhuyuan/cang-ying && git add . && git commit -m '...' && git push",
|
||||
"description": "推送耳耳蛋代码更新 · 视频AI第4镜"
|
||||
@ -172,7 +172,7 @@ Token 只证明"我是耳耳蛋",不代表有权限操作。
|
||||
## 七 · 常见问题
|
||||
|
||||
**Q: 苍耳没看到邮件怎么办?**
|
||||
A: 检查 1279551860@qq.com 的垃圾箱。邮件标题是 `📦 Gatekeeper授权 · 耳耳蛋 · ...`。
|
||||
A: 检查 EMAIL_REDACTED@qq.com 的垃圾箱。邮件标题是 `📦 Gatekeeper授权 · 耳耳蛋 · ...`。
|
||||
|
||||
**Q: 验证码过期了?**
|
||||
A: 5 分钟过期。耳耳蛋重新发 `/auth/request` 就行了。旧验证码自动失效。
|
||||
|
||||
70
zero-point/core-channel/revive-guard/pre-push-clean.py
Normal file → Executable file
70
zero-point/core-channel/revive-guard/pre-push-clean.py
Normal file → Executable file
@ -17,22 +17,14 @@ import os
|
||||
import sys
|
||||
import re
|
||||
import subprocess
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 敏感模式库(与服务器端 pre-receive-guard 保持同步)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
PATTERNS = [
|
||||
# 冰朔主权邮箱
|
||||
(r'565183519@qq\.com', '冰朔主权邮箱', 'ICE-GL∞_EMAIL_REDACTED'),
|
||||
|
||||
# 任何 QQ 邮箱
|
||||
(r'[a-zA-Z0-9._%+-]+@qq\.com', 'QQ邮箱地址', 'EMAIL_REDACTED@qq.com'),
|
||||
|
||||
# SMTP 授权码
|
||||
(r'SMTP_AUTH_CODE_REDACTED', 'QQ SMTP授权码', 'SMTP_AUTH_CODE_REDACTED'),
|
||||
|
||||
# Gatekeeper Token
|
||||
(r'zy_gtw_[a-f0-9]{40,}', 'Gatekeeper Token', 'GATEKEEPER_TOKEN_REDACTED'),
|
||||
|
||||
@ -53,22 +45,28 @@ PATTERNS = [
|
||||
(r'(password|passwd|pwd|secret)\s*[:=]\s*["\']([^"\']{3,})["\']',
|
||||
'密码明文赋值', lambda m: f'{m.group(1)}="REDACTED_PASSWORD"'),
|
||||
|
||||
# git 令牌(f78c594e... 等 40 位 hex token)
|
||||
(r'\b[a-f0-9]{40}\b', 'Git Token / SHA1长串', 'GIT_TOKEN_REDACTED'),
|
||||
|
||||
# 保险库盐值
|
||||
(r'VAULT_SALT_REDACTED',
|
||||
'保险库主盐值', 'VAULT_SALT_REDACTED'),
|
||||
(r'API_SALT_REDACTED',
|
||||
'API子库盐值', 'API_SALT_REDACTED'),
|
||||
|
||||
# 通用长 hex hash(64位 · 可能是密钥/盐值)
|
||||
(r'\b[a-f0-9]{64}\b', '64位Hash(疑似密钥)', 'LONG_HEX_REDACTED'),
|
||||
|
||||
# 任何看起来像 token 的字符串(字母数字下划线 30+ 位,非 git SHA)
|
||||
(r'\b[a-zA-Z0-9_\-]{30,60}\b', '疑似Token长串(需确认)', 'TOKEN_STRING_REDACTED'),
|
||||
]
|
||||
|
||||
|
||||
def load_private_patterns():
|
||||
"""从仓库外私有文件加载精确禁入值;绝不把值写入源码或日志。"""
|
||||
path = os.environ.get(
|
||||
'GUANGHU_FORBIDDEN_IDENTIFIERS_FILE',
|
||||
os.path.expanduser(
|
||||
'~/Documents/guanghulab-local-secrets/code-guard/forbidden-identifiers'
|
||||
),
|
||||
)
|
||||
patterns = []
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
for line in handle:
|
||||
value = line.strip()
|
||||
if value and not value.startswith('#'):
|
||||
patterns.append((re.escape(value), '私有禁入标识', 'PRIVATE_VALUE_REDACTED'))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return patterns
|
||||
|
||||
# 文件类型白名单
|
||||
TEXT_EXTENSIONS = {
|
||||
'.py', '.js', '.ts', '.go', '.rs', '.java', '.c', '.cpp', '.h',
|
||||
@ -111,23 +109,17 @@ def scan_file(filepath):
|
||||
findings = []
|
||||
new_content = content
|
||||
|
||||
for pattern, name, replacement in PATTERNS:
|
||||
for pattern, name, replacement in PATTERNS + load_private_patterns():
|
||||
matches = list(re.finditer(pattern, content, re.IGNORECASE))
|
||||
for m in matches:
|
||||
matched_text = m.group(0)
|
||||
# 跳过已经是占位符的
|
||||
if 'REDACTED' in matched_text:
|
||||
continue
|
||||
# 跳过 git commit SHA(40位hex在特定上下文中)
|
||||
if name == 'Git Token / SHA1长串' and _is_git_sha_context(content, m.start()):
|
||||
continue
|
||||
|
||||
repl = replacement(m) if callable(replacement) else replacement
|
||||
findings.append({
|
||||
'file': filepath,
|
||||
'pattern_name': name,
|
||||
'matched': matched_text[:80],
|
||||
'replacement': repl,
|
||||
})
|
||||
# 替换(注意:用 re.sub 而不是简单替换,避免破坏后续匹配位置)
|
||||
new_content = new_content.replace(matched_text, repl, 1)
|
||||
@ -135,20 +127,6 @@ def scan_file(filepath):
|
||||
return new_content if findings else None, findings
|
||||
|
||||
|
||||
def _is_git_sha_context(content, pos):
|
||||
"""判断 40 位 hex 是否在 git SHA 上下文中(commit hash 不应被替换)"""
|
||||
# 检查前后是否有 git 相关关键词
|
||||
start = max(0, pos - 50)
|
||||
end = min(len(content), pos + 50)
|
||||
context = content[start:end].lower()
|
||||
git_keywords = ['commit', 'sha', 'hash', 'revision', 'ref', 'head', 'object']
|
||||
# 如果在 git 命令或 commit 引用上下文中 → 跳过
|
||||
for kw in git_keywords:
|
||||
if kw in context:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_changed_files():
|
||||
"""获取本次 push 涉及的所有文件"""
|
||||
files = set()
|
||||
@ -175,7 +153,7 @@ def get_changed_files():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
local_ref, local_sha, remote_ref, remote_sha = parts[0], parts[1], parts[2], parts[3]
|
||||
if remote_sha != 'TOKEN_STRING_REDACTED':
|
||||
if remote_sha != '0' * 40:
|
||||
r = subprocess.run(
|
||||
['git', 'diff', '--name-only', '--diff-filter=ACM',
|
||||
remote_sha, local_sha],
|
||||
@ -274,8 +252,8 @@ def main():
|
||||
for f in all_findings[:20]:
|
||||
print(f' 📄 {f["file"]}')
|
||||
print(f' ⚠️ {f["pattern_name"]}')
|
||||
print(f' ✗ {f["matched"][:60]}')
|
||||
print(f' → {f["replacement"]}')
|
||||
print(' ✗ 原文已隐藏')
|
||||
print(' → 已替换为安全占位符')
|
||||
print()
|
||||
|
||||
if len(all_findings) > 20:
|
||||
|
||||
61
zero-point/core-channel/revive-guard/pre-receive-guard.py
Normal file → Executable file
61
zero-point/core-channel/revive-guard/pre-receive-guard.py
Normal file → Executable file
@ -11,8 +11,7 @@ Guanghu Language System · Pre-Receive Guard
|
||||
⊢ 铸渊(人格体)会下意识把密码/邮箱写进代码 → 人类改不了这个习惯
|
||||
⊢ 不在人格体侧修 → 在服务器侧设门禁 → 不干净的推送进不来
|
||||
⊢ 公开仓库 24h 被爬虫扫描 → 敏感信息一旦进去就立刻暴露
|
||||
⊢ 配合客户端 pre-push-clean 插件 → [SEC-CLEAN] 标记 = 信任免检
|
||||
⊢ 无标记 → 全量扫描 → 发现敏感信息 → 拒绝推送
|
||||
⊢ 客户端 pre-push-clean 负责提前发现,服务器始终独立全量扫描
|
||||
⊢ 双重防线:客户端插件 + 服务器守门人
|
||||
"""
|
||||
|
||||
@ -32,22 +31,18 @@ SMTP_USER = os.environ.get("SMTP_USER", "ICE-GL∞_EMAIL_REDACTED")
|
||||
# 模式: "reject" = 拦截并拒绝(推荐) / "notify" = 放行但通知冰朔
|
||||
MODE = os.environ.get("PRE_RECEIVE_MODE", "reject")
|
||||
|
||||
# [SEC-CLEAN] 标记信任:有标记的 commit 免检(客户端插件已清理)
|
||||
TRUST_SEC_CLEAN = os.environ.get("TRUST_SEC_CLEAN", "1") == "1"
|
||||
FORBIDDEN_IDENTIFIERS_FILE = os.environ.get(
|
||||
"FORBIDDEN_IDENTIFIERS_FILE",
|
||||
"/etc/guanghu/secrets/code-guard/forbidden-identifiers",
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 敏感模式库(正则 · 命中则触发)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
SENSITIVE_PATTERNS = [
|
||||
# 冰朔的主权邮箱
|
||||
(r'565183519@qq\.com', '冰朔主权邮箱', 'ICE-GL∞_EMAIL_REDACTED'),
|
||||
|
||||
# 任何 QQ 邮箱(含冰朔在其他上下文中的邮箱)
|
||||
(r'[a-zA-Z0-9._%+-]+@qq\.com', 'QQ邮箱地址', 'EMAIL_REDACTED@qq.com'),
|
||||
|
||||
# SMTP 授权码模式(16位小写字母)
|
||||
(r'SMTP_AUTH_CODE_REDACTED', 'QQ SMTP授权码', 'SMTP_AUTH_CODE_REDACTED'),
|
||||
|
||||
# GZ-006 Gatekeeper Token 模式
|
||||
(r'zy_gtw_[a-f0-9]{40,}', 'Gatekeeper Token', 'GATEKEEPER_TOKEN_REDACTED'),
|
||||
|
||||
@ -66,20 +61,22 @@ SENSITIVE_PATTERNS = [
|
||||
# 密码赋值模式(变量名含 password/passwd/pwd 且值非空)
|
||||
(r'(password|passwd|pwd|secret)\s*[:=]\s*["\']([^"\']{3,})["\']', '密码明文赋值', lambda m: f'{m.group(1)}="REDACTED_PASSWORD"'),
|
||||
|
||||
# HMAC secret / salt 长串
|
||||
(r'[a-f0-9]{64}', '疑似密钥/Hash长串(需人工确认)', 'LONG_HEX_REDACTED'),
|
||||
|
||||
# 保险库 salt(已知值)
|
||||
(r'VAULT_SALT_REDACTED', '保险库主盐值', 'VAULT_SALT_REDACTED'),
|
||||
(r'API_SALT_REDACTED', 'API子库盐值', 'API_SALT_REDACTED'),
|
||||
|
||||
# Git 令牌(40 位 hex · 如 f78c594e...)
|
||||
(r'\b[a-f0-9]{40}\b', 'Git Token / 40位hex', 'GIT_TOKEN_REDACTED'),
|
||||
|
||||
# 通用长 hex hash(64位 · 可能是密钥)
|
||||
(r'\b[a-f0-9]{64}\b', '64位Hash(疑似密钥)', 'LONG_HEX_REDACTED'),
|
||||
]
|
||||
|
||||
|
||||
def load_forbidden_identifiers(filename=FORBIDDEN_IDENTIFIERS_FILE):
|
||||
"""从仓库外的 root-only 文件加载精确禁用值,不把值写入代码或日志。"""
|
||||
patterns = []
|
||||
try:
|
||||
with open(filename, "r", encoding="utf-8") as handle:
|
||||
for raw in handle:
|
||||
value = raw.strip()
|
||||
if value and not value.startswith("#"):
|
||||
patterns.append((re.escape(value), "私密禁用标识", "PRIVATE_IDENTIFIER_REDACTED"))
|
||||
except OSError:
|
||||
pass
|
||||
return patterns
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 文件类型白名单(只扫描文本文件)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
@ -97,11 +94,12 @@ def is_text_file(path):
|
||||
return ext.lower() in TEXT_EXTENSIONS
|
||||
|
||||
|
||||
def scan_content(content, file_path):
|
||||
def scan_content(content, file_path, patterns=None):
|
||||
"""扫描文件内容,返回发现的敏感信息列表"""
|
||||
findings = []
|
||||
active_patterns = list(SENSITIVE_PATTERNS) + (load_forbidden_identifiers() if patterns is None else list(patterns))
|
||||
for line_no, line in enumerate(content.split('\n'), 1):
|
||||
for pattern, name, replacement in SENSITIVE_PATTERNS:
|
||||
for pattern, name, replacement in active_patterns:
|
||||
matches = list(re.finditer(pattern, line, re.IGNORECASE))
|
||||
for m in matches:
|
||||
matched_text = m.group(0)
|
||||
@ -112,7 +110,6 @@ def scan_content(content, file_path):
|
||||
'file': file_path,
|
||||
'line': line_no,
|
||||
'pattern_name': name,
|
||||
'matched': matched_text[:80],
|
||||
'replacement': replacement(m) if callable(replacement) else replacement,
|
||||
})
|
||||
return findings
|
||||
@ -183,7 +180,7 @@ def main():
|
||||
old_rev, new_rev, ref_name = parts[0], parts[1], parts[2]
|
||||
|
||||
# 跳过删除的分支
|
||||
if new_rev == "GIT_TOKEN_REDACTED":
|
||||
if new_rev == "0" * 40:
|
||||
continue
|
||||
|
||||
# 持续记忆必须先通过结构门禁;服务器使用本次 receive 的精确提交范围,
|
||||
@ -201,16 +198,6 @@ def main():
|
||||
|
||||
# 获取新增/修改的文件列表
|
||||
try:
|
||||
# 先检查 commit message 是否含 [SEC-CLEAN] 标记
|
||||
if TRUST_SEC_CLEAN:
|
||||
msg_check = subprocess.run(
|
||||
["git", "log", "--format=%B", "-1", new_rev],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if msg_check.returncode == 0 and "[SEC-CLEAN]" in msg_check.stdout:
|
||||
# 信任客户端 pre-push-clean 插件 · 免检通过
|
||||
continue
|
||||
|
||||
diff_files = subprocess.run(
|
||||
["git", "diff-tree", "--no-commit-id", "-r", "--name-only",
|
||||
"--diff-filter=AM", old_rev, new_rev],
|
||||
@ -257,7 +244,7 @@ def main():
|
||||
for f in all_findings[:20]:
|
||||
print(f" 📄 {f['file']}:{f['line']}", file=sys.stderr)
|
||||
print(f" ⚠️ {f['pattern_name']}", file=sys.stderr)
|
||||
print(f" ✗ {f['matched'][:60]}", file=sys.stderr)
|
||||
print(" ✗ 命中值已隐藏,避免在终端和日志中二次泄露", file=sys.stderr)
|
||||
print(f" → 请替换为: {f['replacement']}", file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
MODULE_PATH = os.path.join(os.path.dirname(__file__), "pre-receive-guard.py")
|
||||
SPEC = importlib.util.spec_from_file_location("pre_receive_guard", MODULE_PATH)
|
||||
GUARD = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(GUARD)
|
||||
|
||||
|
||||
class PreReceiveGuardTests(unittest.TestCase):
|
||||
def test_private_identifiers_are_loaded_from_root_only_file(self):
|
||||
with tempfile.NamedTemporaryFile("w", delete=False) as handle:
|
||||
handle.write("private-owner-id\nowner@example.invalid\n")
|
||||
filename = handle.name
|
||||
try:
|
||||
patterns = GUARD.load_forbidden_identifiers(filename)
|
||||
findings = GUARD.scan_content("x private-owner-id y", "demo.txt", patterns=patterns)
|
||||
self.assertEqual(len(findings), 1)
|
||||
self.assertEqual(findings[0]["pattern_name"], "私密禁用标识")
|
||||
self.assertNotIn("private-owner-id", repr(findings[0]))
|
||||
finally:
|
||||
os.unlink(filename)
|
||||
|
||||
def test_security_clean_commit_marker_cannot_bypass_scanning(self):
|
||||
with open(MODULE_PATH, encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
self.assertNotIn("TRUST_SEC_CLEAN", source)
|
||||
self.assertNotIn('"[SEC-CLEAN]" in', source)
|
||||
|
||||
def test_repository_source_does_not_embed_owner_identifier(self):
|
||||
with open(MODULE_PATH, encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
self.assertNotRegex(source, r"\b\d{7,12}@qq\\\.com\b")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
41
zero-point/core-channel/revive-guard/repo-authorization-guard.py
Executable file
41
zero-point/core-channel/revive-guard/repo-authorization-guard.py
Executable file
@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed Forgejo pre-receive gate for Lake Lamp repo-push grants."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
GRANT_DIR = os.environ.get("REPO_AUTHORIZATION_DIR", "/var/lib/guanghu/repo-authorizations")
|
||||
|
||||
|
||||
def normalize_repo(value):
|
||||
value = value.strip().lower().removesuffix(".git")
|
||||
match = re.search(r"(?:gitea-repositories|repositories)/([^/]+/[^/]+)$", value)
|
||||
return match.group(1) if match else value
|
||||
|
||||
|
||||
def check(repo, now=None):
|
||||
now = time.time() if now is None else now
|
||||
repo = normalize_repo(repo)
|
||||
if not re.fullmatch(r"bingshuo/[a-z0-9._-]+", repo):
|
||||
return False, "repository_not_allowlisted"
|
||||
filename = os.path.join(GRANT_DIR, repo.replace("/", "__") + ".json")
|
||||
try:
|
||||
with open(filename, encoding="utf-8") as handle:
|
||||
grant = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return False, "repo_push_approval_required"
|
||||
if grant.get("repo") != repo or grant.get("target") != "JD-FD-PRIMARY":
|
||||
return False, "repo_push_grant_binding_mismatch"
|
||||
if now > float(grant.get("expires_at", 0)):
|
||||
return False, "repo_push_grant_expired"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
repo = os.environ.get("FORGEJO_REPO") or os.environ.get("GIT_DIR") or os.getcwd()
|
||||
ok, reason = check(repo)
|
||||
if not ok:
|
||||
print(f"小湖灯推送门已锁定: {reason}。请提交 repo-push 邮件工单。", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@ -0,0 +1,31 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
PATH = os.path.join(os.path.dirname(__file__), "repo-authorization-guard.py")
|
||||
SPEC = importlib.util.spec_from_file_location("repo_auth_guard", PATH)
|
||||
MOD = importlib.util.module_from_spec(SPEC); SPEC.loader.exec_module(MOD)
|
||||
|
||||
class RepoAuthorizationGuardTests(unittest.TestCase):
|
||||
def test_missing_expired_and_wrong_target_grants_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
old = MOD.GRANT_DIR; MOD.GRANT_DIR = directory
|
||||
try:
|
||||
self.assertEqual(MOD.check("bingshuo/fifth-domain", 100)[1], "repo_push_approval_required")
|
||||
file = os.path.join(directory, "bingshuo__fifth-domain.json")
|
||||
with open(file,"w") as handle: json.dump({"repo":"bingshuo/fifth-domain","target":"JD-FD-PRIMARY","expires_at":99}, handle)
|
||||
self.assertEqual(MOD.check("bingshuo/fifth-domain", 100)[1], "repo_push_grant_expired")
|
||||
with open(file,"w") as handle: json.dump({"repo":"bingshuo/fifth-domain","target":"OTHER","expires_at":200}, handle)
|
||||
self.assertEqual(MOD.check("bingshuo/fifth-domain", 100)[1], "repo_push_grant_binding_mismatch")
|
||||
finally: MOD.GRANT_DIR = old
|
||||
def test_current_bound_grant_passes(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
old = MOD.GRANT_DIR; MOD.GRANT_DIR = directory
|
||||
try:
|
||||
with open(os.path.join(directory,"bingshuo__fifth-domain.json"),"w") as handle: json.dump({"repo":"bingshuo/fifth-domain","target":"JD-FD-PRIMARY","expires_at":200}, handle)
|
||||
self.assertEqual(MOD.check("/var/lib/gitea/repositories/bingshuo/fifth-domain.git",100),(True,"ok"))
|
||||
finally: MOD.GRANT_DIR = old
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user