@naviyra/cloud-panel 0.0.3-beta → 0.0.3-beta3
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/apps/agent/src/cloudflare.mjs +858 -733
- package/apps/agent/src/files.mjs +162 -0
- package/apps/agent/src/host-tools.mjs +518 -0
- package/apps/agent/src/native.mjs +157 -32
- package/apps/agent/src/runtime.mjs +70 -37
- package/apps/agent/src/server.mjs +61 -6
- package/apps/panel/app/api/[...path]/route.js +96 -14
- package/apps/panel/app/globals.css +313 -7
- package/apps/panel/app/layout.jsx +2 -1
- package/apps/panel/app/login/page.jsx +3 -8
- package/apps/panel/app/page.jsx +17 -7
- package/apps/panel/app/ui/cloudflare.jsx +72 -47
- package/apps/panel/app/ui/confirm.jsx +109 -0
- package/apps/panel/app/ui/dashboard.jsx +87 -72
- package/apps/panel/app/ui/databases.jsx +168 -59
- package/apps/panel/app/ui/project-tools.jsx +604 -0
- package/apps/panel/app/ui/toast.jsx +90 -0
- package/apps/panel/lib/agent.mjs +13 -2
- package/apps/panel/lib/databases.mjs +120 -0
- package/apps/panel/lib/request-origin.mjs +29 -0
- package/package.json +1 -1
- package/packages/shared/index.mjs +49 -0
- package/scripts/panel-control.mjs +214 -214
- package/scripts/service-supervisor.mjs +164 -164
- package/scripts/windows-boot.mjs +259 -259
|
@@ -12,6 +12,8 @@ import { fileURLToPath } from "node:url";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import net from "node:net";
|
|
15
|
+
import { ensurePhpTools } from "./host-tools.mjs";
|
|
16
|
+
import { reservedEnv } from "@naviyra/shared";
|
|
15
17
|
const windows = process.platform === "win32";
|
|
16
18
|
const binaries = {
|
|
17
19
|
node: process.execPath,
|
|
@@ -45,11 +47,48 @@ export function safeEnvironment(extra = {}) {
|
|
|
45
47
|
if (process.env[key]) env[key] = process.env[key];
|
|
46
48
|
return { ...env, GIT_TERMINAL_PROMPT: "0", PYTHONUNBUFFERED: "1", ...extra };
|
|
47
49
|
}
|
|
50
|
+
export function applicationEnvironment(config) {
|
|
51
|
+
const env = {};
|
|
52
|
+
for (const [key, value] of Object.entries(config?.env || {})) {
|
|
53
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key)) continue;
|
|
54
|
+
if (reservedEnv.has(key.toUpperCase()) || key === "Path") continue;
|
|
55
|
+
if (typeof value !== "string") continue;
|
|
56
|
+
env[key] = value.slice(0, 4000);
|
|
57
|
+
}
|
|
58
|
+
return env;
|
|
59
|
+
}
|
|
60
|
+
export function pythonTarget(config) {
|
|
61
|
+
const file = String(config?.startup_file || "").replaceAll("\\", "/");
|
|
62
|
+
if (!file) return config?.entrypoint || "app:app";
|
|
63
|
+
const dir = String(config?.working_dir || ".")
|
|
64
|
+
.replaceAll("\\", "/")
|
|
65
|
+
.replace(/\/$/, "");
|
|
66
|
+
let relative = file;
|
|
67
|
+
if (dir && dir !== "." && (file === dir || file.startsWith(`${dir}/`)))
|
|
68
|
+
relative = file.slice(dir.length).replace(/^\//, "");
|
|
69
|
+
const mod = relative.replace(/\.py$/i, "").replaceAll("/", ".");
|
|
70
|
+
if (
|
|
71
|
+
!file.toLowerCase().endsWith(".py") ||
|
|
72
|
+
!/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(mod)
|
|
73
|
+
)
|
|
74
|
+
throw Error("Python startup file must be a relative .py path such as app.py");
|
|
75
|
+
return `${mod}:app`;
|
|
76
|
+
}
|
|
48
77
|
export async function nativeCommand(name, args, options = {}) {
|
|
49
78
|
const file = binaries[name];
|
|
50
79
|
if (!file) throw Error("Native command is not allowed");
|
|
51
80
|
return run(file, args, options);
|
|
52
81
|
}
|
|
82
|
+
export function parseCommandJson(text) {
|
|
83
|
+
const source = String(text ?? "");
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(source);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const index = Number(String(error.message).match(/position (\d+)/)?.[1]);
|
|
88
|
+
if (!Number.isInteger(index) || index <= 0) throw error;
|
|
89
|
+
return JSON.parse(source.slice(0, index));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
53
92
|
export function run(
|
|
54
93
|
file,
|
|
55
94
|
args,
|
|
@@ -61,19 +100,22 @@ export function run(
|
|
|
61
100
|
[],
|
|
62
101
|
{ env: safeEnvironment(), silent: true, windowsHide: true },
|
|
63
102
|
);
|
|
64
|
-
let
|
|
103
|
+
let stdout = "",
|
|
104
|
+
stderr = "",
|
|
65
105
|
settled = false;
|
|
66
106
|
const timer = setTimeout(() => {
|
|
67
107
|
settled = true;
|
|
68
108
|
worker.send({ stop: true });
|
|
69
109
|
reject(Error("Native command timed out"));
|
|
70
110
|
}, timeout);
|
|
71
|
-
const collect = (chunk) => {
|
|
72
|
-
|
|
73
|
-
|
|
111
|
+
const collect = (target) => (chunk) => {
|
|
112
|
+
const text = chunk.toString();
|
|
113
|
+
if (target === "stdout") stdout = (stdout + text).slice(-100000);
|
|
114
|
+
else stderr = (stderr + text).slice(-100000);
|
|
115
|
+
log(text);
|
|
74
116
|
};
|
|
75
|
-
worker.stdout.on("data", collect);
|
|
76
|
-
worker.stderr.on("data", collect);
|
|
117
|
+
worker.stdout.on("data", collect("stdout"));
|
|
118
|
+
worker.stderr.on("data", collect("stderr"));
|
|
77
119
|
worker.on("error", (error) => {
|
|
78
120
|
clearTimeout(timer);
|
|
79
121
|
if (!settled) reject(error);
|
|
@@ -82,9 +124,11 @@ export function run(
|
|
|
82
124
|
clearTimeout(timer);
|
|
83
125
|
if (!settled) {
|
|
84
126
|
code === 0
|
|
85
|
-
? resolve(
|
|
127
|
+
? resolve(stdout)
|
|
86
128
|
: reject(
|
|
87
|
-
Error(
|
|
129
|
+
Error(
|
|
130
|
+
`${path.basename(file)} failed: ${(stderr || stdout).slice(-3000)}`,
|
|
131
|
+
),
|
|
88
132
|
);
|
|
89
133
|
}
|
|
90
134
|
});
|
|
@@ -188,14 +232,44 @@ export function npmCommand(args) {
|
|
|
188
232
|
args: [cli, ...args],
|
|
189
233
|
};
|
|
190
234
|
}
|
|
191
|
-
export
|
|
235
|
+
export function phpFpmPool(port, directory, uid = process.getuid?.()) {
|
|
236
|
+
const listen = Number(port);
|
|
237
|
+
if (!Number.isInteger(listen) || listen < 1 || listen > 65535)
|
|
238
|
+
throw Error("PHP port is invalid");
|
|
239
|
+
const lines = [
|
|
240
|
+
"[global]",
|
|
241
|
+
"daemonize = no",
|
|
242
|
+
`error_log = ${JSON.stringify(path.join(directory, "php-fpm.log"))}`,
|
|
243
|
+
`pid = ${JSON.stringify(path.join(directory, "php-fpm.pid"))}`,
|
|
244
|
+
"",
|
|
245
|
+
"[www]",
|
|
246
|
+
`listen = 127.0.0.1:${listen}`,
|
|
247
|
+
"listen.allowed_clients = 127.0.0.1",
|
|
248
|
+
"pm = ondemand",
|
|
249
|
+
"pm.max_children = 8",
|
|
250
|
+
"pm.process_idle_timeout = 10s",
|
|
251
|
+
];
|
|
252
|
+
if (uid === 0) {
|
|
253
|
+
const user = process.env.PHP_FPM_USER || "";
|
|
254
|
+
const group = process.env.PHP_FPM_GROUP || user;
|
|
255
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(user) || !/^[a-zA-Z0-9._-]+$/.test(group))
|
|
256
|
+
throw Error(
|
|
257
|
+
"PHP-FPM cannot run workers as root. Set PHP_FPM_USER and PHP_FPM_GROUP, or run the panel as a normal user.",
|
|
258
|
+
);
|
|
259
|
+
lines.push(`user = ${user}`, `group = ${group}`);
|
|
260
|
+
}
|
|
261
|
+
return `${lines.join("\n")}\n`;
|
|
262
|
+
}
|
|
263
|
+
export async function prepareNative(p, source, log = () => {}, toolsDir) {
|
|
192
264
|
const config = p.runtime_config || {};
|
|
193
265
|
const env = safeEnvironment({
|
|
194
266
|
PORT: String(p.port),
|
|
195
267
|
HOST: "127.0.0.1",
|
|
196
268
|
HOSTNAME: "127.0.0.1",
|
|
197
269
|
NODE_ENV: "production",
|
|
270
|
+
...applicationEnvironment(config),
|
|
198
271
|
});
|
|
272
|
+
const cwd = await containedDirectory(source, config.working_dir || ".");
|
|
199
273
|
const execute = (file, args) => run(file, args, { cwd: source, env, log });
|
|
200
274
|
const npm = async (args) => {
|
|
201
275
|
const spec = npmCommand(args);
|
|
@@ -207,8 +281,10 @@ export async function prepareNative(p, source, log = () => {}) {
|
|
|
207
281
|
const manifest = JSON.parse(
|
|
208
282
|
await readFile(path.join(source, "package.json"), "utf8"),
|
|
209
283
|
);
|
|
210
|
-
if (type === "nodejs" && !manifest.scripts?.start)
|
|
211
|
-
throw Error(
|
|
284
|
+
if (type === "nodejs" && !manifest.scripts?.start && !config.startup_file)
|
|
285
|
+
throw Error(
|
|
286
|
+
"Node.js projects require a package.json start script or a startup file",
|
|
287
|
+
);
|
|
212
288
|
await npm([
|
|
213
289
|
(await exists(path.join(source, "package-lock.json")))
|
|
214
290
|
? "ci"
|
|
@@ -218,12 +294,24 @@ export async function prepareNative(p, source, log = () => {}) {
|
|
|
218
294
|
"--no-fund",
|
|
219
295
|
]);
|
|
220
296
|
if (manifest.scripts?.build) await npm(["run", "build"]);
|
|
221
|
-
} else if (type === "nodejs")
|
|
297
|
+
} else if (type === "nodejs" && !config.startup_file)
|
|
222
298
|
throw Error("package.json is missing at the source root");
|
|
223
|
-
if (type === "nodejs")
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
299
|
+
if (type === "nodejs") {
|
|
300
|
+
if (config.startup_file) {
|
|
301
|
+
let startFile;
|
|
302
|
+
try {
|
|
303
|
+
startFile = await containedDirectory(source, config.startup_file);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error.code === "ENOENT")
|
|
306
|
+
throw Error(`Startup file not found: ${config.startup_file}`);
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
return [
|
|
310
|
+
{ key: "app", file: process.execPath, args: [startFile], cwd, env },
|
|
311
|
+
];
|
|
312
|
+
}
|
|
313
|
+
return [{ ...npmCommand(["run", "start"]), cwd, env, key: "app" }];
|
|
314
|
+
}
|
|
227
315
|
const root = await containedDirectory(source, config.public_dir || ".");
|
|
228
316
|
if (!(await exists(path.join(root, "index.html"))))
|
|
229
317
|
throw Error("Static public directory must contain index.html");
|
|
@@ -250,6 +338,7 @@ export async function prepareNative(p, source, log = () => {}) {
|
|
|
250
338
|
if (await exists(path.join(source, "requirements.txt")))
|
|
251
339
|
await execute(python, ["-m", "pip", "install", "-r", "requirements.txt"]);
|
|
252
340
|
const wsgi = config.python_server === "wsgi";
|
|
341
|
+
const entry = pythonTarget(config);
|
|
253
342
|
await execute(python, [
|
|
254
343
|
"-m",
|
|
255
344
|
"pip",
|
|
@@ -265,18 +354,18 @@ export async function prepareNative(p, source, log = () => {}) {
|
|
|
265
354
|
"-m",
|
|
266
355
|
"waitress",
|
|
267
356
|
`--listen=127.0.0.1:${p.port}`,
|
|
268
|
-
|
|
357
|
+
entry,
|
|
269
358
|
]
|
|
270
359
|
: [
|
|
271
360
|
"-m",
|
|
272
361
|
"uvicorn",
|
|
273
|
-
|
|
362
|
+
entry,
|
|
274
363
|
"--host",
|
|
275
364
|
"127.0.0.1",
|
|
276
365
|
"--port",
|
|
277
366
|
String(p.port),
|
|
278
367
|
],
|
|
279
|
-
cwd
|
|
368
|
+
cwd,
|
|
280
369
|
env,
|
|
281
370
|
},
|
|
282
371
|
];
|
|
@@ -286,14 +375,30 @@ export async function prepareNative(p, source, log = () => {}) {
|
|
|
286
375
|
p.folder_path,
|
|
287
376
|
windows ? "application.exe" : "application",
|
|
288
377
|
);
|
|
289
|
-
await
|
|
290
|
-
return [{ key: "app", file: target, args: [], cwd
|
|
378
|
+
await run(binaries.go, ["build", "-o", target, "."], { cwd, env, log });
|
|
379
|
+
return [{ key: "app", file: target, args: [], cwd, env }];
|
|
291
380
|
}
|
|
292
381
|
if (type === "php") {
|
|
382
|
+
const php = await ensurePhpTools(
|
|
383
|
+
toolsDir || path.resolve(".runtime", "host-tools"),
|
|
384
|
+
{ log },
|
|
385
|
+
);
|
|
386
|
+
const fastcgi = php.fpm || php.phpcgi;
|
|
387
|
+
const phpDir = path.dirname(fastcgi);
|
|
388
|
+
const phpEnv = {
|
|
389
|
+
...env,
|
|
390
|
+
Path: `${phpDir}${path.delimiter}${env.Path || env.PATH || ""}`,
|
|
391
|
+
PATH: `${phpDir}${path.delimiter}${env.PATH || env.Path || ""}`,
|
|
392
|
+
PHP_FCGI_MAX_REQUESTS: "0",
|
|
393
|
+
};
|
|
293
394
|
if (await exists(path.join(source, "composer.json"))) {
|
|
294
395
|
if (!process.env.COMPOSER_PHAR)
|
|
295
396
|
throw Error("Set COMPOSER_PHAR to composer.phar for Composer projects");
|
|
296
|
-
|
|
397
|
+
if (php.fpm && path.resolve(php.php) === path.resolve(php.fpm))
|
|
398
|
+
throw Error(
|
|
399
|
+
"Set PHP_BIN to a PHP CLI binary before deploying a Composer project",
|
|
400
|
+
);
|
|
401
|
+
await execute(php.php, [
|
|
297
402
|
process.env.COMPOSER_PHAR,
|
|
298
403
|
"install",
|
|
299
404
|
"--no-dev",
|
|
@@ -307,17 +412,34 @@ export async function prepareNative(p, source, log = () => {}) {
|
|
|
307
412
|
caddy,
|
|
308
413
|
`{\n admin off\n auto_https off\n}\nhttp://127.0.0.1:${p.port} {\n root * ${JSON.stringify(root.replaceAll("\\", "/"))}\n @private path /.git/* /.env /composer.json /composer.lock\n respond @private 404\n php_fastcgi 127.0.0.1:${p.php_port}\n file_server {\n hide .* composer.json composer.lock\n }\n}\n`,
|
|
309
414
|
);
|
|
415
|
+
const phpProcess = php.fpm
|
|
416
|
+
? {
|
|
417
|
+
key: "php",
|
|
418
|
+
file: php.fpm,
|
|
419
|
+
args: ["-F", "-y", path.join(p.folder_path, "php-fpm.conf")],
|
|
420
|
+
cwd: source,
|
|
421
|
+
env: phpEnv,
|
|
422
|
+
}
|
|
423
|
+
: {
|
|
424
|
+
key: "php",
|
|
425
|
+
file: php.phpcgi,
|
|
426
|
+
args: ["-b", `127.0.0.1:${p.php_port}`],
|
|
427
|
+
cwd: source,
|
|
428
|
+
env: phpEnv,
|
|
429
|
+
};
|
|
430
|
+
if (php.fpm) {
|
|
431
|
+
await writeFile(
|
|
432
|
+
path.join(p.folder_path, "php-fpm.conf"),
|
|
433
|
+
phpFpmPool(p.php_port, p.folder_path),
|
|
434
|
+
);
|
|
435
|
+
if (php.ini && (await exists(php.ini)))
|
|
436
|
+
phpProcess.args.push("-c", php.ini);
|
|
437
|
+
}
|
|
310
438
|
return [
|
|
311
|
-
|
|
312
|
-
key: "php",
|
|
313
|
-
file: binaries.phpcgi,
|
|
314
|
-
args: ["-b", `127.0.0.1:${p.php_port}`],
|
|
315
|
-
cwd: source,
|
|
316
|
-
env: { ...env, PHP_FCGI_MAX_REQUESTS: "0" },
|
|
317
|
-
},
|
|
439
|
+
phpProcess,
|
|
318
440
|
{
|
|
319
441
|
key: "app",
|
|
320
|
-
file:
|
|
442
|
+
file: php.caddy,
|
|
321
443
|
args: ["run", "--config", caddy, "--adapter", "caddyfile"],
|
|
322
444
|
cwd: source,
|
|
323
445
|
env,
|
|
@@ -338,7 +460,7 @@ export async function prepareNative(p, source, log = () => {}) {
|
|
|
338
460
|
args: (config.args || []).map((arg) =>
|
|
339
461
|
arg.replaceAll("{PORT}", String(p.port)),
|
|
340
462
|
),
|
|
341
|
-
cwd
|
|
463
|
+
cwd,
|
|
342
464
|
env,
|
|
343
465
|
},
|
|
344
466
|
];
|
|
@@ -416,15 +538,18 @@ export async function provisionNativeTunnel(runtime, p) {
|
|
|
416
538
|
`tunnel: ${credential.TunnelID}\ncredentials-file: ${JSON.stringify(credentials)}\ningress:\n - hostname: ${p.subdomain}\n service: http://127.0.0.1:${p.port}\n - service: http_status:404\n`,
|
|
417
539
|
{ mode: 0o600 },
|
|
418
540
|
);
|
|
541
|
+
runtime.log(p, `Updating DNS for ${p.subdomain}`);
|
|
419
542
|
await nativeCommand("cloudflared", [
|
|
420
543
|
"tunnel",
|
|
421
544
|
"--origincert",
|
|
422
545
|
cert,
|
|
423
546
|
"route",
|
|
424
547
|
"dns",
|
|
548
|
+
"--overwrite-dns",
|
|
425
549
|
credential.TunnelID,
|
|
426
550
|
p.subdomain,
|
|
427
551
|
]);
|
|
552
|
+
runtime.log(p, `DNS updated: ${p.subdomain} points at the tunnel`);
|
|
428
553
|
const spec = {
|
|
429
554
|
key: "tunnel",
|
|
430
555
|
file: binaries.cloudflared,
|
|
@@ -445,7 +570,7 @@ export async function deleteNativeTunnel(p) {
|
|
|
445
570
|
process.env.CLOUDFLARE_CERT ||
|
|
446
571
|
path.join(os.homedir(), ".cloudflared", "cert.pem"),
|
|
447
572
|
];
|
|
448
|
-
const tunnels =
|
|
573
|
+
const tunnels = parseCommandJson(
|
|
449
574
|
await nativeCommand("cloudflared", [...prefix, "list", "--output", "json"]),
|
|
450
575
|
);
|
|
451
576
|
if (tunnels.some((t) => t.id === p.tunnel.tunnel_id))
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
2
4
|
import {
|
|
3
5
|
mkdir,
|
|
4
6
|
readFile,
|
|
@@ -16,6 +18,7 @@ import yauzl from "yauzl";
|
|
|
16
18
|
import {
|
|
17
19
|
NativeSupervisor,
|
|
18
20
|
nativeCommand,
|
|
21
|
+
parseCommandJson,
|
|
19
22
|
prepareNative,
|
|
20
23
|
ready,
|
|
21
24
|
provisionNativeTunnel,
|
|
@@ -41,7 +44,8 @@ export function command(name, args, options = {}) {
|
|
|
41
44
|
stdio: ["ignore", "pipe", "pipe"],
|
|
42
45
|
detached: process.platform !== "win32",
|
|
43
46
|
});
|
|
44
|
-
let
|
|
47
|
+
let stdout = "";
|
|
48
|
+
let stderr = "";
|
|
45
49
|
let timedOut = false;
|
|
46
50
|
const stop = () => {
|
|
47
51
|
try {
|
|
@@ -53,12 +57,14 @@ export function command(name, args, options = {}) {
|
|
|
53
57
|
timedOut = true;
|
|
54
58
|
stop();
|
|
55
59
|
}, options.timeout || 120000);
|
|
56
|
-
const collect = (data) => {
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
const collect = (target) => (data) => {
|
|
61
|
+
const text = data.toString();
|
|
62
|
+
if (target === "stdout") stdout = (stdout + text).slice(-100000);
|
|
63
|
+
else stderr = (stderr + text).slice(-100000);
|
|
64
|
+
options.onOutput?.(text);
|
|
59
65
|
};
|
|
60
|
-
child.stdout.on("data", collect);
|
|
61
|
-
child.stderr.on("data", collect);
|
|
66
|
+
child.stdout.on("data", collect("stdout"));
|
|
67
|
+
child.stderr.on("data", collect("stderr"));
|
|
62
68
|
child.on("error", (error) => {
|
|
63
69
|
clearTimeout(timer);
|
|
64
70
|
reject(error);
|
|
@@ -66,26 +72,22 @@ export function command(name, args, options = {}) {
|
|
|
66
72
|
child.on("close", (code) => {
|
|
67
73
|
clearTimeout(timer);
|
|
68
74
|
code === 0 && !timedOut
|
|
69
|
-
? resolve(
|
|
75
|
+
? resolve(stdout)
|
|
70
76
|
: reject(
|
|
71
77
|
Error(
|
|
72
|
-
`${name} ${timedOut ? "timed out" : `exited ${code}`}: ${
|
|
78
|
+
`${name} ${timedOut ? "timed out" : `exited ${code}`}: ${(stderr || stdout).slice(-3000)}`,
|
|
73
79
|
),
|
|
74
80
|
);
|
|
75
81
|
});
|
|
76
82
|
});
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
export async function extractZip(
|
|
85
|
+
export async function extractZip(source, destination) {
|
|
80
86
|
await mkdir(destination, { recursive: true });
|
|
81
87
|
return new Promise((resolve, reject) => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{ lazyEntries: true, validateEntrySizes: true },
|
|
85
|
-
(error, zip) => {
|
|
88
|
+
const options = { lazyEntries: true, validateEntrySizes: true };
|
|
89
|
+
const opened = (error, zip) => {
|
|
86
90
|
if (error) return reject(error);
|
|
87
|
-
let total = 0,
|
|
88
|
-
count = 0;
|
|
89
91
|
const fail = (error) => {
|
|
90
92
|
zip.close();
|
|
91
93
|
reject(error);
|
|
@@ -112,11 +114,6 @@ export async function extractZip(buffer, destination) {
|
|
|
112
114
|
.some((p) => ["node_modules", ".git"].includes(p))
|
|
113
115
|
)
|
|
114
116
|
throw Error("Remove node_modules and .git from the archive");
|
|
115
|
-
if (
|
|
116
|
-
++count > 10000 ||
|
|
117
|
-
(total += entry.uncompressedSize) > 200 * 1024 * 1024
|
|
118
|
-
)
|
|
119
|
-
throw Error("ZIP exceeds extraction limit");
|
|
120
117
|
if (filename.endsWith("/")) {
|
|
121
118
|
await mkdir(target, { recursive: true });
|
|
122
119
|
zip.readEntry();
|
|
@@ -142,8 +139,9 @@ export async function extractZip(buffer, destination) {
|
|
|
142
139
|
}
|
|
143
140
|
});
|
|
144
141
|
zip.readEntry();
|
|
145
|
-
|
|
146
|
-
);
|
|
142
|
+
};
|
|
143
|
+
if (Buffer.isBuffer(source)) yauzl.fromBuffer(source, options, opened);
|
|
144
|
+
else yauzl.open(source, options, opened);
|
|
147
145
|
});
|
|
148
146
|
}
|
|
149
147
|
|
|
@@ -223,10 +221,22 @@ export class Runtime {
|
|
|
223
221
|
))
|
|
224
222
|
await this.supervisor.start(`${p.id}:${spec.key}`, spec);
|
|
225
223
|
await ready(p.port, this.supervisor, `${p.id}:app`);
|
|
224
|
+
if (
|
|
225
|
+
process.env.NATIVE_TUNNELS !== "false" &&
|
|
226
|
+
!p.native_specs.some((spec) => spec.key === "tunnel")
|
|
227
|
+
) {
|
|
228
|
+
await provisionNativeTunnel(this, p);
|
|
229
|
+
p.error = null;
|
|
230
|
+
}
|
|
226
231
|
} catch (error) {
|
|
227
|
-
p.
|
|
228
|
-
|
|
229
|
-
|
|
232
|
+
if (this.supervisor.active(`${p.id}:app`)) {
|
|
233
|
+
this.log(p, `Cloudflare DNS was not updated: ${error.message}`);
|
|
234
|
+
p.error = error.message;
|
|
235
|
+
} else {
|
|
236
|
+
p.status = "failed";
|
|
237
|
+
p.error = error.message;
|
|
238
|
+
await this.supervisor.stopProject(p.id);
|
|
239
|
+
}
|
|
230
240
|
}
|
|
231
241
|
await this.save();
|
|
232
242
|
});
|
|
@@ -261,7 +271,13 @@ export class Runtime {
|
|
|
261
271
|
).slice(-100000);
|
|
262
272
|
}
|
|
263
273
|
async create(id, input) {
|
|
264
|
-
if (this.state.projects[id])
|
|
274
|
+
if (this.state.projects[id]) {
|
|
275
|
+
const current = this.get(id);
|
|
276
|
+
if (input.type) current.type = input.type;
|
|
277
|
+
if (input.runtime_config) current.runtime_config = input.runtime_config;
|
|
278
|
+
await this.save();
|
|
279
|
+
return current;
|
|
280
|
+
}
|
|
265
281
|
if (
|
|
266
282
|
Object.values(this.state.projects).some(
|
|
267
283
|
(p) => p.name === input.name || p.subdomain === input.subdomain,
|
|
@@ -308,6 +324,23 @@ export class Runtime {
|
|
|
308
324
|
p.has_upload = true;
|
|
309
325
|
await this.save();
|
|
310
326
|
}
|
|
327
|
+
async uploadStream(id, source) {
|
|
328
|
+
const p = this.get(id);
|
|
329
|
+
if (["queued", "deploying"].includes(p.status))
|
|
330
|
+
throw Object.assign(Error("Deployment in progress"), { status: 409 });
|
|
331
|
+
const file = path.join(this.root, `${id}.zip`);
|
|
332
|
+
const temporary = `${file}.${process.pid}.partial`;
|
|
333
|
+
try {
|
|
334
|
+
await pipeline(source, createWriteStream(temporary, { mode: 0o600 }));
|
|
335
|
+
await rm(file, { force: true });
|
|
336
|
+
await rename(temporary, file);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
await rm(temporary, { force: true });
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
p.has_upload = true;
|
|
342
|
+
await this.save();
|
|
343
|
+
}
|
|
311
344
|
async deploy(id, deploymentId) {
|
|
312
345
|
const p = this.get(id);
|
|
313
346
|
if (this.mode === "host" && p.type && p.type !== "nodejs")
|
|
@@ -394,10 +427,7 @@ export class Runtime {
|
|
|
394
427
|
await rm(source, { recursive: true, force: true });
|
|
395
428
|
await mkdir(source, { recursive: true });
|
|
396
429
|
if (p.source_type === "zip")
|
|
397
|
-
await extractZip(
|
|
398
|
-
await readFile(path.join(this.root, `${p.id}.zip`)),
|
|
399
|
-
source,
|
|
400
|
-
);
|
|
430
|
+
await extractZip(path.join(this.root, `${p.id}.zip`), source);
|
|
401
431
|
else
|
|
402
432
|
await nativeCommand(
|
|
403
433
|
"git",
|
|
@@ -418,8 +448,11 @@ export class Runtime {
|
|
|
418
448
|
p,
|
|
419
449
|
`Preparing native ${p.type || "nodejs"} runtime on ${process.platform}`,
|
|
420
450
|
);
|
|
421
|
-
p.native_specs = await prepareNative(
|
|
422
|
-
|
|
451
|
+
p.native_specs = await prepareNative(
|
|
452
|
+
p,
|
|
453
|
+
source,
|
|
454
|
+
(message) => this.log(p, message),
|
|
455
|
+
path.join(this.root, "host-tools"),
|
|
423
456
|
);
|
|
424
457
|
for (const spec of p.native_specs)
|
|
425
458
|
await this.supervisor.start(`${p.id}:${spec.key}`, spec);
|
|
@@ -475,10 +508,7 @@ export class Runtime {
|
|
|
475
508
|
folder,
|
|
476
509
|
);
|
|
477
510
|
else {
|
|
478
|
-
await extractZip(
|
|
479
|
-
await readFile(path.join(this.root, `${p.id}.zip`)),
|
|
480
|
-
release,
|
|
481
|
-
);
|
|
511
|
+
await extractZip(path.join(this.root, `${p.id}.zip`), release);
|
|
482
512
|
await command("chown", ["-R", `${user}:${group}`, release]);
|
|
483
513
|
}
|
|
484
514
|
const manifest = JSON.parse(
|
|
@@ -541,15 +571,18 @@ export class Runtime {
|
|
|
541
571
|
`tunnel: ${p.tunnel.tunnel_id}\ncredentials-file: ${credentials}\ningress:\n - hostname: ${p.subdomain}\n service: http://127.0.0.1:${p.port}\n - service: http_status:404\n`,
|
|
542
572
|
{ mode: 0o600 },
|
|
543
573
|
);
|
|
574
|
+
this.log(p, `Updating DNS for ${p.subdomain}`);
|
|
544
575
|
await command("cloudflared", [
|
|
545
576
|
"tunnel",
|
|
546
577
|
"--origincert",
|
|
547
578
|
cert,
|
|
548
579
|
"route",
|
|
549
580
|
"dns",
|
|
581
|
+
"--overwrite-dns",
|
|
550
582
|
p.tunnel.tunnel_id,
|
|
551
583
|
p.subdomain,
|
|
552
584
|
]);
|
|
585
|
+
this.log(p, `DNS updated: ${p.subdomain} points at the tunnel`);
|
|
553
586
|
const tunnelUnit = `[Unit]\nDescription=Naviyra tunnel ${p.name}\nAfter=network-online.target\nWants=network-online.target\n[Service]\nExecStart=${binaries.cloudflared} tunnel --config ${config} run ${p.tunnel.tunnel_id}\nRestart=on-failure\nRestartSec=5\n[Install]\nWantedBy=multi-user.target\n`;
|
|
554
587
|
await writeFile(`/etc/systemd/system/${this.unit(p, true)}`, tunnelUnit);
|
|
555
588
|
await command("systemctl", ["daemon-reload"]);
|
|
@@ -653,7 +686,7 @@ export class Runtime {
|
|
|
653
686
|
"--origincert",
|
|
654
687
|
process.env.CLOUDFLARE_CERT || "/root/.cloudflared/cert.pem",
|
|
655
688
|
];
|
|
656
|
-
const tunnels =
|
|
689
|
+
const tunnels = parseCommandJson(
|
|
657
690
|
await command("cloudflared", [...prefix, "list", "--output", "json"]),
|
|
658
691
|
);
|
|
659
692
|
if (tunnels.some((t) => t.id === p.tunnel.tunnel_id))
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
2
3
|
import { timingSafeEqual, createHash } from "node:crypto";
|
|
3
4
|
import { pathToFileURL } from "node:url";
|
|
4
5
|
import { Runtime } from "./runtime.mjs";
|
|
6
|
+
import {
|
|
7
|
+
projectAppRoot,
|
|
8
|
+
listFiles,
|
|
9
|
+
readFileText,
|
|
10
|
+
writeFileText,
|
|
11
|
+
createDirectory,
|
|
12
|
+
removePath,
|
|
13
|
+
saveUpload,
|
|
14
|
+
openDownload,
|
|
15
|
+
} from "./files.mjs";
|
|
5
16
|
import {
|
|
6
17
|
projectInput,
|
|
7
18
|
idSchema,
|
|
8
19
|
deploymentInput,
|
|
9
|
-
MAX_ZIP_BYTES,
|
|
10
20
|
} from "@naviyra/shared";
|
|
11
21
|
import {
|
|
12
22
|
cloudflareStatus,
|
|
@@ -26,6 +36,17 @@ import {
|
|
|
26
36
|
deleteDestination,
|
|
27
37
|
} from "./cloudflare.mjs";
|
|
28
38
|
|
|
39
|
+
async function readBody(req, limit) {
|
|
40
|
+
let size = 0;
|
|
41
|
+
const chunks = [];
|
|
42
|
+
for await (const chunk of req) {
|
|
43
|
+
size += chunk.length;
|
|
44
|
+
if (size > limit)
|
|
45
|
+
throw Object.assign(Error("Payload too large"), { status: 413 });
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
}
|
|
48
|
+
return Buffer.concat(chunks);
|
|
49
|
+
}
|
|
29
50
|
export function createServer(runtime, token) {
|
|
30
51
|
if (!token || token.length < 32)
|
|
31
52
|
throw Error("AGENT_TOKEN must contain at least 32 characters");
|
|
@@ -114,6 +135,40 @@ export function createServer(runtime, token) {
|
|
|
114
135
|
return send(200, await deleteDestination(emailId));
|
|
115
136
|
return send(405, { error: "Method not allowed" });
|
|
116
137
|
}
|
|
138
|
+
const fileMatch = url.pathname.match(/^\/projects\/([^/]+)\/files$/);
|
|
139
|
+
if (fileMatch) {
|
|
140
|
+
const id = idSchema.parse(fileMatch[1]);
|
|
141
|
+
const root = await projectAppRoot(runtime.get(id).folder_path);
|
|
142
|
+
const queryPath = url.searchParams.get("path") || "";
|
|
143
|
+
if (req.method === "GET" && url.searchParams.get("download") === "1") {
|
|
144
|
+
const file = await openDownload(root, queryPath);
|
|
145
|
+
res.writeHead(200, {
|
|
146
|
+
"Content-Type": "application/octet-stream",
|
|
147
|
+
"Content-Disposition": `attachment; filename="${file.name}"`,
|
|
148
|
+
"Cache-Control": "no-store",
|
|
149
|
+
});
|
|
150
|
+
createReadStream(file.file).pipe(res);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (req.method === "GET")
|
|
154
|
+
return send(
|
|
155
|
+
200,
|
|
156
|
+
url.searchParams.get("read") === "1"
|
|
157
|
+
? await readFileText(root, queryPath)
|
|
158
|
+
: await listFiles(root, queryPath),
|
|
159
|
+
);
|
|
160
|
+
if (req.method === "POST")
|
|
161
|
+
return send(200, await saveUpload(root, queryPath, req));
|
|
162
|
+
const body = await readBody(req, 2 * 1024 * 1024);
|
|
163
|
+
const input = body.length ? JSON.parse(body.toString()) : {};
|
|
164
|
+
if (req.method === "PUT" && input.action === "mkdir")
|
|
165
|
+
return send(200, await createDirectory(root, input.path));
|
|
166
|
+
if (req.method === "PUT")
|
|
167
|
+
return send(200, await writeFileText(root, input.path, input.content));
|
|
168
|
+
if (req.method === "DELETE")
|
|
169
|
+
return send(200, await removePath(root, queryPath));
|
|
170
|
+
return send(405, { error: "Method not allowed" });
|
|
171
|
+
}
|
|
117
172
|
const match = url.pathname.match(
|
|
118
173
|
/^\/projects(?:\/([^/]+))?(?:\/(deploy|source|restart|logs|metrics))?$/,
|
|
119
174
|
);
|
|
@@ -125,9 +180,13 @@ export function createServer(runtime, token) {
|
|
|
125
180
|
if (action === "metrics") return send(200, await runtime.metrics(id));
|
|
126
181
|
if (!action) return send(200, runtime.get(id));
|
|
127
182
|
}
|
|
183
|
+
if (req.method === "PUT" && action === "source") {
|
|
184
|
+
await runtime.serial(() => runtime.uploadStream(id, req));
|
|
185
|
+
return send(200, { ok: true });
|
|
186
|
+
}
|
|
128
187
|
let size = 0;
|
|
129
188
|
const chunks = [];
|
|
130
|
-
const limit =
|
|
189
|
+
const limit = 8192;
|
|
131
190
|
for await (const chunk of req) {
|
|
132
191
|
size += chunk.length;
|
|
133
192
|
if (size > limit) return send(413, { error: "Payload too large" });
|
|
@@ -143,10 +202,6 @@ export function createServer(runtime, token) {
|
|
|
143
202
|
),
|
|
144
203
|
);
|
|
145
204
|
}
|
|
146
|
-
if (req.method === "PUT" && action === "source") {
|
|
147
|
-
await runtime.serial(() => runtime.upload(id, body));
|
|
148
|
-
return send(200, { ok: true });
|
|
149
|
-
}
|
|
150
205
|
if (req.method === "POST" && action === "deploy") {
|
|
151
206
|
const input = deploymentInput.parse(JSON.parse(body.toString()));
|
|
152
207
|
return send(
|