diff --git a/.vscode/dictionary.txt b/.vscode/dictionary.txt index 8d3458a..fedcda7 100644 --- a/.vscode/dictionary.txt +++ b/.vscode/dictionary.txt @@ -42,3 +42,4 @@ vitejs vitepress vue-demi vueus +windir diff --git a/README.md b/README.md index 96ca431..2a57505 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - Zero-Config Setup - SSL Support _(HTTPS by default)_ - Auto HTTP-to-HTTPS Redirection +- /etc/hosts Auto-Update ## Install diff --git a/src/config.ts b/src/config.ts index 40088fd..7a41956 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,7 @@ export const config: ReverseProxyConfig = await loadConfig({ validityDays: 180, verbose: false, }, + etcHostsCleanup: false, verbose: true, }, }) diff --git a/src/hosts.ts b/src/hosts.ts new file mode 100644 index 0000000..147c48a --- /dev/null +++ b/src/hosts.ts @@ -0,0 +1,211 @@ +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { log } from '@stacksjs/cli' +import { config } from './config' +import { debugLog } from './utils' + +export const hostsFilePath: string = process.platform === 'win32' + ? path.join(process.env.windir || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts') + : '/etc/hosts' + +async function sudoWrite(operation: 'append' | 'write', content: string): Promise { + return new Promise((resolve, reject) => { + if (process.platform === 'win32') { + reject(new Error('Administrator privileges required on Windows')) + return + } + + const tmpFile = path.join(os.tmpdir(), 'hosts.tmp') + + try { + if (operation === 'append') { + // For append, read current content first + const currentContent = fs.readFileSync(hostsFilePath, 'utf8') + fs.writeFileSync(tmpFile, currentContent + content, 'utf8') + } + else { + // For write, just write the new content + fs.writeFileSync(tmpFile, content, 'utf8') + } + + const sudo = spawn('sudo', ['cp', tmpFile, hostsFilePath]) + + sudo.on('close', (code) => { + try { + fs.unlinkSync(tmpFile) + if (code === 0) + resolve() + else + reject(new Error(`sudo process exited with code ${code}`)) + } + catch (err) { + reject(err) + } + }) + + sudo.on('error', (err) => { + try { + fs.unlinkSync(tmpFile) + } + catch { } + reject(err) + }) + } + catch (err) { + reject(err) + } + }) +} + +export async function addHosts(hosts: string[]): Promise { + debugLog('hosts', `Adding hosts: ${hosts.join(', ')}`, config.verbose) + debugLog('hosts', `Using hosts file at: ${hostsFilePath}`, config.verbose) + + try { + // Read existing hosts file content + const existingContent = await fs.promises.readFile(hostsFilePath, 'utf-8') + + // Prepare new entries, only including those that don't exist + const newEntries = hosts.filter((host) => { + const ipv4Entry = `127.0.0.1 ${host}` + const ipv6Entry = `::1 ${host}` + return !existingContent.includes(ipv4Entry) && !existingContent.includes(ipv6Entry) + }) + + if (newEntries.length === 0) { + debugLog('hosts', 'All hosts already exist in hosts file', config.verbose) + log.info('All hosts are already in the hosts file') + return + } + + // Create content for new entries + const hostEntries = newEntries.map(host => + `\n# Added by rpx\n127.0.0.1 ${host}\n::1 ${host}`, + ).join('\n') + + try { + // Try normal write first + await fs.promises.appendFile(hostsFilePath, hostEntries, { flag: 'a' }) + log.success(`Added new hosts: ${newEntries.join(', ')}`) + } + catch (writeErr) { + if ((writeErr as NodeJS.ErrnoException).code === 'EACCES') { + debugLog('hosts', 'Permission denied, attempting with sudo', config.verbose) + try { + await sudoWrite('append', hostEntries) + log.success(`Added new hosts with sudo: ${newEntries.join(', ')}`) + } + catch (sudoErr) { + log.error('Failed to modify hosts file automatically') + log.warn('Please add these entries to your hosts file manually:') + hostEntries.split('\n').forEach(entry => log.warn(entry)) + + if (process.platform === 'win32') { + log.warn('\nOn Windows:') + log.warn('1. Run notepad as administrator') + log.warn('2. Open C:\\Windows\\System32\\drivers\\etc\\hosts') + } + else { + log.warn('\nOn Unix systems:') + log.warn(`sudo nano ${hostsFilePath}`) + } + + throw new Error('Failed to modify hosts file: manual intervention required') + } + } + else { + throw writeErr + } + } + } + catch (err) { + const error = err as Error + log.error(`Failed to manage hosts file: ${error.message}`) + throw error + } +} + +export async function removeHosts(hosts: string[]): Promise { + debugLog('hosts', `Removing hosts: ${hosts.join(', ')}`, config.verbose) + + try { + const content = await fs.promises.readFile(hostsFilePath, 'utf-8') + const lines = content.split('\n') + + // Filter out our added entries and their comments + const filteredLines = lines.filter((line, index) => { + // If it's our comment, skip this line and the following IPv4/IPv6 entries + if (line.trim() === '# Added by rpx') { + // Skip next two lines (IPv4 and IPv6) + lines.splice(index + 1, 2) + return false + } + return true + }) + + // Remove empty lines at the end of the file + while (filteredLines[filteredLines.length - 1]?.trim() === '') + filteredLines.pop() + + // Ensure file ends with a single newline + const newContent = `${filteredLines.join('\n')}\n` + + try { + await fs.promises.writeFile(hostsFilePath, newContent) + log.success('Hosts removed successfully') + } + catch (writeErr) { + if ((writeErr as NodeJS.ErrnoException).code === 'EACCES') { + debugLog('hosts', 'Permission denied, attempting with sudo', config.verbose) + try { + await sudoWrite('write', newContent) + log.success('Hosts removed successfully with sudo') + } + catch (sudoErr) { + log.error('Failed to modify hosts file automatically') + log.warn('Please remove these entries from your hosts file manually:') + hosts.forEach((host) => { + log.warn('# Added by rpx') + log.warn(`127.0.0.1 ${host}`) + log.warn(`::1 ${host}`) + }) + + if (process.platform === 'win32') { + log.warn('\nOn Windows:') + log.warn('1. Run notepad as administrator') + log.warn('2. Open C:\\Windows\\System32\\drivers\\etc\\hosts') + } + else { + log.warn('\nOn Unix systems:') + log.warn(`sudo nano ${hostsFilePath}`) + } + + throw new Error('Failed to modify hosts file: manual intervention required') + } + } + else { + throw writeErr + } + } + } + catch (err) { + const error = err as Error + log.error(`Failed to remove hosts: ${error.message}`) + throw error + } +} + +// Helper function to check if hosts exist +export async function checkHosts(hosts: string[]): Promise { + debugLog('hosts', `Checking hosts: ${hosts}`, config.verbose) + + const content = await fs.promises.readFile(hostsFilePath, 'utf-8') + return hosts.map((host) => { + const ipv4Entry = `127.0.0.1 ${host}` + const ipv6Entry = `::1 ${host}` + return content.includes(ipv4Entry) || content.includes(ipv6Entry) + }) +} diff --git a/src/index.ts b/src/index.ts index 0db9b06..23d2949 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ export { config } from './config' +export * from './hosts' +export * from './https' export * from './start' export * from './types' diff --git a/src/start.ts b/src/start.ts index 1d08b6b..d2c57e3 100644 --- a/src/start.ts +++ b/src/start.ts @@ -12,6 +12,7 @@ import process from 'node:process' import { bold, dim, green, log } from '@stacksjs/cli' import { version } from '../package.json' import { config } from './config' +import { addHosts, checkHosts, removeHosts } from './hosts' import { generateCertificate, httpsConfig } from './https' import { debugLog } from './utils' @@ -21,12 +22,19 @@ const activeServers: Set = new Set() /** * Cleanup function to close all servers and exit gracefully */ -export function cleanup(): void { +/** + * Cleanup function to close all servers and cleanup hosts file if configured + */ +export async function cleanup(): Promise { debugLog('cleanup', 'Starting cleanup process', config.verbose) console.log(`\n`) log.info('Shutting down proxy servers...') - const closePromises = Array.from(activeServers).map(server => + // Create an array to store all cleanup promises + const cleanupPromises: Promise[] = [] + + // Add server closing promises + const serverClosePromises = Array.from(activeServers).map(server => new Promise((resolve) => { server.close(() => { debugLog('cleanup', 'Server closed successfully', config.verbose) @@ -34,16 +42,50 @@ export function cleanup(): void { }) }), ) + cleanupPromises.push(...serverClosePromises) - Promise.all(closePromises).then(() => { - debugLog('cleanup', 'All servers closed successfully', config.verbose) - log.success('All proxy servers shut down successfully') + // Add hosts file cleanup if configured + if (config.etcHostsCleanup) { + debugLog('cleanup', 'Cleaning up hosts file entries', config.verbose) + + // Parse the URL to get the hostname + try { + const toUrl = new URL(config.to.startsWith('http') ? config.to : `http://${config.to}`) + const hostname = toUrl.hostname + + // Only clean up if it's not localhost + if (!hostname.includes('localhost') && !hostname.includes('127.0.0.1')) { + log.info('Cleaning up hosts file entries...') + cleanupPromises.push( + removeHosts([hostname]) + .then(() => { + debugLog('cleanup', `Removed hosts entry for ${hostname}`, config.verbose) + }) + .catch((err) => { + debugLog('cleanup', `Failed to remove hosts entry: ${err}`, config.verbose) + log.warn(`Failed to clean up hosts file entry for ${hostname}:`, err) + // Don't throw here to allow the rest of cleanup to continue + }), + ) + } + } + catch (err) { + debugLog('cleanup', `Error parsing URL during hosts cleanup: ${err}`, config.verbose) + log.warn('Failed to parse URL for hosts cleanup:', err) + } + } + + try { + await Promise.all(cleanupPromises) + debugLog('cleanup', 'All cleanup tasks completed successfully', config.verbose) + log.success('All cleanup tasks completed successfully') process.exit(0) - }).catch((err) => { - debugLog('cleanup', `Error during shutdown: ${err}`, config.verbose) - log.error('Error during shutdown:', err) + } + catch (err) { + debugLog('cleanup', `Error during cleanup: ${err}`, config.verbose) + log.error('Error during cleanup:', err) process.exit(1) - }) + } } // Register cleanup handlers @@ -205,12 +247,57 @@ export async function startServer(options?: ReverseProxyOption): Promise { if (!options.to) options.to = config.to + // Parse URLs early to get the hostnames + const fromUrl = new URL(options.from.startsWith('http') ? options.from : `http://${options.from}`) + const toUrl = new URL(options.to.startsWith('http') ? options.to : `http://${options.to}`) + const fromPort = Number.parseInt(fromUrl.port) || (fromUrl.protocol.includes('https:') ? 443 : 80) + + // Check and update hosts file for custom domains + const hostsToCheck = [toUrl.hostname] + if (!toUrl.hostname.includes('localhost') && !toUrl.hostname.includes('127.0.0.1')) { + debugLog('hosts', `Checking if hosts file entry exists for: ${toUrl.hostname}`, options.verbose) + + try { + const hostsExist = await checkHosts(hostsToCheck) + if (!hostsExist[0]) { + log.info(`Adding ${toUrl.hostname} to hosts file...`) + log.info('This may require sudo/administrator privileges') + try { + await addHosts(hostsToCheck) + } + catch (addError) { + log.error('Failed to add hosts entry:', (addError as Error).message) + log.warn('You can manually add this entry to your hosts file:') + log.warn(`127.0.0.1 ${toUrl.hostname}`) + log.warn(`::1 ${toUrl.hostname}`) + + if (process.platform === 'win32') { + log.warn('On Windows:') + log.warn('1. Run notepad as administrator') + log.warn('2. Open C:\\Windows\\System32\\drivers\\etc\\hosts') + } + else { + log.warn('On Unix systems:') + log.warn('sudo nano /etc/hosts') + } + } + } + else { + debugLog('hosts', `Host entry already exists for ${toUrl.hostname}`, options.verbose) + } + } + catch (checkError) { + log.error('Failed to check hosts file:', (checkError as Error).message) + // Continue with proxy setup even if hosts check fails + } + } + // Check if HTTPS is configured and set SSL paths if (config.https) { if (config.https === true) config.https = httpsConfig() - const domain = config.https.altNameURIs?.[0] || new URL(options.to).hostname + const domain = toUrl.hostname if (typeof options.https !== 'boolean' && options.https) { options.https.keyPath = config.https.keyPath || path.join(os.homedir(), '.stacks', 'ssl', `${domain}.crt.key`) @@ -219,10 +306,6 @@ export async function startServer(options?: ReverseProxyOption): Promise { } } - const fromUrl = new URL(options.from.startsWith('http') ? options.from : `http://${options.from}`) - const toUrl = new URL(options.to.startsWith('http') ? options.to : `http://${options.to}`) - const fromPort = Number.parseInt(fromUrl.port) || (fromUrl.protocol.includes('https:') ? 443 : 80) - debugLog('server', `Parsed URLs - from: ${fromUrl}, to: ${toUrl}`, options.verbose) // Test connection to source server before proceeding diff --git a/src/types.ts b/src/types.ts index d9a0cac..b3cccf0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export interface ReverseProxyConfig { from: string // localhost:5173 to: string // stacks.localhost https: boolean | TlsConfig + etcHostsCleanup: boolean verbose: boolean }