@jr2/orchestrator 0.2.1 → 0.4.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/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -5
- package/src/actor.ts +507 -54
- package/src/agent.ts +2 -2
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +233 -22
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +313 -0
- package/src/custodian.ts +951 -0
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +247 -25
- package/src/held-secrets.ts +643 -0
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +514 -119
- package/src/images.ts +58 -31
- package/src/index.ts +49 -4
- package/src/instance.ts +445 -59
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/model-keys.ts +92 -0
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +89 -7
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +61 -86
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1841 -105
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +285 -89
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +44 -12
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1144
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
-- The Custodian's decisions (ADR-0059), run by Envoy's Lua filter on every listener it owns.
|
|
2
|
+
--
|
|
3
|
+
-- Envoy carries the bytes: the CONNECT listener, TLS on both sides, the upstream pools, streaming.
|
|
4
|
+
-- This script makes every choice about a credential, and nothing else. It ships once per Instance
|
|
5
|
+
-- in the `jr2-held` ConfigMap (`program.lua`); each pod's own `custodian.lua` is a generated `HELD`
|
|
6
|
+
-- table (names, headers, paths, bound targets — never a value) that ends by loading this file.
|
|
7
|
+
-- Envoy loads that once per Lua state at start, so the top-level code below runs before any
|
|
8
|
+
-- listener serves, with `HELD` already set: a value that is missing or cannot ride a header stops
|
|
9
|
+
-- the start-up with a line that names the secret, never the value.
|
|
10
|
+
--
|
|
11
|
+
-- Four roles, one per route family, read from the route's metadata:
|
|
12
|
+
--
|
|
13
|
+
-- control the Harness's Menu and the ask, plain HTTP on loopback, toward the Orchestrator
|
|
14
|
+
-- egress the CONNECT listener: dial guard, then tunnel or hand to an intercept listener
|
|
15
|
+
-- intercept one bound host, TLS already ended: swap the Stand-in, strip the rest
|
|
16
|
+
-- health the kubelet's probe
|
|
17
|
+
|
|
18
|
+
local VALUES = "/etc/jr2/custodian/values/"
|
|
19
|
+
|
|
20
|
+
-- The value of one held secret, read once. One trailing line end is taken off, because
|
|
21
|
+
-- `kubectl create secret --from-file` writes one; anything else a header cannot carry stops us.
|
|
22
|
+
local function load(name)
|
|
23
|
+
local f = io.open(VALUES .. name, "rb")
|
|
24
|
+
if f == nil then
|
|
25
|
+
error("jr2.custodian refuse secret=" .. name .. " reason=missing-value")
|
|
26
|
+
end
|
|
27
|
+
local v = f:read("*a")
|
|
28
|
+
f:close()
|
|
29
|
+
v = v:gsub("\r?\n$", "", 1)
|
|
30
|
+
if v == "" then
|
|
31
|
+
error("jr2.custodian refuse secret=" .. name .. " reason=empty-value")
|
|
32
|
+
end
|
|
33
|
+
if v:find("[^\32-\126]") then
|
|
34
|
+
error("jr2.custodian refuse secret=" .. name .. " reason=value-not-printable-ascii")
|
|
35
|
+
end
|
|
36
|
+
return v
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
local STAND_IN = {}
|
|
40
|
+
local VALUE = {}
|
|
41
|
+
for _, s in ipairs(HELD.secrets) do
|
|
42
|
+
STAND_IN[s.name] = "jr2-held-" .. s.name
|
|
43
|
+
VALUE[s.name] = load(s.name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
-- The headers every bound target treats as a credential (`CREDENTIAL_HEADERS`, held-secrets.ts).
|
|
47
|
+
local CREDENTIAL = {}
|
|
48
|
+
for _, h in ipairs(HELD.credential) do CREDENTIAL[h] = true end
|
|
49
|
+
|
|
50
|
+
-- Everything a target's requests may carry a credential in: the fixed set, plus every header a
|
|
51
|
+
-- secret bound to it names.
|
|
52
|
+
local function credentialHeaders(bound)
|
|
53
|
+
local set = {}
|
|
54
|
+
for h in pairs(CREDENTIAL) do set[h] = true end
|
|
55
|
+
for _, s in ipairs(bound) do
|
|
56
|
+
for _, h in ipairs(s.headers) do set[h] = true end
|
|
57
|
+
end
|
|
58
|
+
return set
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
local function tag(handle, key, value)
|
|
62
|
+
handle:streamInfo():dynamicMetadata():set("jr2", key, value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
local function refuse(handle, status, reason, body)
|
|
66
|
+
tag(handle, "action", "refuse")
|
|
67
|
+
tag(handle, "reason", reason)
|
|
68
|
+
tag(handle, "log", true)
|
|
69
|
+
handle:respond({ [":status"] = tostring(status), ["content-type"] = "text/plain" }, "jr2 custodian: " .. body .. "\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
-- The swap, in the two forms a client writes a key in: the Stand-in alone (`x-api-key`), or one
|
|
73
|
+
-- auth scheme then the Stand-in (`Authorization: Bearer`). Nothing else is ever rewritten.
|
|
74
|
+
local function swapped(value, standIn, secret)
|
|
75
|
+
if value == standIn then return secret end
|
|
76
|
+
local scheme, rest = value:match("^([A-Za-z][A-Za-z0-9._~+/-]*) (.+)$")
|
|
77
|
+
if scheme ~= nil and rest == standIn then return scheme .. " " .. secret end
|
|
78
|
+
return nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
-- One request toward a target whose secrets are `bound`: every credential header is either the
|
|
82
|
+
-- product of a swap or gone, and a request that swapped nothing never leaves.
|
|
83
|
+
local function hold(handle, headers, bound, target, path)
|
|
84
|
+
local creds = credentialHeaders(bound)
|
|
85
|
+
for h in pairs(creds) do
|
|
86
|
+
if headers:getNumValues(h) > 1 then
|
|
87
|
+
return refuse(handle, 400, "credential", target .. " got header " .. h .. " more than once")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
local done = {}
|
|
91
|
+
local names = {}
|
|
92
|
+
for _, s in ipairs(bound) do
|
|
93
|
+
for _, h in ipairs(s.headers) do
|
|
94
|
+
local v = headers:get(h)
|
|
95
|
+
if v ~= nil and not done[h] then
|
|
96
|
+
local out = swapped(v, STAND_IN[s.name], VALUE[s.name])
|
|
97
|
+
if out ~= nil then
|
|
98
|
+
if s.paths ~= nil then
|
|
99
|
+
local allowed = false
|
|
100
|
+
for _, p in ipairs(s.paths) do
|
|
101
|
+
if path == p or path:sub(1, #p) == p and (p:sub(-1) == "/" or path:sub(#p + 1, #p + 1) == "/") then
|
|
102
|
+
allowed = true
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
if not allowed then
|
|
106
|
+
return refuse(handle, 403, "path", s.name .. " may not be sent to this path of " .. target)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
headers:replace(h, out)
|
|
110
|
+
done[h] = true
|
|
111
|
+
names[#names + 1] = s.name
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
for h in pairs(creds) do
|
|
117
|
+
if not done[h] then headers:remove(h) end
|
|
118
|
+
end
|
|
119
|
+
if #names == 0 then
|
|
120
|
+
local wanted, where = {}, {}
|
|
121
|
+
for _, s in ipairs(bound) do
|
|
122
|
+
wanted[#wanted + 1] = s.name
|
|
123
|
+
for _, h in ipairs(s.headers) do where[#where + 1] = h end
|
|
124
|
+
end
|
|
125
|
+
return refuse(handle, 403, "stand-in", target .. " needs the stand-in of " .. table.concat(wanted, ", ") ..
|
|
126
|
+
" in " .. table.concat(where, ", "))
|
|
127
|
+
end
|
|
128
|
+
tag(handle, "action", "intercept")
|
|
129
|
+
tag(handle, "secret", table.concat(names, ","))
|
|
130
|
+
tag(handle, "log", true)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
-- A CONNECT target, one spelling: lowercase, no trailing dot on the name.
|
|
134
|
+
local function normal(authority)
|
|
135
|
+
local a = authority:lower()
|
|
136
|
+
local host, port = a:match("^(%[[^%]]+%]):(%d+)$")
|
|
137
|
+
if host == nil then host, port = a:match("^([^:]+):(%d+)$") end
|
|
138
|
+
if host == nil then return nil end
|
|
139
|
+
host = host:gsub("%.$", "")
|
|
140
|
+
return host, port
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
local function ipv4(host)
|
|
144
|
+
local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
|
145
|
+
if a == nil then return nil end
|
|
146
|
+
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
|
|
147
|
+
if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
|
|
148
|
+
return a, b, c, d
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
-- An IPv6 literal as eight numbers, or nil.
|
|
152
|
+
local function ipv6(host)
|
|
153
|
+
local s = host:match("^%[(.+)%]$")
|
|
154
|
+
if s == nil or not s:find(":") then return nil end
|
|
155
|
+
local v4 = s:match(":(%d+%.%d+%.%d+%.%d+)$")
|
|
156
|
+
if v4 ~= nil then
|
|
157
|
+
local a, b, c, d = ipv4(v4)
|
|
158
|
+
if a == nil then return nil end
|
|
159
|
+
s = s:sub(1, #s - #v4) .. string.format("%x:%x", a * 256 + b, c * 256 + d)
|
|
160
|
+
end
|
|
161
|
+
local head, rest = s:match("^(.-)::(.*)$")
|
|
162
|
+
local function groups(part)
|
|
163
|
+
local out = {}
|
|
164
|
+
if part == "" then return out end
|
|
165
|
+
for g in (part .. ":"):gmatch("([^:]*):") do
|
|
166
|
+
if not g:match("^%x%x?%x?%x?$") then return nil end
|
|
167
|
+
out[#out + 1] = tonumber(g, 16)
|
|
168
|
+
end
|
|
169
|
+
return out
|
|
170
|
+
end
|
|
171
|
+
local left, right
|
|
172
|
+
if head == nil then
|
|
173
|
+
left, right = groups(s), {}
|
|
174
|
+
if left == nil or #left ~= 8 then return nil end
|
|
175
|
+
else
|
|
176
|
+
left, right = groups(head), groups(rest)
|
|
177
|
+
if left == nil or right == nil or #left + #right > 7 then return nil end
|
|
178
|
+
end
|
|
179
|
+
local out = {}
|
|
180
|
+
for _, g in ipairs(left) do out[#out + 1] = g end
|
|
181
|
+
for _ = 1, 8 - #left - #right do out[#out + 1] = 0 end
|
|
182
|
+
for _, g in ipairs(right) do out[#out + 1] = g end
|
|
183
|
+
return out
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
-- The dial guard's first half: a tunnel never reaches loopback, link-local (the cloud metadata
|
|
187
|
+
-- address among it), or an unspecified address — nor an IPv4-mapped spelling of one. By NAME it
|
|
188
|
+
-- knows only `localhost`. The second half is Envoy's: an RBAC filter judges the address the
|
|
189
|
+
-- dynamic forward proxy resolved a name to, and `envoy_on_response` logs its refusal (ADR-0059).
|
|
190
|
+
local function guarded(host)
|
|
191
|
+
if host == "localhost" or host:sub(-10) == ".localhost" then return true end
|
|
192
|
+
local a, b = ipv4(host)
|
|
193
|
+
if a ~= nil then
|
|
194
|
+
return a == 127 or a == 0 or (a == 169 and b == 254)
|
|
195
|
+
end
|
|
196
|
+
local g = ipv6(host)
|
|
197
|
+
if g ~= nil then
|
|
198
|
+
local zero = true
|
|
199
|
+
for i = 1, 7 do if g[i] ~= 0 then zero = false end end
|
|
200
|
+
if zero and (g[8] == 0 or g[8] == 1) then return true end
|
|
201
|
+
if g[1] >= 0xfe80 and g[1] <= 0xfebf then return true end
|
|
202
|
+
if g[1] == 0xfd00 and g[2] == 0xec2 and g[3] == 0 and g[4] == 0 and g[5] == 0 and g[6] == 0 and g[7] == 0
|
|
203
|
+
and g[8] == 0x254 then return true end
|
|
204
|
+
local mapped = g[1] == 0 and g[2] == 0 and g[3] == 0 and g[4] == 0 and g[5] == 0 and g[6] == 0xffff
|
|
205
|
+
if mapped then
|
|
206
|
+
local x, y = math.floor(g[7] / 256), g[7] % 256
|
|
207
|
+
return x == 127 or x == 0 or (x == 169 and y == 254)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
return false
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
local function egress(handle, headers)
|
|
214
|
+
if headers:get(":method") ~= "CONNECT" then
|
|
215
|
+
return refuse(handle, 405, "method", "this listener speaks CONNECT alone")
|
|
216
|
+
end
|
|
217
|
+
local authority = headers:get(":authority") or ""
|
|
218
|
+
local host, port = normal(authority)
|
|
219
|
+
if host == nil then
|
|
220
|
+
return refuse(handle, 400, "target", "a CONNECT names host:port")
|
|
221
|
+
end
|
|
222
|
+
local target = host .. ":" .. port
|
|
223
|
+
if target ~= authority then
|
|
224
|
+
headers:replace(":authority", target)
|
|
225
|
+
handle:clearRouteCache()
|
|
226
|
+
end
|
|
227
|
+
if HELD.targets[target] ~= nil then
|
|
228
|
+
-- Handed to the intercept listener, which logs each request it carries.
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
if guarded(host) then
|
|
232
|
+
return refuse(handle, 403, "guard", target .. " is loopback, link-local or unspecified")
|
|
233
|
+
end
|
|
234
|
+
tag(handle, "action", "tunnel")
|
|
235
|
+
tag(handle, "log", true)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
-- What a bound host is sent. TRACE is not: a server that answers it echoes the request, the
|
|
239
|
+
-- swapped value among it, back to the Agent. Neither is a WebDAV or other extension method.
|
|
240
|
+
local METHODS = { GET = true, HEAD = true, POST = true, PUT = true, PATCH = true, DELETE = true, OPTIONS = true }
|
|
241
|
+
|
|
242
|
+
local function intercept(handle, headers, target)
|
|
243
|
+
if not METHODS[headers:get(":method")] then
|
|
244
|
+
return refuse(handle, 405, "method", target .. " takes GET, HEAD, POST, PUT, PATCH, DELETE or OPTIONS here")
|
|
245
|
+
end
|
|
246
|
+
if headers:get("upgrade") ~= nil then
|
|
247
|
+
return refuse(handle, 501, "upgrade", target .. " takes no protocol upgrade here")
|
|
248
|
+
end
|
|
249
|
+
local host = (headers:get(":authority") or ""):lower()
|
|
250
|
+
local want = target
|
|
251
|
+
if target:sub(-4) == ":443" then
|
|
252
|
+
if host == target:sub(1, -5) then host = target end
|
|
253
|
+
end
|
|
254
|
+
if host ~= want then
|
|
255
|
+
return refuse(handle, 421, "host", "Host does not name " .. target)
|
|
256
|
+
end
|
|
257
|
+
local path = (headers:get(":path") or "/"):match("^[^?]*")
|
|
258
|
+
if path:lower():find("%%2[ef]") then
|
|
259
|
+
return refuse(handle, 400, "path", "an encoded / or . in the path")
|
|
260
|
+
end
|
|
261
|
+
for _, h in ipairs({ "te", "trailer", "keep-alive", "proxy-connection" }) do headers:remove(h) end
|
|
262
|
+
return hold(handle, headers, HELD.targets[target], target, path)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
local function control(handle, headers, route)
|
|
266
|
+
if route == "fetch" then
|
|
267
|
+
local sandbox = os.getenv("JR2_SANDBOX")
|
|
268
|
+
if sandbox == nil or sandbox == "" then
|
|
269
|
+
tag(handle, "action", "refuse")
|
|
270
|
+
tag(handle, "reason", "no-sandbox")
|
|
271
|
+
tag(handle, "log", true)
|
|
272
|
+
return handle:respond({ [":status"] = "404", ["content-type"] = "application/json" },
|
|
273
|
+
'{"error":"this pod serves no Sandbox, so it mounts no Repo to fetch (ADR-0053)"}')
|
|
274
|
+
end
|
|
275
|
+
headers:replace(":path", "/sandboxes/" .. sandbox .. "/fetch")
|
|
276
|
+
end
|
|
277
|
+
return hold(handle, headers, HELD.control, "orchestrator", headers:get(":path"))
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
local READY = false
|
|
281
|
+
|
|
282
|
+
-- A refusal by resolved address comes from the RBAC filter after this one: the one 403 a request
|
|
283
|
+
-- this script let through as a tunnel can get. It is logged as a refusal by spelling is.
|
|
284
|
+
function envoy_on_response(handle)
|
|
285
|
+
if handle:metadata():get("role") ~= "egress" then return end
|
|
286
|
+
if handle:headers():get(":status") ~= "403" then return end
|
|
287
|
+
local jr2 = handle:streamInfo():dynamicMetadata():get("jr2")
|
|
288
|
+
if jr2 == nil or jr2.action ~= "tunnel" then return end
|
|
289
|
+
tag(handle, "action", "refuse")
|
|
290
|
+
tag(handle, "reason", "guard")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
function envoy_on_request(handle)
|
|
294
|
+
local meta = handle:metadata()
|
|
295
|
+
local role = meta:get("role")
|
|
296
|
+
local headers = handle:headers()
|
|
297
|
+
if role == "egress" then return egress(handle, headers) end
|
|
298
|
+
if role == "intercept" then return intercept(handle, headers, meta:get("target")) end
|
|
299
|
+
if role == "control" then return control(handle, headers, meta:get("route")) end
|
|
300
|
+
if role == "health" then
|
|
301
|
+
if not READY then
|
|
302
|
+
READY = true
|
|
303
|
+
local names = {}
|
|
304
|
+
for _, s in ipairs(HELD.secrets) do names[#names + 1] = s.name end
|
|
305
|
+
io.stdout:write("jr2.custodian ready secrets=" .. table.concat(names, ",") .. " hosts=" .. HELD.hosts .. "\n")
|
|
306
|
+
io.stdout:flush()
|
|
307
|
+
end
|
|
308
|
+
return handle:respond({ [":status"] = "200", ["content-type"] = "text/plain" }, "ok\n")
|
|
309
|
+
end
|
|
310
|
+
if role == "deny" then
|
|
311
|
+
return refuse(handle, 404, "route", "no such route")
|
|
312
|
+
end
|
|
313
|
+
end
|