diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 752b824596..0ce6fa4b79 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -2200,11 +2200,6 @@ async function appMain() { const tunnelInfo = await startTunnel(appConfig.GOOSE_PORT, SERVER_SECRET); log.info('Tunnel started:', tunnelInfo.url); - // Open QR code image - if (tunnelInfo.qrCodePath) { - shell.openPath(tunnelInfo.qrCodePath); - } - return tunnelInfo; } catch (error) { log.error('Failed to start tunnel:', error); @@ -2233,11 +2228,8 @@ async function appMain() { ipcMain.handle('tunnel-show-qr', () => { try { const config = getTunnelConfig(); - if (config?.qrCodePath) { - shell.openPath(config.qrCodePath); - return true; - } - return false; + // QR code is now displayed in the UI via qrCodeDataUrl, no file to open + return config?.qrCodeDataUrl ? true : false; } catch (error) { log.error('Failed to show QR code:', error); return false; diff --git a/ui/desktop/src/utils/tunnel.ts b/ui/desktop/src/utils/tunnel.ts index 25bdbaa9fa..e0d7e2ac49 100644 --- a/ui/desktop/src/utils/tunnel.ts +++ b/ui/desktop/src/utils/tunnel.ts @@ -1,18 +1,11 @@ import { CloudflareTunnel } from './cloudflare-tunnel'; import * as QRCode from 'qrcode'; -import * as crypto from 'node:crypto'; -import { Buffer } from 'node:buffer'; -import path from 'node:path'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import https from 'node:https'; import log from './logger'; export interface TunnelConfig { port: number; secret: string; url?: string; - qrCodePath?: string; qrCodeDataUrl?: string; } @@ -21,37 +14,12 @@ export interface TunnelConnectionInfo { secret: string; appUrl: string; qrCodeDataUrl: string; - qrCodePath: string; - ntfyUrl: string; // The ntfy.sh URL used in the QR code } -const HEALTH_CHECK_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes -const FAILURE_THRESHOLD = 3; // Restart after 3 consecutive failures - -// ============================================================================ -// keep alive interval for tunnel - say an hour or so -// ============================================================================ -const PROACTIVE_RESTART_INTERVAL_MS = 60 * 60 * 1000; - -// ============================================================================ -// TODO: TESTING TOGGLE - Set to false to disable ntfy.sh and use direct URLs -// When false, QR code will contain the tunnel URL directly (old behavior) -// When true, QR code contains ntfy.sh URL which resolves to tunnel URL -// ============================================================================ -const USE_NTFY_SH = true; - -const MACHINE_ID_FILE = 'machine-id.txt'; - export class TunnelManager { private cloudflaredTunnel: CloudflareTunnel | null = null; private config: TunnelConfig | null = null; private isStarting = false; - private tunnelUrl: string | null = null; - private healthCheckInterval: ReturnType | null = null; - private proactiveRestartTimeout: ReturnType | null = null; - private consecutiveFailures = 0; - private machineId: string | null = null; - private port: number = 0; constructor() {} @@ -70,37 +38,33 @@ export class TunnelManager { this.isStarting = true; try { - // Store port and secret for potential restarts - this.port = port; - // Use the provided secret (from goosed) - const tunnelSecret = secret; - this.config = { port, - secret: tunnelSecret, + secret, }; // Start Cloudflare tunnel log.info(`Starting Cloudflare tunnel on port ${port}`); - this.tunnelUrl = await this.startCloudflareTunnel(port); - if (!this.tunnelUrl) { - throw new Error('Failed to get tunnel URL'); + if (!this.cloudflaredTunnel) { + this.cloudflaredTunnel = new CloudflareTunnel(); } - // Format connection URL (remove https:// and add :443) - const connectUrl = this.tunnelUrl.replace('https://', '') + ':443'; + const url = await this.cloudflaredTunnel.start(port); - // Get machine ID for ntfy.sh topic - const machineId = await this.getMachineId(); - const ntfyTopic = `goose-tunnel-${machineId}`; - const ntfyUrl = `https://ntfy.sh/${ntfyTopic}`; + if (!url) { + throw new Error('Failed to get Cloudflare tunnel URL'); + } + + log.info(`Cloudflare tunnel established: ${url}`); + + // Format connection URL (remove https:// and add :443) + const connectUrl = url.replace('https://', '') + ':443'; // Create connection config for QR code - // If USE_NTFY_SH is true, use ntfy.sh URL; otherwise use direct tunnel URL const configData = { - url: USE_NTFY_SH ? ntfyUrl : connectUrl, - secret: tunnelSecret, + url: connectUrl, + secret, }; // Create app URL for deep linking @@ -108,349 +72,33 @@ export class TunnelManager { const urlEncodedConfig = encodeURIComponent(configJson); const appUrl = `goosechat://configure?data=${urlEncodedConfig}`; - // Generate QR code (only for GUI display, no need to save to disk) - const qrCodeDataUrl = await this.generateQRCode(appUrl); - - this.config.url = connectUrl; - this.config.qrCodePath = ''; // Not saving to disk anymore - this.config.qrCodeDataUrl = qrCodeDataUrl; - - const result: TunnelConnectionInfo = { - url: connectUrl, - secret: tunnelSecret, - appUrl, - qrCodeDataUrl, - qrCodePath: '', // QR code only shown in GUI, not saved to disk - ntfyUrl: USE_NTFY_SH ? ntfyUrl : connectUrl, // Show the URL that's actually in the QR code - }; - - log.info('Tunnel started successfully:', { - url: connectUrl, - ntfyUrl, - useNtfySh: USE_NTFY_SH, - }); - - // Send notification to ntfy.sh only if using ntfy.sh - if (USE_NTFY_SH) { - await this.notifyNewUrl(this.tunnelUrl); - } - - // Start health monitoring - this.startHealthMonitoring(); - - // Schedule proactive restart - this.scheduleProactiveRestart(); - - return result; - } finally { - this.isStarting = false; - } - } - - /** - * Start Cloudflare tunnel and return the URL - */ - private async startCloudflareTunnel(port: number): Promise { - try { - // Create or get the Cloudflare tunnel instance - if (!this.cloudflaredTunnel) { - this.cloudflaredTunnel = new CloudflareTunnel(); - } - - // Start the tunnel - const url = await this.cloudflaredTunnel.start(port); - - if (!url) { - throw new Error('Failed to get Cloudflare tunnel URL'); - } - - this.tunnelUrl = url; - log.info(`Cloudflare tunnel established: ${url}`); - - return url; - } catch (error) { - log.error('Failed to start Cloudflare tunnel:', error); - this.cloudflaredTunnel = null; - throw new Error(`Failed to start Cloudflare tunnel: ${error}`); - } - } - - /** - * Generate QR code as data URL - */ - private async generateQRCode(data: string): Promise { - try { - const qrCodeDataUrl = await QRCode.toDataURL(data, { + // Generate QR code + const qrCodeDataUrl = await QRCode.toDataURL(appUrl, { errorCorrectionLevel: 'M', type: 'image/png', width: 400, margin: 2, }); - return qrCodeDataUrl; + + this.config.url = connectUrl; + this.config.qrCodeDataUrl = qrCodeDataUrl; + + const result: TunnelConnectionInfo = { + url: connectUrl, + secret, + appUrl, + qrCodeDataUrl, + }; + + log.info('Tunnel started successfully:', { url: connectUrl }); + + return result; } catch (error) { - log.error('Failed to generate QR code:', error); - throw new Error('Failed to generate QR code'); - } - } - /** - * Get or generate a stable machine ID - */ - private async getMachineId(): Promise { - if (this.machineId) { - return this.machineId; - } - - try { - // Get the app data directory - const appDataPath = - process.env.APPDATA || - (process.platform == 'darwin' - ? path.join(os.homedir(), 'Library', 'Application Support') - : path.join(os.homedir(), '.local', 'share')); - - const gooseDataPath = path.join(appDataPath, 'Goose'); - await fs.mkdir(gooseDataPath, { recursive: true }); - - const machineIdPath = path.join(gooseDataPath, MACHINE_ID_FILE); - - // Try to read existing machine ID - try { - const existingId = await fs.readFile(machineIdPath, 'utf-8'); - this.machineId = existingId.trim(); - log.info('Loaded existing machine ID'); - return this.machineId; - } catch { - // File doesn't exist, generate new ID - log.info('Generating new machine ID'); - } - - // Generate a new machine ID based on hostname and random data - const hostname = os.hostname(); - const randomData = crypto.randomBytes(16).toString('hex'); - const combined = `${hostname}-${randomData}`; - - // Hash it to create a stable, short ID - const hash = crypto.createHash('sha256').update(combined).digest('hex'); - this.machineId = hash.substring(0, 16); - - // Save it for future use - await fs.writeFile(machineIdPath, this.machineId, 'utf-8'); - log.info('Generated and saved new machine ID'); - - return this.machineId; - } catch (error) { - log.error('Failed to get/generate machine ID:', error); - // Fallback to a session-only ID - const fallback = crypto.randomBytes(8).toString('hex'); - this.machineId = fallback; - return this.machineId; - } - } - - /** - * Send notification to ntfy.sh with the tunnel URL - */ - private async notifyNewUrl(url: string): Promise { - try { - const machineId = await this.getMachineId(); - const topic = `goose-tunnel-${machineId}`; - const ntfyUrl = `https://ntfy.sh/${topic}`; - - log.info(`Sending notification to ntfy.sh topic: ${topic}`); - - return new Promise((resolve) => { - const postData = url; - - const req = https.request( - ntfyUrl, - { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - 'Content-Length': Buffer.byteLength(postData), - }, - }, - (res) => { - if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { - log.info('Successfully sent notification to ntfy.sh'); - resolve(); - } else { - log.warn(`ntfy.sh returned status code: ${res.statusCode}`); - resolve(); // Don't fail the tunnel start if notification fails - } - } - ); - - req.on('error', (error) => { - log.warn('Failed to send notification to ntfy.sh:', error); - resolve(); // Don't fail the tunnel start if notification fails - }); - - req.write(postData); - req.end(); - }); - } catch (error) { - log.warn('Error in notifyNewUrl:', error); - // Don't throw, notification failure shouldn't stop tunnel - } - } - - /** - * Check if the tunnel URL is responding with 2xx or 4xx status codes - */ - private async checkTunnelHealth(): Promise { - if (!this.tunnelUrl) { - return false; - } - - const url = this.tunnelUrl; // Capture for TypeScript - - return new Promise((resolve) => { - const req = https.request( - url, - { - method: 'GET', - timeout: 10000, // 10 second timeout - }, - (res) => { - const statusCode = res.statusCode || 0; - const isHealthy = - (statusCode >= 200 && statusCode < 300) || (statusCode >= 400 && statusCode < 500); - - if (isHealthy) { - log.info(`Tunnel health check passed: ${statusCode}`); - resolve(true); - } else { - log.warn(`Tunnel health check failed: ${statusCode}`); - resolve(false); - } - } - ); - - req.on('error', (error) => { - log.warn('Tunnel health check error:', error); - resolve(false); - }); - - req.on('timeout', () => { - log.warn('Tunnel health check timeout'); - req.destroy(); - resolve(false); - }); - - req.end(); - }); - } - - /** - * Start health monitoring with periodic checks - */ - private startHealthMonitoring(): void { - // Clear any existing interval - this.stopHealthMonitoring(); - - log.info(`Starting health monitoring (interval: ${HEALTH_CHECK_INTERVAL_MS}ms)`); - - this.healthCheckInterval = setInterval(async () => { - const isHealthy = await this.checkTunnelHealth(); - - if (isHealthy) { - // Reset failure counter on success - this.consecutiveFailures = 0; - } else { - this.consecutiveFailures++; - log.warn(`Tunnel health check failed (${this.consecutiveFailures}/${FAILURE_THRESHOLD})`); - - if (this.consecutiveFailures >= FAILURE_THRESHOLD) { - log.error('Tunnel failed health checks, attempting restart...'); - await this.handleTunnelFailure(); - } - } - }, HEALTH_CHECK_INTERVAL_MS); - } - - /** - * Stop health monitoring - */ - private stopHealthMonitoring(): void { - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval); - this.healthCheckInterval = null; - log.info('Stopped health monitoring'); - } - } - - /** - * Handle tunnel failure by restarting it - */ - private async handleTunnelFailure(): Promise { - try { - log.info('Handling tunnel failure - restarting...'); - - // Cancel proactive restart since we're restarting now - this.cancelProactiveRestart(); - - // Stop the current tunnel - if (this.cloudflaredTunnel) { - this.cloudflaredTunnel.stop(); - this.cloudflaredTunnel = null; - } - - // Reset failure counter - this.consecutiveFailures = 0; - - // Start a new tunnel with the same port and secret - this.tunnelUrl = await this.startCloudflareTunnel(this.port); - - if (!this.tunnelUrl) { - throw new Error('Failed to get new tunnel URL'); - } - - // Update config with new URL - const connectUrl = this.tunnelUrl.replace('https://', '') + ':443'; - if (this.config) { - this.config.url = connectUrl; - } - - // No need to regenerate QR code - it contains the ntfy.sh URL which doesn't change (or direct URL which also doesn't need regen) - log.info('Tunnel restarted successfully with new URL:', this.tunnelUrl); - - // Send notification with new URL (only if using ntfy.sh) - if (USE_NTFY_SH) { - await this.notifyNewUrl(this.tunnelUrl); - } - - // Schedule next proactive restart - this.scheduleProactiveRestart(); - } catch (error) { - log.error('Failed to restart tunnel:', error); - // Will retry on next health check cycle - } - } - - /** - * Schedule a proactive restart of the tunnel after an hour - */ - private scheduleProactiveRestart(): void { - // Cancel any existing scheduled restart - this.cancelProactiveRestart(); - - log.info(`Scheduling proactive restart in ${PROACTIVE_RESTART_INTERVAL_MS}ms (1 hour)`); - - this.proactiveRestartTimeout = setTimeout(async () => { - log.info('Proactive restart triggered - restarting tunnel...'); - await this.handleTunnelFailure(); - }, PROACTIVE_RESTART_INTERVAL_MS); - } - - /** - * Cancel scheduled proactive restart - */ - private cancelProactiveRestart(): void { - if (this.proactiveRestartTimeout) { - clearTimeout(this.proactiveRestartTimeout); - this.proactiveRestartTimeout = null; - log.info('Cancelled scheduled proactive restart'); + log.error('Failed to start Cloudflare tunnel:', error); + this.cloudflaredTunnel = null; + throw error; + } finally { + this.isStarting = false; } } @@ -458,12 +106,6 @@ export class TunnelManager { * Stop the tunnel */ async stop(): Promise { - // Stop health monitoring - this.stopHealthMonitoring(); - - // Cancel proactive restart - this.cancelProactiveRestart(); - if (this.cloudflaredTunnel) { log.info('Stopping Cloudflare tunnel'); try { @@ -475,8 +117,6 @@ export class TunnelManager { } this.config = null; - this.tunnelUrl = null; - this.consecutiveFailures = 0; } /** @@ -492,27 +132,6 @@ export class TunnelManager { getConfig(): TunnelConfig | null { return this.config; } - - /** - * Clean up QR code files - */ - async cleanupQRCodes(): Promise { - try { - const tempDir = path.join(os.tmpdir(), 'goose-tunnels'); - const files = await fs.readdir(tempDir).catch(() => []); - - for (const file of files) { - if (file.startsWith('tunnel-qr-')) { - const filepath = path.join(tempDir, file); - await fs.unlink(filepath).catch((err) => { - log.warn('Failed to delete QR code file:', filepath, err); - }); - } - } - } catch (error) { - log.warn('Failed to cleanup QR codes:', error); - } - } } // Singleton instance @@ -566,5 +185,4 @@ export function getTunnelConfig(): TunnelConfig | null { export async function cleanupTunnel(): Promise { const manager = getTunnelManager(); await manager.stop(); - await manager.cleanupQRCodes(); }