diff --git a/CLAUDE.md b/CLAUDE.md index 0796f2c..f5751b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Alta Video Camera Proxy — an Electron desktop app that authenticates with Avigilon Alta Video deployments, discovers cameras, and launches external proxy executables (`aware-cam-proxy-win.exe`, `aware-cam-proxy.exe`) to establish camera connections. Windows-only due to the proxy executables. +Alta Proxy Tool (APT) — an Electron desktop app that authenticates with Avigilon Alta Video deployments, discovers cameras, and launches external proxy executables (`aware-cam-proxy-win.exe`, `aware-cam-proxy.exe`) to establish camera connections. Windows-only due to the proxy executables. + +## Repository + +- **GitHub**: https://github.com/PageZ948/Alta-Proxy-Tool (private) +- **Branch**: master +- **Git identity**: Zac (repo-local config) ## Commands @@ -19,26 +25,43 @@ No test framework is configured. No linter is configured. ## Architecture -This is a vanilla Electron app (no React/Vue/framework). Four files form the entire application: +This is a vanilla Electron app (no React/Vue/framework). Core files: ``` main.js → Electron main process: IPC handlers, API calls (axios), profile CRUD, - camera proxy process spawning, password encryption (CryptoJS + machine-derived key) + camera proxy process spawning, password encryption (CryptoJS + machine-derived key), + local HTTP cookie server for Chrome extension bridge preload.js → contextBridge exposing window.electronAPI with typed IPC invoke wrappers renderer.js → All UI logic: DOM manipulation, state management, event handlers index.html → Static HTML shell, no inline scripts (CSP enforced) styles.css → Dark theme using CSS custom properties ``` +A companion Chrome extension lives in `chrome-extension/`: + +``` +chrome-extension/ + manifest.json → Manifest V3, cookies + activeTab permissions + popup.html → Extension popup UI + popup.css → Dark theme matching the Electron app + popup.js → Tab detection, cookie retrieval, POST to localhost + icon*.png → Placeholder icons +``` + ### IPC Communication Pattern -All cross-process communication follows one pattern: +Most cross-process communication follows the request/response pattern: 1. `main.js` registers handler: `ipcMain.handle('channel-name', async (event, params) => { ... })` 2. `preload.js` exposes it: `channelName: (params) => ipcRenderer.invoke('channel-name', params)` 3. `renderer.js` calls it: `const result = await window.electronAPI.channelName(params)` All handlers return `{ success: boolean, message?: string, ...data }`. +There is one **push-pattern** channel for the Chrome extension cookie bridge: +- `main.js` sends: `mainWindow.webContents.send('extension-cookie-received', data)` +- `preload.js` bridges: `ipcRenderer.on('extension-cookie-received', callback)` +- `renderer.js` listens via `window.electronAPI.onExtensionCookie(callback)` + ### IPC Channels | Channel | Purpose | @@ -52,6 +75,7 @@ All handlers return `{ success: boolean, message?: string, ...data }`. | `camera-proxy-stop` | Kills all proxy processes via taskkill/powershell | | `camera-proxy-check` | Checks if proxy executable exists | | `camera-proxy-version` | Runs proxy with -v flag | +| `extension-cookie-received` | Push channel: cookie data from Chrome extension → renderer | ### State Management (renderer.js) @@ -68,11 +92,25 @@ Active proxy processes are tracked in two Maps: `activeProxyConnections` and `ac - Legacy profiles auto-migrate from old static key on first load - Clipboard is cleared 30 seconds after password copy - Passwords never written to DOM; kept only in JS variables (`selectedProfile`) +- Local HTTP cookie server (port 18247) bound to `127.0.0.1` only +- Cookie server validates: shared token header, CORS restricted to `chrome-extension://` origins, deployment URL must be `*.avasecurity.com` or `*.avigilon.com` over HTTPS, type/length limits on all inputs, 64KB body size limit ### Profile Storage Profiles stored at `~/.alta-api-profiles.json`. Passwords encrypted with CryptoJS AES using a machine-derived key. The `profiles-load` handler strips passwords before sending to renderer; `profiles-get` decrypts for a specific profile when needed. +### Chrome Extension Cookie Bridge + +Users already logged into Alta in Chrome can send their `va` session cookie to the running Electron app. The flow: + +1. Chrome extension popup detects Alta tab (`*.avasecurity.com` / `*.avigilon.com`) +2. User clicks "Send Cookie to APT" +3. Extension POSTs `{deploymentUrl, cookieValue}` to `http://127.0.0.1:18247/cookie` with `X-APT-Token` header +4. `main.js` HTTP server validates and forwards via IPC push to renderer +5. `renderer.js` `handleExtensionCookie()` sets session state, populates cookie key, expands cookie proxy section, fetches devices + +The extension is loaded unpacked via `chrome://extensions/` → Developer mode → Load unpacked → select `chrome-extension/`. + ## Key Conventions - No inline event handlers in HTML — all use `addEventListener` in renderer.js @@ -86,4 +124,4 @@ Profiles stored at `~/.alta-api-profiles.json`. Passwords encrypted with CryptoJ - `aware-cam-proxy-win.exe` — username/password auth proxy (required) - `aware-cam-proxy.exe` — cookie-based auth proxy (optional) -These are not bundled via npm. They must be in the app root directory. +These are not bundled via npm. They must be in the app root directory. They are gitignored along with `*.pdf`, `node_modules/`, and `dist/`. diff --git a/chrome-extension/icon128.png b/chrome-extension/icon128.png new file mode 100644 index 0000000..a1ad343 Binary files /dev/null and b/chrome-extension/icon128.png differ diff --git a/chrome-extension/icon16.png b/chrome-extension/icon16.png new file mode 100644 index 0000000..6937f9d Binary files /dev/null and b/chrome-extension/icon16.png differ diff --git a/chrome-extension/icon48.png b/chrome-extension/icon48.png new file mode 100644 index 0000000..ec6c267 Binary files /dev/null and b/chrome-extension/icon48.png differ diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json new file mode 100644 index 0000000..1d126e5 --- /dev/null +++ b/chrome-extension/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "Alta Proxy Tool Bridge", + "version": "1.0.0", + "description": "Send Alta session cookies to the Alta Proxy Tool desktop app.", + "permissions": ["cookies", "activeTab"], + "host_permissions": [ + "https://*.avasecurity.com/*", + "https://*.avigilon.com/*", + "http://127.0.0.1:18247/*" + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } + }, + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } +} diff --git a/chrome-extension/popup.css b/chrome-extension/popup.css new file mode 100644 index 0000000..ed6c4f1 --- /dev/null +++ b/chrome-extension/popup.css @@ -0,0 +1,100 @@ +/* Dark theme matching the Electron app */ +body { + margin: 0; + padding: 0; + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; + background: #1E1E1E; + color: #E0E0E0; + font-size: 14px; + min-width: 300px; +} + +.popup-container { + padding: 16px; +} + +h1 { + font-size: 16px; + font-weight: 600; + color: #0E7AFE; + margin: 0 0 12px 0; + text-align: center; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.tab-info { + background: #2D2D30; + border: 1px solid #3C3C3C; + border-radius: 4px; + padding: 10px 12px; + margin-bottom: 12px; + font-size: 13px; + color: #999999; + word-break: break-all; +} + +.tab-info.detected { + color: #4CAF50; + border-color: #4CAF50; +} + +.tab-info.not-detected { + color: #F44336; + border-color: #F44336; +} + +.send-btn { + display: block; + width: 100%; + padding: 10px 16px; + font-family: inherit; + font-size: 14px; + font-weight: bold; + color: white; + background: #0E7AFE; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s ease; +} + +.send-btn:hover:not(:disabled) { + background: #0A5FD9; +} + +.send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.status-msg { + margin-top: 10px; + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; + font-weight: bold; + display: none; + border: 1px solid transparent; +} + +.status-msg.success { + display: block; + background: rgba(76, 175, 80, 0.1); + color: #4CAF50; + border-color: #4CAF50; +} + +.status-msg.error { + display: block; + background: rgba(244, 67, 54, 0.1); + color: #F44336; + border-color: #F44336; +} + +.status-msg.info { + display: block; + background: rgba(14, 122, 254, 0.1); + color: #0E7AFE; + border-color: #0E7AFE; +} diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html new file mode 100644 index 0000000..e82ba1a --- /dev/null +++ b/chrome-extension/popup.html @@ -0,0 +1,18 @@ + + + + + + Alta Proxy Tool Bridge + + + + + + + diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js new file mode 100644 index 0000000..d40a852 --- /dev/null +++ b/chrome-extension/popup.js @@ -0,0 +1,100 @@ +const APT_URL = 'http://127.0.0.1:18247/cookie'; +const APT_TOKEN = 'apt-local-bridge-token'; + +const tabInfo = document.getElementById('tabInfo'); +const sendBtn = document.getElementById('sendBtn'); +const statusMsg = document.getElementById('statusMsg'); + +let detectedOrigin = null; + +function showStatus(message, type) { + statusMsg.textContent = message; + statusMsg.className = 'status-msg ' + type; +} + +// Check the active tab on popup open +chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (!tabs || tabs.length === 0) { + tabInfo.textContent = 'No active tab found.'; + tabInfo.className = 'tab-info not-detected'; + return; + } + + const tab = tabs[0]; + let url; + try { + url = new URL(tab.url); + } catch { + tabInfo.textContent = 'Cannot read this tab URL.'; + tabInfo.className = 'tab-info not-detected'; + return; + } + + const hostname = url.hostname; + const isAlta = hostname.endsWith('.avasecurity.com') || hostname.endsWith('.avigilon.com'); + + if (!isAlta) { + tabInfo.textContent = 'This tab is not an Alta deployment.'; + tabInfo.className = 'tab-info not-detected'; + return; + } + + detectedOrigin = url.origin; + tabInfo.textContent = 'Detected: ' + hostname; + tabInfo.className = 'tab-info detected'; + sendBtn.disabled = false; +}); + +// Send cookie on button click +sendBtn.addEventListener('click', async () => { + if (!detectedOrigin) return; + + sendBtn.disabled = true; + showStatus('Retrieving cookie...', 'info'); + + try { + const cookie = await chrome.cookies.get({ url: detectedOrigin, name: 'va' }); + + if (!cookie || !cookie.value) { + showStatus('No "va" session cookie found. Are you logged in?', 'error'); + sendBtn.disabled = false; + return; + } + + if (cookie.expirationDate && cookie.expirationDate < Date.now() / 1000) { + showStatus('Session cookie has expired. Please log in again.', 'error'); + sendBtn.disabled = false; + return; + } + + showStatus('Sending to Alta Proxy Tool...', 'info'); + + const response = await fetch(APT_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-APT-Token': APT_TOKEN + }, + body: JSON.stringify({ + deploymentUrl: detectedOrigin, + cookieValue: cookie.value + }) + }); + + const data = await response.json(); + + if (data.success) { + showStatus('Cookie sent successfully!', 'success'); + } else { + showStatus('Error: ' + (data.message || 'Unknown error'), 'error'); + sendBtn.disabled = false; + } + } catch (err) { + if (err.message && err.message.includes('Failed to fetch')) { + showStatus('Alta Proxy Tool is not running.', 'error'); + } else { + showStatus('Error: ' + err.message, 'error'); + } + sendBtn.disabled = false; + } +}); diff --git a/main.js b/main.js index b5fa977..96cc340 100644 --- a/main.js +++ b/main.js @@ -6,9 +6,13 @@ const axios = require('axios'); const CryptoJS = require('crypto-js'); const { spawn } = require('child_process'); const crypto = require('crypto'); +const http = require('http'); let mainWindow; let activeProxyProcesses = new Map(); // Track active camera proxy processes +let cookieServer = null; +const COOKIE_SERVER_PORT = 18247; +const COOKIE_SERVER_TOKEN = 'apt-local-bridge-token'; // Sanitize strings before embedding in batch files to prevent command injection function sanitizeBatchInput(input) { @@ -113,6 +117,121 @@ function saveProfiles(profiles) { } } +function startCookieServer() { + cookieServer = http.createServer((req, res) => { + // CORS headers — only allow Chrome extension origins + const origin = req.headers.origin || ''; + if (origin.startsWith('chrome-extension://')) { + res.setHeader('Access-Control-Allow-Origin', origin); + } + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-APT-Token'); + + // Handle preflight + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + // Only accept POST /cookie + if (req.method !== 'POST' || req.url !== '/cookie') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Not found' })); + return; + } + + // Verify shared token + if (req.headers['x-apt-token'] !== COOKIE_SERVER_TOKEN) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Forbidden' })); + return; + } + + // Read body with 64KB size limit + let body = ''; + let bodySize = 0; + const MAX_BODY_SIZE = 65536; + + req.on('data', (chunk) => { + bodySize += chunk.length; + if (bodySize > MAX_BODY_SIZE) { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Payload too large' })); + req.destroy(); + return; + } + body += chunk; + }); + + req.on('end', () => { + try { + const data = JSON.parse(body); + const { deploymentUrl, cookieValue } = data; + + if (!deploymentUrl || !cookieValue) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Missing deploymentUrl or cookieValue' })); + return; + } + + // Validate types and lengths + if (typeof deploymentUrl !== 'string' || typeof cookieValue !== 'string' || + deploymentUrl.length > 512 || cookieValue.length > 4096) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Invalid parameter types or lengths' })); + return; + } + + // Validate deployment URL is an Alta domain + try { + const parsed = new URL(deploymentUrl); + const isAltaDomain = parsed.hostname.endsWith('.avasecurity.com') || + parsed.hostname.endsWith('.avigilon.com'); + if (!isAltaDomain || parsed.protocol !== 'https:') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Invalid deployment URL domain' })); + return; + } + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Invalid deployment URL' })); + return; + } + + if (mainWindow && !mainWindow.isDestroyed()) { + const cookies = ['va=' + cookieValue]; + mainWindow.webContents.send('extension-cookie-received', { + deploymentUrl: deploymentUrl.replace(/\/$/, ''), + cookies, + cookieValue + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, message: 'Cookie received' })); + } else { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Application window not available' })); + } + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Invalid JSON' })); + } + }); + }); + + cookieServer.listen(COOKIE_SERVER_PORT, '127.0.0.1', () => { + console.log(`Cookie server listening on http://127.0.0.1:${COOKIE_SERVER_PORT}`); + }); + + cookieServer.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`Cookie server error: Port ${COOKIE_SERVER_PORT} is already in use`); + } else { + console.error('Cookie server error:', err.message); + } + }); +} + function createWindow() { mainWindow = new BrowserWindow({ width: 1400, @@ -134,7 +253,16 @@ function createWindow() { } } -app.whenReady().then(createWindow); +app.whenReady().then(() => { + createWindow(); + startCookieServer(); +}); + +app.on('before-quit', () => { + if (cookieServer) { + cookieServer.close(); + } +}); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { diff --git a/preload.js b/preload.js index 76598bc..47a22bd 100644 --- a/preload.js +++ b/preload.js @@ -20,5 +20,16 @@ contextBridge.exposeInMainWorld('electronAPI', { stopCameraProxy: (processId) => ipcRenderer.invoke('camera-proxy-stop', { processId }), checkCameraProxy: () => ipcRenderer.invoke('camera-proxy-check'), getCameraProxyVersion: () => ipcRenderer.invoke('camera-proxy-version'), - listActiveCameraProxies: () => ipcRenderer.invoke('camera-proxy-list-active') + listActiveCameraProxies: () => ipcRenderer.invoke('camera-proxy-list-active'), + + // Extension cookie bridge (push from main process) + onExtensionCookie: (callback) => { + ipcRenderer.on('extension-cookie-received', (event, data) => { + try { + callback(data); + } catch (error) { + console.error('Extension cookie handler error:', error); + } + }); + } }); \ No newline at end of file diff --git a/renderer.js b/renderer.js index 86d9574..2db39f5 100644 --- a/renderer.js +++ b/renderer.js @@ -996,6 +996,44 @@ async function deleteProfile(profileId) { // toggleCookieSection is attached via addEventListener in the event listeners section above +// Handle cookie received from Chrome extension via local HTTP bridge +async function handleExtensionCookie(data) { + const { deploymentUrl, cookies, cookieValue } = data; + + // If already connected, disconnect first + if (sessionData.isConnected) { + handleDisconnect(); + } + + // Set session state from extension cookie + sessionData.deploymentUrl = deploymentUrl; + sessionData.cookies = cookies; + sessionData.isConnected = true; + + showStatus(connectionStatus, `Connected via Chrome extension to ${deploymentUrl}`, 'success'); + updateConnectionStatus(true); + updateButtonStates(); + + // Auto-populate cookie key and expand cookie proxy section + cookieKey.value = cookieValue; + const cookieContent = document.getElementById('cookieProxyContent'); + const cookieIcon = document.getElementById('cookieCollapseIcon'); + if (cookieContent.style.display === 'none') { + cookieContent.style.display = 'block'; + cookieIcon.textContent = '\u25B2'; + cookieIcon.classList.add('expanded'); + } + updateCookieProxyButtonStates(); + + // Fetch devices + try { + await handleGetDevices(); + } catch (err) { + console.error('Failed to fetch devices after extension cookie:', err); + showStatus(deviceStatus, 'Connected, but failed to load devices.', 'warning'); + } +} + // Initialize the app document.addEventListener('DOMContentLoaded', async () => { console.log('Alta Video Camera Proxy loaded'); @@ -1003,10 +1041,13 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize connection status updateConnectionStatus(false); updateButtonStates(); - + + // Listen for cookies pushed from Chrome extension + window.electronAPI.onExtensionCookie(handleExtensionCookie); + // Load saved profiles await loadProfiles(); - + // Check camera proxy executable availability await checkCameraProxyAvailability(); });