feat: publish Alta Local Camera Discovery

This commit is contained in:
2026-09-01 18:02:14 +00:00
commit 33f28a3d73
30 changed files with 4939 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules/
dist/
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.coverage
*.log
.DS_Store
Thumbs.db
SECURITY-REVIEW.md
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Peji
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+71
View File
@@ -0,0 +1,71 @@
# Alta Local Camera Discovery
Standalone Electron utility for discovering Alta cloud-native cameras on the technician's current LAN. It listens for `_vaioncam._tcp.local` mDNS announcements and reports each camera's Alta Key, IP address, and hostname Web UI URL without Alta credentials or camera configuration changes.
The original Python web prototype remains available in `app.py`; the Electron app performs discovery directly in its main process through a narrow, isolated preload bridge. `src/scan-session.js` owns the single-flight scan lifecycle, defensive result snapshot, and latest-scan camera URL authorization so Electron bootstrap code remains small and testable.
## Run the Electron app
```bash
npm install
npm start
```
## Use
1. Connect the computer to the same LAN/VLAN as the cameras.
2. Select **Start discovery**.
3. Copy an individual Alta Key or select **Export CSV**.
4. Select a camera hostname to open its local HTTPS Web UI.
The CSV filename is `alta-camera-keys.csv` and its columns are:
```csv
Alta Key,IP Address,Hostname URL
```
## Download
The current Windows x64 portable release is available from GitPeji:
- [Download Alta Local Camera Discovery for Windows](https://git.pejicorp.com/peji/AltaNetowrkScanner/releases/download/v1.0.0/AltaLocalCameraDiscovery-1.0.0-portable.exe)
- [View source and release notes](https://git.pejicorp.com/peji/AltaNetowrkScanner)
No installer or Alta credentials are required. The current Windows release is unsigned and may trigger Microsoft Defender SmartScreen; verify its SHA-256 against the checksum published with the release.
## Build
```bash
npm run build:win # Windows x64 portable EXE
npm run build:linux # Linux x64 AppImage
```
Artifacts are written under `dist/`. The Electron app icon is `assets/icon.png` / `assets/icon.ico`: the approved radar artwork in the Alta colourway with the Alta A layered above it. Release artifacts are built from clean, tagged source revisions and are not committed to the repository.
## Verify
```bash
npm run check
npm run scan
npm run build-test
npm run build-test:win
```
`npm run scan` exercises the same Node mDNS engine used by Electron. The legacy Python scanner can still be checked with:
```bash
.venv/bin/python -m unittest -v test_app.py
```
## Security and limits
- Renderer Node integration is disabled; context isolation and sandboxing are enabled.
- The UI is served from an allowlisted `app://` protocol; arbitrary local files and renderer permissions are denied.
- IPC accepts only the app's main frame at the exact packaged application URL.
- Discovery requires matching Alta Key/hostname, HTTPS port 443, and a valid private or link-local address; scans are concurrency- and result-bounded.
- External navigation is denied except a root HTTPS camera URL from the latest successful scan.
- CSV content is generated in the main process and written only through a native save dialog.
- Release builds are configured to disable Electron's Node/inspection/file-privilege fuses and enable ASAR integrity enforcement.
- mDNS normally does not cross VLANs or routed boundaries.
- mDNS advertisements are not cryptographically authenticated; results are correlated and constrained local announcements, not proof of device identity.
- Only powered, reachable cameras advertising `_vaioncam._tcp.local` are shown.
+16
View File
@@ -0,0 +1,16 @@
# Security
Alta Local Camera Discovery does not request or store Alta credentials. It listens for local `_vaioncam._tcp.local` mDNS announcements, validates expected Alta camera record structure, and restricts privileged Electron actions to a narrow main-process policy.
## Trust model
- mDNS is unauthenticated LAN input. The app correlates service identity, camera key, hostname, port, and local address, but cannot cryptographically prove that an advertiser is a genuine Alta camera.
- Camera Web UI links are HTTPS root URLs authorised only by the latest successful scan.
- The Electron renderer is sandboxed, context-isolated, denied general permissions, and served through an allowlisted `app://` protocol.
- CSV generation and filesystem writes remain in the main process.
## Release status
Release assets are produced from tagged GitPeji commits with a locked dependency graph and published SHA-256 checksums. Windows artifacts are currently unsigned internal releases and may trigger Microsoft Defender SmartScreen.
Report suspected issues through the repository issue tracker or directly to the internal tool owner. Do not include credentials, camera exports, or customer data in reports.
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
import argparse
import json
import re
import threading
import time
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from zeroconf import ServiceBrowser, ServiceInfo, ServiceListener, Zeroconf
ROOT = Path(__file__).resolve().parent
SERVICE_TYPE = "_vaioncam._tcp.local."
KEY_PATTERN = re.compile(r"^[A-Z0-9]{4}(?:-[A-Z0-9]{4}){2}$")
def camera_from_service(name: str, hostname: str, addresses: list[str], port: int):
suffix = "." + SERVICE_TYPE
if not name.lower().endswith(suffix.lower()):
return None
alta_key = name[: -len(suffix)].upper()
if not KEY_PATTERN.fullmatch(alta_key):
return None
ipv4 = next((address for address in addresses if ":" not in address), None)
hostname = (hostname or "").rstrip(".")
return {
"alta_key": alta_key,
"hostname": hostname,
"web_ui_url": f"https://{hostname}" if hostname else "",
"ip_address": ipv4 or (addresses[0] if addresses else ""),
"port": port,
"service": SERVICE_TYPE.rstrip("."),
}
def normalize_results(rows):
unique = {}
for row in rows:
unique[(row["alta_key"], row.get("ip_address", ""))] = row
return sorted(unique.values(), key=lambda row: (row["alta_key"], row.get("ip_address", "")))
class CameraListener(ServiceListener):
def __init__(self):
self.rows = []
self.lock = threading.Lock()
def _capture(self, zeroconf: Zeroconf, service_type: str, name: str):
info: ServiceInfo | None = zeroconf.get_service_info(service_type, name, timeout=2500)
if not info:
return
row = camera_from_service(name, info.server or "", info.parsed_addresses(), info.port)
if row:
with self.lock:
self.rows.append(row)
def add_service(self, zeroconf, service_type, name):
self._capture(zeroconf, service_type, name)
def update_service(self, zeroconf, service_type, name):
self._capture(zeroconf, service_type, name)
def remove_service(self, zeroconf, service_type, name):
pass
def scan_cameras(timeout: float = 5.0):
listener = CameraListener()
zeroconf = Zeroconf()
browser = ServiceBrowser(zeroconf, SERVICE_TYPE, listener)
try:
time.sleep(timeout)
with listener.lock:
cameras = normalize_results(listener.rows)
finally:
browser.cancel()
zeroconf.close()
return cameras
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(ROOT / "static"), **kwargs)
def log_message(self, fmt, *args):
print(f"{self.client_address[0]} - {fmt % args}")
def _json(self, payload, status=HTTPStatus.OK):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/api/health":
return self._json({"ok": True, "service": "Alta Camera Finder"})
if self.path == "/api/scan":
started = time.monotonic()
try:
cameras = scan_cameras()
return self._json({
"ok": True,
"count": len(cameras),
"duration_seconds": round(time.monotonic() - started, 1),
"cameras": cameras,
})
except Exception as exc:
return self._json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
if self.path == "/":
self.path = "/index.html"
return super().do_GET()
def main():
parser = argparse.ArgumentParser(description="Discover Alta cloud-native cameras using mDNS")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8787)
parser.add_argument("--scan", action="store_true", help="scan once and print JSON")
args = parser.parse_args()
if args.scan:
print(json.dumps(scan_cameras(), indent=2))
return
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"Alta Camera Finder listening on http://{args.host}:{args.port}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

+115
View File
@@ -0,0 +1,115 @@
'use strict';
const { app, BrowserWindow, clipboard, dialog, ipcMain, net, protocol, shell } = require('electron');
const fs = require('node:fs/promises');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { buildCsv } = require('./static/export-utils');
const { APP_RENDERER_URL, resolveAppAssetPath } = require('./src/app-protocol');
const { KEY_PATTERN } = require('./src/discovery');
const { createScanSession } = require('./src/scan-session');
protocol.registerSchemesAsPrivileged([{
scheme: 'app',
privileges: { standard: true, secure: true, supportFetchAPI: true },
}]);
let mainWindow = null;
const scanSession = createScanSession();
function isTrustedSender(event) {
if (!mainWindow || mainWindow.isDestroyed() || !event || !event.senderFrame) return false;
return event.sender === mainWindow.webContents &&
event.senderFrame === mainWindow.webContents.mainFrame &&
event.senderFrame.url === APP_RENDERER_URL;
}
function registerIpc(channel, handler) {
ipcMain.handle(channel, async (event, ...args) => {
if (!isTrustedSender(event)) throw new Error('Forbidden IPC sender');
return handler(...args);
});
}
async function openCameraUrl(value) {
const url = scanSession.allowedCameraUrl(value);
if (!url) return;
await shell.openExternal(url);
}
function registerIpcHandlers() {
registerIpc('scan-cameras', scanSession.scanOnce);
registerIpc('copy-text', (value) => {
const text = String(value || '');
if (!KEY_PATTERN.test(text)) throw new Error('Invalid Alta Key.');
clipboard.writeText(text);
return { ok: true };
});
registerIpc('export-csv', async () => {
const csv = buildCsv(scanSession.getLatestCameras());
if (Buffer.byteLength(csv, 'utf8') > 1024 * 1024) throw new Error('CSV export is too large.');
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Export discovered Alta cameras',
defaultPath: 'alta-camera-keys.csv',
filters: [{ name: 'CSV file', extensions: ['csv'] }],
});
if (result.canceled || !result.filePath) return { ok: false, canceled: true };
await fs.writeFile(result.filePath, csv, 'utf8');
return { ok: true, canceled: false };
});
}
function registerAppProtocol() {
const staticDir = path.join(__dirname, 'static');
protocol.handle('app', (request) => {
const assetPath = resolveAppAssetPath(request.url, staticDir);
if (!assetPath) return new Response('Not found', { status: 404 });
return net.fetch(pathToFileURL(assetPath).href);
});
}
function denyRendererPermissions(targetSession) {
targetSession.setPermissionCheckHandler(() => false);
targetSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1180,
height: 760,
minWidth: 820,
minHeight: 620,
backgroundColor: '#f5f6f8',
icon: path.join(__dirname, 'assets', 'icon.png'),
title: 'Alta Local Camera Discovery',
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: path.join(__dirname, 'preload.js'),
},
});
denyRendererPermissions(mainWindow.webContents.session);
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
openCameraUrl(url).catch(() => {});
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event) => event.preventDefault());
mainWindow.loadURL(APP_RENDERER_URL);
if (!app.isPackaged && process.argv.includes('--dev')) mainWindow.webContents.openDevTools();
}
app.whenReady().then(() => {
registerAppProtocol();
registerIpcHandlers();
createWindow();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
+3647
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
{
"name": "alta-local-camera-discovery",
"version": "1.0.0",
"description": "Standalone local discovery utility for Alta cloud-native cameras",
"desktopName": "Alta Local Camera Discovery",
"main": "main.js",
"author": "Peji",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.pejicorp.com/peji/AltaNetowrkScanner.git"
},
"homepage": "https://git.pejicorp.com/peji/AltaNetowrkScanner",
"bugs": {
"url": "https://git.pejicorp.com/peji/AltaNetowrkScanner/issues"
},
"scripts": {
"start": "electron .",
"dev": "electron . --dev",
"scan": "node scripts/scan.js",
"test": "node --test test/*.test.js && node test_export_utils.js",
"check": "npm test && node --check main.js && node --check preload.js && node --check static/app.js && node --check static/export-utils.js",
"build-test": "electron-builder --linux dir --publish=never",
"build:linux": "electron-builder --linux AppImage --publish=never",
"build-test:win": "electron-builder --win dir --publish=never",
"build:win": "electron-builder --win portable --publish=never"
},
"build": {
"appId": "com.internal.alta-local-camera-discovery",
"productName": "Alta Local Camera Discovery",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"preload.js",
"src/**/*",
"static/**/*",
"assets/**/*",
"package.json"
],
"electronFuses": {
"runAsNode": false,
"enableCookieEncryption": true,
"enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": false,
"enableEmbeddedAsarIntegrityValidation": true,
"onlyLoadAppFromAsar": true,
"loadBrowserProcessSpecificV8Snapshot": false,
"grantFileProtocolExtraPrivileges": false
},
"win": {
"target": "portable",
"icon": "assets/icon.ico"
},
"portable": {
"artifactName": "AltaLocalCameraDiscovery-${version}-portable.exe"
},
"linux": {
"target": "AppImage",
"icon": "assets/icon.png",
"category": "Utility",
"syncDesktopName": true,
"artifactName": "AltaLocalCameraDiscovery-${version}.${ext}"
}
},
"dependencies": {
"bonjour-service": "1.4.4"
},
"devDependencies": {
"electron": "43.4.1",
"electron-builder": "26.15.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('altaDiscovery', Object.freeze({
scan: () => ipcRenderer.invoke('scan-cameras'),
copyText: (text) => ipcRenderer.invoke('copy-text', text),
exportCsv: () => ipcRenderer.invoke('export-csv'),
}));
+1
View File
@@ -0,0 +1 @@
zeroconf==0.147.0
+10
View File
@@ -0,0 +1,10 @@
'use strict';
const { scanCameras } = require('../src/discovery');
scanCameras().then((cameras) => {
process.stdout.write(`${JSON.stringify(cameras, null, 2)}\n`);
}).catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
});
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const path = require('node:path');
const APP_ORIGIN = 'app://bundle';
const APP_RENDERER_URL = `${APP_ORIGIN}/index.html`;
const ALLOWED_RENDERER_ASSETS = new Set([
'index.html',
'styles.css',
'app.js',
'export-utils.js',
'alta-mark.png',
'alta-video-logo.png',
]);
function resolveAppAssetPath(value, staticDir) {
try {
const url = new URL(value);
if (url.protocol !== 'app:' ||
url.hostname !== 'bundle' ||
url.username !== '' ||
url.password !== '' ||
url.port !== '' ||
url.hash !== '') {
return null;
}
const asset = decodeURIComponent(url.pathname).replace(/^\//, '');
if (!asset || asset.includes('/') || asset.includes('\\') || !ALLOWED_RENDERER_ASSETS.has(asset)) {
return null;
}
return path.join(staticDir, asset);
} catch {
return null;
}
}
module.exports = {
ALLOWED_RENDERER_ASSETS,
APP_ORIGIN,
APP_RENDERER_URL,
resolveAppAssetPath,
};
+140
View File
@@ -0,0 +1,140 @@
'use strict';
const net = require('node:net');
const { Bonjour } = require('bonjour-service');
const SERVICE_TYPE = 'vaioncam';
const SERVICE_PROTOCOL = 'tcp';
const SERVICE_PORT = 443;
const DEFAULT_MAX_RESULTS = 256;
const KEY_PATTERN = /^[A-Z0-9]{4}(?:-[A-Z0-9]{4}){2}$/;
function altaKeyFromName(name) {
const raw = String(name || '').trim();
const suffix = '._vaioncam._tcp.local';
const candidate = raw.toLowerCase().endsWith(suffix)
? raw.slice(0, -suffix.length)
: raw;
const altaKey = candidate.toUpperCase();
return KEY_PATTERN.test(altaKey) ? altaKey : null;
}
function normalizeIpAddress(value) {
const address = String(value || '').trim().split('%', 1)[0];
return net.isIP(address) ? address.toLowerCase() : '';
}
function isLocalAddress(value) {
const address = normalizeIpAddress(value);
const family = net.isIP(address);
if (family === 4) {
const [first, second] = address.split('.').map(Number);
return first === 10 ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(first === 169 && second === 254);
}
if (family === 6) {
const firstGroup = Number.parseInt(address.split(':', 1)[0], 16);
return (firstGroup & 0xfe00) === 0xfc00 || (firstGroup & 0xffc0) === 0xfe80;
}
return false;
}
function cameraFromService(service) {
if (!service || String(service.type || '').toLowerCase() !== SERVICE_TYPE) return null;
if (String(service.protocol || '').toLowerCase() !== SERVICE_PROTOCOL) return null;
if (service.port !== SERVICE_PORT) return null;
const altaKey = altaKeyFromName(service.name || service.fqdn);
if (!altaKey) return null;
const hostname = String(service.host || '').trim().replace(/\.$/, '').toLowerCase();
const expectedHostname = `avacam-${altaKey.toLowerCase()}.local`;
if (hostname !== expectedHostname || hostname.length > 253) return null;
const addresses = Array.isArray(service.addresses)
? service.addresses.slice(0, 16).map(normalizeIpAddress).filter(isLocalAddress)
: [];
const ipAddress = addresses.find((address) => net.isIP(address) === 4) || addresses[0] || '';
if (!ipAddress) return null;
return {
alta_key: altaKey,
hostname,
web_ui_url: `https://${hostname}`,
ip_address: ipAddress,
port: SERVICE_PORT,
service: '_vaioncam._tcp.local',
};
}
function normalizeResults(rows) {
const unique = new Map();
for (const row of rows) {
if (!row) continue;
unique.set(`${row.alta_key}\0${row.ip_address || ''}`, row);
}
return [...unique.values()].sort((a, b) =>
a.alta_key.localeCompare(b.alta_key) || String(a.ip_address).localeCompare(String(b.ip_address))
);
}
function scanCameras({ durationMs = 5000, maxResults = DEFAULT_MAX_RESULTS, BonjourClass = Bonjour } = {}) {
if (!Number.isFinite(durationMs) || durationMs < 1 || durationMs > 30000) {
return Promise.reject(new Error('Scan duration must be between 1 and 30000 milliseconds.'));
}
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 512) {
return Promise.reject(new Error('Maximum results must be between 1 and 512.'));
}
return new Promise((resolve, reject) => {
const rows = [];
const seen = new Set();
let bonjour;
let browser;
let timer;
let finished = false;
const finish = (error) => {
if (finished) return;
finished = true;
if (timer) clearTimeout(timer);
try { if (browser) browser.stop(); } catch {}
try { if (bonjour) bonjour.destroy(); } catch {}
if (error) reject(error); else resolve(normalizeResults(rows));
};
try {
bonjour = new BonjourClass();
browser = bonjour.find({ type: SERVICE_TYPE, protocol: SERVICE_PROTOCOL });
browser.on('up', (service) => {
if (finished) return;
const camera = cameraFromService(service);
if (!camera) return;
const identity = `${camera.alta_key}\0${camera.ip_address}`;
if (seen.has(identity)) return;
seen.add(identity);
rows.push(camera);
if (rows.length >= maxResults) finish();
});
browser.on('error', finish);
timer = setTimeout(() => finish(), durationMs);
} catch (error) {
finish(error);
}
});
}
module.exports = {
DEFAULT_MAX_RESULTS,
KEY_PATTERN,
SERVICE_PORT,
SERVICE_PROTOCOL,
SERVICE_TYPE,
altaKeyFromName,
cameraFromService,
isLocalAddress,
normalizeResults,
scanCameras,
};
+60
View File
@@ -0,0 +1,60 @@
'use strict';
const { performance } = require('node:perf_hooks');
const { scanCameras } = require('./discovery');
const { normalizeCameraUrl } = require('./url-policy');
function copyCameras(cameras) {
return cameras.map((camera) => ({ ...camera }));
}
function createScanSession({ scan = scanCameras, now = () => performance.now() } = {}) {
let latestCameras = [];
let allowedCameraUrls = new Set();
let scanInFlight = null;
function getLatestCameras() {
return copyCameras(latestCameras);
}
function allowedCameraUrl(value) {
const normalized = normalizeCameraUrl(value);
return normalized && allowedCameraUrls.has(normalized) ? normalized : null;
}
function scanOnce() {
if (scanInFlight) return scanInFlight;
latestCameras = [];
allowedCameraUrls = new Set();
const started = now();
let scanResult;
try {
scanResult = Promise.resolve(scan());
} catch (error) {
scanResult = Promise.reject(error);
}
let sharedPromise;
sharedPromise = scanResult
.then((cameras) => {
latestCameras = copyCameras(cameras);
allowedCameraUrls = new Set(latestCameras.map((camera) => camera.web_ui_url));
return {
ok: true,
count: latestCameras.length,
duration_seconds: Number(((now() - started) / 1000).toFixed(1)),
cameras: getLatestCameras(),
};
})
.finally(() => {
if (scanInFlight === sharedPromise) scanInFlight = null;
});
scanInFlight = sharedPromise;
return sharedPromise;
}
return Object.freeze({ allowedCameraUrl, getLatestCameras, scanOnce });
}
module.exports = { createScanSession };
+28
View File
@@ -0,0 +1,28 @@
'use strict';
const CAMERA_HOST_PATTERN = /^avacam-[a-z0-9]{4}(?:-[a-z0-9]{4}){2}\.local$/;
function normalizeCameraUrl(value) {
try {
const url = new URL(value);
if (url.protocol !== 'https:' ||
!CAMERA_HOST_PATTERN.test(url.hostname) ||
url.username !== '' ||
url.password !== '' ||
url.port !== '' ||
url.pathname !== '/' ||
url.search !== '' ||
url.hash !== '') {
return null;
}
return `https://${url.hostname}`;
} catch {
return null;
}
}
function isCameraUrl(value) {
return normalizeCameraUrl(value) !== null;
}
module.exports = { CAMERA_HOST_PATTERN, isCameraUrl, normalizeCameraUrl };
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+153
View File
@@ -0,0 +1,153 @@
const scanButton = document.querySelector('#scanButton');
const buttonText = document.querySelector('#buttonText');
const results = document.querySelector('#results');
const empty = document.querySelector('#empty');
const errorBox = document.querySelector('#error');
const list = document.querySelector('#cameraList');
const title = document.querySelector('#resultTitle');
const meta = document.querySelector('#scanMeta');
const errorText = document.querySelector('#errorText');
const exportButton = document.querySelector('#exportCsvButton');
const actionStatus = document.querySelector('#actionStatus');
let currentCameras = [];
function hidePanels() {
results.hidden = true;
empty.hidden = true;
errorBox.hidden = true;
actionStatus.textContent = '';
}
function cameraCard(camera) {
const article = document.createElement('article');
article.className = 'camera';
const details = document.createElement('div');
const key = document.createElement('div');
key.className = 'camera-key';
key.textContent = camera.alta_key;
const host = document.createElement('a');
host.className = 'camera-host';
host.href = camera.web_ui_url;
host.target = '_blank';
host.rel = 'noopener noreferrer';
host.textContent = camera.hostname;
host.title = 'Open camera Web UI';
details.append(key, host);
const network = document.createElement('div');
network.className = 'network-data';
const ip = document.createElement('span');
ip.className = 'camera-ip';
ip.textContent = camera.ip_address;
const networkLabel = document.createElement('span');
networkLabel.className = 'network-label';
networkLabel.textContent = 'HTTPS · mDNS';
network.append(ip, networkLabel);
const copy = document.createElement('button');
copy.className = 'copy-button';
copy.type = 'button';
copy.dataset.altaKey = camera.alta_key;
copy.textContent = 'Copy key';
copy.setAttribute('aria-label', `Copy Alta Key ${camera.alta_key}`);
article.append(details, network, copy);
return article;
}
async function copyText(text) {
if (window.altaDiscovery) {
await window.altaDiscovery.copyText(text);
return;
}
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.append(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
if (!copied) throw new Error('Clipboard access was blocked by the browser.');
}
list.addEventListener('click', async (event) => {
const copyButton = event.target.closest('.copy-button');
if (!copyButton) return;
try {
await copyText(copyButton.dataset.altaKey);
copyButton.textContent = 'Copied';
copyButton.classList.add('copied');
actionStatus.textContent = `${copyButton.dataset.altaKey} copied to clipboard.`;
window.setTimeout(() => {
copyButton.textContent = 'Copy key';
copyButton.classList.remove('copied');
}, 1800);
} catch (error) {
actionStatus.textContent = error.message;
}
});
exportButton.addEventListener('click', async () => {
if (window.altaDiscovery) {
try {
const result = await window.altaDiscovery.exportCsv();
actionStatus.textContent = result.canceled ? 'CSV export canceled.' : 'CSV exported successfully.';
} catch (error) {
actionStatus.textContent = error.message;
}
return;
}
const csv = AltaExportUtils.buildCsv(currentCameras);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'alta-camera-keys.csv';
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
actionStatus.textContent = 'CSV exported with Alta Keys and camera Web UI URLs.';
});
scanButton.addEventListener('click', async () => {
hidePanels();
currentCameras = [];
scanButton.disabled = true;
scanButton.classList.add('scanning');
buttonText.textContent = 'Scanning…';
try {
let data;
if (window.altaDiscovery) {
data = await window.altaDiscovery.scan();
} else {
const response = await fetch('/api/scan', { cache: 'no-store' });
data = await response.json();
if (!response.ok) throw new Error(data.error || 'The scanner returned an error.');
}
if (!data.ok) throw new Error(data.error || 'The scanner returned an error.');
currentCameras = data.cameras;
if (!currentCameras.length) {
empty.hidden = false;
} else {
list.replaceChildren(...currentCameras.map(cameraCard));
title.textContent = `${data.count} camera${data.count === 1 ? '' : 's'} found`;
meta.textContent = `Scan completed in ${data.duration_seconds}s`;
results.hidden = false;
}
} catch (error) {
errorText.textContent = error.message;
errorBox.hidden = false;
} finally {
scanButton.disabled = false;
scanButton.classList.remove('scanning');
buttonText.textContent = 'Discover again';
}
});
+5
View File
@@ -0,0 +1,5 @@
(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.AltaExportUtils=api;})(typeof globalThis!=='undefined'?globalThis:this,function(){
function csvCell(value){const text=String(value??'');return /[",\r\n]/.test(text)?`"${text.replaceAll('"','""')}"`:text}
function buildCsv(cameras){const rows=[['Alta Key','IP Address','Hostname URL'],...cameras.map(camera=>[camera.alta_key,camera.ip_address,camera.web_ui_url])];return rows.map(row=>row.map(csvCell).join(',')).join('\r\n')+'\r\n'}
return{buildCsv};
});
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>Local Alta Camera Discovery</title>
<link rel="stylesheet" href="styles.css?v=13">
<script src="export-utils.js?v=13" defer></script>
<script src="app.js?v=13" defer></script>
</head>
<body>
<header class="topbar">
<div class="product-brand">
<img class="alta-logo" src="alta-mark.png" alt="Alta">
</div>
</header>
<main class="app-shell">
<section class="page-heading">
<h1>Local Alta Camera Discovery</h1>
<p>Locate cloud-native Alta cameras available on this network and recover the information required for deployment.</p>
</section>
<section class="discovery-panel" aria-labelledby="scan-title">
<div class="discovery-copy">
<span class="network-icon" aria-hidden="true">
<svg viewBox="0 0 64 64" focusable="false">
<circle class="radar-field" cx="32" cy="32" r="28"></circle>
<path class="radar-sector" d="M32 32V4A28 28 0 0 1 54.7 48.4Z"></path>
<circle class="radar-ring" cx="32" cy="32" r="20.5"></circle>
<circle class="radar-ring" cx="32" cy="32" r="13"></circle>
<circle class="radar-ring" cx="32" cy="32" r="6.2"></circle>
<path class="radar-sweep" d="M32 32V4M32 32l22.7 16.4"></path>
<circle class="radar-target" cx="23.3" cy="13.4" r="3.8"></circle>
<circle class="radar-target" cx="40" cy="21.8" r="3.8"></circle>
<circle class="radar-target" cx="52.3" cy="34.8" r="3.8"></circle>
<circle class="radar-target" cx="16.3" cy="45.2" r="3.8"></circle>
</svg>
</span>
<div>
<h2 id="scan-title">Discover cameras on this network</h2>
<p>The scan uses local mDNS announcements and does not access or modify camera configuration.</p>
</div>
</div>
<div class="scan-action">
<button id="scanButton" class="primary-button" type="button"><span class="radar"></span><span id="buttonText">Start discovery</span></button>
<span>Usually completes in 5 seconds</span>
</div>
</section>
<section id="results" class="results-panel" hidden>
<div class="panel-header">
<div>
<p class="section-label">DISCOVERY RESULTS</p>
<h2 id="resultTitle">Cameras found</h2>
<span id="scanMeta"></span>
</div>
<button id="exportCsvButton" class="secondary-button" type="button">Export CSV</button>
</div>
<div class="table-head" aria-hidden="true">
<span>Alta camera</span><span>Network address</span><span>Action</span>
</div>
<div id="cameraList" class="camera-list"></div>
<div class="panel-footer"><span id="actionStatus" role="status" aria-live="polite"></span></div>
</section>
<section id="empty" class="state-panel" hidden>
<h2>No Alta cameras found</h2>
<p>Confirm the cameras are powered and connected to the same local network or VLAN as this device.</p>
</section>
<section id="error" class="state-panel error" hidden>
<h2>Discovery could not be completed</h2><p id="errorText"></p>
</section>
</main>
<footer class="footer"><span>Alta Video Local Camera Discovery</span></footer>
</body>
</html>
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { resolveAppAssetPath } = require('../src/app-protocol');
const staticDir = path.join(__dirname, '..', 'static');
test('app protocol resolves only explicitly packaged renderer assets', () => {
assert.equal(resolveAppAssetPath('app://bundle/index.html', staticDir), path.join(staticDir, 'index.html'));
assert.equal(resolveAppAssetPath('app://bundle/styles.css?v=12', staticDir), path.join(staticDir, 'styles.css'));
assert.equal(resolveAppAssetPath('app://bundle/alta-mark.png', staticDir), path.join(staticDir, 'alta-mark.png'));
assert.equal(resolveAppAssetPath('app://other/index.html', staticDir), null);
assert.equal(resolveAppAssetPath('app://bundle/../main.js', staticDir), null);
assert.equal(resolveAppAssetPath('app://bundle/%2e%2e/main.js', staticDir), null);
assert.equal(resolveAppAssetPath('file:///etc/passwd', staticDir), null);
assert.equal(resolveAppAssetPath('app://bundle/not-packaged.js', staticDir), null);
});
+108
View File
@@ -0,0 +1,108 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const {
altaKeyFromName,
cameraFromService,
isLocalAddress,
normalizeResults,
scanCameras,
} = require('../src/discovery');
const validService = (overrides = {}) => ({
name: 'ABH7-C62U-GC3E',
type: 'vaioncam',
protocol: 'tcp',
host: 'avacam-abh7-c62u-gc3e.local.',
addresses: ['fe80::1', '10.0.0.223'],
port: 443,
...overrides,
});
test('extracts an Alta Key from service names', () => {
assert.equal(altaKeyFromName('abh7-c62u-gc3e'), 'ABH7-C62U-GC3E');
assert.equal(altaKeyFromName('ABH7-C62U-GC3E._vaioncam._tcp.local'), 'ABH7-C62U-GC3E');
assert.equal(altaKeyFromName('not-a-key'), null);
});
test('projects a correlated local Bonjour service into a safe camera row', () => {
assert.deepEqual(cameraFromService(validService()), {
alta_key: 'ABH7-C62U-GC3E',
hostname: 'avacam-abh7-c62u-gc3e.local',
web_ui_url: 'https://avacam-abh7-c62u-gc3e.local',
ip_address: '10.0.0.223',
port: 443,
service: '_vaioncam._tcp.local',
});
});
test('rejects uncorrelated or non-local service announcements', () => {
assert.equal(cameraFromService(validService({ type: undefined })), null);
assert.equal(cameraFromService(validService({ protocol: undefined })), null);
assert.equal(cameraFromService(validService({ host: 'avacam-attacker.local.' })), null);
assert.equal(cameraFromService(validService({ port: 8443 })), null);
assert.equal(cameraFromService(validService({ addresses: ['203.0.113.10'] })), null);
assert.equal(cameraFromService(validService({ addresses: [] })), null);
});
test('recognizes supported private and link-local IP scopes', () => {
for (const address of ['10.0.0.1', '172.16.0.1', '172.31.255.254', '192.168.1.1', '169.254.1.1', 'fe80::1', 'fc00::1', 'fd12::1']) {
assert.equal(isLocalAddress(address), true, address);
}
for (const address of ['8.8.8.8', '172.32.0.1', '2001:4860:4860::8888', 'not-an-ip']) {
assert.equal(isLocalAddress(address), false, address);
}
});
test('deduplicates and sorts cameras', () => {
const a = { alta_key: 'AHG2-Z4ND-FVRN', ip_address: '10.0.0.192' };
const b = { alta_key: 'ABH7-C62U-GC3E', ip_address: '10.0.0.223' };
assert.deepEqual(normalizeResults([a, b, a]), [b, a]);
});
test('scans through the Bonjour adapter and closes resources', async () => {
let browserStopped = false;
let bonjourDestroyed = false;
class FakeBonjour {
find(query) {
assert.deepEqual(query, { type: 'vaioncam', protocol: 'tcp' });
const browser = new EventEmitter();
browser.stop = () => { browserStopped = true; };
process.nextTick(() => browser.emit('up', validService()));
return browser;
}
destroy() { bonjourDestroyed = true; }
}
const rows = await scanCameras({ durationMs: 5, BonjourClass: FakeBonjour });
assert.equal(rows.length, 1);
assert.equal(rows[0].alta_key, 'ABH7-C62U-GC3E');
assert.equal(browserStopped, true);
assert.equal(bonjourDestroyed, true);
});
test('caps scan results and closes discovery immediately at the cap', async () => {
let browserStopped = false;
class FakeBonjour {
find() {
const browser = new EventEmitter();
browser.stop = () => { browserStopped = true; };
process.nextTick(() => {
for (let index = 0; index < 10; index += 1) {
const key = `AAAA-BBBB-${String(index).padStart(4, '0')}`;
browser.emit('up', validService({
name: key,
host: `avacam-${key.toLowerCase()}.local.`,
addresses: [`10.0.0.${index + 1}`],
}));
}
});
return browser;
}
destroy() {}
}
const rows = await scanCameras({ durationMs: 1000, maxResults: 3, BonjourClass: FakeBonjour });
assert.equal(rows.length, 3);
assert.equal(browserStopped, true);
});
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
test('Electron boundary stays isolated and packaged files are declared', () => {
const main = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
const preload = fs.readFileSync(path.join(root, 'preload.js'), 'utf8');
const appProtocol = fs.readFileSync(path.join(root, 'src', 'app-protocol.js'), 'utf8');
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
assert.match(main, /nodeIntegration:\s*false/);
assert.match(main, /contextIsolation:\s*true/);
assert.match(main, /sandbox:\s*true/);
assert.match(main, /isTrustedSender/);
assert.match(main, /setPermissionCheckHandler/);
assert.match(main, /setPermissionRequestHandler/);
assert.match(main, /registerSchemesAsPrivileged/);
assert.match(appProtocol, /app:\/\/bundle/);
assert.doesNotMatch(main, /\.loadFile\s*\(/);
assert.doesNotMatch(preload, /require\(['"](?:node:)?(?:fs|child_process|dgram)/);
assert.match(preload, /exportCsv:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('export-csv'\)/);
assert.ok(pkg.build.files.includes('src/**/*'));
assert.ok(pkg.build.files.includes('static/**/*'));
assert.equal(pkg.build.win.icon, 'assets/icon.ico');
assert.equal(pkg.build.electronFuses.runAsNode, false);
assert.equal(pkg.build.electronFuses.enableNodeOptionsEnvironmentVariable, false);
assert.equal(pkg.build.electronFuses.enableNodeCliInspectArguments, false);
assert.equal(pkg.build.electronFuses.enableEmbeddedAsarIntegrityValidation, true);
assert.equal(pkg.build.electronFuses.onlyLoadAppFromAsar, true);
assert.equal(pkg.build.electronFuses.grantFileProtocolExtraPrivileges, false);
});
+72
View File
@@ -0,0 +1,72 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createScanSession } = require('../src/scan-session');
const camera = {
alta_key: 'ABH7-C62U-GC3E',
hostname: 'avacam-abh7-c62u-gc3e.local',
web_ui_url: 'https://avacam-abh7-c62u-gc3e.local',
ip_address: '10.0.0.223',
port: 443,
service: '_vaioncam._tcp.local',
};
test('shares one scan, publishes defensive state, and binds camera URLs', async () => {
let scanCalls = 0;
let releaseScan;
const scan = () => {
scanCalls += 1;
return new Promise((resolve) => { releaseScan = resolve; });
};
const times = [1000, 2345];
const session = createScanSession({ scan, now: () => times.shift() });
const first = session.scanOnce();
const second = session.scanOnce();
assert.strictEqual(first, second);
assert.equal(scanCalls, 1);
assert.equal(session.allowedCameraUrl(camera.web_ui_url), null);
assert.deepEqual(session.getLatestCameras(), []);
releaseScan([camera]);
assert.deepEqual(await first, {
ok: true,
count: 1,
duration_seconds: 1.3,
cameras: [camera],
});
assert.equal(session.allowedCameraUrl(camera.web_ui_url), camera.web_ui_url);
assert.equal(session.allowedCameraUrl(`${camera.web_ui_url}/?secret=value`), null);
assert.equal(session.allowedCameraUrl('https://avacam-ahg2-z4nd-fvrn.local'), null);
const exposed = session.getLatestCameras();
exposed[0].alta_key = 'MUTATED';
assert.equal(session.getLatestCameras()[0].alta_key, camera.alta_key);
});
test('revokes prior state at rescan and permits retry after failure', async () => {
let call = 0;
let releaseSecondScan;
const scan = () => {
call += 1;
if (call === 1) return Promise.resolve([camera]);
if (call === 2) return new Promise((_resolve, reject) => { releaseSecondScan = reject; });
return Promise.resolve([camera]);
};
const session = createScanSession({ scan, now: () => 0 });
await session.scanOnce();
assert.equal(session.allowedCameraUrl(camera.web_ui_url), camera.web_ui_url);
const failed = session.scanOnce();
assert.equal(session.allowedCameraUrl(camera.web_ui_url), null);
assert.deepEqual(session.getLatestCameras(), []);
releaseSecondScan(new Error('network failed'));
await assert.rejects(failed, /network failed/);
const retried = await session.scanOnce();
assert.equal(call, 3);
assert.equal(retried.count, 1);
});
+23
View File
@@ -0,0 +1,23 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { isCameraUrl, normalizeCameraUrl } = require('../src/url-policy');
const cameraUrl = 'https://avacam-abh7-c62u-gc3e.local';
test('permits only root local Alta camera HTTPS URLs', () => {
assert.equal(isCameraUrl(cameraUrl), true);
assert.equal(isCameraUrl('http://avacam-abh7-c62u-gc3e.local'), false);
assert.equal(isCameraUrl('https://example.com'), false);
assert.equal(isCameraUrl('https://user:pass@avacam-abh7-c62u-gc3e.local'), false);
assert.equal(isCameraUrl('https://avacam-abh7-c62u-gc3e.local:8443'), false);
assert.equal(isCameraUrl(`${cameraUrl}/anything`), false);
assert.equal(isCameraUrl(`${cameraUrl}/?secret=value`), false);
assert.equal(isCameraUrl(`${cameraUrl}/#fragment`), false);
});
test('normalizes camera URLs to their canonical form', () => {
assert.equal(normalizeCameraUrl(`${cameraUrl}/`), cameraUrl);
assert.equal(normalizeCameraUrl(`${cameraUrl.toUpperCase()}/`), cameraUrl);
});
+37
View File
@@ -0,0 +1,37 @@
import unittest
from app import camera_from_service, normalize_results
class CameraDiscoveryTests(unittest.TestCase):
def test_extracts_alta_key_and_hostname(self):
camera = camera_from_service(
"ABH7-C62U-GC3E._vaioncam._tcp.local.",
"avacam-abh7-c62u-gc3e.local.",
["10.0.0.223"],
443,
)
self.assertEqual(camera["alta_key"], "ABH7-C62U-GC3E")
self.assertEqual(camera["hostname"], "avacam-abh7-c62u-gc3e.local")
self.assertEqual(camera["web_ui_url"], "https://avacam-abh7-c62u-gc3e.local")
self.assertEqual(camera["ip_address"], "10.0.0.223")
def test_rejects_non_camera_service(self):
self.assertIsNone(camera_from_service(
"printer._ipp._tcp.local.", "printer.local.", ["10.0.0.50"], 631
))
def test_deduplicates_and_sorts_results(self):
rows = [
{"alta_key": "BBBB-BBBB-BBBB", "ip_address": "10.0.0.2"},
{"alta_key": "AAAA-AAAA-AAAA", "ip_address": "10.0.0.3"},
{"alta_key": "BBBB-BBBB-BBBB", "ip_address": "10.0.0.2"},
]
self.assertEqual(
[row["alta_key"] for row in normalize_results(rows)],
["AAAA-AAAA-AAAA", "BBBB-BBBB-BBBB"],
)
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -0,0 +1,26 @@
const assert = require('node:assert/strict');
const { buildCsv } = require('./static/export-utils.js');
const cameras = [
{
alta_key: 'ABH7-C62U-GC3E',
hostname: 'avacam-abh7-c62u-gc3e.local',
web_ui_url: 'https://avacam-abh7-c62u-gc3e.local',
ip_address: '10.0.0.223',
},
{
alta_key: 'AHG2-Z4ND-FVRN',
hostname: 'avacam-ahg2-z4nd-fvrn.local',
web_ui_url: 'https://avacam-ahg2-z4nd-fvrn.local',
ip_address: '10.0.0.192',
},
];
assert.equal(require('./static/export-utils.js').buildKeyPaste, undefined);
assert.equal(
buildCsv(cameras),
'Alta Key,IP Address,Hostname URL\r\n' +
'ABH7-C62U-GC3E,10.0.0.223,https://avacam-abh7-c62u-gc3e.local\r\n' +
'AHG2-Z4ND-FVRN,10.0.0.192,https://avacam-ahg2-z4nd-fvrn.local\r\n'
);
console.log('export-utils tests passed');