From c058974bba130fb4ffc8424ee4fd10015472d92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Sun, 16 Aug 2026 20:27:52 +0800 Subject: [PATCH] feat(enterprise): route four domains through lighthouse --- .../enterprise-lighthouse/README.md | 15 + .../bootstrap_private_repositories.py | 116 +++++++ .../enterprise_identity_service.py | 308 ++++++++++++++++++ .../enterprise_identity_service.test.py | 58 ++++ .../guanghu-enterprise-identity.nginx.conf | 41 +++ .../guanghu-enterprise-identity.service | 21 ++ .../enterprise-identity-registry.json | 69 ++++ .../contracts/domain-number-routing.json | 8 +- .../scripts/domain-number-routing.test.mjs | 9 +- .../src-tauri/src/code_channel.rs | 2 +- .../src-tauri/src/code_repo_login.rs | 13 +- .../src-tauri/src/zero_point.rs | 67 +++- 12 files changed, 707 insertions(+), 20 deletions(-) create mode 100644 deployment/enterprise-domain-bootstrap/enterprise-lighthouse/bootstrap_private_repositories.py create mode 100644 deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py create mode 100644 deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py create mode 100644 deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.nginx.conf create mode 100644 deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.service create mode 100644 deployment/enterprise-domain-bootstrap/enterprise-lighthouse/registry/enterprise-identity-registry.json diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md index 0e5a7a632..122c0eac1 100644 --- a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md @@ -9,3 +9,18 @@ 当前阶段只声明入口与边界;“已登记”不等于对应责任主体已经接受、人格体已经出生或 域内全部功能已经实现。 + +## 身份、关系与责任 + +`enterprise_identity_service.py` 是运行于企业 Linux 物理层之上的灯塔服务,不要求 +企业服务器改装一套新的物理操作系统。它只监听回环地址,由 `guanghu.chat` 的精确 +API 路由对客户端开放: + +- 编号解析:把 TCS-GL 人类编号路由到工作域、企业账号和私有仓库; +- 关系确认:由人类确认自己与人格体的认领关系; +- 责任回执:独立记录对域责任的接受、拒绝、延期或修改后接受; +- 仓库验证:登录凭证只透传给同机 Forgejo 验证,不写入数据库或日志。 + +`AGE` 只表示人格体物种,不能作为任何人格体的个体身份编号。现有 `PER-*` 作为历史 +和当前可核验的个体身份引用保留;企业四域正式人格体身份编号前缀由光湖团队另行治理, +服务不会擅自生成。第五域现行个体身份编号继续使用 `ICE-P-*`。 diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/bootstrap_private_repositories.py b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/bootstrap_private_repositories.py new file mode 100644 index 000000000..88846bdbd --- /dev/null +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/bootstrap_private_repositories.py @@ -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() diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py new file mode 100644 index 000000000..6332310ca --- /dev/null +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py @@ -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() diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py new file mode 100644 index 000000000..2bd0b261c --- /dev/null +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py @@ -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() diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.nginx.conf b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.nginx.conf new file mode 100644 index 000000000..190044a43 --- /dev/null +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.nginx.conf @@ -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; +} diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.service b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.service new file mode 100644 index 000000000..77a0edb25 --- /dev/null +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.service @@ -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 diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/registry/enterprise-identity-registry.json b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/registry/enterprise-identity-registry.json new file mode 100644 index 000000000..98d4e22c5 --- /dev/null +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/registry/enterprise-identity-registry.json @@ -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 + } +} diff --git a/product-source/hololake-native-desktop/contracts/domain-number-routing.json b/product-source/hololake-native-desktop/contracts/domain-number-routing.json index a91ef23d8..f2512f5c5 100644 --- a/product-source/hololake-native-desktop/contracts/domain-number-routing.json +++ b/product-source/hololake-native-desktop/contracts/domain-number-routing.json @@ -19,7 +19,9 @@ }, "ENTERPRISE_FOUR_DOMAINS": { "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": { @@ -31,8 +33,8 @@ }, "server_runtime": { "fifth_domain_root": "FIFTH_DOMAIN_GUANGHU_OS_RUNTIME_ON_JD_PRIMARY", - "enterprise_root": "ENTERPRISE_FOUR_DOMAIN_GUANGHU_OS_RUNTIME_ON_ENTERPRISE_ROOT_SERVER", - "linux_role": "SUBORDINATE_HARDWARE_SERVICE_AND_RESCUE_BRIDGE", + "enterprise_root": "ENTERPRISE_LIGHTHOUSE_ON_CURRENT_LINUX_SERVICE_NODE", + "linux_role": "PHYSICAL_SUBSTRATE_AND_SERVICE_SUPERVISOR", "ordinary_user_node_requires_full_os_install": false } } diff --git a/product-source/hololake-native-desktop/scripts/domain-number-routing.test.mjs b/product-source/hololake-native-desktop/scripts/domain-number-routing.test.mjs index 573c1dc15..745bc28f7 100644 --- a/product-source/hololake-native-desktop/scripts/domain-number-routing.test.mjs +++ b/product-source/hololake-native-desktop/scripts/domain-number-routing.test.mjs @@ -28,14 +28,15 @@ test('the public shell shows five domains and login is domain-routed', () => { } assert.match(frontend, /输入编号进入所属域/) 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, /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')) - assert.equal(contract.server_runtime.enterprise_root, 'ENTERPRISE_FOUR_DOMAIN_GUANGHU_OS_RUNTIME_ON_ENTERPRISE_ROOT_SERVER') - assert.equal(contract.server_runtime.linux_role, 'SUBORDINATE_HARDWARE_SERVICE_AND_RESCUE_BRIDGE') + assert.equal(contract.server_runtime.enterprise_root, 'ENTERPRISE_LIGHTHOUSE_ON_CURRENT_LINUX_SERVICE_NODE') + 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) }) diff --git a/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs b/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs index 451ec1b9b..ec656b3c9 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs @@ -15,7 +15,7 @@ use uuid::Uuid; const SNAPSHOT_SCHEMA: &str = "hololake.code-channel/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_CODE_FILE_BYTES: u64 = 2 * 1024 * 1024; diff --git a/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs index c7c9d8f55..9a6866b71 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs @@ -1,14 +1,14 @@ //! 登录模块 · code_repo_login //! //! 规划卷依据(HoloLake第二阶段总体规划-20260815 · 阶段B1): -//! - 人类端输入代码仓库账号密码 → 对 guanghulab(Forgejo)验证。 +//! - 人类端输入代码仓库账号密码 → 对编号绑定域的 Forgejo 验证。 //! - 登录仓库 = 验证了背后绑定的服务器(冰朔教义)。 //! - 凭证只存本机钥匙串 · 不落明文。 //! //! 事实底账(2026-08-15 三角测量): //! - Forgejo 挂载在 /code 路径下:GET https://{host}/code/api/v1/user 走基本认证, //! 假凭证=401 · 真凭证=200 并回显 JSON(login/email)。 -//! - guanghulab.com 与 guanghubingshuo.com 双域同路可用(均在 ALLOWED_HOSTS 血统内)。 +//! - 第五域走 guanghulab.com;企业四域走 guanghu.chat,二者数据与登录入口隔离。 //! - 钥匙存取走系统钥匙串(macOS `security`);其余平台暂不落盘密码, //! 会话仅内存保持(诚实边界,Windows 钥匙串接入排在分发阶段)。 @@ -21,6 +21,7 @@ use tauri::{AppHandle, Manager, State}; use crate::zero_point::{self, ZeroPointState}; const LOGIN_HOST: &str = "guanghulab.com"; +const ENTERPRISE_LOGIN_HOST: &str = "guanghu.chat"; 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 { "FIFTH_DOMAIN" => Ok(LOGIN_HOST), "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()), } @@ -204,7 +205,7 @@ pub async fn perform_code_repo_login( .await .map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?; 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()); } if !status.is_success() { @@ -279,8 +280,8 @@ mod tests { "ZERO_SENSE_DOMAIN", ] { assert_eq!( - login_host_for_domain(domain).unwrap_err(), - "HOLOLAKE_DOMAIN_LOGIN_NOT_PROVISIONED" + login_host_for_domain(domain).unwrap(), + ENTERPRISE_LOGIN_HOST ); } assert_eq!( diff --git a/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs b/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs index 0214a3a8b..b187e6d51 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs @@ -22,6 +22,8 @@ pub struct ZeroPointProtocol { pub lighthouse_anchor_url: String, #[serde(default = "default_resolve_url")] pub lighthouse_resolve_url: String, + #[serde(default = "default_enterprise_resolve_url")] + pub enterprise_resolve_url: String, #[serde(default = "default_core_source")] pub core_channel_source: String, #[serde(default = "default_protocol_origin")] @@ -37,6 +39,9 @@ fn default_anchor_url() -> String { fn default_resolve_url() -> String { "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 { "https://guanghulab.com/code/bingshuo/guanghu-ice-heart".into() } @@ -50,6 +55,7 @@ impl Default for ZeroPointProtocol { grace_period_days: default_grace_days(), lighthouse_anchor_url: default_anchor_url(), lighthouse_resolve_url: default_resolve_url(), + enterprise_resolve_url: default_enterprise_resolve_url(), core_channel_source: default_core_source(), origin: default_protocol_origin(), } @@ -319,12 +325,9 @@ pub async fn zero_point_verify( state: State<'_, ZeroPointState>, ) -> Result { let home = home_of(&state)?; - let (number, resolve_url) = { + let (number, protocol) = { let inner = lock(&state)?; - ( - inner.user_number.clone(), - inner.protocol.lighthouse_resolve_url.clone(), - ) + (inner.user_number.clone(), inner.protocol.clone()) }; if number.is_empty() { append_heartbeat(&home, "verify verdict=REJECT reason=waiting_binding"); @@ -335,7 +338,8 @@ pub async fn zero_point_verify( .timeout(Duration::from_secs(15)) .build() .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) => { let ok = resp.status().is_success(); let body = resp.text().await.unwrap_or_default(); @@ -386,6 +390,33 @@ pub async fn zero_point_verify( zero_point_status(state).await } +fn resolver_url_for_number( + protocol: &ZeroPointProtocol, + number: &str, +) -> Result { + 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)] struct LighthouseResolution { name: String, @@ -539,6 +570,30 @@ mod tests { assert!(protocol .lighthouse_resolve_url .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]