diff --git a/.gitignore b/.gitignore index 5459e6588a..f10ca9ec44 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,10 @@ vendor/* # Include the vendored copy of wp-now in our repo !vendor/wp-now +# CLI npm artifacts +cli/vendor/ +cli/package-lock.json + # Build output dist diff --git a/cli/commands/site/start.ts b/cli/commands/site/start.ts new file mode 100644 index 0000000000..1cc3207cab --- /dev/null +++ b/cli/commands/site/start.ts @@ -0,0 +1,66 @@ +import path from 'path'; +import { __ } from '@wordpress/i18n'; +import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { + isDaemonRunning, + startDaemon, + isProxyProcessRunning, + startProxyProcess, +} from 'cli/lib/pm2-manager'; +import { isRunningAsRoot, getElevatedPrivilegesMessage } from 'cli/lib/sudo-exec'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand(): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + // Step 1: Ensure PM2 daemon is running + if ( ! isDaemonRunning() ) { + logger.reportStart( LoggerAction.LOAD, __( 'Starting PM2 daemon...' ) ); + await startDaemon(); + logger.reportSuccess( __( 'PM2 daemon started' ) ); + } + + // Step 2: Check if proxy is already running + const isRunning = await isProxyProcessRunning(); + if ( isRunning ) { + logger.reportSuccess( __( 'HTTP proxy already running' ) ); + return; + } + + // Step 3: Check for elevated privileges + if ( ! isRunningAsRoot() ) { + throw new Error( getElevatedPrivilegesMessage() ); + } + + // Step 4: Start proxy via PM2 + logger.reportStart( LoggerAction.LOAD, __( 'Starting HTTP proxy server...' ) ); + + // Get the proxy daemon path (cli/proxy-daemon.ts compiled to dist) + // __dirname is dist/cli when running the bundled CLI + const proxyDaemonPath = path.resolve( __dirname, 'proxy-daemon.js' ); + + await startProxyProcess( proxyDaemonPath ); + + logger.reportSuccess( __( 'HTTP proxy server started' ) ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); + logger.reportError( loggerError ); + } + process.exit( 1 ); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'start', + describe: __( 'Start the HTTP proxy for custom domains (requires sudo)' ), + handler: async () => { + await runCommand(); + }, + } ); +}; diff --git a/cli/index.ts b/cli/index.ts index 06a99308da..f81386ddbc 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -9,6 +9,7 @@ import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/d import { registerCommand as registerListCommand } from 'cli/commands/preview/list'; import { registerCommand as registerUpdateCommand } from 'cli/commands/preview/update'; import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list'; +import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start'; import { readAppdata } from 'cli/lib/appdata'; import { loadTranslations } from 'cli/lib/i18n'; import { bumpAggregatedUniqueStat } from 'cli/lib/stats'; @@ -71,6 +72,7 @@ async function main() { hidden: true, } ); registerSiteListCommand( sitesYargs ); + registerSiteStartCommand( sitesYargs ); sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ); } diff --git a/src/lib/certificate-manager.ts b/cli/lib/certificate-manager.ts similarity index 96% rename from src/lib/certificate-manager.ts rename to cli/lib/certificate-manager.ts index 1e3355b845..74dc06058e 100644 --- a/src/lib/certificate-manager.ts +++ b/cli/lib/certificate-manager.ts @@ -1,14 +1,12 @@ -import { shell } from 'electron'; import { execFile } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { domainToASCII } from 'node:url'; import { promisify } from 'node:util'; -import * as Sentry from '@sentry/electron/main'; import sudo from '@vscode/sudo-prompt'; import forge from 'node-forge'; -import { getUserDataCertificatesPath } from 'src/storage/paths'; +import { getAppdataDirectory } from 'cli/lib/appdata'; const execFilePromise = promisify( execFile ); @@ -68,7 +66,7 @@ function createNameConstraintsExtension( domains: string[] ) { const CA_NAME = 'WordPress Studio CA'; const CA_CERT_VALIDITY_DAYS = 3650; // 10 years const SITE_CERT_VALIDITY_DAYS = 825; // a little over 2 years -const CERT_DIRECTORY = getUserDataCertificatesPath(); +const CERT_DIRECTORY = path.join( getAppdataDirectory(), 'certificates' ); const CA_CERT_PATH = path.join( CERT_DIRECTORY, 'studio-ca.crt' ); const CA_KEY_PATH = path.join( CERT_DIRECTORY, 'studio-ca.key' ); @@ -149,10 +147,6 @@ export async function ensureRootCA(): Promise< { cert: string; key: string } > { return { cert: certPem, key: keyPem }; } -export async function openCertificate() { - shell.showItemInFolder( CA_CERT_PATH ); -} - /** * Checks if the root CA certificate is already trusted by the system * @returns A promise that resolves to true if the certificate is trusted, false otherwise @@ -223,7 +217,6 @@ export async function trustRootCA(): Promise< void > { console.error( 'Unsupported platform for automatic certificate trust:', platform ); } } catch ( error ) { - Sentry.captureException( error ); console.error( 'Failed to trust root CA:', error ); throw error; } @@ -307,7 +300,6 @@ export async function generateSiteCertificate( return { cert: certPem, key: keyPem }; } catch ( error ) { - Sentry.captureException( error ); console.error( `Failed to generate certificate for ${ domain }:`, error ); throw error; } @@ -332,7 +324,6 @@ export function deleteSiteCertificate( domain: string ): boolean { return deletedFiles; } catch ( error ) { - Sentry.captureException( error ); console.error( `Failed to delete certificate for ${ domain }:`, error ); return false; } diff --git a/cli/lib/pm2-manager.ts b/cli/lib/pm2-manager.ts new file mode 100644 index 0000000000..c573e2a657 --- /dev/null +++ b/cli/lib/pm2-manager.ts @@ -0,0 +1,338 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { getAppdataPath } from 'cli/lib/appdata'; + +function resolvePm2(): typeof import('pm2') { + try { + return require( 'pm2' ); + } catch ( error ) { + const possiblePaths: string[] = [ + path.join( __dirname, 'node_modules', 'pm2' ), + path.join( path.dirname( __dirname ), 'node_modules', 'pm2' ), + path.join( __dirname, '..', 'node_modules', 'pm2' ), + path.join( __dirname, '..', '..', 'node_modules', 'pm2' ), + path.join( __dirname, '..', '..', '..', 'node_modules', 'pm2' ), + path.resolve( process.cwd(), 'node_modules', 'pm2' ), + ]; + + for ( const pm2Path of possiblePaths ) { + if ( fs.existsSync( pm2Path ) ) { + try { + return require( pm2Path ); + } catch { + continue; + } + } + } + + throw new Error( + `pm2 module not found. Please ensure pm2 is installed in the CLI dependencies. Tried paths: ${ possiblePaths.join( + ', ' + ) }` + ); + } +} + +const pm2 = resolvePm2(); + +/** + * PM2 Manager + * + * PM2 daemon is a singleton - only one daemon can run system-wide at a time. + * When pm2.connect() is called, it will: + * - Connect to an existing daemon if one is already running + * - Start a new daemon if none exists + * + * The daemon persists across CLI invocations and is shared by all Studio CLI processes. + * The isConnected flag only tracks whether THIS process has connected to the daemon, + * not whether the daemon itself is running (which is handled by PM2 internally). + */ + +interface ProcessDescription { + name: string; + pid: number; + pm_id: number; + status: string; + pm2_env: { + status: string; + restart_time: number; + uptime: number; + pm_uptime: number; + created_at: number; + }; +} + +interface StartOptions { + name: string; + script: string; + args?: string[]; + cwd?: string; + env?: Record< string, string >; + instances?: number; + exec_mode?: 'fork' | 'cluster'; + autorestart?: boolean; + max_memory_restart?: string; +} + +let isConnected = false; + +async function connect(): Promise< void > { + if ( isConnected ) { + return; + } + + return new Promise( ( resolve, reject ) => { + pm2.connect( ( error ) => { + if ( error ) { + reject( error ); + return; + } + isConnected = true; + resolve(); + } ); + } ); +} + +function disconnect(): void { + if ( isConnected ) { + pm2.disconnect(); + isConnected = false; + } +} + +export function isDaemonRunning(): boolean { + const homeDir = os.homedir(); + const pm2Dir = path.join( homeDir, '.pm2' ); + const rpcSocket = path.join( pm2Dir, 'rpc.sock' ); + const pidFile = path.join( pm2Dir, 'pm2.pid' ); + + if ( fs.existsSync( rpcSocket ) || fs.existsSync( pidFile ) ) { + try { + if ( fs.existsSync( pidFile ) ) { + const pid = parseInt( fs.readFileSync( pidFile, 'utf-8' ).trim(), 10 ); + try { + process.kill( pid, 0 ); + return true; + } catch { + return false; + } + } + return fs.existsSync( rpcSocket ); + } catch { + return false; + } + } + + return false; +} + +export async function ensureDaemonRunning(): Promise< void > { + await connect(); +} + +export async function startDaemon(): Promise< void > { + if ( isDaemonRunning() ) { + return; + } + await connect(); + // Keep connection open - subsequent operations will reuse it + // The isConnected flag prevents duplicate connections +} + +export async function stopDaemon(): Promise< void > { + await connect(); + return new Promise( ( resolve, reject ) => { + pm2.killDaemon( ( error ) => { + disconnect(); + if ( error ) { + reject( error ); + return; + } + resolve(); + } ); + } ); +} + +export async function startProcess( options: StartOptions ): Promise< ProcessDescription > { + await ensureDaemonRunning(); + + return new Promise( ( resolve, reject ) => { + const processConfig: pm2.StartOptions = { + name: options.name, + script: options.script, + args: options.args || [], + cwd: options.cwd, + env: options.env, + instances: options.instances || 1, + exec_mode: options.exec_mode || 'fork', + autorestart: options.autorestart !== false, + max_memory_restart: options.max_memory_restart, + }; + + pm2.start( processConfig, ( error, apps ) => { + disconnect(); + if ( error ) { + reject( error ); + return; + } + + if ( ! apps || apps.length === 0 ) { + reject( new Error( 'Failed to start process' ) ); + return; + } + + resolve( apps[ 0 ] as ProcessDescription ); + } ); + } ); +} + +export async function stopProcess( name: string ): Promise< void > { + await ensureDaemonRunning(); + + return new Promise( ( resolve, reject ) => { + pm2.stop( name, ( error ) => { + disconnect(); + if ( error ) { + reject( error ); + return; + } + resolve(); + } ); + } ); +} + +export async function deleteProcess( name: string ): Promise< void > { + await ensureDaemonRunning(); + + return new Promise( ( resolve, reject ) => { + pm2.delete( name, ( error ) => { + disconnect(); + if ( error ) { + reject( error ); + return; + } + resolve(); + } ); + } ); +} + +export async function restartProcess( name: string ): Promise< void > { + await ensureDaemonRunning(); + + return new Promise( ( resolve, reject ) => { + pm2.restart( name, ( error ) => { + disconnect(); + if ( error ) { + reject( error ); + return; + } + resolve(); + } ); + } ); +} + +export async function listProcesses( autoStart = true ): Promise< ProcessDescription[] > { + if ( autoStart ) { + await ensureDaemonRunning(); + } else if ( ! isDaemonRunning() ) { + return []; + } else { + await connect(); + } + + return new Promise( ( resolve, reject ) => { + pm2.list( ( error, processes ) => { + disconnect(); + if ( error ) { + reject( error ); + return; + } + resolve( ( processes || [] ) as ProcessDescription[] ); + } ); + } ); +} + +export async function describeProcess( name: string ): Promise< ProcessDescription | null > { + const processes = await listProcesses(); + return processes.find( ( p ) => p.name === name ) || null; +} + +export function cleanup(): void { + disconnect(); +} + +process.on( 'exit', cleanup ); +process.on( 'SIGINT', cleanup ); +process.on( 'SIGTERM', cleanup ); + +/** + * Proxy Server Management Functions + * + * The proxy runs as a PM2-managed CLI process. When `studio proxy start` is called, + * PM2 starts the CLI with the proxy command and keeps it running persistently. + */ + +const PROXY_PROCESS_NAME = 'studio-proxy'; + +/** + * Start the proxy server via PM2 + * This launches the proxy-daemon.js script which runs the proxy servers + */ +export async function startProxyProcess( scriptPath: string ): Promise< ProcessDescription > { + await ensureDaemonRunning(); + + return new Promise( ( resolve, reject ) => { + const processConfig: pm2.StartOptions = { + name: PROXY_PROCESS_NAME, + script: scriptPath, + instances: 1, + exec_mode: 'fork', + autorestart: true, + max_restarts: 10, + min_uptime: '10s', + restart_delay: 3000, + kill_timeout: 5000, + uid: 0, // Run as root to bind to ports 80 and 443 + env: { + // Pass the real user's home directory so proxy can find appdata + // When running as root, os.homedir() returns /var/root instead of the user's home + STUDIO_USER_HOME: os.homedir(), + // Pass the actual appdata file path directly from CLI + STUDIO_APPDATA_PATH: getAppdataPath(), + }, + }; + + pm2.start( processConfig, ( error, apps ) => { + disconnect(); + if ( error ) { + reject( error ); + return; + } + + if ( ! apps || apps.length === 0 ) { + reject( new Error( 'Failed to start proxy process' ) ); + return; + } + + resolve( apps[ 0 ] as ProcessDescription ); + } ); + } ); +} + +/** + * Check if the proxy process is running + */ +export async function isProxyProcessRunning(): Promise< boolean > { + try { + if ( ! isDaemonRunning() ) { + return false; + } + + const processes = await listProcesses( false ); + return processes.some( ( p ) => p.name === PROXY_PROCESS_NAME && p.status === 'online' ); + } catch ( error ) { + console.error( 'Error checking if proxy is running:', error ); + return false; + } +} diff --git a/cli/lib/proxy-server.ts b/cli/lib/proxy-server.ts new file mode 100644 index 0000000000..43c1245124 --- /dev/null +++ b/cli/lib/proxy-server.ts @@ -0,0 +1,396 @@ +/** + * Proxy Server for WordPress Studio CLI + * + * This runs as part of the CLI process when `studio proxy start` is called. + * PM2 manages this CLI process to keep the proxy running persistently. + * + * The proxy listens on ports 80 (HTTP) and 443 (HTTPS) and routes requests + * to local WordPress sites based on the Host header. + */ + +import http from 'http'; +import https from 'https'; +import { watch, readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createSecureContext } from 'node:tls'; +import { domainToASCII } from 'node:url'; +import httpProxy from 'http-proxy'; +import { generateSiteCertificate } from 'cli/lib/certificate-manager'; + +interface SiteDetails { + id: string; + name: string; + port: number; + running: boolean; + customDomain?: string; + enableHttps?: boolean; +} + +let httpProxyServer: http.Server | null = null; +let httpsProxyServer: https.Server | null = null; +let isHttpProxyRunning = false; +let isHttpsProxyRunning = false; + +// Cache for site lookups +let sitesCache: SiteDetails[] = []; +let lastLoadTime = 0; +const CACHE_TTL = 5000; // 5 seconds + +const proxy = httpProxy.createProxyServer(); + +// Setup error handling for the proxy +proxy.on( 'error', ( err, req, res ) => { + console.error( '[Proxy Error]', err.message ); + if ( res && res instanceof http.ServerResponse ) { + res.writeHead( 500, { 'Content-Type': 'text/plain' } ); + res.end( 'Proxy error: ' + err.message ); + } +} ); + +/** + * Get the user data file path + */ +function getUserDataFilePath(): string { + // Use the appdata path passed from the CLI + // This is necessary because when running as root, we can't reliably calculate the user's appdata path + if ( process.env.STUDIO_APPDATA_PATH ) { + return process.env.STUDIO_APPDATA_PATH; + } + + // Fallback: try to calculate it ourselves (for backwards compatibility) + const homeDir = process.env.STUDIO_USER_HOME || os.homedir(); + const platform = process.platform; + + let appDataPath: string; + if ( platform === 'darwin' ) { + appDataPath = path.join( homeDir, 'Library/Application Support/Studio' ); + } else if ( platform === 'win32' ) { + appDataPath = path.join( homeDir, 'AppData/Roaming/Studio' ); + } else { + appDataPath = path.join( homeDir, '.config/Studio' ); + } + + return path.join( appDataPath, 'appdata-v1.json' ); +} + +/** + * Load sites from user data file + */ +function loadUserDataSync(): SiteDetails[] { + const filePath = getUserDataFilePath(); + + try { + const asString = readFileSync( filePath, 'utf-8' ); + const parsed = JSON.parse( asString ); + return parsed.sites || []; + } catch ( err ) { + if ( ( err as NodeJS.ErrnoException ).code === 'ENOENT' ) { + return []; + } + console.error( '[Proxy] Failed to load user data:', err ); + return []; + } +} + +/** + * Load sites from user data with caching + */ +function loadSites(): SiteDetails[] { + const now = Date.now(); + if ( sitesCache.length > 0 && now - lastLoadTime < CACHE_TTL ) { + return sitesCache; + } + + try { + sitesCache = loadUserDataSync(); + lastLoadTime = now; + return sitesCache; + } catch ( error ) { + console.error( '[Proxy] Error loading user data:', error ); + return sitesCache; // Return stale cache on error + } +} + +/** + * Force reload of sites cache (called when file watcher detects changes) + */ +function invalidateCache() { + sitesCache = []; + lastLoadTime = 0; +} + +/** + * Gets the site details for a given domain + */ +function getSiteByHost( domain: string ): SiteDetails | null { + try { + const sites = loadSites(); + const site = sites.find( + ( site ) => domainToASCII( site.customDomain ?? '' ) === domainToASCII( domain ) + ); + return site ?? null; + } catch ( error ) { + console.error( '[Proxy] Error looking up domain:', error ); + return null; + } +} + +/** + * Health check endpoint + */ +function handleHealthCheck( res: http.ServerResponse ) { + res.writeHead( 200, { 'Content-Type': 'application/json' } ); + res.end( + JSON.stringify( { + status: 'ok', + http: isHttpProxyRunning, + https: isHttpsProxyRunning, + timestamp: Date.now(), + } ) + ); +} + +/** + * Common handler for both HTTP and HTTPS requests + */ +function handleProxyRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + isHttps: boolean +) { + // Health check endpoint + if ( req.url === '/__studio_health' ) { + return handleHealthCheck( res ); + } + + const host = req.headers.host?.split( ':' )[ 0 ]; // Remove port if present + + if ( ! host ) { + console.log( '[Proxy] No host header found' ); + res.writeHead( 404, { 'Content-Type': 'text/plain' } ); + res.end( 'No host header found' ); + return; + } + + const site = getSiteByHost( host ); + if ( ! site ) { + console.log( `[Proxy] Domain not found: ${ host }` ); + res.writeHead( 404, { 'Content-Type': 'text/plain' } ); + res.end( `Domain not found: ${ host }` ); + return; + } + + // Note: We don't check site.running here because that field is not persisted to disk + // If the site is stopped, the proxy connection to localhost:port will fail naturally + + // If we're on HTTP and site has HTTPS enabled, redirect to HTTPS + if ( ! isHttps && site.enableHttps ) { + res.writeHead( 301, { + Location: `https://${ host }${ req.url }`, + } ); + res.end(); + return; + } + + const headers: Record< string, string > = {}; + + if ( isHttps ) { + headers[ 'X-Forwarded-Proto' ] = 'https'; + } + + proxy.web( req, res, { + target: `http://localhost:${ site.port }`, + xfwd: true, // Pass along x-forwarded headers + headers, + } ); +} + +/** + * Start HTTP proxy server on port 80 + */ +async function startHttpProxy(): Promise< void > { + if ( isHttpProxyRunning ) { + console.log( '[Proxy] HTTP proxy already running' ); + return; + } + + return new Promise< void >( ( resolve, reject ) => { + httpProxyServer = http.createServer( ( req, res ) => handleProxyRequest( req, res, false ) ); + + httpProxyServer + .listen( 80, () => { + console.log( '[Proxy] HTTP server started on port 80' ); + isHttpProxyRunning = true; + resolve(); + } ) + .on( 'error', ( err ) => { + console.error( '[Proxy] Error starting HTTP server:', err ); + reject( err ); + } ); + } ); +} + +/** + * Start HTTPS proxy server on port 443 + */ +async function startHttpsProxy(): Promise< void > { + if ( isHttpsProxyRunning ) { + console.log( '[Proxy] HTTPS proxy already running' ); + return; + } + + return new Promise< void >( ( resolve, reject ) => { + const defaultOptions: https.ServerOptions = { + SNICallback: async ( servername, cb ) => { + try { + const site = getSiteByHost( servername ); + if ( ! site || ! site.customDomain ) { + console.error( `[Proxy] SNI: Invalid hostname: ${ servername }` ); + cb( new Error( `Invalid hostname: ${ servername }` ) ); + return; + } + + // Generate or load certificate from disk + const { cert, key } = await generateSiteCertificate( site.customDomain ); + + const ctx = createSecureContext( { + key, + cert, + minVersion: 'TLSv1.2', + } ); + + cb( null, ctx ); + } catch ( error ) { + console.error( `[Proxy] SNI callback error for ${ servername }:`, error ); + cb( error as Error ); + } + }, + }; + + httpsProxyServer = https.createServer( defaultOptions, ( req, res ) => + handleProxyRequest( req, res, true ) + ); + + httpsProxyServer + .listen( 443, () => { + console.log( '[Proxy] HTTPS server started on port 443' ); + isHttpsProxyRunning = true; + resolve(); + } ) + .on( 'error', ( err ) => { + console.error( '[Proxy] Error starting HTTPS server:', err ); + reject( err ); + } ); + } ); +} + +/** + * Stop the proxy servers gracefully + */ +export async function stopProxyServers(): Promise< void > { + console.log( '[Proxy] Stopping proxy servers...' ); + + const promises: Promise< void >[] = []; + + if ( httpProxyServer ) { + promises.push( + new Promise< void >( ( resolve ) => { + httpProxyServer!.close( () => { + httpProxyServer = null; + isHttpProxyRunning = false; + console.log( '[Proxy] HTTP server stopped' ); + resolve(); + } ); + } ) + ); + } + + if ( httpsProxyServer ) { + promises.push( + new Promise< void >( ( resolve ) => { + httpsProxyServer!.close( () => { + httpsProxyServer = null; + isHttpsProxyRunning = false; + console.log( '[Proxy] HTTPS server stopped' ); + resolve(); + } ); + } ) + ); + } + + await Promise.all( promises ); + console.log( '[Proxy] All servers stopped' ); +} + +/** + * Setup file watcher for user data changes + */ +function setupFileWatcher() { + try { + const userDataPath = getUserDataFilePath(); + console.log( `[Proxy] Watching user data file: ${ userDataPath }` ); + + // Check if file exists before trying to watch it + const fs = require( 'fs' ); + if ( ! fs.existsSync( userDataPath ) ) { + console.warn( `[Proxy] User data file does not exist yet: ${ userDataPath }` ); + console.warn( '[Proxy] File watcher not set up - will use cached data only' ); + return; + } + + watch( userDataPath, ( eventType ) => { + if ( eventType === 'change' ) { + console.log( '[Proxy] User data file changed, invalidating cache' ); + invalidateCache(); + } + } ); + } catch ( error ) { + console.error( '[Proxy] Error setting up file watcher:', error ); + // Non-fatal error, continue without watching + } +} + +/** + * Start the proxy servers + * This is called by the `studio proxy start` command + */ +export async function startProxyServers(): Promise< void > { + console.log( '[Proxy] Starting WordPress Studio Proxy Server...' ); + + // Setup graceful shutdown + const shutdown = async ( signal: string ) => { + console.log( `[Proxy] Received ${ signal }, shutting down gracefully...` ); + await stopProxyServers(); + process.exit( 0 ); + }; + + process.on( 'SIGTERM', () => shutdown( 'SIGTERM' ) ); + process.on( 'SIGINT', () => shutdown( 'SIGINT' ) ); + + // Setup file watcher + setupFileWatcher(); + + try { + // Start both HTTP and HTTPS proxies + await startHttpProxy(); + await startHttpsProxy(); + + console.log( '[Proxy] Proxy servers started successfully' ); + console.log( '[Proxy] Ready to handle custom domain requests' ); + + // Keep the process running + // PM2 will manage this process and keep it alive + process.stdin.resume(); + } catch ( error ) { + console.error( '[Proxy] Failed to start proxy servers:', error ); + throw error; + } +} + +/** + * Check if the proxy is running + */ +export function isProxyRunning(): boolean { + return isHttpProxyRunning || isHttpsProxyRunning; +} diff --git a/cli/lib/sudo-exec.ts b/cli/lib/sudo-exec.ts new file mode 100644 index 0000000000..61ff6188be --- /dev/null +++ b/cli/lib/sudo-exec.ts @@ -0,0 +1,44 @@ +/** + * Sudo Execution Helper + * + * Checks if the process has elevated privileges and provides + * helpers for executing commands with sudo. + */ + +/** + * Check if the current process is running with elevated privileges + */ +export function isRunningAsRoot(): boolean { + // On Unix-like systems, check if UID is 0 (root) + if ( process.platform !== 'win32' ) { + return process.getuid?.() === 0; + } + + // On Windows, we'd need to check for admin rights + // For now, we'll return false and handle Windows separately + // TODO: Implement Windows admin check + return false; +} + +/** + * Get a helpful error message when elevated privileges are required + */ +export function getElevatedPrivilegesMessage(): string { + const platform = process.platform; + + if ( platform === 'win32' ) { + return 'The proxy server requires administrator privileges.\nPlease run this command in an elevated Command Prompt or PowerShell.'; + } + + // macOS and Linux + return `The proxy server requires elevated privileges to bind to ports 80 and 443.\nPlease run this command with sudo:\n\n sudo studio proxy start`; +} + +/** + * Check if elevated privileges are available and throw if not + */ +export function requireElevatedPrivileges(): void { + if ( ! isRunningAsRoot() ) { + throw new Error( getElevatedPrivilegesMessage() ); + } +} diff --git a/cli/package.json b/cli/package.json index b9f824b8f0..a0b7e96459 100644 --- a/cli/package.json +++ b/cli/package.json @@ -6,5 +6,9 @@ "version": "1.0.0", "description": "WordPress Studio CLI", "license": "GPL-2.0-or-later", - "main": "index.js" -} \ No newline at end of file + "main": "index.js", + "dependencies": { + "pm2": "^5.3.0", + "http-proxy": "^1.18.1" + } +} diff --git a/cli/proxy-daemon.ts b/cli/proxy-daemon.ts new file mode 100644 index 0000000000..df98ccf138 --- /dev/null +++ b/cli/proxy-daemon.ts @@ -0,0 +1,26 @@ +/** + * WordPress Studio Proxy Daemon + * + * This daemon is managed by PM2 and runs the HTTP/HTTPS proxy servers + * for custom domain support. It runs with elevated privileges to bind + * to ports 80 and 443. + * + * The proxy: + * - Reads site configurations from appdata-v1.json + * - Generates SSL certificates for custom domains on-the-fly + * - Routes HTTP/HTTPS requests to the appropriate local WordPress sites + */ + +import { startProxyServers } from 'cli/lib/proxy-server'; + +async function main() { + try { + console.log( '[Proxy Daemon] Starting WordPress Studio Proxy Daemon...' ); + await startProxyServers(); + } catch ( error ) { + console.error( '[Proxy Daemon] Failed to start:', error ); + process.exit( 1 ); + } +} + +void main(); diff --git a/package.json b/package.json index 332c004cde..99dcc7c583 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "prestart": "npm run cli:build", "start": "electron-vite dev --outDir=dist --watch", "start-wayland": "npm run prestart && electron-forge start -- --enable-features=UseOzonePlatform --ozone-platform=wayland .", - "postinstall": "patch-package && ts-node scripts/download-wp-server-files.ts && node ./scripts/download-available-site-translations.mjs", + "postinstall": "patch-package && ts-node scripts/download-wp-server-files.ts && node ./scripts/download-available-site-translations.mjs && npm install --prefix cli", "package": "electron-vite build --outDir=dist && electron-forge package", "make": "electron-vite build --outDir=dist && electron-forge make", "make:windows-x64": "electron-vite build --outDir=dist && electron-forge make --arch=x64 --platform=win32", diff --git a/vite.cli.config.ts b/vite.cli.config.ts index fc42d24eca..6165641a88 100644 --- a/vite.cli.config.ts +++ b/vite.cli.config.ts @@ -4,6 +4,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; import { existsSync } from 'fs'; const yargsLocalesPath = resolve( __dirname, 'node_modules/yargs/locales' ); +const cliNodeModulesPath = resolve( __dirname, 'cli/node_modules' ); export default defineConfig( { plugins: [ @@ -19,12 +20,26 @@ export default defineConfig( { } ), ] : [] ), + ...( existsSync( cliNodeModulesPath ) + ? [ + viteStaticCopy( { + targets: [ + { + src: 'cli/node_modules', + dest: '.', + }, + ], + } ), + ] + : [] ), ], build: { lib: { - entry: resolve( __dirname, 'cli/index.ts' ), + entry: { + main: resolve( __dirname, 'cli/index.ts' ), + 'proxy-daemon': resolve( __dirname, 'cli/proxy-daemon.ts' ), + }, name: 'StudioCLI', - fileName: 'main', formats: [ 'cjs' ], }, outDir: 'dist/cli', @@ -33,10 +48,11 @@ export default defineConfig( { external: [ /^node:/, /^(path|fs|os|child_process|crypto|http|https|http2|url|querystring|stream|util|events|buffer|assert|net|tty|readline|zlib|constants)$/, + 'pm2', ], output: { format: 'cjs', - entryFileNames: 'main.js', + entryFileNames: '[name].js', }, }, commonjsOptions: {