From c4ed092780aac299b8149d5059660de03c318416 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 5 Jun 2026 12:26:58 +0100 Subject: [PATCH 1/4] Add server hostname, IP address, and port rows to System Config page Adds a new authenticated GET /api/v1/system/info endpoint that returns the server's FQDN, primary outbound IP, and listening port. Both the Vue 2 and Vue 3 System Config pages display these as three new table rows, fetched once on mount. Includes backend tests and E2E coverage. Co-Authored-By: Claude Sonnet 4.6 --- client-v3/e2e/tests/03-system-config.spec.ts | 6 ++ .../src/components/config/ConfigSystem.vue | 23 ++++++- client-v3/src/stores/system.ts | 15 +++++ .../vue_components/config/ConfigSystem.vue | 30 ++++++++- docs/pages/user_config.md | 6 +- server/controllers/api/system_info.py | 46 ++++++++++++++ .../test/controllers/api/test_system_info.py | 61 +++++++++++++++++++ 7 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 server/controllers/api/system_info.py create mode 100644 server/test/controllers/api/test_system_info.py diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts index 85dad177..66b8cf2d 100644 --- a/client-v3/e2e/tests/03-system-config.spec.ts +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -235,6 +235,12 @@ test('switches to the System tab', async () => { }); }); +test('System tab shows server info rows', async () => { + await expect(page.locator('strong:has-text("Hostname")')).toBeVisible(); + await expect(page.locator('strong:has-text("IP Address")')).toBeVisible(); + await expect(page.locator('strong:has-text("Port")')).toBeVisible(); +}); + test('can open the View Clients modal', async () => { await page.click('button:has-text("View Clients")'); await waitForModal(page, 'Connected Clients'); diff --git a/client-v3/src/components/config/ConfigSystem.vue b/client-v3/src/components/config/ConfigSystem.vue index 28427d39..ae5e7413 100644 --- a/client-v3/src/components/config/ConfigSystem.vue +++ b/client-v3/src/components/config/ConfigSystem.vue @@ -46,6 +46,21 @@ + + Hostname + {{ serverInfo?.hostname ?? 'Unknown' }} + + + + IP Address + {{ serverInfo?.ip_address ?? 'Unknown' }} + + + + Port + {{ serverInfo?.port ?? 'Unknown' }} + + @@ -80,7 +95,7 @@ import { useSystemStore } from '@/stores/system'; import { toast } from '@/js/toast'; const systemStore = useSystemStore(); -const { connectedSessions, versionStatus } = storeToRefs(systemStore); +const { connectedSessions, versionStatus, serverInfo } = storeToRefs(systemStore); const loading = ref(true); const isCheckingVersion = ref(false); @@ -137,7 +152,11 @@ function scheduleSessionPoll(): void { } onMounted(async () => { - await Promise.all([systemStore.getVersionStatus(), systemStore.getConnectedSessions()]); + await Promise.all([ + systemStore.getVersionStatus(), + systemStore.getConnectedSessions(), + systemStore.getServerInfo(), + ]); loading.value = false; scheduleSessionPoll(); }); diff --git a/client-v3/src/stores/system.ts b/client-v3/src/stores/system.ts index 3c5690a6..b6cd72f6 100644 --- a/client-v3/src/stores/system.ts +++ b/client-v3/src/stores/system.ts @@ -24,6 +24,12 @@ interface VersionStatus { check_error: string | null; } +interface ServerInfo { + hostname: string; + ip_address: string; + port: number; +} + interface RbacRole { key: string; value: number; @@ -49,6 +55,7 @@ export const useSystemStore = defineStore('system', { currentShow: null as Show | null, connectedSessions: [] as ConnectedSession[], versionStatus: null as VersionStatus | null, + serverInfo: null as ServerInfo | null, }), getters: { isAdminUser(): boolean { @@ -246,6 +253,14 @@ export const useSystemStore = defineStore('system', { log.error('Unable to fetch version status'); } }, + async getServerInfo() { + const response = await fetch(makeURL('/api/v1/system/info')); + if (response.ok) { + this.serverInfo = await response.json(); + } else { + log.error('Unable to fetch server info'); + } + }, async checkForUpdates() { const response = await fetch(makeURL('/api/v1/version/check'), { method: 'POST', diff --git a/client/src/vue_components/config/ConfigSystem.vue b/client/src/vue_components/config/ConfigSystem.vue index 901405eb..cca75e9b 100644 --- a/client/src/vue_components/config/ConfigSystem.vue +++ b/client/src/vue_components/config/ConfigSystem.vue @@ -54,6 +54,21 @@ + + Hostname + {{ systemInfo ? systemInfo.hostname : 'Unknown' }} + + + + IP Address + {{ systemInfo ? systemInfo.ip_address : 'Unknown' }} + + + + Port + {{ systemInfo ? systemInfo.port : 'Unknown' }} + + | null, + systemInfo: null as { hostname: string | null; ip_address: string | null; port: number | null } | null, }; }, async mounted() { - await Promise.all([this.getConnectedClients(), this.getVersionStatus()]); + await Promise.all([this.getConnectedClients(), this.getVersionStatus(), this.getSystemInfo()]); this.loading = false; this.timeUpdateInterval = setInterval(() => { this.currentTime = Date.now(); @@ -201,6 +217,18 @@ export default defineComponent({ if (this.versionStatus.update_available) return 'Update Available'; return 'Up to date'; }, + async getSystemInfo(): Promise { + try { + const response = await fetch(`${makeURL('/api/v1/system/info')}`); + if (response.ok) { + this.systemInfo = await response.json(); + } else { + log.error('Unable to get system info'); + } + } catch (error) { + log.error('Error fetching system info:', error); + } + }, formatTimeAgo(isoTimestamp: string | null): string { if (!isoTimestamp) return 'Never'; diff --git a/docs/pages/user_config.md b/docs/pages/user_config.md index ee2e82f4..32b23a5b 100644 --- a/docs/pages/user_config.md +++ b/docs/pages/user_config.md @@ -8,9 +8,11 @@ The **System Config** section, accessible from the top navigation bar, provides The **System** tab provides an overview of the current system state: -- **Current Show**: Displays the currently loaded show name, with buttons to load an existing show or set up a new one. -- **Connected Clients**: Shows the number of WebSocket clients currently connected to the server. Click "View Clients" to see details about each connected session. - **Version**: Displays the current DigiScript version and checks for available updates. +- **Connected Clients**: Shows the number of WebSocket clients currently connected to the server. Click "View Clients" to see details about each connected session. +- **Hostname**: The fully-qualified domain name (FQDN) of the server machine. +- **IP Address**: The primary outbound IP address the server is reachable on. +- **Port**: The port the server is listening on. #### Version Checker diff --git a/server/controllers/api/system_info.py b/server/controllers/api/system_info.py new file mode 100644 index 00000000..f8b259b1 --- /dev/null +++ b/server/controllers/api/system_info.py @@ -0,0 +1,46 @@ +import socket + +from utils.web.base_controller import BaseAPIController +from utils.web.route import ApiRoute, ApiVersion +from utils.web.web_decorators import api_authenticated + + +@ApiRoute("system/info", ApiVersion.V1) +class SystemInfoController(BaseAPIController): + @api_authenticated + async def get(self): + """ + Get server network information. + + Returns the server's fully-qualified hostname, primary outbound IP address, + and the port it is listening on. + + :returns: JSON response with hostname, ip_address, and port. + """ + await self.finish( + { + "hostname": socket.getfqdn(), + "ip_address": _get_primary_ip(), + "port": self.application._port, + } + ) + + +def _get_primary_ip() -> str: + """ + Return the primary outbound IP address. + + Uses the connect-to-8.8.8.8 trick: opening a UDP socket and calling connect() + causes the OS to select the correct source address without sending any data. + + :returns: IP address string, or ``"Unknown"`` on failure. + """ + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + finally: + s.close() + except Exception: + return "Unknown" diff --git a/server/test/controllers/api/test_system_info.py b/server/test/controllers/api/test_system_info.py new file mode 100644 index 00000000..5c8e8fb4 --- /dev/null +++ b/server/test/controllers/api/test_system_info.py @@ -0,0 +1,61 @@ +"""Integration tests for GET /api/v1/system/info.""" + +from tornado import escape + +from test.conftest import DigiScriptTestCase + + +class TestSystemInfoController(DigiScriptTestCase): + def _create_and_login_admin(self, username="admin", password="adminpass"): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": True} + ), + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": username, "password": password}), + ) + return escape.json_decode(resp.body)["access_token"] + + def _fetch_system_info(self, token=None): + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + return self.fetch("/api/v1/system/info", headers=headers, raise_error=False) + + def test_unauthenticated_returns_401(self): + resp = self._fetch_system_info() + self.assertEqual(401, resp.code) + + def test_authenticated_returns_200_with_expected_keys(self): + token = self._create_and_login_admin() + resp = self._fetch_system_info(token=token) + self.assertEqual(200, resp.code) + body = escape.json_decode(resp.body) + self.assertIn("hostname", body) + self.assertIn("ip_address", body) + self.assertIn("port", body) + + def test_hostname_is_non_empty_string(self): + token = self._create_and_login_admin() + resp = self._fetch_system_info(token=token) + body = escape.json_decode(resp.body) + self.assertIsInstance(body["hostname"], str) + self.assertGreater(len(body["hostname"]), 0) + + def test_ip_address_is_non_empty_string(self): + token = self._create_and_login_admin() + resp = self._fetch_system_info(token=token) + body = escape.json_decode(resp.body) + self.assertIsInstance(body["ip_address"], str) + self.assertGreater(len(body["ip_address"]), 0) + + def test_port_is_integer(self): + token = self._create_and_login_admin() + resp = self._fetch_system_info(token=token) + body = escape.json_decode(resp.body) + self.assertIsInstance(body["port"], int) From 60c4707f5646f3bc759b103026707e9c91a1ca78 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 5 Jun 2026 12:30:41 +0100 Subject: [PATCH 2/4] Restrict system/info endpoint to admin users The hostname, IP address, and port are sensitive network information that should only be accessible to administrators, not all authenticated users. Co-Authored-By: Claude Sonnet 4.6 --- server/controllers/api/system_info.py | 3 ++- .../test/controllers/api/test_system_info.py | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/server/controllers/api/system_info.py b/server/controllers/api/system_info.py index f8b259b1..f2418626 100644 --- a/server/controllers/api/system_info.py +++ b/server/controllers/api/system_info.py @@ -2,12 +2,13 @@ from utils.web.base_controller import BaseAPIController from utils.web.route import ApiRoute, ApiVersion -from utils.web.web_decorators import api_authenticated +from utils.web.web_decorators import api_authenticated, require_admin @ApiRoute("system/info", ApiVersion.V1) class SystemInfoController(BaseAPIController): @api_authenticated + @require_admin async def get(self): """ Get server network information. diff --git a/server/test/controllers/api/test_system_info.py b/server/test/controllers/api/test_system_info.py index 5c8e8fb4..ceda09b4 100644 --- a/server/test/controllers/api/test_system_info.py +++ b/server/test/controllers/api/test_system_info.py @@ -21,6 +21,22 @@ def _create_and_login_admin(self, username="admin", password="adminpass"): ) return escape.json_decode(resp.body)["access_token"] + def _create_and_login_user(self, admin_token, username="user", password="userpass"): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": False} + ), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": username, "password": password}), + ) + return escape.json_decode(resp.body)["access_token"] + def _fetch_system_info(self, token=None): headers = {} if token: @@ -31,6 +47,12 @@ def test_unauthenticated_returns_401(self): resp = self._fetch_system_info() self.assertEqual(401, resp.code) + def test_non_admin_returns_401(self): + admin_token = self._create_and_login_admin() + user_token = self._create_and_login_user(admin_token) + resp = self._fetch_system_info(token=user_token) + self.assertEqual(401, resp.code) + def test_authenticated_returns_200_with_expected_keys(self): token = self._create_and_login_admin() resp = self._fetch_system_info(token=token) From 474f504619cc7bf11540bbab3a911c6d24ca9037 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 5 Jun 2026 18:58:32 +0100 Subject: [PATCH 3/4] Fix Prettier formatting in Vue 2 ConfigSystem.vue Co-Authored-By: Claude Sonnet 4.6 --- client/src/vue_components/config/ConfigSystem.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/vue_components/config/ConfigSystem.vue b/client/src/vue_components/config/ConfigSystem.vue index cca75e9b..9a139ef2 100644 --- a/client/src/vue_components/config/ConfigSystem.vue +++ b/client/src/vue_components/config/ConfigSystem.vue @@ -140,7 +140,11 @@ export default defineComponent({ isCheckingVersion: false, currentTime: Date.now(), timeUpdateInterval: null as ReturnType | null, - systemInfo: null as { hostname: string | null; ip_address: string | null; port: number | null } | null, + systemInfo: null as { + hostname: string | null; + ip_address: string | null; + port: number | null; + } | null, }; }, async mounted() { From 5315769640a0c7795794203ca0aaeb8a864c4b7c Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 5 Jun 2026 19:02:25 +0100 Subject: [PATCH 4/4] Fix SonarCloud findings: extract auth helpers and named IP constant - Move _create_and_login_admin/_create_and_login_user to DigiScriptTestCase base class, removing duplication between test_system_info and test_db_backups - Extract hardcoded 8.8.8.8 to named _PROBE_HOST/_PROBE_PORT constants with a comment clarifying no data is sent Co-Authored-By: Claude Sonnet 4.6 --- server/controllers/api/system_info.py | 8 ++++- server/test/conftest.py | 32 +++++++++++++++++++ .../test/controllers/api/test_db_backups.py | 31 ------------------ .../test/controllers/api/test_system_info.py | 31 ------------------ 4 files changed, 39 insertions(+), 63 deletions(-) diff --git a/server/controllers/api/system_info.py b/server/controllers/api/system_info.py index f2418626..787148e0 100644 --- a/server/controllers/api/system_info.py +++ b/server/controllers/api/system_info.py @@ -5,6 +5,12 @@ from utils.web.web_decorators import api_authenticated, require_admin +# Well-known public DNS address used only to determine the local outbound interface. +# No data is ever sent — the UDP socket is closed immediately after getsockname(). +_PROBE_HOST = "8.8.8.8" +_PROBE_PORT = 80 + + @ApiRoute("system/info", ApiVersion.V1) class SystemInfoController(BaseAPIController): @api_authenticated @@ -39,7 +45,7 @@ def _get_primary_ip() -> str: try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: - s.connect(("8.8.8.8", 80)) + s.connect((_PROBE_HOST, _PROBE_PORT)) return s.getsockname()[0] finally: s.close() diff --git a/server/test/conftest.py b/server/test/conftest.py index 7f0ab85f..6c5a6e49 100644 --- a/server/test/conftest.py +++ b/server/test/conftest.py @@ -2,6 +2,7 @@ import os from sqlalchemy import inspect +from tornado import escape from tornado.testing import AsyncHTTPTestCase from digi_server.app_server import DigiScriptServer @@ -43,3 +44,34 @@ def tearDown(self): models.db.engine.dispose() super().tearDown() + + def _create_and_login_admin(self, username="admin", password="adminpass"): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": True} + ), + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": username, "password": password}), + ) + return escape.json_decode(resp.body)["access_token"] + + def _create_and_login_user(self, admin_token, username="user", password="userpass"): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": False} + ), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": username, "password": password}), + ) + return escape.json_decode(resp.body)["access_token"] diff --git a/server/test/controllers/api/test_db_backups.py b/server/test/controllers/api/test_db_backups.py index 8f7424be..4aa53fec 100644 --- a/server/test/controllers/api/test_db_backups.py +++ b/server/test/controllers/api/test_db_backups.py @@ -34,37 +34,6 @@ def _create_backup(self, timestamp: int) -> str: f.write(b"x" * 1024) return backup_path - def _create_and_login_admin(self, username="admin", password="adminpass"): - self.fetch( - "/api/v1/auth/create", - method="POST", - body=escape.json_encode( - {"username": username, "password": password, "is_admin": True} - ), - ) - resp = self.fetch( - "/api/v1/auth/login", - method="POST", - body=escape.json_encode({"username": username, "password": password}), - ) - return escape.json_decode(resp.body)["access_token"] - - def _create_and_login_user(self, admin_token, username="user", password="userpass"): - self.fetch( - "/api/v1/auth/create", - method="POST", - body=escape.json_encode( - {"username": username, "password": password, "is_admin": False} - ), - headers={"Authorization": f"Bearer {admin_token}"}, - ) - resp = self.fetch( - "/api/v1/auth/login", - method="POST", - body=escape.json_encode({"username": username, "password": password}), - ) - return escape.json_decode(resp.body)["access_token"] - def _fetch_backups(self, token=None): headers = {} if token: diff --git a/server/test/controllers/api/test_system_info.py b/server/test/controllers/api/test_system_info.py index ceda09b4..3c89428d 100644 --- a/server/test/controllers/api/test_system_info.py +++ b/server/test/controllers/api/test_system_info.py @@ -6,37 +6,6 @@ class TestSystemInfoController(DigiScriptTestCase): - def _create_and_login_admin(self, username="admin", password="adminpass"): - self.fetch( - "/api/v1/auth/create", - method="POST", - body=escape.json_encode( - {"username": username, "password": password, "is_admin": True} - ), - ) - resp = self.fetch( - "/api/v1/auth/login", - method="POST", - body=escape.json_encode({"username": username, "password": password}), - ) - return escape.json_decode(resp.body)["access_token"] - - def _create_and_login_user(self, admin_token, username="user", password="userpass"): - self.fetch( - "/api/v1/auth/create", - method="POST", - body=escape.json_encode( - {"username": username, "password": password, "is_admin": False} - ), - headers={"Authorization": f"Bearer {admin_token}"}, - ) - resp = self.fetch( - "/api/v1/auth/login", - method="POST", - body=escape.json_encode({"username": username, "password": password}), - ) - return escape.json_decode(resp.body)["access_token"] - def _fetch_system_info(self, token=None): headers = {} if token: