Skip to content

Commit

Permalink
feat: /etc/hosts management
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisbbreuer committed Nov 17, 2024
1 parent 5d62350 commit c67ddbf
Show file tree
Hide file tree
Showing 7 changed files with 314 additions and 14 deletions.
1 change: 1 addition & 0 deletions .vscode/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ vitejs
vitepress
vue-demi
vueus
windir
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Zero-Config Setup
- SSL Support _(HTTPS by default)_
- Auto HTTP-to-HTTPS Redirection
- /etc/hosts Auto-Update

## Install

Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const config: ReverseProxyConfig = await loadConfig({
validityDays: 180,
verbose: false,
},
etcHostsCleanup: false,
verbose: true,
},
})
211 changes: 211 additions & 0 deletions src/hosts.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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<boolean[]> {
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)
})
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { config } from './config'
export * from './hosts'
export * from './https'
export * from './start'
export * from './types'
111 changes: 97 additions & 14 deletions src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -21,29 +22,70 @@ const activeServers: Set<http.Server | https.Server> = 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<void> {
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<void>[] = []

// Add server closing promises
const serverClosePromises = Array.from(activeServers).map(server =>
new Promise<void>((resolve) => {
server.close(() => {
debugLog('cleanup', 'Server closed successfully', config.verbose)
resolve()
})
}),
)
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
Expand Down Expand Up @@ -205,12 +247,57 @@ export async function startServer(options?: ReverseProxyOption): Promise<void> {
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`)
Expand All @@ -219,10 +306,6 @@ export async function startServer(options?: ReverseProxyOption): Promise<void> {
}
}

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
Expand Down
Loading

0 comments on commit c67ddbf

Please sign in to comment.