@patiom/daemon 0.0.1

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/dist/server.js ADDED
@@ -0,0 +1,651 @@
1
+ import { C as PORT_MIN, S as PORT_MAX, _ as validateToken, c as daemonReload, d as start, f as stop, g as revokeToken, h as listTokens, i as addApp, l as enable, m as hasScope, o as removeApp, p as createToken, t as appServiceTemplate, u as listRunningInstances, x as PATIOM_ROOT, y as APPS_DIR } from "./systemd-C0OpX8Bk.js";
2
+ import { Hono } from "hono";
3
+ import { serve } from "@hono/node-server";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { createMiddleware } from "hono/factory";
7
+ import { ulid } from "ulid";
8
+ import "adm-zip";
9
+ import { execa } from "execa";
10
+ import getPort, { portNumbers } from "get-port";
11
+ import dotenv from "dotenv";
12
+ //#region src/middleware/audit.ts
13
+ const AUDIT_LOG = path.join(PATIOM_ROOT, "audit.log");
14
+ const MAX_SIZE = 10 * 1024 * 1024;
15
+ const rotateIfNeeded = async () => {
16
+ try {
17
+ if ((await fs.stat(AUDIT_LOG)).size > MAX_SIZE) await fs.rename(AUDIT_LOG, `${AUDIT_LOG}.1`);
18
+ } catch {}
19
+ };
20
+ const writeAudit = async (line) => {
21
+ await rotateIfNeeded();
22
+ await fs.appendFile(AUDIT_LOG, `${line}\n`);
23
+ };
24
+ const auditMiddleware = createMiddleware(async (c, next) => {
25
+ await next();
26
+ try {
27
+ const token = c.get("token");
28
+ const tokenDisplay = token ? token.name : "Unknown";
29
+ let line = `${(/* @__PURE__ */ new Date()).toISOString()} [${tokenDisplay}] ${c.req.method} ${c.req.path} ${c.res.status}`;
30
+ const releaseId = c.get("releaseId");
31
+ if (releaseId) line += ` releaseId=${releaseId}`;
32
+ await writeAudit(line);
33
+ } catch (err) {
34
+ console.error("Audit log write failed:", err);
35
+ }
36
+ });
37
+ //#endregion
38
+ //#region src/middleware/auth.ts
39
+ const authMiddleware = createMiddleware(async (c, next) => {
40
+ const authHeader = c.req.header("Authorization");
41
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return c.json({ error: "Unauthorized" }, 401);
42
+ const tokenData = await validateToken(authHeader.slice(7));
43
+ if (!tokenData) return c.json({ error: "Unauthorized" }, 401);
44
+ c.set("token", tokenData);
45
+ await next();
46
+ });
47
+ //#endregion
48
+ //#region src/core/releases.ts
49
+ const getAppDir = (appName) => {
50
+ return path.join(APPS_DIR, appName);
51
+ };
52
+ const getReleasesDir = (appName) => {
53
+ return path.join(getAppDir(appName), "releases");
54
+ };
55
+ const getSharedDir = (appName) => {
56
+ return path.join(getAppDir(appName), "shared");
57
+ };
58
+ const getCurrentSymlink = (appName) => {
59
+ return path.join(getAppDir(appName), "current");
60
+ };
61
+ const createSymlinks = async (appName, releaseId, dbFolder, storageFolder, log) => {
62
+ const releaseDir = path.join(getReleasesDir(appName), releaseId);
63
+ const sharedDir = getSharedDir(appName);
64
+ const dbDir = path.join(sharedDir, dbFolder);
65
+ const storageDir = path.join(sharedDir, storageFolder);
66
+ log(`Ensuring shared directories exist: ${dbDir}, ${storageDir}`);
67
+ await fs.mkdir(dbDir, { recursive: true });
68
+ await fs.mkdir(storageDir, { recursive: true });
69
+ const dbSymlink = path.join(releaseDir, dbFolder);
70
+ const storageSymlink = path.join(releaseDir, storageFolder);
71
+ log(`Creating symlink: ${dbSymlink} -> ${dbDir}`);
72
+ await fs.symlink(dbDir, dbSymlink);
73
+ log(`Creating symlink: ${storageSymlink} -> ${storageDir}`);
74
+ await fs.symlink(storageDir, storageSymlink);
75
+ };
76
+ const swapCurrentSymlink = async (appName, releaseId, log) => {
77
+ const releaseDir = path.join(getReleasesDir(appName), releaseId);
78
+ const currentSymlink = getCurrentSymlink(appName);
79
+ const tempSymlink = `${currentSymlink}.tmp`;
80
+ log(`Swapping current symlink to ${releaseId}`);
81
+ await fs.symlink(releaseDir, tempSymlink);
82
+ await fs.rename(tempSymlink, currentSymlink);
83
+ log("Current symlink swapped successfully");
84
+ };
85
+ //#endregion
86
+ //#region src/core/pnpm.ts
87
+ const hasLockfile = async (releaseDir) => {
88
+ try {
89
+ await fs.access(path.join(releaseDir, "pnpm-lock.yaml"));
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ };
95
+ const install = async (releaseDir, log) => {
96
+ const args = await hasLockfile(releaseDir) ? [
97
+ "install",
98
+ "--frozen-lockfile",
99
+ "--prod"
100
+ ] : ["install", "--prod"];
101
+ log(`Running: pnpm ${args.join(" ")}`);
102
+ const proc = execa("pnpm", args, {
103
+ cwd: releaseDir,
104
+ stdout: "pipe",
105
+ stderr: "pipe"
106
+ });
107
+ proc.stdout?.on("data", (data) => log(data.toString().trim()));
108
+ proc.stderr?.on("data", (data) => log(data.toString().trim()));
109
+ await proc;
110
+ log("Dependencies installed successfully");
111
+ };
112
+ //#endregion
113
+ //#region src/core/ports.ts
114
+ const allocatePortBlock = async (instances, log) => {
115
+ log(`Allocating ${instances} contiguous ports...`);
116
+ const port = await getPort({ port: portNumbers(PORT_MIN, PORT_MAX) });
117
+ const ports = Array.from({ length: instances }, (_, i) => port + i);
118
+ if (port + instances - 1 > 51e3) throw new Error(`Could not allocate ${instances} contiguous ports in range ${PORT_MIN}-${PORT_MAX}`);
119
+ log(`Allocated ports: ${ports.join(", ")}`);
120
+ return ports;
121
+ };
122
+ //#endregion
123
+ //#region src/core/env.ts
124
+ const envWriteQueues = /* @__PURE__ */ new Map();
125
+ const getEnvWriteQueue = (appName) => {
126
+ return envWriteQueues.get(appName) ?? Promise.resolve();
127
+ };
128
+ const setEnvWriteQueue = (appName, queue) => {
129
+ envWriteQueues.set(appName, queue);
130
+ };
131
+ const getEnvPath = (appName) => {
132
+ return path.join(getSharedDir(appName), ".env");
133
+ };
134
+ const ensureEnvFile = async (appName, log) => {
135
+ const envPath = getEnvPath(appName);
136
+ try {
137
+ await fs.access(envPath);
138
+ } catch {
139
+ log(`Creating empty .env file: ${envPath}`);
140
+ await fs.writeFile(envPath, "", { mode: 384 });
141
+ log(".env file created");
142
+ }
143
+ };
144
+ const parseEnv = async (envPath) => {
145
+ const content = await fs.readFile(envPath, "utf-8");
146
+ return dotenv.parse(content);
147
+ };
148
+ const serializeEnv = (env) => {
149
+ return Object.entries(env).map(([key, value]) => `${key}=${value}`).join("\n");
150
+ };
151
+ const writeEnvAtomic = async (envPath, content) => {
152
+ const tmpPath = `${envPath}.tmp`;
153
+ await fs.writeFile(tmpPath, content, { mode: 384 });
154
+ await fs.rename(tmpPath, envPath);
155
+ };
156
+ const setEnv = async (appName, key, value, log) => {
157
+ const envPath = getEnvPath(appName);
158
+ const next = getEnvWriteQueue(appName).then(async () => {
159
+ const env = await parseEnv(envPath);
160
+ env[key] = value;
161
+ await writeEnvAtomic(envPath, serializeEnv(env));
162
+ log(`Set ${key} in .env`);
163
+ });
164
+ setEnvWriteQueue(appName, next.catch(() => {}));
165
+ await next;
166
+ };
167
+ const deleteEnv = async (appName, key, log) => {
168
+ const envPath = getEnvPath(appName);
169
+ const next = getEnvWriteQueue(appName).then(async () => {
170
+ const env = await parseEnv(envPath);
171
+ delete env[key];
172
+ await writeEnvAtomic(envPath, serializeEnv(env));
173
+ log(`Deleted ${key} from .env`);
174
+ });
175
+ setEnvWriteQueue(appName, next.catch(() => {}));
176
+ await next;
177
+ };
178
+ //#endregion
179
+ //#region src/core/storage.ts
180
+ const getStorageDir = (appName, storageFolder) => {
181
+ return path.join(getSharedDir(appName), storageFolder);
182
+ };
183
+ const ensureStorageDir = async (appName, storageFolder, log) => {
184
+ const storageDir = getStorageDir(appName, storageFolder);
185
+ log(`Ensuring storage directory exists: ${storageDir}`);
186
+ await fs.mkdir(storageDir, { recursive: true });
187
+ log("Storage directory ready");
188
+ };
189
+ //#endregion
190
+ //#region src/core/logs.ts
191
+ const getLogPath = (appName, releaseId) => {
192
+ return path.join(getReleasesDir(appName), releaseId, "deploy.log");
193
+ };
194
+ const writeLog = async (appName, releaseId, msg) => {
195
+ const logPath = getLogPath(appName, releaseId);
196
+ await fs.appendFile(logPath, `${msg}\n`);
197
+ };
198
+ const readLog = async (appName, releaseId, offset = 0) => {
199
+ const logPath = getLogPath(appName, releaseId);
200
+ const statusPath = path.join(getReleasesDir(appName), releaseId, "status");
201
+ try {
202
+ const lines = (await fs.readFile(logPath, "utf-8")).split("\n").filter((line) => line.length > 0).slice(offset);
203
+ let done = false;
204
+ let status;
205
+ try {
206
+ const statusValue = (await fs.readFile(statusPath, "utf-8")).trim();
207
+ done = statusValue === "complete" || statusValue === "failed";
208
+ if (done) status = statusValue;
209
+ } catch {}
210
+ return {
211
+ lines,
212
+ nextOffset: offset + lines.length,
213
+ done,
214
+ status
215
+ };
216
+ } catch {
217
+ return {
218
+ lines: [],
219
+ nextOffset: offset,
220
+ done: false
221
+ };
222
+ }
223
+ };
224
+ //#endregion
225
+ //#region src/middleware/scope.ts
226
+ const requireScope = (scope) => {
227
+ return createMiddleware(async (c, next) => {
228
+ if (!hasScope(c.get("token").scope, scope)) return c.json({ error: `Forbidden: requires ${scope} scope` }, 403);
229
+ await next();
230
+ });
231
+ };
232
+ //#endregion
233
+ //#region src/core/validation.ts
234
+ const SAFE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u;
235
+ const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/u;
236
+ const validateAppName = (name) => {
237
+ if (!name || !SAFE_NAME_PATTERN.test(name)) throw new Error("Invalid app name. Use only letters, numbers, hyphens, underscores, and dots.");
238
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) throw new Error("Invalid app name. Path traversal characters are not allowed.");
239
+ };
240
+ const validateReleaseId = (releaseId) => {
241
+ if (!releaseId || !ULID_PATTERN.test(releaseId)) throw new Error("Invalid release ID. Must be a 26-character ULID.");
242
+ };
243
+ const validateDbName = (name) => {
244
+ if (!name || !SAFE_NAME_PATTERN.test(name)) throw new Error("Invalid database name. Use only letters, numbers, hyphens, underscores, and dots.");
245
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) throw new Error("Invalid database name. Path traversal characters are not allowed.");
246
+ };
247
+ const validateEnvKey = (key) => {
248
+ if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error("Invalid environment variable key. Must start with a letter or underscore and contain only letters, numbers, and underscores.");
249
+ };
250
+ //#endregion
251
+ //#region src/routes/deploy.ts
252
+ const deployRoute = new Hono();
253
+ const UNIT_FILE_PATH = "/etc/systemd/system";
254
+ const activeDeploys = /* @__PURE__ */ new Set();
255
+ const getStatusPath = (appName, releaseId) => {
256
+ return path.join(getReleasesDir(appName), releaseId, "status");
257
+ };
258
+ const writeStatus = async (appName, releaseId, status) => {
259
+ await fs.writeFile(getStatusPath(appName, releaseId), status);
260
+ };
261
+ const readStatus = async (appName, releaseId) => {
262
+ try {
263
+ return (await fs.readFile(getStatusPath(appName, releaseId), "utf-8")).trim();
264
+ } catch {
265
+ return null;
266
+ }
267
+ };
268
+ const getStartScript = async (releaseDir) => {
269
+ const pkgPath = path.join(releaseDir, "package.json");
270
+ const pkg = JSON.parse(await fs.readFile(pkgPath, "utf-8"));
271
+ if (pkg.scripts?.patiom) return "patiom";
272
+ if (pkg.scripts?.start) return "start";
273
+ throw new Error("No start script found (scripts.patiom or scripts.start)");
274
+ };
275
+ const writeUnitFile = async (appName, startScript) => {
276
+ const content = appServiceTemplate({
277
+ nodeBinPath: path.dirname(process.execPath),
278
+ startScript
279
+ });
280
+ const unitPath = path.join(UNIT_FILE_PATH, `${appName}@.service`);
281
+ await fs.writeFile(unitPath, content);
282
+ };
283
+ const manageInstances = async (appName, ports, log) => {
284
+ log("Detecting running instances...");
285
+ const oldPorts = await listRunningInstances(appName);
286
+ log(`Found ${oldPorts.length} running instance(s)`);
287
+ log("Reloading systemd daemon...");
288
+ await daemonReload();
289
+ log("Enabling and starting new instances...");
290
+ await Promise.all(ports.map(async (port) => {
291
+ await enable(`${appName}@${port}`);
292
+ await start(`${appName}@${port}`);
293
+ }));
294
+ const portsToStop = oldPorts.filter((port) => !ports.includes(parseInt(port, 10)));
295
+ if (portsToStop.length > 0) {
296
+ log(`Stopping ${portsToStop.length} old instance(s)...`);
297
+ await Promise.all(portsToStop.map(async (port) => {
298
+ try {
299
+ await stop(`${appName}@${port}`);
300
+ } catch {}
301
+ }));
302
+ }
303
+ };
304
+ const buildSslipDomain = async (appName) => {
305
+ try {
306
+ return `${appName}.${(await fs.readFile(path.join(PATIOM_ROOT, "ip"), "utf-8")).trim().replaceAll(".", "-")}.sslip.io`;
307
+ } catch {
308
+ return null;
309
+ }
310
+ };
311
+ const sanitizeDomain = (domain) => domain.replaceAll(/[^a-zA-Z0-9-]/gu, "-");
312
+ const updateRpxyConfig = async (appName, domains, ports, log) => {
313
+ log("Updating rpxy config...");
314
+ await removeApp(appName);
315
+ for (const domain of domains) {
316
+ await addApp(domains.length > 1 ? `${appName}-${sanitizeDomain(domain)}` : appName, domain, ports);
317
+ log(`Added domain: ${domain}`);
318
+ }
319
+ };
320
+ const parseDeployRequest = (formData) => {
321
+ const zipFile = formData.get("zip");
322
+ const name = formData.get("name");
323
+ const type = formData.get("type");
324
+ const domainsJson = formData.get("domains");
325
+ const sslipDomain = formData.get("sslipDomain") === "true";
326
+ const instancesRaw = formData.get("instances");
327
+ const instances = instancesRaw ? parseInt(instancesRaw, 10) || 1 : 1;
328
+ const dbFolder = formData.get("dbFolder") || "db";
329
+ const storageFolder = formData.get("storageFolder") || "storage";
330
+ if (!zipFile || !name) throw new Error("Missing required fields: zip, name");
331
+ const domains = JSON.parse(domainsJson || "[]");
332
+ if (domains.length === 0 && !sslipDomain) throw new Error("Must specify at least one of `domains` or `sslipDomain: true`");
333
+ return {
334
+ zipFile,
335
+ name,
336
+ type,
337
+ domains,
338
+ sslipDomain,
339
+ instances,
340
+ dbFolder,
341
+ storageFolder
342
+ };
343
+ };
344
+ const executeDeploy = async (name, releaseId, zipBuffer, domains, sslipDomain, instances, dbFolder, storageFolder) => {
345
+ const log = (msg) => writeLog(name, releaseId, msg);
346
+ await log(`Starting deployment for ${name}...`);
347
+ await log("Extracting archive...");
348
+ const releaseDir = path.join(getReleasesDir(name), releaseId);
349
+ const AdmZip = (await import("adm-zip")).default;
350
+ new AdmZip(zipBuffer).extractAllTo(releaseDir, true);
351
+ await log("Creating symlinks...");
352
+ await createSymlinks(name, releaseId, dbFolder, storageFolder, log);
353
+ await log("Ensuring .env file exists...");
354
+ await ensureEnvFile(name, log);
355
+ await log("Ensuring storage directory exists...");
356
+ await ensureStorageDir(name, storageFolder, log);
357
+ await log("Installing dependencies...");
358
+ await install(releaseDir, log);
359
+ await log("Detecting start script...");
360
+ const startScript = await getStartScript(releaseDir);
361
+ await log(`Using start script: pnpm run ${startScript}`);
362
+ await log("Writing systemd unit file...");
363
+ await writeUnitFile(name, startScript);
364
+ await log("Allocating ports...");
365
+ const ports = await allocatePortBlock(instances, log);
366
+ await log("Swapping current symlink...");
367
+ await swapCurrentSymlink(name, releaseId, log);
368
+ await log("Managing systemd instances...");
369
+ await manageInstances(name, ports, log);
370
+ const allDomains = [...domains];
371
+ if (sslipDomain) {
372
+ const sslip = await buildSslipDomain(name);
373
+ if (sslip) allDomains.push(sslip);
374
+ }
375
+ if (allDomains.length > 0) await updateRpxyConfig(name, allDomains, ports, log);
376
+ await log(`Deployment complete!`);
377
+ await log(`Domains: ${allDomains.join(", ") || "none"}`);
378
+ await log(`Ports: ${ports.join(", ")}`);
379
+ return {
380
+ releaseId,
381
+ domains: allDomains,
382
+ ports
383
+ };
384
+ };
385
+ deployRoute.post("/", requireScope("rw"), async (c) => {
386
+ let formData;
387
+ try {
388
+ formData = await c.req.formData();
389
+ } catch {
390
+ return c.json({ error: "Invalid request body" }, 400);
391
+ }
392
+ let parsed;
393
+ try {
394
+ parsed = await parseDeployRequest(formData);
395
+ } catch (err) {
396
+ return c.json({ error: err instanceof Error ? err.message : "Invalid request" }, 400);
397
+ }
398
+ const { zipFile, name, domains, sslipDomain, instances, dbFolder, storageFolder } = parsed;
399
+ try {
400
+ validateAppName(name);
401
+ } catch (err) {
402
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
403
+ }
404
+ if (activeDeploys.has(name)) return c.json({ error: "Deployment already in progress for this app" }, 409);
405
+ const releaseId = ulid();
406
+ c.set("releaseId", releaseId);
407
+ const releaseDir = path.join(getReleasesDir(name), releaseId);
408
+ await fs.mkdir(releaseDir, { recursive: true });
409
+ await writeStatus(name, releaseId, "running");
410
+ let zipBuffer;
411
+ try {
412
+ zipBuffer = Buffer.from(await zipFile.arrayBuffer());
413
+ } catch (error) {
414
+ await writeLog(name, releaseId, `Deployment failed: ${error instanceof Error ? error.message : String(error)}`);
415
+ await writeStatus(name, releaseId, "failed");
416
+ return c.json({ error: "Failed to read upload" }, 500);
417
+ }
418
+ activeDeploys.add(name);
419
+ executeDeploy(name, releaseId, zipBuffer, domains, sslipDomain, instances, dbFolder, storageFolder).then(() => writeStatus(name, releaseId, "complete")).catch(async (error) => {
420
+ await writeLog(name, releaseId, `Deployment failed: ${error instanceof Error ? error.message : String(error)}`);
421
+ await writeStatus(name, releaseId, "failed");
422
+ }).finally(() => {
423
+ activeDeploys.delete(name);
424
+ });
425
+ return c.json({ releaseId });
426
+ });
427
+ deployRoute.get("/:name/:releaseId/status", async (c) => {
428
+ const name = c.req.param("name");
429
+ const releaseId = c.req.param("releaseId");
430
+ try {
431
+ validateAppName(name);
432
+ validateReleaseId(releaseId);
433
+ } catch (err) {
434
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
435
+ }
436
+ const status = await readStatus(name, releaseId);
437
+ if (!status) return c.json({ error: "Release not found" }, 404);
438
+ return c.json({ status });
439
+ });
440
+ //#endregion
441
+ //#region src/routes/health.ts
442
+ const healthRoute = new Hono();
443
+ healthRoute.get("/", (c) => {
444
+ return c.json({
445
+ status: "ok",
446
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
447
+ });
448
+ });
449
+ //#endregion
450
+ //#region src/routes/env.ts
451
+ const envRoute = new Hono();
452
+ const log$1 = (msg) => console.log(msg);
453
+ envRoute.post("/", requireScope("rw"), async (c) => {
454
+ const { appName, key, value } = await c.req.json();
455
+ if (!appName || !key || value === void 0) return c.json({ error: "Missing required fields: appName, key, value" }, 400);
456
+ try {
457
+ validateAppName(appName);
458
+ validateEnvKey(key);
459
+ } catch (err) {
460
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
461
+ }
462
+ await setEnv(appName, key, value, log$1);
463
+ return c.json({
464
+ success: true,
465
+ key
466
+ });
467
+ });
468
+ envRoute.delete("/:key", requireScope("rw"), async (c) => {
469
+ const appName = c.req.query("appName");
470
+ const key = c.req.param("key");
471
+ if (!appName || !key) return c.json({ error: "Missing required fields: appName, key" }, 400);
472
+ try {
473
+ validateAppName(appName);
474
+ validateEnvKey(key);
475
+ } catch (err) {
476
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
477
+ }
478
+ await deleteEnv(appName, key, log$1);
479
+ return c.json({
480
+ success: true,
481
+ key
482
+ });
483
+ });
484
+ //#endregion
485
+ //#region src/core/db.ts
486
+ const listDbs = async (appName) => {
487
+ const sharedDir = getSharedDir(appName);
488
+ try {
489
+ const entries = await fs.readdir(sharedDir);
490
+ return (await Promise.all(entries.map(async (entry) => {
491
+ const entryPath = path.join(sharedDir, entry);
492
+ return {
493
+ entry,
494
+ isDirectory: (await fs.stat(entryPath)).isDirectory()
495
+ };
496
+ }))).filter(({ isDirectory }) => isDirectory).map(({ entry }) => entry);
497
+ } catch {
498
+ return [];
499
+ }
500
+ };
501
+ const addDb = async (appName, name, log) => {
502
+ const sharedDir = getSharedDir(appName);
503
+ const dbDir = path.join(sharedDir, name);
504
+ log(`Creating database folder: ${dbDir}`);
505
+ await fs.mkdir(dbDir, { recursive: true });
506
+ log(`Database ${name} created`);
507
+ };
508
+ const removeDb = async (appName, name, log) => {
509
+ const sharedDir = getSharedDir(appName);
510
+ const dbDir = path.join(sharedDir, name);
511
+ log(`Removing database folder: ${dbDir}`);
512
+ await fs.rm(dbDir, {
513
+ recursive: true,
514
+ force: true
515
+ });
516
+ log(`Database ${name} removed`);
517
+ };
518
+ //#endregion
519
+ //#region src/routes/db.ts
520
+ const dbRoute = new Hono();
521
+ const log = (msg) => console.log(msg);
522
+ dbRoute.get("/", async (c) => {
523
+ const appName = c.req.query("appName");
524
+ if (!appName) return c.json({ error: "Missing required field: appName" }, 400);
525
+ try {
526
+ validateAppName(appName);
527
+ } catch (err) {
528
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
529
+ }
530
+ const dbs = await listDbs(appName);
531
+ return c.json(dbs);
532
+ });
533
+ dbRoute.post("/", requireScope("rw"), async (c) => {
534
+ const { appName, name } = await c.req.json();
535
+ if (!appName || !name) return c.json({ error: "Missing required fields: appName, name" }, 400);
536
+ try {
537
+ validateAppName(appName);
538
+ validateDbName(name);
539
+ } catch (err) {
540
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
541
+ }
542
+ await addDb(appName, name, log);
543
+ return c.json({
544
+ success: true,
545
+ name
546
+ });
547
+ });
548
+ dbRoute.delete("/:name", requireScope("rw"), async (c) => {
549
+ const appName = c.req.query("appName");
550
+ const name = c.req.param("name");
551
+ if (!appName || !name) return c.json({ error: "Missing required fields: appName, name" }, 400);
552
+ try {
553
+ validateAppName(appName);
554
+ validateDbName(name);
555
+ } catch (err) {
556
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
557
+ }
558
+ await removeDb(appName, name, log);
559
+ return c.json({
560
+ success: true,
561
+ name
562
+ });
563
+ });
564
+ //#endregion
565
+ //#region src/routes/apps.ts
566
+ const appsRoute = new Hono();
567
+ appsRoute.get("/", async (c) => {
568
+ try {
569
+ const entries = await fs.readdir(APPS_DIR);
570
+ const apps = (await Promise.all(entries.map(async (entry) => {
571
+ const entryPath = `${APPS_DIR}/${entry}`;
572
+ return {
573
+ name: entry,
574
+ isDirectory: (await fs.stat(entryPath)).isDirectory()
575
+ };
576
+ }))).filter(({ isDirectory }) => isDirectory).map(({ name }) => name);
577
+ return c.json(apps);
578
+ } catch {
579
+ return c.json([]);
580
+ }
581
+ });
582
+ //#endregion
583
+ //#region src/routes/logs.ts
584
+ const logsRoute = new Hono();
585
+ logsRoute.get("/:name/:releaseId", async (c) => {
586
+ const name = c.req.param("name");
587
+ const releaseId = c.req.param("releaseId");
588
+ const offset = parseInt(c.req.query("offset") || "0", 10);
589
+ try {
590
+ validateAppName(name);
591
+ validateReleaseId(releaseId);
592
+ } catch (err) {
593
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
594
+ }
595
+ const result = await readLog(name, releaseId, offset);
596
+ return c.json(result);
597
+ });
598
+ //#endregion
599
+ //#region src/routes/tokens.ts
600
+ const tokensRoute = new Hono();
601
+ tokensRoute.post("/", async (c) => {
602
+ if (c.get("token").scope !== "master") return c.json({ error: "Forbidden: master scope required" }, 403);
603
+ const { name, scope } = await c.req.json();
604
+ if (!name || !scope) return c.json({ error: "Missing required fields: name, scope" }, 400);
605
+ if (!["rw", "ro"].includes(scope)) return c.json({ error: "Invalid scope. Must be 'rw' or 'ro'" }, 400);
606
+ const newToken = await createToken(name, scope);
607
+ return c.json({
608
+ success: true,
609
+ token: newToken.token,
610
+ warning: "Save this token now. It will not be shown again."
611
+ });
612
+ });
613
+ tokensRoute.get("/", async (c) => {
614
+ if (c.get("token").scope !== "master") return c.json({ error: "Forbidden: master scope required" }, 403);
615
+ const tokens = await listTokens();
616
+ return c.json(tokens);
617
+ });
618
+ tokensRoute.delete("/:id", async (c) => {
619
+ if (c.get("token").scope !== "master") return c.json({ error: "Forbidden: master scope required" }, 403);
620
+ const result = await revokeToken(c.req.param("id"));
621
+ if (!result.success) {
622
+ const status = result.error === "Token not found" ? 404 : 400;
623
+ return c.json({ error: result.error }, status);
624
+ }
625
+ return c.json({ success: true });
626
+ });
627
+ //#endregion
628
+ //#region src/server.ts
629
+ const app = new Hono();
630
+ app.onError((err, c) => {
631
+ console.error("Unhandled error:", err);
632
+ return c.json({ error: "Internal server error" }, 500);
633
+ });
634
+ app.notFound((c) => {
635
+ return c.json({ error: "Not found" }, 404);
636
+ });
637
+ app.route("/health", healthRoute);
638
+ app.use("*", auditMiddleware);
639
+ app.use("*", authMiddleware);
640
+ app.route("/deploy", deployRoute);
641
+ app.route("/env", envRoute);
642
+ app.route("/db", dbRoute);
643
+ app.route("/apps", appsRoute);
644
+ app.route("/logs", logsRoute);
645
+ app.route("/tokens", tokensRoute);
646
+ serve({
647
+ fetch: app.fetch,
648
+ port: Number(process.env.PORT) || 4e3
649
+ });
650
+ //#endregion
651
+ export {};
package/dist/setup.js ADDED
@@ -0,0 +1,189 @@
1
+ import { a as createAcmeConfig, c as daemonReload, d as start, l as enable, n as daemonServiceTemplate, r as rpxyServiceTemplate, s as writeConfig, v as writeTokens, x as PATIOM_ROOT } from "./systemd-C0OpX8Bk.js";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { ulid } from "ulid";
6
+ import { execa } from "execa";
7
+ import { confirm, input } from "@inquirer/prompts";
8
+ import { consola } from "consola";
9
+ //#region src/setup.ts
10
+ const DAEMON_PORT = 4e3;
11
+ const DAEMON_BIN_PATH = "/opt/patiom/daemon/dist/server.js";
12
+ const checkRoot = () => {
13
+ if (process.getuid?.() !== 0) {
14
+ consola.error("This script must be run as root. Try: sudo patiom-server setup");
15
+ process.exit(1);
16
+ }
17
+ };
18
+ const detectOS = async () => {
19
+ try {
20
+ return (await fs.readFile("/etc/os-release", "utf-8")).match(/^ID=(.+)$/mu)?.[1]?.trim().replaceAll(/^["']|["']$/gu, "") ?? "unknown";
21
+ } catch {
22
+ return "unknown";
23
+ }
24
+ };
25
+ const detectIP = async () => {
26
+ try {
27
+ const { stdout } = await execa("curl", ["-s", "https://api.ipify.org"]);
28
+ return stdout.trim();
29
+ } catch {
30
+ consola.warn("Could not detect public IP");
31
+ return "unknown";
32
+ }
33
+ };
34
+ const configureFirewall = async (os, port) => {
35
+ if (!await confirm({
36
+ message: "Configure firewall?",
37
+ default: true
38
+ })) {
39
+ consola.info("Skipping firewall configuration");
40
+ return;
41
+ }
42
+ if (os === "ubuntu" || os === "debian") {
43
+ consola.start("Configuring UFW...");
44
+ await execa("ufw", [
45
+ "default",
46
+ "deny",
47
+ "incoming"
48
+ ]);
49
+ await execa("ufw", [
50
+ "default",
51
+ "allow",
52
+ "outgoing"
53
+ ]);
54
+ await execa("ufw", ["allow", "22/tcp"]);
55
+ await execa("ufw", ["allow", "80/tcp"]);
56
+ await execa("ufw", ["allow", "443/tcp"]);
57
+ await execa("ufw", ["allow", `${port}/tcp`]);
58
+ await execa("ufw", ["--force", "enable"]);
59
+ consola.success("UFW configured");
60
+ } else if ([
61
+ "almalinux",
62
+ "rocky",
63
+ "centos",
64
+ "fedora",
65
+ "rhel"
66
+ ].includes(os)) {
67
+ consola.start("Configuring Firewalld...");
68
+ await execa("systemctl", [
69
+ "enable",
70
+ "--now",
71
+ "firewalld"
72
+ ]);
73
+ await execa("firewall-cmd", [
74
+ "--permanent",
75
+ "--zone=public",
76
+ "--add-port=22/tcp"
77
+ ]);
78
+ await execa("firewall-cmd", [
79
+ "--permanent",
80
+ "--zone=public",
81
+ "--add-port=80/tcp"
82
+ ]);
83
+ await execa("firewall-cmd", [
84
+ "--permanent",
85
+ "--zone=public",
86
+ "--add-port=443/tcp"
87
+ ]);
88
+ await execa("firewall-cmd", [
89
+ "--permanent",
90
+ "--zone=public",
91
+ "--add-port",
92
+ `${port}/tcp`
93
+ ]);
94
+ await execa("firewall-cmd", ["--reload"]);
95
+ await execa("setsebool", [
96
+ "-P",
97
+ "httpd_can_network_connect",
98
+ "1"
99
+ ]);
100
+ consola.success("Firewalld configured");
101
+ } else consola.warn(`Unsupported OS for firewall: ${os}`);
102
+ };
103
+ const configureACME = async () => {
104
+ return { email: await input({
105
+ message: "Email for Let's Encrypt certificates:",
106
+ validate: (v) => v.includes("@") ? true : "Please enter a valid email"
107
+ }) };
108
+ };
109
+ const setupPatiomDirs = async () => {
110
+ await fs.mkdir(PATIOM_ROOT, { recursive: true });
111
+ await fs.mkdir(path.join(PATIOM_ROOT, "apps"), { recursive: true });
112
+ await fs.mkdir(path.join(PATIOM_ROOT, "acme_registry"), { recursive: true });
113
+ };
114
+ const generateMasterToken = async () => {
115
+ const token = {
116
+ id: ulid(),
117
+ name: "Master Token",
118
+ token: crypto.randomBytes(16).toString("hex"),
119
+ scope: "master",
120
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
121
+ };
122
+ await writeTokens({ tokens: [token] });
123
+ return token.token;
124
+ };
125
+ const writeIP = async (ip) => {
126
+ const ipPath = path.join(PATIOM_ROOT, "ip");
127
+ await fs.writeFile(ipPath, ip);
128
+ };
129
+ const writeSystemdUnit = async (name, content) => {
130
+ const unitPath = `/etc/systemd/system/${name}.service`;
131
+ await fs.writeFile(unitPath, content);
132
+ };
133
+ const installServices = async (nodeBinPath) => {
134
+ await writeSystemdUnit("rpxy", rpxyServiceTemplate({ rpxyBinPath: "/usr/local/bin/rpxy" }));
135
+ await writeSystemdUnit("patiom-daemon", daemonServiceTemplate({
136
+ nodeBinPath,
137
+ daemonBinPath: DAEMON_BIN_PATH,
138
+ port: DAEMON_PORT
139
+ }));
140
+ await daemonReload();
141
+ await enable("rpxy");
142
+ await start("rpxy");
143
+ consola.success("rpxy started");
144
+ await enable("patiom-daemon");
145
+ await start("patiom-daemon");
146
+ consola.success("Patiom daemon started");
147
+ };
148
+ const setup = async () => {
149
+ console.log("");
150
+ consola.info("Patiom Server Setup");
151
+ console.log("");
152
+ checkRoot();
153
+ const os = await detectOS();
154
+ consola.info(`Detected OS: ${os}`);
155
+ if (os === "unknown") {
156
+ consola.error("Unsupported OS. Please use Ubuntu, Debian, AlmaLinux, Rocky, CentOS, Fedora, or RHEL.");
157
+ process.exit(1);
158
+ }
159
+ await configureFirewall(os, DAEMON_PORT);
160
+ console.log("");
161
+ const { email } = await configureACME();
162
+ console.log("");
163
+ consola.start("Setting up Patiom...");
164
+ await setupPatiomDirs();
165
+ const token = await generateMasterToken();
166
+ consola.success("Auth token generated");
167
+ const ip = await detectIP();
168
+ await writeIP(ip);
169
+ consola.success(`Public IP: ${ip}`);
170
+ await writeConfig(createAcmeConfig(email));
171
+ consola.success("rpxy config written");
172
+ await installServices(path.dirname(process.execPath));
173
+ console.log("");
174
+ consola.success("Patiom server setup complete!");
175
+ console.log("");
176
+ console.log(`Auth token: ${token}`);
177
+ console.log("");
178
+ consola.info("Next steps:");
179
+ console.log(` patiom login --url http://${ip}:${DAEMON_PORT} --token ${token}`);
180
+ console.log("");
181
+ };
182
+ try {
183
+ await setup();
184
+ } catch (err) {
185
+ consola.error("Setup failed:", err);
186
+ process.exit(1);
187
+ }
188
+ //#endregion
189
+ export {};
@@ -0,0 +1,227 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { ulid } from "ulid";
5
+ import { execa } from "execa";
6
+ import { parse, stringify } from "smol-toml";
7
+ //#region src/config.ts
8
+ const PATIOM_ROOT = "/var/lib/patiom";
9
+ const APPS_DIR = path.join(PATIOM_ROOT, "apps");
10
+ path.join(PATIOM_ROOT, "ip");
11
+ const PORT_MIN = 5e4;
12
+ const PORT_MAX = 51e3;
13
+ const DEFAULT_STORAGE_FOLDER = "storage";
14
+ //#endregion
15
+ //#region src/core/tokens.ts
16
+ const TOKENS_FILE = path.join(PATIOM_ROOT, "tokens.json");
17
+ let tokensWriteQueue = Promise.resolve();
18
+ const writeTokensAtomic = async (config) => {
19
+ const tmpPath = `${TOKENS_FILE}.tmp`;
20
+ await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), { mode: 384 });
21
+ await fs.rename(tmpPath, TOKENS_FILE);
22
+ };
23
+ const readTokens = async () => {
24
+ try {
25
+ const content = await fs.readFile(TOKENS_FILE, "utf-8");
26
+ return JSON.parse(content);
27
+ } catch {
28
+ return { tokens: [] };
29
+ }
30
+ };
31
+ const writeTokens = async (config) => {
32
+ await writeTokensAtomic(config);
33
+ };
34
+ const createToken = async (name, scope) => {
35
+ const token = {
36
+ id: ulid(),
37
+ name,
38
+ token: crypto.randomBytes(16).toString("hex"),
39
+ scope,
40
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
41
+ };
42
+ const next = tokensWriteQueue.then(async () => {
43
+ const config = await readTokens();
44
+ config.tokens.push(token);
45
+ await writeTokensAtomic(config);
46
+ });
47
+ tokensWriteQueue = next.catch(() => {});
48
+ await next;
49
+ return token;
50
+ };
51
+ const listTokens = async () => {
52
+ return (await readTokens()).tokens.map(({ token, ...rest }) => ({
53
+ ...rest,
54
+ last8: token.slice(-8)
55
+ }));
56
+ };
57
+ const revokeToken = async (id) => {
58
+ let result = {
59
+ success: false,
60
+ error: "Token not found"
61
+ };
62
+ const next = tokensWriteQueue.then(async () => {
63
+ const config = await readTokens();
64
+ const token = config.tokens.find((t) => t.id === id);
65
+ if (!token) {
66
+ result = {
67
+ success: false,
68
+ error: "Token not found"
69
+ };
70
+ return;
71
+ }
72
+ if (token.scope === "master") {
73
+ result = {
74
+ success: false,
75
+ error: "Cannot revoke master token"
76
+ };
77
+ return;
78
+ }
79
+ config.tokens = config.tokens.filter((t) => t.id !== id);
80
+ await writeTokensAtomic(config);
81
+ result = { success: true };
82
+ });
83
+ tokensWriteQueue = next.catch(() => {});
84
+ await next;
85
+ return result;
86
+ };
87
+ const validateToken = async (token) => {
88
+ return (await readTokens()).tokens.find((t) => t.token === token) ?? null;
89
+ };
90
+ const hasScope = (tokenScope, requiredScope) => {
91
+ if (tokenScope === "master") return true;
92
+ if (tokenScope === "rw" && requiredScope === "ro") return true;
93
+ return tokenScope === requiredScope;
94
+ };
95
+ //#endregion
96
+ //#region src/core/systemd.ts
97
+ const daemonReload = () => execa("systemctl", ["daemon-reload"]);
98
+ const enable = (name) => execa("systemctl", ["enable", name]);
99
+ const start = (name) => execa("systemctl", ["start", name]);
100
+ const stop = (name) => execa("systemctl", ["stop", name]);
101
+ const listRunningInstances = async (appName) => {
102
+ try {
103
+ const { stdout } = await execa("systemctl", [
104
+ "list-units",
105
+ "--type=service",
106
+ "--state=running",
107
+ "--no-pager",
108
+ "--plain",
109
+ `${appName}@*`
110
+ ]);
111
+ return stdout.split("\n").filter((line) => line.includes(`${appName}@`)).map((line) => {
112
+ const match = line.match(`${appName}@([0-9]+)\\.service`);
113
+ return match ? match[1] : null;
114
+ }).filter((port) => port !== null);
115
+ } catch {
116
+ return [];
117
+ }
118
+ };
119
+ //#endregion
120
+ //#region src/core/proxy.ts
121
+ const CONFIG_PATH = "/etc/rpxy/config.toml";
122
+ let configWriteQueue = Promise.resolve();
123
+ const writeConfigAtomic = async (config) => {
124
+ const toml = stringify(config);
125
+ const tmpPath = `${CONFIG_PATH}.tmp`;
126
+ await fs.writeFile(tmpPath, toml);
127
+ await fs.rename(tmpPath, CONFIG_PATH);
128
+ };
129
+ const readConfig = async () => {
130
+ return parse(await fs.readFile(CONFIG_PATH, "utf-8"));
131
+ };
132
+ const writeConfig = async (config) => {
133
+ await writeConfigAtomic(config);
134
+ };
135
+ const addApp = async (appName, serverName, ports) => {
136
+ const next = configWriteQueue.then(async () => {
137
+ const config = await readConfig();
138
+ const upstreams = ports.map((port) => ({ location: `127.0.0.1:${port}` }));
139
+ config.apps[appName] = {
140
+ server_name: serverName,
141
+ tls: {
142
+ https_redirection: true,
143
+ acme: true
144
+ },
145
+ reverse_proxy: [{
146
+ upstream: upstreams,
147
+ load_balance: "round_robin"
148
+ }]
149
+ };
150
+ await writeConfigAtomic(config);
151
+ });
152
+ configWriteQueue = next.catch(() => {});
153
+ await next;
154
+ };
155
+ const removeApp = async (appName) => {
156
+ const next = configWriteQueue.then(async () => {
157
+ const config = await readConfig();
158
+ const prefix = `${appName}-`;
159
+ config.apps = Object.fromEntries(Object.entries(config.apps).filter(([key]) => key !== appName && !key.startsWith(prefix)));
160
+ await writeConfigAtomic(config);
161
+ });
162
+ configWriteQueue = next.catch(() => {});
163
+ await next;
164
+ };
165
+ const createAcmeConfig = (email) => {
166
+ return {
167
+ listen_port: 80,
168
+ listen_port_tls: 443,
169
+ experimental: { acme: {
170
+ dir_url: "https://acme-v02.api.letsencrypt.org/directory",
171
+ email,
172
+ registry_path: "/var/lib/patiom/acme_registry"
173
+ } },
174
+ apps: {}
175
+ };
176
+ };
177
+ //#endregion
178
+ //#region src/templates/systemd.ts
179
+ const appServiceTemplate = ({ nodeBinPath, startScript }) => `[Unit]
180
+ Description=Patiom App: %p (port %i)
181
+ After=network.target
182
+
183
+ [Service]
184
+ Type=exec
185
+ WorkingDirectory=${PATIOM_ROOT}/apps/%p/current
186
+ ExecStart=${nodeBinPath}/pnpm run ${startScript}
187
+ Restart=always
188
+ EnvironmentFile=${PATIOM_ROOT}/apps/%p/shared/.env
189
+ Environment=PORT=%i
190
+ Environment=PATH=${nodeBinPath}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
191
+
192
+ DynamicUser=yes
193
+ ProtectSystem=strict
194
+ ProtectHome=yes
195
+ ReadWritePaths=${PATIOM_ROOT}/apps/%p
196
+
197
+ [Install]
198
+ WantedBy=multi-user.target
199
+ `;
200
+ const rpxyServiceTemplate = ({ rpxyBinPath }) => `[Unit]
201
+ Description=rpxy Reverse Proxy
202
+ After=network.target
203
+
204
+ [Service]
205
+ ExecStart=${rpxyBinPath} --config /etc/rpxy/config.toml
206
+ Restart=always
207
+ LimitNOFILE=65536
208
+
209
+ [Install]
210
+ WantedBy=multi-user.target
211
+ `;
212
+ const daemonServiceTemplate = ({ nodeBinPath, daemonBinPath, port }) => `[Unit]
213
+ Description=Patiom Daemon
214
+ After=network.target
215
+
216
+ [Service]
217
+ Type=exec
218
+ ExecStart=${nodeBinPath}/node ${daemonBinPath}
219
+ Restart=always
220
+ Environment=PORT=${port}
221
+ Environment=PATH=${nodeBinPath}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
222
+
223
+ [Install]
224
+ WantedBy=multi-user.target
225
+ `;
226
+ //#endregion
227
+ export { PORT_MIN as C, PORT_MAX as S, validateToken as _, createAcmeConfig as a, DEFAULT_STORAGE_FOLDER as b, daemonReload as c, start as d, stop as f, revokeToken as g, listTokens as h, addApp as i, enable as l, hasScope as m, daemonServiceTemplate as n, removeApp as o, createToken as p, rpxyServiceTemplate as r, writeConfig as s, appServiceTemplate as t, listRunningInstances as u, writeTokens as v, PATIOM_ROOT as x, APPS_DIR as y };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@patiom/daemon",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "dependencies": {
9
+ "@hono/node-server": "^2.0.4",
10
+ "@inquirer/prompts": "^7.8.2",
11
+ "adm-zip": "^0.5.17",
12
+ "consola": "^3.4.2",
13
+ "dotenv": "^16.4.7",
14
+ "execa": "^9.6.1",
15
+ "get-port": "^7.2.0",
16
+ "hono": "^4.12.23",
17
+ "smol-toml": "^1.6.1",
18
+ "ulid": "^3.0.2"
19
+ },
20
+ "devDependencies": {
21
+ "@types/adm-zip": "^0.5.8",
22
+ "@types/node": "^25.9.1",
23
+ "tsdown": "^0.12.0",
24
+ "tsx": "^4.22.4",
25
+ "typescript": "^5.8.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=20.0.0"
29
+ },
30
+ "license": "ISC",
31
+ "scripts": {
32
+ "dev": "tsx watch src/server.ts",
33
+ "build": "tsdown"
34
+ }
35
+ }