mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 00:35:47 -07:00
Clarify agent and server log context labeling
This commit is contained in:
@@ -23,6 +23,7 @@ import ssl
|
|||||||
import threading
|
import threading
|
||||||
import contextlib
|
import contextlib
|
||||||
import errno
|
import errno
|
||||||
|
import re
|
||||||
from typing import Any, Dict, Optional, List, Callable, Tuple
|
from typing import Any, Dict, Optional, List, Callable, Tuple
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -66,15 +67,65 @@ def _rotate_daily(path: str):
|
|||||||
|
|
||||||
|
|
||||||
# Early bootstrap logging (goes to agent.log)
|
# Early bootstrap logging (goes to agent.log)
|
||||||
def _bootstrap_log(msg: str):
|
_AGENT_CONTEXT_HEADER = "X-Borealis-Agent-Context"
|
||||||
|
_AGENT_SCOPE_PATTERN = re.compile(r"\\bscope=([A-Za-z0-9_-]+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_scope_value(raw: Optional[str]) -> Optional[str]:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
value = "".join(ch for ch in str(raw) if ch.isalnum() or ch in ("_", "-"))
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return value.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_context_default() -> Optional[str]:
|
||||||
|
suffix = globals().get("CONFIG_SUFFIX_CANONICAL")
|
||||||
|
context = _canonical_scope_value(suffix)
|
||||||
|
if context:
|
||||||
|
return context
|
||||||
|
service = globals().get("SERVICE_MODE_CANONICAL")
|
||||||
|
context = _canonical_scope_value(service)
|
||||||
|
if context:
|
||||||
|
return context
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_agent_scope(message: str, provided_scope: Optional[str] = None) -> Optional[str]:
|
||||||
|
scope = _canonical_scope_value(provided_scope)
|
||||||
|
if scope:
|
||||||
|
return scope
|
||||||
|
match = _AGENT_SCOPE_PATTERN.search(message or "")
|
||||||
|
if match:
|
||||||
|
scope = _canonical_scope_value(match.group(1))
|
||||||
|
if scope:
|
||||||
|
return scope
|
||||||
|
return _agent_context_default()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_agent_log_message(message: str, fname: str, scope: Optional[str] = None) -> str:
|
||||||
|
context = _infer_agent_scope(message, scope)
|
||||||
|
if fname == "agent.error.log":
|
||||||
|
prefix = "[ERROR]"
|
||||||
|
if context:
|
||||||
|
prefix = f"{prefix}[CONTEXT-{context}]"
|
||||||
|
return f"{prefix} {message}"
|
||||||
|
if context:
|
||||||
|
return f"[CONTEXT-{context}] {message}"
|
||||||
|
return f"[INFO] {message}"
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_log(msg: str, *, scope: Optional[str] = None):
|
||||||
try:
|
try:
|
||||||
base = _agent_logs_root()
|
base = _agent_logs_root()
|
||||||
os.makedirs(base, exist_ok=True)
|
os.makedirs(base, exist_ok=True)
|
||||||
path = os.path.join(base, 'agent.log')
|
path = os.path.join(base, 'agent.log')
|
||||||
_rotate_daily(path)
|
_rotate_daily(path)
|
||||||
ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
line = _format_agent_log_message(msg, 'agent.log', scope)
|
||||||
with open(path, 'a', encoding='utf-8') as fh:
|
with open(path, 'a', encoding='utf-8') as fh:
|
||||||
fh.write(f'[{ts}] {msg}\n')
|
fh.write(f'[{ts}] {line}\n')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -360,15 +411,16 @@ def _find_project_root():
|
|||||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
# Simple file logger under Logs/Agent
|
# Simple file logger under Logs/Agent
|
||||||
def _log_agent(message: str, fname: str = 'agent.log'):
|
def _log_agent(message: str, fname: str = 'agent.log', *, scope: Optional[str] = None):
|
||||||
try:
|
try:
|
||||||
log_dir = _agent_logs_root()
|
log_dir = _agent_logs_root()
|
||||||
os.makedirs(log_dir, exist_ok=True)
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
path = os.path.join(log_dir, fname)
|
path = os.path.join(log_dir, fname)
|
||||||
_rotate_daily(path)
|
_rotate_daily(path)
|
||||||
|
line = _format_agent_log_message(message, fname, scope)
|
||||||
with open(path, 'a', encoding='utf-8') as fh:
|
with open(path, 'a', encoding='utf-8') as fh:
|
||||||
fh.write(f'[{ts}] {message}\n')
|
fh.write(f'[{ts}] {line}\n')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -683,6 +735,9 @@ class AgentHttpClient:
|
|||||||
self.key_store = _key_store()
|
self.key_store = _key_store()
|
||||||
self.identity = IDENTITY
|
self.identity = IDENTITY
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
context_label = _agent_context_default()
|
||||||
|
if context_label:
|
||||||
|
self.session.headers.setdefault(_AGENT_CONTEXT_HEADER, context_label)
|
||||||
self.base_url: Optional[str] = None
|
self.base_url: Optional[str] = None
|
||||||
self.guid: Optional[str] = None
|
self.guid: Optional[str] = None
|
||||||
self.access_token: Optional[str] = None
|
self.access_token: Optional[str] = None
|
||||||
@@ -749,9 +804,13 @@ class AgentHttpClient:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def auth_headers(self) -> Dict[str, str]:
|
def auth_headers(self) -> Dict[str, str]:
|
||||||
|
headers: Dict[str, str] = {}
|
||||||
if self.access_token:
|
if self.access_token:
|
||||||
return {"Authorization": f"Bearer {self.access_token}"}
|
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||||
return {}
|
context_label = _agent_context_default()
|
||||||
|
if context_label:
|
||||||
|
headers[_AGENT_CONTEXT_HEADER] = context_label
|
||||||
|
return headers
|
||||||
|
|
||||||
def configure_socketio(self, client: "socketio.AsyncClient") -> None:
|
def configure_socketio(self, client: "socketio.AsyncClient") -> None:
|
||||||
"""Align the Socket.IO engine's TLS verification with the REST client."""
|
"""Align the Socket.IO engine's TLS verification with the REST client."""
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ def register(
|
|||||||
db_conn_factory: Callable[[], sqlite3.Connection],
|
db_conn_factory: Callable[[], sqlite3.Connection],
|
||||||
require_admin: Callable[[], Optional[Any]],
|
require_admin: Callable[[], Optional[Any]],
|
||||||
current_user: Callable[[], Optional[Dict[str, str]]],
|
current_user: Callable[[], Optional[Dict[str, str]]],
|
||||||
log: Callable[[str, str], None],
|
log: Callable[[str, str, Optional[str]], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
blueprint = Blueprint("admin", __name__)
|
blueprint = Blueprint("admin", __name__)
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,24 @@ from flask import Blueprint, jsonify, request, g
|
|||||||
from Modules.auth.device_auth import DeviceAuthManager, require_device_auth
|
from Modules.auth.device_auth import DeviceAuthManager, require_device_auth
|
||||||
from Modules.crypto.signing import ScriptSigner
|
from Modules.crypto.signing import ScriptSigner
|
||||||
|
|
||||||
|
AGENT_CONTEXT_HEADER = "X-Borealis-Agent-Context"
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_context(value: Optional[str]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
cleaned = "".join(ch for ch in str(value) if ch.isalnum() or ch in ("_", "-"))
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
return cleaned.upper()
|
||||||
|
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
app,
|
app,
|
||||||
*,
|
*,
|
||||||
db_conn_factory: Callable[[], Any],
|
db_conn_factory: Callable[[], Any],
|
||||||
auth_manager: DeviceAuthManager,
|
auth_manager: DeviceAuthManager,
|
||||||
log: Callable[[str, str], None],
|
log: Callable[[str, str, Optional[str]], None],
|
||||||
script_signer: ScriptSigner,
|
script_signer: ScriptSigner,
|
||||||
) -> None:
|
) -> None:
|
||||||
blueprint = Blueprint("agents", __name__)
|
blueprint = Blueprint("agents", __name__)
|
||||||
@@ -29,10 +40,15 @@ def register(
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _context_hint(ctx=None) -> Optional[str]:
|
||||||
|
if ctx is not None and getattr(ctx, "service_mode", None):
|
||||||
|
return _canonical_context(getattr(ctx, "service_mode", None))
|
||||||
|
return _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
|
||||||
|
|
||||||
def _auth_context():
|
def _auth_context():
|
||||||
ctx = getattr(g, "device_auth", None)
|
ctx = getattr(g, "device_auth", None)
|
||||||
if ctx is None:
|
if ctx is None:
|
||||||
log("server", f"device auth context missing for {request.path}")
|
log("server", f"device auth context missing for {request.path}", _context_hint())
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@blueprint.route("/api/agent/heartbeat", methods=["POST"])
|
@blueprint.route("/api/agent/heartbeat", methods=["POST"])
|
||||||
@@ -42,6 +58,7 @@ def register(
|
|||||||
if ctx is None:
|
if ctx is None:
|
||||||
return jsonify({"error": "auth_context_missing"}), 500
|
return jsonify({"error": "auth_context_missing"}), 500
|
||||||
payload = request.get_json(force=True, silent=True) or {}
|
payload = request.get_json(force=True, silent=True) or {}
|
||||||
|
context_label = _context_hint(ctx)
|
||||||
|
|
||||||
now_ts = int(time.time())
|
now_ts = int(time.time())
|
||||||
updates: Dict[str, Optional[str]] = {"last_seen": now_ts}
|
updates: Dict[str, Optional[str]] = {"last_seen": now_ts}
|
||||||
@@ -111,12 +128,13 @@ def register(
|
|||||||
"server",
|
"server",
|
||||||
"heartbeat hostname collision ignored for guid="
|
"heartbeat hostname collision ignored for guid="
|
||||||
f"{ctx.guid}",
|
f"{ctx.guid}",
|
||||||
|
context_label,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if rowcount == 0:
|
if rowcount == 0:
|
||||||
log("server", f"heartbeat missing device record guid={ctx.guid}")
|
log("server", f"heartbeat missing device record guid={ctx.guid}", context_label)
|
||||||
return jsonify({"error": "device_not_registered"}), 404
|
return jsonify({"error": "device_not_registered"}), 404
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ from flask import g, jsonify, request
|
|||||||
from Modules.auth.dpop import DPoPValidator, DPoPVerificationError, DPoPReplayError
|
from Modules.auth.dpop import DPoPValidator, DPoPVerificationError, DPoPReplayError
|
||||||
from Modules.auth.rate_limit import SlidingWindowRateLimiter
|
from Modules.auth.rate_limit import SlidingWindowRateLimiter
|
||||||
|
|
||||||
|
AGENT_CONTEXT_HEADER = "X-Borealis-Agent-Context"
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_context(value: Optional[str]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
cleaned = "".join(ch for ch in str(value) if ch.isalnum() or ch in ("_", "-"))
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
return cleaned.upper()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DeviceAuthContext:
|
class DeviceAuthContext:
|
||||||
@@ -23,6 +34,7 @@ class DeviceAuthContext:
|
|||||||
claims: Dict[str, Any]
|
claims: Dict[str, Any]
|
||||||
dpop_jkt: Optional[str]
|
dpop_jkt: Optional[str]
|
||||||
status: str
|
status: str
|
||||||
|
service_mode: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
class DeviceAuthError(Exception):
|
class DeviceAuthError(Exception):
|
||||||
@@ -50,7 +62,7 @@ class DeviceAuthManager:
|
|||||||
db_conn_factory: Callable[[], Any],
|
db_conn_factory: Callable[[], Any],
|
||||||
jwt_service,
|
jwt_service,
|
||||||
dpop_validator: Optional[DPoPValidator],
|
dpop_validator: Optional[DPoPValidator],
|
||||||
log: Callable[[str, str], None],
|
log: Callable[[str, str, Optional[str]], None],
|
||||||
rate_limiter: Optional[SlidingWindowRateLimiter] = None,
|
rate_limiter: Optional[SlidingWindowRateLimiter] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._db_conn_factory = db_conn_factory
|
self._db_conn_factory = db_conn_factory
|
||||||
@@ -89,6 +101,8 @@ class DeviceAuthManager:
|
|||||||
retry_after=decision.retry_after,
|
retry_after=decision.retry_after,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
context_label = _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
|
||||||
|
|
||||||
conn = self._db_conn_factory()
|
conn = self._db_conn_factory()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -100,10 +114,10 @@ class DeviceAuthManager:
|
|||||||
""",
|
""",
|
||||||
(guid,),
|
(guid,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
row = self._recover_device_record(conn, guid, fingerprint, token_version)
|
row = self._recover_device_record(conn, guid, fingerprint, token_version, context_label)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -127,7 +141,11 @@ class DeviceAuthManager:
|
|||||||
if status_normalized not in allowed_statuses:
|
if status_normalized not in allowed_statuses:
|
||||||
raise DeviceAuthError("device_revoked", status_code=403)
|
raise DeviceAuthError("device_revoked", status_code=403)
|
||||||
if status_normalized == "quarantined":
|
if status_normalized == "quarantined":
|
||||||
self._log("server", f"device {guid} is quarantined; limited access for {request.path}")
|
self._log(
|
||||||
|
"server",
|
||||||
|
f"device {guid} is quarantined; limited access for {request.path}",
|
||||||
|
context_label,
|
||||||
|
)
|
||||||
|
|
||||||
dpop_jkt: Optional[str] = None
|
dpop_jkt: Optional[str] = None
|
||||||
dpop_proof = request.headers.get("DPoP")
|
dpop_proof = request.headers.get("DPoP")
|
||||||
@@ -150,6 +168,7 @@ class DeviceAuthManager:
|
|||||||
claims=claims,
|
claims=claims,
|
||||||
dpop_jkt=dpop_jkt,
|
dpop_jkt=dpop_jkt,
|
||||||
status=status_normalized,
|
status=status_normalized,
|
||||||
|
service_mode=context_label,
|
||||||
)
|
)
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@@ -159,6 +178,7 @@ class DeviceAuthManager:
|
|||||||
guid: str,
|
guid: str,
|
||||||
fingerprint: str,
|
fingerprint: str,
|
||||||
token_version: int,
|
token_version: int,
|
||||||
|
context_label: Optional[str],
|
||||||
) -> Optional[tuple]:
|
) -> Optional[tuple]:
|
||||||
"""Attempt to recreate a missing device row for an authenticated token."""
|
"""Attempt to recreate a missing device row for an authenticated token."""
|
||||||
|
|
||||||
@@ -211,6 +231,7 @@ class DeviceAuthManager:
|
|||||||
self._log(
|
self._log(
|
||||||
"server",
|
"server",
|
||||||
f"device auth failed to recover guid={guid} due to integrity error: {exc}",
|
f"device auth failed to recover guid={guid} due to integrity error: {exc}",
|
||||||
|
context_label,
|
||||||
)
|
)
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return None
|
return None
|
||||||
@@ -218,6 +239,7 @@ class DeviceAuthManager:
|
|||||||
self._log(
|
self._log(
|
||||||
"server",
|
"server",
|
||||||
f"device auth unexpected error recovering guid={guid}: {exc}",
|
f"device auth unexpected error recovering guid={guid}: {exc}",
|
||||||
|
context_label,
|
||||||
)
|
)
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return None
|
return None
|
||||||
@@ -229,6 +251,7 @@ class DeviceAuthManager:
|
|||||||
self._log(
|
self._log(
|
||||||
"server",
|
"server",
|
||||||
f"device auth could not recover guid={guid}; hostname collisions persisted",
|
f"device auth could not recover guid={guid}; hostname collisions persisted",
|
||||||
|
context_label,
|
||||||
)
|
)
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return None
|
return None
|
||||||
@@ -246,6 +269,7 @@ class DeviceAuthManager:
|
|||||||
self._log(
|
self._log(
|
||||||
"server",
|
"server",
|
||||||
f"device auth recovery for guid={guid} committed but row still missing",
|
f"device auth recovery for guid={guid} committed but row still missing",
|
||||||
|
context_label,
|
||||||
)
|
)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ from datetime import datetime, timezone, timedelta
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Dict, Optional, Tuple
|
from typing import Any, Callable, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
AGENT_CONTEXT_HEADER = "X-Borealis-Agent-Context"
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_context(value: Optional[str]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
cleaned = "".join(ch for ch in str(value) if ch.isalnum() or ch in ("_", "-"))
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
return cleaned.upper()
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
from Modules.auth.rate_limit import SlidingWindowRateLimiter
|
from Modules.auth.rate_limit import SlidingWindowRateLimiter
|
||||||
@@ -20,7 +31,7 @@ def register(
|
|||||||
app,
|
app,
|
||||||
*,
|
*,
|
||||||
db_conn_factory: Callable[[], sqlite3.Connection],
|
db_conn_factory: Callable[[], sqlite3.Connection],
|
||||||
log: Callable[[str, str], None],
|
log: Callable[[str, str, Optional[str]], None],
|
||||||
jwt_service,
|
jwt_service,
|
||||||
tls_bundle_path: str,
|
tls_bundle_path: str,
|
||||||
ip_rate_limiter: SlidingWindowRateLimiter,
|
ip_rate_limiter: SlidingWindowRateLimiter,
|
||||||
@@ -51,12 +62,19 @@ def register(
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _rate_limited(key: str, limiter: SlidingWindowRateLimiter, limit: int, window_s: float):
|
def _rate_limited(
|
||||||
|
key: str,
|
||||||
|
limiter: SlidingWindowRateLimiter,
|
||||||
|
limit: int,
|
||||||
|
window_s: float,
|
||||||
|
context_hint: Optional[str],
|
||||||
|
):
|
||||||
decision = limiter.check(key, limit, window_s)
|
decision = limiter.check(key, limit, window_s)
|
||||||
if not decision.allowed:
|
if not decision.allowed:
|
||||||
log(
|
log(
|
||||||
"server",
|
"server",
|
||||||
f"enrollment rate limited key={key} limit={limit}/{window_s}s retry_after={decision.retry_after:.2f}",
|
f"enrollment rate limited key={key} limit={limit}/{window_s}s retry_after={decision.retry_after:.2f}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
response = jsonify({"error": "rate_limited", "retry_after": decision.retry_after})
|
response = jsonify({"error": "rate_limited", "retry_after": decision.retry_after})
|
||||||
response.status_code = 429
|
response.status_code = 429
|
||||||
@@ -295,7 +313,9 @@ def register(
|
|||||||
@blueprint.route("/api/agent/enroll/request", methods=["POST"])
|
@blueprint.route("/api/agent/enroll/request", methods=["POST"])
|
||||||
def enrollment_request():
|
def enrollment_request():
|
||||||
remote = _remote_addr()
|
remote = _remote_addr()
|
||||||
rate_error = _rate_limited(f"ip:{remote}", ip_rate_limiter, 40, 60.0)
|
context_hint = _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
|
||||||
|
|
||||||
|
rate_error = _rate_limited(f"ip:{remote}", ip_rate_limiter, 40, 60.0, context_hint)
|
||||||
if rate_error:
|
if rate_error:
|
||||||
return rate_error
|
return rate_error
|
||||||
|
|
||||||
@@ -310,42 +330,43 @@ def register(
|
|||||||
"enrollment request received "
|
"enrollment request received "
|
||||||
f"ip={remote} hostname={hostname or '<missing>'} code_mask={_mask_code(enrollment_code)} "
|
f"ip={remote} hostname={hostname or '<missing>'} code_mask={_mask_code(enrollment_code)} "
|
||||||
f"pubkey_len={len(agent_pubkey_b64 or '')} nonce_len={len(client_nonce_b64 or '')}",
|
f"pubkey_len={len(agent_pubkey_b64 or '')} nonce_len={len(client_nonce_b64 or '')}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not hostname:
|
if not hostname:
|
||||||
log("server", f"enrollment rejected missing_hostname ip={remote}")
|
log("server", f"enrollment rejected missing_hostname ip={remote}", context_hint)
|
||||||
return jsonify({"error": "hostname_required"}), 400
|
return jsonify({"error": "hostname_required"}), 400
|
||||||
if not enrollment_code:
|
if not enrollment_code:
|
||||||
log("server", f"enrollment rejected missing_code ip={remote} host={hostname}")
|
log("server", f"enrollment rejected missing_code ip={remote} host={hostname}", context_hint)
|
||||||
return jsonify({"error": "enrollment_code_required"}), 400
|
return jsonify({"error": "enrollment_code_required"}), 400
|
||||||
if not isinstance(agent_pubkey_b64, str):
|
if not isinstance(agent_pubkey_b64, str):
|
||||||
log("server", f"enrollment rejected missing_pubkey ip={remote} host={hostname}")
|
log("server", f"enrollment rejected missing_pubkey ip={remote} host={hostname}", context_hint)
|
||||||
return jsonify({"error": "agent_pubkey_required"}), 400
|
return jsonify({"error": "agent_pubkey_required"}), 400
|
||||||
if not isinstance(client_nonce_b64, str):
|
if not isinstance(client_nonce_b64, str):
|
||||||
log("server", f"enrollment rejected missing_nonce ip={remote} host={hostname}")
|
log("server", f"enrollment rejected missing_nonce ip={remote} host={hostname}", context_hint)
|
||||||
return jsonify({"error": "client_nonce_required"}), 400
|
return jsonify({"error": "client_nonce_required"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_pubkey_der = crypto_keys.spki_der_from_base64(agent_pubkey_b64)
|
agent_pubkey_der = crypto_keys.spki_der_from_base64(agent_pubkey_b64)
|
||||||
except Exception:
|
except Exception:
|
||||||
log("server", f"enrollment rejected invalid_pubkey ip={remote} host={hostname}")
|
log("server", f"enrollment rejected invalid_pubkey ip={remote} host={hostname}", context_hint)
|
||||||
return jsonify({"error": "invalid_agent_pubkey"}), 400
|
return jsonify({"error": "invalid_agent_pubkey"}), 400
|
||||||
|
|
||||||
if len(agent_pubkey_der) < 10:
|
if len(agent_pubkey_der) < 10:
|
||||||
log("server", f"enrollment rejected short_pubkey ip={remote} host={hostname}")
|
log("server", f"enrollment rejected short_pubkey ip={remote} host={hostname}", context_hint)
|
||||||
return jsonify({"error": "invalid_agent_pubkey"}), 400
|
return jsonify({"error": "invalid_agent_pubkey"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True)
|
client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
log("server", f"enrollment rejected invalid_nonce ip={remote} host={hostname}")
|
log("server", f"enrollment rejected invalid_nonce ip={remote} host={hostname}", context_hint)
|
||||||
return jsonify({"error": "invalid_client_nonce"}), 400
|
return jsonify({"error": "invalid_client_nonce"}), 400
|
||||||
if len(client_nonce_bytes) < 16:
|
if len(client_nonce_bytes) < 16:
|
||||||
log("server", f"enrollment rejected short_nonce ip={remote} host={hostname}")
|
log("server", f"enrollment rejected short_nonce ip={remote} host={hostname}", context_hint)
|
||||||
return jsonify({"error": "invalid_client_nonce"}), 400
|
return jsonify({"error": "invalid_client_nonce"}), 400
|
||||||
|
|
||||||
fingerprint = crypto_keys.fingerprint_from_spki_der(agent_pubkey_der)
|
fingerprint = crypto_keys.fingerprint_from_spki_der(agent_pubkey_der)
|
||||||
rate_error = _rate_limited(f"fp:{fingerprint}", fp_rate_limiter, 12, 60.0)
|
rate_error = _rate_limited(f"fp:{fingerprint}", fp_rate_limiter, 12, 60.0, context_hint)
|
||||||
if rate_error:
|
if rate_error:
|
||||||
return rate_error
|
return rate_error
|
||||||
|
|
||||||
@@ -359,6 +380,7 @@ def register(
|
|||||||
"server",
|
"server",
|
||||||
"enrollment request invalid_code "
|
"enrollment request invalid_code "
|
||||||
f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}",
|
f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
return jsonify({"error": "invalid_enrollment_code"}), 400
|
return jsonify({"error": "invalid_enrollment_code"}), 400
|
||||||
|
|
||||||
@@ -444,7 +466,11 @@ def register(
|
|||||||
"server_certificate": _load_tls_bundle(tls_bundle_path),
|
"server_certificate": _load_tls_bundle(tls_bundle_path),
|
||||||
"signing_key": _signing_key_b64(),
|
"signing_key": _signing_key_b64(),
|
||||||
}
|
}
|
||||||
log("server", f"enrollment request queued fingerprint={fingerprint[:12]} host={hostname} ip={remote}")
|
log(
|
||||||
|
"server",
|
||||||
|
f"enrollment request queued fingerprint={fingerprint[:12]} host={hostname} ip={remote}",
|
||||||
|
context_hint,
|
||||||
|
)
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
@blueprint.route("/api/agent/enroll/poll", methods=["POST"])
|
@blueprint.route("/api/agent/enroll/poll", methods=["POST"])
|
||||||
@@ -453,34 +479,36 @@ def register(
|
|||||||
approval_reference = payload.get("approval_reference")
|
approval_reference = payload.get("approval_reference")
|
||||||
client_nonce_b64 = payload.get("client_nonce")
|
client_nonce_b64 = payload.get("client_nonce")
|
||||||
proof_sig_b64 = payload.get("proof_sig")
|
proof_sig_b64 = payload.get("proof_sig")
|
||||||
|
context_hint = _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
|
||||||
|
|
||||||
log(
|
log(
|
||||||
"server",
|
"server",
|
||||||
"enrollment poll received "
|
"enrollment poll received "
|
||||||
f"ref={approval_reference} client_nonce_len={len(client_nonce_b64 or '')}"
|
f"ref={approval_reference} client_nonce_len={len(client_nonce_b64 or '')}"
|
||||||
f" proof_sig_len={len(proof_sig_b64 or '')}",
|
f" proof_sig_len={len(proof_sig_b64 or '')}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(approval_reference, str) or not approval_reference:
|
if not isinstance(approval_reference, str) or not approval_reference:
|
||||||
log("server", "enrollment poll rejected missing_reference")
|
log("server", "enrollment poll rejected missing_reference", context_hint)
|
||||||
return jsonify({"error": "approval_reference_required"}), 400
|
return jsonify({"error": "approval_reference_required"}), 400
|
||||||
if not isinstance(client_nonce_b64, str):
|
if not isinstance(client_nonce_b64, str):
|
||||||
log("server", f"enrollment poll rejected missing_nonce ref={approval_reference}")
|
log("server", f"enrollment poll rejected missing_nonce ref={approval_reference}", context_hint)
|
||||||
return jsonify({"error": "client_nonce_required"}), 400
|
return jsonify({"error": "client_nonce_required"}), 400
|
||||||
if not isinstance(proof_sig_b64, str):
|
if not isinstance(proof_sig_b64, str):
|
||||||
log("server", f"enrollment poll rejected missing_sig ref={approval_reference}")
|
log("server", f"enrollment poll rejected missing_sig ref={approval_reference}", context_hint)
|
||||||
return jsonify({"error": "proof_sig_required"}), 400
|
return jsonify({"error": "proof_sig_required"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True)
|
client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
log("server", f"enrollment poll invalid_client_nonce ref={approval_reference}")
|
log("server", f"enrollment poll invalid_client_nonce ref={approval_reference}", context_hint)
|
||||||
return jsonify({"error": "invalid_client_nonce"}), 400
|
return jsonify({"error": "invalid_client_nonce"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proof_sig = base64.b64decode(proof_sig_b64, validate=True)
|
proof_sig = base64.b64decode(proof_sig_b64, validate=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
log("server", f"enrollment poll invalid_sig ref={approval_reference}")
|
log("server", f"enrollment poll invalid_sig ref={approval_reference}", context_hint)
|
||||||
return jsonify({"error": "invalid_proof_sig"}), 400
|
return jsonify({"error": "invalid_proof_sig"}), 400
|
||||||
|
|
||||||
conn = db_conn_factory()
|
conn = db_conn_factory()
|
||||||
@@ -498,7 +526,7 @@ def register(
|
|||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
log("server", f"enrollment poll unknown_reference ref={approval_reference}")
|
log("server", f"enrollment poll unknown_reference ref={approval_reference}", context_hint)
|
||||||
return jsonify({"status": "unknown"}), 404
|
return jsonify({"status": "unknown"}), 404
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -517,13 +545,13 @@ def register(
|
|||||||
) = row
|
) = row
|
||||||
|
|
||||||
if client_nonce_stored != client_nonce_b64:
|
if client_nonce_stored != client_nonce_b64:
|
||||||
log("server", f"enrollment poll nonce_mismatch ref={approval_reference}")
|
log("server", f"enrollment poll nonce_mismatch ref={approval_reference}", context_hint)
|
||||||
return jsonify({"error": "nonce_mismatch"}), 400
|
return jsonify({"error": "nonce_mismatch"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
server_nonce_bytes = base64.b64decode(server_nonce_b64, validate=True)
|
server_nonce_bytes = base64.b64decode(server_nonce_b64, validate=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
log("server", f"enrollment poll invalid_server_nonce ref={approval_reference}")
|
log("server", f"enrollment poll invalid_server_nonce ref={approval_reference}", context_hint)
|
||||||
return jsonify({"error": "server_nonce_invalid"}), 400
|
return jsonify({"error": "server_nonce_invalid"}), 400
|
||||||
|
|
||||||
message = server_nonce_bytes + approval_reference.encode("utf-8") + client_nonce_bytes
|
message = server_nonce_bytes + approval_reference.encode("utf-8") + client_nonce_bytes
|
||||||
@@ -531,17 +559,17 @@ def register(
|
|||||||
try:
|
try:
|
||||||
public_key = serialization.load_der_public_key(agent_pubkey_der)
|
public_key = serialization.load_der_public_key(agent_pubkey_der)
|
||||||
except Exception:
|
except Exception:
|
||||||
log("server", f"enrollment poll pubkey_load_failed ref={approval_reference}")
|
log("server", f"enrollment poll pubkey_load_failed ref={approval_reference}", context_hint)
|
||||||
public_key = None
|
public_key = None
|
||||||
|
|
||||||
if public_key is None:
|
if public_key is None:
|
||||||
log("server", f"enrollment poll invalid_pubkey ref={approval_reference}")
|
log("server", f"enrollment poll invalid_pubkey ref={approval_reference}", context_hint)
|
||||||
return jsonify({"error": "agent_pubkey_invalid"}), 400
|
return jsonify({"error": "agent_pubkey_invalid"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
public_key.verify(proof_sig, message)
|
public_key.verify(proof_sig, message)
|
||||||
except Exception:
|
except Exception:
|
||||||
log("server", f"enrollment poll invalid_proof ref={approval_reference}")
|
log("server", f"enrollment poll invalid_proof ref={approval_reference}", context_hint)
|
||||||
return jsonify({"error": "invalid_proof"}), 400
|
return jsonify({"error": "invalid_proof"}), 400
|
||||||
|
|
||||||
if status == "pending":
|
if status == "pending":
|
||||||
@@ -549,24 +577,28 @@ def register(
|
|||||||
"server",
|
"server",
|
||||||
f"enrollment poll pending ref={approval_reference} host={hostname_claimed}"
|
f"enrollment poll pending ref={approval_reference} host={hostname_claimed}"
|
||||||
f" fingerprint={fingerprint[:12]}",
|
f" fingerprint={fingerprint[:12]}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
return jsonify({"status": "pending", "poll_after_ms": 5000})
|
return jsonify({"status": "pending", "poll_after_ms": 5000})
|
||||||
if status == "denied":
|
if status == "denied":
|
||||||
log(
|
log(
|
||||||
"server",
|
"server",
|
||||||
f"enrollment poll denied ref={approval_reference} host={hostname_claimed}",
|
f"enrollment poll denied ref={approval_reference} host={hostname_claimed}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
return jsonify({"status": "denied", "reason": "operator_denied"})
|
return jsonify({"status": "denied", "reason": "operator_denied"})
|
||||||
if status == "expired":
|
if status == "expired":
|
||||||
log(
|
log(
|
||||||
"server",
|
"server",
|
||||||
f"enrollment poll expired ref={approval_reference} host={hostname_claimed}",
|
f"enrollment poll expired ref={approval_reference} host={hostname_claimed}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
return jsonify({"status": "expired"})
|
return jsonify({"status": "expired"})
|
||||||
if status == "completed":
|
if status == "completed":
|
||||||
log(
|
log(
|
||||||
"server",
|
"server",
|
||||||
f"enrollment poll already_completed ref={approval_reference} host={hostname_claimed}",
|
f"enrollment poll already_completed ref={approval_reference} host={hostname_claimed}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
return jsonify({"status": "approved", "detail": "finalized"})
|
return jsonify({"status": "approved", "detail": "finalized"})
|
||||||
|
|
||||||
@@ -574,6 +606,7 @@ def register(
|
|||||||
log(
|
log(
|
||||||
"server",
|
"server",
|
||||||
f"enrollment poll unexpected_status={status} ref={approval_reference}",
|
f"enrollment poll unexpected_status={status} ref={approval_reference}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
return jsonify({"status": status or "unknown"}), 400
|
return jsonify({"status": status or "unknown"}), 400
|
||||||
|
|
||||||
@@ -582,6 +615,7 @@ def register(
|
|||||||
log(
|
log(
|
||||||
"server",
|
"server",
|
||||||
f"enrollment poll replay_detected ref={approval_reference} fingerprint={fingerprint[:12]}",
|
f"enrollment poll replay_detected ref={approval_reference} fingerprint={fingerprint[:12]}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
return jsonify({"error": "proof_replayed"}), 409
|
return jsonify({"error": "proof_replayed"}), 409
|
||||||
|
|
||||||
@@ -656,6 +690,7 @@ def register(
|
|||||||
log(
|
log(
|
||||||
"server",
|
"server",
|
||||||
f"enrollment finalized guid={effective_guid} fingerprint={fingerprint[:12]} host={hostname_claimed}",
|
f"enrollment finalized guid={effective_guid} fingerprint={fingerprint[:12]} host={hostname_claimed}",
|
||||||
|
context_hint,
|
||||||
)
|
)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Callable
|
from typing import Callable, Optional
|
||||||
|
|
||||||
import eventlet
|
import eventlet
|
||||||
from flask_socketio import SocketIO
|
from flask_socketio import SocketIO
|
||||||
@@ -11,7 +11,7 @@ def start_prune_job(
|
|||||||
socketio: SocketIO,
|
socketio: SocketIO,
|
||||||
*,
|
*,
|
||||||
db_conn_factory: Callable[[], any],
|
db_conn_factory: Callable[[], any],
|
||||||
log: Callable[[str, str], None],
|
log: Callable[[str, str, Optional[str]], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
def _job_loop():
|
def _job_loop():
|
||||||
while True:
|
while True:
|
||||||
@@ -24,7 +24,7 @@ def start_prune_job(
|
|||||||
socketio.start_background_task(_job_loop)
|
socketio.start_background_task(_job_loop)
|
||||||
|
|
||||||
|
|
||||||
def _run_once(db_conn_factory: Callable[[], any], log: Callable[[str, str], None]) -> None:
|
def _run_once(db_conn_factory: Callable[[], any], log: Callable[[str, str, Optional[str]], None]) -> None:
|
||||||
now = datetime.now(tz=timezone.utc)
|
now = datetime.now(tz=timezone.utc)
|
||||||
now_iso = now.isoformat()
|
now_iso = now.isoformat()
|
||||||
stale_before = (now - timedelta(hours=24)).isoformat()
|
stale_before = (now - timedelta(hours=24)).isoformat()
|
||||||
|
|||||||
@@ -152,15 +152,62 @@ def _rotate_daily(path: str):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _write_service_log(service: str, msg: str):
|
_SERVER_SCOPE_PATTERN = re.compile(r"\\b(?:scope|context|agent_context)=([A-Za-z0-9_-]+)", re.IGNORECASE)
|
||||||
|
_SERVER_AGENT_ID_PATTERN = re.compile(r"\\bagent_id=([^\s,]+)", re.IGNORECASE)
|
||||||
|
_AGENT_CONTEXT_HEADER = "X-Borealis-Agent-Context"
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_server_scope(raw: Optional[str]) -> Optional[str]:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
value = "".join(ch for ch in str(raw) if ch.isalnum() or ch in ("_", "-"))
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return value.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_from_agent_id(agent_id: Optional[str]) -> Optional[str]:
|
||||||
|
candidate = _canonical_server_scope(agent_id)
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
if candidate.endswith("_SYSTEM"):
|
||||||
|
return "SYSTEM"
|
||||||
|
if candidate.endswith("_CURRENTUSER"):
|
||||||
|
return "CURRENTUSER"
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_server_scope(message: str, explicit: Optional[str]) -> Optional[str]:
|
||||||
|
scope = _canonical_server_scope(explicit)
|
||||||
|
if scope:
|
||||||
|
return scope
|
||||||
|
match = _SERVER_SCOPE_PATTERN.search(message or "")
|
||||||
|
if match:
|
||||||
|
scope = _canonical_server_scope(match.group(1))
|
||||||
|
if scope:
|
||||||
|
return scope
|
||||||
|
agent_match = _SERVER_AGENT_ID_PATTERN.search(message or "")
|
||||||
|
if agent_match:
|
||||||
|
scope = _scope_from_agent_id(agent_match.group(1))
|
||||||
|
if scope:
|
||||||
|
return scope
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_service_log(service: str, msg: str, scope: Optional[str] = None, *, level: str = "INFO"):
|
||||||
try:
|
try:
|
||||||
base = _server_logs_root()
|
base = _server_logs_root()
|
||||||
os.makedirs(base, exist_ok=True)
|
os.makedirs(base, exist_ok=True)
|
||||||
path = os.path.join(base, f"{service}.log")
|
path = os.path.join(base, f"{service}.log")
|
||||||
_rotate_daily(path)
|
_rotate_daily(path)
|
||||||
ts = time.strftime('%Y-%m-%d %H:%M:%S')
|
ts = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
resolved_scope = _infer_server_scope(msg, scope)
|
||||||
|
prefix_parts = [f"[{level.upper()}]"]
|
||||||
|
if resolved_scope:
|
||||||
|
prefix_parts.append(f"[CONTEXT-{resolved_scope}]")
|
||||||
|
prefix = "".join(prefix_parts)
|
||||||
with open(path, 'a', encoding='utf-8') as fh:
|
with open(path, 'a', encoding='utf-8') as fh:
|
||||||
fh.write(f'[{ts}] {msg}\n')
|
fh.write(f'[{ts}] {prefix} {msg}\n')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user