@naviyra/cloud-panel 0.0.0-stage → 0.0.2
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/.env.example +40 -0
- package/README.md +145 -2
- package/apps/agent/package.json +13 -0
- package/apps/agent/src/cloudflare.mjs +733 -0
- package/apps/agent/src/native-worker.mjs +52 -0
- package/apps/agent/src/native.mjs +457 -0
- package/apps/agent/src/runtime.mjs +760 -0
- package/apps/agent/src/server.mjs +195 -0
- package/apps/agent/src/static-server.mjs +53 -0
- package/apps/panel/AGENTS.md +9 -0
- package/apps/panel/CLAUDE.md +1 -0
- package/apps/panel/app/api/[...path]/route.js +445 -0
- package/apps/panel/app/globals.css +2057 -0
- package/apps/panel/app/icon.png +0 -0
- package/apps/panel/app/layout.jsx +18 -0
- package/apps/panel/app/login/page.jsx +108 -0
- package/apps/panel/app/page.jsx +33 -0
- package/apps/panel/app/ui/cloudflare.jsx +926 -0
- package/apps/panel/app/ui/dashboard.jsx +1487 -0
- package/apps/panel/app/ui/databases.jsx +293 -0
- package/apps/panel/app/ui/skeleton.jsx +196 -0
- package/apps/panel/app/ui/theme.jsx +35 -0
- package/apps/panel/lib/agent.mjs +83 -0
- package/apps/panel/lib/auth.mjs +48 -0
- package/apps/panel/lib/database-runtime.mjs +243 -0
- package/apps/panel/lib/databases.mjs +292 -0
- package/apps/panel/lib/db.mjs +6 -0
- package/apps/panel/next.config.mjs +8 -0
- package/apps/panel/package.json +22 -0
- package/apps/panel/public/naviyra-logo.png +0 -0
- package/bin/naviyra.mjs +215 -0
- package/db/001_init.sql +28 -0
- package/db/002_native_runtimes.sql +3 -0
- package/db/003_databases.sql +8 -0
- package/db/004_database_engines.sql +4 -0
- package/deploy/naviyra-agent.service +17 -0
- package/docker-compose.yml +18 -0
- package/docs/managed-runtime.md +52 -0
- package/docs/native-hosts.md +48 -0
- package/package.json +61 -4
- package/packages/runtime/artifacts.json +32 -0
- package/packages/runtime/index.cjs +526 -0
- package/packages/runtime/index.mjs +10 -0
- package/packages/runtime/package.json +6 -0
- package/packages/shared/index.mjs +85 -0
- package/packages/shared/package.json +9 -0
- package/scripts/migrate.mjs +18 -0
- package/scripts/panel-control.mjs +195 -0
- package/scripts/postgres-local.mjs +97 -0
- package/scripts/runtime-download-manifest.mjs +30 -0
- package/scripts/runtime.mjs +20 -0
- package/scripts/seed.mjs +35 -0
- package/scripts/service-supervisor.mjs +164 -0
- package/scripts/setup-local.mjs +215 -0
- package/scripts/start-naviyra.mjs +303 -0
- package/scripts/windows-boot.mjs +259 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
readFile,
|
|
5
|
+
writeFile,
|
|
6
|
+
rename,
|
|
7
|
+
rm,
|
|
8
|
+
readdir,
|
|
9
|
+
chmod,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import net from "node:net";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import yauzl from "yauzl";
|
|
16
|
+
import {
|
|
17
|
+
NativeSupervisor,
|
|
18
|
+
nativeCommand,
|
|
19
|
+
prepareNative,
|
|
20
|
+
ready,
|
|
21
|
+
provisionNativeTunnel,
|
|
22
|
+
deleteNativeTunnel,
|
|
23
|
+
} from "./native.mjs";
|
|
24
|
+
|
|
25
|
+
const binaries = {
|
|
26
|
+
git: "/usr/bin/git",
|
|
27
|
+
npm: "/usr/bin/npm",
|
|
28
|
+
systemctl: "/usr/bin/systemctl",
|
|
29
|
+
journalctl: "/usr/bin/journalctl",
|
|
30
|
+
chown: "/usr/bin/chown",
|
|
31
|
+
runuser: "/usr/sbin/runuser",
|
|
32
|
+
cloudflared: process.env.CLOUDFLARED_BIN || "/usr/local/bin/cloudflared",
|
|
33
|
+
};
|
|
34
|
+
export function command(name, args, options = {}) {
|
|
35
|
+
if (!Object.hasOwn(binaries, name)) throw Error("Command is not allowed");
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const child = spawn(binaries[name], args, {
|
|
38
|
+
cwd: options.cwd,
|
|
39
|
+
shell: false,
|
|
40
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
42
|
+
detached: process.platform !== "win32",
|
|
43
|
+
});
|
|
44
|
+
let output = "";
|
|
45
|
+
let timedOut = false;
|
|
46
|
+
const stop = () => {
|
|
47
|
+
try {
|
|
48
|
+
if (process.platform !== "win32") process.kill(-child.pid, "SIGKILL");
|
|
49
|
+
else child.kill("SIGKILL");
|
|
50
|
+
} catch {}
|
|
51
|
+
};
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
timedOut = true;
|
|
54
|
+
stop();
|
|
55
|
+
}, options.timeout || 120000);
|
|
56
|
+
const collect = (data) => {
|
|
57
|
+
output = (output + data.toString()).slice(-100000);
|
|
58
|
+
options.onOutput?.(data.toString());
|
|
59
|
+
};
|
|
60
|
+
child.stdout.on("data", collect);
|
|
61
|
+
child.stderr.on("data", collect);
|
|
62
|
+
child.on("error", (error) => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
reject(error);
|
|
65
|
+
});
|
|
66
|
+
child.on("close", (code) => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
code === 0 && !timedOut
|
|
69
|
+
? resolve(output)
|
|
70
|
+
: reject(
|
|
71
|
+
Error(
|
|
72
|
+
`${name} ${timedOut ? "timed out" : `exited ${code}`}: ${output.slice(-3000)}`,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function extractZip(buffer, destination) {
|
|
80
|
+
await mkdir(destination, { recursive: true });
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
yauzl.fromBuffer(
|
|
83
|
+
buffer,
|
|
84
|
+
{ lazyEntries: true, validateEntrySizes: true },
|
|
85
|
+
(error, zip) => {
|
|
86
|
+
if (error) return reject(error);
|
|
87
|
+
let total = 0,
|
|
88
|
+
count = 0;
|
|
89
|
+
const fail = (error) => {
|
|
90
|
+
zip.close();
|
|
91
|
+
reject(error);
|
|
92
|
+
};
|
|
93
|
+
zip.on("error", fail);
|
|
94
|
+
zip.on("end", resolve);
|
|
95
|
+
zip.on("entry", async (entry) => {
|
|
96
|
+
try {
|
|
97
|
+
const filename = entry.fileName;
|
|
98
|
+
const target = path.resolve(destination, filename);
|
|
99
|
+
const rel = path.relative(destination, target);
|
|
100
|
+
if (
|
|
101
|
+
!rel ||
|
|
102
|
+
rel.startsWith("..") ||
|
|
103
|
+
path.isAbsolute(rel) ||
|
|
104
|
+
filename.includes("\\") ||
|
|
105
|
+
filename.includes(":") ||
|
|
106
|
+
((entry.externalFileAttributes >>> 16) & 0xf000) === 0xa000
|
|
107
|
+
)
|
|
108
|
+
throw Error("Unsafe ZIP entry");
|
|
109
|
+
if (
|
|
110
|
+
filename
|
|
111
|
+
.split("/")
|
|
112
|
+
.some((p) => ["node_modules", ".git"].includes(p))
|
|
113
|
+
)
|
|
114
|
+
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
|
+
if (filename.endsWith("/")) {
|
|
121
|
+
await mkdir(target, { recursive: true });
|
|
122
|
+
zip.readEntry();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
126
|
+
zip.openReadStream(entry, async (error, stream) => {
|
|
127
|
+
if (error) return fail(error);
|
|
128
|
+
try {
|
|
129
|
+
const chunks = [];
|
|
130
|
+
for await (const chunk of stream) chunks.push(chunk);
|
|
131
|
+
await writeFile(target, Buffer.concat(chunks), {
|
|
132
|
+
flag: "wx",
|
|
133
|
+
mode: 0o644,
|
|
134
|
+
});
|
|
135
|
+
zip.readEntry();
|
|
136
|
+
} catch (error) {
|
|
137
|
+
fail(error);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
fail(error);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
zip.readEntry();
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class Runtime {
|
|
151
|
+
constructor({ root, mode = "mock", portMin = 3100, portMax = 3999 }) {
|
|
152
|
+
if (!["mock", "host", "native"].includes(mode))
|
|
153
|
+
throw Error("AGENT_MODE must be mock, native or host");
|
|
154
|
+
if (mode === "host" && process.platform !== "linux")
|
|
155
|
+
throw Error("Host mode requires Linux");
|
|
156
|
+
if (
|
|
157
|
+
!Number.isInteger(portMin) ||
|
|
158
|
+
!Number.isInteger(portMax) ||
|
|
159
|
+
portMin < 1024 ||
|
|
160
|
+
portMax > 65535 ||
|
|
161
|
+
portMin > portMax
|
|
162
|
+
)
|
|
163
|
+
throw Error("Invalid port range");
|
|
164
|
+
this.root = path.resolve(root);
|
|
165
|
+
this.mode = mode;
|
|
166
|
+
this.portMin = portMin;
|
|
167
|
+
this.portMax = portMax;
|
|
168
|
+
this.state = { projects: {} };
|
|
169
|
+
this.tail = Promise.resolve();
|
|
170
|
+
this.jobs = Promise.resolve();
|
|
171
|
+
this.writes = Promise.resolve();
|
|
172
|
+
this.supervisor = new NativeSupervisor(
|
|
173
|
+
(key, message) => {
|
|
174
|
+
const p = this.state.projects[key.split(":")[0]];
|
|
175
|
+
if (p) this.log(p, message);
|
|
176
|
+
},
|
|
177
|
+
(key, error) => {
|
|
178
|
+
const p = this.state.projects[key.split(":")[0]];
|
|
179
|
+
if (p) {
|
|
180
|
+
p.status = "failed";
|
|
181
|
+
p.error = error.message;
|
|
182
|
+
this.log(p, error.message);
|
|
183
|
+
this.save().catch(console.error);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
if (mode === "host" && !/^\/[a-zA-Z0-9/_-]+$/.test(this.root))
|
|
188
|
+
throw Error("Host root must be an absolute path without spaces");
|
|
189
|
+
}
|
|
190
|
+
async init() {
|
|
191
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
192
|
+
if (this.mode === "host") await chmod(this.root, 0o711);
|
|
193
|
+
try {
|
|
194
|
+
this.state = JSON.parse(
|
|
195
|
+
await readFile(path.join(this.root, "state.json"), "utf8"),
|
|
196
|
+
);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.code !== "ENOENT") throw error;
|
|
199
|
+
}
|
|
200
|
+
for (const p of Object.values(this.state.projects)) {
|
|
201
|
+
p.backend ||= p.tunnel?.credentials_path ? "host" : "mock";
|
|
202
|
+
p.type ||= "nodejs";
|
|
203
|
+
}
|
|
204
|
+
for (const p of Object.values(this.state.projects))
|
|
205
|
+
if (["queued", "deploying"].includes(p.status)) {
|
|
206
|
+
p.status = "failed";
|
|
207
|
+
p.error = "Agent interrupted during deployment. Redeploy to retry.";
|
|
208
|
+
}
|
|
209
|
+
await this.save();
|
|
210
|
+
if (this.mode === "native")
|
|
211
|
+
for (const p of Object.values(this.state.projects)) {
|
|
212
|
+
if (
|
|
213
|
+
p.backend === "native" &&
|
|
214
|
+
p.status === "running" &&
|
|
215
|
+
p.native_specs?.length
|
|
216
|
+
) {
|
|
217
|
+
this.jobs = this.jobs.then(async () => {
|
|
218
|
+
try {
|
|
219
|
+
for (const spec of p.native_specs.filter(
|
|
220
|
+
(spec) =>
|
|
221
|
+
spec.key !== "tunnel" ||
|
|
222
|
+
process.env.NATIVE_TUNNELS !== "false",
|
|
223
|
+
))
|
|
224
|
+
await this.supervisor.start(`${p.id}:${spec.key}`, spec);
|
|
225
|
+
await ready(p.port, this.supervisor, `${p.id}:app`);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
p.status = "failed";
|
|
228
|
+
p.error = error.message;
|
|
229
|
+
await this.supervisor.stopProject(p.id);
|
|
230
|
+
}
|
|
231
|
+
await this.save();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return this;
|
|
236
|
+
}
|
|
237
|
+
async save() {
|
|
238
|
+
const write = this.writes.then(async () => {
|
|
239
|
+
const file = path.join(this.root, "state.json");
|
|
240
|
+
await writeFile(file + ".tmp", JSON.stringify(this.state, null, 2), {
|
|
241
|
+
mode: 0o600,
|
|
242
|
+
});
|
|
243
|
+
await rename(file + ".tmp", file);
|
|
244
|
+
});
|
|
245
|
+
this.writes = write.catch(() => {});
|
|
246
|
+
return write;
|
|
247
|
+
}
|
|
248
|
+
serial(fn) {
|
|
249
|
+
const task = this.tail.then(fn);
|
|
250
|
+
this.tail = task.catch(() => {});
|
|
251
|
+
return task;
|
|
252
|
+
}
|
|
253
|
+
get(id) {
|
|
254
|
+
const p = this.state.projects[id];
|
|
255
|
+
if (!p) throw Object.assign(Error("Project not found"), { status: 404 });
|
|
256
|
+
return p;
|
|
257
|
+
}
|
|
258
|
+
log(p, message) {
|
|
259
|
+
p.logs = (
|
|
260
|
+
(p.logs || "") + `[${new Date().toISOString()}] ${message}\n`
|
|
261
|
+
).slice(-100000);
|
|
262
|
+
}
|
|
263
|
+
async create(id, input) {
|
|
264
|
+
if (this.state.projects[id]) return this.get(id);
|
|
265
|
+
if (
|
|
266
|
+
Object.values(this.state.projects).some(
|
|
267
|
+
(p) => p.name === input.name || p.subdomain === input.subdomain,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
throw Object.assign(Error("Name or hostname already exists"), {
|
|
271
|
+
status: 409,
|
|
272
|
+
});
|
|
273
|
+
const p = {
|
|
274
|
+
...input,
|
|
275
|
+
backend: this.mode,
|
|
276
|
+
id,
|
|
277
|
+
status: "created",
|
|
278
|
+
port: null,
|
|
279
|
+
logs: "",
|
|
280
|
+
folder_path: path.join(this.root, "projects", id),
|
|
281
|
+
};
|
|
282
|
+
this.state.projects[id] = p;
|
|
283
|
+
await this.save();
|
|
284
|
+
return p;
|
|
285
|
+
}
|
|
286
|
+
async allocate() {
|
|
287
|
+
const occupied = new Set(
|
|
288
|
+
Object.values(this.state.projects).flatMap((p) => [p.port, p.php_port]),
|
|
289
|
+
);
|
|
290
|
+
for (let port = this.portMin; port <= this.portMax; port++) {
|
|
291
|
+
if (occupied.has(port)) continue;
|
|
292
|
+
const free = await new Promise((resolve) => {
|
|
293
|
+
const server = net.createServer();
|
|
294
|
+
server.once("error", () => resolve(false));
|
|
295
|
+
server.listen(port, "127.0.0.1", () =>
|
|
296
|
+
server.close(() => resolve(true)),
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
if (free) return port;
|
|
300
|
+
}
|
|
301
|
+
throw Error("No ports available in the configured range");
|
|
302
|
+
}
|
|
303
|
+
async upload(id, buffer) {
|
|
304
|
+
const p = this.get(id);
|
|
305
|
+
if (["queued", "deploying"].includes(p.status))
|
|
306
|
+
throw Object.assign(Error("Deployment in progress"), { status: 409 });
|
|
307
|
+
await writeFile(path.join(this.root, `${id}.zip`), buffer, { mode: 0o600 });
|
|
308
|
+
p.has_upload = true;
|
|
309
|
+
await this.save();
|
|
310
|
+
}
|
|
311
|
+
async deploy(id, deploymentId) {
|
|
312
|
+
const p = this.get(id);
|
|
313
|
+
if (this.mode === "host" && p.type && p.type !== "nodejs")
|
|
314
|
+
throw Error("Use AGENT_MODE=native for multiple runtimes");
|
|
315
|
+
if (p.backend && p.backend !== this.mode && p.backend !== "mock")
|
|
316
|
+
throw Error(
|
|
317
|
+
"Project belongs to another deployment backend. Switch to its original agent mode to manage it.",
|
|
318
|
+
);
|
|
319
|
+
if (p.backend === "mock" && this.mode !== "mock") {
|
|
320
|
+
p.tunnel = null;
|
|
321
|
+
p.native_specs = [];
|
|
322
|
+
}
|
|
323
|
+
p.backend = this.mode;
|
|
324
|
+
if (p.deployment_id === deploymentId) return p;
|
|
325
|
+
if (["queued", "deploying"].includes(p.status))
|
|
326
|
+
throw Object.assign(Error("Deployment already in progress"), {
|
|
327
|
+
status: 409,
|
|
328
|
+
});
|
|
329
|
+
if (p.source_type === "zip" && !p.has_upload)
|
|
330
|
+
throw Error("Upload a ZIP before deploying");
|
|
331
|
+
p.status = "queued";
|
|
332
|
+
p.deployment_id = deploymentId;
|
|
333
|
+
p.error = null;
|
|
334
|
+
await this.save();
|
|
335
|
+
// Builds run serially without blocking short API mutations for other projects.
|
|
336
|
+
this.jobs = this.jobs
|
|
337
|
+
.then(() => this.pipeline(p))
|
|
338
|
+
.catch((error) => {
|
|
339
|
+
p.status = "failed";
|
|
340
|
+
p.error = error.message;
|
|
341
|
+
console.error("Unable to persist deployment state:", error.message);
|
|
342
|
+
});
|
|
343
|
+
return p;
|
|
344
|
+
}
|
|
345
|
+
async pipeline(p) {
|
|
346
|
+
try {
|
|
347
|
+
p.status = "deploying";
|
|
348
|
+
p.port ||= await this.allocate();
|
|
349
|
+
this.log(p, `Starting deployment ${p.deployment_id}`);
|
|
350
|
+
await this.save();
|
|
351
|
+
if (this.mode === "mock") {
|
|
352
|
+
for (const step of [
|
|
353
|
+
"Source received",
|
|
354
|
+
"Port allocated: " + p.port,
|
|
355
|
+
"Dependencies installed (simulated)",
|
|
356
|
+
"Build completed (simulated)",
|
|
357
|
+
"App service started (simulated)",
|
|
358
|
+
"Cloudflare tunnel connected (simulated)",
|
|
359
|
+
]) {
|
|
360
|
+
this.log(p, step);
|
|
361
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
362
|
+
}
|
|
363
|
+
p.tunnel = {
|
|
364
|
+
tunnel_id: p.tunnel?.tunnel_id || randomUUID(),
|
|
365
|
+
tunnel_name: `naviyra-${p.id}`,
|
|
366
|
+
credentials_path: null,
|
|
367
|
+
config_path: null,
|
|
368
|
+
};
|
|
369
|
+
} else if (this.mode === "native") await this.nativeDeploy(p);
|
|
370
|
+
else await this.hostDeploy(p);
|
|
371
|
+
p.status = "running";
|
|
372
|
+
this.log(p, "Deployment complete");
|
|
373
|
+
} catch (error) {
|
|
374
|
+
p.status = "failed";
|
|
375
|
+
p.error = error.message;
|
|
376
|
+
this.log(p, `FAILED: ${error.message}`);
|
|
377
|
+
if (this.mode === "native") await this.supervisor.stopProject(p.id);
|
|
378
|
+
if (this.mode === "host")
|
|
379
|
+
for (const unit of [this.unit(p), this.unit(p, true)])
|
|
380
|
+
await command("systemctl", ["stop", unit]).catch(() => {});
|
|
381
|
+
}
|
|
382
|
+
await this.save();
|
|
383
|
+
}
|
|
384
|
+
unit(p, tunnel = false) {
|
|
385
|
+
return `naviyra-${p.id}${tunnel ? "-tunnel" : ""}.service`;
|
|
386
|
+
}
|
|
387
|
+
async nativeDeploy(p) {
|
|
388
|
+
await this.supervisor.stopProject(p.id);
|
|
389
|
+
const source = path.resolve(p.folder_path, "app");
|
|
390
|
+
const relative = path.relative(this.root, source);
|
|
391
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
392
|
+
throw Error("Project path escapes agent root");
|
|
393
|
+
await mkdir(p.folder_path, { recursive: true });
|
|
394
|
+
await rm(source, { recursive: true, force: true });
|
|
395
|
+
await mkdir(source, { recursive: true });
|
|
396
|
+
if (p.source_type === "zip")
|
|
397
|
+
await extractZip(
|
|
398
|
+
await readFile(path.join(this.root, `${p.id}.zip`)),
|
|
399
|
+
source,
|
|
400
|
+
);
|
|
401
|
+
else
|
|
402
|
+
await nativeCommand(
|
|
403
|
+
"git",
|
|
404
|
+
[
|
|
405
|
+
"-c",
|
|
406
|
+
"http.followRedirects=false",
|
|
407
|
+
"clone",
|
|
408
|
+
"--depth",
|
|
409
|
+
"1",
|
|
410
|
+
"--",
|
|
411
|
+
p.git_url,
|
|
412
|
+
source,
|
|
413
|
+
],
|
|
414
|
+
{ cwd: p.folder_path, log: (message) => this.log(p, message) },
|
|
415
|
+
);
|
|
416
|
+
if (p.type === "php") p.php_port ||= await this.allocate();
|
|
417
|
+
this.log(
|
|
418
|
+
p,
|
|
419
|
+
`Preparing native ${p.type || "nodejs"} runtime on ${process.platform}`,
|
|
420
|
+
);
|
|
421
|
+
p.native_specs = await prepareNative(p, source, (message) =>
|
|
422
|
+
this.log(p, message),
|
|
423
|
+
);
|
|
424
|
+
for (const spec of p.native_specs)
|
|
425
|
+
await this.supervisor.start(`${p.id}:${spec.key}`, spec);
|
|
426
|
+
await ready(p.port, this.supervisor, `${p.id}:app`);
|
|
427
|
+
if (p.type === "php")
|
|
428
|
+
await ready(p.php_port, this.supervisor, `${p.id}:php`);
|
|
429
|
+
await provisionNativeTunnel(this, p);
|
|
430
|
+
}
|
|
431
|
+
async hostDeploy(p) {
|
|
432
|
+
const user = process.env.APP_USER || "naviyra-app";
|
|
433
|
+
const group = process.env.APP_GROUP || user;
|
|
434
|
+
if (!/^[a-z_][a-z0-9_-]*$/.test(user) || !/^[a-z_][a-z0-9_-]*$/.test(group))
|
|
435
|
+
throw Error("Invalid app user or group");
|
|
436
|
+
const folder = p.folder_path;
|
|
437
|
+
const release = path.join(folder, "app");
|
|
438
|
+
await mkdir(path.join(this.root, "projects"), { recursive: true });
|
|
439
|
+
await chmod(path.join(this.root, "projects"), 0o711);
|
|
440
|
+
await command("systemctl", ["stop", this.unit(p)]).catch(() => {});
|
|
441
|
+
await mkdir(folder, { recursive: true, mode: 0o755 });
|
|
442
|
+
await rm(release, { recursive: true, force: true });
|
|
443
|
+
await mkdir(release, { recursive: true });
|
|
444
|
+
await command("chown", ["-R", `${user}:${group}`, folder]);
|
|
445
|
+
const asUser = async (binary, args, cwd = release) =>
|
|
446
|
+
command(
|
|
447
|
+
"runuser",
|
|
448
|
+
[
|
|
449
|
+
"-u",
|
|
450
|
+
user,
|
|
451
|
+
"--",
|
|
452
|
+
"/usr/bin/env",
|
|
453
|
+
"-i",
|
|
454
|
+
"PATH=/usr/local/bin:/usr/bin:/bin",
|
|
455
|
+
`HOME=${folder}`,
|
|
456
|
+
"GIT_TERMINAL_PROMPT=0",
|
|
457
|
+
binary,
|
|
458
|
+
...args,
|
|
459
|
+
],
|
|
460
|
+
{ cwd, timeout: 600000, onOutput: (chunk) => this.log(p, chunk) },
|
|
461
|
+
);
|
|
462
|
+
if (p.source_type === "git")
|
|
463
|
+
await asUser(
|
|
464
|
+
binaries.git,
|
|
465
|
+
[
|
|
466
|
+
"-c",
|
|
467
|
+
"http.followRedirects=false",
|
|
468
|
+
"clone",
|
|
469
|
+
"--depth",
|
|
470
|
+
"1",
|
|
471
|
+
"--",
|
|
472
|
+
p.git_url,
|
|
473
|
+
release,
|
|
474
|
+
],
|
|
475
|
+
folder,
|
|
476
|
+
);
|
|
477
|
+
else {
|
|
478
|
+
await extractZip(
|
|
479
|
+
await readFile(path.join(this.root, `${p.id}.zip`)),
|
|
480
|
+
release,
|
|
481
|
+
);
|
|
482
|
+
await command("chown", ["-R", `${user}:${group}`, release]);
|
|
483
|
+
}
|
|
484
|
+
const manifest = JSON.parse(
|
|
485
|
+
await readFile(path.join(release, "package.json"), "utf8"),
|
|
486
|
+
);
|
|
487
|
+
if (!manifest.scripts?.start)
|
|
488
|
+
throw Error(
|
|
489
|
+
"package.json must contain a start script at the archive/repository root",
|
|
490
|
+
);
|
|
491
|
+
const files = await readdir(release);
|
|
492
|
+
this.log(p, "Installing dependencies");
|
|
493
|
+
await asUser(binaries.npm, [
|
|
494
|
+
files.includes("package-lock.json") ? "ci" : "install",
|
|
495
|
+
"--no-audit",
|
|
496
|
+
"--no-fund",
|
|
497
|
+
]);
|
|
498
|
+
if (manifest.scripts.build) {
|
|
499
|
+
this.log(p, "Building application");
|
|
500
|
+
await asUser(binaries.npm, ["run", "build"]);
|
|
501
|
+
}
|
|
502
|
+
const appUnit = `[Unit]\nDescription=Naviyra ${p.name}\nAfter=network.target\n[Service]\nUser=${user}\nGroup=${group}\nWorkingDirectory=${release}\nExecStart=/usr/bin/npm run start\nEnvironment=NODE_ENV=production\nEnvironment=PORT=${p.port}\nEnvironment=HOST=127.0.0.1\nEnvironment=HOSTNAME=127.0.0.1\nRestart=on-failure\nRestartSec=3\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nReadWritePaths=${folder}\n[Install]\nWantedBy=multi-user.target\n`;
|
|
503
|
+
await writeFile(`/etc/systemd/system/${this.unit(p)}`, appUnit);
|
|
504
|
+
await mkdir(path.join(this.root, "cloudflare"), {
|
|
505
|
+
recursive: true,
|
|
506
|
+
mode: 0o700,
|
|
507
|
+
});
|
|
508
|
+
const credentials = path.join(this.root, "cloudflare", `${p.id}.json`);
|
|
509
|
+
const config = path.join(this.root, "cloudflare", `${p.id}.yml`);
|
|
510
|
+
const cert = process.env.CLOUDFLARE_CERT || "/root/.cloudflared/cert.pem";
|
|
511
|
+
if (!p.tunnel) {
|
|
512
|
+
// Recover a tunnel created before an interrupted state write using its credentials file.
|
|
513
|
+
let credential;
|
|
514
|
+
try {
|
|
515
|
+
credential = JSON.parse(await readFile(credentials, "utf8"));
|
|
516
|
+
} catch (error) {
|
|
517
|
+
if (error.code !== "ENOENT") throw error;
|
|
518
|
+
}
|
|
519
|
+
if (!credential) {
|
|
520
|
+
await command("cloudflared", [
|
|
521
|
+
"tunnel",
|
|
522
|
+
"--origincert",
|
|
523
|
+
cert,
|
|
524
|
+
"--credentials-file",
|
|
525
|
+
credentials,
|
|
526
|
+
"create",
|
|
527
|
+
`naviyra-${p.id}`,
|
|
528
|
+
]);
|
|
529
|
+
credential = JSON.parse(await readFile(credentials, "utf8"));
|
|
530
|
+
}
|
|
531
|
+
p.tunnel = {
|
|
532
|
+
tunnel_id: credential.TunnelID,
|
|
533
|
+
tunnel_name: `naviyra-${p.id}`,
|
|
534
|
+
credentials_path: credentials,
|
|
535
|
+
config_path: config,
|
|
536
|
+
};
|
|
537
|
+
await this.save();
|
|
538
|
+
}
|
|
539
|
+
await writeFile(
|
|
540
|
+
config,
|
|
541
|
+
`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
|
+
{ mode: 0o600 },
|
|
543
|
+
);
|
|
544
|
+
await command("cloudflared", [
|
|
545
|
+
"tunnel",
|
|
546
|
+
"--origincert",
|
|
547
|
+
cert,
|
|
548
|
+
"route",
|
|
549
|
+
"dns",
|
|
550
|
+
p.tunnel.tunnel_id,
|
|
551
|
+
p.subdomain,
|
|
552
|
+
]);
|
|
553
|
+
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
|
+
await writeFile(`/etc/systemd/system/${this.unit(p, true)}`, tunnelUnit);
|
|
555
|
+
await command("systemctl", ["daemon-reload"]);
|
|
556
|
+
await command("systemctl", [
|
|
557
|
+
"enable",
|
|
558
|
+
"--now",
|
|
559
|
+
this.unit(p),
|
|
560
|
+
this.unit(p, true),
|
|
561
|
+
]);
|
|
562
|
+
await command("systemctl", ["restart", this.unit(p), this.unit(p, true)]);
|
|
563
|
+
let ready = false;
|
|
564
|
+
for (let i = 0; i < 30; i++) {
|
|
565
|
+
ready = await new Promise((resolve) => {
|
|
566
|
+
const socket = net.connect(p.port, "127.0.0.1");
|
|
567
|
+
socket.setTimeout(1000);
|
|
568
|
+
socket.once("connect", () => {
|
|
569
|
+
socket.destroy();
|
|
570
|
+
resolve(true);
|
|
571
|
+
});
|
|
572
|
+
socket.once("error", () => resolve(false));
|
|
573
|
+
socket.once("timeout", () => {
|
|
574
|
+
socket.destroy();
|
|
575
|
+
resolve(false);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
if (ready) break;
|
|
579
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
580
|
+
}
|
|
581
|
+
if (!ready)
|
|
582
|
+
throw Error(
|
|
583
|
+
"Application did not listen on its assigned PORT within 30 seconds",
|
|
584
|
+
);
|
|
585
|
+
await command("systemctl", ["is-active", this.unit(p), this.unit(p, true)]);
|
|
586
|
+
}
|
|
587
|
+
async restart(id) {
|
|
588
|
+
const p = this.get(id);
|
|
589
|
+
if (p.backend && p.backend !== this.mode)
|
|
590
|
+
throw Error(
|
|
591
|
+
"Switch the agent to the project deployment mode before restarting",
|
|
592
|
+
);
|
|
593
|
+
if (p.status !== "running")
|
|
594
|
+
throw Object.assign(Error("Only running projects can be restarted"), {
|
|
595
|
+
status: 409,
|
|
596
|
+
});
|
|
597
|
+
if (this.mode === "host")
|
|
598
|
+
await command("systemctl", ["restart", this.unit(p), this.unit(p, true)]);
|
|
599
|
+
if (this.mode === "native") {
|
|
600
|
+
await this.supervisor.stopProject(id);
|
|
601
|
+
for (const spec of (p.native_specs || []).filter(
|
|
602
|
+
(spec) =>
|
|
603
|
+
spec.key !== "tunnel" || process.env.NATIVE_TUNNELS !== "false",
|
|
604
|
+
))
|
|
605
|
+
await this.supervisor.start(`${id}:${spec.key}`, spec);
|
|
606
|
+
await ready(p.port, this.supervisor, `${id}:app`);
|
|
607
|
+
}
|
|
608
|
+
this.log(p, "Services restarted");
|
|
609
|
+
await this.save();
|
|
610
|
+
return p;
|
|
611
|
+
}
|
|
612
|
+
async remove(id) {
|
|
613
|
+
const p = this.get(id);
|
|
614
|
+
if (p.backend && p.backend !== this.mode)
|
|
615
|
+
throw Error(
|
|
616
|
+
"Switch the agent to the project deployment mode before deleting",
|
|
617
|
+
);
|
|
618
|
+
if (["queued", "deploying"].includes(p.status))
|
|
619
|
+
throw Object.assign(Error("Wait for the deployment to finish"), {
|
|
620
|
+
status: 409,
|
|
621
|
+
});
|
|
622
|
+
if (this.mode === "native") {
|
|
623
|
+
await this.supervisor.stopProject(id);
|
|
624
|
+
try {
|
|
625
|
+
await deleteNativeTunnel(p);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
p.status = "failed";
|
|
628
|
+
p.error = error.message;
|
|
629
|
+
await this.save();
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (this.mode === "host") {
|
|
634
|
+
// Do not discard local state until all external cleanup succeeds; deletion is retryable.
|
|
635
|
+
if (p.tunnel) {
|
|
636
|
+
const { deleteTunnelCname } = await import("./cloudflare.mjs");
|
|
637
|
+
await deleteTunnelCname(p.subdomain, p.tunnel.tunnel_id);
|
|
638
|
+
}
|
|
639
|
+
for (const tunnel of [false, true]) {
|
|
640
|
+
const unit = this.unit(p, tunnel);
|
|
641
|
+
try {
|
|
642
|
+
await readFile(`/etc/systemd/system/${unit}`);
|
|
643
|
+
await command("systemctl", ["disable", "--now", unit]);
|
|
644
|
+
await rm(`/etc/systemd/system/${unit}`);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (error.code !== "ENOENT") throw error;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
await command("systemctl", ["daemon-reload"]);
|
|
650
|
+
if (p.tunnel) {
|
|
651
|
+
const prefix = [
|
|
652
|
+
"tunnel",
|
|
653
|
+
"--origincert",
|
|
654
|
+
process.env.CLOUDFLARE_CERT || "/root/.cloudflared/cert.pem",
|
|
655
|
+
];
|
|
656
|
+
const tunnels = JSON.parse(
|
|
657
|
+
await command("cloudflared", [...prefix, "list", "--output", "json"]),
|
|
658
|
+
);
|
|
659
|
+
if (tunnels.some((t) => t.id === p.tunnel.tunnel_id))
|
|
660
|
+
await command("cloudflared", [
|
|
661
|
+
...prefix,
|
|
662
|
+
"delete",
|
|
663
|
+
p.tunnel.tunnel_id,
|
|
664
|
+
]);
|
|
665
|
+
p.tunnel = null;
|
|
666
|
+
await this.save();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
await rm(p.folder_path, { recursive: true, force: true });
|
|
670
|
+
for (const file of [
|
|
671
|
+
`${id}.zip`,
|
|
672
|
+
`cloudflare/${id}.json`,
|
|
673
|
+
`cloudflare/${id}.yml`,
|
|
674
|
+
])
|
|
675
|
+
await rm(path.join(this.root, file), { force: true });
|
|
676
|
+
delete this.state.projects[id];
|
|
677
|
+
await this.save();
|
|
678
|
+
}
|
|
679
|
+
async logs(id) {
|
|
680
|
+
const p = this.get(id);
|
|
681
|
+
let journal = "";
|
|
682
|
+
if (this.mode === "host" && p.port)
|
|
683
|
+
journal = await command("journalctl", [
|
|
684
|
+
"-u",
|
|
685
|
+
this.unit(p),
|
|
686
|
+
"-u",
|
|
687
|
+
this.unit(p, true),
|
|
688
|
+
"-n",
|
|
689
|
+
"120",
|
|
690
|
+
"--no-pager",
|
|
691
|
+
"-o",
|
|
692
|
+
"short-iso",
|
|
693
|
+
]).catch((e) => e.message);
|
|
694
|
+
return { logs: (p.logs + "\n" + journal).slice(-100000) };
|
|
695
|
+
}
|
|
696
|
+
async metrics(id) {
|
|
697
|
+
const p = this.get(id);
|
|
698
|
+
if (this.mode === "native")
|
|
699
|
+
return {
|
|
700
|
+
mode: "native",
|
|
701
|
+
platform: process.platform,
|
|
702
|
+
active: this.supervisor.active(`${id}:app`),
|
|
703
|
+
tunnel_active: this.supervisor.active(`${id}:tunnel`),
|
|
704
|
+
memory_bytes: null,
|
|
705
|
+
cpu_nanoseconds: null,
|
|
706
|
+
metrics_available: false,
|
|
707
|
+
local_url: p.port ? `http://127.0.0.1:${p.port}` : null,
|
|
708
|
+
};
|
|
709
|
+
if (this.mode === "mock")
|
|
710
|
+
return {
|
|
711
|
+
mode: "mock",
|
|
712
|
+
active: p.status === "running",
|
|
713
|
+
tunnel_active: p.status === "running",
|
|
714
|
+
memory_bytes: 0,
|
|
715
|
+
cpu_nanoseconds: 0,
|
|
716
|
+
};
|
|
717
|
+
const values = await command("systemctl", [
|
|
718
|
+
"show",
|
|
719
|
+
this.unit(p),
|
|
720
|
+
"--property=ActiveState,MemoryCurrent,CPUUsageNSec",
|
|
721
|
+
]);
|
|
722
|
+
const props = Object.fromEntries(
|
|
723
|
+
values
|
|
724
|
+
.trim()
|
|
725
|
+
.split("\n")
|
|
726
|
+
.map((line) => line.split("=")),
|
|
727
|
+
);
|
|
728
|
+
const tunnel = await command("systemctl", [
|
|
729
|
+
"is-active",
|
|
730
|
+
this.unit(p, true),
|
|
731
|
+
]).catch(() => "inactive");
|
|
732
|
+
const numeric = (value) =>
|
|
733
|
+
Number.isSafeInteger(Number(value)) ? Number(value) : 0;
|
|
734
|
+
return {
|
|
735
|
+
mode: "host",
|
|
736
|
+
active: props.ActiveState === "active",
|
|
737
|
+
tunnel_active: tunnel.trim() === "active",
|
|
738
|
+
memory_bytes: numeric(props.MemoryCurrent),
|
|
739
|
+
cpu_nanoseconds: numeric(props.CPUUsageNSec),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
systemMetrics() {
|
|
743
|
+
return {
|
|
744
|
+
mode: this.mode,
|
|
745
|
+
platform: process.platform,
|
|
746
|
+
runtimes:
|
|
747
|
+
this.mode === "host"
|
|
748
|
+
? ["nodejs"]
|
|
749
|
+
: ["nodejs", "python", "php", "go", "static", "custom"],
|
|
750
|
+
tunnels_enabled:
|
|
751
|
+
this.mode === "native" ? process.env.NATIVE_TUNNELS !== "false" : true,
|
|
752
|
+
hostname: os.hostname(),
|
|
753
|
+
uptime: os.uptime(),
|
|
754
|
+
load: os.loadavg(),
|
|
755
|
+
memory_total: os.totalmem(),
|
|
756
|
+
memory_used: os.totalmem() - os.freemem(),
|
|
757
|
+
projects: Object.keys(this.state.projects).length,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
}
|