FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
File diff suppressed because it is too large.
Load diff
+1
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0-alpha.1"
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any
|
||||
|
||||
AUTHORIZATION_ENDPOINT="https://auth.openai.com/api/accounts/authorize"
|
||||
TOKEN_ENDPOINT="https://auth.openai.com/api/accounts/oauth/token"
|
||||
DISCOVERY_ENDPOINT="https://auth.openai.com/.well-known/openid-configuration"
|
||||
RESOURCE="https://api.openai.com/v1"
|
||||
DYNAMIC_CLIENT_ID="dynamic_agent_client"
|
||||
DEFAULT_SCOPES=("openid","profile","email","offline_access","resource.invoke","chatgpt.tokens.use.direct")
|
||||
AGENT_NAME="FrameBridge"
|
||||
|
||||
|
||||
class ChatGPTAuthError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def discover_openai(*, requests_module=None) -> dict[str,str]:
|
||||
if requests_module is None:
|
||||
import requests as requests_module
|
||||
response=requests_module.get(DISCOVERY_ENDPOINT,timeout=15)
|
||||
if response.status_code!=200:
|
||||
raise ChatGPTAuthError("CHATGPT_DISCOVERY_FAILED")
|
||||
value=response.json()
|
||||
if not isinstance(value,dict):
|
||||
raise ChatGPTAuthError("CHATGPT_DISCOVERY_INVALID")
|
||||
issuer=str(value.get("issuer") or "")
|
||||
auth=str(value.get("authorization_endpoint") or "")
|
||||
token=str(value.get("token_endpoint") or "")
|
||||
jwks=str(value.get("jwks_uri") or "")
|
||||
if issuer!="https://auth.openai.com":
|
||||
raise ChatGPTAuthError("CHATGPT_DISCOVERY_INVALID")
|
||||
for url in (auth,token,jwks):
|
||||
if not url.startswith("https://auth.openai.com/"):
|
||||
raise ChatGPTAuthError("CHATGPT_DISCOVERY_INVALID")
|
||||
return {
|
||||
"issuer":issuer,
|
||||
"authorization_endpoint":auth,
|
||||
"token_endpoint":token,
|
||||
"jwks_uri":jwks,
|
||||
}
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _random(n: int=48) -> str:
|
||||
return secrets.token_urlsafe(n)
|
||||
|
||||
|
||||
def account_binding_id(issuer: str, subject: str) -> str:
|
||||
raw=(str(issuer)+"\0"+str(subject)).encode("utf-8")
|
||||
return "fbacct_"+hashlib.sha256(raw).hexdigest()[:40]
|
||||
|
||||
|
||||
def build_authorization(
|
||||
host_id: str,
|
||||
redirect_uri: str,
|
||||
*,
|
||||
scopes: tuple[str,...]=DEFAULT_SCOPES,
|
||||
authorization_endpoint: str=AUTHORIZATION_ENDPOINT,
|
||||
client_id: str=DYNAMIC_CLIENT_ID,
|
||||
) -> tuple[str,dict[str,str]]:
|
||||
host_id=str(host_id).strip()
|
||||
if not host_id:
|
||||
raise ChatGPTAuthError("CHATGPT_HOST_ID_REQUIRED")
|
||||
state=_random(32)
|
||||
nonce=_random(32)
|
||||
verifier=_random(64)
|
||||
challenge=_b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
client_id=str(client_id or DYNAMIC_CLIENT_ID)
|
||||
tx={
|
||||
"state":state,
|
||||
"nonce":nonce,
|
||||
"code_verifier":verifier,
|
||||
"redirect_uri":str(redirect_uri),
|
||||
"created_at":str(time.time()),
|
||||
"client_id":client_id,
|
||||
}
|
||||
query={
|
||||
"client_id":client_id,
|
||||
"ext_agent_host_id":host_id,
|
||||
"response_type":"code",
|
||||
"redirect_uri":str(redirect_uri),
|
||||
"scope":" ".join(scopes),
|
||||
"resource":RESOURCE,
|
||||
"state":state,
|
||||
"nonce":nonce,
|
||||
"code_challenge_method":"S256",
|
||||
"code_challenge":challenge,
|
||||
}
|
||||
if client_id==DYNAMIC_CLIENT_ID:
|
||||
query["agent_name_hint"]=AGENT_NAME
|
||||
return str(authorization_endpoint)+"?"+urllib.parse.urlencode(query),tx
|
||||
|
||||
|
||||
class _Callback:
|
||||
def __init__(self):
|
||||
self.event=threading.Event()
|
||||
self.query: dict[str,str]={}
|
||||
|
||||
|
||||
def _handler(callback: _Callback):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self,fmt,*args):
|
||||
return
|
||||
def do_GET(self):
|
||||
p=urllib.parse.urlparse(self.path)
|
||||
if p.path!="/auth/callback":
|
||||
self.send_response(404);self.end_headers();return
|
||||
q=urllib.parse.parse_qs(p.query,keep_blank_values=True)
|
||||
callback.query={k:(v[0] if v else "") for k,v in q.items()}
|
||||
callback.event.set()
|
||||
body=(
|
||||
b"<!doctype html><meta charset=utf-8>"
|
||||
b"<title>FrameBridge</title>"
|
||||
b"<h2>FrameBridge</h2>"
|
||||
b"<p>ChatGPT authentication returned to FrameBridge.</p>"
|
||||
b"<p>You may close this browser tab.</p>"
|
||||
)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type","text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control","no-store")
|
||||
self.send_header("Content-Length",str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return Handler
|
||||
|
||||
|
||||
def start_loopback_listener() -> tuple[HTTPServer,_Callback,str]:
|
||||
cb=_Callback()
|
||||
server=HTTPServer(("127.0.0.1",0),_handler(cb))
|
||||
port=int(server.server_address[1])
|
||||
uri=f"http://127.0.0.1:{port}/auth/callback"
|
||||
thread=threading.Thread(target=server.serve_forever,daemon=True)
|
||||
thread.start()
|
||||
return server,cb,uri
|
||||
|
||||
|
||||
def exchange_and_validate(
|
||||
callback_query: dict[str,str],
|
||||
tx: dict[str,str],
|
||||
*,
|
||||
requests_module=None,
|
||||
jwt_module=None,
|
||||
provider_metadata: dict[str,str] | None=None,
|
||||
) -> dict[str,Any]:
|
||||
if callback_query.get("state") != tx.get("state"):
|
||||
raise ChatGPTAuthError("CHATGPT_STATE_MISMATCH")
|
||||
if callback_query.get("error"):
|
||||
raise ChatGPTAuthError("CHATGPT_AUTH_DENIED")
|
||||
code=str(callback_query.get("code") or "")
|
||||
requested_client_id=str(tx.get("client_id") or DYNAMIC_CLIENT_ID)
|
||||
callback_client_id=str(callback_query.get("client_id") or "")
|
||||
if requested_client_id==DYNAMIC_CLIENT_ID:
|
||||
issued_client_id=callback_client_id
|
||||
if not code or not issued_client_id or issued_client_id==DYNAMIC_CLIENT_ID:
|
||||
raise ChatGPTAuthError("CHATGPT_CALLBACK_INCOMPLETE")
|
||||
else:
|
||||
issued_client_id=requested_client_id
|
||||
if callback_client_id and callback_client_id!=requested_client_id:
|
||||
raise ChatGPTAuthError("CHATGPT_AUTH_CLIENT_MISMATCH")
|
||||
if not code:
|
||||
raise ChatGPTAuthError("CHATGPT_CALLBACK_INCOMPLETE")
|
||||
|
||||
if requests_module is None:
|
||||
import requests as requests_module
|
||||
if jwt_module is None:
|
||||
import jwt as jwt_module
|
||||
|
||||
provider_metadata=provider_metadata or discover_openai(requests_module=requests_module)
|
||||
token_endpoint=provider_metadata["token_endpoint"]
|
||||
issuer=provider_metadata["issuer"]
|
||||
jwks_uri=provider_metadata["jwks_uri"]
|
||||
|
||||
token_resp=requests_module.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type":"authorization_code",
|
||||
"client_id":issued_client_id,
|
||||
"code":code,
|
||||
"code_verifier":tx["code_verifier"],
|
||||
"redirect_uri":tx["redirect_uri"],
|
||||
"resource":RESOURCE,
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
if token_resp.status_code != 200:
|
||||
raise ChatGPTAuthError("CHATGPT_TOKEN_EXCHANGE_FAILED")
|
||||
tokens=token_resp.json()
|
||||
id_token=str(tokens.get("id_token") or "")
|
||||
if not id_token:
|
||||
raise ChatGPTAuthError("CHATGPT_ID_TOKEN_MISSING")
|
||||
|
||||
jwk_client=jwt_module.PyJWKClient(jwks_uri)
|
||||
signing_key=jwk_client.get_signing_key_from_jwt(id_token)
|
||||
alg=str(getattr(signing_key,"algorithm_name","") or "")
|
||||
if not alg:
|
||||
try:
|
||||
alg=str(jwt_module.get_unverified_header(id_token).get("alg") or "")
|
||||
except Exception:
|
||||
alg=""
|
||||
if alg not in {"RS256","ES256","EdDSA"}:
|
||||
raise ChatGPTAuthError("CHATGPT_ID_TOKEN_ALG_INVALID")
|
||||
|
||||
claims=jwt_module.decode(
|
||||
id_token,
|
||||
signing_key.key,
|
||||
algorithms=[alg],
|
||||
audience=issued_client_id,
|
||||
issuer=issuer,
|
||||
options={"require":["sub","exp","iat"]},
|
||||
leeway=5,
|
||||
)
|
||||
if claims.get("nonce") != tx.get("nonce"):
|
||||
raise ChatGPTAuthError("CHATGPT_NONCE_MISMATCH")
|
||||
subject=str(claims.get("sub") or "")
|
||||
if not subject:
|
||||
raise ChatGPTAuthError("CHATGPT_SUBJECT_MISSING")
|
||||
|
||||
granted=str(tokens.get("scope") or callback_query.get("scope") or "")
|
||||
scopes=sorted({x for x in granted.split() if x})
|
||||
email=str(claims.get("email") or "")
|
||||
|
||||
# No OAuth access/refresh/ID token is persisted or returned.
|
||||
return {
|
||||
"ok":True,
|
||||
"issuer":issuer,
|
||||
"client_id":issued_client_id,
|
||||
"account_binding_id":account_binding_id(issuer,subject),
|
||||
"subject_sha256":hashlib.sha256(subject.encode("utf-8")).hexdigest(),
|
||||
"email_sha256":hashlib.sha256(email.encode("utf-8")).hexdigest() if email else None,
|
||||
"scopes":scopes,
|
||||
"identity_only": "chatgpt.tokens.use.direct" not in scopes,
|
||||
}
|
||||
|
||||
|
||||
def authenticate_interactive(
|
||||
host_id: str,
|
||||
*,
|
||||
browser_opener=None,
|
||||
timeout_seconds: int=300,
|
||||
client_id: str=DYNAMIC_CLIENT_ID,
|
||||
) -> dict[str,Any]:
|
||||
server,cb,redirect_uri=start_loopback_listener()
|
||||
try:
|
||||
provider=discover_openai()
|
||||
url,tx=build_authorization(
|
||||
host_id,
|
||||
redirect_uri,
|
||||
authorization_endpoint=provider["authorization_endpoint"],
|
||||
client_id=client_id,
|
||||
)
|
||||
if browser_opener is None:
|
||||
import webbrowser
|
||||
opened=webbrowser.open(url,new=1,autoraise=True)
|
||||
else:
|
||||
opened=bool(browser_opener(url))
|
||||
if not opened:
|
||||
raise ChatGPTAuthError("CHATGPT_BROWSER_OPEN_FAILED")
|
||||
if not cb.event.wait(timeout_seconds):
|
||||
raise ChatGPTAuthError("CHATGPT_AUTH_TIMEOUT")
|
||||
return exchange_and_validate(cb.query,tx,provider_metadata=provider)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .product_core import ProductError
|
||||
from .seed_capabilities import execute_capability, list_capabilities
|
||||
from .seed_core import SeedContext
|
||||
|
||||
FRAME_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClassicRequest:
|
||||
frame_id: str
|
||||
target: str
|
||||
mode: str
|
||||
lang: str
|
||||
body: dict[str, Any]
|
||||
body_sha256: str
|
||||
|
||||
|
||||
def parse_frame(text: str) -> ClassicRequest:
|
||||
raw = str(text).replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = raw.splitlines()
|
||||
|
||||
try:
|
||||
start = lines.index("FRAMEEXEC/1")
|
||||
except ValueError:
|
||||
raise ProductError("CLASSIC_FRAME_START_MISSING")
|
||||
|
||||
try:
|
||||
end = lines.index("FRAMEEND/1", start + 1)
|
||||
except ValueError:
|
||||
raise ProductError("CLASSIC_FRAME_END_MISSING")
|
||||
|
||||
frame_lines = lines[start + 1:end]
|
||||
headers: dict[str, str] = {}
|
||||
body_start = None
|
||||
|
||||
for i, line in enumerate(frame_lines):
|
||||
if not line.strip():
|
||||
body_start = i + 1
|
||||
break
|
||||
if "=" not in line:
|
||||
raise ProductError("CLASSIC_HEADER_INVALID")
|
||||
key, value = line.split("=", 1)
|
||||
headers[key.strip().upper()] = value.strip()
|
||||
|
||||
if body_start is None:
|
||||
raise ProductError("CLASSIC_BODY_SEPARATOR_MISSING")
|
||||
|
||||
frame_id = headers.get("ID", "")
|
||||
target = headers.get("TARGET", "")
|
||||
mode = headers.get("MODE", "")
|
||||
lang = headers.get("LANG", "")
|
||||
|
||||
if not FRAME_ID_RE.fullmatch(frame_id):
|
||||
raise ProductError("CLASSIC_ID_INVALID")
|
||||
if not target:
|
||||
raise ProductError("CLASSIC_TARGET_REQUIRED")
|
||||
if mode != "EXEC":
|
||||
raise ProductError("CLASSIC_MODE_UNSUPPORTED")
|
||||
if lang != "framebridge-cap-v1":
|
||||
raise ProductError("CLASSIC_LANG_UNSUPPORTED")
|
||||
|
||||
body_text = "\n".join(frame_lines[body_start:]).strip()
|
||||
if not body_text:
|
||||
raise ProductError("CLASSIC_BODY_MISSING")
|
||||
if len(body_text.encode("utf-8")) > 32768:
|
||||
raise ProductError("CLASSIC_BODY_TOO_LARGE")
|
||||
|
||||
try:
|
||||
body = json.loads(body_text)
|
||||
except Exception:
|
||||
raise ProductError("CLASSIC_BODY_JSON_INVALID")
|
||||
|
||||
if not isinstance(body, dict):
|
||||
raise ProductError("CLASSIC_BODY_NOT_OBJECT")
|
||||
|
||||
canonical = json.dumps(
|
||||
body,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
|
||||
return ClassicRequest(
|
||||
frame_id=frame_id,
|
||||
target=target,
|
||||
mode=mode,
|
||||
lang=lang,
|
||||
body=body,
|
||||
body_sha256=hashlib.sha256(canonical).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def _target_matches(seed: SeedContext, target: str) -> bool:
|
||||
identity = seed.load_identity()
|
||||
if identity is None:
|
||||
raise ProductError("SEED_NOT_INITIALIZED")
|
||||
|
||||
accepted = {
|
||||
str(identity.get("hostname") or ""),
|
||||
str(identity.get("node_id") or ""),
|
||||
str(identity.get("node_id_short") or ""),
|
||||
}
|
||||
return target in accepted
|
||||
|
||||
|
||||
def execute_frame(seed: SeedContext, text: str) -> dict[str, Any]:
|
||||
request = parse_frame(text)
|
||||
|
||||
if not _target_matches(seed, request.target):
|
||||
raise ProductError("CLASSIC_TARGET_MISMATCH")
|
||||
|
||||
capability = str(request.body.get("capability") or "").strip()
|
||||
args = request.body.get("args") or {}
|
||||
|
||||
if "script" in request.body or "shell" in request.body or "command" in request.body:
|
||||
raise ProductError("CLASSIC_RAW_SHELL_FORBIDDEN")
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result = execute_capability(capability, args)
|
||||
status = "PASS"
|
||||
exit_code = 0
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
status = "ERROR"
|
||||
exit_code = 1
|
||||
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
|
||||
seed.audit.append(
|
||||
"classic." + capability,
|
||||
status,
|
||||
request={
|
||||
"id": request.frame_id,
|
||||
"target": request.target,
|
||||
"lang": request.lang,
|
||||
"body_sha256": request.body_sha256,
|
||||
"capability": capability,
|
||||
"args": args,
|
||||
},
|
||||
result={
|
||||
"exit": exit_code,
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
principal="chat-manual",
|
||||
)
|
||||
|
||||
return {
|
||||
"id": request.frame_id,
|
||||
"exit": exit_code,
|
||||
"channel": "SEED-CLASSIC-TTY",
|
||||
"capability": capability,
|
||||
"body_sha256": request.body_sha256,
|
||||
"duration_ms": duration_ms,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
|
||||
def render_result(value: dict[str, Any]) -> str:
|
||||
payload = json.dumps(
|
||||
value.get("result"),
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
ensure_ascii=True,
|
||||
)
|
||||
return "\n".join([
|
||||
"FRAMERESULT/1",
|
||||
"ID=" + str(value.get("id")),
|
||||
"EXIT=" + str(value.get("exit")),
|
||||
"CHANNEL=" + str(value.get("channel")),
|
||||
"CAPABILITY=" + str(value.get("capability")),
|
||||
"BODY_SHA256=" + str(value.get("body_sha256")),
|
||||
"DURATION_MS=" + str(value.get("duration_ms")),
|
||||
"",
|
||||
payload,
|
||||
"FRAMEEND/1",
|
||||
])
|
||||
|
||||
|
||||
def render_snapshot(seed: SeedContext) -> str:
|
||||
identity = seed.load_identity()
|
||||
config = seed.load_config()
|
||||
if identity is None or config is None:
|
||||
raise ProductError("SEED_NOT_INITIALIZED")
|
||||
|
||||
snapshot = {
|
||||
"protocol": "framebridge-cap-v1",
|
||||
"hostname": identity.get("hostname"),
|
||||
"node_id": identity.get("node_id"),
|
||||
"node_id_short": identity.get("node_id_short"),
|
||||
"provider": config.get("provider"),
|
||||
"connection_mode": config.get("connection_mode", "MANUAL"),
|
||||
"capability_profile": config.get("capability_profile"),
|
||||
"capabilities": list_capabilities(),
|
||||
"instruction": (
|
||||
"Return only one FRAMEEXEC/1 frame using LANG=framebridge-cap-v1. "
|
||||
"Use only a listed READONLY capability. Never include raw shell."
|
||||
),
|
||||
}
|
||||
|
||||
payload = json.dumps(snapshot, indent=2, sort_keys=True, ensure_ascii=True)
|
||||
|
||||
return "\n".join([
|
||||
"FRAMEBRIDGE-SNAPSHOT/1",
|
||||
"TARGET=" + str(identity.get("node_id_short")),
|
||||
"PROVIDER=" + str(config.get("provider")),
|
||||
"CONNECTION=MANUAL",
|
||||
"",
|
||||
payload,
|
||||
"FRAMEEND/1",
|
||||
])
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .memory_store import MemoryStore
|
||||
from .product_core import ProductError
|
||||
|
||||
|
||||
BANNER = """
|
||||
==============================================================
|
||||
FrameBridge Console
|
||||
==============================================================
|
||||
""".strip("\n")
|
||||
|
||||
|
||||
HELP = """
|
||||
Commands:
|
||||
help Show this help
|
||||
status Show node/Memory summary
|
||||
manifest Show the AI bootstrap manifest
|
||||
memory <domain> Query normalized Memory
|
||||
phone Show official mobile-app guidance
|
||||
provider Show configured AI provider
|
||||
objectives Show future objective-prompt roadmap
|
||||
quit / exit Leave the console
|
||||
|
||||
Anything else is treated as a chat message.
|
||||
""".strip("\n")
|
||||
|
||||
|
||||
PHONE_HINT = "📱 You can also continue from the official ChatGPT app on your phone."
|
||||
|
||||
|
||||
def _setup(ctx) -> dict[str, Any]:
|
||||
cfg = ctx.load_config() or {}
|
||||
return cfg.get("setup") or {}
|
||||
|
||||
|
||||
def session_header(ctx) -> str:
|
||||
setup = _setup(ctx)
|
||||
provider = setup.get("provider") or (ctx.load_config() or {}).get("provider") or "unknown"
|
||||
memory = MemoryStore(ctx).verify()
|
||||
identity = ctx.load_identity() or {}
|
||||
connected = os.environ.get("FRAMEBRIDGE_AGENT_CONNECTED") == "1"
|
||||
icon = "🤖" if connected else "○"
|
||||
lines = [
|
||||
BANNER,
|
||||
f"Node: {identity.get('hostname') or 'unknown'} ({identity.get('node_id_short') or '--------'})",
|
||||
f"AI provider: {provider}",
|
||||
f"Agent: {icon} {'connected' if connected else 'not connected'}",
|
||||
f"Memory: {memory.get('protocol')} R/W | events={memory.get('events')}",
|
||||
PHONE_HINT,
|
||||
"",
|
||||
"Type help for commands.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _chat_response(ctx, text: str) -> str:
|
||||
setup = _setup(ctx)
|
||||
provider = setup.get("provider") or "chatgpt"
|
||||
# Chat transcripts are intentionally NOT written to technical Memory.
|
||||
# Only normalized technical facts/decisions/incidents/artifacts belong in FBM.
|
||||
if provider == "chatgpt":
|
||||
if os.environ.get("FRAMEBRIDGE_AGENT_CONNECTED") == "1":
|
||||
return (
|
||||
"A remote ChatGPT session is connected to this node. "
|
||||
"Console-to-agent conversational transport is not enabled in this candidate yet. "
|
||||
"Continue in the official ChatGPT app while this console remains available locally."
|
||||
)
|
||||
return (
|
||||
"ChatGPT companion mode is configured, but no remote agent session is currently attached.\n"
|
||||
+ PHONE_HINT + "\n"
|
||||
"Open ChatGPT and connect to this FrameBridge node. The local console will remain active."
|
||||
)
|
||||
return (
|
||||
f"Provider '{provider}' is configured, but direct conversational AgentAdapter transport "
|
||||
"is not implemented in this candidate."
|
||||
)
|
||||
|
||||
|
||||
def execute_console_command(ctx, line: str) -> tuple[bool, str]:
|
||||
raw = str(line).strip()
|
||||
if not raw:
|
||||
return True, ""
|
||||
if raw in {"quit", "exit"}:
|
||||
return False, "Goodbye."
|
||||
if raw == "help":
|
||||
return True, HELP
|
||||
if raw == "phone":
|
||||
return True, PHONE_HINT
|
||||
if raw == "provider":
|
||||
setup = _setup(ctx)
|
||||
return True, json.dumps({"provider": setup.get("provider"), "mode": "companion" if setup.get("provider") == "chatgpt" else "adapter"}, indent=2)
|
||||
if raw == "status":
|
||||
return True, json.dumps({"status": ctx.status(), "memory": MemoryStore(ctx).stats()}, indent=2, sort_keys=True)
|
||||
if raw == "manifest":
|
||||
return True, json.dumps(MemoryStore(ctx).bootstrap(), indent=2, sort_keys=True)
|
||||
if raw.startswith("memory "):
|
||||
domain = raw.split(None, 1)[1].strip()
|
||||
return True, json.dumps(MemoryStore(ctx).query(domain, 20), indent=2, sort_keys=True)
|
||||
if raw == "objectives":
|
||||
return True, (
|
||||
"Objective prompts are planned for a later update. Examples: install a desktop, "
|
||||
"create a server, configure a workstation, prepare a NAS. They are NOT active in this candidate."
|
||||
)
|
||||
return True, _chat_response(ctx, raw)
|
||||
|
||||
|
||||
def run_console_chat(ctx) -> int:
|
||||
try:
|
||||
MemoryStore(ctx).require_ready()
|
||||
except Exception as exc:
|
||||
print("FrameBridge Console blocked: mandatory Memory is not healthy.")
|
||||
print(str(exc))
|
||||
return 2
|
||||
print(session_header(ctx))
|
||||
while True:
|
||||
try:
|
||||
line = input("\nYou > ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nGoodbye.")
|
||||
return 0
|
||||
keep, out = execute_console_command(ctx, line)
|
||||
if out:
|
||||
print("\n" + ("AI > " if line.strip() not in {"help","status","manifest","phone","provider","objectives"} and not line.strip().startswith("memory ") else "") + out)
|
||||
if not keep:
|
||||
return 0
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from .classic_tty import execute_frame, render_result, render_snapshot
|
||||
from .product_core import ProductError
|
||||
from .seed_core import SeedContext
|
||||
|
||||
|
||||
BANNER = """FrameBridge Seed // Classic TTY
|
||||
Modo: MANUAL / CHAT_ONLY
|
||||
Seguridad: READONLY typed capabilities
|
||||
Comandos locales: /snapshot /status /audit /help /quit
|
||||
Pegue un FRAMEEXEC/1 completo para ejecutarlo.
|
||||
"""
|
||||
|
||||
|
||||
def _read_frame(first_line: str) -> str:
|
||||
lines = [first_line.rstrip("\n")]
|
||||
while True:
|
||||
raw = sys.stdin.readline()
|
||||
if raw == "":
|
||||
raise ProductError("CLASSIC_FRAME_EOF_BEFORE_END")
|
||||
line = raw.rstrip("\n")
|
||||
lines.append(line)
|
||||
if line == "FRAMEEND/1":
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_console(seed: SeedContext) -> int:
|
||||
if seed.load_identity() is None:
|
||||
print("ERROR: Seed no inicializado. Ejecute: framebridge init --provider chatgpt")
|
||||
return 2
|
||||
|
||||
print(BANNER)
|
||||
print("--- SNAPSHOT PARA SU IA ---")
|
||||
print(render_snapshot(seed))
|
||||
print("--- FIN SNAPSHOT ---")
|
||||
|
||||
while True:
|
||||
if sys.stdin.isatty():
|
||||
print("fb> ", end="", flush=True)
|
||||
|
||||
raw = sys.stdin.readline()
|
||||
if raw == "":
|
||||
return 0
|
||||
|
||||
line = raw.rstrip("\n")
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
if stripped == "/quit":
|
||||
return 0
|
||||
if stripped == "/help":
|
||||
print(BANNER)
|
||||
continue
|
||||
if stripped == "/snapshot":
|
||||
print(render_snapshot(seed))
|
||||
continue
|
||||
if stripped == "/status":
|
||||
import json
|
||||
print(json.dumps(seed.status(), indent=2, sort_keys=True, ensure_ascii=True))
|
||||
continue
|
||||
if stripped == "/audit":
|
||||
import json
|
||||
print(json.dumps(seed.audit.verify(), indent=2, sort_keys=True, ensure_ascii=True))
|
||||
continue
|
||||
|
||||
if stripped != "FRAMEEXEC/1":
|
||||
print("ERROR: comando desconocido. Use /help o pegue FRAMEEXEC/1.")
|
||||
continue
|
||||
|
||||
try:
|
||||
frame = _read_frame(line)
|
||||
result = execute_frame(seed, frame)
|
||||
print(render_result(result))
|
||||
except ProductError as exc:
|
||||
print("FRAMERESULT/1")
|
||||
print("ID=unknown")
|
||||
print("EXIT=2")
|
||||
print("CHANNEL=SEED-CLASSIC-TTY")
|
||||
print("")
|
||||
print('{"ok":false,"error":' + __import__("json").dumps(str(exc)) + "}")
|
||||
print("FRAMEEND/1")
|
||||
|
||||
return 0
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .memory_store import MemoryStore
|
||||
from .product_core import ProductError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetupChoice:
|
||||
privilege_mode: str
|
||||
provider: str
|
||||
ui_language: str
|
||||
|
||||
|
||||
VALID_PRIVILEGE_MODES = {"totp", "sudo_tty"}
|
||||
VALID_PROVIDERS = {"chatgpt", "ollama", "lmstudio", "other"}
|
||||
VALID_LANGUAGES = {"en", "es", "zh", "fr", "de", "pt", "ja", "ko"}
|
||||
|
||||
|
||||
def detect_system() -> dict[str, Any]:
|
||||
from .seed_capabilities import hardware_summary, system_status, storage_inspect, network_inspect
|
||||
system = system_status({})
|
||||
hardware = hardware_summary({})
|
||||
storage = storage_inspect({})
|
||||
network = network_inspect({})
|
||||
return {
|
||||
"hostname": socket.gethostname(),
|
||||
"system": system,
|
||||
"hardware": hardware,
|
||||
"storage": storage,
|
||||
"network": network,
|
||||
}
|
||||
|
||||
|
||||
def validate_choice(choice: SetupChoice) -> SetupChoice:
|
||||
pm = str(choice.privilege_mode).strip().lower()
|
||||
provider = str(choice.provider).strip().lower()
|
||||
lang = str(choice.ui_language).strip().lower()
|
||||
if pm not in VALID_PRIVILEGE_MODES:
|
||||
raise ProductError("SETUP_PRIVILEGE_MODE_INVALID")
|
||||
if provider not in VALID_PROVIDERS:
|
||||
raise ProductError("SETUP_PROVIDER_INVALID")
|
||||
if lang not in VALID_LANGUAGES:
|
||||
raise ProductError("SETUP_LANGUAGE_INVALID")
|
||||
return SetupChoice(pm, provider, lang)
|
||||
|
||||
|
||||
def save_setup_config(ctx, choice: SetupChoice) -> dict[str, Any]:
|
||||
choice = validate_choice(choice)
|
||||
config = ctx.load_config() or {}
|
||||
config["provider"] = choice.provider
|
||||
config["setup"] = {
|
||||
"completed": True,
|
||||
"privilege_mode": choice.privilege_mode,
|
||||
"provider": choice.provider,
|
||||
"ui_language": choice.ui_language,
|
||||
"chat_mode": "companion" if choice.provider == "chatgpt" else "agent_adapter",
|
||||
"pairing_state": (
|
||||
(config.get("pairing") or {}).get("state", "not_paired")
|
||||
if choice.provider == "chatgpt"
|
||||
else "adapter_pending"
|
||||
),
|
||||
}
|
||||
path = Path(ctx.config_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(path.name + ".setup.tmp")
|
||||
tmp.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
return config
|
||||
|
||||
|
||||
def setup_summary(ctx) -> dict[str, Any]:
|
||||
config = ctx.load_config() or {}
|
||||
setup = config.get("setup") or {}
|
||||
memory = MemoryStore(ctx).verify() if (config.get("memory") or {}).get("required") else {"ok": False, "required": False}
|
||||
return {
|
||||
"ok": True,
|
||||
"setup": setup,
|
||||
"memory": memory,
|
||||
"provider": config.get("provider"),
|
||||
"seed_version": config.get("seed_version"),
|
||||
}
|
||||
|
||||
|
||||
def render_privilege_explanation(mode: str) -> list[str]:
|
||||
if mode == "totp":
|
||||
return [
|
||||
"Authenticator / QR TOTP selected.",
|
||||
"FrameBridge will show enrollment QR locally.",
|
||||
"The TOTP secret and OTP codes never leave this machine.",
|
||||
"Privileged CHANGE operations still use typed plans and one-shot approval.",
|
||||
]
|
||||
return [
|
||||
"Local sudo password selected.",
|
||||
"FrameBridge never stores or forwards your sudo password.",
|
||||
"sudo prompts are handled directly by the local terminal.",
|
||||
"The AI does not receive general sudo or raw root shell access.",
|
||||
]
|
||||
|
||||
|
||||
def provider_instructions(provider: str) -> list[str]:
|
||||
if provider == "chatgpt":
|
||||
return [
|
||||
"ChatGPT companion mode selected.",
|
||||
"Use the official ChatGPT app or web interface for your OpenAI account.",
|
||||
"FrameBridge does not ask for or store your ChatGPT password.",
|
||||
"After pairing, this local console remains the primary on-machine FrameBridge interface.",
|
||||
]
|
||||
if provider == "ollama":
|
||||
return ["Ollama selected. Local AgentAdapter integration will be configured when available."]
|
||||
if provider == "lmstudio":
|
||||
return ["LM Studio selected. Local AgentAdapter integration will be configured when available."]
|
||||
return ["Custom provider selected. Configure an AgentAdapter after setup."]
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from .product_core import ProductError, redact
|
||||
from .seed_capabilities import hardware_summary, list_capabilities, network_inspect, storage_inspect, system_status
|
||||
|
||||
FBAM_PROTOCOL = "FBAM/1"
|
||||
FBM_PROTOCOL = "FBM/1"
|
||||
FBM_SCHEMA = 1
|
||||
CODEC_ZLIB = 1
|
||||
HEADER = struct.Struct(">4sBBII32s")
|
||||
MAGIC_MANIFEST = b"FBAM"
|
||||
MAGIC_STATE = b"FBM1"
|
||||
MAGIC_RECORD = b"FBMR"
|
||||
MAGIC_INDEX = b"FBI1"
|
||||
MAX_EVENT_RAW = 65536
|
||||
MAX_QUERY_EVENTS = 100
|
||||
|
||||
READ_CAPABILITIES = tuple(x["name"] for x in list_capabilities())
|
||||
CHANGE_CAPABILITIES = (
|
||||
"workspace.write_text",
|
||||
"package.install",
|
||||
"package.remove",
|
||||
"service.manage",
|
||||
"system.hostname",
|
||||
"system.timezone",
|
||||
"package.refresh",
|
||||
"package.upgrade",
|
||||
"system.file.write_text",
|
||||
"system.directory.ensure",
|
||||
"system.file.mode",
|
||||
"system.daemon_reload",
|
||||
)
|
||||
MEMORY_CAPABILITIES = ("memory.bootstrap", "memory.query", "memory.append", "memory.checkpoint", "memory.stats")
|
||||
|
||||
RECORD_TYPE_IDS = {
|
||||
"checkpoint": 1,
|
||||
"change": 2,
|
||||
"incident": 3,
|
||||
"decision": 4,
|
||||
"artifact": 5,
|
||||
"recovery": 6,
|
||||
"certification": 7,
|
||||
"note": 8,
|
||||
}
|
||||
RECORD_TYPE_NAMES = {v: k for k, v in RECORD_TYPE_IDS.items()}
|
||||
SOURCE_IDS = {"system": 1, "agent": 2, "operator": 3, "import": 4}
|
||||
SOURCE_NAMES = {v: k for k, v in SOURCE_IDS.items()}
|
||||
FIELD_REGISTRY = {
|
||||
1: "schema", 2: "record_type", 3: "timestamp_ns", 4: "node_id",
|
||||
5: "identity", 6: "platform", 7: "hardware", 8: "storage", 9: "network",
|
||||
10: "software", 11: "capabilities", 12: "policy", 13: "connections",
|
||||
14: "plans", 15: "audit", 16: "changes", 17: "incidents", 18: "decisions",
|
||||
19: "artifacts", 20: "recovery", 21: "relationships", 22: "pending",
|
||||
23: "freshness", 24: "provenance",
|
||||
}
|
||||
DOMAIN_TO_FIELD = {name: fid for fid, name in FIELD_REGISTRY.items() if fid >= 5}
|
||||
FORBIDDEN_KEY_RE = re.compile(r"(?i)(password|passwd|secret|token|private[_-]?key|otp|credential|client[_-]?secret|api[_-]?key)")
|
||||
|
||||
|
||||
def _uint_head(major: int, value: int) -> bytes:
|
||||
if value < 0:
|
||||
raise ValueError("CBOR_UINT_NEGATIVE")
|
||||
if value < 24:
|
||||
return bytes([(major << 5) | value])
|
||||
if value <= 0xFF:
|
||||
return bytes([(major << 5) | 24, value])
|
||||
if value <= 0xFFFF:
|
||||
return bytes([(major << 5) | 25]) + struct.pack(">H", value)
|
||||
if value <= 0xFFFFFFFF:
|
||||
return bytes([(major << 5) | 26]) + struct.pack(">I", value)
|
||||
if value <= 0xFFFFFFFFFFFFFFFF:
|
||||
return bytes([(major << 5) | 27]) + struct.pack(">Q", value)
|
||||
raise ValueError("CBOR_INTEGER_TOO_LARGE")
|
||||
|
||||
|
||||
def cbor_encode(value: Any) -> bytes:
|
||||
if value is None:
|
||||
return b"\xf6"
|
||||
if value is False:
|
||||
return b"\xf4"
|
||||
if value is True:
|
||||
return b"\xf5"
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return _uint_head(0, value) if value >= 0 else _uint_head(1, -1 - value)
|
||||
if isinstance(value, float):
|
||||
return b"\xfb" + struct.pack(">d", value)
|
||||
if isinstance(value, bytes):
|
||||
return _uint_head(2, len(value)) + value
|
||||
if isinstance(value, str):
|
||||
raw = value.encode("utf-8")
|
||||
return _uint_head(3, len(raw)) + raw
|
||||
if isinstance(value, (list, tuple)):
|
||||
parts = [cbor_encode(x) for x in value]
|
||||
return _uint_head(4, len(parts)) + b"".join(parts)
|
||||
if isinstance(value, dict):
|
||||
items = []
|
||||
for key, item in value.items():
|
||||
ek = cbor_encode(key)
|
||||
items.append((len(ek), ek, cbor_encode(item)))
|
||||
items.sort(key=lambda x: (x[0], x[1]))
|
||||
return _uint_head(5, len(items)) + b"".join(ek + ev for _, ek, ev in items)
|
||||
raise TypeError("CBOR_TYPE_UNSUPPORTED:" + type(value).__name__)
|
||||
|
||||
|
||||
def _read_ai(data: bytes, offset: int, ai: int) -> tuple[int, int]:
|
||||
if ai < 24:
|
||||
return ai, offset
|
||||
if ai == 24:
|
||||
if offset + 1 > len(data): raise ValueError("CBOR_TRUNCATED")
|
||||
return data[offset], offset + 1
|
||||
if ai == 25:
|
||||
if offset + 2 > len(data): raise ValueError("CBOR_TRUNCATED")
|
||||
return struct.unpack_from(">H", data, offset)[0], offset + 2
|
||||
if ai == 26:
|
||||
if offset + 4 > len(data): raise ValueError("CBOR_TRUNCATED")
|
||||
return struct.unpack_from(">I", data, offset)[0], offset + 4
|
||||
if ai == 27:
|
||||
if offset + 8 > len(data): raise ValueError("CBOR_TRUNCATED")
|
||||
return struct.unpack_from(">Q", data, offset)[0], offset + 8
|
||||
raise ValueError("CBOR_INDEFINITE_NOT_SUPPORTED")
|
||||
|
||||
|
||||
def _decode_one(data: bytes, offset: int = 0) -> tuple[Any, int]:
|
||||
if offset >= len(data):
|
||||
raise ValueError("CBOR_TRUNCATED")
|
||||
first = data[offset]; offset += 1
|
||||
major, ai = first >> 5, first & 31
|
||||
if major in (0, 1):
|
||||
n, offset = _read_ai(data, offset, ai)
|
||||
return (n if major == 0 else -1 - n), offset
|
||||
if major in (2, 3):
|
||||
n, offset = _read_ai(data, offset, ai)
|
||||
end = offset + n
|
||||
if end > len(data): raise ValueError("CBOR_TRUNCATED")
|
||||
raw = data[offset:end]
|
||||
return (raw if major == 2 else raw.decode("utf-8")), end
|
||||
if major == 4:
|
||||
n, offset = _read_ai(data, offset, ai)
|
||||
out = []
|
||||
for _ in range(n):
|
||||
item, offset = _decode_one(data, offset); out.append(item)
|
||||
return out, offset
|
||||
if major == 5:
|
||||
n, offset = _read_ai(data, offset, ai)
|
||||
out = {}
|
||||
for _ in range(n):
|
||||
key, offset = _decode_one(data, offset)
|
||||
val, offset = _decode_one(data, offset)
|
||||
out[key] = val
|
||||
return out, offset
|
||||
if major == 7:
|
||||
if ai == 20: return False, offset
|
||||
if ai == 21: return True, offset
|
||||
if ai == 22: return None, offset
|
||||
if ai == 27:
|
||||
if offset + 8 > len(data): raise ValueError("CBOR_TRUNCATED")
|
||||
return struct.unpack_from(">d", data, offset)[0], offset + 8
|
||||
raise ValueError("CBOR_VALUE_UNSUPPORTED")
|
||||
|
||||
|
||||
def cbor_decode(data: bytes) -> Any:
|
||||
value, offset = _decode_one(bytes(data), 0)
|
||||
if offset != len(data):
|
||||
raise ValueError("CBOR_TRAILING_DATA")
|
||||
return value
|
||||
|
||||
|
||||
def _pack_container(magic: bytes, value: Any) -> bytes:
|
||||
raw = cbor_encode(value)
|
||||
comp = zlib.compress(raw, 9)
|
||||
digest = hashlib.sha256(raw).digest()
|
||||
return HEADER.pack(magic, FBM_SCHEMA, CODEC_ZLIB, len(raw), len(comp), digest) + comp
|
||||
|
||||
|
||||
def _unpack_container(data: bytes, expected_magic: bytes) -> tuple[Any, dict[str, Any]]:
|
||||
if len(data) < HEADER.size:
|
||||
raise ProductError("MEMORY_CONTAINER_TRUNCATED")
|
||||
magic, schema, codec, raw_len, comp_len, digest = HEADER.unpack_from(data, 0)
|
||||
if magic != expected_magic: raise ProductError("MEMORY_MAGIC_INVALID")
|
||||
if schema != FBM_SCHEMA: raise ProductError("MEMORY_SCHEMA_UNSUPPORTED")
|
||||
if codec != CODEC_ZLIB: raise ProductError("MEMORY_CODEC_UNSUPPORTED")
|
||||
if len(data) != HEADER.size + comp_len: raise ProductError("MEMORY_CONTAINER_SIZE_INVALID")
|
||||
try: raw = zlib.decompress(data[HEADER.size:])
|
||||
except Exception as exc: raise ProductError("MEMORY_DECOMPRESS_FAILED") from exc
|
||||
if len(raw) != raw_len: raise ProductError("MEMORY_RAW_SIZE_INVALID")
|
||||
if hashlib.sha256(raw).digest() != digest: raise ProductError("MEMORY_DIGEST_MISMATCH")
|
||||
try: value = cbor_decode(raw)
|
||||
except Exception as exc: raise ProductError("MEMORY_CBOR_INVALID") from exc
|
||||
return value, {"raw_bytes": raw_len, "compressed_bytes": comp_len, "raw_sha256": digest.hex()}
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(path.parent, 0o700)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".fbm-", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data); handle.flush(); os.fsync(handle.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp): os.unlink(tmp)
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, value: dict[str, Any]) -> None:
|
||||
data = (json.dumps(value, sort_keys=True, indent=2, ensure_ascii=True) + "\n").encode("utf-8")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".fbm-config-", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data); handle.flush(); os.fsync(handle.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp): os.unlink(tmp)
|
||||
|
||||
|
||||
def _uuid_bytes(value: str) -> bytes:
|
||||
try: return uuid.UUID(str(value)).bytes
|
||||
except Exception: return hashlib.sha256(str(value).encode("utf-8")).digest()[:16]
|
||||
|
||||
|
||||
def _uuid_text(value: bytes) -> str:
|
||||
try: return str(uuid.UUID(bytes=bytes(value)))
|
||||
except Exception: return bytes(value).hex()
|
||||
|
||||
|
||||
def _human(value: Any) -> Any:
|
||||
if isinstance(value, bytes):
|
||||
return value.hex()
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _human(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_human(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _safe_payload(value: Any, depth: int = 0) -> Any:
|
||||
if depth > 8: raise ProductError("MEMORY_PAYLOAD_DEPTH_LIMIT")
|
||||
if isinstance(value, dict):
|
||||
out = {}
|
||||
for key, item in value.items():
|
||||
key = str(key)
|
||||
if len(key) > 80: raise ProductError("MEMORY_KEY_TOO_LONG")
|
||||
out[key] = "[REDACTED]" if FORBIDDEN_KEY_RE.search(key) else _safe_payload(item, depth + 1)
|
||||
return out
|
||||
if isinstance(value, list):
|
||||
if len(value) > 256: raise ProductError("MEMORY_LIST_TOO_LONG")
|
||||
return [_safe_payload(x, depth + 1) for x in value]
|
||||
if isinstance(value, str):
|
||||
return str(redact(value))[:4096]
|
||||
if isinstance(value, (int, float, bool)) or value is None:
|
||||
return value
|
||||
return str(value)[:4096]
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
def __init__(self, seed):
|
||||
self.seed = seed
|
||||
self.root = Path(seed.state_dir) / "memory"
|
||||
self.state_path = self.root / "node.fbm"
|
||||
self.events_path = self.root / "events.fbm"
|
||||
self.index_path = self.root / "index.fbi"
|
||||
self.manifest_path = self.root / "manifest.fbam"
|
||||
|
||||
def _identity_config(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
identity = self.seed.load_identity()
|
||||
config = self.seed.load_config()
|
||||
if not isinstance(identity, dict) or not isinstance(config, dict):
|
||||
raise ProductError("MEMORY_SEED_NOT_INITIALIZED")
|
||||
return identity, config
|
||||
|
||||
def _plans(self) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
plans_dir = Path(self.seed.state_dir) / "plans"
|
||||
if not plans_dir.is_dir(): return out
|
||||
import json
|
||||
for path in sorted(plans_dir.glob("*.json")):
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
plan = value.get("plan") or {}
|
||||
out.append({1: path.stem, 2: value.get("state"), 3: value.get("plan_sha256"), 4: plan.get("title")})
|
||||
except Exception:
|
||||
out.append({1: path.stem, 2: "INVALID"})
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _normalize_storage(value: dict[str, Any]) -> dict[int, Any]:
|
||||
devices = []
|
||||
for item in (value.get("blockdevices") or [])[:128]:
|
||||
if not isinstance(item, dict): continue
|
||||
devices.append({
|
||||
1: item.get("name"), 2: item.get("type"), 3: item.get("size"),
|
||||
4: item.get("fstype"), 5: item.get("mountpoints"), 6: item.get("rota"), 7: item.get("model"),
|
||||
})
|
||||
return {1: value.get("ok") is True, 2: devices}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_network(value: dict[str, Any]) -> dict[int, Any]:
|
||||
addresses = []
|
||||
for item in (value.get("addresses") or [])[:64]:
|
||||
if not isinstance(item, dict): continue
|
||||
infos = []
|
||||
for a in (item.get("addr_info") or [])[:32]:
|
||||
if not isinstance(a, dict): continue
|
||||
infos.append({1:a.get("family"),2:a.get("local"),3:a.get("prefixlen"),4:a.get("scope")})
|
||||
addresses.append({1:item.get("ifname"),2:item.get("operstate"),3:infos})
|
||||
routes = []
|
||||
for r in (value.get("routes") or [])[:128]:
|
||||
if not isinstance(r, dict): continue
|
||||
routes.append({1:r.get("dst"),2:r.get("gateway"),3:r.get("dev"),4:r.get("protocol"),5:r.get("metric")})
|
||||
return {1:value.get("ok") is True,2:addresses,3:routes}
|
||||
|
||||
def _collect_state(self) -> dict[int, Any]:
|
||||
identity, config = self._identity_config()
|
||||
doctor = self.seed.doctor()
|
||||
sys_state = system_status({})
|
||||
hw = hardware_summary({})
|
||||
storage = storage_inspect({})
|
||||
network = network_inspect({})
|
||||
audit = self.seed.audit.verify()
|
||||
plans = self._plans()
|
||||
pending = [p for p in plans if p.get(2) not in ("ROLLED_BACK",)]
|
||||
memory_cfg = config.get("memory") or {}
|
||||
return {
|
||||
1: FBM_SCHEMA,
|
||||
2: 1,
|
||||
3: time.time_ns(),
|
||||
4: _uuid_bytes(identity.get("node_id", "")),
|
||||
5: {
|
||||
1: identity.get("hostname"),
|
||||
2: identity.get("node_id_short"),
|
||||
3: config.get("provider"),
|
||||
4: identity.get("node_host_id"),
|
||||
5: identity.get("node_public_key_sha256"),
|
||||
},
|
||||
6: {1: sys_state.get("os_id"), 2: (doctor.get("checks") or {}).get("os_version"), 3: sys_state.get("os_name"), 4: sys_state.get("kernel"), 5: sys_state.get("architecture"), 6: (doctor.get("checks") or {}).get("python")},
|
||||
7: {1: hw.get("cpu_model"), 2: hw.get("logical_cpus"), 3: hw.get("memory_total_kib"), 4: hw.get("architecture")},
|
||||
8: self._normalize_storage(storage),
|
||||
9: self._normalize_network(network),
|
||||
10: {1: config.get("seed_version"), 2: (doctor.get("checks") or {}).get("python")},
|
||||
11: {1: list(READ_CAPABILITIES), 2: list(CHANGE_CAPABILITIES), 3: list(MEMORY_CAPABILITIES)},
|
||||
12: {1: config.get("capability_profile"), 2: True, 3: "plan-required"},
|
||||
13: {1: config.get("connection_name"), 2: config.get("connection_mode"), 3: (config.get("pairing") or {}).get("state"), 4: (config.get("pairing") or {}).get("transport")},
|
||||
14: plans,
|
||||
15: {1: audit.get("ok") is True, 2: audit.get("entries"), 3: audit.get("last_hash")},
|
||||
21: [],
|
||||
22: pending,
|
||||
23: {1: time.time_ns(), 2: "live-collection"},
|
||||
24: {1: "framebridge-node", 2: memory_cfg.get("authority", "deployment-defined"), 3: "node-operational"},
|
||||
}
|
||||
|
||||
def _manifest_object(self, state: dict[int, Any]) -> dict[int, Any]:
|
||||
identity, config = self._identity_config()
|
||||
return {
|
||||
1: 1,
|
||||
2: "FrameBridge",
|
||||
3: FBAM_PROTOCOL,
|
||||
4: _uuid_bytes(identity.get("node_id", "")),
|
||||
5: identity.get("node_id_short"),
|
||||
6: identity.get("hostname"),
|
||||
7: config.get("seed_version"),
|
||||
8: state.get(6),
|
||||
9: state.get(7),
|
||||
10: state.get(11),
|
||||
11: {1: config.get("capability_profile"), 2: True},
|
||||
12: {1: True, 2: "read_write", 3: FBM_PROTOCOL, 4: list(MEMORY_CAPABILITIES)},
|
||||
13: {1: (config.get("memory") or {}).get("authority", "deployment-defined"), 2: "node-operational"},
|
||||
14: time.time_ns(),
|
||||
}
|
||||
|
||||
def _read_container(self, path: Path, magic: bytes) -> tuple[Any, dict[str, Any]]:
|
||||
try: data = path.read_bytes()
|
||||
except FileNotFoundError as exc: raise ProductError("MEMORY_FILE_MISSING") from exc
|
||||
return _unpack_container(data, magic)
|
||||
|
||||
def _load_index(self) -> dict[int, Any]:
|
||||
if not self.index_path.is_file(): return {1:1,2:0,3:b"",4:[]}
|
||||
value, _ = self._read_container(self.index_path, MAGIC_INDEX)
|
||||
if not isinstance(value, dict): raise ProductError("MEMORY_INDEX_INVALID")
|
||||
return value
|
||||
|
||||
def initialize(self) -> dict[str, Any]:
|
||||
_, config = self._identity_config()
|
||||
desired = {
|
||||
"required": True, "protocol": FBM_PROTOCOL, "manifest_protocol": FBAM_PROTOCOL,
|
||||
"mode": "read_write", "authority": (config.get("memory") or {}).get("authority", "deployment-defined"),
|
||||
}
|
||||
if config.get("memory") != desired:
|
||||
config["memory"] = desired
|
||||
_atomic_write_json(Path(self.seed.config_path), config)
|
||||
self.root.mkdir(parents=True, exist_ok=True); os.chmod(self.root, 0o700)
|
||||
if not self.events_path.exists():
|
||||
self.events_path.touch(mode=0o600); os.chmod(self.events_path, 0o600)
|
||||
if not self.index_path.exists():
|
||||
_atomic_write(self.index_path, _pack_container(MAGIC_INDEX, {1:1,2:0,3:b"",4:[]}))
|
||||
checkpoint = self.checkpoint()
|
||||
if self._load_index().get(2, 0) == 0:
|
||||
self._append_event_raw("checkpoint", {"action":"memory.initialize","protocol":FBM_PROTOCOL}, "system")
|
||||
return {"ok":True,"protocol":FBM_PROTOCOL,"manifest_protocol":FBAM_PROTOCOL,"required":True,"mode":"read_write","checkpoint":checkpoint,"verify":self.verify()}
|
||||
|
||||
def checkpoint(self) -> dict[str, Any]:
|
||||
state = self._collect_state()
|
||||
manifest = self._manifest_object(state)
|
||||
state_blob = _pack_container(MAGIC_STATE, state)
|
||||
manifest_blob = _pack_container(MAGIC_MANIFEST, manifest)
|
||||
_atomic_write(self.state_path, state_blob)
|
||||
_atomic_write(self.manifest_path, manifest_blob)
|
||||
return {
|
||||
"ok": True,
|
||||
"state_sha256": hashlib.sha256(state_blob).hexdigest(),
|
||||
"manifest_sha256": hashlib.sha256(manifest_blob).hexdigest(),
|
||||
"state_bytes": len(state_blob),
|
||||
"manifest_bytes": len(manifest_blob),
|
||||
}
|
||||
|
||||
def ready(self) -> bool:
|
||||
try: return self.verify().get("ok") is True
|
||||
except Exception: return False
|
||||
|
||||
def require_ready(self) -> None:
|
||||
_, config = self._identity_config()
|
||||
required = bool((config.get("memory") or {}).get("required"))
|
||||
if required and not self.ready(): raise ProductError("MEMORY_REQUIRED_NOT_READY")
|
||||
|
||||
def _append_event_raw(self, record_type: str, data: dict[str, Any], source: str) -> dict[str, Any]:
|
||||
if record_type not in RECORD_TYPE_IDS: raise ProductError("MEMORY_RECORD_TYPE_INVALID")
|
||||
if source not in SOURCE_IDS: raise ProductError("MEMORY_SOURCE_INVALID")
|
||||
identity, _ = self._identity_config()
|
||||
safe = _safe_payload(data)
|
||||
if not isinstance(safe, dict): raise ProductError("MEMORY_PAYLOAD_NOT_OBJECT")
|
||||
index = self._load_index()
|
||||
prev = index.get(3) or b""
|
||||
record = {1:FBM_SCHEMA,2:RECORD_TYPE_IDS[record_type],3:time.time_ns(),4:_uuid_bytes(identity.get("node_id","")),5:safe,6:SOURCE_IDS[source],7:prev}
|
||||
raw = cbor_encode(record)
|
||||
if len(raw) > MAX_EVENT_RAW: raise ProductError("MEMORY_EVENT_TOO_LARGE")
|
||||
block = _pack_container(MAGIC_RECORD, record)
|
||||
self.events_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
offset = self.events_path.stat().st_size if self.events_path.exists() else 0
|
||||
with self.events_path.open("ab") as handle:
|
||||
handle.write(block); handle.flush(); os.fsync(handle.fileno())
|
||||
os.chmod(self.events_path, 0o600)
|
||||
record_hash = hashlib.sha256(raw).digest()
|
||||
offsets = list(index.get(4) or [])
|
||||
offsets.append(offset)
|
||||
offsets = offsets[-1024:]
|
||||
new_index = {1:1,2:int(index.get(2) or 0)+1,3:record_hash,4:offsets}
|
||||
_atomic_write(self.index_path, _pack_container(MAGIC_INDEX, new_index))
|
||||
return {"ok":True,"record_type":record_type,"event_number":new_index[2],"event_sha256":record_hash.hex(),"source":source}
|
||||
|
||||
def append(self, record_type: str, data: dict[str, Any], source: str = "agent") -> dict[str, Any]:
|
||||
self.require_ready()
|
||||
return self._append_event_raw(record_type, data, source)
|
||||
|
||||
def _iter_events(self) -> Iterator[tuple[dict[int, Any], bytes, int]]:
|
||||
if not self.events_path.exists(): return
|
||||
data = self.events_path.read_bytes(); off = 0; number = 0
|
||||
while off < len(data):
|
||||
if off + HEADER.size > len(data): raise ProductError("MEMORY_EVENT_STREAM_TRUNCATED")
|
||||
_, _, _, _, comp_len, _ = HEADER.unpack_from(data, off)
|
||||
end = off + HEADER.size + comp_len
|
||||
if end > len(data): raise ProductError("MEMORY_EVENT_STREAM_TRUNCATED")
|
||||
value, _ = _unpack_container(data[off:end], MAGIC_RECORD)
|
||||
if not isinstance(value, dict): raise ProductError("MEMORY_EVENT_INVALID")
|
||||
number += 1
|
||||
yield value, hashlib.sha256(cbor_encode(value)).digest(), off
|
||||
off = end
|
||||
|
||||
def verify(self) -> dict[str, Any]:
|
||||
state, sm = self._read_container(self.state_path, MAGIC_STATE)
|
||||
manifest, mm = self._read_container(self.manifest_path, MAGIC_MANIFEST)
|
||||
index = self._load_index()
|
||||
prev = b""; count = 0; offsets = []
|
||||
for record, record_hash, offset in self._iter_events():
|
||||
if record.get(7, b"") != prev: raise ProductError("MEMORY_EVENT_CHAIN_INVALID")
|
||||
prev = record_hash; count += 1; offsets.append(offset)
|
||||
if int(index.get(2) or 0) != count: raise ProductError("MEMORY_INDEX_COUNT_MISMATCH")
|
||||
if (index.get(3) or b"") != prev: raise ProductError("MEMORY_INDEX_HASH_MISMATCH")
|
||||
identity, _ = self._identity_config()
|
||||
if state.get(4) != _uuid_bytes(identity.get("node_id","")): raise ProductError("MEMORY_NODE_ID_MISMATCH")
|
||||
if manifest.get(4) != state.get(4): raise ProductError("MEMORY_MANIFEST_NODE_MISMATCH")
|
||||
return {
|
||||
"ok":True,"protocol":FBM_PROTOCOL,"manifest_protocol":FBAM_PROTOCOL,"events":count,
|
||||
"event_head_sha256":prev.hex() if prev else "GENESIS",
|
||||
"state_raw_bytes":sm["raw_bytes"],"state_compressed_bytes":sm["compressed_bytes"],
|
||||
"manifest_raw_bytes":mm["raw_bytes"],"manifest_compressed_bytes":mm["compressed_bytes"],
|
||||
}
|
||||
|
||||
def bootstrap(self) -> dict[str, Any]:
|
||||
self.require_ready()
|
||||
manifest, _ = self._read_container(self.manifest_path, MAGIC_MANIFEST)
|
||||
platform = manifest.get(8) or {}; hardware = manifest.get(9) or {}; caps = manifest.get(10) or {}; mem = manifest.get(12) or {}; authority = manifest.get(13) or {}
|
||||
return {
|
||||
"ok": True,
|
||||
"protocol": FBAM_PROTOCOL,
|
||||
"framebridge": {"product":"FrameBridge","seed_version":manifest.get(7)},
|
||||
"node": {"node_id":_uuid_text(manifest.get(4,b"")),"node_id_short":manifest.get(5),"hostname":manifest.get(6)},
|
||||
"os": {"id":platform.get(1),"version":platform.get(2),"name":platform.get(3),"kernel":platform.get(4),"architecture":platform.get(5)},
|
||||
"hardware": {"cpu_model":hardware.get(1),"logical_cpus":hardware.get(2),"memory_total_kib":hardware.get(3),"architecture":hardware.get(4)},
|
||||
"capabilities": {"read":caps.get(1) or [],"change":caps.get(2) or [],"memory":caps.get(3) or []},
|
||||
"policy": {"profile":(manifest.get(11) or {}).get(1),"change_requires_plan":True},
|
||||
"memory": {"required":mem.get(1) is True,"mode":mem.get(2),"protocol":mem.get(3),"capabilities":mem.get(4) or [],"rule":"READ_BEFORE_DEPENDENT_ACTION__WRITE_AFTER_MATERIAL_CHANGE"},
|
||||
"authority": {"mode":authority.get(1),"node_role":authority.get(2)},
|
||||
"generated_at_ns": manifest.get(14),
|
||||
"manifest_sha256": hashlib.sha256(self.manifest_path.read_bytes()).hexdigest(),
|
||||
}
|
||||
|
||||
def query(self, domain: str = "summary", limit: int = 20) -> dict[str, Any]:
|
||||
self.require_ready()
|
||||
domain = str(domain).strip().lower()
|
||||
limit = max(1, min(int(limit), MAX_QUERY_EVENTS))
|
||||
state, _ = self._read_container(self.state_path, MAGIC_STATE)
|
||||
if domain == "summary":
|
||||
verify = self.verify()
|
||||
return {"ok":True,"domain":"summary","node_id":_uuid_text(state.get(4,b"")),"hostname":(state.get(5) or {}).get(1),"seed_version":(state.get(10) or {}).get(1),"audit":_human(state.get(15)),"pending":_human(state.get(22)),"memory":verify}
|
||||
if domain == "events":
|
||||
items = []
|
||||
for record, record_hash, _ in self._iter_events():
|
||||
items.append({"record_type":RECORD_TYPE_NAMES.get(record.get(2),"unknown"),"timestamp_ns":record.get(3),"source":SOURCE_NAMES.get(record.get(6),"unknown"),"data":_human(record.get(5)),"sha256":record_hash.hex()})
|
||||
return {"ok":True,"domain":"events","events":items[-limit:]}
|
||||
if domain not in DOMAIN_TO_FIELD: raise ProductError("MEMORY_QUERY_DOMAIN_INVALID")
|
||||
return {"ok":True,"domain":domain,"value":_human(state.get(DOMAIN_TO_FIELD[domain]))}
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
self.require_ready(); verify = self.verify()
|
||||
return {
|
||||
"ok":True,"protocol":FBM_PROTOCOL,
|
||||
"files": {
|
||||
"node.fbm":self.state_path.stat().st_size,
|
||||
"events.fbm":self.events_path.stat().st_size,
|
||||
"index.fbi":self.index_path.stat().st_size,
|
||||
"manifest.fbam":self.manifest_path.stat().st_size,
|
||||
},
|
||||
"events":verify.get("events"),"event_head_sha256":verify.get("event_head_sha256"),
|
||||
"state_compression_ratio":round(verify["state_compressed_bytes"] / max(1, verify["state_raw_bytes"]), 4),
|
||||
}
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from .memory_store import MemoryStore
|
||||
from .product_core import ProductError
|
||||
from .network_security import inspect_network
|
||||
from .pairing import PairingError, PairingStore, public_pairing_view
|
||||
from .pairing_client import PairingClientError, cancel_pairing_remote
|
||||
from .pairing_window import close_pairing_window, pairing_window_status
|
||||
from .seed_core import SeedContext, atomic_write_json
|
||||
|
||||
|
||||
MONITOR_SCHEMA = 1
|
||||
DEFAULT_INTERVAL_SECONDS = 1.0
|
||||
|
||||
|
||||
def _candidate_binding(candidate: dict[str, Any] | None) -> dict[str, Any]:
|
||||
c=candidate or {}
|
||||
return {
|
||||
"interface":str(c.get("interface") or ""),
|
||||
"source_subnet":str(c.get("subnet") or ""),
|
||||
"destination_address":str(c.get("address") or ""),
|
||||
"network_fingerprint":str(c.get("network_fingerprint") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _window_binding(window: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"interface":str(window.get("interface") or ""),
|
||||
"source_subnet":str(window.get("source_subnet") or ""),
|
||||
"destination_address":str(window.get("destination_address") or ""),
|
||||
"network_fingerprint":str(window.get("network_fingerprint") or ""),
|
||||
}
|
||||
|
||||
|
||||
def compare_binding(window: dict[str, Any], candidate: dict[str, Any] | None) -> dict[str, Any]:
|
||||
old=_window_binding(window)
|
||||
new=_candidate_binding(candidate)
|
||||
reasons=[]
|
||||
if not candidate:
|
||||
reasons.append("NO_ELIGIBLE_PAIRING_CANDIDATE")
|
||||
else:
|
||||
for key,label in (
|
||||
("interface","INTERFACE_CHANGED"),
|
||||
("source_subnet","SUBNET_CHANGED"),
|
||||
("destination_address","ADDRESS_CHANGED"),
|
||||
("network_fingerprint","NETWORK_FINGERPRINT_CHANGED"),
|
||||
):
|
||||
if old.get(key)!=new.get(key):
|
||||
reasons.append(label)
|
||||
return {
|
||||
"changed":bool(reasons),
|
||||
"reasons":reasons,
|
||||
"old":old,
|
||||
"new":new,
|
||||
}
|
||||
|
||||
|
||||
def _sync_pairing_config(ctx: SeedContext, view: dict[str, Any]) -> None:
|
||||
config=ctx.load_config() or {}
|
||||
pairing=config.get("pairing") or {}
|
||||
pairing["state"]=view.get("state","expired")
|
||||
if view.get("expiration_reason"):
|
||||
pairing["expiration_reason"]=view.get("expiration_reason")
|
||||
pairing["requires_new_local_action"]=True
|
||||
config["pairing"]=pairing
|
||||
config["connection_mode"]="PAIRING"
|
||||
setup=config.get("setup")
|
||||
if isinstance(setup,dict):
|
||||
setup["pairing_state"]=view.get("state","expired")
|
||||
atomic_write_json(ctx.config_path,config)
|
||||
|
||||
|
||||
def _invalidate_pending_pair(ctx: SeedContext, reason: str) -> dict[str, Any] | None:
|
||||
store=PairingStore(ctx.state_dir)
|
||||
current=store.load()
|
||||
if current.get("state")!="pair_requested":
|
||||
return None
|
||||
view=store.invalidate_pending(reason)
|
||||
_sync_pairing_config(ctx,view)
|
||||
return view
|
||||
|
||||
|
||||
def reconcile_broker_cancel(
|
||||
ctx: SeedContext,
|
||||
*,
|
||||
cancel_func=cancel_pairing_remote,
|
||||
retry_seconds: float=5.0,
|
||||
) -> dict[str, Any]:
|
||||
store=PairingStore(ctx.state_dir)
|
||||
current=store.load()
|
||||
if current.get("broker_cancel_required") is not True:
|
||||
return {"ok":True,"state":"NOT_REQUIRED","cancelled":False}
|
||||
pair_id=str(current.get("pair_id") or "")
|
||||
reason=str(current.get("broker_cancel_reason") or current.get("expiration_reason") or "reconcile")
|
||||
last=str(current.get("broker_cancel_last_attempt_at") or "")
|
||||
if last:
|
||||
try:
|
||||
last_dt=datetime.fromisoformat(last.replace("Z","+00:00")).astimezone(timezone.utc)
|
||||
age=(datetime.now(timezone.utc)-last_dt).total_seconds()
|
||||
if age<max(1.0,float(retry_seconds)):
|
||||
return {
|
||||
"ok":True,"state":"RETRY_DEFERRED","cancelled":False,
|
||||
"pair_id":pair_id,"retry_after":round(max(0.0,float(retry_seconds)-age),3),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result=cancel_func(state_dir=ctx.state_dir,reason=reason,record=current)
|
||||
except PairingClientError as exc:
|
||||
store.note_broker_cancel_error(pair_id,str(exc))
|
||||
return {
|
||||
"ok":False,"state":"BROKER_CANCEL_PENDING","cancelled":False,
|
||||
"pair_id":pair_id,"error":str(exc),
|
||||
}
|
||||
updated=store.mark_broker_cancelled(pair_id,result)
|
||||
ctx.audit.append(
|
||||
"pair.broker_cancelled","PASS",
|
||||
request={"pair_id":pair_id,"reason":reason},
|
||||
result={"broker_state":result.get("state"),"idempotent":result.get("idempotent",False)},
|
||||
principal="network-monitor",
|
||||
)
|
||||
memory=MemoryStore(ctx)
|
||||
if memory.ready():
|
||||
memory.append(
|
||||
"recovery",
|
||||
{"operation":"pair.broker_cancelled","pair_id":pair_id,"reason":reason,"broker_state":result.get("state")},
|
||||
source="system",
|
||||
)
|
||||
return {
|
||||
"ok":True,"state":"BROKER_CANCELLED","cancelled":True,
|
||||
"pair_id":pair_id,"broker_state":result.get("state"),
|
||||
"local_state":updated.get("state"),
|
||||
}
|
||||
|
||||
|
||||
def _lifecycle_state_path(ctx: SeedContext):
|
||||
return ctx.state_dir/"network-lifecycle.json"
|
||||
|
||||
|
||||
def _sync_expired_window(ctx: SeedContext, window: dict[str, Any]) -> dict[str, Any]:
|
||||
window_id=str(window.get("window_id") or "")
|
||||
if window.get("state")=="expired" and window.get("rule_present") is not False:
|
||||
raise ProductError("NETWORK_WINDOW_EXPIRY_UNVERIFIED")
|
||||
if not window_id or window.get("state")!="expired" or window.get("close_reason")!="expired":
|
||||
return {"ok":True,"synced":False}
|
||||
path=_lifecycle_state_path(ctx)
|
||||
prior={}
|
||||
if path.is_file():
|
||||
try:
|
||||
prior=json.loads(path.read_text())
|
||||
except Exception:
|
||||
prior={}
|
||||
if prior.get("last_expiry_window_id")==window_id:
|
||||
return {"ok":True,"synced":False,"already_synced":True,"window_id":window_id}
|
||||
pair_view=_invalidate_pending_pair(ctx,"window_expired")
|
||||
broker_cancel=reconcile_broker_cancel(ctx)
|
||||
ctx.audit.append(
|
||||
"network.pairing_window.expired","PASS",
|
||||
request={"window_id":window_id,"expires_at":window.get("expires_at")},
|
||||
result={
|
||||
"rule_present":window.get("rule_present"),
|
||||
"pair_state":(pair_view or {}).get("state"),
|
||||
"requires_new_local_action":bool(pair_view),
|
||||
"broker_cancel_state":broker_cancel.get("state"),
|
||||
},
|
||||
principal="network-monitor",
|
||||
)
|
||||
memory=MemoryStore(ctx)
|
||||
if memory.ready():
|
||||
memory.append(
|
||||
"recovery",
|
||||
{
|
||||
"operation":"network.pairing_window.expired",
|
||||
"window_id":window_id,
|
||||
"rule_present":window.get("rule_present"),
|
||||
"pair_state":(pair_view or {}).get("state"),
|
||||
"requires_new_local_action":bool(pair_view),
|
||||
"broker_cancel_state":broker_cancel.get("state"),
|
||||
},source="system",
|
||||
)
|
||||
atomic_write_json(path,{"schema":MONITOR_SCHEMA,"last_expiry_window_id":window_id,"last_expiry_synced_at":time.time()})
|
||||
return {
|
||||
"ok":True,"synced":True,"window_id":window_id,
|
||||
"pair_state":(pair_view or {}).get("state"),
|
||||
"requires_new_local_action":bool(pair_view),
|
||||
"broker_cancel":broker_cancel,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _record_invalidation(
|
||||
ctx: SeedContext,
|
||||
*,
|
||||
reason: str,
|
||||
comparison: dict[str, Any],
|
||||
window: dict[str, Any],
|
||||
closed: dict[str, Any],
|
||||
pair_view: dict[str, Any] | None,
|
||||
) -> None:
|
||||
result={
|
||||
"window_id":window.get("window_id"),
|
||||
"close_reason":closed.get("close_reason"),
|
||||
"rule_present":closed.get("rule_present"),
|
||||
"pair_state":(pair_view or {}).get("state"),
|
||||
"requires_new_local_action":True,
|
||||
"reasons":comparison.get("reasons") or [reason],
|
||||
}
|
||||
ctx.audit.append(
|
||||
"network.pairing_window.invalidated",
|
||||
"PASS",
|
||||
request={
|
||||
"reason":reason,
|
||||
"old":comparison.get("old"),
|
||||
"new":comparison.get("new"),
|
||||
},
|
||||
result=result,
|
||||
principal="network-monitor",
|
||||
)
|
||||
store=MemoryStore(ctx)
|
||||
if store.ready():
|
||||
store.append(
|
||||
"incident",
|
||||
{
|
||||
"operation":"network.pairing_window.invalidated",
|
||||
"reason":reason,
|
||||
"reasons":comparison.get("reasons") or [reason],
|
||||
"window_id":window.get("window_id"),
|
||||
"old":comparison.get("old"),
|
||||
"new":comparison.get("new"),
|
||||
"pair_state":(pair_view or {}).get("state"),
|
||||
"requires_new_local_action":True,
|
||||
},
|
||||
source="system",
|
||||
)
|
||||
|
||||
|
||||
def invalidate_active_window(
|
||||
*,
|
||||
ctx: SeedContext | None=None,
|
||||
reason: str,
|
||||
comparison: dict[str, Any] | None=None,
|
||||
status_func: Callable[..., dict[str, Any]]=pairing_window_status,
|
||||
close_func: Callable[..., dict[str, Any]]=close_pairing_window,
|
||||
) -> dict[str, Any]:
|
||||
ctx=ctx or SeedContext()
|
||||
window=status_func(non_interactive=True)
|
||||
if window.get("active") is not True:
|
||||
return {"ok":True,"schema":MONITOR_SCHEMA,"state":"NO_ACTIVE_PAIRING_WINDOW","invalidated":False}
|
||||
if comparison is None:
|
||||
comparison={"changed":True,"reasons":[reason.upper()],"old":_window_binding(window),"new":{}}
|
||||
closed=close_func(reason,non_interactive=True)
|
||||
if closed.get("ok") is not True or closed.get("rule_present") is not False or closed.get("active") is True:
|
||||
raise ProductError("NETWORK_WINDOW_CLOSE_UNVERIFIED")
|
||||
pair_view=_invalidate_pending_pair(ctx,reason)
|
||||
broker_cancel=reconcile_broker_cancel(ctx)
|
||||
_record_invalidation(
|
||||
ctx,reason=reason,comparison=comparison,window=window,closed=closed,pair_view=pair_view,
|
||||
)
|
||||
return {
|
||||
"ok":True,"schema":MONITOR_SCHEMA,"state":"INVALIDATED","invalidated":True,
|
||||
"reason":reason,"reasons":comparison.get("reasons") or [],
|
||||
"window_id":window.get("window_id"),"rule_present":closed.get("rule_present"),
|
||||
"pair_state":(pair_view or {}).get("state"),"requires_new_local_action":True,
|
||||
"broker_cancel":broker_cancel,"old":comparison.get("old"),"new":comparison.get("new"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
def reconcile_once(
|
||||
*,
|
||||
ctx: SeedContext | None=None,
|
||||
snapshot: dict[str, Any] | None=None,
|
||||
status_func: Callable[..., dict[str, Any]]=pairing_window_status,
|
||||
close_func: Callable[..., dict[str, Any]]=close_pairing_window,
|
||||
) -> dict[str, Any]:
|
||||
ctx=ctx or SeedContext()
|
||||
broker_cancel=reconcile_broker_cancel(ctx)
|
||||
window=status_func(non_interactive=True)
|
||||
if window.get("active") is not True:
|
||||
expiry_sync=_sync_expired_window(ctx,window)
|
||||
return {
|
||||
"ok":True,"schema":MONITOR_SCHEMA,
|
||||
"state":"PAIRING_WINDOW_EXPIRED_SYNCED" if expiry_sync.get("synced") else "NO_ACTIVE_PAIRING_WINDOW",
|
||||
"invalidated":False,"expiry_sync":expiry_sync,"broker_cancel":broker_cancel,
|
||||
}
|
||||
try:
|
||||
snap=snapshot if snapshot is not None else inspect_network()
|
||||
if not isinstance(snap,dict): raise ValueError("NETWORK_SNAPSHOT_NOT_OBJECT")
|
||||
except Exception:
|
||||
return invalidate_active_window(
|
||||
ctx=ctx,reason="reconcile",
|
||||
comparison={"changed":True,"reasons":["NETWORK_INSPECTION_FAILED"],"old":_window_binding(window),"new":{}},
|
||||
status_func=lambda **_:window,close_func=close_func,
|
||||
)
|
||||
candidate=snap.get("pairing_candidate")
|
||||
comparison=compare_binding(window,candidate)
|
||||
if not comparison["changed"]:
|
||||
return {
|
||||
"ok":True,"schema":MONITOR_SCHEMA,"state":"STABLE","invalidated":False,
|
||||
"window_id":window.get("window_id"),"binding":comparison["old"],"broker_cancel":broker_cancel,
|
||||
}
|
||||
return invalidate_active_window(
|
||||
ctx=ctx,reason="network_change",comparison=comparison,
|
||||
status_func=lambda **_:window,close_func=close_func,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def run_forever(interval: float=DEFAULT_INTERVAL_SECONDS) -> None:
|
||||
interval=max(0.25,float(interval))
|
||||
ctx=SeedContext()
|
||||
while True:
|
||||
try:
|
||||
result=reconcile_once(ctx=ctx)
|
||||
if result.get("invalidated") is True:
|
||||
print(json.dumps(result,sort_keys=True),flush=True)
|
||||
except Exception as exc:
|
||||
print(json.dumps({
|
||||
"ok":False,
|
||||
"schema":MONITOR_SCHEMA,
|
||||
"state":"MONITOR_ERROR",
|
||||
"error":type(exc).__name__,
|
||||
"message":str(exc),
|
||||
},sort_keys=True),file=sys.stderr,flush=True)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser=argparse.ArgumentParser(prog="framebridge-network-monitor")
|
||||
sub=parser.add_subparsers(dest="command",required=True)
|
||||
run=sub.add_parser("run")
|
||||
run.add_argument("--interval",type=float,default=DEFAULT_INTERVAL_SECONDS)
|
||||
sub.add_parser("once")
|
||||
close=sub.add_parser("close")
|
||||
close.add_argument("--reason",choices=("service_stop","reconcile"),default="service_stop")
|
||||
args=parser.parse_args()
|
||||
if args.command=="run":
|
||||
run_forever(args.interval)
|
||||
elif args.command=="once":
|
||||
print(json.dumps(reconcile_once(),sort_keys=True,indent=2))
|
||||
else:
|
||||
print(json.dumps(invalidate_active_window(reason=args.reason),sort_keys=True,indent=2))
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
main()
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
SCHEMA = 1
|
||||
PAIRING_WINDOW_TTL_SECONDS = 600
|
||||
|
||||
RFC1918 = (
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
)
|
||||
ULA_V6 = ipaddress.ip_network("fc00::/7")
|
||||
|
||||
OVERLAY_NAMES = {"tailscale0"}
|
||||
VIRTUAL_PREFIXES = (
|
||||
"docker", "br-", "virbr", "veth", "lxc", "waydroid",
|
||||
"tap", "tun", "zt", "wg",
|
||||
)
|
||||
|
||||
|
||||
def _run(argv: list[str], timeout: int = 5) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
argv,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _run_json(argv: list[str]) -> list[dict[str, Any]]:
|
||||
try:
|
||||
cp = _run(argv)
|
||||
except Exception:
|
||||
return []
|
||||
if cp.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
value = json.loads(cp.stdout or "[]")
|
||||
except Exception:
|
||||
return []
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def address_class(address: str) -> str:
|
||||
try:
|
||||
ip = ipaddress.ip_address(str(address))
|
||||
except ValueError:
|
||||
return "INVALID"
|
||||
if ip.is_loopback:
|
||||
return "LOOPBACK"
|
||||
if ip.version == 4:
|
||||
if any(ip in net for net in RFC1918):
|
||||
return "PRIVATE_LAN"
|
||||
if ip.is_link_local:
|
||||
return "LINK_LOCAL"
|
||||
if ip.is_global:
|
||||
return "PUBLIC"
|
||||
return "SPECIAL"
|
||||
if ip in ULA_V6:
|
||||
return "ULA"
|
||||
if ip.is_link_local:
|
||||
return "LINK_LOCAL"
|
||||
if ip.is_global:
|
||||
return "PUBLIC"
|
||||
return "SPECIAL"
|
||||
|
||||
|
||||
def classify_interface(
|
||||
name: str,
|
||||
*,
|
||||
has_device: bool = False,
|
||||
wireless: bool = False,
|
||||
) -> str:
|
||||
n = str(name or "")
|
||||
if n == "lo":
|
||||
return "LOOPBACK"
|
||||
if n in OVERLAY_NAMES:
|
||||
return "PRIVATE_OVERLAY"
|
||||
if n.startswith(VIRTUAL_PREFIXES):
|
||||
return "VIRTUAL_LOCAL"
|
||||
if wireless or n.startswith(("wl", "wlan")):
|
||||
return "PHYSICAL_LAN"
|
||||
if has_device or n.startswith(("en", "eth")):
|
||||
return "PHYSICAL_LAN"
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def _sysfs_interface_kind(name: str) -> str:
|
||||
base = Path("/sys/class/net") / name
|
||||
return classify_interface(
|
||||
name,
|
||||
has_device=(base / "device").exists(),
|
||||
wireless=(base / "wireless").exists(),
|
||||
)
|
||||
|
||||
|
||||
def _prefix_subnet(address: str, prefixlen: int) -> str:
|
||||
try:
|
||||
return str(ipaddress.ip_interface(f"{address}/{int(prefixlen)}").network)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _default_routes() -> list[dict[str, Any]]:
|
||||
routes = _run_json(["ip", "-j", "-4", "route", "show", "default"])
|
||||
clean: list[dict[str, Any]] = []
|
||||
for r in routes:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
clean.append(
|
||||
{
|
||||
"dev": str(r.get("dev") or ""),
|
||||
"gateway": str(r.get("gateway") or ""),
|
||||
"metric": int(r.get("metric") or 0),
|
||||
"prefsrc": str(r.get("prefsrc") or ""),
|
||||
}
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _network_manager_identity(ifname: str) -> dict[str, str]:
|
||||
result = {"connection": "", "connection_uuid": ""}
|
||||
try:
|
||||
cp = _run(["nmcli", "-g", "GENERAL.CONNECTION", "device", "show", ifname])
|
||||
except Exception:
|
||||
return result
|
||||
if cp.returncode != 0:
|
||||
return result
|
||||
connection = (cp.stdout or "").strip().splitlines()
|
||||
if not connection:
|
||||
return result
|
||||
name = connection[0].strip()
|
||||
if not name or name == "--":
|
||||
return result
|
||||
result["connection"] = name
|
||||
try:
|
||||
cp2 = _run(["nmcli", "-g", "connection.uuid", "connection", "show", name])
|
||||
except Exception:
|
||||
return result
|
||||
if cp2.returncode == 0:
|
||||
result["connection_uuid"] = (cp2.stdout or "").strip().splitlines()[0] if (cp2.stdout or "").strip() else ""
|
||||
return result
|
||||
|
||||
|
||||
def collect_interfaces() -> list[dict[str, Any]]:
|
||||
links = _run_json(["ip", "-j", "link", "show"])
|
||||
addrs = _run_json(["ip", "-j", "addr", "show"])
|
||||
addr_by_name = {str(x.get("ifname") or ""): x for x in addrs if isinstance(x, dict)}
|
||||
out: list[dict[str, Any]] = []
|
||||
for link in links:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
name = str(link.get("ifname") or "")
|
||||
if not name:
|
||||
continue
|
||||
a = addr_by_name.get(name, {})
|
||||
ipv4 = []
|
||||
ipv6 = []
|
||||
for ai in a.get("addr_info") or []:
|
||||
if not isinstance(ai, dict):
|
||||
continue
|
||||
local = str(ai.get("local") or "")
|
||||
if not local:
|
||||
continue
|
||||
item = {
|
||||
"address": local,
|
||||
"prefixlen": int(ai.get("prefixlen") or 0),
|
||||
"class": address_class(local),
|
||||
"scope": str(ai.get("scope") or ""),
|
||||
}
|
||||
if ai.get("family") == "inet":
|
||||
ipv4.append(item)
|
||||
elif ai.get("family") == "inet6":
|
||||
ipv6.append(item)
|
||||
kind = _sysfs_interface_kind(name)
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"kind": kind,
|
||||
"operstate": str(link.get("operstate") or ""),
|
||||
"flags": list(link.get("flags") or []),
|
||||
"ipv4": ipv4,
|
||||
"ipv6": ipv6,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def choose_pairing_candidate(
|
||||
interfaces: list[dict[str, Any]],
|
||||
routes: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Select only an unambiguous physical private-LAN binding.
|
||||
|
||||
Metric zero is valid. Route preferred source is honored. Equal-priority
|
||||
candidates that differ in interface/address/prefix/gateway are blocked,
|
||||
not resolved by comparing payload dictionaries or snapshot order.
|
||||
"""
|
||||
route_by_dev: dict[str, list[dict[str, Any]]] = {}
|
||||
for route in routes:
|
||||
route_by_dev.setdefault(str(route.get("dev") or ""), []).append(route)
|
||||
|
||||
choices: dict[tuple, tuple[tuple, dict[str, Any]]] = {}
|
||||
for iface in interfaces:
|
||||
if iface.get("kind") != "PHYSICAL_LAN":
|
||||
continue
|
||||
if str(iface.get("operstate") or "").upper() not in {"UP", "UNKNOWN"}:
|
||||
continue
|
||||
name = str(iface.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
dev_routes = route_by_dev.get(name, [])
|
||||
if dev_routes:
|
||||
minimum = min(int(r.get("metric") or 0) for r in dev_routes)
|
||||
best_routes = [r for r in dev_routes if int(r.get("metric") or 0) == minimum]
|
||||
else:
|
||||
best_routes = [{}]
|
||||
for selected_route in best_routes:
|
||||
preferred = str(selected_route.get("prefsrc") or "")
|
||||
metric = int(selected_route.get("metric") or 0) if dev_routes else None
|
||||
for addr in iface.get("ipv4") or []:
|
||||
address = str(addr.get("address") or "")
|
||||
if address_class(address) != "PRIVATE_LAN":
|
||||
continue
|
||||
if preferred and address != preferred:
|
||||
continue
|
||||
prefix = addr.get("prefixlen")
|
||||
if isinstance(prefix, bool) or not isinstance(prefix, int) or not 1 <= prefix <= 32:
|
||||
continue
|
||||
gateway = str(selected_route.get("gateway") or "")
|
||||
item = {
|
||||
"interface": name,
|
||||
"interface_kind": "PHYSICAL_LAN",
|
||||
"address": address,
|
||||
"prefixlen": prefix,
|
||||
"subnet": _prefix_subnet(address, prefix),
|
||||
"address_class": "PRIVATE_LAN",
|
||||
"gateway": gateway,
|
||||
"route_metric": metric,
|
||||
}
|
||||
rank = (0 if dev_routes else 1, metric if metric is not None else 0,
|
||||
0 if preferred else 1)
|
||||
identity = (name, address, prefix, gateway, metric)
|
||||
choices[identity] = (rank, item)
|
||||
if not choices:
|
||||
return None
|
||||
best_rank = min(rank for rank, item in choices.values())
|
||||
top = [item for rank, item in choices.values() if rank == best_rank]
|
||||
if len(top) != 1:
|
||||
return None
|
||||
selected = dict(top[0])
|
||||
selected.update(_network_manager_identity(selected["interface"]))
|
||||
binding = {key: selected.get(key) for key in (
|
||||
"interface", "address", "prefixlen", "gateway", "route_metric", "connection_uuid"
|
||||
)}
|
||||
selected["network_fingerprint_schema"] = 2
|
||||
raw = json.dumps(binding, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
selected["network_fingerprint"] = hashlib.sha256(raw).hexdigest()
|
||||
return selected
|
||||
|
||||
|
||||
def _service_active(name: str) -> bool:
|
||||
try:
|
||||
cp = _run(["systemctl", "is-active", "--quiet", name])
|
||||
except Exception:
|
||||
return False
|
||||
return cp.returncode == 0
|
||||
|
||||
|
||||
def _ufw_config_enabled(path: Path=Path("/etc/ufw/ufw.conf")) -> bool:
|
||||
try:
|
||||
text=path.read_text(encoding="utf-8",errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
for raw in text.splitlines():
|
||||
line=raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.upper().startswith("ENABLED="):
|
||||
return line.split("=",1)[1].strip().lower() in {"yes","true","1"}
|
||||
return False
|
||||
|
||||
|
||||
def firewall_backend() -> dict[str, Any]:
|
||||
ufw_systemd_active=_service_active("ufw")
|
||||
ufw_config_enabled=_ufw_config_enabled()
|
||||
if ufw_systemd_active or ufw_config_enabled:
|
||||
return {
|
||||
"backend": "ufw",
|
||||
"active": True,
|
||||
"systemd_active": ufw_systemd_active,
|
||||
"config_enabled": ufw_config_enabled,
|
||||
"managed_pairing_rules": True,
|
||||
}
|
||||
if _service_active("firewalld"):
|
||||
return {"backend": "firewalld", "active": True, "managed_pairing_rules": False}
|
||||
if _service_active("nftables"):
|
||||
return {"backend": "nftables", "active": True, "managed_pairing_rules": False}
|
||||
return {"backend": "none", "active": False, "managed_pairing_rules": False}
|
||||
|
||||
|
||||
def ufw_pairing_plan(candidate: dict[str, Any], port: int) -> dict[str, Any]:
|
||||
iface = str(candidate.get("interface") or "")
|
||||
subnet = str(candidate.get("subnet") or "")
|
||||
address = str(candidate.get("address") or "")
|
||||
fp = str(candidate.get("network_fingerprint") or "")
|
||||
if not iface or not subnet or not address or not fp:
|
||||
return {"ok": False, "error": "PAIRING_CANDIDATE_INCOMPLETE"}
|
||||
return {
|
||||
"ok": True,
|
||||
"backend": "ufw",
|
||||
"execution_backend": "iptables-runtime-inside-ufw",
|
||||
"chain": "ufw-user-input",
|
||||
"execute": False,
|
||||
"operation": "runtime_scoped_accept",
|
||||
"interface": iface,
|
||||
"source_subnet": subnet,
|
||||
"destination_address": address,
|
||||
"port": int(port),
|
||||
"protocol": "tcp",
|
||||
"network_fingerprint": fp,
|
||||
"ttl_seconds": PAIRING_WINDOW_TTL_SECONDS,
|
||||
"requires_local_tty": True,
|
||||
"runtime_only": True,
|
||||
"reboot_fail_closed": True,
|
||||
"automatic_expiry": True,
|
||||
"persistent_ufw_rule": False,
|
||||
"rule_comment_template": "FrameBridge:<window_id>:pairing",
|
||||
}
|
||||
|
||||
|
||||
def inspect_network() -> dict[str, Any]:
|
||||
interfaces = collect_interfaces()
|
||||
routes = _default_routes()
|
||||
candidate = choose_pairing_candidate(interfaces, routes)
|
||||
fw = firewall_backend()
|
||||
return {
|
||||
"ok": True,
|
||||
"schema": SCHEMA,
|
||||
"interfaces": interfaces,
|
||||
"default_routes": routes,
|
||||
"pairing_candidate": candidate,
|
||||
"firewall": fw,
|
||||
"policy": {
|
||||
"local_pairing_transport": "LAN_HTTP",
|
||||
"tailscale_required": False,
|
||||
"public_http_allowed": False,
|
||||
"wildcard_pairing_bind_allowed": False,
|
||||
"local_tty_required": True,
|
||||
"pairing_window_ttl_seconds": PAIRING_WINDOW_TTL_SECONDS,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def pairing_policy(port: int = 18765) -> dict[str, Any]:
|
||||
snap = inspect_network()
|
||||
candidate = snap.get("pairing_candidate")
|
||||
fw = snap.get("firewall") or {}
|
||||
if not candidate:
|
||||
return {
|
||||
"ok": False,
|
||||
"schema": SCHEMA,
|
||||
"state": "BLOCKED",
|
||||
"reason": "NO_ELIGIBLE_PHYSICAL_PRIVATE_LAN",
|
||||
"execute": False,
|
||||
"snapshot": snap,
|
||||
}
|
||||
if not fw.get("active"):
|
||||
return {
|
||||
"ok": False,
|
||||
"schema": SCHEMA,
|
||||
"state": "BLOCKED",
|
||||
"reason": "NO_ACTIVE_FIREWALL_BACKEND",
|
||||
"execute": False,
|
||||
"snapshot": snap,
|
||||
}
|
||||
if fw.get("backend") != "ufw":
|
||||
return {
|
||||
"ok": False,
|
||||
"schema": SCHEMA,
|
||||
"state": "BLOCKED",
|
||||
"reason": "FIREWALL_BACKEND_NOT_YET_MANAGED",
|
||||
"execute": False,
|
||||
"snapshot": snap,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"schema": SCHEMA,
|
||||
"state": "ELIGIBLE_REQUIRES_LOCAL_TTY",
|
||||
"execute": False,
|
||||
"candidate": candidate,
|
||||
"firewall_plan": ufw_pairing_plan(candidate, int(port)),
|
||||
"network_change_policy": "INVALIDATE_PENDING_PAIR_AND_CLOSE_OLD_WINDOW",
|
||||
}
|
||||
|
||||
CGNAT_V4 = ipaddress.ip_network("100.64.0.0/10")
|
||||
FORWARD_PROTOCOLS = {"any", "tcp", "udp"}
|
||||
|
||||
|
||||
def _valid_interface_name(value: str) -> bool:
|
||||
import re
|
||||
return re.fullmatch(r"[A-Za-z0-9_.:-]{1,32}", str(value or "")) is not None
|
||||
|
||||
|
||||
def _normalize_ipv4_network(value: str) -> str:
|
||||
try:
|
||||
net = ipaddress.ip_network(str(value), strict=False)
|
||||
except ValueError as exc:
|
||||
raise ValueError("NETWORK_CIDR_INVALID") from exc
|
||||
if net.version != 4:
|
||||
raise ValueError("NETWORK_IPV4_REQUIRED")
|
||||
if net.is_multicast or net.is_loopback or net.is_link_local:
|
||||
raise ValueError("NETWORK_CIDR_UNSAFE")
|
||||
return str(net)
|
||||
|
||||
|
||||
def _source_network_allowed(value: str) -> bool:
|
||||
net = ipaddress.ip_network(value, strict=False)
|
||||
if net.version != 4:
|
||||
return False
|
||||
if net.subnet_of(CGNAT_V4):
|
||||
return True
|
||||
return any(net.subnet_of(x) for x in RFC1918)
|
||||
|
||||
|
||||
def forwarding_policy(
|
||||
*,
|
||||
ingress_interface: str,
|
||||
egress_interface: str,
|
||||
source_subnet: str,
|
||||
destination_subnet: str,
|
||||
protocol: str = "any",
|
||||
destination_port: int = 0,
|
||||
nat: bool = False,
|
||||
lifetime: str = "runtime",
|
||||
) -> dict[str, Any]:
|
||||
ingress_interface = str(ingress_interface or "").strip()
|
||||
egress_interface = str(egress_interface or "").strip()
|
||||
protocol = str(protocol or "any").strip().lower()
|
||||
lifetime = str(lifetime or "runtime").strip().lower()
|
||||
try:
|
||||
destination_port = int(destination_port)
|
||||
except Exception:
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PORT_INVALID", "execute": False}
|
||||
|
||||
if not _valid_interface_name(ingress_interface) or not _valid_interface_name(egress_interface):
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_INTERFACE_INVALID", "execute": False}
|
||||
if ingress_interface == egress_interface:
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_INTERFACES_MUST_DIFFER", "execute": False}
|
||||
|
||||
try:
|
||||
source_subnet = _normalize_ipv4_network(source_subnet)
|
||||
destination_subnet = _normalize_ipv4_network(destination_subnet)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "state": "BLOCKED", "reason": str(exc), "execute": False}
|
||||
|
||||
if not _source_network_allowed(source_subnet):
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_SOURCE_NOT_PRIVATE", "execute": False}
|
||||
|
||||
if protocol not in FORWARD_PROTOCOLS:
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PROTOCOL_INVALID", "execute": False}
|
||||
if destination_port < 0 or destination_port > 65535:
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PORT_INVALID", "execute": False}
|
||||
if protocol == "any" and destination_port != 0:
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PORT_REQUIRES_PROTOCOL", "execute": False}
|
||||
if lifetime != "runtime":
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_PERSISTENCE_REQUIRES_SEPARATE_CAPABILITY", "execute": False}
|
||||
|
||||
fw = firewall_backend()
|
||||
if not fw.get("active"):
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NO_ACTIVE_FIREWALL_BACKEND", "execute": False}
|
||||
if fw.get("backend") != "ufw":
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "FIREWALL_BACKEND_NOT_YET_MANAGED", "execute": False}
|
||||
|
||||
names = {x.get("name") for x in collect_interfaces()}
|
||||
if ingress_interface not in names or egress_interface not in names:
|
||||
return {"ok": False, "state": "BLOCKED", "reason": "NETWORK_FORWARD_INTERFACE_NOT_PRESENT", "execute": False}
|
||||
|
||||
args = {
|
||||
"ingress_interface": ingress_interface,
|
||||
"egress_interface": egress_interface,
|
||||
"source_subnet": source_subnet,
|
||||
"destination_subnet": destination_subnet,
|
||||
"protocol": protocol,
|
||||
"destination_port": destination_port,
|
||||
"nat": bool(nat),
|
||||
"lifetime": "runtime",
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"schema": SCHEMA,
|
||||
"state": "ELIGIBLE_REQUIRES_TYPED_PRIVILEGED_PLAN",
|
||||
"execute": False,
|
||||
"capability": "network.forward",
|
||||
"args": args,
|
||||
"security": {
|
||||
"local_tty_privileged_required": True,
|
||||
"independent_rollback_approval_required": True,
|
||||
"runtime_only": True,
|
||||
"reboot_fail_closed": True,
|
||||
"nat_explicit": bool(nat),
|
||||
"wildcard_source_forbidden": True,
|
||||
},
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
NODE_KEY_NAMESPACE = "framebridge-pairing"
|
||||
NODE_KEY_ALGORITHM = "ssh-ed25519"
|
||||
|
||||
|
||||
class NodeIdentityError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _ssh_string(blob: bytes, offset: int) -> tuple[bytes, int]:
|
||||
if offset + 4 > len(blob):
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
|
||||
size = struct.unpack(">I", blob[offset:offset + 4])[0]
|
||||
offset += 4
|
||||
end = offset + size
|
||||
if end > len(blob):
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
|
||||
return blob[offset:end], end
|
||||
|
||||
|
||||
def read_ed25519_public_key(public_path: Path) -> dict[str, Any]:
|
||||
path = Path(public_path)
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_MISSING")
|
||||
parts = path.read_text(encoding="utf-8").strip().split()
|
||||
if len(parts) < 2 or parts[0] != NODE_KEY_ALGORITHM:
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
|
||||
try:
|
||||
blob = base64.b64decode(parts[1], validate=True)
|
||||
except Exception as exc:
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID") from exc
|
||||
alg, off = _ssh_string(blob, 0)
|
||||
raw, off = _ssh_string(blob, off)
|
||||
if off != len(blob) or alg != b"ssh-ed25519" or len(raw) != 32:
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
|
||||
x = _b64url(raw)
|
||||
jwk = {"crv": "Ed25519", "kty": "OKP", "x": x}
|
||||
canonical = json.dumps(
|
||||
jwk, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
).encode("ascii")
|
||||
thumbprint = _b64url(hashlib.sha256(canonical).digest())
|
||||
host_id = (
|
||||
"urn:ietf:params:oauth:jwk-thumbprint:sha-256:" + thumbprint
|
||||
)
|
||||
return {
|
||||
"algorithm": "Ed25519",
|
||||
"public_jwk": jwk,
|
||||
"jwk_thumbprint_sha256": thumbprint,
|
||||
"host_id": host_id,
|
||||
"public_key_sha256": hashlib.sha256(blob).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def ensure_node_keypair(
|
||||
state_dir: Path,
|
||||
*,
|
||||
comment: str = "framebridge-node",
|
||||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> dict[str, Any]:
|
||||
root = Path(state_dir) / "node-identity"
|
||||
private_path = root / "node_ed25519"
|
||||
public_path = root / "node_ed25519.pub"
|
||||
|
||||
if root.exists() and root.is_symlink():
|
||||
raise NodeIdentityError("NODE_KEY_DIR_SYMLINK_FORBIDDEN")
|
||||
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(root, 0o700)
|
||||
|
||||
private_exists = private_path.exists()
|
||||
public_exists = public_path.exists()
|
||||
if private_exists != public_exists:
|
||||
raise NodeIdentityError("NODE_KEYPAIR_INCOMPLETE")
|
||||
|
||||
if not private_exists:
|
||||
proc = runner(
|
||||
[
|
||||
"/usr/bin/ssh-keygen",
|
||||
"-q",
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-N",
|
||||
"",
|
||||
"-C",
|
||||
str(comment),
|
||||
"-f",
|
||||
str(private_path),
|
||||
],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise NodeIdentityError("NODE_KEYGEN_FAILED")
|
||||
|
||||
if (
|
||||
not private_path.is_file()
|
||||
or private_path.is_symlink()
|
||||
or not public_path.is_file()
|
||||
or public_path.is_symlink()
|
||||
):
|
||||
raise NodeIdentityError("NODE_KEYPAIR_INVALID")
|
||||
|
||||
os.chmod(private_path, 0o600)
|
||||
os.chmod(public_path, 0o644)
|
||||
mode = stat.S_IMODE(private_path.stat().st_mode)
|
||||
if mode != 0o600:
|
||||
raise NodeIdentityError("NODE_PRIVATE_KEY_MODE_INVALID")
|
||||
|
||||
public = read_ed25519_public_key(public_path)
|
||||
return {
|
||||
"ok": True,
|
||||
"private_key_path": str(private_path),
|
||||
"public_key_path": str(public_path),
|
||||
"public_key_ssh": _safe_public_line(public_path),
|
||||
**public,
|
||||
}
|
||||
|
||||
|
||||
def _safe_public_line(public_path: Path) -> str:
|
||||
parts=Path(public_path).read_text(encoding="utf-8").strip().split()
|
||||
if len(parts)<2 or parts[0] != NODE_KEY_ALGORITHM:
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
|
||||
return parts[0] + " " + parts[1]
|
||||
|
||||
|
||||
def sign_pairing_payload(
|
||||
private_path: Path,
|
||||
payload: bytes,
|
||||
*,
|
||||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> str:
|
||||
private_path=Path(private_path)
|
||||
if not private_path.is_file() or private_path.is_symlink():
|
||||
raise NodeIdentityError("NODE_PRIVATE_KEY_MISSING")
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-pair-sign-") as td:
|
||||
msg=Path(td)/"message"
|
||||
msg.write_bytes(bytes(payload))
|
||||
proc=runner(
|
||||
[
|
||||
"/usr/bin/ssh-keygen","-Y","sign",
|
||||
"-f",str(private_path),
|
||||
"-n",NODE_KEY_NAMESPACE,
|
||||
str(msg),
|
||||
],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
sig=Path(str(msg)+".sig")
|
||||
if proc.returncode!=0 or not sig.is_file():
|
||||
raise NodeIdentityError("NODE_PAIR_SIGNATURE_FAILED")
|
||||
return base64.b64encode(sig.read_bytes()).decode("ascii")
|
||||
|
||||
|
||||
def verify_pairing_payload(
|
||||
public_key_ssh: str,
|
||||
payload: bytes,
|
||||
signature_b64: str,
|
||||
*,
|
||||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> bool:
|
||||
import tempfile
|
||||
try:
|
||||
signature=base64.b64decode(str(signature_b64),validate=True)
|
||||
except Exception:
|
||||
return False
|
||||
public_key_ssh=str(public_key_ssh).strip()
|
||||
if not public_key_ssh.startswith("ssh-ed25519 "):
|
||||
return False
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-pair-verify-") as td:
|
||||
root=Path(td)
|
||||
msg=root/"message"
|
||||
sig=root/"message.sig"
|
||||
allowed=root/"allowed_signers"
|
||||
msg.write_bytes(bytes(payload))
|
||||
sig.write_bytes(signature)
|
||||
allowed.write_text(
|
||||
"framebridge-node " + public_key_ssh + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc=runner(
|
||||
[
|
||||
"/usr/bin/ssh-keygen","-Y","verify",
|
||||
"-f",str(allowed),
|
||||
"-I","framebridge-node",
|
||||
"-n",NODE_KEY_NAMESPACE,
|
||||
"-s",str(sig),
|
||||
],
|
||||
input=bytes(payload),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode==0
|
||||
|
||||
|
||||
def parse_ed25519_public_key_line(line: str) -> dict[str, Any]:
|
||||
parts=str(line).strip().split()
|
||||
if len(parts)<2 or parts[0] != NODE_KEY_ALGORITHM:
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
|
||||
try:
|
||||
blob=base64.b64decode(parts[1],validate=True)
|
||||
except Exception as exc:
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID") from exc
|
||||
alg,off=_ssh_string(blob,0)
|
||||
raw,off=_ssh_string(blob,off)
|
||||
if off!=len(blob) or alg!=b"ssh-ed25519" or len(raw)!=32:
|
||||
raise NodeIdentityError("NODE_PUBLIC_KEY_INVALID")
|
||||
x=_b64url(raw)
|
||||
jwk={"crv":"Ed25519","kty":"OKP","x":x}
|
||||
canonical=json.dumps(
|
||||
jwk,sort_keys=True,separators=(",",":"),ensure_ascii=True
|
||||
).encode("ascii")
|
||||
thumbprint=_b64url(hashlib.sha256(canonical).digest())
|
||||
return {
|
||||
"algorithm":"Ed25519",
|
||||
"public_jwk":jwk,
|
||||
"jwk_thumbprint_sha256":thumbprint,
|
||||
"host_id":"urn:ietf:params:oauth:jwk-thumbprint:sha-256:"+thumbprint,
|
||||
"public_key_sha256":hashlib.sha256(blob).hexdigest(),
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .node_identity import ensure_node_keypair, sign_pairing_payload
|
||||
from .pairing import PairingStore, canonical
|
||||
from .pairing_client import PairingClientError, confirm_pairing_remote
|
||||
from .product_core import ProductError
|
||||
from .seed_capabilities import execute_capability, list_capabilities
|
||||
from .seed_core import SeedContext, atomic_write_json
|
||||
from .plan_engine import PlanEngine
|
||||
from .memory_store import MemoryStore
|
||||
|
||||
READ_CAPS={x["name"] for x in list_capabilities() if x.get("risk")=="READONLY"}
|
||||
|
||||
|
||||
class OutboundAgentError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _post(url: str, value: dict[str,Any], timeout: float=10.0) -> dict[str,Any]:
|
||||
req=urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(value,sort_keys=True,separators=(",",":")).encode("utf-8"),
|
||||
headers={"Content-Type":"application/json","Accept":"application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req,timeout=timeout) as resp:
|
||||
out=json.loads(resp.read(1024*1024).decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail=""
|
||||
try:
|
||||
body=json.loads(exc.read(1024*1024).decode("utf-8"))
|
||||
if isinstance(body,dict):
|
||||
detail=str(body.get("error") or "")
|
||||
except Exception:
|
||||
detail=""
|
||||
if detail=="node_revoked":
|
||||
raise OutboundAgentError("OUTBOUND_NODE_REVOKED") from exc
|
||||
raise OutboundAgentError("OUTBOUND_BROKER_REJECTED") from exc
|
||||
except Exception as exc:
|
||||
raise OutboundAgentError("OUTBOUND_BROKER_UNREACHABLE") from exc
|
||||
if not isinstance(out,dict):
|
||||
raise OutboundAgentError("OUTBOUND_BROKER_RESPONSE_INVALID")
|
||||
return out
|
||||
|
||||
|
||||
def _signed_envelope(ctx: SeedContext, kind: str, payload: dict[str,Any]) -> dict[str,Any]:
|
||||
identity=ctx.load_identity() or {}
|
||||
node_id=str(identity.get("node_id") or "")
|
||||
host_id=str(identity.get("node_host_id") or "")
|
||||
if not node_id or not host_id:
|
||||
raise OutboundAgentError("OUTBOUND_NODE_IDENTITY_MISSING")
|
||||
crypto=ensure_node_keypair(ctx.state_dir,comment="framebridge-"+node_id.replace("-","")[:8])
|
||||
base={
|
||||
"schema":1,
|
||||
"kind":str(kind),
|
||||
"node_id":node_id,
|
||||
"node_host_id":host_id,
|
||||
"timestamp":time.time(),
|
||||
"nonce":secrets.token_hex(16),
|
||||
"payload":payload,
|
||||
}
|
||||
sig=sign_pairing_payload(crypto["private_key_path"],canonical(base))
|
||||
return {
|
||||
**base,
|
||||
"node_proof":{
|
||||
"format":"sshsig",
|
||||
"namespace":"framebridge-pairing",
|
||||
"signature_b64":sig,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def broker_base(ctx: SeedContext) -> str:
|
||||
config=ctx.load_config() or {}
|
||||
pairing=config.get("pairing") or {}
|
||||
base=str(pairing.get("broker_base_url") or "").rstrip("/")
|
||||
if not base:
|
||||
raise OutboundAgentError("OUTBOUND_BROKER_NOT_CONFIGURED")
|
||||
return base
|
||||
|
||||
|
||||
def sync_local_revocation(ctx: SeedContext) -> dict[str,Any]:
|
||||
store=PairingStore(ctx.state_dir)
|
||||
current=store.load()
|
||||
if current.get("state")=="paired":
|
||||
view=store.revoke()
|
||||
elif current.get("state")=="revoked":
|
||||
view=current
|
||||
else:
|
||||
raise OutboundAgentError("OUTBOUND_LOCAL_PAIRING_STATE_INVALID")
|
||||
|
||||
config=ctx.load_config() or {}
|
||||
pairing=config.get("pairing") or {}
|
||||
pairing["state"]="revoked"
|
||||
pairing["transport"]="broker_outbound"
|
||||
if view.get("revoked_at"):
|
||||
pairing["revoked_at"]=view.get("revoked_at")
|
||||
config["pairing"]=pairing
|
||||
config["connection_mode"]="PAIRING"
|
||||
setup=config.get("setup")
|
||||
if isinstance(setup,dict):
|
||||
setup["pairing_state"]="revoked"
|
||||
atomic_write_json(ctx.config_path,config)
|
||||
|
||||
ctx.audit.append(
|
||||
"pair.revoked",
|
||||
"PASS",
|
||||
request={"source":"broker"},
|
||||
result={"state":"revoked"},
|
||||
principal="pairing-broker",
|
||||
)
|
||||
MemoryStore(ctx).append(
|
||||
"recovery",
|
||||
{"event":"pair.revoked","state":"revoked","source":"broker"},
|
||||
source="system",
|
||||
)
|
||||
return {"ok":True,"state":"revoked"}
|
||||
|
||||
|
||||
def execute_plan_operation(ctx: SeedContext, operation: str, payload: dict[str,Any]) -> dict[str,Any]:
|
||||
if operation not in {"plan.import","plan.status","plan.apply","plan.rollback"}:
|
||||
raise OutboundAgentError("OUTBOUND_PLAN_OPERATION_NOT_ALLOWED")
|
||||
if not isinstance(payload,dict):
|
||||
raise OutboundAgentError("OUTBOUND_PLAN_PAYLOAD_INVALID")
|
||||
engine=PlanEngine(ctx)
|
||||
|
||||
if operation=="plan.import":
|
||||
plan=payload.get("plan")
|
||||
if not isinstance(plan,dict):
|
||||
raise OutboundAgentError("OUTBOUND_PLAN_DOCUMENT_INVALID")
|
||||
return engine.import_document(plan)
|
||||
|
||||
plan_id=str(payload.get("plan_id") or "")
|
||||
if not plan_id:
|
||||
raise OutboundAgentError("OUTBOUND_PLAN_ID_MISSING")
|
||||
|
||||
if operation=="plan.status":
|
||||
return engine.show(plan_id)
|
||||
|
||||
expected=str(payload.get("plan_sha256") or "")
|
||||
shown=engine.show(plan_id)
|
||||
if shown.get("plan_sha256")!=expected:
|
||||
raise OutboundAgentError("OUTBOUND_PLAN_SHA_MISMATCH")
|
||||
|
||||
if operation=="plan.apply":
|
||||
return engine.apply(plan_id)
|
||||
return engine.rollback(plan_id)
|
||||
|
||||
|
||||
def run_once(ctx: SeedContext | None=None) -> dict[str,Any]:
|
||||
ctx=ctx or SeedContext()
|
||||
store=PairingStore(ctx.state_dir)
|
||||
local_pair=store.load()
|
||||
if local_pair.get("state")=="paired" and local_pair.get("broker_confirm_required") is True:
|
||||
try:
|
||||
confirm_pairing_remote(state_dir=ctx.state_dir,record=local_pair)
|
||||
except PairingClientError as exc:
|
||||
raise OutboundAgentError("OUTBOUND_PAIR_CONFIRM_PENDING") from exc
|
||||
local_pair=store.load()
|
||||
config=ctx.load_config() or {}
|
||||
pairing_cfg=config.get("pairing") or {}
|
||||
pairing_cfg["broker_confirm_required"]=False
|
||||
pairing_cfg["broker_confirmed_at"]=local_pair.get("broker_confirmed_at")
|
||||
config["pairing"]=pairing_cfg
|
||||
atomic_write_json(ctx.config_path,config)
|
||||
config=ctx.load_config() or {}
|
||||
pairing=config.get("pairing") or {}
|
||||
if pairing.get("state")!="paired":
|
||||
raise OutboundAgentError("OUTBOUND_NODE_NOT_PAIRED")
|
||||
if store.load().get("broker_confirm_required") is True:
|
||||
raise OutboundAgentError("OUTBOUND_PAIR_CONFIRM_PENDING")
|
||||
base=broker_base(ctx)
|
||||
poll=_post(
|
||||
base+"/fbpair/v1/node/poll",
|
||||
_signed_envelope(ctx,"node.poll",{}),
|
||||
)
|
||||
if poll.get("ok") is not True:
|
||||
raise OutboundAgentError("OUTBOUND_POLL_REJECTED")
|
||||
req=poll.get("request")
|
||||
if not isinstance(req,dict):
|
||||
return {"ok":True,"idle":True}
|
||||
rid=str(req.get("request_id") or "")
|
||||
operation=str(req.get("operation") or "")
|
||||
if operation:
|
||||
payload=req.get("payload") or {}
|
||||
try:
|
||||
value=execute_plan_operation(ctx,operation,payload)
|
||||
result={"ok":True,"operation":operation,"result":value}
|
||||
ctx.audit.append(
|
||||
"outbound."+operation,
|
||||
"PASS",
|
||||
request={"request_id":rid,"operation":operation},
|
||||
result={"ok":True},
|
||||
principal="paired-ai",
|
||||
)
|
||||
except Exception as exc:
|
||||
result={"ok":False,"operation":operation,"error":str(exc)}
|
||||
ctx.audit.append(
|
||||
"outbound."+operation,
|
||||
"FAIL",
|
||||
request={"request_id":rid,"operation":operation},
|
||||
result={"ok":False,"error":type(exc).__name__},
|
||||
principal="paired-ai",
|
||||
)
|
||||
cap=operation
|
||||
else:
|
||||
cap=str(req.get("capability") or "")
|
||||
args=req.get("args") or {}
|
||||
if cap not in READ_CAPS:
|
||||
result={"ok":False,"error":"CAPABILITY_NOT_ALLOWED","capability":cap}
|
||||
elif not isinstance(args,dict):
|
||||
result={"ok":False,"error":"CAPABILITY_ARGS_NOT_OBJECT","capability":cap}
|
||||
else:
|
||||
try:
|
||||
value=execute_capability(cap,args)
|
||||
result={"ok":True,"capability":cap,"result":value}
|
||||
ctx.audit.append(
|
||||
"outbound."+cap,
|
||||
"PASS",
|
||||
request={"request_id":rid,"capability":cap},
|
||||
result={"ok":True},
|
||||
principal="paired-ai",
|
||||
)
|
||||
except Exception as exc:
|
||||
result={"ok":False,"capability":cap,"error":str(exc)}
|
||||
ctx.audit.append(
|
||||
"outbound."+cap,
|
||||
"FAIL",
|
||||
request={"request_id":rid,"capability":cap},
|
||||
result={"ok":False,"error":type(exc).__name__},
|
||||
principal="paired-ai",
|
||||
)
|
||||
response=_post(
|
||||
base+"/fbpair/v1/node/result",
|
||||
_signed_envelope(
|
||||
ctx,
|
||||
"node.result",
|
||||
{"request_id":rid,"result":result},
|
||||
),
|
||||
)
|
||||
if response.get("ok") is not True:
|
||||
raise OutboundAgentError("OUTBOUND_RESULT_REJECTED")
|
||||
return {"ok":True,"idle":False,"request_id":rid,"capability":cap}
|
||||
|
||||
|
||||
def run_forever(interval: float=1.0) -> None:
|
||||
interval=max(0.5,min(float(interval),30.0))
|
||||
ctx=SeedContext()
|
||||
while True:
|
||||
try:
|
||||
run_once(ctx)
|
||||
except OutboundAgentError as exc:
|
||||
if str(exc)=="OUTBOUND_NODE_REVOKED":
|
||||
try:
|
||||
sync_local_revocation(ctx)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
parser=argparse.ArgumentParser(prog="framebridge-outbound-agent")
|
||||
sub=parser.add_subparsers(dest="command",required=True)
|
||||
sub.add_parser("once")
|
||||
run=sub.add_parser("run")
|
||||
run.add_argument("--interval",type=float,default=1.0)
|
||||
args=parser.parse_args()
|
||||
if args.command=="once":
|
||||
print(json.dumps(run_once(),sort_keys=True))
|
||||
else:
|
||||
run_forever(args.interval)
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
main()
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import fcntl
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
FBPAIR_PROTOCOL = "FBPAIR/1"
|
||||
PAIR_STATES = {
|
||||
"not_paired",
|
||||
"pair_requested",
|
||||
"paired",
|
||||
"denied",
|
||||
"expired",
|
||||
"revoked",
|
||||
}
|
||||
PROVIDERS = {"chatgpt", "ollama", "lmstudio", "other"}
|
||||
|
||||
|
||||
class PairingError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
).encode("ascii")
|
||||
|
||||
|
||||
def sha256_hex(value: Any) -> str:
|
||||
return hashlib.sha256(canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def _is_local_ip(host: str) -> bool:
|
||||
try:
|
||||
addr=ipaddress.ip_address(str(host))
|
||||
except ValueError:
|
||||
return False
|
||||
if addr.is_loopback or addr.is_link_local:
|
||||
return True
|
||||
if addr.version==4:
|
||||
return any(addr in net for net in (
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
))
|
||||
return addr in ipaddress.ip_network("fc00::/7")
|
||||
|
||||
|
||||
def _https_or_loopback_http(uri: str, allow_insecure_dev: bool=False) -> bool:
|
||||
try:
|
||||
p = urlparse(str(uri))
|
||||
except Exception:
|
||||
return False
|
||||
if p.scheme == "https" and bool(p.netloc):
|
||||
return True
|
||||
if p.scheme == "http" and bool(p.netloc):
|
||||
host=str(p.hostname or "")
|
||||
if _is_local_ip(host):
|
||||
return True
|
||||
if allow_insecure_dev:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_pair_request(
|
||||
*,
|
||||
node_id: str,
|
||||
node_label: str,
|
||||
node_host_id: str,
|
||||
public_jwk: dict[str, Any],
|
||||
provider: str,
|
||||
capability_manifest_sha256: str,
|
||||
) -> dict[str, Any]:
|
||||
provider = str(provider).strip().lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise PairingError("PAIR_PROVIDER_UNSUPPORTED")
|
||||
node_label=str(node_label).strip()
|
||||
if not node_id or not node_host_id or not node_label or len(node_label)>253:
|
||||
raise PairingError("PAIR_NODE_IDENTITY_REQUIRED")
|
||||
manifest_sha = str(capability_manifest_sha256).strip().lower()
|
||||
if len(manifest_sha) != 64 or any(c not in "0123456789abcdef" for c in manifest_sha):
|
||||
raise PairingError("PAIR_CAPABILITY_MANIFEST_SHA_INVALID")
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
request = {
|
||||
"protocol": FBPAIR_PROTOCOL,
|
||||
"schema": 1,
|
||||
"node_id": str(node_id),
|
||||
"node_label": node_label,
|
||||
"node_host_id": str(node_host_id),
|
||||
"public_jwk": public_jwk,
|
||||
"provider": provider,
|
||||
"capability_manifest_sha256": manifest_sha,
|
||||
"node_nonce": nonce,
|
||||
"requested_at": utc_now(),
|
||||
}
|
||||
request["request_sha256"] = sha256_hex(request)
|
||||
return request
|
||||
|
||||
|
||||
def attach_node_proof(
|
||||
request: dict[str, Any],
|
||||
*,
|
||||
public_key_ssh: str,
|
||||
signature_b64: str,
|
||||
) -> dict[str, Any]:
|
||||
if request.get("protocol") != FBPAIR_PROTOCOL:
|
||||
raise PairingError("PAIR_REQUEST_PROTOCOL_INVALID")
|
||||
if not str(public_key_ssh).startswith("ssh-ed25519 "):
|
||||
raise PairingError("PAIR_PUBLIC_KEY_INVALID")
|
||||
if not signature_b64:
|
||||
raise PairingError("PAIR_NODE_PROOF_REQUIRED")
|
||||
value=dict(request)
|
||||
value["public_key_ssh"]=str(public_key_ssh)
|
||||
value["node_proof"]={
|
||||
"format":"sshsig",
|
||||
"namespace":"framebridge-pairing",
|
||||
"signature_b64":str(signature_b64),
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def pairing_signing_payload(request: dict[str, Any]) -> bytes:
|
||||
value=dict(request)
|
||||
value.pop("node_proof",None)
|
||||
value.pop("public_key_ssh",None)
|
||||
return canonical(value)
|
||||
|
||||
|
||||
def validate_device_authorization(
|
||||
request: dict[str, Any],
|
||||
response: dict[str, Any],
|
||||
*,
|
||||
allow_insecure_dev: bool=False,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(response, dict):
|
||||
raise PairingError("PAIR_DEVICE_RESPONSE_INVALID")
|
||||
required = {
|
||||
"pair_id",
|
||||
"device_code",
|
||||
"user_code",
|
||||
"verification_uri",
|
||||
"expires_in",
|
||||
}
|
||||
if not required.issubset(response):
|
||||
raise PairingError("PAIR_DEVICE_RESPONSE_INCOMPLETE")
|
||||
pair_id = str(response["pair_id"])
|
||||
device_code = str(response["device_code"])
|
||||
user_code = str(response["user_code"])
|
||||
verification_uri = str(response["verification_uri"])
|
||||
complete = str(response.get("verification_uri_complete") or "")
|
||||
try:
|
||||
expires_in = int(response["expires_in"])
|
||||
interval = int(response.get("interval", 5))
|
||||
except Exception as exc:
|
||||
raise PairingError("PAIR_DEVICE_RESPONSE_INVALID") from exc
|
||||
if not pair_id or len(device_code) < 24 or not user_code:
|
||||
raise PairingError("PAIR_DEVICE_RESPONSE_INVALID")
|
||||
if not _https_or_loopback_http(verification_uri, allow_insecure_dev):
|
||||
raise PairingError("PAIR_VERIFICATION_URI_INVALID")
|
||||
if complete and not _https_or_loopback_http(complete, allow_insecure_dev):
|
||||
raise PairingError("PAIR_VERIFICATION_URI_COMPLETE_INVALID")
|
||||
if not (30 <= expires_in <= 1800):
|
||||
raise PairingError("PAIR_EXPIRES_INVALID")
|
||||
if not (1 <= interval <= 60):
|
||||
raise PairingError("PAIR_INTERVAL_INVALID")
|
||||
try:
|
||||
requested_dt=datetime.fromisoformat(
|
||||
str(request["requested_at"]).replace("Z","+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
except Exception as exc:
|
||||
raise PairingError("PAIR_REQUEST_TIME_INVALID") from exc
|
||||
expires_at=(requested_dt+timedelta(seconds=expires_in)).isoformat()
|
||||
|
||||
return {
|
||||
"protocol": FBPAIR_PROTOCOL,
|
||||
"schema": 1,
|
||||
"state": "pair_requested",
|
||||
"pair_id": pair_id,
|
||||
"request_sha256": request["request_sha256"],
|
||||
"node_id": request["node_id"],
|
||||
"node_label": request["node_label"],
|
||||
"node_host_id": request["node_host_id"],
|
||||
"provider": request["provider"],
|
||||
"device_code": device_code,
|
||||
"device_code_sha256": hashlib.sha256(device_code.encode("utf-8")).hexdigest(),
|
||||
"user_code": user_code,
|
||||
"verification_uri": verification_uri,
|
||||
"verification_uri_complete": complete or None,
|
||||
"expires_in": expires_in,
|
||||
"expires_at": expires_at,
|
||||
"interval": interval,
|
||||
"requested_at": request["requested_at"],
|
||||
"last_poll_at": None,
|
||||
}
|
||||
|
||||
|
||||
def public_pairing_view(record: dict[str, Any]) -> dict[str, Any]:
|
||||
value = dict(record)
|
||||
value.pop("device_code", None)
|
||||
value.pop("account_subject", None)
|
||||
value.pop("provider_access_token", None)
|
||||
value.pop("provider_refresh_token", None)
|
||||
return value
|
||||
|
||||
|
||||
def pairing_qr_payload(record: dict[str, Any]) -> str:
|
||||
if record.get("state") != "pair_requested":
|
||||
raise PairingError("PAIR_NOT_PENDING")
|
||||
return str(
|
||||
record.get("verification_uri_complete")
|
||||
or record.get("verification_uri")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def validate_pair_receipt(
|
||||
pending: dict[str, Any],
|
||||
receipt: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if pending.get("state") != "pair_requested":
|
||||
raise PairingError("PAIR_NOT_PENDING")
|
||||
if not isinstance(receipt, dict):
|
||||
raise PairingError("PAIR_RECEIPT_INVALID")
|
||||
checks = {
|
||||
"protocol": receipt.get("protocol") == FBPAIR_PROTOCOL,
|
||||
"schema": receipt.get("schema") == 1,
|
||||
"state": receipt.get("state") == "paired",
|
||||
"pair_id": receipt.get("pair_id") == pending.get("pair_id"),
|
||||
"request_sha256": receipt.get("request_sha256") == pending.get("request_sha256"),
|
||||
"node_id": receipt.get("node_id") == pending.get("node_id"),
|
||||
"node_label": receipt.get("node_label") == pending.get("node_label"),
|
||||
"node_host_id": receipt.get("node_host_id") == pending.get("node_host_id"),
|
||||
"provider": receipt.get("provider") == pending.get("provider"),
|
||||
}
|
||||
if not all(checks.values()):
|
||||
raise PairingError("PAIR_RECEIPT_BINDING_MISMATCH")
|
||||
broker_issuer = str(receipt.get("broker_issuer") or "")
|
||||
account_binding_id = str(receipt.get("account_binding_id") or "")
|
||||
grant_id = str(receipt.get("grant_id") or "")
|
||||
if (
|
||||
not broker_issuer
|
||||
or not account_binding_id
|
||||
or len(account_binding_id)>160
|
||||
or not grant_id
|
||||
or len(grant_id)>160
|
||||
):
|
||||
raise PairingError("PAIR_ACCOUNT_BINDING_MISSING")
|
||||
return {
|
||||
"protocol": FBPAIR_PROTOCOL,
|
||||
"schema": 1,
|
||||
"state": "paired",
|
||||
"pair_id": pending["pair_id"],
|
||||
"request_sha256": pending["request_sha256"],
|
||||
"node_id": pending["node_id"],
|
||||
"node_label": pending["node_label"],
|
||||
"node_host_id": pending["node_host_id"],
|
||||
"provider": pending["provider"],
|
||||
"broker_issuer": broker_issuer,
|
||||
"account_binding_id": account_binding_id,
|
||||
"grant_id": grant_id,
|
||||
"paired_at": str(receipt.get("paired_at") or utc_now()),
|
||||
"scopes": sorted({str(x) for x in (receipt.get("scopes") or [])}),
|
||||
"revocation_id": str(receipt.get("revocation_id") or ""),
|
||||
"node_grant_binding": str(receipt.get("node_grant_binding") or "proof-of-possession-required"),
|
||||
}
|
||||
|
||||
|
||||
class PairingStore:
|
||||
def __init__(self, state_dir: Path):
|
||||
self.path = Path(state_dir) / "pairing.json"
|
||||
self.lock_path = Path(state_dir) / ".pairing.lock"
|
||||
|
||||
@contextmanager
|
||||
def _exclusive(self):
|
||||
self.lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
flags=os.O_RDWR|os.O_CREAT|getattr(os,"O_NOFOLLOW",0)
|
||||
fd=os.open(self.lock_path,flags,0o600)
|
||||
try:
|
||||
fcntl.flock(fd,fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd,fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def _load_unlocked(self) -> dict[str, Any]:
|
||||
if not self.path.is_file():
|
||||
return {"protocol": FBPAIR_PROTOCOL, "schema": 1, "state": "not_paired"}
|
||||
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict) or value.get("state") not in PAIR_STATES:
|
||||
raise PairingError("PAIR_STATE_INVALID")
|
||||
return value
|
||||
|
||||
def load(self) -> dict[str, Any]:
|
||||
# Atomic replacement makes a lock-free reader see old-or-new complete JSON.
|
||||
return self._load_unlocked()
|
||||
|
||||
def _save_unlocked(self, value: dict[str, Any]) -> None:
|
||||
if value.get("state") not in PAIR_STATES:
|
||||
raise PairingError("PAIR_STATE_INVALID")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".pairing.", dir=str(self.path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, self.path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
def save(self, value: dict[str, Any]) -> None:
|
||||
with self._exclusive():
|
||||
self._save_unlocked(value)
|
||||
|
||||
def begin(self, pending: dict[str, Any]) -> dict[str, Any]:
|
||||
with self._exclusive():
|
||||
current = self._load_unlocked()
|
||||
if current.get("state") == "paired":
|
||||
raise PairingError("PAIR_ALREADY_PAIRED")
|
||||
if current.get("state") == "pair_requested":
|
||||
raise PairingError("PAIR_REQUEST_ALREADY_PENDING")
|
||||
self._save_unlocked(pending)
|
||||
return public_pairing_view(pending)
|
||||
|
||||
def update_pending_fields(self, pair_id: str, fields: dict[str, Any]) -> dict[str, Any]:
|
||||
pair_id=str(pair_id or "")
|
||||
if not pair_id:
|
||||
raise PairingError("PAIR_ID_REQUIRED")
|
||||
with self._exclusive():
|
||||
current=self._load_unlocked()
|
||||
if current.get("state")!="pair_requested" or str(current.get("pair_id") or "")!=pair_id:
|
||||
return current
|
||||
current=dict(current)
|
||||
current.update(dict(fields or {}))
|
||||
self._save_unlocked(current)
|
||||
return current
|
||||
|
||||
def complete(self, receipt: dict[str, Any]) -> dict[str, Any]:
|
||||
with self._exclusive():
|
||||
current = self._load_unlocked()
|
||||
paired = validate_pair_receipt(current, receipt)
|
||||
paired["broker_base_url"]=str(current.get("broker_base_url") or "")
|
||||
paired["broker_confirm_required"]=True
|
||||
self._save_unlocked(paired)
|
||||
return public_pairing_view(paired)
|
||||
|
||||
def invalidate_unconfirmed_pair(self, pair_id: str, broker_state: str) -> dict[str, Any]:
|
||||
pair_id=str(pair_id or "")
|
||||
broker_state=str(broker_state or "unknown").lower()
|
||||
with self._exclusive():
|
||||
current=self._load_unlocked()
|
||||
if current.get("state")!="paired" or current.get("broker_confirm_required") is not True or str(current.get("pair_id") or "")!=pair_id:
|
||||
return current
|
||||
current=dict(current)
|
||||
current["state"]="expired"
|
||||
current["expired_at"]=utc_now()
|
||||
current["expiration_reason"]="broker_confirm_"+broker_state
|
||||
current["requires_new_local_action"]=True
|
||||
current["broker_confirm_required"]=False
|
||||
current["broker_confirm_state"]=broker_state
|
||||
self._save_unlocked(current)
|
||||
return current
|
||||
|
||||
def mark_broker_confirmed(self, pair_id: str, result: dict[str, Any]) -> dict[str, Any]:
|
||||
pair_id=str(pair_id or "")
|
||||
with self._exclusive():
|
||||
current=self._load_unlocked()
|
||||
if current.get("state")!="paired" or str(current.get("pair_id") or "")!=pair_id:
|
||||
return current
|
||||
current=dict(current)
|
||||
current["broker_confirm_required"]=False
|
||||
current["broker_confirmed_at"]=utc_now()
|
||||
current["broker_confirm_state"]=str((result or {}).get("state") or "paired")
|
||||
self._save_unlocked(current)
|
||||
return current
|
||||
|
||||
def transition_pending(self, pair_id: str, new_state: str, fields: dict[str, Any] | None=None) -> dict[str, Any]:
|
||||
pair_id=str(pair_id or "")
|
||||
if new_state not in {"denied","expired"}:
|
||||
raise PairingError("PAIR_TRANSITION_STATE_INVALID")
|
||||
with self._exclusive():
|
||||
current=self._load_unlocked()
|
||||
if current.get("state")!="pair_requested" or str(current.get("pair_id") or "")!=pair_id:
|
||||
return current
|
||||
current=dict(current)
|
||||
current.pop("device_code",None)
|
||||
current["state"]=new_state
|
||||
current.update(dict(fields or {}))
|
||||
self._save_unlocked(current)
|
||||
return current
|
||||
|
||||
def cancel_pending(self) -> dict[str, Any]:
|
||||
with self._exclusive():
|
||||
current = self._load_unlocked()
|
||||
if current.get("state") != "pair_requested":
|
||||
raise PairingError("PAIR_NOT_PENDING")
|
||||
current=dict(current)
|
||||
current.pop("device_code", None)
|
||||
current["state"] = "revoked"
|
||||
current["revoked_at"] = utc_now()
|
||||
current["revocation_reason"] = "local_operator_cancelled"
|
||||
if current.get("pair_id") and current.get("broker_base_url"):
|
||||
current["broker_cancel_required"]=True
|
||||
current["broker_cancel_reason"]="local_operator_cancelled"
|
||||
self._save_unlocked(current)
|
||||
return public_pairing_view(current)
|
||||
|
||||
def invalidate_pending(self, reason: str) -> dict[str, Any]:
|
||||
reason=str(reason or "").strip().lower()
|
||||
if reason not in {"network_change","service_stop","reconcile","window_expired","window_closed"}:
|
||||
raise PairingError("PAIR_INVALIDATION_REASON_INVALID")
|
||||
with self._exclusive():
|
||||
current=self._load_unlocked()
|
||||
if current.get("state") != "pair_requested":
|
||||
raise PairingError("PAIR_NOT_PENDING")
|
||||
current=dict(current)
|
||||
current.pop("device_code",None)
|
||||
current["state"]="expired"
|
||||
current["expired_at"]=utc_now()
|
||||
current["expiration_reason"]=reason
|
||||
current["requires_new_local_action"]=True
|
||||
if current.get("pair_id") and current.get("broker_base_url"):
|
||||
current["broker_cancel_required"]=True
|
||||
current["broker_cancel_reason"]=reason
|
||||
self._save_unlocked(current)
|
||||
return public_pairing_view(current)
|
||||
|
||||
def mark_broker_cancelled(self, pair_id: str, result: dict[str, Any]) -> dict[str, Any]:
|
||||
pair_id=str(pair_id or "")
|
||||
with self._exclusive():
|
||||
current=self._load_unlocked()
|
||||
if str(current.get("pair_id") or "")!=pair_id:
|
||||
return current
|
||||
current=dict(current)
|
||||
current["broker_cancel_required"]=False
|
||||
current["broker_cancel_state"]=str((result or {}).get("state") or "cancelled")
|
||||
current["broker_cancelled_at"]=utc_now()
|
||||
current.pop("broker_cancel_last_error",None)
|
||||
self._save_unlocked(current)
|
||||
return current
|
||||
|
||||
def note_broker_cancel_error(self, pair_id: str, error: str) -> dict[str, Any]:
|
||||
pair_id=str(pair_id or "")
|
||||
with self._exclusive():
|
||||
current=self._load_unlocked()
|
||||
if str(current.get("pair_id") or "")!=pair_id:
|
||||
return current
|
||||
current=dict(current)
|
||||
current["broker_cancel_required"]=True
|
||||
current["broker_cancel_last_error"]=str(error or "PAIR_BROKER_CANCEL_FAILED")[:128]
|
||||
current["broker_cancel_attempts"]=int(current.get("broker_cancel_attempts") or 0)+1
|
||||
current["broker_cancel_last_attempt_at"]=utc_now()
|
||||
self._save_unlocked(current)
|
||||
return current
|
||||
|
||||
def revoke(self) -> dict[str, Any]:
|
||||
with self._exclusive():
|
||||
current = self._load_unlocked()
|
||||
if current.get("state") != "paired":
|
||||
raise PairingError("PAIR_NOT_PAIRED")
|
||||
current=dict(current)
|
||||
current["state"] = "revoked"
|
||||
current["revoked_at"] = utc_now()
|
||||
self._save_unlocked(current)
|
||||
return public_pairing_view(current)
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import secrets
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .node_identity import ensure_node_keypair, sign_pairing_payload
|
||||
from .pairing import (
|
||||
PairingError,
|
||||
PairingStore,
|
||||
attach_node_proof,
|
||||
create_pair_request,
|
||||
pairing_signing_payload,
|
||||
public_pairing_view,
|
||||
validate_device_authorization,
|
||||
)
|
||||
|
||||
|
||||
class PairingClientError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _is_local_ip(host: str) -> bool:
|
||||
try:
|
||||
addr=ipaddress.ip_address(str(host))
|
||||
except ValueError:
|
||||
return False
|
||||
if addr.is_loopback or addr.is_link_local:
|
||||
return True
|
||||
if addr.version==4:
|
||||
return any(addr in net for net in (
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
))
|
||||
return addr in ipaddress.ip_network("fc00::/7")
|
||||
|
||||
|
||||
def _broker_base_ok(url: str, allow_insecure_dev: bool) -> bool:
|
||||
try:
|
||||
p=urllib.parse.urlparse(str(url))
|
||||
except Exception:
|
||||
return False
|
||||
if p.scheme=="https" and p.netloc:
|
||||
return True
|
||||
if p.scheme=="http" and p.netloc:
|
||||
host=str(p.hostname or "")
|
||||
if _is_local_ip(host):
|
||||
return True
|
||||
if allow_insecure_dev:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _endpoint(base: str, path: str) -> str:
|
||||
return str(base).rstrip("/") + "/" + path.lstrip("/")
|
||||
|
||||
|
||||
def post_json(
|
||||
url: str,
|
||||
value: dict[str, Any],
|
||||
*,
|
||||
timeout: float=10.0,
|
||||
opener: Callable[..., Any] | None=None,
|
||||
) -> dict[str, Any]:
|
||||
data=json.dumps(
|
||||
value, sort_keys=True, separators=(",",":"), ensure_ascii=True
|
||||
).encode("utf-8")
|
||||
req=urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type":"application/json",
|
||||
"Accept":"application/json",
|
||||
"User-Agent":"FrameBridge-Linux-Core/0.5",
|
||||
},
|
||||
)
|
||||
try:
|
||||
if opener is None:
|
||||
with urllib.request.urlopen(req,timeout=timeout) as resp:
|
||||
raw=resp.read(1024*1024)
|
||||
else:
|
||||
raw=opener(req,timeout=timeout)
|
||||
if hasattr(raw,"read"):
|
||||
raw=raw.read(1024*1024)
|
||||
result=json.loads(bytes(raw).decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
raw=exc.read(1024*1024)
|
||||
result=json.loads(bytes(raw).decode("utf-8"))
|
||||
except Exception as parse_exc:
|
||||
raise PairingClientError("PAIR_BROKER_RESPONSE_INVALID") from parse_exc
|
||||
if not isinstance(result,dict):
|
||||
raise PairingClientError("PAIR_BROKER_RESPONSE_INVALID")
|
||||
return result
|
||||
except (urllib.error.URLError,TimeoutError,OSError) as exc:
|
||||
raise PairingClientError("PAIR_BROKER_UNREACHABLE") from exc
|
||||
except Exception as exc:
|
||||
raise PairingClientError("PAIR_BROKER_RESPONSE_INVALID") from exc
|
||||
if not isinstance(result,dict):
|
||||
raise PairingClientError("PAIR_BROKER_RESPONSE_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def start_pairing(
|
||||
*,
|
||||
state_dir: Path,
|
||||
node_id: str,
|
||||
node_label: str,
|
||||
node_host_id: str,
|
||||
public_jwk: dict[str, Any],
|
||||
provider: str,
|
||||
capability_manifest_sha256: str,
|
||||
broker_base_url: str,
|
||||
allow_insecure_dev: bool=False,
|
||||
poster: Callable[..., dict[str,Any]]=post_json,
|
||||
) -> dict[str,Any]:
|
||||
if not _broker_base_ok(broker_base_url,allow_insecure_dev):
|
||||
raise PairingClientError("PAIR_BROKER_URL_INVALID")
|
||||
crypto=ensure_node_keypair(Path(state_dir),comment="framebridge-"+str(node_id)[:8])
|
||||
request=create_pair_request(
|
||||
node_id=node_id,
|
||||
node_label=node_label,
|
||||
node_host_id=node_host_id,
|
||||
public_jwk=public_jwk,
|
||||
provider=provider,
|
||||
capability_manifest_sha256=capability_manifest_sha256,
|
||||
)
|
||||
signature=sign_pairing_payload(
|
||||
crypto["private_key_path"],
|
||||
pairing_signing_payload(request),
|
||||
)
|
||||
signed=attach_node_proof(
|
||||
request,
|
||||
public_key_ssh=crypto["public_key_ssh"],
|
||||
signature_b64=signature,
|
||||
)
|
||||
response=poster(
|
||||
_endpoint(broker_base_url,"fbpair/v1/device_authorization"),
|
||||
signed,
|
||||
)
|
||||
pending=validate_device_authorization(
|
||||
request,response,allow_insecure_dev=allow_insecure_dev
|
||||
)
|
||||
pending["broker_base_url"]=str(broker_base_url).rstrip("/")
|
||||
PairingStore(Path(state_dir)).begin(pending)
|
||||
return public_pairing_view(pending)
|
||||
|
||||
|
||||
def confirm_pairing_remote(
|
||||
*,
|
||||
state_dir: Path,
|
||||
allow_insecure_dev: bool=False,
|
||||
poster: Callable[..., dict[str,Any]]=post_json,
|
||||
record: dict[str,Any] | None=None,
|
||||
) -> dict[str,Any]:
|
||||
store=PairingStore(Path(state_dir))
|
||||
current=dict(record) if isinstance(record,dict) else store.load()
|
||||
if current.get("state")!="paired":
|
||||
raise PairingClientError("PAIR_CONFIRM_STATE_INVALID")
|
||||
pair_id=str(current.get("pair_id") or "")
|
||||
node_id=str(current.get("node_id") or "")
|
||||
node_host_id=str(current.get("node_host_id") or "")
|
||||
request_sha256=str(current.get("request_sha256") or "")
|
||||
broker=str(current.get("broker_base_url") or "")
|
||||
if not pair_id or not node_id or not node_host_id or len(request_sha256)!=64:
|
||||
raise PairingClientError("PAIR_CONFIRM_STATE_INCOMPLETE")
|
||||
if not _broker_base_ok(broker,allow_insecure_dev):
|
||||
raise PairingClientError("PAIR_BROKER_URL_INVALID")
|
||||
crypto=ensure_node_keypair(Path(state_dir),comment="framebridge-"+node_id.replace("-","")[:8])
|
||||
base={
|
||||
"protocol":"FBPAIR/1",
|
||||
"schema":1,
|
||||
"operation":"pair.confirm",
|
||||
"pair_id":pair_id,
|
||||
"request_sha256":request_sha256,
|
||||
"node_id":node_id,
|
||||
"node_host_id":node_host_id,
|
||||
"confirm_nonce":secrets.token_urlsafe(24),
|
||||
"confirmed_at":datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
signature=sign_pairing_payload(crypto["private_key_path"],pairing_signing_payload(base))
|
||||
signed=attach_node_proof(
|
||||
base,public_key_ssh=crypto["public_key_ssh"],signature_b64=signature
|
||||
)
|
||||
response=poster(_endpoint(broker,"fbpair/v1/confirm"),signed)
|
||||
if isinstance(response,dict) and response.get("ok") is not True and str(response.get("state") or "") in {"cancelled","denied","expired"}:
|
||||
store.invalidate_unconfirmed_pair(pair_id,str(response.get("state")))
|
||||
return response
|
||||
if not isinstance(response,dict) or response.get("ok") is not True or response.get("state")!="paired":
|
||||
error=str((response or {}).get("error") or "pair_confirm_failed").upper() if isinstance(response,dict) else "PAIR_CONFIRM_RESPONSE_INVALID"
|
||||
raise PairingClientError("PAIR_BROKER_"+error)
|
||||
store.mark_broker_confirmed(pair_id,response)
|
||||
return response
|
||||
|
||||
|
||||
def cancel_pairing_remote(
|
||||
*,
|
||||
state_dir: Path,
|
||||
reason: str,
|
||||
allow_insecure_dev: bool=False,
|
||||
poster: Callable[..., dict[str,Any]]=post_json,
|
||||
record: dict[str,Any] | None=None,
|
||||
) -> dict[str,Any]:
|
||||
reason=str(reason or "").strip().lower()
|
||||
if reason not in {
|
||||
"network_change","service_stop","reconcile","window_expired",
|
||||
"window_closed","local_operator_cancelled",
|
||||
}:
|
||||
raise PairingClientError("PAIR_CANCEL_REASON_INVALID")
|
||||
store=PairingStore(Path(state_dir))
|
||||
current=dict(record) if isinstance(record,dict) else store.load()
|
||||
pair_id=str(current.get("pair_id") or "")
|
||||
node_id=str(current.get("node_id") or "")
|
||||
node_host_id=str(current.get("node_host_id") or "")
|
||||
broker=str(current.get("broker_base_url") or "")
|
||||
if not pair_id or not node_id or not node_host_id:
|
||||
raise PairingClientError("PAIR_CANCEL_STATE_INCOMPLETE")
|
||||
if not _broker_base_ok(broker,allow_insecure_dev):
|
||||
raise PairingClientError("PAIR_BROKER_URL_INVALID")
|
||||
crypto=ensure_node_keypair(Path(state_dir),comment="framebridge-"+node_id.replace("-","")[:8])
|
||||
base={
|
||||
"protocol":"FBPAIR/1",
|
||||
"schema":1,
|
||||
"operation":"pair.cancel",
|
||||
"pair_id":pair_id,
|
||||
"node_id":node_id,
|
||||
"node_host_id":node_host_id,
|
||||
"reason":reason,
|
||||
"cancel_nonce":secrets.token_urlsafe(24),
|
||||
"cancelled_at":datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
signature=sign_pairing_payload(crypto["private_key_path"],pairing_signing_payload(base))
|
||||
signed=attach_node_proof(
|
||||
base,
|
||||
public_key_ssh=crypto["public_key_ssh"],
|
||||
signature_b64=signature,
|
||||
)
|
||||
response=poster(_endpoint(broker,"fbpair/v1/cancel"),signed)
|
||||
if not isinstance(response,dict):
|
||||
raise PairingClientError("PAIR_CANCEL_RESPONSE_INVALID")
|
||||
if response.get("ok") is True and str(response.get("state") or "") in {
|
||||
"cancelled","expired","denied",
|
||||
}:
|
||||
return response
|
||||
error=str(response.get("error") or "pair_cancel_failed").upper()
|
||||
raise PairingClientError("PAIR_BROKER_"+error)
|
||||
|
||||
|
||||
def poll_pairing(
|
||||
*,
|
||||
state_dir: Path,
|
||||
allow_insecure_dev: bool=False,
|
||||
poster: Callable[..., dict[str,Any]]=post_json,
|
||||
) -> dict[str,Any]:
|
||||
store=PairingStore(Path(state_dir))
|
||||
pending=store.load()
|
||||
if pending.get("state")=="paired" and pending.get("broker_confirm_required") is True:
|
||||
try:
|
||||
confirm_pairing_remote(
|
||||
state_dir=Path(state_dir),allow_insecure_dev=allow_insecure_dev,
|
||||
poster=poster,record=pending,
|
||||
)
|
||||
except PairingClientError:
|
||||
result=public_pairing_view(store.load())
|
||||
result["broker_status"]="confirm_pending"
|
||||
return result
|
||||
return public_pairing_view(store.load())
|
||||
if pending.get("state")!="pair_requested":
|
||||
raise PairingClientError("PAIR_NOT_PENDING")
|
||||
pair_id=str(pending.get("pair_id") or "")
|
||||
broker=str(pending.get("broker_base_url") or "")
|
||||
if not _broker_base_ok(broker,allow_insecure_dev):
|
||||
raise PairingClientError("PAIR_BROKER_URL_INVALID")
|
||||
|
||||
now=datetime.now(timezone.utc)
|
||||
try:
|
||||
expires=datetime.fromisoformat(
|
||||
str(pending.get("expires_at") or "").replace("Z","+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
except Exception as exc:
|
||||
raise PairingClientError("PAIR_EXPIRY_STATE_INVALID") from exc
|
||||
if now>=expires:
|
||||
current=store.transition_pending(
|
||||
pair_id,"expired",
|
||||
{"expired_at":now.isoformat(),"expiration_reason":"pairing_timeout"},
|
||||
)
|
||||
return public_pairing_view(current)
|
||||
|
||||
last=pending.get("last_poll_at")
|
||||
interval=max(1,int(pending.get("interval") or 5))
|
||||
if last:
|
||||
try:
|
||||
last_dt=datetime.fromisoformat(
|
||||
str(last).replace("Z","+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
except Exception as exc:
|
||||
raise PairingClientError("PAIR_POLL_STATE_INVALID") from exc
|
||||
remaining=interval-(now-last_dt).total_seconds()
|
||||
if remaining>0:
|
||||
result=public_pairing_view(pending)
|
||||
result["broker_status"]="local_poll_interval"
|
||||
result["retry_after"]=max(1,int(remaining+0.999))
|
||||
return result
|
||||
|
||||
pending=store.update_pending_fields(pair_id,{"last_poll_at":now.isoformat()})
|
||||
if pending.get("state")!="pair_requested":
|
||||
return public_pairing_view(pending)
|
||||
device_code=str(pending.get("device_code") or "")
|
||||
if not device_code:
|
||||
return public_pairing_view(pending)
|
||||
|
||||
response=poster(
|
||||
_endpoint(broker,"fbpair/v1/token"),
|
||||
{
|
||||
"protocol":"FBPAIR/1",
|
||||
"pair_id":pair_id,
|
||||
"device_code":device_code,
|
||||
},
|
||||
)
|
||||
|
||||
# A network monitor may have invalidated this pair while the POST was in flight.
|
||||
current=store.load()
|
||||
if current.get("state")!="pair_requested" or str(current.get("pair_id") or "")!=pair_id:
|
||||
return public_pairing_view(current)
|
||||
|
||||
error=str(response.get("error") or "")
|
||||
if error in {"authorization_pending","slow_down"}:
|
||||
if error=="slow_down":
|
||||
current=store.update_pending_fields(
|
||||
pair_id,{"interval":min(60,int(current.get("interval") or 5)+5)}
|
||||
)
|
||||
result=public_pairing_view(current)
|
||||
result["broker_status"]=error
|
||||
return result
|
||||
if error=="access_denied":
|
||||
current=store.transition_pending(
|
||||
pair_id,"denied",{"denied_at":datetime.now(timezone.utc).isoformat()}
|
||||
)
|
||||
return public_pairing_view(current)
|
||||
if error in {"expired_token","expired"}:
|
||||
current=store.transition_pending(
|
||||
pair_id,"expired",
|
||||
{"expired_at":datetime.now(timezone.utc).isoformat(),"expiration_reason":"broker_expired"},
|
||||
)
|
||||
return public_pairing_view(current)
|
||||
if error:
|
||||
raise PairingClientError("PAIR_BROKER_"+error.upper())
|
||||
try:
|
||||
store.complete(response)
|
||||
except PairingError as exc:
|
||||
# If local invalidation won the race, do not revive the pairing.
|
||||
current=store.load()
|
||||
if current.get("state")!="pair_requested":
|
||||
return public_pairing_view(current)
|
||||
raise PairingClientError(str(exc)) from exc
|
||||
paired=store.load()
|
||||
try:
|
||||
confirm_pairing_remote(
|
||||
state_dir=Path(state_dir),
|
||||
allow_insecure_dev=allow_insecure_dev,
|
||||
poster=poster,
|
||||
record=paired,
|
||||
)
|
||||
except PairingClientError:
|
||||
current=public_pairing_view(store.load())
|
||||
current["broker_status"]="confirm_pending"
|
||||
return current
|
||||
return public_pairing_view(store.load())
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .network_security import PAIRING_WINDOW_TTL_SECONDS, pairing_policy
|
||||
from .product_core import ProductError
|
||||
|
||||
SUDO = "/usr/bin/sudo"
|
||||
PRIV_HELPER = "/usr/local/libexec/framebridge-seed-priv"
|
||||
MIN_TTL = 30
|
||||
MAX_TTL = PAIRING_WINDOW_TTL_SECONDS
|
||||
|
||||
|
||||
def _decode_helper(proc: subprocess.CompletedProcess[str]) -> dict[str, Any]:
|
||||
try:
|
||||
value=json.loads(proc.stdout or "{}")
|
||||
except Exception as exc:
|
||||
raise ProductError("PAIRING_WINDOW_HELPER_INVALID_JSON") from exc
|
||||
if not isinstance(value,dict):
|
||||
raise ProductError("PAIRING_WINDOW_HELPER_INVALID_RESPONSE")
|
||||
if proc.returncode!=0 or value.get("ok") is not True:
|
||||
raise ProductError(str(value.get("error") or "PAIRING_WINDOW_HELPER_FAILED"))
|
||||
return value
|
||||
|
||||
|
||||
def _run_helper(args: list[str], *, interactive: bool=True) -> dict[str, Any]:
|
||||
argv=[SUDO]
|
||||
if not interactive:
|
||||
argv.append("-n")
|
||||
argv.extend([PRIV_HELPER,*args])
|
||||
proc=subprocess.run(
|
||||
argv,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=None,
|
||||
check=False,
|
||||
shell=False,
|
||||
)
|
||||
return _decode_helper(proc)
|
||||
|
||||
|
||||
def open_pairing_window(port: int=18765, ttl_seconds: int=PAIRING_WINDOW_TTL_SECONDS) -> dict[str, Any]:
|
||||
if not sys.stdin.isatty():
|
||||
raise ProductError("PAIRING_WINDOW_LOCAL_TTY_REQUIRED")
|
||||
if isinstance(ttl_seconds,bool) or not isinstance(ttl_seconds,int) or not (MIN_TTL <= ttl_seconds <= MAX_TTL):
|
||||
raise ProductError("PAIRING_WINDOW_TTL_INVALID")
|
||||
policy=pairing_policy(port)
|
||||
if policy.get("ok") is not True:
|
||||
raise ProductError(str(policy.get("reason") or "PAIRING_WINDOW_POLICY_BLOCKED"))
|
||||
candidate=policy.get("candidate") or {}
|
||||
required=("interface","subnet","address","network_fingerprint")
|
||||
if not all(candidate.get(k) for k in required):
|
||||
raise ProductError("PAIRING_WINDOW_CANDIDATE_INCOMPLETE")
|
||||
result=_run_helper([
|
||||
"pairing-window-open",
|
||||
"--interface",str(candidate["interface"]),
|
||||
"--source-subnet",str(candidate["subnet"]),
|
||||
"--destination-address",str(candidate["address"]),
|
||||
"--port",str(int(port)),
|
||||
"--network-fingerprint",str(candidate["network_fingerprint"]),
|
||||
"--ttl",str(int(ttl_seconds)),
|
||||
])
|
||||
result["policy_state"]=policy.get("state")
|
||||
result["transport"]="LAN_HTTP"
|
||||
result["listen_url_base"]=f"http://{candidate['address']}:{int(port)}"
|
||||
return result
|
||||
|
||||
|
||||
def pairing_window_status(*, non_interactive: bool=False) -> dict[str, Any]:
|
||||
return _run_helper(["pairing-window-status"],interactive=not non_interactive)
|
||||
|
||||
|
||||
def close_pairing_window(reason: str="user", *, non_interactive: bool=False) -> dict[str, Any]:
|
||||
return _run_helper(["pairing-window-close","--reason",str(reason)],interactive=not non_interactive)
|
||||
+1388
File diff suppressed because it is too large.
Load diff
+1540
File diff suppressed because it is too large.
Load diff
+349
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import __version__
|
||||
|
||||
SECRET_KEY_RE = re.compile(r"(secret|token|password|passwd|api[_-]?key|private[_-]?key|credential|authorization|cookie)", re.I)
|
||||
TOKEN_RE = re.compile(r"(?i)\\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9_]{10,}|github_pat_[A-Za-z0-9_]{10,}|xox[baprs]-[A-Za-z0-9-]{10,})\\b")
|
||||
|
||||
class ProductError(RuntimeError):
|
||||
pass
|
||||
|
||||
class PolicyDenied(ProductError):
|
||||
pass
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
def sha256_text(text: str) -> str:
|
||||
return sha256_bytes(text.encode("utf-8"))
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
|
||||
def redact(value: Any, depth: int = 0) -> Any:
|
||||
if depth > 8:
|
||||
return "[DEPTH_LIMIT]"
|
||||
if isinstance(value, dict):
|
||||
out = {}
|
||||
for key, item in value.items():
|
||||
out[str(key)] = "[REDACTED]" if SECRET_KEY_RE.search(str(key)) else redact(item, depth + 1)
|
||||
return out
|
||||
if isinstance(value, list):
|
||||
return [redact(x, depth + 1) for x in value[:200]]
|
||||
if isinstance(value, tuple):
|
||||
return [redact(x, depth + 1) for x in value[:200]]
|
||||
if isinstance(value, str):
|
||||
clean = TOKEN_RE.sub("[REDACTED TOKEN]", value)
|
||||
return clean[:8192] + ("[TRUNCATED]" if len(clean) > 8192 else "")
|
||||
if isinstance(value, (int, float, bool)) or value is None:
|
||||
return value
|
||||
return redact(str(value), depth + 1)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
allowed: bool
|
||||
mode: str
|
||||
reason: str
|
||||
|
||||
class PolicyEngine:
|
||||
VALID = {"allow", "deny", "require_authorized"}
|
||||
def __init__(self, policy: dict[str, Any] | None = None):
|
||||
policy = policy or {}
|
||||
self.default = str(policy.get("default", "deny")).strip().lower()
|
||||
if self.default not in self.VALID:
|
||||
self.default = "deny"
|
||||
self.tools = {}
|
||||
for key, value in (policy.get("tools") or {}).items():
|
||||
mode = str(value).strip().lower()
|
||||
self.tools[str(key)] = mode if mode in self.VALID else "deny"
|
||||
def decide(self, tool: str, authorized: bool = False) -> PolicyDecision:
|
||||
mode = self.tools.get(tool, self.default)
|
||||
if mode == "allow":
|
||||
return PolicyDecision(True, mode, "POLICY_ALLOW")
|
||||
if mode == "require_authorized" and authorized is True:
|
||||
return PolicyDecision(True, mode, "AUTHORIZED_TRUE")
|
||||
if mode == "require_authorized":
|
||||
return PolicyDecision(False, mode, "AUTHORIZED_TRUE_REQUIRED")
|
||||
return PolicyDecision(False, mode, "POLICY_DENY")
|
||||
|
||||
class AuditLog:
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
def verify(self) -> dict[str, Any]:
|
||||
previous = "GENESIS"
|
||||
count = 0
|
||||
if not self.path.exists():
|
||||
return {"ok": True, "entries": 0, "last_hash": previous}
|
||||
try:
|
||||
with self.path.open("r", encoding="utf-8") as handle:
|
||||
for number, raw in enumerate(handle, 1):
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
item = json.loads(raw)
|
||||
event_hash = str(item.pop("event_hash"))
|
||||
if item.get("prev_hash") != previous:
|
||||
return {"ok": False, "entries": count, "line": number, "error": "PREV_HASH_MISMATCH"}
|
||||
expected = sha256_text(previous + canonical_json(item))
|
||||
if event_hash != expected:
|
||||
return {"ok": False, "entries": count, "line": number, "error": "EVENT_HASH_MISMATCH"}
|
||||
previous = event_hash
|
||||
count += 1
|
||||
except Exception as exc:
|
||||
return {"ok": False, "entries": count, "error": type(exc).__name__}
|
||||
return {"ok": True, "entries": count, "last_hash": previous}
|
||||
def append(self, tool: str, status: str, request: Any = None, result: Any = None, principal: str = "chatgpt") -> dict[str, Any]:
|
||||
with self._lock:
|
||||
current = self.verify()
|
||||
if not current.get("ok"):
|
||||
raise ProductError("AUDIT_CHAIN_INVALID")
|
||||
previous = str(current.get("last_hash", "GENESIS"))
|
||||
event = {"event_id": uuid.uuid4().hex, "timestamp": utc_now(), "principal": str(principal), "tool": str(tool), "status": str(status), "request": redact(request), "result": redact(result), "prev_hash": previous}
|
||||
event_hash = sha256_text(previous + canonical_json(event))
|
||||
stored = dict(event)
|
||||
stored["event_hash"] = event_hash
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(canonical_json(stored) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
return {"event_id": event["event_id"], "event_hash": event_hash}
|
||||
def status(self) -> dict[str, Any]:
|
||||
value = self.verify()
|
||||
value["path"] = str(self.path)
|
||||
return value
|
||||
|
||||
def tail(self, limit: int = 20) -> dict[str, Any]:
|
||||
limit = max(1, min(int(limit), 100))
|
||||
if not self.path.exists():
|
||||
return {"ok": True, "events": []}
|
||||
events = []
|
||||
with self.path.open("r", encoding="utf-8") as handle:
|
||||
for raw in handle:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
item = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
events.append({
|
||||
"timestamp": item.get("timestamp"),
|
||||
"tool": item.get("tool"),
|
||||
"status": item.get("status"),
|
||||
"event_hash": item.get("event_hash"),
|
||||
"request": item.get("request"),
|
||||
"result": item.get("result"),
|
||||
})
|
||||
return {"ok": True, "events": events[-limit:]}
|
||||
|
||||
|
||||
class SafeFiles:
|
||||
def __init__(self, roots: list[str]):
|
||||
self.roots = [Path(x).expanduser().resolve(strict=False) for x in roots]
|
||||
def _resolve(self, value: str) -> Path:
|
||||
if not self.roots:
|
||||
raise ProductError("NO_ALLOWED_ROOTS")
|
||||
candidate = Path(value).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = self.roots[0] / candidate
|
||||
resolved = candidate.resolve(strict=False)
|
||||
for root in self.roots:
|
||||
try:
|
||||
if os.path.commonpath([str(root), str(resolved)]) == str(root):
|
||||
return resolved
|
||||
except ValueError:
|
||||
pass
|
||||
raise ProductError("PATH_OUTSIDE_ALLOWED_ROOTS")
|
||||
def list_dir(self, path: str = ".", limit: int = 200) -> dict[str, Any]:
|
||||
target = self._resolve(path)
|
||||
if not target.is_dir():
|
||||
raise ProductError("DIRECTORY_NOT_FOUND")
|
||||
limit = max(1, min(int(limit), 500))
|
||||
items = []
|
||||
for entry in sorted(target.iterdir(), key=lambda p: p.name.lower())[:limit]:
|
||||
info = entry.lstat()
|
||||
items.append({"name": entry.name, "type": "symlink" if entry.is_symlink() else ("dir" if entry.is_dir() else "file"), "size": info.st_size})
|
||||
return {"path": str(target), "items": items, "count": len(items)}
|
||||
def read_text(self, path: str, max_bytes: int = 65536) -> dict[str, Any]:
|
||||
target = self._resolve(path)
|
||||
if not target.is_file():
|
||||
raise ProductError("FILE_NOT_FOUND")
|
||||
max_bytes = max(1, min(int(max_bytes), 262144))
|
||||
size = target.stat().st_size
|
||||
if size > max_bytes:
|
||||
raise ProductError("FILE_TOO_LARGE")
|
||||
data = target.read_bytes()
|
||||
if b"\\x00" in data:
|
||||
raise ProductError("BINARY_FILE_NOT_ALLOWED")
|
||||
text = data.decode("utf-8", errors="strict")
|
||||
return {"path": str(target), "size": len(data), "sha256": sha256_bytes(data), "text": text}
|
||||
def write_text(self, path: str, text: str, expected_sha256: str = "", max_bytes: int = 262144) -> dict[str, Any]:
|
||||
target = self._resolve(path)
|
||||
parent = target.parent
|
||||
if not parent.is_dir():
|
||||
raise ProductError("PARENT_DIRECTORY_NOT_FOUND")
|
||||
if target.exists() and target.is_symlink():
|
||||
raise ProductError("SYMLINK_WRITE_BLOCKED")
|
||||
data = str(text).encode("utf-8")
|
||||
if len(data) > int(max_bytes):
|
||||
raise ProductError("WRITE_TOO_LARGE")
|
||||
before_sha = ""
|
||||
if target.exists():
|
||||
before_sha = sha256_bytes(target.read_bytes())
|
||||
if expected_sha256 and before_sha != expected_sha256:
|
||||
raise ProductError("EXPECTED_SHA256_MISMATCH")
|
||||
elif expected_sha256:
|
||||
raise ProductError("EXPECTED_SHA256_WITHOUT_EXISTING_FILE")
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".framebridge-", dir=str(parent))
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_name, target)
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
os.unlink(tmp_name)
|
||||
return {"path": str(target), "size": len(data), "sha256": sha256_bytes(data), "previous_sha256": before_sha}
|
||||
|
||||
class MemoryIndex:
|
||||
ALLOWED_SUFFIXES = {".md", ".txt", ".json", ".yaml", ".yml"}
|
||||
BLOCKED_NAMES = {"rclone.conf", "runtime.env", ".env"}
|
||||
BLOCKED_SUFFIXES = {".pem", ".key", ".p12", ".pfx"}
|
||||
def __init__(self, db_path: Path, sources: list[str]):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.sources = [Path(x).expanduser().resolve(strict=False) for x in sources]
|
||||
self._init_db()
|
||||
def _connect(self):
|
||||
con = sqlite3.connect(self.db_path)
|
||||
con.row_factory = sqlite3.Row
|
||||
return con
|
||||
def _init_db(self):
|
||||
with self._connect() as con:
|
||||
con.execute("CREATE TABLE IF NOT EXISTS documents (source_path TEXT PRIMARY KEY, title TEXT NOT NULL, sha256 TEXT NOT NULL, mtime REAL NOT NULL, body TEXT NOT NULL, indexed_at TEXT NOT NULL)")
|
||||
con.execute("CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(source_path UNINDEXED, title, body)")
|
||||
def _safe_file(self, path: Path, root: Path) -> bool:
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
if os.path.commonpath([str(root), str(resolved)]) != str(root):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return False
|
||||
if path.name in self.BLOCKED_NAMES or path.suffix.lower() in self.BLOCKED_SUFFIXES:
|
||||
return False
|
||||
if path.suffix.lower() not in self.ALLOWED_SUFFIXES:
|
||||
return False
|
||||
try:
|
||||
return path.stat().st_size <= 2_000_000
|
||||
except OSError:
|
||||
return False
|
||||
def refresh(self) -> dict[str, Any]:
|
||||
docs = []
|
||||
for root in self.sources:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not self._safe_file(path, root):
|
||||
continue
|
||||
try:
|
||||
body = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
data = body.encode("utf-8")
|
||||
lines = body.splitlines()
|
||||
title = lines[0].lstrip("# ").strip() if lines else path.name
|
||||
docs.append({"source_path": str(path.resolve()), "title": title[:500] or path.name, "sha256": sha256_bytes(data), "mtime": path.stat().st_mtime, "body": body, "indexed_at": utc_now()})
|
||||
with self._connect() as con:
|
||||
con.execute("DELETE FROM documents")
|
||||
con.execute("DELETE FROM docs_fts")
|
||||
for item in docs:
|
||||
con.execute("INSERT INTO documents(source_path,title,sha256,mtime,body,indexed_at) VALUES (?,?,?,?,?,?)", (item["source_path"], item["title"], item["sha256"], item["mtime"], item["body"], item["indexed_at"]))
|
||||
con.execute("INSERT INTO docs_fts(source_path,title,body) VALUES (?,?,?)", (item["source_path"], item["title"], item["body"]))
|
||||
return self.status()
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._connect() as con:
|
||||
row = con.execute("SELECT COUNT(*) AS n, MAX(indexed_at) AS updated FROM documents").fetchone()
|
||||
return {"db_path": str(self.db_path), "documents": int(row["n"]), "last_indexed_at": row["updated"], "sources": [str(x) for x in self.sources]}
|
||||
def search(self, query: str, limit: int = 8) -> dict[str, Any]:
|
||||
tokens = re.findall(r"[A-Za-z0-9_]{2,}", str(query))
|
||||
if not tokens:
|
||||
return {"query": str(query), "results": []}
|
||||
expression = " AND ".join('"' + token.replace('"', '') + '"' for token in tokens[:12])
|
||||
limit = max(1, min(int(limit), 20))
|
||||
with self._connect() as con:
|
||||
rows = con.execute("SELECT source_path,title,snippet(docs_fts,2,'[',']','...',24) AS snippet,bm25(docs_fts) AS rank FROM docs_fts WHERE docs_fts MATCH ? ORDER BY rank LIMIT ?", (expression, limit)).fetchall()
|
||||
return {"query": str(query), "results": [{"source_path": row["source_path"], "title": row["title"], "snippet": row["snippet"], "rank": row["rank"]} for row in rows]}
|
||||
def read(self, source_path: str, start_line: int = 1, max_lines: int = 120) -> dict[str, Any]:
|
||||
start_line = max(1, int(start_line))
|
||||
max_lines = max(1, min(int(max_lines), 400))
|
||||
with self._connect() as con:
|
||||
row = con.execute("SELECT source_path,title,sha256,body,indexed_at FROM documents WHERE source_path=?", (str(source_path),)).fetchone()
|
||||
if row is None:
|
||||
raise ProductError("MEMORY_DOCUMENT_NOT_FOUND")
|
||||
lines = row["body"].splitlines()
|
||||
selected = lines[start_line - 1:start_line - 1 + max_lines]
|
||||
return {"source_path": row["source_path"], "title": row["title"], "sha256": row["sha256"], "indexed_at": row["indexed_at"], "start_line": start_line, "end_line": start_line + len(selected) - 1 if selected else start_line - 1, "text": "\n".join(selected)}
|
||||
|
||||
DEFAULT_POLICY = {"default": "deny", "tools": {"framebridge_product_status": "allow", "framebridge_files_list": "allow", "framebridge_files_read": "allow", "framebridge_files_write": "require_authorized", "framebridge_memory_status": "allow", "framebridge_memory_refresh": "require_authorized", "framebridge_memory_search": "allow", "framebridge_memory_read": "allow", "framebridge_audit_verify": "allow", "framebridge_exec": "require_authorized", "framebridge_cua": "require_authorized"}}
|
||||
|
||||
def load_config(path: Path) -> dict[str, Any]:
|
||||
if path.is_file():
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ProductError("CONFIG_NOT_OBJECT")
|
||||
return value
|
||||
return {"node_name": os.uname().nodename, "state_dir": "~/.local/share/framebridge-linux-chatgpt", "allowed_roots": ["~/FrameBridge-Workspace"], "memory_sources": [], "policy": DEFAULT_POLICY}
|
||||
|
||||
class AlphaContext:
|
||||
def __init__(self, config_path: Path):
|
||||
self.config_path = Path(config_path).expanduser()
|
||||
self.config = load_config(self.config_path)
|
||||
self.node_name = str(self.config.get("node_name") or os.uname().nodename)
|
||||
self.state_dir = Path(str(self.config.get("state_dir") or "~/.local/share/framebridge-linux-chatgpt")).expanduser()
|
||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.policy = PolicyEngine(self.config.get("policy") or DEFAULT_POLICY)
|
||||
self.files = SafeFiles(list(self.config.get("allowed_roots") or []))
|
||||
self.memory = MemoryIndex(self.state_dir / "memory.sqlite3", list(self.config.get("memory_sources") or []))
|
||||
self.audit = AuditLog(self.state_dir / "audit.jsonl")
|
||||
@classmethod
|
||||
def from_environment(cls) -> "AlphaContext":
|
||||
raw = os.environ.get("FRAMEBRIDGE_ALPHA_CONFIG", "~/.config/framebridge-linux-chatgpt/config.json")
|
||||
return cls(Path(raw).expanduser())
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {"ok": True, "version": __version__, "node_name": self.node_name, "config_path": str(self.config_path), "state_dir": str(self.state_dir), "allowed_roots": [str(x) for x in self.files.roots], "memory": self.memory.status(), "audit": self.audit.status(), "policy_default": self.policy.default}
|
||||
def guarded(self, tool: str, authorized: bool, request: dict[str, Any], fn):
|
||||
decision = self.policy.decide(tool, authorized)
|
||||
if not decision.allowed:
|
||||
result = {"ok": False, "error": decision.reason, "policy_mode": decision.mode}
|
||||
self.audit.append(tool, "DENY", request=request, result=result)
|
||||
return result
|
||||
try:
|
||||
value = fn()
|
||||
result = {"ok": True, "policy_mode": decision.mode, "result": value}
|
||||
self.audit.append(tool, "PASS", request=request, result=result)
|
||||
return result
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error": str(exc), "error_type": type(exc).__name__}
|
||||
self.audit.append(tool, "ERROR", request=request, result=result)
|
||||
return result
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from .vendor.qrcodegen import QrCode
|
||||
|
||||
class TerminalQrError(RuntimeError):
|
||||
pass
|
||||
|
||||
def _prepare(text, border, stream, require_tty):
|
||||
if not isinstance(text,str) or not text: raise TerminalQrError('QR_TEXT_INVALID')
|
||||
if not (2 <= int(border) <= 8): raise TerminalQrError('QR_BORDER_INVALID')
|
||||
out=stream or sys.stdout
|
||||
if require_tty and not getattr(out,'isatty',lambda:False)(): raise TerminalQrError('INTERACTIVE_TTY_REQUIRED')
|
||||
return QrCode.encode_text(text,QrCode.Ecc.MEDIUM),out
|
||||
|
||||
def _dark(qr,border,x,y):
|
||||
size=qr.get_size()
|
||||
if x<border or y<border or x>=border+size or y>=border+size: return False
|
||||
return qr.get_module(x-border,y-border)
|
||||
|
||||
def render_qr_compact(text, *, border=4, stream=None, require_tty=True):
|
||||
qr,out=_prepare(text,border,stream,require_tty)
|
||||
total=qr.get_size()+border*2
|
||||
for y in range(0,total,2):
|
||||
row=[]
|
||||
for x in range(total):
|
||||
top=_dark(qr,border,x,y)
|
||||
bottom=_dark(qr,border,x,y+1) if y+1<total else False
|
||||
if top and bottom: row.append('\x1b[40m ')
|
||||
elif top: row.append('\x1b[30;47m▀')
|
||||
elif bottom: row.append('\x1b[30;47m▄')
|
||||
else: row.append('\x1b[47m ')
|
||||
out.write(''.join(row)+'\x1b[0m\n')
|
||||
out.flush()
|
||||
|
||||
def render_qr_mono(text, *, border=4, stream=None, require_tty=True):
|
||||
qr,out=_prepare(text,border,stream,require_tty)
|
||||
total=qr.get_size()+border*2
|
||||
for y in range(0,total,2):
|
||||
row=[]
|
||||
for x in range(total):
|
||||
top=_dark(qr,border,x,y)
|
||||
bottom=_dark(qr,border,x,y+1) if y+1<total else False
|
||||
row.append('█' if top and bottom else '▀' if top else '▄' if bottom else ' ')
|
||||
out.write(''.join(row)+'\n')
|
||||
out.flush()
|
||||
|
||||
def render_qr_ansi(text, *, border=4, stream=None, require_tty=True):
|
||||
qr,out=_prepare(text,border,stream,require_tty)
|
||||
size=qr.get_size(); black='\x1b[40m '; white='\x1b[47m '; reset='\x1b[0m'; quiet=white*(size+border*2)+reset+'\n'
|
||||
for _ in range(border): out.write(quiet)
|
||||
for y in range(size):
|
||||
row=[white]*border
|
||||
for x in range(size): row.append(black if qr.get_module(x,y) else white)
|
||||
row.extend([white]*border); out.write(''.join(row)+reset+'\n')
|
||||
for _ in range(border): out.write(quiet)
|
||||
out.flush()
|
||||
|
||||
def render_qr_unicode(text, *, border=4, stream=None, require_tty=True):
|
||||
qr,out=_prepare(text,border,stream,require_tty)
|
||||
size=qr.get_size(); pad=' '*(size+border*2)
|
||||
for _ in range(border): out.write(pad+'\n')
|
||||
for y in range(size):
|
||||
out.write(' '*border)
|
||||
for x in range(size): out.write('██' if qr.get_module(x,y) else ' ')
|
||||
out.write(' '*border+'\n')
|
||||
for _ in range(border): out.write(pad+'\n')
|
||||
out.flush()
|
||||
+780
@@ -0,0 +1,780 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .product_core import ProductError
|
||||
|
||||
MAX_OUTPUT = 32768
|
||||
|
||||
|
||||
def _run(args: list[str], timeout: float = 5.0) -> dict[str, Any]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
args,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
shell=False,
|
||||
)
|
||||
return {
|
||||
"ok": proc.returncode == 0,
|
||||
"rc": proc.returncode,
|
||||
"stdout": proc.stdout[:MAX_OUTPUT],
|
||||
"stderr": proc.stderr[:4096],
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {"ok": False, "error": "COMMAND_NOT_FOUND"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "error": "COMMAND_TIMEOUT"}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": type(exc).__name__}
|
||||
|
||||
|
||||
def _os_release() -> dict[str, str]:
|
||||
path = Path("/etc/os-release")
|
||||
out: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return out
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
out[key] = value.strip().strip('"')
|
||||
return out
|
||||
|
||||
|
||||
def system_status(args: dict[str, Any]) -> dict[str, Any]:
|
||||
osr = _os_release()
|
||||
uptime = _run(["uptime", "-p"]) if shutil.which("uptime") else {"ok": False}
|
||||
return {
|
||||
"hostname": socket.gethostname(),
|
||||
"kernel": platform.release(),
|
||||
"architecture": platform.machine(),
|
||||
"os_id": osr.get("ID"),
|
||||
"os_name": osr.get("PRETTY_NAME"),
|
||||
"uptime": uptime.get("stdout", "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def hardware_summary(args: dict[str, Any]) -> dict[str, Any]:
|
||||
cpu_model = ""
|
||||
cpuinfo = Path("/proc/cpuinfo")
|
||||
if cpuinfo.is_file():
|
||||
for line in cpuinfo.read_text(errors="replace").splitlines():
|
||||
if line.lower().startswith("model name") and ":" in line:
|
||||
cpu_model = line.split(":", 1)[1].strip()
|
||||
break
|
||||
|
||||
mem_total_kib = None
|
||||
meminfo = Path("/proc/meminfo")
|
||||
if meminfo.is_file():
|
||||
for line in meminfo.read_text(errors="replace").splitlines():
|
||||
if line.startswith("MemTotal:"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
mem_total_kib = int(parts[1])
|
||||
break
|
||||
|
||||
return {
|
||||
"cpu_model": cpu_model or None,
|
||||
"logical_cpus": os.cpu_count(),
|
||||
"memory_total_kib": mem_total_kib,
|
||||
"architecture": platform.machine(),
|
||||
}
|
||||
|
||||
|
||||
def storage_inspect(args: dict[str, Any]) -> dict[str, Any]:
|
||||
if not shutil.which("lsblk"):
|
||||
return {"ok": False, "error": "LSBLK_NOT_FOUND"}
|
||||
result = _run([
|
||||
"lsblk",
|
||||
"-J",
|
||||
"-o",
|
||||
"NAME,TYPE,SIZE,FSTYPE,MOUNTPOINTS,ROTA,MODEL",
|
||||
])
|
||||
if not result.get("ok"):
|
||||
return result
|
||||
try:
|
||||
return {
|
||||
"ok": True,
|
||||
"blockdevices": json.loads(result["stdout"]).get("blockdevices", []),
|
||||
}
|
||||
except Exception:
|
||||
return {"ok": False, "error": "LSBLK_JSON_INVALID"}
|
||||
|
||||
|
||||
def network_inspect(args: dict[str, Any]) -> dict[str, Any]:
|
||||
if not shutil.which("ip"):
|
||||
return {"ok": False, "error": "IP_COMMAND_NOT_FOUND"}
|
||||
addresses = _run(["ip", "-j", "address"])
|
||||
routes = _run(["ip", "-j", "route"])
|
||||
out: dict[str, Any] = {
|
||||
"ok": bool(addresses.get("ok") and routes.get("ok")),
|
||||
}
|
||||
try:
|
||||
out["addresses"] = json.loads(addresses.get("stdout", "[]"))
|
||||
except Exception:
|
||||
out["addresses"] = []
|
||||
out["ok"] = False
|
||||
try:
|
||||
out["routes"] = json.loads(routes.get("stdout", "[]"))
|
||||
except Exception:
|
||||
out["routes"] = []
|
||||
out["ok"] = False
|
||||
return out
|
||||
|
||||
|
||||
def service_status(args: dict[str, Any]) -> dict[str, Any]:
|
||||
service = str(args.get("service") or "").strip()
|
||||
if not service:
|
||||
raise ProductError("SERVICE_REQUIRED")
|
||||
if len(service) > 128:
|
||||
raise ProductError("SERVICE_NAME_TOO_LONG")
|
||||
allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@_.-")
|
||||
if any(ch not in allowed for ch in service):
|
||||
raise ProductError("SERVICE_NAME_INVALID")
|
||||
if not shutil.which("systemctl"):
|
||||
return {"ok": False, "error": "SYSTEMCTL_NOT_FOUND"}
|
||||
|
||||
active = _run(["systemctl", "is-active", service])
|
||||
enabled = _run(["systemctl", "is-enabled", service])
|
||||
return {
|
||||
"service": service,
|
||||
"active": active.get("stdout", "").strip(),
|
||||
"active_rc": active.get("rc"),
|
||||
"enabled": enabled.get("stdout", "").strip(),
|
||||
"enabled_rc": enabled.get("rc"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
PACKAGE_RE = re.compile(r"^[a-z0-9][a-z0-9+.-]{0,127}$")
|
||||
SIMPLE_NAME_RE = re.compile(r"^[A-Za-z0-9@_.-]{1,128}$")
|
||||
SAFE_TEXT_ROOTS = (
|
||||
Path("/etc"),
|
||||
Path("/usr/local/etc"),
|
||||
Path("/var/log"),
|
||||
Path("/var/lib/framebridge-seed"),
|
||||
)
|
||||
SAFE_TEXT_FILES = {
|
||||
Path("/usr/lib/os-release"),
|
||||
}
|
||||
SENSITIVE_PATH_PARTS = (
|
||||
"/etc/shadow",
|
||||
"/etc/gshadow",
|
||||
"/etc/sudoers",
|
||||
"/etc/sudoers.d/",
|
||||
"/etc/ssl/private/",
|
||||
"/etc/wireguard/",
|
||||
"/etc/openvpn/",
|
||||
"/etc/NetworkManager/system-connections/",
|
||||
"/etc/apt/auth.conf",
|
||||
"/.ssh/",
|
||||
)
|
||||
SENSITIVE_NAME_RE = re.compile(
|
||||
r"(?i)(?:^|[._-])(?:secret|token|password|passwd|credential|private[_-]?key|api[_-]?key)(?:$|[._-])"
|
||||
)
|
||||
SENSITIVE_ASSIGNMENT_RE = re.compile(
|
||||
r"(?i)^\s*([A-Za-z0-9_.-]*(?:secret|token|password|passwd|api[_-]?key|"
|
||||
r"private[_-]?key|cookie|authorization|credential)[A-Za-z0-9_.-]*)\s*[:=]"
|
||||
)
|
||||
BEARER_RE = re.compile(r"(?i)\bBearer\s+[^\s,;]+")
|
||||
URL_USERINFO_RE = re.compile(r"(?i)(https?://)[^/@\s]+:[^/@\s]+@")
|
||||
PRIVATE_KEY_BEGIN_RE = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")
|
||||
PRIVATE_KEY_END_RE = re.compile(r"-----END [A-Z0-9 ]*PRIVATE KEY-----")
|
||||
|
||||
|
||||
def _bounded_int(value: Any, *, default: int, low: int, high: int, error: str) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
if type(value) is not int or not (low <= value <= high):
|
||||
raise ProductError(error)
|
||||
return value
|
||||
|
||||
|
||||
def _simple_name(value: Any, error: str) -> str:
|
||||
name = str(value or "").strip()
|
||||
if not SIMPLE_NAME_RE.fullmatch(name):
|
||||
raise ProductError(error)
|
||||
return name
|
||||
|
||||
|
||||
def _package_name(value: Any) -> str:
|
||||
package = str(value or "").strip()
|
||||
if not PACKAGE_RE.fullmatch(package):
|
||||
raise ProductError("PACKAGE_NAME_INVALID")
|
||||
return package
|
||||
|
||||
|
||||
def _sanitize_text(text: str) -> str:
|
||||
out: list[str] = []
|
||||
in_key = False
|
||||
for line in str(text).splitlines():
|
||||
if PRIVATE_KEY_BEGIN_RE.search(line):
|
||||
in_key = True
|
||||
out.append("[REDACTED PRIVATE KEY BLOCK]")
|
||||
continue
|
||||
if in_key:
|
||||
if PRIVATE_KEY_END_RE.search(line):
|
||||
in_key = False
|
||||
continue
|
||||
m = SENSITIVE_ASSIGNMENT_RE.search(line)
|
||||
if m:
|
||||
out.append(f"{m.group(1)}=[REDACTED]")
|
||||
continue
|
||||
line = BEARER_RE.sub("Bearer [REDACTED]", line)
|
||||
line = URL_USERINFO_RE.sub(r"\1[REDACTED]@", line)
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _safe_text_path(raw: Any) -> Path:
|
||||
value = str(raw or "").strip()
|
||||
if not value or len(value) > 4096 or "\x00" in value or "\n" in value:
|
||||
raise ProductError("PATH_INVALID")
|
||||
src = Path(value)
|
||||
if not src.is_absolute():
|
||||
raise ProductError("PATH_ABSOLUTE_REQUIRED")
|
||||
try:
|
||||
resolved = src.resolve(strict=True)
|
||||
except FileNotFoundError:
|
||||
raise ProductError("PATH_NOT_FOUND") from None
|
||||
except OSError:
|
||||
raise ProductError("PATH_INVALID") from None
|
||||
|
||||
allowed = False
|
||||
for root in SAFE_TEXT_ROOTS:
|
||||
try:
|
||||
if os.path.commonpath([str(root), str(resolved)]) == str(root):
|
||||
allowed = True
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if not allowed and resolved not in SAFE_TEXT_FILES:
|
||||
raise ProductError("PATH_OUTSIDE_SAFE_READ_ROOTS")
|
||||
|
||||
lower = str(resolved).lower()
|
||||
if any(part.lower() in lower for part in SENSITIVE_PATH_PARTS):
|
||||
raise ProductError("SENSITIVE_PATH_FORBIDDEN")
|
||||
if SENSITIVE_NAME_RE.search(resolved.name):
|
||||
raise ProductError("SENSITIVE_PATH_FORBIDDEN")
|
||||
if not resolved.is_file():
|
||||
raise ProductError("PATH_NOT_REGULAR_FILE")
|
||||
return resolved
|
||||
|
||||
|
||||
def system_resources(args: dict[str, Any]) -> dict[str, Any]:
|
||||
mem: dict[str, int] = {}
|
||||
p = Path("/proc/meminfo")
|
||||
if p.is_file():
|
||||
for line in p.read_text(errors="replace").splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, rest = line.split(":", 1)
|
||||
fields = rest.strip().split()
|
||||
if fields and fields[0].isdigit():
|
||||
mem[key] = int(fields[0])
|
||||
|
||||
load = os.getloadavg() if hasattr(os, "getloadavg") else (None, None, None)
|
||||
process_count = 0
|
||||
proc = Path("/proc")
|
||||
if proc.is_dir():
|
||||
process_count = sum(1 for x in proc.iterdir() if x.name.isdigit())
|
||||
|
||||
return {
|
||||
"load_1": load[0],
|
||||
"load_5": load[1],
|
||||
"load_15": load[2],
|
||||
"memory_total_kib": mem.get("MemTotal"),
|
||||
"memory_available_kib": mem.get("MemAvailable"),
|
||||
"swap_total_kib": mem.get("SwapTotal"),
|
||||
"swap_free_kib": mem.get("SwapFree"),
|
||||
"process_count": process_count,
|
||||
}
|
||||
|
||||
|
||||
def system_processes(args: dict[str, Any]) -> dict[str, Any]:
|
||||
limit = _bounded_int(args.get("limit"), default=40, low=1, high=200, error="PROCESS_LIMIT_INVALID")
|
||||
result = _run([
|
||||
"ps", "-eo",
|
||||
"pid=,ppid=,user=,stat=,pcpu=,pmem=,etime=,comm=",
|
||||
"--sort=-pcpu",
|
||||
])
|
||||
if not result.get("ok"):
|
||||
return result
|
||||
rows = []
|
||||
for raw in result.get("stdout", "").splitlines():
|
||||
parts = raw.split(None, 7)
|
||||
if len(parts) != 8:
|
||||
continue
|
||||
pid, ppid, user, state, cpu, mem, etime, comm = parts
|
||||
rows.append({
|
||||
"pid": int(pid) if pid.isdigit() else pid,
|
||||
"ppid": int(ppid) if ppid.isdigit() else ppid,
|
||||
"user": user,
|
||||
"state": state,
|
||||
"cpu_pct": cpu,
|
||||
"mem_pct": mem,
|
||||
"etime": etime,
|
||||
"comm": comm,
|
||||
})
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return {"ok": True, "processes": rows, "limit": limit}
|
||||
|
||||
|
||||
def system_time(args: dict[str, Any]) -> dict[str, Any]:
|
||||
timezone = None
|
||||
if shutil.which("timedatectl"):
|
||||
r = _run(["timedatectl", "show", "-p", "Timezone", "--value"])
|
||||
if r.get("ok"):
|
||||
timezone = r.get("stdout", "").strip() or None
|
||||
if timezone is None:
|
||||
tz = Path("/etc/timezone")
|
||||
if tz.is_file():
|
||||
timezone = tz.read_text(errors="replace").strip() or None
|
||||
return {
|
||||
"local_time": datetime.now().astimezone().isoformat(),
|
||||
"timezone": timezone,
|
||||
}
|
||||
|
||||
|
||||
def kernel_modules(args: dict[str, Any]) -> dict[str, Any]:
|
||||
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="MODULE_LIMIT_INVALID")
|
||||
if not shutil.which("lsmod"):
|
||||
return {"ok": False, "error": "LSMOD_NOT_FOUND"}
|
||||
r = _run(["lsmod"])
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
rows = []
|
||||
for line in r.get("stdout", "").splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
rows.append({
|
||||
"module": parts[0],
|
||||
"size": int(parts[1]) if parts[1].isdigit() else parts[1],
|
||||
"used_by_count": int(parts[2]) if parts[2].isdigit() else parts[2],
|
||||
})
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return {"ok": True, "modules": rows, "limit": limit}
|
||||
|
||||
|
||||
def storage_usage(args: dict[str, Any]) -> dict[str, Any]:
|
||||
r = _run(["df", "-B1", "-P", "-T"])
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
rows = []
|
||||
for line in r.get("stdout", "").splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
fs, fstype, total, used, avail, pct = parts[:6]
|
||||
mount = " ".join(parts[6:])
|
||||
rows.append({
|
||||
"filesystem": fs,
|
||||
"fstype": fstype,
|
||||
"bytes_total": int(total) if total.isdigit() else total,
|
||||
"bytes_used": int(used) if used.isdigit() else used,
|
||||
"bytes_available": int(avail) if avail.isdigit() else avail,
|
||||
"use_percent": pct,
|
||||
"mountpoint": mount,
|
||||
})
|
||||
return {"ok": True, "filesystems": rows}
|
||||
|
||||
|
||||
def storage_mounts(args: dict[str, Any]) -> dict[str, Any]:
|
||||
if not shutil.which("findmnt"):
|
||||
return {"ok": False, "error": "FINDMNT_NOT_FOUND"}
|
||||
r = _run(["findmnt", "-J", "-o", "TARGET,SOURCE,FSTYPE"])
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
try:
|
||||
return {"ok": True, "filesystems": json.loads(r["stdout"]).get("filesystems", [])}
|
||||
except Exception:
|
||||
return {"ok": False, "error": "FINDMNT_JSON_INVALID"}
|
||||
|
||||
|
||||
def network_sockets(args: dict[str, Any]) -> dict[str, Any]:
|
||||
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="SOCKET_LIMIT_INVALID")
|
||||
if not shutil.which("ss"):
|
||||
return {"ok": False, "error": "SS_NOT_FOUND"}
|
||||
r = _run(["ss", "-H", "-lntu"])
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
rows = []
|
||||
for line in r.get("stdout", "").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
rows.append({
|
||||
"netid": parts[0],
|
||||
"state": parts[1],
|
||||
"local": parts[4] if len(parts) > 4 else None,
|
||||
"peer": parts[5] if len(parts) > 5 else None,
|
||||
})
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return {"ok": True, "listeners": rows, "limit": limit}
|
||||
|
||||
|
||||
def network_dns(args: dict[str, Any]) -> dict[str, Any]:
|
||||
path = Path("/etc/resolv.conf")
|
||||
if not path.exists():
|
||||
return {"ok": False, "error": "RESOLV_CONF_NOT_FOUND"}
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except Exception:
|
||||
resolved = path
|
||||
lines = []
|
||||
try:
|
||||
text = path.read_text(errors="replace")
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": type(exc).__name__}
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(("nameserver ", "search ", "domain ", "options ")):
|
||||
lines.append(stripped)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(path),
|
||||
"resolved_path": str(resolved),
|
||||
"directives": lines,
|
||||
}
|
||||
|
||||
|
||||
def package_status_read(args: dict[str, Any]) -> dict[str, Any]:
|
||||
package = _package_name(args.get("package"))
|
||||
r = _run(["dpkg-query", "-W", "-f=${Status}|${Version}|${Architecture}", package])
|
||||
if not r.get("ok"):
|
||||
return {"ok": True, "package": package, "installed": False, "version": None, "architecture": None}
|
||||
out = r.get("stdout", "").strip()
|
||||
parts = out.split("|")
|
||||
installed = len(parts) >= 1 and parts[0] == "install ok installed"
|
||||
return {
|
||||
"ok": True,
|
||||
"package": package,
|
||||
"installed": installed,
|
||||
"version": parts[1] if installed and len(parts) > 1 else None,
|
||||
"architecture": parts[2] if installed and len(parts) > 2 else None,
|
||||
}
|
||||
|
||||
|
||||
def package_search(args: dict[str, Any]) -> dict[str, Any]:
|
||||
query = str(args.get("query") or "").strip()
|
||||
if not query or len(query) > 80:
|
||||
raise ProductError("PACKAGE_QUERY_INVALID")
|
||||
if not re.fullmatch(r"[A-Za-z0-9+._ -]+", query):
|
||||
raise ProductError("PACKAGE_QUERY_INVALID")
|
||||
limit = _bounded_int(args.get("limit"), default=30, low=1, high=100, error="PACKAGE_LIMIT_INVALID")
|
||||
if not shutil.which("apt-cache"):
|
||||
return {"ok": False, "error": "APT_CACHE_NOT_FOUND"}
|
||||
r = _run(["apt-cache", "search", "--names-only", re.escape(query)], timeout=15.0)
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
rows = []
|
||||
for line in r.get("stdout", "").splitlines():
|
||||
if " - " in line:
|
||||
name, desc = line.split(" - ", 1)
|
||||
else:
|
||||
name, desc = line, ""
|
||||
rows.append({"package": name.strip(), "description": desc.strip()})
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return {"ok": True, "query": query, "results": rows, "limit": limit}
|
||||
|
||||
|
||||
def package_upgradable(args: dict[str, Any]) -> dict[str, Any]:
|
||||
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="PACKAGE_LIMIT_INVALID")
|
||||
if not shutil.which("apt"):
|
||||
return {"ok": False, "error": "APT_NOT_FOUND"}
|
||||
r = _run(["apt", "list", "--upgradable"], timeout=20.0)
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
rows = []
|
||||
for line in r.get("stdout", "").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("Listing...") or "/" not in line:
|
||||
continue
|
||||
first, *rest = line.split()
|
||||
name, suite = first.split("/", 1)
|
||||
version = rest[0] if rest else None
|
||||
rows.append({"package": name, "suite": suite, "candidate_version": version})
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return {"ok": True, "packages": rows, "limit": limit}
|
||||
|
||||
|
||||
def service_list(args: dict[str, Any]) -> dict[str, Any]:
|
||||
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="SERVICE_LIMIT_INVALID")
|
||||
if not shutil.which("systemctl"):
|
||||
return {"ok": False, "error": "SYSTEMCTL_NOT_FOUND"}
|
||||
r = _run([
|
||||
"systemctl", "list-units", "--type=service", "--all",
|
||||
"--no-legend", "--no-pager", "--plain",
|
||||
], timeout=10.0)
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
rows = []
|
||||
for line in r.get("stdout", "").splitlines():
|
||||
parts = line.split(None, 4)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
rows.append({
|
||||
"unit": parts[0],
|
||||
"load": parts[1],
|
||||
"active": parts[2],
|
||||
"sub": parts[3],
|
||||
"description": parts[4] if len(parts) > 4 else "",
|
||||
})
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return {"ok": True, "services": rows, "limit": limit}
|
||||
|
||||
|
||||
def journal_service(args: dict[str, Any]) -> dict[str, Any]:
|
||||
service = _simple_name(args.get("service"), "SERVICE_NAME_INVALID")
|
||||
lines = _bounded_int(args.get("lines"), default=80, low=1, high=500, error="JOURNAL_LINES_INVALID")
|
||||
if not shutil.which("journalctl"):
|
||||
return {"ok": False, "error": "JOURNALCTL_NOT_FOUND"}
|
||||
r = _run([
|
||||
"journalctl", "-u", service, "-n", str(lines),
|
||||
"--no-pager", "--output=short-iso-precise",
|
||||
], timeout=10.0)
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
return {
|
||||
"ok": True,
|
||||
"service": service,
|
||||
"lines": lines,
|
||||
"text": _sanitize_text(r.get("stdout", ""))[:MAX_OUTPUT],
|
||||
}
|
||||
|
||||
|
||||
def user_list(args: dict[str, Any]) -> dict[str, Any]:
|
||||
limit = _bounded_int(args.get("limit"), default=200, low=1, high=1000, error="USER_LIMIT_INVALID")
|
||||
r = _run(["getent", "passwd"])
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
rows = []
|
||||
for line in r.get("stdout", "").splitlines():
|
||||
parts = line.split(":")
|
||||
if len(parts) != 7:
|
||||
continue
|
||||
name, _pw, uid, gid, gecos, home, shell = parts
|
||||
rows.append({
|
||||
"name": name,
|
||||
"uid": int(uid) if uid.isdigit() else uid,
|
||||
"gid": int(gid) if gid.isdigit() else gid,
|
||||
"gecos": gecos,
|
||||
"home": home,
|
||||
"shell": shell,
|
||||
})
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return {"ok": True, "users": rows, "limit": limit}
|
||||
|
||||
|
||||
def group_list(args: dict[str, Any]) -> dict[str, Any]:
|
||||
limit = _bounded_int(args.get("limit"), default=200, low=1, high=1000, error="GROUP_LIMIT_INVALID")
|
||||
r = _run(["getent", "group"])
|
||||
if not r.get("ok"):
|
||||
return r
|
||||
rows = []
|
||||
for line in r.get("stdout", "").splitlines():
|
||||
parts = line.split(":")
|
||||
if len(parts) != 4:
|
||||
continue
|
||||
name, _pw, gid, members = parts
|
||||
rows.append({
|
||||
"name": name,
|
||||
"gid": int(gid) if gid.isdigit() else gid,
|
||||
"members": [x for x in members.split(",") if x],
|
||||
})
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return {"ok": True, "groups": rows, "limit": limit}
|
||||
|
||||
|
||||
def filesystem_stat(args: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = str(args.get("path") or "").strip()
|
||||
if not raw or len(raw) > 4096 or "\x00" in raw or "\n" in raw:
|
||||
raise ProductError("PATH_INVALID")
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
raise ProductError("PATH_ABSOLUTE_REQUIRED")
|
||||
try:
|
||||
st = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return {"ok": True, "path": raw, "exists": False}
|
||||
return {
|
||||
"ok": True,
|
||||
"path": raw,
|
||||
"exists": True,
|
||||
"is_file": path.is_file(),
|
||||
"is_dir": path.is_dir(),
|
||||
"is_symlink": path.is_symlink(),
|
||||
"size": st.st_size,
|
||||
"mode": oct(st.st_mode & 0o7777),
|
||||
"uid": st.st_uid,
|
||||
"gid": st.st_gid,
|
||||
"mtime_ns": st.st_mtime_ns,
|
||||
}
|
||||
|
||||
|
||||
def filesystem_list(args: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = str(args.get("path") or "").strip()
|
||||
if not raw or len(raw) > 4096 or "\x00" in raw or "\n" in raw:
|
||||
raise ProductError("PATH_INVALID")
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
raise ProductError("PATH_ABSOLUTE_REQUIRED")
|
||||
limit = _bounded_int(args.get("limit"), default=100, low=1, high=500, error="FILE_LIST_LIMIT_INVALID")
|
||||
if not path.is_dir():
|
||||
raise ProductError("DIRECTORY_NOT_FOUND")
|
||||
rows = []
|
||||
try:
|
||||
children = sorted(path.iterdir(), key=lambda x: x.name)
|
||||
except PermissionError:
|
||||
raise ProductError("DIRECTORY_PERMISSION_DENIED") from None
|
||||
for child in children[:limit]:
|
||||
try:
|
||||
st = child.lstat()
|
||||
rows.append({
|
||||
"name": child.name,
|
||||
"is_file": child.is_file(),
|
||||
"is_dir": child.is_dir(),
|
||||
"is_symlink": child.is_symlink(),
|
||||
"size": st.st_size,
|
||||
"mode": oct(st.st_mode & 0o7777),
|
||||
})
|
||||
except OSError:
|
||||
rows.append({"name": child.name, "error": "STAT_FAILED"})
|
||||
return {"ok": True, "path": raw, "entries": rows, "limit": limit}
|
||||
|
||||
|
||||
def filesystem_hash(args: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = str(args.get("path") or "").strip()
|
||||
if not raw or len(raw) > 4096 or "\x00" in raw or "\n" in raw:
|
||||
raise ProductError("PATH_INVALID")
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
raise ProductError("PATH_ABSOLUTE_REQUIRED")
|
||||
if not path.is_file():
|
||||
raise ProductError("PATH_NOT_REGULAR_FILE")
|
||||
size = path.stat().st_size
|
||||
if size > 16 * 1024 * 1024:
|
||||
raise ProductError("FILE_TOO_LARGE_TO_HASH")
|
||||
h = hashlib.sha256()
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
except PermissionError:
|
||||
raise ProductError("FILE_PERMISSION_DENIED") from None
|
||||
return {"ok": True, "path": raw, "size": size, "sha256": h.hexdigest()}
|
||||
|
||||
|
||||
def filesystem_read_text(args: dict[str, Any]) -> dict[str, Any]:
|
||||
path = _safe_text_path(args.get("path"))
|
||||
max_bytes = _bounded_int(args.get("max_bytes"), default=16384, low=1, high=32768, error="MAX_BYTES_INVALID")
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except PermissionError:
|
||||
raise ProductError("FILE_PERMISSION_DENIED") from None
|
||||
if len(data) > max_bytes:
|
||||
data = data[:max_bytes]
|
||||
truncated = True
|
||||
else:
|
||||
truncated = False
|
||||
if b"\x00" in data:
|
||||
raise ProductError("BINARY_FILE_NOT_ALLOWED")
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(path),
|
||||
"bytes_returned": len(data),
|
||||
"truncated": truncated,
|
||||
"text": _sanitize_text(text),
|
||||
}
|
||||
|
||||
|
||||
CAPABILITIES = {
|
||||
"system.status": system_status,
|
||||
"system.resources": system_resources,
|
||||
"system.processes": system_processes,
|
||||
"system.time": system_time,
|
||||
"hardware.summary": hardware_summary,
|
||||
"kernel.modules": kernel_modules,
|
||||
"storage.inspect": storage_inspect,
|
||||
"storage.usage": storage_usage,
|
||||
"storage.mounts": storage_mounts,
|
||||
"network.inspect": network_inspect,
|
||||
"network.sockets": network_sockets,
|
||||
"network.dns": network_dns,
|
||||
"package.status": package_status_read,
|
||||
"package.search": package_search,
|
||||
"package.upgradable": package_upgradable,
|
||||
"service.status": service_status,
|
||||
"service.list": service_list,
|
||||
"journal.service": journal_service,
|
||||
"user.list": user_list,
|
||||
"group.list": group_list,
|
||||
"filesystem.stat": filesystem_stat,
|
||||
"filesystem.list": filesystem_list,
|
||||
"filesystem.hash": filesystem_hash,
|
||||
"filesystem.read_text": filesystem_read_text,
|
||||
}
|
||||
|
||||
|
||||
def list_capabilities() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"name": "system.status", "risk": "READONLY"},
|
||||
{"name": "system.resources", "risk": "READONLY"},
|
||||
{"name": "system.processes", "risk": "READONLY", "args": {"limit": "int:1..200"}},
|
||||
{"name": "system.time", "risk": "READONLY"},
|
||||
{"name": "hardware.summary", "risk": "READONLY"},
|
||||
{"name": "kernel.modules", "risk": "READONLY", "args": {"limit": "int:1..500"}},
|
||||
{"name": "storage.inspect", "risk": "READONLY"},
|
||||
{"name": "storage.usage", "risk": "READONLY"},
|
||||
{"name": "storage.mounts", "risk": "READONLY"},
|
||||
{"name": "network.inspect", "risk": "READONLY"},
|
||||
{"name": "network.sockets", "risk": "READONLY", "args": {"limit": "int:1..500"}},
|
||||
{"name": "network.dns", "risk": "READONLY"},
|
||||
{"name": "package.status", "risk": "READONLY", "args": {"package": "debian-package-name"}},
|
||||
{"name": "package.search", "risk": "READONLY", "args": {"query": "string", "limit": "int:1..100"}},
|
||||
{"name": "package.upgradable", "risk": "READONLY", "args": {"limit": "int:1..500"}},
|
||||
{"name": "service.status", "risk": "READONLY", "args": {"service": "systemd-unit"}},
|
||||
{"name": "service.list", "risk": "READONLY", "args": {"limit": "int:1..500"}},
|
||||
{"name": "journal.service", "risk": "READONLY", "args": {"service": "systemd-unit", "lines": "int:1..500"}},
|
||||
{"name": "user.list", "risk": "READONLY", "args": {"limit": "int:1..1000"}},
|
||||
{"name": "group.list", "risk": "READONLY", "args": {"limit": "int:1..1000"}},
|
||||
{"name": "filesystem.stat", "risk": "READONLY", "args": {"path": "absolute-path"}},
|
||||
{"name": "filesystem.list", "risk": "READONLY", "args": {"path": "absolute-directory", "limit": "int:1..500"}},
|
||||
{"name": "filesystem.hash", "risk": "READONLY", "args": {"path": "absolute-file<=16MiB"}},
|
||||
{"name": "filesystem.read_text", "risk": "READONLY", "args": {"path": "safe-absolute-text-path", "max_bytes": "int:1..32768"}},
|
||||
]
|
||||
|
||||
|
||||
def execute_capability(name: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
name = str(name).strip()
|
||||
fn = CAPABILITIES.get(name)
|
||||
if fn is None:
|
||||
raise ProductError("CAPABILITY_NOT_ALLOWED")
|
||||
args = args or {}
|
||||
if not isinstance(args, dict):
|
||||
raise ProductError("CAPABILITY_ARGS_NOT_OBJECT")
|
||||
return fn(args)
|
||||
+572
@@ -0,0 +1,572 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .product_core import ProductError
|
||||
from .seed_core import SEED_VERSION, SeedContext, atomic_write_json
|
||||
from .classic_tty import execute_frame, render_result, render_snapshot
|
||||
from .console_tty import run_console
|
||||
from .plan_engine import PlanEngine
|
||||
from .memory_store import MemoryStore
|
||||
from .first_run import SetupChoice, detect_system, provider_instructions, render_privilege_explanation, save_setup_config, setup_summary
|
||||
from .console_chat import run_console_chat
|
||||
from .qr_terminal import render_qr_compact, render_qr_mono, render_qr_ansi, render_qr_unicode, TerminalQrError
|
||||
from .pairing import FBPAIR_PROTOCOL, PairingStore, pairing_qr_payload, public_pairing_view
|
||||
from .pairing_client import PairingClientError, poll_pairing, start_pairing
|
||||
from .network_security import inspect_network, pairing_policy, forwarding_policy
|
||||
from .pairing_window import open_pairing_window, pairing_window_status, close_pairing_window
|
||||
from .network_lifecycle import reconcile_once, reconcile_broker_cancel
|
||||
|
||||
|
||||
def emit(value) -> None:
|
||||
print(json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False))
|
||||
|
||||
|
||||
def _sync_pairing_config(ctx: SeedContext, view: dict) -> dict:
|
||||
config=ctx.load_config() or {}
|
||||
pairing=config.get("pairing") or {}
|
||||
pairing.update({
|
||||
"protocol":FBPAIR_PROTOCOL,
|
||||
"state":view.get("state","not_paired"),
|
||||
"transport":"broker_outbound" if view.get("state")!="not_paired" else "unpaired",
|
||||
})
|
||||
for key in (
|
||||
"pair_id","broker_base_url","account_issuer",
|
||||
"account_subject_sha256","revocation_id","paired_at",
|
||||
"broker_confirm_required","broker_confirmed_at",
|
||||
"broker_cancel_required","broker_cancelled_at","broker_cancel_state",
|
||||
):
|
||||
if view.get(key) is not None:
|
||||
pairing[key]=view.get(key)
|
||||
config["pairing"]=pairing
|
||||
config["connection_mode"]="PAIRED" if view.get("state")=="paired" else "PAIRING"
|
||||
setup=config.get("setup")
|
||||
if isinstance(setup,dict):
|
||||
setup["pairing_state"]=view.get("state","not_paired")
|
||||
atomic_write_json(ctx.config_path,config)
|
||||
return config
|
||||
|
||||
|
||||
def _render_pair_qr(view: dict, mode: str) -> None:
|
||||
payload=pairing_qr_payload(view)
|
||||
if not sys.stdout.isatty():
|
||||
return
|
||||
if mode=="compact":
|
||||
render_qr_compact(payload)
|
||||
elif mode=="mono":
|
||||
render_qr_mono(payload)
|
||||
elif mode=="unicode":
|
||||
render_qr_unicode(payload)
|
||||
else:
|
||||
render_qr_ansi(payload)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="framebridge",
|
||||
description="FrameBridge Seed - Linux text-mode bootstrap",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"%(prog)s {SEED_VERSION}",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
init = sub.add_parser("init", help="Initialize node identity and provider")
|
||||
init.add_argument("--provider", default="chatgpt")
|
||||
init.add_argument("--hostname", default="")
|
||||
|
||||
sub.add_parser("identity", help="Show persistent node identity")
|
||||
sub.add_parser("doctor", help="Run READONLY bootstrap diagnostics")
|
||||
sub.add_parser("status", help="Show Seed status")
|
||||
sub.add_parser("fingerprint", help="Show non-secret node fingerprint")
|
||||
sub.add_parser("snapshot", help="Generate Classic AI context snapshot")
|
||||
sub.add_parser("console", help="Open FrameBridge Classic in the current TTY")
|
||||
classic = sub.add_parser("classic", help="Process one Classic frame from stdin or file")
|
||||
classic.add_argument("--file", default="-")
|
||||
setup = sub.add_parser("setup", help="Run the FrameBridge first-run setup wizard")
|
||||
setup.add_argument("--provider", choices=("chatgpt","ollama","lmstudio","other"), default="")
|
||||
setup.add_argument("--privilege-mode", choices=("totp","sudo_tty"), default="")
|
||||
setup.add_argument("--language", default="en")
|
||||
setup.add_argument("--non-interactive", action="store_true")
|
||||
sub.add_parser("setup-status", help="Show first-run setup state")
|
||||
|
||||
net = sub.add_parser("net", help="Inspect FrameBridge network/firewall policy")
|
||||
net_sub = net.add_subparsers(dest="net_command", required=True)
|
||||
net_sub.add_parser("status", help="Show READONLY FBNET interface/firewall status")
|
||||
p_net_policy = net_sub.add_parser("pairing-policy", help="Render pairing-window policy without applying changes")
|
||||
p_net_policy.add_argument("--port", type=int, default=18765)
|
||||
p_net_forward = net_sub.add_parser("forwarding-policy", help="Render typed runtime forwarding policy without applying changes")
|
||||
p_net_forward.add_argument("--in-interface", required=True)
|
||||
p_net_forward.add_argument("--out-interface", required=True)
|
||||
p_net_forward.add_argument("--source-subnet", required=True)
|
||||
p_net_forward.add_argument("--destination-subnet", required=True)
|
||||
p_net_forward.add_argument("--protocol", choices=("any","tcp","udp"), default="any")
|
||||
p_net_forward.add_argument("--destination-port", type=int, default=0)
|
||||
p_net_forward.add_argument("--nat", action="store_true")
|
||||
p_net_forward.add_argument("--lifetime", choices=("runtime",), default="runtime")
|
||||
|
||||
p_window = net_sub.add_parser("pairing-window", help="Manage the short-lived local LAN pairing firewall window")
|
||||
p_window_sub = p_window.add_subparsers(dest="pairing_window_command", required=True)
|
||||
p_window_open = p_window_sub.add_parser("open", help="Open a scoped runtime pairing window after local authorization")
|
||||
p_window_open.add_argument("--port", type=int, default=18765)
|
||||
p_window_open.add_argument("--ttl", type=int, default=600)
|
||||
p_window_sub.add_parser("status", help="Show the current pairing-window transaction")
|
||||
p_window_close = p_window_sub.add_parser("close", help="Close the active pairing window")
|
||||
p_window_close.add_argument("--reason", choices=("user","cancelled","paired","denied","network_change","service_stop","reconcile"), default="user")
|
||||
|
||||
net_sub.add_parser("reconcile", help="Re-evaluate an active pairing window against current network identity")
|
||||
|
||||
pair = sub.add_parser("pair", help="Pair this Linux node with an authorized AI account")
|
||||
pair_sub = pair.add_subparsers(dest="pair_command", required=True)
|
||||
p_pair_start = pair_sub.add_parser("start", help="Start FBPAIR/1 device-style pairing")
|
||||
p_pair_start.add_argument("--broker", required=True)
|
||||
p_pair_start.add_argument("--provider", default="")
|
||||
p_pair_start.add_argument("--mode", choices=("compact","mono","ansi","unicode"), default="compact")
|
||||
p_pair_start.add_argument("--dev-insecure", action="store_true")
|
||||
pair_sub.add_parser("status", help="Show public pairing status")
|
||||
pair_sub.add_parser("cancel", help="Cancel the current pending pairing request")
|
||||
p_pair_poll = pair_sub.add_parser("poll", help="Poll pairing broker once")
|
||||
p_pair_poll.add_argument("--dev-insecure", action="store_true")
|
||||
p_pair_wait = pair_sub.add_parser("wait", help="Wait for human account authorization")
|
||||
p_pair_wait.add_argument("--dev-insecure", action="store_true")
|
||||
p_pair_wait.add_argument("--max-wait", type=int, default=600)
|
||||
p_pair_qr = pair_sub.add_parser("qr", help="Render the current pairing QR")
|
||||
p_pair_qr.add_argument("--mode", choices=("compact","mono","ansi","unicode"), default="compact")
|
||||
|
||||
sub.add_parser("console-chat", help="Open the primary FrameBridge conversational console")
|
||||
|
||||
sub.add_parser("agent-manifest", help="Return the mandatory compact AI bootstrap manifest")
|
||||
memory = sub.add_parser("memory", help="Manage mandatory FrameBridge binary technical memory")
|
||||
memory_sub = memory.add_subparsers(dest="memory_command", required=True)
|
||||
memory_sub.add_parser("init", help="Initialize or repair node binary memory")
|
||||
memory_sub.add_parser("bootstrap", help="Return compact mandatory AI memory bootstrap")
|
||||
p_mem_query = memory_sub.add_parser("query", help="Query a normalized memory domain")
|
||||
p_mem_query.add_argument("--domain", default="summary")
|
||||
p_mem_query.add_argument("--limit", type=int, default=20)
|
||||
p_mem_append = memory_sub.add_parser("append", help="Append one normalized memory record")
|
||||
p_mem_append.add_argument("--record-type", required=True, choices=("change","incident","decision","artifact","recovery","certification","note"))
|
||||
p_mem_append.add_argument("--json-stdin", action="store_true", required=True)
|
||||
memory_sub.add_parser("checkpoint", help="Refresh the compact current-state checkpoint")
|
||||
memory_sub.add_parser("stats", help="Show binary memory statistics")
|
||||
|
||||
plan = sub.add_parser("plan", help="Manage local CHANGE plans")
|
||||
plan_sub = plan.add_subparsers(dest="plan_command", required=True)
|
||||
p_import = plan_sub.add_parser("import")
|
||||
p_import.add_argument("--file", required=True)
|
||||
plan_sub.add_parser("import-stdin")
|
||||
p_show = plan_sub.add_parser("show")
|
||||
p_show.add_argument("plan_id")
|
||||
p_validate = plan_sub.add_parser("validate")
|
||||
p_validate.add_argument("plan_id")
|
||||
p_approve = plan_sub.add_parser("approve")
|
||||
p_approve.add_argument("plan_id")
|
||||
p_approve_system = plan_sub.add_parser("approve-system")
|
||||
p_approve_system.add_argument("plan_id")
|
||||
p_apply = plan_sub.add_parser("apply")
|
||||
p_apply.add_argument("plan_id")
|
||||
p_approve_rollback = plan_sub.add_parser("approve-rollback")
|
||||
p_approve_rollback.add_argument("plan_id")
|
||||
p_remote_challenge = plan_sub.add_parser("remote-challenge")
|
||||
p_remote_challenge.add_argument("plan_id")
|
||||
p_remote_challenge.add_argument("--rollback", action="store_true")
|
||||
p_remote_challenge.add_argument("--ttl", type=int, default=300)
|
||||
p_remote_approve = plan_sub.add_parser("remote-approve")
|
||||
p_remote_approve.add_argument("plan_id")
|
||||
p_remote_approve.add_argument("--challenge-id", required=True)
|
||||
p_remote_approve.add_argument("--key-id", required=True)
|
||||
p_remote_approve.add_argument("--signature-b64", required=True)
|
||||
p_remote_approve.add_argument("--rollback", action="store_true")
|
||||
p_totp_enroll = plan_sub.add_parser("totp-enroll")
|
||||
p_totp_enroll.add_argument("--key-id", required=True)
|
||||
p_totp_enroll.add_argument("--node-label", required=True)
|
||||
p_totp_enroll.add_argument("--issuer", default="FrameBridge")
|
||||
p_totp_enroll.add_argument("--mode", choices=("compact","mono","ansi","unicode"), default="compact")
|
||||
p_totp_challenge = plan_sub.add_parser("totp-challenge")
|
||||
p_totp_challenge.add_argument("plan_id")
|
||||
p_totp_challenge.add_argument("--key-id", required=True)
|
||||
p_totp_challenge.add_argument("--rollback", action="store_true")
|
||||
p_totp_challenge.add_argument("--ttl", type=int, default=120)
|
||||
p_totp_approve = plan_sub.add_parser("totp-approve")
|
||||
p_totp_approve.add_argument("plan_id")
|
||||
p_totp_approve.add_argument("--challenge-id", required=True)
|
||||
p_totp_approve.add_argument("--key-id", required=True)
|
||||
p_totp_approve.add_argument("--rollback", action="store_true")
|
||||
p_rollback = plan_sub.add_parser("rollback")
|
||||
p_rollback.add_argument("plan_id")
|
||||
plan_sub.add_parser("list")
|
||||
sub.add_parser("audit", help="Verify the audit chain")
|
||||
|
||||
args = parser.parse_args()
|
||||
ctx = SeedContext()
|
||||
|
||||
try:
|
||||
if args.command == "init":
|
||||
emit(ctx.init(args.provider, args.hostname))
|
||||
elif args.command == "identity":
|
||||
emit(ctx.identity())
|
||||
elif args.command == "doctor":
|
||||
emit(ctx.doctor())
|
||||
elif args.command == "status":
|
||||
emit(ctx.status())
|
||||
elif args.command == "fingerprint":
|
||||
emit(ctx.fingerprint())
|
||||
elif args.command == "snapshot":
|
||||
print(render_snapshot(ctx))
|
||||
elif args.command == "setup":
|
||||
if not (ctx.load_identity() and ctx.load_config()):
|
||||
emit(ctx.init("chatgpt", ""))
|
||||
detected = detect_system()
|
||||
provider = args.provider
|
||||
privilege = args.privilege_mode
|
||||
language = args.language or "en"
|
||||
if args.non_interactive:
|
||||
if not provider or not privilege:
|
||||
raise ProductError("SETUP_NON_INTERACTIVE_OPTIONS_REQUIRED")
|
||||
else:
|
||||
print("\nFrameBridge First-Run Setup\n")
|
||||
print("System detected:")
|
||||
sysinfo = detected.get("system") or {}
|
||||
hw = detected.get("hardware") or {}
|
||||
print(f" OS: {sysinfo.get('os_name')} / kernel {sysinfo.get('kernel')}")
|
||||
print(f" CPU: {hw.get('cpu_model')}")
|
||||
print(f" RAM: {hw.get('memory_total_kib')} KiB")
|
||||
print("\nPrivileged authorization:")
|
||||
print(" 1) Authenticator / QR TOTP")
|
||||
print(" 2) Local sudo password")
|
||||
if not privilege:
|
||||
answer=input("Select [1/2]: ").strip()
|
||||
privilege="totp" if answer=="1" else "sudo_tty" if answer=="2" else ""
|
||||
print("\nAI provider:")
|
||||
print(" 1) ChatGPT")
|
||||
print(" 2) Ollama")
|
||||
print(" 3) LM Studio")
|
||||
print(" 4) Other")
|
||||
if not provider:
|
||||
answer=input("Select [1-4]: ").strip()
|
||||
provider={"1":"chatgpt","2":"ollama","3":"lmstudio","4":"other"}.get(answer,"")
|
||||
choice=SetupChoice(privilege,provider,language)
|
||||
config=save_setup_config(ctx,choice)
|
||||
mem=MemoryStore(ctx).initialize()
|
||||
print("\n"+"\n".join(render_privilege_explanation(choice.privilege_mode)))
|
||||
print("\n"+"\n".join(provider_instructions(choice.provider)))
|
||||
print("\nFrameBridge setup complete.")
|
||||
print("Run: framebridge console-chat")
|
||||
emit({"ok":True,"setup":config.get("setup"),"memory":mem,"detected":detected})
|
||||
elif args.command == "setup-status":
|
||||
emit(setup_summary(ctx))
|
||||
elif args.command == "net":
|
||||
if args.net_command == "status":
|
||||
emit(inspect_network())
|
||||
elif args.net_command == "pairing-policy":
|
||||
emit(pairing_policy(args.port))
|
||||
elif args.net_command == "forwarding-policy":
|
||||
emit(forwarding_policy(
|
||||
ingress_interface=args.in_interface,
|
||||
egress_interface=args.out_interface,
|
||||
source_subnet=args.source_subnet,
|
||||
destination_subnet=args.destination_subnet,
|
||||
protocol=args.protocol,
|
||||
destination_port=args.destination_port,
|
||||
nat=args.nat,
|
||||
lifetime=args.lifetime,
|
||||
))
|
||||
elif args.net_command == "pairing-window":
|
||||
if args.pairing_window_command == "open":
|
||||
MemoryStore(ctx).require_ready()
|
||||
result=open_pairing_window(args.port,args.ttl)
|
||||
ctx.audit.append(
|
||||
"network.pairing_window.open","PASS",
|
||||
request={"port":args.port,"ttl_seconds":args.ttl},
|
||||
result={
|
||||
"window_id":result.get("window_id"),
|
||||
"interface":result.get("interface"),
|
||||
"source_subnet":result.get("source_subnet"),
|
||||
"destination_address":result.get("destination_address"),
|
||||
"expires_at":result.get("expires_at"),
|
||||
},
|
||||
principal="local-operator",
|
||||
)
|
||||
MemoryStore(ctx).append(
|
||||
"change",
|
||||
{
|
||||
"operation":"network.pairing_window.open",
|
||||
"window_id":result.get("window_id"),
|
||||
"interface":result.get("interface"),
|
||||
"source_subnet":result.get("source_subnet"),
|
||||
"destination_address":result.get("destination_address"),
|
||||
"port":result.get("port"),
|
||||
"expires_at":result.get("expires_at"),
|
||||
},
|
||||
source="operator",
|
||||
)
|
||||
emit(result)
|
||||
elif args.pairing_window_command == "status":
|
||||
emit(pairing_window_status())
|
||||
elif args.pairing_window_command == "close":
|
||||
MemoryStore(ctx).require_ready()
|
||||
before=pairing_window_status()
|
||||
result=close_pairing_window(args.reason)
|
||||
ctx.audit.append(
|
||||
"network.pairing_window.close","PASS",
|
||||
request={"reason":args.reason,"window_id":before.get("window_id")},
|
||||
result={"state":result.get("state"),"rule_present":result.get("rule_present")},
|
||||
principal="local-operator",
|
||||
)
|
||||
MemoryStore(ctx).append(
|
||||
"recovery",
|
||||
{
|
||||
"operation":"network.pairing_window.close",
|
||||
"reason":args.reason,
|
||||
"window_id":before.get("window_id"),
|
||||
"state":result.get("state"),
|
||||
},
|
||||
source="operator",
|
||||
)
|
||||
emit(result)
|
||||
elif args.net_command == "reconcile":
|
||||
emit(reconcile_once(ctx=ctx))
|
||||
elif args.command == "pair":
|
||||
if not (ctx.load_identity() and ctx.load_config()):
|
||||
raise ProductError("PAIR_NODE_NOT_INITIALIZED")
|
||||
pair_store=PairingStore(ctx.state_dir)
|
||||
if args.pair_command == "status":
|
||||
emit(public_pairing_view(pair_store.load()))
|
||||
elif args.pair_command == "start":
|
||||
MemoryStore(ctx).require_ready()
|
||||
identity=ctx.load_identity() or {}
|
||||
config=ctx.load_config() or {}
|
||||
manifest_path=ctx.state_dir/"memory"/"manifest.fbam"
|
||||
if not manifest_path.is_file():
|
||||
raise ProductError("PAIR_CAPABILITY_MANIFEST_MISSING")
|
||||
manifest_sha=hashlib.sha256(manifest_path.read_bytes()).hexdigest()
|
||||
provider=(args.provider or config.get("provider") or "chatgpt").strip().lower()
|
||||
try:
|
||||
view=start_pairing(
|
||||
state_dir=ctx.state_dir,
|
||||
node_id=str(identity.get("node_id") or ""),
|
||||
node_label=str(identity.get("hostname") or ""),
|
||||
node_host_id=str(identity.get("node_host_id") or ""),
|
||||
public_jwk=identity.get("node_public_jwk") or {},
|
||||
provider=provider,
|
||||
capability_manifest_sha256=manifest_sha,
|
||||
broker_base_url=args.broker,
|
||||
allow_insecure_dev=args.dev_insecure,
|
||||
)
|
||||
except PairingClientError as exc:
|
||||
raise ProductError(str(exc)) from exc
|
||||
_sync_pairing_config(ctx,view)
|
||||
ctx.audit.append(
|
||||
"pair.request","PASS",
|
||||
request={
|
||||
"protocol":FBPAIR_PROTOCOL,
|
||||
"pair_id":view.get("pair_id"),
|
||||
"provider":provider,
|
||||
"node_host_id":identity.get("node_host_id"),
|
||||
},
|
||||
result={"state":view.get("state")},
|
||||
principal="local-operator",
|
||||
)
|
||||
print("\nFrameBridge AI pairing")
|
||||
print("Open: "+str(view.get("verification_uri_complete") or view.get("verification_uri") or ""))
|
||||
print("Code: "+str(view.get("user_code")))
|
||||
print("Expires in: "+str(view.get("expires_in"))+" seconds")
|
||||
_render_pair_qr(view,args.mode)
|
||||
emit(view)
|
||||
elif args.pair_command == "cancel":
|
||||
try:
|
||||
view=pair_store.cancel_pending()
|
||||
except Exception as exc:
|
||||
raise ProductError(str(exc)) from exc
|
||||
_sync_pairing_config(ctx,view)
|
||||
broker_cancel=reconcile_broker_cancel(ctx)
|
||||
view=public_pairing_view(pair_store.load())
|
||||
_sync_pairing_config(ctx,view)
|
||||
ctx.audit.append(
|
||||
"pair.cancel","PASS",
|
||||
request={
|
||||
"protocol":FBPAIR_PROTOCOL,
|
||||
"pair_id":view.get("pair_id"),
|
||||
},
|
||||
result={"state":view.get("state"),"broker_cancel_state":broker_cancel.get("state")},
|
||||
principal="local-operator",
|
||||
)
|
||||
emit(view)
|
||||
elif args.pair_command == "poll":
|
||||
before=pair_store.load()
|
||||
try:
|
||||
view=poll_pairing(
|
||||
state_dir=ctx.state_dir,
|
||||
allow_insecure_dev=args.dev_insecure,
|
||||
)
|
||||
except PairingClientError as exc:
|
||||
raise ProductError(str(exc)) from exc
|
||||
_sync_pairing_config(ctx,view)
|
||||
if view.get("state") != before.get("state"):
|
||||
ctx.audit.append(
|
||||
"pair."+str(view.get("state")),"PASS",
|
||||
request={
|
||||
"protocol":FBPAIR_PROTOCOL,
|
||||
"pair_id":view.get("pair_id"),
|
||||
},
|
||||
result={
|
||||
"state":view.get("state"),
|
||||
"provider":view.get("provider"),
|
||||
},
|
||||
principal="pairing-broker",
|
||||
)
|
||||
if view.get("state")=="paired":
|
||||
MemoryStore(ctx).checkpoint()
|
||||
emit(view)
|
||||
elif args.pair_command == "wait":
|
||||
max_wait=int(args.max_wait)
|
||||
if max_wait<1 or max_wait>1800:
|
||||
raise ProductError("PAIR_WAIT_TIMEOUT_INVALID")
|
||||
started=time.monotonic()
|
||||
view=public_pairing_view(pair_store.load())
|
||||
while (
|
||||
view.get("state")=="pair_requested"
|
||||
or (view.get("state")=="paired" and view.get("broker_confirm_required") is True)
|
||||
):
|
||||
if time.monotonic()-started>=max_wait:
|
||||
raise ProductError("PAIR_WAIT_TIMEOUT")
|
||||
try:
|
||||
view=poll_pairing(
|
||||
state_dir=ctx.state_dir,
|
||||
allow_insecure_dev=args.dev_insecure,
|
||||
)
|
||||
except PairingClientError as exc:
|
||||
raise ProductError(str(exc)) from exc
|
||||
if view.get("state")=="paired" and view.get("broker_confirm_required") is not True:
|
||||
break
|
||||
if view.get("state") not in {"pair_requested","paired"}:
|
||||
break
|
||||
retry=int(view.get("retry_after") or view.get("interval") or 2)
|
||||
time.sleep(max(1,min(60,retry)))
|
||||
_sync_pairing_config(ctx,view)
|
||||
ctx.audit.append(
|
||||
"pair."+str(view.get("state")),"PASS",
|
||||
request={
|
||||
"protocol":FBPAIR_PROTOCOL,
|
||||
"pair_id":view.get("pair_id"),
|
||||
},
|
||||
result={
|
||||
"state":view.get("state"),
|
||||
"provider":view.get("provider"),
|
||||
},
|
||||
principal="pairing-broker",
|
||||
)
|
||||
if view.get("state")=="paired":
|
||||
MemoryStore(ctx).checkpoint()
|
||||
emit(view)
|
||||
if view.get("state")!="paired" or view.get("broker_confirm_required") is True:
|
||||
raise SystemExit(3)
|
||||
elif args.pair_command == "qr":
|
||||
view=public_pairing_view(pair_store.load())
|
||||
_render_pair_qr(view,args.mode)
|
||||
emit(view)
|
||||
elif args.command == "console-chat":
|
||||
raise SystemExit(run_console_chat(ctx))
|
||||
elif args.command == "agent-manifest":
|
||||
emit(MemoryStore(ctx).bootstrap())
|
||||
elif args.command == "memory":
|
||||
store = MemoryStore(ctx)
|
||||
if args.memory_command == "init":
|
||||
value = store.initialize()
|
||||
ctx.audit.append("memory.init", "PASS", request={"protocol":"FBM/1"}, result={"required":True,"mode":"read_write"}, principal="local-operator")
|
||||
emit(value)
|
||||
elif args.memory_command == "bootstrap":
|
||||
emit(store.bootstrap())
|
||||
elif args.memory_command == "query":
|
||||
emit(store.query(args.domain, args.limit))
|
||||
elif args.memory_command == "append":
|
||||
try:
|
||||
value = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise ProductError("MEMORY_APPEND_JSON_INVALID")
|
||||
if not isinstance(value, dict):
|
||||
raise ProductError("MEMORY_APPEND_JSON_NOT_OBJECT")
|
||||
result = store.append(args.record_type, value, source="agent")
|
||||
ctx.audit.append("memory.append", "PASS", request={"record_type":args.record_type}, result={"event_number":result.get("event_number"),"event_sha256":result.get("event_sha256")}, principal="ai-agent")
|
||||
store.checkpoint()
|
||||
emit(result)
|
||||
elif args.memory_command == "checkpoint":
|
||||
value = store.checkpoint()
|
||||
ctx.audit.append("memory.checkpoint", "PASS", request={}, result={"state_sha256":value.get("state_sha256"),"manifest_sha256":value.get("manifest_sha256")}, principal="local-operator")
|
||||
emit(value)
|
||||
elif args.memory_command == "stats":
|
||||
emit(store.stats())
|
||||
elif args.command == "console":
|
||||
raise SystemExit(run_console(ctx))
|
||||
elif args.command == "classic":
|
||||
if args.file == "-":
|
||||
text = sys.stdin.read()
|
||||
else:
|
||||
with open(args.file, "r", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
print(render_result(execute_frame(ctx, text)))
|
||||
elif args.command == "plan":
|
||||
engine = PlanEngine(ctx)
|
||||
if args.plan_command == "import":
|
||||
emit(engine.import_file(args.file))
|
||||
elif args.plan_command == "import-stdin":
|
||||
try:
|
||||
value = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise ProductError("PLAN_IMPORT_JSON_INVALID")
|
||||
emit(engine.import_document(value))
|
||||
elif args.plan_command == "show":
|
||||
emit(engine.show(args.plan_id))
|
||||
elif args.plan_command == "validate":
|
||||
emit(engine.validate(args.plan_id))
|
||||
elif args.plan_command == "approve":
|
||||
emit(engine.approve(args.plan_id))
|
||||
elif args.plan_command == "approve-system":
|
||||
emit(engine.approve_system(args.plan_id))
|
||||
elif args.plan_command == "apply":
|
||||
emit(engine.apply(args.plan_id))
|
||||
elif args.plan_command == "approve-rollback":
|
||||
emit(engine.approve_rollback(args.plan_id))
|
||||
elif args.plan_command == "remote-challenge":
|
||||
emit(engine.remote_challenge(args.plan_id, rollback=args.rollback, ttl_seconds=args.ttl))
|
||||
elif args.plan_command == "remote-approve":
|
||||
emit(engine.approve_remote(args.plan_id, args.challenge_id, args.key_id, args.signature_b64, rollback=args.rollback))
|
||||
elif args.plan_command == "totp-enroll":
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
raise ProductError("INTERACTIVE_TTY_REQUIRED")
|
||||
value=engine.totp_enroll(args.key_id,args.node_label,args.issuer)
|
||||
print("\nFrameBridge TOTP enrollment")
|
||||
print("Scan this QR with your authenticator app.\n")
|
||||
if args.mode == "compact": render_qr_compact(value["otpauth_uri"])
|
||||
elif args.mode == "mono": render_qr_mono(value["otpauth_uri"])
|
||||
elif args.mode == "unicode": render_qr_unicode(value["otpauth_uri"])
|
||||
else: render_qr_ansi(value["otpauth_uri"])
|
||||
print("Key ID: "+value["key_id"])
|
||||
print("Enrollment complete. Do not share the secret or a QR screenshot.\n")
|
||||
emit({"ok":True,"key_id":value["key_id"],"secret_sha256":value["secret_sha256"],"enrolled_at":value["enrolled_at"]})
|
||||
elif args.plan_command == "totp-challenge":
|
||||
emit(engine.totp_challenge(args.plan_id, args.key_id, rollback=args.rollback, ttl_seconds=args.ttl))
|
||||
elif args.plan_command == "totp-approve":
|
||||
code=sys.stdin.readline().strip()
|
||||
emit(engine.approve_totp(args.plan_id, args.challenge_id, args.key_id, code, rollback=args.rollback))
|
||||
elif args.plan_command == "rollback":
|
||||
emit(engine.rollback(args.plan_id))
|
||||
elif args.plan_command == "list":
|
||||
emit(engine.list())
|
||||
elif args.command == "audit":
|
||||
emit(ctx.audit.verify())
|
||||
except ProductError as exc:
|
||||
emit({
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
})
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .product_core import AuditLog, ProductError
|
||||
from .node_identity import NodeIdentityError, ensure_node_keypair
|
||||
from .pairing import FBPAIR_PROTOCOL
|
||||
|
||||
SUPPORTED_PROVIDERS = {"chatgpt"}
|
||||
SEED_VERSION = "0.5.0-candidate.1"
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, value: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name("." + path.name + ".tmp")
|
||||
data = json.dumps(value, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
|
||||
tmp.write_text(data, encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def read_os_release() -> dict[str, str]:
|
||||
path = Path("/etc/os-release")
|
||||
result: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return result
|
||||
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if "=" not in raw:
|
||||
continue
|
||||
key, value = raw.split("=", 1)
|
||||
result[key] = value.strip().strip('"')
|
||||
return result
|
||||
|
||||
|
||||
def run_readonly(args: list[str], timeout: float = 3.0) -> dict[str, Any]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
args,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
return {
|
||||
"ok": proc.returncode == 0,
|
||||
"rc": proc.returncode,
|
||||
"stdout": proc.stdout.strip()[:8192],
|
||||
"stderr": proc.stderr.strip()[:2048],
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": type(exc).__name__,
|
||||
}
|
||||
|
||||
|
||||
class SeedContext:
|
||||
def __init__(
|
||||
self,
|
||||
state_dir: Path | None = None,
|
||||
config_dir: Path | None = None,
|
||||
):
|
||||
self.state_dir = Path(
|
||||
state_dir
|
||||
or os.environ.get(
|
||||
"FRAMEBRIDGE_SEED_STATE_DIR",
|
||||
"~/.local/share/framebridge-seed",
|
||||
)
|
||||
).expanduser()
|
||||
self.config_dir = Path(
|
||||
config_dir
|
||||
or os.environ.get(
|
||||
"FRAMEBRIDGE_SEED_CONFIG_DIR",
|
||||
"~/.config/framebridge-seed",
|
||||
)
|
||||
).expanduser()
|
||||
|
||||
self.identity_path = self.state_dir / "identity.json"
|
||||
self.config_path = self.config_dir / "config.json"
|
||||
self.audit = AuditLog(self.state_dir / "audit.jsonl")
|
||||
|
||||
def detect_hostname(self) -> str:
|
||||
value = socket.gethostname().strip()
|
||||
return value or "linux-node"
|
||||
|
||||
def load_identity(self) -> dict[str, Any] | None:
|
||||
if not self.identity_path.is_file():
|
||||
return None
|
||||
value = json.loads(self.identity_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ProductError("SEED_IDENTITY_INVALID")
|
||||
return value
|
||||
|
||||
def load_config(self) -> dict[str, Any] | None:
|
||||
if not self.config_path.is_file():
|
||||
return None
|
||||
value = json.loads(self.config_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ProductError("SEED_CONFIG_INVALID")
|
||||
return value
|
||||
|
||||
def init(
|
||||
self,
|
||||
provider: str = "chatgpt",
|
||||
hostname: str = "",
|
||||
) -> dict[str, Any]:
|
||||
provider = str(provider).strip().lower()
|
||||
if provider not in SUPPORTED_PROVIDERS:
|
||||
raise ProductError("SEED_PROVIDER_UNSUPPORTED")
|
||||
|
||||
existing = self.load_identity()
|
||||
host = str(hostname).strip() or self.detect_hostname()
|
||||
|
||||
if existing:
|
||||
node_id = str(existing.get("node_id") or "")
|
||||
if not node_id:
|
||||
raise ProductError("SEED_IDENTITY_NODE_ID_MISSING")
|
||||
created_at = str(existing.get("created_at") or utc_now())
|
||||
else:
|
||||
node_id = str(uuid.uuid4())
|
||||
created_at = utc_now()
|
||||
|
||||
try:
|
||||
crypto_identity = ensure_node_keypair(
|
||||
self.state_dir,
|
||||
comment="framebridge-" + node_id.replace("-", "")[:8],
|
||||
)
|
||||
except NodeIdentityError as exc:
|
||||
raise ProductError(str(exc)) from exc
|
||||
|
||||
identity = {
|
||||
"schema": 2,
|
||||
"node_id": node_id,
|
||||
"node_id_short": node_id.replace("-", "")[:8],
|
||||
"node_host_id": crypto_identity["host_id"],
|
||||
"node_public_jwk": crypto_identity["public_jwk"],
|
||||
"node_public_key_sha256": crypto_identity["public_key_sha256"],
|
||||
"hostname": host,
|
||||
"created_at": created_at,
|
||||
"updated_at": utc_now(),
|
||||
}
|
||||
|
||||
config = {
|
||||
"schema": 1,
|
||||
"seed_version": SEED_VERSION,
|
||||
"provider": provider,
|
||||
"connection_name": f"FrameBridge {host}",
|
||||
"pairing": {
|
||||
"protocol": FBPAIR_PROTOCOL,
|
||||
"state": "not_paired",
|
||||
"transport": "unpaired",
|
||||
},
|
||||
"connection_mode": "PAIRING",
|
||||
"capability_profile": "seed-readonly",
|
||||
"memory": {
|
||||
"required": True,
|
||||
"protocol": "FBM/1",
|
||||
"manifest_protocol": "FBAM/1",
|
||||
"mode": "read_write",
|
||||
"authority": os.environ.get("FRAMEBRIDGE_MEMORY_AUTHORITY", "deployment-defined"),
|
||||
},
|
||||
"hostname": host,
|
||||
"node_id": node_id,
|
||||
"updated_at": utc_now(),
|
||||
}
|
||||
|
||||
atomic_write_json(self.identity_path, identity)
|
||||
atomic_write_json(self.config_path, config)
|
||||
|
||||
self.audit.append(
|
||||
"seed.init",
|
||||
"PASS",
|
||||
request={
|
||||
"provider": provider,
|
||||
"hostname": host,
|
||||
},
|
||||
result={
|
||||
"node_id_short": identity["node_id_short"],
|
||||
"pairing_state": "not_paired",
|
||||
"capability_profile": "seed-readonly",
|
||||
"memory_required": True,
|
||||
},
|
||||
principal="local-operator",
|
||||
)
|
||||
|
||||
from .memory_store import MemoryStore
|
||||
memory = MemoryStore(self).initialize()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"seed_version": SEED_VERSION,
|
||||
"identity": identity,
|
||||
"config": config,
|
||||
"memory": memory,
|
||||
}
|
||||
|
||||
def identity(self) -> dict[str, Any]:
|
||||
identity = self.load_identity()
|
||||
if identity is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "SEED_NOT_INITIALIZED",
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"identity": identity,
|
||||
}
|
||||
|
||||
def doctor(self) -> dict[str, Any]:
|
||||
os_release = read_os_release()
|
||||
route = (
|
||||
run_readonly(["ip", "route", "show", "default"])
|
||||
if shutil.which("ip")
|
||||
else {"ok": False, "error": "IP_COMMAND_MISSING"}
|
||||
)
|
||||
dns_path = Path("/etc/resolv.conf")
|
||||
dns_present = dns_path.exists() and dns_path.stat().st_size > 0
|
||||
ca = ssl.get_default_verify_paths()
|
||||
ca_present = bool(
|
||||
(ca.cafile and Path(ca.cafile).is_file())
|
||||
or (ca.capath and Path(ca.capath).is_dir())
|
||||
)
|
||||
|
||||
checks = {
|
||||
"kernel": platform.release(),
|
||||
"architecture": platform.machine(),
|
||||
"python": platform.python_version(),
|
||||
"hostname": self.detect_hostname(),
|
||||
"os_id": os_release.get("ID"),
|
||||
"os_version": os_release.get("VERSION_ID"),
|
||||
"shell": os.environ.get("SHELL"),
|
||||
"network_default_route": bool(route.get("ok") and route.get("stdout")),
|
||||
"dns_config_present": dns_present,
|
||||
"ca_certificates_present": ca_present,
|
||||
"systemd_present": shutil.which("systemctl") is not None,
|
||||
"ip_command_present": shutil.which("ip") is not None,
|
||||
"curl_present": shutil.which("curl") is not None,
|
||||
"python_venv_available": self._venv_available(),
|
||||
}
|
||||
|
||||
required = [
|
||||
checks["network_default_route"],
|
||||
checks["dns_config_present"],
|
||||
checks["ca_certificates_present"],
|
||||
checks["ip_command_present"],
|
||||
checks["python_venv_available"],
|
||||
]
|
||||
|
||||
return {
|
||||
"ok": all(bool(x) for x in required),
|
||||
"seed_version": SEED_VERSION,
|
||||
"checks": checks,
|
||||
"pairing_ready": all(bool(x) for x in required),
|
||||
"readonly_only": True,
|
||||
}
|
||||
|
||||
def _venv_available(self) -> bool:
|
||||
try:
|
||||
import venv # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
identity = self.load_identity()
|
||||
config = self.load_config()
|
||||
doctor = self.doctor()
|
||||
|
||||
if identity is None or config is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"initialized": False,
|
||||
"seed_version": SEED_VERSION,
|
||||
"doctor": doctor,
|
||||
}
|
||||
|
||||
from .memory_store import MemoryStore
|
||||
memory_required = bool((config.get("memory") or {}).get("required"))
|
||||
try:
|
||||
memory = MemoryStore(self).verify() if memory_required else {"ok": True, "required": False}
|
||||
except Exception as exc:
|
||||
memory = {"ok": False, "required": memory_required, "error": str(exc)}
|
||||
|
||||
return {
|
||||
"ok": bool(memory.get("ok")) if memory_required else True,
|
||||
"initialized": True,
|
||||
"seed_version": SEED_VERSION,
|
||||
"node": {
|
||||
"hostname": identity.get("hostname"),
|
||||
"node_id": identity.get("node_id"),
|
||||
"node_id_short": identity.get("node_id_short"),
|
||||
},
|
||||
"provider": config.get("provider"),
|
||||
"connection_name": config.get("connection_name"),
|
||||
"pairing": config.get("pairing"),
|
||||
"capability_profile": config.get("capability_profile"),
|
||||
"memory": memory,
|
||||
"doctor": doctor,
|
||||
"audit": self.audit.verify(),
|
||||
}
|
||||
|
||||
def fingerprint(self) -> dict[str, Any]:
|
||||
identity = self.load_identity()
|
||||
config = self.load_config()
|
||||
if identity is None or config is None:
|
||||
raise ProductError("SEED_NOT_INITIALIZED")
|
||||
|
||||
body = json.dumps(
|
||||
{
|
||||
"node_id": identity.get("node_id"),
|
||||
"hostname": identity.get("hostname"),
|
||||
"provider": config.get("provider"),
|
||||
"connection_name": config.get("connection_name"),
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"sha256": hashlib.sha256(body).hexdigest(),
|
||||
"node_id_short": identity.get("node_id_short"),
|
||||
"hostname": identity.get("hostname"),
|
||||
"provider": config.get("provider"),
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
import base64,hashlib,hmac,os,struct,time
|
||||
from pathlib import Path
|
||||
|
||||
class TotpError(RuntimeError): pass
|
||||
|
||||
def b32decode_nopad(value:str)->bytes:
|
||||
s=''.join(str(value).strip().upper().split())
|
||||
if not s or any(c not in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' for c in s): raise TotpError('TOTP_SECRET_INVALID')
|
||||
s += '='*((8-len(s)%8)%8)
|
||||
try:return base64.b32decode(s,casefold=True)
|
||||
except Exception: raise TotpError('TOTP_SECRET_INVALID') from None
|
||||
|
||||
def hotp(secret:bytes,counter:int,digits:int=6)->str:
|
||||
digest=hmac.new(secret,struct.pack('>Q',int(counter)),hashlib.sha1).digest()
|
||||
off=digest[-1]&0x0f
|
||||
code=((digest[off]&0x7f)<<24)|((digest[off+1]&0xff)<<16)|((digest[off+2]&0xff)<<8)|(digest[off+3]&0xff)
|
||||
return str(code%(10**digits)).zfill(digits)
|
||||
|
||||
def verify_code(secret:bytes,code:str,when:float|None=None,window:int=1,period:int=30)->int|None:
|
||||
if not isinstance(code,str) or len(code)!=6 or not code.isdigit(): return None
|
||||
now=time.time() if when is None else float(when)
|
||||
step=int(now//period)
|
||||
for s in range(step-window,step+window+1):
|
||||
if hmac.compare_digest(hotp(secret,s),code): return s
|
||||
return None
|
||||
|
||||
def load_secret(path:Path)->bytes:
|
||||
p=Path(path)
|
||||
if not p.is_file() or p.is_symlink(): raise TotpError('TOTP_KEY_NOT_FOUND')
|
||||
raw=p.read_text().strip()
|
||||
secret=b32decode_nopad(raw)
|
||||
if len(secret)<20: raise TotpError('TOTP_SECRET_TOO_SHORT')
|
||||
return secret
|
||||
|
||||
def consume_step(used_dir:Path,key_id:str,step:int,challenge_id:str)->None:
|
||||
d=Path(used_dir);d.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
p=d/(f'{key_id}-{step}.used')
|
||||
try:fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
except FileExistsError: raise TotpError('TOTP_REPLAY') from None
|
||||
with os.fdopen(fd,'w') as f:f.write(str(challenge_id)+'\n')
|
||||
+1
@@ -0,0 +1 @@
|
||||
"""Vendored third-party libraries used by FrameBridge."""
|
||||
+907
@@ -0,0 +1,907 @@
|
||||
#
|
||||
# QR Code generator library (Python)
|
||||
#
|
||||
# Copyright (c) Project Nayuki. (MIT License)
|
||||
# https://www.nayuki.io/page/qr-code-generator-library
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
# this software and associated documentation files (the "Software"), to deal in
|
||||
# the Software without restriction, including without limitation the rights to
|
||||
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
# the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
# subject to the following conditions:
|
||||
# - The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
# - The Software is provided "as is", without warranty of any kind, express or
|
||||
# implied, including but not limited to the warranties of merchantability,
|
||||
# fitness for a particular purpose and noninfringement. In no event shall the
|
||||
# authors or copyright holders be liable for any claim, damages or other
|
||||
# liability, whether in an action of contract, tort or otherwise, arising from,
|
||||
# out of or in connection with the Software or the use or other dealings in the
|
||||
# Software.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
import collections, itertools, re
|
||||
from collections.abc import Sequence
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
# ---- QR Code symbol class ----
|
||||
|
||||
class QrCode:
|
||||
"""A QR Code symbol, which is a type of two-dimension barcode.
|
||||
Invented by Denso Wave and described in the ISO/IEC 18004 standard.
|
||||
Instances of this class represent an immutable square grid of dark and light cells.
|
||||
The class provides static factory functions to create a QR Code from text or binary data.
|
||||
The class covers the QR Code Model 2 specification, supporting all versions (sizes)
|
||||
from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
|
||||
|
||||
Ways to create a QR Code object:
|
||||
- High level: Take the payload data and call QrCode.encode_text() or QrCode.encode_binary().
|
||||
- Mid level: Custom-make the list of segments and call QrCode.encode_segments().
|
||||
- Low level: Custom-make the array of data codeword bytes (including
|
||||
segment headers and final padding, excluding error correction codewords),
|
||||
supply the appropriate version number, and call the QrCode() constructor.
|
||||
(Note that all ways require supplying the desired error correction level.)"""
|
||||
|
||||
# ---- Static factory functions (high level) ----
|
||||
|
||||
@staticmethod
|
||||
def encode_text(text: str, ecl: QrCode.Ecc) -> QrCode:
|
||||
"""Returns a QR Code representing the given Unicode text string at the given error correction level.
|
||||
As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
|
||||
Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
|
||||
QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
|
||||
ecl argument if it can be done without increasing the version."""
|
||||
segs: list[QrSegment] = QrSegment.make_segments(text)
|
||||
return QrCode.encode_segments(segs, ecl)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def encode_binary(data: Union[bytes,Sequence[int]], ecl: QrCode.Ecc) -> QrCode:
|
||||
"""Returns a QR Code representing the given binary data at the given error correction level.
|
||||
This function always encodes using the binary segment mode, not any text mode. The maximum number of
|
||||
bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
|
||||
The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version."""
|
||||
return QrCode.encode_segments([QrSegment.make_bytes(data)], ecl)
|
||||
|
||||
|
||||
# ---- Static factory functions (mid level) ----
|
||||
|
||||
@staticmethod
|
||||
def encode_segments(segs: Sequence[QrSegment], ecl: QrCode.Ecc, minversion: int = 1, maxversion: int = 40, mask: int = -1, boostecl: bool = True) -> QrCode:
|
||||
"""Returns a QR Code representing the given segments with the given encoding parameters.
|
||||
The smallest possible QR Code version within the given range is automatically
|
||||
chosen for the output. Iff boostecl is true, then the ECC level of the result
|
||||
may be higher than the ecl argument if it can be done without increasing the
|
||||
version. The mask number is either between 0 to 7 (inclusive) to force that
|
||||
mask, or -1 to automatically choose an appropriate mask (which may be slow).
|
||||
This function allows the user to create a custom sequence of segments that switches
|
||||
between modes (such as alphanumeric and byte) to encode text in less space.
|
||||
This is a mid-level API; the high-level API is encode_text() and encode_binary()."""
|
||||
|
||||
if not (QrCode.MIN_VERSION <= minversion <= maxversion <= QrCode.MAX_VERSION) or not (-1 <= mask <= 7):
|
||||
raise ValueError("Invalid value")
|
||||
|
||||
# Find the minimal version number to use
|
||||
for version in range(minversion, maxversion + 1):
|
||||
datacapacitybits: int = QrCode._get_num_data_codewords(version, ecl) * 8 # Number of data bits available
|
||||
datausedbits: Optional[int] = QrSegment.get_total_bits(segs, version)
|
||||
if (datausedbits is not None) and (datausedbits <= datacapacitybits):
|
||||
break # This version number is found to be suitable
|
||||
if version >= maxversion: # All versions in the range could not fit the given data
|
||||
msg: str = "Segment too long"
|
||||
if datausedbits is not None:
|
||||
msg = f"Data length = {datausedbits} bits, Max capacity = {datacapacitybits} bits"
|
||||
raise DataTooLongError(msg)
|
||||
assert datausedbits is not None
|
||||
|
||||
# Increase the error correction level while the data still fits in the current version number
|
||||
for newecl in (QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH): # From low to high
|
||||
if boostecl and (datausedbits <= QrCode._get_num_data_codewords(version, newecl) * 8):
|
||||
ecl = newecl
|
||||
|
||||
# Concatenate all segments to create the data bit string
|
||||
bb = _BitBuffer()
|
||||
for seg in segs:
|
||||
bb.append_bits(seg.get_mode().get_mode_bits(), 4)
|
||||
bb.append_bits(seg.get_num_chars(), seg.get_mode().num_char_count_bits(version))
|
||||
bb.extend(seg._bitdata)
|
||||
assert len(bb) == datausedbits
|
||||
|
||||
# Add terminator and pad up to a byte if applicable
|
||||
datacapacitybits = QrCode._get_num_data_codewords(version, ecl) * 8
|
||||
assert len(bb) <= datacapacitybits
|
||||
bb.append_bits(0, min(4, datacapacitybits - len(bb)))
|
||||
bb.append_bits(0, -len(bb) % 8) # Note: Python's modulo on negative numbers behaves better than C family languages
|
||||
assert len(bb) % 8 == 0
|
||||
|
||||
# Pad with alternating bytes until data capacity is reached
|
||||
for padbyte in itertools.cycle((0xEC, 0x11)):
|
||||
if len(bb) >= datacapacitybits:
|
||||
break
|
||||
bb.append_bits(padbyte, 8)
|
||||
|
||||
# Pack bits into bytes in big endian
|
||||
datacodewords = bytearray([0] * (len(bb) // 8))
|
||||
for (i, bit) in enumerate(bb):
|
||||
datacodewords[i >> 3] |= bit << (7 - (i & 7))
|
||||
|
||||
# Create the QR Code object
|
||||
return QrCode(version, ecl, datacodewords, mask)
|
||||
|
||||
|
||||
# ---- Private fields ----
|
||||
|
||||
# The version number of this QR Code, which is between 1 and 40 (inclusive).
|
||||
# This determines the size of this barcode.
|
||||
_version: int
|
||||
|
||||
# The width and height of this QR Code, measured in modules, between
|
||||
# 21 and 177 (inclusive). This is equal to version * 4 + 17.
|
||||
_size: int
|
||||
|
||||
# The error correction level used in this QR Code.
|
||||
_errcorlvl: QrCode.Ecc
|
||||
|
||||
# The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive).
|
||||
# Even if a QR Code is created with automatic masking requested (mask = -1),
|
||||
# the resulting object still has a mask value between 0 and 7.
|
||||
_mask: int
|
||||
|
||||
# The modules of this QR Code (False = light, True = dark).
|
||||
# Immutable after constructor finishes. Accessed through get_module().
|
||||
_modules: list[list[bool]]
|
||||
|
||||
# Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
|
||||
_isfunction: list[list[bool]]
|
||||
|
||||
|
||||
# ---- Constructor (low level) ----
|
||||
|
||||
def __init__(self, version: int, errcorlvl: QrCode.Ecc, datacodewords: Union[bytes,Sequence[int]], msk: int) -> None:
|
||||
"""Creates a new QR Code with the given version number,
|
||||
error correction level, data codeword bytes, and mask number.
|
||||
This is a low-level API that most users should not use directly.
|
||||
A mid-level API is the encode_segments() function."""
|
||||
|
||||
# Check scalar arguments and set fields
|
||||
if not (QrCode.MIN_VERSION <= version <= QrCode.MAX_VERSION):
|
||||
raise ValueError("Version value out of range")
|
||||
if not (-1 <= msk <= 7):
|
||||
raise ValueError("Mask value out of range")
|
||||
|
||||
self._version = version
|
||||
self._size = version * 4 + 17
|
||||
self._errcorlvl = errcorlvl
|
||||
|
||||
# Initialize both grids to be size*size arrays of Boolean false
|
||||
self._modules = [[False] * self._size for _ in range(self._size)] # Initially all light
|
||||
self._isfunction = [[False] * self._size for _ in range(self._size)]
|
||||
|
||||
# Compute ECC, draw modules
|
||||
self._draw_function_patterns()
|
||||
allcodewords: bytes = self._add_ecc_and_interleave(bytearray(datacodewords))
|
||||
self._draw_codewords(allcodewords)
|
||||
|
||||
# Do masking
|
||||
if msk == -1: # Automatically choose best mask
|
||||
minpenalty: int = 1 << 32
|
||||
for i in range(8):
|
||||
self._apply_mask(i)
|
||||
self._draw_format_bits(i)
|
||||
penalty = self._get_penalty_score()
|
||||
if penalty < minpenalty:
|
||||
msk = i
|
||||
minpenalty = penalty
|
||||
self._apply_mask(i) # Undoes the mask due to XOR
|
||||
assert 0 <= msk <= 7
|
||||
self._mask = msk
|
||||
self._apply_mask(msk) # Apply the final choice of mask
|
||||
self._draw_format_bits(msk) # Overwrite old format bits
|
||||
|
||||
del self._isfunction
|
||||
|
||||
|
||||
# ---- Accessor methods ----
|
||||
|
||||
def get_version(self) -> int:
|
||||
"""Returns this QR Code's version number, in the range [1, 40]."""
|
||||
return self._version
|
||||
|
||||
def get_size(self) -> int:
|
||||
"""Returns this QR Code's size, in the range [21, 177]."""
|
||||
return self._size
|
||||
|
||||
def get_error_correction_level(self) -> QrCode.Ecc:
|
||||
"""Returns this QR Code's error correction level."""
|
||||
return self._errcorlvl
|
||||
|
||||
def get_mask(self) -> int:
|
||||
"""Returns this QR Code's mask, in the range [0, 7]."""
|
||||
return self._mask
|
||||
|
||||
def get_module(self, x: int, y: int) -> bool:
|
||||
"""Returns the color of the module (pixel) at the given coordinates, which is False
|
||||
for light or True for dark. The top left corner has the coordinates (x=0, y=0).
|
||||
If the given coordinates are out of bounds, then False (light) is returned."""
|
||||
return (0 <= x < self._size) and (0 <= y < self._size) and self._modules[y][x]
|
||||
|
||||
|
||||
# ---- Private helper methods for constructor: Drawing function modules ----
|
||||
|
||||
def _draw_function_patterns(self) -> None:
|
||||
"""Reads this object's version field, and draws and marks all function modules."""
|
||||
# Draw horizontal and vertical timing patterns
|
||||
for i in range(self._size):
|
||||
self._set_function_module(6, i, i % 2 == 0)
|
||||
self._set_function_module(i, 6, i % 2 == 0)
|
||||
|
||||
# Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
|
||||
self._draw_finder_pattern(3, 3)
|
||||
self._draw_finder_pattern(self._size - 4, 3)
|
||||
self._draw_finder_pattern(3, self._size - 4)
|
||||
|
||||
# Draw numerous alignment patterns
|
||||
alignpatpos: list[int] = self._get_alignment_pattern_positions()
|
||||
numalign: int = len(alignpatpos)
|
||||
skips: Sequence[tuple[int,int]] = ((0, 0), (0, numalign - 1), (numalign - 1, 0))
|
||||
for i in range(numalign):
|
||||
for j in range(numalign):
|
||||
if (i, j) not in skips: # Don't draw on the three finder corners
|
||||
self._draw_alignment_pattern(alignpatpos[i], alignpatpos[j])
|
||||
|
||||
# Draw configuration data
|
||||
self._draw_format_bits(0) # Dummy mask value; overwritten later in the constructor
|
||||
self._draw_version()
|
||||
|
||||
|
||||
def _draw_format_bits(self, mask: int) -> None:
|
||||
"""Draws two copies of the format bits (with its own error correction code)
|
||||
based on the given mask and this object's error correction level field."""
|
||||
# Calculate error correction code and pack bits
|
||||
data: int = self._errcorlvl.formatbits << 3 | mask # errCorrLvl is uint2, mask is uint3
|
||||
rem: int = data
|
||||
for _ in range(10):
|
||||
rem = (rem << 1) ^ ((rem >> 9) * 0x537)
|
||||
bits: int = (data << 10 | rem) ^ 0x5412 # uint15
|
||||
assert bits >> 15 == 0
|
||||
|
||||
# Draw first copy
|
||||
for i in range(0, 6):
|
||||
self._set_function_module(8, i, _get_bit(bits, i))
|
||||
self._set_function_module(8, 7, _get_bit(bits, 6))
|
||||
self._set_function_module(8, 8, _get_bit(bits, 7))
|
||||
self._set_function_module(7, 8, _get_bit(bits, 8))
|
||||
for i in range(9, 15):
|
||||
self._set_function_module(14 - i, 8, _get_bit(bits, i))
|
||||
|
||||
# Draw second copy
|
||||
for i in range(0, 8):
|
||||
self._set_function_module(self._size - 1 - i, 8, _get_bit(bits, i))
|
||||
for i in range(8, 15):
|
||||
self._set_function_module(8, self._size - 15 + i, _get_bit(bits, i))
|
||||
self._set_function_module(8, self._size - 8, True) # Always dark
|
||||
|
||||
|
||||
def _draw_version(self) -> None:
|
||||
"""Draws two copies of the version bits (with its own error correction code),
|
||||
based on this object's version field, iff 7 <= version <= 40."""
|
||||
if self._version < 7:
|
||||
return
|
||||
|
||||
# Calculate error correction code and pack bits
|
||||
rem: int = self._version # version is uint6, in the range [7, 40]
|
||||
for _ in range(12):
|
||||
rem = (rem << 1) ^ ((rem >> 11) * 0x1F25)
|
||||
bits: int = self._version << 12 | rem # uint18
|
||||
assert bits >> 18 == 0
|
||||
|
||||
# Draw two copies
|
||||
for i in range(18):
|
||||
bit: bool = _get_bit(bits, i)
|
||||
a: int = self._size - 11 + i % 3
|
||||
b: int = i // 3
|
||||
self._set_function_module(a, b, bit)
|
||||
self._set_function_module(b, a, bit)
|
||||
|
||||
|
||||
def _draw_finder_pattern(self, x: int, y: int) -> None:
|
||||
"""Draws a 9*9 finder pattern including the border separator,
|
||||
with the center module at (x, y). Modules can be out of bounds."""
|
||||
for dy in range(-4, 5):
|
||||
for dx in range(-4, 5):
|
||||
xx, yy = x + dx, y + dy
|
||||
if (0 <= xx < self._size) and (0 <= yy < self._size):
|
||||
# Chebyshev/infinity norm
|
||||
self._set_function_module(xx, yy, max(abs(dx), abs(dy)) not in (2, 4))
|
||||
|
||||
|
||||
def _draw_alignment_pattern(self, x: int, y: int) -> None:
|
||||
"""Draws a 5*5 alignment pattern, with the center module
|
||||
at (x, y). All modules must be in bounds."""
|
||||
for dy in range(-2, 3):
|
||||
for dx in range(-2, 3):
|
||||
self._set_function_module(x + dx, y + dy, max(abs(dx), abs(dy)) != 1)
|
||||
|
||||
|
||||
def _set_function_module(self, x: int, y: int, isdark: bool) -> None:
|
||||
"""Sets the color of a module and marks it as a function module.
|
||||
Only used by the constructor. Coordinates must be in bounds."""
|
||||
assert type(isdark) is bool
|
||||
self._modules[y][x] = isdark
|
||||
self._isfunction[y][x] = True
|
||||
|
||||
|
||||
# ---- Private helper methods for constructor: Codewords and masking ----
|
||||
|
||||
def _add_ecc_and_interleave(self, data: bytearray) -> bytes:
|
||||
"""Returns a new byte string representing the given data with the appropriate error correction
|
||||
codewords appended to it, based on this object's version and error correction level."""
|
||||
version: int = self._version
|
||||
assert len(data) == QrCode._get_num_data_codewords(version, self._errcorlvl)
|
||||
|
||||
# Calculate parameter numbers
|
||||
numblocks: int = QrCode._NUM_ERROR_CORRECTION_BLOCKS[self._errcorlvl.ordinal][version]
|
||||
blockecclen: int = QrCode._ECC_CODEWORDS_PER_BLOCK [self._errcorlvl.ordinal][version]
|
||||
rawcodewords: int = QrCode._get_num_raw_data_modules(version) // 8
|
||||
numshortblocks: int = numblocks - rawcodewords % numblocks
|
||||
shortblocklen: int = rawcodewords // numblocks
|
||||
|
||||
# Split data into blocks and append ECC to each block
|
||||
blocks: list[bytes] = []
|
||||
rsdiv: bytes = QrCode._reed_solomon_compute_divisor(blockecclen)
|
||||
k: int = 0
|
||||
for i in range(numblocks):
|
||||
dat: bytearray = data[k : k + shortblocklen - blockecclen + (0 if i < numshortblocks else 1)]
|
||||
k += len(dat)
|
||||
ecc: bytes = QrCode._reed_solomon_compute_remainder(dat, rsdiv)
|
||||
if i < numshortblocks:
|
||||
dat.append(0)
|
||||
blocks.append(dat + ecc)
|
||||
assert k == len(data)
|
||||
|
||||
# Interleave (not concatenate) the bytes from every block into a single sequence
|
||||
result = bytearray()
|
||||
for i in range(len(blocks[0])):
|
||||
for (j, blk) in enumerate(blocks):
|
||||
# Skip the padding byte in short blocks
|
||||
if (i != shortblocklen - blockecclen) or (j >= numshortblocks):
|
||||
result.append(blk[i])
|
||||
assert len(result) == rawcodewords
|
||||
return result
|
||||
|
||||
|
||||
def _draw_codewords(self, data: bytes) -> None:
|
||||
"""Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
|
||||
data area of this QR Code. Function modules need to be marked off before this is called."""
|
||||
assert len(data) == QrCode._get_num_raw_data_modules(self._version) // 8
|
||||
|
||||
i: int = 0 # Bit index into the data
|
||||
# Do the funny zigzag scan
|
||||
for right in range(self._size - 1, 0, -2): # Index of right column in each column pair
|
||||
if right <= 6:
|
||||
right -= 1
|
||||
for vert in range(self._size): # Vertical counter
|
||||
for j in range(2):
|
||||
x: int = right - j # Actual x coordinate
|
||||
upward: bool = (right + 1) & 2 == 0
|
||||
y: int = (self._size - 1 - vert) if upward else vert # Actual y coordinate
|
||||
if (not self._isfunction[y][x]) and (i < len(data) * 8):
|
||||
self._modules[y][x] = _get_bit(data[i >> 3], 7 - (i & 7))
|
||||
i += 1
|
||||
# If this QR Code has any remainder bits (0 to 7), they were assigned as
|
||||
# 0/false/light by the constructor and are left unchanged by this method
|
||||
assert i == len(data) * 8
|
||||
|
||||
|
||||
def _apply_mask(self, mask: int) -> None:
|
||||
"""XORs the codeword modules in this QR Code with the given mask pattern.
|
||||
The function modules must be marked and the codeword bits must be drawn
|
||||
before masking. Due to the arithmetic of XOR, calling _apply_mask() with
|
||||
the same mask value a second time will undo the mask. A final well-formed
|
||||
QR Code needs exactly one (not zero, two, etc.) mask applied."""
|
||||
if not (0 <= mask <= 7):
|
||||
raise ValueError("Mask value out of range")
|
||||
masker: collections.abc.Callable[[int,int],int] = QrCode._MASK_PATTERNS[mask]
|
||||
for y in range(self._size):
|
||||
for x in range(self._size):
|
||||
self._modules[y][x] ^= (masker(x, y) == 0) and (not self._isfunction[y][x])
|
||||
|
||||
|
||||
def _get_penalty_score(self) -> int:
|
||||
"""Calculates and returns the penalty score based on state of this QR Code's current modules.
|
||||
This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score."""
|
||||
result: int = 0
|
||||
size: int = self._size
|
||||
modules: list[list[bool]] = self._modules
|
||||
|
||||
# Adjacent modules in row having same color, and finder-like patterns
|
||||
for y in range(size):
|
||||
runcolor: bool = False
|
||||
runx: int = 0
|
||||
runhistory = collections.deque([0] * 7, 7)
|
||||
for x in range(size):
|
||||
if modules[y][x] == runcolor:
|
||||
runx += 1
|
||||
if runx == 5:
|
||||
result += QrCode._PENALTY_N1
|
||||
elif runx > 5:
|
||||
result += 1
|
||||
else:
|
||||
self._finder_penalty_add_history(runx, runhistory)
|
||||
if not runcolor:
|
||||
result += self._finder_penalty_count_patterns(runhistory) * QrCode._PENALTY_N3
|
||||
runcolor = modules[y][x]
|
||||
runx = 1
|
||||
result += self._finder_penalty_terminate_and_count(runcolor, runx, runhistory) * QrCode._PENALTY_N3
|
||||
# Adjacent modules in column having same color, and finder-like patterns
|
||||
for x in range(size):
|
||||
runcolor = False
|
||||
runy: int = 0
|
||||
runhistory = collections.deque([0] * 7, 7)
|
||||
for y in range(size):
|
||||
if modules[y][x] == runcolor:
|
||||
runy += 1
|
||||
if runy == 5:
|
||||
result += QrCode._PENALTY_N1
|
||||
elif runy > 5:
|
||||
result += 1
|
||||
else:
|
||||
self._finder_penalty_add_history(runy, runhistory)
|
||||
if not runcolor:
|
||||
result += self._finder_penalty_count_patterns(runhistory) * QrCode._PENALTY_N3
|
||||
runcolor = modules[y][x]
|
||||
runy = 1
|
||||
result += self._finder_penalty_terminate_and_count(runcolor, runy, runhistory) * QrCode._PENALTY_N3
|
||||
|
||||
# 2*2 blocks of modules having same color
|
||||
for y in range(size - 1):
|
||||
for x in range(size - 1):
|
||||
if modules[y][x] == modules[y][x + 1] == modules[y + 1][x] == modules[y + 1][x + 1]:
|
||||
result += QrCode._PENALTY_N2
|
||||
|
||||
# Balance of dark and light modules
|
||||
dark: int = sum((1 if cell else 0) for row in modules for cell in row)
|
||||
total: int = size**2 # Note that size is odd, so dark/total != 1/2
|
||||
# Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
|
||||
k: int = (abs(dark * 20 - total * 10) + total - 1) // total - 1
|
||||
assert 0 <= k <= 9
|
||||
result += k * QrCode._PENALTY_N4
|
||||
assert 0 <= result <= 2568888 # Non-tight upper bound based on default values of PENALTY_N1, ..., N4
|
||||
return result
|
||||
|
||||
|
||||
# ---- Private helper functions ----
|
||||
|
||||
def _get_alignment_pattern_positions(self) -> list[int]:
|
||||
"""Returns an ascending list of positions of alignment patterns for this version number.
|
||||
Each position is in the range [0,177), and are used on both the x and y axes.
|
||||
This could be implemented as lookup table of 40 variable-length lists of integers."""
|
||||
if self._version == 1:
|
||||
return []
|
||||
else:
|
||||
numalign: int = self._version // 7 + 2
|
||||
step: int = (self._version * 8 + numalign * 3 + 5) // (numalign * 4 - 4) * 2
|
||||
result: list[int] = [(self._size - 7 - i * step) for i in range(numalign - 1)] + [6]
|
||||
return list(reversed(result))
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _get_num_raw_data_modules(ver: int) -> int:
|
||||
"""Returns the number of data bits that can be stored in a QR Code of the given version number, after
|
||||
all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
|
||||
The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table."""
|
||||
if not (QrCode.MIN_VERSION <= ver <= QrCode.MAX_VERSION):
|
||||
raise ValueError("Version number out of range")
|
||||
result: int = (16 * ver + 128) * ver + 64
|
||||
if ver >= 2:
|
||||
numalign: int = ver // 7 + 2
|
||||
result -= (25 * numalign - 10) * numalign - 55
|
||||
if ver >= 7:
|
||||
result -= 36
|
||||
assert 208 <= result <= 29648
|
||||
return result
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _get_num_data_codewords(ver: int, ecl: QrCode.Ecc) -> int:
|
||||
"""Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
|
||||
QR Code of the given version number and error correction level, with remainder bits discarded.
|
||||
This stateless pure function could be implemented as a (40*4)-cell lookup table."""
|
||||
return QrCode._get_num_raw_data_modules(ver) // 8 \
|
||||
- QrCode._ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver] \
|
||||
* QrCode._NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _reed_solomon_compute_divisor(degree: int) -> bytes:
|
||||
"""Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
|
||||
implemented as a lookup table over all possible parameter values, instead of as an algorithm."""
|
||||
if not (1 <= degree <= 255):
|
||||
raise ValueError("Degree out of range")
|
||||
# Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
|
||||
# For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
|
||||
result = bytearray([0] * (degree - 1) + [1]) # Start off with the monomial x^0
|
||||
|
||||
# Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
|
||||
# and drop the highest monomial term which is always 1x^degree.
|
||||
# Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
|
||||
root: int = 1
|
||||
for _ in range(degree): # Unused variable i
|
||||
# Multiply the current product by (x - r^i)
|
||||
for j in range(degree):
|
||||
result[j] = QrCode._reed_solomon_multiply(result[j], root)
|
||||
if j + 1 < degree:
|
||||
result[j] ^= result[j + 1]
|
||||
root = QrCode._reed_solomon_multiply(root, 0x02)
|
||||
return result
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _reed_solomon_compute_remainder(data: bytes, divisor: bytes) -> bytes:
|
||||
"""Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials."""
|
||||
result = bytearray([0] * len(divisor))
|
||||
for b in data: # Polynomial division
|
||||
factor: int = b ^ result.pop(0)
|
||||
result.append(0)
|
||||
for (i, coef) in enumerate(divisor):
|
||||
result[i] ^= QrCode._reed_solomon_multiply(coef, factor)
|
||||
return result
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _reed_solomon_multiply(x: int, y: int) -> int:
|
||||
"""Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result
|
||||
are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8."""
|
||||
if (x >> 8 != 0) or (y >> 8 != 0):
|
||||
raise ValueError("Byte out of range")
|
||||
# Russian peasant multiplication
|
||||
z: int = 0
|
||||
for i in reversed(range(8)):
|
||||
z = (z << 1) ^ ((z >> 7) * 0x11D)
|
||||
z ^= ((y >> i) & 1) * x
|
||||
assert z >> 8 == 0
|
||||
return z
|
||||
|
||||
|
||||
def _finder_penalty_count_patterns(self, runhistory: collections.deque[int]) -> int:
|
||||
"""Can only be called immediately after a light run is added, and
|
||||
returns either 0, 1, or 2. A helper function for _get_penalty_score()."""
|
||||
n: int = runhistory[1]
|
||||
assert n <= self._size * 3
|
||||
core: bool = n > 0 and (runhistory[2] == runhistory[4] == runhistory[5] == n) and runhistory[3] == n * 3
|
||||
return (1 if (core and runhistory[0] >= n * 4 and runhistory[6] >= n) else 0) \
|
||||
+ (1 if (core and runhistory[6] >= n * 4 and runhistory[0] >= n) else 0)
|
||||
|
||||
|
||||
def _finder_penalty_terminate_and_count(self, currentruncolor: bool, currentrunlength: int, runhistory: collections.deque[int]) -> int:
|
||||
"""Must be called at the end of a line (row or column) of modules. A helper function for _get_penalty_score()."""
|
||||
if currentruncolor: # Terminate dark run
|
||||
self._finder_penalty_add_history(currentrunlength, runhistory)
|
||||
currentrunlength = 0
|
||||
currentrunlength += self._size # Add light border to final run
|
||||
self._finder_penalty_add_history(currentrunlength, runhistory)
|
||||
return self._finder_penalty_count_patterns(runhistory)
|
||||
|
||||
|
||||
def _finder_penalty_add_history(self, currentrunlength: int, runhistory: collections.deque[int]) -> None:
|
||||
if runhistory[0] == 0:
|
||||
currentrunlength += self._size # Add light border to initial run
|
||||
runhistory.appendleft(currentrunlength)
|
||||
|
||||
|
||||
# ---- Constants and tables ----
|
||||
|
||||
MIN_VERSION: int = 1 # The minimum version number supported in the QR Code Model 2 standard
|
||||
MAX_VERSION: int = 40 # The maximum version number supported in the QR Code Model 2 standard
|
||||
|
||||
# For use in _get_penalty_score(), when evaluating which mask is best.
|
||||
_PENALTY_N1: int = 3
|
||||
_PENALTY_N2: int = 3
|
||||
_PENALTY_N3: int = 40
|
||||
_PENALTY_N4: int = 10
|
||||
|
||||
_ECC_CODEWORDS_PER_BLOCK: Sequence[Sequence[int]] = (
|
||||
# Version: (note that index 0 is for padding, and is set to an illegal value)
|
||||
# 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
|
||||
(-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30), # Low
|
||||
(-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28), # Medium
|
||||
(-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30), # Quartile
|
||||
(-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30)) # High
|
||||
|
||||
_NUM_ERROR_CORRECTION_BLOCKS: Sequence[Sequence[int]] = (
|
||||
# Version: (note that index 0 is for padding, and is set to an illegal value)
|
||||
# 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
|
||||
(-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25), # Low
|
||||
(-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49), # Medium
|
||||
(-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68), # Quartile
|
||||
(-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81)) # High
|
||||
|
||||
_MASK_PATTERNS: Sequence[collections.abc.Callable[[int,int],int]] = (
|
||||
(lambda x, y: (x + y) % 2 ),
|
||||
(lambda x, y: y % 2 ),
|
||||
(lambda x, y: x % 3 ),
|
||||
(lambda x, y: (x + y) % 3 ),
|
||||
(lambda x, y: (x // 3 + y // 2) % 2 ),
|
||||
(lambda x, y: x * y % 2 + x * y % 3 ),
|
||||
(lambda x, y: (x * y % 2 + x * y % 3) % 2 ),
|
||||
(lambda x, y: ((x + y) % 2 + x * y % 3) % 2),
|
||||
)
|
||||
|
||||
|
||||
# ---- Public helper enumeration ----
|
||||
|
||||
class Ecc:
|
||||
ordinal: int # (Public) In the range 0 to 3 (unsigned 2-bit integer)
|
||||
formatbits: int # (Package-private) In the range 0 to 3 (unsigned 2-bit integer)
|
||||
|
||||
"""The error correction level in a QR Code symbol. Immutable."""
|
||||
# Private constructor
|
||||
def __init__(self, i: int, fb: int) -> None:
|
||||
self.ordinal = i
|
||||
self.formatbits = fb
|
||||
|
||||
# Placeholders
|
||||
LOW : QrCode.Ecc
|
||||
MEDIUM : QrCode.Ecc
|
||||
QUARTILE: QrCode.Ecc
|
||||
HIGH : QrCode.Ecc
|
||||
|
||||
# Public constants. Create them outside the class.
|
||||
Ecc.LOW = Ecc(0, 1) # The QR Code can tolerate about 7% erroneous codewords
|
||||
Ecc.MEDIUM = Ecc(1, 0) # The QR Code can tolerate about 15% erroneous codewords
|
||||
Ecc.QUARTILE = Ecc(2, 3) # The QR Code can tolerate about 25% erroneous codewords
|
||||
Ecc.HIGH = Ecc(3, 2) # The QR Code can tolerate about 30% erroneous codewords
|
||||
|
||||
|
||||
|
||||
# ---- Data segment class ----
|
||||
|
||||
class QrSegment:
|
||||
"""A segment of character/binary/control data in a QR Code symbol.
|
||||
Instances of this class are immutable.
|
||||
The mid-level way to create a segment is to take the payload data
|
||||
and call a static factory function such as QrSegment.make_numeric().
|
||||
The low-level way to create a segment is to custom-make the bit buffer
|
||||
and call the QrSegment() constructor with appropriate values.
|
||||
This segment class imposes no length restrictions, but QR Codes have restrictions.
|
||||
Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
|
||||
Any segment longer than this is meaningless for the purpose of generating QR Codes."""
|
||||
|
||||
# ---- Static factory functions (mid level) ----
|
||||
|
||||
@staticmethod
|
||||
def make_bytes(data: Union[bytes,Sequence[int]]) -> QrSegment:
|
||||
"""Returns a segment representing the given binary data encoded in byte mode.
|
||||
All input byte lists are acceptable. Any text string can be converted to
|
||||
UTF-8 bytes (s.encode("UTF-8")) and encoded as a byte mode segment."""
|
||||
bb = _BitBuffer()
|
||||
for b in data:
|
||||
bb.append_bits(b, 8)
|
||||
return QrSegment(QrSegment.Mode.BYTE, len(data), bb)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def make_numeric(digits: str) -> QrSegment:
|
||||
"""Returns a segment representing the given string of decimal digits encoded in numeric mode."""
|
||||
if not QrSegment.is_numeric(digits):
|
||||
raise ValueError("String contains non-numeric characters")
|
||||
bb = _BitBuffer()
|
||||
i: int = 0
|
||||
while i < len(digits): # Consume up to 3 digits per iteration
|
||||
n: int = min(len(digits) - i, 3)
|
||||
bb.append_bits(int(digits[i : i + n]), n * 3 + 1)
|
||||
i += n
|
||||
return QrSegment(QrSegment.Mode.NUMERIC, len(digits), bb)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def make_alphanumeric(text: str) -> QrSegment:
|
||||
"""Returns a segment representing the given text string encoded in alphanumeric mode.
|
||||
The characters allowed are: 0 to 9, A to Z (uppercase only), space,
|
||||
dollar, percent, asterisk, plus, hyphen, period, slash, colon."""
|
||||
if not QrSegment.is_alphanumeric(text):
|
||||
raise ValueError("String contains unencodable characters in alphanumeric mode")
|
||||
bb = _BitBuffer()
|
||||
for i in range(0, len(text) - 1, 2): # Process groups of 2
|
||||
temp: int = QrSegment._ALPHANUMERIC_ENCODING_TABLE[text[i]] * 45
|
||||
temp += QrSegment._ALPHANUMERIC_ENCODING_TABLE[text[i + 1]]
|
||||
bb.append_bits(temp, 11)
|
||||
if len(text) % 2 > 0: # 1 character remaining
|
||||
bb.append_bits(QrSegment._ALPHANUMERIC_ENCODING_TABLE[text[-1]], 6)
|
||||
return QrSegment(QrSegment.Mode.ALPHANUMERIC, len(text), bb)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def make_segments(text: str) -> list[QrSegment]:
|
||||
"""Returns a new mutable list of zero or more segments to represent the given Unicode text string.
|
||||
The result may use various segment modes and switch modes to optimize the length of the bit stream."""
|
||||
|
||||
# Select the most efficient segment encoding automatically
|
||||
if text == "":
|
||||
return []
|
||||
elif QrSegment.is_numeric(text):
|
||||
return [QrSegment.make_numeric(text)]
|
||||
elif QrSegment.is_alphanumeric(text):
|
||||
return [QrSegment.make_alphanumeric(text)]
|
||||
else:
|
||||
return [QrSegment.make_bytes(text.encode("UTF-8"))]
|
||||
|
||||
|
||||
@staticmethod
|
||||
def make_eci(assignval: int) -> QrSegment:
|
||||
"""Returns a segment representing an Extended Channel Interpretation
|
||||
(ECI) designator with the given assignment value."""
|
||||
bb = _BitBuffer()
|
||||
if assignval < 0:
|
||||
raise ValueError("ECI assignment value out of range")
|
||||
elif assignval < (1 << 7):
|
||||
bb.append_bits(assignval, 8)
|
||||
elif assignval < (1 << 14):
|
||||
bb.append_bits(0b10, 2)
|
||||
bb.append_bits(assignval, 14)
|
||||
elif assignval < 1000000:
|
||||
bb.append_bits(0b110, 3)
|
||||
bb.append_bits(assignval, 21)
|
||||
else:
|
||||
raise ValueError("ECI assignment value out of range")
|
||||
return QrSegment(QrSegment.Mode.ECI, 0, bb)
|
||||
|
||||
|
||||
# Tests whether the given string can be encoded as a segment in numeric mode.
|
||||
# A string is encodable iff each character is in the range 0 to 9.
|
||||
@staticmethod
|
||||
def is_numeric(text: str) -> bool:
|
||||
return QrSegment._NUMERIC_REGEX.fullmatch(text) is not None
|
||||
|
||||
|
||||
# Tests whether the given string can be encoded as a segment in alphanumeric mode.
|
||||
# A string is encodable iff each character is in the following set: 0 to 9, A to Z
|
||||
# (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
|
||||
@staticmethod
|
||||
def is_alphanumeric(text: str) -> bool:
|
||||
return QrSegment._ALPHANUMERIC_REGEX.fullmatch(text) is not None
|
||||
|
||||
|
||||
# ---- Private fields ----
|
||||
|
||||
# The mode indicator of this segment. Accessed through get_mode().
|
||||
_mode: QrSegment.Mode
|
||||
|
||||
# The length of this segment's unencoded data. Measured in characters for
|
||||
# numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
|
||||
# Always zero or positive. Not the same as the data's bit length.
|
||||
# Accessed through get_num_chars().
|
||||
_numchars: int
|
||||
|
||||
# The data bits of this segment. Accessed through get_data().
|
||||
_bitdata: list[int]
|
||||
|
||||
|
||||
# ---- Constructor (low level) ----
|
||||
|
||||
def __init__(self, mode: QrSegment.Mode, numch: int, bitdata: Sequence[int]) -> None:
|
||||
"""Creates a new QR Code segment with the given attributes and data.
|
||||
The character count (numch) must agree with the mode and the bit buffer length,
|
||||
but the constraint isn't checked. The given bit buffer is cloned and stored."""
|
||||
if numch < 0:
|
||||
raise ValueError()
|
||||
self._mode = mode
|
||||
self._numchars = numch
|
||||
self._bitdata = list(bitdata) # Make defensive copy
|
||||
|
||||
|
||||
# ---- Accessor methods ----
|
||||
|
||||
def get_mode(self) -> QrSegment.Mode:
|
||||
"""Returns the mode field of this segment."""
|
||||
return self._mode
|
||||
|
||||
def get_num_chars(self) -> int:
|
||||
"""Returns the character count field of this segment."""
|
||||
return self._numchars
|
||||
|
||||
def get_data(self) -> list[int]:
|
||||
"""Returns a new copy of the data bits of this segment."""
|
||||
return list(self._bitdata) # Make defensive copy
|
||||
|
||||
|
||||
# Package-private function
|
||||
@staticmethod
|
||||
def get_total_bits(segs: Sequence[QrSegment], version: int) -> Optional[int]:
|
||||
"""Calculates the number of bits needed to encode the given segments at
|
||||
the given version. Returns a non-negative number if successful. Otherwise
|
||||
returns None if a segment has too many characters to fit its length field."""
|
||||
result = 0
|
||||
for seg in segs:
|
||||
ccbits: int = seg.get_mode().num_char_count_bits(version)
|
||||
if seg.get_num_chars() >= (1 << ccbits):
|
||||
return None # The segment's length doesn't fit the field's bit width
|
||||
result += 4 + ccbits + len(seg._bitdata)
|
||||
return result
|
||||
|
||||
|
||||
# ---- Constants ----
|
||||
|
||||
# Describes precisely all strings that are encodable in numeric mode.
|
||||
_NUMERIC_REGEX: re.Pattern[str] = re.compile(r"[0-9]*")
|
||||
|
||||
# Describes precisely all strings that are encodable in alphanumeric mode.
|
||||
_ALPHANUMERIC_REGEX: re.Pattern[str] = re.compile(r"[A-Z0-9 $%*+./:-]*")
|
||||
|
||||
# Dictionary of "0"->0, "A"->10, "$"->37, etc.
|
||||
_ALPHANUMERIC_ENCODING_TABLE: dict[str,int] = {ch: i for (i, ch) in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:")}
|
||||
|
||||
|
||||
# ---- Public helper enumeration ----
|
||||
|
||||
class Mode:
|
||||
"""Describes how a segment's data bits are interpreted. Immutable."""
|
||||
|
||||
_modebits: int # The mode indicator bits, which is a uint4 value (range 0 to 15)
|
||||
_charcounts: tuple[int,int,int] # Number of character count bits for three different version ranges
|
||||
|
||||
# Private constructor
|
||||
def __init__(self, modebits: int, charcounts: tuple[int,int,int]):
|
||||
self._modebits = modebits
|
||||
self._charcounts = charcounts
|
||||
|
||||
# Package-private method
|
||||
def get_mode_bits(self) -> int:
|
||||
"""Returns an unsigned 4-bit integer value (range 0 to 15) representing the mode indicator bits for this mode object."""
|
||||
return self._modebits
|
||||
|
||||
# Package-private method
|
||||
def num_char_count_bits(self, ver: int) -> int:
|
||||
"""Returns the bit width of the character count field for a segment in this mode
|
||||
in a QR Code at the given version number. The result is in the range [0, 16]."""
|
||||
return self._charcounts[(ver + 7) // 17]
|
||||
|
||||
# Placeholders
|
||||
NUMERIC : QrSegment.Mode
|
||||
ALPHANUMERIC: QrSegment.Mode
|
||||
BYTE : QrSegment.Mode
|
||||
KANJI : QrSegment.Mode
|
||||
ECI : QrSegment.Mode
|
||||
|
||||
# Public constants. Create them outside the class.
|
||||
Mode.NUMERIC = Mode(0x1, (10, 12, 14))
|
||||
Mode.ALPHANUMERIC = Mode(0x2, ( 9, 11, 13))
|
||||
Mode.BYTE = Mode(0x4, ( 8, 16, 16))
|
||||
Mode.KANJI = Mode(0x8, ( 8, 10, 12))
|
||||
Mode.ECI = Mode(0x7, ( 0, 0, 0))
|
||||
|
||||
|
||||
|
||||
# ---- Private helper class ----
|
||||
|
||||
class _BitBuffer(list[int]):
|
||||
"""An appendable sequence of bits (0s and 1s). Mainly used by QrSegment."""
|
||||
|
||||
def append_bits(self, val: int, n: int) -> None:
|
||||
"""Appends the given number of low-order bits of the given
|
||||
value to this buffer. Requires n >= 0 and 0 <= val < 2^n."""
|
||||
if (n < 0) or (val >> n != 0):
|
||||
raise ValueError("Value out of range")
|
||||
self.extend(((val >> i) & 1) for i in reversed(range(n)))
|
||||
|
||||
|
||||
def _get_bit(x: int, i: int) -> bool:
|
||||
"""Returns true iff the i'th bit of x is set to 1."""
|
||||
return (x >> i) & 1 != 0
|
||||
|
||||
|
||||
|
||||
class DataTooLongError(ValueError):
|
||||
"""Raised when the supplied data does not fit any QR Code version. Ways to handle this exception include:
|
||||
- Decrease the error correction level if it was greater than Ecc.LOW.
|
||||
- If the encode_segments() function was called with a maxversion argument, then increase
|
||||
it if it was less than QrCode.MAX_VERSION. (This advice does not apply to the other
|
||||
factory functions because they search all versions up to QrCode.MAX_VERSION.)
|
||||
- Split the text data into better or optimal segments in order to reduce the number of bits required.
|
||||
- Change the text or binary data to be shorter.
|
||||
- Change the text to fit the character set of a particular segment mode (e.g. alphanumeric).
|
||||
- Propagate the error upward to the caller/user."""
|
||||
pass
|
||||
Reference in new issue
Block a user