levix-bot 2.0.0

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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/SETUP.md +242 -0
  4. package/app.cjs +337 -0
  5. package/bin/levix.js +7 -0
  6. package/deploy/install.sh +182 -0
  7. package/deploy/levix.service +56 -0
  8. package/deploy/nginx/levix.leviro.net.conf +79 -0
  9. package/package.json +78 -0
  10. package/public/brand/apple-touch-icon.png +0 -0
  11. package/public/brand/banner.jpg +0 -0
  12. package/public/brand/banner.webp +0 -0
  13. package/public/brand/icon-16.png +0 -0
  14. package/public/brand/icon-192.png +0 -0
  15. package/public/brand/icon-32.png +0 -0
  16. package/public/brand/icon-512.png +0 -0
  17. package/public/brand/icon-512.webp +0 -0
  18. package/public/brand/mark.png +0 -0
  19. package/public/brand/mark.svg +16 -0
  20. package/public/brand/mark.webp +0 -0
  21. package/public/brand/wordmark.png +0 -0
  22. package/public/brand/wordmark.webp +0 -0
  23. package/public/dashboard.css +881 -0
  24. package/public/dashboard.js +1057 -0
  25. package/public/logo.svg +16 -0
  26. package/public/qrcode.min.js +1 -0
  27. package/public/socket.io.min.js +7 -0
  28. package/scheduler.cjs +197 -0
  29. package/src/auth/auth-storage.cjs +74 -0
  30. package/src/auth/use-database-auth-state.js +130 -0
  31. package/src/bootstrap/core.js +74 -0
  32. package/src/bootstrap/events.cjs +43 -0
  33. package/src/bootstrap/panel.js +97 -0
  34. package/src/cli.js +153 -0
  35. package/src/commands/autoschedule.cjs +84 -0
  36. package/src/commands/blacklist.cjs +116 -0
  37. package/src/commands/block.cjs +70 -0
  38. package/src/commands/calc.cjs +387 -0
  39. package/src/commands/debt.cjs +175 -0
  40. package/src/commands/deleteschedule.cjs +36 -0
  41. package/src/commands/gemini.cjs +727 -0
  42. package/src/commands/group/add.cjs +136 -0
  43. package/src/commands/group/all.cjs +43 -0
  44. package/src/commands/group/antiSpam.cjs +96 -0
  45. package/src/commands/group/antilink.cjs +167 -0
  46. package/src/commands/group/approveall.cjs +54 -0
  47. package/src/commands/group/clearwarns.cjs +41 -0
  48. package/src/commands/group/deletenote.cjs +33 -0
  49. package/src/commands/group/demote.cjs +62 -0
  50. package/src/commands/group/kick.cjs +85 -0
  51. package/src/commands/group/media.cjs +120 -0
  52. package/src/commands/group/members.cjs +59 -0
  53. package/src/commands/group/note.cjs +32 -0
  54. package/src/commands/group/promote.cjs +60 -0
  55. package/src/commands/group/removeall.cjs +42 -0
  56. package/src/commands/group/rules.cjs +31 -0
  57. package/src/commands/group/save.cjs +36 -0
  58. package/src/commands/group/setname.cjs +41 -0
  59. package/src/commands/group/setpp.cjs +51 -0
  60. package/src/commands/group/setrules.cjs +46 -0
  61. package/src/commands/group/setwarn.cjs +58 -0
  62. package/src/commands/group/tagadmins.cjs +50 -0
  63. package/src/commands/group/warn.cjs +105 -0
  64. package/src/commands/group/warns.cjs +66 -0
  65. package/src/commands/group/welcome.cjs +109 -0
  66. package/src/commands/group.cjs +160 -0
  67. package/src/commands/help.cjs +230 -0
  68. package/src/commands/listschedules.cjs +47 -0
  69. package/src/commands/loop.cjs +163 -0
  70. package/src/commands/memory.cjs +201 -0
  71. package/src/commands/mod.cjs +155 -0
  72. package/src/commands/notes.cjs +28 -0
  73. package/src/commands/perm.cjs +236 -0
  74. package/src/commands/ping.cjs +17 -0
  75. package/src/commands/poll.cjs +80 -0
  76. package/src/commands/prayer.cjs +64 -0
  77. package/src/commands/qr.cjs +39 -0
  78. package/src/commands/rand.cjs +130 -0
  79. package/src/commands/restart.cjs +25 -0
  80. package/src/commands/schedule.cjs +86 -0
  81. package/src/commands/score.cjs +130 -0
  82. package/src/commands/setprefix.cjs +40 -0
  83. package/src/commands/shortlink.cjs +74 -0
  84. package/src/commands/shutdown.cjs +45 -0
  85. package/src/commands/status.cjs +75 -0
  86. package/src/commands/stt.cjs +150 -0
  87. package/src/commands/todo.cjs +88 -0
  88. package/src/commands/tts.cjs +170 -0
  89. package/src/commands/unblock.cjs +57 -0
  90. package/src/commands/weather.cjs +79 -0
  91. package/src/config/ai-persona.md +17 -0
  92. package/src/config/baileys.config.js +51 -0
  93. package/src/config/brand.cjs +36 -0
  94. package/src/config/brand.esm.js +17 -0
  95. package/src/config/constants.js +16 -0
  96. package/src/config/defaults.cjs +91 -0
  97. package/src/config/lock.cjs +110 -0
  98. package/src/config/paths.cjs +90 -0
  99. package/src/config/runtime-config.cjs +219 -0
  100. package/src/config/secrets.cjs +130 -0
  101. package/src/config/settings.cjs +386 -0
  102. package/src/core/connection.js +199 -0
  103. package/src/core/events.js +61 -0
  104. package/src/core/socket.js +64 -0
  105. package/src/db/db.cjs +282 -0
  106. package/src/db/store.cjs +837 -0
  107. package/src/db/store.esm.js +17 -0
  108. package/src/domain/caddy.js +120 -0
  109. package/src/domain/command.js +380 -0
  110. package/src/domain/detect.js +173 -0
  111. package/src/domain/nginx.js +138 -0
  112. package/src/domain/system.js +148 -0
  113. package/src/handlers/command.handler.js +414 -0
  114. package/src/handlers/group.handler.js +73 -0
  115. package/src/handlers/message.handler.js +149 -0
  116. package/src/index.js +217 -0
  117. package/src/middleware/antispam.middleware.js +68 -0
  118. package/src/middleware/blacklist.middleware.js +29 -0
  119. package/src/middleware/forward-tracking.middleware.js +49 -0
  120. package/src/middleware/permissions.middleware.js +114 -0
  121. package/src/routes/dashboard.api.esm.js +779 -0
  122. package/src/services/aiAgent.cjs +317 -0
  123. package/src/services/aiTools.cjs +635 -0
  124. package/src/utils/datetime.cjs +61 -0
  125. package/src/utils/lid-helper.esm.js +143 -0
  126. package/src/utils/logger.cjs +72 -0
  127. package/src/utils/memory.cjs +385 -0
  128. package/src/utils/normalizeJid.cjs +65 -0
  129. package/src/utils/normalizeJid.esm.js +37 -0
  130. package/src/utils/openBrowser.cjs +129 -0
  131. package/src/utils/permissions.cjs +154 -0
  132. package/src/utils/permissions.esm.js +375 -0
  133. package/src/utils/recentMessageCache.esm.js +70 -0
  134. package/src/utils/requestOrigin.cjs +102 -0
  135. package/src/utils/sendBotMessage.cjs +43 -0
  136. package/src/utils/sendBotMessage.esm.js +139 -0
  137. package/src/utils/statusMessage.cjs +179 -0
  138. package/src/utils/storage-hub.cjs +49 -0
  139. package/src/utils/storage-hub.esm.js +24 -0
  140. package/src/utils/storage.cjs +80 -0
  141. package/src/utils/storage.esm.js +88 -0
  142. package/src/utils/textDecode.cjs +197 -0
  143. package/src/utils/thumbnail.cjs +591 -0
  144. package/src/utils/typing.esm.js +102 -0
  145. package/views/dashboard.ejs +329 -0
  146. package/views/login.ejs +105 -0
  147. package/views/qr.ejs +108 -0
  148. package/views/setup.ejs +122 -0
@@ -0,0 +1,17 @@
1
+ // ESM face of the store (src/db/store.cjs).
2
+ //
3
+ // store.cjs has to be CommonJS so the command files and the ESM handlers share
4
+ // one singleton; this wrapper is what the ESM side imports for the lifecycle
5
+ // calls. Data access goes through src/utils/storage.esm.js as it always did.
6
+
7
+ import { createRequire } from "module";
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const store = require("./store.cjs");
11
+
12
+ export const initStore = store.initStore;
13
+ export const flushStore = store.flushStore;
14
+ export const isStoreReady = store.isStoreReady;
15
+ export const pendingWrites = store.pendingWrites;
16
+
17
+ export default store;
@@ -0,0 +1,120 @@
1
+ // Caddy: the one web server Levix will install, and only onto a machine that
2
+ // has none.
3
+ //
4
+ // It is the fresh-server choice because it gets and renews the certificate by
5
+ // itself — no certbot step, no cron, nothing for the operator to remember.
6
+ //
7
+ // On a machine that already runs Caddy we add one import line and one site
8
+ // file. We never rewrite somebody's Caddyfile.
9
+
10
+ const IMPORT_LINE = "import /etc/caddy/levix/*.caddy";
11
+ const SITE_DIR = "/etc/caddy/levix";
12
+
13
+ export function renderSite({ domain, port }) {
14
+ return `# Managed by Levix. Rewritten by \`levix domain ${domain}\`.
15
+ ${domain} {
16
+ reverse_proxy 127.0.0.1:${port}
17
+ }
18
+ `;
19
+ }
20
+
21
+ export function sitePath(domain) {
22
+ return `${SITE_DIR}/${domain}.caddy`;
23
+ }
24
+
25
+ /**
26
+ * Add (or update) the Levix site, validate, reload.
27
+ *
28
+ * @returns {{ ok: boolean, path: string, error?: string, reloaded: boolean }}
29
+ */
30
+ export function applySite(system, { domain, port, caddyfile = "/etc/caddy/Caddyfile" }) {
31
+ const path = sitePath(domain);
32
+
33
+ const existed = system.exists(path);
34
+ const previous = existed ? system.readFile(path) : null;
35
+ const hadCaddyfile = system.exists(caddyfile);
36
+ const previousCaddyfile = hadCaddyfile ? system.readFile(caddyfile) : null;
37
+
38
+ system.mkdirp(SITE_DIR);
39
+ system.writeFile(path, renderSite({ domain, port }));
40
+
41
+ // One import line, appended once. Everything else in their Caddyfile is
42
+ // theirs and stays exactly as it is.
43
+ const current = hadCaddyfile ? previousCaddyfile : "";
44
+ if (!current.includes(IMPORT_LINE)) {
45
+ system.writeFile(
46
+ caddyfile,
47
+ `${current}${current.endsWith("\n") || current === "" ? "" : "\n"}\n# Added by Levix — loads its site files.\n${IMPORT_LINE}\n`
48
+ );
49
+ }
50
+
51
+ const restore = () => {
52
+ if (existed) system.writeFile(path, previous);
53
+ else system.remove(path);
54
+ if (hadCaddyfile) system.writeFile(caddyfile, previousCaddyfile);
55
+ else system.remove(caddyfile);
56
+ };
57
+
58
+ const validated = system.run("caddy", ["validate", "--config", caddyfile]);
59
+ if (validated.status !== 0) {
60
+ restore();
61
+ return {
62
+ ok: false,
63
+ path,
64
+ reloaded: false,
65
+ error: (validated.stderr || validated.stdout || "caddy validate failed").trim(),
66
+ };
67
+ }
68
+
69
+ const reload = system.run("systemctl", ["reload", "caddy"]);
70
+ if (reload.status !== 0) {
71
+ return {
72
+ ok: false,
73
+ path,
74
+ reloaded: false,
75
+ error: `configuration is valid but Caddy did not reload: ${(reload.stderr || "").trim()}`,
76
+ };
77
+ }
78
+
79
+ return { ok: true, path, reloaded: true };
80
+ }
81
+
82
+ /**
83
+ * Install Caddy from its official repository. Only ever called after the
84
+ * operator has said yes, and only on a machine with no web server at all.
85
+ */
86
+ export function install(system) {
87
+ if (system.which("caddy")) return { ok: true, skipped: "already installed" };
88
+
89
+ if (system.which("apt-get")) {
90
+ const script = [
91
+ "set -e",
92
+ "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl",
93
+ "curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/gpg.key | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
94
+ "curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt | tee /etc/apt/sources.list.d/caddy-stable.list",
95
+ "apt-get update",
96
+ "apt-get install -y caddy",
97
+ ].join("\n");
98
+ const result = system.run("sh", ["-c", script], { timeoutMs: 300000 });
99
+ if (result.status !== 0) {
100
+ return { ok: false, error: (result.stderr || result.stdout || "").trim() };
101
+ }
102
+ return { ok: true };
103
+ }
104
+
105
+ if (system.which("dnf") || system.which("yum")) {
106
+ const manager = system.which("dnf") ? "dnf" : "yum";
107
+ const result = system.run("sh", ["-c", `${manager} install -y caddy`], { timeoutMs: 300000 });
108
+ if (result.status !== 0) {
109
+ return { ok: false, error: (result.stderr || result.stdout || "").trim() };
110
+ }
111
+ return { ok: true };
112
+ }
113
+
114
+ return {
115
+ ok: false,
116
+ error: "no supported package manager found (apt-get, dnf or yum)",
117
+ };
118
+ }
119
+
120
+ export { IMPORT_LINE, SITE_DIR };
@@ -0,0 +1,380 @@
1
+ // `levix domain [name]`
2
+ //
3
+ // Points a domain at Levix by working with whatever is already on the server.
4
+ // It inspects first, decides second, and asks before it changes anything. On a
5
+ // machine it does not understand it prints the exact reverse-proxy settings a
6
+ // person would need and changes nothing at all.
7
+ //
8
+ // The bot itself never needs root. This command may, and says so when it does.
9
+
10
+ import readline from "node:readline/promises";
11
+ import { createRequire } from "node:module";
12
+
13
+ import { createSystem } from "./system.js";
14
+ import { inspect, classify, checkDns } from "./detect.js";
15
+ import * as nginx from "./nginx.js";
16
+ import * as caddy from "./caddy.js";
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const settings = require("../config/settings.cjs");
20
+ const { inContainer } = require("../utils/openBrowser.cjs");
21
+
22
+ const line = (text = "") => console.log(text);
23
+ const tick = (text) => line(` ✓ ${text}`);
24
+ const cross = (text) => line(` ✗ ${text}`);
25
+
26
+ // A hostname, not a URL and not an IP. Anything else is a typo we can catch
27
+ // before it reaches certbot.
28
+ const DOMAIN_PATTERN = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/i;
29
+
30
+ /** Short, reliable guidance per panel. No panel APIs — just where to click. */
31
+ const PANEL_HINTS = {
32
+ "CloudPanel": 'Sites → Add Site → Reverse Proxy, then "SSL/TLS → Let\'s Encrypt".',
33
+ "Plesk": "Domains → Hosting & DNS → Apache & nginx Settings → additional nginx directives.",
34
+ "cPanel / WHM": "WHM → Apache → Reverse Proxy (or ProxyPass in an include file).",
35
+ "CyberPanel": "Websites → Manage → vHost Conf, then Issue SSL.",
36
+ "aaPanel": "Website → Settings → Reverse proxy, then Let's Encrypt under SSL.",
37
+ "HestiaCP": "Web → Edit domain → Proxy Template, then enable Let's Encrypt.",
38
+ "ISPConfig": "Sites → Options → nginx Directives.",
39
+ "Coolify": "Add a service pointing at the host port, and let Coolify terminate TLS.",
40
+ "Dokploy": "Create an application/domain entry pointing at the host port.",
41
+ "Virtualmin / Webmin": "Services → Configure Website → Edit Directives.",
42
+ };
43
+
44
+ function proxyInstructions(domain, port, { https = "your panel" } = {}) {
45
+ line();
46
+ line(" Configure a reverse proxy with:");
47
+ line();
48
+ line(` Domain: ${domain}`);
49
+ line(` Upstream: http://127.0.0.1:${port}`);
50
+ line(" WebSocket: required (the panel pushes the pairing QR over it)");
51
+ line(` HTTPS: enable from ${https}`);
52
+ line();
53
+ }
54
+
55
+ async function ask(question, { assumeYes }) {
56
+ if (assumeYes) return true;
57
+ if (!process.stdin.isTTY) {
58
+ line();
59
+ line(" Not running interactively. Re-run with --yes to accept, or run it in a terminal.");
60
+ return false;
61
+ }
62
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
63
+ try {
64
+ const answer = (await rl.question(` ${question} [Y/n] `)).trim().toLowerCase();
65
+ return answer === "" || answer === "y" || answer === "yes";
66
+ } finally {
67
+ rl.close();
68
+ }
69
+ }
70
+
71
+ async function askDomain({ assumeYes }) {
72
+ if (assumeYes || !process.stdin.isTTY) return null;
73
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
74
+ try {
75
+ return (await rl.question(" Domain (e.g. bot.example.com): ")).trim();
76
+ } finally {
77
+ rl.close();
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Remember the domain. `proxied` is what decides the security-relevant part:
83
+ * only a proxy we actually put in front justifies trusting one hop of
84
+ * forwarded headers, and only then is it safe to stop listening publicly.
85
+ */
86
+ function persist({ domain, proxied }) {
87
+ settings.set("public_domain", domain);
88
+ if (proxied) {
89
+ // Exactly one hop: the proxy on this machine. See src/utils/requestOrigin.cjs
90
+ // for why this does not weaken the first-run check.
91
+ settings.set("trust_proxy", "1");
92
+ // Nobody should be able to reach the panel past the certificate by asking
93
+ // for the raw port.
94
+ settings.set("bind_address", "127.0.0.1");
95
+ }
96
+ }
97
+
98
+ export async function runDomainCommand({ domain = null, assumeYes = false, system } = {}) {
99
+ const sys = system || createSystem();
100
+ const port = settings.get("port");
101
+
102
+ line();
103
+ line(" Levix — domain setup");
104
+ line();
105
+
106
+ let name = domain || (await askDomain({ assumeYes }));
107
+ if (!name) {
108
+ cross("No domain given. Try: levix domain bot.example.com");
109
+ return 1;
110
+ }
111
+ name = name.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
112
+
113
+ if (!DOMAIN_PATTERN.test(name)) {
114
+ cross(`"${name}" doesn't look like a domain name.`);
115
+ return 1;
116
+ }
117
+
118
+ const report = await inspect(sys, { domain: name, container: inContainer() });
119
+ const decision = classify(report);
120
+
121
+ printFindings(report);
122
+
123
+ switch (decision.action) {
124
+ case "docker":
125
+ line(" Levix is running inside a container.");
126
+ line(" A container cannot — and should not — configure the host's web server.");
127
+ proxyInstructions(name, port, { https: "the host proxy" });
128
+ persist({ domain: name, proxied: false });
129
+ tick(`Saved ${name} as the public address.`);
130
+ return 0;
131
+
132
+ case "panel": {
133
+ line(` Detected: ${decision.target}`);
134
+ line();
135
+ line(" This server appears to be managed by a hosting panel, so Levix will not");
136
+ line(" touch its web server configuration — the panel would overwrite it, or");
137
+ line(" break on it.");
138
+ proxyInstructions(name, port, { https: decision.target });
139
+ const hint = PANEL_HINTS[decision.target];
140
+ if (hint) line(` In ${decision.target}: ${hint}\n`);
141
+ persist({ domain: name, proxied: false });
142
+ tick(`Saved ${name} as the public address.`);
143
+ return 0;
144
+ }
145
+
146
+ case "apache":
147
+ line(" Apache is installed.");
148
+ line();
149
+ line(" Levix does not edit Apache automatically: doing it correctly depends on");
150
+ line(" which modules and vhost layout this server uses, and getting it wrong");
151
+ line(" takes other sites down with it.");
152
+ proxyInstructions(name, port, { https: "certbot --apache" });
153
+ line(" The proxy needs mod_proxy, mod_proxy_http and mod_proxy_wstunnel.");
154
+ line();
155
+ persist({ domain: name, proxied: false });
156
+ return 0;
157
+
158
+ case "blocked":
159
+ cross(`Port 80/443 is in use by ${decision.target || "an unknown service"}.`);
160
+ line();
161
+ line(" Levix will not install or reconfigure a web server on top of something");
162
+ line(" it doesn't recognise — that would take the other service down.");
163
+ proxyInstructions(name, port, { https: "that service" });
164
+ return 1;
165
+
166
+ case "nginx":
167
+ return configureNginx(sys, { name, port, report, assumeYes });
168
+
169
+ case "caddy":
170
+ return configureCaddy(sys, { name, port, report, assumeYes, install: false });
171
+
172
+ case "install-caddy":
173
+ return configureCaddy(sys, { name, port, report, assumeYes, install: true });
174
+
175
+ default:
176
+ cross(`Nothing to do (${decision.reason}).`);
177
+ return 1;
178
+ }
179
+ }
180
+
181
+ function printFindings(report) {
182
+ const found = [];
183
+ if (report.nginx.installed) found.push("nginx");
184
+ if (report.apache.installed) found.push("Apache");
185
+ if (report.caddy.installed) found.push("Caddy");
186
+ if (report.certbot) found.push("certbot");
187
+ if (report.panel) found.push(report.panel);
188
+
189
+ line(` Found: ${found.length ? found.join(", ") : "no web server"}`);
190
+ for (const port of [80, 443]) {
191
+ const state = report.ports[port];
192
+ line(` Port ${port}: ${state.taken ? `in use by ${state.process || "an unknown process"}` : "free"}`);
193
+ }
194
+ line();
195
+ }
196
+
197
+ function requireRoot(report) {
198
+ if (report.root) return true;
199
+ cross("This step edits files under /etc, so it needs root.");
200
+ line();
201
+ line(" Re-run it as: sudo levix domain <name>");
202
+ line();
203
+ return false;
204
+ }
205
+
206
+ async function confirmDns(sys, name, { assumeYes }) {
207
+ const dnsCheck = await checkDns(sys, name);
208
+
209
+ if (dnsCheck.status === "ok") {
210
+ tick("DNS points at this server");
211
+ return true;
212
+ }
213
+
214
+ if (dnsCheck.status === "unknown") {
215
+ line(" ? Could not confirm DNS (no public IP available from here).");
216
+ return ask("Continue anyway?", { assumeYes });
217
+ }
218
+
219
+ const ip = dnsCheck.mine.v4 || dnsCheck.mine.v6 || "<this server's IP>";
220
+ const [subdomain, ...rest] = name.split(".");
221
+ const isApex = rest.length < 2;
222
+
223
+ cross(
224
+ dnsCheck.status === "unresolved"
225
+ ? `${name} does not resolve yet.`
226
+ : `${name} resolves to ${dnsCheck.resolved.v4.join(", ") || dnsCheck.resolved.v6.join(", ")}, not to this server.`
227
+ );
228
+ line();
229
+ line(" Add this DNS record:");
230
+ line();
231
+ line(` Type: A`);
232
+ line(` Name: ${isApex ? "@" : subdomain}`);
233
+ line(` Value: ${ip}`);
234
+ if (dnsCheck.mine.v6) {
235
+ line();
236
+ line(` Type: AAAA`);
237
+ line(` Name: ${isApex ? "@" : subdomain}`);
238
+ line(` Value: ${dnsCheck.mine.v6}`);
239
+ }
240
+ line();
241
+ line(" DNS can take a few minutes. HTTPS can't be issued until it resolves.");
242
+ line();
243
+
244
+ return ask("Continue without HTTPS for now?", { assumeYes });
245
+ }
246
+
247
+ async function configureNginx(sys, { name, port, report, assumeYes }) {
248
+ if (!requireRoot(report)) return 1;
249
+
250
+ const dnsOk = await confirmDns(sys, name, { assumeYes });
251
+ if (!dnsOk) {
252
+ line(" Stopped. Nothing was changed.");
253
+ return 1;
254
+ }
255
+
256
+ line();
257
+ line(` Levix will add one nginx site for ${name} and leave every other site alone.`);
258
+ if (!(await ask("Continue?", { assumeYes }))) {
259
+ line(" Stopped. Nothing was changed.");
260
+ return 1;
261
+ }
262
+
263
+ const applied = nginx.applySite(sys, { domain: name, port });
264
+ if (!applied.ok) {
265
+ cross("nginx rejected the configuration — nothing was reloaded.");
266
+ line();
267
+ line(applied.error.split("\n").map((l) => ` ${l}`).join("\n"));
268
+ line();
269
+ line(" The previous state has been restored.");
270
+ return 1;
271
+ }
272
+
273
+ tick(`nginx site written to ${applied.path}`);
274
+ tick("nginx -t passed");
275
+ tick("nginx reloaded");
276
+
277
+ let https = false;
278
+ if (report.certbot) {
279
+ if (await ask("Issue an HTTPS certificate with certbot?", { assumeYes })) {
280
+ const cert = nginx.ensureCertificate(sys, { domain: name });
281
+ if (cert.ok) {
282
+ https = true;
283
+ tick(cert.skipped ? `HTTPS: ${cert.skipped}` : "HTTPS certificate issued");
284
+ } else {
285
+ cross("certbot failed — the site is up over HTTP.");
286
+ line();
287
+ line(cert.error.split("\n").slice(0, 8).map((l) => ` ${l}`).join("\n"));
288
+ line();
289
+ }
290
+ }
291
+ } else {
292
+ line();
293
+ line(" certbot is not installed, so the site is HTTP only for now.");
294
+ line(" To add HTTPS later:");
295
+ line(" apt install certbot python3-certbot-nginx");
296
+ line(` certbot --nginx -d ${name}`);
297
+ line();
298
+ }
299
+
300
+ persist({ domain: name, proxied: true });
301
+ finalUrl(name, https, { restart: true });
302
+ return 0;
303
+ }
304
+
305
+ async function configureCaddy(sys, { name, port, report, assumeYes, install }) {
306
+ if (!requireRoot(report)) return 1;
307
+
308
+ if (install) {
309
+ line(" No reverse proxy was detected.");
310
+ line(" Caddy is the simplest option here: it gets and renews HTTPS by itself.");
311
+ line();
312
+ if (!(await ask("Install Caddy and configure HTTPS?", { assumeYes }))) {
313
+ line(" Stopped. Nothing was installed.");
314
+ proxyInstructions(name, port, { https: "whatever proxy you prefer" });
315
+ return 1;
316
+ }
317
+
318
+ const dnsOk = await confirmDns(sys, name, { assumeYes });
319
+ if (!dnsOk) {
320
+ line(" Stopped before installing anything.");
321
+ return 1;
322
+ }
323
+
324
+ const installed = caddy.install(sys);
325
+ if (!installed.ok) {
326
+ cross(`Could not install Caddy: ${installed.error}`);
327
+ return 1;
328
+ }
329
+ tick(installed.skipped ? "Caddy was already installed" : "Caddy installed");
330
+ } else {
331
+ line(" Caddy is already running here — Levix will add one site to it.");
332
+ if (!(await ask("Continue?", { assumeYes }))) {
333
+ line(" Stopped. Nothing was changed.");
334
+ return 1;
335
+ }
336
+ const dnsOk = await confirmDns(sys, name, { assumeYes });
337
+ if (!dnsOk) {
338
+ line(" Stopped. Nothing was changed.");
339
+ return 1;
340
+ }
341
+ }
342
+
343
+ const applied = caddy.applySite(sys, {
344
+ domain: name,
345
+ port,
346
+ caddyfile: report.caddy.caddyfile || "/etc/caddy/Caddyfile",
347
+ });
348
+ if (!applied.ok) {
349
+ cross("Caddy rejected the configuration — nothing was reloaded.");
350
+ line();
351
+ line(applied.error.split("\n").map((l) => ` ${l}`).join("\n"));
352
+ line();
353
+ return 1;
354
+ }
355
+
356
+ tick(`Caddy site written to ${applied.path}`);
357
+ tick("caddy validate passed");
358
+ tick("Caddy reloaded");
359
+ tick("HTTPS will be issued automatically on the first request");
360
+
361
+ persist({ domain: name, proxied: true });
362
+ finalUrl(name, true, { restart: true });
363
+ return 0;
364
+ }
365
+
366
+ function finalUrl(name, https, { restart }) {
367
+ tick("Levix proxy settings updated");
368
+ line();
369
+ line(` Levix will be available at:`);
370
+ line();
371
+ line(` ${https ? "https" : "http"}://${name}`);
372
+ line();
373
+ if (restart) {
374
+ line(" Restart Levix for the new bind address to take effect:");
375
+ line(" systemctl restart levix (or stop and start it however you run it)");
376
+ line();
377
+ }
378
+ }
379
+
380
+ export default runDomainCommand;
@@ -0,0 +1,173 @@
1
+ // What is already on this machine, and what that means Levix may do to it.
2
+ //
3
+ // The rule the whole file serves: **Levix integrates with the server it finds;
4
+ // it does not take over the server.** A VPS may be running somebody's shop.
5
+ // Detection therefore never changes anything, and the classification errs
6
+ // towards printing instructions rather than editing another program's config.
7
+
8
+ const PANELS = [
9
+ // Strong signals only: a directory a panel owns, not a package that might
10
+ // have been installed for another reason.
11
+ { name: "Plesk", paths: ["/usr/local/psa", "/opt/psa"] },
12
+ { name: "cPanel / WHM", paths: ["/usr/local/cpanel"] },
13
+ { name: "CyberPanel", paths: ["/usr/local/CyberCP", "/usr/local/CyberPanel"] },
14
+ { name: "aaPanel", paths: ["/www/server/panel"] },
15
+ { name: "CloudPanel", paths: ["/home/clp", "/etc/clp", "/usr/local/bin/clpctl"] },
16
+ { name: "HestiaCP", paths: ["/usr/local/hestia"] },
17
+ { name: "ISPConfig", paths: ["/usr/local/ispconfig"] },
18
+ { name: "Coolify", paths: ["/data/coolify"] },
19
+ { name: "Dokploy", paths: ["/etc/dokploy", "/var/lib/dokploy"] },
20
+ { name: "Virtualmin / Webmin", paths: ["/etc/webmin/virtual-server"] },
21
+ ];
22
+
23
+ // Programs we know how to work alongside on ports 80/443. Anything else
24
+ // holding those ports means we stop and let a human look.
25
+ const KNOWN_WEB_SERVERS = /^(nginx|apache2?|httpd|caddy|haproxy|traefik|litespeed|openlitespeed)$/i;
26
+
27
+ /**
28
+ * Look at the machine. Changes nothing.
29
+ *
30
+ * @param {import("./system.js").System} system
31
+ * @param {object} [context] - what Levix already knows about itself
32
+ */
33
+ export async function inspect(system, context = {}) {
34
+ const panel = PANELS.find((entry) => entry.paths.some((path) => system.exists(path))) || null;
35
+
36
+ const nginx = {
37
+ installed: system.which("nginx"),
38
+ sitesAvailable: system.exists("/etc/nginx/sites-available"),
39
+ confD: system.exists("/etc/nginx/conf.d"),
40
+ levixSite: system.exists(nginxSitePath(context.domain, system)),
41
+ };
42
+
43
+ const caddy = {
44
+ installed: system.which("caddy"),
45
+ caddyfile: ["/etc/caddy/Caddyfile"].find((path) => system.exists(path)) || null,
46
+ };
47
+
48
+ const apache = {
49
+ installed: system.which("apache2") || system.which("httpd"),
50
+ };
51
+
52
+ const listeners = system.listeners();
53
+ const ports = {};
54
+ for (const port of [80, 443]) {
55
+ const hit = listeners.find((entry) => entry.port === port);
56
+ ports[port] = hit ? { taken: true, process: hit.process } : { taken: false, process: null };
57
+ }
58
+
59
+ return {
60
+ panel: panel ? panel.name : null,
61
+ nginx,
62
+ caddy,
63
+ apache,
64
+ certbot: system.which("certbot"),
65
+ systemd: system.which("systemctl"),
66
+ container: Boolean(context.container),
67
+ root: typeof process.getuid === "function" ? process.getuid() === 0 : false,
68
+ ports,
69
+ listeners,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Turn a report into a decision.
75
+ *
76
+ * @returns {{ action: string, reason: string, target?: string }}
77
+ * action is one of:
78
+ * docker — instructions; the host's web server isn't ours to touch
79
+ * panel — instructions; a control panel owns this web server
80
+ * blocked — stop; something unknown holds 80/443
81
+ * nginx — configure a Levix site in the existing nginx
82
+ * caddy — add a Levix site to the existing Caddy
83
+ * apache — instructions; we don't edit Apache automatically
84
+ * install-caddy — nothing is installed; offer to install Caddy
85
+ */
86
+ export function classify(report) {
87
+ // A container cannot configure the host's proxy, and installing one inside
88
+ // the application image would be a second thing to keep alive.
89
+ if (report.container) {
90
+ return {
91
+ action: "docker",
92
+ reason: "Levix is running inside a container",
93
+ };
94
+ }
95
+
96
+ // A panel owns its web server's configuration and will overwrite ours, or
97
+ // break when it can't parse what we wrote.
98
+ if (report.panel) {
99
+ return {
100
+ action: "panel",
101
+ reason: `${report.panel} appears to manage this server`,
102
+ target: report.panel,
103
+ };
104
+ }
105
+
106
+ // Something is on 80/443 that we don't recognise. Installing a web server
107
+ // now would collide with it; editing it isn't possible either.
108
+ const unknown = [80, 443]
109
+ .map((port) => ({ port, ...report.ports[port] }))
110
+ .find((entry) => entry.taken && !KNOWN_WEB_SERVERS.test(entry.process || ""));
111
+
112
+ if (unknown && !report.nginx.installed && !report.caddy.installed && !report.apache.installed) {
113
+ return {
114
+ action: "blocked",
115
+ reason: `port ${unknown.port} is held by ${unknown.process || "an unknown process"}`,
116
+ target: unknown.process,
117
+ };
118
+ }
119
+
120
+ // In order of how well we can work with them.
121
+ if (report.nginx.installed) {
122
+ return { action: "nginx", reason: "nginx is installed and not panel-managed" };
123
+ }
124
+ if (report.caddy.installed) {
125
+ return { action: "caddy", reason: "Caddy is installed" };
126
+ }
127
+ if (report.apache.installed) {
128
+ return {
129
+ action: "apache",
130
+ reason: "Apache is installed, and Levix does not edit it automatically",
131
+ };
132
+ }
133
+
134
+ if (unknown) {
135
+ return {
136
+ action: "blocked",
137
+ reason: `port ${unknown.port} is held by ${unknown.process || "an unknown process"}`,
138
+ target: unknown.process,
139
+ };
140
+ }
141
+
142
+ return { action: "install-caddy", reason: "no web server is installed" };
143
+ }
144
+
145
+ /** Where a Levix nginx site lives on this distro. */
146
+ export function nginxSitePath(domain, system) {
147
+ const name = `levix-${domain || "site"}.conf`;
148
+ if (system.exists("/etc/nginx/sites-available")) return `/etc/nginx/sites-available/${name}`;
149
+ return `/etc/nginx/conf.d/${name}`;
150
+ }
151
+
152
+ /**
153
+ * Does this domain point at this machine?
154
+ *
155
+ * Advisory: without a public IP to compare against (no network, an internal
156
+ * DNS view) it returns `unknown` rather than blocking.
157
+ */
158
+ export async function checkDns(system, domain) {
159
+ const [resolved, mine] = await Promise.all([system.resolve(domain), system.publicIp()]);
160
+
161
+ if (!resolved.v4.length && !resolved.v6.length) {
162
+ return { status: "unresolved", resolved, mine };
163
+ }
164
+ if (!mine.v4 && !mine.v6) {
165
+ return { status: "unknown", resolved, mine };
166
+ }
167
+ const matches =
168
+ (mine.v4 && resolved.v4.includes(mine.v4)) || (mine.v6 && resolved.v6.includes(mine.v6));
169
+
170
+ return { status: matches ? "ok" : "elsewhere", resolved, mine };
171
+ }
172
+
173
+ export { PANELS, KNOWN_WEB_SERVERS };