@patiom/daemon 0.0.1 → 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.
@@ -1,14 +1,29 @@
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";
1
+ import { consola } from "consola";
2
+ import { program } from "commander";
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { execa } from "execa";
2
6
  import { Hono } from "hono";
3
7
  import { serve } from "@hono/node-server";
4
8
  import fs from "node:fs/promises";
5
- import path from "node:path";
6
9
  import { createMiddleware } from "hono/factory";
10
+ import crypto from "node:crypto";
7
11
  import { ulid } from "ulid";
8
12
  import "adm-zip";
9
- import { execa } from "execa";
10
13
  import getPort, { portNumbers } from "get-port";
14
+ import { parse, stringify } from "smol-toml";
11
15
  import dotenv from "dotenv";
16
+ import { confirm, input } from "@inquirer/prompts";
17
+ //#region package.json
18
+ var version = "0.0.2";
19
+ //#endregion
20
+ //#region src/config.ts
21
+ const PATIOM_ROOT = "/var/lib/patiom";
22
+ const APPS_DIR = path.join(PATIOM_ROOT, "apps");
23
+ path.join(PATIOM_ROOT, "ip");
24
+ const PORT_MIN = 5e4;
25
+ const PORT_MAX = 51e3;
26
+ //#endregion
12
27
  //#region src/middleware/audit.ts
13
28
  const AUDIT_LOG = path.join(PATIOM_ROOT, "audit.log");
14
29
  const MAX_SIZE = 10 * 1024 * 1024;
@@ -35,6 +50,87 @@ const auditMiddleware = createMiddleware(async (c, next) => {
35
50
  }
36
51
  });
37
52
  //#endregion
53
+ //#region src/core/tokens.ts
54
+ const TOKENS_FILE = path.join(PATIOM_ROOT, "tokens.json");
55
+ let tokensWriteQueue = Promise.resolve();
56
+ const writeTokensAtomic = async (config) => {
57
+ const tmpPath = `${TOKENS_FILE}.tmp`;
58
+ await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), { mode: 384 });
59
+ await fs.rename(tmpPath, TOKENS_FILE);
60
+ };
61
+ const readTokens = async () => {
62
+ try {
63
+ const content = await fs.readFile(TOKENS_FILE, "utf-8");
64
+ return JSON.parse(content);
65
+ } catch {
66
+ return { tokens: [] };
67
+ }
68
+ };
69
+ const writeTokens = async (config) => {
70
+ await writeTokensAtomic(config);
71
+ };
72
+ const createToken = async (name, scope) => {
73
+ const token = {
74
+ id: ulid(),
75
+ name,
76
+ token: crypto.randomBytes(16).toString("hex"),
77
+ scope,
78
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
79
+ };
80
+ const next = tokensWriteQueue.then(async () => {
81
+ const config = await readTokens();
82
+ config.tokens.push(token);
83
+ await writeTokensAtomic(config);
84
+ });
85
+ tokensWriteQueue = next.catch(() => {});
86
+ await next;
87
+ return token;
88
+ };
89
+ const listTokens = async () => {
90
+ return (await readTokens()).tokens.map(({ token, ...rest }) => ({
91
+ ...rest,
92
+ last8: token.slice(-8)
93
+ }));
94
+ };
95
+ const revokeToken = async (id) => {
96
+ let result = {
97
+ success: false,
98
+ error: "Token not found"
99
+ };
100
+ const next = tokensWriteQueue.then(async () => {
101
+ const config = await readTokens();
102
+ const token = config.tokens.find((t) => t.id === id);
103
+ if (!token) {
104
+ result = {
105
+ success: false,
106
+ error: "Token not found"
107
+ };
108
+ return;
109
+ }
110
+ if (token.scope === "master") {
111
+ result = {
112
+ success: false,
113
+ error: "Cannot revoke master token"
114
+ };
115
+ return;
116
+ }
117
+ config.tokens = config.tokens.filter((t) => t.id !== id);
118
+ await writeTokensAtomic(config);
119
+ result = { success: true };
120
+ });
121
+ tokensWriteQueue = next.catch(() => {});
122
+ await next;
123
+ return result;
124
+ };
125
+ const validateToken = async (token) => {
126
+ return (await readTokens()).tokens.find((t) => t.token === token) ?? null;
127
+ };
128
+ const hasScope = (tokenScope, requiredScope) => {
129
+ if (tokenScope === "master") return true;
130
+ if (tokenScope === "rw" && requiredScope === "ro") return true;
131
+ return tokenScope === requiredScope;
132
+ };
133
+ //#endregion
38
134
  //#region src/middleware/auth.ts
39
135
  const authMiddleware = createMiddleware(async (c, next) => {
40
136
  const authHeader = c.req.header("Authorization");
@@ -120,6 +216,89 @@ const allocatePortBlock = async (instances, log) => {
120
216
  return ports;
121
217
  };
122
218
  //#endregion
219
+ //#region src/core/systemd.ts
220
+ const daemonReload = () => execa("systemctl", ["daemon-reload"]);
221
+ const enable = (name) => execa("systemctl", ["enable", name]);
222
+ const start = (name) => execa("systemctl", ["start", name]);
223
+ const stop = (name) => execa("systemctl", ["stop", name]);
224
+ const restart = (name) => execa("systemctl", ["restart", name]);
225
+ const listRunningInstances = async (appName) => {
226
+ try {
227
+ const { stdout } = await execa("systemctl", [
228
+ "list-units",
229
+ "--type=service",
230
+ "--state=running",
231
+ "--no-pager",
232
+ "--plain",
233
+ `${appName}@*`
234
+ ]);
235
+ return stdout.split("\n").filter((line) => line.includes(`${appName}@`)).map((line) => {
236
+ const match = line.match(`${appName}@([0-9]+)\\.service`);
237
+ return match ? match[1] : null;
238
+ }).filter((port) => port !== null);
239
+ } catch {
240
+ return [];
241
+ }
242
+ };
243
+ //#endregion
244
+ //#region src/core/proxy.ts
245
+ const CONFIG_PATH = "/etc/rpxy/config.toml";
246
+ let configWriteQueue = Promise.resolve();
247
+ const writeConfigAtomic = async (config) => {
248
+ const toml = stringify(config);
249
+ const tmpPath = `${CONFIG_PATH}.tmp`;
250
+ await fs.writeFile(tmpPath, toml);
251
+ await fs.rename(tmpPath, CONFIG_PATH);
252
+ };
253
+ const readConfig = async () => {
254
+ return parse(await fs.readFile(CONFIG_PATH, "utf-8"));
255
+ };
256
+ const writeConfig = async (config) => {
257
+ await writeConfigAtomic(config);
258
+ };
259
+ const addApp = async (appName, serverName, ports) => {
260
+ const next = configWriteQueue.then(async () => {
261
+ const config = await readConfig();
262
+ const upstreams = ports.map((port) => ({ location: `127.0.0.1:${port}` }));
263
+ config.apps[appName] = {
264
+ server_name: serverName,
265
+ tls: {
266
+ https_redirection: true,
267
+ acme: true
268
+ },
269
+ reverse_proxy: [{
270
+ upstream: upstreams,
271
+ load_balance: "round_robin"
272
+ }]
273
+ };
274
+ await writeConfigAtomic(config);
275
+ });
276
+ configWriteQueue = next.catch(() => {});
277
+ await next;
278
+ };
279
+ const removeApp = async (appName) => {
280
+ const next = configWriteQueue.then(async () => {
281
+ const config = await readConfig();
282
+ const prefix = `${appName}-`;
283
+ config.apps = Object.fromEntries(Object.entries(config.apps).filter(([key]) => key !== appName && !key.startsWith(prefix)));
284
+ await writeConfigAtomic(config);
285
+ });
286
+ configWriteQueue = next.catch(() => {});
287
+ await next;
288
+ };
289
+ const createAcmeConfig = (email) => {
290
+ return {
291
+ listen_port: 80,
292
+ listen_port_tls: 443,
293
+ experimental: { acme: {
294
+ dir_url: "https://acme-v02.api.letsencrypt.org/directory",
295
+ email,
296
+ registry_path: "/var/lib/patiom/acme_registry"
297
+ } },
298
+ apps: {}
299
+ };
300
+ };
301
+ //#endregion
123
302
  //#region src/core/env.ts
124
303
  const envWriteQueues = /* @__PURE__ */ new Map();
125
304
  const getEnvWriteQueue = (appName) => {
@@ -187,6 +366,55 @@ const ensureStorageDir = async (appName, storageFolder, log) => {
187
366
  log("Storage directory ready");
188
367
  };
189
368
  //#endregion
369
+ //#region src/templates/systemd.ts
370
+ const appServiceTemplate = ({ nodeBinPath, startScript }) => `[Unit]
371
+ Description=Patiom App: %p (port %i)
372
+ After=network.target
373
+
374
+ [Service]
375
+ Type=exec
376
+ WorkingDirectory=${PATIOM_ROOT}/apps/%p/current
377
+ ExecStart=${nodeBinPath}/pnpm run ${startScript}
378
+ Restart=always
379
+ EnvironmentFile=${PATIOM_ROOT}/apps/%p/shared/.env
380
+ Environment=PORT=%i
381
+ Environment=PATH=${nodeBinPath}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
382
+
383
+ DynamicUser=yes
384
+ ProtectSystem=strict
385
+ ProtectHome=yes
386
+ ReadWritePaths=${PATIOM_ROOT}/apps/%p
387
+
388
+ [Install]
389
+ WantedBy=multi-user.target
390
+ `;
391
+ const rpxyServiceTemplate = ({ rpxyBinPath }) => `[Unit]
392
+ Description=rpxy Reverse Proxy
393
+ After=network.target
394
+
395
+ [Service]
396
+ ExecStart=${rpxyBinPath} --config /etc/rpxy/config.toml
397
+ Restart=always
398
+ LimitNOFILE=65536
399
+
400
+ [Install]
401
+ WantedBy=multi-user.target
402
+ `;
403
+ const daemonServiceTemplate = ({ nodeBinPath, port }) => `[Unit]
404
+ Description=Patiom Daemon
405
+ After=network.target
406
+
407
+ [Service]
408
+ Type=exec
409
+ ExecStart=${nodeBinPath}/patiom-server serve
410
+ Restart=always
411
+ Environment=PORT=${port}
412
+ Environment=PATH=${nodeBinPath}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
413
+
414
+ [Install]
415
+ WantedBy=multi-user.target
416
+ `;
417
+ //#endregion
190
418
  //#region src/core/logs.ts
191
419
  const getLogPath = (appName, releaseId) => {
192
420
  return path.join(getReleasesDir(appName), releaseId, "deploy.log");
@@ -643,9 +871,238 @@ app.route("/db", dbRoute);
643
871
  app.route("/apps", appsRoute);
644
872
  app.route("/logs", logsRoute);
645
873
  app.route("/tokens", tokensRoute);
646
- serve({
647
- fetch: app.fetch,
648
- port: Number(process.env.PORT) || 4e3
874
+ const startServer = () => {
875
+ serve({
876
+ fetch: app.fetch,
877
+ port: Number(process.env.PORT) || 4e3
878
+ });
879
+ };
880
+ //#endregion
881
+ //#region src/setup.ts
882
+ const DAEMON_PORT = 4e3;
883
+ const detectOS = async () => {
884
+ try {
885
+ return (await fs.readFile("/etc/os-release", "utf-8")).match(/^ID=(.+)$/mu)?.[1]?.trim().replaceAll(/^["']|["']$/gu, "") ?? "unknown";
886
+ } catch {
887
+ return "unknown";
888
+ }
889
+ };
890
+ const detectIP = async () => {
891
+ try {
892
+ const { stdout } = await execa("curl", ["-s", "https://api.ipify.org"]);
893
+ return stdout.trim();
894
+ } catch {
895
+ consola.warn("Could not detect public IP");
896
+ return "unknown";
897
+ }
898
+ };
899
+ const configureFirewall = async (os, port) => {
900
+ if (!await confirm({
901
+ message: "Configure firewall?",
902
+ default: true
903
+ })) {
904
+ consola.info("Skipping firewall configuration");
905
+ return;
906
+ }
907
+ if (os === "ubuntu" || os === "debian") {
908
+ consola.start("Configuring UFW...");
909
+ await execa("ufw", [
910
+ "default",
911
+ "deny",
912
+ "incoming"
913
+ ]);
914
+ await execa("ufw", [
915
+ "default",
916
+ "allow",
917
+ "outgoing"
918
+ ]);
919
+ await execa("ufw", ["allow", "22/tcp"]);
920
+ await execa("ufw", ["allow", "80/tcp"]);
921
+ await execa("ufw", ["allow", "443/tcp"]);
922
+ await execa("ufw", ["allow", `${port}/tcp`]);
923
+ await execa("ufw", ["--force", "enable"]);
924
+ consola.success("UFW configured");
925
+ } else if ([
926
+ "almalinux",
927
+ "rocky",
928
+ "centos",
929
+ "fedora",
930
+ "rhel"
931
+ ].includes(os)) {
932
+ consola.start("Configuring Firewalld...");
933
+ await execa("systemctl", [
934
+ "enable",
935
+ "--now",
936
+ "firewalld"
937
+ ]);
938
+ await execa("firewall-cmd", [
939
+ "--permanent",
940
+ "--zone=public",
941
+ "--add-port=22/tcp"
942
+ ]);
943
+ await execa("firewall-cmd", [
944
+ "--permanent",
945
+ "--zone=public",
946
+ "--add-port=80/tcp"
947
+ ]);
948
+ await execa("firewall-cmd", [
949
+ "--permanent",
950
+ "--zone=public",
951
+ "--add-port=443/tcp"
952
+ ]);
953
+ await execa("firewall-cmd", [
954
+ "--permanent",
955
+ "--zone=public",
956
+ "--add-port",
957
+ `${port}/tcp`
958
+ ]);
959
+ await execa("firewall-cmd", ["--reload"]);
960
+ await execa("setsebool", [
961
+ "-P",
962
+ "httpd_can_network_connect",
963
+ "1"
964
+ ]);
965
+ consola.success("Firewalld configured");
966
+ } else consola.warn(`Unsupported OS for firewall: ${os}`);
967
+ };
968
+ const configureACME = async () => {
969
+ return { email: await input({
970
+ message: "Email for Let's Encrypt certificates:",
971
+ validate: (v) => v.includes("@") ? true : "Please enter a valid email"
972
+ }) };
973
+ };
974
+ const setupPatiomDirs = async () => {
975
+ await fs.mkdir(PATIOM_ROOT, { recursive: true });
976
+ await fs.mkdir(path.join(PATIOM_ROOT, "apps"), { recursive: true });
977
+ await fs.mkdir(path.join(PATIOM_ROOT, "acme_registry"), { recursive: true });
978
+ };
979
+ const generateMasterToken = async () => {
980
+ const token = {
981
+ id: ulid(),
982
+ name: "Master Token",
983
+ token: crypto.randomBytes(16).toString("hex"),
984
+ scope: "master",
985
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
986
+ };
987
+ await writeTokens({ tokens: [token] });
988
+ return token.token;
989
+ };
990
+ const writeIP = async (ip) => {
991
+ const ipPath = path.join(PATIOM_ROOT, "ip");
992
+ await fs.writeFile(ipPath, ip);
993
+ };
994
+ const writeSystemdUnit = async (name, content) => {
995
+ const unitPath = `/etc/systemd/system/${name}.service`;
996
+ await fs.writeFile(unitPath, content);
997
+ };
998
+ const installServices = async (nodeBinPath) => {
999
+ await writeSystemdUnit("rpxy", rpxyServiceTemplate({ rpxyBinPath: "/usr/local/bin/rpxy" }));
1000
+ await writeSystemdUnit("patiom-daemon", daemonServiceTemplate({
1001
+ nodeBinPath,
1002
+ port: DAEMON_PORT
1003
+ }));
1004
+ await daemonReload();
1005
+ await enable("rpxy");
1006
+ await start("rpxy");
1007
+ consola.success("rpxy started");
1008
+ await enable("patiom-daemon");
1009
+ await start("patiom-daemon");
1010
+ consola.success("Patiom daemon started");
1011
+ };
1012
+ const setup = async () => {
1013
+ console.log("");
1014
+ consola.info("Patiom Server Setup");
1015
+ console.log("");
1016
+ const os = await detectOS();
1017
+ consola.info(`Detected OS: ${os}`);
1018
+ if (os === "unknown") {
1019
+ consola.error("Unsupported OS. Please use Ubuntu, Debian, AlmaLinux, Rocky, CentOS, Fedora, or RHEL.");
1020
+ process.exit(1);
1021
+ }
1022
+ await configureFirewall(os, DAEMON_PORT);
1023
+ console.log("");
1024
+ const { email } = await configureACME();
1025
+ console.log("");
1026
+ consola.start("Setting up Patiom...");
1027
+ await setupPatiomDirs();
1028
+ const token = await generateMasterToken();
1029
+ consola.success("Auth token generated");
1030
+ const ip = await detectIP();
1031
+ await writeIP(ip);
1032
+ consola.success(`Public IP: ${ip}`);
1033
+ await writeConfig(createAcmeConfig(email));
1034
+ consola.success("rpxy config written");
1035
+ await installServices(path.dirname(process.execPath));
1036
+ console.log("");
1037
+ consola.success("Patiom server setup complete!");
1038
+ console.log("");
1039
+ console.log(`Auth token: ${token}`);
1040
+ console.log("");
1041
+ consola.info("Next steps:");
1042
+ console.log(` patiom login --url http://${ip}:${DAEMON_PORT} --token ${token}`);
1043
+ console.log("");
1044
+ };
1045
+ const runSetup = async () => {
1046
+ try {
1047
+ await setup();
1048
+ } catch (err) {
1049
+ consola.error("Setup failed:", err);
1050
+ process.exit(1);
1051
+ }
1052
+ };
1053
+ //#endregion
1054
+ //#region src/index.ts
1055
+ consola.options.formatOptions = { date: false };
1056
+ const skipRootOpt = "--devSkipRootCheck";
1057
+ const checkRoot = (skip) => {
1058
+ if (skip) return;
1059
+ if (process.getuid?.() !== 0) {
1060
+ consola.error("This command must be run as root. Try: sudo patiom-server <command>");
1061
+ process.exit(1);
1062
+ }
1063
+ };
1064
+ program.name("patiom-server").description("Patiom daemon — bare-metal deployment server");
1065
+ program.command("serve").description("Start the daemon HTTP server").option(skipRootOpt, "Skip root check for development").action((options) => {
1066
+ checkRoot(options.devSkipRootCheck);
1067
+ startServer();
1068
+ });
1069
+ program.command("setup").description("Interactive first-time server setup").option(skipRootOpt, "Skip root check for development").action((options) => {
1070
+ checkRoot(options.devSkipRootCheck);
1071
+ return runSetup();
1072
+ });
1073
+ program.command("upgrade").description("Update the daemon package and restart the service").option(skipRootOpt, "Skip root check for development").action(async (options) => {
1074
+ checkRoot(options.devSkipRootCheck);
1075
+ consola.info(`Current version: ${version}`);
1076
+ let latest = null;
1077
+ try {
1078
+ const { stdout } = await execa("pnpm", [
1079
+ "info",
1080
+ "@patiom/daemon",
1081
+ "version"
1082
+ ]);
1083
+ latest = stdout.trim();
1084
+ } catch {
1085
+ consola.warn("Could not check npm registry, proceeding with update...");
1086
+ }
1087
+ if (latest && latest === version) {
1088
+ consola.success("Already up to date");
1089
+ return;
1090
+ }
1091
+ if (latest) consola.info(`Latest version: ${latest}`);
1092
+ consola.start("Updating @patiom/daemon...");
1093
+ await execa("pnpm", [
1094
+ "update",
1095
+ "-g",
1096
+ "@patiom/daemon"
1097
+ ]);
1098
+ const pkgPath = path.resolve(import.meta.dirname, "..", "package.json");
1099
+ const newVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
1100
+ consola.success(`Updated to ${newVersion}`);
1101
+ consola.start("Restarting patiom-daemon service...");
1102
+ await restart("patiom-daemon");
1103
+ consola.success("Daemon restarted");
649
1104
  });
1105
+ program.version(version);
1106
+ program.parse();
650
1107
  //#endregion
651
1108
  export {};
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@patiom/daemon",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
+ "bin": {
6
+ "patiom-server": "./dist/index.js"
7
+ },
5
8
  "files": [
6
9
  "dist"
7
10
  ],
@@ -9,6 +12,7 @@
9
12
  "@hono/node-server": "^2.0.4",
10
13
  "@inquirer/prompts": "^7.8.2",
11
14
  "adm-zip": "^0.5.17",
15
+ "commander": "^15.0.0",
12
16
  "consola": "^3.4.2",
13
17
  "dotenv": "^16.4.7",
14
18
  "execa": "^9.6.1",
@@ -25,11 +29,11 @@
25
29
  "typescript": "^5.8.0"
26
30
  },
27
31
  "engines": {
28
- "node": ">=20.0.0"
32
+ "node": ">=20.11.0"
29
33
  },
30
34
  "license": "ISC",
31
35
  "scripts": {
32
- "dev": "tsx watch src/server.ts",
36
+ "dev": "tsx watch src/index.ts serve --devSkipRootCheck",
33
37
  "build": "tsdown"
34
38
  }
35
39
  }
package/dist/setup.js DELETED
@@ -1,189 +0,0 @@
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 {};
@@ -1,227 +0,0 @@
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 };