@p10i/create-nestbox 0.1.2 → 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.
@@ -0,0 +1,660 @@
1
+ #!/usr/bin/env python3
2
+ import http.client
3
+ import hmac
4
+ import json
5
+ import os
6
+ import re
7
+ import socket
8
+ import sys
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from urllib.parse import parse_qs, quote, unquote, urlparse
15
+
16
+ DOCKER_SOCKET = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
17
+ WORKSPACE_PATH = os.environ.get("WORKSPACE_PATH", "/workspace")
18
+ PROJECT_NAME = os.environ.get("COMPOSE_PROJECT_NAME", "").strip()
19
+ SERVICE_NAME = os.environ.get("NESTBOX_CONTROL_SERVICE", "control")
20
+ HOST_TOKEN_FILE = os.environ.get("NESTBOX_HOST_TOKEN_FILE", "/run/secrets/nestbox-host-token")
21
+ try:
22
+ with open(HOST_TOKEN_FILE, encoding="utf-8") as token_stream:
23
+ HOST_TOKEN = token_stream.read().strip()
24
+ except OSError:
25
+ HOST_TOKEN = ""
26
+ HOST_TIMEOUT = int(os.environ.get("NESTBOX_HOST_RUNNER_TIMEOUT", "3600"))
27
+ HOST_HEARTBEAT_TTL = int(os.environ.get("NESTBOX_HOST_RUNNER_HEARTBEAT_TTL", "10"))
28
+ HOST_LONG_POLL_TIMEOUT = int(os.environ.get("NESTBOX_HOST_LONG_POLL_TIMEOUT", "25"))
29
+ MAX_HOST_JOBS = int(os.environ.get("NESTBOX_MAX_HOST_JOBS", "100"))
30
+ MAX_BODY_BYTES = 128 * 1024
31
+ MAX_HOST_BODY_BYTES = 64 * 1024 * 1024
32
+ MAX_COMMAND_PARTS = 256
33
+ MAX_COMMAND_BYTES = 64 * 1024
34
+ LOG_OUTPUT = os.environ.get("NESTBOX_CONTROL_LOG_OUTPUT", "text").lower()
35
+ LOG_MAX_BYTES = int(os.environ.get("NESTBOX_CONTROL_LOG_MAX_BYTES", str(256 * 1024)))
36
+ LOG_LOCK = threading.Lock()
37
+
38
+
39
+ def now_iso():
40
+ return datetime.now(timezone.utc).isoformat()
41
+
42
+
43
+ def run_id(prefix):
44
+ return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:12]}"
45
+
46
+
47
+ def log_event(event, **fields):
48
+ safe = dict(fields)
49
+ command = safe.pop("command", None)
50
+ if isinstance(command, list) and command:
51
+ safe["commandName"] = os.path.basename(command[0])
52
+ safe["argsCount"] = len(command) - 1
53
+ safe.pop("args", None)
54
+ record = {
55
+ "timestamp": now_iso(),
56
+ "schemaVersion": 1,
57
+ "service": "nestbox-control",
58
+ "event": event,
59
+ **safe,
60
+ }
61
+ with LOG_LOCK:
62
+ sys.stderr.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
63
+ sys.stderr.flush()
64
+
65
+
66
+ def log_output(identifier, stream, content):
67
+ if LOG_OUTPUT != "text" or not content:
68
+ return
69
+ data = content if isinstance(content, bytes) else str(content).encode()
70
+ captured = data[:LOG_MAX_BYTES].decode("utf-8", "replace")
71
+ for line in captured.splitlines():
72
+ log_event("command.output", runId=identifier, stream=stream, message=line)
73
+ if len(data) > LOG_MAX_BYTES:
74
+ log_event("command.output.truncated", runId=identifier, stream=stream, capturedBytes=LOG_MAX_BYTES, totalBytes=len(data))
75
+
76
+
77
+ class UnixHTTPConnection(http.client.HTTPConnection):
78
+ def __init__(self, path):
79
+ super().__init__("localhost")
80
+ self.path = path
81
+
82
+ def connect(self):
83
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
84
+ self.sock.connect(self.path)
85
+
86
+
87
+ def docker_request(method, path, body=None, max_bytes=None):
88
+ payload = json.dumps(body).encode() if body is not None else None
89
+ headers = {"Content-Type": "application/json"} if payload is not None else {}
90
+ connection = UnixHTTPConnection(DOCKER_SOCKET)
91
+ try:
92
+ connection.request(method, path, body=payload, headers=headers)
93
+ response = connection.getresponse()
94
+ if max_bytes is None:
95
+ data = response.read()
96
+ else:
97
+ chunks = []
98
+ remaining = max_bytes
99
+ total_bytes = 0
100
+ while True:
101
+ chunk = response.read(65536)
102
+ if not chunk:
103
+ break
104
+ total_bytes += len(chunk)
105
+ if remaining > 0:
106
+ chunks.append(chunk[:remaining])
107
+ remaining -= min(len(chunk), remaining)
108
+ data = b"".join(chunks)
109
+ finally:
110
+ connection.close()
111
+ if response.status >= 400:
112
+ raise RuntimeError(f"Docker API returned HTTP {response.status}: {data.decode('utf-8', 'replace')}")
113
+ if not data:
114
+ return (None, 0) if max_bytes is not None else None
115
+ value = json.loads(data) if "application/json" in response.getheader("Content-Type", "") else data
116
+ return (value, total_bytes) if max_bytes is not None else value
117
+
118
+
119
+ def project_containers():
120
+ containers = docker_request("GET", "/containers/json?all=1") or []
121
+ return [item for item in containers if (item.get("Labels") or {}).get("com.docker.compose.project") == PROJECT_NAME]
122
+
123
+
124
+ def names(container):
125
+ labels = container.get("Labels") or {}
126
+ values = {labels.get("com.docker.compose.service", ""), container.get("Id", "")}
127
+ values.update(name.strip("/") for name in container.get("Names") or [])
128
+ return {value for value in values if value}
129
+
130
+
131
+ def find_by_name(name):
132
+ return next((container for container in project_containers() if name in names(container)), None)
133
+
134
+
135
+ def find_by_ip(ip):
136
+ for container in project_containers():
137
+ details = docker_request("GET", f"/containers/{container['Id']}/json")
138
+ networks = (details.get("NetworkSettings") or {}).get("Networks") or {}
139
+ if any(ip in {network.get("IPAddress"), network.get("GlobalIPv6Address")} for network in networks.values()):
140
+ return container
141
+ return None
142
+
143
+
144
+ def truthy(labels, key):
145
+ return str(labels.get(key, "")).lower() in {"1", "true", "yes", "on"}
146
+
147
+
148
+ def service(container):
149
+ return ((container or {}).get("Labels") or {}).get("com.docker.compose.service", "")
150
+
151
+
152
+ def is_root_user(user):
153
+ account = str(user).split(":", 1)[0].strip().lower()
154
+ return account == "root" or bool(re.fullmatch(r"\+?0+", account))
155
+
156
+
157
+ def policy_error(source, target, user, configured_user=""):
158
+ if not source:
159
+ return "caller container was not found in this Compose project"
160
+ source_labels = source.get("Labels") or {}
161
+ target_labels = target.get("Labels") or {}
162
+ if service(source) == SERVICE_NAME:
163
+ return "control service cannot call itself through the API"
164
+ if truthy(source_labels, "nestbox.exec.deny-source"):
165
+ return f"source {service(source)} is denied by label"
166
+ if truthy(target_labels, "nestbox.exec.deny-target"):
167
+ return f"target {service(target)} is denied by label"
168
+ if not truthy(source_labels, "nestbox.exec.allow-source"):
169
+ return f"source {service(source)} is not allowed to call exec"
170
+ if not truthy(target_labels, "nestbox.exec.allow-target"):
171
+ return f"target {service(target)} does not allow inbound exec"
172
+ allowed = {item.strip() for item in str(source_labels.get("nestbox.exec.targets", "")).split(",") if item.strip()}
173
+ if not allowed:
174
+ return f"source {service(source)} has no allowed exec targets"
175
+ if "*" not in allowed and names(target).isdisjoint(allowed):
176
+ return f"source {service(source)} is not allowed to exec into {service(target)}"
177
+ if is_root_user(user) and not truthy(target_labels, "nestbox.exec.allow-root"):
178
+ return f"target {service(target)} does not allow root exec"
179
+ account = str(user).split(":", 1)[0].strip().lower()
180
+ configured_account = str(configured_user).split(":", 1)[0].strip().lower()
181
+ if account and not re.fullmatch(r"\+?\d+", account) and account != configured_account and not truthy(target_labels, "nestbox.exec.allow-root"):
182
+ return f"target {service(target)} does not allow named user overrides"
183
+ return ""
184
+
185
+
186
+ def allows_npm(source):
187
+ labels = (source or {}).get("Labels") or {}
188
+ return bool(source) and truthy(labels, "nestbox.npm.allow") and not truthy(labels, "nestbox.exec.deny-source")
189
+
190
+
191
+ def package_scripts():
192
+ path = os.path.join(WORKSPACE_PATH, "package.json")
193
+ if not os.path.isfile(path):
194
+ return None
195
+ with open(path, encoding="utf-8") as stream:
196
+ document = json.load(stream)
197
+ scripts = document.get("scripts", {})
198
+ if not isinstance(scripts, dict):
199
+ raise ValueError("package.json scripts must be an object")
200
+ return {str(name): str(command) for name, command in scripts.items()}
201
+
202
+
203
+ class HostBroker:
204
+ def __init__(self):
205
+ self.condition = threading.Condition()
206
+ self.pending = []
207
+ self.jobs = {}
208
+ self.runner = {}
209
+
210
+ def touch(self, runner_id, headers):
211
+ if not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", runner_id):
212
+ raise ValueError("invalid runner ID")
213
+ now = time.monotonic()
214
+ with self.condition:
215
+ active_id = self.runner.get("id")
216
+ active_age = now - self.runner.get("seen", 0)
217
+ if active_id and active_id != runner_id and active_age <= HOST_HEARTBEAT_TTL:
218
+ raise PermissionError("another host runner is active")
219
+ if active_id and active_id != runner_id:
220
+ for record in self.jobs.values():
221
+ if record.get("runnerId") == active_id and record.get("result") is None:
222
+ record["result"] = {
223
+ "jobId": record["job"]["jobId"],
224
+ "exitCode": 1,
225
+ "stdout": "",
226
+ "stderr": "The assigned host runner disconnected before returning a result.",
227
+ "interrupted": True,
228
+ }
229
+ self.runner = {
230
+ "id": runner_id,
231
+ "seen": now,
232
+ "pid": headers.get("X-Nestbox-Runner-Pid"),
233
+ "platform": headers.get("X-Nestbox-Runner-Platform"),
234
+ "hostname": headers.get("X-Nestbox-Runner-Host"),
235
+ }
236
+ self.condition.notify_all()
237
+
238
+ def status(self):
239
+ with self.condition:
240
+ if not self.runner:
241
+ return {"available": False}
242
+ age = max(0, time.monotonic() - self.runner["seen"])
243
+ return {
244
+ "available": age <= HOST_HEARTBEAT_TTL,
245
+ "ageSeconds": round(age, 3),
246
+ "pid": self.runner.get("pid"),
247
+ "platform": self.runner.get("platform"),
248
+ "hostname": self.runner.get("hostname"),
249
+ }
250
+
251
+ def enqueue(self, job):
252
+ with self.condition:
253
+ if len(self.jobs) >= MAX_HOST_JOBS:
254
+ raise RuntimeError("too many host jobs are pending")
255
+ self.jobs[job["jobId"]] = {"job": job, "result": None, "runnerId": None}
256
+ self.pending.append(job["jobId"])
257
+ self.condition.notify_all()
258
+
259
+ def next(self, runner_id, headers, timeout):
260
+ deadline = time.monotonic() + timeout
261
+ with self.condition:
262
+ while True:
263
+ if self.runner.get("id") != runner_id:
264
+ raise PermissionError("another host runner is active")
265
+ if time.monotonic() - self.runner.get("seen", 0) > HOST_HEARTBEAT_TTL:
266
+ return None
267
+ if self.pending:
268
+ identifier = self.pending.pop(0)
269
+ record = self.jobs.get(identifier)
270
+ if record:
271
+ record["runnerId"] = runner_id
272
+ return record["job"]
273
+ remaining = deadline - time.monotonic()
274
+ if remaining <= 0:
275
+ return None
276
+ self.condition.wait(min(1, remaining))
277
+
278
+ def complete(self, runner_id, identifier, result):
279
+ with self.condition:
280
+ record = self.jobs.get(identifier)
281
+ if not record or record.get("runnerId") != runner_id:
282
+ raise ValueError("host job is unknown or belongs to another runner")
283
+ if record.get("result") is not None:
284
+ raise ValueError("host job already has a result")
285
+ if result.get("jobId") != identifier:
286
+ raise ValueError("host result contains a mismatched job ID")
287
+ job = record["job"]
288
+ if job["type"] == "npm.run":
289
+ if not isinstance(result.get("exitCode"), int) or not isinstance(result.get("stdout"), str) or not isinstance(result.get("stderr"), str):
290
+ raise ValueError("invalid npm run result")
291
+ elif job["type"] == "npm.scripts.request":
292
+ if result.get("status") != "confirmation_required" or result.get("requestId") != job.get("requestId") or result.get("operation") != job.get("operation") or result.get("name") != job.get("name"):
293
+ if not isinstance(result.get("exitCode"), int):
294
+ raise ValueError("invalid script change request result")
295
+ elif job["type"] == "npm.scripts.confirm":
296
+ if result.get("status") == "success" and result.get("requestId") != job.get("requestId"):
297
+ raise ValueError("invalid script confirmation result")
298
+ if result.get("status") != "success" and not isinstance(result.get("exitCode"), int):
299
+ raise ValueError("invalid script confirmation result")
300
+ record["result"] = result
301
+ self.condition.notify_all()
302
+
303
+ def wait(self, identifier, timeout=HOST_TIMEOUT):
304
+ deadline = time.monotonic() + timeout
305
+ with self.condition:
306
+ while True:
307
+ record = self.jobs.get(identifier)
308
+ if record and record.get("runnerId") and record.get("result") is None:
309
+ owner = record["runnerId"]
310
+ if self.runner.get("id") != owner or time.monotonic() - self.runner.get("seen", 0) > HOST_HEARTBEAT_TTL:
311
+ record["result"] = {
312
+ "jobId": identifier,
313
+ "exitCode": 1,
314
+ "stdout": "",
315
+ "stderr": "The assigned host runner disconnected before returning a result.",
316
+ "interrupted": True,
317
+ }
318
+ elif record and record.get("result") is None and self.runner and time.monotonic() - self.runner.get("seen", 0) > HOST_HEARTBEAT_TTL:
319
+ record["result"] = {
320
+ "jobId": identifier,
321
+ "exitCode": 1,
322
+ "stdout": "",
323
+ "stderr": "The host runner disconnected before accepting the job.",
324
+ "interrupted": True,
325
+ }
326
+ if record and record.get("result") is not None:
327
+ result = record["result"]
328
+ del self.jobs[identifier]
329
+ return result
330
+ remaining = deadline - time.monotonic()
331
+ if remaining <= 0:
332
+ self.jobs.pop(identifier, None)
333
+ if identifier in self.pending:
334
+ self.pending.remove(identifier)
335
+ raise TimeoutError(f"host runner did not finish within {timeout} seconds")
336
+ self.condition.wait(min(1, remaining))
337
+
338
+
339
+ HOST_BROKER = HostBroker()
340
+
341
+
342
+ def host_status():
343
+ return HOST_BROKER.status()
344
+
345
+
346
+ def enqueue(job):
347
+ HOST_BROKER.enqueue(job)
348
+
349
+
350
+ def wait_result(identifier, timeout=HOST_TIMEOUT):
351
+ return HOST_BROKER.wait(identifier, timeout)
352
+
353
+
354
+ def record_host_result(identifier, source_name, script, result, detached):
355
+ stdout, stderr = str(result.get("stdout", "")), str(result.get("stderr", ""))
356
+ log_output(identifier, "npm.stdout", stdout)
357
+ log_output(identifier, "npm.stderr", stderr)
358
+ log_event("npm.complete", runId=identifier, source=source_name, script=script, detached=detached, executor="host", exitCode=result.get("exitCode"), hostPlatform=result.get("hostPlatform"), hostName=result.get("hostName"), stdoutTruncated=bool(result.get("stdoutTruncated")), stderrTruncated=bool(result.get("stderrTruncated")))
359
+
360
+
361
+ def wait_detached(identifier, source_name, script):
362
+ try:
363
+ record_host_result(identifier, source_name, script, wait_result(identifier), True)
364
+ except Exception as error:
365
+ log_event("npm.error", runId=identifier, source=source_name, script=script, detached=True, error=str(error))
366
+
367
+
368
+ def valid_command(command):
369
+ return (isinstance(command, list) and 0 < len(command) <= MAX_COMMAND_PARTS
370
+ and all(isinstance(part, str) and "\0" not in part for part in command)
371
+ and sum(len(part.encode()) for part in command) <= MAX_COMMAND_BYTES)
372
+
373
+
374
+ class Handler(BaseHTTPRequestHandler):
375
+ server_version = "nestbox-control/0.3"
376
+
377
+ def setup(self):
378
+ super().setup()
379
+ self.connection.settimeout(30)
380
+
381
+ def log_message(self, fmt, *args):
382
+ log_event("http.access", client=self.address_string(), message=fmt % args)
383
+
384
+ def send_json(self, status, value):
385
+ payload = json.dumps(value, ensure_ascii=False).encode()
386
+ self.send_response(status)
387
+ self.send_header("Content-Type", "application/json; charset=utf-8")
388
+ self.send_header("Content-Length", str(len(payload)))
389
+ self.end_headers()
390
+ self.wfile.write(payload)
391
+
392
+ def caller(self):
393
+ return find_by_ip(self.client_address[0])
394
+
395
+ def body(self):
396
+ if self.headers.get("Transfer-Encoding"):
397
+ raise ValueError("transfer encoding is not supported")
398
+ length = int(self.headers.get("Content-Length", "0"))
399
+ if length < 0 or length > MAX_BODY_BYTES:
400
+ raise ValueError("request body is too large")
401
+ value = json.loads(self.rfile.read(length)) if length else {}
402
+ if not isinstance(value, dict):
403
+ raise ValueError("request body must be a JSON object")
404
+ return value
405
+
406
+ def do_GET(self):
407
+ path = urlparse(self.path).path
408
+ if path == "/health":
409
+ try:
410
+ docker_request("GET", "/_ping")
411
+ self.send_json(200, {"ok": True, "dockerSocketAccess": True, "workspacePackageJson": os.path.isfile(os.path.join(WORKSPACE_PATH, "package.json")), "hostRunner": host_status()})
412
+ except Exception as error:
413
+ self.send_json(503, {"ok": False, "dockerSocketAccess": False, "error": str(error), "hostRunner": host_status()})
414
+ return
415
+ if path == "/npm/scripts":
416
+ source = self.caller()
417
+ if not allows_npm(source):
418
+ self.send_json(403, {"error": "caller is not allowed to use npm"})
419
+ return
420
+ try:
421
+ scripts = package_scripts()
422
+ except Exception as error:
423
+ self.send_json(400, {"error": str(error)})
424
+ return
425
+ self.send_json(200, {"scripts": scripts}) if scripts is not None else self.send_json(404, {"error": "package.json was not found at /workspace/package.json"})
426
+ return
427
+ self.send_json(404, {"error": "not found"})
428
+
429
+ def do_POST(self):
430
+ try:
431
+ body = self.body()
432
+ except Exception as error:
433
+ self.send_json(400, {"error": str(error)})
434
+ return
435
+ path = urlparse(self.path).path
436
+ if path.startswith("/containers/") and path.endswith("/exec"):
437
+ self.container_exec(unquote(path[len("/containers/"):-len("/exec")]).strip("/"), body)
438
+ elif path == "/npm/run":
439
+ self.npm_run(body)
440
+ elif path == "/npm/scripts/request":
441
+ self.script_request(body)
442
+ elif path == "/npm/scripts/confirm":
443
+ self.script_confirm(body)
444
+ else:
445
+ self.send_json(404, {"error": "not found"})
446
+
447
+ def container_exec(self, target_name, body):
448
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", target_name):
449
+ self.send_json(400, {"error": "invalid target name"}); return
450
+ target, source = find_by_name(target_name), self.caller()
451
+ if not target:
452
+ self.send_json(404, {"error": "target container was not found in this Compose project"}); return
453
+ command, user, workdir = body.get("command"), str(body.get("user", "")), str(body.get("workdir", ""))
454
+ if not valid_command(command):
455
+ self.send_json(400, {"error": "command must be a bounded, non-empty string array"}); return
456
+ if not workdir.startswith("/") or "\0" in workdir:
457
+ self.send_json(400, {"error": "workdir must be an absolute container path"}); return
458
+ effective_user = user
459
+ details = docker_request("GET", f"/containers/{quote(target['Id'])}/json") or {}
460
+ configured_user = str((details.get("Config") or {}).get("User", ""))
461
+ if not effective_user:
462
+ effective_user = configured_user or "0"
463
+ error = policy_error(source, target, effective_user, configured_user)
464
+ if error:
465
+ self.send_json(403, {"error": error}); return
466
+ identifier, detached = run_id("exec"), bool(body.get("detach", False))
467
+ try:
468
+ created = docker_request("POST", f"/containers/{quote(target['Id'])}/exec", {"AttachStdout": True, "AttachStderr": True, "Cmd": command, "Tty": True, "User": user, "WorkingDir": workdir})
469
+ if detached:
470
+ def finish():
471
+ output, total_bytes = docker_request("POST", f"/exec/{created['Id']}/start", {"Detach": False, "Tty": True}, LOG_MAX_BYTES)
472
+ output = output or b""
473
+ info = docker_request("GET", f"/exec/{created['Id']}/json") or {}
474
+ log_output(identifier, "exec.output", output)
475
+ log_event("exec.complete", runId=identifier, source=service(source), target=service(target), command=command, detached=True, exitCode=info.get("ExitCode"), outputTruncated=total_bytes > len(output), outputTotalBytes=total_bytes)
476
+ threading.Thread(target=finish, daemon=True).start()
477
+ self.send_json(202, {"runId": identifier, "execId": created["Id"], "detached": True}); return
478
+ output, total_bytes = docker_request("POST", f"/exec/{created['Id']}/start", {"Detach": False, "Tty": True}, LOG_MAX_BYTES)
479
+ output = output or b""
480
+ info = docker_request("GET", f"/exec/{created['Id']}/json") or {}
481
+ log_output(identifier, "exec.output", output)
482
+ self.send_json(200, {"runId": identifier, "exitCode": info.get("ExitCode"), "output": output.decode("utf-8", "replace"), "outputTruncated": total_bytes > len(output), "outputTotalBytes": total_bytes})
483
+ except Exception as error:
484
+ log_event("exec.error", runId=identifier, source=service(source), target=service(target), command=command, error=str(error))
485
+ self.send_json(502, {"error": str(error)})
486
+
487
+ def npm_context(self):
488
+ source = self.caller()
489
+ if not allows_npm(source):
490
+ self.send_json(403, {"error": "caller is not allowed to use npm"})
491
+ return None
492
+ if not host_status().get("available"):
493
+ self.send_json(503, {"error": "host runner is unavailable; run `npm run host --` from the workspace"})
494
+ return None
495
+ return source
496
+
497
+ def npm_run(self, body):
498
+ source = self.npm_context()
499
+ if source is None: return
500
+ script, args = body.get("script"), body.get("args", [])
501
+ if not isinstance(script, str) or not re.fullmatch(r"[A-Za-z0-9:_@./ -]{1,128}", script) or script == "host":
502
+ self.send_json(400, {"error": "invalid or protected script name"}); return
503
+ if not isinstance(args, list) or not all(isinstance(arg, str) and "\0" not in arg for arg in args):
504
+ self.send_json(400, {"error": "args must be a string array"}); return
505
+ try:
506
+ scripts = package_scripts()
507
+ except Exception as error:
508
+ self.send_json(400, {"error": str(error)}); return
509
+ if scripts is None or script not in scripts:
510
+ self.send_json(404, {"error": "script is not defined in /workspace/package.json"}); return
511
+ identifier = run_id("npm")
512
+ job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.run", "script": script, "args": args, "source": service(source), "createdAt": now_iso()}
513
+ try:
514
+ enqueue(job)
515
+ if body.get("detach"):
516
+ threading.Thread(target=wait_detached, args=(identifier, service(source), script), daemon=True).start()
517
+ self.send_json(202, {"runId": identifier, "detached": True, "executor": "host"}); return
518
+ result = wait_result(identifier)
519
+ record_host_result(identifier, service(source), script, result, False)
520
+ self.send_json(200, {"runId": identifier, "exitCode": result.get("exitCode"), "stdout": str(result.get("stdout", "")), "stderr": str(result.get("stderr", "")), "pid": result.get("pid"), "executor": "host"})
521
+ except Exception as error:
522
+ self.send_json(502, {"error": str(error)})
523
+
524
+ def script_request(self, body):
525
+ source = self.npm_context()
526
+ if source is None: return
527
+ operation, name, command = body.get("operation"), body.get("name"), body.get("command")
528
+ if operation not in {"add", "edit", "delete"} or not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9:_@./ -]{1,128}", name) or name == "host":
529
+ self.send_json(400, {"error": "invalid script change"}); return
530
+ if operation != "delete" and (not isinstance(command, str) or not command.strip() or len(command) > 8192):
531
+ self.send_json(400, {"error": "command must be a non-empty string of at most 8192 characters"}); return
532
+ if operation == "delete" and command is not None:
533
+ self.send_json(400, {"error": "delete does not accept a command"}); return
534
+ identifier = run_id("npm-script-request")
535
+ job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.request", "requestId": identifier, "operation": operation, "name": name, "source": service(source), "createdAt": now_iso()}
536
+ if operation != "delete": job["command"] = command
537
+ try:
538
+ enqueue(job)
539
+ result = wait_result(identifier)
540
+ if result.get("status") != "confirmation_required": raise ValueError(result.get("stderr", "host runner rejected the request"))
541
+ self.send_json(202, {"status": "confirmation_required", "requestId": result.get("requestId"), "operation": operation, "name": name, "expiresAt": result.get("expiresAt")})
542
+ except Exception as error:
543
+ self.send_json(400, {"error": str(error)})
544
+
545
+ def script_confirm(self, body):
546
+ source = self.npm_context()
547
+ if source is None: return
548
+ request_id, code = body.get("requestId"), body.get("code")
549
+ if not isinstance(request_id, str) or not re.fullmatch(r"npm-script-request-[A-Za-z0-9-]{16,80}", request_id) or not isinstance(code, str) or not re.fullmatch(r"[A-Za-z0-9_-]{4,32}", code):
550
+ self.send_json(400, {"error": "invalid request ID or confirmation code"}); return
551
+ identifier = run_id("npm-script-confirm")
552
+ job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.confirm", "requestId": request_id, "code": code, "source": service(source), "createdAt": now_iso()}
553
+ try:
554
+ enqueue(job)
555
+ result = wait_result(identifier)
556
+ if result.get("status") != "success": raise ValueError(result.get("stderr", "host runner rejected the confirmation"))
557
+ self.send_json(200, {"status": "success", "requestId": request_id, "operation": result.get("operation"), "name": result.get("name")})
558
+ except Exception as error:
559
+ self.send_json(400, {"error": str(error)})
560
+
561
+
562
+ class HostHandler(BaseHTTPRequestHandler):
563
+ server_version = "nestbox-host-control/0.3"
564
+
565
+ def setup(self):
566
+ super().setup()
567
+ self.connection.settimeout(HOST_LONG_POLL_TIMEOUT + 10)
568
+
569
+ def log_message(self, fmt, *args):
570
+ log_event("host.http.access", client=self.address_string(), message=fmt % args)
571
+
572
+ def send_json(self, status, value):
573
+ payload = json.dumps(value, ensure_ascii=False).encode()
574
+ self.send_response(status)
575
+ self.send_header("Content-Type", "application/json; charset=utf-8")
576
+ self.send_header("Content-Length", str(len(payload)))
577
+ self.end_headers()
578
+ self.wfile.write(payload)
579
+
580
+ def authorized(self):
581
+ supplied = self.headers.get("Authorization", "")
582
+ expected = f"Bearer {HOST_TOKEN}"
583
+ return hmac.compare_digest(supplied.encode(), expected.encode())
584
+
585
+ def runner(self):
586
+ return self.headers.get("X-Nestbox-Runner-Id", "")
587
+
588
+ def body(self):
589
+ if self.headers.get("Transfer-Encoding"):
590
+ raise ValueError("transfer encoding is not supported")
591
+ length = int(self.headers.get("Content-Length", "0"))
592
+ if length < 0 or length > MAX_HOST_BODY_BYTES:
593
+ raise ValueError("request body is too large")
594
+ value = json.loads(self.rfile.read(length)) if length else {}
595
+ if not isinstance(value, dict):
596
+ raise ValueError("request body must be a JSON object")
597
+ return value
598
+
599
+ def authenticate(self):
600
+ if not self.authorized():
601
+ self.send_json(401, {"error": "invalid host runner token"})
602
+ return False
603
+ try:
604
+ HOST_BROKER.touch(self.runner(), self.headers)
605
+ return True
606
+ except PermissionError as error:
607
+ self.send_json(409, {"error": str(error)})
608
+ except ValueError as error:
609
+ self.send_json(400, {"error": str(error)})
610
+ return False
611
+
612
+ def do_GET(self):
613
+ parsed = urlparse(self.path)
614
+ if parsed.path == "/host/health":
615
+ if not self.authenticate(): return
616
+ self.send_json(200, {"ok": True, "hostRunner": host_status()})
617
+ return
618
+ if parsed.path == "/host/jobs/next":
619
+ if not self.authenticate(): return
620
+ try:
621
+ requested = int((parse_qs(parsed.query).get("timeout") or [HOST_LONG_POLL_TIMEOUT])[0])
622
+ timeout = max(1, min(HOST_LONG_POLL_TIMEOUT, requested))
623
+ job = HOST_BROKER.next(self.runner(), self.headers, timeout)
624
+ if job is None:
625
+ self.send_response(204); self.end_headers(); return
626
+ self.send_json(200, job)
627
+ except (TypeError, ValueError) as error:
628
+ self.send_json(400, {"error": str(error)})
629
+ return
630
+ self.send_json(404, {"error": "not found"})
631
+
632
+ def do_POST(self):
633
+ if not self.authenticate(): return
634
+ path = urlparse(self.path).path
635
+ if path == "/host/heartbeat":
636
+ self.send_json(200, {"ok": True})
637
+ return
638
+ match = re.fullmatch(r"/host/jobs/([A-Za-z0-9._:-]{1,128})/result", path)
639
+ if not match:
640
+ self.send_json(404, {"error": "not found"}); return
641
+ try:
642
+ result = self.body()
643
+ HOST_BROKER.complete(self.runner(), match.group(1), result)
644
+ self.send_json(202, {"accepted": True})
645
+ except ValueError as error:
646
+ self.send_json(400, {"error": str(error)})
647
+
648
+
649
+ def main():
650
+ if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", PROJECT_NAME):
651
+ raise SystemExit("COMPOSE_PROJECT_NAME must be a non-empty lowercase project slug")
652
+ if len(HOST_TOKEN) < 32:
653
+ raise SystemExit("NESTBOX_HOST_TOKEN_FILE must contain a token of at least 32 characters")
654
+ host_server = ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("NESTBOX_CONTROL_HOST_PORT", "4089"))), HostHandler)
655
+ threading.Thread(target=host_server.serve_forever, daemon=True).start()
656
+ ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("NESTBOX_CONTROL_PORT", "4088"))), Handler).serve_forever()
657
+
658
+
659
+ if __name__ == "__main__":
660
+ main()
@@ -10,7 +10,7 @@ FROM ghcr.io/anomalyco/opencode:latest
10
10
  ARG APP_UID=1000
11
11
  ARG APP_GID=1000
12
12
 
13
- RUN apk add --no-cache git php85 php85-dom php85-mbstring php85-openssl php85-phar php85-tokenizer php85-xml php85-xmlwriter su-exec && \
13
+ RUN apk add --no-cache git nodejs php85 php85-dom php85-mbstring php85-openssl php85-phar php85-tokenizer php85-xml php85-xmlwriter su-exec && \
14
14
  ln -s /usr/bin/php85 /usr/local/bin/php && \
15
15
  addgroup -g "${APP_GID}" -S nestbox && \
16
16
  adduser -u "${APP_UID}" -G nestbox -S -h /home/nestbox nestbox