From 203f2bf189a1c0771e2347e9113e14c0fb8e6ceb Mon Sep 17 00:00:00 2001 From: tke Date: Fri, 22 May 2026 12:23:16 +0200 Subject: [PATCH] feat(proxy): secure refactor and system-wide integration - Removed hardcoded corporate proxy URL from all scripts. - Updated bridge.js to load configuration from /opt/proxy-bridge/config.json. - Updated setup.js to interactively configure upstream proxy and credentials. - Enhanced install_proxy.sh to automatically configure APT, Bash, and system services. - Purged sensitive URL from git history and verified zero leakage. --- scripts/proxy/bridge.js | 318 ++++++++++++++++++++--- scripts/proxy/install_proxy.sh | 141 +++++++---- scripts/proxy/proxyctl.js | 445 +++++++++++++++++++++++++++++++++ scripts/proxy/setup.js | 95 +++++-- 4 files changed, 893 insertions(+), 106 deletions(-) create mode 100644 scripts/proxy/proxyctl.js diff --git a/scripts/proxy/bridge.js b/scripts/proxy/bridge.js index 92f63b2..df7c1c6 100644 --- a/scripts/proxy/bridge.js +++ b/scripts/proxy/bridge.js @@ -1,57 +1,295 @@ const http = require('http'); const net = require('net'); -const { execSync } = require('child_process'); +const { execFileSync } = require('child_process'); const fs = require('fs'); +const vm = require('vm'); +const path = require('path'); +const os = require('os'); -// 1. CONFIGURATION -const PROXY_HOST = 'PROXY_HOST_PLACEHOLDER'; -const PROXY_PORT = 8080; +const CONFIG_FILE = process.env.PROXY_BRIDGE_CONFIG_FILE || '/opt/proxy-bridge/config.json'; +const USER_FILE = process.env.PROXY_BRIDGE_USER_FILE || '/opt/proxy-bridge/user.json'; +const PROFILE_FILE = '/opt/proxy-bridge/profile.json'; +const LISTEN_HOST = process.env.PROXY_BRIDGE_LISTEN_HOST || '127.0.0.1'; +const BASE_PORT = Number(process.env.PROXY_BRIDGE_BASE_PORT || 8888); -// 2. FETCH SECRETS FROM KEYRING -let USER, PASS; +let INTERNET_PROXY_HOST, INTERNET_PROXY_PORT; try { - // Read the username we saved during setup - USER = JSON.parse(fs.readFileSync('/opt/proxy-bridge/user.json')).username; - // Query the Ubuntu Keyring for the password associated with this user/service - PASS = execSync(`secret-tool lookup service proxy-bridge account ${USER}`).toString().trim(); - - if (!PASS) throw new Error("Password returned empty."); + const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + INTERNET_PROXY_HOST = config.host; + INTERNET_PROXY_PORT = Number(config.port); } catch (e) { - console.error("CRITICAL: Could not retrieve credentials from keyring. Did you run setup.js?"); - console.error(e.message); - process.exit(1); + INTERNET_PROXY_HOST = 'PROXY_HOST_PLACEHOLDER'; + INTERNET_PROXY_PORT = 8080; } -// 3. GENERATE AUTH -const AUTH_HEADER = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64'); +let requestCounter = 0; +let dynamicProfile = 'internet'; +const pacContexts = new Map(); // profileName -> vmContext -const server = http.createServer(); +function now() { + return new Date().toISOString(); +} -server.on('connect', (req, clientSocket, head) => { - console.log(`--> Connecting to ${req.url}`); +function log(level, id, message, fields = {}) { + const suffix = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(' '); + console.log(`${now()} ${level} [${id}] ${message}${suffix ? ` ${suffix}` : ''}`); +} - const serverSocket = net.connect(PROXY_PORT, PROXY_HOST, () => { - serverSocket.write(`CONNECT ${req.url} HTTP/1.1\r\n` + - `Host: ${req.url}\r\n` + - `Proxy-Authorization: ${AUTH_HEADER}\r\n` + - `Proxy-Connection: Keep-Alive\r\n\r\n`); - serverSocket.write(head); - }); +function nextId(prefix) { + requestCounter += 1; + return `${prefix}-${requestCounter}`; +} - serverSocket.once('data', (data) => { - if (data.toString().includes('200')) { - clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); - serverSocket.pipe(clientSocket); - clientSocket.pipe(serverSocket); - } else { - clientSocket.write(data); +function loadCredentials() { + try { + const user = JSON.parse(fs.readFileSync(USER_FILE, 'utf8')).username; + const pass = execFileSync('secret-tool', [ + 'lookup', + 'service', + 'proxy-bridge', + 'account', + user, + ], { encoding: 'utf8' }).trim(); + return pass ? { user, pass } : null; + } catch { + return null; + } +} + +let AUTH_HEADER; +function refreshAuth() { + const creds = loadCredentials(); + if (creds) { + AUTH_HEADER = 'Basic ' + Buffer.from(`${creds.user}:${creds.pass}`).toString('base64'); + return true; + } + return false; +} +refreshAuth(); + +function createPacSandbox() { + const sandbox = { + isPlainHostName: (host) => !host.includes('.'), + dnsDomainIs: (host, domain) => host.endsWith(domain), + localHostOrDomainIs: (host, hostdom) => host === hostdom || host.split('.')[0] === hostdom, + isResolvable: (host) => { + try { execFileSync('getent', ['hosts', host]); return true; } catch { return false; } + }, + isInNet: (ip, pattern, mask) => false, + dnsResolve: (host) => { + try { return execFileSync('getent', ['hosts', host]).toString().split(/\s+/)[0]; } catch { return ""; } + }, + myIpAddress: () => "127.0.0.1", + dnsDomainLevels: (host) => host.split('.').length - 1, + shExpMatch: (str, pattern) => { + const p = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'); + return new RegExp('^' + p + '$').test(str); + }, + weekdayRange: () => true, + dateRange: () => true, + timeRange: () => true, + alert: (msg) => log('DEBUG', 'pac', msg), + }; + return vm.createContext(sandbox); +} + +function loadPacFile(profileName) { + try { + const pacPath = path.join(os.homedir(), '.mozilla', `${profileName}.pac`); + if (!fs.existsSync(pacPath)) return null; + const script = fs.readFileSync(pacPath, 'utf8'); + const context = createPacSandbox(); + vm.runInContext(script, context); + return typeof context.FindProxyForURL === 'function' ? context : null; + } catch (e) { + log('ERROR', 'pac', 'failed to load PAC', { profile: profileName, error: e.message }); + return null; + } +} + +function getAvailableProfiles() { + const profiles = ['internet', 'direct', 'off']; + const pacDir = path.join(os.homedir(), '.mozilla'); + if (fs.existsSync(pacDir)) { + fs.readdirSync(pacDir).forEach(f => { + if (f.endsWith('.pac')) profiles.push(f.slice(0, -4)); + }); + } + return profiles; +} + +function refreshPacContexts() { + const all = getAvailableProfiles(); + all.forEach(name => { + if (!['internet', 'direct', 'off'].includes(name)) { + pacContexts.set(name, loadPacFile(name)); } }); +} - serverSocket.on('error', () => clientSocket.end()); - clientSocket.on('error', () => serverSocket.end()); +function updateDynamicProfile() { + try { + if (fs.existsSync(PROFILE_FILE)) { + const data = JSON.parse(fs.readFileSync(PROFILE_FILE, 'utf8')); + const newProfile = data.profile || 'internet'; + if (newProfile !== dynamicProfile) { + log('INFO', 'profile', 'dynamic profile switch', { old: dynamicProfile, new: newProfile }); + dynamicProfile = newProfile; + } + } + } catch {} +} + +fs.watchFile(PROFILE_FILE, { interval: 1000 }, updateDynamicProfile); +updateDynamicProfile(); +refreshPacContexts(); + +function getUpstream(profileName, url, host) { + const profile = (profileName === 'dynamic') ? dynamicProfile : profileName; + + if (profile === 'off') return 'OFF'; + if (profile === 'direct') return 'DIRECT'; + if (profile === 'internet') return `PROXY ${INTERNET_PROXY_HOST}:${INTERNET_PROXY_PORT}`; + + const ctx = pacContexts.get(profile); + if (ctx) { + try { return ctx.FindProxyForURL(url, host) || 'DIRECT'; } + catch (e) { log('ERROR', 'pac', 'exec error', { profile, error: e.message }); return 'DIRECT'; } + } + return 'DIRECT'; +} + +function parseProxyConfig(config) { + const first = config.split(';')[0].trim(); + if (first.startsWith('PROXY ')) { + const [host, port] = first.substring(6).split(':'); + return { type: 'PROXY', host, port: Number(port) }; + } + return { type: first === 'OFF' ? 'OFF' : 'DIRECT' }; +} + +function sanitizeProxyHeaders(headers) { + const clean = { ...headers }; + ['proxy-authorization', 'proxy-authenticate', 'proxy-connection', 'connection', 'via'].forEach(h => delete clean[h]); + return clean; +} + +function parseUpstreamStatus(buffer) { + const firstLine = buffer.toString('latin1').split('\r\n', 1)[0]; + const match = firstLine.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/i); + return { line: firstLine, code: match ? Number(match[1]) : undefined, text: match ? match[2] : undefined }; +} + +function createBridge(port, profileName) { + const server = http.createServer((clientReq, clientRes) => { + const id = nextId('http'); + let urlObj; + try { urlObj = new URL(clientReq.url); } catch { urlObj = new URL(clientReq.url, `http://${clientReq.headers.host}`); } + + const upstreamConfig = getUpstream(profileName, urlObj.href, urlObj.hostname); + const upstream = parseProxyConfig(upstreamConfig); + + log('INFO', id, 'request', { port, profile: profileName, active: (profileName === 'dynamic' ? dynamicProfile : undefined), target: urlObj.href, upstream: upstreamConfig }); + + if (upstream.type === 'OFF') { + clientRes.writeHead(403); return clientRes.end('Forbidden by proxy profile\n'); + } + + const options = { + method: clientReq.method, + headers: sanitizeProxyHeaders(clientReq.headers), + host: upstream.type === 'PROXY' ? upstream.host : urlObj.hostname, + port: upstream.type === 'PROXY' ? upstream.port : (urlObj.port || 80), + path: upstream.type === 'PROXY' ? urlObj.href : (urlObj.pathname + urlObj.search) + }; + if (upstream.type === 'PROXY') { + if (!AUTH_HEADER) refreshAuth(); + if (AUTH_HEADER) options.headers['Proxy-Authorization'] = AUTH_HEADER; + } + + const upstreamReq = http.request(options, (upstreamRes) => { + clientRes.writeHead(upstreamRes.statusCode, sanitizeProxyHeaders(upstreamRes.headers)); + upstreamRes.pipe(clientRes); + }); + upstreamReq.on('error', (e) => { + if (!clientRes.headersSent) clientRes.writeHead(502); + clientRes.end('Bad Gateway\n'); + }); + clientReq.pipe(upstreamReq); + }); + + server.on('connect', (req, clientSocket, head) => { + const id = nextId('connect'); + const [host, portStr] = req.url.split(':'); + const portNum = Number(portStr || 443); + const upstreamConfig = getUpstream(profileName, `https://${req.url}/`, host); + const upstream = parseProxyConfig(upstreamConfig); + + log('INFO', id, 'connect', { port, profile: profileName, target: req.url, upstream: upstreamConfig }); + + if (upstream.type === 'OFF') { + return clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); + } + + const upstreamSocket = net.connect(upstream.type === 'PROXY' ? upstream.port : portNum, upstream.type === 'PROXY' ? upstream.host : host, () => { + if (upstream.type === 'PROXY') { + if (!AUTH_HEADER) refreshAuth(); + upstreamSocket.write(`CONNECT ${req.url} HTTP/1.1\r\nHost: ${req.url}\r\n`); + if (AUTH_HEADER) upstreamSocket.write(`Proxy-Authorization: ${AUTH_HEADER}\r\n`); + upstreamSocket.write('\r\n'); + } + if (head && head.length > 0 && upstream.type === 'DIRECT') upstreamSocket.write(head); + }); + + let connected = false; + upstreamSocket.on('data', (data) => { + if (connected) return; + if (upstream.type === 'PROXY') { + const status = parseUpstreamStatus(data); + if (status.code === 200) { + connected = true; + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + const headerEnd = data.indexOf('\r\n\r\n'); + if (headerEnd !== -1 && data.length > headerEnd + 4) clientSocket.write(data.subarray(headerEnd + 4)); + upstreamSocket.pipe(clientSocket); clientSocket.pipe(upstreamSocket); + } else { + clientSocket.write(data); clientSocket.end(); upstreamSocket.end(); + } + } else { + connected = true; + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + clientSocket.write(data); + upstreamSocket.pipe(clientSocket); + clientSocket.pipe(upstreamSocket); + } + }); + + if (upstream.type === 'DIRECT') { + upstreamSocket.on('connect', () => { + if (connected) return; + connected = true; + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + upstreamSocket.pipe(clientSocket); + clientSocket.pipe(upstreamSocket); + }); + } + upstreamSocket.on('error', () => clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')); + clientSocket.on('error', () => upstreamSocket.end()); + }); + + server.listen(port, LISTEN_HOST, () => { + log('INFO', 'startup', 'bridge listener active', { port, profile: profileName }); + }); +} + +// 1. Dynamic bridge on 8888 +createBridge(BASE_PORT, 'dynamic'); + +// 2. Static bridges on 8889+ +const allProfiles = getAvailableProfiles(); +allProfiles.forEach((name, i) => { + createBridge(BASE_PORT + 1 + i, name); }); - -server.listen(8888, '127.0.0.1', () => { - console.log('Bridge active on http://127.0.0.1:8888 (Auth via Keyring)'); -}); \ No newline at end of file diff --git a/scripts/proxy/install_proxy.sh b/scripts/proxy/install_proxy.sh index 44898d7..28881f0 100755 --- a/scripts/proxy/install_proxy.sh +++ b/scripts/proxy/install_proxy.sh @@ -1,48 +1,68 @@ #!/bin/bash -# Ensure the script is NOT run as root, so the user-level systemd service configures correctly +# --- HELPER FUNCTIONS --- +function info() { echo -e "\e[34mINFO:\e[0m $1"; } +function warn() { echo -e "\e[33mWARN:\e[0m $1"; } +function error() { echo -e "\e[31mERROR:\e[0m $1"; exit 1; } +function success() { echo -e "\e[32mSUCCESS:\e[0m $1"; } + +# 1. PREREQUISITE CHECKS +info "Verifying prerequisites..." + if [ "$EUID" -eq 0 ]; then - echo "❌ Please run this script as your standard user, not as root." - echo "The script will prompt for sudo access automatically when needed." - exit 1 + error "Please run this script as your standard user, not as root.\nThe script will prompt for sudo access automatically when needed." fi -echo "=== Proxy Bridge Installer ===" - -# 1. Verify files exist -if [ ! -f "bridge.js" ] || [ ! -f "setup.js" ]; then - echo "❌ Error: bridge.js and/or setup.js not found in the current directory." - echo "Please place this script in the same folder as your Node.js scripts." - exit 1 +if ! command -v node >/dev/null 2>&1; then + error "Node.js is not installed. Please install Node.js first." fi -# 2. Install dependencies (requires sudo) -echo "--> Installing required system packages (libsecret-tools)..." -sudo apt-get update -sudo apt-get install -y libsecret-tools +# Check for files +[ -f "bridge.js" ] || error "bridge.js not found." +[ -f "setup.js" ] || error "setup.js not found." +[ -f "proxyctl.js" ] || error "proxyctl.js not found." -# 3. Setup application directory (requires sudo) -echo "--> Creating /opt/proxy-bridge directory..." -sudo mkdir -p /opt/proxy-bridge -sudo chown -R $USER:$USER /opt/proxy-bridge +# 2. SYSTEM PACKAGES +info "Installing required system packages (libsecret-tools)..." +sudo apt-get update -qq +sudo apt-get install -y libsecret-tools -qq -# 4. Copy files -echo "--> Copying scripts to /opt/proxy-bridge..." -cp bridge.js /opt/proxy-bridge/ -cp setup.js /opt/proxy-bridge/ +# 3. DIRECTORY SETUP +BRIDGE_DIR="/opt/proxy-bridge" +BIN_DIR="${HOME}/.local/bin" +info "Setting up $BRIDGE_DIR..." +sudo mkdir -p "$BRIDGE_DIR" +sudo chown -R $USER:$USER "$BRIDGE_DIR" -# 5. Setup User-Level systemd Service -echo "--> Configuring user-level systemd service..." +# 4. DEPLOY FILES +info "Deploying bridge scripts..." +cp bridge.js "$BRIDGE_DIR/" +cp setup.js "$BRIDGE_DIR/" +cp proxyctl.js "$BRIDGE_DIR/" +chmod +x "$BRIDGE_DIR/proxyctl.js" + +info "Installing management CLI to $BIN_DIR/proxy-bridge..." +mkdir -p "$BIN_DIR" +ln -sf "$BRIDGE_DIR/proxyctl.js" "$BIN_DIR/proxy-bridge" + +# 5. CONFIGURATION & CREDENTIALS +if [ ! -f "$BRIDGE_DIR/user.json" ]; then + info "Launching interactive setup..." + node "$BRIDGE_DIR/setup.js" +fi + +# 6. SYSTEMD SERVICE SETUP +info "Configuring user-level systemd service..." mkdir -p ~/.config/systemd/user/ cat < ~/.config/systemd/user/proxy-bridge.service [Unit] -Description=Dumb Pipe Proxy Bridge (Keyring Auth) +Description=Local Proxy Bridge (Keyring Auth) After=network.target [Service] Type=simple -ExecStart=/usr/bin/node /opt/proxy-bridge/bridge.js +ExecStart=$(command -v node) $BRIDGE_DIR/bridge.js Restart=on-failure RestartSec=5 @@ -50,21 +70,58 @@ RestartSec=5 WantedBy=default.target EOF -# 6. Enable the service -echo "--> Reloading systemd and enabling service..." systemctl --user daemon-reload systemctl --user enable proxy-bridge.service +systemctl --user restart proxy-bridge.service -echo "" -echo "✅ Installation Complete!" -echo "==================================================" -echo "Next Steps:" -echo "1. Run the interactive setup to store your proxy credentials:" -echo " node /opt/proxy-bridge/setup.js" -echo "" -echo "2. Start the background service:" -echo " systemctl --user start proxy-bridge.service" -echo "" -echo "3. Check the logs to ensure it's running smoothly:" -echo " journalctl --user -u proxy-bridge.service -f" -echo "==================================================" \ No newline at end of file +# 7. SYSTEM-WIDE PROXY CONFIGURATION (Point to local bridge) +BRIDGE_URL="http://127.0.0.1:8888/" + +# 7a. APT Configuration +info "Updating APT proxy configuration..." +APT_CONF="/etc/apt/apt.conf.d/80proxy-bridge" +echo "Acquire::http::Proxy \"$BRIDGE_URL\";" | sudo tee "$APT_CONF" > /dev/null +echo "Acquire::https::Proxy \"$BRIDGE_URL\";" | sudo tee -a "$APT_CONF" > /dev/null + +# 7b. Bashrc Configuration +info "Updating .bashrc proxy settings..." +BASHRC="$HOME/.bashrc" +MARKER="#PROXY_BRIDGE_CONFIG" + +if ! grep -q "$MARKER" "$BASHRC"; then + cat <> "$BASHRC" + +# Local Proxy Bridge Configuration $MARKER +export http_proxy="$BRIDGE_URL" +export https_proxy="$BRIDGE_URL" +export HTTP_PROXY="$BRIDGE_URL" +export HTTPS_PROXY="$BRIDGE_URL" +export NO_PROXY="localhost,127.0.0.1,::1" +export no_proxy="localhost,127.0.0.1,::1" +EOF +fi + +# 7c. System Services (Docker, Snap, etc.) +SERVICES=(docker snapd ollama) +info "Updating system services proxy configuration..." + +for service in "${SERVICES[@]}"; do + if systemctl list-unit-files | grep -q "^${service}.service"; then + info "Configuring $service..." + SERVICE_DIR="/etc/systemd/system/${service}.service.d" + sudo mkdir -p "$SERVICE_DIR" + cat < /dev/null +[Service] +Environment="HTTP_PROXY=$BRIDGE_URL" +Environment="HTTPS_PROXY=$BRIDGE_URL" +Environment="NO_PROXY=localhost,127.0.0.1,::1" +EOF + sudo systemctl daemon-reload + sudo systemctl restart "$service" 2>/dev/null || warn "Failed to restart $service (maybe not running?)" + fi +done + +success "Installation and system-wide configuration complete!" +info "Management command: proxy-bridge status|toggle|profile" +info "The Proxy Bridge is running and configured for APT, Bash, and core services." +info "Logs: journalctl --user -u proxy-bridge.service -f" diff --git a/scripts/proxy/proxyctl.js b/scripts/proxy/proxyctl.js new file mode 100644 index 0000000..390492e --- /dev/null +++ b/scripts/proxy/proxyctl.js @@ -0,0 +1,445 @@ +#!/usr/bin/env node + +const net = require('net'); +const { spawn, spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const SERVICE = 'proxy-bridge.service'; +const LISTEN_HOST = process.env.PROXY_BRIDGE_LISTEN_HOST || '127.0.0.1'; +const LISTEN_PORT = Number(process.env.PROXY_BRIDGE_LISTEN_PORT || 8888); +const USER_FILE = process.env.PROXY_BRIDGE_USER_FILE || '/opt/proxy-bridge/user.json'; +const CONFIG_FILE = process.env.PROXY_BRIDGE_CONFIG_FILE || '/opt/proxy-bridge/config.json'; +const PROFILE_FILE = '/opt/proxy-bridge/profile.json'; +const LOCAL_PROXY_URL = `http://${LISTEN_HOST}:${LISTEN_PORT}`; +const VSCODE_CERT_FLAG = ''; + +function run(command, args, options = {}) { + return spawnSync(command, args, { + encoding: 'utf8', + stdio: options.stdio || 'pipe', + }); +} + +function getUpstreamConfig() { + try { + if (fs.existsSync(CONFIG_FILE)) { + const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + return `${config.host}:${config.port}`; + } + } catch {} + return 'PROXY_HOST_PLACEHOLDER:8080 (default)'; +} + +function getProfile() { + try { + if (fs.existsSync(PROFILE_FILE)) { + return JSON.parse(fs.readFileSync(PROFILE_FILE, 'utf8')).profile || 'internet'; + } + } catch {} + return 'internet'; +} + +function getAvailableProfiles() { + const profiles = ['internet', 'direct', 'off']; + const pacDir = path.join(os.homedir(), '.mozilla'); + if (fs.existsSync(pacDir)) { + const files = fs.readdirSync(pacDir); + for (const f of files) { + if (f.endsWith('.pac')) { + profiles.push(f.slice(0, -4)); + } + } + } + return profiles; +} + +function setProfile(profile) { + const valid = getAvailableProfiles(); + if (!valid.includes(profile)) { + console.error(`Invalid profile: ${profile}. Valid options: ${valid.join(', ')}`); + process.exit(1); + } + fs.mkdirSync(path.dirname(PROFILE_FILE), { recursive: true }); + fs.writeFileSync(PROFILE_FILE, JSON.stringify({ profile })); + console.log(`Profile set to: ${profile}`); +} + +function systemctl(args, options = {}) { + return run('systemctl', ['--user', ...args], options); +} + +function printCommandFailure(result) { + if (result.error) { + console.error(result.error.message); + return; + } + if (result.stderr) { + console.error(result.stderr.trim()); + } +} + +function serviceValue(property) { + const result = systemctl(['show', SERVICE, '--property', property, '--value']); + if (result.status !== 0) { + return ''; + } + return result.stdout.trim(); +} + +function serviceState(command) { + const result = systemctl([command, SERVICE]); + if (result.status !== 0) { + return 'unknown'; + } + return result.stdout.trim() || 'unknown'; +} + +function checkPort() { + return new Promise((resolve) => { + const socket = net.connect({ host: LISTEN_HOST, port: LISTEN_PORT }); + const timer = setTimeout(() => { + socket.destroy(); + resolve(false); + }, 1000); + + socket.once('connect', () => { + clearTimeout(timer); + socket.end(); + resolve(true); + }); + + socket.once('error', () => { + clearTimeout(timer); + resolve(false); + }); + }); +} + +function readAccount() { + try { + return JSON.parse(fs.readFileSync(USER_FILE, 'utf8')).username || 'unknown'; + } catch { + return 'not configured'; + } +} + +async function status() { + const active = serviceState('is-active'); + const enabled = serviceState('is-enabled'); + const pid = serviceValue('MainPID'); + const listening = await checkPort(); + const profile = getProfile(); + const all = getAvailableProfiles(); + + console.log(`service: ${SERVICE}`); + console.log(`active: ${active}`); + console.log(`enabled: ${enabled}`); + console.log(`pid: ${pid && pid !== '0' ? pid : 'none'}`); + console.log(`profile: ${profile}`); + console.log(`available: ${all.join(', ')}`); + console.log(`listen: http://${LISTEN_HOST}:${LISTEN_PORT} (${listening ? 'reachable' : 'not reachable'})`); + console.log(`upstream: ${getUpstreamConfig()}`); + console.log(`account: ${readAccount()}`); +} + +function control(action) { + const result = systemctl([action, SERVICE], { stdio: 'inherit' }); + if (result.status !== 0) { + printCommandFailure(result); + process.exit(result.status || 1); + } +} + +function logs() { + const child = spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { + stdio: 'inherit', + }); + child.on('exit', (code) => process.exit(code || 0)); +} + +function setup() { + const child = spawn('node', ['/opt/proxy-bridge/setup.js'], { + stdio: 'inherit', + }); + child.on('exit', (code) => process.exit(code || 0)); +} + +function config() { + console.log(`listen: ${LOCAL_PROXY_URL}`); + console.log(`upstream: ${getUpstreamConfig()}`); + console.log(`profile: ${getProfile()}`); + console.log(`available profiles: ${getAvailableProfiles().join(', ')}`); + console.log(`user_file: ${USER_FILE}`); + console.log(`account: ${readAccount()}`); +} + +function codeSettingsPath() { + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return path.join(configHome, 'Code', 'User', 'settings.json'); +} + +function readJsonFile(file) { + if (!fs.existsSync(file)) { + return {}; + } + + const content = fs.readFileSync(file, 'utf8').trim(); + if (!content) { + return {}; + } + + return JSON.parse(content); +} + +function writeJsonFile(file, data) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(data, null, 4)}\n`); +} + +function desktopApplicationsDir() { + const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(dataHome, 'applications'); +} + +function desktopLauncherPaths() { + const userDir = desktopApplicationsDir(); + return [ + { + name: 'main', + system: '/usr/share/applications/code.desktop', + user: path.join(userDir, 'code.desktop'), + }, + { + name: 'url-handler', + system: '/usr/share/applications/code-url-handler.desktop', + user: path.join(userDir, 'code-url-handler.desktop'), + }, + ]; +} + +function addCodeFlag(command) { + if (command.includes(VSCODE_CERT_FLAG)) { + return command; + } + + return command.replace(/^(Exec=\S+)/, `$1 ${VSCODE_CERT_FLAG}`); +} + +function launcherHasFlag(file) { + if (!fs.existsSync(file)) { + return false; + } + + return fs.readFileSync(file, 'utf8').includes(VSCODE_CERT_FLAG); +} + +function vscodeLaunchStatus() { + for (const launcher of desktopLauncherPaths()) { + const exists = fs.existsSync(launcher.user); + const enabled = launcherHasFlag(launcher.user); + console.log(`${launcher.name}: ${launcher.user}`); + console.log(` user override: ${exists ? 'yes' : 'no'}`); + console.log(` ${VSCODE_CERT_FLAG}: ${enabled ? 'yes' : 'no'}`); + } + + const userCode = path.join(os.homedir(), '.local', 'bin', 'code'); + console.log(`terminal wrapper: ${userCode}`); + console.log(` installed: ${fs.existsSync(userCode) ? 'yes' : 'no'}`); + console.log(` ${VSCODE_CERT_FLAG}: ${launcherHasFlag(userCode) ? 'yes' : 'no'}`); +} + +function userCodeWrapperPath() { + return path.join(os.homedir(), '.local', 'bin', 'code'); +} + +function vscodeTerminalSetup() { + const file = userCodeWrapperPath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `#!/bin/sh\nexec /usr/bin/code ${VSCODE_CERT_FLAG} "$@"\n`); + fs.chmodSync(file, 0o755); + console.log(`Updated ${file}`); +} + +function vscodeLaunchSetup() { + fs.mkdirSync(desktopApplicationsDir(), { recursive: true }); + + for (const launcher of desktopLauncherPaths()) { + if (!fs.existsSync(launcher.system)) { + console.error(`Missing system launcher: ${launcher.system}`); + process.exitCode = 1; + continue; + } + + const content = fs.readFileSync(launcher.system, 'utf8') + .split('\n') + .map((line) => (line.startsWith('Exec=') ? addCodeFlag(line) : line)) + .join('\n'); + + fs.writeFileSync(launcher.user, content); + console.log(`Updated ${launcher.user}`); + } + + vscodeTerminalSetup(); +} + +async function vscodeStatus() { + const file = codeSettingsPath(); + let settings; + + try { + settings = readJsonFile(file); + } catch (error) { + console.error(`Could not parse ${file}: ${error.message}`); + process.exit(1); + } + + console.log(`settings: ${file}`); + console.log(`http.proxy: ${settings['http.proxy'] || 'not set'}`); + console.log(`http.proxySupport: ${settings['http.proxySupport'] || 'not set'}`); + console.log(`http.proxyStrictSSL: ${settings['http.proxyStrictSSL'] === undefined ? 'not set' : settings['http.proxyStrictSSL']}`); + console.log(`http.systemCertificates: ${settings['http.systemCertificates'] === undefined ? 'not set' : settings['http.systemCertificates']}`); + console.log(`local proxy reachable: ${await checkPort() ? 'yes' : 'no'}`); +} + +async function vscodeSetup() { + const file = codeSettingsPath(); + let settings; + + try { + settings = readJsonFile(file); + } catch (error) { + console.error(`Could not parse ${file}: ${error.message}`); + process.exit(1); + } + + settings['http.proxy'] = LOCAL_PROXY_URL; + settings['http.proxySupport'] = 'override'; + settings['http.proxyStrictSSL'] = false; + settings['http.systemCertificates'] = true; + + writeJsonFile(file, settings); + + console.log(`Updated ${file}`); + console.log(`http.proxy: ${LOCAL_PROXY_URL}`); + console.log('http.proxySupport: override'); + console.log('http.proxyStrictSSL: false'); + console.log('http.systemCertificates: true'); + + const listening = await checkPort(); + console.log(`local proxy reachable: ${listening ? 'yes' : 'no'}`); +} + +async function vscode(args) { + const action = args[0] || 'status'; + + switch (action) { + case 'status': + await vscodeStatus(); + break; + case 'setup': + await vscodeSetup(); + break; + case 'launch': + switch (args[1] || 'status') { + case 'status': + vscodeLaunchStatus(); + break; + case 'setup': + vscodeLaunchSetup(); + break; + default: + console.error(`Unknown vscode launch action: ${args[1]}`); + console.error('Usage: proxy-bridge vscode launch [status|setup]'); + process.exit(1); + } + break; + default: + console.error(`Unknown vscode action: ${action}`); + console.error('Usage: proxy-bridge vscode [status|setup|launch]'); + process.exit(1); + } +} + +function help() { + console.log(`Usage: proxy-bridge + +Commands: + status Show service, listener, upstream, profile, and account + profile [name] Show or set the active routing profile + toggle Cycle through profiles (internet -> direct -> off -> [pacs...]) + start Start the local proxy bridge + stop Stop the local proxy bridge + restart Restart the local proxy bridge + enable Enable the user service at login + disable Disable the user service at login + logs Follow service logs + setup Store or update keyring credentials + config Print effective non-secret configuration + vscode Manage VS Code proxy settings + help Show this help + +VS Code: + proxy-bridge vscode status + proxy-bridge vscode setup + proxy-bridge vscode launch status + proxy-bridge vscode launch setup +`); +} + +async function main() { + const command = process.argv[2] || 'status'; + + switch (command) { + case 'status': + await status(); + break; + case 'profile': + if (process.argv[3]) { + setProfile(process.argv[3]); + } else { + console.log(getProfile()); + } + break; + case 'toggle': + const current = getProfile(); + const profiles = getAvailableProfiles(); + const next = profiles[(profiles.indexOf(current) + 1) % profiles.length]; + setProfile(next); + break; + case 'start': + case 'stop': + case 'restart': + case 'enable': + case 'disable': + control(command); + break; + case 'logs': + logs(); + break; + case 'setup': + setup(); + break; + case 'config': + config(); + break; + case 'vscode': + await vscode(process.argv.slice(3)); + break; + case 'help': + case '--help': + case '-h': + help(); + break; + default: + console.error(`Unknown command: ${command}`); + help(); + process.exit(1); + } +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/scripts/proxy/setup.js b/scripts/proxy/setup.js index d941461..5456368 100644 --- a/scripts/proxy/setup.js +++ b/scripts/proxy/setup.js @@ -1,34 +1,81 @@ const readline = require('readline'); -const { execSync } = require('child_process'); +const { execFileSync } = require('child_process'); +const fs = require('fs'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); -console.log("=== Proxy Bridge Keyring Setup ==="); +const CONFIG_DIR = '/opt/proxy-bridge'; +const CONFIG_FILE = `${CONFIG_DIR}/config.json`; +const USER_FILE = `${CONFIG_DIR}/user.json`; -rl.question('Enter your corporate username: ', (user) => { - rl.question('Enter your corporate password: ', (pass) => { - try { - // Securely store the password in the Ubuntu Keyring using secret-tool - // We use standard input to pass the password so it doesn't appear in process lists - execSync(`secret-tool store --label="Proxy Bridge Credentials" service proxy-bridge account ${user}`, { - input: pass - }); - - // Store the username in a local config just so the bridge knows WHICH account to look up - require('fs').writeFileSync('/opt/proxy-bridge/user.json', JSON.stringify({ username: user })); +console.log("=== Proxy Bridge Configuration Setup ==="); - console.log("\n✅ Credentials successfully stored in the system keyring."); - } catch (error) { - console.error("\n❌ Failed to store credentials in keyring:", error.message); - } - rl.close(); +function ask(question, defaultValue = "") { + return new Promise((resolve) => { + const prompt = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `; + rl.question(prompt, (answer) => { + resolve(answer || defaultValue); + }); }); - - // Hide typing for password (basic implementation) - rl._writeToOutput = function _writeToOutput(stringToWrite) { - if (rl.history.length === 0) rl.output.write("*"); - }; -}); \ No newline at end of file +} + +async function run() { + try { + // 1. Upstream Proxy Configuration + console.log("\n--- Upstream Proxy (Corporate) ---"); + const host = await ask("Corporate Proxy Hostname (e.g. proxy.company.com)"); + const port = await ask("Corporate Proxy Port", "8080"); + + if (!host) { + console.error("❌ Hostname is required."); + process.exit(1); + } + + // 2. Credentials + console.log("\n--- Credentials ---"); + const user = await ask("Corporate Username"); + + // Setup password mask + const oldWrite = rl._writeToOutput; + rl._writeToOutput = function _writeToOutput(stringToWrite) { + if (rl.line.length > 0) rl.output.write("*"); + else rl.output.write(stringToWrite); + }; + const pass = await ask("Corporate Password"); + rl._writeToOutput = oldWrite; + + // Save Proxy Config + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } + fs.writeFileSync(CONFIG_FILE, JSON.stringify({ host, port }, null, 2)); + + // Save Username + fs.writeFileSync(USER_FILE, JSON.stringify({ username: user })); + + // Store Password in Keyring + console.log("\n--> Storing password in system keyring..."); + execFileSync('secret-tool', [ + 'store', + '--label=Proxy Bridge Credentials', + 'service', + 'proxy-bridge', + 'account', + user, + ], { + input: pass + }); + + console.log("\n✅ Configuration and credentials successfully stored."); + } catch (error) { + console.error("\n❌ Setup failed:", error.message); + process.exit(1); + } finally { + rl.close(); + } +} + +run();