feat: publish Alta Local Camera Discovery
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
SECURITY-REVIEW.md
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -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();
|
||||
});
|
||||
Generated
+3647
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
zeroconf==0.147.0
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
});
|
||||
@@ -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};
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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()
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user