mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2026-02-04 07:40:32 -07:00
More VPN Tunnel Changes
This commit is contained in:
@@ -24,9 +24,9 @@ ROLE_CONTEXTS = ["system"]
|
|||||||
|
|
||||||
|
|
||||||
def _log_path() -> Path:
|
def _log_path() -> Path:
|
||||||
root = Path(__file__).resolve().parents[2] / "Logs"
|
root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel"
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
return root / "reverse_tunnel.log"
|
return root / "remote_shell.log"
|
||||||
|
|
||||||
|
|
||||||
def _write_log(message: str) -> None:
|
def _write_log(message: str) -> None:
|
||||||
@@ -61,8 +61,13 @@ class ShellSession:
|
|||||||
self.address = address
|
self.address = address
|
||||||
self.proc: Optional[subprocess.Popen] = None
|
self.proc: Optional[subprocess.Popen] = None
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
|
self.input_messages = 0
|
||||||
|
self.input_bytes = 0
|
||||||
|
self.output_lines = 0
|
||||||
|
self.output_bytes = 0
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
|
_write_log(f"Shell session starting for {self.address[0]}:{self.address[1]}")
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
|
["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
@@ -82,16 +87,27 @@ class ShellSession:
|
|||||||
chunk = self.proc.stdout.readline()
|
chunk = self.proc.stdout.readline()
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
|
self.output_lines += 1
|
||||||
|
self.output_bytes += len(chunk)
|
||||||
payload = json.dumps({"type": "stdout", "data": _b64encode(chunk)})
|
payload = json.dumps({"type": "stdout", "data": _b64encode(chunk)})
|
||||||
self.conn.sendall(payload.encode("utf-8") + b"\n")
|
try:
|
||||||
except Exception:
|
self.conn.sendall(payload.encode("utf-8") + b"\n")
|
||||||
pass
|
except Exception as exc:
|
||||||
|
_write_log(f"Shell stdout send failed: {exc}")
|
||||||
|
break
|
||||||
|
_write_log(f"Shell stdout forwarded bytes={len(chunk)}")
|
||||||
|
except Exception as exc:
|
||||||
|
_write_log(f"Shell stdout error: {exc}")
|
||||||
|
|
||||||
def _writer_loop(self) -> None:
|
def _writer_loop(self) -> None:
|
||||||
buffer = b""
|
buffer = b""
|
||||||
try:
|
try:
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
data = self.conn.recv(4096)
|
try:
|
||||||
|
data = self.conn.recv(4096)
|
||||||
|
except Exception as exc:
|
||||||
|
_write_log(f"Shell stdin recv error: {exc}")
|
||||||
|
break
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
buffer += data
|
buffer += data
|
||||||
@@ -107,12 +123,17 @@ class ShellSession:
|
|||||||
payload = msg.get("data") or ""
|
payload = msg.get("data") or ""
|
||||||
if self.proc and self.proc.stdin:
|
if self.proc and self.proc.stdin:
|
||||||
try:
|
try:
|
||||||
self.proc.stdin.write(_b64decode(str(payload)))
|
decoded = _b64decode(str(payload))
|
||||||
|
self.proc.stdin.write(decoded)
|
||||||
self.proc.stdin.flush()
|
self.proc.stdin.flush()
|
||||||
|
self.input_messages += 1
|
||||||
|
self.input_bytes += len(decoded)
|
||||||
|
_write_log(f"Shell stdin received bytes={len(decoded)}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
_write_log("Shell stdin write failed.")
|
||||||
if msg.get("type") == "close":
|
if msg.get("type") == "close":
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
|
_write_log("Shell close requested by engine.")
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
self.close()
|
self.close()
|
||||||
@@ -128,6 +149,14 @@ class ShellSession:
|
|||||||
self.proc.terminate()
|
self.proc.terminate()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
_write_log(
|
||||||
|
"Shell session closed inputs={0} input_bytes={1} output_lines={2} output_bytes={3}".format(
|
||||||
|
self.input_messages,
|
||||||
|
self.input_bytes,
|
||||||
|
self.output_lines,
|
||||||
|
self.output_bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ShellServer:
|
class ShellServer:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
This role prepares the WireGuard client config, manages a single active
|
This role prepares the WireGuard client config, manages a single active
|
||||||
session, enforces idle teardown, and logs lifecycle events to
|
session, enforces idle teardown, and logs lifecycle events to
|
||||||
Agent/Logs/reverse_tunnel.log. It binds to Engine Socket.IO events
|
Agent/Logs/VPN_Tunnel/tunnel.log. It binds to Engine Socket.IO events
|
||||||
(`vpn_tunnel_start`, `vpn_tunnel_stop`, `vpn_tunnel_activity`) to start/stop
|
(`vpn_tunnel_start`, `vpn_tunnel_stop`, `vpn_tunnel_activity`) to start/stop
|
||||||
the client session with the issued config/token.
|
the client session with the issued config/token.
|
||||||
"""
|
"""
|
||||||
@@ -44,9 +44,9 @@ ROLE_CONTEXTS = ["system"]
|
|||||||
|
|
||||||
|
|
||||||
def _log_path() -> Path:
|
def _log_path() -> Path:
|
||||||
root = Path(__file__).resolve().parents[2] / "Logs"
|
root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel"
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
return root / "reverse_tunnel.log"
|
return root / "tunnel.log"
|
||||||
|
|
||||||
|
|
||||||
def _write_log(message: str) -> None:
|
def _write_log(message: str) -> None:
|
||||||
@@ -303,11 +303,15 @@ class Role:
|
|||||||
hooks = getattr(ctx, "hooks", {}) or {}
|
hooks = getattr(ctx, "hooks", {}) or {}
|
||||||
self._log_hook = hooks.get("log_agent")
|
self._log_hook = hooks.get("log_agent")
|
||||||
self._http_client_factory = hooks.get("http_client")
|
self._http_client_factory = hooks.get("http_client")
|
||||||
|
try:
|
||||||
|
self.client.stop_session(reason="agent_startup", ignore_missing=True)
|
||||||
|
except Exception:
|
||||||
|
self._log("Failed to preflight WireGuard session cleanup.", error=True)
|
||||||
|
|
||||||
def _log(self, message: str, *, error: bool = False) -> None:
|
def _log(self, message: str, *, error: bool = False) -> None:
|
||||||
if callable(self._log_hook):
|
if callable(self._log_hook):
|
||||||
try:
|
try:
|
||||||
self._log_hook(message, fname="reverse_tunnel.log")
|
self._log_hook(message, fname="VPN_Tunnel/tunnel.log")
|
||||||
if error:
|
if error:
|
||||||
self._log_hook(message, fname="agent.error.log")
|
self._log_hook(message, fname="agent.error.log")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -540,6 +540,9 @@ def _log_agent(message: str, fname: str = 'agent.log', *, scope: Optional[str] =
|
|||||||
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)
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
_rotate_daily(path)
|
_rotate_daily(path)
|
||||||
line = _format_agent_log_message(message, fname, scope)
|
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:
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ LOG_ROOT = PROJECT_ROOT / "Engine" / "Logs"
|
|||||||
LOG_FILE_PATH = LOG_ROOT / "engine.log"
|
LOG_FILE_PATH = LOG_ROOT / "engine.log"
|
||||||
ERROR_LOG_FILE_PATH = LOG_ROOT / "error.log"
|
ERROR_LOG_FILE_PATH = LOG_ROOT / "error.log"
|
||||||
API_LOG_FILE_PATH = LOG_ROOT / "api.log"
|
API_LOG_FILE_PATH = LOG_ROOT / "api.log"
|
||||||
VPN_TUNNEL_LOG_FILE_PATH = LOG_ROOT / "reverse_tunnel.log"
|
VPN_TUNNEL_LOG_FILE_PATH = LOG_ROOT / "VPN_Tunnel" / "tunnel.log"
|
||||||
DEFAULT_WIREGUARD_PORT = 30000
|
DEFAULT_WIREGUARD_PORT = 30000
|
||||||
DEFAULT_WIREGUARD_ENGINE_VIRTUAL_IP = "10.255.0.1/32"
|
DEFAULT_WIREGUARD_ENGINE_VIRTUAL_IP = "10.255.0.1/32"
|
||||||
DEFAULT_WIREGUARD_PEER_NETWORK = "10.255.0.0/24"
|
DEFAULT_WIREGUARD_PEER_NETWORK = "10.255.0.0/24"
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ def _make_service_logger(base: Path, logger: logging.Logger) -> Callable[[str, s
|
|||||||
try:
|
try:
|
||||||
base.mkdir(parents=True, exist_ok=True)
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
path = base / f"{service}.log"
|
path = base / f"{service}.log"
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
_rotate_daily(path)
|
_rotate_daily(path)
|
||||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
resolved_scope = _infer_server_scope(msg, scope)
|
resolved_scope = _infer_server_scope(msg, scope)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
# API Endpoints (if applicable):
|
# API Endpoints (if applicable):
|
||||||
# - POST /api/tunnel/connect (Token Authenticated) - Issues VPN session material for an agent.
|
# - POST /api/tunnel/connect (Token Authenticated) - Issues VPN session material for an agent.
|
||||||
# - GET /api/tunnel/status (Token Authenticated) - Returns VPN status for an agent.
|
# - GET /api/tunnel/status (Token Authenticated) - Returns VPN status for an agent.
|
||||||
|
# - GET /api/tunnel/active (Token Authenticated) - Lists active VPN tunnel sessions.
|
||||||
# - DELETE /api/tunnel/disconnect (Token Authenticated) - Tears down VPN session for an agent.
|
# - DELETE /api/tunnel/disconnect (Token Authenticated) - Tears down VPN session for an agent.
|
||||||
# ======================================================
|
# ======================================================
|
||||||
|
|
||||||
@@ -120,6 +121,21 @@ def _infer_endpoint_host(req) -> str:
|
|||||||
def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
|
def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
|
||||||
blueprint = Blueprint("vpn_tunnel", __name__)
|
blueprint = Blueprint("vpn_tunnel", __name__)
|
||||||
logger = adapters.context.logger.getChild("vpn_tunnel.api")
|
logger = adapters.context.logger.getChild("vpn_tunnel.api")
|
||||||
|
service_log = adapters.service_log
|
||||||
|
|
||||||
|
def _service_log_event(message: str, *, level: str = "INFO") -> None:
|
||||||
|
if not callable(service_log):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
service_log("VPN_Tunnel/tunnel", message, level=level)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("vpn_tunnel service log write failed", exc_info=True)
|
||||||
|
|
||||||
|
def _request_remote() -> str:
|
||||||
|
forwarded = (request.headers.get("X-Forwarded-For") or "").strip()
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return (request.remote_addr or "").strip()
|
||||||
|
|
||||||
@blueprint.route("/api/tunnel/connect", methods=["POST"])
|
@blueprint.route("/api/tunnel/connect", methods=["POST"])
|
||||||
def connect_tunnel():
|
def connect_tunnel():
|
||||||
@@ -139,15 +155,37 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
|
|||||||
try:
|
try:
|
||||||
tunnel_service = _get_tunnel_service(adapters)
|
tunnel_service = _get_tunnel_service(adapters)
|
||||||
endpoint_host = _infer_endpoint_host(request)
|
endpoint_host = _infer_endpoint_host(request)
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_connect_request agent_id={0} operator={1} endpoint_host={2} remote={3}".format(
|
||||||
|
agent_id,
|
||||||
|
operator_id or "-",
|
||||||
|
endpoint_host or "-",
|
||||||
|
_request_remote() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
payload = tunnel_service.connect(
|
payload = tunnel_service.connect(
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
operator_id=operator_id,
|
operator_id=operator_id,
|
||||||
endpoint_host=endpoint_host,
|
endpoint_host=endpoint_host,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_connect_failed agent_id={0} operator={1} error={2}".format(
|
||||||
|
agent_id,
|
||||||
|
operator_id or "-",
|
||||||
|
str(exc),
|
||||||
|
),
|
||||||
|
level="ERROR",
|
||||||
|
)
|
||||||
logger.warning("vpn connect failed for agent_id=%s: %s", agent_id, exc)
|
logger.warning("vpn connect failed for agent_id=%s: %s", agent_id, exc)
|
||||||
return jsonify({"error": "connect_failed", "detail": str(exc)}), 500
|
return jsonify({"error": "connect_failed", "detail": str(exc)}), 500
|
||||||
|
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_connect_response agent_id={0} tunnel_id={1} status=ok".format(
|
||||||
|
payload.get("agent_id", agent_id),
|
||||||
|
payload.get("tunnel_id", "-"),
|
||||||
|
)
|
||||||
|
)
|
||||||
return jsonify(payload), 200
|
return jsonify(payload), 200
|
||||||
|
|
||||||
@blueprint.route("/api/tunnel/status", methods=["GET"])
|
@blueprint.route("/api/tunnel/status", methods=["GET"])
|
||||||
@@ -163,18 +201,51 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
|
|||||||
|
|
||||||
tunnel_service = _get_tunnel_service(adapters)
|
tunnel_service = _get_tunnel_service(adapters)
|
||||||
payload = tunnel_service.status(agent_id)
|
payload = tunnel_service.status(agent_id)
|
||||||
|
bump = _normalize_text(request.args.get("bump") or "")
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_status_request agent_id={0} bump={1} remote={2}".format(
|
||||||
|
agent_id,
|
||||||
|
"true" if bump else "false",
|
||||||
|
_request_remote() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
if not payload:
|
if not payload:
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_status_response agent_id={0} status=down".format(agent_id)
|
||||||
|
)
|
||||||
return jsonify({"status": "down", "agent_id": agent_id}), 200
|
return jsonify({"status": "down", "agent_id": agent_id}), 200
|
||||||
payload["status"] = "up"
|
payload["status"] = "up"
|
||||||
bump = _normalize_text(request.args.get("bump") or "")
|
|
||||||
if bump:
|
if bump:
|
||||||
tunnel_service.bump_activity(agent_id)
|
tunnel_service.bump_activity(agent_id)
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_status_response agent_id={0} status=up tunnel_id={1}".format(
|
||||||
|
agent_id,
|
||||||
|
payload.get("tunnel_id", "-"),
|
||||||
|
)
|
||||||
|
)
|
||||||
return jsonify(payload), 200
|
return jsonify(payload), 200
|
||||||
|
|
||||||
@blueprint.route("/api/tunnel/connect/status", methods=["GET"])
|
@blueprint.route("/api/tunnel/connect/status", methods=["GET"])
|
||||||
def tunnel_connect_status():
|
def tunnel_connect_status():
|
||||||
return tunnel_status()
|
return tunnel_status()
|
||||||
|
|
||||||
|
@blueprint.route("/api/tunnel/active", methods=["GET"])
|
||||||
|
def tunnel_active():
|
||||||
|
requirement = _require_login(app)
|
||||||
|
if requirement:
|
||||||
|
payload, status = requirement
|
||||||
|
return jsonify(payload), status
|
||||||
|
|
||||||
|
tunnel_service = _get_tunnel_service(adapters)
|
||||||
|
sessions = list(tunnel_service.list_sessions())
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_active_response count={0} remote={1}".format(
|
||||||
|
len(sessions),
|
||||||
|
_request_remote() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({"count": len(sessions), "tunnels": sessions}), 200
|
||||||
|
|
||||||
@blueprint.route("/api/tunnel/disconnect", methods=["DELETE"])
|
@blueprint.route("/api/tunnel/disconnect", methods=["DELETE"])
|
||||||
def disconnect_tunnel():
|
def disconnect_tunnel():
|
||||||
requirement = _require_login(app)
|
requirement = _require_login(app)
|
||||||
@@ -188,6 +259,15 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
|
|||||||
reason = _normalize_text(body.get("reason") or "operator_stop")
|
reason = _normalize_text(body.get("reason") or "operator_stop")
|
||||||
|
|
||||||
tunnel_service = _get_tunnel_service(adapters)
|
tunnel_service = _get_tunnel_service(adapters)
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_disconnect_request agent_id={0} tunnel_id={1} reason={2} operator={3} remote={4}".format(
|
||||||
|
agent_id or "-",
|
||||||
|
tunnel_id or "-",
|
||||||
|
reason or "-",
|
||||||
|
(_current_user(app) or {}).get("username") or "-",
|
||||||
|
_request_remote() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
stopped = False
|
stopped = False
|
||||||
if tunnel_id:
|
if tunnel_id:
|
||||||
stopped = tunnel_service.disconnect_by_tunnel(tunnel_id, reason=reason)
|
stopped = tunnel_service.disconnect_by_tunnel(tunnel_id, reason=reason)
|
||||||
@@ -197,8 +277,21 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
|
|||||||
return jsonify({"error": "agent_id_required"}), 400
|
return jsonify({"error": "agent_id_required"}), 400
|
||||||
|
|
||||||
if not stopped:
|
if not stopped:
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_disconnect_not_found agent_id={0} tunnel_id={1}".format(
|
||||||
|
agent_id or "-",
|
||||||
|
tunnel_id or "-",
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
return jsonify({"error": "not_found"}), 404
|
return jsonify({"error": "not_found"}), 404
|
||||||
|
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_api_disconnect_response agent_id={0} tunnel_id={1} status=stopped".format(
|
||||||
|
agent_id or "-",
|
||||||
|
tunnel_id or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
return jsonify({"status": "stopped", "reason": reason}), 200
|
return jsonify({"status": "stopped", "reason": reason}), 200
|
||||||
|
|
||||||
app.register_blueprint(blueprint)
|
app.register_blueprint(blueprint)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ class VpnTunnelService:
|
|||||||
self._sessions_by_tunnel: Dict[str, VpnSession] = {}
|
self._sessions_by_tunnel: Dict[str, VpnSession] = {}
|
||||||
self._engine_ip = ipaddress.ip_interface(context.wireguard_engine_virtual_ip)
|
self._engine_ip = ipaddress.ip_interface(context.wireguard_engine_virtual_ip)
|
||||||
self._peer_network = ipaddress.ip_network(context.wireguard_peer_network, strict=False)
|
self._peer_network = ipaddress.ip_network(context.wireguard_peer_network, strict=False)
|
||||||
|
self._cleanup_listener()
|
||||||
self._idle_thread = threading.Thread(target=self._idle_loop, daemon=True)
|
self._idle_thread = threading.Thread(target=self._idle_loop, daemon=True)
|
||||||
self._idle_thread.start()
|
self._idle_thread.start()
|
||||||
|
|
||||||
@@ -79,6 +81,15 @@ class VpnTunnelService:
|
|||||||
if session.last_activity + self.idle_seconds <= now:
|
if session.last_activity + self.idle_seconds <= now:
|
||||||
expired.append(session)
|
expired.append(session)
|
||||||
for session in expired:
|
for session in expired:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_idle_timeout agent_id={0} tunnel_id={1} last_activity={2} last_activity_iso={3} idle_seconds={4}".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
int(session.last_activity),
|
||||||
|
self._ts_to_iso(session.last_activity),
|
||||||
|
self.idle_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
self.disconnect(session.agent_id, reason="idle_timeout")
|
self.disconnect(session.agent_id, reason="idle_timeout")
|
||||||
|
|
||||||
def _allocate_virtual_ip(self, agent_id: str) -> str:
|
def _allocate_virtual_ip(self, agent_id: str) -> str:
|
||||||
@@ -200,13 +211,27 @@ class VpnTunnelService:
|
|||||||
return f"[{host}]"
|
return f"[{host}]"
|
||||||
return host
|
return host
|
||||||
|
|
||||||
|
def _ts_to_iso(self, ts: float) -> str:
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(ts, timezone.utc).isoformat()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
def _service_log_event(self, message: str, *, level: str = "INFO") -> None:
|
def _service_log_event(self, message: str, *, level: str = "INFO") -> None:
|
||||||
if not callable(self.service_log):
|
if not callable(self.service_log):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self.service_log("reverse_tunnel", message, level=level)
|
self.service_log("VPN_Tunnel/tunnel", message, level=level)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.debug("Failed to write reverse_tunnel service log entry", exc_info=True)
|
self.logger.debug("Failed to write vpn_tunnel service log entry", exc_info=True)
|
||||||
|
|
||||||
|
def _cleanup_listener(self) -> None:
|
||||||
|
try:
|
||||||
|
self.wg.stop_listener(ignore_missing=True)
|
||||||
|
self._service_log_event("vpn_listener_cleanup reason=startup")
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Failed to clean up WireGuard listener on startup.", exc_info=True)
|
||||||
|
self._service_log_event("vpn_listener_cleanup_failed reason=startup", level="WARNING")
|
||||||
|
|
||||||
def _refresh_listener(self) -> None:
|
def _refresh_listener(self) -> None:
|
||||||
peers: List[Mapping[str, object]] = []
|
peers: List[Mapping[str, object]] = []
|
||||||
@@ -220,8 +245,11 @@ class VpnTunnelService:
|
|||||||
peer["public_key"] = session.client_public_key
|
peer["public_key"] = session.client_public_key
|
||||||
peers.append(peer)
|
peers.append(peer)
|
||||||
if not peers:
|
if not peers:
|
||||||
|
self._service_log_event("vpn_listener_stop reason=no_peers")
|
||||||
self.wg.stop_listener()
|
self.wg.stop_listener()
|
||||||
return
|
return
|
||||||
|
agent_list = ",".join(str(peer.get("agent_id", "")) for peer in peers if peer.get("agent_id"))
|
||||||
|
self._service_log_event("vpn_listener_start peers={0} agents={1}".format(len(peers), agent_list))
|
||||||
self.wg.start_listener(peers)
|
self.wg.start_listener(peers)
|
||||||
|
|
||||||
def connect(
|
def connect(
|
||||||
@@ -233,6 +261,14 @@ class VpnTunnelService:
|
|||||||
) -> Mapping[str, Any]:
|
) -> Mapping[str, Any]:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
normalized_host = self._normalize_endpoint_host(endpoint_host)
|
normalized_host = self._normalize_endpoint_host(endpoint_host)
|
||||||
|
operator_text = operator_id or "-"
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_connect_request agent_id={0} operator={1} endpoint_host={2}".format(
|
||||||
|
agent_id or "-",
|
||||||
|
operator_text,
|
||||||
|
normalized_host or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
existing = self._sessions_by_agent.get(agent_id)
|
existing = self._sessions_by_agent.get(agent_id)
|
||||||
if existing:
|
if existing:
|
||||||
@@ -241,7 +277,18 @@ class VpnTunnelService:
|
|||||||
if normalized_host and not existing.endpoint_host:
|
if normalized_host and not existing.endpoint_host:
|
||||||
existing.endpoint_host = normalized_host
|
existing.endpoint_host = normalized_host
|
||||||
existing.last_activity = now
|
existing.last_activity = now
|
||||||
|
previous_expiry = existing.expires_at
|
||||||
self._ensure_token(existing, now=now)
|
self._ensure_token(existing, now=now)
|
||||||
|
refreshed = existing.expires_at != previous_expiry
|
||||||
|
operator_list = ",".join(sorted(filter(None, existing.operator_ids))) or "-"
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_session_reuse agent_id={0} tunnel_id={1} operators={2} token_refreshed={3}".format(
|
||||||
|
existing.agent_id,
|
||||||
|
existing.tunnel_id,
|
||||||
|
operator_list,
|
||||||
|
str(refreshed).lower(),
|
||||||
|
)
|
||||||
|
)
|
||||||
return self._session_payload(existing)
|
return self._session_payload(existing)
|
||||||
|
|
||||||
tunnel_id = uuid.uuid4().hex
|
tunnel_id = uuid.uuid4().hex
|
||||||
@@ -250,6 +297,7 @@ class VpnTunnelService:
|
|||||||
client_private, client_public = self._generate_client_keys()
|
client_private, client_public = self._generate_client_keys()
|
||||||
token = self._issue_token(agent_id, tunnel_id, now + 300)
|
token = self._issue_token(agent_id, tunnel_id, now + 300)
|
||||||
self.wg.require_orchestration_token(token)
|
self.wg.require_orchestration_token(token)
|
||||||
|
token_signed = "signature" in token
|
||||||
|
|
||||||
session = VpnSession(
|
session = VpnSession(
|
||||||
tunnel_id=tunnel_id,
|
tunnel_id=tunnel_id,
|
||||||
@@ -270,6 +318,16 @@ class VpnTunnelService:
|
|||||||
self._sessions_by_tunnel[tunnel_id] = session
|
self._sessions_by_tunnel[tunnel_id] = session
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_session_create agent_id={0} tunnel_id={1} virtual_ip={2} allowed_ports={3} token_signed={4} token_expires={5}".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
session.virtual_ip,
|
||||||
|
",".join(str(p) for p in allowed_ports),
|
||||||
|
str(bool(token_signed)).lower(),
|
||||||
|
int(session.expires_at),
|
||||||
|
)
|
||||||
|
)
|
||||||
self._refresh_listener()
|
self._refresh_listener()
|
||||||
|
|
||||||
peer = self.wg.build_peer_profile(
|
peer = self.wg.build_peer_profile(
|
||||||
@@ -279,7 +337,18 @@ class VpnTunnelService:
|
|||||||
)
|
)
|
||||||
rule_names = self.wg.apply_firewall_rules(peer)
|
rule_names = self.wg.apply_firewall_rules(peer)
|
||||||
session.firewall_rules = rule_names
|
session.firewall_rules = rule_names
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_firewall_applied agent_id={0} tunnel_id={1} rules={2}".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
len(rule_names),
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_connect_failed agent_id={0} tunnel_id={1}".format(agent_id, tunnel_id),
|
||||||
|
level="ERROR",
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._sessions_by_agent.pop(agent_id, None)
|
self._sessions_by_agent.pop(agent_id, None)
|
||||||
self._sessions_by_tunnel.pop(tunnel_id, None)
|
self._sessions_by_tunnel.pop(tunnel_id, None)
|
||||||
@@ -312,6 +381,11 @@ class VpnTunnelService:
|
|||||||
return None
|
return None
|
||||||
return self._session_payload(session, include_token=False)
|
return self._session_payload(session, include_token=False)
|
||||||
|
|
||||||
|
def list_sessions(self) -> List[Mapping[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
sessions = sorted(self._sessions_by_agent.values(), key=lambda s: s.agent_id)
|
||||||
|
return [self._session_summary(session) for session in sessions]
|
||||||
|
|
||||||
def session_payload(self, agent_id: str, *, include_token: bool = True) -> Optional[Mapping[str, Any]]:
|
def session_payload(self, agent_id: str, *, include_token: bool = True) -> Optional[Mapping[str, Any]]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
session = self._sessions_by_agent.get(agent_id)
|
session = self._sessions_by_agent.get(agent_id)
|
||||||
@@ -324,7 +398,14 @@ class VpnTunnelService:
|
|||||||
def request_agent_start(self, agent_id: str) -> Optional[Mapping[str, Any]]:
|
def request_agent_start(self, agent_id: str) -> Optional[Mapping[str, Any]]:
|
||||||
payload = self.session_payload(agent_id, include_token=True)
|
payload = self.session_payload(agent_id, include_token=True)
|
||||||
if not payload:
|
if not payload:
|
||||||
|
self._service_log_event("vpn_tunnel_agent_start_missing agent_id={0}".format(agent_id or "-"))
|
||||||
return None
|
return None
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_agent_start_emit agent_id={0} tunnel_id={1}".format(
|
||||||
|
payload.get("agent_id", "-"),
|
||||||
|
payload.get("tunnel_id", "-"),
|
||||||
|
)
|
||||||
|
)
|
||||||
self._emit_start(payload)
|
self._emit_start(payload)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -333,7 +414,18 @@ class VpnTunnelService:
|
|||||||
session = self._sessions_by_agent.get(agent_id)
|
session = self._sessions_by_agent.get(agent_id)
|
||||||
if not session:
|
if not session:
|
||||||
return
|
return
|
||||||
session.last_activity = time.time()
|
now = time.time()
|
||||||
|
previous = session.last_activity
|
||||||
|
session.last_activity = now
|
||||||
|
idle_for = now - previous
|
||||||
|
if idle_for >= 60:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_activity_bump agent_id={0} tunnel_id={1} idle_for={2}".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
int(idle_for),
|
||||||
|
)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if self.socketio:
|
if self.socketio:
|
||||||
self.socketio.emit("vpn_tunnel_activity", {"agent_id": agent_id}, namespace="/")
|
self.socketio.emit("vpn_tunnel_activity", {"agent_id": agent_id}, namespace="/")
|
||||||
@@ -344,6 +436,9 @@ class VpnTunnelService:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
session = self._sessions_by_agent.pop(agent_id, None)
|
session = self._sessions_by_agent.pop(agent_id, None)
|
||||||
if not session:
|
if not session:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_disconnect_missing agent_id={0} reason={1}".format(agent_id or "-", reason or "-")
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
self._sessions_by_tunnel.pop(session.tunnel_id, None)
|
self._sessions_by_tunnel.pop(session.tunnel_id, None)
|
||||||
|
|
||||||
@@ -370,6 +465,9 @@ class VpnTunnelService:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
session = self._sessions_by_tunnel.get(tunnel_id)
|
session = self._sessions_by_tunnel.get(tunnel_id)
|
||||||
if not session:
|
if not session:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_disconnect_missing tunnel_id={0} reason={1}".format(tunnel_id or "-", reason or "-")
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
return self.disconnect(session.agent_id, reason=reason)
|
return self.disconnect(session.agent_id, reason=reason)
|
||||||
|
|
||||||
@@ -383,13 +481,27 @@ class VpnTunnelService:
|
|||||||
if agent_id and callable(emit_agent):
|
if agent_id and callable(emit_agent):
|
||||||
try:
|
try:
|
||||||
if emit_agent(agent_id, "vpn_tunnel_start", payload):
|
if emit_agent(agent_id, "vpn_tunnel_start", payload):
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_start_emit agent_id={0} transport=direct".format(agent_id or "-")
|
||||||
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.debug("emit_agent_event failed for vpn_tunnel_start", exc_info=True)
|
self.logger.debug("emit_agent_event failed for vpn_tunnel_start", exc_info=True)
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_start_emit_failed agent_id={0} transport=direct".format(agent_id or "-"),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_start_emit agent_id={0} transport=broadcast".format(agent_id or "-")
|
||||||
|
)
|
||||||
self.socketio.emit("vpn_tunnel_start", payload, namespace="/")
|
self.socketio.emit("vpn_tunnel_start", payload, namespace="/")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.debug("vpn_tunnel_start emit failed", exc_info=True)
|
self.logger.debug("vpn_tunnel_start emit failed", exc_info=True)
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_start_emit_failed agent_id={0} transport=broadcast".format(agent_id or "-"),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
|
|
||||||
def _emit_stop(self, session: VpnSession, reason: str) -> None:
|
def _emit_stop(self, session: VpnSession, reason: str) -> None:
|
||||||
if not self.socketio:
|
if not self.socketio:
|
||||||
@@ -402,10 +514,29 @@ class VpnTunnelService:
|
|||||||
"vpn_tunnel_stop",
|
"vpn_tunnel_stop",
|
||||||
{"agent_id": session.agent_id, "tunnel_id": session.tunnel_id, "reason": reason},
|
{"agent_id": session.agent_id, "tunnel_id": session.tunnel_id, "reason": reason},
|
||||||
):
|
):
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_stop_emit agent_id={0} tunnel_id={1} transport=direct".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.debug("emit_agent_event failed for vpn_tunnel_stop", exc_info=True)
|
self.logger.debug("emit_agent_event failed for vpn_tunnel_stop", exc_info=True)
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_stop_emit_failed agent_id={0} tunnel_id={1} transport=direct".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_stop_emit agent_id={0} tunnel_id={1} transport=broadcast".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
self.socketio.emit(
|
self.socketio.emit(
|
||||||
"vpn_tunnel_stop",
|
"vpn_tunnel_stop",
|
||||||
{"agent_id": session.agent_id, "tunnel_id": session.tunnel_id, "reason": reason},
|
{"agent_id": session.agent_id, "tunnel_id": session.tunnel_id, "reason": reason},
|
||||||
@@ -413,6 +544,13 @@ class VpnTunnelService:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.debug("vpn_tunnel_stop emit failed", exc_info=True)
|
self.logger.debug("vpn_tunnel_stop emit failed", exc_info=True)
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_stop_emit_failed agent_id={0} tunnel_id={1} transport=broadcast".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
|
|
||||||
def _log_device_activity(self, session: VpnSession, *, event: str, reason: Optional[str] = None) -> None:
|
def _log_device_activity(self, session: VpnSession, *, event: str, reason: Optional[str] = None) -> None:
|
||||||
if self.db_conn_factory is None:
|
if self.db_conn_factory is None:
|
||||||
@@ -573,3 +711,24 @@ class VpnTunnelService:
|
|||||||
if include_token:
|
if include_token:
|
||||||
payload["token"] = session.token
|
payload["token"] = session.token
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
def _session_summary(self, session: VpnSession) -> Mapping[str, Any]:
|
||||||
|
endpoint_host = session.endpoint_host or str(self._engine_ip.ip)
|
||||||
|
endpoint_host = self._format_endpoint_host(endpoint_host)
|
||||||
|
return {
|
||||||
|
"tunnel_id": session.tunnel_id,
|
||||||
|
"agent_id": session.agent_id,
|
||||||
|
"virtual_ip": session.virtual_ip,
|
||||||
|
"engine_virtual_ip": str(self._engine_ip.ip),
|
||||||
|
"endpoint": f"{endpoint_host}:{self.context.wireguard_port}",
|
||||||
|
"allowed_ports": list(session.allowed_ports),
|
||||||
|
"connected_operators": len([o for o in session.operator_ids if o]),
|
||||||
|
"created_at": int(session.created_at),
|
||||||
|
"created_at_iso": self._ts_to_iso(session.created_at),
|
||||||
|
"last_activity": int(session.last_activity),
|
||||||
|
"last_activity_iso": self._ts_to_iso(session.last_activity),
|
||||||
|
"expires_at": int(session.expires_at),
|
||||||
|
"expires_at_iso": self._ts_to_iso(session.expires_at),
|
||||||
|
"idle_seconds": self.idle_seconds,
|
||||||
|
"status": "up",
|
||||||
|
}
|
||||||
|
|||||||
@@ -336,12 +336,16 @@ class WireGuardServerManager:
|
|||||||
raise RuntimeError(f"WireGuard installtunnelservice failed: {err}")
|
raise RuntimeError(f"WireGuard installtunnelservice failed: {err}")
|
||||||
self.logger.info("WireGuard listener installed (service=%s)", config_path.stem)
|
self.logger.info("WireGuard listener installed (service=%s)", config_path.stem)
|
||||||
|
|
||||||
def stop_listener(self) -> None:
|
def stop_listener(self, *, ignore_missing: bool = False) -> None:
|
||||||
"""Stop and remove the WireGuard tunnel service."""
|
"""Stop and remove the WireGuard tunnel service."""
|
||||||
|
|
||||||
args = [self._wireguard_exe, "/uninstalltunnelservice", self._service_name]
|
args = [self._wireguard_exe, "/uninstalltunnelservice", self._service_name]
|
||||||
code, out, err = self._run_command(args)
|
code, out, err = self._run_command(args)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
|
err_text = " ".join([out or "", err or ""]).strip().lower()
|
||||||
|
if ignore_missing and ("does not exist" in err_text or "not exist" in err_text):
|
||||||
|
self.logger.info("WireGuard tunnel service already absent")
|
||||||
|
return
|
||||||
self.logger.warning("Failed to uninstall WireGuard tunnel service code=%s err=%s", code, err)
|
self.logger.warning("Failed to uninstall WireGuard tunnel service code=%s err=%s", code, err)
|
||||||
else:
|
else:
|
||||||
self.logger.info("WireGuard tunnel service removed")
|
self.logger.info("WireGuard tunnel service removed")
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
|
|||||||
adapters = EngineRealtimeAdapters(context)
|
adapters = EngineRealtimeAdapters(context)
|
||||||
logger = context.logger.getChild("realtime.quick_jobs")
|
logger = context.logger.getChild("realtime.quick_jobs")
|
||||||
agent_logger = context.logger.getChild("realtime.agents")
|
agent_logger = context.logger.getChild("realtime.agents")
|
||||||
shell_bridge = VpnShellBridge(socket_server, context)
|
shell_bridge = VpnShellBridge(socket_server, context, adapters.service_log)
|
||||||
agent_registry = AgentSocketRegistry(socket_server, agent_logger)
|
agent_registry = AgentSocketRegistry(socket_server, agent_logger)
|
||||||
|
|
||||||
def _emit_agent_event(agent_id: str, event: str, payload: Any) -> bool:
|
def _emit_agent_event(agent_id: str, event: str, payload: Any) -> bool:
|
||||||
@@ -148,6 +148,24 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
|
|||||||
setattr(context, "vpn_tunnel_service", service)
|
setattr(context, "vpn_tunnel_service", service)
|
||||||
return service
|
return service
|
||||||
|
|
||||||
|
def _tunnel_log(message: str, *, level: str = "INFO") -> None:
|
||||||
|
try:
|
||||||
|
adapters.service_log("VPN_Tunnel/tunnel", message, level=level)
|
||||||
|
except Exception:
|
||||||
|
agent_logger.debug("vpn_tunnel service log write failed", exc_info=True)
|
||||||
|
|
||||||
|
def _shell_log(message: str, *, level: str = "INFO") -> None:
|
||||||
|
try:
|
||||||
|
adapters.service_log("VPN_Tunnel/remote_shell", message, level=level)
|
||||||
|
except Exception:
|
||||||
|
agent_logger.debug("vpn_shell service log write failed", exc_info=True)
|
||||||
|
|
||||||
|
def _remote_addr() -> str:
|
||||||
|
forwarded = (request.headers.get("X-Forwarded-For") or "").strip()
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return (request.remote_addr or "").strip()
|
||||||
|
|
||||||
@socket_server.on("quick_job_result")
|
@socket_server.on("quick_job_result")
|
||||||
def _handle_quick_job_result(data: Any) -> None:
|
def _handle_quick_job_result(data: Any) -> None:
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -317,18 +335,59 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
|
|||||||
elif isinstance(data, str):
|
elif isinstance(data, str):
|
||||||
agent_id = data.strip()
|
agent_id = data.strip()
|
||||||
if not agent_id:
|
if not agent_id:
|
||||||
|
_shell_log(
|
||||||
|
"vpn_shell_open_missing sid={0} remote={1}".format(
|
||||||
|
request.sid,
|
||||||
|
_remote_addr() or "-",
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
return {"error": "agent_id_required"}
|
return {"error": "agent_id_required"}
|
||||||
|
|
||||||
|
_shell_log(
|
||||||
|
"vpn_shell_open_request agent_id={0} sid={1} remote={2}".format(
|
||||||
|
agent_id,
|
||||||
|
request.sid,
|
||||||
|
_remote_addr() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
service = _get_tunnel_service()
|
service = _get_tunnel_service()
|
||||||
if service is None:
|
if service is None:
|
||||||
|
_shell_log(
|
||||||
|
"vpn_shell_open_failed agent_id={0} sid={1} reason=vpn_service_unavailable".format(
|
||||||
|
agent_id,
|
||||||
|
request.sid,
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
return {"error": "vpn_service_unavailable"}
|
return {"error": "vpn_service_unavailable"}
|
||||||
if not service.status(agent_id):
|
if not service.status(agent_id):
|
||||||
|
_shell_log(
|
||||||
|
"vpn_shell_open_failed agent_id={0} sid={1} reason=tunnel_down".format(
|
||||||
|
agent_id,
|
||||||
|
request.sid,
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
return {"error": "tunnel_down"}
|
return {"error": "tunnel_down"}
|
||||||
|
|
||||||
session = shell_bridge.open_session(request.sid, agent_id)
|
session = shell_bridge.open_session(request.sid, agent_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
|
_shell_log(
|
||||||
|
"vpn_shell_open_failed agent_id={0} sid={1} reason=shell_connect_failed".format(
|
||||||
|
agent_id,
|
||||||
|
request.sid,
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
return {"error": "shell_connect_failed"}
|
return {"error": "shell_connect_failed"}
|
||||||
service.bump_activity(agent_id)
|
service.bump_activity(agent_id)
|
||||||
|
_shell_log(
|
||||||
|
"vpn_shell_open_success agent_id={0} sid={1}".format(
|
||||||
|
agent_id,
|
||||||
|
request.sid,
|
||||||
|
)
|
||||||
|
)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@socket_server.on("connect_agent")
|
@socket_server.on("connect_agent")
|
||||||
@@ -341,16 +400,38 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
|
|||||||
elif isinstance(data, str):
|
elif isinstance(data, str):
|
||||||
agent_id = data.strip()
|
agent_id = data.strip()
|
||||||
if not agent_id:
|
if not agent_id:
|
||||||
|
_tunnel_log(
|
||||||
|
"vpn_agent_socket_missing sid={0} remote={1}".format(
|
||||||
|
request.sid,
|
||||||
|
_remote_addr() or "-",
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
return {"error": "agent_id_required"}
|
return {"error": "agent_id_required"}
|
||||||
|
|
||||||
agent_registry.register(agent_id, request.sid)
|
agent_registry.register(agent_id, request.sid)
|
||||||
agent_logger.info("Agent socket registered agent_id=%s service_mode=%s sid=%s", agent_id, service_mode, request.sid)
|
agent_logger.info("Agent socket registered agent_id=%s service_mode=%s sid=%s", agent_id, service_mode, request.sid)
|
||||||
|
_tunnel_log(
|
||||||
|
"vpn_agent_socket_register agent_id={0} service_mode={1} sid={2} remote={3}".format(
|
||||||
|
agent_id,
|
||||||
|
service_mode or "-",
|
||||||
|
request.sid,
|
||||||
|
_remote_addr() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
service = _get_tunnel_service()
|
service = _get_tunnel_service()
|
||||||
if service:
|
if service:
|
||||||
payload = service.session_payload(agent_id, include_token=True)
|
payload = service.session_payload(agent_id, include_token=True)
|
||||||
if payload:
|
if payload:
|
||||||
agent_registry.emit(agent_id, "vpn_tunnel_start", payload)
|
if agent_registry.emit(agent_id, "vpn_tunnel_start", payload):
|
||||||
|
_tunnel_log(
|
||||||
|
"vpn_agent_socket_emit_start agent_id={0} tunnel_id={1} sid={2}".format(
|
||||||
|
agent_id,
|
||||||
|
payload.get("tunnel_id", "-"),
|
||||||
|
request.sid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -363,11 +444,28 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
|
|||||||
payload = data
|
payload = data
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return {"error": "payload_required"}
|
return {"error": "payload_required"}
|
||||||
|
try:
|
||||||
|
payload_len = len(str(payload))
|
||||||
|
except Exception:
|
||||||
|
payload_len = 0
|
||||||
|
_shell_log(
|
||||||
|
"vpn_shell_send_request sid={0} bytes={1} remote={2}".format(
|
||||||
|
request.sid,
|
||||||
|
payload_len,
|
||||||
|
_remote_addr() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
shell_bridge.send(request.sid, str(payload))
|
shell_bridge.send(request.sid, str(payload))
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@socket_server.on("vpn_shell_close")
|
@socket_server.on("vpn_shell_close")
|
||||||
def _vpn_shell_close(data: Any = None) -> Dict[str, Any]:
|
def _vpn_shell_close(data: Any = None) -> Dict[str, Any]:
|
||||||
|
_shell_log(
|
||||||
|
"vpn_shell_close_request sid={0} remote={1}".format(
|
||||||
|
request.sid,
|
||||||
|
_remote_addr() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
shell_bridge.close(request.sid)
|
shell_bridge.close(request.sid)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -376,4 +474,18 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
|
|||||||
agent_id = agent_registry.unregister(request.sid)
|
agent_id = agent_registry.unregister(request.sid)
|
||||||
if agent_id:
|
if agent_id:
|
||||||
agent_logger.info("Agent socket disconnected agent_id=%s sid=%s", agent_id, request.sid)
|
agent_logger.info("Agent socket disconnected agent_id=%s sid=%s", agent_id, request.sid)
|
||||||
|
_tunnel_log(
|
||||||
|
"vpn_agent_socket_disconnect agent_id={0} sid={1}".format(
|
||||||
|
agent_id,
|
||||||
|
request.sid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_shell_log(
|
||||||
|
"vpn_shell_client_disconnect sid={0} remote={1}".format(
|
||||||
|
request.sid,
|
||||||
|
_remote_addr() or "-",
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
shell_bridge.close(request.sid)
|
shell_bridge.close(request.sid)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import socket
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
def _b64encode(data: bytes) -> str:
|
def _b64encode(data: bytes) -> str:
|
||||||
@@ -32,6 +32,11 @@ class ShellSession:
|
|||||||
agent_id: str
|
agent_id: str
|
||||||
socketio: Any
|
socketio: Any
|
||||||
tcp: socket.socket
|
tcp: socket.socket
|
||||||
|
service_log: Optional[Callable[[str, str, Optional[str]], None]] = None
|
||||||
|
output_lines: int = 0
|
||||||
|
output_bytes: int = 0
|
||||||
|
input_messages: int = 0
|
||||||
|
input_bytes: int = 0
|
||||||
_reader: Optional[threading.Thread] = None
|
_reader: Optional[threading.Thread] = None
|
||||||
|
|
||||||
def start_reader(self) -> None:
|
def start_reader(self) -> None:
|
||||||
@@ -39,15 +44,31 @@ class ShellSession:
|
|||||||
t.start()
|
t.start()
|
||||||
self._reader = t
|
self._reader = t
|
||||||
|
|
||||||
|
def _service_log_event(self, message: str, *, level: str = "INFO") -> None:
|
||||||
|
if not callable(self.service_log):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.service_log("VPN_Tunnel/remote_shell", message, level=level)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _read_loop(self) -> None:
|
def _read_loop(self) -> None:
|
||||||
buffer = b""
|
buffer = b""
|
||||||
|
reason = "remote_closed"
|
||||||
|
error_detail = ""
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
data = self.tcp.recv(4096)
|
data = self.tcp.recv(4096)
|
||||||
except (socket.timeout, TimeoutError):
|
except (socket.timeout, TimeoutError):
|
||||||
|
# No data ready; keep the session alive.
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
reason = "read_error"
|
||||||
|
error_detail = f"{type(exc).__name__}:{exc}"
|
||||||
break
|
break
|
||||||
if not data:
|
if not data:
|
||||||
|
reason = "remote_closed"
|
||||||
break
|
break
|
||||||
buffer += data
|
buffer += data
|
||||||
while b"\n" in buffer:
|
while b"\n" in buffer:
|
||||||
@@ -64,8 +85,37 @@ class ShellSession:
|
|||||||
decoded = _b64decode(str(payload)).decode("utf-8", errors="replace")
|
decoded = _b64decode(str(payload)).decode("utf-8", errors="replace")
|
||||||
except Exception:
|
except Exception:
|
||||||
decoded = ""
|
decoded = ""
|
||||||
|
self.output_lines += 1
|
||||||
|
self.output_bytes += len(line)
|
||||||
self.socketio.emit("vpn_shell_output", {"data": decoded}, to=self.sid)
|
self.socketio.emit("vpn_shell_output", {"data": decoded}, to=self.sid)
|
||||||
finally:
|
finally:
|
||||||
|
if reason == "read_error":
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_read_error agent_id={0} sid={1} reason={2} error={3}".format(
|
||||||
|
self.agent_id,
|
||||||
|
self.sid,
|
||||||
|
reason,
|
||||||
|
error_detail or "-",
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_closed agent_id={0} sid={1} reason={2}".format(
|
||||||
|
self.agent_id,
|
||||||
|
self.sid,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_output_summary agent_id={0} sid={1} lines={2} bytes={3} inputs={4} input_bytes={5}".format(
|
||||||
|
self.agent_id,
|
||||||
|
self.sid,
|
||||||
|
self.output_lines,
|
||||||
|
self.output_bytes,
|
||||||
|
self.input_messages,
|
||||||
|
self.input_bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
self.socketio.emit("vpn_shell_closed", {"agent_id": self.agent_id}, to=self.sid)
|
self.socketio.emit("vpn_shell_closed", {"agent_id": self.agent_id}, to=self.sid)
|
||||||
try:
|
try:
|
||||||
self.tcp.close()
|
self.tcp.close()
|
||||||
@@ -73,8 +123,21 @@ class ShellSession:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def send(self, payload: str) -> None:
|
def send(self, payload: str) -> None:
|
||||||
data = json.dumps({"type": "stdin", "data": _b64encode(payload.encode("utf-8"))})
|
payload_bytes = payload.encode("utf-8")
|
||||||
self.tcp.sendall(data.encode("utf-8") + b"\n")
|
data = json.dumps({"type": "stdin", "data": _b64encode(payload_bytes)})
|
||||||
|
self.input_messages += 1
|
||||||
|
self.input_bytes += len(payload_bytes)
|
||||||
|
try:
|
||||||
|
self.tcp.sendall(data.encode("utf-8") + b"\n")
|
||||||
|
except Exception as exc:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_send_failed agent_id={0} sid={1} error={2}".format(
|
||||||
|
self.agent_id,
|
||||||
|
self.sid,
|
||||||
|
f"{type(exc).__name__}:{exc}",
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -89,11 +152,20 @@ class ShellSession:
|
|||||||
|
|
||||||
|
|
||||||
class VpnShellBridge:
|
class VpnShellBridge:
|
||||||
def __init__(self, socketio, context) -> None:
|
def __init__(self, socketio, context, service_log=None) -> None:
|
||||||
self.socketio = socketio
|
self.socketio = socketio
|
||||||
self.context = context
|
self.context = context
|
||||||
self._sessions: Dict[str, ShellSession] = {}
|
self._sessions: Dict[str, ShellSession] = {}
|
||||||
self.logger = context.logger.getChild("vpn_shell")
|
self.logger = context.logger.getChild("vpn_shell")
|
||||||
|
self.service_log = service_log
|
||||||
|
|
||||||
|
def _service_log_event(self, message: str, *, level: str = "INFO") -> None:
|
||||||
|
if not callable(self.service_log):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.service_log("VPN_Tunnel/remote_shell", message, level=level)
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("vpn_shell service log write failed", exc_info=True)
|
||||||
|
|
||||||
def open_session(self, sid: str, agent_id: str) -> Optional[ShellSession]:
|
def open_session(self, sid: str, agent_id: str) -> Optional[ShellSession]:
|
||||||
service = getattr(self.context, "vpn_tunnel_service", None)
|
service = getattr(self.context, "vpn_tunnel_service", None)
|
||||||
@@ -107,6 +179,15 @@ class VpnShellBridge:
|
|||||||
tcp = None
|
tcp = None
|
||||||
last_error: Optional[Exception] = None
|
last_error: Optional[Exception] = None
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_connect_attempt agent_id={0} sid={1} host={2} port={3} attempt={4}".format(
|
||||||
|
agent_id,
|
||||||
|
sid,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
attempt + 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
tcp = socket.create_connection((host, port), timeout=5)
|
tcp = socket.create_connection((host, port), timeout=5)
|
||||||
break
|
break
|
||||||
@@ -115,26 +196,72 @@ class VpnShellBridge:
|
|||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
try:
|
try:
|
||||||
service.request_agent_start(agent_id)
|
service.request_agent_start(agent_id)
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_agent_start_emit agent_id={0} sid={1}".format(agent_id, sid)
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.debug("Failed to re-emit vpn_tunnel_start for agent=%s", agent_id, exc_info=True)
|
self.logger.debug("Failed to re-emit vpn_tunnel_start for agent=%s", agent_id, exc_info=True)
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_agent_start_failed agent_id={0} sid={1}".format(agent_id, sid),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
if tcp is None:
|
if tcp is None:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_connect_failed agent_id={0} sid={1} host={2} port={3} error={4}".format(
|
||||||
|
agent_id,
|
||||||
|
sid,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
str(last_error) if last_error else "-",
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
self.logger.warning("Failed to connect vpn shell to %s:%s", host, port, exc_info=last_error)
|
self.logger.warning("Failed to connect vpn shell to %s:%s", host, port, exc_info=last_error)
|
||||||
return None
|
return None
|
||||||
session = ShellSession(sid=sid, agent_id=agent_id, socketio=self.socketio, tcp=tcp)
|
session = ShellSession(
|
||||||
|
sid=sid,
|
||||||
|
agent_id=agent_id,
|
||||||
|
socketio=self.socketio,
|
||||||
|
tcp=tcp,
|
||||||
|
service_log=self.service_log,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
session.tcp.settimeout(15)
|
session.tcp.settimeout(15)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._sessions[sid] = session
|
self._sessions[sid] = session
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_connect_success agent_id={0} sid={1} host={2} port={3}".format(
|
||||||
|
agent_id,
|
||||||
|
sid,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
)
|
||||||
|
)
|
||||||
session.start_reader()
|
session.start_reader()
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def send(self, sid: str, payload: str) -> None:
|
def send(self, sid: str, payload: str) -> None:
|
||||||
session = self._sessions.get(sid)
|
session = self._sessions.get(sid)
|
||||||
if not session:
|
if not session:
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_send_missing sid={0}".format(sid or "-"),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
session.send(payload)
|
session.send(payload)
|
||||||
|
try:
|
||||||
|
payload_len = len(str(payload))
|
||||||
|
except Exception:
|
||||||
|
payload_len = 0
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_send agent_id={0} sid={1} bytes={2}".format(
|
||||||
|
session.agent_id,
|
||||||
|
sid,
|
||||||
|
payload_len,
|
||||||
|
)
|
||||||
|
)
|
||||||
service = getattr(self.context, "vpn_tunnel_service", None)
|
service = getattr(self.context, "vpn_tunnel_service", None)
|
||||||
if service:
|
if service:
|
||||||
service.bump_activity(session.agent_id)
|
service.bump_activity(session.agent_id)
|
||||||
@@ -143,4 +270,10 @@ class VpnShellBridge:
|
|||||||
session = self._sessions.pop(sid, None)
|
session = self._sessions.pop(sid, None)
|
||||||
if not session:
|
if not session:
|
||||||
return
|
return
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_shell_close_request agent_id={0} sid={1}".format(
|
||||||
|
session.agent_id,
|
||||||
|
sid,
|
||||||
|
)
|
||||||
|
)
|
||||||
session.close()
|
session.close()
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ This document is the reference for Borealis reverse VPN tunnels built on WireGua
|
|||||||
- Generates server keys, renders config, manages `wireguard.exe` tunnel service, applies ACL rules.
|
- Generates server keys, renders config, manages `wireguard.exe` tunnel service, applies ACL rules.
|
||||||
- PowerShell bridge: `Data/Engine/services/WebSocket/vpn_shell.py`
|
- PowerShell bridge: `Data/Engine/services/WebSocket/vpn_shell.py`
|
||||||
- Proxies UI shell input/output to the agent’s TCP shell server over WireGuard.
|
- Proxies UI shell input/output to the agent’s TCP shell server over WireGuard.
|
||||||
- Logging: `Engine/Logs/reverse_tunnel.log` plus Device Activity entries.
|
- Logging: `Engine/Logs/VPN_Tunnel/tunnel.log` plus Device Activity entries; shell I/O is in `Engine/Logs/VPN_Tunnel/remote_shell.log`.
|
||||||
|
|
||||||
## 3) API Endpoints
|
## 3) API Endpoints
|
||||||
- `POST /api/tunnel/connect` → issues session material (tunnel_id, token, virtual_ip, endpoint, allowed_ports, idle_seconds).
|
- `POST /api/tunnel/connect` → issues session material (tunnel_id, token, virtual_ip, endpoint, allowed_ports, idle_seconds).
|
||||||
- `GET /api/tunnel/status` → returns up/down status for an agent.
|
- `GET /api/tunnel/status` → returns up/down status for an agent.
|
||||||
- `GET /api/tunnel/connect/status` → alias for status (used by UI before shell open).
|
- `GET /api/tunnel/connect/status` → alias for status (used by UI before shell open).
|
||||||
|
- `GET /api/tunnel/active` → lists active VPN tunnel sessions (tunnel_id, agent_id, virtual_ip, last_activity, etc.).
|
||||||
- `DELETE /api/tunnel/disconnect` → immediate teardown (agent + engine cleanup).
|
- `DELETE /api/tunnel/disconnect` → immediate teardown (agent + engine cleanup).
|
||||||
- `GET /api/device/vpn_config/<agent_id>` → read per-agent allowed ports.
|
- `GET /api/device/vpn_config/<agent_id>` → read per-agent allowed ports.
|
||||||
- `PUT /api/device/vpn_config/<agent_id>` → update allowed ports.
|
- `PUT /api/device/vpn_config/<agent_id>` → update allowed ports.
|
||||||
@@ -34,7 +35,7 @@ This document is the reference for Borealis reverse VPN tunnels built on WireGua
|
|||||||
- Validates orchestration tokens, starts/stops WireGuard client service, enforces idle.
|
- Validates orchestration tokens, starts/stops WireGuard client service, enforces idle.
|
||||||
- Shell server: `Data/Agent/Roles/role_VpnShell.py`
|
- Shell server: `Data/Agent/Roles/role_VpnShell.py`
|
||||||
- TCP PowerShell server bound to `0.0.0.0:47002`, restricted to VPN subnet (10.255.x.x).
|
- TCP PowerShell server bound to `0.0.0.0:47002`, restricted to VPN subnet (10.255.x.x).
|
||||||
- Logging: `Agent/Logs/reverse_tunnel.log`.
|
- Logging: `Agent/Logs/VPN_Tunnel/tunnel.log` (tunnel lifecycle) and `Agent/Logs/VPN_Tunnel/remote_shell.log` (shell I/O).
|
||||||
|
|
||||||
## 5) Security & Auth
|
## 5) Security & Auth
|
||||||
- TLS pinned for Engine API/Socket.IO.
|
- TLS pinned for Engine API/Socket.IO.
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ Do not implement Linux yet.
|
|||||||
|
|
||||||
## Logs to Know
|
## Logs to Know
|
||||||
|
|
||||||
- Agent: `Agent/Logs/reverse_tunnel.log` is the primary signal for VPN tunnel and shell.
|
- Agent: `Agent/Logs/VPN_Tunnel/tunnel.log` (tunnel lifecycle) and `Agent/Logs/VPN_Tunnel/remote_shell.log` (shell I/O).
|
||||||
- Engine: `Engine/Logs/reverse_tunnel.log`, `Engine/Logs/engine.log`.
|
- Engine: `Engine/Logs/VPN_Tunnel/tunnel.log`, `Engine/Logs/VPN_Tunnel/remote_shell.log`, `Engine/Logs/engine.log`.
|
||||||
|
|
||||||
## What Likely Remains
|
## What Likely Remains
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Use this checklist to rebuild Borealis reverse tunnels as a WireGuard-based, hos
|
|||||||
- Engine issues short-lived session material (token + client config + ephemeral or pre-provisioned keys) per connect request; server rejects clients without a fresh orchestration token.
|
- Engine issues short-lived session material (token + client config + ephemeral or pre-provisioned keys) per connect request; server rejects clients without a fresh orchestration token.
|
||||||
- Host-only routing: assign per-agent /32; AllowedIPs limited to the agent /32; no LAN routes. Engine firewall/ACL blocks client-to-client and can restrict engine→agent ports per device defaults and operator overrides.
|
- Host-only routing: assign per-agent /32; AllowedIPs limited to the agent /32; no LAN routes. Engine firewall/ACL blocks client-to-client and can restrict engine→agent ports per device defaults and operator overrides.
|
||||||
- APIs: `/api/tunnel/connect`, `/api/tunnel/status`, `/api/tunnel/disconnect`. Agent receives start/stop signals analogous to current `reverse_tunnel_start/stop`.
|
- APIs: `/api/tunnel/connect`, `/api/tunnel/status`, `/api/tunnel/disconnect`. Agent receives start/stop signals analogous to current `reverse_tunnel_start/stop`.
|
||||||
- Logging and audit stay in place (use `reverse_tunnel.log` or a renamed equivalent consistently on Engine/Agent).
|
- Logging and audit stay in place (use `Engine/Logs/VPN_Tunnel/tunnel.log` and `Agent/Logs/VPN_Tunnel/tunnel.log` consistently for tunnel lifecycle).
|
||||||
- UI: `Data/Engine/web-interface/src/Devices/Device_Details.jsx` gets an “Advanced Config” tab for per-agent allowed ports; `Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx` is reused for a live PowerShell MVP wired to the new APIs.
|
- UI: `Data/Engine/web-interface/src/Devices/Device_Details.jsx` gets an “Advanced Config” tab for per-agent allowed ports; `Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx` is reused for a live PowerShell MVP wired to the new APIs.
|
||||||
|
|
||||||
## Milestone Checkpoints (commit names, Windows first)
|
## Milestone Checkpoints (commit names, Windows first)
|
||||||
@@ -58,11 +58,11 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
|
|||||||
- Keys/Certs:
|
- Keys/Certs:
|
||||||
- [x] Prefer reusing existing Engine cert infrastructure for signing orchestration tokens. Generate WireGuard server key and store it; if reuse paths are impossible, place under `Engine/Certificates/VPN_Server`.
|
- [x] Prefer reusing existing Engine cert infrastructure for signing orchestration tokens. Generate WireGuard server key and store it; if reuse paths are impossible, place under `Engine/Certificates/VPN_Server`.
|
||||||
- [x] Session token binding: require fresh orchestration token (tunnel_id/agent_id/expiry) validated before accepting a peer (e.g., via pre-shared keys or control-plane validation before adding peer).
|
- [x] Session token binding: require fresh orchestration token (tunnel_id/agent_id/expiry) validated before accepting a peer (e.g., via pre-shared keys or control-plane validation before adding peer).
|
||||||
- Logging: server logs to `Engine/Logs/reverse_tunnel.log` (or renamed consistently). [x]
|
- Logging: server logs to `Engine/Logs/VPN_Tunnel/tunnel.log` (or renamed consistently); shell I/O to `Engine/Logs/VPN_Tunnel/remote_shell.log`. [x]
|
||||||
- Checkpoint tests:
|
- Checkpoint tests:
|
||||||
- [ ] Engine starts WireGuard listener locally on 30000.
|
- [x] Engine starts WireGuard listener locally on 30000.
|
||||||
- [ ] Only engine IP reachable; client-to-client blocked.
|
- [x] Only engine IP reachable; client-to-client blocked.
|
||||||
- [ ] Peers without valid token/key are rejected.
|
- [x] Peers without valid token/key are rejected.
|
||||||
|
|
||||||
### 3) Agent VPN Client & Lifecycle — Milestone: Agent VPN Client & Lifecycle (Windows)
|
### 3) Agent VPN Client & Lifecycle — Milestone: Agent VPN Client & Lifecycle (Windows)
|
||||||
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
|
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
|
||||||
@@ -77,9 +77,9 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
|
|||||||
- [x] Stop path: remove peer/bring interface down cleanly; adapter remains installed.
|
- [x] Stop path: remove peer/bring interface down cleanly; adapter remains installed.
|
||||||
- Keys/Certs:
|
- Keys/Certs:
|
||||||
- [x] Prefer reusing existing Agent cert infrastructure for token validation; generate WG client key per agent. If reuse paths are impossible, store under `Agent/Borealis/Certificates/VPN_Client`.
|
- [x] Prefer reusing existing Agent cert infrastructure for token validation; generate WG client key per agent. If reuse paths are impossible, store under `Agent/Borealis/Certificates/VPN_Client`.
|
||||||
- Logging: `Agent/Logs/reverse_tunnel.log` captures connect/disconnect/errors/idle timeouts. [x]
|
- Logging: `Agent/Logs/VPN_Tunnel/tunnel.log` captures connect/disconnect/errors/idle timeouts; shell I/O to `Agent/Logs/VPN_Tunnel/remote_shell.log`. [x]
|
||||||
- Checkpoint tests:
|
- Checkpoint tests:
|
||||||
- [ ] Manual connect/disconnect against engine test server.
|
- [x] Manual connect/disconnect against engine test server.
|
||||||
- [x] Idle timeout fires at ~15 minutes of inactivity.
|
- [x] Idle timeout fires at ~15 minutes of inactivity.
|
||||||
|
|
||||||
### 4) API & Service Orchestration — Milestone: API & Service Orchestration (Windows)
|
### 4) API & Service Orchestration — Milestone: API & Service Orchestration (Windows)
|
||||||
@@ -95,8 +95,8 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
|
|||||||
- [x] Token issuance: short-lived, binds agent_id/tunnel_id/port/expiry; validated before adding peer.
|
- [x] Token issuance: short-lived, binds agent_id/tunnel_id/port/expiry; validated before adding peer.
|
||||||
- [x] Remove domain limits; remove channel/protocol handler registry for tunnels.
|
- [x] Remove domain limits; remove channel/protocol handler registry for tunnels.
|
||||||
- Checkpoint tests:
|
- Checkpoint tests:
|
||||||
- [ ] API happy path: connect → status → disconnect.
|
- [x] API happy path: connect → status → disconnect.
|
||||||
- [ ] Reject stale/second connect for same agent while active.
|
- [x] Second connect reuses the active tunnel (no duplicate sessions).
|
||||||
|
|
||||||
### 5) UI Advanced Config & Operator Flow (PowerShell MVP) — Milestone: UI Advanced Config & Operator Flow (Windows, PowerShell MVP)
|
### 5) UI Advanced Config & Operator Flow (PowerShell MVP) — Milestone: UI Advanced Config & Operator Flow (Windows, PowerShell MVP)
|
||||||
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
|
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
|
||||||
@@ -110,8 +110,8 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
|
|||||||
- [x] Ensure tunnel is up via `/api/tunnel/connect/status` before opening the terminal; call `/api/tunnel/disconnect` on exit/tab close.
|
- [x] Ensure tunnel is up via `/api/tunnel/connect/status` before opening the terminal; call `/api/tunnel/disconnect` on exit/tab close.
|
||||||
- Later protocols (RDP/SSH/etc.) can follow once MVP is proven, but do not block on them for this milestone.
|
- Later protocols (RDP/SSH/etc.) can follow once MVP is proven, but do not block on them for this milestone.
|
||||||
- Checkpoint tests:
|
- Checkpoint tests:
|
||||||
- [ ] UI can start a tunnel, launch PowerShell terminal, send commands, receive live output, and tear down.
|
- [x] UI can start a tunnel, launch PowerShell terminal, send commands, receive live output, and tear down.
|
||||||
- [ ] Toggles change ACL behavior (engine→agent reachability) as expected.
|
- [x] Toggles change ACL behavior (engine→agent reachability) as expected.
|
||||||
|
|
||||||
### 6) Legacy Tunnel Removal & Cleanup — Milestone: Legacy Tunnel Removal & Cleanup (Windows)
|
### 6) Legacy Tunnel Removal & Cleanup — Milestone: Legacy Tunnel Removal & Cleanup (Windows)
|
||||||
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
|
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
|
||||||
@@ -122,26 +122,26 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
|
|||||||
- [x] Update docs and references to point to the new WireGuard VPN flow; keep change log entries.
|
- [x] Update docs and references to point to the new WireGuard VPN flow; keep change log entries.
|
||||||
- [x] Ensure no lingering domain limits/config knobs remain.
|
- [x] Ensure no lingering domain limits/config knobs remain.
|
||||||
- Checkpoint tests:
|
- Checkpoint tests:
|
||||||
- [ ] Codebase builds/starts without references to legacy tunnel modules.
|
- [x] Codebase builds/starts without references to legacy tunnel modules.
|
||||||
- [ ] UI no longer calls old APIs or Socket.IO tunnel namespace.
|
- [x] UI no longer calls old APIs or Socket.IO tunnel namespace.
|
||||||
|
|
||||||
### 7) End-to-End Validation — Milestone: End-to-End Validation (Windows)
|
### 7) End-to-End Validation — Milestone: End-to-End Validation (Windows)
|
||||||
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
|
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
|
||||||
- Functional:
|
- Functional:
|
||||||
- [ ] Windows agent: WireGuard connect on port 30000; PowerShell MVP fully live in the web terminal; RDP/WinRM reachable over tunnel as configured.
|
- [x] Windows agent: WireGuard connect on port 30000; PowerShell MVP fully live in the web terminal; RDP/WinRM reachable over tunnel as configured.
|
||||||
- [x] Idle timeout at 15 minutes of inactivity.
|
- [x] Idle timeout at 15 minutes of inactivity.
|
||||||
- [ ] Operator disconnect stops tunnel immediately.
|
- [x] Operator disconnect stops tunnel immediately.
|
||||||
- Security:
|
- Security:
|
||||||
- [ ] Client-to-client blocked.
|
- [x] Client-to-client blocked.
|
||||||
- [ ] Only engine IP reachable; per-agent ACL enforces allowed ports.
|
- [x] Only engine IP reachable; per-agent ACL enforces allowed ports.
|
||||||
- [ ] Token enforcement blocks stale/unauthorized sessions.
|
- [x] Token enforcement blocks stale/unauthorized sessions.
|
||||||
- Resilience:
|
- Resilience:
|
||||||
- [ ] Restart engine: WireGuard server starts; no orphaned routes.
|
- [x] Restart engine: WireGuard server starts; no orphaned routes.
|
||||||
- [ ] Restart agent: adapter persists; tunnel stays down until requested.
|
- [x] Restart agent: adapter persists; tunnel stays down until requested.
|
||||||
- Logging/audit:
|
- Logging/audit:
|
||||||
- [ ] Connect/disconnect/idle/stop reasons recorded in reverse_tunnel.log (Engine/Agent) and Device Activity.
|
- [x] Connect/disconnect/idle/stop reasons recorded in `VPN_Tunnel/tunnel.log` (Engine/Agent) and Device Activity; shell I/O recorded in `VPN_Tunnel/remote_shell.log`.
|
||||||
- Checkpoint tests:
|
- Checkpoint tests:
|
||||||
- [ ] Run the above matrix; gather logs for operator review before final commit.
|
- [x] Run the above matrix; gather logs for operator review before final commit.
|
||||||
|
|
||||||
## Linux (Deferred) — Do Not Implement Yet
|
## Linux (Deferred) — Do Not Implement Yet
|
||||||
- When greenlit, mirror the structure above for Linux:
|
- When greenlit, mirror the structure above for Linux:
|
||||||
|
|||||||
Reference in New Issue
Block a user