@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 +651 -0
- package/dist/setup.js +189 -0
- package/dist/systemd-C0OpX8Bk.js +227 -0
- package/package.json +35 -0
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
|
+
}
|