@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,195 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
TASK_NAME,
|
|
6
|
+
panelOrigin,
|
|
7
|
+
removeBootTask,
|
|
8
|
+
runPowerShell,
|
|
9
|
+
startBootTask,
|
|
10
|
+
stopBootTask,
|
|
11
|
+
} from "./windows-boot.mjs";
|
|
12
|
+
|
|
13
|
+
export const COMMANDS = ["status", "open", "stop", "start", "update", "uninstall"];
|
|
14
|
+
|
|
15
|
+
export function isProtectedDir(dir) {
|
|
16
|
+
const resolved = path.resolve(dir);
|
|
17
|
+
const roots = [
|
|
18
|
+
process.env.WINDIR,
|
|
19
|
+
process.env.ProgramFiles,
|
|
20
|
+
process.env["ProgramFiles(x86)"],
|
|
21
|
+
].filter(Boolean);
|
|
22
|
+
return roots.some((root) => {
|
|
23
|
+
const base = path.resolve(root);
|
|
24
|
+
const prefix = base.toLowerCase() + path.sep;
|
|
25
|
+
return resolved.toLowerCase().startsWith(prefix) || resolved.toLowerCase() === base.toLowerCase();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function canRemoveInstall(dir) {
|
|
30
|
+
const resolved = path.resolve(dir);
|
|
31
|
+
const base = path.basename(resolved).toLowerCase();
|
|
32
|
+
if (base === "system32" || base === "windows" || base === "syswow64") return false;
|
|
33
|
+
const windir = path.resolve(process.env.WINDIR || "C:\\Windows");
|
|
34
|
+
return resolved.toLowerCase() !== windir.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function problemFromLog(text) {
|
|
38
|
+
const lines = String(text)
|
|
39
|
+
.split(/\r?\n/)
|
|
40
|
+
.map((line) => line.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
if (lines.some((line) => /permission denied/i.test(line)))
|
|
43
|
+
return "PostgreSQL could not create its data directory because this folder is protected. Install into an empty folder you own, such as D:\\naviyra-install.";
|
|
44
|
+
return lines.findLast((line) => /could not start|exited 1|error:/i.test(line)) || "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatStatus({ dir, taskState, origin, ready, problem }) {
|
|
48
|
+
if (!dir && (!taskState || taskState === "missing"))
|
|
49
|
+
return "Naviyra is not installed. From an empty folder you own, run: npx @naviyra/cloud-panel";
|
|
50
|
+
const lines = [
|
|
51
|
+
ready ? "Naviyra is running." : "Naviyra is not ready.",
|
|
52
|
+
`Panel: ${origin || "http://127.0.0.1:3000"}`,
|
|
53
|
+
];
|
|
54
|
+
if (dir) lines.push(`Install folder: ${dir}`);
|
|
55
|
+
lines.push(`Startup task: ${taskState || "not registered"}`);
|
|
56
|
+
if (!ready && problem) lines.push(problem);
|
|
57
|
+
lines.push(
|
|
58
|
+
ready
|
|
59
|
+
? "Open the panel with: npx @naviyra/cloud-panel open"
|
|
60
|
+
: "After it is ready, open it with: npx @naviyra/cloud-panel open",
|
|
61
|
+
);
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function isPanel(dir) {
|
|
66
|
+
try {
|
|
67
|
+
const pkg = JSON.parse(await readFile(path.join(dir, "package.json"), "utf8"));
|
|
68
|
+
await access(path.join(dir, "apps", "panel", "package.json"));
|
|
69
|
+
return pkg.name === "@naviyra/cloud-panel";
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function queryTask(run = runPowerShell) {
|
|
76
|
+
const result = await run(`$task = Get-ScheduledTask -TaskName ${"'" + TASK_NAME.replaceAll("'", "''") + "'"} -ErrorAction SilentlyContinue
|
|
77
|
+
if (-not $task) { Write-Output 'STATE=missing'; exit 0 }
|
|
78
|
+
Write-Output ('STATE=' + $task.State)
|
|
79
|
+
Write-Output ('DIR=' + $task.Actions[0].WorkingDirectory)
|
|
80
|
+
`);
|
|
81
|
+
const state = result.output.match(/STATE=(.*)/)?.[1]?.trim() || "missing";
|
|
82
|
+
const dir = result.output.match(/DIR=(.*)/)?.[1]?.trim() || "";
|
|
83
|
+
return { state, dir };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function panelHealthy(origin) {
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(`${origin}/api/me`, {
|
|
89
|
+
redirect: "manual",
|
|
90
|
+
signal: AbortSignal.timeout(2000),
|
|
91
|
+
});
|
|
92
|
+
const body = await response.json();
|
|
93
|
+
return response.status === 401 && body.error === "Please sign in";
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function openBrowser(origin) {
|
|
100
|
+
const file = process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
101
|
+
const browser = spawn(file, [origin], { windowsHide: true, stdio: "ignore" });
|
|
102
|
+
browser.once("error", () => console.log(`Open ${origin} in your browser.`));
|
|
103
|
+
browser.unref();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function locate(options) {
|
|
107
|
+
const task = options.queryTask ? await options.queryTask() : await queryTask(options.runPowerShell);
|
|
108
|
+
if (task.dir && (await isPanel(task.dir))) return { ...task, dir: task.dir };
|
|
109
|
+
if (options.cwd && (await isPanel(options.cwd)))
|
|
110
|
+
return { state: task.state, dir: options.cwd };
|
|
111
|
+
return { state: task.state || "missing", dir: "" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function readStatus(dir, taskState) {
|
|
115
|
+
let envText = "";
|
|
116
|
+
let logText = "";
|
|
117
|
+
if (dir) {
|
|
118
|
+
try {
|
|
119
|
+
envText = await readFile(path.join(dir, ".env"), "utf8");
|
|
120
|
+
} catch {
|
|
121
|
+
envText = "";
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
logText = await readFile(path.join(dir, ".runtime", "service.log"), "utf8");
|
|
125
|
+
} catch {
|
|
126
|
+
logText = "";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const origin = panelOrigin(envText);
|
|
130
|
+
const ready = await panelHealthy(origin);
|
|
131
|
+
return {
|
|
132
|
+
dir,
|
|
133
|
+
taskState,
|
|
134
|
+
origin,
|
|
135
|
+
ready,
|
|
136
|
+
problem: ready ? "" : problemFromLog(logText),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function execute(command, options = {}) {
|
|
141
|
+
const located = await locate(options);
|
|
142
|
+
if (command === "status") {
|
|
143
|
+
console.log(formatStatus(await readStatus(located.dir, located.state)));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!located.dir)
|
|
147
|
+
throw Error("Naviyra is not installed. From an empty folder you own, run: npx @naviyra/cloud-panel");
|
|
148
|
+
const root = located.dir;
|
|
149
|
+
const run = options.runPowerShell;
|
|
150
|
+
if (command === "stop") {
|
|
151
|
+
const stopped = await stopBootTask({ root, runPowerShell: run });
|
|
152
|
+
if (!stopped.ok) throw Error(stopped.message || "Could not stop Naviyra.");
|
|
153
|
+
console.log("Naviyra is stopped.");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (command === "start") {
|
|
157
|
+
const started = await startBootTask(run);
|
|
158
|
+
if (!started.ok) throw Error(started.message || "Could not start Naviyra.");
|
|
159
|
+
console.log("Naviyra is starting. Check it with: npx @naviyra/cloud-panel status");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (command === "open") {
|
|
163
|
+
const status = await readStatus(root, located.state);
|
|
164
|
+
if (!status.ready) {
|
|
165
|
+
console.log(formatStatus(status));
|
|
166
|
+
throw Error("The panel is not ready to open yet.");
|
|
167
|
+
}
|
|
168
|
+
openBrowser(status.origin);
|
|
169
|
+
console.log(`Opened ${status.origin}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (command === "uninstall") {
|
|
173
|
+
const removed = await removeBootTask({ root, runPowerShell: run });
|
|
174
|
+
if (!removed.ok) throw Error(removed.message || "Could not remove the startup task.");
|
|
175
|
+
if (canRemoveInstall(root) && (await isPanel(root))) {
|
|
176
|
+
await rm(root, { recursive: true, force: true });
|
|
177
|
+
console.log(`Uninstalled Naviyra and removed ${root}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
console.log(`${removed.message} The install folder was left at ${root}`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (command === "update") {
|
|
184
|
+
if (!options.source) throw Error("Cannot find the new Naviyra package to copy.");
|
|
185
|
+
if (path.resolve(options.source) === path.resolve(root))
|
|
186
|
+
throw Error("Run npx @naviyra/cloud-panel update so the newest package can replace this install.");
|
|
187
|
+
await options.copy(options.source, root);
|
|
188
|
+
await options.install(root);
|
|
189
|
+
const started = await startBootTask(run);
|
|
190
|
+
if (!started.ok) throw Error(started.message || "Update copied, but Naviyra did not start.");
|
|
191
|
+
console.log(`Updated Naviyra in ${root}. Check it with: npx @naviyra/cloud-panel status`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
throw Error(`Unknown command: ${command}`);
|
|
195
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, mkdir, writeFile, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import pg from "pg";
|
|
5
|
+
const root = path.resolve(".runtime/postgres");
|
|
6
|
+
const bin =
|
|
7
|
+
process.env.POSTGRES_BIN ||
|
|
8
|
+
(process.platform === "win32" ? "C:/Program Files/PostgreSQL/18/bin" : "");
|
|
9
|
+
const exe = (name) =>
|
|
10
|
+
path.join(bin, name + (process.platform === "win32" ? ".exe" : ""));
|
|
11
|
+
const run = (name, args) =>
|
|
12
|
+
new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(exe(name), args, {
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
stdio: "inherit",
|
|
16
|
+
shell: false,
|
|
17
|
+
});
|
|
18
|
+
child.on("error", reject);
|
|
19
|
+
child.on("exit", (code) =>
|
|
20
|
+
code === 0 ? resolve() : reject(Error(`${name} exited ${code}`)),
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
const url = new URL(process.env.DATABASE_URL || "");
|
|
24
|
+
if (!["localhost", "127.0.0.1"].includes(url.hostname))
|
|
25
|
+
throw Error(
|
|
26
|
+
"db:local only manages a loopback database; keep remote PostgreSQL separately managed",
|
|
27
|
+
);
|
|
28
|
+
const port = Number(url.port || 5432),
|
|
29
|
+
user = decodeURIComponent(url.username),
|
|
30
|
+
password = decodeURIComponent(url.password),
|
|
31
|
+
database = url.pathname.slice(1);
|
|
32
|
+
if (
|
|
33
|
+
!password ||
|
|
34
|
+
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(user) ||
|
|
35
|
+
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(database)
|
|
36
|
+
)
|
|
37
|
+
throw Error(
|
|
38
|
+
"Local database URL needs a password and simple database/user names",
|
|
39
|
+
);
|
|
40
|
+
if (process.argv.includes("--stop")) {
|
|
41
|
+
await run("pg_ctl", ["-D", root, "stop", "-m", "fast"]);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
await mkdir(".runtime", { recursive: true });
|
|
45
|
+
let initialized = false;
|
|
46
|
+
try {
|
|
47
|
+
await access(path.join(root, "PG_VERSION"));
|
|
48
|
+
initialized = true;
|
|
49
|
+
} catch {}
|
|
50
|
+
if (!initialized) {
|
|
51
|
+
const pwfile = path.resolve(".runtime/postgres-init-password");
|
|
52
|
+
await writeFile(pwfile, password, { mode: 0o600 });
|
|
53
|
+
try {
|
|
54
|
+
await run("initdb", [
|
|
55
|
+
"-D",
|
|
56
|
+
root,
|
|
57
|
+
"-U",
|
|
58
|
+
user,
|
|
59
|
+
"--auth=scram-sha-256",
|
|
60
|
+
"--pwfile",
|
|
61
|
+
pwfile,
|
|
62
|
+
"--encoding=UTF8",
|
|
63
|
+
"--locale=C",
|
|
64
|
+
]);
|
|
65
|
+
} finally {
|
|
66
|
+
await unlink(pwfile);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
await run("pg_ctl", [
|
|
70
|
+
"-D",
|
|
71
|
+
root,
|
|
72
|
+
"-l",
|
|
73
|
+
path.resolve(".runtime/postgres.log"),
|
|
74
|
+
"-o",
|
|
75
|
+
`-h 127.0.0.1 -p ${port}`,
|
|
76
|
+
"-w",
|
|
77
|
+
"start",
|
|
78
|
+
]);
|
|
79
|
+
const adminUrl = new URL(url);
|
|
80
|
+
adminUrl.pathname = "/postgres";
|
|
81
|
+
const client = new pg.Client({ connectionString: adminUrl.href });
|
|
82
|
+
await client.connect();
|
|
83
|
+
try {
|
|
84
|
+
if (
|
|
85
|
+
!(
|
|
86
|
+
await client.query("SELECT 1 FROM pg_database WHERE datname=$1", [
|
|
87
|
+
database,
|
|
88
|
+
])
|
|
89
|
+
).rowCount
|
|
90
|
+
)
|
|
91
|
+
await client.query(`CREATE DATABASE "${database}"`);
|
|
92
|
+
} finally {
|
|
93
|
+
await client.end();
|
|
94
|
+
}
|
|
95
|
+
console.log(
|
|
96
|
+
"Native PostgreSQL is ready. Run npm run db:migrate and npm run db:seed.",
|
|
97
|
+
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Maintainer utility: resolve hashes from the official HTTPS distribution endpoints.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
const artifacts = {};
|
|
5
|
+
await mkdir(".runtime/runtime-downloads", { recursive: true });
|
|
6
|
+
for (const arch of ["x86_64", "aarch64"]) {
|
|
7
|
+
for (const name of ["docker", "docker-rootless-extras"]) {
|
|
8
|
+
const file = `${name}-29.8.2-${arch}.tgz`;
|
|
9
|
+
const url = `https://download.docker.com/linux/static/stable/${arch}/${name}-29.8.2.tgz`;
|
|
10
|
+
const response = await fetch(url);
|
|
11
|
+
if (!response.ok) throw Error(`${response.status}: ${url}`);
|
|
12
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
13
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
14
|
+
await writeFile(`.runtime/runtime-downloads/${file}`, bytes);
|
|
15
|
+
artifacts[`${name}-${arch}`] = { file, url, sha256 };
|
|
16
|
+
console.log(file, sha256);
|
|
17
|
+
}
|
|
18
|
+
const file = `alpine-minirootfs-3.23.6-${arch}.tar.gz`;
|
|
19
|
+
const url = `https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/${arch}/${file}`;
|
|
20
|
+
const response = await fetch(`${url}.sha256`);
|
|
21
|
+
if (!response.ok)
|
|
22
|
+
throw Error(`Cannot read Alpine checksum: ${response.status}`);
|
|
23
|
+
const sha256 = (await response.text()).split(/\s/)[0];
|
|
24
|
+
if (!/^[0-9a-f]{64}$/.test(sha256)) throw Error("Invalid Alpine checksum");
|
|
25
|
+
artifacts[`alpine-${arch}`] = { file, url, sha256 };
|
|
26
|
+
}
|
|
27
|
+
await writeFile(
|
|
28
|
+
"packages/runtime/artifacts.json",
|
|
29
|
+
JSON.stringify(artifacts, null, 2) + "\n",
|
|
30
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { managedRuntime } from "../packages/runtime/index.mjs";
|
|
2
|
+
try {
|
|
3
|
+
const runtime = managedRuntime();
|
|
4
|
+
const action = process.argv[2] || "setup";
|
|
5
|
+
if (action === "status")
|
|
6
|
+
console.log(JSON.stringify(await runtime.status(), null, 2));
|
|
7
|
+
else if (action === "stop") {
|
|
8
|
+
await runtime.stop();
|
|
9
|
+
console.log("Naviyra Docker stopped. Database data is preserved.");
|
|
10
|
+
} else if (["setup", "start"].includes(action)) {
|
|
11
|
+
console.log(
|
|
12
|
+
"Preparing Naviyra's private Docker runtime. First setup downloads verified runtime files.",
|
|
13
|
+
);
|
|
14
|
+
await runtime.ensure();
|
|
15
|
+
console.log(JSON.stringify(await runtime.status(), null, 2));
|
|
16
|
+
} else throw Error("Use setup, start, status, or stop");
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error(error.message);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
package/scripts/seed.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
import bcrypt from "bcryptjs";
|
|
3
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
4
|
+
if (
|
|
5
|
+
!process.env.ADMIN_EMAIL ||
|
|
6
|
+
(process.env.ADMIN_PASSWORD?.length || 0) < 12 ||
|
|
7
|
+
(process.env.AGENT_TOKEN?.length || 0) < 32
|
|
8
|
+
)
|
|
9
|
+
throw Error(
|
|
10
|
+
"Set ADMIN_EMAIL, ADMIN_PASSWORD (12+ characters), and AGENT_TOKEN (32+ characters).",
|
|
11
|
+
);
|
|
12
|
+
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
13
|
+
await client.connect();
|
|
14
|
+
try {
|
|
15
|
+
await client.query(
|
|
16
|
+
"INSERT INTO users(id,email,password_hash,role) VALUES($1,$2,$3,$4) ON CONFLICT(email) DO UPDATE SET password_hash=$3",
|
|
17
|
+
[
|
|
18
|
+
randomUUID(),
|
|
19
|
+
process.env.ADMIN_EMAIL.toLowerCase(),
|
|
20
|
+
await bcrypt.hash(process.env.ADMIN_PASSWORD, 12),
|
|
21
|
+
"admin",
|
|
22
|
+
],
|
|
23
|
+
);
|
|
24
|
+
await client.query(
|
|
25
|
+
"INSERT INTO agents(id,name,token_hash) VALUES($1,$2,$3) ON CONFLICT(name) DO UPDATE SET token_hash=$3",
|
|
26
|
+
[
|
|
27
|
+
randomUUID(),
|
|
28
|
+
"primary",
|
|
29
|
+
createHash("sha256").update(process.env.AGENT_TOKEN).digest("hex"),
|
|
30
|
+
],
|
|
31
|
+
);
|
|
32
|
+
console.log("Administrator and primary agent ready.");
|
|
33
|
+
} finally {
|
|
34
|
+
await client.end();
|
|
35
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir, stat, rename, writeFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
|
|
9
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
|
|
11
|
+
function portOpen(port) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const socket = net.createConnection({ host: "127.0.0.1", port });
|
|
14
|
+
const finish = (open) => {
|
|
15
|
+
socket.destroy();
|
|
16
|
+
resolve(open);
|
|
17
|
+
};
|
|
18
|
+
socket.once("connect", () => finish(true));
|
|
19
|
+
socket.once("error", () => finish(false));
|
|
20
|
+
socket.setTimeout(1500, () => finish(false));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function stackUp() {
|
|
25
|
+
try {
|
|
26
|
+
process.loadEnvFile(path.join(root, ".env"));
|
|
27
|
+
} catch {
|
|
28
|
+
// The launcher creates .env when it is missing.
|
|
29
|
+
}
|
|
30
|
+
const panelPort = Number(process.env.PANEL_PORT || 3000);
|
|
31
|
+
const agentPort = Number(process.env.AGENT_PORT || 4001);
|
|
32
|
+
if (
|
|
33
|
+
!Number.isInteger(panelPort) ||
|
|
34
|
+
!Number.isInteger(agentPort) ||
|
|
35
|
+
panelPort < 1 ||
|
|
36
|
+
agentPort < 1
|
|
37
|
+
)
|
|
38
|
+
return false;
|
|
39
|
+
return (await portOpen(panelPort)) && (await portOpen(agentPort));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function killTree(pid) {
|
|
43
|
+
if (!pid) return;
|
|
44
|
+
if (process.platform === "win32") {
|
|
45
|
+
const killer = spawn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
|
|
46
|
+
windowsHide: true,
|
|
47
|
+
stdio: "ignore",
|
|
48
|
+
});
|
|
49
|
+
killer.once("error", () => {});
|
|
50
|
+
killer.unref();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, "SIGTERM");
|
|
55
|
+
} catch {
|
|
56
|
+
// The child has already exited.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function supervise(options = {}) {
|
|
61
|
+
const log = options.log ?? (() => {});
|
|
62
|
+
const spawnChild = options.spawnChild;
|
|
63
|
+
const healthy = options.stackUp ?? stackUp;
|
|
64
|
+
const sleep =
|
|
65
|
+
options.sleep ??
|
|
66
|
+
(async (ms, shouldStop) => {
|
|
67
|
+
const step = 200;
|
|
68
|
+
for (let waited = 0; waited < ms && !shouldStop(); waited += step)
|
|
69
|
+
await delay(Math.min(step, ms - waited));
|
|
70
|
+
});
|
|
71
|
+
let stopping = false;
|
|
72
|
+
const shouldStop = options.shouldStop ?? (() => stopping);
|
|
73
|
+
let current = null;
|
|
74
|
+
if (options.hookSignals !== false && !options.shouldStop) {
|
|
75
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
76
|
+
process.on(signal, () => {
|
|
77
|
+
stopping = true;
|
|
78
|
+
current?.kill();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
let backoff = 3000;
|
|
82
|
+
log("Naviyra service supervisor started.");
|
|
83
|
+
while (!shouldStop()) {
|
|
84
|
+
const started = Date.now();
|
|
85
|
+
current = spawnChild();
|
|
86
|
+
const code = await current.exited;
|
|
87
|
+
current = null;
|
|
88
|
+
if (shouldStop()) break;
|
|
89
|
+
const ran = Date.now() - started;
|
|
90
|
+
if (code === 0 && (await healthy())) {
|
|
91
|
+
log("Launcher exited cleanly. Watching until the panel or agent stops.");
|
|
92
|
+
while (!shouldStop() && (await healthy())) await sleep(5000, shouldStop);
|
|
93
|
+
if (shouldStop()) break;
|
|
94
|
+
backoff = 3000;
|
|
95
|
+
log("Panel or agent is not listening. Starting again in 3s.");
|
|
96
|
+
await sleep(3000, shouldStop);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (ran > 60000) backoff = 3000;
|
|
100
|
+
const wait = code === 0 ? Math.max(backoff, 3000) : backoff;
|
|
101
|
+
log(
|
|
102
|
+
`Launcher exited with code ${code}. Restarting in ${Math.round(wait / 1000)}s.`,
|
|
103
|
+
);
|
|
104
|
+
await sleep(wait, shouldStop);
|
|
105
|
+
backoff = Math.min(Math.max(wait, 3000) * 2, 60000);
|
|
106
|
+
}
|
|
107
|
+
log("Naviyra service supervisor stopped.");
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function spawnLauncher(logStream) {
|
|
112
|
+
const child = spawn(
|
|
113
|
+
process.execPath,
|
|
114
|
+
["scripts/start-naviyra.mjs", "--no-browser"],
|
|
115
|
+
{
|
|
116
|
+
cwd: root,
|
|
117
|
+
windowsHide: true,
|
|
118
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
child.stdout?.on("data", (chunk) => logStream.write(chunk));
|
|
122
|
+
child.stderr?.on("data", (chunk) => logStream.write(chunk));
|
|
123
|
+
return {
|
|
124
|
+
exited: new Promise((resolve) => {
|
|
125
|
+
child.once("error", () => resolve(1));
|
|
126
|
+
child.once("exit", (code) => resolve(code ?? 1));
|
|
127
|
+
}),
|
|
128
|
+
kill() {
|
|
129
|
+
killTree(child.pid);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function main() {
|
|
135
|
+
const runtime = path.join(root, ".runtime");
|
|
136
|
+
await mkdir(runtime, { recursive: true });
|
|
137
|
+
const logPath = path.join(runtime, "service.log");
|
|
138
|
+
try {
|
|
139
|
+
if ((await stat(logPath)).size > 2_000_000)
|
|
140
|
+
await rename(logPath, `${logPath}.1`);
|
|
141
|
+
} catch {
|
|
142
|
+
// A missing log is created by the write stream.
|
|
143
|
+
}
|
|
144
|
+
const logStream = createWriteStream(logPath, { flags: "a" });
|
|
145
|
+
const log = (line) => {
|
|
146
|
+
logStream.write(`[${new Date().toISOString()}] ${line}\n`);
|
|
147
|
+
};
|
|
148
|
+
const pidFile = path.join(runtime, "service.pid");
|
|
149
|
+
await writeFile(pidFile, String(process.pid));
|
|
150
|
+
try {
|
|
151
|
+
await supervise({ log, spawnChild: () => spawnLauncher(logStream), stackUp });
|
|
152
|
+
} finally {
|
|
153
|
+
await unlink(pidFile).catch(() => {});
|
|
154
|
+
logStream.end();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
159
|
+
process.chdir(root);
|
|
160
|
+
main().catch((error) => {
|
|
161
|
+
console.error(error.message);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
});
|
|
164
|
+
}
|