Update tunnel access implementation

This commit is contained in:
Michael Neale
2025-10-03 15:11:23 +10:00
parent b1fddbff5e
commit 866e615cff
2 changed files with 37 additions and 427 deletions
+2 -10
View File
@@ -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;
+35 -417
View File
@@ -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<typeof setInterval> | null = null;
private proactiveRestartTimeout: ReturnType<typeof setTimeout> | 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<string> {
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<string> {
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<string> {
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<void> {
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<boolean> {
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<void> {
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<void> {
// 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<void> {
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<void> {
const manager = getTunnelManager();
await manager.stop();
await manager.cleanupQRCodes();
}