lazyufw 1.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.
package/dist/index.js ADDED
@@ -0,0 +1,2968 @@
1
+ #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // cli/checkSudo.ts
10
+ import { execFileSync } from "child_process";
11
+
12
+ // utils/config.ts
13
+ import { existsSync } from "fs";
14
+ var COMMON_UFW_PATHS = [
15
+ "/usr/sbin/ufw",
16
+ "/usr/bin/ufw",
17
+ "/sbin/ufw",
18
+ "/usr/local/sbin/ufw",
19
+ "/usr/local/bin/ufw"
20
+ ];
21
+ function findUfwPath() {
22
+ if (process.env.UFW_PATH && existsSync(process.env.UFW_PATH)) {
23
+ return process.env.UFW_PATH;
24
+ }
25
+ const found = COMMON_UFW_PATHS.find(existsSync);
26
+ return found ?? null;
27
+ }
28
+ function isUfwInstalled() {
29
+ return findUfwPath() !== null;
30
+ }
31
+ function getUfwPath() {
32
+ if (process.env.UFW_PATH) {
33
+ return process.env.UFW_PATH;
34
+ }
35
+ const detected = findUfwPath();
36
+ if (detected) {
37
+ return detected;
38
+ }
39
+ return "/usr/sbin/ufw";
40
+ }
41
+ var UFW_PATH = getUfwPath();
42
+ var SUDOERS_FILE = "/etc/sudoers.d/lazyufw-nopasswd";
43
+ var SUDO_USER = process.env.SUDO_USER || process.env.USER || "";
44
+
45
+ // cli/checkSudo.ts
46
+ import { spawnSync } from "child_process";
47
+ function ensureSudoCached() {
48
+ const result = spawnSync("sudo", ["-v"], { stdio: "inherit" });
49
+ return result.status === 0;
50
+ }
51
+ function isSudoConfigured() {
52
+ try {
53
+ execFileSync("sudo", ["-n", UFW_PATH, "status"], { stdio: "ignore" });
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ // cli/setupSudo.ts
61
+ import { writeFileSync, chmodSync, existsSync as existsSync2, unlinkSync } from "fs";
62
+ import { execFileSync as execFileSync2, spawnSync as spawnSync2 } from "child_process";
63
+ function setupSudo() {
64
+ if (process.platform !== "linux") {
65
+ console.warn("lazyufw: UFW and sudoers configuration are designed for Linux systems.");
66
+ }
67
+ if (process.getuid && process.getuid() !== 0) {
68
+ console.log("lazyufw: Sudoers configuration requires root. Re-running with sudo...");
69
+ const res = spawnSync2("sudo", [process.execPath, ...process.argv.slice(1)], {
70
+ stdio: "inherit"
71
+ });
72
+ if (res.status !== 0) {
73
+ console.error("Sudo authentication cancelled or failed. Run: sudo lazyufw setup");
74
+ process.exit(res.status ?? 1);
75
+ }
76
+ return;
77
+ }
78
+ const targetUser = process.env.SUDO_USER || process.env.USER;
79
+ if (!targetUser || targetUser === "root") {
80
+ console.log("lazyufw: Currently running as root. Passwordless sudo drop-in is not required for root.");
81
+ return;
82
+ }
83
+ const ufwPath = getUfwPath();
84
+ const line = `${targetUser} ALL=(root) NOPASSWD: ${ufwPath}
85
+ `;
86
+ try {
87
+ writeFileSync(SUDOERS_FILE, line, { mode: 288 });
88
+ chmodSync(SUDOERS_FILE, 288);
89
+ } catch (err) {
90
+ console.error(`Failed to write sudoers file at ${SUDOERS_FILE}:`, err);
91
+ process.exit(1);
92
+ }
93
+ try {
94
+ execFileSync2("visudo", ["-c", "-f", SUDOERS_FILE]);
95
+ } catch {
96
+ if (existsSync2(SUDOERS_FILE)) {
97
+ unlinkSync(SUDOERS_FILE);
98
+ }
99
+ console.error("Generated sudoers rule failed validation (visudo). No changes were made.");
100
+ process.exit(1);
101
+ }
102
+ console.log(`
103
+ \u2705 lazyufw: passwordless sudo enabled for '${targetUser}' on ${ufwPath}`);
104
+ console.log(` You can now run 'lazyufw' freely without password prompts!
105
+ `);
106
+ }
107
+
108
+ // cli/teardownSudo.ts
109
+ import { existsSync as existsSync3, unlinkSync as unlinkSync2 } from "fs";
110
+ import { spawnSync as spawnSync3 } from "child_process";
111
+ function teardownSudo() {
112
+ if (process.getuid && process.getuid() !== 0) {
113
+ console.log("lazyufw: Removing sudoers file requires root. Re-running with sudo...");
114
+ const res = spawnSync3("sudo", [process.execPath, ...process.argv.slice(1)], {
115
+ stdio: "inherit"
116
+ });
117
+ if (res.status !== 0) {
118
+ console.error("Authentication failed. Run manually: sudo lazyufw teardown");
119
+ process.exit(res.status ?? 1);
120
+ }
121
+ return;
122
+ }
123
+ if (existsSync3(SUDOERS_FILE)) {
124
+ try {
125
+ unlinkSync2(SUDOERS_FILE);
126
+ console.log(`
127
+ \u2705 lazyufw: removed sudoers rule from ${SUDOERS_FILE}
128
+ `);
129
+ } catch (err) {
130
+ console.error(`Failed to remove ${SUDOERS_FILE}:`, err);
131
+ process.exit(1);
132
+ }
133
+ } else {
134
+ console.log("\n lazyufw: no sudoers rule found, nothing to remove.\n");
135
+ }
136
+ }
137
+
138
+ // tui/dashboard.ts
139
+ import blessed15 from "blessed";
140
+
141
+ // tui/components/RulesPanel.ts
142
+ import blessed from "blessed";
143
+
144
+ // tui/theme.ts
145
+ var theme = {
146
+ border: { idle: "gray", focus: "green", accent: "cyan" },
147
+ status: { active: "green", inactive: "red", unknown: "gray" },
148
+ action: {
149
+ ALLOW: "green",
150
+ DENY: "red",
151
+ REJECT: "red",
152
+ LIMIT: "yellow"
153
+ },
154
+ text: { muted: "gray", accent: "cyan", danger: "red", warn: "yellow" }
155
+ };
156
+ function focusable(widget, setBorder, setLabel) {
157
+ widget.on("focus", () => {
158
+ setBorder(theme.border.focus);
159
+ setLabel?.(theme.border.focus);
160
+ });
161
+ widget.on("blur", () => {
162
+ setBorder(theme.border.idle);
163
+ setLabel?.(theme.border.idle);
164
+ });
165
+ }
166
+
167
+ // tui/format.ts
168
+ function truncate(str, max) {
169
+ if (str.length <= max) return str;
170
+ return str.slice(0, Math.max(0, max - 3)) + "...";
171
+ }
172
+
173
+ // tui/components/RulesPanel.ts
174
+ var EMPTY_MESSAGE = "{gray-fg}No rules configured. Press 'a' to add one.{/gray-fg}";
175
+ var NO_MATCH_MESSAGE = "{gray-fg}No rules match the current filter. Press 'c' to clear.{/gray-fg}";
176
+ var LOADING_MESSAGE = "{gray-fg}Loading rules...{/gray-fg}";
177
+ var RulesPanel = class {
178
+ widget;
179
+ rules = [];
180
+ displayedRules = [];
181
+ state = "loading";
182
+ filterQuery = "";
183
+ sortMode = "id";
184
+ constructor() {
185
+ this.widget = blessed.list({
186
+ label: " [2] Rules ",
187
+ top: "31%",
188
+ left: 0,
189
+ width: "35%",
190
+ height: "44%",
191
+ keys: true,
192
+ vi: true,
193
+ mouse: true,
194
+ tags: true,
195
+ border: { type: "line" },
196
+ style: {
197
+ selected: { fg: "black", bg: theme.border.focus, bold: true },
198
+ border: { fg: theme.border.idle },
199
+ label: { fg: theme.border.idle }
200
+ },
201
+ items: [LOADING_MESSAGE]
202
+ });
203
+ focusable(
204
+ this.widget,
205
+ (color) => this.widget.style.border.fg = color,
206
+ (color) => this.widget.style.label.fg = color
207
+ );
208
+ this.widget.key(["?", "S-/", "h", "S-h", "f1"], () => {
209
+ this.widget.screen.emit("key ?", "?", { full: "?" });
210
+ });
211
+ this.widget.key(["D", "S-d"], () => {
212
+ this.widget.screen.emit("key S-d", "D", { full: "S-d" });
213
+ });
214
+ this.widget.key(["/"], () => this.openSearchPrompt());
215
+ this.widget.key(["o", "S-s"], () => this.cycleSort());
216
+ this.widget.key(["c"], () => {
217
+ if (this.filterQuery) {
218
+ this.clearFilter();
219
+ }
220
+ });
221
+ }
222
+ setLoading() {
223
+ this.state = "loading";
224
+ this.widget.setItems([LOADING_MESSAGE]);
225
+ }
226
+ setRules(rules) {
227
+ this.state = "loaded";
228
+ this.rules = rules;
229
+ this.applyFilterAndSort();
230
+ }
231
+ applyFilterAndSort() {
232
+ const currentId = this.getSelectedRule()?.id;
233
+ let list = [...this.rules];
234
+ if (this.filterQuery) {
235
+ const q = this.filterQuery.toLowerCase();
236
+ list = list.filter(
237
+ (r) => r.to.toLowerCase().includes(q) || r.from.toLowerCase().includes(q) || r.action.toLowerCase().includes(q) || r.comment && r.comment.toLowerCase().includes(q) || String(r.id).includes(q)
238
+ );
239
+ }
240
+ if (this.sortMode === "action") {
241
+ list.sort((a, b) => a.action.localeCompare(b.action) || a.id - b.id);
242
+ } else if (this.sortMode === "to") {
243
+ list.sort((a, b) => a.to.localeCompare(b.to) || a.id - b.id);
244
+ } else if (this.sortMode === "from") {
245
+ list.sort((a, b) => a.from.localeCompare(b.from) || a.id - b.id);
246
+ } else {
247
+ list.sort((a, b) => a.id - b.id);
248
+ }
249
+ this.displayedRules = list;
250
+ this.renderItems();
251
+ this.updateLabel();
252
+ const restoredIndex = currentId != null ? list.findIndex((r) => r.id === currentId) : -1;
253
+ this.widget.select(restoredIndex >= 0 ? restoredIndex : 0);
254
+ }
255
+ renderItems() {
256
+ if (this.rules.length === 0) {
257
+ this.widget.setItems([EMPTY_MESSAGE]);
258
+ return;
259
+ }
260
+ if (this.displayedRules.length === 0) {
261
+ this.widget.setItems([NO_MATCH_MESSAGE]);
262
+ return;
263
+ }
264
+ this.widget.setItems(
265
+ this.displayedRules.map((rule) => {
266
+ const color = theme.action[rule.action] ?? "white";
267
+ const direction = rule.direction === "OUT" ? "\u2192" : "\u2190";
268
+ const actionBadge = `{${color}-fg}{bold}[${rule.action}]{/bold}{/${color}-fg}`;
269
+ const to = truncate(rule.to, 16).padEnd(16);
270
+ const from = truncate(rule.from, 14);
271
+ return `${String(rule.id).padStart(2)} ${direction} ${to} ${actionBadge} ${from}`;
272
+ })
273
+ );
274
+ }
275
+ updateLabel() {
276
+ let label = " [2] Rules ";
277
+ const badges = [];
278
+ if (this.sortMode !== "id") {
279
+ badges.push(`sort:${this.sortMode}`);
280
+ }
281
+ if (this.filterQuery) {
282
+ badges.push(`filter:"${this.filterQuery}"`);
283
+ }
284
+ if (badges.length > 0) {
285
+ label = ` [2] Rules (${badges.join(" | ")}) `;
286
+ }
287
+ this.widget.setLabel(label);
288
+ }
289
+ openSearchPrompt() {
290
+ const prompt = blessed.prompt({
291
+ parent: this.widget.screen,
292
+ top: "center",
293
+ left: "center",
294
+ width: "50%",
295
+ height: 7,
296
+ border: { type: "line" },
297
+ label: " Filter Rules [/] ",
298
+ style: { border: { fg: "cyan" }, label: { fg: "cyan" } },
299
+ tags: true,
300
+ shadow: true
301
+ });
302
+ prompt.input("Enter filter query (Enter empty to clear):", this.filterQuery, (_err, value) => {
303
+ if (value !== null && value !== void 0) {
304
+ this.filterQuery = value.trim();
305
+ this.applyFilterAndSort();
306
+ }
307
+ prompt.destroy();
308
+ this.widget.screen.render();
309
+ this.widget.focus();
310
+ });
311
+ this.widget.screen.render();
312
+ }
313
+ cycleSort() {
314
+ const modes = ["id", "action", "to", "from"];
315
+ const nextIdx = (modes.indexOf(this.sortMode) + 1) % modes.length;
316
+ this.sortMode = modes[nextIdx];
317
+ this.applyFilterAndSort();
318
+ this.widget.screen.render();
319
+ }
320
+ clearFilter() {
321
+ this.filterQuery = "";
322
+ this.applyFilterAndSort();
323
+ this.widget.screen.render();
324
+ }
325
+ getSelectedRule() {
326
+ if (this.displayedRules.length === 0) return void 0;
327
+ const index = this.widget.selected;
328
+ return this.displayedRules[index];
329
+ }
330
+ get isEmpty() {
331
+ return this.rules.length === 0;
332
+ }
333
+ get count() {
334
+ return this.rules.length;
335
+ }
336
+ get filter() {
337
+ return this.filterQuery;
338
+ }
339
+ get sort() {
340
+ return this.sortMode;
341
+ }
342
+ focus() {
343
+ this.widget.focus();
344
+ }
345
+ };
346
+
347
+ // tui/components/DetailPanel.ts
348
+ import blessed2 from "blessed";
349
+ var PLACEHOLDER = "{cyan-fg}{bold}Firewall Rule Inspector{/bold}{/cyan-fg}\n\n{gray-fg}Select a rule on the left [2] to inspect details, actions, and raw commands.\n\nQuick Actions:\n [a] Add Rule [i] Insert Rule\n [P] App Profiles [d] Delete Rule\n [e] Enable UFW [D] Disable UFW (SSH Protected)\n [L] Log Level Modal [l] Quick Toggle Log\n [/] Search / Filter [o] Sort Rules\n [r] Refresh [x] Action Menu\n [?] Help Cheatsheet [q] Quit{/gray-fg}";
350
+ var DetailPanel = class {
351
+ widget;
352
+ constructor() {
353
+ this.widget = blessed2.box({
354
+ label: " [4] Inspection / Details ",
355
+ top: 3,
356
+ left: "35%",
357
+ width: "65%",
358
+ height: "100%-6",
359
+ border: { type: "line" },
360
+ tags: true,
361
+ scrollable: true,
362
+ alwaysScroll: false,
363
+ keys: true,
364
+ vi: true,
365
+ mouse: true,
366
+ style: {
367
+ border: { fg: theme.border.idle },
368
+ label: { fg: theme.border.idle }
369
+ },
370
+ content: PLACEHOLDER
371
+ });
372
+ focusable(
373
+ this.widget,
374
+ (color) => this.widget.style.border.fg = color,
375
+ (color) => this.widget.style.label.fg = color
376
+ );
377
+ }
378
+ render(rule) {
379
+ if (!rule) {
380
+ this.widget.setContent(PLACEHOLDER);
381
+ return;
382
+ }
383
+ const color = theme.action[rule.action] ?? "white";
384
+ const actionBadge = `{${color}-bg}{black-fg}{bold} ${rule.action} {/bold}{/black-fg}{/${color}-bg}`;
385
+ const cleanTo = rule.to.replace(/\s*\(v6\)/i, "").trim();
386
+ const cleanFrom = rule.from.replace(/\s*\(v6\)/i, "").trim();
387
+ const isV6 = rule.to.includes("(v6)") || rule.from.includes("(v6)");
388
+ const isPortRule = /^\d+(?::\d+)?(\/\w+)?$/.test(cleanTo);
389
+ let cmdEquivalent;
390
+ let protoDisplay;
391
+ if (isPortRule) {
392
+ const protocolMatch = /\/(\w+)$/.exec(cleanTo);
393
+ const protocol = protocolMatch?.[1] ?? "any";
394
+ const portMatch = /^(\d+(?::\d+)?)/.exec(cleanTo);
395
+ const port = portMatch ? portMatch[1] : cleanTo;
396
+ protoDisplay = protocol.toUpperCase();
397
+ if (cleanFrom.toLowerCase() === "anywhere") {
398
+ cmdEquivalent = `ufw ${rule.action.toLowerCase()} ${cleanTo}${rule.comment ? ` comment "${rule.comment}"` : ""}`;
399
+ } else {
400
+ cmdEquivalent = `ufw ${rule.action.toLowerCase()} from ${cleanFrom} to any port ${port}${protocol !== "any" ? ` proto ${protocol}` : ""}${rule.comment ? ` comment "${rule.comment}"` : ""}`;
401
+ }
402
+ } else {
403
+ protoDisplay = "APP PROFILE";
404
+ if (cleanFrom.toLowerCase() === "anywhere") {
405
+ cmdEquivalent = `ufw ${rule.action.toLowerCase()} "${cleanTo}"${rule.comment ? ` comment "${rule.comment}"` : ""}`;
406
+ } else {
407
+ cmdEquivalent = `ufw ${rule.action.toLowerCase()} from ${cleanFrom} to any app "${cleanTo}"${rule.comment ? ` comment "${rule.comment}"` : ""}`;
408
+ }
409
+ }
410
+ const deleteCmd = `sudo ufw delete ${rule.id}`;
411
+ const lines = [
412
+ `{bold}{cyan-fg}RULE INSPECTION \u2014 #${rule.id}{/cyan-fg}{/bold}`,
413
+ `\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
414
+ ` {bold}Action:{/bold} ${actionBadge}`,
415
+ ` {bold}Direction:{/bold} ${rule.direction === "OUT" ? "OUTGOING (OUT \u2192)" : "INCOMING (IN \u2190)"}`,
416
+ ` {bold}Destination:{/bold} ${rule.to}`,
417
+ ` {bold}Source:{/bold} ${rule.from}`,
418
+ ` {bold}Protocol:{/bold} ${protoDisplay}`,
419
+ ` {bold}IP Version:{/bold} ${isV6 ? "IPv6" : "IPv4"}`,
420
+ rule.comment ? ` {bold}Comment:{/bold} {yellow-fg}${rule.comment}{/yellow-fg}` : "",
421
+ "",
422
+ `{bold}CLI Equivalence:{/bold}`,
423
+ ` {green-fg}$ ${cmdEquivalent}{/green-fg}`,
424
+ ` {gray-fg}$ ${deleteCmd}{/gray-fg}`,
425
+ "",
426
+ `{bold}Keyboard Shortcuts for this rule:{/bold}`,
427
+ ` Press {bold}d{/bold} to delete rule #${rule.id}`,
428
+ ` Press {bold}i{/bold} to insert a new rule before #${rule.id}`,
429
+ ` Press {bold}x{/bold} to open the Lazydocker Action Menu`
430
+ ].filter(Boolean).join("\n");
431
+ this.widget.setContent(lines);
432
+ }
433
+ focus() {
434
+ this.widget.focus();
435
+ }
436
+ };
437
+
438
+ // tui/components/StatusPanel.ts
439
+ import blessed3 from "blessed";
440
+ var StatusPanel = class {
441
+ widget;
442
+ statusData = {};
443
+ isSudoReady = false;
444
+ constructor() {
445
+ this.widget = blessed3.box({
446
+ label: " [1] Status ",
447
+ top: 3,
448
+ left: 0,
449
+ width: "35%",
450
+ height: "28%",
451
+ border: { type: "line" },
452
+ tags: true,
453
+ scrollable: false,
454
+ keys: true,
455
+ mouse: true,
456
+ style: {
457
+ border: { fg: theme.border.idle },
458
+ label: { fg: theme.border.idle }
459
+ },
460
+ content: "{gray-fg}Loading firewall status...{/gray-fg}"
461
+ });
462
+ focusable(
463
+ this.widget,
464
+ (color) => this.widget.style.border.fg = color,
465
+ (color) => this.widget.style.label.fg = color
466
+ );
467
+ }
468
+ setStatus(data, sudoReady = false) {
469
+ this.statusData = data;
470
+ this.isSudoReady = sudoReady;
471
+ this.render();
472
+ }
473
+ render() {
474
+ const { status, logging, defaultIncoming, defaultOutgoing, defaultRouted, rules } = this.statusData;
475
+ const isAct = status === "active";
476
+ const statusBadge = isAct ? "{green-bg}{black-fg}{bold} ACTIVE {/bold}{/black-fg}{/green-bg}" : "{red-bg}{white-fg}{bold} INACTIVE {/bold}{/white-fg}{/red-bg}";
477
+ const logBadge = logging && logging.toLowerCase().includes("on") ? `{green-fg}\u25CF ON ${logging.replace(/^on\s*/i, "").trim() || "low"}{/green-fg}` : "{gray-fg}\u25CB OFF{/gray-fg}";
478
+ const sudoBadge = this.isSudoReady ? "{green-fg}Passwordless Sudo{/green-fg}" : "{yellow-fg}Standard Sudo{/yellow-fg}";
479
+ const inPolicy = defaultIncoming ? defaultIncoming.toUpperCase() : "DENY";
480
+ const outPolicy = defaultOutgoing ? defaultOutgoing.toUpperCase() : "ALLOW";
481
+ const routedPolicy = defaultRouted ? defaultRouted.toUpperCase() : "DISABLED";
482
+ const inColor = inPolicy === "ALLOW" ? "green" : "red";
483
+ const outColor = outPolicy === "ALLOW" ? "green" : "red";
484
+ const content = [
485
+ ` {bold}State:{/bold} ${statusBadge} {gray-fg}|{/gray-fg} {bold}Log:{/bold} ${logBadge}`,
486
+ ` {bold}Policy:{/bold} In: {${inColor}-fg}${inPolicy}{/${inColor}-fg} | Out: {${outColor}-fg}${outPolicy}{/${outColor}-fg} | Route: ${routedPolicy}`,
487
+ ` {bold}Rules:{/bold} ${rules?.length ?? 0} loaded`,
488
+ ` {bold}Auth:{/bold} ${sudoBadge}`
489
+ ].join("\n");
490
+ this.widget.setContent(content);
491
+ }
492
+ focus() {
493
+ this.widget.focus();
494
+ }
495
+ };
496
+
497
+ // tui/components/RawPanel.ts
498
+ import blessed4 from "blessed";
499
+ var RawPanel = class {
500
+ widget;
501
+ constructor() {
502
+ this.widget = blessed4.box({
503
+ label: " [3] Raw Output ",
504
+ top: 3,
505
+ left: "50%",
506
+ width: "50%",
507
+ height: "50%",
508
+ border: { type: "line" },
509
+ tags: true,
510
+ scrollable: true,
511
+ alwaysScroll: false,
512
+ keys: true,
513
+ vi: true,
514
+ mouse: true,
515
+ style: {
516
+ border: { fg: theme.border.idle },
517
+ label: { fg: theme.border.idle }
518
+ },
519
+ content: "{gray-fg}Loading raw rules...{/gray-fg}"
520
+ });
521
+ focusable(
522
+ this.widget,
523
+ (color) => this.widget.style.border.fg = color,
524
+ (color) => this.widget.style.label.fg = color
525
+ );
526
+ }
527
+ setContent(text) {
528
+ this.widget.setContent(text ? `{white-fg}${text.trim()}{/white-fg}` : "{gray-fg}No raw output available{/gray-fg}");
529
+ }
530
+ focus() {
531
+ this.widget.focus();
532
+ }
533
+ };
534
+
535
+ // tui/components/Focusmanager.ts
536
+ var FocusManager = class {
537
+ constructor(screen) {
538
+ this.screen = screen;
539
+ }
540
+ screen;
541
+ stack = [];
542
+ previouslyFocused = null;
543
+ get isModalOpen() {
544
+ return this.stack.length > 0;
545
+ }
546
+ open(modal) {
547
+ if (this.stack.length === 0) {
548
+ this.previouslyFocused = this.screen.focused ?? null;
549
+ }
550
+ this.stack.push(modal);
551
+ modal.show();
552
+ modal.focus();
553
+ this.screen.render();
554
+ }
555
+ closeTop() {
556
+ const modal = this.stack.pop();
557
+ if (!modal) return;
558
+ modal.destroy();
559
+ const next = this.stack[this.stack.length - 1];
560
+ if (next) {
561
+ next.focus();
562
+ } else if (this.previouslyFocused) {
563
+ this.previouslyFocused.focus();
564
+ }
565
+ this.screen.render();
566
+ }
567
+ closeAll() {
568
+ while (this.stack.length) this.closeTop();
569
+ }
570
+ };
571
+
572
+ // tui/components/StatusBar.ts
573
+ import blessed5 from "blessed";
574
+ var DEFAULT_HINT = " {bold}[1-4]{/bold} Panels {bold}[a]{/bold} Add {bold}[P]{/bold} Apps {bold}[i]{/bold} Insert {bold}[d]{/bold} Del {bold}[L]{/bold} Log {bold}[/]{/bold} Find {bold}[o]{/bold} Sort {bold}[x]{/bold} Menu {bold}[?]{/bold} Help {bold}[q]{/bold} Quit";
575
+ var COPYRIGHT_LINE = "{gray-fg}lazyufw \xB7 The lazier way to manage UFW \xB7 Press [x] for Actions Menu{/gray-fg}";
576
+ function createHeader() {
577
+ return blessed5.box({
578
+ top: 0,
579
+ left: 0,
580
+ width: "100%",
581
+ height: 3,
582
+ border: { type: "line" },
583
+ tags: true,
584
+ style: { border: { fg: "cyan" } },
585
+ content: " {bold}{cyan-fg}lazyufw{/cyan-fg}{/bold} {gray-fg}\u2502 Loading firewall status...{/gray-fg}"
586
+ });
587
+ }
588
+ function renderHeaderStatus(header, status) {
589
+ if (status.statusUnavailable) {
590
+ header.setContent(
591
+ " {bold}{cyan-fg}lazyufw{/cyan-fg}{/bold} {gray-fg}\u2502{/gray-fg} {red-bg}{white-fg}{bold} STATUS UNAVAILABLE {/bold}{/white-fg}{/red-bg} {gray-fg}(run 'lazyufw setup' or check permissions){/gray-fg}"
592
+ );
593
+ return;
594
+ }
595
+ const fw = status.firewallActive ? "{green-bg}{black-fg}{bold} ACTIVE {/bold}{/black-fg}{/green-bg}" : "{red-bg}{white-fg}{bold} INACTIVE {/bold}{/white-fg}{/red-bg}";
596
+ const log = status.loggingOn ? "{green-fg}log: on{/green-fg}" : "{gray-fg}log: off{/gray-fg}";
597
+ const rules = status.ruleCount != null ? `{bold}${status.ruleCount}{/bold} rules` : "";
598
+ header.setContent(
599
+ ` {bold}{cyan-fg}lazyufw{/cyan-fg}{/bold} {gray-fg}\u2502{/gray-fg} ufw: ${fw} ${log} ${rules ? `{gray-fg}\u2502{/gray-fg} ${rules}` : ""}`
600
+ );
601
+ }
602
+ function createFooter() {
603
+ const footer = blessed5.box({
604
+ bottom: 0,
605
+ left: 0,
606
+ width: "100%",
607
+ height: 3,
608
+ border: { type: "line" },
609
+ tags: true,
610
+ style: { border: { fg: "gray" } }
611
+ });
612
+ footer.setContent(`${DEFAULT_HINT}
613
+ ${COPYRIGHT_LINE}`);
614
+ return footer;
615
+ }
616
+ function setFooterHint(footer, text) {
617
+ footer.setContent(` ${text}
618
+ ${COPYRIGHT_LINE}`);
619
+ }
620
+ function resetFooterHint(footer) {
621
+ footer.setContent(`${DEFAULT_HINT}
622
+ ${COPYRIGHT_LINE}`);
623
+ }
624
+
625
+ // firewall/ufwParser.ts
626
+ var NUMBERED_RULE = /^\[\s*(\d+)\]\s+(.+?)\s+(ALLOW|DENY|REJECT|LIMIT)(?:\s+(IN|OUT))?\s+(.+?)(?:\s+#\s*(.*))?$/i;
627
+ function parseRules(output) {
628
+ const rules = [];
629
+ for (const line of output.split(/\r?\n/)) {
630
+ const trimmed = line.trim();
631
+ if (!trimmed.startsWith("[")) continue;
632
+ const match = NUMBERED_RULE.exec(trimmed);
633
+ if (!match) continue;
634
+ const id = Number(match[1]);
635
+ const to = match[2].trim();
636
+ const action = match[3].toUpperCase();
637
+ const direction = match[4]?.toUpperCase() || "IN";
638
+ const from = match[5].trim();
639
+ const comment = match[6]?.trim();
640
+ rules.push({
641
+ id,
642
+ to,
643
+ action,
644
+ direction,
645
+ from,
646
+ ...comment ? { comment } : {},
647
+ raw: trimmed
648
+ });
649
+ }
650
+ return rules;
651
+ }
652
+ function parseStatus(output) {
653
+ const statusLine = /^Status:\s*(active|inactive)/im.exec(output);
654
+ const loggingLine = /^Logging:\s*(.+)$/im.exec(output);
655
+ const defaultLine = /^Default:\s*(.+)$/im.exec(output);
656
+ let defaultIncoming;
657
+ let defaultOutgoing;
658
+ let defaultRouted;
659
+ if (defaultLine && defaultLine[1]) {
660
+ const parts = defaultLine[1].split(",").map((p) => p.trim());
661
+ for (const part of parts) {
662
+ const lower = part.toLowerCase();
663
+ if (lower.includes("incoming")) {
664
+ defaultIncoming = part.split("(")[0]?.trim();
665
+ } else if (lower.includes("outgoing")) {
666
+ defaultOutgoing = part.split("(")[0]?.trim();
667
+ } else if (lower.includes("routed")) {
668
+ defaultRouted = part.split("(")[0]?.trim();
669
+ }
670
+ }
671
+ }
672
+ const status = statusLine && statusLine[1] ? statusLine[1].toLowerCase() : "unknown";
673
+ return {
674
+ status,
675
+ logging: loggingLine?.[1]?.trim(),
676
+ defaultIncoming,
677
+ defaultOutgoing,
678
+ defaultRouted,
679
+ raw: output.trim()
680
+ };
681
+ }
682
+ function parseAppList(output) {
683
+ const apps = [];
684
+ let start = false;
685
+ for (const line of output.split(/\r?\n/)) {
686
+ const trimmed = line.trim();
687
+ if (!trimmed) continue;
688
+ if (/available applications:/i.test(trimmed)) {
689
+ start = true;
690
+ continue;
691
+ }
692
+ if (start) {
693
+ apps.push(trimmed);
694
+ }
695
+ }
696
+ return apps;
697
+ }
698
+ function parseAppInfo(output) {
699
+ let title;
700
+ let description;
701
+ let ports;
702
+ const lines = output.split(/\r?\n/);
703
+ let inPorts = false;
704
+ const portLines = [];
705
+ for (const rawLine of lines) {
706
+ const line = rawLine.trim();
707
+ if (!line) continue;
708
+ const titleMatch = /^Title:\s*(.+)$/i.exec(line);
709
+ if (titleMatch) {
710
+ title = titleMatch[1]?.trim();
711
+ inPorts = false;
712
+ continue;
713
+ }
714
+ const descMatch = /^Description:\s*(.+)$/i.exec(line);
715
+ if (descMatch) {
716
+ description = descMatch[1]?.trim();
717
+ inPorts = false;
718
+ continue;
719
+ }
720
+ if (/^Ports:/i.test(line)) {
721
+ inPorts = true;
722
+ const remainder = line.replace(/^Ports:\s*/i, "").trim();
723
+ if (remainder) portLines.push(remainder);
724
+ continue;
725
+ }
726
+ if (inPorts) {
727
+ if (/^\w+:/.test(line)) {
728
+ inPorts = false;
729
+ } else {
730
+ portLines.push(line);
731
+ }
732
+ }
733
+ }
734
+ if (portLines.length > 0) {
735
+ ports = portLines.join(", ");
736
+ }
737
+ return { title, description, ports };
738
+ }
739
+ function getLocalAppProfiles(appDir = "/etc/ufw/applications.d") {
740
+ try {
741
+ const { readdirSync, readFileSync, existsSync: existsSync4 } = __require("fs");
742
+ if (!existsSync4(appDir)) return [];
743
+ const files = readdirSync(appDir);
744
+ const apps = [];
745
+ for (const file of files) {
746
+ const content = readFileSync(`${appDir}/${file}`, "utf-8");
747
+ for (const line of content.split(/\r?\n/)) {
748
+ const match = /^\[([^\]]+)\]/.exec(line.trim());
749
+ if (match && match[1]) {
750
+ apps.push(match[1].trim());
751
+ }
752
+ }
753
+ }
754
+ return Array.from(new Set(apps)).sort();
755
+ } catch {
756
+ return [];
757
+ }
758
+ }
759
+
760
+ // tui/components/Addrulemodal.ts
761
+ import blessed7 from "blessed";
762
+
763
+ // tui/components/BaseModal.ts
764
+ import blessed6 from "blessed";
765
+ var BaseModal = class {
766
+ constructor(screen, opts) {
767
+ this.screen = screen;
768
+ this.box = blessed6.box({
769
+ parent: screen,
770
+ label: ` ${opts.title} `,
771
+ top: "center",
772
+ left: "center",
773
+ width: opts.width ?? "60%",
774
+ height: opts.height ?? "40%",
775
+ border: { type: "line" },
776
+ style: {
777
+ border: { fg: "yellow" },
778
+ label: { fg: "yellow" }
779
+ },
780
+ shadow: true,
781
+ hidden: true
782
+ });
783
+ }
784
+ screen;
785
+ box;
786
+ screenBindings = [];
787
+ /**
788
+ * Modal chrome (Esc/Tab) can't be bound on `this.box` itself: blessed only
789
+ * emits "key <name>" on whichever element currently has focus, and focus
790
+ * always sits on a child field/button, not the box. Bind at the screen
791
+ * level instead and tear the listener down when the modal closes.
792
+ */
793
+ bindKey(keys, handler) {
794
+ this.screen.key(keys, handler);
795
+ this.screenBindings.push({ keys, handler });
796
+ }
797
+ show() {
798
+ this.box.show();
799
+ this.box.setFront();
800
+ }
801
+ destroy() {
802
+ for (const { keys, handler } of this.screenBindings) {
803
+ for (const key of keys) {
804
+ this.screen.removeKey(key, handler);
805
+ }
806
+ }
807
+ this.screenBindings.length = 0;
808
+ this.box.destroy();
809
+ }
810
+ };
811
+
812
+ // tui/components/Addrulemodal.ts
813
+ var ACTIONS = ["allow", "deny", "reject", "limit"];
814
+ var PROTOCOLS = ["any", "tcp", "udp"];
815
+ var IP_OR_CIDR_RE = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
816
+ var AddRuleModal = class {
817
+ constructor(screen, callbacks) {
818
+ this.screen = screen;
819
+ this.callbacks = callbacks;
820
+ this.base = new BaseModal(screen, { title: "Add Rule", width: "55%", height: 16 });
821
+ const box = this.base.box;
822
+ blessed7.box({ parent: box, top: 0, left: 2, width: 14, content: "Action:" });
823
+ this.actionLabel = blessed7.box({
824
+ parent: box,
825
+ top: 0,
826
+ left: 16,
827
+ width: 28,
828
+ keys: true,
829
+ content: this.formatChoice(ACTIONS, this.actionIndex),
830
+ style: { fg: "cyan" }
831
+ });
832
+ blessed7.box({ parent: box, top: 1, left: 2, width: 14, content: "Port:" });
833
+ this.portInput = blessed7.textbox({
834
+ parent: box,
835
+ top: 1,
836
+ left: 16,
837
+ width: "70%-16",
838
+ height: 1,
839
+ keys: true,
840
+ style: { fg: "white", focus: { fg: "black", bg: "white" } }
841
+ });
842
+ blessed7.box({ parent: box, top: 2, left: 2, width: 14, content: "Protocol:" });
843
+ this.protocolLabel = blessed7.box({
844
+ parent: box,
845
+ top: 2,
846
+ left: 16,
847
+ width: 20,
848
+ keys: true,
849
+ content: this.formatChoice(PROTOCOLS, this.protocolIndex),
850
+ style: { fg: "cyan" }
851
+ });
852
+ blessed7.box({ parent: box, top: 3, left: 2, width: 14, content: "From (IP/CIDR):" });
853
+ this.fromInput = blessed7.textbox({
854
+ parent: box,
855
+ top: 3,
856
+ left: 16,
857
+ width: "70%-16",
858
+ height: 1,
859
+ keys: true,
860
+ style: { fg: "white", focus: { fg: "black", bg: "white" } }
861
+ });
862
+ blessed7.box({ parent: box, top: 4, left: 2, width: 14, content: "Comment:" });
863
+ this.commentInput = blessed7.textbox({
864
+ parent: box,
865
+ top: 4,
866
+ left: 16,
867
+ width: "70%-16",
868
+ height: 1,
869
+ keys: true,
870
+ style: { fg: "white", focus: { fg: "black", bg: "white" } }
871
+ });
872
+ this.errorLine = blessed7.box({
873
+ parent: box,
874
+ top: 6,
875
+ left: 2,
876
+ width: "90%",
877
+ height: 1,
878
+ content: "",
879
+ tags: true,
880
+ style: { fg: "red" }
881
+ });
882
+ this.submitBtn = blessed7.button({
883
+ parent: box,
884
+ top: 8,
885
+ left: 2,
886
+ width: 12,
887
+ height: 1,
888
+ keys: true,
889
+ content: "[ Submit ]",
890
+ align: "center",
891
+ style: { fg: "green", focus: { fg: "black", bg: "green" } }
892
+ });
893
+ blessed7.box({
894
+ parent: box,
895
+ top: 8,
896
+ right: 2,
897
+ width: 32,
898
+ height: 1,
899
+ content: "Tab: next \u2190/\u2192: choice Esc: cancel",
900
+ style: { fg: "gray" }
901
+ });
902
+ this.focusables = [
903
+ this.actionLabel,
904
+ this.portInput,
905
+ this.protocolLabel,
906
+ this.fromInput,
907
+ this.commentInput,
908
+ this.submitBtn
909
+ ];
910
+ this.bindKeys();
911
+ this.portInput.on("keypress", () => this.clearError());
912
+ this.fromInput.on("keypress", () => this.clearError());
913
+ this.commentInput.on("keypress", () => this.clearError());
914
+ }
915
+ screen;
916
+ callbacks;
917
+ base;
918
+ actionIndex = 0;
919
+ protocolIndex = 0;
920
+ actionLabel;
921
+ portInput;
922
+ protocolLabel;
923
+ fromInput;
924
+ commentInput;
925
+ errorLine;
926
+ submitBtn;
927
+ focusables;
928
+ focusIndex = 0;
929
+ formatChoice(options, index) {
930
+ return options.map((o, i) => i === index ? `[${o}]` : ` ${o} `).join(" ");
931
+ }
932
+ bindKeys() {
933
+ for (const widget of this.focusables) {
934
+ widget.key(["escape"], () => {
935
+ if (widget === this.portInput) this.portInput.cancel();
936
+ if (widget === this.fromInput) this.fromInput.cancel();
937
+ if (widget === this.commentInput) this.commentInput.cancel();
938
+ this.callbacks.onCancel();
939
+ });
940
+ }
941
+ this.actionLabel.key(["tab"], () => this.moveFocus(1));
942
+ this.actionLabel.key(["S-tab"], () => this.moveFocus(-1));
943
+ this.actionLabel.key(["left"], () => this.cycleCurrentSelect(-1));
944
+ this.actionLabel.key(["right"], () => this.cycleCurrentSelect(1));
945
+ this.actionLabel.key(["enter"], () => void this.trySubmit());
946
+ this.protocolLabel.key(["tab"], () => this.moveFocus(1));
947
+ this.protocolLabel.key(["S-tab"], () => this.moveFocus(-1));
948
+ this.protocolLabel.key(["left"], () => this.cycleCurrentSelect(-1));
949
+ this.protocolLabel.key(["right"], () => this.cycleCurrentSelect(1));
950
+ this.protocolLabel.key(["enter"], () => void this.trySubmit());
951
+ this.submitBtn.key(["tab"], () => this.moveFocus(1));
952
+ this.submitBtn.key(["S-tab"], () => this.moveFocus(-1));
953
+ this.submitBtn.key(["enter"], () => void this.trySubmit());
954
+ this.submitBtn.on("press", () => void this.trySubmit());
955
+ const wireTextbox = (tb) => {
956
+ tb.key(["tab"], () => {
957
+ tb.cancel();
958
+ this.moveFocus(1);
959
+ });
960
+ tb.key(["S-tab"], () => {
961
+ tb.cancel();
962
+ this.moveFocus(-1);
963
+ });
964
+ tb.key(["enter"], () => {
965
+ tb.submit();
966
+ void this.trySubmit();
967
+ });
968
+ };
969
+ wireTextbox(this.portInput);
970
+ wireTextbox(this.fromInput);
971
+ wireTextbox(this.commentInput);
972
+ this.base.bindKey(["escape"], () => this.callbacks.onCancel());
973
+ }
974
+ cycleCurrentSelect(delta) {
975
+ const current = this.focusables[this.focusIndex];
976
+ if (current === this.actionLabel) {
977
+ this.actionIndex = (this.actionIndex + delta + ACTIONS.length) % ACTIONS.length;
978
+ this.actionLabel.setContent(this.formatChoice(ACTIONS, this.actionIndex));
979
+ this.screen.render();
980
+ } else if (current === this.protocolLabel) {
981
+ this.protocolIndex = (this.protocolIndex + delta + PROTOCOLS.length) % PROTOCOLS.length;
982
+ this.protocolLabel.setContent(this.formatChoice(PROTOCOLS, this.protocolIndex));
983
+ this.screen.render();
984
+ }
985
+ }
986
+ moveFocus(delta) {
987
+ this.focusIndex = (this.focusIndex + delta + this.focusables.length) % this.focusables.length;
988
+ this.focus();
989
+ }
990
+ setError(message) {
991
+ this.errorLine.setContent(message ? `{red-fg}\u2717 ${message}{/red-fg}` : "");
992
+ this.screen.render();
993
+ }
994
+ clearError() {
995
+ if (this.errorLine.getContent()) this.setError("");
996
+ }
997
+ async trySubmit() {
998
+ const port = this.portInput.getValue().trim();
999
+ const from = this.fromInput.getValue().trim();
1000
+ const comment = this.commentInput.getValue().trim();
1001
+ if (!port) {
1002
+ this.setError("Port is required.");
1003
+ return;
1004
+ }
1005
+ if (!/^\d{1,5}(:\d{1,5})?$/.test(port)) {
1006
+ this.setError("Port must be a number or a range like 8000:8100.");
1007
+ return;
1008
+ }
1009
+ if (from && !IP_OR_CIDR_RE.test(from) && from.toLowerCase() !== "any") {
1010
+ this.setError("From must be a valid IP/CIDR (e.g. 192.168.1.0/24) or empty.");
1011
+ return;
1012
+ }
1013
+ this.setError("");
1014
+ const protocol = PROTOCOLS[this.protocolIndex];
1015
+ try {
1016
+ await this.callbacks.onSubmit({
1017
+ action: ACTIONS[this.actionIndex],
1018
+ port,
1019
+ protocol: protocol === "any" ? void 0 : protocol,
1020
+ from: from ? from : void 0,
1021
+ comment: comment ? comment : void 0
1022
+ });
1023
+ } catch (err) {
1024
+ this.setError(err instanceof Error ? err.message : String(err));
1025
+ }
1026
+ }
1027
+ show() {
1028
+ this.base.show();
1029
+ }
1030
+ destroy() {
1031
+ this.base.destroy();
1032
+ }
1033
+ focus() {
1034
+ const current = this.focusables[this.focusIndex];
1035
+ current.focus();
1036
+ if (current === this.portInput || current === this.fromInput || current === this.commentInput) {
1037
+ current.readInput();
1038
+ }
1039
+ this.screen.render();
1040
+ }
1041
+ };
1042
+
1043
+ // tui/components/Insertrulemodal.ts
1044
+ import blessed8 from "blessed";
1045
+ var IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
1046
+ var InsertRuleModal = class {
1047
+ constructor(screen, defaultPosition, callbacks) {
1048
+ this.screen = screen;
1049
+ this.callbacks = callbacks;
1050
+ this.base = new BaseModal(screen, { title: "Insert Rule (allow from IP)", width: "65%", height: "45%" });
1051
+ const box = this.base.box;
1052
+ blessed8.box({ parent: box, top: 1, left: 2, width: 16, content: "Insert at #:" });
1053
+ this.ruleInput = blessed8.textbox({
1054
+ parent: box,
1055
+ top: 1,
1056
+ left: 18,
1057
+ width: 10,
1058
+ height: 1,
1059
+ inputOnFocus: true,
1060
+ value: String(defaultPosition),
1061
+ style: { fg: "white", focus: { fg: "black", bg: "white" } }
1062
+ });
1063
+ blessed8.box({ parent: box, top: 3, left: 2, width: 16, content: "From IP:" });
1064
+ this.ipInput = blessed8.textbox({
1065
+ parent: box,
1066
+ top: 3,
1067
+ left: 18,
1068
+ width: "70%-20",
1069
+ height: 1,
1070
+ inputOnFocus: true,
1071
+ style: { fg: "white", focus: { fg: "black", bg: "white" } }
1072
+ });
1073
+ this.errorLine = blessed8.box({
1074
+ parent: box,
1075
+ top: 5,
1076
+ left: 2,
1077
+ width: "90%",
1078
+ height: 2,
1079
+ content: "",
1080
+ tags: true,
1081
+ style: { fg: "red" }
1082
+ });
1083
+ this.submitBtn = blessed8.button({
1084
+ parent: box,
1085
+ bottom: 1,
1086
+ left: 2,
1087
+ width: 12,
1088
+ height: 1,
1089
+ content: "[ Submit ]",
1090
+ align: "center",
1091
+ style: { fg: "green", focus: { fg: "black", bg: "green" } }
1092
+ });
1093
+ this.focusables = [this.ruleInput, this.ipInput, this.submitBtn];
1094
+ this.bindKeys();
1095
+ this.ruleInput.on("keypress", () => this.clearError());
1096
+ this.ipInput.on("keypress", () => this.clearError());
1097
+ }
1098
+ screen;
1099
+ callbacks;
1100
+ base;
1101
+ ruleInput;
1102
+ ipInput;
1103
+ errorLine;
1104
+ submitBtn;
1105
+ focusables;
1106
+ focusIndex = 0;
1107
+ bindKeys() {
1108
+ this.base.bindKey(["escape"], () => this.callbacks.onCancel());
1109
+ this.base.bindKey(["tab"], () => this.moveFocus(1));
1110
+ this.base.bindKey(["S-tab"], () => this.moveFocus(-1));
1111
+ this.ruleInput.key(["enter"], () => this.moveFocus(1));
1112
+ this.ipInput.key(["enter"], () => void this.trySubmit());
1113
+ this.submitBtn.on("press", () => void this.trySubmit());
1114
+ this.base.bindKey(["enter"], () => {
1115
+ if (this.focusables[this.focusIndex] === this.submitBtn) void this.trySubmit();
1116
+ });
1117
+ }
1118
+ moveFocus(delta) {
1119
+ this.focusIndex = (this.focusIndex + delta + this.focusables.length) % this.focusables.length;
1120
+ this.focus();
1121
+ }
1122
+ setError(message) {
1123
+ this.errorLine.setContent(message ? `{red-fg}\u2717 ${message}{/red-fg}` : "");
1124
+ this.screen.render();
1125
+ }
1126
+ clearError() {
1127
+ if (this.errorLine.getContent()) this.setError("");
1128
+ }
1129
+ async trySubmit() {
1130
+ const ruleStr = this.ruleInput.getValue().trim();
1131
+ const ipAddr = this.ipInput.getValue().trim();
1132
+ const rule = Number(ruleStr);
1133
+ if (!Number.isInteger(rule) || rule < 1) {
1134
+ this.setError("Position must be a positive integer.");
1135
+ return;
1136
+ }
1137
+ if (!IPV4_RE.test(ipAddr)) {
1138
+ this.setError("Enter a valid IPv4 address, e.g. 10.0.0.5 or 10.0.0.0/24.");
1139
+ return;
1140
+ }
1141
+ this.setError("");
1142
+ try {
1143
+ await this.callbacks.onSubmit({ rule, ipAddr });
1144
+ } catch (err) {
1145
+ this.setError(err instanceof Error ? err.message : String(err));
1146
+ }
1147
+ }
1148
+ show() {
1149
+ this.base.show();
1150
+ }
1151
+ destroy() {
1152
+ this.base.destroy();
1153
+ }
1154
+ focus() {
1155
+ this.focusables[this.focusIndex].focus();
1156
+ this.screen.render();
1157
+ }
1158
+ };
1159
+
1160
+ // tui/components/Confirmmodal.ts
1161
+ import blessed9 from "blessed";
1162
+ var ConfirmModal = class {
1163
+ constructor(screen, opts, callbacks) {
1164
+ this.screen = screen;
1165
+ this.callbacks = callbacks;
1166
+ this.base = new BaseModal(screen, { title: opts.title, width: "55%", height: "35%" });
1167
+ const box = this.base.box;
1168
+ blessed9.box({ parent: box, top: 1, left: 2, width: "90%", height: 3, content: opts.message });
1169
+ this.errorLine = blessed9.box({
1170
+ parent: box,
1171
+ top: 4,
1172
+ left: 2,
1173
+ width: "90%",
1174
+ height: 2,
1175
+ content: "",
1176
+ style: { fg: "red" }
1177
+ });
1178
+ this.yesBtn = blessed9.button({
1179
+ parent: box,
1180
+ bottom: 1,
1181
+ left: 2,
1182
+ width: (opts.confirmLabel?.length ?? 3) + 4,
1183
+ height: 1,
1184
+ content: `[ ${opts.confirmLabel ?? "Yes"} ]`,
1185
+ align: "center",
1186
+ style: { fg: "red", focus: { fg: "black", bg: "red" } }
1187
+ });
1188
+ this.noBtn = blessed9.button({
1189
+ parent: box,
1190
+ bottom: 1,
1191
+ left: (opts.confirmLabel?.length ?? 3) + 8,
1192
+ width: 10,
1193
+ height: 1,
1194
+ content: "[ No ]",
1195
+ align: "center",
1196
+ style: { fg: "white", focus: { fg: "black", bg: "white" } }
1197
+ });
1198
+ this.focusables = [this.yesBtn, this.noBtn];
1199
+ this.focusIndex = opts.dangerous === false ? 0 : 1;
1200
+ this.base.bindKey(["escape"], () => this.callbacks.onCancel());
1201
+ this.base.bindKey(["tab", "left", "right"], () => {
1202
+ this.focusIndex = 1 - this.focusIndex;
1203
+ this.focus();
1204
+ });
1205
+ this.base.bindKey(["enter"], () => {
1206
+ if (this.focusables[this.focusIndex] === this.yesBtn) void this.tryConfirm();
1207
+ else this.callbacks.onCancel();
1208
+ });
1209
+ this.yesBtn.on("press", () => void this.tryConfirm());
1210
+ this.noBtn.on("press", () => this.callbacks.onCancel());
1211
+ }
1212
+ screen;
1213
+ callbacks;
1214
+ base;
1215
+ yesBtn;
1216
+ noBtn;
1217
+ focusables;
1218
+ focusIndex;
1219
+ errorLine;
1220
+ async tryConfirm() {
1221
+ try {
1222
+ await this.callbacks.onConfirm();
1223
+ } catch (err) {
1224
+ this.errorLine.setContent(err instanceof Error ? err.message : String(err));
1225
+ this.screen.render();
1226
+ }
1227
+ }
1228
+ show() {
1229
+ this.base.show();
1230
+ }
1231
+ destroy() {
1232
+ this.base.destroy();
1233
+ }
1234
+ focus() {
1235
+ this.focusables[this.focusIndex].focus();
1236
+ this.screen.render();
1237
+ }
1238
+ };
1239
+
1240
+ // tui/components/Helpmodal.ts
1241
+ import blessed10 from "blessed";
1242
+ var HELP_TEXT = [
1243
+ "{bold}{cyan-fg}Navigation & Layout:{/cyan-fg}{/bold}",
1244
+ " 1 / 2 / 3 / 4 Jump to Panel (Status / Rules / Raw / Detail)",
1245
+ " Tab / Shift-Tab Cycle focus forward / backward between panels",
1246
+ " Esc Restore normal split layout from maximized view",
1247
+ " x Open Lazydocker Actions Menu",
1248
+ "",
1249
+ "{bold}{cyan-fg}Firewall Management Actions:{/cyan-fg}{/bold}",
1250
+ " a Add firewall rule (port, protocol, from IP/CIDR, comment)",
1251
+ " P Application profiles (browse, inspect, allow/deny apps)",
1252
+ " i Insert rule at specific position",
1253
+ " d Delete currently selected rule",
1254
+ " e Enable UFW firewall (ufw --force enable)",
1255
+ " D Disable UFW firewall (with SSH Lockout Protection)",
1256
+ " L Set UFW logging level modal (low, medium, high, off)",
1257
+ " l Quick toggle UFW logging (on / off)",
1258
+ " / Search / filter rules in Rules panel",
1259
+ " o Cycle sort mode in Rules panel (id, action, to, from)",
1260
+ " c Clear active search filter",
1261
+ " R Reset UFW firewall (factory reset rules)",
1262
+ " r Refresh firewall status and rules",
1263
+ " s Configure passwordless sudo (one-time setup)",
1264
+ "",
1265
+ "{bold}{cyan-fg}General:{/cyan-fg}{/bold}",
1266
+ " ? Toggle this help dialog",
1267
+ " q / Ctrl-C Quit lazyufw",
1268
+ "",
1269
+ "{bold}{cyan-fg}Inside Dialogs / Modals:{/cyan-fg}{/bold}",
1270
+ " Tab / Shift-Tab Switch between fields and buttons",
1271
+ " \u2190 / \u2192 Cycle selection values (allow/deny/protocol)",
1272
+ " Enter Submit form or trigger focused button",
1273
+ " Esc Close / Cancel"
1274
+ ].join("\n");
1275
+ var HelpModal = class {
1276
+ constructor(screen, onClose) {
1277
+ this.screen = screen;
1278
+ this.onClose = onClose;
1279
+ this.base = new BaseModal(screen, { title: "Help", width: "60%", height: "60%" });
1280
+ blessed10.box({
1281
+ parent: this.base.box,
1282
+ top: 1,
1283
+ left: 2,
1284
+ width: "90%",
1285
+ height: "80%",
1286
+ content: HELP_TEXT
1287
+ });
1288
+ this.base.bindKey(["escape", "?", "S-/", "h", "S-h", "q", "enter"], () => this.onClose());
1289
+ }
1290
+ screen;
1291
+ onClose;
1292
+ base;
1293
+ show() {
1294
+ this.base.show();
1295
+ }
1296
+ destroy() {
1297
+ this.base.destroy();
1298
+ }
1299
+ focus() {
1300
+ this.base.box.focus();
1301
+ this.screen.render();
1302
+ }
1303
+ };
1304
+
1305
+ // tui/components/SshWarningModal.ts
1306
+ import blessed11 from "blessed";
1307
+ var SshWarningModal = class {
1308
+ constructor(screen, sshInfo, callbacks) {
1309
+ this.screen = screen;
1310
+ this.callbacks = callbacks;
1311
+ this.base = new BaseModal(screen, {
1312
+ title: "\u26A0\uFE0F SSH LOCKOUT PROTECTION WARNING",
1313
+ width: "65%",
1314
+ height: "55%"
1315
+ });
1316
+ const box = this.base.box;
1317
+ box.style.border.fg = "red";
1318
+ box.style.label = { fg: "red", bold: true };
1319
+ const message = `{bold}{red-fg}\u26A0\uFE0F DANGER: ACTIVE SSH SESSION DETECTED{/red-fg}{/bold}
1320
+
1321
+ Disabling the firewall may disconnect your session or leave your server
1322
+ vulnerable to lockouts!
1323
+
1324
+ {yellow-fg}Connection Info:{/yellow-fg} ${sshInfo.details || "SSH is active on port 22"}
1325
+
1326
+ {white-fg}Are you sure you want to proceed with disabling UFW?{/white-fg}`;
1327
+ blessed11.box({
1328
+ parent: box,
1329
+ top: 1,
1330
+ left: 2,
1331
+ width: "92%",
1332
+ height: 8,
1333
+ tags: true,
1334
+ content: message
1335
+ });
1336
+ this.errorLine = blessed11.box({
1337
+ parent: box,
1338
+ top: 9,
1339
+ left: 2,
1340
+ width: "92%",
1341
+ height: 2,
1342
+ tags: true,
1343
+ content: "",
1344
+ style: { fg: "red" }
1345
+ });
1346
+ this.cancelBtn = blessed11.button({
1347
+ parent: box,
1348
+ bottom: 1,
1349
+ left: 2,
1350
+ width: 25,
1351
+ height: 1,
1352
+ content: "[ Cancel (Keep Safe) ]",
1353
+ align: "center",
1354
+ style: {
1355
+ fg: "green",
1356
+ bold: true,
1357
+ focus: { fg: "black", bg: "green" }
1358
+ }
1359
+ });
1360
+ this.confirmBtn = blessed11.button({
1361
+ parent: box,
1362
+ bottom: 1,
1363
+ left: 30,
1364
+ width: 26,
1365
+ height: 1,
1366
+ content: "[ Force Disable Firewall ]",
1367
+ align: "center",
1368
+ style: {
1369
+ fg: "red",
1370
+ bold: true,
1371
+ focus: { fg: "white", bg: "red" }
1372
+ }
1373
+ });
1374
+ this.focusables = [this.cancelBtn, this.confirmBtn];
1375
+ this.focusIndex = 0;
1376
+ this.base.bindKey(["escape"], () => this.callbacks.onCancel());
1377
+ this.base.bindKey(["tab", "left", "right"], () => {
1378
+ this.focusIndex = 1 - this.focusIndex;
1379
+ this.focus();
1380
+ });
1381
+ this.base.bindKey(["enter"], () => {
1382
+ if (this.focusables[this.focusIndex] === this.confirmBtn) {
1383
+ void this.tryConfirm();
1384
+ } else {
1385
+ this.callbacks.onCancel();
1386
+ }
1387
+ });
1388
+ this.cancelBtn.on("press", () => this.callbacks.onCancel());
1389
+ this.confirmBtn.on("press", () => void this.tryConfirm());
1390
+ }
1391
+ screen;
1392
+ callbacks;
1393
+ base;
1394
+ cancelBtn;
1395
+ confirmBtn;
1396
+ focusables;
1397
+ focusIndex = 0;
1398
+ errorLine;
1399
+ async tryConfirm() {
1400
+ try {
1401
+ await this.callbacks.onConfirm();
1402
+ } catch (err) {
1403
+ this.errorLine.setContent(err instanceof Error ? err.message : String(err));
1404
+ this.screen.render();
1405
+ }
1406
+ }
1407
+ show() {
1408
+ this.base.show();
1409
+ }
1410
+ destroy() {
1411
+ this.base.destroy();
1412
+ }
1413
+ focus() {
1414
+ this.focusables[this.focusIndex].focus();
1415
+ this.screen.render();
1416
+ }
1417
+ };
1418
+
1419
+ // tui/components/LoggingModal.ts
1420
+ import blessed12 from "blessed";
1421
+ var LOG_OPTIONS = [
1422
+ {
1423
+ level: "low",
1424
+ label: "Low (Recommended)",
1425
+ description: "Logs all blocked packets (not matching default policy) and packets matching logged rules."
1426
+ },
1427
+ {
1428
+ level: "medium",
1429
+ label: "Medium",
1430
+ description: "Low + logs all allowed packets not matching policy, INVALID packets, and all new connections."
1431
+ },
1432
+ {
1433
+ level: "high",
1434
+ label: "High",
1435
+ description: "Medium + rate-limiting logs (very verbose; may produce high disk I/O)."
1436
+ },
1437
+ {
1438
+ level: "off",
1439
+ label: "Off (Disable Logging)",
1440
+ description: "Completely disables UFW firewall packet logging."
1441
+ }
1442
+ ];
1443
+ var LoggingModal = class {
1444
+ constructor(screen, currentLevel, callbacks) {
1445
+ this.screen = screen;
1446
+ this.callbacks = callbacks;
1447
+ this.base = new BaseModal(screen, {
1448
+ title: "Set UFW Logging Level [L]",
1449
+ width: "55%",
1450
+ height: 17
1451
+ });
1452
+ const box = this.base.box;
1453
+ const normCurrent = (currentLevel ?? "").toLowerCase();
1454
+ blessed12.box({
1455
+ parent: box,
1456
+ top: 0,
1457
+ left: 2,
1458
+ width: "90%",
1459
+ height: 1,
1460
+ tags: true,
1461
+ content: `{bold}Select firewall logging level:{/bold} Current: {yellow-fg}${currentLevel || "off"}{/yellow-fg}`
1462
+ });
1463
+ const items = LOG_OPTIONS.map((opt, i) => {
1464
+ const isCur = normCurrent.includes(opt.level);
1465
+ const marker = isCur ? "{green-fg}\u25CF{/green-fg} " : "\u25CB ";
1466
+ return `${i + 1}. ${marker}${opt.label}`;
1467
+ });
1468
+ this.list = blessed12.list({
1469
+ parent: box,
1470
+ top: 2,
1471
+ left: 2,
1472
+ width: "90%",
1473
+ height: 5,
1474
+ keys: true,
1475
+ vi: true,
1476
+ mouse: true,
1477
+ tags: true,
1478
+ items,
1479
+ style: {
1480
+ selected: { fg: "black", bg: "cyan", bold: true },
1481
+ item: { fg: "white" }
1482
+ }
1483
+ });
1484
+ this.descBox = blessed12.box({
1485
+ parent: box,
1486
+ top: 8,
1487
+ left: 2,
1488
+ width: "90%",
1489
+ height: 4,
1490
+ border: { type: "line" },
1491
+ style: { border: { fg: "gray" } },
1492
+ tags: true,
1493
+ content: `{gray-fg}${LOG_OPTIONS[0]?.description}{/gray-fg}`
1494
+ });
1495
+ this.errorLine = blessed12.box({
1496
+ parent: box,
1497
+ top: 12,
1498
+ left: 2,
1499
+ width: "90%",
1500
+ height: 1,
1501
+ tags: true,
1502
+ content: "",
1503
+ style: { fg: "red" }
1504
+ });
1505
+ blessed12.box({
1506
+ parent: box,
1507
+ top: 14,
1508
+ left: 2,
1509
+ width: "90%",
1510
+ height: 1,
1511
+ style: { fg: "gray" },
1512
+ content: "\u2191/\u2193: select Enter: apply Esc: cancel"
1513
+ });
1514
+ const initialIdx = LOG_OPTIONS.findIndex((o) => normCurrent.includes(o.level));
1515
+ this.list.select(initialIdx >= 0 ? initialIdx : 0);
1516
+ this.updateDescription();
1517
+ this.list.on("select item", () => this.updateDescription());
1518
+ this.list.key(["enter"], () => void this.trySubmit());
1519
+ this.list.on("select", () => void this.trySubmit());
1520
+ this.base.bindKey(["escape"], () => this.callbacks.onCancel());
1521
+ this.list.key(["1", "2", "3", "4"], (_ch, key) => {
1522
+ const idx = Number(key.name) - 1;
1523
+ if (idx >= 0 && idx < LOG_OPTIONS.length) {
1524
+ this.list.select(idx);
1525
+ this.updateDescription();
1526
+ void this.trySubmit();
1527
+ }
1528
+ });
1529
+ }
1530
+ screen;
1531
+ callbacks;
1532
+ base;
1533
+ list;
1534
+ descBox;
1535
+ errorLine;
1536
+ updateDescription() {
1537
+ const idx = this.list.selected;
1538
+ const opt = LOG_OPTIONS[idx];
1539
+ if (opt) {
1540
+ this.descBox.setContent(`{gray-fg}${opt.description}{/gray-fg}`);
1541
+ this.screen.render();
1542
+ }
1543
+ }
1544
+ async trySubmit() {
1545
+ const idx = this.list.selected;
1546
+ const opt = LOG_OPTIONS[idx];
1547
+ if (!opt) return;
1548
+ try {
1549
+ await this.callbacks.onSelect(opt.level);
1550
+ } catch (err) {
1551
+ this.errorLine.setContent(err instanceof Error ? err.message : String(err));
1552
+ this.screen.render();
1553
+ }
1554
+ }
1555
+ show() {
1556
+ this.base.show();
1557
+ }
1558
+ destroy() {
1559
+ this.base.destroy();
1560
+ }
1561
+ focus() {
1562
+ this.list.focus();
1563
+ this.screen.render();
1564
+ }
1565
+ };
1566
+
1567
+ // tui/components/AppProfilesModal.ts
1568
+ import blessed13 from "blessed";
1569
+ var ACTIONS2 = ["allow", "deny", "reject", "limit"];
1570
+ var AppProfilesModal = class {
1571
+ constructor(screen, profiles, callbacks) {
1572
+ this.screen = screen;
1573
+ this.callbacks = callbacks;
1574
+ this.base = new BaseModal(screen, {
1575
+ title: "Application Profiles [P]",
1576
+ width: "75%",
1577
+ height: "75%"
1578
+ });
1579
+ const box = this.base.box;
1580
+ this.allProfiles = [...profiles];
1581
+ this.filteredProfiles = [...profiles];
1582
+ blessed13.box({ parent: box, top: 0, left: 2, width: 8, height: 1, content: "Search:" });
1583
+ this.searchInput = blessed13.textbox({
1584
+ parent: box,
1585
+ top: 0,
1586
+ left: 11,
1587
+ width: "40%-11",
1588
+ height: 1,
1589
+ keys: true,
1590
+ style: { fg: "white", focus: { fg: "black", bg: "white" } }
1591
+ });
1592
+ this.profileList = blessed13.list({
1593
+ parent: box,
1594
+ top: 2,
1595
+ left: 2,
1596
+ width: "40%",
1597
+ height: "100%-7",
1598
+ border: { type: "line" },
1599
+ keys: true,
1600
+ vi: true,
1601
+ mouse: true,
1602
+ tags: true,
1603
+ items: this.filteredProfiles.length > 0 ? this.filteredProfiles : ["{gray-fg}No profiles found{/gray-fg}"],
1604
+ style: {
1605
+ selected: { fg: "black", bg: "cyan", bold: true },
1606
+ border: { fg: "gray" },
1607
+ item: { fg: "white" }
1608
+ }
1609
+ });
1610
+ this.detailBox = blessed13.box({
1611
+ parent: box,
1612
+ top: 0,
1613
+ left: "44%",
1614
+ width: "54%",
1615
+ height: "100%-7",
1616
+ border: { type: "line" },
1617
+ tags: true,
1618
+ style: { border: { fg: "gray" } },
1619
+ content: "{gray-fg}Select an application profile to view details...{/gray-fg}"
1620
+ });
1621
+ blessed13.box({
1622
+ parent: box,
1623
+ bottom: 2,
1624
+ left: 2,
1625
+ width: 8,
1626
+ height: 1,
1627
+ content: "Action:"
1628
+ });
1629
+ this.actionLabel = blessed13.box({
1630
+ parent: box,
1631
+ bottom: 2,
1632
+ left: 11,
1633
+ width: 34,
1634
+ height: 1,
1635
+ keys: true,
1636
+ content: this.formatActions(),
1637
+ style: { fg: "cyan" }
1638
+ });
1639
+ this.applyBtn = blessed13.button({
1640
+ parent: box,
1641
+ bottom: 2,
1642
+ left: 48,
1643
+ width: 14,
1644
+ height: 1,
1645
+ keys: true,
1646
+ content: "[ Apply Rule ]",
1647
+ align: "center",
1648
+ style: { fg: "green", focus: { fg: "black", bg: "green" } }
1649
+ });
1650
+ this.errorLine = blessed13.box({
1651
+ parent: box,
1652
+ bottom: 1,
1653
+ left: 2,
1654
+ width: "90%",
1655
+ height: 1,
1656
+ tags: true,
1657
+ content: "",
1658
+ style: { fg: "red" }
1659
+ });
1660
+ blessed13.box({
1661
+ parent: box,
1662
+ bottom: 0,
1663
+ left: 2,
1664
+ width: "90%",
1665
+ height: 1,
1666
+ style: { fg: "gray" },
1667
+ content: "Tab: switch controls \u2191/\u2193: select profile \u2190/\u2192: change action Enter: apply Esc: close"
1668
+ });
1669
+ this.focusables = [this.searchInput, this.profileList, this.actionLabel, this.applyBtn];
1670
+ this.bindEvents();
1671
+ if (this.filteredProfiles.length > 0) {
1672
+ void this.loadSelectedDetail();
1673
+ }
1674
+ }
1675
+ screen;
1676
+ callbacks;
1677
+ base;
1678
+ profileList;
1679
+ searchInput;
1680
+ detailBox;
1681
+ actionLabel;
1682
+ applyBtn;
1683
+ errorLine;
1684
+ focusables;
1685
+ focusIndex = 0;
1686
+ actionIndex = 0;
1687
+ allProfiles = [];
1688
+ filteredProfiles = [];
1689
+ infoCache = /* @__PURE__ */ new Map();
1690
+ formatActions() {
1691
+ return ACTIONS2.map((a, i) => i === this.actionIndex ? `[${a}]` : ` ${a} `).join(" ");
1692
+ }
1693
+ bindEvents() {
1694
+ this.base.bindKey(["escape"], () => {
1695
+ if (this.screen.focused === this.searchInput) this.searchInput.cancel();
1696
+ this.callbacks.onCancel();
1697
+ });
1698
+ this.searchInput.on("keypress", () => {
1699
+ setTimeout(() => this.filterList(this.searchInput.getValue()), 10);
1700
+ });
1701
+ this.searchInput.key(["enter"], () => {
1702
+ this.searchInput.cancel();
1703
+ this.moveFocus(1);
1704
+ });
1705
+ this.searchInput.key(["tab"], () => {
1706
+ this.searchInput.cancel();
1707
+ this.moveFocus(1);
1708
+ });
1709
+ this.searchInput.key(["S-tab"], () => {
1710
+ this.searchInput.cancel();
1711
+ this.moveFocus(-1);
1712
+ });
1713
+ this.profileList.on("select item", () => void this.loadSelectedDetail());
1714
+ this.profileList.key(["tab"], () => this.moveFocus(1));
1715
+ this.profileList.key(["S-tab"], () => this.moveFocus(-1));
1716
+ this.profileList.key(["enter"], () => void this.tryApply());
1717
+ this.actionLabel.key(["tab"], () => this.moveFocus(1));
1718
+ this.actionLabel.key(["S-tab"], () => this.moveFocus(-1));
1719
+ this.actionLabel.key(["left"], () => this.cycleAction(-1));
1720
+ this.actionLabel.key(["right"], () => this.cycleAction(1));
1721
+ this.actionLabel.key(["enter"], () => void this.tryApply());
1722
+ this.applyBtn.key(["tab"], () => this.moveFocus(1));
1723
+ this.applyBtn.key(["S-tab"], () => this.moveFocus(-1));
1724
+ this.applyBtn.key(["enter"], () => void this.tryApply());
1725
+ this.applyBtn.on("press", () => void this.tryApply());
1726
+ }
1727
+ filterList(query) {
1728
+ const q = query.trim().toLowerCase();
1729
+ if (!q) {
1730
+ this.filteredProfiles = [...this.allProfiles];
1731
+ } else {
1732
+ this.filteredProfiles = this.allProfiles.filter((p) => p.toLowerCase().includes(q));
1733
+ }
1734
+ if (this.filteredProfiles.length === 0) {
1735
+ this.profileList.setItems(["{gray-fg}No matching profiles{/gray-fg}"]);
1736
+ this.detailBox.setContent("{gray-fg}No profile selected{/gray-fg}");
1737
+ } else {
1738
+ this.profileList.setItems(this.filteredProfiles);
1739
+ this.profileList.select(0);
1740
+ void this.loadSelectedDetail();
1741
+ }
1742
+ this.screen.render();
1743
+ }
1744
+ getSelectedProfile() {
1745
+ if (this.filteredProfiles.length === 0) return void 0;
1746
+ const idx = this.profileList.selected;
1747
+ return this.filteredProfiles[idx];
1748
+ }
1749
+ async loadSelectedDetail() {
1750
+ const appName = this.getSelectedProfile();
1751
+ if (!appName) return;
1752
+ let info = this.infoCache.get(appName);
1753
+ if (!info) {
1754
+ this.detailBox.setContent(`{gray-fg}Loading details for ${appName}...{/gray-fg}`);
1755
+ this.screen.render();
1756
+ try {
1757
+ info = await this.callbacks.onLoadInfo(appName);
1758
+ this.infoCache.set(appName, info);
1759
+ } catch (err) {
1760
+ this.detailBox.setContent(`{red-fg}Failed to load profile: ${err instanceof Error ? err.message : String(err)}{/red-fg}`);
1761
+ this.screen.render();
1762
+ return;
1763
+ }
1764
+ }
1765
+ const lines = [
1766
+ `{bold}{cyan-fg}Profile:{/cyan-fg} ${info.name}{/bold}`,
1767
+ `\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
1768
+ info.title ? ` {bold}Title:{/bold} ${info.title}` : "",
1769
+ info.description ? ` {bold}Description:{/bold} ${info.description}` : "",
1770
+ ` {bold}Ports:{/bold} {green-fg}${info.ports || "N/A"}{/green-fg}`,
1771
+ "",
1772
+ `{bold}CLI Command Equivalent:{/bold}`,
1773
+ ` {green-fg}$ sudo ufw ${ACTIONS2[this.actionIndex]} "${info.name}"{/green-fg}`
1774
+ ].filter(Boolean);
1775
+ this.detailBox.setContent(lines.join("\n"));
1776
+ this.screen.render();
1777
+ }
1778
+ cycleAction(delta) {
1779
+ this.actionIndex = (this.actionIndex + delta + ACTIONS2.length) % ACTIONS2.length;
1780
+ this.actionLabel.setContent(this.formatActions());
1781
+ void this.loadSelectedDetail();
1782
+ this.screen.render();
1783
+ }
1784
+ moveFocus(delta) {
1785
+ this.focusIndex = (this.focusIndex + delta + this.focusables.length) % this.focusables.length;
1786
+ this.focus();
1787
+ }
1788
+ async tryApply() {
1789
+ const appName = this.getSelectedProfile();
1790
+ if (!appName) {
1791
+ this.errorLine.setContent("{red-fg}No application profile selected.{/red-fg}");
1792
+ this.screen.render();
1793
+ return;
1794
+ }
1795
+ this.errorLine.setContent("");
1796
+ const action = ACTIONS2[this.actionIndex];
1797
+ try {
1798
+ await this.callbacks.onApply(appName, action);
1799
+ } catch (err) {
1800
+ this.errorLine.setContent(`{red-fg}Error: ${err instanceof Error ? err.message : String(err)}{/red-fg}`);
1801
+ this.screen.render();
1802
+ }
1803
+ }
1804
+ show() {
1805
+ this.base.show();
1806
+ }
1807
+ destroy() {
1808
+ this.base.destroy();
1809
+ }
1810
+ focus() {
1811
+ const current = this.focusables[this.focusIndex];
1812
+ current.focus();
1813
+ if (current === this.searchInput) {
1814
+ this.searchInput.readInput();
1815
+ }
1816
+ this.screen.render();
1817
+ }
1818
+ };
1819
+
1820
+ // tui/components/ActionMenuModal.ts
1821
+ import blessed14 from "blessed";
1822
+ var ActionMenuModal = class {
1823
+ constructor(screen, items, onClose) {
1824
+ this.screen = screen;
1825
+ this.items = items;
1826
+ this.onClose = onClose;
1827
+ const itemCount = items.length;
1828
+ const boxHeight = itemCount + 3;
1829
+ this.base = new BaseModal(screen, {
1830
+ title: "Actions (Lazydocker Menu)",
1831
+ width: "50%",
1832
+ height: boxHeight
1833
+ });
1834
+ const box = this.base.box;
1835
+ const renderedItems = items.map(
1836
+ (item) => `{bold}{cyan-fg}[${item.key}]{/cyan-fg} ${item.label.padEnd(20)}{/bold} {gray-fg}${item.description}{/gray-fg}`
1837
+ );
1838
+ this.list = blessed14.list({
1839
+ parent: box,
1840
+ top: 0,
1841
+ left: 1,
1842
+ width: "96%",
1843
+ height: itemCount,
1844
+ keys: true,
1845
+ mouse: true,
1846
+ tags: true,
1847
+ style: {
1848
+ selected: { fg: "black", bg: theme.border.focus, bold: true },
1849
+ item: { fg: "white" }
1850
+ },
1851
+ items: renderedItems
1852
+ });
1853
+ blessed14.box({
1854
+ parent: box,
1855
+ top: itemCount,
1856
+ left: 2,
1857
+ width: "90%",
1858
+ height: 1,
1859
+ content: "{gray-fg}Enter/Key: run action | Esc: close menu{/gray-fg}",
1860
+ tags: true
1861
+ });
1862
+ this.base.bindKey(["escape"], () => this.onClose());
1863
+ this.list.on("select", (_item, index) => {
1864
+ const chosen = this.items[index];
1865
+ this.onClose();
1866
+ if (chosen) void chosen.action();
1867
+ });
1868
+ for (const item of items) {
1869
+ const keys = [item.key];
1870
+ if (item.key === item.key.toUpperCase() && item.key !== item.key.toLowerCase()) {
1871
+ keys.push(`S-${item.key.toLowerCase()}`);
1872
+ } else if (item.key === item.key.toLowerCase() && item.key !== item.key.toUpperCase()) {
1873
+ keys.push(item.key.toLowerCase());
1874
+ }
1875
+ if (item.key === "?") {
1876
+ keys.push("S-/", "h", "S-h", "f1");
1877
+ }
1878
+ this.base.bindKey(keys, () => {
1879
+ this.onClose();
1880
+ void item.action();
1881
+ });
1882
+ }
1883
+ }
1884
+ screen;
1885
+ items;
1886
+ onClose;
1887
+ base;
1888
+ list;
1889
+ show() {
1890
+ this.base.show();
1891
+ }
1892
+ destroy() {
1893
+ this.base.destroy();
1894
+ }
1895
+ focus() {
1896
+ this.list.focus();
1897
+ this.screen.render();
1898
+ }
1899
+ };
1900
+
1901
+ // utils/exec.ts
1902
+ import { execFile } from "child_process";
1903
+ import { promisify } from "util";
1904
+ var execFileAsync = promisify(execFile);
1905
+ var execute = async (command, args) => {
1906
+ try {
1907
+ const result = await execFileAsync(command, args);
1908
+ return {
1909
+ stdout: result.stdout,
1910
+ stderr: result.stderr,
1911
+ code: 0
1912
+ };
1913
+ } catch (error) {
1914
+ return {
1915
+ stdout: error.stdout ?? "",
1916
+ stderr: error.stderr || error.message || "",
1917
+ code: typeof error.code === "number" ? error.code : 1
1918
+ };
1919
+ }
1920
+ };
1921
+ var exec_default = execute;
1922
+
1923
+ // security/sshProtection.ts
1924
+ function parseSshEnv(env = process.env) {
1925
+ const client = env.SSH_CLIENT;
1926
+ const connection = env.SSH_CONNECTION;
1927
+ const tty = env.SSH_TTY;
1928
+ if (client) {
1929
+ const [clientIp, clientPort, serverPort] = client.trim().split(/\s+/);
1930
+ return {
1931
+ isActive: true,
1932
+ clientIp,
1933
+ clientPort,
1934
+ serverPort: serverPort || "22",
1935
+ tty,
1936
+ details: `Active SSH session from ${clientIp}:${clientPort || "unknown"} (port ${serverPort || "22"})${tty ? ` on ${tty}` : ""}`
1937
+ };
1938
+ }
1939
+ if (connection) {
1940
+ const [clientIp, clientPort, , serverPort] = connection.trim().split(/\s+/);
1941
+ return {
1942
+ isActive: true,
1943
+ clientIp,
1944
+ clientPort,
1945
+ serverPort: serverPort || "22",
1946
+ tty,
1947
+ details: `Active SSH connection from ${clientIp}:${clientPort || "unknown"} (port ${serverPort || "22"})`
1948
+ };
1949
+ }
1950
+ if (tty) {
1951
+ return {
1952
+ isActive: true,
1953
+ tty,
1954
+ details: `Active SSH TTY ${tty}`
1955
+ };
1956
+ }
1957
+ return { isActive: false };
1958
+ }
1959
+ async function detectActiveSshSession(env = process.env) {
1960
+ const envInfo = parseSshEnv(env);
1961
+ if (envInfo.isActive) {
1962
+ return envInfo;
1963
+ }
1964
+ try {
1965
+ const ssRes = await exec_default("ss", ["-tlpn", "sport = :22"]);
1966
+ if (ssRes.code === 0 && ssRes.stdout.includes(":22")) {
1967
+ return {
1968
+ isActive: true,
1969
+ serverPort: "22",
1970
+ details: "SSH service is listening on port 22"
1971
+ };
1972
+ }
1973
+ } catch {
1974
+ }
1975
+ try {
1976
+ const systemctlRes = await exec_default("systemctl", ["is-active", "ssh"]);
1977
+ if (systemctlRes.code === 0 && systemctlRes.stdout.trim() === "active") {
1978
+ return {
1979
+ isActive: true,
1980
+ serverPort: "22",
1981
+ details: "SSH systemd service is active"
1982
+ };
1983
+ }
1984
+ } catch {
1985
+ }
1986
+ return { isActive: false };
1987
+ }
1988
+
1989
+ // tui/layout.ts
1990
+ var LayoutManager = class {
1991
+ constructor(screen, panels) {
1992
+ this.screen = screen;
1993
+ this.panels = panels;
1994
+ this.screen.on("resize", () => {
1995
+ this.apply(false);
1996
+ });
1997
+ }
1998
+ screen;
1999
+ panels;
2000
+ maximized = null;
2001
+ leftActive = "rules";
2002
+ rightActive = "detail";
2003
+ animTimer = null;
2004
+ currentGeometries = /* @__PURE__ */ new Map();
2005
+ find(id) {
2006
+ const panel = this.panels.find((p) => p.id === id);
2007
+ if (!panel) throw new Error(`Unknown panel: ${id}`);
2008
+ return panel;
2009
+ }
2010
+ setLabel(panel, suffix = "") {
2011
+ panel.widget.setLabel(
2012
+ ` ${panel.baseLabel}${suffix} `
2013
+ );
2014
+ }
2015
+ computeTargets() {
2016
+ const targets = /* @__PURE__ */ new Map();
2017
+ const screenW = Number(this.screen.width) || 80;
2018
+ const screenH = Number(this.screen.height) || 24;
2019
+ const usableTop = 3;
2020
+ const usableH = Math.max(12, screenH - 6);
2021
+ const leftW = Math.floor(screenW / 2);
2022
+ const rightW = screenW - leftW;
2023
+ const spineH = Math.max(3, Math.min(5, Math.floor(usableH / 4)));
2024
+ if (this.leftActive === "rules") {
2025
+ targets.set("status", {
2026
+ top: usableTop,
2027
+ left: 0,
2028
+ width: leftW,
2029
+ height: spineH + 1
2030
+ });
2031
+ targets.set("rules", {
2032
+ top: usableTop + spineH - 1,
2033
+ left: 0,
2034
+ width: leftW,
2035
+ height: usableH - (spineH - 1)
2036
+ });
2037
+ } else {
2038
+ targets.set("status", {
2039
+ top: usableTop,
2040
+ left: 0,
2041
+ width: leftW,
2042
+ height: usableH - spineH
2043
+ });
2044
+ targets.set("rules", {
2045
+ top: usableTop + usableH - spineH,
2046
+ left: 0,
2047
+ width: leftW,
2048
+ height: spineH
2049
+ });
2050
+ }
2051
+ if (this.rightActive === "detail") {
2052
+ targets.set("raw", {
2053
+ top: usableTop,
2054
+ left: leftW,
2055
+ width: rightW,
2056
+ height: spineH + 1
2057
+ });
2058
+ targets.set("detail", {
2059
+ top: usableTop + spineH - 1,
2060
+ left: leftW,
2061
+ width: rightW,
2062
+ height: usableH - (spineH - 1)
2063
+ });
2064
+ } else {
2065
+ targets.set("raw", {
2066
+ top: usableTop,
2067
+ left: leftW,
2068
+ width: rightW,
2069
+ height: usableH - spineH
2070
+ });
2071
+ targets.set("detail", {
2072
+ top: usableTop + usableH - spineH,
2073
+ left: leftW,
2074
+ width: rightW,
2075
+ height: spineH
2076
+ });
2077
+ }
2078
+ return targets;
2079
+ }
2080
+ applyGeometry(panel, geom) {
2081
+ panel.widget.top = geom.top;
2082
+ panel.widget.left = geom.left;
2083
+ panel.widget.width = geom.width;
2084
+ panel.widget.height = geom.height;
2085
+ }
2086
+ orderFront() {
2087
+ const status = this.find("status");
2088
+ const rules = this.find("rules");
2089
+ const raw = this.find("raw");
2090
+ const detail = this.find("detail");
2091
+ if (this.leftActive === "rules") {
2092
+ status.widget.setFront();
2093
+ rules.widget.setFront();
2094
+ } else {
2095
+ rules.widget.setFront();
2096
+ status.widget.setFront();
2097
+ }
2098
+ if (this.rightActive === "detail") {
2099
+ raw.widget.setFront();
2100
+ detail.widget.setFront();
2101
+ } else {
2102
+ detail.widget.setFront();
2103
+ raw.widget.setFront();
2104
+ }
2105
+ }
2106
+ apply(animate = false) {
2107
+ if (this.animTimer) {
2108
+ clearTimeout(this.animTimer);
2109
+ this.animTimer = null;
2110
+ }
2111
+ if (this.maximized) {
2112
+ for (const p of this.panels) {
2113
+ if (p.id === this.maximized) {
2114
+ p.widget.top = 3;
2115
+ p.widget.left = 0;
2116
+ p.widget.width = "100%";
2117
+ p.widget.height = "100%-6";
2118
+ p.widget.show();
2119
+ p.widget.setFront();
2120
+ this.setLabel(p, " [max]");
2121
+ } else {
2122
+ p.widget.hide();
2123
+ }
2124
+ }
2125
+ this.screen.render();
2126
+ return;
2127
+ }
2128
+ for (const p of this.panels) {
2129
+ p.widget.show();
2130
+ this.setLabel(p, "");
2131
+ }
2132
+ const targets = this.computeTargets();
2133
+ if (!animate || this.currentGeometries.size === 0) {
2134
+ for (const p of this.panels) {
2135
+ const geom = targets.get(p.id);
2136
+ this.applyGeometry(p, geom);
2137
+ this.currentGeometries.set(p.id, { ...geom });
2138
+ }
2139
+ this.orderFront();
2140
+ this.screen.render();
2141
+ return;
2142
+ }
2143
+ const starts = /* @__PURE__ */ new Map();
2144
+ for (const p of this.panels) {
2145
+ const cur = this.currentGeometries.get(p.id) ?? targets.get(p.id);
2146
+ starts.set(p.id, { ...cur });
2147
+ }
2148
+ this.orderFront();
2149
+ const totalFrames = 7;
2150
+ const frameIntervalMs = 15;
2151
+ let currentFrame = 0;
2152
+ const step = () => {
2153
+ currentFrame++;
2154
+ const progress = currentFrame / totalFrames;
2155
+ const ease = 1 - Math.pow(1 - progress, 3);
2156
+ for (const p of this.panels) {
2157
+ const start = starts.get(p.id);
2158
+ const target = targets.get(p.id);
2159
+ const inter = {
2160
+ top: Math.round(start.top + (target.top - start.top) * ease),
2161
+ left: Math.round(start.left + (target.left - start.left) * ease),
2162
+ width: Math.round(start.width + (target.width - start.width) * ease),
2163
+ height: Math.round(start.height + (target.height - start.height) * ease)
2164
+ };
2165
+ this.applyGeometry(p, inter);
2166
+ this.currentGeometries.set(p.id, inter);
2167
+ }
2168
+ this.screen.render();
2169
+ if (currentFrame < totalFrames) {
2170
+ this.animTimer = setTimeout(step, frameIntervalMs);
2171
+ } else {
2172
+ for (const p of this.panels) {
2173
+ const finalGeom = targets.get(p.id);
2174
+ this.applyGeometry(p, finalGeom);
2175
+ this.currentGeometries.set(p.id, { ...finalGeom });
2176
+ }
2177
+ this.orderFront();
2178
+ this.screen.render();
2179
+ this.animTimer = null;
2180
+ }
2181
+ };
2182
+ this.animTimer = setTimeout(step, frameIntervalMs);
2183
+ }
2184
+ /**
2185
+ * Activates a panel and smoothly animates its stack to bring the book forward.
2186
+ */
2187
+ activatePanel(id, animate = true) {
2188
+ let changed = false;
2189
+ if (id === "status" && this.leftActive !== "status") {
2190
+ this.leftActive = "status";
2191
+ changed = true;
2192
+ } else if (id === "rules" && this.leftActive !== "rules") {
2193
+ this.leftActive = "rules";
2194
+ changed = true;
2195
+ } else if (id === "raw" && this.rightActive !== "raw") {
2196
+ this.rightActive = "raw";
2197
+ changed = true;
2198
+ } else if (id === "detail" && this.rightActive !== "detail") {
2199
+ this.rightActive = "detail";
2200
+ changed = true;
2201
+ }
2202
+ if (this.maximized !== null) {
2203
+ this.maximized = id;
2204
+ this.apply(false);
2205
+ return;
2206
+ }
2207
+ if (changed || this.currentGeometries.size === 0) {
2208
+ this.apply(animate);
2209
+ } else {
2210
+ this.orderFront();
2211
+ this.screen.render();
2212
+ }
2213
+ }
2214
+ toggleMaximize(id) {
2215
+ this.maximized = this.maximized === id ? null : id;
2216
+ this.apply(false);
2217
+ }
2218
+ restoreSplit() {
2219
+ if (this.maximized !== null) {
2220
+ this.maximized = null;
2221
+ this.apply(false);
2222
+ }
2223
+ }
2224
+ isMaximized() {
2225
+ return this.maximized !== null;
2226
+ }
2227
+ };
2228
+
2229
+ // tui/dashboard.ts
2230
+ var Dashboard = class {
2231
+ constructor(client) {
2232
+ this.client = client;
2233
+ this.screen = blessed15.screen({
2234
+ smartCSR: true,
2235
+ title: "lazyufw - Lazydocker Style UFW Manager",
2236
+ ignoreLocked: ["escape", "tab", "S-tab", "C-c"]
2237
+ });
2238
+ this.header = createHeader();
2239
+ this.footer = createFooter();
2240
+ this.statusPanel = new StatusPanel();
2241
+ this.rulesPanel = new RulesPanel();
2242
+ this.rawPanel = new RawPanel();
2243
+ this.detailPanel = new DetailPanel();
2244
+ this.focusManager = new FocusManager(this.screen);
2245
+ this.layout = new LayoutManager(this.screen, [
2246
+ { id: "status", widget: this.statusPanel.widget, baseLabel: "[1] Status" },
2247
+ { id: "rules", widget: this.rulesPanel.widget, baseLabel: "[2] Rules" },
2248
+ { id: "raw", widget: this.rawPanel.widget, baseLabel: "[3] Raw Output" },
2249
+ { id: "detail", widget: this.detailPanel.widget, baseLabel: "[4] Detail" }
2250
+ ]);
2251
+ this.screen.append(this.header);
2252
+ this.screen.append(this.statusPanel.widget);
2253
+ this.screen.append(this.rulesPanel.widget);
2254
+ this.screen.append(this.rawPanel.widget);
2255
+ this.screen.append(this.detailPanel.widget);
2256
+ this.screen.append(this.footer);
2257
+ this.rulesPanel.widget.on("select item", () => this.renderDetail());
2258
+ this.bindGlobalKeys();
2259
+ this.layout.apply();
2260
+ }
2261
+ client;
2262
+ screen;
2263
+ header;
2264
+ footer;
2265
+ statusPanel;
2266
+ rulesPanel;
2267
+ rawPanel;
2268
+ detailPanel;
2269
+ layout;
2270
+ focusManager;
2271
+ focusOrder = ["status", "rules", "raw", "detail"];
2272
+ focusIndex = 1;
2273
+ // Start on rules panel
2274
+ transientMessage = "";
2275
+ transientTimer = null;
2276
+ firewallActive = false;
2277
+ loggingOn = false;
2278
+ currentLoggingLevel = "off";
2279
+ statusUnavailable = false;
2280
+ bindGlobalKeys() {
2281
+ const guarded = (handler) => () => {
2282
+ if (this.focusManager.isModalOpen) return;
2283
+ void handler();
2284
+ };
2285
+ const switchTo = (id) => {
2286
+ const targetIndex = this.focusOrder.indexOf(id);
2287
+ if (this.layout.isMaximized()) {
2288
+ if (this.focusIndex === targetIndex) {
2289
+ this.layout.restoreSplit();
2290
+ } else {
2291
+ this.focusPanel(targetIndex, false);
2292
+ this.layout.toggleMaximize(id);
2293
+ }
2294
+ } else {
2295
+ if (this.focusIndex === targetIndex) {
2296
+ this.layout.toggleMaximize(id);
2297
+ } else {
2298
+ this.focusPanel(targetIndex, true);
2299
+ }
2300
+ }
2301
+ };
2302
+ this.screen.key(["1"], guarded(() => switchTo("status")));
2303
+ this.screen.key(["2"], guarded(() => switchTo("rules")));
2304
+ this.screen.key(["3"], guarded(() => switchTo("raw")));
2305
+ this.screen.key(["4"], guarded(() => switchTo("detail")));
2306
+ this.screen.key(["tab"], guarded(() => this.cycleFocus(1)));
2307
+ this.screen.key(["S-tab"], guarded(() => this.cycleFocus(-1)));
2308
+ this.screen.key(["escape"], guarded(() => this.layout.restoreSplit()));
2309
+ this.screen.key(["a"], guarded(() => this.openAddModal()));
2310
+ this.screen.key(["P", "S-p"], guarded(() => this.openAppProfilesModal()));
2311
+ this.screen.key(["i"], guarded(() => this.openInsertModal()));
2312
+ this.screen.key(["d"], guarded(() => this.openDeleteModal()));
2313
+ this.screen.key(["e"], guarded(() => this.enableFirewall()));
2314
+ this.screen.key(["D", "S-d"], guarded(() => this.handleDisableFirewall()));
2315
+ this.screen.key(["L", "S-l"], guarded(() => this.openLoggingModal()));
2316
+ this.screen.key(["l"], guarded(() => this.toggleLogging()));
2317
+ this.screen.key(["R", "S-r"], guarded(() => this.openResetModal()));
2318
+ this.screen.key(["x"], guarded(() => this.openActionMenu()));
2319
+ this.screen.key(["r"], guarded(() => this.refresh()));
2320
+ this.screen.key(["s"], guarded(() => this.handleSetupSudo()));
2321
+ this.screen.key(["?", "S-/", "h", "S-h", "f1"], guarded(() => this.openHelpModal()));
2322
+ this.screen.key(["q", "C-c"], guarded(() => process.exit(0)));
2323
+ this.screen.on("keypress", (ch, key) => {
2324
+ if (this.focusManager.isModalOpen) return;
2325
+ if (ch === "D" || key && (key.full === "S-d" || key.name === "d" && key.shift)) {
2326
+ void this.handleDisableFirewall();
2327
+ return;
2328
+ }
2329
+ if (ch === "L" || key && (key.full === "S-l" || key.name === "l" && key.shift)) {
2330
+ void this.openLoggingModal();
2331
+ return;
2332
+ }
2333
+ if (ch === "P" || key && (key.full === "S-p" || key.name === "p" && key.shift)) {
2334
+ void this.openAppProfilesModal();
2335
+ return;
2336
+ }
2337
+ if (ch === "R" || key && (key.full === "S-r" || key.name === "r" && key.shift)) {
2338
+ void this.openResetModal();
2339
+ return;
2340
+ }
2341
+ if (ch === "?" || key && (key.full === "?" || key.full === "S-/" || key.name === "?" || key.sequence === "?")) {
2342
+ void this.openHelpModal();
2343
+ return;
2344
+ }
2345
+ });
2346
+ this.focusPanel(this.focusIndex, false);
2347
+ }
2348
+ toggleMaximize(id) {
2349
+ this.focusIndex = this.focusOrder.indexOf(id);
2350
+ this.layout.toggleMaximize(id);
2351
+ this.focusPanel(this.focusIndex, false);
2352
+ }
2353
+ cycleFocus(delta) {
2354
+ const nextIndex = (this.focusIndex + delta + this.focusOrder.length) % this.focusOrder.length;
2355
+ if (this.layout.isMaximized()) {
2356
+ this.layout.restoreSplit();
2357
+ }
2358
+ this.focusPanel(nextIndex, true);
2359
+ }
2360
+ focusPanel(index, animate = true) {
2361
+ this.focusIndex = index;
2362
+ const id = this.focusOrder[index];
2363
+ this.layout.activatePanel(id, animate);
2364
+ if (id === "status") this.statusPanel.focus();
2365
+ else if (id === "rules") this.rulesPanel.focus();
2366
+ else if (id === "raw") this.rawPanel.focus();
2367
+ else this.detailPanel.focus();
2368
+ this.screen.render();
2369
+ }
2370
+ renderDetail() {
2371
+ this.detailPanel.render(this.rulesPanel.getSelectedRule());
2372
+ this.screen.render();
2373
+ }
2374
+ setTransientMessage(message, ttlMs = 2500) {
2375
+ this.transientMessage = message;
2376
+ this.renderFooterStatus();
2377
+ if (this.transientTimer) clearTimeout(this.transientTimer);
2378
+ if (ttlMs > 0) {
2379
+ this.transientTimer = setTimeout(() => {
2380
+ this.transientMessage = "";
2381
+ this.transientTimer = null;
2382
+ this.renderFooterStatus();
2383
+ }, ttlMs);
2384
+ }
2385
+ }
2386
+ renderFooterStatus() {
2387
+ if (this.focusManager.isModalOpen) return;
2388
+ if (this.transientMessage) {
2389
+ setFooterHint(this.footer, this.transientMessage);
2390
+ } else {
2391
+ resetFooterHint(this.footer);
2392
+ }
2393
+ this.screen.render();
2394
+ }
2395
+ renderHeader() {
2396
+ renderHeaderStatus(this.header, {
2397
+ firewallActive: this.firewallActive,
2398
+ loggingOn: this.loggingOn,
2399
+ ruleCount: this.rulesPanel.count,
2400
+ statusUnavailable: this.statusUnavailable
2401
+ });
2402
+ }
2403
+ async refresh() {
2404
+ this.rulesPanel.setLoading();
2405
+ this.setTransientMessage("Refreshing firewall status...", 0);
2406
+ this.screen.render();
2407
+ try {
2408
+ const [statusRes, rulesRes, rawRes] = await Promise.all([
2409
+ this.client.status(),
2410
+ this.client.numberedRules(),
2411
+ this.client.rawRules()
2412
+ ]);
2413
+ if (statusRes.code !== 0) {
2414
+ this.statusUnavailable = true;
2415
+ } else {
2416
+ this.statusUnavailable = false;
2417
+ const parsedStatus = parseStatus(statusRes.stdout);
2418
+ this.firewallActive = parsedStatus.status === "active";
2419
+ this.loggingOn = Boolean(parsedStatus.logging && parsedStatus.logging.toLowerCase().includes("on"));
2420
+ this.currentLoggingLevel = parsedStatus.logging ?? "off";
2421
+ const parsedRules = parseRules(rulesRes.stdout);
2422
+ this.rulesPanel.setRules(parsedRules);
2423
+ this.statusPanel.setStatus({
2424
+ ...parsedStatus,
2425
+ rules: parsedRules
2426
+ }, isSudoConfigured());
2427
+ }
2428
+ this.rawPanel.setContent(rawRes.stdout || statusRes.stdout || "");
2429
+ this.renderHeader();
2430
+ this.renderDetail();
2431
+ this.transientMessage = "";
2432
+ this.renderFooterStatus();
2433
+ } catch (err) {
2434
+ this.statusUnavailable = true;
2435
+ this.renderHeader();
2436
+ this.setTransientMessage(`Refresh failed: ${err instanceof Error ? err.message : String(err)}`, 4e3);
2437
+ }
2438
+ }
2439
+ // --- Actions ---
2440
+ async enableFirewall() {
2441
+ try {
2442
+ this.setTransientMessage("Enabling firewall...", 0);
2443
+ await this.client.assertOk(this.client.enable());
2444
+ await this.refresh();
2445
+ this.setTransientMessage("Firewall ENABLED successfully!");
2446
+ } catch (err) {
2447
+ this.setTransientMessage(`Failed to enable: ${err instanceof Error ? err.message : String(err)}`, 4e3);
2448
+ }
2449
+ }
2450
+ async handleDisableFirewall() {
2451
+ const sshInfo = await detectActiveSshSession();
2452
+ if (sshInfo.isActive) {
2453
+ const modal2 = new SshWarningModal(this.screen, sshInfo, {
2454
+ onCancel: () => {
2455
+ resetFooterHint(this.footer);
2456
+ this.focusManager.closeTop();
2457
+ this.setTransientMessage("Firewall disable canceled (SSH protected).");
2458
+ },
2459
+ onConfirm: async () => {
2460
+ this.focusManager.closeTop();
2461
+ await this.client.assertOk(this.client.disable());
2462
+ await this.refresh();
2463
+ this.setTransientMessage("Firewall DISABLED.");
2464
+ }
2465
+ });
2466
+ setFooterHint(this.footer, "\u26A0\uFE0F Active SSH Session! Tab: choose | Enter: confirm | Esc: cancel");
2467
+ this.focusManager.open(modal2);
2468
+ return;
2469
+ }
2470
+ const modal = new ConfirmModal(
2471
+ this.screen,
2472
+ {
2473
+ title: "Disable Firewall",
2474
+ message: "Are you sure you want to disable the UFW firewall?",
2475
+ confirmLabel: "Disable",
2476
+ dangerous: true
2477
+ },
2478
+ {
2479
+ onCancel: () => {
2480
+ resetFooterHint(this.footer);
2481
+ this.focusManager.closeTop();
2482
+ },
2483
+ onConfirm: async () => {
2484
+ this.focusManager.closeTop();
2485
+ await this.client.assertOk(this.client.disable());
2486
+ await this.refresh();
2487
+ this.setTransientMessage("Firewall DISABLED.");
2488
+ }
2489
+ }
2490
+ );
2491
+ setFooterHint(this.footer, "Tab/\u2190\u2192: choose | Enter: confirm | Esc: cancel");
2492
+ this.focusManager.open(modal);
2493
+ }
2494
+ async toggleLogging() {
2495
+ try {
2496
+ this.setTransientMessage("Toggling logging...", 0);
2497
+ if (this.loggingOn) {
2498
+ await this.client.assertOk(this.client.disableLog());
2499
+ this.setTransientMessage("Logging turned OFF.");
2500
+ } else {
2501
+ await this.client.assertOk(this.client.enableLog());
2502
+ this.setTransientMessage("Logging turned ON.");
2503
+ }
2504
+ await this.refresh();
2505
+ } catch (err) {
2506
+ this.setTransientMessage(`Log toggle failed: ${err instanceof Error ? err.message : String(err)}`, 4e3);
2507
+ }
2508
+ }
2509
+ openLoggingModal() {
2510
+ const modal = new LoggingModal(
2511
+ this.screen,
2512
+ this.currentLoggingLevel,
2513
+ {
2514
+ onCancel: () => {
2515
+ resetFooterHint(this.footer);
2516
+ this.focusManager.closeTop();
2517
+ },
2518
+ onSelect: async (level) => {
2519
+ await this.client.assertOk(this.client.setLogging(level));
2520
+ resetFooterHint(this.footer);
2521
+ this.focusManager.closeTop();
2522
+ await this.refresh();
2523
+ this.setTransientMessage(`Logging level set to ${level}.`);
2524
+ }
2525
+ }
2526
+ );
2527
+ setFooterHint(this.footer, "\u2191/\u2193: select | Enter: apply | Esc: cancel");
2528
+ this.focusManager.open(modal);
2529
+ }
2530
+ async openAppProfilesModal() {
2531
+ let profiles = [];
2532
+ try {
2533
+ const res = await this.client.listAppProfiles();
2534
+ if (res.code === 0) {
2535
+ profiles = parseAppList(res.stdout);
2536
+ }
2537
+ } catch {
2538
+ }
2539
+ if (profiles.length === 0) {
2540
+ profiles = getLocalAppProfiles();
2541
+ }
2542
+ const modal = new AppProfilesModal(
2543
+ this.screen,
2544
+ profiles,
2545
+ {
2546
+ onCancel: () => {
2547
+ resetFooterHint(this.footer);
2548
+ this.focusManager.closeTop();
2549
+ },
2550
+ onLoadInfo: async (appName) => {
2551
+ const res = await this.client.appProfileInfo(appName);
2552
+ if (res.code !== 0) {
2553
+ throw new Error(res.stderr.trim() || `Failed to load profile: ${appName}`);
2554
+ }
2555
+ const info = parseAppInfo(res.stdout);
2556
+ return { name: appName, ...info };
2557
+ },
2558
+ onApply: async (appName, action) => {
2559
+ await this.client.assertOk(this.client.allowApp(appName, action));
2560
+ resetFooterHint(this.footer);
2561
+ this.focusManager.closeTop();
2562
+ await this.refresh();
2563
+ this.setTransientMessage(`Applied ${action} rule for app "${appName}".`);
2564
+ }
2565
+ }
2566
+ );
2567
+ setFooterHint(this.footer, "Tab: switch | \u2191\u2193: profile | \u2190\u2192: action | Enter: apply | Esc: close");
2568
+ this.focusManager.open(modal);
2569
+ }
2570
+ openAddModal() {
2571
+ const modal = new AddRuleModal(this.screen, {
2572
+ onCancel: () => {
2573
+ resetFooterHint(this.footer);
2574
+ this.focusManager.closeTop();
2575
+ },
2576
+ onSubmit: async (input) => {
2577
+ await this.client.assertOk(this.client.createRule(input));
2578
+ resetFooterHint(this.footer);
2579
+ this.focusManager.closeTop();
2580
+ await this.refresh();
2581
+ this.setTransientMessage(`Added rule: ${input.action} ${input.port}${input.protocol ? "/" + input.protocol : ""}`);
2582
+ }
2583
+ });
2584
+ setFooterHint(this.footer, "Tab: next | \u2190\u2192 option | Enter: submit | Esc: cancel");
2585
+ this.focusManager.open(modal);
2586
+ }
2587
+ openInsertModal() {
2588
+ const currentRule = this.rulesPanel.getSelectedRule();
2589
+ const nextPosition = currentRule ? currentRule.id : 1;
2590
+ const modal = new InsertRuleModal(this.screen, nextPosition, {
2591
+ onCancel: () => {
2592
+ resetFooterHint(this.footer);
2593
+ this.focusManager.closeTop();
2594
+ },
2595
+ onSubmit: async (input) => {
2596
+ await this.client.assertOk(this.client.insertRule(input.rule, input.ipAddr, input.action || "allow"));
2597
+ resetFooterHint(this.footer);
2598
+ this.focusManager.closeTop();
2599
+ await this.refresh();
2600
+ this.setTransientMessage(`Inserted rule #${input.rule}: allow from ${input.ipAddr}`);
2601
+ }
2602
+ });
2603
+ setFooterHint(this.footer, "Tab: next | Enter: submit | Esc: cancel");
2604
+ this.focusManager.open(modal);
2605
+ }
2606
+ openDeleteModal() {
2607
+ const rule = this.rulesPanel.getSelectedRule();
2608
+ if (!rule) {
2609
+ this.setTransientMessage("No rule selected to delete.");
2610
+ return;
2611
+ }
2612
+ const modal = new ConfirmModal(
2613
+ this.screen,
2614
+ {
2615
+ title: "Delete Rule",
2616
+ message: `Delete rule #${rule.id}: ${rule.to} [${rule.action}] from ${rule.from}?`,
2617
+ confirmLabel: "Delete",
2618
+ dangerous: true
2619
+ },
2620
+ {
2621
+ onCancel: () => {
2622
+ resetFooterHint(this.footer);
2623
+ this.focusManager.closeTop();
2624
+ },
2625
+ onConfirm: async () => {
2626
+ await this.client.assertOk(this.client.deleteRule(String(rule.id)));
2627
+ resetFooterHint(this.footer);
2628
+ this.focusManager.closeTop();
2629
+ await this.refresh();
2630
+ this.setTransientMessage(`Deleted rule #${rule.id}.`);
2631
+ }
2632
+ }
2633
+ );
2634
+ setFooterHint(this.footer, "Tab/\u2190\u2192: choose | Enter: confirm | Esc: cancel");
2635
+ this.focusManager.open(modal);
2636
+ }
2637
+ openResetModal() {
2638
+ const modal = new ConfirmModal(
2639
+ this.screen,
2640
+ {
2641
+ title: "RESET FIREWALL \u2014 CRITICAL",
2642
+ message: "WARNING: This will reset UFW to default factory state and DELETE ALL custom rules! Continue?",
2643
+ confirmLabel: "RESET ALL",
2644
+ dangerous: true
2645
+ },
2646
+ {
2647
+ onCancel: () => {
2648
+ resetFooterHint(this.footer);
2649
+ this.focusManager.closeTop();
2650
+ },
2651
+ onConfirm: async () => {
2652
+ this.focusManager.closeTop();
2653
+ await this.client.assertOk(this.client.reset());
2654
+ await this.refresh();
2655
+ this.setTransientMessage("Firewall has been reset to defaults.");
2656
+ }
2657
+ }
2658
+ );
2659
+ setFooterHint(this.footer, "Tab/\u2190\u2192: choose | Enter: confirm | Esc: cancel");
2660
+ this.focusManager.open(modal);
2661
+ }
2662
+ handleSetupSudo() {
2663
+ if (isSudoConfigured()) {
2664
+ this.setTransientMessage("Passwordless sudo is already configured!");
2665
+ return;
2666
+ }
2667
+ const modal = new ConfirmModal(
2668
+ this.screen,
2669
+ {
2670
+ title: "Setup Passwordless Sudo",
2671
+ message: "Enable passwordless sudo for ufw commands? (Installs /etc/sudoers.d/lazyufw-nopasswd)",
2672
+ confirmLabel: "Configure",
2673
+ dangerous: false
2674
+ },
2675
+ {
2676
+ onCancel: () => {
2677
+ this.focusManager.closeTop();
2678
+ },
2679
+ onConfirm: async () => {
2680
+ this.focusManager.closeTop();
2681
+ try {
2682
+ setupSudo();
2683
+ this.setTransientMessage("Passwordless sudo configured!");
2684
+ await this.refresh();
2685
+ } catch (err) {
2686
+ this.setTransientMessage(`Sudo setup: ${err instanceof Error ? err.message : String(err)}`, 4e3);
2687
+ }
2688
+ }
2689
+ }
2690
+ );
2691
+ this.focusManager.open(modal);
2692
+ }
2693
+ openActionMenu() {
2694
+ const items = [
2695
+ { key: "a", label: "Add Rule", description: "Create rule (allow/deny/reject/limit)", action: () => this.openAddModal() },
2696
+ { key: "P", label: "Application Profiles", description: "Browse & allow/deny installed app profiles", action: () => this.openAppProfilesModal() },
2697
+ { key: "i", label: "Insert Rule", description: "Insert rule at specific position", action: () => this.openInsertModal() },
2698
+ { key: "d", label: "Delete Rule", description: "Delete selected rule", action: () => this.openDeleteModal() },
2699
+ { key: "e", label: "Enable Firewall", description: "Activate UFW protection", action: () => this.enableFirewall() },
2700
+ { key: "D", label: "Disable Firewall", description: "Turn off UFW (SSH Protected)", action: () => this.handleDisableFirewall() },
2701
+ { key: "l", label: "Toggle Logging", description: `Turn logging ${this.loggingOn ? "OFF" : "ON"}`, action: () => this.toggleLogging() },
2702
+ { key: "L", label: "Set Logging Level", description: `Set UFW logging level (current: ${this.currentLoggingLevel})`, action: () => this.openLoggingModal() },
2703
+ { key: "R", label: "Reset Rules", description: "Reset all rules to factory defaults", action: () => this.openResetModal() },
2704
+ { key: "r", label: "Refresh", description: "Reload rules and status", action: () => this.refresh() },
2705
+ { key: "s", label: "Sudoless Setup", description: "Configure passwordless sudoers rule", action: () => this.handleSetupSudo() },
2706
+ { key: "?", label: "Help Cheatsheet", description: "View all keyboard shortcuts", action: () => this.openHelpModal() },
2707
+ { key: "q", label: "Quit", description: "Exit lazyufw", action: () => process.exit(0) }
2708
+ ];
2709
+ const modal = new ActionMenuModal(this.screen, items, () => {
2710
+ resetFooterHint(this.footer);
2711
+ this.focusManager.closeTop();
2712
+ });
2713
+ setFooterHint(this.footer, "\u2191\u2193/Key: select action | Enter: execute | Esc: close");
2714
+ this.focusManager.open(modal);
2715
+ }
2716
+ openHelpModal() {
2717
+ const modal = new HelpModal(this.screen, () => {
2718
+ resetFooterHint(this.footer);
2719
+ this.focusManager.closeTop();
2720
+ });
2721
+ setFooterHint(this.footer, "Esc / ? / Enter: close help");
2722
+ this.focusManager.open(modal);
2723
+ }
2724
+ async start() {
2725
+ await this.refresh();
2726
+ this.focusPanel(this.focusIndex, false);
2727
+ this.screen.render();
2728
+ }
2729
+ };
2730
+ async function startDashboard(client) {
2731
+ if (!isSudoConfigured()) {
2732
+ if (!ensureSudoCached()) {
2733
+ console.warn("lazyufw: sudo credentials not cached. Continuing; commands may prompt.");
2734
+ }
2735
+ }
2736
+ const dashboard = new Dashboard(client);
2737
+ await dashboard.start();
2738
+ }
2739
+
2740
+ // firewall/ufwClient.ts
2741
+ var PORT_RE = /^\d{1,5}(:\d{1,5})?$/;
2742
+ var PROTO_RE = /^(tcp|udp)$/;
2743
+ var IPV4_RE2 = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
2744
+ var RULE_NUM_RE = /^\d+$/;
2745
+ var DELETE_RULE_RE = /^\d+$|^(allow|deny|reject|limit)\b.*$/;
2746
+ function invalid(message) {
2747
+ return { stdout: "", stderr: message, code: 1 };
2748
+ }
2749
+ var ufwClient = class {
2750
+ async status() {
2751
+ return exec_default("sudo", [UFW_PATH, "status", "verbose"]);
2752
+ }
2753
+ async rawRules() {
2754
+ const res = await exec_default("sudo", [UFW_PATH, "status", "raw"]);
2755
+ if (res.code === 0 && res.stdout.trim()) {
2756
+ return res;
2757
+ }
2758
+ return exec_default("sudo", [UFW_PATH, "status", "verbose"]);
2759
+ }
2760
+ async numberedRules() {
2761
+ return exec_default("sudo", [UFW_PATH, "status", "numbered"]);
2762
+ }
2763
+ async allow(port, protocol) {
2764
+ if (!PORT_RE.test(port)) return invalid(`Invalid port: ${port}`);
2765
+ if (protocol && !PROTO_RE.test(protocol)) return invalid(`Invalid protocol: ${protocol}`);
2766
+ const args = [UFW_PATH, "allow", port];
2767
+ if (protocol && protocol !== "any") args.push(protocol);
2768
+ return exec_default("sudo", args);
2769
+ }
2770
+ async deny(port, protocol) {
2771
+ if (!PORT_RE.test(port)) return invalid(`Invalid port: ${port}`);
2772
+ if (protocol && !PROTO_RE.test(protocol)) return invalid(`Invalid protocol: ${protocol}`);
2773
+ const args = [UFW_PATH, "deny", port];
2774
+ if (protocol && protocol !== "any") args.push(protocol);
2775
+ return exec_default("sudo", args);
2776
+ }
2777
+ async reject(port, protocol) {
2778
+ if (!PORT_RE.test(port)) return invalid(`Invalid port: ${port}`);
2779
+ if (protocol && !PROTO_RE.test(protocol)) return invalid(`Invalid protocol: ${protocol}`);
2780
+ const args = [UFW_PATH, "reject", port];
2781
+ if (protocol && protocol !== "any") args.push(protocol);
2782
+ return exec_default("sudo", args);
2783
+ }
2784
+ async limit(port, protocol) {
2785
+ if (!PORT_RE.test(port)) return invalid(`Invalid port: ${port}`);
2786
+ if (protocol && !PROTO_RE.test(protocol)) return invalid(`Invalid protocol: ${protocol}`);
2787
+ const args = [UFW_PATH, "limit", port];
2788
+ if (protocol && protocol !== "any") args.push(protocol);
2789
+ return exec_default("sudo", args);
2790
+ }
2791
+ async createRule(input) {
2792
+ const action = input.action.toLowerCase();
2793
+ const args = [UFW_PATH, action];
2794
+ if (input.direction) {
2795
+ args.push(input.direction.toLowerCase());
2796
+ }
2797
+ if (input.app && input.app.trim()) {
2798
+ const app = input.app.trim();
2799
+ if (input.from && input.from.trim()) {
2800
+ args.push("from", input.from.trim(), "to", "any", "app", app);
2801
+ } else {
2802
+ args.push(app);
2803
+ }
2804
+ } else {
2805
+ const port = String(input.port ?? "").trim();
2806
+ if (!PORT_RE.test(port)) return invalid(`Invalid port: ${port}`);
2807
+ const proto = input.protocol && input.protocol !== "any" ? input.protocol : void 0;
2808
+ if (proto && !PROTO_RE.test(proto)) return invalid(`Invalid protocol: ${proto}`);
2809
+ if (input.from && input.from.trim()) {
2810
+ args.push("from", input.from.trim(), "to", "any", "port", port);
2811
+ } else {
2812
+ args.push(proto ? `${port}/${proto}` : port);
2813
+ }
2814
+ }
2815
+ if (input.comment && input.comment.trim()) {
2816
+ args.push("comment", input.comment.trim());
2817
+ }
2818
+ return exec_default("sudo", args);
2819
+ }
2820
+ async insertRule(rule, ipAddr, action = "allow") {
2821
+ if (!Number.isInteger(rule) || rule < 1) return invalid(`Invalid rule number: ${rule}`);
2822
+ const trimmedIp = ipAddr.trim();
2823
+ if (!IPV4_RE2.test(trimmedIp)) return invalid(`Invalid IP address: ${trimmedIp}`);
2824
+ return exec_default("sudo", [UFW_PATH, "insert", rule.toString(), action.toLowerCase(), "from", trimmedIp]);
2825
+ }
2826
+ async deleteRule(rule) {
2827
+ const trimmed = rule.trim();
2828
+ if (!DELETE_RULE_RE.test(trimmed)) return invalid(`Invalid rule: ${rule}`);
2829
+ const args = RULE_NUM_RE.test(trimmed) ? [UFW_PATH, "--force", "delete", trimmed] : [UFW_PATH, "--force", "delete", ...trimmed.split(/\s+/)];
2830
+ return exec_default("sudo", args);
2831
+ }
2832
+ async setLogging(level) {
2833
+ const validLevels = ["off", "low", "medium", "high", "full"];
2834
+ if (!validLevels.includes(level)) {
2835
+ return invalid(`Invalid logging level: ${level}`);
2836
+ }
2837
+ return exec_default("sudo", [UFW_PATH, "logging", level]);
2838
+ }
2839
+ async enableLog() {
2840
+ return this.setLogging("low");
2841
+ }
2842
+ async disableLog() {
2843
+ return this.setLogging("off");
2844
+ }
2845
+ async listAppProfiles() {
2846
+ return exec_default("sudo", [UFW_PATH, "app", "list"]);
2847
+ }
2848
+ async appProfileInfo(profile) {
2849
+ const trimmed = profile.trim();
2850
+ if (!trimmed) return invalid("Application profile name required");
2851
+ return exec_default("sudo", [UFW_PATH, "app", "info", trimmed]);
2852
+ }
2853
+ async allowApp(profile, action = "allow") {
2854
+ const trimmed = profile.trim();
2855
+ if (!trimmed) return invalid("Application profile name required");
2856
+ const act = action.toLowerCase();
2857
+ if (!["allow", "deny", "reject", "limit"].includes(act)) {
2858
+ return invalid(`Invalid action: ${action}`);
2859
+ }
2860
+ return exec_default("sudo", [UFW_PATH, act, trimmed]);
2861
+ }
2862
+ async enable() {
2863
+ return exec_default("sudo", [UFW_PATH, "--force", "enable"]);
2864
+ }
2865
+ async disable() {
2866
+ return exec_default("sudo", [UFW_PATH, "disable"]);
2867
+ }
2868
+ async reset() {
2869
+ return exec_default("sudo", [UFW_PATH, "--force", "reset"]);
2870
+ }
2871
+ async assertOk(promise) {
2872
+ const result = await promise;
2873
+ if (result.code !== 0) {
2874
+ throw new Error(result.stderr.trim() || `Command failed with exit code ${result.code}`);
2875
+ }
2876
+ return result;
2877
+ }
2878
+ };
2879
+
2880
+ // index.ts
2881
+ var VERSION = "1.0.0";
2882
+ function printHelp() {
2883
+ console.log(`
2884
+ \x1B[36m\x1B[1mlazyufw\x1B[0m v${VERSION} \u2014 The lazier way to manage UFW (Lazydocker style)
2885
+
2886
+ \x1B[1mUSAGE:\x1B[0m
2887
+ lazyufw [command] [options]
2888
+
2889
+ \x1B[1mCOMMANDS:\x1B[0m
2890
+ (default) Launch the interactive Lazydocker-style TUI
2891
+ setup Configure passwordless sudoers rule (/etc/sudoers.d/lazyufw-nopasswd)
2892
+ teardown Remove the passwordless sudoers rule
2893
+ status Print verbose firewall status and exit
2894
+
2895
+ \x1B[1mOPTIONS:\x1B[0m
2896
+ -h, --help Display this help guide
2897
+ -v, --version Display lazyufw version
2898
+
2899
+ \x1B[1mKEYBINDINGS (in TUI):\x1B[0m
2900
+ 1, 2, 3, 4 Jump to Panel (Status, Rules, Raw, Details)
2901
+ Tab / Shift-Tab Cycle focus across panels
2902
+ x Open Lazydocker Action Menu
2903
+ a (allow, deny, reject, limit)
2904
+ i Insert rule at specific index
2905
+ d Delete selected rule
2906
+ e Enable UFW firewall
2907
+ D Disable UFW firewall (SSH Lockout Protected)
2908
+ l Toggle logging (on / off)
2909
+ R Reset firewall to factory defaults
2910
+ r Refresh status
2911
+ ? Help Cheatsheet
2912
+ q / Ctrl-C Quit
2913
+ `);
2914
+ }
2915
+ async function printStatus() {
2916
+ const client = new ufwClient();
2917
+ try {
2918
+ const res = await client.status();
2919
+ console.log(res.stdout || res.stderr);
2920
+ const rulesRes = await client.numberedRules();
2921
+ if (rulesRes.stdout.trim()) {
2922
+ console.log("\n" + rulesRes.stdout);
2923
+ }
2924
+ } catch (err) {
2925
+ console.error("Failed to query UFW status:", err instanceof Error ? err.message : err);
2926
+ process.exit(1);
2927
+ }
2928
+ }
2929
+ async function main() {
2930
+ const args = process.argv.slice(2);
2931
+ const command = args[0];
2932
+ if (command === "-h" || command === "--help" || command === "help") {
2933
+ printHelp();
2934
+ return;
2935
+ }
2936
+ if (command === "-v" || command === "--version" || command === "version") {
2937
+ console.log(`lazyufw v${VERSION}`);
2938
+ return;
2939
+ }
2940
+ switch (command) {
2941
+ case "setup":
2942
+ setupSudo();
2943
+ break;
2944
+ case "teardown":
2945
+ teardownSudo();
2946
+ break;
2947
+ case "status":
2948
+ await printStatus();
2949
+ break;
2950
+ default:
2951
+ if (process.platform !== "linux" && !process.env.UFW_PATH) {
2952
+ console.warn("\x1B[33mWarning: lazyufw is designed for Linux with UFW installed.\x1B[0m");
2953
+ }
2954
+ if (!isUfwInstalled() && process.platform === "linux") {
2955
+ console.warn("\x1B[33mWarning: UFW binary not found in standard paths (/usr/sbin/ufw).\x1B[0m");
2956
+ console.warn("Set UFW_PATH environment variable if installed elsewhere.\n");
2957
+ }
2958
+ await startDashboard(new ufwClient());
2959
+ break;
2960
+ }
2961
+ }
2962
+ main().catch((err) => {
2963
+ console.error("lazyufw encountered an error:", err);
2964
+ process.exit(1);
2965
+ });
2966
+ export {
2967
+ main
2968
+ };