@avelor/mesh 0.1.0 → 0.2.1
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 +19 -10
- package/bin/mesh.js +147 -4
- package/package.json +4 -1
- package/src/config.js +7 -1
- package/src/error-page.js +68 -0
- package/src/hosts.js +9 -14
- package/src/proxy.js +51 -14
- package/src/rules.js +8 -3
package/README.md
CHANGED
|
@@ -25,11 +25,14 @@ Requires Node.js 18+. For HTTPS, install [mkcert](https://github.com/FiloSottile
|
|
|
25
25
|
## Usage
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
mesh init
|
|
29
|
-
sudo mesh
|
|
28
|
+
mesh init # create mesh.yml in current directory
|
|
29
|
+
sudo mesh start # start proxy in background
|
|
30
|
+
mesh status # show running services
|
|
31
|
+
sudo mesh stop # stop the proxy
|
|
32
|
+
sudo mesh route # start in foreground (useful for debugging)
|
|
30
33
|
```
|
|
31
34
|
|
|
32
|
-
`mesh
|
|
35
|
+
`mesh start` runs on `:80` and `:443` (if mkcert is available), writes the hostname entries to `/etc/hosts`, and cleans them up automatically on stop.
|
|
33
36
|
|
|
34
37
|
---
|
|
35
38
|
|
|
@@ -56,7 +59,11 @@ services:
|
|
|
56
59
|
rules:
|
|
57
60
|
api:
|
|
58
61
|
- path: /payments
|
|
62
|
+
method: POST
|
|
59
63
|
status: 503
|
|
64
|
+
body:
|
|
65
|
+
error: Payment service unavailable
|
|
66
|
+
retryAfter: 30
|
|
60
67
|
rate: 30
|
|
61
68
|
- path: /auth/login
|
|
62
69
|
status: 401
|
|
@@ -70,14 +77,16 @@ rules:
|
|
|
70
77
|
rate: 25
|
|
71
78
|
```
|
|
72
79
|
|
|
73
|
-
| Field | Description
|
|
74
|
-
|
|
75
|
-
| `path` | Request path prefix to match
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
80
|
+
| Field | Description |
|
|
81
|
+
|----------|---------------------------------------------------------------|
|
|
82
|
+
| `path` | Request path prefix to match |
|
|
83
|
+
| `method` | HTTP method to match (GET, POST, PUT, PATCH, DELETE…) |
|
|
84
|
+
| `status` | HTTP status code to return (400, 401, 500, etc.) |
|
|
85
|
+
| `body` | Response body — object (sent as JSON) or string (plain text) |
|
|
86
|
+
| `delay` | Milliseconds to wait before responding |
|
|
87
|
+
| `rate` | Percentage of matching requests to affect (1–100) |
|
|
79
88
|
|
|
80
|
-
`status` and `delay` can be combined: wait N ms, then fail with that status.
|
|
89
|
+
`status` and `delay` can be combined: wait N ms, then fail with that status. Without `body`, the default is `{ "error": "<status text>", "injected": true }`.
|
|
81
90
|
|
|
82
91
|
### Subdomains
|
|
83
92
|
|
package/bin/mesh.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { watch, existsSync, readFileSync } from 'fs'
|
|
2
|
+
import { watch, existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
|
|
3
|
+
import { spawn } from 'child_process'
|
|
3
4
|
import { dirname, resolve } from 'path'
|
|
4
5
|
import { createRequire } from 'module'
|
|
5
6
|
import { loadConfig } from '../src/config.js'
|
|
@@ -8,6 +9,14 @@ import { startProxy } from '../src/proxy.js'
|
|
|
8
9
|
import { ensureCerts } from '../src/certs.js'
|
|
9
10
|
import { init } from '../src/init.js'
|
|
10
11
|
|
|
12
|
+
const STATE_FILE = '/tmp/.mesh.json'
|
|
13
|
+
|
|
14
|
+
const RESET = '\x1b[0m'
|
|
15
|
+
const DIM = '\x1b[2m'
|
|
16
|
+
const GREEN = '\x1b[32m'
|
|
17
|
+
const YELLOW = '\x1b[33m'
|
|
18
|
+
const CYAN = '\x1b[36m'
|
|
19
|
+
|
|
11
20
|
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
12
21
|
|
|
13
22
|
const args = process.argv.slice(2)
|
|
@@ -27,11 +36,141 @@ if (cmd === 'init') {
|
|
|
27
36
|
process.exit(0)
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
if (cmd === 'start') {
|
|
40
|
+
try {
|
|
41
|
+
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
42
|
+
let alive = false
|
|
43
|
+
try { process.kill(state.pid, 0); alive = true } catch (e) { if (e.code === 'EPERM') alive = true }
|
|
44
|
+
if (alive) {
|
|
45
|
+
console.error(`mesh: already running (pid ${state.pid}) — run sudo mesh stop first`)
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
} catch { /* not running */ }
|
|
49
|
+
|
|
50
|
+
if (process.getuid?.() !== 0) {
|
|
51
|
+
console.error('mesh: requires sudo to bind ports 80/443')
|
|
52
|
+
console.error(' sudo mesh start')
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const forwardArgs = args.filter(a => a !== 'start')
|
|
57
|
+
const child = spawn(process.execPath, [process.argv[1], 'route', ...forwardArgs], {
|
|
58
|
+
detached: true,
|
|
59
|
+
stdio: 'ignore',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const pid = child.pid
|
|
63
|
+
const deadline = Date.now() + 5000
|
|
64
|
+
|
|
65
|
+
;(function poll() {
|
|
66
|
+
let alive = true
|
|
67
|
+
try { process.kill(pid, 0) } catch (e) { if (e.code !== 'EPERM') alive = false }
|
|
68
|
+
|
|
69
|
+
if (!alive) {
|
|
70
|
+
console.error('mesh: failed to start — check your mesh.yml and that ports 80/443 are free')
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
76
|
+
if (state.pid === pid) {
|
|
77
|
+
child.unref()
|
|
78
|
+
console.log(`mesh: started (pid ${pid})`)
|
|
79
|
+
process.exit(0)
|
|
80
|
+
}
|
|
81
|
+
} catch { /* not ready yet */ }
|
|
82
|
+
|
|
83
|
+
if (Date.now() >= deadline) {
|
|
84
|
+
child.kill()
|
|
85
|
+
console.error('mesh: timed out waiting for proxy to start')
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setTimeout(poll, 50)
|
|
90
|
+
})()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (cmd === 'stop') {
|
|
94
|
+
let state
|
|
95
|
+
try {
|
|
96
|
+
state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
97
|
+
} catch {
|
|
98
|
+
console.error('mesh: not running')
|
|
99
|
+
process.exit(1)
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
process.kill(state.pid, 'SIGTERM')
|
|
103
|
+
console.log(`mesh: stopped (pid ${state.pid})`)
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err.code === 'EPERM') {
|
|
106
|
+
console.error(`mesh: permission denied — try: sudo mesh stop`)
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
if (err.code === 'ESRCH') {
|
|
110
|
+
try { unlinkSync(STATE_FILE) } catch {}
|
|
111
|
+
console.error('mesh: not running (stale state removed)')
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
console.error('mesh:', err.message)
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
process.exit(0)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (cmd === 'status') {
|
|
121
|
+
let state
|
|
122
|
+
try {
|
|
123
|
+
state = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
124
|
+
} catch {
|
|
125
|
+
console.error('mesh: not running')
|
|
126
|
+
process.exit(1)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let alive = false
|
|
130
|
+
try {
|
|
131
|
+
process.kill(state.pid, 0)
|
|
132
|
+
alive = true
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err.code === 'EPERM') alive = true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!alive) {
|
|
138
|
+
try { unlinkSync(STATE_FILE) } catch {}
|
|
139
|
+
console.error('mesh: not running (stale state removed)')
|
|
140
|
+
process.exit(1)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const protocol = state.https ? 'https' : 'http'
|
|
144
|
+
const pad = Math.max(...Object.keys(state.services).map(s => s.length))
|
|
145
|
+
console.log('')
|
|
146
|
+
console.log(` ${CYAN}mesh${RESET} running ${DIM}pid ${state.pid}${RESET}`)
|
|
147
|
+
console.log('')
|
|
148
|
+
for (const [name, port] of Object.entries(state.services)) {
|
|
149
|
+
console.log(` ${GREEN}${name.padEnd(pad)}.test${RESET} ${DIM}→ :${port} ${protocol}://${name}.test${RESET}`)
|
|
150
|
+
}
|
|
151
|
+
const rules = state.rules ?? {}
|
|
152
|
+
if (Object.keys(rules).length) {
|
|
153
|
+
console.log('')
|
|
154
|
+
for (const [svc, ruleList] of Object.entries(rules)) {
|
|
155
|
+
for (const r of ruleList) {
|
|
156
|
+
const type = r.status ? `${r.status}` : `${r.delay}ms delay`
|
|
157
|
+
const methodStr = r.method ? `${r.method} ` : ''
|
|
158
|
+
console.log(` ${YELLOW}${svc}${r.path}${RESET} ${DIM}${methodStr}${r.rate}% → ${type}${RESET}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
console.log('')
|
|
163
|
+
process.exit(0)
|
|
164
|
+
}
|
|
165
|
+
|
|
30
166
|
if (cmd !== 'route') {
|
|
31
167
|
console.error('Usage:')
|
|
32
|
-
console.error(' mesh init
|
|
33
|
-
console.error(' sudo mesh
|
|
34
|
-
console.error(' sudo mesh
|
|
168
|
+
console.error(' mesh init create mesh.yml in current directory')
|
|
169
|
+
console.error(' sudo mesh start start proxy in background')
|
|
170
|
+
console.error(' sudo mesh start --config <path> use a specific config file')
|
|
171
|
+
console.error(' sudo mesh stop stop the background proxy')
|
|
172
|
+
console.error(' mesh status show running services')
|
|
173
|
+
console.error(' sudo mesh route start proxy in foreground (debug)')
|
|
35
174
|
process.exit(1)
|
|
36
175
|
}
|
|
37
176
|
|
|
@@ -60,10 +199,13 @@ const configDir = dirname(resolve(configPath))
|
|
|
60
199
|
const certs = ensureCerts(services, configDir)
|
|
61
200
|
const servers = startProxy(services, rules, certs)
|
|
62
201
|
|
|
202
|
+
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
|
|
203
|
+
|
|
63
204
|
// ── Crash safety — clean /etc/hosts even on unexpected exit ───────────────────
|
|
64
205
|
|
|
65
206
|
function shutdown(code = 0) {
|
|
66
207
|
removeHosts()
|
|
208
|
+
try { unlinkSync(STATE_FILE) } catch {}
|
|
67
209
|
servers.http.close()
|
|
68
210
|
servers.https?.close()
|
|
69
211
|
process.exit(code)
|
|
@@ -99,6 +241,7 @@ watch(configPath, () => {
|
|
|
99
241
|
Object.assign(rules, next.rules)
|
|
100
242
|
|
|
101
243
|
writeHosts(services)
|
|
244
|
+
writeFileSync(STATE_FILE, JSON.stringify({ pid: process.pid, configPath, services, rules, https: !!certs }))
|
|
102
245
|
|
|
103
246
|
if (certs && hasNewServices) {
|
|
104
247
|
const newCerts = ensureCerts(services, configDir)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avelor/mesh",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Local dev proxy. Named services, failure rules, no ports.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"proxy",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"http-proxy": "^1.18.1",
|
|
36
36
|
"js-yaml": "^4.1.0"
|
|
37
37
|
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node --test test/*.test.js"
|
|
40
|
+
},
|
|
38
41
|
"engines": {
|
|
39
42
|
"node": ">=18"
|
|
40
43
|
}
|
package/src/config.js
CHANGED
|
@@ -2,7 +2,8 @@ import { readFileSync, existsSync } from 'fs'
|
|
|
2
2
|
import { resolve } from 'path'
|
|
3
3
|
import yaml from 'js-yaml'
|
|
4
4
|
|
|
5
|
-
const NAME_RE
|
|
5
|
+
const NAME_RE = /^[a-z0-9][a-z0-9.-]*$/
|
|
6
|
+
const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])
|
|
6
7
|
|
|
7
8
|
export function findConfigFile(cwd = process.cwd()) {
|
|
8
9
|
for (const name of ['mesh.yml', 'mesh.yaml']) {
|
|
@@ -53,11 +54,16 @@ export function loadConfig(cwdOrFile = process.cwd()) {
|
|
|
53
54
|
if (r.delay !== undefined && (typeof r.delay !== 'number' || r.delay < 0)) {
|
|
54
55
|
throw new Error(`${prefix}.delay must be a positive number`)
|
|
55
56
|
}
|
|
57
|
+
if (r.method !== undefined && !METHODS.has(r.method.toUpperCase())) {
|
|
58
|
+
throw new Error(`${prefix}.method must be a valid HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)`)
|
|
59
|
+
}
|
|
56
60
|
return {
|
|
57
61
|
path: r.path ?? '/',
|
|
62
|
+
method: r.method ? r.method.toUpperCase() : null,
|
|
58
63
|
status: r.status ?? null,
|
|
59
64
|
delay: r.delay ?? null,
|
|
60
65
|
rate: r.rate ?? 100,
|
|
66
|
+
body: r.body ?? null,
|
|
61
67
|
}
|
|
62
68
|
})
|
|
63
69
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export function wantsHtml(req) {
|
|
2
|
+
return (req.headers.accept ?? '').includes('text/html')
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function errorPage(status, name, { services = null, port = null, protocol = 'http' } = {}) {
|
|
6
|
+
const is404 = status === 404
|
|
7
|
+
const heading = is404
|
|
8
|
+
? `<span class="hl">${name}.test</span> not found`
|
|
9
|
+
: `<span class="hl">${name}.test</span> is not responding`
|
|
10
|
+
const message = is404
|
|
11
|
+
? 'No service is configured for this hostname.'
|
|
12
|
+
: `The service is configured but not reachable on <span class="mono">:${port}</span>. Is it running?`
|
|
13
|
+
|
|
14
|
+
const servicesBlock = (is404 && services && Object.keys(services).length)
|
|
15
|
+
? `<div class="services">
|
|
16
|
+
<div class="label">configured services</div>
|
|
17
|
+
${Object.entries(services).map(([n, p]) =>
|
|
18
|
+
`<div class="row">
|
|
19
|
+
<a class="name" href="${protocol}://${n}.test">${n}.test</a>
|
|
20
|
+
<span class="port">:${p}</span>
|
|
21
|
+
</div>`
|
|
22
|
+
).join('')}
|
|
23
|
+
</div>`
|
|
24
|
+
: ''
|
|
25
|
+
|
|
26
|
+
return `<!DOCTYPE html>
|
|
27
|
+
<html lang="en">
|
|
28
|
+
<head>
|
|
29
|
+
<meta charset="utf-8">
|
|
30
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
31
|
+
<title>mesh — ${status}</title>
|
|
32
|
+
<style>
|
|
33
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
34
|
+
body {
|
|
35
|
+
background: #0d0d0d;
|
|
36
|
+
color: #555;
|
|
37
|
+
font-family: 'SF Mono', ui-monospace, 'Cascadia Code', monospace;
|
|
38
|
+
font-size: 13px;
|
|
39
|
+
min-height: 100vh;
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
padding: 40px 24px;
|
|
44
|
+
}
|
|
45
|
+
.wrap { width: 100%; max-width: 440px; }
|
|
46
|
+
.status { color: #2a2a2a; font-size: 11px; letter-spacing: 0.08em; margin-bottom: 28px; }
|
|
47
|
+
h1 { color: #ccc; font-size: 16px; font-weight: 500; line-height: 1.5; margin-bottom: 10px; }
|
|
48
|
+
.hl { color: #e2e2e2; }
|
|
49
|
+
p { line-height: 1.7; }
|
|
50
|
+
.mono { font-family: inherit; color: #888; }
|
|
51
|
+
.services { margin-top: 40px; }
|
|
52
|
+
.label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: #2a2a2a; margin-bottom: 14px; }
|
|
53
|
+
.row { display: flex; align-items: baseline; gap: 10px; padding: 7px 0; border-top: 1px solid #181818; }
|
|
54
|
+
.name { color: #4ade80; text-decoration: none; }
|
|
55
|
+
.name:hover { color: #86efac; }
|
|
56
|
+
.port { color: #2e2e2e; }
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<div class="wrap">
|
|
61
|
+
<div class="status">mesh / ${status}</div>
|
|
62
|
+
<h1>${heading}</h1>
|
|
63
|
+
<p>${message}</p>
|
|
64
|
+
${servicesBlock}
|
|
65
|
+
</div>
|
|
66
|
+
</body>
|
|
67
|
+
</html>`
|
|
68
|
+
}
|
package/src/hosts.js
CHANGED
|
@@ -4,31 +4,26 @@ const HOSTS_FILE = '/etc/hosts'
|
|
|
4
4
|
const MESH_START = '# mesh:start'
|
|
5
5
|
const MESH_END = '# mesh:end'
|
|
6
6
|
|
|
7
|
-
export function writeHosts(services) {
|
|
8
|
-
const current = readFileSync(
|
|
9
|
-
|
|
10
|
-
// Remove any previous mesh block
|
|
11
|
-
const clean = removeMeshBlock(current)
|
|
12
|
-
|
|
7
|
+
export function writeHosts(services, hostsFile = HOSTS_FILE) {
|
|
8
|
+
const current = readFileSync(hostsFile, 'utf8')
|
|
9
|
+
const clean = removeMeshBlock(current)
|
|
13
10
|
const entries = Object.keys(services)
|
|
14
|
-
.
|
|
11
|
+
.flatMap(name => [`127.0.0.1 ${name}.test`, `::1 ${name}.test`])
|
|
15
12
|
.join('\n')
|
|
16
|
-
|
|
17
13
|
const next = `${clean.trimEnd()}\n\n${MESH_START}\n${entries}\n${MESH_END}\n`
|
|
18
|
-
|
|
19
|
-
writeFileSync(HOSTS_FILE, next, 'utf8')
|
|
14
|
+
writeFileSync(hostsFile, next, 'utf8')
|
|
20
15
|
}
|
|
21
16
|
|
|
22
|
-
export function removeHosts() {
|
|
17
|
+
export function removeHosts(hostsFile = HOSTS_FILE) {
|
|
23
18
|
try {
|
|
24
|
-
const current = readFileSync(
|
|
25
|
-
writeFileSync(
|
|
19
|
+
const current = readFileSync(hostsFile, 'utf8')
|
|
20
|
+
writeFileSync(hostsFile, removeMeshBlock(current).trimEnd() + '\n', 'utf8')
|
|
26
21
|
} catch {
|
|
27
22
|
// Best-effort cleanup
|
|
28
23
|
}
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
function removeMeshBlock(content) {
|
|
26
|
+
export function removeMeshBlock(content) {
|
|
32
27
|
return content.replace(
|
|
33
28
|
new RegExp(`\\n?${MESH_START}[\\s\\S]*?${MESH_END}\\n?`, 'g'),
|
|
34
29
|
''
|
package/src/proxy.js
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
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'
|
|
7
|
+
import { wantsHtml, errorPage } from './error-page.js'
|
|
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
|
+
}
|
|
6
28
|
|
|
7
29
|
const RESET = '\x1b[0m'
|
|
8
30
|
const DIM = '\x1b[2m'
|
|
@@ -15,11 +37,17 @@ export function startProxy(services, rules, certs = null) {
|
|
|
15
37
|
const proxy = httpProxy.createProxyServer({ xfwd: true })
|
|
16
38
|
|
|
17
39
|
proxy.on('error', (err, req, res) => {
|
|
18
|
-
const
|
|
19
|
-
log(RED, 'ERR',
|
|
40
|
+
const { name, target } = resolveService(req.headers.host)
|
|
41
|
+
log(RED, 'ERR', name, req.url, `→ ${err.code ?? err.message}`)
|
|
20
42
|
if (!res.headersSent) {
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
const protocol = certs ? 'https' : 'http'
|
|
44
|
+
if (wantsHtml(req)) {
|
|
45
|
+
res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
46
|
+
res.end(errorPage(502, name, { port: target, protocol }))
|
|
47
|
+
} else {
|
|
48
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
49
|
+
res.end(JSON.stringify({ error: 'Service unavailable', service: name }))
|
|
50
|
+
}
|
|
23
51
|
}
|
|
24
52
|
})
|
|
25
53
|
|
|
@@ -33,14 +61,20 @@ export function startProxy(services, rules, certs = null) {
|
|
|
33
61
|
const { name, target } = resolveService(req.headers.host)
|
|
34
62
|
|
|
35
63
|
if (!target) {
|
|
36
|
-
|
|
37
|
-
|
|
64
|
+
const protocol = certs ? 'https' : 'http'
|
|
65
|
+
if (wantsHtml(req)) {
|
|
66
|
+
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
67
|
+
res.end(errorPage(404, name, { services, protocol }))
|
|
68
|
+
} else {
|
|
69
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
70
|
+
res.end(JSON.stringify({ error: `Unknown service: ${name}` }))
|
|
71
|
+
}
|
|
38
72
|
return
|
|
39
73
|
}
|
|
40
74
|
|
|
41
75
|
const pathname = new URL(req.url, 'http://x').pathname
|
|
42
76
|
|
|
43
|
-
const rule = matchRule(rules, name, pathname)
|
|
77
|
+
const rule = matchRule(rules, name, pathname, req.method)
|
|
44
78
|
|
|
45
79
|
if (rule) {
|
|
46
80
|
const injected = await applyRule(rule, res)
|
|
@@ -52,14 +86,16 @@ export function startProxy(services, rules, certs = null) {
|
|
|
52
86
|
log(YELLOW, `${rule.delay}ms`, name, pathname, `→ :${target} (delayed)`)
|
|
53
87
|
}
|
|
54
88
|
|
|
55
|
-
|
|
89
|
+
const host = await probeHost(target)
|
|
90
|
+
proxy.web(req, res, { target: `http://${fmtHost(host)}:${target}` })
|
|
56
91
|
if (!rule) log(DIM, '→', name, pathname, `→ :${target}`)
|
|
57
92
|
}
|
|
58
93
|
|
|
59
|
-
function handleUpgrade(req, socket, head) {
|
|
94
|
+
async function handleUpgrade(req, socket, head) {
|
|
60
95
|
const { name, target } = resolveService(req.headers.host)
|
|
61
96
|
if (!target) { socket.destroy(); return }
|
|
62
|
-
|
|
97
|
+
const host = await probeHost(target)
|
|
98
|
+
proxy.ws(req, socket, head, { target: `ws://${fmtHost(host)}:${target}` }, err => {
|
|
63
99
|
if (err) log(RED, 'WSE', name, req.url, `→ ${err.code ?? err.message}`)
|
|
64
100
|
})
|
|
65
101
|
log(DIM, 'WS', name, req.url, `→ :${target}`)
|
|
@@ -85,7 +121,7 @@ export function startProxy(services, rules, certs = null) {
|
|
|
85
121
|
throw err
|
|
86
122
|
})
|
|
87
123
|
|
|
88
|
-
httpServer.listen(80, '
|
|
124
|
+
httpServer.listen(80, '::', () => onReady(services, rules, certs))
|
|
89
125
|
|
|
90
126
|
let httpsServer = null
|
|
91
127
|
|
|
@@ -102,7 +138,7 @@ export function startProxy(services, rules, certs = null) {
|
|
|
102
138
|
}
|
|
103
139
|
throw err
|
|
104
140
|
})
|
|
105
|
-
httpsServer.listen(443, '
|
|
141
|
+
httpsServer.listen(443, '::')
|
|
106
142
|
}
|
|
107
143
|
|
|
108
144
|
return { http: httpServer, https: httpsServer }
|
|
@@ -122,8 +158,9 @@ function onReady(services, rules, certs) {
|
|
|
122
158
|
console.log('')
|
|
123
159
|
for (const [svc, ruleList] of Object.entries(rules)) {
|
|
124
160
|
for (const r of ruleList) {
|
|
125
|
-
const type
|
|
126
|
-
|
|
161
|
+
const type = r.status ? `${r.status}` : `${r.delay}ms delay`
|
|
162
|
+
const methodStr = r.method ? `${r.method} ` : ''
|
|
163
|
+
console.log(` ${YELLOW}${svc}${r.path}${RESET} ${DIM}${methodStr}${r.rate}% → ${type}${RESET}`)
|
|
127
164
|
}
|
|
128
165
|
}
|
|
129
166
|
}
|
package/src/rules.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export function matchRule(rules, serviceName, pathname) {
|
|
1
|
+
export function matchRule(rules, serviceName, pathname, method) {
|
|
2
2
|
const serviceRules = rules[serviceName]
|
|
3
3
|
if (!serviceRules) return null
|
|
4
4
|
|
|
5
5
|
for (const rule of serviceRules) {
|
|
6
6
|
if (!pathname.startsWith(rule.path)) continue
|
|
7
|
+
if (rule.method && rule.method !== method.toUpperCase()) continue
|
|
7
8
|
if (rule.rate === 0) continue
|
|
8
9
|
if (Math.random() * 100 > rule.rate) continue
|
|
9
10
|
return rule
|
|
@@ -16,8 +17,12 @@ export function applyRule(rule, res) {
|
|
|
16
17
|
return new Promise(resolve => {
|
|
17
18
|
const respond = () => {
|
|
18
19
|
if (rule.status) {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const body = rule.body ?? { error: statusText(rule.status), injected: true }
|
|
21
|
+
const isObj = typeof body !== 'string'
|
|
22
|
+
const raw = isObj ? JSON.stringify(body) : body
|
|
23
|
+
const type = isObj ? 'application/json' : 'text/plain'
|
|
24
|
+
res.writeHead(rule.status, { 'Content-Type': type })
|
|
25
|
+
res.end(raw)
|
|
21
26
|
}
|
|
22
27
|
resolve(rule.status != null)
|
|
23
28
|
}
|