@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.
- package/.dockerignore +3 -0
- package/.env.example +13 -0
- package/CHANGELOG.md +17 -0
- package/INSTALL.md +25 -5
- package/README.md +10 -10
- package/bin/create-nestbox.js +89 -2
- package/docker/control/Dockerfile +12 -0
- package/docker/control/server.py +660 -0
- package/docker/opencode/Dockerfile +1 -1
- package/docker-compose.yaml +70 -0
- package/home/code/__includes/functions.php +68 -0
- package/home/configs/opencode/control-mcp.js +102 -0
- package/home/configs/opencode/instructions.md +20 -4
- package/home/configs/opencode/opencode.json +11 -0
- package/home/configs/rollup/package-lock.json +10 -13
- package/host-runner.mjs +288 -0
- package/package.host.json +8 -0
- package/package.json +13 -2
- package/scripts/check-package.js +18 -1
- package/template.gitignore +1 -0
- package/tests/control.mjs +70 -0
- package/tests/creator.mjs +43 -0
- package/tests/host-runner.mjs +143 -0
- package/tests/nestbox.ps1 +25 -6
- package/tests/nestbox.sh +24 -5
- package/home/code/scripts-bundle.css +0 -13295
- package/home/code/scripts-bundle.js +0 -6277
|
@@ -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
|