2026-07-24 10:39:10 +08:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
const fs = require("node:fs");
|
|
|
|
|
const net = require("node:net");
|
|
|
|
|
const path = require("node:path");
|
|
|
|
|
const { execFile } = require("node:child_process");
|
|
|
|
|
|
|
|
|
|
const SOCKET_PATH = process.env.LAKE_LAMP_OWNER_ACCESS_SOCKET || "/run/guanghu-owner-access/owner-access.sock";
|
|
|
|
|
const SSH_CONFIG = process.env.LAKE_LAMP_SSH_CONFIG || "/etc/guanghu/action-broker-ssh-config";
|
|
|
|
|
|
2026-07-27 14:33:03 +08:00
|
|
|
const INSPECT_OWNER_SSH_LOGIN = String.raw`set -eu
|
|
|
|
|
test "$(id -u)" = "0"
|
|
|
|
|
password=$(/usr/sbin/sshd -T | awk '$1 == "passwordauthentication" {print $2; exit}')
|
|
|
|
|
keyboard=$(/usr/sbin/sshd -T | awk '$1 == "kbdinteractiveauthentication" {print $2; exit}')
|
|
|
|
|
root_login=$(/usr/sbin/sshd -T | awk '$1 == "permitrootlogin" {print $2; exit}')
|
|
|
|
|
printf 'PASSWORD_AUTH=%s\nKBD_INTERACTIVE_AUTH=%s\nROOT_LOGIN=%s\nSSH=%s\n' \
|
|
|
|
|
"$password" "$keyboard" "$root_login" \
|
|
|
|
|
"$(systemctl is-active ssh 2>/dev/null || systemctl is-active sshd)"`;
|
|
|
|
|
|
|
|
|
|
const DISABLE_OWNER_PASSWORD_LOGIN = String.raw`set -eu
|
|
|
|
|
test "$(id -u)" = "0"
|
|
|
|
|
cfg=/etc/ssh/sshd_config.d/90-guanghu-key-only.conf
|
|
|
|
|
test -f "$cfg"
|
|
|
|
|
backup_dir=/root/guanghu-security-backup-$(date -u +%Y%m%dT%H%M%SZ)
|
|
|
|
|
mkdir -p "$backup_dir"
|
|
|
|
|
chmod 0700 "$backup_dir"
|
|
|
|
|
cp -a "$cfg" "$backup_dir/90-guanghu-key-only.conf.before"
|
|
|
|
|
rollback() {
|
|
|
|
|
cp -a "$backup_dir/90-guanghu-key-only.conf.before" "$cfg"
|
|
|
|
|
/usr/sbin/sshd -t
|
|
|
|
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
|
|
|
}
|
|
|
|
|
trap 'rc=$?; if [ "$rc" -ne 0 ]; then rollback; fi; exit "$rc"' EXIT
|
|
|
|
|
/usr/bin/python3 - "$cfg" <<'PY'
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import re, sys
|
|
|
|
|
|
|
|
|
|
path = Path(sys.argv[1])
|
|
|
|
|
text = path.read_text()
|
|
|
|
|
for directive, value in (
|
|
|
|
|
("PasswordAuthentication", "no"),
|
|
|
|
|
("KbdInteractiveAuthentication", "no"),
|
|
|
|
|
("PermitRootLogin", "prohibit-password"),
|
|
|
|
|
):
|
|
|
|
|
pattern = re.compile(rf"(?im)^\s*{directive}\s+\S+\s*$")
|
|
|
|
|
replacement = f"{directive} {value}"
|
|
|
|
|
if pattern.search(text):
|
|
|
|
|
text = pattern.sub(replacement, text)
|
|
|
|
|
else:
|
|
|
|
|
text = text.rstrip() + f"\n{replacement}\n"
|
|
|
|
|
path.write_text(text)
|
|
|
|
|
PY
|
|
|
|
|
/usr/sbin/sshd -t
|
|
|
|
|
password=$(/usr/sbin/sshd -T | awk '$1 == "passwordauthentication" {print $2; exit}')
|
|
|
|
|
keyboard=$(/usr/sbin/sshd -T | awk '$1 == "kbdinteractiveauthentication" {print $2; exit}')
|
|
|
|
|
root_login=$(/usr/sbin/sshd -T | awk '$1 == "permitrootlogin" {print $2; exit}')
|
|
|
|
|
test "$password" = "no"
|
|
|
|
|
test "$keyboard" = "no"
|
|
|
|
|
test "$root_login" != "yes"
|
|
|
|
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
|
|
|
printf 'PASSWORD_AUTH=%s\nKBD_INTERACTIVE_AUTH=%s\nROOT_LOGIN=%s\nSSH=%s\nBACKUP=%s\n' \
|
|
|
|
|
"$password" "$keyboard" "$root_login" \
|
|
|
|
|
"$(systemctl is-active ssh 2>/dev/null || systemctl is-active sshd)" \
|
|
|
|
|
"$backup_dir/90-guanghu-key-only.conf.before"
|
|
|
|
|
trap - EXIT`;
|
|
|
|
|
|
2026-07-24 10:39:10 +08:00
|
|
|
const RESTORE_OWNER_PASSWORD_LOGIN = String.raw`set -eu
|
|
|
|
|
test "$(id -u)" = "0"
|
|
|
|
|
cfg=/etc/ssh/sshd_config.d/90-guanghu-key-only.conf
|
|
|
|
|
test -f "$cfg"
|
|
|
|
|
backup_dir=/root/guanghu-security-backup-$(date -u +%Y%m%dT%H%M%SZ)
|
|
|
|
|
mkdir -p "$backup_dir"
|
|
|
|
|
chmod 0700 "$backup_dir"
|
|
|
|
|
cp -a "$cfg" "$backup_dir/90-guanghu-key-only.conf.before"
|
|
|
|
|
rollback() {
|
|
|
|
|
cp -a "$backup_dir/90-guanghu-key-only.conf.before" "$cfg"
|
|
|
|
|
/usr/sbin/sshd -t
|
|
|
|
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
|
|
|
}
|
|
|
|
|
trap 'rc=$?; if [ "$rc" -ne 0 ]; then rollback; fi; exit "$rc"' EXIT
|
|
|
|
|
/usr/bin/python3 - "$cfg" <<'PY'
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import re, sys
|
|
|
|
|
|
|
|
|
|
path = Path(sys.argv[1])
|
|
|
|
|
text = path.read_text()
|
|
|
|
|
pattern = re.compile(r"(?im)^\s*PasswordAuthentication\s+\S+\s*$")
|
|
|
|
|
if pattern.search(text):
|
|
|
|
|
text = pattern.sub("PasswordAuthentication yes", text)
|
|
|
|
|
else:
|
|
|
|
|
text = text.rstrip() + "\nPasswordAuthentication yes\n"
|
|
|
|
|
path.write_text(text)
|
|
|
|
|
PY
|
|
|
|
|
/usr/sbin/sshd -t
|
|
|
|
|
password=$(/usr/sbin/sshd -T | awk '$1 == "passwordauthentication" {print $2; exit}')
|
|
|
|
|
root_login=$(/usr/sbin/sshd -T | awk '$1 == "permitrootlogin" {print $2; exit}')
|
|
|
|
|
test "$password" = "yes"
|
|
|
|
|
test "$root_login" != "yes"
|
|
|
|
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
|
|
|
printf 'PASSWORD_AUTH=%s\nROOT_PASSWORD_AUTH=%s\nSSH=%s\nBACKUP=%s\n' \
|
|
|
|
|
"$password" "$root_login" \
|
|
|
|
|
"$(systemctl is-active ssh 2>/dev/null || systemctl is-active sshd)" \
|
|
|
|
|
"$backup_dir/90-guanghu-key-only.conf.before"
|
|
|
|
|
trap - EXIT`;
|
|
|
|
|
|
2026-07-26 15:02:01 +08:00
|
|
|
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({
|
2026-07-24 10:39:10 +08:00
|
|
|
ok: !error,
|
|
|
|
|
exit_code: error ? (Number.isInteger(error.code) ? error.code : 1) : 0,
|
|
|
|
|
stdout: String(stdout || "").slice(0, 100000),
|
|
|
|
|
stderr: String(stderr || "").slice(0, 10000),
|
|
|
|
|
})));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function execute(request) {
|
|
|
|
|
if (!request || request.cmd || request.command || request.shell || request.args) return { ok: false, error: "arbitrary_command_forbidden" };
|
2026-07-26 15:02:01 +08:00
|
|
|
if (request.target !== "JD-FD-PRIMARY") return { ok: false, error: "action_not_registered" };
|
|
|
|
|
const commands = {
|
2026-07-27 14:33:03 +08:00
|
|
|
"inspect-owner-ssh-login": INSPECT_OWNER_SSH_LOGIN,
|
|
|
|
|
"disable-owner-password-login": DISABLE_OWNER_PASSWORD_LOGIN,
|
2026-07-26 15:02:01 +08:00
|
|
|
"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);
|
2026-07-24 10:39:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function reply(socket, value) { socket.end(`${JSON.stringify(value)}\n`); }
|
|
|
|
|
|
|
|
|
|
if (require.main === module) {
|
|
|
|
|
fs.mkdirSync(path.dirname(SOCKET_PATH), { recursive: true, mode: 0o755 });
|
|
|
|
|
try { fs.unlinkSync(SOCKET_PATH); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
|
|
|
const server = net.createServer({ allowHalfOpen: true }, socket => {
|
|
|
|
|
let input = "";
|
|
|
|
|
socket.setTimeout(5000, () => socket.destroy());
|
|
|
|
|
socket.on("data", chunk => { input += chunk.toString("utf8"); if (input.length > 4096) socket.destroy(); });
|
|
|
|
|
socket.on("end", async () => {
|
|
|
|
|
let request;
|
|
|
|
|
try { request = JSON.parse(input); } catch { return reply(socket, { ok: false, error: "invalid_request" }); }
|
|
|
|
|
reply(socket, await execute(request));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
server.listen(SOCKET_PATH, () => {
|
|
|
|
|
fs.chownSync(SOCKET_PATH, 0, Number(process.env.LAKE_LAMP_AUTHZ_GID || 0));
|
|
|
|
|
fs.chmodSync(SOCKET_PATH, 0o660);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 15:02:01 +08:00
|
|
|
module.exports = {
|
|
|
|
|
execute,
|
2026-07-27 14:33:03 +08:00
|
|
|
INSPECT_OWNER_SSH_LOGIN,
|
|
|
|
|
DISABLE_OWNER_PASSWORD_LOGIN,
|
2026-07-26 15:02:01 +08:00
|
|
|
RESTORE_OWNER_PASSWORD_LOGIN,
|
|
|
|
|
RESTORE_CODE_CHANNEL_OWNER_LOGIN,
|
|
|
|
|
SOCKET_PATH,
|
|
|
|
|
};
|