Skip to content

Commit 53c930f

Browse files
committed
feat: /etc/hosts management
chore: add eslint ignore comments
1 parent 5d62350 commit 53c930f

File tree

7 files changed

+316
-14
lines changed

7 files changed

+316
-14
lines changed

.vscode/dictionary.txt

+1
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ vitejs
4242
vitepress
4343
vue-demi
4444
vueus
45+
windir

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- Zero-Config Setup
1818
- SSL Support _(HTTPS by default)_
1919
- Auto HTTP-to-HTTPS Redirection
20+
- /etc/hosts Auto-Update
2021

2122
## Install
2223

src/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const config: ReverseProxyConfig = await loadConfig({
2525
validityDays: 180,
2626
verbose: false,
2727
},
28+
etcHostsCleanup: false,
2829
verbose: true,
2930
},
3031
})

src/hosts.ts

+213
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { spawn } from 'node:child_process'
2+
import fs from 'node:fs'
3+
import os from 'node:os'
4+
import path from 'node:path'
5+
import process from 'node:process'
6+
import { log } from '@stacksjs/cli'
7+
import { config } from './config'
8+
import { debugLog } from './utils'
9+
10+
export const hostsFilePath: string = process.platform === 'win32'
11+
? path.join(process.env.windir || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')
12+
: '/etc/hosts'
13+
14+
async function sudoWrite(operation: 'append' | 'write', content: string): Promise<void> {
15+
return new Promise((resolve, reject) => {
16+
if (process.platform === 'win32') {
17+
reject(new Error('Administrator privileges required on Windows'))
18+
return
19+
}
20+
21+
const tmpFile = path.join(os.tmpdir(), 'hosts.tmp')
22+
23+
try {
24+
if (operation === 'append') {
25+
// For append, read current content first
26+
const currentContent = fs.readFileSync(hostsFilePath, 'utf8')
27+
fs.writeFileSync(tmpFile, currentContent + content, 'utf8')
28+
}
29+
else {
30+
// For write, just write the new content
31+
fs.writeFileSync(tmpFile, content, 'utf8')
32+
}
33+
34+
const sudo = spawn('sudo', ['cp', tmpFile, hostsFilePath])
35+
36+
sudo.on('close', (code) => {
37+
try {
38+
fs.unlinkSync(tmpFile)
39+
if (code === 0)
40+
resolve()
41+
else
42+
reject(new Error(`sudo process exited with code ${code}`))
43+
}
44+
catch (err) {
45+
reject(err)
46+
}
47+
})
48+
49+
sudo.on('error', (err) => {
50+
try {
51+
fs.unlinkSync(tmpFile)
52+
}
53+
catch { }
54+
reject(err)
55+
})
56+
}
57+
catch (err) {
58+
reject(err)
59+
}
60+
})
61+
}
62+
63+
export async function addHosts(hosts: string[]): Promise<void> {
64+
debugLog('hosts', `Adding hosts: ${hosts.join(', ')}`, config.verbose)
65+
debugLog('hosts', `Using hosts file at: ${hostsFilePath}`, config.verbose)
66+
67+
try {
68+
// Read existing hosts file content
69+
const existingContent = await fs.promises.readFile(hostsFilePath, 'utf-8')
70+
71+
// Prepare new entries, only including those that don't exist
72+
const newEntries = hosts.filter((host) => {
73+
const ipv4Entry = `127.0.0.1 ${host}`
74+
const ipv6Entry = `::1 ${host}`
75+
return !existingContent.includes(ipv4Entry) && !existingContent.includes(ipv6Entry)
76+
})
77+
78+
if (newEntries.length === 0) {
79+
debugLog('hosts', 'All hosts already exist in hosts file', config.verbose)
80+
log.info('All hosts are already in the hosts file')
81+
return
82+
}
83+
84+
// Create content for new entries
85+
const hostEntries = newEntries.map(host =>
86+
`\n# Added by rpx\n127.0.0.1 ${host}\n::1 ${host}`,
87+
).join('\n')
88+
89+
try {
90+
// Try normal write first
91+
await fs.promises.appendFile(hostsFilePath, hostEntries, { flag: 'a' })
92+
log.success(`Added new hosts: ${newEntries.join(', ')}`)
93+
}
94+
catch (writeErr) {
95+
if ((writeErr as NodeJS.ErrnoException).code === 'EACCES') {
96+
debugLog('hosts', 'Permission denied, attempting with sudo', config.verbose)
97+
try {
98+
await sudoWrite('append', hostEntries)
99+
log.success(`Added new hosts with sudo: ${newEntries.join(', ')}`)
100+
}
101+
// eslint-disable-next-line unused-imports/no-unused-vars
102+
catch (sudoErr) {
103+
log.error('Failed to modify hosts file automatically')
104+
log.warn('Please add these entries to your hosts file manually:')
105+
hostEntries.split('\n').forEach(entry => log.warn(entry))
106+
107+
if (process.platform === 'win32') {
108+
log.warn('\nOn Windows:')
109+
log.warn('1. Run notepad as administrator')
110+
log.warn('2. Open C:\\Windows\\System32\\drivers\\etc\\hosts')
111+
}
112+
else {
113+
log.warn('\nOn Unix systems:')
114+
log.warn(`sudo nano ${hostsFilePath}`)
115+
}
116+
117+
throw new Error('Failed to modify hosts file: manual intervention required')
118+
}
119+
}
120+
else {
121+
throw writeErr
122+
}
123+
}
124+
}
125+
catch (err) {
126+
const error = err as Error
127+
log.error(`Failed to manage hosts file: ${error.message}`)
128+
throw error
129+
}
130+
}
131+
132+
export async function removeHosts(hosts: string[]): Promise<void> {
133+
debugLog('hosts', `Removing hosts: ${hosts.join(', ')}`, config.verbose)
134+
135+
try {
136+
const content = await fs.promises.readFile(hostsFilePath, 'utf-8')
137+
const lines = content.split('\n')
138+
139+
// Filter out our added entries and their comments
140+
const filteredLines = lines.filter((line, index) => {
141+
// If it's our comment, skip this line and the following IPv4/IPv6 entries
142+
if (line.trim() === '# Added by rpx') {
143+
// Skip next two lines (IPv4 and IPv6)
144+
lines.splice(index + 1, 2)
145+
return false
146+
}
147+
return true
148+
})
149+
150+
// Remove empty lines at the end of the file
151+
while (filteredLines[filteredLines.length - 1]?.trim() === '')
152+
filteredLines.pop()
153+
154+
// Ensure file ends with a single newline
155+
const newContent = `${filteredLines.join('\n')}\n`
156+
157+
try {
158+
await fs.promises.writeFile(hostsFilePath, newContent)
159+
log.success('Hosts removed successfully')
160+
}
161+
catch (writeErr) {
162+
if ((writeErr as NodeJS.ErrnoException).code === 'EACCES') {
163+
debugLog('hosts', 'Permission denied, attempting with sudo', config.verbose)
164+
try {
165+
await sudoWrite('write', newContent)
166+
log.success('Hosts removed successfully with sudo')
167+
}
168+
// eslint-disable-next-line unused-imports/no-unused-vars
169+
catch (sudoErr) {
170+
log.error('Failed to modify hosts file automatically')
171+
log.warn('Please remove these entries from your hosts file manually:')
172+
hosts.forEach((host) => {
173+
log.warn('# Added by rpx')
174+
log.warn(`127.0.0.1 ${host}`)
175+
log.warn(`::1 ${host}`)
176+
})
177+
178+
if (process.platform === 'win32') {
179+
log.warn('\nOn Windows:')
180+
log.warn('1. Run notepad as administrator')
181+
log.warn('2. Open C:\\Windows\\System32\\drivers\\etc\\hosts')
182+
}
183+
else {
184+
log.warn('\nOn Unix systems:')
185+
log.warn(`sudo nano ${hostsFilePath}`)
186+
}
187+
188+
throw new Error('Failed to modify hosts file: manual intervention required')
189+
}
190+
}
191+
else {
192+
throw writeErr
193+
}
194+
}
195+
}
196+
catch (err) {
197+
const error = err as Error
198+
log.error(`Failed to remove hosts: ${error.message}`)
199+
throw error
200+
}
201+
}
202+
203+
// Helper function to check if hosts exist
204+
export async function checkHosts(hosts: string[]): Promise<boolean[]> {
205+
debugLog('hosts', `Checking hosts: ${hosts}`, config.verbose)
206+
207+
const content = await fs.promises.readFile(hostsFilePath, 'utf-8')
208+
return hosts.map((host) => {
209+
const ipv4Entry = `127.0.0.1 ${host}`
210+
const ipv6Entry = `::1 ${host}`
211+
return content.includes(ipv4Entry) || content.includes(ipv6Entry)
212+
})
213+
}

src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export { config } from './config'
2+
export * from './hosts'
3+
export * from './https'
24
export * from './start'
35
export * from './types'

src/start.ts

+97-14
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import process from 'node:process'
1212
import { bold, dim, green, log } from '@stacksjs/cli'
1313
import { version } from '../package.json'
1414
import { config } from './config'
15+
import { addHosts, checkHosts, removeHosts } from './hosts'
1516
import { generateCertificate, httpsConfig } from './https'
1617
import { debugLog } from './utils'
1718

@@ -21,29 +22,70 @@ const activeServers: Set<http.Server | https.Server> = new Set()
2122
/**
2223
* Cleanup function to close all servers and exit gracefully
2324
*/
24-
export function cleanup(): void {
25+
/**
26+
* Cleanup function to close all servers and cleanup hosts file if configured
27+
*/
28+
export async function cleanup(): Promise<void> {
2529
debugLog('cleanup', 'Starting cleanup process', config.verbose)
2630
console.log(`\n`)
2731
log.info('Shutting down proxy servers...')
2832

29-
const closePromises = Array.from(activeServers).map(server =>
33+
// Create an array to store all cleanup promises
34+
const cleanupPromises: Promise<void>[] = []
35+
36+
// Add server closing promises
37+
const serverClosePromises = Array.from(activeServers).map(server =>
3038
new Promise<void>((resolve) => {
3139
server.close(() => {
3240
debugLog('cleanup', 'Server closed successfully', config.verbose)
3341
resolve()
3442
})
3543
}),
3644
)
45+
cleanupPromises.push(...serverClosePromises)
3746

38-
Promise.all(closePromises).then(() => {
39-
debugLog('cleanup', 'All servers closed successfully', config.verbose)
40-
log.success('All proxy servers shut down successfully')
47+
// Add hosts file cleanup if configured
48+
if (config.etcHostsCleanup) {
49+
debugLog('cleanup', 'Cleaning up hosts file entries', config.verbose)
50+
51+
// Parse the URL to get the hostname
52+
try {
53+
const toUrl = new URL(config.to.startsWith('http') ? config.to : `http://${config.to}`)
54+
const hostname = toUrl.hostname
55+
56+
// Only clean up if it's not localhost
57+
if (!hostname.includes('localhost') && !hostname.includes('127.0.0.1')) {
58+
log.info('Cleaning up hosts file entries...')
59+
cleanupPromises.push(
60+
removeHosts([hostname])
61+
.then(() => {
62+
debugLog('cleanup', `Removed hosts entry for ${hostname}`, config.verbose)
63+
})
64+
.catch((err) => {
65+
debugLog('cleanup', `Failed to remove hosts entry: ${err}`, config.verbose)
66+
log.warn(`Failed to clean up hosts file entry for ${hostname}:`, err)
67+
// Don't throw here to allow the rest of cleanup to continue
68+
}),
69+
)
70+
}
71+
}
72+
catch (err) {
73+
debugLog('cleanup', `Error parsing URL during hosts cleanup: ${err}`, config.verbose)
74+
log.warn('Failed to parse URL for hosts cleanup:', err)
75+
}
76+
}
77+
78+
try {
79+
await Promise.all(cleanupPromises)
80+
debugLog('cleanup', 'All cleanup tasks completed successfully', config.verbose)
81+
log.success('All cleanup tasks completed successfully')
4182
process.exit(0)
42-
}).catch((err) => {
43-
debugLog('cleanup', `Error during shutdown: ${err}`, config.verbose)
44-
log.error('Error during shutdown:', err)
83+
}
84+
catch (err) {
85+
debugLog('cleanup', `Error during cleanup: ${err}`, config.verbose)
86+
log.error('Error during cleanup:', err)
4587
process.exit(1)
46-
})
88+
}
4789
}
4890

4991
// Register cleanup handlers
@@ -205,12 +247,57 @@ export async function startServer(options?: ReverseProxyOption): Promise<void> {
205247
if (!options.to)
206248
options.to = config.to
207249

250+
// Parse URLs early to get the hostnames
251+
const fromUrl = new URL(options.from.startsWith('http') ? options.from : `http://${options.from}`)
252+
const toUrl = new URL(options.to.startsWith('http') ? options.to : `http://${options.to}`)
253+
const fromPort = Number.parseInt(fromUrl.port) || (fromUrl.protocol.includes('https:') ? 443 : 80)
254+
255+
// Check and update hosts file for custom domains
256+
const hostsToCheck = [toUrl.hostname]
257+
if (!toUrl.hostname.includes('localhost') && !toUrl.hostname.includes('127.0.0.1')) {
258+
debugLog('hosts', `Checking if hosts file entry exists for: ${toUrl.hostname}`, options.verbose)
259+
260+
try {
261+
const hostsExist = await checkHosts(hostsToCheck)
262+
if (!hostsExist[0]) {
263+
log.info(`Adding ${toUrl.hostname} to hosts file...`)
264+
log.info('This may require sudo/administrator privileges')
265+
try {
266+
await addHosts(hostsToCheck)
267+
}
268+
catch (addError) {
269+
log.error('Failed to add hosts entry:', (addError as Error).message)
270+
log.warn('You can manually add this entry to your hosts file:')
271+
log.warn(`127.0.0.1 ${toUrl.hostname}`)
272+
log.warn(`::1 ${toUrl.hostname}`)
273+
274+
if (process.platform === 'win32') {
275+
log.warn('On Windows:')
276+
log.warn('1. Run notepad as administrator')
277+
log.warn('2. Open C:\\Windows\\System32\\drivers\\etc\\hosts')
278+
}
279+
else {
280+
log.warn('On Unix systems:')
281+
log.warn('sudo nano /etc/hosts')
282+
}
283+
}
284+
}
285+
else {
286+
debugLog('hosts', `Host entry already exists for ${toUrl.hostname}`, options.verbose)
287+
}
288+
}
289+
catch (checkError) {
290+
log.error('Failed to check hosts file:', (checkError as Error).message)
291+
// Continue with proxy setup even if hosts check fails
292+
}
293+
}
294+
208295
// Check if HTTPS is configured and set SSL paths
209296
if (config.https) {
210297
if (config.https === true)
211298
config.https = httpsConfig()
212299

213-
const domain = config.https.altNameURIs?.[0] || new URL(options.to).hostname
300+
const domain = toUrl.hostname
214301

215302
if (typeof options.https !== 'boolean' && options.https) {
216303
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<void> {
219306
}
220307
}
221308

222-
const fromUrl = new URL(options.from.startsWith('http') ? options.from : `http://${options.from}`)
223-
const toUrl = new URL(options.to.startsWith('http') ? options.to : `http://${options.to}`)
224-
const fromPort = Number.parseInt(fromUrl.port) || (fromUrl.protocol.includes('https:') ? 443 : 80)
225-
226309
debugLog('server', `Parsed URLs - from: ${fromUrl}, to: ${toUrl}`, options.verbose)
227310

228311
// Test connection to source server before proceeding

0 commit comments

Comments
 (0)