@gtadi/k8s-node-debugger 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.
@@ -0,0 +1,523 @@
1
+ 'use strict';
2
+
3
+ /* ══════════════════════════════════════════════════════════════════════════
4
+ * k8s-node-debugger — rich iptables renderer
5
+ * Parses iptables-save output and renders it as an interactive table UI.
6
+ * Exposed as window.renderIptablesView(raw, containerEl).
7
+ * ══════════════════════════════════════════════════════════════════════════ */
8
+ (function () {
9
+
10
+ /* ── Port → human name ───────────────────────────────────────────────── */
11
+ const PORT_NAMES = {
12
+ 20:'FTP-data', 21:'FTP', 22:'SSH', 23:'Telnet', 25:'SMTP',
13
+ 53:'DNS', 67:'DHCP', 68:'DHCP', 80:'HTTP', 110:'POP3',
14
+ 111:'RPC', 123:'NTP', 143:'IMAP', 161:'SNMP', 162:'SNMP-trap',
15
+ 179:'BGP', 389:'LDAP', 443:'HTTPS', 465:'SMTPS', 514:'Syslog',
16
+ 587:'SMTP/TLS', 636:'LDAPS', 853:'DNS-TLS', 993:'IMAPS', 995:'POP3S',
17
+ 1194:'OpenVPN', 1883:'MQTT', 2379:'etcd', 2380:'etcd-peer',
18
+ 3000:'Grafana', 3306:'MySQL', 4789:'VXLAN/Flannel', 5432:'Postgres',
19
+ 5601:'Kibana', 5671:'AMQP-TLS', 5672:'AMQP', 6379:'Redis',
20
+ 6443:'k8s-API', 7472:'MetalLB-BGP', 7946:'MetalLB-member',
21
+ 8080:'HTTP-alt', 8443:'HTTPS-alt', 8472:'VXLAN/Flannel',
22
+ 9090:'Prometheus', 9091:'Pushgateway', 9093:'Alertmanager',
23
+ 9100:'node-exporter', 9200:'Elasticsearch', 9300:'ES-cluster',
24
+ 10248:'kubelet-healthz', 10249:'kube-proxy-metrics',
25
+ 10250:'kubelet', 10251:'kube-scheduler-insecure',
26
+ 10252:'kube-ctrl-mgr-insecure', 10255:'kubelet-readonly',
27
+ 10256:'kube-proxy', 10257:'kube-ctrl-mgr', 10259:'kube-scheduler',
28
+ };
29
+
30
+ const NODEPORT_MIN = 30000, NODEPORT_MAX = 32767;
31
+
32
+ function portLabel(p) {
33
+ if (!p) return null;
34
+ if (p.includes(':')) {
35
+ const [a, b] = p.split(':').map(Number);
36
+ if (a >= NODEPORT_MIN && b <= NODEPORT_MAX) return `${p} (NodePort range)`;
37
+ const aName = PORT_NAMES[a], bName = PORT_NAMES[b];
38
+ return aName ? `${p} (${aName}…)` : p;
39
+ }
40
+ const n = parseInt(p);
41
+ return PORT_NAMES[n] ? `${p} (${PORT_NAMES[n]})` : p;
42
+ }
43
+
44
+ function humanBytes(b) {
45
+ if (!b) return null;
46
+ const u = ['B','KB','MB','GB','TB'];
47
+ const i = b > 0 ? Math.min(Math.floor(Math.log(b) / Math.log(1024)), 4) : 0;
48
+ return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + u[i];
49
+ }
50
+
51
+ /* ── HTML escape ─────────────────────────────────────────────────────── */
52
+ function h(s) {
53
+ return String(s ?? '')
54
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;')
55
+ .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
56
+ }
57
+
58
+ /* ══════════════════════════════════════════════════════════════════════
59
+ * PARSER — iptables-save format
60
+ * ══════════════════════════════════════════════════════════════════════ */
61
+
62
+ function parseIptablesSave(raw) {
63
+ const tables = {};
64
+ let cur = null;
65
+
66
+ for (const rawLine of raw.split('\n')) {
67
+ const line = rawLine.trim();
68
+ if (!line || line === 'COMMIT' || line.startsWith('#')) continue;
69
+
70
+ if (line[0] === '*') {
71
+ cur = line.slice(1).trim();
72
+ if (!tables[cur]) tables[cur] = { name: cur, chains: {}, order: [] };
73
+ } else if (line[0] === ':' && cur) {
74
+ const m = line.match(/^:(\S+)\s+(\S+)(?:\s+\[(\d+):(\d+)\])?/);
75
+ if (!m) continue;
76
+ const [, name, policy, pkts = '0', bytes = '0'] = m;
77
+ tables[cur].chains[name] = {
78
+ name, policy,
79
+ packets: +pkts, bytes: +bytes,
80
+ rules: [], builtin: true,
81
+ };
82
+ tables[cur].order.push(name);
83
+ } else if (line.startsWith('-A') && cur) {
84
+ const m = line.match(/^-A\s+(\S+)\s*(.*)/);
85
+ if (!m) continue;
86
+ const [, chain, rest] = m;
87
+ if (!tables[cur].chains[chain]) {
88
+ tables[cur].chains[chain] = {
89
+ name: chain, policy: '-',
90
+ packets: 0, bytes: 0,
91
+ rules: [], builtin: false,
92
+ };
93
+ tables[cur].order.push(chain);
94
+ }
95
+ tables[cur].chains[chain].rules.push(parseRule(rest, line));
96
+ }
97
+ }
98
+ return tables;
99
+ }
100
+
101
+ function parseRule(flags, raw) {
102
+ const rule = {
103
+ raw, target: null, protocol: null,
104
+ src: null, dst: null, inIface: null, outIface: null,
105
+ dstPort: null, srcPort: null, state: null, comment: null,
106
+ natDest: null, natSrc: null, natPort: null,
107
+ mark: null, icmpType: null,
108
+ neg: {}, // which fields are negated
109
+ extra: [],
110
+ };
111
+
112
+ // Tokenise, respecting quotes
113
+ const tokens = [];
114
+ let tok = '', inQ = false;
115
+ for (const ch of flags + ' ') {
116
+ if (ch === '"') { inQ = !inQ; continue; }
117
+ if (ch === ' ' && !inQ) { if (tok) { tokens.push(tok); tok = ''; } }
118
+ else tok += ch;
119
+ }
120
+
121
+ let neg = false;
122
+ for (let i = 0; i < tokens.length; i++) {
123
+ const t = tokens[i];
124
+ if (t === '!') { neg = true; continue; }
125
+ const n = neg; neg = false;
126
+
127
+ switch (t) {
128
+ case '-j': case '-g': rule.target = tokens[++i]; break;
129
+ case '-p': rule.protocol = tokens[++i]; if (n) rule.neg.protocol = true; break;
130
+ case '-s': rule.src = tokens[++i]; if (n) rule.neg.src = true; break;
131
+ case '-d': rule.dst = tokens[++i]; if (n) rule.neg.dst = true; break;
132
+ case '-i': rule.inIface = tokens[++i]; if (n) rule.neg.inIface = true; break;
133
+ case '-o': rule.outIface = tokens[++i]; if (n) rule.neg.outIface = true; break;
134
+ case '--dport': case '--destination-port': rule.dstPort = tokens[++i]; break;
135
+ case '--sport': case '--source-port': rule.srcPort = tokens[++i]; break;
136
+ case '--state': case '--ctstate': rule.state = tokens[++i]; break;
137
+ case '--comment': rule.comment = tokens[++i]; break;
138
+ case '--to-destination': rule.natDest = tokens[++i]; break;
139
+ case '--to-source': rule.natSrc = tokens[++i]; break;
140
+ case '--to-ports': rule.natPort = tokens[++i]; break;
141
+ case '--set-xmark': case '--set-mark': rule.mark = tokens[++i]; break;
142
+ case '--icmp-type': rule.icmpType = tokens[++i]; break;
143
+ // skip known noise
144
+ case '-m': i++; break;
145
+ case '--tcp-flags': i += 2; break;
146
+ case '--uid-owner': case '--gid-owner':
147
+ case '--physdev-in': case '--physdev-out':
148
+ case '--match-set': i++; break;
149
+ default: rule.extra.push(t);
150
+ }
151
+ }
152
+ return rule;
153
+ }
154
+
155
+ /* ══════════════════════════════════════════════════════════════════════
156
+ * HUMAN DESCRIPTION
157
+ * ══════════════════════════════════════════════════════════════════════ */
158
+
159
+ const STATE_LABELS = {
160
+ ESTABLISHED: 'established', RELATED: 'related',
161
+ NEW: 'new', INVALID: 'invalid', UNTRACKED: 'untracked',
162
+ };
163
+
164
+ function ruleDesc(rule) {
165
+ const parts = [], tags = [];
166
+
167
+ if (rule.state) {
168
+ const s = rule.state.split(',').map(x => STATE_LABELS[x] || x.toLowerCase()).join('/');
169
+ parts.push(s + ' connections');
170
+ tags.push({ text: 'state:' + rule.state, kind: 'state' });
171
+ }
172
+ if (rule.inIface) {
173
+ parts.push('in ' + (rule.neg.inIface ? '≠ ' : '') + rule.inIface);
174
+ tags.push({ text: 'in:' + rule.inIface, kind: 'iface' });
175
+ }
176
+ if (rule.outIface) {
177
+ parts.push('out ' + (rule.neg.outIface ? '≠ ' : '') + rule.outIface);
178
+ tags.push({ text: 'out:' + rule.outIface, kind: 'iface' });
179
+ }
180
+ const anyIP = v => !v || v === '0.0.0.0/0' || v === '::/0';
181
+ if (!anyIP(rule.src)) {
182
+ parts.push('from ' + (rule.neg.src ? '≠ ' : '') + rule.src);
183
+ tags.push({ text: 'src:' + rule.src, kind: 'addr' });
184
+ }
185
+ if (!anyIP(rule.dst)) {
186
+ parts.push('to ' + (rule.neg.dst ? '≠ ' : '') + rule.dst);
187
+ tags.push({ text: 'dst:' + rule.dst, kind: 'addr' });
188
+ }
189
+ if (rule.dstPort) {
190
+ parts.push('port ' + portLabel(rule.dstPort));
191
+ tags.push({ text: 'dport:' + rule.dstPort, kind: 'port' });
192
+ }
193
+ if (rule.srcPort) {
194
+ parts.push('src-port ' + portLabel(rule.srcPort));
195
+ }
196
+ if (rule.protocol && rule.protocol !== 'all') {
197
+ tags.push({ text: rule.protocol.toUpperCase(), kind: 'proto' });
198
+ }
199
+ if (rule.icmpType) {
200
+ parts.push('ICMP type ' + rule.icmpType);
201
+ }
202
+ if (rule.natDest) parts.push('→ ' + rule.natDest);
203
+ if (rule.natSrc) parts.push('src-nat → ' + rule.natSrc);
204
+ if (rule.natPort) parts.push('redirect-port → ' + rule.natPort);
205
+ if (rule.mark) parts.push('mark ' + rule.mark);
206
+ if (rule.comment) tags.push({ text: rule.comment, kind: 'comment' });
207
+
208
+ return {
209
+ summary: parts.length ? parts.join(' · ') : 'all traffic',
210
+ tags,
211
+ };
212
+ }
213
+
214
+ /* ══════════════════════════════════════════════════════════════════════
215
+ * TARGET STYLING
216
+ * ══════════════════════════════════════════════════════════════════════ */
217
+
218
+ function targetKind(target) {
219
+ const t = (target || '').toUpperCase();
220
+ if (t === 'ACCEPT') return 'accept';
221
+ if (t === 'DROP') return 'drop';
222
+ if (t === 'REJECT') return 'reject';
223
+ if (t === 'LOG') return 'log';
224
+ if (t === 'MASQUERADE') return 'masq';
225
+ if (t === 'DNAT' || t === 'SNAT') return 'nat';
226
+ if (t === 'REDIRECT') return 'redirect';
227
+ if (t === 'RETURN') return 'return';
228
+ if (t === 'MARK' || t === 'CONNMARK') return 'mark';
229
+ if (t.startsWith('KUBE-') || t.startsWith('kube-')) return 'k8s';
230
+ if (t.startsWith('DOCKER')) return 'docker';
231
+ return 'jump'; // user-defined chain jump
232
+ }
233
+
234
+ const TARGET_TOOLTIP = {
235
+ accept: 'Allow the packet through.',
236
+ drop: 'Silently discard the packet. Sender gets no response.',
237
+ reject: 'Discard and send an error reply (ICMP or TCP RST) to the sender.',
238
+ log: 'Write a log entry for the packet, then continue to next rule.',
239
+ masq: 'Source-NAT the packet to the outgoing interface IP (used for pods reaching the internet).',
240
+ nat: 'Rewrite the packet destination (DNAT) or source (SNAT) address.',
241
+ redirect: 'Redirect the packet to a local port.',
242
+ return: 'Stop traversing this chain; return to the calling chain.',
243
+ mark: 'Set a netfilter mark on the packet (used for routing policy or later rules).',
244
+ k8s: 'Jump to a Kubernetes-managed chain (kube-proxy or CNI).',
245
+ docker: 'Jump to a Docker-managed chain.',
246
+ jump: 'Jump to a user-defined chain for further processing.',
247
+ };
248
+
249
+ /* ══════════════════════════════════════════════════════════════════════
250
+ * CHAIN CATEGORISATION
251
+ * ══════════════════════════════════════════════════════════════════════ */
252
+
253
+ // Chains that are "noisy" k8s detail chains — collapsed by default
254
+ function isDetailChain(name) {
255
+ return /^KUBE-(SVC|SEP|FW|XLB)-/.test(name);
256
+ }
257
+
258
+ function chainBadges(name) {
259
+ const badges = [];
260
+ if (name.startsWith('KUBE-')) badges.push({ text: 'k8s', cls: 'badge-k8s' });
261
+ else if (name.startsWith('DOCKER')) badges.push({ text: 'docker', cls: 'badge-docker' });
262
+ return badges;
263
+ }
264
+
265
+ /* ══════════════════════════════════════════════════════════════════════
266
+ * RENDERER
267
+ * ══════════════════════════════════════════════════════════════════════ */
268
+
269
+ function renderIptablesView(raw, container) {
270
+ let tables;
271
+ try { tables = parseIptablesSave(raw); }
272
+ catch (e) {
273
+ container.textContent = raw;
274
+ return;
275
+ }
276
+
277
+ const tableNames = Object.keys(tables);
278
+ if (!tableNames.length) {
279
+ container.innerHTML = '<div class="ipt-empty">No iptables data found.</div>';
280
+ return;
281
+ }
282
+
283
+ // Total stats
284
+ let totalChains = 0, totalRules = 0;
285
+ for (const t of Object.values(tables)) {
286
+ for (const c of Object.values(t.chains)) {
287
+ totalChains++;
288
+ totalRules += c.rules.length;
289
+ }
290
+ }
291
+
292
+ // Root element
293
+ const wrap = document.createElement('div');
294
+ wrap.className = 'ipt-wrap';
295
+
296
+ // ── Top bar ────────────────────────────────────────────────────────
297
+ const bar = document.createElement('div');
298
+ bar.className = 'ipt-bar';
299
+ bar.innerHTML = `
300
+ <span class="ipt-stats">
301
+ <span class="ipt-stat"><b>${tableNames.length}</b> table${tableNames.length !== 1 ? 's' : ''}</span>
302
+ <span class="ipt-sep">·</span>
303
+ <span class="ipt-stat"><b>${totalChains}</b> chain${totalChains !== 1 ? 's' : ''}</span>
304
+ <span class="ipt-sep">·</span>
305
+ <span class="ipt-stat"><b>${totalRules}</b> rule${totalRules !== 1 ? 's' : ''}</span>
306
+ </span>
307
+ <input class="ipt-search" placeholder="🔍 filter rules, IPs, ports…" type="text" />
308
+ <div class="ipt-bar-actions">
309
+ <button class="ipt-btn ipt-collapse-k8s" title="Toggle KUBE-SVC-* / KUBE-SEP-* chains">K8s chains ▼</button>
310
+ <button class="ipt-btn ipt-raw-toggle">Raw</button>
311
+ </div>
312
+ `;
313
+ wrap.appendChild(bar);
314
+
315
+ // Raw pre (hidden by default)
316
+ const rawPre = document.createElement('pre');
317
+ rawPre.className = 'output ipt-raw-view';
318
+ rawPre.style.display = 'none';
319
+ rawPre.textContent = raw;
320
+ wrap.appendChild(rawPre);
321
+
322
+ // ── Table tabs ────────────────────────────────────────────────────
323
+ const tabs = document.createElement('div');
324
+ tabs.className = 'ipt-tabs';
325
+ wrap.appendChild(tabs);
326
+
327
+ // ── Table body ────────────────────────────────────────────────────
328
+ const body = document.createElement('div');
329
+ body.className = 'ipt-body';
330
+ wrap.appendChild(body);
331
+
332
+ // ── Legend ────────────────────────────────────────────────────────
333
+ const legend = document.createElement('div');
334
+ legend.className = 'ipt-legend';
335
+ legend.innerHTML = [
336
+ ['accept','ACCEPT'],['drop','DROP'],['reject','REJECT'],
337
+ ['log','LOG'],['masq','MASQUERADE'],['nat','DNAT/SNAT'],
338
+ ['redirect','REDIRECT'],['return','RETURN'],['mark','MARK'],
339
+ ['k8s','→ k8s chain'],['jump','→ user chain'],
340
+ ].map(([k,l]) => `<span class="ipt-target ipt-target-${k}">${l}</span>`).join('');
341
+ wrap.appendChild(legend);
342
+
343
+ // Render a table into body
344
+ let activeTable = tableNames[0];
345
+ function showTable(name) {
346
+ activeTable = name;
347
+ tabs.querySelectorAll('.ipt-tab').forEach(t => t.classList.toggle('active', t.dataset.t === name));
348
+ body.innerHTML = '';
349
+ renderTable(tables[name], body);
350
+ // Re-apply search filter
351
+ applyFilter(bar.querySelector('.ipt-search').value);
352
+ }
353
+
354
+ for (const name of tableNames) {
355
+ const t = tables[name];
356
+ const rCount = Object.values(t.chains).reduce((s, c) => s + c.rules.length, 0);
357
+ const tab = document.createElement('button');
358
+ tab.className = 'ipt-tab' + (name === activeTable ? ' active' : '');
359
+ tab.dataset.t = name;
360
+ tab.innerHTML = `${h(name)} <small>${rCount}</small>`;
361
+ tab.title = `${Object.keys(t.chains).length} chains, ${rCount} rules`;
362
+ tab.addEventListener('click', () => showTable(name));
363
+ tabs.appendChild(tab);
364
+ }
365
+ showTable(activeTable);
366
+
367
+ // ── Search ────────────────────────────────────────────────────────
368
+ function applyFilter(q) {
369
+ const ql = (q || '').toLowerCase();
370
+ body.querySelectorAll('.ipt-rule').forEach(row => {
371
+ row.style.display = (!ql || (row.dataset.s || '').includes(ql)) ? '' : 'none';
372
+ });
373
+ body.querySelectorAll('.ipt-chain').forEach(chain => {
374
+ if (!ql) { chain.style.display = ''; return; }
375
+ const vis = chain.querySelectorAll('.ipt-rule:not([style*="none"])').length;
376
+ chain.style.display = vis > 0 ? '' : 'none';
377
+ });
378
+ }
379
+ bar.querySelector('.ipt-search').addEventListener('input', e => applyFilter(e.target.value));
380
+
381
+ // ── Toggle raw ────────────────────────────────────────────────────
382
+ bar.querySelector('.ipt-raw-toggle').addEventListener('click', e => {
383
+ const show = rawPre.style.display !== 'none';
384
+ rawPre.style.display = show ? 'none' : 'block';
385
+ tabs.style.display = show ? '' : 'none';
386
+ body.style.display = show ? '' : 'none';
387
+ legend.style.display = show ? '' : 'none';
388
+ e.target.textContent = show ? 'Raw' : 'Fancy';
389
+ });
390
+
391
+ // ── Collapse/expand k8s detail chains ────────────────────────────
392
+ let k8sExpanded = false;
393
+ bar.querySelector('.ipt-collapse-k8s').addEventListener('click', e => {
394
+ k8sExpanded = !k8sExpanded;
395
+ body.querySelectorAll('.ipt-chain[data-detail]').forEach(c => {
396
+ c.classList.toggle('collapsed', !k8sExpanded);
397
+ c.querySelector('.ipt-chain-chevron').textContent = k8sExpanded ? '▼' : '▶';
398
+ });
399
+ e.target.textContent = `K8s chains ${k8sExpanded ? '▲' : '▼'}`;
400
+ });
401
+
402
+ container.innerHTML = '';
403
+ container.className = ''; // remove "output" class — we own the layout now
404
+ container.appendChild(wrap);
405
+ }
406
+
407
+ /* ── Chain renderer ────────────────────────────────────────────────── */
408
+ function renderTable(table, container) {
409
+ for (const name of table.order) {
410
+ const chain = table.chains[name];
411
+ const detail = isDetailChain(name);
412
+ const collapsed = detail;
413
+
414
+ const el = document.createElement('div');
415
+ el.className = 'ipt-chain' + (collapsed ? ' collapsed' : '');
416
+ if (detail) el.dataset.detail = '1';
417
+
418
+ const badges = chainBadges(name);
419
+ const policyClass = chain.policy === 'ACCEPT' ? 'accept' : chain.policy === 'DROP' ? 'drop' : 'other';
420
+ const counters = chain.bytes > 0
421
+ ? `<span class="ipt-chain-counter">${humanBytes(chain.bytes)} · ${chain.packets.toLocaleString()} pkts</span>`
422
+ : '';
423
+
424
+ const hdr = document.createElement('div');
425
+ hdr.className = 'ipt-chain-hdr';
426
+ hdr.innerHTML = `
427
+ <span class="ipt-chain-chevron">${collapsed ? '▶' : '▼'}</span>
428
+ <span class="ipt-chain-name">${h(name)}</span>
429
+ ${badges.map(b => `<span class="ipt-badge ${b.cls}">${b.text}</span>`).join('')}
430
+ ${chain.policy !== '-' ? `<span class="ipt-policy ipt-policy-${policyClass}" title="Default policy">policy: ${h(chain.policy)}</span>` : ''}
431
+ <span class="ipt-chain-rcount">${chain.rules.length} rule${chain.rules.length !== 1 ? 's' : ''}</span>
432
+ ${counters}
433
+ `;
434
+ hdr.addEventListener('click', () => {
435
+ el.classList.toggle('collapsed');
436
+ hdr.querySelector('.ipt-chain-chevron').textContent = el.classList.contains('collapsed') ? '▶' : '▼';
437
+ });
438
+ el.appendChild(hdr);
439
+
440
+ // Rules container (hidden when collapsed via CSS)
441
+ const rulesWrap = document.createElement('div');
442
+ rulesWrap.className = 'ipt-chain-body';
443
+
444
+ if (!chain.rules.length) {
445
+ rulesWrap.innerHTML = `<div class="ipt-no-rules">No rules — ${chain.policy !== '-' ? 'default <b>' + chain.policy + '</b> policy applies' : 'empty chain'}.</div>`;
446
+ } else {
447
+ const tbl = document.createElement('table');
448
+ tbl.className = 'ipt-rules';
449
+ tbl.innerHTML = `<thead><tr>
450
+ <th class="col-num">#</th>
451
+ <th class="col-target">Target</th>
452
+ <th class="col-desc">Match &amp; Action</th>
453
+ <th class="col-proto">Proto</th>
454
+ <th class="col-addr">Source</th>
455
+ <th class="col-addr">Destination</th>
456
+ </tr></thead>`;
457
+ const tbody = document.createElement('tbody');
458
+
459
+ chain.rules.forEach((rule, i) => {
460
+ const kind = targetKind(rule.target);
461
+ const desc = ruleDesc(rule);
462
+ const tooltip = TARGET_TOOLTIP[kind] || '';
463
+ const proto = rule.protocol && rule.protocol !== 'all'
464
+ ? `<span class="ipt-proto ipt-proto-${rule.protocol}">${h(rule.protocol.toUpperCase())}</span>`
465
+ : '<span class="dim">—</span>';
466
+
467
+ const anyIP = v => !v || v === '0.0.0.0/0' || v === '::/0';
468
+ const srcCell = anyIP(rule.src)
469
+ ? '<span class="dim">any</span>'
470
+ : `<code>${h((rule.neg.src ? '≠ ' : '') + rule.src)}</code>`;
471
+ const dstCell = anyIP(rule.dst)
472
+ ? '<span class="dim">any</span>'
473
+ : `<code>${h((rule.neg.dst ? '≠ ' : '') + rule.dst)}</code>`;
474
+
475
+ const tagHtml = desc.tags.map(tag =>
476
+ `<span class="ipt-tag ipt-tag-${tag.kind}">${h(tag.text)}</span>`
477
+ ).join('');
478
+
479
+ const tr = document.createElement('tr');
480
+ tr.className = 'ipt-rule';
481
+ // Searchable text: everything useful
482
+ tr.dataset.s = [
483
+ rule.raw, rule.target, rule.protocol, rule.src, rule.dst,
484
+ rule.dstPort, rule.srcPort, rule.state, rule.comment,
485
+ rule.inIface, rule.outIface, rule.natDest, rule.natSrc,
486
+ ].filter(Boolean).join(' ').toLowerCase();
487
+
488
+ tr.innerHTML = `
489
+ <td class="col-num ipt-rule-num">${i + 1}</td>
490
+ <td class="col-target">
491
+ <span class="ipt-target ipt-target-${kind}" title="${h(tooltip)}">${h(rule.target || '?')}</span>
492
+ </td>
493
+ <td class="col-desc">
494
+ <div class="ipt-rule-summary">${h(desc.summary)}</div>
495
+ <div class="ipt-rule-tags">${tagHtml}</div>
496
+ <div class="ipt-rule-raw">${h(rule.raw)}</div>
497
+ </td>
498
+ <td class="col-proto">${proto}</td>
499
+ <td class="col-addr">${srcCell}</td>
500
+ <td class="col-addr">${dstCell}</td>
501
+ `;
502
+
503
+ // Click → toggle raw rule
504
+ tr.addEventListener('click', () => {
505
+ const raw = tr.querySelector('.ipt-rule-raw');
506
+ raw.classList.toggle('visible');
507
+ });
508
+ tbody.appendChild(tr);
509
+ });
510
+
511
+ tbl.appendChild(tbody);
512
+ rulesWrap.appendChild(tbl);
513
+ }
514
+
515
+ el.appendChild(rulesWrap);
516
+ container.appendChild(el);
517
+ }
518
+ }
519
+
520
+ /* ── Export ──────────────────────────────────────────────────────── */
521
+ window.renderIptablesView = renderIptablesView;
522
+
523
+ })(); // end IIFE