@rip-lang/server 1.3.115 → 1.3.116
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 +435 -622
- package/api.rip +4 -4
- package/control/cli.rip +221 -1
- package/control/control.rip +9 -0
- package/control/lifecycle.rip +6 -1
- package/control/watchers.rip +10 -0
- package/control/workers.rip +9 -5
- package/default.rip +3 -1
- package/docs/READ_VALIDATORS.md +656 -0
- package/docs/edge/CONFIG_LIFECYCLE.md +70 -32
- package/docs/edge/CONTRACTS.md +60 -69
- package/docs/edge/EDGEFILE_CONTRACT.md +258 -29
- package/docs/edge/M0B_REVIEW_NOTES.md +5 -5
- package/edge/config.rip +584 -52
- package/edge/forwarding.rip +6 -2
- package/edge/metrics.rip +19 -1
- package/edge/registry.rip +29 -3
- package/edge/router.rip +138 -0
- package/edge/runtime.rip +98 -0
- package/edge/static.rip +69 -0
- package/edge/tls.rip +23 -0
- package/edge/upstream.rip +272 -0
- package/edge/verify.rip +73 -0
- package/middleware.rip +3 -3
- package/package.json +2 -2
- package/server.rip +775 -393
- package/tests/control.rip +18 -0
- package/tests/edgefile.rip +165 -0
- package/tests/metrics.rip +16 -0
- package/tests/proxy.rip +22 -1
- package/tests/registry.rip +27 -0
- package/tests/router.rip +101 -0
- package/tests/runtime_entrypoints.rip +16 -0
- package/tests/servers.rip +262 -0
- package/tests/static.rip +64 -0
- package/tests/streams_clienthello.rip +108 -0
- package/tests/streams_index.rip +53 -0
- package/tests/streams_pipe.rip +70 -0
- package/tests/streams_router.rip +39 -0
- package/tests/streams_runtime.rip +38 -0
- package/tests/streams_upstream.rip +34 -0
- package/tests/upstream.rip +191 -0
- package/tests/verify.rip +148 -0
- package/tests/watchers.rip +15 -0
package/tests/static.rip
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test, eq, ok } from '../test.rip'
|
|
2
|
+
import { serveStaticRoute, buildRedirectResponse } from '../edge/static.rip'
|
|
3
|
+
|
|
4
|
+
# --- helpers ---
|
|
5
|
+
|
|
6
|
+
mockReq = (method = 'GET', accept = 'text/html') ->
|
|
7
|
+
headers: new Headers({ accept })
|
|
8
|
+
method: method
|
|
9
|
+
|
|
10
|
+
mockUrl = (pathname) ->
|
|
11
|
+
new URL("https://example.com#{pathname}")
|
|
12
|
+
|
|
13
|
+
# --- static route tests ---
|
|
14
|
+
|
|
15
|
+
test "serveStaticRoute returns 405 for POST", ->
|
|
16
|
+
req = mockReq('POST')
|
|
17
|
+
route = { static: '/tmp', root: '/tmp', path: '/*' }
|
|
18
|
+
res = serveStaticRoute(req, mockUrl('/foo'), route)
|
|
19
|
+
eq res.status, 405
|
|
20
|
+
|
|
21
|
+
test "serveStaticRoute returns 404 for missing file", ->
|
|
22
|
+
req = mockReq()
|
|
23
|
+
route = { static: '/tmp/nonexistent-dir-rip-test', root: '/tmp/nonexistent-dir-rip-test', path: '/*' }
|
|
24
|
+
res = serveStaticRoute(req, mockUrl('/missing.txt'), route)
|
|
25
|
+
eq res.status, 404
|
|
26
|
+
|
|
27
|
+
test "serveStaticRoute rejects percent-encoded traversal", ->
|
|
28
|
+
req = mockReq()
|
|
29
|
+
route = { static: '/tmp/rip-static-test', root: '/tmp/rip-static-test', path: '/*' }
|
|
30
|
+
url = { pathname: '/%2e%2e/%2e%2e/etc/passwd' }
|
|
31
|
+
res = serveStaticRoute(req, url, route)
|
|
32
|
+
ok res.status is 403 or res.status is 404
|
|
33
|
+
|
|
34
|
+
test "URL-normalized traversal stays safe", ->
|
|
35
|
+
req = mockReq()
|
|
36
|
+
route = { static: '/tmp/rip-static-test', root: '/tmp/rip-static-test', path: '/*' }
|
|
37
|
+
res = serveStaticRoute(req, mockUrl('/../../etc/passwd'), route)
|
|
38
|
+
ok res.status >= 400
|
|
39
|
+
|
|
40
|
+
test "serveStaticRoute SPA fallback requires Accept text/html", ->
|
|
41
|
+
req = mockReq('GET', 'application/json')
|
|
42
|
+
route = { static: '/tmp', root: '/tmp', path: '/*', spa: true }
|
|
43
|
+
res = serveStaticRoute(req, mockUrl('/missing-page'), route)
|
|
44
|
+
eq res.status, 404
|
|
45
|
+
|
|
46
|
+
test "serveStaticRoute HEAD returns 405-safe", ->
|
|
47
|
+
req = mockReq('HEAD')
|
|
48
|
+
route = { static: '/tmp/nonexistent-dir-rip-test', root: '/tmp/nonexistent-dir-rip-test', path: '/*' }
|
|
49
|
+
res = serveStaticRoute(req, mockUrl('/missing.txt'), route)
|
|
50
|
+
eq res.status, 404
|
|
51
|
+
|
|
52
|
+
# --- redirect tests ---
|
|
53
|
+
|
|
54
|
+
test "buildRedirectResponse returns 302 by default", ->
|
|
55
|
+
req = mockReq()
|
|
56
|
+
route = { redirect: { to: 'https://example.com' } }
|
|
57
|
+
res = buildRedirectResponse(req, mockUrl('/old'), route)
|
|
58
|
+
eq res.status, 302
|
|
59
|
+
|
|
60
|
+
test "buildRedirectResponse uses custom status", ->
|
|
61
|
+
req = mockReq()
|
|
62
|
+
route = { redirect: { to: 'https://example.com', status: 301 } }
|
|
63
|
+
res = buildRedirectResponse(req, mockUrl('/old'), route)
|
|
64
|
+
eq res.status, 301
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { test, eq, ok } from '../test.rip'
|
|
2
|
+
import { parseSNI } from '../streams/tls_clienthello.rip'
|
|
3
|
+
|
|
4
|
+
buildClientHello = (hostname = 'incus.example.com', opts = {}) ->
|
|
5
|
+
hostBytes = new TextEncoder().encode(hostname)
|
|
6
|
+
sniEntryLen = 1 + 2 + hostBytes.length
|
|
7
|
+
serverNameListLen = sniEntryLen
|
|
8
|
+
sniExtLen = 2 + serverNameListLen
|
|
9
|
+
extensionsLen = if opts.noExtensions then 0 else 4 + sniExtLen
|
|
10
|
+
sessionId = new Uint8Array(0)
|
|
11
|
+
cipherSuites = new Uint8Array([0x00, 0x2f])
|
|
12
|
+
compression = new Uint8Array([0x00])
|
|
13
|
+
|
|
14
|
+
bodyLen = 2 + 32 + 1 + sessionId.length + 2 + cipherSuites.length + 1 + compression.length + 2 + extensionsLen
|
|
15
|
+
recordLen = 4 + bodyLen
|
|
16
|
+
totalLen = 5 + recordLen
|
|
17
|
+
out = new Uint8Array(totalLen)
|
|
18
|
+
i = 0
|
|
19
|
+
out[i++] = opts.contentType or 0x16
|
|
20
|
+
out[i++] = 0x03
|
|
21
|
+
out[i++] = opts.tlsMinor or 0x03
|
|
22
|
+
out[i++] = (recordLen >> 8) & 0xff
|
|
23
|
+
out[i++] = recordLen & 0xff
|
|
24
|
+
out[i++] = opts.handshakeType or 0x01
|
|
25
|
+
out[i++] = (bodyLen >> 16) & 0xff
|
|
26
|
+
out[i++] = (bodyLen >> 8) & 0xff
|
|
27
|
+
out[i++] = bodyLen & 0xff
|
|
28
|
+
out[i++] = 0x03
|
|
29
|
+
out[i++] = opts.clientMinor or 0x03
|
|
30
|
+
for n in [0...32]
|
|
31
|
+
out[i++] = n
|
|
32
|
+
out[i++] = sessionId.length
|
|
33
|
+
out.set(sessionId, i)
|
|
34
|
+
i += sessionId.length
|
|
35
|
+
out[i++] = 0x00
|
|
36
|
+
out[i++] = cipherSuites.length
|
|
37
|
+
out.set(cipherSuites, i)
|
|
38
|
+
i += cipherSuites.length
|
|
39
|
+
out[i++] = compression.length
|
|
40
|
+
out.set(compression, i)
|
|
41
|
+
i += compression.length
|
|
42
|
+
out[i++] = (extensionsLen >> 8) & 0xff
|
|
43
|
+
out[i++] = extensionsLen & 0xff
|
|
44
|
+
unless opts.noExtensions
|
|
45
|
+
out[i++] = 0x00
|
|
46
|
+
out[i++] = 0x00
|
|
47
|
+
out[i++] = (sniExtLen >> 8) & 0xff
|
|
48
|
+
out[i++] = sniExtLen & 0xff
|
|
49
|
+
out[i++] = (serverNameListLen >> 8) & 0xff
|
|
50
|
+
out[i++] = serverNameListLen & 0xff
|
|
51
|
+
out[i++] = 0x00
|
|
52
|
+
out[i++] = (hostBytes.length >> 8) & 0xff
|
|
53
|
+
out[i++] = hostBytes.length & 0xff
|
|
54
|
+
out.set(hostBytes, i)
|
|
55
|
+
i += hostBytes.length
|
|
56
|
+
out
|
|
57
|
+
|
|
58
|
+
test "parseSNI extracts hostname from TLS 1.2 ClientHello", ->
|
|
59
|
+
hello = buildClientHello('incus.example.com', tlsMinor: 0x03, clientMinor: 0x03)
|
|
60
|
+
parsed = parseSNI(hello)
|
|
61
|
+
eq parsed.ok, true
|
|
62
|
+
eq parsed.sni, 'incus.example.com'
|
|
63
|
+
|
|
64
|
+
test "parseSNI extracts hostname from TLS 1.3-style ClientHello", ->
|
|
65
|
+
hello = buildClientHello('api.example.com', tlsMinor: 0x03, clientMinor: 0x03)
|
|
66
|
+
parsed = parseSNI(hello)
|
|
67
|
+
eq parsed.ok, true
|
|
68
|
+
eq parsed.sni, 'api.example.com'
|
|
69
|
+
|
|
70
|
+
test "parseSNI lowercases hostname", ->
|
|
71
|
+
hello = buildClientHello('InCus.Example.Com')
|
|
72
|
+
parsed = parseSNI(hello)
|
|
73
|
+
eq parsed.sni, 'incus.example.com'
|
|
74
|
+
|
|
75
|
+
test "parseSNI returns need_more_bytes for short buffer", ->
|
|
76
|
+
parsed = parseSNI(new Uint8Array([0x16, 0x03, 0x03]))
|
|
77
|
+
eq parsed.ok, false
|
|
78
|
+
eq parsed.code, 'need_more_bytes'
|
|
79
|
+
|
|
80
|
+
test "parseSNI rejects wrong record type", ->
|
|
81
|
+
hello = buildClientHello('incus.example.com', contentType: 0x17)
|
|
82
|
+
parsed = parseSNI(hello)
|
|
83
|
+
eq parsed.ok, false
|
|
84
|
+
eq parsed.code, 'bad_record_type'
|
|
85
|
+
|
|
86
|
+
test "parseSNI rejects no-extensions hello as no_sni", ->
|
|
87
|
+
hello = buildClientHello('incus.example.com', noExtensions: true)
|
|
88
|
+
parsed = parseSNI(hello)
|
|
89
|
+
eq parsed.ok, false
|
|
90
|
+
eq parsed.code, 'no_sni'
|
|
91
|
+
|
|
92
|
+
test "parseSNI rejects oversized ClientHello", ->
|
|
93
|
+
huge = new Uint8Array(5)
|
|
94
|
+
huge[0] = 0x16
|
|
95
|
+
huge[1] = 0x03
|
|
96
|
+
huge[2] = 0x03
|
|
97
|
+
huge[3] = 0x40
|
|
98
|
+
huge[4] = 0x01
|
|
99
|
+
parsed = parseSNI(huge)
|
|
100
|
+
eq parsed.ok, false
|
|
101
|
+
eq parsed.code, 'client_hello_too_large'
|
|
102
|
+
|
|
103
|
+
test "parseSNI rejects malformed extension bounds", ->
|
|
104
|
+
hello = buildClientHello('incus.example.com')
|
|
105
|
+
hello[54] = 0xff
|
|
106
|
+
hello[55] = 0xff
|
|
107
|
+
parsed = parseSNI(hello)
|
|
108
|
+
ok parsed.ok is false
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test, eq } from '../test.rip'
|
|
2
|
+
import { buildStreamRuntime, resolveHandshakeTarget } from '../streams/index.rip'
|
|
3
|
+
|
|
4
|
+
test "resolveHandshakeTarget falls through to http fallback when no route matches", ->
|
|
5
|
+
runtime = buildStreamRuntime(
|
|
6
|
+
streamUpstreams:
|
|
7
|
+
incus:
|
|
8
|
+
targets: [{ host: '127.0.0.1', port: 8443 }]
|
|
9
|
+
streams: [
|
|
10
|
+
{ id: 'incus', order: 0, listen: 443, sni: ['incus.example.com'], upstream: 'incus' }
|
|
11
|
+
]
|
|
12
|
+
)
|
|
13
|
+
result = resolveHandshakeTarget(runtime, 443, 'app.example.com',
|
|
14
|
+
443:
|
|
15
|
+
hostname: '127.0.0.1'
|
|
16
|
+
port: 9443
|
|
17
|
+
)
|
|
18
|
+
eq result.kind, 'fallback'
|
|
19
|
+
eq result.target.port, 9443
|
|
20
|
+
|
|
21
|
+
test "resolveHandshakeTarget prefers configured stream routes over fallback", ->
|
|
22
|
+
runtime = buildStreamRuntime(
|
|
23
|
+
streamUpstreams:
|
|
24
|
+
incus:
|
|
25
|
+
targets: [{ host: '127.0.0.1', port: 8443 }]
|
|
26
|
+
streams: [
|
|
27
|
+
{ id: 'incus', order: 0, listen: 443, sni: ['incus.example.com'], upstream: 'incus' }
|
|
28
|
+
]
|
|
29
|
+
)
|
|
30
|
+
result = resolveHandshakeTarget(runtime, 443, 'incus.example.com',
|
|
31
|
+
443:
|
|
32
|
+
hostname: '127.0.0.1'
|
|
33
|
+
port: 9443
|
|
34
|
+
)
|
|
35
|
+
eq result.kind, 'stream'
|
|
36
|
+
eq result.target.port, 8443
|
|
37
|
+
|
|
38
|
+
test "resolveHandshakeTarget rejects when stream route matches but upstream is unavailable", ->
|
|
39
|
+
runtime = buildStreamRuntime(
|
|
40
|
+
streamUpstreams:
|
|
41
|
+
incus:
|
|
42
|
+
targets: []
|
|
43
|
+
streams: [
|
|
44
|
+
{ id: 'incus', order: 0, listen: 443, sni: ['incus.example.com'], upstream: 'incus' }
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
result = resolveHandshakeTarget(runtime, 443, 'incus.example.com',
|
|
48
|
+
443:
|
|
49
|
+
hostname: '127.0.0.1'
|
|
50
|
+
port: 9443
|
|
51
|
+
)
|
|
52
|
+
eq result.kind, 'reject'
|
|
53
|
+
eq result.code, 'no_available_target'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { test, eq, ok } from '../test.rip'
|
|
2
|
+
import { createPipeState, writeChunk, flushPending } from '../streams/pipe.rip'
|
|
3
|
+
|
|
4
|
+
fakeSocket = (returns = []) ->
|
|
5
|
+
writes = []
|
|
6
|
+
idx = 0
|
|
7
|
+
paused = 0
|
|
8
|
+
resumed = 0
|
|
9
|
+
{
|
|
10
|
+
writes
|
|
11
|
+
write: (data) ->
|
|
12
|
+
writes.push(new Uint8Array(data))
|
|
13
|
+
ret = returns[idx]
|
|
14
|
+
idx++
|
|
15
|
+
if ret? then ret else data.byteLength
|
|
16
|
+
pause: -> paused++
|
|
17
|
+
resume: -> resumed++
|
|
18
|
+
pausedCount: -> paused
|
|
19
|
+
resumedCount: -> resumed
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test "writeChunk succeeds when full write completes", ->
|
|
23
|
+
sock = fakeSocket()
|
|
24
|
+
state = createPipeState()
|
|
25
|
+
res = writeChunk(sock, state, new Uint8Array([1, 2, 3]))
|
|
26
|
+
eq res.needDrain, false
|
|
27
|
+
eq state.pending.byteLength, 0
|
|
28
|
+
|
|
29
|
+
test "writeChunk buffers remainder on partial write", ->
|
|
30
|
+
sock = fakeSocket([1])
|
|
31
|
+
state = createPipeState()
|
|
32
|
+
res = writeChunk(sock, state, new Uint8Array([1, 2, 3]))
|
|
33
|
+
eq res.needDrain, true
|
|
34
|
+
eq state.pending.byteLength, 2
|
|
35
|
+
eq Array.from(state.pending), [2, 3]
|
|
36
|
+
|
|
37
|
+
test "flushPending clears buffer when write completes", ->
|
|
38
|
+
sock = fakeSocket()
|
|
39
|
+
state = createPipeState()
|
|
40
|
+
state.pending = new Uint8Array([4, 5])
|
|
41
|
+
state.waitingForDrain = true
|
|
42
|
+
res = flushPending(sock, state)
|
|
43
|
+
eq res.needDrain, false
|
|
44
|
+
eq state.pending.byteLength, 0
|
|
45
|
+
|
|
46
|
+
test "flushPending keeps remainder on partial write", ->
|
|
47
|
+
sock = fakeSocket([1])
|
|
48
|
+
state = createPipeState()
|
|
49
|
+
state.pending = new Uint8Array([4, 5, 6])
|
|
50
|
+
state.waitingForDrain = true
|
|
51
|
+
res = flushPending(sock, state)
|
|
52
|
+
eq res.needDrain, true
|
|
53
|
+
eq Array.from(state.pending), [5, 6]
|
|
54
|
+
|
|
55
|
+
test "writeChunk appends new data behind existing pending buffer", ->
|
|
56
|
+
sock = fakeSocket()
|
|
57
|
+
state = createPipeState()
|
|
58
|
+
state.pending = new Uint8Array([9, 9])
|
|
59
|
+
state.waitingForDrain = true
|
|
60
|
+
res = writeChunk(sock, state, new Uint8Array([1, 2]))
|
|
61
|
+
eq res.needDrain, true
|
|
62
|
+
eq res.wrote, 0
|
|
63
|
+
eq Array.from(state.pending), [9, 9, 1, 2]
|
|
64
|
+
|
|
65
|
+
test "writeChunk reports closed socket on -1", ->
|
|
66
|
+
sock = fakeSocket([-1])
|
|
67
|
+
state = createPipeState()
|
|
68
|
+
res = writeChunk(sock, state, new Uint8Array([1]))
|
|
69
|
+
eq res.closed, true
|
|
70
|
+
eq res.ok, false
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { test, eq } from '../test.rip'
|
|
2
|
+
import { compileStreamTable, matchStreamRoute, describeStreamRoute } from '../streams/router.rip'
|
|
3
|
+
|
|
4
|
+
test "compileStreamTable expands sni patterns", ->
|
|
5
|
+
table = compileStreamTable([
|
|
6
|
+
{ id: 'incus', order: 0, listen: 8443, sni: ['incus.example.com', '*.example.com'], upstream: 'incus' }
|
|
7
|
+
])
|
|
8
|
+
eq table.routes.length, 2
|
|
9
|
+
|
|
10
|
+
test "matchStreamRoute prefers exact sni", ->
|
|
11
|
+
table = compileStreamTable([
|
|
12
|
+
{ id: 'wild', order: 0, listen: 8443, sni: ['*.example.com'], upstream: 'wild' }
|
|
13
|
+
{ id: 'exact', order: 1, listen: 8443, sni: ['incus.example.com'], upstream: 'exact' }
|
|
14
|
+
])
|
|
15
|
+
eq matchStreamRoute(table, 8443, 'incus.example.com').upstream, 'exact'
|
|
16
|
+
|
|
17
|
+
test "matchStreamRoute matches wildcard sni", ->
|
|
18
|
+
table = compileStreamTable([
|
|
19
|
+
{ id: 'wild', order: 0, listen: 8443, sni: ['*.example.com'], upstream: 'wild' }
|
|
20
|
+
])
|
|
21
|
+
eq matchStreamRoute(table, 8443, 'api.example.com').upstream, 'wild'
|
|
22
|
+
|
|
23
|
+
test "matchStreamRoute wildcard is single-label only", ->
|
|
24
|
+
table = compileStreamTable([
|
|
25
|
+
{ id: 'wild', order: 0, listen: 8443, sni: ['*.example.com'], upstream: 'wild' }
|
|
26
|
+
])
|
|
27
|
+
eq matchStreamRoute(table, 8443, 'a.b.example.com'), null
|
|
28
|
+
|
|
29
|
+
test "matchStreamRoute filters by listen port", ->
|
|
30
|
+
table = compileStreamTable([
|
|
31
|
+
{ id: 'incus', order: 0, listen: 8443, sni: ['incus.example.com'], upstream: 'incus' }
|
|
32
|
+
])
|
|
33
|
+
eq matchStreamRoute(table, 443, 'incus.example.com'), null
|
|
34
|
+
|
|
35
|
+
test "describeStreamRoute summarizes route", ->
|
|
36
|
+
table = compileStreamTable([
|
|
37
|
+
{ id: 'incus', order: 0, listen: 8443, sni: ['incus.example.com'], upstream: 'incus' }
|
|
38
|
+
])
|
|
39
|
+
eq describeStreamRoute(table.routes[0]), '8443 incus.example.com -> incus'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { test, eq } from '../test.rip'
|
|
2
|
+
import { createStreamRuntime, describeStreamRuntime, buildStreamConfigInfo, streamUsesListenPort } from '../streams/runtime.rip'
|
|
3
|
+
|
|
4
|
+
test "createStreamRuntime starts empty", ->
|
|
5
|
+
runtime = createStreamRuntime()
|
|
6
|
+
eq runtime.inflight, 0
|
|
7
|
+
eq runtime.listeners.size, 0
|
|
8
|
+
|
|
9
|
+
test "describeStreamRuntime returns summary", ->
|
|
10
|
+
runtime = createStreamRuntime()
|
|
11
|
+
runtime.inflight = 2
|
|
12
|
+
runtime.retiredAt = '2026-01-01T00:00:00.000Z'
|
|
13
|
+
runtime.listeners.set(8443, {})
|
|
14
|
+
summary = describeStreamRuntime(runtime)
|
|
15
|
+
eq summary.inflight, 2
|
|
16
|
+
eq summary.listeners[0], 8443
|
|
17
|
+
|
|
18
|
+
test "buildStreamConfigInfo reports counts and descriptions", ->
|
|
19
|
+
info = buildStreamConfigInfo(
|
|
20
|
+
streamUpstreams:
|
|
21
|
+
incus: {}
|
|
22
|
+
streams: [
|
|
23
|
+
{ id: 'incus', order: 0, listen: 8443, sni: ['incus.example.com'], upstream: 'incus' }
|
|
24
|
+
]
|
|
25
|
+
)
|
|
26
|
+
eq info.streamCounts.streamUpstreams, 1
|
|
27
|
+
eq info.streamCounts.streams, 1
|
|
28
|
+
eq info.activeStreamRouteDescriptions[0], '8443 incus.example.com -> incus'
|
|
29
|
+
|
|
30
|
+
test "streamUsesListenPort detects shared listener ports", ->
|
|
31
|
+
runtime = createStreamRuntime(null, null,
|
|
32
|
+
routes: [
|
|
33
|
+
{ listen: 443, sni: 'incus.example.com', upstream: 'incus' }
|
|
34
|
+
{ listen: 8443, sni: 'db.example.com', upstream: 'db' }
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
eq streamUsesListenPort(runtime, 443), true
|
|
38
|
+
eq streamUsesListenPort(runtime, 80), false
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { test, eq } from '../test.rip'
|
|
2
|
+
import { createStreamUpstreamPool, addStreamUpstream, getStreamUpstream, selectStreamTarget, openStreamConnection, releaseStreamConnection } from '../streams/upstream.rip'
|
|
3
|
+
|
|
4
|
+
test "addStreamUpstream registers targets", ->
|
|
5
|
+
pool = createStreamUpstreamPool()
|
|
6
|
+
upstream = addStreamUpstream(pool, 'incus',
|
|
7
|
+
targets: [{ targetId: '127.0.0.1:8443', host: '127.0.0.1', port: 8443 }]
|
|
8
|
+
)
|
|
9
|
+
eq getStreamUpstream(pool, 'incus').targets.length, 1
|
|
10
|
+
eq upstream.connectTimeoutMs, 5000
|
|
11
|
+
|
|
12
|
+
test "selectStreamTarget prefers least connections", ->
|
|
13
|
+
pool = createStreamUpstreamPool()
|
|
14
|
+
upstream = addStreamUpstream(pool, 'incus',
|
|
15
|
+
targets: [
|
|
16
|
+
{ targetId: 'a:1', host: 'a', port: 1 }
|
|
17
|
+
{ targetId: 'b:1', host: 'b', port: 1 }
|
|
18
|
+
]
|
|
19
|
+
)
|
|
20
|
+
upstream.targets[0].activeConnections = 3
|
|
21
|
+
upstream.targets[1].activeConnections = 1
|
|
22
|
+
eq selectStreamTarget(upstream).targetId, 'b:1'
|
|
23
|
+
|
|
24
|
+
test "open and release stream connection update counters", ->
|
|
25
|
+
pool = createStreamUpstreamPool()
|
|
26
|
+
upstream = addStreamUpstream(pool, 'incus',
|
|
27
|
+
targets: [{ targetId: 'a:1', host: 'a', port: 1 }]
|
|
28
|
+
)
|
|
29
|
+
target = upstream.targets[0]
|
|
30
|
+
openStreamConnection(target)
|
|
31
|
+
eq target.activeConnections, 1
|
|
32
|
+
eq target.totalConnections, 1
|
|
33
|
+
releaseStreamConnection(target)
|
|
34
|
+
eq target.activeConnections, 0
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { test, eq, ok } from '../test.rip'
|
|
2
|
+
import {
|
|
3
|
+
createUpstreamPool
|
|
4
|
+
addUpstream
|
|
5
|
+
getUpstream
|
|
6
|
+
listUpstreams
|
|
7
|
+
removeUpstream
|
|
8
|
+
selectTarget
|
|
9
|
+
markTargetBusy
|
|
10
|
+
releaseTarget
|
|
11
|
+
shouldRetry
|
|
12
|
+
computeRetryDelayMs
|
|
13
|
+
updateTargetHealth
|
|
14
|
+
} from '../edge/upstream.rip'
|
|
15
|
+
|
|
16
|
+
test "createUpstreamPool starts empty", ->
|
|
17
|
+
pool = createUpstreamPool()
|
|
18
|
+
eq pool.upstreams.size, 0
|
|
19
|
+
|
|
20
|
+
test "addUpstream normalizes targets and defaults", ->
|
|
21
|
+
pool = createUpstreamPool()
|
|
22
|
+
upstream = addUpstream(pool, 'app',
|
|
23
|
+
targets: ['http://127.0.0.1:3000']
|
|
24
|
+
)
|
|
25
|
+
eq pool.upstreams.size, 1
|
|
26
|
+
eq upstream.targets.length, 1
|
|
27
|
+
eq upstream.targets[0].healthy, true
|
|
28
|
+
eq upstream.targets[0].circuitState, 'closed'
|
|
29
|
+
|
|
30
|
+
test "listUpstreams returns added upstreams", ->
|
|
31
|
+
pool = createUpstreamPool()
|
|
32
|
+
addUpstream(pool, 'app', targets: ['http://127.0.0.1:3000'])
|
|
33
|
+
eq listUpstreams(pool).length, 1
|
|
34
|
+
|
|
35
|
+
test "removeUpstream deletes entry", ->
|
|
36
|
+
pool = createUpstreamPool()
|
|
37
|
+
addUpstream(pool, 'app', targets: ['http://127.0.0.1:3000'])
|
|
38
|
+
removeUpstream(pool, 'app')
|
|
39
|
+
eq getUpstream(pool, 'app'), null
|
|
40
|
+
|
|
41
|
+
test "selectTarget picks least inflight target", ->
|
|
42
|
+
pool = createUpstreamPool()
|
|
43
|
+
upstream = addUpstream(pool, 'app',
|
|
44
|
+
targets: ['http://a', 'http://b']
|
|
45
|
+
)
|
|
46
|
+
upstream.targets[0].inflight = 2
|
|
47
|
+
upstream.targets[1].inflight = 0
|
|
48
|
+
eq selectTarget(upstream).url, 'http://b'
|
|
49
|
+
|
|
50
|
+
test "selectTarget falls back to latency tie-break", ->
|
|
51
|
+
pool = createUpstreamPool()
|
|
52
|
+
upstream = addUpstream(pool, 'app',
|
|
53
|
+
targets: ['http://a', 'http://b']
|
|
54
|
+
)
|
|
55
|
+
upstream.targets[0].latencyEwma = 50
|
|
56
|
+
upstream.targets[1].latencyEwma = 10
|
|
57
|
+
eq selectTarget(upstream).url, 'http://b'
|
|
58
|
+
|
|
59
|
+
test "selectTarget returns null when all targets unhealthy", ->
|
|
60
|
+
pool = createUpstreamPool()
|
|
61
|
+
upstream = addUpstream(pool, 'app',
|
|
62
|
+
targets: ['http://a', 'http://b']
|
|
63
|
+
)
|
|
64
|
+
upstream.targets[0].healthy = false
|
|
65
|
+
upstream.targets[1].healthy = false
|
|
66
|
+
eq selectTarget(upstream), null
|
|
67
|
+
|
|
68
|
+
test "markTargetBusy increments inflight", ->
|
|
69
|
+
pool = createUpstreamPool()
|
|
70
|
+
upstream = addUpstream(pool, 'app', targets: ['http://a'])
|
|
71
|
+
target = upstream.targets[0]
|
|
72
|
+
markTargetBusy(target)
|
|
73
|
+
eq target.inflight, 1
|
|
74
|
+
|
|
75
|
+
test "releaseTarget decrements inflight and updates ewma", ->
|
|
76
|
+
pool = createUpstreamPool()
|
|
77
|
+
upstream = addUpstream(pool, 'app', targets: ['http://a'])
|
|
78
|
+
target = upstream.targets[0]
|
|
79
|
+
markTargetBusy(target)
|
|
80
|
+
releaseTarget(target, 100, true, pool)
|
|
81
|
+
eq target.inflight, 0
|
|
82
|
+
ok target.latencyEwma > 0
|
|
83
|
+
|
|
84
|
+
test "updateTargetHealth marks target unhealthy after threshold", ->
|
|
85
|
+
pool = createUpstreamPool(nowFn: -> 1000, randomFn: -> 0)
|
|
86
|
+
upstream = addUpstream(pool, 'app',
|
|
87
|
+
targets: ['http://a']
|
|
88
|
+
healthCheck:
|
|
89
|
+
unhealthyThreshold: 2
|
|
90
|
+
)
|
|
91
|
+
target = upstream.targets[0]
|
|
92
|
+
updateTargetHealth(target, false, pool)
|
|
93
|
+
eq target.healthy, true
|
|
94
|
+
updateTargetHealth(target, false, pool)
|
|
95
|
+
eq target.healthy, false
|
|
96
|
+
|
|
97
|
+
test "updateTargetHealth restores healthy after success threshold", ->
|
|
98
|
+
pool = createUpstreamPool(nowFn: -> 1000, randomFn: -> 0)
|
|
99
|
+
upstream = addUpstream(pool, 'app',
|
|
100
|
+
targets: ['http://a']
|
|
101
|
+
healthCheck:
|
|
102
|
+
unhealthyThreshold: 1
|
|
103
|
+
healthyThreshold: 2
|
|
104
|
+
)
|
|
105
|
+
target = upstream.targets[0]
|
|
106
|
+
updateTargetHealth(target, false, pool)
|
|
107
|
+
eq target.healthy, false
|
|
108
|
+
updateTargetHealth(target, true, pool)
|
|
109
|
+
eq target.healthy, false
|
|
110
|
+
updateTargetHealth(target, true, pool)
|
|
111
|
+
eq target.healthy, true
|
|
112
|
+
|
|
113
|
+
test "releaseTarget opens circuit after enough failures", ->
|
|
114
|
+
pool = createUpstreamPool(nowFn: -> 1234, randomFn: -> 0)
|
|
115
|
+
upstream = addUpstream(pool, 'app',
|
|
116
|
+
targets: ['http://a']
|
|
117
|
+
circuit:
|
|
118
|
+
minRequests: 3
|
|
119
|
+
errorThreshold: 0.5
|
|
120
|
+
cooldownMs: 1000
|
|
121
|
+
)
|
|
122
|
+
target = upstream.targets[0]
|
|
123
|
+
for _ in [1..3]
|
|
124
|
+
markTargetBusy(target)
|
|
125
|
+
releaseTarget(target, 10, false, pool)
|
|
126
|
+
eq target.circuitState, 'open'
|
|
127
|
+
eq target.circuitOpenedAt, 1234
|
|
128
|
+
|
|
129
|
+
test "selectTarget reopens half-open target after cooldown", ->
|
|
130
|
+
now = 0
|
|
131
|
+
pool = createUpstreamPool(nowFn: -> now, randomFn: -> 0)
|
|
132
|
+
upstream = addUpstream(pool, 'app',
|
|
133
|
+
targets: ['http://a']
|
|
134
|
+
circuit:
|
|
135
|
+
minRequests: 1
|
|
136
|
+
cooldownMs: 100
|
|
137
|
+
jitterRatio: 0
|
|
138
|
+
)
|
|
139
|
+
target = upstream.targets[0]
|
|
140
|
+
markTargetBusy(target)
|
|
141
|
+
releaseTarget(target, 10, false, pool)
|
|
142
|
+
eq selectTarget(upstream, pool.nowFn), null
|
|
143
|
+
now = 150
|
|
144
|
+
selected = selectTarget(upstream, pool.nowFn)
|
|
145
|
+
eq selected.url, 'http://a'
|
|
146
|
+
eq target.circuitState, 'half-open'
|
|
147
|
+
|
|
148
|
+
test "half-open success closes circuit", ->
|
|
149
|
+
pool = createUpstreamPool(nowFn: -> 500, randomFn: -> 0)
|
|
150
|
+
upstream = addUpstream(pool, 'app',
|
|
151
|
+
targets: ['http://a']
|
|
152
|
+
circuit:
|
|
153
|
+
minRequests: 1
|
|
154
|
+
cooldownMs: 100
|
|
155
|
+
jitterRatio: 0
|
|
156
|
+
)
|
|
157
|
+
target = upstream.targets[0]
|
|
158
|
+
markTargetBusy(target)
|
|
159
|
+
releaseTarget(target, 10, false, pool)
|
|
160
|
+
target.circuitOpenedAt = 0
|
|
161
|
+
selected = selectTarget(upstream, pool.nowFn)
|
|
162
|
+
markTargetBusy(selected)
|
|
163
|
+
releaseTarget(selected, 5, true, pool)
|
|
164
|
+
eq target.circuitState, 'closed'
|
|
165
|
+
|
|
166
|
+
test "shouldRetry honors method status and body state", ->
|
|
167
|
+
retry = attempts: 2, retryOn: [502, 503], retryMethods: ['GET']
|
|
168
|
+
ok shouldRetry(retry, 'GET', 502, false)
|
|
169
|
+
eq shouldRetry(retry, 'POST', 502, false), false
|
|
170
|
+
eq shouldRetry(retry, 'GET', 500, false), false
|
|
171
|
+
eq shouldRetry(retry, 'GET', 502, true), false
|
|
172
|
+
|
|
173
|
+
test "computeRetryDelayMs adds bounded jitter", ->
|
|
174
|
+
delay = computeRetryDelayMs({ backoffMs: 100 }, 2, -> 0)
|
|
175
|
+
eq delay, 200
|
|
176
|
+
|
|
177
|
+
test "updateTargetHealth records last check timestamp", ->
|
|
178
|
+
pool = createUpstreamPool(nowFn: -> 42, randomFn: -> 0)
|
|
179
|
+
upstream = addUpstream(pool, 'app', targets: ['http://a'])
|
|
180
|
+
target = upstream.targets[0]
|
|
181
|
+
updateTargetHealth(target, true, pool)
|
|
182
|
+
eq target.lastCheckAt, 42
|
|
183
|
+
|
|
184
|
+
test "updateTargetHealth can trip half-open target back to open", ->
|
|
185
|
+
pool = createUpstreamPool(nowFn: -> 99, randomFn: -> 0)
|
|
186
|
+
upstream = addUpstream(pool, 'app', targets: ['http://a'])
|
|
187
|
+
target = upstream.targets[0]
|
|
188
|
+
target.circuitState = 'half-open'
|
|
189
|
+
updateTargetHealth(target, false, pool)
|
|
190
|
+
eq target.circuitState, 'open'
|
|
191
|
+
eq target.circuitOpenedAt, 99
|