fix: integrate hardened APT security boundary
This commit is contained in:
@@ -1,112 +1,50 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
## Project
|
||||
|
||||
## Project Overview
|
||||
Alta Proxy Tool (APT) is a Windows-only Electron app. Its source of record and releases are on GitPeji:
|
||||
|
||||
Alta Proxy Tool (APT) — an Electron desktop app that authenticates with Avigilon Alta Video deployments via a companion Chrome extension, discovers cameras, and launches `aware-cam-proxy.exe` to establish camera connections. Authentication uses cookie import from Chrome — no username/password login flow. Windows-only due to the proxy executable.
|
||||
- Repository: `https://git.pejicorp.com/peji/Alta-Proxy-Tool`
|
||||
- Releases: `https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases`
|
||||
|
||||
## Repository
|
||||
|
||||
- **GitHub**: https://github.com/PageZ948/Alta-Proxy-Tool (private)
|
||||
- **Branch**: master
|
||||
- **Git identity**: Zac <zpage948@gmail.com> (repo-local config)
|
||||
The app imports an Alta Video session through a stable-ID paired Chrome extension, discovers cameras, and directly launches the fixed `aware-cam-proxy.exe` helper.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # Run the app
|
||||
npm run dev # Run with DevTools open (--dev flag)
|
||||
npm run build # Build portable Windows .exe (output: dist/)
|
||||
npm run build-test # Build to directory without packaging
|
||||
npm start
|
||||
npm run dev
|
||||
npm test
|
||||
npm run check
|
||||
npm run build-test
|
||||
npm run build
|
||||
```
|
||||
|
||||
No test framework is configured. No linter is configured.
|
||||
## Required security architecture
|
||||
|
||||
## Architecture
|
||||
- Main owns `SessionStore`, `AltaClient`, `ProxyProcessManager`, bridge authentication, and safe update checking.
|
||||
- Renderer/preload contracts must never carry the Alta deployment credential or cookie.
|
||||
- IPC methods are narrow and sender-validated against the main local file frame.
|
||||
- Bridge is exactly `127.0.0.1:18247`, exact committed extension origin, and `X-APT-Pairing` authenticated.
|
||||
- Persist only the pairing hash envelope under Electron `userData`; plaintext is shown once after first run/rotation.
|
||||
- Proxy helper launches directly with `shell: false`; stop only app-owned child processes.
|
||||
- Update behavior is check-only against the exact GitPeji API. The only follow-up action opens the fixed GitPeji releases page externally.
|
||||
- Never add download/install/replace logic, arbitrary URLs, redirect following, shell scripts, broad process killing, hardcoded bridge secrets, or credential-bearing renderer state.
|
||||
- Synthetic tests only: never use a real tenant or Alta session.
|
||||
|
||||
This is a vanilla Electron app (no React/Vue/framework). Core files:
|
||||
## File map
|
||||
|
||||
```
|
||||
main.js → Electron main process: IPC handlers, API calls (axios),
|
||||
cookie proxy process spawning, local HTTP cookie server
|
||||
preload.js → contextBridge exposing window.electronAPI with IPC 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
|
||||
```
|
||||
- `main.js`: Electron adapter, lifecycle, trusted IPC registration, fixed bridge listener
|
||||
- `src/electron-runtime.js`: runtime orchestration, pairing persistence, bridge request contract
|
||||
- `src/session-store.js`: in-memory credential boundary
|
||||
- `src/alta-client.js`: bounded same-origin Alta API client
|
||||
- `src/proxy-launch.js`: Windows direct-spawn owned-child manager
|
||||
- `src/update-policy.js`: GitPeji check-only policy
|
||||
- `src/bridge-auth.js`: stable extension origin, scrypt pairing, limits/deadlines
|
||||
- `preload.js`: narrow methods and event payload stripping
|
||||
- `renderer.js`: non-secret connection/device/proxy/pairing/update UI
|
||||
- `test/runtime-contract.test.js`: integration and forbidden-source policy
|
||||
|
||||
A companion Chrome extension lives in `chrome-extension/`:
|
||||
## Scope
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
There is no login form or profile system. Authentication works exclusively through the Chrome extension cookie bridge:
|
||||
|
||||
1. User logs into Alta deployment in Chrome
|
||||
2. Clicks the Chrome extension popup → "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, auto-populates cookie key, fetches devices
|
||||
|
||||
The extension is loaded unpacked via `chrome://extensions/` → Developer mode → Load unpacked → select `chrome-extension/`.
|
||||
|
||||
### IPC Communication 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 |
|
||||
|---------|---------|
|
||||
| `api-get-devices` | GET /api/v1/devices with cookie auth |
|
||||
| `api-get-auth-info` | GET /api/v1/auth to verify session |
|
||||
| `camera-proxy-cookie-launch` | Spawns aware-cam-proxy.exe (cookie method) |
|
||||
| `camera-proxy-stop` | Kills all proxy processes via taskkill/powershell |
|
||||
| `extension-cookie-received` | Push channel: cookie data from Chrome extension → renderer |
|
||||
|
||||
### State Management (renderer.js)
|
||||
|
||||
All connection state lives in the `sessionData` object (deploymentUrl, cookies, isConnected). There is no separate `isConnected` flag — always use `sessionData.isConnected`.
|
||||
|
||||
Active cookie proxy processes are tracked in `activeCookieProxyConnections` Map, keyed by device GUID.
|
||||
|
||||
### Security Model
|
||||
|
||||
- Context isolation enabled, nodeIntegration disabled
|
||||
- CSP meta tag: `script-src 'self'` — no inline scripts or onclick handlers allowed
|
||||
- Batch file inputs are sanitized via `sanitizeBatchInput()` to prevent command injection
|
||||
- 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
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- No inline event handlers in HTML — all use `addEventListener` in renderer.js
|
||||
- All user-provided content rendered to DOM must go through `escapeHtml()` (XSS prevention)
|
||||
- External processes spawned with `detached: true` + `unref()` so they survive if the app closes
|
||||
- Device list filters out cloud cameras (`capabilities.localStorage === false` only)
|
||||
- `clearDeviceList()` must NOT clear proxy connection Maps (proxies may still be running)
|
||||
|
||||
## External Executable
|
||||
|
||||
- `aware-cam-proxy.exe` — cookie-based auth proxy (required)
|
||||
|
||||
Not bundled via npm. Must be in the app root directory. Gitignored along with `*.pdf`, `node_modules/`, and `dist/`.
|
||||
Windows is the current supported runtime because `aware-cam-proxy.exe` is Windows-specific. Do not upgrade dependencies or change production/Tool Hub as part of runtime hardening unless explicitly requested.
|
||||
|
||||
@@ -1,186 +1,65 @@
|
||||
# Alta Video Camera Proxy
|
||||
# Alta Video Camera Proxy
|
||||
|
||||
An Electron desktop application for managing Alta Video camera proxy connections. Authenticates via a companion Chrome extension that imports your existing Alta session cookie, discovers cameras, and launches proxy connections.
|
||||
APT is a Windows Electron desktop app that imports an existing Alta Video session through its paired Chrome extension, discovers local cameras, and launches `aware-cam-proxy.exe` without exposing Alta credentials to the renderer.
|
||||
|
||||
## Features
|
||||
## Security boundary
|
||||
|
||||
- **Chrome Extension Authentication**: Import your Alta session cookie from Chrome with one click — no manual login
|
||||
- **API Integration**: Device discovery via Alta Video API using cookie auth
|
||||
- **Camera Proxy Management**: Launch and manage camera proxy connections
|
||||
- **Device Filtering**: Automatically filters to show only local (non-cloud) cameras
|
||||
- **Device Search**: Quick search functionality to find cameras by name, ID, IP, or model
|
||||
- **Real-time Status**: Live connection status and device online/offline indicators
|
||||
- **Auto-Update**: Checks for updates on startup via GitHub Releases, with one-click in-place update
|
||||
- **Modern Dark UI**: Professional dark-mode interface with responsive design
|
||||
- `main.js` owns the in-memory `SessionStore`, `AltaClient`, `ProxyProcessManager`, bridge pairing envelope, and update checker.
|
||||
- The renderer receives only connection origin/state, device/site/auth responses, and owned proxy metadata. It never receives or supplies the Alta session value.
|
||||
- The bridge listens only on `127.0.0.1:18247`, accepts only the committed extension origin, requires the `X-APT-Pairing` secret, limits concurrent/body/deadline work, and validates an exact canonical Alta HTTPS origin.
|
||||
- The pairing envelope is a scrypt hash stored atomically under Electron `userData` with restrictive permissions. The plaintext secret is shown once on first run or rotation. Revoke invalidates it.
|
||||
- Proxy launch is a direct `spawn` of the fixed helper with `shell: false`; stop actions can target only children owned by this app.
|
||||
- Updates are **check-only**. APT checks the exact GitPeji release API and can open only `https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases` in the system browser. It never downloads, replaces, or executes an update.
|
||||
|
||||
## Prerequisites
|
||||
## Requirements
|
||||
|
||||
- **Node.js** (version 14 or higher)
|
||||
- **npm** (comes with Node.js)
|
||||
- **Google Chrome** (for the authentication extension)
|
||||
- **Windows OS** (required for camera proxy executable)
|
||||
- **aware-cam-proxy.exe** (camera proxy executable) — must be placed in the application directory
|
||||
- **An active Alta Video session** in Chrome (logged in to your deployment)
|
||||
- Windows (current supported runtime scope)
|
||||
- Node.js and npm for development
|
||||
- Chrome with the bundled extension loaded unpacked
|
||||
- `aware-cam-proxy.exe` beside the development app or packaged portable executable
|
||||
- An active Alta Video login in Chrome
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone or download this project
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. Place `aware-cam-proxy.exe` in the project root directory
|
||||
|
||||
### Chrome Extension Setup
|
||||
|
||||
1. Open Chrome and navigate to `chrome://extensions/`
|
||||
2. Enable **Developer mode** (toggle in top-right)
|
||||
3. Click **Load unpacked**
|
||||
4. Select the `chrome-extension/` folder from this project
|
||||
5. The extension icon will appear in the Chrome toolbar
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting the Application
|
||||
## Setup and pairing
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Or for development mode with DevTools:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
1. Open `chrome://extensions`, enable Developer mode, and load `chrome-extension/` unpacked.
|
||||
2. Start APT. Under **Bridge Pairing**, copy the one-time secret.
|
||||
3. Open the extension pairing settings, paste the secret, and save it.
|
||||
4. Visit your Alta deployment in Chrome and use **Send to APT**.
|
||||
5. Select a local camera and choose **Start Proxy**.
|
||||
|
||||
### Connecting to Alta
|
||||
Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication.
|
||||
|
||||
1. **Log into your Alta deployment** in Chrome (e.g., `https://your-site.eu1.aware.avasecurity.com`)
|
||||
2. **Click the extension icon** in Chrome — it will detect the Alta tab
|
||||
3. **Click "Send Cookie to APT"** — the app will connect and load devices automatically
|
||||
|
||||
### Launching Camera Proxy
|
||||
|
||||
1. **Connect via Chrome extension** (above)
|
||||
2. **Select a device** from the left sidebar
|
||||
3. **Click "Start Camera Proxy"**
|
||||
4. A command prompt window will open with the proxy connection
|
||||
|
||||
### Updating
|
||||
|
||||
The app checks for updates automatically 2 seconds after launch. If a newer version is available on GitHub Releases, the "Check for Updates" button in the header will show a green badge.
|
||||
|
||||
- **Manual check**: Click "Check for Updates" in the top-right corner
|
||||
- **Install**: The update modal shows release notes — click "Install Update" to download and replace the current executable
|
||||
- The app will quit, swap the `.exe`, and relaunch automatically
|
||||
|
||||
## API Endpoints Used
|
||||
|
||||
- **Device List**: `GET /api/v1/devices` — Retrieve all devices
|
||||
- **Auth Info**: `GET /api/v1/auth` — Verify authentication status
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Chrome Extension (popup click)
|
||||
→ POST http://127.0.0.1:18247/cookie
|
||||
→ Electron app HTTP server receives cookie
|
||||
→ Sets session state, fetches devices
|
||||
→ User selects device → launches aware-cam-proxy.exe
|
||||
```
|
||||
|
||||
The Electron app runs a local HTTP server on port 18247 that only accepts requests from Chrome extensions with a shared token. The Chrome extension reads the `va` session cookie from the active Alta tab and sends it to the app.
|
||||
|
||||
## Security
|
||||
|
||||
- **Context Isolation**: Renderer process is isolated from Node.js APIs
|
||||
- **Preload Script**: Secure IPC communication between main and renderer processes
|
||||
- **CSP Enforced**: `script-src 'self'` — no inline scripts allowed
|
||||
- **Localhost Only**: Cookie server binds to `127.0.0.1`, not accessible from network
|
||||
- **CORS Restricted**: Only `chrome-extension://` origins accepted
|
||||
- **Domain Validation**: Only `*.avasecurity.com` and `*.avigilon.com` URLs accepted
|
||||
- **Input Sanitization**: Batch file inputs sanitized to prevent command injection
|
||||
- **Size Limits**: 64KB body limit on cookie server, type/length validation on all inputs
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
├── main.js # Main process (IPC, API calls, proxy spawning, cookie server)
|
||||
├── renderer.js # Renderer process (UI logic, state management)
|
||||
├── preload.js # Secure IPC bridge (contextBridge)
|
||||
├── index.html # Static HTML shell (CSP enforced)
|
||||
├── styles.css # Dark theme styling
|
||||
├── package.json # Dependencies and build config
|
||||
├── chrome-extension/ # Chrome extension for cookie import
|
||||
│ ├── manifest.json # Manifest V3
|
||||
│ ├── popup.html # Extension popup UI
|
||||
│ ├── popup.css # Dark theme styling
|
||||
│ ├── popup.js # Tab detection, cookie retrieval
|
||||
│ └── icon*.png # Extension icons
|
||||
├── assets/
|
||||
│ └── icon.png # Application icon
|
||||
├── CLAUDE.md # Claude Code project instructions
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
**External executable** (not included in repo):
|
||||
- `aware-cam-proxy.exe` — cookie-based auth proxy (required, place in app root)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
- Ensure you are **logged into Alta in Chrome** before clicking the extension
|
||||
- Verify the extension shows "Detected: [hostname]" in green
|
||||
- If extension shows "Alta Proxy Tool is not running" — start the Electron app first
|
||||
- If "Session cookie has expired" — log into Alta again in Chrome
|
||||
- Check that the app console shows "Cookie server listening on http://127.0.0.1:18247"
|
||||
|
||||
### Camera Proxy Issues
|
||||
- **Executable not found**: Ensure `aware-cam-proxy.exe` is in the application directory
|
||||
- **Proxy won't start**: Check that you're connected and have selected a device
|
||||
- **Command window closes immediately**: Check network connectivity to the deployment
|
||||
|
||||
### Device List Issues
|
||||
- Ensure you're connected via the Chrome extension first
|
||||
- Check that your user account has permissions to view devices
|
||||
- **No devices shown**: You may only have cloud cameras which are filtered out
|
||||
- Use the search box to find specific devices
|
||||
|
||||
## Building for Distribution
|
||||
## Development and verification
|
||||
|
||||
```bash
|
||||
npm test # Node test suite, including synthetic runtime contracts
|
||||
npm run check # Syntax checks plus tests
|
||||
npm run build-test
|
||||
```
|
||||
|
||||
Tests use synthetic sessions/transports/processes only. Never add a real Alta tenant URL or session value to fixtures, logs, screenshots, or commits.
|
||||
|
||||
Core files:
|
||||
|
||||
- `main.js` — Electron lifecycle, trusted-sender IPC, fixed loopback server
|
||||
- `src/electron-runtime.js` — pairing persistence, bridge handler, narrow runtime orchestration
|
||||
- `src/session-store.js`, `src/alta-client.js` — main-only Alta session and requests
|
||||
- `src/proxy-launch.js` — fixed shell-free helper process management
|
||||
- `src/update-policy.js` — exact GitPeji check-only release policy
|
||||
- `preload.js` — narrow context bridge
|
||||
- `renderer.js`, `index.html`, `styles.css` — non-secret UI
|
||||
- `chrome-extension/` — stable-ID paired cookie sender
|
||||
- `test/` — pure and end-to-end contract tests
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Build portable Windows executable
|
||||
npm run build
|
||||
|
||||
# Output: dist/AltaCameraProxy-1.0.0-portable.exe
|
||||
```
|
||||
|
||||
**Important**: Copy `aware-cam-proxy.exe` to the same directory as the built executable before distribution.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Windows Only**: Camera proxy executable is Windows-specific
|
||||
- **Chrome Required**: Authentication requires the Chrome extension
|
||||
- **Local Cameras Only**: Automatically filters out cloud-based cameras
|
||||
- **No Session Refresh**: Sessions may expire and require re-import from Chrome
|
||||
- **Executable Required**: `aware-cam-proxy.exe` must be obtained separately
|
||||
- **Update Requires Write Access**: Self-update needs write permission to the directory containing the `.exe`
|
||||
|
||||
## Development
|
||||
|
||||
To modify or extend this application:
|
||||
|
||||
1. **Main Process** ([main.js](main.js)): App lifecycle, API requests, proxy spawning, cookie server
|
||||
2. **Renderer Process** ([renderer.js](renderer.js)): UI interactions and state management
|
||||
3. **Preload Script** ([preload.js](preload.js)): Secure IPC bridge with context isolation
|
||||
4. **Chrome Extension** ([chrome-extension/](chrome-extension/)): Cookie import from browser
|
||||
5. **Styling** ([styles.css](styles.css)): Dark mode theme and responsive design
|
||||
|
||||
### Adding New IPC Endpoints
|
||||
|
||||
1. Add handler in [main.js](main.js) using `ipcMain.handle()`
|
||||
2. Expose method in [preload.js](preload.js) via `contextBridge.exposeInMainWorld()`
|
||||
3. Call from [renderer.js](renderer.js) using `window.electronAPI.yourMethod()`
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Feel free to modify and distribute as needed.
|
||||
The output remains Windows-only because the external camera helper is Windows-specific. Copy `aware-cam-proxy.exe` beside the portable APT executable before use. Dependencies are intentionally unchanged in this hardening release.
|
||||
|
||||
@@ -83,7 +83,7 @@ function createPopupController({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-APT-Pairing-Secret': pairingSecret
|
||||
'X-APT-Pairing': pairingSecret
|
||||
},
|
||||
body: JSON.stringify({ deploymentUrl: detectedOrigin, cookieValue })
|
||||
});
|
||||
|
||||
+6
-5
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- GitHub Pages rebuild marker: 2026-05-22 -->
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Alta Proxy Tool</title>
|
||||
@@ -303,7 +303,7 @@
|
||||
<img src="icon.png" alt="Alta Proxy Tool">
|
||||
<span>Alta Proxy Tool</span>
|
||||
</div>
|
||||
<a href="https://github.com/PageZ948/Alta-Proxy-Tool" class="header-link">GitHub</a>
|
||||
<a href="https://git.pejicorp.com/peji/Alta-Proxy-Tool" class="header-link">GitPeji</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
<div class="hero">
|
||||
<img src="icon.png" alt="" class="hero-icon">
|
||||
<h1>Alta Proxy Tool</h1>
|
||||
<a href="https://github.com/PageZ948/Alta-Proxy-Tool/releases/latest/download/AltaProxyToolKit.zip" class="btn-download">Download Kit for Windows</a>
|
||||
<a href="https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases" class="btn-download">View Windows Releases</a>
|
||||
</div>
|
||||
|
||||
<section class="setup">
|
||||
@@ -321,8 +321,9 @@
|
||||
<ol class="setup-steps">
|
||||
<li>Extract the zip to any folder</li>
|
||||
<li>Load <code>chrome-extension/</code> in Chrome via <code>chrome://extensions</code> (Developer mode)</li>
|
||||
<li>Start APT and paste its one-time pairing secret into the extension settings</li>
|
||||
<li>Log into your Alta deployment in Chrome</li>
|
||||
<li>Click the extension icon and send cookies to the app</li>
|
||||
<li>Click the extension icon and securely send the session to the app</li>
|
||||
<li>Run <strong>AltaCameraProxy.exe</strong> and start proxying</li>
|
||||
</ol>
|
||||
<p class="setup-note">Requires Windows 10+, Google Chrome, and an Avigilon Alta account.</p>
|
||||
@@ -333,7 +334,7 @@
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<a href="https://github.com/PageZ948/Alta-Proxy-Tool">Alta Proxy Tool on GitHub</a>
|
||||
<a href="https://git.pejicorp.com/peji/Alta-Proxy-Tool">Alta Proxy Tool on GitPeji</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
+33
-41
@@ -3,45 +3,35 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
|
||||
<title>Alta Video Camera Proxy with API</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none';">
|
||||
<title>Alta Video Camera Proxy</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Main Content Layout -->
|
||||
<div class="main-layout">
|
||||
<!-- Left Sidebar - Available Devices -->
|
||||
<aside class="devices-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Available Devices</h2>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-header"><h2>Available Devices</h2></div>
|
||||
<div id="deviceStatus" class="status-message"></div>
|
||||
|
||||
<!-- Device Search -->
|
||||
<div class="device-search-container">
|
||||
<input type="text" id="deviceSearch" placeholder="Search devices..." class="device-search-input">
|
||||
<input type="text" id="deviceSearch" placeholder="Search devices..." class="device-search-input" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="device-list-container">
|
||||
<div id="deviceList" class="device-list">
|
||||
<p class="placeholder-text">Connect to API to load devices</p>
|
||||
<p class="placeholder-text">Connect through the paired Chrome extension to load devices</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content">
|
||||
<div class="content-header">
|
||||
<h1>Alta Video Camera Proxy</h1>
|
||||
<button type="button" id="checkUpdateBtn" class="btn-update" title="Check for Updates">
|
||||
<button type="button" id="checkUpdateBtn" class="btn-update" title="Securely check GitPeji for a newer release">
|
||||
<span class="update-icon">↻</span>
|
||||
<span class="update-text">Check for Updates</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- API Connection Section -->
|
||||
<section class="content-section">
|
||||
<h2>API Connection</h2>
|
||||
<div class="connection-status">
|
||||
@@ -59,21 +49,34 @@
|
||||
<div id="connectionStatus" class="status-message"></div>
|
||||
</section>
|
||||
|
||||
<!-- Cookie-Based Camera Proxy Section -->
|
||||
<section class="content-section pairing-section">
|
||||
<h2>Bridge Pairing</h2>
|
||||
<p class="section-help">Only the committed APT Chrome extension can send a session. Pair it with a one-time secret.</p>
|
||||
<div class="status-row">
|
||||
<label>Pairing:</label>
|
||||
<span id="pairingState" class="unpaired">Checking...</span>
|
||||
</div>
|
||||
<div id="pairingSecretRow" class="pairing-secret-row" style="display: none;">
|
||||
<label for="pairingSecret">One-time secret:</label>
|
||||
<input type="text" id="pairingSecret" readonly autocomplete="off" spellcheck="false">
|
||||
<small>Paste this into the extension pairing settings now. APT will not show it again.</small>
|
||||
</div>
|
||||
<div class="proxy-buttons">
|
||||
<button type="button" id="rotatePairingBtn" class="btn-primary">Generate / Rotate</button>
|
||||
<button type="button" id="revokePairingBtn" class="btn-outline">Revoke</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-section">
|
||||
<h2>Camera Proxy</h2>
|
||||
<div class="proxy-controls">
|
||||
<div class="input-row">
|
||||
<label for="cookieDeviceUUID">Device UUID:</label>
|
||||
<input type="text" id="cookieDeviceUUID" placeholder="(Auto-filled when you select a device from the list)" readonly>
|
||||
</div>
|
||||
<div class="input-row" style="display: none;">
|
||||
<label for="cookieKey">Cookie Key:</label>
|
||||
<input type="text" id="cookieKey" placeholder="Paste your cookie key here">
|
||||
<label for="selectedDeviceId">Device UUID:</label>
|
||||
<input type="text" id="selectedDeviceId" placeholder="Select a device from the list" readonly>
|
||||
</div>
|
||||
<div class="proxy-buttons">
|
||||
<button type="button" id="startCookieProxyBtn" class="btn-primary" disabled>Start Proxy</button>
|
||||
<button type="button" id="stopCookieProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
|
||||
<button type="button" id="startProxyBtn" class="btn-primary" disabled>Start Proxy</button>
|
||||
<button type="button" id="stopProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -81,26 +84,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Modal -->
|
||||
<div id="updateModalOverlay" class="update-modal-overlay" style="display: none;">
|
||||
<div id="updateNotice" class="update-modal-overlay" style="display: none;">
|
||||
<div class="update-modal-card">
|
||||
<div class="update-modal-header">
|
||||
<h3>Update Available</h3>
|
||||
<button type="button" id="updateModalCloseBtn" class="update-modal-close">×</button>
|
||||
</div>
|
||||
<div class="update-modal-header"><h3>Update Available</h3></div>
|
||||
<div class="update-modal-body">
|
||||
<p id="updateModalMessage" class="update-modal-message"></p>
|
||||
<div id="updateModalNotes" class="update-modal-notes"></div>
|
||||
<div id="updateProgressContainer" class="update-progress-container" style="display: none;">
|
||||
<div class="update-progress-track">
|
||||
<div id="updateProgressFill" class="update-progress-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<span id="updateProgressText" class="update-progress-text">0%</span>
|
||||
</div>
|
||||
<p id="updateMessage" class="update-modal-message"></p>
|
||||
</div>
|
||||
<div class="update-modal-footer">
|
||||
<button type="button" id="updateInstallBtn" class="btn-primary">Install Update</button>
|
||||
<button type="button" id="updateLaterBtn" class="btn-outline">Later</button>
|
||||
<button type="button" id="openReleasesBtn" class="btn-primary">Open GitPeji Releases</button>
|
||||
<button type="button" id="dismissUpdateBtn" class="btn-outline">Later</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,631 +1,170 @@
|
||||
'use strict';
|
||||
|
||||
const { app, BrowserWindow, ipcMain, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const http = require('node:http');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { createSessionStore } = require('./src/session-store');
|
||||
const { createAltaClient } = require('./src/alta-client');
|
||||
const { createProxyManager } = require('./src/proxy-launch');
|
||||
const { checkForUpdate } = require('./src/update-policy');
|
||||
const {
|
||||
AppRuntime,
|
||||
PAIRING_ENVELOPE_FILENAME,
|
||||
PairingController,
|
||||
createBridgeHandler,
|
||||
} = require('./src/electron-runtime');
|
||||
|
||||
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';
|
||||
const BRIDGE_HOST = '127.0.0.1';
|
||||
const BRIDGE_PORT = 18247;
|
||||
|
||||
let mainWindow = null;
|
||||
let bridgeServer = null;
|
||||
let runtime = null;
|
||||
let pairingController = null;
|
||||
let firstRunPairingSecret = null;
|
||||
|
||||
// Get the directory where the user-facing executable resides.
|
||||
// In portable builds, __dirname points to a temp extraction directory,
|
||||
// so we use the actual .exe location instead.
|
||||
function getAppDirectory() {
|
||||
if (app.isPackaged) {
|
||||
return path.dirname(process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'));
|
||||
return app.isPackaged
|
||||
? path.dirname(process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'))
|
||||
: __dirname;
|
||||
}
|
||||
|
||||
function sendConnectionState() {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && runtime) {
|
||||
mainWindow.webContents.send('connection-state-changed', runtime.getConnectionState());
|
||||
}
|
||||
return __dirname;
|
||||
}
|
||||
|
||||
// Sanitize strings before embedding in batch files to prevent command injection
|
||||
function sanitizeBatchInput(input) {
|
||||
if (typeof input !== 'string') return '';
|
||||
// Remove characters that have special meaning in batch/cmd: & | < > ^ % " ` !
|
||||
return input.replace(/[&|<>^%"`!]/g, '');
|
||||
}
|
||||
|
||||
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,
|
||||
height: 900,
|
||||
icon: path.join(__dirname, 'assets', 'icon.png'),
|
||||
title: 'Alta Video Camera Proxy',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
sandbox: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
icon: path.join(__dirname, 'assets', 'icon.png'), // Optional icon
|
||||
title: 'Alta Video Camera Proxy with API'
|
||||
});
|
||||
|
||||
mainWindow.loadFile('index.html');
|
||||
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||
mainWindow.webContents.on('will-navigate', (event) => event.preventDefault());
|
||||
mainWindow.loadFile(path.join(__dirname, 'index.html'));
|
||||
if (process.argv.includes('--dev')) mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// Open DevTools in development
|
||||
if (process.argv.includes('--dev')) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
function isTrustedSender(event) {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !event) return false;
|
||||
const webContents = mainWindow.webContents;
|
||||
const frame = event.senderFrame;
|
||||
const expectedUrl = pathToFileURL(path.join(__dirname, 'index.html')).href;
|
||||
return event.sender === webContents &&
|
||||
frame === webContents.mainFrame &&
|
||||
frame.url === expectedUrl;
|
||||
}
|
||||
|
||||
function registerIpc(channel, handler) {
|
||||
ipcMain.handle(channel, async (event, ...args) => {
|
||||
if (!isTrustedSender(event)) throw new Error('Forbidden IPC sender');
|
||||
return handler(...args);
|
||||
});
|
||||
}
|
||||
|
||||
function registerIpcHandlers() {
|
||||
registerIpc('get-devices', () => runtime.getDevices());
|
||||
registerIpc('get-device-sites', () => runtime.getDeviceSites());
|
||||
registerIpc('get-auth-info', () => runtime.getAuthInfo());
|
||||
registerIpc('launch-proxy', (deviceId) => runtime.launchProxy(deviceId));
|
||||
registerIpc('stop-proxy', async (key) => {
|
||||
const result = await runtime.stopProxy(key);
|
||||
sendConnectionState();
|
||||
return result;
|
||||
});
|
||||
registerIpc('disconnect', () => {
|
||||
const state = runtime.disconnect();
|
||||
sendConnectionState();
|
||||
return state;
|
||||
});
|
||||
registerIpc('get-connection-state', () => runtime.getConnectionState());
|
||||
registerIpc('check-for-updates', () => runtime.checkForUpdates());
|
||||
registerIpc('open-fixed-releases-page', () => runtime.openFixedReleasesPage());
|
||||
registerIpc('rotate-pairing', () => {
|
||||
firstRunPairingSecret = null;
|
||||
return pairingController.rotate();
|
||||
});
|
||||
registerIpc('revoke-pairing', () => {
|
||||
firstRunPairingSecret = null;
|
||||
return pairingController.revoke();
|
||||
});
|
||||
registerIpc('get-pairing-status', () => {
|
||||
const result = pairingController.getStatus();
|
||||
if (firstRunPairingSecret) {
|
||||
result.secret = firstRunPairingSecret;
|
||||
firstRunPairingSecret = null;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function startBridgeServer() {
|
||||
const handler = createBridgeHandler({
|
||||
bridgeAuth: pairingController.bridgeAuth,
|
||||
sessionStore: runtime.sessionStore,
|
||||
onConnectionStateChanged: sendConnectionState,
|
||||
});
|
||||
bridgeServer = http.createServer((request, response) => {
|
||||
handler(request, response).catch(() => {
|
||||
if (!response.writableEnded) {
|
||||
response.writeHead(500, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
response.end(JSON.stringify({ success: false, message: 'Bridge request failed' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
bridgeServer.on('clientError', (_error, socket) => socket.destroy());
|
||||
bridgeServer.on('error', (error) => {
|
||||
console.error(`Bridge server unavailable (${error.code || 'UNKNOWN'}).`);
|
||||
});
|
||||
bridgeServer.listen(BRIDGE_PORT, BRIDGE_HOST);
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const sessionStore = createSessionStore();
|
||||
const altaClient = createAltaClient({ sessionStore });
|
||||
const proxyManager = createProxyManager({ appDirectory: getAppDirectory() });
|
||||
pairingController = new PairingController({
|
||||
envelopePath: path.join(app.getPath('userData'), PAIRING_ENVELOPE_FILENAME),
|
||||
});
|
||||
const pairingState = pairingController.initialize();
|
||||
firstRunPairingSecret = pairingState.secret || null;
|
||||
runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient,
|
||||
proxyManager,
|
||||
checkForUpdate,
|
||||
currentVersion: app.getVersion(),
|
||||
openExternal: (url) => shell.openExternal(url),
|
||||
});
|
||||
|
||||
registerIpcHandlers();
|
||||
createWindow();
|
||||
startCookieServer();
|
||||
startBridgeServer();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (cookieServer) {
|
||||
cookieServer.close();
|
||||
if (bridgeServer) bridgeServer.close();
|
||||
if (runtime) {
|
||||
for (const proxy of runtime.getConnectionState().activeProxies) runtime.stopProxy(proxy.processId);
|
||||
runtime.sessionStore.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
if (BrowserWindow.getAllWindows().length === 0 && runtime) createWindow();
|
||||
});
|
||||
|
||||
// IPC handlers for API communication
|
||||
ipcMain.handle('api-get-devices', async (event, { deploymentUrl, cookies }) => {
|
||||
try {
|
||||
const devicesUrl = `${deploymentUrl}/api/v1/devices`;
|
||||
|
||||
// Create axios instance with cookies
|
||||
const axiosInstance = axios.create({
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Cookie': cookies ? cookies.join('; ') : ''
|
||||
}
|
||||
});
|
||||
|
||||
const response = await axiosInstance.get(devicesUrl);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
devices: response.data,
|
||||
message: `Found ${response.data.length} devices`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get devices error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || 'Failed to get devices'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('api-get-device-sites', async (event, { deploymentUrl, cookies }) => {
|
||||
try {
|
||||
const sitesUrl = `${deploymentUrl}/api/v1/deviceSites`;
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Cookie': cookies ? cookies.join('; ') : ''
|
||||
}
|
||||
});
|
||||
|
||||
const response = await axiosInstance.get(sitesUrl);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sites: response.data
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get device sites error:', error);
|
||||
return {
|
||||
success: false,
|
||||
sites: [],
|
||||
message: error.response?.data?.message || error.message || 'Failed to get device sites'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('api-get-auth-info', async (event, { deploymentUrl, cookies }) => {
|
||||
try {
|
||||
const authUrl = `${deploymentUrl}/api/v1/auth`;
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Cookie': cookies ? cookies.join('; ') : ''
|
||||
}
|
||||
});
|
||||
|
||||
const response = await axiosInstance.get(authUrl);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
authInfo: response.data
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get auth info error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || 'Failed to get auth info'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Cookie-based camera proxy functionality
|
||||
ipcMain.handle('camera-proxy-cookie-launch', async (event, { deploymentUrl, cookieKey, deviceUuid }) => {
|
||||
try {
|
||||
// Path to the cookie-based camera proxy executable
|
||||
const proxyExePath = path.join(getAppDirectory(), 'aware-cam-proxy.exe');
|
||||
|
||||
// Check if the executable exists
|
||||
if (!fs.existsSync(proxyExePath)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cookie-based camera proxy executable not found. Please ensure aware-cam-proxy.exe is in the application directory.'
|
||||
};
|
||||
}
|
||||
|
||||
// Extract the domain from the deployment URL
|
||||
let domain = deploymentUrl;
|
||||
if (domain.startsWith('https://')) {
|
||||
domain = domain.substring(8);
|
||||
} else if (domain.startsWith('http://')) {
|
||||
domain = domain.substring(7);
|
||||
}
|
||||
// Remove trailing path segments
|
||||
domain = domain.split('/')[0];
|
||||
|
||||
// Sanitize all inputs before embedding in batch file
|
||||
const safeDomain = sanitizeBatchInput(domain);
|
||||
const safeDeviceUuid = sanitizeBatchInput(deviceUuid);
|
||||
const safeCookieKey = sanitizeBatchInput(cookieKey);
|
||||
|
||||
if (!safeDomain || !safeDeviceUuid || !safeCookieKey) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Invalid characters detected in connection parameters.'
|
||||
};
|
||||
}
|
||||
|
||||
// Create a batch file to launch the cookie-based camera proxy
|
||||
const truncatedKey = safeCookieKey.length > 20 ? safeCookieKey.substring(0, 20) + '...' : safeCookieKey;
|
||||
const batchContent = `@echo off
|
||||
title APT-Proxy-${safeDeviceUuid}
|
||||
echo Launching Alta Video Camera Proxy (Cookie Method)...
|
||||
echo Domain: ${safeDomain}
|
||||
echo Device UUID: ${safeDeviceUuid}
|
||||
echo Cookie Key: ${truncatedKey}
|
||||
echo.
|
||||
"${proxyExePath}" -a "${safeDomain}" -d "${safeDeviceUuid}" -k "${safeCookieKey}"
|
||||
echo.
|
||||
echo Cookie-based camera proxy has finished. Press any key to close this window.
|
||||
pause >nul`;
|
||||
|
||||
const tempDir = os.tmpdir();
|
||||
const batchPath = path.join(tempDir, `cookie-proxy-${Date.now()}.bat`);
|
||||
|
||||
// Write the batch file
|
||||
fs.writeFileSync(batchPath, batchContent);
|
||||
|
||||
console.log('Launching cookie-based camera proxy via batch file:', batchPath);
|
||||
console.log('Command will be: aware-cam-proxy.exe -a', safeDomain, '-d', safeDeviceUuid, '-k [REDACTED]');
|
||||
|
||||
// Launch the batch file in a new command prompt window with unique title
|
||||
const windowTitle = `APT-Proxy-${safeDeviceUuid}`;
|
||||
const cmdProcess = spawn('cmd', ['/c', 'start', `"${windowTitle}"`, 'cmd', '/c', batchPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
|
||||
// Store the process information for later termination
|
||||
const processInfo = {
|
||||
process: cmdProcess,
|
||||
batchPath: batchPath,
|
||||
deviceUuid: safeDeviceUuid,
|
||||
startTime: Date.now(),
|
||||
cookieKey: truncatedKey,
|
||||
domain: safeDomain,
|
||||
windowTitle: windowTitle, // Store window title for targeted cleanup
|
||||
type: 'cookie' // Mark as cookie-based proxy
|
||||
};
|
||||
|
||||
activeProxyProcesses.set(cmdProcess.pid, processInfo);
|
||||
|
||||
// Clean up the batch file after a delay
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (fs.existsSync(batchPath)) {
|
||||
fs.unlinkSync(batchPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not clean up cookie proxy batch file:', error.message);
|
||||
}
|
||||
}, 60000); // Clean up after 1 minute
|
||||
|
||||
cmdProcess.unref(); // Allow the parent process to exit independently
|
||||
|
||||
// Clean up process tracking when it exits
|
||||
cmdProcess.on('exit', () => {
|
||||
activeProxyProcesses.delete(cmdProcess.pid);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Cookie-based camera proxy launched for ${deviceUuid}!`,
|
||||
processId: cmdProcess.pid,
|
||||
deviceUuid: deviceUuid,
|
||||
type: 'cookie'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to launch cookie-based camera proxy:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to launch cookie-based camera proxy: ${error.message}`
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Stop camera proxy functionality
|
||||
ipcMain.handle('camera-proxy-stop', async (event, { processId }) => {
|
||||
try {
|
||||
console.log('Attempting to stop camera proxy processes...');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Kill all aware-cam-proxy.exe processes by name
|
||||
const killProxy = spawn('taskkill', ['/f', '/im', 'aware-cam-proxy.exe'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let proxyOutput = '';
|
||||
let proxyError = '';
|
||||
|
||||
killProxy.stdout.on('data', (data) => {
|
||||
proxyOutput += data.toString();
|
||||
});
|
||||
|
||||
killProxy.stderr.on('data', (data) => {
|
||||
proxyError += data.toString();
|
||||
});
|
||||
|
||||
killProxy.on('close', (code) => {
|
||||
// Clean up our process tracking
|
||||
activeProxyProcesses.clear();
|
||||
|
||||
if (code === 0 || proxyOutput.includes('SUCCESS')) {
|
||||
console.log('Camera proxy processes terminated successfully');
|
||||
resolve({
|
||||
success: true,
|
||||
message: 'Camera proxy processes stopped successfully'
|
||||
});
|
||||
} else if (proxyError.includes('not found') || proxyError.includes('No tasks')) {
|
||||
console.log('No camera proxy processes were running');
|
||||
resolve({
|
||||
success: true,
|
||||
message: 'No camera proxy processes were running'
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: true,
|
||||
message: 'Attempted to stop all camera proxy processes'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
killProxy.on('error', (error) => {
|
||||
console.error('Error with taskkill by name:', error);
|
||||
activeProxyProcesses.clear();
|
||||
resolve({
|
||||
success: false,
|
||||
message: `Failed to stop camera proxy: ${error.message}`
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to stop camera proxy:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to stop camera proxy: ${error.message}`
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// --- Self-Update Functionality ---
|
||||
|
||||
// Compare semver versions: returns -1 if a < b, 0 if equal, 1 if a > b
|
||||
function compareVersions(a, b) {
|
||||
// Strip pre-release tags (e.g. "1.2.3-beta.1" → "1.2.3")
|
||||
const cleanA = a.replace(/-.*$/, '');
|
||||
const cleanB = b.replace(/-.*$/, '');
|
||||
const partsA = cleanA.split('.').map(Number);
|
||||
const partsB = cleanB.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const numA = partsA[i] || 0;
|
||||
const numB = partsB[i] || 0;
|
||||
if (numA < numB) return -1;
|
||||
if (numA > numB) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Follow HTTPS redirects and return the final response (for GitHub asset downloads)
|
||||
function httpsGetFollowRedirects(url, callback, redirectCount = 0) {
|
||||
if (redirectCount >= 5) {
|
||||
return callback(null, new Error('Too many redirects'));
|
||||
}
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return callback(null, new Error('Only HTTPS URLs are allowed'));
|
||||
}
|
||||
https.get(url, { headers: { 'User-Agent': 'Alta-Proxy-Tool' } }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const redirectUrl = new URL(res.headers.location, url).href;
|
||||
httpsGetFollowRedirects(redirectUrl, callback, redirectCount + 1);
|
||||
} else {
|
||||
callback(res);
|
||||
}
|
||||
}).on('error', (err) => {
|
||||
callback(null, err);
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle('get-current-version', async () => {
|
||||
return { success: true, version: app.getVersion() };
|
||||
});
|
||||
|
||||
ipcMain.handle('check-for-updates', async () => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/PageZ948/Alta-Proxy-Tool/releases/latest',
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: { 'User-Agent': 'Alta-Proxy-Tool', 'Accept': 'application/vnd.github.v3+json' }
|
||||
}
|
||||
);
|
||||
|
||||
const release = response.data;
|
||||
const latestVersion = release.tag_name.replace(/^v/, '');
|
||||
const currentVersion = app.getVersion();
|
||||
|
||||
const updateAvailable = compareVersions(currentVersion, latestVersion) < 0;
|
||||
|
||||
// Find the portable .exe asset
|
||||
const exeAsset = release.assets.find(a => /AltaCameraProxy-.*-portable\.exe$/i.test(a.name));
|
||||
const downloadUrl = exeAsset ? exeAsset.browser_download_url : null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updateAvailable,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
downloadUrl,
|
||||
releaseNotes: release.body || '',
|
||||
releaseName: release.name || `v${latestVersion}`
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 404) {
|
||||
return { success: true, updateAvailable: false, currentVersion: app.getVersion(), message: 'No releases available yet' };
|
||||
}
|
||||
if (error.response && error.response.status === 403) {
|
||||
return { success: false, message: 'GitHub API rate limit exceeded. Try again later.' };
|
||||
}
|
||||
console.error('Check for updates error:', error.message);
|
||||
return { success: false, message: error.message || 'Failed to check for updates' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('download-and-install-update', async (event, { downloadUrl }) => {
|
||||
try {
|
||||
// Determine the path to the currently running executable
|
||||
const currentExePath = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe');
|
||||
const currentDir = getAppDirectory();
|
||||
const currentExeName = path.basename(currentExePath);
|
||||
|
||||
// Check write permission on the app directory
|
||||
try {
|
||||
fs.accessSync(currentDir, fs.constants.W_OK);
|
||||
} catch {
|
||||
return { success: false, message: 'No write permission to the application directory. Try running as administrator.' };
|
||||
}
|
||||
|
||||
const tempDir = os.tmpdir();
|
||||
const tempExePath = path.join(tempDir, `AltaCameraProxy-update-${Date.now()}.exe`);
|
||||
|
||||
// Download the file with progress reporting
|
||||
await new Promise((resolve, reject) => {
|
||||
httpsGetFollowRedirects(downloadUrl, (res, err) => {
|
||||
if (err) return reject(err);
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
return reject(new Error(`Download failed with status ${res.statusCode}`));
|
||||
}
|
||||
|
||||
const totalSize = parseInt(res.headers['content-length'], 10) || 0;
|
||||
let downloadedSize = 0;
|
||||
const fileStream = fs.createWriteStream(tempExePath);
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
if (totalSize > 0 && mainWindow && !mainWindow.isDestroyed()) {
|
||||
const percent = Math.round((downloadedSize / totalSize) * 100);
|
||||
mainWindow.webContents.send('update-download-progress', { percent, downloadedSize, totalSize });
|
||||
}
|
||||
});
|
||||
|
||||
res.pipe(fileStream);
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
fileStream.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
fileStream.on('error', (err) => {
|
||||
fs.unlink(tempExePath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Verify downloaded file size (sanity check: > 10MB for an Electron portable exe)
|
||||
const stats = fs.statSync(tempExePath);
|
||||
if (stats.size < 10 * 1024 * 1024) {
|
||||
fs.unlinkSync(tempExePath);
|
||||
return { success: false, message: 'Downloaded file is too small — update may be corrupt.' };
|
||||
}
|
||||
|
||||
// Create batch script to replace the exe after this process exits
|
||||
const batchPath = path.join(tempDir, `apt-update-${Date.now()}.bat`);
|
||||
const pid = process.pid;
|
||||
const batchContent = `@echo off\r\ntitle APT-Updater\r\necho Waiting for Alta Proxy Tool to close...\r\n:waitloop\r\ntasklist /fi "PID eq ${pid}" 2>nul | find "${pid}" >nul\r\nif not errorlevel 1 (\r\n timeout /t 1 /nobreak >nul\r\n goto waitloop\r\n)\r\necho Applying update...\r\ncopy /y "${tempExePath}" "${path.join(currentDir, currentExeName)}"\r\nif errorlevel 1 (\r\n echo Update failed! Could not copy new version.\r\n pause\r\n del "${tempExePath}" >nul 2>&1\r\n del "%~f0" >nul 2>&1\r\n exit /b 1\r\n)\r\necho Update complete. Launching new version...\r\nstart "" "${path.join(currentDir, currentExeName)}"\r\ndel "${tempExePath}" >nul 2>&1\r\ndel "%~f0" >nul 2>&1\r\n`;
|
||||
|
||||
fs.writeFileSync(batchPath, batchContent);
|
||||
|
||||
// Spawn the updater batch script detached
|
||||
let updater;
|
||||
try {
|
||||
updater = spawn('cmd', ['/c', batchPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
});
|
||||
updater.unref();
|
||||
} catch (spawnError) {
|
||||
console.error('Failed to spawn updater:', spawnError);
|
||||
try { fs.unlinkSync(tempExePath); } catch {}
|
||||
try { fs.unlinkSync(batchPath); } catch {}
|
||||
return { success: false, message: 'Failed to start updater process.' };
|
||||
}
|
||||
|
||||
// Quit the app after a delay to let the IPC response return to renderer
|
||||
setTimeout(() => {
|
||||
app.quit();
|
||||
}, 1500);
|
||||
|
||||
return { success: true, message: 'Update is being installed. The app will restart shortly.' };
|
||||
} catch (error) {
|
||||
console.error('Download and install update error:', error);
|
||||
return { success: false, message: error.message || 'Failed to download and install update' };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"test": "node --test test/*.test.js",
|
||||
"check": "node --check main.js && node --check preload.js && node --check renderer.js && node --check src/electron-runtime.js && node --check chrome-extension/popup.js && npm test",
|
||||
"build": "electron-builder --win --publish=never",
|
||||
"build-test": "electron-builder --win --dir",
|
||||
"build-kit": "powershell -ExecutionPolicy Bypass -File build-kit.ps1",
|
||||
|
||||
+22
-34
@@ -1,38 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getDevices: (params) => ipcRenderer.invoke('api-get-devices', params),
|
||||
getDeviceSites: (params) => ipcRenderer.invoke('api-get-device-sites', params),
|
||||
getAuthInfo: (params) => ipcRenderer.invoke('api-get-auth-info', params),
|
||||
function onConnectionStateChanged(callback) {
|
||||
if (typeof callback !== 'function') return () => {};
|
||||
const listener = (_event, state) => callback(state);
|
||||
ipcRenderer.on('connection-state-changed', listener);
|
||||
return () => ipcRenderer.removeListener('connection-state-changed', listener);
|
||||
}
|
||||
|
||||
// Camera proxy functionality
|
||||
launchCookieCameraProxy: (params) => ipcRenderer.invoke('camera-proxy-cookie-launch', params),
|
||||
stopCameraProxy: (processId) => ipcRenderer.invoke('camera-proxy-stop', { processId }),
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Self-update functionality
|
||||
contextBridge.exposeInMainWorld('electronAPI', Object.freeze({
|
||||
getDevices: () => ipcRenderer.invoke('get-devices'),
|
||||
getDeviceSites: () => ipcRenderer.invoke('get-device-sites'),
|
||||
getAuthInfo: () => ipcRenderer.invoke('get-auth-info'),
|
||||
launchProxy: (deviceId) => ipcRenderer.invoke('launch-proxy', deviceId),
|
||||
stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key),
|
||||
disconnect: () => ipcRenderer.invoke('disconnect'),
|
||||
getConnectionState: () => ipcRenderer.invoke('get-connection-state'),
|
||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
||||
downloadAndInstallUpdate: (params) => ipcRenderer.invoke('download-and-install-update', params),
|
||||
getCurrentVersion: () => ipcRenderer.invoke('get-current-version'),
|
||||
onUpdateDownloadProgress: (callback) => {
|
||||
ipcRenderer.on('update-download-progress', (event, data) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Update download progress handler error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
openFixedReleasesPage: () => ipcRenderer.invoke('open-fixed-releases-page'),
|
||||
rotatePairing: () => ipcRenderer.invoke('rotate-pairing'),
|
||||
revokePairing: () => ipcRenderer.invoke('revoke-pairing'),
|
||||
getPairingStatus: () => ipcRenderer.invoke('get-pairing-status'),
|
||||
onConnectionStateChanged,
|
||||
}));
|
||||
|
||||
+279
-735
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -133,7 +133,7 @@ class BridgeAuth {
|
||||
authenticateRequest(request) {
|
||||
if (!request || typeof request !== 'object') return false;
|
||||
const headers = request.headers || {};
|
||||
const secret = headers['x-apt-pairing-secret'];
|
||||
const secret = headers['x-apt-pairing'];
|
||||
return this.authenticate({ origin: headers.origin, secret });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
APT_EXTENSION_ORIGIN,
|
||||
BridgeAuth,
|
||||
BridgeAuthError,
|
||||
createRequestLimiter,
|
||||
readJsonBody,
|
||||
} = require('./bridge-auth');
|
||||
const { RELEASES_PAGE_URL } = require('./update-policy');
|
||||
const { validateDeviceId } = require('./proxy-launch');
|
||||
|
||||
const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json';
|
||||
|
||||
function safeErrorMessage(error, fallback) {
|
||||
const allowed = new Set([
|
||||
'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR',
|
||||
'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE',
|
||||
'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND',
|
||||
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'SPAWN_FAILED',
|
||||
]);
|
||||
return error && allowed.has(error.code) && typeof error.message === 'string'
|
||||
? error.message
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function loadPairingEnvelope(envelopePath) {
|
||||
if (!fs.existsSync(envelopePath)) return null;
|
||||
const bytes = fs.readFileSync(envelopePath);
|
||||
if (bytes.length === 0 || bytes.length > 4096) throw new Error('Invalid pairing envelope file');
|
||||
const parsed = JSON.parse(bytes.toString('utf8'));
|
||||
// BridgeAuth performs the authoritative envelope schema validation.
|
||||
return new BridgeAuth({ envelope: parsed }).envelope;
|
||||
}
|
||||
|
||||
function atomicWritePairingEnvelope(envelopePath, envelope) {
|
||||
const directory = path.dirname(envelopePath);
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
const temporaryPath = `${envelopePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
const payload = `${JSON.stringify(envelope)}\n`;
|
||||
try {
|
||||
fs.writeFileSync(temporaryPath, payload, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
fs.renameSync(temporaryPath, envelopePath);
|
||||
fs.chmodSync(envelopePath, 0o600);
|
||||
} catch (error) {
|
||||
try { fs.unlinkSync(temporaryPath); } catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
class PairingController {
|
||||
constructor({ envelopePath, bridgeAuth } = {}) {
|
||||
if (typeof envelopePath !== 'string' || envelopePath.length === 0) {
|
||||
throw new TypeError('PairingController requires an envelope path');
|
||||
}
|
||||
this.envelopePath = envelopePath;
|
||||
if (bridgeAuth) {
|
||||
this.bridgeAuth = bridgeAuth;
|
||||
} else {
|
||||
let envelope = null;
|
||||
try {
|
||||
envelope = loadPairingEnvelope(envelopePath);
|
||||
} catch {
|
||||
try { fs.unlinkSync(envelopePath); } catch {}
|
||||
}
|
||||
this.bridgeAuth = new BridgeAuth({ envelope });
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.bridgeAuth.envelope) return { paired: true };
|
||||
return this.rotate();
|
||||
}
|
||||
|
||||
rotate() {
|
||||
const { secret, envelope } = this.bridgeAuth.rotate();
|
||||
try {
|
||||
atomicWritePairingEnvelope(this.envelopePath, envelope);
|
||||
} catch (error) {
|
||||
this.bridgeAuth.revoke();
|
||||
throw error;
|
||||
}
|
||||
return { paired: true, secret };
|
||||
}
|
||||
|
||||
revoke() {
|
||||
this.bridgeAuth.revoke();
|
||||
try {
|
||||
fs.unlinkSync(this.envelopePath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
return { paired: false };
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return { paired: Boolean(this.bridgeAuth.envelope) };
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(response, statusCode, payload) {
|
||||
if (response.writableEnded) return;
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function createBridgeHandler({
|
||||
bridgeAuth,
|
||||
sessionStore,
|
||||
onConnectionStateChanged = () => {},
|
||||
limiter = createRequestLimiter({ maxConcurrent: 4 }),
|
||||
maxBodyBytes,
|
||||
deadlineMs,
|
||||
} = {}) {
|
||||
if (!bridgeAuth || typeof bridgeAuth.authenticateRequest !== 'function') {
|
||||
throw new TypeError('Bridge handler requires bridge authentication');
|
||||
}
|
||||
if (!sessionStore || typeof sessionStore.establish !== 'function') {
|
||||
throw new TypeError('Bridge handler requires a session store');
|
||||
}
|
||||
|
||||
return async function handleBridgeRequest(request, response) {
|
||||
const origin = request && request.headers && request.headers.origin;
|
||||
if (origin !== APT_EXTENSION_ORIGIN) {
|
||||
writeJson(response, 403, { success: false, message: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
response.setHeader('Access-Control-Allow-Origin', APT_EXTENSION_ORIGIN);
|
||||
response.setHeader('Vary', 'Origin');
|
||||
response.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-APT-Pairing');
|
||||
response.setHeader('Access-Control-Max-Age', '600');
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(204, { 'Cache-Control': 'no-store' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'POST' || request.url !== '/cookie') {
|
||||
writeJson(response, 404, { success: false, message: 'Not found' });
|
||||
return;
|
||||
}
|
||||
if (!bridgeAuth.authenticateRequest(request)) {
|
||||
writeJson(response, 403, { success: false, message: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await limiter.run(async () => {
|
||||
const data = await readJsonBody(request, { maxBytes: maxBodyBytes, deadlineMs });
|
||||
const state = sessionStore.establish(data.deploymentUrl, data.cookieValue);
|
||||
onConnectionStateChanged({ connected: state.connected, origin: state.origin });
|
||||
});
|
||||
writeJson(response, 200, { success: true, message: 'Session received' });
|
||||
} catch (error) {
|
||||
const statusCode = error instanceof BridgeAuthError
|
||||
? error.statusCode
|
||||
: error && error.code === 'INVALID_SESSION_COOKIE'
|
||||
? 400
|
||||
: 400;
|
||||
writeJson(response, statusCode, { success: false, message: statusCode === 429 ? 'Too many requests' : 'Invalid request' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class AppRuntime {
|
||||
constructor({
|
||||
sessionStore,
|
||||
altaClient,
|
||||
proxyManager,
|
||||
checkForUpdate,
|
||||
currentVersion,
|
||||
openExternal,
|
||||
} = {}) {
|
||||
this.sessionStore = sessionStore;
|
||||
this.altaClient = altaClient;
|
||||
this.proxyManager = proxyManager;
|
||||
this.checkForUpdatePolicy = checkForUpdate;
|
||||
this.currentVersion = currentVersion;
|
||||
this.openExternal = openExternal;
|
||||
this.allowedDeviceIds = new Set();
|
||||
this.proxyByDevice = new Map();
|
||||
}
|
||||
|
||||
async getDevices() {
|
||||
try {
|
||||
const devices = await this.altaClient.getDevices();
|
||||
this.allowedDeviceIds = new Set();
|
||||
for (const device of devices) {
|
||||
const candidate = device && (device.guid || device.id);
|
||||
try { this.allowedDeviceIds.add(validateDeviceId(candidate)); } catch {}
|
||||
}
|
||||
return { success: true, devices };
|
||||
} catch (error) {
|
||||
return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') };
|
||||
}
|
||||
}
|
||||
|
||||
async getDeviceSites() {
|
||||
try {
|
||||
return { success: true, sites: await this.altaClient.getDeviceSites() };
|
||||
} catch (error) {
|
||||
return { success: false, sites: [], message: safeErrorMessage(error, 'Failed to get device sites') };
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthInfo() {
|
||||
try {
|
||||
return { success: true, authInfo: await this.altaClient.getAuthInfo() };
|
||||
} catch (error) {
|
||||
return { success: false, message: safeErrorMessage(error, 'Failed to get authentication information') };
|
||||
}
|
||||
}
|
||||
|
||||
async launchProxy(deviceId) {
|
||||
try {
|
||||
const validatedId = validateDeviceId(deviceId);
|
||||
if (!this.allowedDeviceIds.has(validatedId)) {
|
||||
return { success: false, message: 'Select a device from the current Alta device list.' };
|
||||
}
|
||||
if (this.proxyByDevice.has(validatedId)) {
|
||||
return { success: false, message: 'A proxy is already running for this device.' };
|
||||
}
|
||||
const session = this.sessionStore.requireSession();
|
||||
const result = this.proxyManager.launchProxy({
|
||||
deploymentHost: new URL(session.origin).hostname,
|
||||
cookie: session.cookie,
|
||||
deviceId: validatedId,
|
||||
});
|
||||
this.proxyByDevice.set(validatedId, result.processId);
|
||||
return { success: true, processId: result.processId, deviceId: validatedId, status: result.status };
|
||||
} catch (error) {
|
||||
return { success: false, message: safeErrorMessage(error, 'Failed to launch proxy') };
|
||||
}
|
||||
}
|
||||
|
||||
async stopProxy(key) {
|
||||
let deviceId = null;
|
||||
let processId = null;
|
||||
if (typeof key === 'string') {
|
||||
try { deviceId = validateDeviceId(key); } catch { return { success: false, message: 'Invalid proxy key.' }; }
|
||||
processId = this.proxyByDevice.get(deviceId);
|
||||
} else if (Number.isSafeInteger(key) && key > 0) {
|
||||
processId = key;
|
||||
for (const [candidateDeviceId, candidateProcessId] of this.proxyByDevice) {
|
||||
if (candidateProcessId === processId) deviceId = candidateDeviceId;
|
||||
}
|
||||
}
|
||||
if (!processId || !deviceId) return { success: false, message: 'Proxy process is not owned by this app.' };
|
||||
|
||||
const result = this.proxyManager.stopProxy(processId);
|
||||
if (result.success) this.proxyByDevice.delete(deviceId);
|
||||
return { ...result, deviceId };
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.sessionStore.clear();
|
||||
this.allowedDeviceIds.clear();
|
||||
return this.getConnectionState();
|
||||
}
|
||||
|
||||
getConnectionState() {
|
||||
const state = this.sessionStore.describe();
|
||||
return {
|
||||
connected: state.connected,
|
||||
origin: state.origin,
|
||||
activeProxies: Array.from(this.proxyByDevice, ([deviceId, processId]) => ({ deviceId, processId })),
|
||||
};
|
||||
}
|
||||
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
const result = await this.checkForUpdatePolicy({ currentVersion: this.currentVersion });
|
||||
return { success: true, ...result };
|
||||
} catch {
|
||||
return { success: false, message: 'Could not securely check for updates.' };
|
||||
}
|
||||
}
|
||||
|
||||
async openFixedReleasesPage() {
|
||||
await this.openExternal(RELEASES_PAGE_URL);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AppRuntime,
|
||||
PAIRING_ENVELOPE_FILENAME,
|
||||
PairingController,
|
||||
atomicWritePairingEnvelope,
|
||||
createBridgeHandler,
|
||||
loadPairingEnvelope,
|
||||
};
|
||||
+42
-50
@@ -532,7 +532,47 @@ button:disabled {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Update Modal */
|
||||
/* Bridge pairing */
|
||||
.section-help {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.pairing-section .paired {
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pairing-section .unpaired {
|
||||
color: var(--warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pairing-secret-row {
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.pairing-secret-row label,
|
||||
.pairing-secret-row small {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pairing-secret-row input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Update notification */
|
||||
.update-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -595,55 +635,7 @@ button:disabled {
|
||||
.update-modal-message {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.update-modal-notes {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.update-modal-notes:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.update-progress-container {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.update-progress-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.update-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.update-progress-text {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: var(--text-secondary);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.update-modal-footer {
|
||||
|
||||
@@ -129,7 +129,7 @@ test('Send to APT uses the paired secret header and exact endpoint', async () =>
|
||||
assert.equal(harness.fetchCalls.length, 1);
|
||||
const [url, request] = harness.fetchCalls[0];
|
||||
assert.equal(url, 'http://127.0.0.1:18247/cookie');
|
||||
assert.equal(request.headers['X-APT-Pairing-Secret'], 'A'.repeat(43));
|
||||
assert.equal(request.headers['X-APT-Pairing'], 'A'.repeat(43));
|
||||
assert.deepEqual(JSON.parse(request.body), {
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'sensitive-va-token'
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { PassThrough } = require('node:stream');
|
||||
|
||||
const {
|
||||
AppRuntime,
|
||||
PairingController,
|
||||
createBridgeHandler,
|
||||
loadPairingEnvelope,
|
||||
} = require('../src/electron-runtime');
|
||||
const { APT_EXTENSION_ORIGIN, BridgeAuth } = require('../src/bridge-auth');
|
||||
const { createSessionStore } = require('../src/session-store');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8');
|
||||
|
||||
function responseHarness() {
|
||||
return {
|
||||
statusCode: 0,
|
||||
headers: {},
|
||||
body: '',
|
||||
setHeader(name, value) { this.headers[name.toLowerCase()] = value; },
|
||||
writeHead(statusCode, headers = {}) {
|
||||
this.statusCode = statusCode;
|
||||
for (const [name, value] of Object.entries(headers)) this.setHeader(name, value);
|
||||
},
|
||||
end(chunk = '') { this.body += chunk; },
|
||||
};
|
||||
}
|
||||
|
||||
function requestHarness({ method = 'POST', origin = APT_EXTENSION_ORIGIN, secret, body = '{}' } = {}) {
|
||||
const request = new PassThrough();
|
||||
request.method = method;
|
||||
request.url = '/cookie';
|
||||
request.headers = { origin };
|
||||
if (secret !== undefined) request.headers['x-apt-pairing'] = secret;
|
||||
process.nextTick(() => request.end(body));
|
||||
return request;
|
||||
}
|
||||
|
||||
test('runtime keeps Alta credentials in main-owned modules and exposes only non-secret state', async () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie');
|
||||
const calls = [];
|
||||
const proxyManager = {
|
||||
launchProxy(request) { calls.push(request); return { success: true, processId: 91, deviceId: request.deviceId, status: 'running' }; },
|
||||
stopProxy(processId) { calls.push({ processId }); return { success: true, processId, status: 'stop-requested' }; },
|
||||
listTrackedProxies() { return []; },
|
||||
};
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: {
|
||||
getDevices: async () => [{ guid: '550e8400-e29b-41d4-a716-446655440000' }],
|
||||
getDeviceSites: async () => [],
|
||||
getAuthInfo: async () => ({ user: 'safe' }),
|
||||
},
|
||||
proxyManager,
|
||||
checkForUpdate: async ({ currentVersion }) => ({ status: 'up-to-date', currentVersion }),
|
||||
currentVersion: '1.0.0',
|
||||
openExternal: async () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(await runtime.getDevices(), { success: true, devices: [{ guid: '550e8400-e29b-41d4-a716-446655440000' }] });
|
||||
const launched = await runtime.launchProxy('550e8400-e29b-41d4-a716-446655440000');
|
||||
assert.equal(launched.success, true);
|
||||
assert.deepEqual(calls[0], {
|
||||
deploymentHost: 'customer.avasecurity.com',
|
||||
cookie: 'top-secret-cookie',
|
||||
deviceId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
});
|
||||
assert.deepEqual(runtime.getConnectionState(), {
|
||||
connected: true,
|
||||
origin: 'https://customer.avasecurity.com',
|
||||
activeProxies: [{ deviceId: '550e8400-e29b-41d4-a716-446655440000', processId: 91 }],
|
||||
});
|
||||
assert.equal(JSON.stringify(launched).includes('top-secret-cookie'), false);
|
||||
assert.equal(JSON.stringify(runtime.getConnectionState()).includes('top-secret-cookie'), false);
|
||||
|
||||
assert.equal((await runtime.stopProxy('550e8400-e29b-41d4-a716-446655440000')).success, true);
|
||||
assert.deepEqual(calls[1], { processId: 91 });
|
||||
assert.equal((await runtime.stopProxy(999)).success, false);
|
||||
});
|
||||
|
||||
test('bridge rejects unknown preflight and unauthenticated requests before reading their body', async () => {
|
||||
const auth = new BridgeAuth();
|
||||
const secret = auth.rotate().secret;
|
||||
const sessionStore = createSessionStore();
|
||||
let stateNotifications = 0;
|
||||
const handler = createBridgeHandler({
|
||||
bridgeAuth: auth,
|
||||
sessionStore,
|
||||
onConnectionStateChanged: () => { stateNotifications += 1; },
|
||||
});
|
||||
|
||||
const unknownPreflight = requestHarness({ method: 'OPTIONS', origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' });
|
||||
const unknownResponse = responseHarness();
|
||||
await handler(unknownPreflight, unknownResponse);
|
||||
assert.equal(unknownResponse.statusCode, 403);
|
||||
assert.equal(unknownResponse.headers['access-control-allow-origin'], undefined);
|
||||
|
||||
const unauthenticated = requestHarness({ body: '{'.repeat(1000) });
|
||||
let dataRead = false;
|
||||
unauthenticated.on('data', () => { dataRead = true; });
|
||||
const unauthenticatedResponse = responseHarness();
|
||||
await handler(unauthenticated, unauthenticatedResponse);
|
||||
assert.equal(unauthenticatedResponse.statusCode, 403);
|
||||
assert.equal(dataRead, false);
|
||||
|
||||
const allowedPreflight = requestHarness({ method: 'OPTIONS', secret });
|
||||
const allowedResponse = responseHarness();
|
||||
await handler(allowedPreflight, allowedResponse);
|
||||
assert.equal(allowedResponse.statusCode, 204);
|
||||
assert.equal(allowedResponse.headers['access-control-allow-origin'], APT_EXTENSION_ORIGIN);
|
||||
assert.match(allowedResponse.headers['access-control-allow-headers'], /X-APT-Pairing/);
|
||||
|
||||
const accepted = requestHarness({
|
||||
secret,
|
||||
body: JSON.stringify({ deploymentUrl: 'https://customer.avasecurity.com', cookieValue: 'valid-cookie' }),
|
||||
});
|
||||
const acceptedResponse = responseHarness();
|
||||
await handler(accepted, acceptedResponse);
|
||||
assert.equal(acceptedResponse.statusCode, 200);
|
||||
assert.deepEqual(sessionStore.describe(), { connected: true, origin: 'https://customer.avasecurity.com' });
|
||||
assert.equal(stateNotifications, 1);
|
||||
assert.equal(acceptedResponse.body.includes('valid-cookie'), false);
|
||||
});
|
||||
|
||||
test('pairing envelope persists with restrictive permissions and secrets are returned once', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-pairing-'));
|
||||
const envelopePath = path.join(directory, 'bridge-pairing.json');
|
||||
const controller = new PairingController({ envelopePath });
|
||||
|
||||
const first = controller.initialize();
|
||||
assert.equal(first.paired, true);
|
||||
assert.match(first.secret, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.deepEqual(controller.getStatus(), { paired: true });
|
||||
const persisted = loadPairingEnvelope(envelopePath);
|
||||
assert.equal(Object.values(persisted).includes(first.secret), false);
|
||||
if (process.platform !== 'win32') assert.equal(fs.statSync(envelopePath).mode & 0o777, 0o600);
|
||||
|
||||
const rotated = controller.rotate();
|
||||
assert.match(rotated.secret, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.notEqual(rotated.secret, first.secret);
|
||||
assert.deepEqual(controller.getStatus(), { paired: true });
|
||||
controller.revoke();
|
||||
assert.deepEqual(controller.getStatus(), { paired: false });
|
||||
assert.equal(fs.existsSync(envelopePath), false);
|
||||
});
|
||||
|
||||
test('update runtime is check-only and opens only the fixed GitPeji releases page', async () => {
|
||||
const opened = [];
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore: createSessionStore(),
|
||||
altaClient: {},
|
||||
proxyManager: {},
|
||||
checkForUpdate: async ({ currentVersion }) => ({ status: 'update-available', currentVersion, latestVersion: '1.1.0' }),
|
||||
currentVersion: '1.0.0',
|
||||
openExternal: async (url) => { opened.push(url); },
|
||||
});
|
||||
assert.deepEqual(await runtime.checkForUpdates(), {
|
||||
success: true,
|
||||
status: 'update-available',
|
||||
currentVersion: '1.0.0',
|
||||
latestVersion: '1.1.0',
|
||||
});
|
||||
assert.deepEqual(await runtime.openFixedReleasesPage(), { success: true });
|
||||
assert.deepEqual(opened, ['https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases']);
|
||||
});
|
||||
|
||||
test('preload and renderer expose only narrow, credential-free contracts', () => {
|
||||
const preload = read('preload.js');
|
||||
const renderer = read('renderer.js');
|
||||
const html = read('index.html');
|
||||
const expectedMethods = [
|
||||
'getDevices', 'getDeviceSites', 'getAuthInfo', 'launchProxy', 'stopProxy', 'disconnect',
|
||||
'getConnectionState', 'checkForUpdates', 'openFixedReleasesPage', 'rotatePairing',
|
||||
'revokePairing', 'getPairingStatus', 'onConnectionStateChanged',
|
||||
];
|
||||
for (const method of expectedMethods) assert.match(preload, new RegExp(`\\b${method}\\b`));
|
||||
assert.doesNotMatch(preload, /downloadAndInstall|download-and-install|onUpdateDownloadProgress|onExtensionCookie/);
|
||||
assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/);
|
||||
assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/);
|
||||
assert.match(renderer, /openFixedReleasesPage/);
|
||||
assert.match(html, /Bridge Pairing/);
|
||||
});
|
||||
|
||||
test('source policy removes legacy credential IPC, shell launch, broad kill, and executable updater', () => {
|
||||
const production = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/electron-runtime.js']
|
||||
.map(read).join('\n');
|
||||
const forbidden = [
|
||||
/sanitizeBatchInput/, /taskkill/i, /\.bat\b/i, /download-and-install-update/,
|
||||
/httpsGetFollowRedirects/, /api\.github\.com/i, /github releases/i,
|
||||
/apt-local-bridge-token/, /X-APT-Token/, /cookie-proxy-/,
|
||||
];
|
||||
for (const pattern of forbidden) assert.doesNotMatch(production, pattern);
|
||||
assert.doesNotMatch(read('main.js'), /axios/);
|
||||
assert.match(read('main.js'), /event\.sender === webContents/);
|
||||
assert.match(read('main.js'), /frame === webContents\.mainFrame/);
|
||||
assert.match(read('main.js'), /pathToFileURL\(path\.join\(__dirname, 'index\.html'\)\)/);
|
||||
assert.match(read('main.js'), /127\.0\.0\.1/);
|
||||
assert.match(read('main.js'), /18247/);
|
||||
assert.match(read('main.js'), /shell\.openExternal/);
|
||||
});
|
||||
Reference in New Issue
Block a user