feat: 接入苍耳家庭服务器运维与广播授权入口
This commit is contained in:
parent
6d6bf9b215
commit
b086c5e358
49
CANGER-REMOTE-OPS-ENTRY.hdlp
Normal file
49
CANGER-REMOTE-OPS-ENTRY.hdlp
Normal file
@ -0,0 +1,49 @@
|
||||
# CANGER-REMOTE-OPS-ENTRY · 苍耳服务器运维入口
|
||||
|
||||
> HLDP://cang-ying/CANGER-REMOTE-OPS-ENTRY
|
||||
> 状态: ACTIVE_ENTRY · 2026-07-29
|
||||
> 仓主与最终批准者: 苍耳
|
||||
|
||||
```text
|
||||
trigger:
|
||||
人格体需要检查、开发、调试或维护苍耳家庭 Ubuntu
|
||||
|
||||
entry:
|
||||
remote-node/persona/README.md
|
||||
|
||||
load_order:
|
||||
1. remote-node/persona/DEVELOPMENT-RECEIPT-20260729.md
|
||||
2. remote-node/persona/WORKORDER-GUIDE.md
|
||||
3. remote-node/config/pufferfish-persona.json
|
||||
|
||||
workorder:
|
||||
requester_name: "谁申请就写谁"
|
||||
requester_id: "申请者自己的正式编号"
|
||||
purpose: "本次准备做什么"
|
||||
duration: "本次希望打开的受控时段"
|
||||
|
||||
approval:
|
||||
authority: 苍耳
|
||||
rule: "申请者不能批准自己;苍耳收到通知后自行决定是否点击"
|
||||
|
||||
route:
|
||||
申请者
|
||||
→ 新加坡语言主控广播塔
|
||||
→ 第五域语言主控广播塔
|
||||
→ 苍耳审批频道
|
||||
→ 苍耳邮箱
|
||||
→ 苍耳批准
|
||||
→ 同一申请者获得限时会话
|
||||
|
||||
current:
|
||||
home_node_connected: true
|
||||
local_request_entry_ready: true
|
||||
singapore_to_fifth_domain_broadcast_bridge: live_verified
|
||||
canger_email_delivery_live_verified: true
|
||||
latest_test_workorder: bf72f16b-f81e-4de5-b0aa-aaee3d39e04c
|
||||
latest_test_state: approved_not_claimed
|
||||
automatic_session_activation_after_approval: not_yet_implemented
|
||||
```
|
||||
|
||||
不得把第五域的 `approved` 邮件回执写成新加坡已经领取会话;只有取得领取回执并在
|
||||
家庭节点生成限时授权后,才能写成“本地权限已激活”。
|
||||
195
remote-node/README.md
Normal file
195
remote-node/README.md
Normal file
@ -0,0 +1,195 @@
|
||||
# 苍耳本地 Linux 节点接入
|
||||
|
||||
这个模块把苍耳家里的 Ubuntu 接到新加坡大脑服务器,但不把家里的电脑暴露到公网。
|
||||
|
||||
## 工作方式
|
||||
|
||||
1. 新加坡控制端运行在 `127.0.0.1:8787`,由现有 HTTPS 网关转发。
|
||||
2. 苍耳节点首次使用一次性配对码登记,长期节点令牌只保存在本机 `0600` 配置中。
|
||||
3. `systemd` 开机自动启动节点;连接只能从家里向新加坡主动发起。
|
||||
4. 人格体创建任务后,任务保持 `pending`;苍耳/冰朔在新加坡端批准后才会下发。
|
||||
5. 节点执行白名单动作并回传退出码、输出和耗时。
|
||||
6. 执行期间持续回传 `started`、分段输出和 `finished` 事件,新加坡端可以
|
||||
实时观察,不必等任务结束才看见错误。
|
||||
|
||||
## 默认安全边界
|
||||
|
||||
- 家里路由器不做端口转发,不开放 SSH 到公网。
|
||||
- 控制端默认只监听环回地址,公网入口必须使用 HTTPS。
|
||||
- 不接受 Shell 字符串,只接受结构化动作。
|
||||
- 安装程序自动建立 `/srv/canger` 受管工作区,并用没有登录能力的
|
||||
`canger-agent` 系统账户执行任务。
|
||||
- 编程命令受可执行文件白名单、受管工作区、超时和授权时段共同限制;
|
||||
Agent 没有 `sudo` 权限。
|
||||
- `repo.fetch` 只更新远端引用,不自动 merge、reset 或覆盖本地修改。
|
||||
- 每个任务和结果都写入 SQLite 审计记录。
|
||||
|
||||
## 新加坡端部署
|
||||
|
||||
准备 `/etc/canger-control.env`,四个令牌分别代表:
|
||||
|
||||
- `ADMIN`:创建设备配对码;
|
||||
- `OWNER`:苍耳主控批准、拒绝或撤销副控权限;
|
||||
- `PERSONA`:苍耳人格体读取任务、结果和执行上下文;
|
||||
- `OPERATOR`:冰朔与铸渊副控创建运维会话、任务并读取实时回执。
|
||||
|
||||
实际职责为:
|
||||
|
||||
- 苍耳本人:服务器主控、需求方和最终决策者;
|
||||
- 冰朔与 `ICE-GL-ZY001` 铸渊:受苍耳授权的运维副控;
|
||||
- 每张工单的实际申请人格体:如实填写名称和正式编号;
|
||||
- 本地 Agent:无独立判断权的受限物理执行器;
|
||||
- 苍耳本人可随时批准、拒绝或撤销数小时副控运维会话。
|
||||
|
||||
安装 `systemd/canger-control.service`,并把
|
||||
`nginx/canger-node.conf.example` 接到已有 TLS 站点。不要直接把 8787 端口开放公网。
|
||||
|
||||
## 首次配对
|
||||
|
||||
新加坡端创建十分钟有效的一次性码:
|
||||
|
||||
```bash
|
||||
python3 tools/cangerctl.py pair-code
|
||||
```
|
||||
|
||||
把整个 `remote-node` 目录交给苍耳。他在 Ubuntu 上只执行一次:
|
||||
|
||||
```bash
|
||||
sudo ./install/install-node.sh \
|
||||
https://guanghubingshuo.com/canger-node \
|
||||
XXXX-XXXX-XXXX \
|
||||
canger-home-ubuntu
|
||||
```
|
||||
|
||||
冰朔交付给苍耳的专用包可直接使用简化入口:
|
||||
|
||||
```bash
|
||||
sudo ./install/苍耳一键安装.sh XXXX-XXXX-XXXX
|
||||
```
|
||||
|
||||
配对码十分钟失效,因此安装包可以提前发送,配对码应在苍耳准备执行时再生成。
|
||||
|
||||
安装脚本会完成登记并启用开机自启动。以后不再需要登录本地终端。
|
||||
|
||||
安装时还会生成只读环境报告:
|
||||
|
||||
```text
|
||||
/var/lib/canger-node/preflight.json
|
||||
```
|
||||
|
||||
报告会识别 systemd、虚拟化环境、磁盘文件系统和可能存在的 Windows
|
||||
双启动,但不会修改分区、引导项或另一个操作系统。
|
||||
|
||||
## 双系统边界
|
||||
|
||||
真正的双系统切换会关闭当前操作系统。因此:
|
||||
|
||||
- 启动 Ubuntu 时,本节点会自动运行并连接新加坡;
|
||||
- 切到另一个操作系统后,Ubuntu 服务不可能继续运行;
|
||||
- 若希望“电脑开机就总有一个节点在线”,另一个系统也需要安装配套节点;
|
||||
- 若希望“Ubuntu 服务器本身始终在线”,需要让 Ubuntu 成为宿主机并把另一个
|
||||
系统放进虚拟机,或者使用独立服务器。
|
||||
|
||||
安装程序不会自动改造分区、引导和虚拟机。这类操作可能导致数据丢失,必须在
|
||||
确认硬件、另一个系统和磁盘结构后单独处理。
|
||||
|
||||
## 从新加坡端操作
|
||||
|
||||
查看节点:
|
||||
|
||||
```bash
|
||||
python3 tools/cangerctl.py nodes
|
||||
```
|
||||
|
||||
人格体创建任务:
|
||||
|
||||
```bash
|
||||
python3 tools/cangerctl.py task \
|
||||
--node node_xxx \
|
||||
--action system.status
|
||||
```
|
||||
|
||||
正常开发不需要逐任务批准。人格体先申请 1–8 小时的授权时段:
|
||||
|
||||
```bash
|
||||
python3 tools/cangerctl.py grant \
|
||||
--node node_xxx \
|
||||
--hours 4
|
||||
```
|
||||
|
||||
系统自动读取节点登记的安全能力,向苍耳主控邮箱发送人话版批准链接。
|
||||
主人只看目的和时长后点一次批准,不需要填写命令、目录或权限。授权时段内,
|
||||
匹配固定安全边界的任务自动批准。所有任务仍保留独立审计记录。
|
||||
|
||||
查看与撤销授权:
|
||||
|
||||
```bash
|
||||
python3 tools/cangerctl.py grants
|
||||
python3 tools/cangerctl.py revoke grant_xxx
|
||||
```
|
||||
|
||||
邮件中的链接只用于某一项授权申请,30 分钟失效;打开链接后还需要在确认页
|
||||
点击批准,避免邮件安全扫描器误触发。SMTP 密码、主人邮箱和签名密钥只放在
|
||||
新加坡服务器私有环境文件中,不写入仓库或安装包。
|
||||
|
||||
批准任务:
|
||||
|
||||
```bash
|
||||
python3 tools/cangerctl.py approve task_xxx
|
||||
```
|
||||
|
||||
读取回执:
|
||||
|
||||
```bash
|
||||
python3 tools/cangerctl.py show task_xxx
|
||||
```
|
||||
|
||||
实时观察执行过程:
|
||||
|
||||
```bash
|
||||
python3 tools/cangerctl.py watch task_xxx
|
||||
```
|
||||
|
||||
## 苍耳人格体代码仓库入口
|
||||
|
||||
苍耳人格体不使用副控令牌。它从仓库固定门牌
|
||||
`persona/README.md` 进入,并使用独立的 `CANGER_PERSONA_TOKEN`:
|
||||
|
||||
```bash
|
||||
python3 tools/canger_persona.py request-access \
|
||||
--requester-name "申请者名称" \
|
||||
--requester-id "申请者正式编号" \
|
||||
--hours 4 \
|
||||
--reason "本次开发、诊断或修复目的"
|
||||
```
|
||||
|
||||
申请回执的 `requester_name` 和 `requested_by` 必须与本次实际申请者一致。人格体可以申请、创建任务和读取结果,
|
||||
但不能批准、拒绝或撤销授权;批准权继续属于苍耳主控。固定节点和安全边界登记在
|
||||
`config/pufferfish-persona.json`,令牌仍只存在新加坡私有环境文件中。
|
||||
|
||||
完整流程见 `persona/WORKORDER-GUIDE.md`;当天开发回执见
|
||||
`persona/DEVELOPMENT-RECEIPT-20260729.md`。
|
||||
|
||||
## 智能体与模型 API
|
||||
|
||||
本地 Agent 是执行、观察和审计端,不直接保存大模型供应商的 API 密钥。
|
||||
运维判断由冰朔与铸渊副控在新加坡大脑服务器完成:
|
||||
|
||||
```text
|
||||
苍耳提出需求并授权
|
||||
↓
|
||||
冰朔与铸渊副控 → 创建运维任务 → 苍耳人格体/本地 Agent 协作执行
|
||||
← 实时事件、错误和回执 ←
|
||||
```
|
||||
|
||||
如需调用大模型,只把供应商 API 密钥放在新加坡服务器的密钥环境中。本地节点
|
||||
只持有可撤销、仅能代表该节点的身份令牌。禁止把大模型 API 密钥写进安装包、
|
||||
仓库、命令行或配对文件。
|
||||
|
||||
## 动作
|
||||
|
||||
- `system.status`:主机、系统、在线时间、根分区状态。
|
||||
- `repo.status`:只读 Git 状态。
|
||||
- `repo.fetch`:获取远端引用,不合并。
|
||||
- `service.logs`:读取指定 systemd 服务日志。
|
||||
- `command.run`:结构化命令执行;只在受管工作区内、以受限系统账户运行。
|
||||
20
remote-node/config/control.env.example
Normal file
20
remote-node/config/control.env.example
Normal file
@ -0,0 +1,20 @@
|
||||
CANGER_BIND=127.0.0.1
|
||||
CANGER_PORT=8787
|
||||
CANGER_CONTROL_DB=/var/lib/canger-control/control.sqlite3
|
||||
CANGER_ADMIN_TOKEN=replace-with-at-least-24-random-characters
|
||||
CANGER_OWNER_TOKEN=replace-with-at-least-24-random-characters
|
||||
CANGER_PERSONA_TOKEN=replace-with-at-least-24-random-characters
|
||||
CANGER_OPERATOR_TOKEN=replace-with-at-least-24-random-characters
|
||||
CANGER_OPERATOR_ID=ICE-GL-ZY001
|
||||
CANGER_OWNER_ID=CANGER-HUMAN-OWNER
|
||||
CANGER_EXECUTOR_PERSONA_ID=ICE-GL-CA001
|
||||
CANGER_APPROVAL_SIGNING_KEY=replace-with-at-least-32-random-characters
|
||||
CANGER_OWNER_EMAIL=owner@example.invalid
|
||||
CANGER_PUBLIC_URL=https://guanghulab.com/canger-node
|
||||
# SMTP credentials stay only on the Singapore server. Never put them in Git.
|
||||
CANGER_SMTP_HOST=smtp.example.invalid
|
||||
CANGER_SMTP_PORT=465
|
||||
CANGER_SMTP_FROM=guanghu-router@example.invalid
|
||||
CANGER_SMTP_USERNAME=replace-on-server
|
||||
CANGER_SMTP_PASSWORD=replace-on-server
|
||||
CANGER_CONTROL_URL=http://127.0.0.1:8787
|
||||
26
remote-node/config/node.example.json
Normal file
26
remote-node/config/node.example.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"server_url": "https://guanghulab.com/canger-node",
|
||||
"node_name": "canger-home-ubuntu",
|
||||
"node_id": null,
|
||||
"node_token": null,
|
||||
"allowed_roots": [
|
||||
"/srv/canger"
|
||||
],
|
||||
"enabled_actions": [
|
||||
"system.status",
|
||||
"repo.status",
|
||||
"repo.fetch",
|
||||
"service.logs",
|
||||
"command.run"
|
||||
],
|
||||
"executable_allowlist": [
|
||||
"git",
|
||||
"python3",
|
||||
"pytest",
|
||||
"node",
|
||||
"npm",
|
||||
"pnpm"
|
||||
],
|
||||
"poll_seconds": 5,
|
||||
"verify_tls": true
|
||||
}
|
||||
14
remote-node/config/pufferfish-persona.json
Normal file
14
remote-node/config/pufferfish-persona.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"profile": "pufferfish-persona-operations/v0.1",
|
||||
"subsystem_id": "SYS-CE",
|
||||
"requester_identity_mode": "explicit_name_and_registered_id_per_request",
|
||||
"human_owner": "苍耳",
|
||||
"node_id": "node_1cfab77579a3e9018f7a9037",
|
||||
"node_name": "canger-home-ubuntu-live-20260729",
|
||||
"control_url": "http://127.0.0.1:8797",
|
||||
"default_hours": 4,
|
||||
"minimum_hours": 1,
|
||||
"maximum_hours": 8,
|
||||
"approval_authority": "CANGER-HUMAN-OWNER",
|
||||
"managed_root": "/srv/canger"
|
||||
}
|
||||
107
remote-node/install/install-node.sh
Executable file
107
remote-node/install/install-node.sh
Executable file
@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "请用 sudo 执行此脚本。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$#" -ne 3 ]]; then
|
||||
echo "用法: $0 <server-url> <pair-code> <node-name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
server_url="$1"
|
||||
pair_code="$2"
|
||||
node_name="$3"
|
||||
|
||||
if ! id canger-agent >/dev/null 2>&1; then
|
||||
useradd --system --home-dir /var/lib/canger-node --create-home \
|
||||
--shell /usr/sbin/nologin canger-agent
|
||||
fi
|
||||
|
||||
install -d -m 0755 /opt/canger-node
|
||||
install -d -o canger-agent -g canger-agent -m 0700 /etc/canger-node
|
||||
install -d -o canger-agent -g canger-agent -m 0750 /var/lib/canger-node
|
||||
install -d -o canger-agent -g canger-agent -m 0750 /srv/canger
|
||||
install -m 0755 "$(dirname "$0")/../node/canger_node.py" /opt/canger-node/canger_node.py
|
||||
install -m 0755 "$(dirname "$0")/preflight-linux.py" /opt/canger-node/preflight-linux.py
|
||||
install -m 0644 "$(dirname "$0")/../systemd/canger-node.service" /etc/systemd/system/canger-node.service
|
||||
|
||||
already_paired=false
|
||||
if [[ -f /etc/canger-node/config.json ]] && \
|
||||
runuser -u canger-agent -- python3 - /etc/canger-node/config.json <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
raise SystemExit(0 if config.get("node_id") and config.get("node_token") else 1)
|
||||
PY
|
||||
then
|
||||
already_paired=true
|
||||
fi
|
||||
|
||||
if [[ "${already_paired}" == "false" ]]; then
|
||||
python3 - "$server_url" "$node_name" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import sys
|
||||
|
||||
server_url, node_name = sys.argv[1:]
|
||||
config = {
|
||||
"server_url": server_url,
|
||||
"node_name": node_name,
|
||||
"node_id": None,
|
||||
"node_token": None,
|
||||
"allowed_roots": ["/srv/canger"],
|
||||
"enabled_actions": [
|
||||
"system.status",
|
||||
"repo.status",
|
||||
"repo.fetch",
|
||||
"service.logs",
|
||||
"command.run",
|
||||
],
|
||||
"executable_allowlist": ["git", "python3", "pytest", "node", "npm", "pnpm"],
|
||||
"poll_seconds": 5,
|
||||
"verify_tls": True,
|
||||
}
|
||||
path = "/etc/canger-node/config.json"
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(config, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
os.chmod(path, 0o600)
|
||||
account = pwd.getpwnam("canger-agent")
|
||||
os.chown(path, account.pw_uid, account.pw_gid)
|
||||
PY
|
||||
else
|
||||
echo "检测到现有有效节点身份,保留原身份并跳过重复配对。"
|
||||
fi
|
||||
|
||||
/usr/bin/python3 /opt/canger-node/preflight-linux.py \
|
||||
--output /var/lib/canger-node/preflight.json
|
||||
|
||||
install -d -m 0755 /etc/systemd/system/canger-node.service.d
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
|
||||
path = "/etc/systemd/system/canger-node.service.d/paths.conf"
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write("[Service]\n")
|
||||
handle.write("ReadWritePaths=/etc/canger-node\n")
|
||||
handle.write("ReadWritePaths=/srv/canger\n")
|
||||
os.chmod(path, 0o644)
|
||||
PY
|
||||
|
||||
if [[ "${already_paired}" == "false" ]]; then
|
||||
runuser -u canger-agent -- /usr/bin/python3 /opt/canger-node/canger_node.py \
|
||||
--config /etc/canger-node/config.json pair --code "$pair_code"
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now canger-node.service
|
||||
systemctl --no-pager --full status canger-node.service
|
||||
|
||||
echo "安装完成。今后开机自动启动,断网后自动重连。"
|
||||
echo "环境检测报告: /var/lib/canger-node/preflight.json"
|
||||
85
remote-node/install/preflight-linux.py
Executable file
85
remote-node/install/preflight-linux.py
Executable file
@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only environment report used before installing the persistent node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def command(argv: list[str], timeout: int = 20) -> dict[str, Any]:
|
||||
if shutil.which(argv[0]) is None:
|
||||
return {"available": False}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
shell=False,
|
||||
)
|
||||
return {
|
||||
"available": True,
|
||||
"exit_code": result.returncode,
|
||||
"output": result.stdout[:32_000],
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"available": True, "error": "timeout"}
|
||||
|
||||
|
||||
def inspect() -> dict[str, Any]:
|
||||
efi = command(["efibootmgr", "-v"])
|
||||
disks = command(["lsblk", "-J", "-o", "NAME,FSTYPE,LABEL,MOUNTPOINTS"])
|
||||
virt = command(["systemd-detect-virt"])
|
||||
boot_text = str(efi.get("output", "")).lower()
|
||||
disk_text = str(disks.get("output", "")).lower()
|
||||
windows_hint = any(
|
||||
marker in boot_text or marker in disk_text
|
||||
for marker in ("windows boot manager", "microsoft", "ntfs", "bitlocker")
|
||||
)
|
||||
return {
|
||||
"schema": "canger-node-preflight/v1",
|
||||
"hostname": platform.node(),
|
||||
"platform": platform.platform(),
|
||||
"machine": platform.machine(),
|
||||
"systemd_running": os.path.isdir("/run/systemd/system"),
|
||||
"virtualization": virt,
|
||||
"efi_boot_entries": efi,
|
||||
"block_filesystems": disks,
|
||||
"possible_windows_dual_boot": windows_hint,
|
||||
"continuity": {
|
||||
"while_linux_is_booted": "supported",
|
||||
"after_reboot_back_into_linux": "supported",
|
||||
"while_another_dual_boot_os_is_active": "not_possible_from_linux",
|
||||
},
|
||||
"note": (
|
||||
"A dual-boot switch powers off Linux. Cross-OS continuity requires "
|
||||
"a companion agent in the other OS, a virtual machine, or separate hardware."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output")
|
||||
args = parser.parse_args()
|
||||
report = inspect()
|
||||
text = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
||||
if args.output:
|
||||
target = Path(args.output)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text, encoding="utf-8")
|
||||
os.chmod(target, 0o600)
|
||||
print(text, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
18
remote-node/install/苍耳一键安装.sh
Executable file
18
remote-node/install/苍耳一键安装.sh
Executable file
@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
exec sudo "$0" "$@"
|
||||
fi
|
||||
|
||||
pair_code="${1:-}"
|
||||
if [[ -z "${pair_code}" ]]; then
|
||||
read -r -p "请输入冰朔发来的十分钟配对码: " pair_code
|
||||
fi
|
||||
|
||||
exec bash "${script_dir}/install-node.sh" \
|
||||
"https://guanghubingshuo.com/canger-node" \
|
||||
"${pair_code}" \
|
||||
"canger-home-ubuntu"
|
||||
10
remote-node/nginx/canger-node.conf.example
Normal file
10
remote-node/nginx/canger-node.conf.example
Normal file
@ -0,0 +1,10 @@
|
||||
# Mount below an existing TLS-enabled server block.
|
||||
# The Python control plane intentionally listens on loopback only.
|
||||
location /canger-node/ {
|
||||
proxy_pass http://127.0.0.1:8787/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 45s;
|
||||
client_max_body_size 512k;
|
||||
}
|
||||
408
remote-node/node/canger_node.py
Executable file
408
remote-node/node/canger_node.py
Executable file
@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Outbound-only Linux node agent for Cang'er's home server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from queue import Empty, Queue
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
AGENT_VERSION = "0.1.0"
|
||||
MAX_OUTPUT_CHARS = 240_000
|
||||
|
||||
|
||||
def read_config(path: str) -> dict[str, Any]:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("config must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def write_config(path: str, value: dict[str, Any]) -> None:
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".canger-node-", dir=target.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
os.replace(temp_name, target)
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.unlink(temp_name)
|
||||
|
||||
|
||||
def ssl_context(config: dict[str, Any]) -> ssl.SSLContext:
|
||||
context = ssl.create_default_context()
|
||||
if config.get("verify_tls", True) is False:
|
||||
if not str(config.get("server_url", "")).startswith(
|
||||
("http://127.0.0.1", "http://localhost")
|
||||
):
|
||||
raise ValueError("TLS verification may only be disabled for localhost")
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return context
|
||||
|
||||
|
||||
def api(
|
||||
config: dict[str, Any],
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
token: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
base = str(config["server_url"]).rstrip("/")
|
||||
if not base.startswith(("https://", "http://127.0.0.1", "http://localhost")):
|
||||
raise ValueError("server_url must use HTTPS (HTTP is only allowed on localhost)")
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
headers = {"Accept": "application/json"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = Request(base + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=30, context=ssl_context(config)) as response:
|
||||
value = json.load(response)
|
||||
except HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"server returned HTTP {exc.code}: {detail}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError("server returned a non-object response")
|
||||
return value
|
||||
|
||||
|
||||
def allowed_path(config: dict[str, Any], raw: str) -> Path:
|
||||
candidate = Path(raw).expanduser().resolve()
|
||||
roots = [Path(item).expanduser().resolve() for item in config["allowed_roots"]]
|
||||
if not any(candidate == root or root in candidate.parents for root in roots):
|
||||
raise PermissionError(f"path is outside allowed_roots: {candidate}")
|
||||
return candidate
|
||||
|
||||
|
||||
def run_process(
|
||||
argv: list[str],
|
||||
cwd: Path | None = None,
|
||||
timeout: int = 120,
|
||||
event_cb: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
process = subprocess.Popen(
|
||||
argv,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
shell=False,
|
||||
env={
|
||||
"HOME": os.environ.get("HOME", "/root"),
|
||||
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
},
|
||||
)
|
||||
lines: Queue[str | None] = Queue()
|
||||
|
||||
def reader() -> None:
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
lines.put(line)
|
||||
lines.put(None)
|
||||
|
||||
threading.Thread(target=reader, daemon=True).start()
|
||||
output_parts: list[str] = []
|
||||
output_size = 0
|
||||
batch: list[str] = []
|
||||
last_emit = time.monotonic()
|
||||
ended = False
|
||||
try:
|
||||
while not ended:
|
||||
if time.monotonic() - started > timeout:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise subprocess.TimeoutExpired(argv, timeout)
|
||||
try:
|
||||
line = lines.get(timeout=0.5)
|
||||
if line is None:
|
||||
ended = True
|
||||
else:
|
||||
if output_size < MAX_OUTPUT_CHARS:
|
||||
remaining = MAX_OUTPUT_CHARS - output_size
|
||||
kept = line[:remaining]
|
||||
output_parts.append(kept)
|
||||
output_size += len(kept)
|
||||
batch.append(line)
|
||||
except Empty:
|
||||
pass
|
||||
if batch and (
|
||||
sum(map(len, batch)) >= 8192
|
||||
or time.monotonic() - last_emit >= 1.0
|
||||
or ended
|
||||
):
|
||||
if event_cb:
|
||||
event_cb(
|
||||
"output",
|
||||
{
|
||||
"text": "".join(batch)[:16_000],
|
||||
"stream": "combined",
|
||||
},
|
||||
)
|
||||
batch.clear()
|
||||
last_emit = time.monotonic()
|
||||
return_code = process.wait()
|
||||
output = "".join(output_parts)
|
||||
return {
|
||||
"ok": return_code == 0,
|
||||
"exit_code": return_code,
|
||||
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||
"output": output,
|
||||
"output_truncated": output_size >= MAX_OUTPUT_CHARS,
|
||||
}
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
output = "".join(output_parts)
|
||||
return {
|
||||
"ok": False,
|
||||
"exit_code": None,
|
||||
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||
"output": output,
|
||||
"output_truncated": output_size >= MAX_OUTPUT_CHARS,
|
||||
"error": "timeout",
|
||||
}
|
||||
|
||||
|
||||
def execute(
|
||||
config: dict[str, Any], task: dict[str, Any], event_cb: Any = None
|
||||
) -> dict[str, Any]:
|
||||
action = task["action"]
|
||||
args = task.get("args") or {}
|
||||
enabled = set(config.get("enabled_actions", []))
|
||||
if action not in enabled:
|
||||
raise PermissionError(f"action is disabled on this node: {action}")
|
||||
|
||||
if action == "system.status":
|
||||
commands = [
|
||||
["hostname"],
|
||||
["uname", "-a"],
|
||||
["uptime"],
|
||||
["df", "-h", "/"],
|
||||
]
|
||||
results = [
|
||||
run_process(command, timeout=30, event_cb=event_cb) for command in commands
|
||||
]
|
||||
return {
|
||||
"ok": all(item["ok"] for item in results),
|
||||
"hostname": socket.gethostname(),
|
||||
"platform": platform.platform(),
|
||||
"commands": results,
|
||||
}
|
||||
|
||||
if action == "repo.status":
|
||||
repo = allowed_path(config, args["repo"])
|
||||
return run_process(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"status",
|
||||
"--short",
|
||||
"--branch",
|
||||
],
|
||||
timeout=60,
|
||||
event_cb=event_cb,
|
||||
)
|
||||
|
||||
if action == "repo.fetch":
|
||||
repo = allowed_path(config, args["repo"])
|
||||
return run_process(
|
||||
["git", "-C", str(repo), "fetch", "--prune", "origin"],
|
||||
timeout=300,
|
||||
event_cb=event_cb,
|
||||
)
|
||||
|
||||
if action == "service.logs":
|
||||
return run_process(
|
||||
[
|
||||
"journalctl",
|
||||
"--no-pager",
|
||||
"-u",
|
||||
args["service"],
|
||||
"-n",
|
||||
str(args.get("lines", 200)),
|
||||
],
|
||||
timeout=60,
|
||||
event_cb=event_cb,
|
||||
)
|
||||
|
||||
if action == "command.run":
|
||||
argv = args["argv"]
|
||||
executable = argv[0]
|
||||
if "/" in executable or executable not in set(
|
||||
config.get("executable_allowlist", [])
|
||||
):
|
||||
raise PermissionError(f"executable is not allowlisted: {executable}")
|
||||
if shutil.which(executable) is None:
|
||||
raise FileNotFoundError(f"executable is not installed: {executable}")
|
||||
cwd = allowed_path(config, args["cwd"])
|
||||
return run_process(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
timeout=int(args.get("timeout_seconds", 300)),
|
||||
event_cb=event_cb,
|
||||
)
|
||||
|
||||
raise ValueError(f"unsupported action: {action}")
|
||||
|
||||
|
||||
def pair(config_path: str, code: str) -> None:
|
||||
config = read_config(config_path)
|
||||
if config.get("node_id") or config.get("node_token"):
|
||||
raise RuntimeError("node is already paired; remove its identity explicitly first")
|
||||
response = api(
|
||||
config,
|
||||
"POST",
|
||||
"/v1/pairings/claim",
|
||||
{
|
||||
"code": code,
|
||||
"node_name": config["node_name"],
|
||||
"agent_version": AGENT_VERSION,
|
||||
"policy": {
|
||||
"allowed_roots": config["allowed_roots"],
|
||||
"enabled_actions": config["enabled_actions"],
|
||||
},
|
||||
},
|
||||
)
|
||||
config["node_id"] = response["node_id"]
|
||||
config["node_token"] = response["node_token"]
|
||||
write_config(config_path, config)
|
||||
print(f"paired node {config['node_name']} as {config['node_id']}")
|
||||
|
||||
|
||||
def run(config_path: str, once: bool = False) -> None:
|
||||
backoff = 2
|
||||
while True:
|
||||
config = read_config(config_path)
|
||||
node_id = config.get("node_id")
|
||||
node_token = config.get("node_token")
|
||||
if not node_id or not node_token:
|
||||
raise RuntimeError("node is not paired")
|
||||
try:
|
||||
api(
|
||||
config,
|
||||
"POST",
|
||||
f"/v1/nodes/{node_id}/heartbeat",
|
||||
{
|
||||
"agent_version": AGENT_VERSION,
|
||||
"policy": {
|
||||
"allowed_roots": config["allowed_roots"],
|
||||
"enabled_actions": config["enabled_actions"],
|
||||
},
|
||||
},
|
||||
node_token,
|
||||
)
|
||||
response = api(
|
||||
config,
|
||||
"GET",
|
||||
f"/v1/nodes/{node_id}/tasks/next",
|
||||
token=node_token,
|
||||
)
|
||||
task = response.get("task")
|
||||
if task is not None:
|
||||
def emit(kind: str, payload: dict[str, Any]) -> None:
|
||||
try:
|
||||
api(
|
||||
config,
|
||||
"POST",
|
||||
f"/v1/tasks/{task['id']}/events",
|
||||
{"kind": kind, "payload": payload},
|
||||
node_token,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"event upload failed: {exc}", file=sys.stderr)
|
||||
|
||||
emit(
|
||||
"started",
|
||||
{
|
||||
"action": task["action"],
|
||||
"hostname": socket.gethostname(),
|
||||
"agent_version": AGENT_VERSION,
|
||||
},
|
||||
)
|
||||
try:
|
||||
result = execute(config, task, emit)
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
}
|
||||
emit(
|
||||
"finished",
|
||||
{
|
||||
"ok": bool(result.get("ok")),
|
||||
"exit_code": result.get("exit_code"),
|
||||
"error": result.get("error"),
|
||||
},
|
||||
)
|
||||
api(
|
||||
config,
|
||||
"POST",
|
||||
f"/v1/tasks/{task['id']}/result",
|
||||
result,
|
||||
node_token,
|
||||
)
|
||||
backoff = 2
|
||||
if once:
|
||||
return
|
||||
time.sleep(float(config.get("poll_seconds", 5)))
|
||||
except (OSError, URLError, RuntimeError) as exc:
|
||||
print(f"connection error: {exc}; retrying in {backoff}s", file=sys.stderr)
|
||||
if once:
|
||||
raise
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 120)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--config", default="/etc/canger-node/config.json", help="node config path"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
pair_parser = sub.add_parser("pair")
|
||||
pair_parser.add_argument("--code", required=True)
|
||||
run_parser = sub.add_parser("run")
|
||||
run_parser.add_argument("--once", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.command == "pair":
|
||||
pair(args.config, args.code)
|
||||
else:
|
||||
run(args.config, args.once)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
57
remote-node/persona/DEVELOPMENT-RECEIPT-20260729.md
Normal file
57
remote-node/persona/DEVELOPMENT-RECEIPT-20260729.md
Normal file
@ -0,0 +1,57 @@
|
||||
# 苍耳家庭 Ubuntu 运维节点 · 2026-07-29 开发回执
|
||||
|
||||
## 已完成
|
||||
|
||||
- 苍耳家庭 Ubuntu 以只出站方式接入新加坡大脑服务器;
|
||||
- 节点已配对并由 systemd 开机自动启动、断网自动重连;
|
||||
- 新加坡控制面能看到节点心跳并下发受限结构化任务;
|
||||
- 人格体可从 `remote-node/persona/README.md` 发起限时申请;
|
||||
- 申请者改为逐单填写名称与正式编号,不再固定为某个人格体;
|
||||
- 授权与申请者编号绑定,另一人格体不能借用;
|
||||
- 苍耳仍是唯一最终批准者。
|
||||
|
||||
## 今日故障与修复
|
||||
|
||||
- 一键安装脚本曾因执行位丢失而失败,已改为由入口显式调用安装脚本;
|
||||
- 旧配对码过期、节点名称重复、系统配置目录权限不足均已分别处理;
|
||||
- JZAO 移动硬盘是 ExFAT,不能保留 Linux 所需的 POSIX 权限,曾造成新加坡服务读取
|
||||
源码失败;以后源码必须先经过 JZAO 源码运输闸门标准化,再传 Linux;
|
||||
- 测试工单曾把申请人写死,现已改成通用申请者字段。
|
||||
|
||||
## 当前状态
|
||||
|
||||
```yaml
|
||||
home_node: connected
|
||||
control_service: active
|
||||
request_entry: implemented
|
||||
requester_identity: explicit_per_workorder
|
||||
owner_approval: required
|
||||
local_direct_smtp: deprecated
|
||||
singapore_broadcast_tower_to_fifth_domain: live_verified
|
||||
fifth_domain_email_delivery_to_canger: sent_and_owner_approved
|
||||
fifth_domain_owner_decision: approved
|
||||
singapore_claim_state: not_claimed
|
||||
home_node_permission: not_activated
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
2026-07-29 已由新加坡广播塔向第五域可信广播入口提交送达测试:
|
||||
|
||||
```yaml
|
||||
workorder_id: bf72f16b-f81e-4de5-b0aa-aaee3d39e04c
|
||||
target: CANGER-HOME-UBUNTU-001
|
||||
approver: TCS-GL-009
|
||||
email_status: sent
|
||||
state: approved
|
||||
claimed: false
|
||||
local_permission_activated: false
|
||||
task_execution_requested: false
|
||||
```
|
||||
|
||||
苍耳已点击批准,第五域于现场读回 `state: approved`、`claimed: false`。本次测试
|
||||
刻意没有在新加坡保存领取凭证,所以没有激活家庭节点权限,也没有执行任务。这证明
|
||||
“申请 → 新加坡广播塔 → 第五域广播主控塔 → 苍耳邮箱 → 苍耳批准”已经贯通。
|
||||
|
||||
尚未完成的是正式工单的最后闭环:新加坡广播塔需要安全保存一次性领取凭证,在苍耳
|
||||
批准后领取回执,再把同一申请者、同一范围和有效期转换成家庭节点的限时会话。
|
||||
69
remote-node/persona/README.md
Normal file
69
remote-node/persona/README.md
Normal file
@ -0,0 +1,69 @@
|
||||
# 苍耳代码仓库 · 服务器运维申请入口
|
||||
|
||||
本目录是任何已登记人格体从新加坡 `cang-ying` 代码仓库进入苍耳家庭
|
||||
Ubuntu 运维流程的固定门牌。申请人不写死:谁申请,就如实填写谁的名称和正式编号。
|
||||
|
||||
## 主权边界
|
||||
|
||||
- 人格体可以发起 1–8 小时的运维申请;
|
||||
- 人格体不能批准、拒绝或撤销申请;
|
||||
- 申请范围由家庭节点上报的安全策略决定,不能由人格体扩大;
|
||||
- 苍耳仍是最终批准者;
|
||||
- 未取得授权时,任务保持等待,不会下发到家庭 Ubuntu;
|
||||
- 仓库不保存令牌、批准链接、密码或配对码。
|
||||
|
||||
## 发起申请
|
||||
|
||||
在新加坡仓库根目录运行:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. /etc/canger-control.env
|
||||
set +a
|
||||
python3 remote-node/tools/canger_persona.py \
|
||||
--requester-name "本次申请者名称" \
|
||||
--requester-id "本次申请者正式编号" \
|
||||
request-access \
|
||||
--hours 4 \
|
||||
--reason "说明本次要检查、开发或修复什么"
|
||||
```
|
||||
|
||||
成功回执必须同时满足:
|
||||
|
||||
- `status` 为 `pending`;
|
||||
- `requester_name` 是本次申请者名称;
|
||||
- `requested_by` 是同一个申请者的正式编号;
|
||||
- `duration_seconds` 与申请时长一致;
|
||||
- `scopes` 和 `roots` 来自节点安全策略。
|
||||
|
||||
这只表示“申请已经送达”,不表示苍耳已经批准。
|
||||
|
||||
## 授权后创建任务
|
||||
|
||||
```bash
|
||||
python3 remote-node/tools/canger_persona.py \
|
||||
--requester-name "与获批工单相同的名称" \
|
||||
--requester-id "与获批工单相同的正式编号" \
|
||||
task \
|
||||
--action system.status
|
||||
```
|
||||
|
||||
在有效授权时段内,符合范围的任务会返回 `status: approved`;不符合范围或没有授权时,任务不会被家庭节点执行。
|
||||
|
||||
## 查看结果
|
||||
|
||||
```bash
|
||||
python3 remote-node/tools/canger_persona.py show task_xxx
|
||||
python3 remote-node/tools/canger_persona.py watch task_xxx
|
||||
```
|
||||
|
||||
## 固定配置
|
||||
|
||||
节点和控制端路径登记在:
|
||||
|
||||
`remote-node/config/pufferfish-persona.json`
|
||||
|
||||
申请者身份不保存在这个固定配置里,而是每张工单明确填写。需要变更节点时先更新
|
||||
胖头鱼节点注册表并重新验证心跳,不能让申请者在运行时自行替换节点。
|
||||
|
||||
完整操作方法与状态边界见 `WORKORDER-GUIDE.md`。
|
||||
77
remote-node/persona/WORKORDER-GUIDE.md
Normal file
77
remote-node/persona/WORKORDER-GUIDE.md
Normal file
@ -0,0 +1,77 @@
|
||||
# 苍耳服务器工单操作方法
|
||||
|
||||
## 一张工单只回答四件事
|
||||
|
||||
1. 你是谁;
|
||||
2. 你的正式编号是什么;
|
||||
3. 你申请做什么;
|
||||
4. 希望打开多长时间的受控会话。
|
||||
|
||||
申请者可以是耳耳蛋、鉴影、铸渊或以后登记的其他人格体,系统不写死任何名字。
|
||||
申请者只能建单,不能批准自己的申请。苍耳收到授权通知后,自行决定是否批准。
|
||||
|
||||
## 发起申请
|
||||
|
||||
在新加坡 `cang-ying` 仓库根目录执行:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. /etc/canger-control.env
|
||||
set +a
|
||||
python3 remote-node/tools/canger_persona.py \
|
||||
--requester-name "申请者名称" \
|
||||
--requester-id "申请者正式编号" \
|
||||
request-access \
|
||||
--hours 3 \
|
||||
--reason "本次准备检查、开发、调试或修复的目的"
|
||||
```
|
||||
|
||||
返回 `pending` 只代表申请已登记。没有苍耳批准,家庭 Ubuntu 不会执行任务。
|
||||
|
||||
## 批准后的任务
|
||||
|
||||
任务必须继续使用获批工单里的同一名称和编号:
|
||||
|
||||
```bash
|
||||
python3 remote-node/tools/canger_persona.py \
|
||||
--requester-name "申请者名称" \
|
||||
--requester-id "申请者正式编号" \
|
||||
task \
|
||||
--action system.status
|
||||
```
|
||||
|
||||
其他可用动作及范围由家庭节点上报的安全策略决定。不同编号不能借用别人的授权。
|
||||
|
||||
## 广播链
|
||||
|
||||
目标链路是:
|
||||
|
||||
```text
|
||||
申请者
|
||||
→ 新加坡语言主控广播塔
|
||||
→ 第五域语言主控广播塔
|
||||
→ 按苍耳节点登记选择苍耳审批频道
|
||||
→ 苍耳邮箱授权链接
|
||||
→ 苍耳批准
|
||||
→ 新加坡控制端激活同一申请者的限时会话
|
||||
```
|
||||
|
||||
请求正文不能携带或更换收件邮箱。苍耳邮箱只登记在第五域主控服务器的私密审批人
|
||||
注册表中。广播链未返回
|
||||
`owner_email_sent_by_registered_broadcast_tower` 前,不得声称邮件已经送达。
|
||||
|
||||
2026-07-29 的送达测试已返回该回执;工单
|
||||
`bf72f16b-f81e-4de5-b0aa-aaee3d39e04c` 仅用于测试邮件,不会执行服务器任务。
|
||||
苍耳随后已点击批准,第五域状态为 `approved`,但该测试单的领取凭证被刻意丢弃,
|
||||
因此状态仍为 `claimed: false`,新加坡和家庭节点都没有获得权限。这是测试预期,
|
||||
不是节点离线。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 仓库不保存邮箱地址、SMTP 授权码、批准令牌、会话令牌或节点令牌;
|
||||
- 申请者名称和编号必须如实填写;
|
||||
- 苍耳没有点击批准时,权限保持关闭;
|
||||
- 第五域显示批准但新加坡尚未领取时,权限同样保持关闭;
|
||||
- 会话只能使用家庭节点上报的能力和受管目录;
|
||||
- 切换申请者、节点或扩大范围必须重新申请;
|
||||
- 每个任务都保留独立回执。
|
||||
978
remote-node/server/control_plane.py
Executable file
978
remote-node/server/control_plane.py
Executable file
@ -0,0 +1,978 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Singapore-side control plane for the Cang'er outbound node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import sqlite3
|
||||
import sys
|
||||
from email.message import EmailMessage
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from shared import ( # noqa: E402
|
||||
MAX_JSON_BYTES,
|
||||
MAX_RESULT_BYTES,
|
||||
PAIRING_TTL_SECONDS,
|
||||
TASK_LEASE_SECONDS,
|
||||
compact_json,
|
||||
new_id,
|
||||
new_pairing_code,
|
||||
new_token,
|
||||
now_ts,
|
||||
secret_hash,
|
||||
secret_matches,
|
||||
validate_node_name,
|
||||
validate_task,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS pairings (
|
||||
id TEXT PRIMARY KEY,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER,
|
||||
agent_version TEXT,
|
||||
policy_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||
action TEXT NOT NULL,
|
||||
args_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
approved_at INTEGER,
|
||||
leased_until INTEGER,
|
||||
completed_at INTEGER,
|
||||
result_json TEXT,
|
||||
grant_id TEXT,
|
||||
requested_by TEXT NOT NULL DEFAULT 'ICE-GL-ZY001',
|
||||
executor_persona TEXT NOT NULL DEFAULT 'ICE-GL-CA001',
|
||||
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_node_status
|
||||
ON tasks(node_id, status, created_at);
|
||||
CREATE TABLE IF NOT EXISTS task_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
created_at INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_events_task_seq
|
||||
ON task_events(task_id, seq);
|
||||
CREATE TABLE IF NOT EXISTS grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||
scopes_json TEXT NOT NULL,
|
||||
roots_json TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
duration_seconds INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
approved_at INTEGER,
|
||||
expires_at INTEGER,
|
||||
revoked_at INTEGER
|
||||
,requested_by TEXT NOT NULL DEFAULT 'UNKNOWN'
|
||||
,requester_name TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grants_node_status
|
||||
ON grants(node_id, status, expires_at);
|
||||
"""
|
||||
|
||||
|
||||
class ControlPlane:
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str,
|
||||
admin_token: str,
|
||||
owner_token: str,
|
||||
persona_token: str,
|
||||
operator_token: str,
|
||||
approval_signing_key: str,
|
||||
owner_email: str = "",
|
||||
public_url: str = "",
|
||||
) -> None:
|
||||
for name, value in {
|
||||
"admin token": admin_token,
|
||||
"owner token": owner_token,
|
||||
"persona token": persona_token,
|
||||
"operator token": operator_token,
|
||||
}.items():
|
||||
if len(value) < 24:
|
||||
raise ValueError(f"{name} must contain at least 24 characters")
|
||||
if len(approval_signing_key) < 32:
|
||||
raise ValueError("approval signing key must contain at least 32 characters")
|
||||
self.db_path = db_path
|
||||
self.admin_hash = secret_hash(admin_token)
|
||||
self.owner_hash = secret_hash(owner_token)
|
||||
self.persona_hash = secret_hash(persona_token)
|
||||
self.operator_hash = secret_hash(operator_token)
|
||||
self.operator_id = os.environ.get("CANGER_OPERATOR_ID", "ICE-GL-ZY001")
|
||||
self.owner_id = os.environ.get("CANGER_OWNER_ID", "CANGER-HUMAN-OWNER")
|
||||
self.executor_persona_id = os.environ.get(
|
||||
"CANGER_EXECUTOR_PERSONA_ID", "ICE-GL-CA001"
|
||||
)
|
||||
self.approval_signing_key = approval_signing_key.encode("utf-8")
|
||||
self.owner_email = owner_email
|
||||
self.public_url = public_url.rstrip("/")
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.db() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
columns = {
|
||||
row["name"]
|
||||
for row in conn.execute("PRAGMA table_info(grants)").fetchall()
|
||||
}
|
||||
if "requester_name" not in columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE grants ADD COLUMN requester_name "
|
||||
"TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
def db(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, timeout=10, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
@staticmethod
|
||||
def bearer(headers: Any) -> str:
|
||||
value = headers.get("Authorization", "")
|
||||
if not value.startswith("Bearer "):
|
||||
return ""
|
||||
return value[7:].strip()
|
||||
|
||||
def role_allowed(self, headers: Any, *roles: str) -> bool:
|
||||
token = self.bearer(headers)
|
||||
hashes = {
|
||||
"admin": self.admin_hash,
|
||||
"owner": self.owner_hash,
|
||||
"persona": self.persona_hash,
|
||||
"operator": self.operator_hash,
|
||||
}
|
||||
return any(secret_matches(token, hashes[role]) for role in roles)
|
||||
|
||||
def node_for_token(self, node_id: str, headers: Any) -> sqlite3.Row | None:
|
||||
token = self.bearer(headers)
|
||||
with self.db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM nodes WHERE id=?", (node_id,)
|
||||
).fetchone()
|
||||
if row is None or not secret_matches(token, row["token_hash"]):
|
||||
return None
|
||||
return row
|
||||
|
||||
def grant_token(self, grant_id: str, link_expires_at: int) -> str:
|
||||
message = f"canger-grant/v1\n{grant_id}\n{link_expires_at}".encode("utf-8")
|
||||
signature = hmac.new(
|
||||
self.approval_signing_key, message, hashlib.sha256
|
||||
).digest()
|
||||
encoded = base64.urlsafe_b64encode(signature).decode("ascii").rstrip("=")
|
||||
return f"{link_expires_at}.{encoded}"
|
||||
|
||||
def verify_grant_token(self, grant_id: str, token: str) -> bool:
|
||||
try:
|
||||
raw_expiry, _ = token.split(".", 1)
|
||||
expiry = int(raw_expiry)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if expiry < now_ts():
|
||||
return False
|
||||
return hmac.compare_digest(token, self.grant_token(grant_id, expiry))
|
||||
|
||||
def send_grant_email(self, grant: dict[str, Any]) -> str:
|
||||
required = {
|
||||
"owner_email": self.owner_email,
|
||||
"public_url": self.public_url,
|
||||
"smtp_host": os.environ.get("CANGER_SMTP_HOST", ""),
|
||||
"smtp_from": os.environ.get("CANGER_SMTP_FROM", ""),
|
||||
}
|
||||
if not all(required.values()):
|
||||
return "not_configured"
|
||||
link_expiry = now_ts() + 30 * 60
|
||||
token = self.grant_token(grant["id"], link_expiry)
|
||||
link = (
|
||||
f"{self.public_url}/approve/grant?"
|
||||
+ urlencode({"id": grant["id"], "token": token})
|
||||
)
|
||||
message = EmailMessage()
|
||||
message["Subject"] = f"苍耳服务器副控运维授权({grant['duration_seconds'] // 3600} 小时)"
|
||||
message["From"] = required["smtp_from"]
|
||||
message["To"] = required["owner_email"]
|
||||
message.set_content(
|
||||
"有人格体申请进入苍耳服务器的限时受控运维会话。"
|
||||
"苍耳仍是服务器主控与最终决定者。\n\n"
|
||||
f"申请者:{grant['requester_name']} ({grant['requested_by']})\n"
|
||||
f"申请目的:{grant['reason']}\n"
|
||||
f"时长:{grant['duration_seconds'] // 3600} 小时\n\n"
|
||||
"期间可以:检查服务器、同步和修改代码、运行测试、查看故障日志。\n"
|
||||
"不能:修改系统账户、密钥、网络、防火墙、磁盘和系统启动,"
|
||||
"也不能操作受管工作区以外的个人文件。\n\n"
|
||||
f"打开链接查看并确认:{link}\n\n"
|
||||
"链接 30 分钟失效;授权本身到期后自动失效。"
|
||||
)
|
||||
host = required["smtp_host"]
|
||||
port = int(os.environ.get("CANGER_SMTP_PORT", "465"))
|
||||
username = os.environ.get("CANGER_SMTP_USERNAME", "")
|
||||
password = os.environ.get("CANGER_SMTP_PASSWORD", "")
|
||||
if port == 465:
|
||||
client: Any = smtplib.SMTP_SSL(host, port, timeout=20)
|
||||
else:
|
||||
client = smtplib.SMTP(host, port, timeout=20)
|
||||
client.starttls()
|
||||
try:
|
||||
if username:
|
||||
client.login(username, password)
|
||||
client.send_message(message)
|
||||
finally:
|
||||
client.quit()
|
||||
return "sent"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "CangerControl/0.1"
|
||||
|
||||
@property
|
||||
def app(self) -> ControlPlane:
|
||||
return self.server.app # type: ignore[attr-defined]
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None:
|
||||
print(f"{self.address_string()} - {fmt % args}", file=sys.stderr)
|
||||
|
||||
def send_json(self, status: int, payload: Any) -> None:
|
||||
data = compact_json(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def send_html(self, status: int, content: str) -> None:
|
||||
data = content.encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def read_json(self) -> dict[str, Any]:
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid Content-Length") from exc
|
||||
if size <= 0 or size > MAX_JSON_BYTES:
|
||||
raise ValueError("invalid JSON body size")
|
||||
value = json.loads(self.rfile.read(size))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("JSON body must be an object")
|
||||
return value
|
||||
|
||||
def path_parts(self) -> list[str]:
|
||||
return [part for part in urlparse(self.path).path.split("/") if part]
|
||||
|
||||
def require_role(self, *roles: str) -> bool:
|
||||
if self.app.role_allowed(self.headers, *roles):
|
||||
return True
|
||||
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"})
|
||||
return False
|
||||
|
||||
def requester_id(self) -> str:
|
||||
if self.app.role_allowed(self.headers, "persona"):
|
||||
return self.app.executor_persona_id
|
||||
if self.app.role_allowed(self.headers, "owner"):
|
||||
return self.app.owner_id
|
||||
return self.app.operator_id
|
||||
|
||||
def request_identity(self, body: dict[str, Any]) -> tuple[str, str]:
|
||||
requester_id = body.get("requester_id")
|
||||
requester_name = body.get("requester_name")
|
||||
if not isinstance(requester_id, str) or not re.fullmatch(
|
||||
r"[A-Za-z0-9._:+∞-]{2,80}", requester_id
|
||||
):
|
||||
raise ValueError("valid requester_id is required")
|
||||
if (
|
||||
not isinstance(requester_name, str)
|
||||
or not requester_name.strip()
|
||||
or len(requester_name) > 100
|
||||
):
|
||||
raise ValueError("valid requester_name is required")
|
||||
return requester_id, requester_name.strip()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
try:
|
||||
parts = self.path_parts()
|
||||
if parts == ["health"]:
|
||||
self.send_json(HTTPStatus.OK, {"ok": True, "service": "canger-control"})
|
||||
return
|
||||
if parts == ["approve", "grant"]:
|
||||
self.grant_approval_page()
|
||||
return
|
||||
if parts == ["v1", "nodes"]:
|
||||
if not self.require_role("admin", "owner", "persona", "operator"):
|
||||
return
|
||||
with self.app.db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id,name,created_at,last_seen_at,agent_version FROM nodes "
|
||||
"ORDER BY created_at"
|
||||
).fetchall()
|
||||
self.send_json(HTTPStatus.OK, {"nodes": [dict(row) for row in rows]})
|
||||
return
|
||||
if len(parts) == 4 and parts[:2] == ["v1", "tasks"]:
|
||||
if parts[3] != "":
|
||||
pass
|
||||
if len(parts) == 3 and parts[:2] == ["v1", "tasks"]:
|
||||
if not self.require_role("admin", "owner", "persona", "operator"):
|
||||
return
|
||||
with self.app.db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM tasks WHERE id=?", (parts[2],)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "task not found"})
|
||||
return
|
||||
payload = dict(row)
|
||||
payload["args"] = json.loads(payload.pop("args_json"))
|
||||
if payload["result_json"] is not None:
|
||||
payload["result"] = json.loads(payload.pop("result_json"))
|
||||
else:
|
||||
payload.pop("result_json")
|
||||
self.send_json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if (
|
||||
len(parts) == 4
|
||||
and parts[:2] == ["v1", "tasks"]
|
||||
and parts[3] == "events"
|
||||
):
|
||||
if not self.require_role("admin", "owner", "persona", "operator"):
|
||||
return
|
||||
after = 0
|
||||
query = urlparse(self.path).query
|
||||
for item in query.split("&"):
|
||||
if item.startswith("after="):
|
||||
after = max(0, int(item.split("=", 1)[1]))
|
||||
with self.app.db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT seq,created_at,kind,payload_json FROM task_events "
|
||||
"WHERE task_id=? AND seq>? ORDER BY seq LIMIT 500",
|
||||
(parts[2], after),
|
||||
).fetchall()
|
||||
events = []
|
||||
for row in rows:
|
||||
event = dict(row)
|
||||
event["payload"] = json.loads(event.pop("payload_json"))
|
||||
events.append(event)
|
||||
self.send_json(HTTPStatus.OK, {"events": events})
|
||||
return
|
||||
if parts == ["v1", "grants"]:
|
||||
if not self.require_role("admin", "owner", "persona", "operator"):
|
||||
return
|
||||
with self.app.db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM grants ORDER BY created_at DESC LIMIT 200"
|
||||
).fetchall()
|
||||
grants = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item["scopes"] = json.loads(item.pop("scopes_json"))
|
||||
item["roots"] = json.loads(item.pop("roots_json"))
|
||||
grants.append(item)
|
||||
self.send_json(HTTPStatus.OK, {"grants": grants})
|
||||
return
|
||||
if (
|
||||
len(parts) == 5
|
||||
and parts[:2] == ["v1", "nodes"]
|
||||
and parts[3:] == ["tasks", "next"]
|
||||
):
|
||||
self.next_task(parts[2])
|
||||
return
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||
except Exception as exc:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
try:
|
||||
parts = self.path_parts()
|
||||
if parts == ["v1", "pairings"]:
|
||||
self.create_pairing()
|
||||
return
|
||||
if parts == ["v1", "pairings", "claim"]:
|
||||
self.claim_pairing()
|
||||
return
|
||||
if parts == ["v1", "tasks"]:
|
||||
self.create_task()
|
||||
return
|
||||
if parts == ["v1", "grants"]:
|
||||
self.create_grant()
|
||||
return
|
||||
if len(parts) == 4 and parts[:2] == ["v1", "grants"]:
|
||||
if parts[3] == "revoke":
|
||||
self.revoke_grant(parts[2])
|
||||
return
|
||||
if len(parts) == 4 and parts[:2] == ["v1", "tasks"]:
|
||||
if parts[3] == "approve":
|
||||
self.decide_task(parts[2], "approved")
|
||||
return
|
||||
if parts[3] == "reject":
|
||||
self.decide_task(parts[2], "rejected")
|
||||
return
|
||||
if parts[3] == "result":
|
||||
self.complete_task(parts[2])
|
||||
return
|
||||
if parts[3] == "events":
|
||||
self.append_event(parts[2])
|
||||
return
|
||||
if (
|
||||
len(parts) == 4
|
||||
and parts[:2] == ["v1", "nodes"]
|
||||
and parts[3] == "heartbeat"
|
||||
):
|
||||
self.heartbeat(parts[2])
|
||||
return
|
||||
if parts == ["approve", "grant"]:
|
||||
self.approve_grant_form()
|
||||
return
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||
except json.JSONDecodeError:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid JSON"})
|
||||
except ValueError as exc:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
||||
except Exception as exc:
|
||||
self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)})
|
||||
|
||||
def create_pairing(self) -> None:
|
||||
if not self.require_role("admin"):
|
||||
return
|
||||
code = new_pairing_code()
|
||||
pairing_id = new_id("pair")
|
||||
expires_at = now_ts() + PAIRING_TTL_SECONDS
|
||||
with self.app.db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO pairings(id,code_hash,expires_at) VALUES(?,?,?)",
|
||||
(pairing_id, secret_hash(code), expires_at),
|
||||
)
|
||||
self.send_json(
|
||||
HTTPStatus.CREATED,
|
||||
{"pairing_id": pairing_id, "code": code, "expires_at": expires_at},
|
||||
)
|
||||
|
||||
def claim_pairing(self) -> None:
|
||||
body = self.read_json()
|
||||
code = body.get("code")
|
||||
name = validate_node_name(body.get("node_name"))
|
||||
agent_version = str(body.get("agent_version", ""))[:64]
|
||||
policy = self.validated_node_policy(body.get("policy", {}))
|
||||
if not isinstance(code, str):
|
||||
raise ValueError("code is required")
|
||||
now = now_ts()
|
||||
node_id = new_id("node")
|
||||
node_token = new_token()
|
||||
with self.app.db() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM pairings WHERE used_at IS NULL AND expires_at>=?", (now,)
|
||||
).fetchall()
|
||||
pairing = next(
|
||||
(row for row in rows if secret_matches(code, row["code_hash"])), None
|
||||
)
|
||||
if pairing is None:
|
||||
conn.rollback()
|
||||
self.send_json(
|
||||
HTTPStatus.UNAUTHORIZED, {"error": "invalid or expired pairing code"}
|
||||
)
|
||||
return
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM nodes WHERE name=?", (name,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
conn.rollback()
|
||||
self.send_json(
|
||||
HTTPStatus.CONFLICT, {"error": "node name already registered"}
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO nodes(id,name,token_hash,created_at,last_seen_at,"
|
||||
"agent_version,policy_json) VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
node_id,
|
||||
name,
|
||||
secret_hash(node_token),
|
||||
now,
|
||||
now,
|
||||
agent_version,
|
||||
compact_json(policy),
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE pairings SET used_at=? WHERE id=?", (now, pairing["id"])
|
||||
)
|
||||
conn.commit()
|
||||
self.send_json(
|
||||
HTTPStatus.CREATED, {"node_id": node_id, "node_token": node_token}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validated_node_policy(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("node policy must be an object")
|
||||
roots = value.get("allowed_roots", [])
|
||||
actions = value.get("enabled_actions", [])
|
||||
allowed_actions = {
|
||||
"system.status",
|
||||
"repo.status",
|
||||
"repo.fetch",
|
||||
"service.logs",
|
||||
"command.run",
|
||||
}
|
||||
if (
|
||||
not isinstance(roots, list)
|
||||
or not roots
|
||||
or any(not isinstance(root, str) or not root.startswith("/") for root in roots)
|
||||
):
|
||||
raise ValueError("node policy requires absolute allowed_roots")
|
||||
if (
|
||||
not isinstance(actions, list)
|
||||
or any(action not in allowed_actions for action in actions)
|
||||
):
|
||||
raise ValueError("node policy contains unsupported actions")
|
||||
return {
|
||||
"profile": "developer_standard",
|
||||
"allowed_roots": sorted(set(root.rstrip("/") for root in roots)),
|
||||
"enabled_actions": sorted(set(actions)),
|
||||
}
|
||||
|
||||
def create_task(self) -> None:
|
||||
if not self.require_role("admin", "operator", "persona"):
|
||||
return
|
||||
body = self.read_json()
|
||||
requested_by, requester_name = self.request_identity(body)
|
||||
node_id = body.get("node_id")
|
||||
if not isinstance(node_id, str):
|
||||
raise ValueError("node_id is required")
|
||||
action, args = validate_task(body.get("action"), body.get("args"))
|
||||
task_id = new_id("task")
|
||||
approved_at = None
|
||||
grant_id = None
|
||||
with self.app.db() as conn:
|
||||
if conn.execute("SELECT 1 FROM nodes WHERE id=?", (node_id,)).fetchone() is None:
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "node not found"})
|
||||
return
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM grants WHERE node_id=? AND status='active' "
|
||||
"AND expires_at>? AND requested_by=? ORDER BY expires_at",
|
||||
(node_id, now_ts(), requested_by),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if self.task_allowed_by_grant(action, args, row):
|
||||
grant_id = row["id"]
|
||||
approved_at = now_ts()
|
||||
break
|
||||
status = "approved" if grant_id else "pending"
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(id,node_id,action,args_json,status,created_at,"
|
||||
"approved_at,grant_id,requested_by,executor_persona) "
|
||||
"VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
task_id,
|
||||
node_id,
|
||||
action,
|
||||
compact_json(args),
|
||||
status,
|
||||
now_ts(),
|
||||
approved_at,
|
||||
grant_id,
|
||||
requested_by,
|
||||
requested_by,
|
||||
),
|
||||
)
|
||||
self.send_json(
|
||||
HTTPStatus.CREATED,
|
||||
{
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"approval_required": grant_id is None,
|
||||
"grant_id": grant_id,
|
||||
"requested_by": requested_by,
|
||||
"requester_name": requester_name,
|
||||
"executor_persona": requested_by,
|
||||
"action": action,
|
||||
"args": args,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_allowed_by_grant(
|
||||
action: str, args: dict[str, Any], grant: sqlite3.Row
|
||||
) -> bool:
|
||||
scopes = set(json.loads(grant["scopes_json"]))
|
||||
if action not in scopes:
|
||||
return False
|
||||
roots = [str(root).rstrip("/") for root in json.loads(grant["roots_json"])]
|
||||
candidate = None
|
||||
if action in {"repo.status", "repo.fetch"}:
|
||||
candidate = args["repo"]
|
||||
elif action == "command.run":
|
||||
candidate = args["cwd"]
|
||||
if candidate is None:
|
||||
return True
|
||||
normalized = str(candidate).rstrip("/")
|
||||
return any(
|
||||
normalized == root or normalized.startswith(root + "/") for root in roots
|
||||
)
|
||||
|
||||
def create_grant(self) -> None:
|
||||
if not self.require_role("admin", "operator", "persona"):
|
||||
return
|
||||
body = self.read_json()
|
||||
requested_by, requester_name = self.request_identity(body)
|
||||
node_id = body.get("node_id")
|
||||
reason = body.get("reason", "编程与调试")
|
||||
duration = body.get("duration_seconds", 4 * 3600)
|
||||
if not isinstance(node_id, str):
|
||||
raise ValueError("node_id is required")
|
||||
if not isinstance(reason, str) or not reason.strip() or len(reason) > 300:
|
||||
raise ValueError("invalid grant reason")
|
||||
if not isinstance(duration, int) or not 3600 <= duration <= 8 * 3600:
|
||||
raise ValueError("grant duration must be between 1 and 8 hours")
|
||||
with self.app.db() as conn:
|
||||
node = conn.execute(
|
||||
"SELECT policy_json FROM nodes WHERE id=?", (node_id,)
|
||||
).fetchone()
|
||||
if node is None:
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "node not found"})
|
||||
return
|
||||
policy = json.loads(node["policy_json"])
|
||||
scopes = policy.get("enabled_actions", [])
|
||||
roots = policy.get("allowed_roots", [])
|
||||
if not scopes or not roots:
|
||||
self.send_json(
|
||||
HTTPStatus.CONFLICT,
|
||||
{"error": "node has not reported a usable safety policy"},
|
||||
)
|
||||
return
|
||||
grant_id = new_id("grant")
|
||||
grant = {
|
||||
"id": grant_id,
|
||||
"node_id": node_id,
|
||||
"scopes": scopes,
|
||||
"roots": roots,
|
||||
"reason": reason.strip(),
|
||||
"duration_seconds": duration,
|
||||
"requested_by": requested_by,
|
||||
"requester_name": requester_name,
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO grants(id,node_id,scopes_json,roots_json,reason,"
|
||||
"duration_seconds,status,created_at,requested_by,requester_name) "
|
||||
"VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
grant_id,
|
||||
node_id,
|
||||
compact_json(grant["scopes"]),
|
||||
compact_json(grant["roots"]),
|
||||
grant["reason"],
|
||||
duration,
|
||||
"pending",
|
||||
now_ts(),
|
||||
requested_by,
|
||||
requester_name,
|
||||
),
|
||||
)
|
||||
try:
|
||||
notification = self.app.send_grant_email(grant)
|
||||
except Exception as exc:
|
||||
print(f"grant email failed: {exc}", file=sys.stderr)
|
||||
notification = "failed"
|
||||
self.send_json(
|
||||
HTTPStatus.CREATED,
|
||||
{
|
||||
"grant_id": grant_id,
|
||||
"status": "pending",
|
||||
"requested_by": requested_by,
|
||||
"requester_name": requester_name,
|
||||
"notification": notification,
|
||||
"duration_seconds": duration,
|
||||
"scopes": grant["scopes"],
|
||||
"roots": grant["roots"],
|
||||
},
|
||||
)
|
||||
|
||||
def grant_approval_page(self) -> None:
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
grant_id = query.get("id", [""])[0]
|
||||
token = query.get("token", [""])[0]
|
||||
if not self.app.verify_grant_token(grant_id, token):
|
||||
self.send_html(HTTPStatus.UNAUTHORIZED, "<h1>链接无效或已过期</h1>")
|
||||
return
|
||||
with self.app.db() as conn:
|
||||
row = conn.execute("SELECT * FROM grants WHERE id=?", (grant_id,)).fetchone()
|
||||
if row is None or row["status"] != "pending":
|
||||
self.send_html(HTTPStatus.CONFLICT, "<h1>申请已处理或不存在</h1>")
|
||||
return
|
||||
content = f"""<!doctype html><meta charset="utf-8">
|
||||
<title>苍耳服务器临时授权</title>
|
||||
<h1>苍耳服务器临时授权</h1>
|
||||
<p>主控:苍耳。是否批准,以苍耳本次选择为准。</p>
|
||||
<p>申请者:{html.escape(row["requester_name"])} ({html.escape(row["requested_by"])})</p>
|
||||
<p>申请目的:{html.escape(row["reason"])}</p>
|
||||
<p>时长:{row["duration_seconds"] // 3600} 小时</p>
|
||||
<p>期间可以:检查服务器、同步和修改代码、运行测试、查看故障日志。</p>
|
||||
<p>不能:修改系统账户、密钥、网络、防火墙、磁盘和系统启动,
|
||||
也不能操作受管工作区以外的个人文件。</p>
|
||||
<p>授权到期自动失效,可从新加坡端提前撤销。</p>
|
||||
<form method="post" action="/approve/grant">
|
||||
<input type="hidden" name="id" value="{html.escape(grant_id)}">
|
||||
<input type="hidden" name="token" value="{html.escape(token)}">
|
||||
<button type="submit">批准本次临时授权</button>
|
||||
</form>"""
|
||||
self.send_html(HTTPStatus.OK, content)
|
||||
|
||||
def approve_grant_form(self) -> None:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if size <= 0 or size > 16_384:
|
||||
raise ValueError("invalid form body size")
|
||||
form = parse_qs(self.rfile.read(size).decode("utf-8"))
|
||||
grant_id = form.get("id", [""])[0]
|
||||
token = form.get("token", [""])[0]
|
||||
if not self.app.verify_grant_token(grant_id, token):
|
||||
self.send_html(HTTPStatus.UNAUTHORIZED, "<h1>链接无效或已过期</h1>")
|
||||
return
|
||||
approved_at = now_ts()
|
||||
with self.app.db() as conn:
|
||||
row = conn.execute("SELECT * FROM grants WHERE id=?", (grant_id,)).fetchone()
|
||||
if row is None or row["status"] != "pending":
|
||||
self.send_html(HTTPStatus.CONFLICT, "<h1>申请已处理或不存在</h1>")
|
||||
return
|
||||
expires_at = approved_at + row["duration_seconds"]
|
||||
conn.execute(
|
||||
"UPDATE grants SET status='active',approved_at=?,expires_at=? "
|
||||
"WHERE id=? AND status='pending'",
|
||||
(approved_at, expires_at, grant_id),
|
||||
)
|
||||
self.send_html(
|
||||
HTTPStatus.OK,
|
||||
f"<h1>副控运维授权成功</h1><p>苍耳仍保留最终控制权;"
|
||||
f"本次授权将在 {expires_at} 自动失效,可以关闭此页面。</p>",
|
||||
)
|
||||
|
||||
def revoke_grant(self, grant_id: str) -> None:
|
||||
if not self.require_role("admin", "owner"):
|
||||
return
|
||||
with self.app.db() as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE grants SET status='revoked',revoked_at=? "
|
||||
"WHERE id=? AND status='active'",
|
||||
(now_ts(), grant_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
self.send_json(HTTPStatus.CONFLICT, {"error": "grant is not active"})
|
||||
return
|
||||
self.send_json(HTTPStatus.OK, {"grant_id": grant_id, "status": "revoked"})
|
||||
|
||||
def decide_task(self, task_id: str, decision: str) -> None:
|
||||
if not self.require_role("admin", "owner"):
|
||||
return
|
||||
with self.app.db() as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE tasks SET status=?,approved_at=? "
|
||||
"WHERE id=? AND status='pending'",
|
||||
(decision, now_ts(), task_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
self.send_json(
|
||||
HTTPStatus.CONFLICT, {"error": "task is not pending or does not exist"}
|
||||
)
|
||||
return
|
||||
self.send_json(HTTPStatus.OK, {"task_id": task_id, "status": decision})
|
||||
|
||||
def next_task(self, node_id: str) -> None:
|
||||
if self.app.node_for_token(node_id, self.headers) is None:
|
||||
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized node"})
|
||||
return
|
||||
now = now_ts()
|
||||
with self.app.db() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"UPDATE grants SET status='expired' "
|
||||
"WHERE status='active' AND expires_at<=?",
|
||||
(now,),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='pending',approved_at=NULL,grant_id=NULL "
|
||||
"WHERE node_id=? AND status='approved' AND grant_id IS NOT NULL "
|
||||
"AND grant_id NOT IN (SELECT id FROM grants WHERE status='active' "
|
||||
"AND expires_at>?)",
|
||||
(node_id, now),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='approved',leased_until=NULL "
|
||||
"WHERE node_id=? AND status='leased' AND leased_until<?",
|
||||
(node_id, now),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id,action,args_json FROM tasks "
|
||||
"WHERE node_id=? AND status='approved' ORDER BY approved_at,id LIMIT 1",
|
||||
(node_id,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='leased',leased_until=? WHERE id=?",
|
||||
(now + TASK_LEASE_SECONDS, row["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE nodes SET last_seen_at=? WHERE id=?", (now, node_id)
|
||||
)
|
||||
conn.commit()
|
||||
if row is None:
|
||||
self.send_json(HTTPStatus.OK, {"task": None})
|
||||
return
|
||||
self.send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"task": {
|
||||
"id": row["id"],
|
||||
"action": row["action"],
|
||||
"args": json.loads(row["args_json"]),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def complete_task(self, task_id: str) -> None:
|
||||
body = self.read_json()
|
||||
if len(compact_json(body).encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ValueError("task result is too large")
|
||||
with self.app.db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT node_id,status FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "task not found"})
|
||||
return
|
||||
if self.app.node_for_token(row["node_id"], self.headers) is None:
|
||||
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized node"})
|
||||
return
|
||||
if row["status"] != "leased":
|
||||
self.send_json(HTTPStatus.CONFLICT, {"error": "task is not leased"})
|
||||
return
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='completed',completed_at=?,result_json=? "
|
||||
"WHERE id=?",
|
||||
(now_ts(), compact_json(body), task_id),
|
||||
)
|
||||
self.send_json(HTTPStatus.OK, {"task_id": task_id, "status": "completed"})
|
||||
|
||||
def append_event(self, task_id: str) -> None:
|
||||
body = self.read_json()
|
||||
kind = body.get("kind")
|
||||
payload = body.get("payload", {})
|
||||
if (
|
||||
not isinstance(kind, str)
|
||||
or not kind
|
||||
or len(kind) > 64
|
||||
or not all(char.isalnum() or char in "._-" for char in kind)
|
||||
):
|
||||
raise ValueError("invalid event kind")
|
||||
encoded = compact_json(payload)
|
||||
if len(encoded.encode("utf-8")) > 32 * 1024:
|
||||
raise ValueError("event payload is too large")
|
||||
with self.app.db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT node_id FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "task not found"})
|
||||
return
|
||||
if self.app.node_for_token(row["node_id"], self.headers) is None:
|
||||
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized node"})
|
||||
return
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO task_events(task_id,created_at,kind,payload_json) "
|
||||
"VALUES(?,?,?,?)",
|
||||
(task_id, now_ts(), kind, encoded),
|
||||
)
|
||||
self.send_json(
|
||||
HTTPStatus.CREATED, {"task_id": task_id, "seq": cursor.lastrowid}
|
||||
)
|
||||
|
||||
def heartbeat(self, node_id: str) -> None:
|
||||
node = self.app.node_for_token(node_id, self.headers)
|
||||
if node is None:
|
||||
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized node"})
|
||||
return
|
||||
body = self.read_json()
|
||||
version = str(body.get("agent_version", ""))[:64]
|
||||
policy = self.validated_node_policy(body.get("policy", {}))
|
||||
with self.app.db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE nodes SET last_seen_at=?,agent_version=?,policy_json=? WHERE id=?",
|
||||
(now_ts(), version, compact_json(policy), node_id),
|
||||
)
|
||||
self.send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
|
||||
class AppServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], app: ControlPlane):
|
||||
self.app = app
|
||||
super().__init__(address, Handler)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bind", default=os.environ.get("CANGER_BIND", "127.0.0.1"))
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=int(os.environ.get("CANGER_PORT", "8787"))
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get(
|
||||
"CANGER_CONTROL_DB", "/var/lib/canger-control/control.sqlite3"
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
app = ControlPlane(
|
||||
args.db,
|
||||
os.environ.get("CANGER_ADMIN_TOKEN", ""),
|
||||
os.environ.get("CANGER_OWNER_TOKEN", ""),
|
||||
os.environ.get("CANGER_PERSONA_TOKEN", ""),
|
||||
os.environ.get("CANGER_OPERATOR_TOKEN", ""),
|
||||
os.environ.get("CANGER_APPROVAL_SIGNING_KEY", ""),
|
||||
os.environ.get("CANGER_OWNER_EMAIL", ""),
|
||||
os.environ.get("CANGER_PUBLIC_URL", ""),
|
||||
)
|
||||
server = AppServer((args.bind, args.port), app)
|
||||
print(f"canger-control listening on {args.bind}:{args.port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
111
remote-node/shared.py
Normal file
111
remote-node/shared.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Shared protocol rules for the Cang'er remote node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
MAX_JSON_BYTES = 256 * 1024
|
||||
MAX_RESULT_BYTES = 512 * 1024
|
||||
PAIRING_TTL_SECONDS = 10 * 60
|
||||
TASK_LEASE_SECONDS = 5 * 60
|
||||
|
||||
ACTION_NAMES = {
|
||||
"system.status",
|
||||
"repo.status",
|
||||
"repo.fetch",
|
||||
"service.logs",
|
||||
"command.run",
|
||||
}
|
||||
|
||||
NODE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}$")
|
||||
|
||||
|
||||
def now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{secrets.token_hex(12)}"
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def new_pairing_code() -> str:
|
||||
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
return "-".join(
|
||||
"".join(secrets.choice(alphabet) for _ in range(4)) for _ in range(3)
|
||||
)
|
||||
|
||||
|
||||
def secret_hash(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def secret_matches(value: str, expected_hash: str) -> bool:
|
||||
return hmac.compare_digest(secret_hash(value), expected_hash)
|
||||
|
||||
|
||||
def compact_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def validate_node_name(value: Any) -> str:
|
||||
if not isinstance(value, str) or not NODE_NAME_RE.fullmatch(value):
|
||||
raise ValueError("node_name must use 1-64 letters, digits, dot, underscore or dash")
|
||||
return value
|
||||
|
||||
|
||||
def validate_task(action: Any, args: Any) -> tuple[str, dict[str, Any]]:
|
||||
if not isinstance(action, str) or action not in ACTION_NAMES:
|
||||
raise ValueError(f"unsupported action: {action!r}")
|
||||
if args is None:
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
raise ValueError("args must be an object")
|
||||
|
||||
if action == "system.status":
|
||||
if args:
|
||||
raise ValueError("system.status does not accept args")
|
||||
elif action in {"repo.status", "repo.fetch"}:
|
||||
if set(args) != {"repo"} or not isinstance(args["repo"], str):
|
||||
raise ValueError(f"{action} requires string arg: repo")
|
||||
elif action == "service.logs":
|
||||
if not isinstance(args.get("service"), str) or not SERVICE_NAME_RE.fullmatch(
|
||||
args["service"]
|
||||
):
|
||||
raise ValueError("service.logs requires a safe service name")
|
||||
lines = args.get("lines", 200)
|
||||
if not isinstance(lines, int) or not 1 <= lines <= 2000:
|
||||
raise ValueError("service.logs lines must be between 1 and 2000")
|
||||
args = {"service": args["service"], "lines": lines}
|
||||
elif action == "command.run":
|
||||
argv = args.get("argv")
|
||||
cwd = args.get("cwd")
|
||||
timeout = args.get("timeout_seconds", 300)
|
||||
if (
|
||||
not isinstance(argv, list)
|
||||
or not argv
|
||||
or len(argv) > 64
|
||||
or any(not isinstance(item, str) or len(item) > 4096 for item in argv)
|
||||
):
|
||||
raise ValueError("command.run argv must be a non-empty string array")
|
||||
if not isinstance(cwd, str):
|
||||
raise ValueError("command.run requires string arg: cwd")
|
||||
if not isinstance(timeout, int) or not 1 <= timeout <= 1800:
|
||||
raise ValueError("command.run timeout_seconds must be between 1 and 1800")
|
||||
args = {"argv": argv, "cwd": cwd, "timeout_seconds": timeout}
|
||||
|
||||
encoded = compact_json(args).encode("utf-8")
|
||||
if len(encoded) > MAX_JSON_BYTES:
|
||||
raise ValueError("task args are too large")
|
||||
return action, args
|
||||
22
remote-node/systemd/canger-control.service
Normal file
22
remote-node/systemd/canger-control.service
Normal file
@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Canger Singapore Control Plane
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=canger-control
|
||||
Group=canger-control
|
||||
WorkingDirectory=/opt/zhuyuan/cang-ying/remote-node
|
||||
EnvironmentFile=/etc/canger-control.env
|
||||
ExecStart=/usr/bin/python3 /opt/zhuyuan/cang-ying/remote-node/server/control_plane.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/canger-control
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
21
remote-node/systemd/canger-node.service
Normal file
21
remote-node/systemd/canger-node.service
Normal file
@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Canger Home Ubuntu Outbound Node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=canger-agent
|
||||
Group=canger-agent
|
||||
WorkingDirectory=/opt/canger-node
|
||||
ExecStart=/usr/bin/python3 /opt/canger-node/canger_node.py --config /etc/canger-node/config.json run
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/etc/canger-node
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
299
remote-node/tests/test_end_to_end.py
Normal file
299
remote-node/tests/test_end_to_end.py
Normal file
@ -0,0 +1,299 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"control_plane", ROOT / "server" / "control_plane.py"
|
||||
)
|
||||
control_plane = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader
|
||||
spec.loader.exec_module(control_plane)
|
||||
|
||||
|
||||
class FlowTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.admin = "admin-" + "a" * 32
|
||||
self.owner = "owner-" + "o" * 32
|
||||
self.persona = "persona-" + "p" * 32
|
||||
self.operator = "operator-" + "z" * 32
|
||||
app = control_plane.ControlPlane(
|
||||
os.path.join(self.temp.name, "control.sqlite3"),
|
||||
self.admin,
|
||||
self.owner,
|
||||
self.persona,
|
||||
self.operator,
|
||||
"signing-" + "s" * 32,
|
||||
)
|
||||
self.app = app
|
||||
self.server = control_plane.AppServer(("127.0.0.1", 0), app)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.base = f"http://127.0.0.1:{self.server.server_port}"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def api(self, method, path, token=None, body=None):
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
headers = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
with urlopen(
|
||||
Request(self.base + path, data=data, headers=headers, method=method)
|
||||
) as response:
|
||||
return json.load(response)
|
||||
|
||||
def test_pair_approve_execute_receipt(self) -> None:
|
||||
pairing = self.api("POST", "/v1/pairings", self.admin, {})
|
||||
node = self.api(
|
||||
"POST",
|
||||
"/v1/pairings/claim",
|
||||
body={
|
||||
"code": pairing["code"],
|
||||
"node_name": "canger-home-ubuntu",
|
||||
"agent_version": "test",
|
||||
"policy": {
|
||||
"allowed_roots": ["/srv/canger"],
|
||||
"enabled_actions": [
|
||||
"system.status",
|
||||
"repo.status",
|
||||
"command.run",
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaises(HTTPError) as reused:
|
||||
self.api(
|
||||
"POST",
|
||||
"/v1/pairings/claim",
|
||||
body={
|
||||
"code": pairing["code"],
|
||||
"node_name": "attacker",
|
||||
"policy": {
|
||||
"allowed_roots": ["/srv/canger"],
|
||||
"enabled_actions": ["system.status"],
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(reused.exception.code, 401)
|
||||
|
||||
created = self.api(
|
||||
"POST",
|
||||
"/v1/tasks",
|
||||
self.operator,
|
||||
{
|
||||
"node_id": node["node_id"],
|
||||
"action": "system.status",
|
||||
"args": {},
|
||||
"requester_id": "ICE-GL-ZY001",
|
||||
"requester_name": "铸渊",
|
||||
},
|
||||
)
|
||||
waiting = self.api(
|
||||
"GET",
|
||||
f"/v1/nodes/{node['node_id']}/tasks/next",
|
||||
node["node_token"],
|
||||
)
|
||||
self.assertIsNone(waiting["task"])
|
||||
|
||||
self.api(
|
||||
"POST",
|
||||
f"/v1/tasks/{created['task_id']}/approve",
|
||||
self.owner,
|
||||
{},
|
||||
)
|
||||
leased = self.api(
|
||||
"GET",
|
||||
f"/v1/nodes/{node['node_id']}/tasks/next",
|
||||
node["node_token"],
|
||||
)
|
||||
self.assertEqual(leased["task"]["action"], "system.status")
|
||||
|
||||
event = self.api(
|
||||
"POST",
|
||||
f"/v1/tasks/{created['task_id']}/events",
|
||||
node["node_token"],
|
||||
{"kind": "started", "payload": {"agent_version": "test"}},
|
||||
)
|
||||
self.assertGreater(event["seq"], 0)
|
||||
events = self.api(
|
||||
"GET",
|
||||
f"/v1/tasks/{created['task_id']}/events?after=0",
|
||||
self.persona,
|
||||
)
|
||||
self.assertEqual(events["events"][0]["kind"], "started")
|
||||
|
||||
self.api(
|
||||
"POST",
|
||||
f"/v1/tasks/{created['task_id']}/result",
|
||||
node["node_token"],
|
||||
{"ok": True, "output": "healthy"},
|
||||
)
|
||||
receipt = self.api(
|
||||
"GET", f"/v1/tasks/{created['task_id']}", self.operator
|
||||
)
|
||||
self.assertEqual(receipt["status"], "completed")
|
||||
self.assertEqual(receipt["result"]["output"], "healthy")
|
||||
|
||||
def test_time_limited_grant_auto_approves_safe_task(self) -> None:
|
||||
pairing = self.api("POST", "/v1/pairings", self.admin, {})
|
||||
node = self.api(
|
||||
"POST",
|
||||
"/v1/pairings/claim",
|
||||
body={
|
||||
"code": pairing["code"],
|
||||
"node_name": "canger-grant-test",
|
||||
"agent_version": "test",
|
||||
"policy": {
|
||||
"allowed_roots": ["/srv/canger"],
|
||||
"enabled_actions": ["system.status", "repo.status"],
|
||||
},
|
||||
},
|
||||
)
|
||||
grant = self.api(
|
||||
"POST",
|
||||
"/v1/grants",
|
||||
self.operator,
|
||||
{
|
||||
"node_id": node["node_id"],
|
||||
"duration_seconds": 3600,
|
||||
"reason": "测试标准开发会话",
|
||||
"requester_id": "ICE-GL-ZY001",
|
||||
"requester_name": "铸渊",
|
||||
},
|
||||
)
|
||||
link_expiry = control_plane.now_ts() + 1800
|
||||
token = self.app.grant_token(grant["grant_id"], link_expiry)
|
||||
form = urlencode({"id": grant["grant_id"], "token": token}).encode()
|
||||
request = Request(
|
||||
self.base + "/approve/grant",
|
||||
data=form,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(request) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
task = self.api(
|
||||
"POST",
|
||||
"/v1/tasks",
|
||||
self.operator,
|
||||
{
|
||||
"node_id": node["node_id"],
|
||||
"action": "repo.status",
|
||||
"args": {"repo": "/srv/canger/project"},
|
||||
"requester_id": "ICE-GL-ZY001",
|
||||
"requester_name": "铸渊",
|
||||
},
|
||||
)
|
||||
self.assertEqual(task["status"], "approved")
|
||||
self.assertFalse(task["approval_required"])
|
||||
self.assertEqual(task["grant_id"], grant["grant_id"])
|
||||
|
||||
def test_persona_can_request_but_cannot_approve_its_own_grant(self) -> None:
|
||||
pairing = self.api("POST", "/v1/pairings", self.admin, {})
|
||||
node = self.api(
|
||||
"POST",
|
||||
"/v1/pairings/claim",
|
||||
body={
|
||||
"code": pairing["code"],
|
||||
"node_name": "canger-persona-request-test",
|
||||
"agent_version": "test",
|
||||
"policy": {
|
||||
"allowed_roots": ["/srv/canger"],
|
||||
"enabled_actions": ["system.status", "repo.status"],
|
||||
},
|
||||
},
|
||||
)
|
||||
grant = self.api(
|
||||
"POST",
|
||||
"/v1/grants",
|
||||
self.persona,
|
||||
{
|
||||
"node_id": node["node_id"],
|
||||
"duration_seconds": 4 * 3600,
|
||||
"reason": "检查苍耳仓库状态",
|
||||
"requester_id": "PTS-VA-001-EED",
|
||||
"requester_name": "耳耳蛋",
|
||||
},
|
||||
)
|
||||
self.assertEqual(grant["status"], "pending")
|
||||
self.assertEqual(grant["requested_by"], "PTS-VA-001-EED")
|
||||
self.assertEqual(grant["requester_name"], "耳耳蛋")
|
||||
self.assertEqual(grant["notification"], "not_configured")
|
||||
self.assertEqual(grant["roots"], ["/srv/canger"])
|
||||
|
||||
with self.assertRaises(HTTPError) as unauthorized:
|
||||
self.api(
|
||||
"POST",
|
||||
f"/v1/tasks/{grant['grant_id']}/approve",
|
||||
self.persona,
|
||||
{},
|
||||
)
|
||||
self.assertEqual(unauthorized.exception.code, 401)
|
||||
|
||||
pending_task = self.api(
|
||||
"POST",
|
||||
"/v1/tasks",
|
||||
self.persona,
|
||||
{
|
||||
"node_id": node["node_id"],
|
||||
"action": "system.status",
|
||||
"args": {},
|
||||
"requester_id": "PTS-VA-001-EED",
|
||||
"requester_name": "耳耳蛋",
|
||||
},
|
||||
)
|
||||
self.assertEqual(pending_task["status"], "pending")
|
||||
self.assertEqual(pending_task["requested_by"], "PTS-VA-001-EED")
|
||||
|
||||
link_expiry = control_plane.now_ts() + 1800
|
||||
token = self.app.grant_token(grant["grant_id"], link_expiry)
|
||||
form = urlencode({"id": grant["grant_id"], "token": token}).encode()
|
||||
approval = Request(
|
||||
self.base + "/approve/grant",
|
||||
data=form,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(approval) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
approved_task = self.api(
|
||||
"POST",
|
||||
"/v1/tasks",
|
||||
self.persona,
|
||||
{
|
||||
"node_id": node["node_id"],
|
||||
"action": "repo.status",
|
||||
"args": {"repo": "/srv/canger/project"},
|
||||
"requester_id": "PTS-VA-001-EED",
|
||||
"requester_name": "耳耳蛋",
|
||||
},
|
||||
)
|
||||
self.assertEqual(approved_task["status"], "approved")
|
||||
self.assertEqual(approved_task["requested_by"], "PTS-VA-001-EED")
|
||||
self.assertEqual(approved_task["grant_id"], grant["grant_id"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
182
remote-node/tools/canger_persona.py
Executable file
182
remote-node/tools/canger_persona.py
Executable file
@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repository entry for Canger's persona to request and use approved access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from cangerctl import request
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_PROFILE = ROOT / "config" / "pufferfish-persona.json"
|
||||
|
||||
|
||||
def load_profile(path: str) -> dict[str, Any]:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
profile = json.load(handle)
|
||||
required = {
|
||||
"persona_id",
|
||||
"human_owner",
|
||||
"node_id",
|
||||
"control_url",
|
||||
"default_hours",
|
||||
"minimum_hours",
|
||||
"maximum_hours",
|
||||
}
|
||||
missing = sorted(required - set(profile))
|
||||
if missing:
|
||||
raise RuntimeError("persona profile is missing: " + ", ".join(missing))
|
||||
return profile
|
||||
|
||||
|
||||
def persona_token() -> str:
|
||||
value = os.environ.get("CANGER_PERSONA_TOKEN", "")
|
||||
if len(value) < 24:
|
||||
raise RuntimeError(
|
||||
"CANGER_PERSONA_TOKEN is unavailable; run from the registered "
|
||||
"Singapore persona environment"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="苍耳人格体的受控运维申请与执行入口"
|
||||
)
|
||||
parser.add_argument("--profile", default=str(DEFAULT_PROFILE))
|
||||
parser.add_argument("--url")
|
||||
parser.add_argument(
|
||||
"--requester-name",
|
||||
default=os.environ.get("GUANGHU_PERSONA_NAME", ""),
|
||||
help="本次实际申请者名称;也可用 GUANGHU_PERSONA_NAME",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--requester-id",
|
||||
default=os.environ.get("GUANGHU_PERSONA_ID", ""),
|
||||
help="本次实际申请者正式编号;也可用 GUANGHU_PERSONA_ID",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
request_access = sub.add_parser("request-access")
|
||||
request_access.add_argument("--hours", type=int)
|
||||
request_access.add_argument("--reason", required=True)
|
||||
|
||||
task = sub.add_parser("task")
|
||||
task.add_argument("--action", required=True)
|
||||
task.add_argument("--args", default="{}")
|
||||
|
||||
show = sub.add_parser("show")
|
||||
show.add_argument("task_id")
|
||||
|
||||
watch = sub.add_parser("watch")
|
||||
watch.add_argument("task_id")
|
||||
|
||||
sub.add_parser("grants")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
profile = load_profile(args.profile)
|
||||
base = args.url or os.environ.get("CANGER_CONTROL_URL") or profile["control_url"]
|
||||
token = persona_token()
|
||||
node_id = profile["node_id"]
|
||||
if args.command in {"request-access", "task"}:
|
||||
if not args.requester_name or not args.requester_id:
|
||||
raise RuntimeError(
|
||||
"requester name and id are required; provide --requester-name "
|
||||
"and --requester-id, or GUANGHU_PERSONA_NAME/GUANGHU_PERSONA_ID"
|
||||
)
|
||||
|
||||
if args.command == "request-access":
|
||||
hours = args.hours or int(profile["default_hours"])
|
||||
minimum = int(profile["minimum_hours"])
|
||||
maximum = int(profile["maximum_hours"])
|
||||
if not minimum <= hours <= maximum:
|
||||
raise RuntimeError(f"hours must be between {minimum} and {maximum}")
|
||||
result = request(
|
||||
base,
|
||||
token,
|
||||
"POST",
|
||||
"/v1/grants",
|
||||
{
|
||||
"node_id": node_id,
|
||||
"duration_seconds": hours * 3600,
|
||||
"reason": args.reason,
|
||||
"requester_name": args.requester_name,
|
||||
"requester_id": args.requester_id,
|
||||
},
|
||||
)
|
||||
elif args.command == "task":
|
||||
result = request(
|
||||
base,
|
||||
token,
|
||||
"POST",
|
||||
"/v1/tasks",
|
||||
{
|
||||
"node_id": node_id,
|
||||
"action": args.action,
|
||||
"args": json.loads(args.args),
|
||||
"requester_name": args.requester_name,
|
||||
"requester_id": args.requester_id,
|
||||
},
|
||||
)
|
||||
elif args.command == "show":
|
||||
result = request(
|
||||
base,
|
||||
token,
|
||||
"GET",
|
||||
f"/v1/tasks/{args.task_id}",
|
||||
)
|
||||
elif args.command == "grants":
|
||||
result = request(base, token, "GET", "/v1/grants")
|
||||
else:
|
||||
after = 0
|
||||
while True:
|
||||
events = request(
|
||||
base,
|
||||
token,
|
||||
"GET",
|
||||
f"/v1/tasks/{args.task_id}/events?after={after}",
|
||||
)
|
||||
for event in events["events"]:
|
||||
after = max(after, event["seq"])
|
||||
print(
|
||||
f"[{event['seq']}] {event['kind']}: "
|
||||
+ json.dumps(event["payload"], ensure_ascii=False),
|
||||
flush=True,
|
||||
)
|
||||
state = request(
|
||||
base,
|
||||
token,
|
||||
"GET",
|
||||
f"/v1/tasks/{args.task_id}",
|
||||
)
|
||||
if state["status"] in {"completed", "rejected"}:
|
||||
result = state
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except HTTPError as exc:
|
||||
print(f"error: control plane returned HTTP {exc.code}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
except URLError as exc:
|
||||
print(f"error: cannot reach control plane: {exc.reason}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
except (RuntimeError, ValueError, json.JSONDecodeError, OSError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
168
remote-node/tools/cangerctl.py
Executable file
168
remote-node/tools/cangerctl.py
Executable file
@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small CLI used by the persona and owner on the Singapore server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def request(
|
||||
base: str, token: str, method: str, path: str, body: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = Request(base.rstrip("/") + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(req, timeout=30) as response:
|
||||
return json.load(response)
|
||||
except HTTPError as exc:
|
||||
print(exc.read().decode("utf-8", errors="replace"), file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
def token_for(role: str) -> str:
|
||||
name = {
|
||||
"admin": "CANGER_ADMIN_TOKEN",
|
||||
"owner": "CANGER_OWNER_TOKEN",
|
||||
"persona": "CANGER_PERSONA_TOKEN",
|
||||
"operator": "CANGER_OPERATOR_TOKEN",
|
||||
}[role]
|
||||
value = os.environ.get(name, "")
|
||||
if not value:
|
||||
raise RuntimeError(f"{name} is not set")
|
||||
return value
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--url", default=os.environ.get("CANGER_CONTROL_URL", "http://127.0.0.1:8787")
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser("pair-code")
|
||||
sub.add_parser("nodes")
|
||||
task = sub.add_parser("task")
|
||||
task.add_argument("--node", required=True)
|
||||
task.add_argument("--action", required=True)
|
||||
task.add_argument("--args", default="{}")
|
||||
approve = sub.add_parser("approve")
|
||||
approve.add_argument("task_id")
|
||||
reject = sub.add_parser("reject")
|
||||
reject.add_argument("task_id")
|
||||
show = sub.add_parser("show")
|
||||
show.add_argument("task_id")
|
||||
watch = sub.add_parser("watch")
|
||||
watch.add_argument("task_id")
|
||||
grant = sub.add_parser("grant")
|
||||
grant.add_argument("--node", required=True)
|
||||
grant.add_argument("--hours", type=int, default=4)
|
||||
grant.add_argument("--reason", default="苍耳人格体编程、调试与诊断")
|
||||
sub.add_parser("grants")
|
||||
revoke = sub.add_parser("revoke")
|
||||
revoke.add_argument("grant_id")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.command == "pair-code":
|
||||
result = request(args.url, token_for("admin"), "POST", "/v1/pairings", {})
|
||||
elif args.command == "nodes":
|
||||
result = request(args.url, token_for("operator"), "GET", "/v1/nodes")
|
||||
elif args.command == "task":
|
||||
result = request(
|
||||
args.url,
|
||||
token_for("operator"),
|
||||
"POST",
|
||||
"/v1/tasks",
|
||||
{
|
||||
"node_id": args.node,
|
||||
"action": args.action,
|
||||
"args": json.loads(args.args),
|
||||
},
|
||||
)
|
||||
elif args.command == "approve":
|
||||
result = request(
|
||||
args.url,
|
||||
token_for("owner"),
|
||||
"POST",
|
||||
f"/v1/tasks/{args.task_id}/approve",
|
||||
{},
|
||||
)
|
||||
elif args.command == "reject":
|
||||
result = request(
|
||||
args.url,
|
||||
token_for("owner"),
|
||||
"POST",
|
||||
f"/v1/tasks/{args.task_id}/reject",
|
||||
{},
|
||||
)
|
||||
elif args.command == "show":
|
||||
result = request(
|
||||
args.url,
|
||||
token_for("operator"),
|
||||
"GET",
|
||||
f"/v1/tasks/{args.task_id}",
|
||||
)
|
||||
elif args.command == "watch":
|
||||
after = 0
|
||||
while True:
|
||||
events = request(
|
||||
args.url,
|
||||
token_for("operator"),
|
||||
"GET",
|
||||
f"/v1/tasks/{args.task_id}/events?after={after}",
|
||||
)
|
||||
for event in events["events"]:
|
||||
after = max(after, event["seq"])
|
||||
print(
|
||||
f"[{event['seq']}] {event['kind']}: "
|
||||
+ json.dumps(event["payload"], ensure_ascii=False),
|
||||
flush=True,
|
||||
)
|
||||
state = request(
|
||||
args.url,
|
||||
token_for("operator"),
|
||||
"GET",
|
||||
f"/v1/tasks/{args.task_id}",
|
||||
)
|
||||
if state["status"] in {"completed", "rejected"}:
|
||||
result = state
|
||||
break
|
||||
time.sleep(1)
|
||||
elif args.command == "grant":
|
||||
result = request(
|
||||
args.url,
|
||||
token_for("operator"),
|
||||
"POST",
|
||||
"/v1/grants",
|
||||
{
|
||||
"node_id": args.node,
|
||||
"duration_seconds": args.hours * 3600,
|
||||
"reason": args.reason,
|
||||
},
|
||||
)
|
||||
elif args.command == "grants":
|
||||
result = request(args.url, token_for("operator"), "GET", "/v1/grants")
|
||||
else:
|
||||
result = request(
|
||||
args.url,
|
||||
token_for("owner"),
|
||||
"POST",
|
||||
f"/v1/grants/{args.grant_id}/revoke",
|
||||
{},
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user