[HLCC-ICE-000003] fix: recover code-channel owner login safely
This commit is contained in:
parent
928a6ddb2c
commit
100848e9e4
12 changed files with 235 additions and 16 deletions
|
|
@ -23,12 +23,23 @@ test("canonical AI and persona entry files route to the domestic map", () => {
|
|||
test("code map resolves REPO-001 to domestic and labels the Singapore route legacy", () => {
|
||||
const codeMap = read(".code-map");
|
||||
assert.match(codeMap, /^REPO-001=https:\/\/guanghulab\.com\/fifth-domain\/bingshuo\/fifth-domain\.git$/m);
|
||||
assert.match(codeMap, /^REPO-012=https:\/\/guanghulab\.com\/code\/bingshuo\/guanghu-ice-heart\.git$/m);
|
||||
assert.match(codeMap, /^REPO-001-LEGACY-SG=/m);
|
||||
assert.match(codeMap, /^FD-REPO-MAP-001=routing\/repository-route-map\.json$/m);
|
||||
assert.match(codeMap, /^FD-NODE-MAP-001=routing\/server-node-map\.json$/m);
|
||||
assert.match(codeMap, /^ZY-OPS-LOOP-001=.*zhuyuan-persona-system\//m);
|
||||
});
|
||||
|
||||
test("repository map exposes the current code channel as a public domestic route", () => {
|
||||
const map = JSON.parse(read("routing/repository-route-map.json"));
|
||||
const channel = map.repositories.find(item => item.code === "REPO-012");
|
||||
assert.equal(channel.slug, "guanghu-ice-heart");
|
||||
assert.equal(channel.state, "DOMESTIC_CODE_CHANNEL_PRIMARY");
|
||||
assert.equal(channel.primary.url, "https://guanghulab.com/code/bingshuo/guanghu-ice-heart");
|
||||
assert.equal(channel.primary.clone_url, "https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git");
|
||||
assert.match(JSON.stringify(channel.keywords), /公开读取/);
|
||||
});
|
||||
|
||||
test("Zhuyuan current chain resolves to the domestic node without secrets", () => {
|
||||
const nodeMap = JSON.parse(read("routing/server-node-map.json"));
|
||||
assert.equal(nodeMap.map_id, "FD-NODE-MAP-001");
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ const { createServer, loadMap, loadNodeMap, search, searchAll } = require("./ser
|
|||
|
||||
test("repository map has unique sequential codes and domestic primary routes", () => {
|
||||
const map = loadMap();
|
||||
assert.equal(map.repositories.length, 11);
|
||||
assert.deepEqual(map.repositories.map(item => item.code), Array.from({ length: 11 }, (_, index) => `REPO-${String(index + 1).padStart(3, "0")}`));
|
||||
assert.equal(new Set(map.repositories.map(item => item.code)).size, 11);
|
||||
for (const item of map.repositories) assert.match(item.primary.url, /^https:\/\/guanghulab\.com\/fifth-domain\//);
|
||||
assert.equal(map.repositories.length, 12);
|
||||
assert.deepEqual(map.repositories.map(item => item.code), Array.from({ length: 12 }, (_, index) => `REPO-${String(index + 1).padStart(3, "0")}`));
|
||||
assert.equal(new Set(map.repositories.map(item => item.code)).size, 12);
|
||||
for (const item of map.repositories) assert.match(item.primary.url, /^https:\/\/guanghulab\.com\/(?:fifth-domain|code)\//);
|
||||
});
|
||||
|
||||
test("Chinese language-world query resolves the Fifth Domain primary", () => {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,12 @@ node server-tools/lake-lamp-authz/authorize-repo-push.js \
|
|||
|
||||
运行入口:公开建单 `/api/public/workorders`,会话续签 `/api/session/renew`,登记动作执行 `/api/actions/execute`。多节点需求由人格体按当前任务拆成并行申请,不再使用固定“三封邮件”作为协作规则。
|
||||
|
||||
## 登录恢复动作不得混用
|
||||
|
||||
- `restore-owner-password-login` 只恢复京东服务器的 SSH 密码认证开关;它不读取、不重置,也不验证光湖代码频道账号。
|
||||
- `restore-code-channel-owner-login` 只把京东本机旧第五域数据库中的 `bingshuo` 密码摘要恢复到新代码频道,同时校正启用、管理员和禁止登录状态。它不读取明文密码,不修改 SSH,并在变更前使用 SQLite backup API 生成一致性备份。
|
||||
- 登录问题先执行 `inspect-code-channel-owner-auth`。只有确认新旧频道凭证不一致时,才申请后一项恢复动作;不得绕到新加坡灾备节点,不得向冰朔索要密码。
|
||||
|
||||
## 新架构首次部署
|
||||
|
||||
旧的 `deploy-registered-service` 只能操作已经登记的服务,不能承担首次安装。新架构统一使用固定动作 `provision-approved-architecture`,并把仓库请求编号与不可变提交绑定进工单:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ const OWNER_ACCESS_SOCKET_PATH = process.env.LAKE_LAMP_OWNER_ACCESS_SOCKET || "/
|
|||
const ARCHITECTURE_PROVISION_SOCKET_PATH = process.env.LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET || "/run/guanghu-architecture-provision/provision.sock";
|
||||
|
||||
function executeRegisteredAction(request, socketPath) {
|
||||
const selectedSocket = socketPath || (request.action === "restore-owner-password-login" ? OWNER_ACCESS_SOCKET_PATH : request.action === "provision-approved-architecture" ? ARCHITECTURE_PROVISION_SOCKET_PATH : SOCKET_PATH);
|
||||
const ownerAccessActions = new Set([
|
||||
"restore-owner-password-login",
|
||||
"restore-code-channel-owner-login",
|
||||
]);
|
||||
const selectedSocket = socketPath || (ownerAccessActions.has(request.action) ? OWNER_ACCESS_SOCKET_PATH : request.action === "provision-approved-architecture" ? ARCHITECTURE_PROVISION_SOCKET_PATH : SOCKET_PATH);
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection(selectedSocket);
|
||||
let response = "";
|
||||
|
|
|
|||
|
|
@ -47,8 +47,119 @@ printf 'PASSWORD_AUTH=%s\nROOT_PASSWORD_AUTH=%s\nSSH=%s\nBACKUP=%s\n' \
|
|||
"$backup_dir/90-guanghu-key-only.conf.before"
|
||||
trap - EXIT`;
|
||||
|
||||
function run(file, args) {
|
||||
return new Promise(resolve => execFile(file, args, { timeout: 30000, maxBuffer: 100000 }, (error, stdout, stderr) => resolve({
|
||||
const RESTORE_CODE_CHANNEL_OWNER_LOGIN = String.raw`set -eu
|
||||
test "$(id -u)" = "0"
|
||||
/usr/bin/python3 - <<'PY'
|
||||
import atexit
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
state = pathlib.Path("/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1")
|
||||
channel_db = state / "data/hlcc.db"
|
||||
legacy_db = pathlib.Path("/var/lib/guanghu/forgejo/data/forgejo.db")
|
||||
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
backup = state / "data/backups" / f"owner-login-recovery-{stamp}.db"
|
||||
|
||||
service_started = False
|
||||
def ensure_service_started():
|
||||
if not service_started:
|
||||
subprocess.run(["systemctl", "start", "hlcc-jd-candidate.service"])
|
||||
|
||||
atexit.register(ensure_service_started)
|
||||
subprocess.run(["systemctl", "stop", "hlcc-jd-candidate.service"], check=True)
|
||||
channel = sqlite3.connect(channel_db, timeout=30)
|
||||
snapshot = sqlite3.connect(backup, timeout=30)
|
||||
try:
|
||||
channel.execute("pragma wal_checkpoint(truncate)")
|
||||
channel.backup(snapshot)
|
||||
finally:
|
||||
snapshot.close()
|
||||
os.chmod(backup, 0o600)
|
||||
|
||||
legacy = sqlite3.connect(f"file:{legacy_db}?mode=ro", uri=True, timeout=15)
|
||||
try:
|
||||
credential = legacy.execute(
|
||||
"select passwd,salt,passwd_hash_algo from user where lower_name=?",
|
||||
("bingshuo",),
|
||||
).fetchone()
|
||||
finally:
|
||||
legacy.close()
|
||||
if not credential or not all(credential):
|
||||
raise RuntimeError("legacy owner credential unavailable")
|
||||
|
||||
try:
|
||||
channel.execute("begin immediate")
|
||||
changed = channel.execute(
|
||||
"""update user
|
||||
set passwd=?,salt=?,passwd_hash_algo=?,is_active=1,is_admin=1,
|
||||
prohibit_login=0,must_change_password=0,updated_unix=?
|
||||
where lower_name=?""",
|
||||
(*credential, int(time.time()), "bingshuo"),
|
||||
).rowcount
|
||||
if changed != 1:
|
||||
raise RuntimeError("channel owner identity mismatch")
|
||||
channel.commit()
|
||||
copied = channel.execute(
|
||||
"""select passwd,salt,passwd_hash_algo,is_active,is_admin,
|
||||
prohibit_login,must_change_password
|
||||
from user where lower_name=?""",
|
||||
("bingshuo",),
|
||||
).fetchone()
|
||||
if copied[:3] != credential or copied[3:] != (1, 1, 0, 0):
|
||||
raise RuntimeError("owner login recovery verification failed")
|
||||
if channel.execute("pragma integrity_check").fetchone()[0] != "ok":
|
||||
raise RuntimeError("channel database integrity check failed")
|
||||
channel.execute("pragma wal_checkpoint(truncate)")
|
||||
finally:
|
||||
channel.close()
|
||||
|
||||
subprocess.run(["systemctl", "start", "hlcc-jd-candidate.service"], check=True)
|
||||
service_started = True
|
||||
status = {}
|
||||
for _ in range(90):
|
||||
try:
|
||||
with urllib.request.urlopen("http://127.0.0.1:3341/health", timeout=2) as response:
|
||||
status = json.load(response)
|
||||
with urllib.request.urlopen("http://127.0.0.1:3340/api/healthz", timeout=2) as response:
|
||||
app = json.load(response)
|
||||
if status.get("ready") is True and app.get("status") == "pass":
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
else:
|
||||
subprocess.run(["systemctl", "stop", "hlcc-jd-candidate.service"])
|
||||
service_started = False
|
||||
restored = sqlite3.connect(channel_db, timeout=30)
|
||||
saved = sqlite3.connect(f"file:{backup}?mode=ro", uri=True, timeout=30)
|
||||
try:
|
||||
saved.backup(restored)
|
||||
finally:
|
||||
saved.close()
|
||||
restored.close()
|
||||
subprocess.run(["systemctl", "start", "hlcc-jd-candidate.service"], check=True)
|
||||
service_started = True
|
||||
raise RuntimeError("channel health failed; database restored from backup")
|
||||
|
||||
print(json.dumps({
|
||||
"ACCOUNT": "bingshuo",
|
||||
"CREDENTIAL_SOURCE": "legacy-local-db",
|
||||
"ACTIVE": True,
|
||||
"ADMIN": True,
|
||||
"PROHIBIT_LOGIN": False,
|
||||
"SERVICE": "ready",
|
||||
"BACKUP": str(backup),
|
||||
}, ensure_ascii=False))
|
||||
PY`;
|
||||
|
||||
function run(file, args, timeout = 30000) {
|
||||
return new Promise(resolve => execFile(file, args, { timeout, maxBuffer: 100000 }, (error, stdout, stderr) => resolve({
|
||||
ok: !error,
|
||||
exit_code: error ? (Number.isInteger(error.code) ? error.code : 1) : 0,
|
||||
stdout: String(stdout || "").slice(0, 100000),
|
||||
|
|
@ -58,8 +169,14 @@ function run(file, args) {
|
|||
|
||||
async function execute(request) {
|
||||
if (!request || request.cmd || request.command || request.shell || request.args) return { ok: false, error: "arbitrary_command_forbidden" };
|
||||
if (request.target !== "JD-FD-PRIMARY" || request.action !== "restore-owner-password-login") return { ok: false, error: "action_not_registered" };
|
||||
return run("/usr/bin/ssh", ["-F", SSH_CONFIG, "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "enterprise-lighthouse", RESTORE_OWNER_PASSWORD_LOGIN]);
|
||||
if (request.target !== "JD-FD-PRIMARY") return { ok: false, error: "action_not_registered" };
|
||||
const commands = {
|
||||
"restore-owner-password-login": RESTORE_OWNER_PASSWORD_LOGIN,
|
||||
"restore-code-channel-owner-login": RESTORE_CODE_CHANNEL_OWNER_LOGIN,
|
||||
};
|
||||
if (!commands[request.action]) return { ok: false, error: "action_not_registered" };
|
||||
const timeout = request.action === "restore-code-channel-owner-login" ? 150000 : 30000;
|
||||
return run("/usr/bin/ssh", ["-F", SSH_CONFIG, "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "enterprise-lighthouse", commands[request.action]], timeout);
|
||||
}
|
||||
|
||||
function reply(socket, value) { socket.end(`${JSON.stringify(value)}\n`); }
|
||||
|
|
@ -83,4 +200,9 @@ if (require.main === module) {
|
|||
});
|
||||
}
|
||||
|
||||
module.exports = { execute, RESTORE_OWNER_PASSWORD_LOGIN, SOCKET_PATH };
|
||||
module.exports = {
|
||||
execute,
|
||||
RESTORE_OWNER_PASSWORD_LOGIN,
|
||||
RESTORE_CODE_CHANNEL_OWNER_LOGIN,
|
||||
SOCKET_PATH,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,13 +2,25 @@
|
|||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { execute, RESTORE_OWNER_PASSWORD_LOGIN } = require("./owner-access-broker");
|
||||
const {
|
||||
execute,
|
||||
RESTORE_OWNER_PASSWORD_LOGIN,
|
||||
RESTORE_CODE_CHANNEL_OWNER_LOGIN,
|
||||
} = require("./owner-access-broker");
|
||||
|
||||
test("owner-access executor exposes one bounded recovery action", async () => {
|
||||
test("owner-access executor keeps SSH and code-channel recovery distinct", async () => {
|
||||
assert.match(RESTORE_OWNER_PASSWORD_LOGIN, /PasswordAuthentication yes/);
|
||||
assert.match(RESTORE_OWNER_PASSWORD_LOGIN, /sshd -t/);
|
||||
assert.match(RESTORE_OWNER_PASSWORD_LOGIN, /systemctl reload ssh/);
|
||||
assert.doesNotMatch(RESTORE_OWNER_PASSWORD_LOGIN, /PermitRootLogin yes/);
|
||||
assert.match(RESTORE_CODE_CHANNEL_OWNER_LOGIN, /legacy owner credential unavailable/);
|
||||
assert.match(RESTORE_CODE_CHANNEL_OWNER_LOGIN, /pragma wal_checkpoint\(truncate\)/);
|
||||
assert.match(RESTORE_CODE_CHANNEL_OWNER_LOGIN, /channel\.backup\(snapshot\)/);
|
||||
assert.match(RESTORE_CODE_CHANNEL_OWNER_LOGIN, /prohibit_login=0/);
|
||||
assert.match(RESTORE_CODE_CHANNEL_OWNER_LOGIN, /hlcc-jd-candidate\.service/);
|
||||
assert.doesNotMatch(RESTORE_CODE_CHANNEL_OWNER_LOGIN, /PasswordAuthentication/);
|
||||
assert.match(require("node:fs").readFileSync(require.resolve("./owner-access-broker"), "utf8"), /150000/);
|
||||
assert.deepEqual(await execute({ target: "JD-FD-PRIMARY", action: "unknown" }), { ok: false, error: "action_not_registered" });
|
||||
assert.deepEqual(await execute({ target: "JD-FD-PRIMARY", action: "restore-owner-password-login", cmd: "id" }), { ok: false, error: "arbitrary_command_forbidden" });
|
||||
assert.deepEqual(await execute({ target: "JD-FD-PRIMARY", action: "restore-code-channel-owner-login", shell: "id" }), { ok: false, error: "arbitrary_command_forbidden" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const DEFAULT_ACTIONS = Object.freeze({
|
|||
"provision-approved-architecture",
|
||||
"push-repository",
|
||||
"restore-owner-password-login",
|
||||
"restore-code-channel-owner-login",
|
||||
],
|
||||
"repo-push": ["read-navigation-map", "push-repository"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ test("one server-ops approval exposes the full registered three-hour operation s
|
|||
"provision-approved-architecture",
|
||||
"push-repository",
|
||||
"restore-owner-password-login",
|
||||
"restore-code-channel-owner-login",
|
||||
]);
|
||||
} finally {
|
||||
await new Promise(resolve => app.close(resolve));
|
||||
|
|
|
|||
Loading…
Reference in a new issue