diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..cebd69d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,16 @@
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+*.log
+venv/
+.env
+.env.*
+.DS_Store
+Thumbs.db
+ansible_workspace/staging/
+ansible_workspace/network_tool_backup_*.tar.gz
+openvpn/runtime/
+.git
+.gitmodules
+.gitignore
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a23f280
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,16 @@
+# Flask / Gunicorn
+FLASK_SECRET_KEY=change-this
+CORE_API_USER=admin
+CORE_API_PASS=Super4dmin!
+
+# Dashboard credentials for ComboCore portal access
+DASHBOARD_USER=admin@hpe.com
+DASHBOARD_PASSWORD=kxly7o6FboYUoQXSeLrs2xodHIeybQRwcMs33QC5#
+
+# Dashboard environments (JSON object mapping name -> URL)
+DASHBOARD_ENVIRONMENTS={"Production":"https://dashboard.private5g.networking.hpe.com","Test":"https://your-test-dashboard-url.com"}
+
+# VPN runtime
+VPN_CONFIG_DIR=/vpn/configs
+VPN_RUNTIME_DIR=/vpn/runtime
+VPN_CONFIG_NAMES=Triton,Star,Bluebonnet,Lonestar,Production,US-Support,EU-Support
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0261505
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,37 @@
+FROM python:3.11-slim
+
+ENV PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ FLASK_ENV=production \
+ VPN_CONFIG_DIR=/vpn/configs \
+ VPN_RUNTIME_DIR=/vpn/runtime
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ dumb-init \
+ openvpn \
+ iproute2 \
+ iputils-ping \
+ net-tools \
+ openssh-client \
+ ansible \
+ sshpass \
+ jq \
+ curl \
+ tzdata \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY requirements.txt /tmp/requirements.txt
+RUN pip install --upgrade pip \
+ && pip install -r /tmp/requirements.txt
+
+COPY . /app
+
+RUN mkdir -p /vpn/configs /vpn/runtime
+
+EXPOSE 8000
+
+ENTRYPOINT ["/usr/bin/dumb-init", "--"]
+CMD ["gunicorn", "--timeout", "300", "--bind", "0.0.0.0:8000", "app:app"]
diff --git a/Triton.conf b/Triton.conf
deleted file mode 100644
index a7809b2..0000000
--- a/Triton.conf
+++ /dev/null
@@ -1,90 +0,0 @@
-client
-remote vpn.arubaedge-triton.athonetusa.com 1091
-
-comp-lzo yes
-dev tun
-proto udp
-
-nobind
-
-script-security 2
-persist-key
-persist-tun
-
-
------BEGIN CERTIFICATE-----
-MIIDWDCCAkCgAwIBAgIQCRnQJil5kyTX9cW/Oc6KwjANBgkqhkiG9w0BAQsFADAW
-MRQwEgYDVQQDDAtFYXN5LVJTQSBDQTAeFw0yMzExMTAxNTE0NDVaFw0yNjAyMTIx
-NTE0NDVaMBUxEzARBgNVBAMMCnJhcy1jbGllbnQwggEiMA0GCSqGSIb3DQEBAQUA
-A4IBDwAwggEKAoIBAQC9usyHF2gN/Vbcq8VzHG8YjdZH8ffnKfcCDlScn6QShSJU
-U/Vvt9e95XgnNNf3CV16kgwccHltTIDsnQ0xIg6slKZe9199O9jW5FMbgsqyHr17
-d31/r2dnDrGCwqzW2J8GruGAfGnORrP7yyXbtPAg9Xo6dSNAJP2LKPNBSAgC1qJX
-zaU4abqTu1S9bHtZbdBM5Gu44IEq4OmOjzhkK/HDUIdxsW4M2XDmwS+LEEdJrQzX
-QfPuy0fIqG6m64yMj8KqE3UJudq/ZPvBTMicEwQtiEH0ZPoVR8mUOA6EbdLcPOgD
-zVnTtoDI/g87SRk8akec00U+TkFfDwtejhaun1WLAgMBAAGjgaIwgZ8wCQYDVR0T
-BAIwADAdBgNVHQ4EFgQUzmKlGzqAyAPWF3/dP8nyhIE8d40wUQYDVR0jBEowSIAU
-LPMtzvN7A0qEAbbfjHUxACs/x8ahGqQYMBYxFDASBgNVBAMMC0Vhc3ktUlNBIENB
-ghQKCs3AEEY/UWj6kXXTA8+2HtC1vTATBgNVHSUEDDAKBggrBgEFBQcDAjALBgNV
-HQ8EBAMCB4AwDQYJKoZIhvcNAQELBQADggEBACZkv79VL5TMQRvJ6WWOvqihweLE
-BAmVFyZfwD/y2biTPeQTpojkJs3XMXIRvluCxihpMkAS73d2bzx1HZPQm0dF2Szb
-/iHLgMmUbeInaT78pFpCu+4Va6YIUcFM2jqRLkg4Aa+bzFtT1hG8TkXOu7VvPI8l
-4fSKnRSN77uHEQp6KWd+oDMUqtjSzo3Lc0g9LU5Ex0p0z0Cx7wihLXLJsDRFxp8G
-EMU4apula0lHCd8fc5sIQarweybY9CM0Vymkes4FmQr/1yWQwucdC8CTDvAR3X2m
-GfLIyNhvHQKMzJKEpGrj4tMqLNZrnwuTvrrLbfIH/B+6w7f4bI3Il/m/8LU=
------END CERTIFICATE-----
-
-
-
------BEGIN ENCRYPTED PRIVATE KEY-----
-MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQI0YGeflzHjMQCAggA
-MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECOMCCAht/lqeBIIEyNabZEOIhXYp
-m5Jn69rv65Ififg0Tar7kBmyll5KHGP7KfXM8wtT8JvYyNYceEi78aO+/pAF1woz
-Yd0EmC/BrvmhtWuvSJXqs3CnEZk3vO3jXTsnWYzQSHs8I/Os/AZSY8jr0ZYlaiiO
-PcnSNyejnU3JFCI8LJam2soqqpKyoSNGGoRSVW9+EK8fz4cGmNdtWnHR7TwwCRn0
-mczVWEhX9x8OmpNdYtD9ZCCQ369VkoSE+11zXvUkoTtMmMIG59M8F7s4jvdmGJGz
-P8JDAVaIj3Cl7muvN1ob51UBlIciEQxIa3ozApJutHBNP4nnvI8jRJa9uspajVQR
-T/lKp5vmUc17cSLG9CTmJtBEvJ3tdfPq34C9n3kEwau9nAmp0Hoh/4axfhWfIVnY
-vgXI5zkvDm98DNHNjy0Ic6IttJPeDkTR9DfuIjcPehdPd5x1s/Hcc63VwtzceZlu
-dLtOWnceEfqJkLIfIheXDaJdPgEknpH3FX8iKMY2F1WeSBBjYBQWkJq7Gg6ULmow
-bHg/k0Ah0KQo6m7uhoV8r7F29NOwuHAnguCdKwHXYdQJDJNP5Yr7HtqUiMqPHYSS
-IeWH4+h3Rmug/A5pexwePddlHAXcFFTPbMjRkpXPHWUXoBbBad7JtJgTBKV14ZnZ
-1u/nMCEQ22oU7BXIA+B0BBl4HdeotOwD7Ocbqgf1d0fgYoUbgPxOLYk2kvOM93EP
-GRqUtkTGOzBi7IIINQbRKLAQJFAb62XxRXv3tKGDR7y4H6IdmMVcbGzSIIeKNppS
-yIQadpi/O23qhgD+cp3dhIlpnXe0JvoN9JdQE0wfhkhpScBv7XIy5mtuidOVqUl4
-pXFipjRUKaLy0qKY43wxxUQXfHVKGYi8ubfGCgeDPD0wNFSk5qfDgOPxuzE/L7Fc
-scjJKZ9rAaJ+SpbC2GC8DujFBZyaLSCi3HZS6cpVSucAhUyJnKiT6YEEaFakLMeT
-E7GYX6upuMoXBY+Km0Dz9pu+PwJVTjohzc4NmBfIAUz+eM/Mi42MoP0nnhStVR9D
-UhvWx3bVRC38Pzh6Zg34/1BREfdPAuYvG1VXSe1zZ6Ak12txAy9YxFVdqVAsNjT1
-zmWMYFzXkWraQlhXkGBCWgeevLrB2Hmu4aeLesXBvY1qV1v57qSWuAksr0wvSdL+
-wfol/6JRLJfSt0uyO04CpE0rh/T+pwpRBLogH8XqUYzZtJq9SdQEH8ObKgg/Yx8p
-p+7pQYlLVZtJVJueiTjqaE3rZ46oT8FHpyQUkubHtiQB5P7mPRn2u0UezUUbThei
-SWcxKfES1laP7MBRiUspmVxT/JR7WZ5RV9mm6AWo8FZ1bWo0Fy7yCaYKR6xkH2W8
-bEmlZvPbS0twO9rem5CuOrDZtfevQt7PQm6cQ/GXh3XpUOOl11acce3KNK/xfatG
-UOueLLJwHEGP+wB3/5QRwrvQ3t59mwCph5pbM+f5wIhNUJwBDGVm95JzMVxj1oCg
-ROCrChFkioo/TXnWoHl6TPqqUf/fFDv/nZPnwos4qY9jmuepYZWtXYmH4hrBcvul
-h/05mBmjYrE+LnoehIikjEpsixaryyMalF947tCGJOlgLecth2PlxgguVaEhuKVR
-Q9ESsikOaNTCn8UScfQzBw==
------END ENCRYPTED PRIVATE KEY-----
-
-
------BEGIN CERTIFICATE-----
-MIIDSzCCAjOgAwIBAgIUCgrNwBBGP1Fo+pF10wPPth7Qtb0wDQYJKoZIhvcNAQEL
-BQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMjMxMTEwMTUxMzE0WhcNMzMx
-MTA3MTUxMzE0WjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcN
-AQEBBQADggEPADCCAQoCggEBAJT1bO4fs5sIDOsVecr9vY0VomMqzRLbbVN0lBdo
-qxdSOXHEosJ6qZSJoOb/XIIj0828NQQc7TBUhzZsRDn4oyopQ0wIXPN5hgmx9kOm
-pGhdiy3boyjpoXgoHUd2CGlIJJNneXs3OqfxM3NjuLkfxwbX7SuU5r6KXEszbyA0
-0CpGm6YKLshkVO4QbVG82A0+KkqRhGdk7pddsvXhtaHyz7OsUI3EHg6FoANKAjrl
-4SgTDBfbg++iFEaZwst73P4pHcOx3r2zzseNwGEFdPnwXPjQQMxUl3ikaAzsKRhG
-4zTiAXkUtBbDUvEXDY0yoG7eyARXANWdYi0pxU86aVQJ0HUCAwEAAaOBkDCBjTAd
-BgNVHQ4EFgQULPMtzvN7A0qEAbbfjHUxACs/x8YwUQYDVR0jBEowSIAULPMtzvN7
-A0qEAbbfjHUxACs/x8ahGqQYMBYxFDASBgNVBAMMC0Vhc3ktUlNBIENBghQKCs3A
-EEY/UWj6kXXTA8+2HtC1vTAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIBBjANBgkq
-hkiG9w0BAQsFAAOCAQEAb+j9JzhXrP1xY9AtBAhkYLTLfcqICiSXGjRjONngTe82
-MFcfJx53ptHk1Xs2GTjv7hshgo4ADhCHfqnGfyj4weoZpwd7VBswqqvuikhCXfpx
-NbkI/E2Gk5BK8ThsJbGbNgJg9Vg34V1za00T7lAWwRNdJC/kBnTwI/sdvQ0AYAZy
-IYlOCThhpNhtlIiLZ36ebw3recuAgA0YklwH3oMRh+hsFgjcmJg9bx/VcjIapsjO
-OnksHxRDMxQs1n2Qd+XC724mHS4eSvJwoIC/WeWX0r3N7X63cRWcRQA83TpGtUAA
-rerEaydkWUO+6+HA7FQglxf06VcpgJwtqa6Tm7iDZw==
------END CERTIFICATE-----
-
diff --git a/ansible_workspace/staging/host_vars/ESXI-1/esxi.yaml b/ansible_workspace/staging/host_vars/ESXI-1/esxi.yaml
index 1612f50..aaf1bfa 100644
--- a/ansible_workspace/staging/host_vars/ESXI-1/esxi.yaml
+++ b/ansible_workspace/staging/host_vars/ESXI-1/esxi.yaml
@@ -8,5 +8,5 @@ vswitches:
portgroups:
- { vSwitch: GAF_VSWITCH, vlanId: 501, vlanName: GAF_BP_501_OAM }
- { vSwitch: GAF_VSWITCH, vlanId: 502, vlanName: GAF_BP_502_RAN }
-- { vSwitch: GAF_VSWITCH, vlanId: 5, vlanName: DN_01 }
+- { vSwitch: GAF_VSWITCH, vlanId: 2100, vlanName: DN_01 }
- { vSwitch: GAF_VSWITCH, vlanId: 4095, vlanName: GAF_BP_T_510_515 }
\ No newline at end of file
diff --git a/ansible_workspace/staging/host_vars/GBP08-AIO-1/aio_3gpp.yaml b/ansible_workspace/staging/host_vars/GBP08-AIO-1/aio_3gpp.yaml
index 5465361..5f9b72e 100644
--- a/ansible_workspace/staging/host_vars/GBP08-AIO-1/aio_3gpp.yaml
+++ b/ansible_workspace/staging/host_vars/GBP08-AIO-1/aio_3gpp.yaml
@@ -39,15 +39,15 @@ _ngc_ext_aio_transport:
# RAN transports (use RAN IP)
- action: override_amf_n2_transport
- params: { address: 10.5.19.195, vrf: RAN }
+ params: { address: 10.10.0.2, vrf: RAN }
- action: override_mme_transport
- params: { s1_address: 10.5.19.195, s1_vrf: RAN }
+ params: { s1_address: 10.10.0.2, s1_vrf: RAN }
# UPF transports (N3 on RAN)
- action: override_upf_transport
params:
n3_interface: eth1
- n3_address: 10.5.19.195
+ n3_address: 10.10.0.2
n3_vrf: RAN
# Avoid s-NSSAI on PFCP
@@ -91,13 +91,13 @@ _ngc_ext_aio_dnn:
params:
n6_dnn: internet
n6_vrf: DN_01
- n6_vlan: 5
+ n6_vlan: 2100
n6_vrf_table: 511
n6_interface: eth2
- n6_ip: 10.5.20.195/24
- n6_gw: 10.5.20.254
+ n6_ip: 10.121.0.150/24
+ n6_gw: 10.121.0.1
n6_upf_pools:
- - upf_route: 172.16.2.0/24
+ - upf_route: 192.168.4.0/24
nssai: false
n6_bgp:
local_as: 65001
diff --git a/ansible_workspace/staging/host_vars/GBP08-AIO-1/aio_networking.yaml b/ansible_workspace/staging/host_vars/GBP08-AIO-1/aio_networking.yaml
index 17e9288..bcbcd72 100644
--- a/ansible_workspace/staging/host_vars/GBP08-AIO-1/aio_networking.yaml
+++ b/ansible_workspace/staging/host_vars/GBP08-AIO-1/aio_networking.yaml
@@ -5,8 +5,8 @@ net_recipe: generic_bgp
oam_network:
add_ansible_host_address: false
addresses:
- - 10.5.21.54/24
- gateway4: 10.5.21.254
+ - 10.121.2.94/24
+ gateway4: 10.121.2.1
# --- NTP ---
ntp:
@@ -27,7 +27,7 @@ _ngc_ext_aio_net:
interface: eth1
vrf: RAN
addresses:
- - 10.5.19.195/23 # S1+N2+N3
+ - 10.10.0.2/24 # S1+N2+N3
routes:
- destination: 0.0.0.0/0
- gateway: 10.5.19.254
\ No newline at end of file
+ gateway: 10.10.0.254
\ No newline at end of file
diff --git a/ansible_workspace/staging/hosts.yaml b/ansible_workspace/staging/hosts.yaml
index 4c2cb64..da76fc8 100644
--- a/ansible_workspace/staging/hosts.yaml
+++ b/ansible_workspace/staging/hosts.yaml
@@ -1,7 +1,7 @@
all:
hosts:
GBP08-AIO-1:
- ansible_host: 100.93.1.84
+ ansible_host: 100.93.0.240
children:
ESXi:
hosts:
diff --git a/app.py b/app.py
index 07fceda..e4e796d 100644
--- a/app.py
+++ b/app.py
@@ -1,8 +1,9 @@
-from flask import Flask, render_template, request, jsonify, Response
+from flask import Flask, render_template, request, jsonify, Response, stream_with_context
import core_functions
import auth_utils
import logging
import os
+import json
import urllib3; urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
import requests
from requests.exceptions import HTTPError, RequestException
@@ -17,20 +18,60 @@ from services.remote_admin import (
)
from services.yaml_writer import STAGING, render_to_file
from pathlib import Path
+from services import vpn_runtime
+from services.log_stream import JournalctlStream, LogTarget
API_USER = os.getenv("CORE_API_USER", "admin")
API_PASS = os.getenv("CORE_API_PASS", "Super4dmin!") # consider moving to env/secret
-DASHBOARD_URLS = {
- "Triton": "https://dashboard.arubaedge-triton.athonetusa.com",
- "Star": "https://dashboard.arubaedge-star.athonetusa.com",
- "Bluebonnet": "https://dashboard.arubaedge-bluebonnet.athonetusa.com",
- "Lonestar": "https://dashboard.arubaedge-lonestar.athonetusa.com",
- "Production": "https://dashboard.us-east-2.p5g.athonet.cloud",
- "Test (future)": "https://your-test-dashboard-url.com"
-}
+
+def _load_dashboard_urls() -> dict:
+ default = {
+ "Production": "https://dashboard.private5g.networking.hpe.com",
+ "Test": "https://your-test-dashboard-url.com",
+ }
+ raw = os.getenv("DASHBOARD_ENVIRONMENTS")
+ if not raw:
+ return default
+ try:
+ parsed = json.loads(raw)
+ cleaned = {str(k): str(v) for k, v in parsed.items() if str(v).strip()}
+ return cleaned or default
+ except json.JSONDecodeError as exc:
+ logging.warning("Failed to parse DASHBOARD_ENVIRONMENTS (%s); falling back to defaults", exc)
+ return default
+
+DASHBOARD_URLS = _load_dashboard_urls()
+
+def _resolve_dashboard_url(name: str) -> str:
+ if not name:
+ raise ValueError("Dashboard selection is required")
+ url = DASHBOARD_URLS.get(name)
+ if not url:
+ raise ValueError(f"Unknown dashboard '{name}'")
+ return url
+
+def _get_available_vpn_configs() -> list:
+ try:
+ available = vpn_runtime.list_available_vpns()
+ except Exception:
+ available = []
+ configured = getattr(core_functions, "VPN_CONFIG_NAMES", [])
+ if not available:
+ return configured
+ ordered = [name for name in configured if name in available]
+ extras = [name for name in available if name not in ordered]
+ return ordered + extras
logging.basicConfig(level=logging.DEBUG)
app = Flask(__name__)
+app.secret_key = os.getenv("FLASK_SECRET_KEY", "dev-secret")
+
+@app.context_processor
+def inject_global_options():
+ return {
+ "dashboard_names": list(DASHBOARD_URLS.keys()),
+ "available_vpn_configs": _get_available_vpn_configs(),
+ }
def _format_ipv6(host: str) -> str:
return f"[{host}]" if ":" in host and not host.startswith("[") else host
@@ -393,6 +434,23 @@ def hnk_page():
def network_clients_page():
return render_template("pages/network_clients.html", active_page='network_clients')
+@app.post("/api/supis/list")
+def api_list_supis():
+ data = request.get_json(silent=True) or {}
+ host = (data.get("host") or "").strip()
+ try:
+ limit = int(data.get("limit", 500))
+ except (TypeError, ValueError):
+ return jsonify({"error": "limit must be an integer"}), 400
+ if not host:
+ return jsonify({"error": "Host IP is missing"}), 400
+ try:
+ supis = core_functions.list_network_clients(host, limit=limit)
+ return jsonify({"count": len(supis), "supis": supis})
+ except Exception as e:
+ app.logger.error(f"Error listing SUPIs for host {host}: {e}", exc_info=True)
+ return jsonify({"error": str(e)}), 502
+
@app.route("/system-browser")
def system_browser_page():
return render_template("pages/system_browser.html", active_page='system_browser')
@@ -457,7 +515,10 @@ def gaf_desk_page():
def api_list_m2000():
data = request.json
dashboard_name = data.get('dashboard')
- base_url = DASHBOARD_URLS.get(dashboard_name)
+ try:
+ base_url = _resolve_dashboard_url(dashboard_name)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
try:
token, session = auth_utils.get_vpn_dashboard_token(base_url)
devices = core_functions.list_m2000_vpns(base_url, token, session)
@@ -470,7 +531,10 @@ def api_list_m2000():
def api_get_network_config():
data = request.json
dashboard_name = data.get('dashboard')
- base_url = DASHBOARD_URLS.get(dashboard_name)
+ try:
+ base_url = _resolve_dashboard_url(dashboard_name)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
try:
token, session = auth_utils.get_vpn_dashboard_token(base_url)
config_data = core_functions.get_full_network_config(base_url, token, session)
@@ -483,7 +547,10 @@ def api_get_network_config():
def api_list_tenants():
data = request.json
dashboard_name = data.get('dashboard')
- base_url = DASHBOARD_URLS.get(dashboard_name)
+ try:
+ base_url = _resolve_dashboard_url(dashboard_name)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
try:
token, session = auth_utils.get_vpn_dashboard_token(base_url)
tenants = core_functions.list_tenants(base_url, token, session)
@@ -497,7 +564,10 @@ def api_list_plmns():
data = request.json
dashboard_name = data.get('dashboard')
tenant_id = data.get('tenant_id')
- base_url = DASHBOARD_URLS.get(dashboard_name)
+ try:
+ base_url = _resolve_dashboard_url(dashboard_name)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
try:
token, session = auth_utils.get_vpn_dashboard_token(base_url)
plmns = core_functions.list_plmns(base_url, token, session, tenant_id)
@@ -563,7 +633,10 @@ def api_list_plmn_hnks():
dashboard_name = data.get('dashboard')
tenant_id = data.get('tenant_id')
plmn_id = data.get('plmn_id')
- base_url = DASHBOARD_URLS.get(dashboard_name)
+ try:
+ base_url = _resolve_dashboard_url(dashboard_name)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
try:
token, session = auth_utils.get_vpn_dashboard_token(base_url)
hnks = core_functions.list_plmn_hnks(base_url, token, session, tenant_id, plmn_id)
@@ -675,7 +748,10 @@ def api_update_radios():
network_id = data.get('network_id')
new_count = data.get('new_count')
operation = data.get('operation')
- base_url = DASHBOARD_URLS.get(dashboard_name)
+ try:
+ base_url = _resolve_dashboard_url(dashboard_name)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
try:
token, session = auth_utils.get_vpn_dashboard_token(base_url)
result = core_functions.update_radio_count(base_url, token, session, network_id, new_count, operation)
@@ -693,6 +769,66 @@ def api_get_system_browser_data():
app.logger.error(f"Error getting system browser data: {e}", exc_info=True)
return jsonify({"error": str(e)}), 500
+@app.post("/api/logs/processes")
+def api_logs_processes():
+ data = request.get_json(force=True) or {}
+ hosts = data.get("hosts") or []
+ if not isinstance(hosts, list) or not hosts:
+ return jsonify({"error": "Provide a list of host IPs"}), 400
+
+ results = []
+ for raw_host in hosts:
+ host = (raw_host or "").strip()
+ if not host:
+ continue
+ try:
+ token = auth_utils.authenticate(host)
+ system_info = core_functions.get_system_info(host, token)
+ frontend = core_functions.get_frontend_config(host, token)
+ services = frontend.get("services", []) if isinstance(frontend, dict) else []
+ running = [svc for svc in services if svc.get("state") == "started"]
+ results.append({
+ "host": host,
+ "hostname": system_info.get("hostname"),
+ "services": running,
+ })
+ except Exception as exc:
+ app.logger.error(f"Failed to fetch services for {host}: {exc}", exc_info=True)
+ results.append({"host": host, "error": str(exc)})
+
+ return jsonify({"hosts": results})
+
+@app.post("/api/logs/stream")
+def api_logs_stream():
+ data = request.get_json(force=True) or {}
+ targets_in = data.get("targets") or []
+ if not isinstance(targets_in, list) or not targets_in:
+ return jsonify({"error": "No log targets supplied"}), 400
+
+ targets: list[LogTarget] = []
+ for item in targets_in:
+ host = (item.get("host") or "").strip()
+ processes = [p.strip() for p in item.get("processes", []) if p and p.strip()]
+ if host and processes:
+ targets.append(LogTarget(host=host, processes=processes, hostname=item.get("hostname")))
+
+ if not targets:
+ return jsonify({"error": "No valid hosts/processes to stream"}), 400
+
+ try:
+ streamer = JournalctlStream(targets)
+ except Exception as exc:
+ return jsonify({"error": str(exc)}), 500
+
+ def generate():
+ try:
+ for event in streamer.iter_events():
+ yield json.dumps(event) + "\n"
+ finally:
+ streamer.stop()
+
+ return Response(stream_with_context(generate()), mimetype="text/plain")
+
@app.route("/api/backup/create", methods=["POST"])
def api_create_backup():
data = request.json
@@ -741,7 +877,10 @@ def api_get_host_details():
def api_list_users():
data = request.json
dashboard_name = data.get('dashboard')
- base_url = DASHBOARD_URLS.get(dashboard_name)
+ try:
+ base_url = _resolve_dashboard_url(dashboard_name)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
try:
token, session = auth_utils.get_vpn_dashboard_token(base_url)
users = core_functions.list_users(base_url, token, session)
@@ -751,4 +890,4 @@ def api_list_users():
return jsonify({"error": str(e)}), 500
if __name__ == "__main__":
- app.run(debug=True)
\ No newline at end of file
+ app.run(debug=True)
diff --git a/auth_utils.py b/auth_utils.py
index a9c513e..cbe3b28 100644
--- a/auth_utils.py
+++ b/auth_utils.py
@@ -1,9 +1,16 @@
+import os
import requests
import json
from requests.exceptions import HTTPError
requests.packages.urllib3.disable_warnings()
+DASHBOARD_USER = os.getenv("DASHBOARD_USER", "admin@hpe.com")
+DASHBOARD_PASSWORD = os.getenv(
+ "DASHBOARD_PASSWORD",
+ "kxly7o6FboYUoQXSeLrs2xodHIeybQRwcMs33QC5#",
+)
+
def _format_ipv6(host_ip):
"""If the host_ip is an IPv6 address, enclose it in square brackets."""
if ":" in host_ip and not host_ip.startswith("["):
@@ -34,11 +41,11 @@ def get_vpn_dashboard_token(base_url):
"Content-Type": "application/json", "Accept": "*/*", "User-Agent": "Mozilla/5.0"
})
- credentials = {
- "user": "admin@hpe.com", "password": "JohnWayne#21",
- # "user": "admin@hpe.com", "password": "administratoR!1",
- # "user": "admin@athonet.com", "password": "administratoR!1",
- "lang": "en", "auth_provider": "enterprise"
+ credentials = {
+ "user": DASHBOARD_USER,
+ "password": DASHBOARD_PASSWORD,
+ "lang": "en",
+ "auth_provider": "enterprise",
}
auth_response = session.post(f"{base_url}/portal/api/session/authenticate", json=credentials, verify=False)
@@ -56,4 +63,4 @@ def get_vpn_dashboard_token(base_url):
if not token:
raise ValueError("Login failed: Could not retrieve session token.")
- return token, session
\ No newline at end of file
+ return token, session
diff --git a/core_functions.py b/core_functions.py
index e5a8fa1..62bd0dd 100644
--- a/core_functions.py
+++ b/core_functions.py
@@ -5,14 +5,25 @@ import subprocess
import time
import re
import os
+from typing import Any, Dict, List
import auth_utils
import hashlib
from requests.exceptions import HTTPError
from datetime import datetime
+from services import vpn_runtime
+from services.vpn_runtime import VPNRuntimeError
requests.packages.urllib3.disable_warnings()
-VPN_CONFIG_NAMES = ["Triton", "Star", "Bluebonnet", "Lonestar", "Production", "US-Support", "EU-Support"]
+_VPN_NAMES_ENV = os.getenv(
+ "VPN_CONFIG_NAMES",
+ "Triton,Star,Bluebonnet,Lonestar,Production,US-Support,EU-Support",
+)
+VPN_CONFIG_NAMES = [name.strip() for name in _VPN_NAMES_ENV.split(",") if name.strip()]
+
+
+def _format_host_for_https(host: str) -> str:
+ return f"[{host}]" if ":" in host and not host.startswith("[") else host
SERIAL_PASSWORDS = {
"3M1D2211Z3": "EP5G!f15878b4af20", "3M1D10146B": "EP5G!076689528baf",
@@ -207,38 +218,25 @@ def set_vpn_endpoint(host_ip, region):
return {"status": "success", "message": f"VPN endpoint switched to {region} ({new_ip})."}
def get_active_vpn():
- for name in VPN_CONFIG_NAMES:
- for pattern in ["openvpn-client@{name}.service", "openvpn@{name}.service"]:
- try:
- service_name = pattern.format(name=name)
- cmd = ["/usr/bin/sudo", "/usr/bin/systemctl", "is-active", service_name]
- result = subprocess.run(cmd, capture_output=True, text=True)
- if result.stdout.strip() == "active":
- return name
- except FileNotFoundError:
- continue
- return None
+ return vpn_runtime.get_active_vpn()
+
def toggle_vpn_connection(vpn_name, turn_on):
- active_vpn = get_active_vpn()
- if active_vpn:
- subprocess.run(["/usr/bin/sudo", "/usr/bin/systemctl", "stop", f"openvpn-client@{active_vpn}.service"])
- subprocess.run(["/usr/bin/sudo", "/usr/bin/systemctl", "stop", f"openvpn@{active_vpn}.service"])
+ vpn_name = (vpn_name or "").strip()
+ if turn_on and vpn_name and VPN_CONFIG_NAMES and vpn_name not in VPN_CONFIG_NAMES:
+ raise ValueError(f"Unsupported VPN '{vpn_name}'")
- if turn_on:
- try:
- service_name = f"openvpn-client@{vpn_name}.service"
- cmd = ["/usr/bin/sudo", "/usr/bin/systemctl", "start", service_name]
- subprocess.run(cmd, check=True)
- except subprocess.CalledProcessError:
- service_name = f"openvpn@{vpn_name}.service"
- cmd = ["/usr/bin/sudo", "/usr/bin/systemctl", "start", service_name]
- subprocess.run(cmd, check=True)
- time.sleep(4)
- if get_active_vpn() != vpn_name:
- raise Exception(f"Connection failed for {vpn_name}. Check OpenVPN logs for details.")
+ try:
+ if turn_on:
+ if not vpn_name:
+ raise ValueError("vpn_name is required when turning on a VPN")
+ vpn_runtime.start_vpn(vpn_name)
+ else:
+ vpn_runtime.stop_active_vpn()
+ except VPNRuntimeError as exc:
+ raise Exception(str(exc)) from exc
- return get_active_vpn()
+ return vpn_runtime.get_active_vpn()
def get_full_network_config(base_url, token, session):
network_url = f"{base_url}/portal/api/1/network"
@@ -372,6 +370,47 @@ def get_system_browser_data():
return list(vpn_clients.values())
+
+def _extract_subscriber_list(payload: Any) -> List[Dict[str, Any]]:
+ if isinstance(payload, list):
+ return payload
+ if not isinstance(payload, dict):
+ return []
+ for key in ("subscribers", "items", "data", "results"):
+ value = payload.get(key)
+ if isinstance(value, list):
+ return value
+ return []
+
+
+def list_network_clients(host_ip: str, limit: int = 500, page_size: int = 200, timeout: float = 15.0) -> List[Dict[str, Any]]:
+ """Fetch SUPI/network client data from the target ComboCore host."""
+ if limit <= 0:
+ raise ValueError("limit must be positive")
+ token = auth_utils.authenticate(host_ip)
+ formatted_host = _format_host_for_https(host_ip)
+ base_url = f"https://{formatted_host}/core/udm/api/1/status/supis/"
+ headers = {"Authorization": f"Bearer {token}"}
+
+ collected: List[Dict[str, Any]] = []
+ offset = 0
+
+ while len(collected) < limit:
+ batch_size = min(page_size, limit - len(collected))
+ params = {"limit": batch_size, "offset": offset}
+ resp = requests.get(base_url, headers=headers, params=params, verify=False, timeout=timeout)
+ resp.raise_for_status()
+ data = resp.json()
+ items = _extract_subscriber_list(data)
+ if not isinstance(items, list):
+ raise RuntimeError("Unexpected subscriber payload format")
+ collected.extend(items)
+ if len(items) < batch_size:
+ break
+ offset = data.get("next_offset", offset + batch_size) if isinstance(data, dict) else offset + batch_size
+
+ return collected[:limit]
+
# ------- System ID Data Begin ---------
def _make_host_api_get_request(host_ip, token, endpoint):
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2d589cf
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,30 @@
+version: "3.8"
+
+services:
+ app:
+ build: .
+ image: network-tool:latest
+ ports:
+ - "5050:8000"
+ env_file:
+ - .env
+ volumes:
+ - ./ansible_workspace:/app/ansible_workspace
+ - ./openvpn/configs:/vpn/configs
+ - ./openvpn/runtime:/vpn/runtime
+ #- openvpn-state:/vpn/runtime
+ - ./keys/5G-SSH-Key.pem:/app/.ssh/5G-SSH-Key.pem:ro
+ - ./keys/5G-SSH-Key.pem:/root/.ssh/5G-SSH-Key.pem:ro
+ devices:
+ - /dev/net/tun:/dev/net/tun
+ cap_add:
+ - NET_ADMIN
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/api/ping"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
+volumes:
+ openvpn-state:
diff --git a/keys/5G-SSH-Key.pem b/keys/5G-SSH-Key.pem
new file mode 100755
index 0000000..120d730
--- /dev/null
+++ b/keys/5G-SSH-Key.pem
@@ -0,0 +1,38 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
+NhAAAAAwEAAQAAAYEAv81946SvXssgs4GbLu4PZx9cHGuyU9P/9MonOhWceUYJ9QVLnm1/
+E13FOdVKfIy8T5lyb2EUReKZKLzsEMIjr+BTKNlFNLplyyTOC5CYTlPJIFCxCV5G+Kv+Tt
+KrAJdm3VgMuA0BSs3O9vg4Y35J+/CIG47MOVAM4KfX2EVYhH5eNBuI13YiBhv7n/KRPAx5
+PKkA9PHjmihB2ua+tyDpkkOuU54v0v6uTOsfDjA5Qs22tb1GMix1P5pNB0ovgzjXB6b2xq
+HP7fvECcMNJSBky+kQfiK2tDUxoCssJDqSZccC7l2qknK9nFlJtZfL+Nb9ZyyOnXaMWhJA
+FV6HKrZr5EHKWNEzkdO5JNGn0HbcIZsvbYTc+h2bRKBEeNWvkFihmHen962s/MDhrdRXLl
+VwdhBtdtBIgBbp+oC/E2M8Sf6Gl03/QNkTgXFlUzEzodv7PysnKIz8PoGDXp/jk1OZqxk5
++5wPhVGT9DI9Y7XLVGzU7BAQkf2ncCDv16lXiPc7AAAFiLCDAfiwgwH4AAAAB3NzaC1yc2
+EAAAGBAL/NfeOkr17LILOBmy7uD2cfXBxrslPT//TKJzoVnHlGCfUFS55tfxNdxTnVSnyM
+vE+Zcm9hFEXimSi87BDCI6/gUyjZRTS6ZcskzguQmE5TySBQsQleRvir/k7SqwCXZt1YDL
+gNAUrNzvb4OGN+SfvwiBuOzDlQDOCn19hFWIR+XjQbiNd2IgYb+5/ykTwMeTypAPTx45oo
+Qdrmvrcg6ZJDrlOeL9L+rkzrHw4wOULNtrW9RjIsdT+aTQdKL4M41wem9sahz+37xAnDDS
+UgZMvpEH4itrQ1MaArLCQ6kmXHAu5dqpJyvZxZSbWXy/jW/Wcsjp12jFoSQBVehyq2a+RB
+yljRM5HTuSTRp9B23CGbL22E3Podm0SgRHjVr5BYoZh3p/etrPzA4a3UVy5VcHYQbXbQSI
+AW6fqAvxNjPEn+hpdN/0DZE4FxZVMxM6Hb+z8rJyiM/D6Bg16f45NTmasZOfucD4VRk/Qy
+PWO1y1Rs1OwQEJH9p3Ag79epV4j3OwAAAAMBAAEAAAGBAJIvbsm4VSlsrEnkeIB2VSsFzd
+CjjNEzfZv3D5rHqfEMnr4vAQmI7xe1moKPvCvdoBETJRMa7LavFIjmJQ5IyaZc1UUHBCZC
+Ax+nt5s847ifR2Xn2mcHghQ6EqPFESxsOKxvVZJZ5yg6YIn/egrq0DzDgRlv5tuv1YDMrE
+hb4jFplycj7VI66Ye6gDfSSzt3Tlgbf20xh4WRNVBAGg+9eAhQqWd144FvNy3M7miD/MCb
+xawSzJOlKuAFHLx3oGrlQDmOFMPcLN2E7mXqWS5XZtQ4bBkF2nUVMs/mLjHMdFdLvR9u5t
+yLHEedpc7PXJ/66P5o+ebDZL5tG9B0/fXNsc73W+mdV/FrwBnpn3PbPj9RpIs+b6v1MwQI
+1i6y3z3SfYTEFNkMwtYsC75i3ga4N7v4I/CmlGczYwoq6zY2ZSg03fQ0AzyDQxgzIkSc6w
+pGuPEvp3Er/NolHd1ErSDKWlGzsICCif9ES6W6LvDqoi9TyuqIiaAnRx8IE/EA9COaYQAA
+AMEAl2Tv8te3LvnLzuYaobqltjXk3PFEG2GvP5wnr87ItSX99Iv4avycvKA67xk80hC6Pq
+MaL7AyRkkP8vs3lMCRx90Kwy+ZZZonPuvClwFd8yNhk74ZzbhgPgd6oVM8eI8mmWZWcCzz
+6kp6AQ3P1x5ZI0UB3oQHYGDzzFICaaj35I2XpBkQN29ErtRV5IQRN+ZKi6yZQnWaxJDPi6
+K2HEyPnS2Te+Ck6q6t3Os+YWMlgr/s1eFRWCYLioWHNGl5krHsAAAAwQD/kFZgz+/6b7v0
+FbaUW016K4xj1FpgAI6YF3w6fy1g+2pmFaFx4HAWdWiob0oPAhRoYBPrPPPKACYXtXgNBm
+IkBJK/7uTdvOxJqrXlM2rDK+BgyZLo/y8U1AARgEw0x1oFnJLNsT/qYhfeHScizp/Lj5qF
+JznZMu8Hbhdi3XnPmPMH1AuaQ2E6Leh+5Wl0svw3/dXHc8dRh7AFyUMDN8S2ujWKGiRUOl
+NQBmaSJIBOj4pKn4ixbK1GaSLa8i4cqYMAAADBAMAhS6DjH0yp6Vb/hN9i5d/ASC7BbsEL
+Zo9IafnFNBCXyfaazg7Rdez/b0qx2n07j7gXuYq0IUVzG9FQTMxC7Rkc8eP4jYC6BHSEjb
+R46FnVP4v7wjqBQCKTaw9J6xvC1SoHTeISgXRozaHssR5IiEHeiuFzuuNTvM30+TbYAsuc
+ASFRLdv0i0IoZ3A03zlrsDbeTkTsZxw4LYQ5lMHi99C9wmZmsQMob/9RPSbHshwgycXnCx
+jthSo+rcUFcoZl6QAAABB1YnVudHVAeW9jdG8tc2RrAQ==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/openvpn/configs/Production.ovpn b/openvpn/configs/Production.ovpn
new file mode 100644
index 0000000..6868892
--- /dev/null
+++ b/openvpn/configs/Production.ovpn
@@ -0,0 +1,157 @@
+client
+remote vpn.us-east-2.p5g.athonet.cloud 1091
+
+comp-lzo yes
+dev tun
+proto udp
+
+askpass /vpn/configs/cm-prod.auth
+
+nobind
+
+script-security 2
+persist-key
+persist-tun
+
+
+Certificate:
+ Data:
+ Version: 3 (0x2)
+ Serial Number:
+ 74:c7:cc:25:45:57:b9:0e:4c:f3:e8:dd:a4:5c:29:57
+ Signature Algorithm: sha256WithRSAEncryption
+ Issuer: CN=ras-server
+ Validity
+ Not Before: Feb 13 13:45:21 2026 GMT
+ Not After : May 18 13:45:21 2028 GMT
+ Subject: CN=client
+ Subject Public Key Info:
+ Public Key Algorithm: rsaEncryption
+ Public-Key: (2048 bit)
+ Modulus:
+ 00:8e:54:19:52:6b:8c:7a:bf:1b:5a:eb:88:a1:06:
+ ae:3a:17:b3:bb:9d:e9:44:ab:68:5d:2c:12:51:ea:
+ c2:df:53:d7:19:61:9d:8f:cb:f0:0f:ba:70:d9:d3:
+ 7a:c4:42:4d:27:fe:fb:ba:fc:f0:37:fa:7e:7e:26:
+ 86:b2:dd:81:b8:b0:db:5c:e1:13:0a:4f:87:72:df:
+ 46:0b:62:e0:11:19:f4:66:35:a4:76:28:1c:8c:9d:
+ 46:13:10:22:d4:45:8f:7f:45:6c:38:a1:28:49:07:
+ 38:8f:14:81:05:d0:25:4c:83:b3:1a:ec:91:ce:06:
+ 68:64:04:fb:b0:6e:84:46:58:34:f9:1c:83:26:ba:
+ 9a:f4:e9:62:47:5d:3d:3b:05:56:2c:0a:ff:0a:a5:
+ bb:7a:c5:34:23:84:ea:2b:16:ad:72:f7:22:3a:88:
+ df:25:78:03:58:a5:bf:a2:be:73:c9:d0:ce:46:3d:
+ 04:27:fc:8c:ca:02:23:70:c9:83:be:c0:50:97:6b:
+ e1:cc:3e:77:5d:b5:8a:ce:8c:27:c0:40:3e:db:ea:
+ b4:29:f0:79:5a:0a:c0:f5:62:d7:6e:71:31:71:5a:
+ 85:6a:20:66:86:16:a5:82:c3:89:7d:b5:3e:3a:9f:
+ 89:75:1f:1f:cd:1b:aa:2f:d6:f2:4b:58:79:1f:21:
+ 63:a1
+ Exponent: 65537 (0x10001)
+ X509v3 extensions:
+ X509v3 Basic Constraints:
+ CA:FALSE
+ X509v3 Subject Key Identifier:
+ 51:BE:CE:53:81:1A:FE:DA:8E:BA:87:A9:09:CA:E8:D3:4D:02:1A:DA
+ X509v3 Authority Key Identifier:
+ keyid:17:2C:34:81:52:BB:79:9E:59:F6:AF:8E:18:C0:A9:14:4E:30:86:B0
+ DirName:/CN=ras-server
+ serial:71:BA:B6:63:39:2D:E7:71:DC:77:10:BB:43:3E:4B:47:AD:EF:5D:80
+ X509v3 Extended Key Usage:
+ TLS Web Client Authentication
+ X509v3 Key Usage:
+ Digital Signature
+ Signature Algorithm: sha256WithRSAEncryption
+ Signature Value:
+ 28:f2:80:23:d0:51:4b:07:b2:99:62:31:8c:3f:ca:ba:30:2c:
+ 3c:84:da:96:81:f0:18:78:e2:33:8a:cc:12:05:03:9a:e4:ca:
+ 7e:06:73:f2:27:c3:c9:49:94:6b:91:71:57:a7:40:45:59:f2:
+ 39:b3:98:ce:8e:7a:c0:ac:eb:72:4f:7c:e4:c9:47:a0:01:99:
+ 7a:74:9a:29:f0:b3:1b:e5:24:20:c2:f4:77:fd:d9:41:c5:3b:
+ 5e:ba:df:d4:ec:dc:5b:45:f2:e8:95:4b:5e:8f:e1:b3:ce:1a:
+ b9:82:a3:03:52:cc:d3:7d:76:e7:0e:e9:39:e3:93:87:92:66:
+ 7a:f0:b3:ca:39:7c:06:2b:96:df:fd:87:a8:81:1c:ba:ee:c5:
+ 92:0b:0c:0f:e9:8f:87:f0:49:ed:8e:e7:2b:e5:7a:19:05:01:
+ 88:14:df:2c:5d:8e:b7:1e:66:01:f0:c5:b2:be:9d:e6:78:f8:
+ 60:15:a0:b4:8c:46:e6:35:e6:d0:c6:3b:55:d6:1c:f5:40:90:
+ df:5f:bf:9c:dc:14:b6:aa:66:1d:98:ca:00:54:b7:09:59:79:
+ fe:ad:f9:70:26:fe:cf:a9:83:dd:7e:29:c9:8a:51:8b:42:96:
+ 76:2e:42:2a:ee:0e:1e:cc:a6:e8:87:fa:40:be:58:54:40:c8:
+ d8:55:53:02
+-----BEGIN CERTIFICATE-----
+MIIDUjCCAjqgAwIBAgIQdMfMJUVXuQ5M8+jdpFwpVzANBgkqhkiG9w0BAQsFADAV
+MRMwEQYDVQQDDApyYXMtc2VydmVyMB4XDTI2MDIxMzEzNDUyMVoXDTI4MDUxODEz
+NDUyMVowETEPMA0GA1UEAwwGY2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEAjlQZUmuMer8bWuuIoQauOhezu53pRKtoXSwSUerC31PXGWGdj8vw
+D7pw2dN6xEJNJ/77uvzwN/p+fiaGst2BuLDbXOETCk+Hct9GC2LgERn0ZjWkdigc
+jJ1GExAi1EWPf0VsOKEoSQc4jxSBBdAlTIOzGuyRzgZoZAT7sG6ERlg0+RyDJrqa
+9OliR109OwVWLAr/CqW7esU0I4TqKxatcvciOojfJXgDWKW/or5zydDORj0EJ/yM
+ygIjcMmDvsBQl2vhzD53XbWKzownwEA+2+q0KfB5WgrA9WLXbnExcVqFaiBmhhal
+gsOJfbU+Op+JdR8fzRuqL9byS1h5HyFjoQIDAQABo4GhMIGeMAkGA1UdEwQCMAAw
+HQYDVR0OBBYEFFG+zlOBGv7ajrqHqQnK6NNNAhraMFAGA1UdIwRJMEeAFBcsNIFS
+u3meWfavjhjAqRROMIawoRmkFzAVMRMwEQYDVQQDDApyYXMtc2VydmVyghRxurZj
+OS3ncdx3ELtDPktHre9dgDATBgNVHSUEDDAKBggrBgEFBQcDAjALBgNVHQ8EBAMC
+B4AwDQYJKoZIhvcNAQELBQADggEBACjygCPQUUsHspliMYw/yrowLDyE2paB8Bh4
+4jOKzBIFA5rkyn4Gc/Inw8lJlGuRcVenQEVZ8jmzmM6OesCs63JPfOTJR6ABmXp0
+minwsxvlJCDC9Hf92UHFO16639Ts3FtF8uiVS16P4bPOGrmCowNSzNN9ducO6Tnj
+k4eSZnrws8o5fAYrlt/9h6iBHLruxZILDA/pj4fwSe2O5yvlehkFAYgU3yxdjrce
+ZgHwxbK+neZ4+GAVoLSMRuY15tDGO1XWHPVAkN9fv5zcFLaqZh2YygBUtwlZef6t
++XAm/s+pg91+KcmKUYtClnYuQiruDh7MpuiH+kC+WFRAyNhVUwI=
+-----END CERTIFICATE-----
+
+
+
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIa+OAPnbBLPoCAggA
+MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECMIGNyqOMgmdBIIEyCKHB6hSeetF
+GmqrqPawa4TZt5sRqFJOOh2ctVr7e/vs7pfbxpyZ3uFauDktOtnZYL2HbDfuBegE
+1T5tOOXSoB+8K2ITggMNTx8Qdqutdrzpb/D0H9FOp4b9ey3e3BB0xUphVRbUA9VE
+spJGvTKiox6N1JE6PXAtU/1LvjLaZpI0kWAu+dokPDpVcLYrUPgG4wYMtrtSAgS6
+2ViU7Emhnx4JzVCHbPw8ssnDMQe9A43+fMlthBYHYMZRLZvjpGmE825U0TJD8g6S
+JFbFXL+9ptphO7SAhgPy7jmFyVJ/Ydp22JRj6fijLkYxncL/RA4rB8uakmaEzKev
+47CAAMJC0RmKmesggmaSprvLYlSW/ZUoK2PztrNnQLwe9T5/UiXe9e61XbxrYpYY
+xVXuxEjMUXhaHvoLi8vcuPD1Oir52b5sNyis2L5W5/iBnyUPZTPf7gKYYndP/wAg
+DgEablWGLrRGmdd/zAzU5Vb5csIHqoHC0kAlHAK7Yz7Dn1jwUI1YoLA3RUxht6ZL
+T5HohPgEyXZDrZExBvKyVu+qxHE4emy1kkHMqV83y/9xQbTy44akwdJ0z7LRweOD
+ja7exF7D4MX0BLLUTotGv7p9fe34qeJC5kSNT3gFEgxPiznFslmMMHAvt/siJcy3
+3hxbNUS1xUq0jxXcPjTioiox2mcplIAHyILlOXhguwFPlVBNv1R/QjndrZ1zJsnH
+zJ9CC/kkPEVp0BSZ2y0kliSh0U0a00gFc9w7hxXT9wv60wZ427koTLZbdfSHYELg
+WvWgtU7OBEYa0HDGiLb2O4kXbNn0JsoYGmE18HmNF3a4lXrYjNZcNHsPw2aduVPz
+EdBv4QfKn0hFjgFY/qh90LqQ6Qa83jhSgPQaoyzODMjDcy0dTNAexgNg7eKL6VLW
+T76zJ3i77jJF2yuUlDqFWatEtjAbBtgoRoHkSqWdG+Nd+/O7vve1bptZWTeaQSTu
+jl4VHLviWZzJtMA8iSvskiSWc0YmoxvxbakUMTeztvopqkrA+x8J+um0gwD6lnjV
+T6Tway5MKnbEuHA1dFFxNIPDxwDrzNcPBuUVAV8biP0M6f1tAFn9mGUViiRxSv3n
+8nTjgzl8KE7IMig9kLoI5iSmL2jKgB/WB6mWsL+UF/stJ5Q8axNjcBVrd2T1C6SH
+ZN8HWgtnu38sA19nCTpnKxkDZD0hJTvF/Y/8J2HCkJV9NBXZcMSHUJqZikmBLxcq
+bxtbTG9peM6FqtCciUaCIs6spH7s8Bz0HANwfGG50OPN/8TIhFPRlzMNfRwbmKbY
+E6EOywYKRE2BsETYRbfs4dZP8tVuqI5jdOn2BDnip4swZOoRcboEzE8HFgjnAJPg
+cK14ii9S/mQxDDOsRWaZmSK0T9MJuSJpKTZWkbbjFhmTEq9Q89oqv5/bMZS9utga
+Zxud353mqKLQJbwqJFouggQKY3QjjAdx3dUg42rhbSzxOuypgXDCg7Ydrxe0BeFf
+sB1h71wmnJkjzZXbBRqjlExmPfasVwar52YUclOqjYBEBUKdErUVoI0GtcKo/Ike
+3emQhIv0eJXkDSaU72fTHTiCY6cwfm/HwPZ1IJ+UW+CyhXO2gQX5YoYEqkxCFMxF
+N0hRJ8XbnEQGvsfQSsnm1Q==
+-----END ENCRYPTED PRIVATE KEY-----
+
+
+
+-----BEGIN CERTIFICATE-----
+MIIDSzCCAjOgAwIBAgIUcbq2Yzkt53HcdxC7Qz5LR63vXYAwDQYJKoZIhvcNAQEL
+BQAwFTETMBEGA1UEAwwKcmFzLXNlcnZlcjAeFw0yNjAyMTMxMzQxMTdaFw0zNjAy
+MTExMzQxMTdaMBUxEzARBgNVBAMMCnJhcy1zZXJ2ZXIwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCXbky7LHJ/AdngYmP/NsDkjqr0q1ChksXSn9nfY0+w
+7QFrD+OW0lcLYj3nOBbgtfsJo7YEPC2flws7hath/F0QrcBXBRRtYPjYgNuyWjUL
+34uNTb0MdQjcVD7n7nBbmFYaYcmS+6BAB1nE8Y8wkQhcuMSKxWaE92ivhEQlCxSZ
+NuAEmgfDK7Ow4Vq/MhvFTD+lxpctaJVeslU38De5kuYWd04jOOphitrRvdSHm0fE
+zRI2lWfNp5a6f6QV6uVdSOK5CacK6Fv8mKOn3UW3qr1AJyhJHbg1hu1ZS8+KtAq0
+bhnY9vQyfP3xhLe8Hp019uqW1WG5P7mberIAsXWXEYX3AgMBAAGjgZIwgY8wDwYD
+VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUFyw0gVK7eZ5Z9q+OGMCpFE4whrAwUAYD
+VR0jBEkwR4AUFyw0gVK7eZ5Z9q+OGMCpFE4whrChGaQXMBUxEzARBgNVBAMMCnJh
+cy1zZXJ2ZXKCFHG6tmM5Ledx3HcQu0M+S0et712AMAsGA1UdDwQEAwIBBjANBgkq
+hkiG9w0BAQsFAAOCAQEAFv8JCCYmzgL+g6QEh+cCmwL/oxLsZR19cqoATIvx1A83
+uOVa3HeNKe6iaLr+Qo3qk9SuGfJeNL/ETiW+UlQkQN6WptE/5J36XfcvsgEUes0r
+8kCZybO/m9xTKDm/kZ3fRy6w5E79uT674Cxkzi+03bIV40X/IPO7YgIlwqOEoGBt
+cWlGWKFNIJmdRUDqUQAtrZ0bFQyTefesvZ8BTM5grOGvEPtjHz4VRPOE/114Msf1
+u0NudAeHo9qqTxH4SzX6AEYvkIrbK0Cv6Z3aeu0coSnXyWGgCBdVAuzH7rbTo5ex
+s97EKGeaY5pQbntmnuB5x4bhryRFVRfY41ngfL9PkA==
+-----END CERTIFICATE-----
+
\ No newline at end of file
diff --git a/openvpn/configs/US-Support.ovpn b/openvpn/configs/US-Support.ovpn
new file mode 100755
index 0000000..0ee42c8
--- /dev/null
+++ b/openvpn/configs/US-Support.ovpn
@@ -0,0 +1,115 @@
+client
+remote 128.136.82.165 1499
+auth-user-pass /vpn/configs/noc-us.auth
+comp-lzo yes
+dev tun
+proto udp
+key-direction 1
+nobind
+;auth-nocache
+script-security 2
+persist-key
+persist-tun
+
+data-ciphers AES-256-GCM:AES-128-GCM:AES-256-CBC:AES-128-CBC
+data-ciphers-fallback AES-256-CBC
+allow-compression yes
+
+
+#
+# 2048 bit OpenVPN static key
+#
+-----BEGIN OpenVPN Static key V1-----
+a4a76c0cfe4f90c3e69899712226a4cb
+5fc51962c16455a069a3a17812e5b876
+7f7761e00ab20fd34448dc1341fcd11d
+5343474e49665b613edecd9eb31be1fa
+29b4d0c22ea0fa0fb48ad6fe6bf78905
+87ecd070d735908a480dfa1a393d8fef
+74ee18dea11c0be9fe800b6b50859ce9
+b608b7f717ded5c6708bf3e6b40d9af5
+c8247886749596ab5c82aee7fc863a12
+3fc47c54e1a310001636943e20981992
+0e316b6e697ef2298060c8176160a69b
+1ef2e68145c46cc92664ab2dc5349eeb
+1598deb0145a2c76318f03689436a426
+3dd84e22912900d63f4869524cdba5e8
+e8dc2d46b89e8cd6418f36ffaed3f561
+68cb4cf047aa2433793291908b3ba334
+-----END OpenVPN Static key V1-----
+
+
+
+-----BEGIN CERTIFICATE-----
+MIIDRTCCAi2gAwIBAgIRAJ6L07yCYjna3T2iqXTNXGYwDQYJKoZIhvcNAQELBQAw
+DTELMAkGA1UEAwwCQ0EwIBcNMjExMjI3MTIyODU4WhgPMjA5MDAxMTQxMjI4NTha
+MBExDzANBgNVBAMMBnJlbW90ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
+ggEBAOAoz5bO6nK0r9QMkOVzV1f464DGtZKWcXGT280qaORUHH5TGTTseVH0a36b
+6qVvCBBRPWuettClLerltQHe5OoIJGkkKZ5ZVQyDzfjNcLqp+ieZVI5EognWuCUx
+w7HgAfRbFrFKSAyX1xtSrOypoKwrM3XfaFOFCye9T3Vt5QjcWm5PfMKJqs3Ljmeb
+op8XoRIM0guxXlis6a+m8koWDBQArOUHtTjmfJWFOZiBU++lk3hQerUf9vOJWybU
+fzraB2BBz4k/qbOzNxv4NNOP7FEIc9ijfN11DUADZqzjPffADTa3Ipd5mN9Otvue
+cgHq2iKrERKJq0gqJH4BdYD3PP0CAwEAAaOBmTCBljAJBgNVHRMEAjAAMB0GA1Ud
+DgQWBBSef+DbTCUUcYbONWB4dm0eDh56sDBIBgNVHSMEQTA/gBTs/L0I4+lJVT31
+mBoXOslN+NydgKERpA8wDTELMAkGA1UEAwwCQ0GCFDSh+PclVE+hqkJaKiJBxpSh
+bKtbMBMGA1UdJQQMMAoGCCsGAQUFBwMCMAsGA1UdDwQEAwIHgDANBgkqhkiG9w0B
+AQsFAAOCAQEACPVwrl1hrERJDsaS1L4PXqTIwGj8G9oD9cx+bbZFa8YYYzot9742
+5oeEjZEttCFbt6r5EY2/YG2/hC78L04QF6CpuIDVuJrcFqk6lb6SIXCWyZxl7x7I
+lHHdommSxhuiKvnBdV1VELsuxGwUADObuFuhS60V/GlrOHaCbdJyxEzJgSVKmBaw
+YHEt91ficzynYMci5q23/L9DF4WSkrRJJYIOraCpjiQ5Bg0GyJNrqMVdKfeS38XO
+W7ZpCcy8/jb9noHCVAyXewhNpUtue2dEZF4axQIDHpPky/UoSpk7sUnKG6bYokG+
+9IRT6CA/KnfUtC36oyHEZWjmfTB0pHMZjA==
+-----END CERTIFICATE-----
+
+
+-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDgKM+WzupytK/U
+DJDlc1dX+OuAxrWSlnFxk9vNKmjkVBx+Uxk07HlR9Gt+m+qlbwgQUT1rnrbQpS3q
+5bUB3uTqCCRpJCmeWVUMg834zXC6qfonmVSORKIJ1rglMcOx4AH0WxaxSkgMl9cb
+UqzsqaCsKzN132hThQsnvU91beUI3FpuT3zCiarNy45nm6KfF6ESDNILsV5YrOmv
+pvJKFgwUAKzlB7U45nyVhTmYgVPvpZN4UHq1H/bziVsm1H862gdgQc+JP6mzszcb
++DTTj+xRCHPYo3zddQ1AA2as4z33wA02tyKXeZjfTrb7nnIB6toiqxESiatIKiR+
+AXWA9zz9AgMBAAECggEBAK70S2nKX8RdcGqR0Dm193MLLkxZS3h5AVwDamfMdQfY
++lBCbYcYFmy313p/GPo8GdAaiFTKEKfydE9FMCygmoxrBHgnqHAWC0J0UTuipRyb
+9EoZ65wKx2nkc99b4wCe9QeSXLjNYESr8lE6CwvALU6TfVu/nf6p9ZXztOPTfRKW
+6+eSY8FQqPLxfAJymAsfJw6U+WFVGBP74jX4tt1sZESma+KKZQNj0PK9lSv/wKA1
+ZEpgRs3kPdNRbvWTdAoA9JFHHPQ9skiBq4LLjdFrR5Mg2Sqz1BCxxXZwbWdQEOWm
+aY/NA5IdNK6KeROtu+X1t7TqVi3kUR9wtj+Bi3SAagECgYEA8vaNQcF6WCB+Z59T
+5hGOn2wFAYwqB0+hiEzzNy3bLYfWkMgS9Jcj6FbqdfhA+jTML1pi4Zkq8EHcGUI5
+Us2bN/XZy4y5DNGY1hWLeMN18tJTH102Z3FqdoeHNC3YB3wi253vG4aAZPUbmR6S
+kNr3YqVQsmOCfSHQdGScZNoMsoECgYEA7C/2qf3o5l2DSd3DRG3or+bSh1Tj93GG
+Tp0CHJpW4K8xzzwW5wJ6fk+O4NN19CSR/YE+zg3VWOUZhk2stQ4iZSmtC6RZIv2U
+XuQGyLM7aqf9n2qFG5r3kyQjg161497HA0BalgR4/HFpzk/DG2Jo7QOThOJVrOHe
+4cVJCx3SFH0CgYEAtOKKWCJLi8DlWxBrziXUISyyrWxE/hxzDp77lGE3hLZVMIVE
+V4UO3rOW6y1gcudL/RU1+O+n4CeoTcXYF6WrogYPmFO0ka6aMwjnRYmADsA30mn7
+TxhJQuWz600WQLxS117F3aBNhtxVJ7JzPBVJiM+7PJSJWdAK+hzNsugD/AECgYAN
+7r5kRMAqZrXJ87UDImCpj7o3lYBlJmM/2+819LzPQEZ17RuEwRaswNCy3oaEwmuC
+Qs+LpDFDrzAURhy8CXtp8E1u9GD1uXO5LUZhLIGCxyok70mu2TNlkKovo7SjHo8q
+1+8ADn98lMwjCX1+7g02fhGDsz5OlnxpYRPv/fBYmQKBgQDIH8uljwUh8TZ2lU/L
+NFijQCCo//d9yyusudDhy/maV5gY2daQf9HAh658hqT5rdBE1Te468E/m7O6cR3H
+x8Shm9d4yhGj26Auo/BmVEP+N7zHQORMx1E4d58TFrPMTQmYfciIfyR0d2Zm3nEn
+QKzHAh30+zz/hRbtt1EgmwvJTw==
+-----END PRIVATE KEY-----
+
+
+-----BEGIN CERTIFICATE-----
+MIIDMjCCAhqgAwIBAgIUNKH49yVUT6GqQloqIkHGlKFsq1swDQYJKoZIhvcNAQEL
+BQAwDTELMAkGA1UEAwwCQ0EwIBcNMjExMjI3MDgzMjI4WhgPMjA5MDAxMTQwODMy
+MjhaMA0xCzAJBgNVBAMMAkNBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
+AQEA01ruJJUQP/K+R/DzeEi7DA8BlQDEtgmtBnHuZsnllqWU6Ie3qVdE4naQU3GS
+4TAkVl1cn1Ojq2DEwMlAbyeh6nTMYp9J9Glemg3c/Tei9hDnNSE1PgMyBZ+TXPVc
+MNikSb58Tv4SB50CQ2Zcg+vX8O9h4gW7NttMlyaRmfJ42oUXURIG93gosQVDeOXg
+QGo5B13msKkXFuACWOHEWBq68r+jZefh7dbTx+LS0wxCoPNlntMmHECvmrfaFwBT
+4s96NsbJRgnAtw8jR0g9JqVJH2mblke/wsljysqueb5aFGYtXQ/LnkgPUh7h+MeW
+UuSVdw6tVWdM1z67jQzmQXljkwIDAQABo4GHMIGEMB0GA1UdDgQWBBTs/L0I4+lJ
+VT31mBoXOslN+NydgDBIBgNVHSMEQTA/gBTs/L0I4+lJVT31mBoXOslN+NydgKER
+pA8wDTELMAkGA1UEAwwCQ0GCFDSh+PclVE+hqkJaKiJBxpShbKtbMAwGA1UdEwQF
+MAMBAf8wCwYDVR0PBAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IBAQB6OUj2mNeXkKt7
+y++gznXcs6BviCDAMMtj4/p9NRoA1Ra8l7JxyOIEVEUnNz0Nfm1CUPlw6O4F3WMi
+qJEkaieNmxPqiWaTIi6qcvJZAg23iCuqDUifOfVeT70vyDYVvCUPAsyvUhhQznUG
+INOwy7FF6kBpI7alTO33EeQ0mCL3tiwXdKEk42fkfygr06Ryhix/TcUdS7U0/Y4q
+JwyiFjkZBa/sJIG0cqCjeZuhERQa0+7skqvSjLJWIj1yXxX6NUp68FKXGKrJhil9
+lVHbnlPv+TF63n80GSbEJEs0FJY7oI0oGHNQCj1dwzlkOH5FLhBeNOWyl8AyeKUk
+CqjjCVgg
+-----END CERTIFICATE-----
+
diff --git a/openvpn/configs/cm-prod.auth b/openvpn/configs/cm-prod.auth
new file mode 100644
index 0000000..e9a9ea1
--- /dev/null
+++ b/openvpn/configs/cm-prod.auth
@@ -0,0 +1 @@
+12345678
\ No newline at end of file
diff --git a/openvpn/configs/noc-us.auth b/openvpn/configs/noc-us.auth
new file mode 100644
index 0000000..f7cd033
--- /dev/null
+++ b/openvpn/configs/noc-us.auth
@@ -0,0 +1,2 @@
+jake.kasper
+N@t74han
\ No newline at end of file
diff --git a/openvpn/runtime/.gitkeep b/openvpn/runtime/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/openvpn/runtime/US-Support.auth b/openvpn/runtime/US-Support.auth
new file mode 100644
index 0000000..f7cd033
--- /dev/null
+++ b/openvpn/runtime/US-Support.auth
@@ -0,0 +1,2 @@
+jake.kasper
+N@t74han
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 2b09d3d..0f1236d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,4 @@ Flask
requests
gunicorn
paramiko
+Jinja2
diff --git a/services/log_stream.py b/services/log_stream.py
new file mode 100644
index 0000000..74cb354
--- /dev/null
+++ b/services/log_stream.py
@@ -0,0 +1,158 @@
+"""Helpers for streaming journalctl logs over SSH."""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import queue
+import shlex
+import threading
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Iterable, List
+
+import paramiko
+
+SSH_KEY_PATH = Path(os.getenv("SSH_KEY_PATH", "keys/5G-SSH-Key.pem"))
+JQ_FILTER = '.TIMESTAMP + " " + .SYSLOG_IDENTIFIER + " " + (.SUPI // "") + " " + .MESSAGE'
+
+@dataclass
+class LogTarget:
+ host: str
+ processes: List[str]
+ hostname: str | None = None
+
+class JournalctlStream:
+ """Manage concurrent journalctl streams for multiple hosts."""
+
+ def __init__(self, targets: Iterable[LogTarget]) -> None:
+ self._logger = logging.getLogger(__name__)
+ self.targets = [t for t in targets if t.processes]
+ if not self.targets:
+ raise ValueError("No valid log targets provided")
+ if not SSH_KEY_PATH.exists():
+ raise FileNotFoundError(f"SSH key not found at {SSH_KEY_PATH}")
+ self._queue: "queue.Queue[dict]" = queue.Queue()
+ self._stop_event = threading.Event()
+ self._threads: list[threading.Thread] = []
+ self._clients: dict[str, paramiko.SSHClient] = {}
+
+ def start(self) -> None:
+ for target in self.targets:
+ thread = threading.Thread(target=self._stream_host, args=(target,), daemon=True)
+ thread.start()
+ self._threads.append(thread)
+
+ def iter_events(self):
+ self.start()
+ finished = 0
+ total = len(self.targets)
+ while finished < total and not self._stop_event.is_set():
+ try:
+ event = self._queue.get(timeout=0.5)
+ except queue.Empty:
+ yield {"type": "heartbeat", "timestamp": datetime.now(timezone.utc).isoformat()}
+ continue
+ if event.get("type") == "complete":
+ finished += 1
+ yield event
+ # Drain remaining events if any
+ while not self._queue.empty():
+ yield self._queue.get()
+
+ def stop(self) -> None:
+ self._stop_event.set()
+ for client in self._clients.values():
+ try:
+ client.close()
+ except Exception:
+ pass
+ for thread in self._threads:
+ thread.join(timeout=1)
+
+ # --- internal helpers ---
+
+ def _stream_host(self, target: LogTarget) -> None:
+ filter_args = []
+ for proc in target.processes:
+ proc = (proc or "").strip()
+ if not proc:
+ continue
+ filter_args.append(f"-t {shlex.quote(proc)}")
+
+ safe_filters = " ".join(filter_args)
+ if not safe_filters:
+ message = "No processes selected"
+ self._logger.error("Log stream aborted for %s: %s", target.host, message)
+ self._queue.put({
+ "type": "error",
+ "host": target.host,
+ "hostname": target.hostname or target.host,
+ "message": message
+ })
+ self._queue.put({"type": "complete", "host": target.host, "hostname": target.hostname or target.host})
+ return
+
+ pipeline = (
+ f"journalctl {safe_filters} -o json -n 50 -f "
+ f"| jq -r --unbuffered '{JQ_FILTER}'"
+ )
+ command = f"bash -lc {shlex.quote(pipeline)}"
+ self._logger.debug("Executing log command on %s: %s", target.host, pipeline)
+
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ try:
+ client.connect(
+ target.host,
+ username="root",
+ key_filename=str(SSH_KEY_PATH),
+ look_for_keys=False,
+ timeout=15,
+ )
+ self._clients[target.host] = client
+ _, stdout, stderr = client.exec_command(command, get_pty=True)
+ for line in iter(lambda: stdout.readline(), ""):
+ if self._stop_event.is_set():
+ break
+ payload = line.rstrip("\r\n")
+ if not payload:
+ continue
+ self._queue.put({
+ "type": "log",
+ "host": target.host,
+ "hostname": target.hostname or target.host,
+ "line": payload,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ })
+ err = stderr.read().decode(errors="ignore").strip()
+ exit_status = stdout.channel.recv_exit_status()
+ if exit_status not in (0, -1) or err:
+ message = err or f"journalctl exited with status {exit_status}"
+ self._logger.error("Log stream error on %s: %s", target.host, message)
+ self._queue.put({
+ "type": "error",
+ "host": target.host,
+ "hostname": target.hostname or target.host,
+ "message": message,
+ })
+ except Exception as exc:
+ message = str(exc)
+ self._logger.exception("Log stream exception for %s: %s", target.host, message)
+ self._queue.put({
+ "type": "error",
+ "host": target.host,
+ "hostname": target.hostname or target.host,
+ "message": message,
+ })
+ finally:
+ self._queue.put({
+ "type": "complete",
+ "host": target.host,
+ "hostname": target.hostname or target.host,
+ })
+ try:
+ client.close()
+ except Exception:
+ pass
diff --git a/services/vpn_runtime.py b/services/vpn_runtime.py
new file mode 100644
index 0000000..e8333a9
--- /dev/null
+++ b/services/vpn_runtime.py
@@ -0,0 +1,194 @@
+"""Helpers for managing OpenVPN processes inside the container."""
+from __future__ import annotations
+
+import os
+import signal
+import subprocess
+import time
+from pathlib import Path
+
+VPN_CONFIG_DIR = Path(os.getenv("VPN_CONFIG_DIR", "/vpn/configs"))
+VPN_RUNTIME_DIR = Path(os.getenv("VPN_RUNTIME_DIR", "/vpn/runtime"))
+PID_FILE = VPN_RUNTIME_DIR / "openvpn.pid"
+STATE_FILE = VPN_RUNTIME_DIR / "active_vpn"
+LOG_FILE = VPN_RUNTIME_DIR / "openvpn.log"
+START_TIMEOUT = float(os.getenv("VPN_START_TIMEOUT", "15"))
+STOP_TIMEOUT = float(os.getenv("VPN_STOP_TIMEOUT", "10"))
+AUTH_DIRECTIVE = "auth-user-pass"
+
+class VPNRuntimeError(RuntimeError):
+ pass
+
+
+def _ensure_runtime_dir() -> None:
+ VPN_RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def _config_path(vpn_name: str) -> Path:
+ VPN_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ for suffix in (".conf", ".ovpn"):
+ candidate = VPN_CONFIG_DIR / f"{vpn_name}{suffix}"
+ if candidate.exists():
+ return candidate
+ raise VPNRuntimeError(f"No config found for VPN '{vpn_name}' in {VPN_CONFIG_DIR}")
+
+
+def _prepare_auth_override(vpn_name: str, config_path: Path) -> list[str]:
+ """Return CLI args to supply a sanitized auth-user-pass file if needed."""
+ try:
+ lines = config_path.read_text().splitlines()
+ except FileNotFoundError as exc:
+ raise VPNRuntimeError(f"Config {config_path} missing: {exc}") from exc
+
+ auth_target: Path | None = None
+ for raw in lines:
+ line = raw.strip()
+ if not line or line.startswith("#") or line.startswith(";"):
+ continue
+ if not line.lower().startswith(AUTH_DIRECTIVE):
+ continue
+ parts = line.split()
+ if len(parts) < 2:
+ continue
+ auth_path = parts[1].strip('"')
+ candidate = Path(auth_path)
+ if not candidate.is_absolute():
+ candidate = config_path.parent / candidate
+ auth_target = candidate
+ break
+
+ if not auth_target:
+ return []
+ if not auth_target.exists():
+ raise VPNRuntimeError(
+ f"Auth file {auth_target} referenced by {config_path} does not exist"
+ )
+
+ dest = VPN_RUNTIME_DIR / f"{vpn_name}.auth"
+ try:
+ data = auth_target.read_bytes()
+ except OSError as exc:
+ raise VPNRuntimeError(f"Failed to read auth file {auth_target}: {exc}") from exc
+ if not data.strip():
+ raise VPNRuntimeError(f"Auth file {auth_target} is empty")
+
+ try:
+ dest.write_bytes(data)
+ dest.chmod(0o600)
+ except OSError as exc:
+ raise VPNRuntimeError(f"Failed to stage auth file at {dest}: {exc}") from exc
+
+ return ["--auth-user-pass", str(dest)]
+
+
+def _is_pid_running(pid: int) -> bool:
+ try:
+ os.kill(pid, 0)
+ return True
+ except OSError:
+ return False
+
+
+def _read_pid() -> int | None:
+ if PID_FILE.exists():
+ try:
+ return int(PID_FILE.read_text().strip())
+ except ValueError:
+ PID_FILE.unlink(missing_ok=True)
+ return None
+
+
+def _write_state(vpn_name: str) -> None:
+ STATE_FILE.write_text(vpn_name)
+
+
+def _clear_state() -> None:
+ PID_FILE.unlink(missing_ok=True)
+ STATE_FILE.unlink(missing_ok=True)
+
+
+def get_active_vpn() -> str | None:
+ pid = _read_pid()
+ if not pid:
+ _clear_state()
+ return None
+ if not _is_pid_running(pid):
+ _clear_state()
+ return None
+ if STATE_FILE.exists():
+ return STATE_FILE.read_text().strip() or None
+ return None
+
+
+def stop_active_vpn() -> None:
+ pid = _read_pid()
+ if not pid:
+ _clear_state()
+ return
+ if not _is_pid_running(pid):
+ _clear_state()
+ return
+
+ os.kill(pid, signal.SIGTERM)
+ deadline = time.time() + STOP_TIMEOUT
+ while time.time() < deadline:
+ if not _is_pid_running(pid):
+ _clear_state()
+ return
+ time.sleep(0.5)
+
+ # escalate
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ _clear_state()
+
+
+def start_vpn(vpn_name: str) -> None:
+ config_path = _config_path(vpn_name)
+ _ensure_runtime_dir()
+ extra_args = _prepare_auth_override(vpn_name, config_path)
+ stop_active_vpn()
+
+ cmd = [
+ "openvpn",
+ "--config",
+ str(config_path),
+ "--daemon",
+ "--writepid",
+ str(PID_FILE),
+ "--log",
+ str(LOG_FILE),
+ "--setenv",
+ "VPN_NAME",
+ vpn_name,
+ ]
+ cmd.extend(extra_args)
+ try:
+ subprocess.run(cmd, check=True, cwd=str(config_path.parent))
+ except subprocess.CalledProcessError as exc:
+ raise VPNRuntimeError(f"OpenVPN failed to start for {vpn_name}: {exc}") from exc
+
+ deadline = time.time() + START_TIMEOUT
+ while time.time() < deadline:
+ pid = _read_pid()
+ if pid and _is_pid_running(pid):
+ _write_state(vpn_name)
+ return
+ time.sleep(0.5)
+
+ stop_active_vpn()
+ raise VPNRuntimeError(f"Timed out waiting for OpenVPN to start for {vpn_name}")
+
+
+def list_available_vpns() -> list[str]:
+ if not VPN_CONFIG_DIR.exists():
+ return []
+ names: list[str] = []
+ for path in sorted(VPN_CONFIG_DIR.glob("*.conf")):
+ names.append(path.stem)
+ for path in sorted(VPN_CONFIG_DIR.glob("*.ovpn")):
+ if path.stem not in names:
+ names.append(path.stem)
+ return names
diff --git a/system_info.json b/system_info.json
index a6b7975..a40bd30 100644
--- a/system_info.json
+++ b/system_info.json
@@ -16,7 +16,7 @@
"machine_id": "7ebd37b3c5a44ff7acafc84fa3af449d",
"num_cpu": 4,
"virtualization": "kvm",
- "target_host_ip": "100.93.1.84",
+ "target_host_ip": "100.93.0.240",
"mgmt": {
"cidr": "192.168.105.156/24",
"gw": "192.168.105.1"
diff --git a/templates/index.html b/templates/index.html
index f6578c0..f25d971 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -101,30 +101,6 @@
@@ -326,4 +293,4 @@
{% block extra_scripts %}{% endblock %}