Agent Reverse Tunneling - Engine Tunnel Service Implementation

This commit is contained in:
2025-12-01 01:40:23 -07:00
parent 33b6351c78
commit db8dd423f6
12 changed files with 1638 additions and 13 deletions

View File

@@ -27,6 +27,7 @@ pywinauto # Windows-based Macro Automation Library
sounddevice
numpy
pywin32; platform_system == "Windows"
pywinpty; platform_system == "Windows" # ConPTY bridge for reverse tunnel PowerShell sessions
# Ansible Libraries
ansible-core

View File

@@ -77,6 +77,13 @@ LOG_ROOT = PROJECT_ROOT / "Engine" / "Logs"
LOG_FILE_PATH = LOG_ROOT / "engine.log"
ERROR_LOG_FILE_PATH = LOG_ROOT / "error.log"
API_LOG_FILE_PATH = LOG_ROOT / "api.log"
REVERSE_TUNNEL_LOG_FILE_PATH = LOG_ROOT / "reverse_tunnel.log"
DEFAULT_TUNNEL_FIXED_PORT = 8443
DEFAULT_TUNNEL_PORT_RANGE = (30000, 40000)
DEFAULT_TUNNEL_IDLE_TIMEOUT_SECONDS = 3600
DEFAULT_TUNNEL_GRACE_TIMEOUT_SECONDS = 3600
DEFAULT_TUNNEL_HEARTBEAT_INTERVAL_SECONDS = 20
def _ensure_parent(path: Path) -> None:
@@ -140,6 +147,71 @@ def _parse_bool(raw: Any, *, default: bool = False) -> bool:
return default
def _parse_int(
raw: Any,
*,
default: int,
minimum: Optional[int] = None,
maximum: Optional[int] = None,
) -> int:
try:
value = int(raw)
except Exception:
return default
if minimum is not None and value < minimum:
return default
if maximum is not None and value > maximum:
return default
return value
def _parse_port_range(
raw: Any,
*,
default: Tuple[int, int],
) -> Tuple[int, int]:
if raw is None:
return default
start, end = default
candidate: Optional[Tuple[int, int]] = None
def _clamp_pair(values: Tuple[int, int]) -> Tuple[int, int]:
low, high = values
if low < 1 or high > 65535 or low > high:
return default
return low, high
if isinstance(raw, str):
separators = ("-", ":", ",")
for separator in separators:
if separator in raw:
parts = [part.strip() for part in raw.split(separator)]
break
else:
parts = [raw.strip()]
try:
if len(parts) == 2:
candidate = (int(parts[0]), int(parts[1]))
elif len(parts) == 1 and parts[0]:
port = int(parts[0])
candidate = (port, port)
except Exception:
candidate = None
elif isinstance(raw, Sequence):
try:
values = [int(part) for part in raw]
except Exception:
values = []
if len(values) >= 2:
candidate = (values[0], values[1])
if candidate is None:
return default
return _clamp_pair(candidate)
def _discover_tls_material(config: Mapping[str, Any]) -> Sequence[Optional[str]]:
cert_path = config.get("TLS_CERT_PATH") or os.environ.get("BOREALIS_TLS_CERT") or None
key_path = config.get("TLS_KEY_PATH") or os.environ.get("BOREALIS_TLS_KEY") or None
@@ -183,6 +255,12 @@ class EngineSettings:
error_log_file: str
api_log_file: str
api_groups: Tuple[str, ...]
reverse_tunnel_fixed_port: int
reverse_tunnel_port_range: Tuple[int, int]
reverse_tunnel_idle_timeout_seconds: int
reverse_tunnel_grace_timeout_seconds: int
reverse_tunnel_heartbeat_seconds: int
reverse_tunnel_log_file: str
raw: MutableMapping[str, Any] = field(default_factory=dict)
def to_flask_config(self) -> MutableMapping[str, Any]:
@@ -279,6 +357,11 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine
api_log_file = str(runtime_config.get("API_LOG_FILE") or API_LOG_FILE_PATH)
_ensure_parent(Path(api_log_file))
reverse_tunnel_log_file = str(
runtime_config.get("REVERSE_TUNNEL_LOG_FILE") or REVERSE_TUNNEL_LOG_FILE_PATH
)
_ensure_parent(Path(reverse_tunnel_log_file))
api_groups = _parse_api_groups(
runtime_config.get("API_GROUPS") or os.environ.get("BOREALIS_API_GROUPS")
)
@@ -294,6 +377,35 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine
"scheduled_jobs",
)
tunnel_fixed_port = _parse_int(
runtime_config.get("TUNNEL_FIXED_PORT") or os.environ.get("BOREALIS_TUNNEL_FIXED_PORT"),
default=DEFAULT_TUNNEL_FIXED_PORT,
minimum=1,
maximum=65535,
)
tunnel_port_range = _parse_port_range(
runtime_config.get("TUNNEL_PORT_RANGE") or os.environ.get("BOREALIS_TUNNEL_PORT_RANGE"),
default=DEFAULT_TUNNEL_PORT_RANGE,
)
tunnel_idle_timeout_seconds = _parse_int(
runtime_config.get("TUNNEL_IDLE_TIMEOUT_SECONDS")
or os.environ.get("BOREALIS_TUNNEL_IDLE_TIMEOUT_SECONDS"),
default=DEFAULT_TUNNEL_IDLE_TIMEOUT_SECONDS,
minimum=60,
)
tunnel_grace_timeout_seconds = _parse_int(
runtime_config.get("TUNNEL_GRACE_TIMEOUT_SECONDS")
or os.environ.get("BOREALIS_TUNNEL_GRACE_TIMEOUT_SECONDS"),
default=DEFAULT_TUNNEL_GRACE_TIMEOUT_SECONDS,
minimum=60,
)
tunnel_heartbeat_seconds = _parse_int(
runtime_config.get("TUNNEL_HEARTBEAT_SECONDS")
or os.environ.get("BOREALIS_TUNNEL_HEARTBEAT_SECONDS"),
default=DEFAULT_TUNNEL_HEARTBEAT_INTERVAL_SECONDS,
minimum=5,
)
settings = EngineSettings(
database_path=database_path,
static_folder=static_folder,
@@ -309,6 +421,12 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine
error_log_file=str(error_log_file),
api_log_file=str(api_log_file),
api_groups=api_groups,
reverse_tunnel_fixed_port=tunnel_fixed_port,
reverse_tunnel_port_range=tunnel_port_range,
reverse_tunnel_idle_timeout_seconds=tunnel_idle_timeout_seconds,
reverse_tunnel_grace_timeout_seconds=tunnel_grace_timeout_seconds,
reverse_tunnel_heartbeat_seconds=tunnel_heartbeat_seconds,
reverse_tunnel_log_file=reverse_tunnel_log_file,
raw=runtime_config,
)
return settings

View File

@@ -9,3 +9,4 @@ pyotp
qrcode
Pillow
requests
websockets

View File

@@ -118,6 +118,12 @@ class EngineContext:
config: Mapping[str, Any]
api_groups: Sequence[str]
api_log_path: str
reverse_tunnel_fixed_port: int
reverse_tunnel_port_range: Tuple[int, int]
reverse_tunnel_idle_timeout_seconds: int
reverse_tunnel_grace_timeout_seconds: int
reverse_tunnel_heartbeat_seconds: int
reverse_tunnel_log_path: str
assembly_cache: Optional[Any] = None
@@ -136,6 +142,12 @@ def _build_engine_context(settings: EngineSettings, logger: logging.Logger) -> E
config=settings.as_dict(),
api_groups=settings.api_groups,
api_log_path=settings.api_log_file,
reverse_tunnel_fixed_port=settings.reverse_tunnel_fixed_port,
reverse_tunnel_port_range=settings.reverse_tunnel_port_range,
reverse_tunnel_idle_timeout_seconds=settings.reverse_tunnel_idle_timeout_seconds,
reverse_tunnel_grace_timeout_seconds=settings.reverse_tunnel_grace_timeout_seconds,
reverse_tunnel_heartbeat_seconds=settings.reverse_tunnel_heartbeat_seconds,
reverse_tunnel_log_path=settings.reverse_tunnel_log_file,
assembly_cache=None,
)

View File

@@ -32,6 +32,7 @@ from ...integrations import GitHubIntegration
from ..auth import DevModeManager
from .enrollment import routes as enrollment_routes
from .tokens import routes as token_routes
from .devices.tunnel import register_tunnel
from ...server import EngineContext
from .access_management.login import register_auth
@@ -285,6 +286,7 @@ def _register_devices(app: Flask, adapters: EngineServiceAdapters) -> None:
register_management(app, adapters)
register_admin_endpoints(app, adapters)
device_routes.register_agents(app, adapters)
register_tunnel(app, adapters)
def _register_filters(app: Flask, adapters: EngineServiceAdapters) -> None:
filters_management.register_filters(app, adapters)

View File

@@ -0,0 +1,138 @@
# ======================================================
# Data\Engine\services\API\devices\tunnel.py
# Description: Negotiation endpoint for reverse tunnel leases (operator-initiated; dormant until tunnel listener is wired).
#
# API Endpoints (if applicable):
# - POST /api/tunnel/request (Token Authenticated) - Allocates a reverse tunnel lease for the requested agent/protocol.
# ======================================================
"""Reverse tunnel negotiation API (Engine side)."""
from __future__ import annotations
import os
from typing import Any, Dict, Optional, Tuple
from flask import Blueprint, jsonify, request, session
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from ...WebSocket.Agent.ReverseTunnel import ReverseTunnelService
if False: # pragma: no cover - import cycle hint for type checkers
from .. import EngineServiceAdapters
def _current_user(app) -> Optional[Dict[str, str]]:
"""Resolve operator identity from session or signed token."""
username = session.get("username")
role = session.get("role") or "User"
if username:
return {"username": username, "role": role}
token = None
auth_header = request.headers.get("Authorization") or ""
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.cookies.get("borealis_auth")
if not token:
return None
try:
serializer = URLSafeTimedSerializer(app.secret_key or "borealis-dev-secret", salt="borealis-auth")
token_ttl = int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30))
data = serializer.loads(token, max_age=token_ttl)
username = data.get("u")
role = data.get("r") or "User"
if username:
return {"username": username, "role": role}
except (BadSignature, SignatureExpired, Exception):
return None
return None
def _require_login(app) -> Optional[Tuple[Dict[str, Any], int]]:
user = _current_user(app)
if not user:
return {"error": "unauthorized"}, 401
return None
def _get_tunnel_service(adapters: "EngineServiceAdapters") -> ReverseTunnelService:
service = getattr(adapters.context, "reverse_tunnel_service", None) or getattr(adapters, "_reverse_tunnel_service", None)
if service is None:
service = ReverseTunnelService(
adapters.context,
signer=getattr(adapters, "script_signer", None),
db_conn_factory=adapters.db_conn_factory,
socketio=getattr(adapters.context, "socketio", None),
)
service.start()
setattr(adapters, "_reverse_tunnel_service", service)
setattr(adapters.context, "reverse_tunnel_service", service)
return service
def _normalize_text(value: Any) -> str:
if value is None:
return ""
try:
return str(value).strip()
except Exception:
return ""
def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
"""Register reverse tunnel negotiation endpoints."""
blueprint = Blueprint("reverse_tunnel", __name__)
service_log = adapters.service_log
logger = adapters.context.logger.getChild("tunnel.api")
@blueprint.route("/api/tunnel/request", methods=["POST"])
def request_tunnel():
requirement = _require_login(app)
if requirement:
payload, status = requirement
return jsonify(payload), status
user = _current_user(app) or {}
operator_id = user.get("username") or None
body = request.get_json(silent=True) or {}
agent_id = _normalize_text(body.get("agent_id"))
protocol = _normalize_text(body.get("protocol") or "ps").lower() or "ps"
domain = _normalize_text(body.get("domain") or protocol).lower() or protocol
if not agent_id:
return jsonify({"error": "agent_id_required"}), 400
tunnel_service = _get_tunnel_service(adapters)
try:
lease = tunnel_service.request_lease(
agent_id=agent_id,
protocol=protocol,
domain=domain,
operator_id=operator_id,
)
except RuntimeError as exc:
message = str(exc)
if message.startswith("domain_limit:"):
domain_name = message.split(":", 1)[-1] if ":" in message else domain
return jsonify({"error": "domain_limit", "domain": domain_name}), 409
if message == "port_pool_exhausted":
return jsonify({"error": "port_pool_exhausted"}), 503
logger.warning("tunnel lease request failed for agent_id=%s: %s", agent_id, message)
return jsonify({"error": "lease_allocation_failed"}), 500
summary = tunnel_service.lease_summary(lease)
summary["fixed_port"] = tunnel_service.fixed_port
summary["heartbeat_seconds"] = tunnel_service.heartbeat_seconds
service_log(
"reverse_tunnel",
f"lease created tunnel_id={lease.tunnel_id} agent_id={lease.agent_id} domain={lease.domain} protocol={lease.protocol}",
)
return jsonify(summary), 200
app.register_blueprint(blueprint)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
# ======================================================
# Data\Engine\services\WebSocket\Agent\__init__.py
# Description: Package marker for Agent-facing WebSocket services (reverse tunnel scaffolding).
#
# API Endpoints (if applicable): None
# ======================================================
"""Agent-facing WebSocket services for the Engine runtime."""
__all__ = []

View File

@@ -8,6 +8,7 @@
"""WebSocket service registration for the Borealis Engine runtime."""
from __future__ import annotations
import base64
import sqlite3
import time
from dataclasses import dataclass, field
@@ -15,9 +16,16 @@ from pathlib import Path
from typing import Any, Callable, Dict, Optional
from flask_socketio import SocketIO
from flask import session, request
from ...database import initialise_engine_database
from ...server import EngineContext
from .Agent.ReverseTunnel import (
ReverseTunnelService,
TunnelBridge,
decode_frame,
TunnelFrame,
)
from ..API import _make_db_conn_factory, _make_service_logger
@@ -63,6 +71,16 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
adapters = EngineRealtimeAdapters(context)
logger = context.logger.getChild("realtime.quick_jobs")
tunnel_service = getattr(context, "reverse_tunnel_service", None)
if tunnel_service is None:
tunnel_service = ReverseTunnelService(
context,
signer=None,
db_conn_factory=adapters.db_conn_factory,
socketio=socket_server,
)
tunnel_service.start()
setattr(context, "reverse_tunnel_service", tunnel_service)
@socket_server.on("quick_job_result")
def _handle_quick_job_result(data: Any) -> None:
@@ -224,3 +242,163 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
job_id,
exc,
)
@socket_server.on("tunnel_bridge_attach")
def _tunnel_bridge_attach(data: Any) -> Any:
"""Placeholder operator bridge attach handler (no data channel yet)."""
if not isinstance(data, dict):
return {"error": "invalid_payload"}
tunnel_id = str(data.get("tunnel_id") or "").strip()
operator_id = str(data.get("operator_id") or "").strip() or None
if not tunnel_id:
return {"error": "tunnel_id_required"}
try:
tunnel_service.operator_attach(tunnel_id, operator_id)
except ValueError as exc:
return {"error": str(exc)}
except Exception as exc: # pragma: no cover - defensive guard
logger.debug("tunnel_bridge_attach failed tunnel_id=%s: %s", tunnel_id, exc, exc_info=True)
return {"error": "bridge_attach_failed"}
return {"status": "ok", "tunnel_id": tunnel_id, "operator_id": operator_id or "-"}
def _encode_frame(frame: TunnelFrame) -> str:
return base64.b64encode(frame.encode()).decode("ascii")
def _decode_frame_payload(raw: Any) -> TunnelFrame:
if isinstance(raw, str):
try:
raw_bytes = base64.b64decode(raw)
except Exception:
raise ValueError("invalid_frame")
elif isinstance(raw, (bytes, bytearray)):
raw_bytes = bytes(raw)
else:
raise ValueError("invalid_frame")
return decode_frame(raw_bytes)
@socket_server.on("tunnel_operator_send")
def _tunnel_operator_send(data: Any) -> Any:
"""Operator -> agent frame enqueue (placeholder queue)."""
if not isinstance(data, dict):
return {"error": "invalid_payload"}
tunnel_id = str(data.get("tunnel_id") or "").strip()
frame_raw = data.get("frame")
if not tunnel_id or frame_raw is None:
return {"error": "tunnel_id_and_frame_required"}
try:
frame = _decode_frame_payload(frame_raw)
except Exception as exc:
return {"error": str(exc)}
bridge: Optional[TunnelBridge] = tunnel_service.get_bridge(tunnel_id)
if bridge is None:
return {"error": "unknown_tunnel"}
bridge.operator_to_agent(frame)
return {"status": "ok"}
@socket_server.on("tunnel_operator_poll")
def _tunnel_operator_poll(data: Any) -> Any:
"""Operator polls queued frames from agent."""
tunnel_id = ""
if isinstance(data, dict):
tunnel_id = str(data.get("tunnel_id") or "").strip()
if not tunnel_id:
return {"error": "tunnel_id_required"}
bridge: Optional[TunnelBridge] = tunnel_service.get_bridge(tunnel_id)
if bridge is None:
return {"error": "unknown_tunnel"}
frames = []
while True:
frame = bridge.next_for_operator()
if frame is None:
break
frames.append(_encode_frame(frame))
return {"frames": frames}
# WebUI operator bridge namespace for browser clients
tunnel_namespace = "/tunnel"
_operator_sessions: Dict[str, str] = {}
def _current_operator() -> Optional[str]:
username = session.get("username")
if username:
return str(username)
auth_header = (request.headers.get("Authorization") or "").strip()
token = None
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.cookies.get("borealis_auth")
return token or None
@socket_server.on("join", namespace=tunnel_namespace)
def _ws_tunnel_join(data: Any) -> Any:
if not isinstance(data, dict):
return {"error": "invalid_payload"}
operator_id = _current_operator()
if not operator_id:
return {"error": "unauthorized"}
tunnel_id = str(data.get("tunnel_id") or "").strip()
if not tunnel_id:
return {"error": "tunnel_id_required"}
bridge = tunnel_service.get_bridge(tunnel_id)
if bridge is None:
return {"error": "unknown_tunnel"}
try:
tunnel_service.operator_attach(tunnel_id, operator_id)
except Exception as exc:
logger.debug("ws_tunnel_join failed tunnel_id=%s: %s", tunnel_id, exc, exc_info=True)
return {"error": "attach_failed"}
sid = request.sid
_operator_sessions[sid] = tunnel_id
return {"status": "ok", "tunnel_id": tunnel_id}
@socket_server.on("send", namespace=tunnel_namespace)
def _ws_tunnel_send(data: Any) -> Any:
sid = request.sid
tunnel_id = _operator_sessions.get(sid)
if not tunnel_id:
return {"error": "not_joined"}
if not isinstance(data, dict):
return {"error": "invalid_payload"}
frame_raw = data.get("frame")
if frame_raw is None:
return {"error": "frame_required"}
try:
frame = _decode_frame_payload(frame_raw)
except Exception:
return {"error": "invalid_frame"}
bridge = tunnel_service.get_bridge(tunnel_id)
if bridge is None:
return {"error": "unknown_tunnel"}
bridge.operator_to_agent(frame)
return {"status": "ok"}
@socket_server.on("poll", namespace=tunnel_namespace)
def _ws_tunnel_poll() -> Any:
sid = request.sid
tunnel_id = _operator_sessions.get(sid)
if not tunnel_id:
return {"error": "not_joined"}
bridge = tunnel_service.get_bridge(tunnel_id)
if bridge is None:
return {"error": "unknown_tunnel"}
frames = []
while True:
frame = bridge.next_for_operator()
if frame is None:
break
frames.append(_encode_frame(frame))
return {"frames": frames}
@socket_server.on("disconnect", namespace=tunnel_namespace)
def _ws_tunnel_disconnect():
sid = request.sid
_operator_sessions.pop(sid, None)

View File

@@ -917,11 +917,14 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, o
id: initialFilter?.id || initialFilter?.filter_id,
name: name.trim() || "Unnamed Filter",
site_scope: siteScope,
site_scope_value: primarySite,
site_scope_values: scopedSites,
sites: scopedSites,
site_ids: scopedSites,
site_names: siteScope === "scoped" ? selectedSiteLabels : [],
site: siteScope === "scoped" ? primarySite : null,
site_scope_value: primarySite,
scope: siteScope,
type: siteScope,
site: primarySite,
groups: groups.map((g, gIdx) => ({
join_with: gIdx === 0 ? null : g.joinWith || "OR",
conditions: (g.conditions || []).map((c, cIdx) => ({

View File

@@ -147,7 +147,13 @@ function normalizeFilters(raw) {
id: f.id || f.filter_id || `filter-${idx}`,
name: f.name || f.title || "Unnamed Filter",
type: (f.site_scope || f.scope || f.type || "global") === "scoped" ? "site" : "global",
site: f.site || f.site_scope || f.site_name || f.target_site || null,
site: (() => {
if (Array.isArray(f.site_scope_values) && f.site_scope_values.length) return f.site_scope_values.join(", ");
if (Array.isArray(f.sites) && f.sites.length) return f.sites.join(", ");
if (Array.isArray(f.site_ids) && f.site_ids.length) return f.site_ids.join(", ");
if (Array.isArray(f.site_names) && f.site_names.length) return f.site_names.join(", ");
return f.site || f.site_scope || f.site_name || f.target_site || null;
})(),
lastEditedBy: resolveLastEditor(f),
lastEdited: f.last_edited || f.updated_at || f.updated || f.created_at || null,
deviceCount: