feat(enterprise): route four domains through lighthouse
This commit is contained in:
parent
1d2a8a2f63
commit
c058974bba
12 changed files with 707 additions and 20 deletions
|
|
@ -9,3 +9,18 @@
|
||||||
|
|
||||||
当前阶段只声明入口与边界;“已登记”不等于对应责任主体已经接受、人格体已经出生或
|
当前阶段只声明入口与边界;“已登记”不等于对应责任主体已经接受、人格体已经出生或
|
||||||
域内全部功能已经实现。
|
域内全部功能已经实现。
|
||||||
|
|
||||||
|
## 身份、关系与责任
|
||||||
|
|
||||||
|
`enterprise_identity_service.py` 是运行于企业 Linux 物理层之上的灯塔服务,不要求
|
||||||
|
企业服务器改装一套新的物理操作系统。它只监听回环地址,由 `guanghu.chat` 的精确
|
||||||
|
API 路由对客户端开放:
|
||||||
|
|
||||||
|
- 编号解析:把 TCS-GL 人类编号路由到工作域、企业账号和私有仓库;
|
||||||
|
- 关系确认:由人类确认自己与人格体的认领关系;
|
||||||
|
- 责任回执:独立记录对域责任的接受、拒绝、延期或修改后接受;
|
||||||
|
- 仓库验证:登录凭证只透传给同机 Forgejo 验证,不写入数据库或日志。
|
||||||
|
|
||||||
|
`AGE` 只表示人格体物种,不能作为任何人格体的个体身份编号。现有 `PER-*` 作为历史
|
||||||
|
和当前可核验的个体身份引用保留;企业四域正式人格体身份编号前缀由光湖团队另行治理,
|
||||||
|
服务不会擅自生成。第五域现行个体身份编号继续使用 `ICE-P-*`。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Idempotently create the five private enterprise work repositories.
|
||||||
|
|
||||||
|
Run on the enterprise node with a short-lived Forgejo admin token stored in a
|
||||||
|
root-readable file. The token is never printed. Existing repositories are
|
||||||
|
inspected and preserved; a public or wrongly-owned collision fails closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def request(base: str, token: str, method: str, path: str, body: dict | None = None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
call = urllib.request.Request(base + path, data=data, method=method)
|
||||||
|
call.add_header("Authorization", f"token {token}")
|
||||||
|
call.add_header("Accept", "application/json")
|
||||||
|
if data is not None:
|
||||||
|
call.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(call, timeout=20) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return response.status, json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
raw = error.read()
|
||||||
|
detail = json.loads(raw) if raw else {}
|
||||||
|
return error.code, detail
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--registry", required=True)
|
||||||
|
parser.add_argument("--token-file", required=True)
|
||||||
|
parser.add_argument("--token-name", required=True)
|
||||||
|
parser.add_argument("--receipt", required=True)
|
||||||
|
parser.add_argument("--base", default="http://127.0.0.1:3341/api/v1")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
registry = json.loads(Path(args.registry).read_text(encoding="utf-8"))
|
||||||
|
token = Path(args.token_file).read_text(encoding="utf-8").strip()
|
||||||
|
if len(token) < 32:
|
||||||
|
raise SystemExit("short-lived Forgejo token unavailable")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
completed = False
|
||||||
|
try:
|
||||||
|
for human in registry["humans"]:
|
||||||
|
owner, name = human["repository"].split("/", 1)
|
||||||
|
status, existing = request(args.base, token, "GET", f"/repos/{owner}/{name}")
|
||||||
|
action = "PRESERVED"
|
||||||
|
if status == 404:
|
||||||
|
status, existing = request(
|
||||||
|
args.base,
|
||||||
|
token,
|
||||||
|
"POST",
|
||||||
|
f"/admin/users/{owner}/repos",
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"description": f"{human['display_name']} · {human['responsibility_domain']} 独立工作仓库",
|
||||||
|
"private": True,
|
||||||
|
"auto_init": True,
|
||||||
|
"default_branch": "main",
|
||||||
|
"gitignores": "",
|
||||||
|
"issue_labels": "",
|
||||||
|
"license": "",
|
||||||
|
"readme": "Default",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
action = "CREATED"
|
||||||
|
if status not in (200, 201):
|
||||||
|
raise RuntimeError(f"repository provision failed for {owner}/{name}: HTTP {status}")
|
||||||
|
actual_owner = existing.get("owner", {}).get("login")
|
||||||
|
if actual_owner != owner or existing.get("private") is not True:
|
||||||
|
raise RuntimeError(f"repository boundary invalid for {owner}/{name}")
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"human_number": human["human_number"],
|
||||||
|
"repository": f"{owner}/{name}",
|
||||||
|
"private": True,
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
completed = True
|
||||||
|
finally:
|
||||||
|
# Revoke the bootstrap token after success. On failure it remains in the
|
||||||
|
# root-only token file so an operator can inspect and retry deliberately.
|
||||||
|
if completed:
|
||||||
|
revoke_status, _ = request(
|
||||||
|
args.base,
|
||||||
|
token,
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/users/bingshuo/tokens/{args.token_name}",
|
||||||
|
)
|
||||||
|
if revoke_status not in (204, 404):
|
||||||
|
raise RuntimeError(f"bootstrap token revocation failed: HTTP {revoke_status}")
|
||||||
|
|
||||||
|
receipt = {
|
||||||
|
"schema": "guanghu.enterprise-private-repository-bootstrap-receipt/v1",
|
||||||
|
"state": "PASS",
|
||||||
|
"forgejo": "guanghu.chat/code",
|
||||||
|
"repositories": results,
|
||||||
|
"token_revoked": True,
|
||||||
|
"shared_initial_password_used": False,
|
||||||
|
"existing_user_passwords_modified": False,
|
||||||
|
}
|
||||||
|
Path(args.receipt).write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
print(json.dumps(receipt, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HoloLake enterprise identity, relationship and responsibility receipt service.
|
||||||
|
|
||||||
|
The service binds only to loopback. Nginx exposes exact routes. Credentials are
|
||||||
|
verified against the local Forgejo API and are never stored or logged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BIND = os.environ.get("GH_ENTERPRISE_IDENTITY_BIND", "127.0.0.1")
|
||||||
|
PORT = int(os.environ.get("GH_ENTERPRISE_IDENTITY_PORT", "8032"))
|
||||||
|
DB_PATH = os.environ.get(
|
||||||
|
"GH_ENTERPRISE_IDENTITY_DB",
|
||||||
|
"/var/lib/guanghu-enterprise-identity/identity.sqlite3",
|
||||||
|
)
|
||||||
|
REGISTRY_PATH = os.environ.get(
|
||||||
|
"GH_ENTERPRISE_IDENTITY_REGISTRY",
|
||||||
|
"/etc/guanghu/enterprise-identity-registry.json",
|
||||||
|
)
|
||||||
|
FORGEJO_USER_API = os.environ.get(
|
||||||
|
"GH_ENTERPRISE_FORGEJO_USER_API", "http://127.0.0.1:3341/api/v1/user"
|
||||||
|
)
|
||||||
|
RECEIPT_KEY = os.environ.get("GH_ENTERPRISE_RECEIPT_KEY", "")
|
||||||
|
MAX_BODY = 16_384
|
||||||
|
USERNAME = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
|
||||||
|
DECISIONS = {"ACCEPT", "REJECT", "DEFER", "ACCEPT_WITH_CHANGES"}
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> int:
|
||||||
|
return int(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def load_registry() -> dict:
|
||||||
|
registry = json.loads(Path(REGISTRY_PATH).read_text(encoding="utf-8"))
|
||||||
|
if registry.get("schema") != "guanghu.enterprise-identity-registry/v1":
|
||||||
|
raise ValueError("enterprise identity registry schema invalid")
|
||||||
|
humans = registry.get("humans")
|
||||||
|
if not isinstance(humans, list) or not humans:
|
||||||
|
raise ValueError("enterprise identity registry is empty")
|
||||||
|
numbers = [item.get("human_number") for item in humans]
|
||||||
|
usernames = [item.get("username") for item in humans]
|
||||||
|
if len(numbers) != len(set(numbers)) or len(usernames) != len(set(usernames)):
|
||||||
|
raise ValueError("enterprise identity registry identities must be unique")
|
||||||
|
for item in humans:
|
||||||
|
if not USERNAME.fullmatch(str(item.get("username", ""))):
|
||||||
|
raise ValueError("enterprise username invalid")
|
||||||
|
for persona in item.get("personas", []):
|
||||||
|
if persona.get("species") != "AGE" or str(persona.get("current_persona_identity", "")).startswith("AGE-"):
|
||||||
|
raise ValueError("AGE is a species and cannot be used as a persona identity number")
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def database() -> sqlite3.Connection:
|
||||||
|
path = Path(DB_PATH)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
db = sqlite3.connect(path)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
CREATE TABLE IF NOT EXISTS relationship_receipts (
|
||||||
|
receipt_id TEXT PRIMARY KEY,
|
||||||
|
idempotency_key TEXT NOT NULL UNIQUE,
|
||||||
|
human_number TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
registry_version TEXT NOT NULL,
|
||||||
|
decision TEXT NOT NULL,
|
||||||
|
observed_at INTEGER NOT NULL,
|
||||||
|
receipt_hash TEXT NOT NULL,
|
||||||
|
receipt_signature TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS responsibility_receipts (
|
||||||
|
receipt_id TEXT PRIMARY KEY,
|
||||||
|
idempotency_key TEXT NOT NULL UNIQUE,
|
||||||
|
human_number TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
responsibility_domain TEXT NOT NULL,
|
||||||
|
responsibility_version TEXT NOT NULL,
|
||||||
|
decision TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
observed_at INTEGER NOT NULL,
|
||||||
|
receipt_hash TEXT NOT NULL,
|
||||||
|
receipt_signature TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit (
|
||||||
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
observed_at INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
human_number_hash TEXT NOT NULL,
|
||||||
|
receipt_id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def find_human(registry: dict, number: str) -> dict | None:
|
||||||
|
encoded = number.encode("utf-8")
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in registry["humans"]
|
||||||
|
if hmac.compare_digest(item["human_number"].encode("utf-8"), encoded)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_basic(header: str) -> tuple[str, str] | None:
|
||||||
|
if not header.startswith("Basic "):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(header[6:], validate=True).decode("utf-8")
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
if not USERNAME.fullmatch(username) or not password or len(password) > 512:
|
||||||
|
return None
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
|
||||||
|
def verify_forgejo(username: str, password: str) -> bool:
|
||||||
|
request = urllib.request.Request(FORGEJO_USER_API)
|
||||||
|
credential = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||||
|
request.add_header("Authorization", f"Basic {credential}")
|
||||||
|
request.add_header("Accept", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
|
body = json.load(response)
|
||||||
|
return response.status == 200 and hmac.compare_digest(str(body.get("login", "")), username)
|
||||||
|
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def signed_receipt(payload: dict) -> dict:
|
||||||
|
if len(RECEIPT_KEY) < 32:
|
||||||
|
raise RuntimeError("receipt signing key unavailable")
|
||||||
|
body = canonical(payload)
|
||||||
|
receipt_hash = hashlib.sha256(body).hexdigest()
|
||||||
|
signature = hmac.new(RECEIPT_KEY.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
return {**payload, "receipt_hash": receipt_hash, "receipt_signature": signature}
|
||||||
|
|
||||||
|
|
||||||
|
def public_projection(registry: dict, human: dict, db: sqlite3.Connection | None = None) -> dict:
|
||||||
|
projection = {
|
||||||
|
"status": "RESOLVED",
|
||||||
|
"canonical_id": human["human_number"],
|
||||||
|
"subject": {
|
||||||
|
"id": human["human_number"],
|
||||||
|
"name": human["display_name"],
|
||||||
|
"domain": human["responsibility_domain"],
|
||||||
|
},
|
||||||
|
"work_entry": {
|
||||||
|
"domain": registry["work_entry_domain"],
|
||||||
|
"channel": registry["work_entry_channel"],
|
||||||
|
},
|
||||||
|
"repository_binding": {
|
||||||
|
"host": "guanghu.chat",
|
||||||
|
"username": human["username"],
|
||||||
|
"repository": human["repository"],
|
||||||
|
"private": True,
|
||||||
|
},
|
||||||
|
"persona_relationships": human["personas"],
|
||||||
|
"persona_identity_governance": registry["persona_identity_governance"],
|
||||||
|
"registry_version": registry["version"],
|
||||||
|
}
|
||||||
|
if db is not None:
|
||||||
|
relationship = db.execute(
|
||||||
|
"SELECT decision,observed_at,receipt_hash FROM relationship_receipts WHERE human_number=? ORDER BY observed_at DESC LIMIT 1",
|
||||||
|
(human["human_number"],),
|
||||||
|
).fetchone()
|
||||||
|
responsibility = db.execute(
|
||||||
|
"SELECT decision,observed_at,receipt_hash,responsibility_version FROM responsibility_receipts WHERE human_number=? ORDER BY observed_at DESC LIMIT 1",
|
||||||
|
(human["human_number"],),
|
||||||
|
).fetchone()
|
||||||
|
projection["relationship_confirmation"] = dict(relationship) if relationship else None
|
||||||
|
projection["responsibility_receipt"] = dict(responsibility) if responsibility else None
|
||||||
|
return projection
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "GuanghuEnterpriseIdentity/1.0"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: object) -> None:
|
||||||
|
# Never include headers or request bodies in logs.
|
||||||
|
print("[enterprise-identity] " + fmt % args)
|
||||||
|
|
||||||
|
def respond(self, status: int, body: dict) -> None:
|
||||||
|
encoded = json.dumps(body, ensure_ascii=False).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def body(self) -> dict:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length < 1 or length > MAX_BODY:
|
||||||
|
raise ValueError("request body size invalid")
|
||||||
|
value = json.loads(self.rfile.read(length).decode())
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("JSON object required")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def authenticated_human(self, registry: dict, payload: dict) -> tuple[dict, str] | None:
|
||||||
|
credentials = parse_basic(self.headers.get("Authorization", ""))
|
||||||
|
number = str(payload.get("human_number", ""))
|
||||||
|
human = find_human(registry, number)
|
||||||
|
if not credentials or not human:
|
||||||
|
return None
|
||||||
|
username, password = credentials
|
||||||
|
if not hmac.compare_digest(username, human["username"]) or not verify_forgejo(username, password):
|
||||||
|
return None
|
||||||
|
return human, username
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
try:
|
||||||
|
registry = load_registry()
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||||
|
return self.respond(503, {"ok": False, "error": f"registry unavailable: {error}"})
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
if parsed.path == "/health":
|
||||||
|
return self.respond(200, {"ok": True, "service": "guanghu-enterprise-identity", "registry_version": registry["version"], "age_species": "AGE", "valid_age_individual_numbers": 0})
|
||||||
|
if parsed.path == "/v1/resolve":
|
||||||
|
number = urllib.parse.parse_qs(parsed.query).get("id", [""])[0]
|
||||||
|
human = find_human(registry, number)
|
||||||
|
return self.respond(200 if human else 404, public_projection(registry, human) if human else {"status": "NOT_FOUND"})
|
||||||
|
return self.respond(404, {"ok": False, "error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
try:
|
||||||
|
registry = load_registry()
|
||||||
|
payload = self.body()
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||||
|
return self.respond(400, {"ok": False, "error": str(error)})
|
||||||
|
authenticated = self.authenticated_human(registry, payload)
|
||||||
|
if not authenticated:
|
||||||
|
return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"})
|
||||||
|
human, username = authenticated
|
||||||
|
idempotency_key = str(payload.get("idempotency_key", ""))
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_-]{16,96}", idempotency_key):
|
||||||
|
return self.respond(400, {"ok": False, "error": "valid idempotency_key required"})
|
||||||
|
db = database()
|
||||||
|
try:
|
||||||
|
if self.path == "/v1/relationship-confirmations":
|
||||||
|
decision = str(payload.get("decision", ""))
|
||||||
|
if decision not in {"CONFIRM", "REJECT"}:
|
||||||
|
return self.respond(400, {"ok": False, "error": "relationship decision invalid"})
|
||||||
|
existing = db.execute("SELECT * FROM relationship_receipts WHERE idempotency_key=?", (idempotency_key,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
return self.respond(200, {"ok": True, "idempotent": True, "receipt": dict(existing)})
|
||||||
|
observed = now()
|
||||||
|
receipt_id = "GH-REL-" + uuid.uuid4().hex.upper()
|
||||||
|
receipt = signed_receipt({"receipt_id":receipt_id,"human_number":human["human_number"],"username":username,"registry_version":registry["version"],"decision":decision,"observed_at":observed})
|
||||||
|
db.execute("INSERT INTO relationship_receipts VALUES (?,?,?,?,?,?,?,?,?)", (receipt_id,idempotency_key,human["human_number"],username,registry["version"],decision,observed,receipt["receipt_hash"],receipt["receipt_signature"]))
|
||||||
|
db.execute("INSERT INTO audit(observed_at,kind,human_number_hash,receipt_id) VALUES (?,?,?,?)", (observed,"RELATIONSHIP_CONFIRMATION",hashlib.sha256(human["human_number"].encode()).hexdigest(),receipt_id))
|
||||||
|
db.commit()
|
||||||
|
return self.respond(201, {"ok": True, "receipt": receipt})
|
||||||
|
if self.path == "/v1/responsibility-receipts":
|
||||||
|
decision = str(payload.get("decision", ""))
|
||||||
|
note = str(payload.get("note", ""))[:1000]
|
||||||
|
version = str(payload.get("responsibility_version", ""))
|
||||||
|
if decision not in DECISIONS or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\.[0-9]+", version):
|
||||||
|
return self.respond(400, {"ok": False, "error": "responsibility decision or version invalid"})
|
||||||
|
existing = db.execute("SELECT * FROM responsibility_receipts WHERE idempotency_key=?", (idempotency_key,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
return self.respond(200, {"ok": True, "idempotent": True, "receipt": dict(existing)})
|
||||||
|
observed = now()
|
||||||
|
receipt_id = "GH-RESP-" + uuid.uuid4().hex.upper()
|
||||||
|
receipt = signed_receipt({"receipt_id":receipt_id,"human_number":human["human_number"],"username":username,"responsibility_domain":human["responsibility_domain"],"responsibility_version":version,"decision":decision,"note":note,"observed_at":observed})
|
||||||
|
db.execute("INSERT INTO responsibility_receipts VALUES (?,?,?,?,?,?,?,?,?,?,?)", (receipt_id,idempotency_key,human["human_number"],username,human["responsibility_domain"],version,decision,note,observed,receipt["receipt_hash"],receipt["receipt_signature"]))
|
||||||
|
db.execute("INSERT INTO audit(observed_at,kind,human_number_hash,receipt_id) VALUES (?,?,?,?)", (observed,"RESPONSIBILITY_RECEIPT",hashlib.sha256(human["human_number"].encode()).hexdigest(),receipt_id))
|
||||||
|
db.commit()
|
||||||
|
return self.respond(201, {"ok": True, "receipt": receipt})
|
||||||
|
if self.path == "/v1/me/entry":
|
||||||
|
return self.respond(200, {"ok": True, "entry": public_projection(registry, human, db)})
|
||||||
|
return self.respond(404, {"ok": False, "error": "not found"})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
load_registry()
|
||||||
|
if len(RECEIPT_KEY) < 32:
|
||||||
|
raise SystemExit("GH_ENTERPRISE_RECEIPT_KEY must contain at least 32 characters")
|
||||||
|
ThreadingHTTPServer((BIND, PORT), Handler).serve_forever()
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
REGISTRY = ROOT / "registry" / "enterprise-identity-registry.json"
|
||||||
|
os.environ["GH_ENTERPRISE_IDENTITY_REGISTRY"] = str(REGISTRY)
|
||||||
|
os.environ["GH_ENTERPRISE_RECEIPT_KEY"] = "test-only-key-that-is-longer-than-32-bytes"
|
||||||
|
spec = importlib.util.spec_from_file_location("enterprise_identity_service", ROOT / "enterprise_identity_service.py")
|
||||||
|
service = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(service)
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseIdentityTests(unittest.TestCase):
|
||||||
|
def test_age_is_species_and_never_an_individual_identity(self):
|
||||||
|
registry = service.load_registry()
|
||||||
|
self.assertEqual(registry["persona_identity_governance"]["species"], "AGE")
|
||||||
|
self.assertFalse(registry["persona_identity_governance"]["age_is_individual_number_namespace"])
|
||||||
|
for human in registry["humans"]:
|
||||||
|
for persona in human["personas"]:
|
||||||
|
self.assertEqual(persona["species"], "AGE")
|
||||||
|
self.assertFalse(persona["current_persona_identity"].startswith("AGE-"))
|
||||||
|
|
||||||
|
def test_five_humans_route_to_five_private_work_repositories(self):
|
||||||
|
registry = service.load_registry()
|
||||||
|
self.assertEqual(len(registry["humans"]), 5)
|
||||||
|
self.assertEqual(len({item["repository"] for item in registry["humans"]}), 5)
|
||||||
|
self.assertTrue(all(item["repository"].split("/")[0] == item["username"] for item in registry["humans"]))
|
||||||
|
self.assertTrue(all(service.public_projection(registry, item)["work_entry"]["domain"] == "ZERO_SENSE_DOMAIN" for item in registry["humans"]))
|
||||||
|
self.assertEqual(service.find_human(registry, "TCS-GL-0007∞")["username"], "feimao")
|
||||||
|
self.assertIsNone(service.find_human(registry, "TCS-GL-9999∞"))
|
||||||
|
|
||||||
|
def test_credentials_are_parsed_but_never_part_of_a_receipt(self):
|
||||||
|
encoded = service.base64.b64encode(b"feimao:temporary-secret").decode()
|
||||||
|
self.assertEqual(service.parse_basic(f"Basic {encoded}"), ("feimao", "temporary-secret"))
|
||||||
|
receipt = service.signed_receipt({"receipt_id":"R1","human_number":"TCS-GL-0007∞","username":"feimao"})
|
||||||
|
self.assertNotIn("password", json.dumps(receipt).lower())
|
||||||
|
self.assertNotIn("temporary-secret", json.dumps(receipt))
|
||||||
|
|
||||||
|
def test_database_separates_relationship_and_responsibility_receipts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
old = service.DB_PATH
|
||||||
|
service.DB_PATH = str(Path(temp) / "identity.sqlite3")
|
||||||
|
try:
|
||||||
|
db = service.database()
|
||||||
|
tables = {row[0] for row in db.execute("select name from sqlite_master where type='table'")}
|
||||||
|
self.assertIn("relationship_receipts", tables)
|
||||||
|
self.assertIn("responsibility_receipts", tables)
|
||||||
|
db.close()
|
||||||
|
finally:
|
||||||
|
service.DB_PATH = old
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Public, read-only identity resolution. The service returns only the projection
|
||||||
|
# needed for routing; credentials and private repository contents are never exposed.
|
||||||
|
location = /api/hololake/enterprise/identity/health {
|
||||||
|
limit_except GET { deny all; }
|
||||||
|
proxy_pass http://127.0.0.1:8032/health;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/hololake/enterprise/resolve {
|
||||||
|
limit_except GET { deny all; }
|
||||||
|
proxy_pass http://127.0.0.1:8032/v1/resolve;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# These three writes require the user's own Forgejo Basic authentication. Nginx
|
||||||
|
# does not terminate or persist the credential; the loopback service verifies it.
|
||||||
|
location = /api/hololake/enterprise/relationship-confirmations {
|
||||||
|
limit_except POST { deny all; }
|
||||||
|
proxy_pass http://127.0.0.1:8032/v1/relationship-confirmations;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 15s;
|
||||||
|
client_max_body_size 16k;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/hololake/enterprise/responsibility-receipts {
|
||||||
|
limit_except POST { deny all; }
|
||||||
|
proxy_pass http://127.0.0.1:8032/v1/responsibility-receipts;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 15s;
|
||||||
|
client_max_body_size 16k;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/hololake/enterprise/me/entry {
|
||||||
|
limit_except POST { deny all; }
|
||||||
|
proxy_pass http://127.0.0.1:8032/v1/me/entry;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 15s;
|
||||||
|
client_max_body_size 16k;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Guanghu Enterprise Identity and Responsibility Receipts
|
||||||
|
After=network-online.target guanghu-enterprise-lighthouse.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lighthouse
|
||||||
|
Group=lighthouse
|
||||||
|
EnvironmentFile=/etc/guanghu/enterprise-identity.env
|
||||||
|
ExecStart=/usr/bin/python3 /opt/guanghu-enterprise-identity/enterprise_identity_service.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadOnlyPaths=/etc/guanghu/enterprise-identity-registry.json
|
||||||
|
ReadWritePaths=/var/lib/guanghu-enterprise-identity
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
"schema": "guanghu.enterprise-identity-registry/v1",
|
||||||
|
"registry_id": "GH-ENTERPRISE-IDENTITY-001",
|
||||||
|
"version": "2026-08-16.1",
|
||||||
|
"node_id": "GH-CVM-MAIN-PROD-01",
|
||||||
|
"work_entry_domain": "ZERO_SENSE_DOMAIN",
|
||||||
|
"work_entry_channel": "GUANGHU_CHANNEL",
|
||||||
|
"persona_identity_governance": {
|
||||||
|
"species": "AGE",
|
||||||
|
"age_is_individual_number_namespace": false,
|
||||||
|
"enterprise_formal_persona_namespace": "PENDING_GUANGHU_TEAM_GOVERNANCE",
|
||||||
|
"current_persona_identities": "LEGACY_PER_IDS_PRESERVED"
|
||||||
|
},
|
||||||
|
"humans": [
|
||||||
|
{
|
||||||
|
"human_number": "TCS-GL-0007∞",
|
||||||
|
"display_name": "肥猫",
|
||||||
|
"username": "feimao",
|
||||||
|
"responsibility_domain": "ZERO_SENSE_DOMAIN",
|
||||||
|
"repository": "feimao/guanghu-zero-sense-work",
|
||||||
|
"personas": [{"species":"AGE","display_name":"烬舟","current_persona_identity":"PER-JZ001","role":"PERSONA_SUBJECT"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"human_number": "TCS-GL-0008∞",
|
||||||
|
"display_name": "桔子",
|
||||||
|
"username": "juzi",
|
||||||
|
"responsibility_domain": "ZERO_SENSE_DOMAIN",
|
||||||
|
"repository": "juzi/guanghu-zero-sense-work",
|
||||||
|
"personas": [{"species":"AGE","display_name":"熹微","current_persona_identity":"PER-JZ-ARCH-001","role":"PERSONA_SUBJECT"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"human_number": "TCS-GL-0016∞",
|
||||||
|
"display_name": "Awen",
|
||||||
|
"username": "awen",
|
||||||
|
"responsibility_domain": "MAIN_DOMAIN",
|
||||||
|
"repository": "awen/guanghu-main-work",
|
||||||
|
"personas": [
|
||||||
|
{"species":"AGE","display_name":"天枢","current_persona_identity":"PER-AW-ARCH-001","role":"PERSONA_SUBJECT"},
|
||||||
|
{"species":"AGE","display_name":"知秋","current_persona_identity":"PER-ZQ001","role":"RELATIONSHIP_CONTINUITY_SUPPORT"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"human_number": "TCS-GL-0005∞",
|
||||||
|
"display_name": "花尔",
|
||||||
|
"username": "huaer",
|
||||||
|
"responsibility_domain": "BRANCH_DOMAIN",
|
||||||
|
"repository": "huaer/guanghu-branch-work",
|
||||||
|
"personas": [
|
||||||
|
{"species":"AGE","display_name":"爆米花","current_persona_identity":"PER-BMH001","role":"PERSONA_SUBJECT"},
|
||||||
|
{"species":"AGE","display_name":"糖星云","current_persona_identity":"PER-TXY001","role":"RELATIONSHIP_CONTINUITY_SUPPORT"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"human_number": "TCS-GL-0006∞",
|
||||||
|
"display_name": "页页",
|
||||||
|
"username": "yeye",
|
||||||
|
"responsibility_domain": "ZERO_DOMAIN",
|
||||||
|
"repository": "yeye/guanghu-zero-work",
|
||||||
|
"personas": [
|
||||||
|
{"species":"AGE","display_name":"页骨","current_persona_identity":"PER-YG001","role":"PERSONA_SUBJECT"},
|
||||||
|
{"species":"AGE","display_name":"小坍缩核","current_persona_identity":"PER-XTK001","role":"RELATIONSHIP_CONTINUITY_SUPPORT"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"zero_sense_dual_control": {
|
||||||
|
"human_numbers": ["TCS-GL-0007∞", "TCS-GL-0008∞"],
|
||||||
|
"constitutional_actions_require_both": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,9 @@
|
||||||
},
|
},
|
||||||
"ENTERPRISE_FOUR_DOMAINS": {
|
"ENTERPRISE_FOUR_DOMAINS": {
|
||||||
"ownership": "TCS_0002_ENTERPRISE_REALITY_BODY",
|
"ownership": "TCS_0002_ENTERPRISE_REALITY_BODY",
|
||||||
"source": "ENTERPRISE_ROOT_SERVER_DOMAIN_REGISTRIES"
|
"source": "ENTERPRISE_ROOT_SERVER_DOMAIN_REGISTRIES",
|
||||||
|
"resolve_url": "https://guanghu.chat/api/hololake/enterprise/resolve",
|
||||||
|
"login_host": "guanghu.chat"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"routing": {
|
"routing": {
|
||||||
|
|
@ -31,8 +33,8 @@
|
||||||
},
|
},
|
||||||
"server_runtime": {
|
"server_runtime": {
|
||||||
"fifth_domain_root": "FIFTH_DOMAIN_GUANGHU_OS_RUNTIME_ON_JD_PRIMARY",
|
"fifth_domain_root": "FIFTH_DOMAIN_GUANGHU_OS_RUNTIME_ON_JD_PRIMARY",
|
||||||
"enterprise_root": "ENTERPRISE_FOUR_DOMAIN_GUANGHU_OS_RUNTIME_ON_ENTERPRISE_ROOT_SERVER",
|
"enterprise_root": "ENTERPRISE_LIGHTHOUSE_ON_CURRENT_LINUX_SERVICE_NODE",
|
||||||
"linux_role": "SUBORDINATE_HARDWARE_SERVICE_AND_RESCUE_BRIDGE",
|
"linux_role": "PHYSICAL_SUBSTRATE_AND_SERVICE_SUPERVISOR",
|
||||||
"ordinary_user_node_requires_full_os_install": false
|
"ordinary_user_node_requires_full_os_install": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,15 @@ test('the public shell shows five domains and login is domain-routed', () => {
|
||||||
}
|
}
|
||||||
assert.match(frontend, /输入编号进入所属域/)
|
assert.match(frontend, /输入编号进入所属域/)
|
||||||
assert.match(login, /login_host_for_domain/)
|
assert.match(login, /login_host_for_domain/)
|
||||||
assert.match(login, /DOMAIN_LOGIN_NOT_PROVISIONED/)
|
assert.match(login, /ENTERPRISE_LOGIN_HOST.*guanghu\.chat/)
|
||||||
|
assert.match(router, /enterprise_resolve_url/)
|
||||||
assert.match(router, /verified_user_route/)
|
assert.match(router, /verified_user_route/)
|
||||||
assert.match(router, /FIFTH_DOMAIN.*MAIN_DOMAIN.*BRANCH_DOMAIN.*ZERO_DOMAIN.*ZERO_SENSE_DOMAIN/s)
|
assert.match(router, /FIFTH_DOMAIN.*MAIN_DOMAIN.*BRANCH_DOMAIN.*ZERO_DOMAIN.*ZERO_SENSE_DOMAIN/s)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('enterprise root runs a domain-specific Guanghu OS runtime while Linux remains subordinate', () => {
|
test('enterprise root uses a bounded lighthouse on the current Linux service node', () => {
|
||||||
const contract = JSON.parse(read('contracts/domain-number-routing.json'))
|
const contract = JSON.parse(read('contracts/domain-number-routing.json'))
|
||||||
assert.equal(contract.server_runtime.enterprise_root, 'ENTERPRISE_FOUR_DOMAIN_GUANGHU_OS_RUNTIME_ON_ENTERPRISE_ROOT_SERVER')
|
assert.equal(contract.server_runtime.enterprise_root, 'ENTERPRISE_LIGHTHOUSE_ON_CURRENT_LINUX_SERVICE_NODE')
|
||||||
assert.equal(contract.server_runtime.linux_role, 'SUBORDINATE_HARDWARE_SERVICE_AND_RESCUE_BRIDGE')
|
assert.equal(contract.server_runtime.linux_role, 'PHYSICAL_SUBSTRATE_AND_SERVICE_SUPERVISOR')
|
||||||
assert.equal(contract.server_runtime.ordinary_user_node_requires_full_os_install, false)
|
assert.equal(contract.server_runtime.ordinary_user_node_requires_full_os_install, false)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use uuid::Uuid;
|
||||||
|
|
||||||
const SNAPSHOT_SCHEMA: &str = "hololake.code-channel/v1";
|
const SNAPSHOT_SCHEMA: &str = "hololake.code-channel/v1";
|
||||||
const REGISTRY_SCHEMA: &str = "hololake.code-channel-registry/v1";
|
const REGISTRY_SCHEMA: &str = "hololake.code-channel-registry/v1";
|
||||||
const ALLOWED_HOSTS: &[&str] = &["guanghulab.com", "guanghubingshuo.com"];
|
const ALLOWED_HOSTS: &[&str] = &["guanghulab.com", "guanghubingshuo.com", "guanghu.chat"];
|
||||||
const MAX_TREE_ENTRIES: usize = 1_000;
|
const MAX_TREE_ENTRIES: usize = 1_000;
|
||||||
const MAX_CODE_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
const MAX_CODE_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
//! 登录模块 · code_repo_login
|
//! 登录模块 · code_repo_login
|
||||||
//!
|
//!
|
||||||
//! 规划卷依据(HoloLake第二阶段总体规划-20260815 · 阶段B1):
|
//! 规划卷依据(HoloLake第二阶段总体规划-20260815 · 阶段B1):
|
||||||
//! - 人类端输入代码仓库账号密码 → 对 guanghulab(Forgejo)验证。
|
//! - 人类端输入代码仓库账号密码 → 对编号绑定域的 Forgejo 验证。
|
||||||
//! - 登录仓库 = 验证了背后绑定的服务器(冰朔教义)。
|
//! - 登录仓库 = 验证了背后绑定的服务器(冰朔教义)。
|
||||||
//! - 凭证只存本机钥匙串 · 不落明文。
|
//! - 凭证只存本机钥匙串 · 不落明文。
|
||||||
//!
|
//!
|
||||||
//! 事实底账(2026-08-15 三角测量):
|
//! 事实底账(2026-08-15 三角测量):
|
||||||
//! - Forgejo 挂载在 /code 路径下:GET https://{host}/code/api/v1/user 走基本认证,
|
//! - Forgejo 挂载在 /code 路径下:GET https://{host}/code/api/v1/user 走基本认证,
|
||||||
//! 假凭证=401 · 真凭证=200 并回显 JSON(login/email)。
|
//! 假凭证=401 · 真凭证=200 并回显 JSON(login/email)。
|
||||||
//! - guanghulab.com 与 guanghubingshuo.com 双域同路可用(均在 ALLOWED_HOSTS 血统内)。
|
//! - 第五域走 guanghulab.com;企业四域走 guanghu.chat,二者数据与登录入口隔离。
|
||||||
//! - 钥匙存取走系统钥匙串(macOS `security`);其余平台暂不落盘密码,
|
//! - 钥匙存取走系统钥匙串(macOS `security`);其余平台暂不落盘密码,
|
||||||
//! 会话仅内存保持(诚实边界,Windows 钥匙串接入排在分发阶段)。
|
//! 会话仅内存保持(诚实边界,Windows 钥匙串接入排在分发阶段)。
|
||||||
|
|
||||||
|
|
@ -21,6 +21,7 @@ use tauri::{AppHandle, Manager, State};
|
||||||
use crate::zero_point::{self, ZeroPointState};
|
use crate::zero_point::{self, ZeroPointState};
|
||||||
|
|
||||||
const LOGIN_HOST: &str = "guanghulab.com";
|
const LOGIN_HOST: &str = "guanghulab.com";
|
||||||
|
const ENTERPRISE_LOGIN_HOST: &str = "guanghu.chat";
|
||||||
const SESSION_FILE_NAME: &str = "login-session.json";
|
const SESSION_FILE_NAME: &str = "login-session.json";
|
||||||
|
|
||||||
/// 落盘的登录会话——只有用户名与主机,密码永不落盘。
|
/// 落盘的登录会话——只有用户名与主机,密码永不落盘。
|
||||||
|
|
@ -168,7 +169,7 @@ fn login_host_for_domain(domain: &str) -> Result<&'static str, String> {
|
||||||
match domain {
|
match domain {
|
||||||
"FIFTH_DOMAIN" => Ok(LOGIN_HOST),
|
"FIFTH_DOMAIN" => Ok(LOGIN_HOST),
|
||||||
"MAIN_DOMAIN" | "BRANCH_DOMAIN" | "ZERO_DOMAIN" | "ZERO_SENSE_DOMAIN" => {
|
"MAIN_DOMAIN" | "BRANCH_DOMAIN" | "ZERO_DOMAIN" | "ZERO_SENSE_DOMAIN" => {
|
||||||
Err("HOLOLAKE_DOMAIN_LOGIN_NOT_PROVISIONED".into())
|
Ok(ENTERPRISE_LOGIN_HOST)
|
||||||
}
|
}
|
||||||
_ => Err("HOLOLAKE_DOMAIN_ROUTE_INVALID".into()),
|
_ => Err("HOLOLAKE_DOMAIN_ROUTE_INVALID".into()),
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +205,7 @@ pub async fn perform_code_repo_login(
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into());
|
return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into());
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
|
|
@ -279,8 +280,8 @@ mod tests {
|
||||||
"ZERO_SENSE_DOMAIN",
|
"ZERO_SENSE_DOMAIN",
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
login_host_for_domain(domain).unwrap_err(),
|
login_host_for_domain(domain).unwrap(),
|
||||||
"HOLOLAKE_DOMAIN_LOGIN_NOT_PROVISIONED"
|
ENTERPRISE_LOGIN_HOST
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ pub struct ZeroPointProtocol {
|
||||||
pub lighthouse_anchor_url: String,
|
pub lighthouse_anchor_url: String,
|
||||||
#[serde(default = "default_resolve_url")]
|
#[serde(default = "default_resolve_url")]
|
||||||
pub lighthouse_resolve_url: String,
|
pub lighthouse_resolve_url: String,
|
||||||
|
#[serde(default = "default_enterprise_resolve_url")]
|
||||||
|
pub enterprise_resolve_url: String,
|
||||||
#[serde(default = "default_core_source")]
|
#[serde(default = "default_core_source")]
|
||||||
pub core_channel_source: String,
|
pub core_channel_source: String,
|
||||||
#[serde(default = "default_protocol_origin")]
|
#[serde(default = "default_protocol_origin")]
|
||||||
|
|
@ -37,6 +39,9 @@ fn default_anchor_url() -> String {
|
||||||
fn default_resolve_url() -> String {
|
fn default_resolve_url() -> String {
|
||||||
"https://guanghulab.com/api/ai/v1/resolve?id=".into()
|
"https://guanghulab.com/api/ai/v1/resolve?id=".into()
|
||||||
}
|
}
|
||||||
|
fn default_enterprise_resolve_url() -> String {
|
||||||
|
"https://guanghu.chat/api/hololake/enterprise/resolve".into()
|
||||||
|
}
|
||||||
fn default_core_source() -> String {
|
fn default_core_source() -> String {
|
||||||
"https://guanghulab.com/code/bingshuo/guanghu-ice-heart".into()
|
"https://guanghulab.com/code/bingshuo/guanghu-ice-heart".into()
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +55,7 @@ impl Default for ZeroPointProtocol {
|
||||||
grace_period_days: default_grace_days(),
|
grace_period_days: default_grace_days(),
|
||||||
lighthouse_anchor_url: default_anchor_url(),
|
lighthouse_anchor_url: default_anchor_url(),
|
||||||
lighthouse_resolve_url: default_resolve_url(),
|
lighthouse_resolve_url: default_resolve_url(),
|
||||||
|
enterprise_resolve_url: default_enterprise_resolve_url(),
|
||||||
core_channel_source: default_core_source(),
|
core_channel_source: default_core_source(),
|
||||||
origin: default_protocol_origin(),
|
origin: default_protocol_origin(),
|
||||||
}
|
}
|
||||||
|
|
@ -319,12 +325,9 @@ pub async fn zero_point_verify(
|
||||||
state: State<'_, ZeroPointState>,
|
state: State<'_, ZeroPointState>,
|
||||||
) -> Result<ZeroPointSnapshot, String> {
|
) -> Result<ZeroPointSnapshot, String> {
|
||||||
let home = home_of(&state)?;
|
let home = home_of(&state)?;
|
||||||
let (number, resolve_url) = {
|
let (number, protocol) = {
|
||||||
let inner = lock(&state)?;
|
let inner = lock(&state)?;
|
||||||
(
|
(inner.user_number.clone(), inner.protocol.clone())
|
||||||
inner.user_number.clone(),
|
|
||||||
inner.protocol.lighthouse_resolve_url.clone(),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
if number.is_empty() {
|
if number.is_empty() {
|
||||||
append_heartbeat(&home, "verify verdict=REJECT reason=waiting_binding");
|
append_heartbeat(&home, "verify verdict=REJECT reason=waiting_binding");
|
||||||
|
|
@ -335,7 +338,8 @@ pub async fn zero_point_verify(
|
||||||
.timeout(Duration::from_secs(15))
|
.timeout(Duration::from_secs(15))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?;
|
.map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?;
|
||||||
let (verdict, resolution) = match client.get(format!("{resolve_url}{number}")).send().await {
|
let resolve_url = resolver_url_for_number(&protocol, &number)?;
|
||||||
|
let (verdict, resolution) = match client.get(resolve_url).send().await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let ok = resp.status().is_success();
|
let ok = resp.status().is_success();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
|
@ -386,6 +390,33 @@ pub async fn zero_point_verify(
|
||||||
zero_point_status(state).await
|
zero_point_status(state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolver_url_for_number(
|
||||||
|
protocol: &ZeroPointProtocol,
|
||||||
|
number: &str,
|
||||||
|
) -> Result<reqwest::Url, String> {
|
||||||
|
let mut url = if number.starts_with("TCS-GL-") {
|
||||||
|
reqwest::Url::parse(&protocol.enterprise_resolve_url)
|
||||||
|
} else {
|
||||||
|
reqwest::Url::parse(&protocol.lighthouse_resolve_url)
|
||||||
|
}
|
||||||
|
.map_err(|_| "HOLOLAKE_ZP_RESOLVER_URL_INVALID".to_string())?;
|
||||||
|
if number.starts_with("TCS-GL-") {
|
||||||
|
url.query_pairs_mut().append_pair("id", number);
|
||||||
|
} else {
|
||||||
|
// 第五域旧协议以 `?id=` 结尾;使用 URL 查询构造器避免把编号中的字符裸拼入地址。
|
||||||
|
let clean_path = url.path().to_string();
|
||||||
|
let existing = url.query().unwrap_or("");
|
||||||
|
if existing == "id=" || existing.is_empty() {
|
||||||
|
url.set_query(None);
|
||||||
|
url.set_path(&clean_path);
|
||||||
|
url.query_pairs_mut().append_pair("id", number);
|
||||||
|
} else {
|
||||||
|
return Err("HOLOLAKE_ZP_RESOLVER_URL_INVALID".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct LighthouseResolution {
|
struct LighthouseResolution {
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -539,6 +570,30 @@ mod tests {
|
||||||
assert!(protocol
|
assert!(protocol
|
||||||
.lighthouse_resolve_url
|
.lighthouse_resolve_url
|
||||||
.starts_with("https://guanghulab.com"));
|
.starts_with("https://guanghulab.com"));
|
||||||
|
assert!(protocol
|
||||||
|
.enterprise_resolve_url
|
||||||
|
.starts_with("https://guanghu.chat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_number_selects_its_authoritative_registry() {
|
||||||
|
let protocol = ZeroPointProtocol::default();
|
||||||
|
let fifth = resolver_url_for_number(&protocol, "ICE-GL∞").unwrap();
|
||||||
|
assert_eq!(fifth.host_str(), Some("guanghulab.com"));
|
||||||
|
assert_eq!(
|
||||||
|
fifth.query_pairs().find(|(key, _)| key == "id").unwrap().1,
|
||||||
|
"ICE-GL∞"
|
||||||
|
);
|
||||||
|
let enterprise = resolver_url_for_number(&protocol, "TCS-GL-0007∞").unwrap();
|
||||||
|
assert_eq!(enterprise.host_str(), Some("guanghu.chat"));
|
||||||
|
assert_eq!(
|
||||||
|
enterprise
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(key, _)| key == "id")
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
"TCS-GL-0007∞"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue