@avelor/mesh 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/mesh.js +63 -26
- package/package.json +1 -1
- package/src/certs.js +18 -4
- package/src/hosts.js +1 -1
- package/src/proxy.js +28 -5
package/README.md
CHANGED
package/bin/mesh.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { watch, existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
|
|
3
|
-
import { spawn }
|
|
3
|
+
import { spawn, spawnSync } from 'child_process'
|
|
4
4
|
import { dirname, resolve } from 'path'
|
|
5
5
|
import { createRequire } from 'module'
|
|
6
6
|
import { loadConfig } from '../src/config.js'
|
|
@@ -11,9 +11,18 @@ import { init } from '../src/init.js'
|
|
|
11
11
|
|
|
12
12
|
const STATE_FILE = '/tmp/.mesh.json'
|
|
13
13
|
|
|
14
|
+
// Re-exec the current command under sudo if not already root.
|
|
15
|
+
// Uses sudo -E to preserve PATH so mkcert and other tools remain findable.
|
|
16
|
+
function autoSudo() {
|
|
17
|
+
if (process.getuid?.() === 0) return
|
|
18
|
+
const result = spawnSync('sudo', ['-E', process.execPath, ...process.argv.slice(1)], { stdio: 'inherit' })
|
|
19
|
+
process.exit(result.status ?? 1)
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
const RESET = '\x1b[0m'
|
|
15
23
|
const DIM = '\x1b[2m'
|
|
16
24
|
const GREEN = '\x1b[32m'
|
|
25
|
+
const YELLOW = '\x1b[33m'
|
|
17
26
|
const CYAN = '\x1b[36m'
|
|
18
27
|
|
|
19
28
|
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
@@ -41,25 +50,48 @@ if (cmd === 'start') {
|
|
|
41
50
|
let alive = false
|
|
42
51
|
try { process.kill(state.pid, 0); alive = true } catch (e) { if (e.code === 'EPERM') alive = true }
|
|
43
52
|
if (alive) {
|
|
44
|
-
console.error(`mesh: already running (pid ${state.pid}) — run
|
|
53
|
+
console.error(`mesh: already running (pid ${state.pid}) — run mesh stop first`)
|
|
45
54
|
process.exit(1)
|
|
46
55
|
}
|
|
47
56
|
} catch { /* not running */ }
|
|
48
57
|
|
|
49
|
-
|
|
50
|
-
console.error('mesh: requires sudo to bind ports 80/443')
|
|
51
|
-
console.error(' sudo mesh start')
|
|
52
|
-
process.exit(1)
|
|
53
|
-
}
|
|
58
|
+
autoSudo()
|
|
54
59
|
|
|
55
60
|
const forwardArgs = args.filter(a => a !== 'start')
|
|
56
61
|
const child = spawn(process.execPath, [process.argv[1], 'route', ...forwardArgs], {
|
|
57
62
|
detached: true,
|
|
58
63
|
stdio: 'ignore',
|
|
59
64
|
})
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
|
|
66
|
+
const pid = child.pid
|
|
67
|
+
const deadline = Date.now() + 5000
|
|
68
|
+
|
|
69
|
+
;(function poll() {
|
|
70
|
+
let alive = true
|
|
71
|
+
try { process.kill(pid, 0) } catch (e) { if (e.code !== 'EPERM') alive = false }
|
|
72
|
+
|
|
73
|
+
if (!alive) {
|
|
74
|
+
console.error('mesh: failed to start — check your mesh.yml and that ports 80/443 are free')
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
80
|
+
if (state.pid === pid) {
|
|
81
|
+
child.unref()
|
|
82
|
+
console.log(`mesh: started (pid ${pid})`)
|
|
83
|
+
process.exit(0)
|
|
84
|
+
}
|
|
85
|
+
} catch { /* not ready yet */ }
|
|
86
|
+
|
|
87
|
+
if (Date.now() >= deadline) {
|
|
88
|
+
child.kill()
|
|
89
|
+
console.error('mesh: timed out waiting for proxy to start')
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setTimeout(poll, 50)
|
|
94
|
+
})()
|
|
63
95
|
}
|
|
64
96
|
|
|
65
97
|
if (cmd === 'stop') {
|
|
@@ -75,8 +107,7 @@ if (cmd === 'stop') {
|
|
|
75
107
|
console.log(`mesh: stopped (pid ${state.pid})`)
|
|
76
108
|
} catch (err) {
|
|
77
109
|
if (err.code === 'EPERM') {
|
|
78
|
-
|
|
79
|
-
process.exit(1)
|
|
110
|
+
autoSudo()
|
|
80
111
|
}
|
|
81
112
|
if (err.code === 'ESRCH') {
|
|
82
113
|
try { unlinkSync(STATE_FILE) } catch {}
|
|
@@ -120,27 +151,33 @@ if (cmd === 'status') {
|
|
|
120
151
|
for (const [name, port] of Object.entries(state.services)) {
|
|
121
152
|
console.log(` ${GREEN}${name.padEnd(pad)}.test${RESET} ${DIM}→ :${port} ${protocol}://${name}.test${RESET}`)
|
|
122
153
|
}
|
|
154
|
+
const rules = state.rules ?? {}
|
|
155
|
+
if (Object.keys(rules).length) {
|
|
156
|
+
console.log('')
|
|
157
|
+
for (const [svc, ruleList] of Object.entries(rules)) {
|
|
158
|
+
for (const r of ruleList) {
|
|
159
|
+
const type = r.status ? `${r.status}` : `${r.delay}ms delay`
|
|
160
|
+
const methodStr = r.method ? `${r.method} ` : ''
|
|
161
|
+
console.log(` ${YELLOW}${svc}${r.path}${RESET} ${DIM}${methodStr}${r.rate}% → ${type}${RESET}`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
123
165
|
console.log('')
|
|
124
166
|
process.exit(0)
|
|
125
167
|
}
|
|
126
168
|
|
|
127
169
|
if (cmd !== 'route') {
|
|
128
170
|
console.error('Usage:')
|
|
129
|
-
console.error(' mesh init
|
|
130
|
-
console.error('
|
|
131
|
-
console.error('
|
|
132
|
-
console.error('
|
|
133
|
-
console.error(' mesh status
|
|
134
|
-
console.error('
|
|
171
|
+
console.error(' mesh init create mesh.yml in current directory')
|
|
172
|
+
console.error(' mesh start start proxy in background')
|
|
173
|
+
console.error(' mesh start --config <path> use a specific config file')
|
|
174
|
+
console.error(' mesh stop stop the background proxy')
|
|
175
|
+
console.error(' mesh status show running services')
|
|
176
|
+
console.error(' mesh route start proxy in foreground (debug)')
|
|
135
177
|
process.exit(1)
|
|
136
178
|
}
|
|
137
179
|
|
|
138
|
-
|
|
139
|
-
console.error('mesh: requires sudo to write /etc/hosts and bind ports 80/443')
|
|
140
|
-
console.error(' both are cleaned up automatically on exit')
|
|
141
|
-
console.error(' sudo mesh route')
|
|
142
|
-
process.exit(1)
|
|
143
|
-
}
|
|
180
|
+
autoSudo()
|
|
144
181
|
|
|
145
182
|
// ── Load config ───────────────────────────────────────────────────────────────
|
|
146
183
|
|
|
@@ -160,7 +197,7 @@ const configDir = dirname(resolve(configPath))
|
|
|
160
197
|
const certs = ensureCerts(services, configDir)
|
|
161
198
|
const servers = startProxy(services, rules, certs)
|
|
162
199
|
|
|
163
|
-
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, https: !!certs }))
|
|
200
|
+
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
|
|
164
201
|
|
|
165
202
|
// ── Crash safety — clean /etc/hosts even on unexpected exit ───────────────────
|
|
166
203
|
|
|
@@ -202,7 +239,7 @@ watch(configPath, () => {
|
|
|
202
239
|
Object.assign(rules, next.rules)
|
|
203
240
|
|
|
204
241
|
writeHosts(services)
|
|
205
|
-
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, https: !!certs }))
|
|
242
|
+
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
|
|
206
243
|
|
|
207
244
|
if (certs && hasNewServices) {
|
|
208
245
|
const newCerts = ensureCerts(services, configDir)
|
package/package.json
CHANGED
package/src/certs.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from 'child_process'
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
2
|
+
import { chownSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
3
3
|
import { resolve } from 'path'
|
|
4
4
|
|
|
5
|
+
// When running under sudo, restore file ownership to the invoking user
|
|
6
|
+
// so that .mesh/ doesn't end up owned by root.
|
|
7
|
+
function fixOwnership(...paths) {
|
|
8
|
+
const uid = parseInt(process.env.SUDO_UID ?? '')
|
|
9
|
+
const gid = parseInt(process.env.SUDO_GID ?? '')
|
|
10
|
+
if (!uid || !gid) return
|
|
11
|
+
for (const p of paths) {
|
|
12
|
+
try { chownSync(p, uid, gid) } catch {}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
function mkcertInstalled() {
|
|
6
17
|
return spawnSync('which', ['mkcert']).status === 0
|
|
7
18
|
}
|
|
@@ -37,13 +48,15 @@ export function ensureCerts(services, cwd = process.cwd()) {
|
|
|
37
48
|
const keyFile = resolve(dir, 'key.pem')
|
|
38
49
|
|
|
39
50
|
mkdirSync(dir, { recursive: true })
|
|
51
|
+
fixOwnership(dir)
|
|
40
52
|
|
|
41
53
|
if (!caInstalled()) {
|
|
42
54
|
execFileSync('mkcert', ['-install'], { stdio: 'ignore' })
|
|
43
55
|
}
|
|
44
56
|
|
|
45
|
-
const domains
|
|
46
|
-
const cached
|
|
57
|
+
const domains = Object.keys(services).map(n => `${n}.test`).sort()
|
|
58
|
+
const cached = cachedDomains(dir).sort()
|
|
59
|
+
const domainsFile = resolve(dir, 'domains.json')
|
|
47
60
|
|
|
48
61
|
const needsRegen = JSON.stringify(domains) !== JSON.stringify(cached)
|
|
49
62
|
|| !existsSync(certFile)
|
|
@@ -51,7 +64,8 @@ export function ensureCerts(services, cwd = process.cwd()) {
|
|
|
51
64
|
|
|
52
65
|
if (needsRegen) {
|
|
53
66
|
execFileSync('mkcert', ['-cert-file', certFile, '-key-file', keyFile, ...domains], { stdio: 'ignore' })
|
|
54
|
-
writeFileSync(
|
|
67
|
+
writeFileSync(domainsFile, JSON.stringify(domains))
|
|
68
|
+
fixOwnership(certFile, keyFile, domainsFile)
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
return { certFile, keyFile }
|
package/src/hosts.js
CHANGED
|
@@ -8,7 +8,7 @@ export function writeHosts(services, hostsFile = HOSTS_FILE) {
|
|
|
8
8
|
const current = readFileSync(hostsFile, 'utf8')
|
|
9
9
|
const clean = removeMeshBlock(current)
|
|
10
10
|
const entries = Object.keys(services)
|
|
11
|
-
.
|
|
11
|
+
.flatMap(name => [`127.0.0.1 ${name}.test`, `::1 ${name}.test`])
|
|
12
12
|
.join('\n')
|
|
13
13
|
const next = `${clean.trimEnd()}\n\n${MESH_START}\n${entries}\n${MESH_END}\n`
|
|
14
14
|
writeFileSync(hostsFile, next, 'utf8')
|
package/src/proxy.js
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import http from 'http'
|
|
2
2
|
import https from 'https'
|
|
3
|
+
import net from 'net'
|
|
3
4
|
import { readFileSync } from 'fs'
|
|
4
5
|
import httpProxy from 'http-proxy'
|
|
5
6
|
import { matchRule, applyRule } from './rules.js'
|
|
6
7
|
import { wantsHtml, errorPage } from './error-page.js'
|
|
7
8
|
|
|
9
|
+
const hostCache = new Map()
|
|
10
|
+
|
|
11
|
+
function probeHost(port) {
|
|
12
|
+
if (hostCache.has(port)) return Promise.resolve(hostCache.get(port))
|
|
13
|
+
const probe = addr => new Promise((resolve, reject) => {
|
|
14
|
+
const s = net.connect(port, addr)
|
|
15
|
+
s.setTimeout(200)
|
|
16
|
+
s.on('connect', () => { s.destroy(); resolve(addr) })
|
|
17
|
+
s.on('timeout', () => { s.destroy(); reject(new Error('timeout')) })
|
|
18
|
+
s.on('error', reject)
|
|
19
|
+
})
|
|
20
|
+
return Promise.any([probe('127.0.0.1'), probe('::1')])
|
|
21
|
+
.then(host => { hostCache.set(port, host); return host })
|
|
22
|
+
.catch(() => '127.0.0.1')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fmtHost(host) {
|
|
26
|
+
return host.includes(':') ? `[${host}]` : host
|
|
27
|
+
}
|
|
28
|
+
|
|
8
29
|
const RESET = '\x1b[0m'
|
|
9
30
|
const DIM = '\x1b[2m'
|
|
10
31
|
const GREEN = '\x1b[32m'
|
|
@@ -65,14 +86,16 @@ export function startProxy(services, rules, certs = null) {
|
|
|
65
86
|
log(YELLOW, `${rule.delay}ms`, name, pathname, `→ :${target} (delayed)`)
|
|
66
87
|
}
|
|
67
88
|
|
|
68
|
-
|
|
89
|
+
const host = await probeHost(target)
|
|
90
|
+
proxy.web(req, res, { target: `http://${fmtHost(host)}:${target}` })
|
|
69
91
|
if (!rule) log(DIM, '→', name, pathname, `→ :${target}`)
|
|
70
92
|
}
|
|
71
93
|
|
|
72
|
-
function handleUpgrade(req, socket, head) {
|
|
94
|
+
async function handleUpgrade(req, socket, head) {
|
|
73
95
|
const { name, target } = resolveService(req.headers.host)
|
|
74
96
|
if (!target) { socket.destroy(); return }
|
|
75
|
-
|
|
97
|
+
const host = await probeHost(target)
|
|
98
|
+
proxy.ws(req, socket, head, { target: `ws://${fmtHost(host)}:${target}` }, err => {
|
|
76
99
|
if (err) log(RED, 'WSE', name, req.url, `→ ${err.code ?? err.message}`)
|
|
77
100
|
})
|
|
78
101
|
log(DIM, 'WS', name, req.url, `→ :${target}`)
|
|
@@ -98,7 +121,7 @@ export function startProxy(services, rules, certs = null) {
|
|
|
98
121
|
throw err
|
|
99
122
|
})
|
|
100
123
|
|
|
101
|
-
httpServer.listen(80, '
|
|
124
|
+
httpServer.listen(80, '::', () => onReady(services, rules, certs))
|
|
102
125
|
|
|
103
126
|
let httpsServer = null
|
|
104
127
|
|
|
@@ -115,7 +138,7 @@ export function startProxy(services, rules, certs = null) {
|
|
|
115
138
|
}
|
|
116
139
|
throw err
|
|
117
140
|
})
|
|
118
|
-
httpsServer.listen(443, '
|
|
141
|
+
httpsServer.listen(443, '::')
|
|
119
142
|
}
|
|
120
143
|
|
|
121
144
|
return { http: httpServer, https: httpsServer }
|