@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.
package/src/k8s.js ADDED
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ const { spawn, execFile } = require('child_process');
4
+
5
+ /**
6
+ * Thin wrapper around the `kubectl` binary on the user's PATH. It deliberately
7
+ * shells out to kubectl so the active kubeconfig / current-context (including
8
+ * any exec auth plugins, e.g. EKS/GKE) is reused exactly as on the shell.
9
+ */
10
+
11
+ const KUBECTL = process.env.KUBECTL_BIN || 'kubectl';
12
+ const DEBUG_IMAGE = process.env.DEBUGGER_IMAGE || 'nicolaka/netshoot:latest';
13
+
14
+ function kubectl(args, { input, timeout = 60000 } = {}) {
15
+ return new Promise((resolve, reject) => {
16
+ const child = execFile(
17
+ KUBECTL,
18
+ args,
19
+ { timeout, maxBuffer: 64 * 1024 * 1024 },
20
+ (err, stdout, stderr) => {
21
+ if (err) {
22
+ err.stdout = stdout;
23
+ err.stderr = stderr;
24
+ err.message = stderr?.trim() || err.message;
25
+ return reject(err);
26
+ }
27
+ resolve({ stdout, stderr });
28
+ }
29
+ );
30
+ if (input !== undefined) {
31
+ child.stdin.write(input);
32
+ child.stdin.end();
33
+ }
34
+ });
35
+ }
36
+
37
+ function buildKubectlArgs(extra, { context, kubeconfig } = {}) {
38
+ const args = [];
39
+ if (kubeconfig) args.push('--kubeconfig', kubeconfig);
40
+ if (context) args.push('--context', context);
41
+ return args.concat(extra);
42
+ }
43
+
44
+ async function currentContext(opts = {}) {
45
+ try {
46
+ const { stdout } = await kubectl(
47
+ buildKubectlArgs(['config', 'current-context'], opts)
48
+ );
49
+ return stdout.trim();
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ async function listNodes(opts = {}) {
56
+ const { stdout } = await kubectl(
57
+ buildKubectlArgs(['get', 'nodes', '-o', 'json'], opts)
58
+ );
59
+ const data = JSON.parse(stdout);
60
+ return (data.items || []).map((n) => {
61
+ const addr = (n.status?.addresses || []).reduce((acc, a) => {
62
+ acc[a.type] = a.address;
63
+ return acc;
64
+ }, {});
65
+ const conditions = (n.status?.conditions || []).reduce((acc, c) => {
66
+ acc[c.type] = c.status;
67
+ return acc;
68
+ }, {});
69
+ return {
70
+ name: n.metadata?.name,
71
+ roles: Object.keys(n.metadata?.labels || {})
72
+ .filter((l) => l.startsWith('node-role.kubernetes.io/'))
73
+ .map((l) => l.replace('node-role.kubernetes.io/', '') || 'control-plane')
74
+ .filter(Boolean),
75
+ ready: conditions.Ready === 'True',
76
+ internalIP: addr.InternalIP,
77
+ hostname: addr.Hostname,
78
+ os: n.status?.nodeInfo?.osImage,
79
+ kernel: n.status?.nodeInfo?.kernelVersion,
80
+ kubelet: n.status?.nodeInfo?.kubeletVersion,
81
+ runtime: n.status?.nodeInfo?.containerRuntimeVersion,
82
+ };
83
+ });
84
+ }
85
+
86
+ function debugPodManifest(node, podName, namespace) {
87
+ return JSON.stringify({
88
+ apiVersion: 'v1',
89
+ kind: 'Pod',
90
+ metadata: {
91
+ name: podName,
92
+ namespace,
93
+ labels: { app: 'k8s-node-debugger' },
94
+ },
95
+ spec: {
96
+ nodeName: node,
97
+ hostNetwork: true,
98
+ hostPID: true,
99
+ hostIPC: true,
100
+ restartPolicy: 'Never',
101
+ // Schedule onto control-plane / tainted nodes too.
102
+ tolerations: [{ operator: 'Exists' }],
103
+ containers: [
104
+ {
105
+ name: 'debugger',
106
+ image: DEBUG_IMAGE,
107
+ imagePullPolicy: 'IfNotPresent',
108
+ command: ['sleep', 'infinity'],
109
+ securityContext: {
110
+ privileged: true,
111
+ runAsUser: 0,
112
+ },
113
+ volumeMounts: [{ name: 'host-root', mountPath: '/host' }],
114
+ },
115
+ ],
116
+ volumes: [{ name: 'host-root', hostPath: { path: '/' } }],
117
+ },
118
+ });
119
+ }
120
+
121
+ async function createDebugPod(node, { namespace = 'default', context, kubeconfig } = {}) {
122
+ const suffix = node.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 30);
123
+ const podName = `node-debugger-${suffix}-${process.pid.toString(36)}`;
124
+ const opts = { context, kubeconfig };
125
+ const manifest = debugPodManifest(node, podName, namespace);
126
+ await kubectl(
127
+ buildKubectlArgs(['apply', '-n', namespace, '-f', '-'], opts),
128
+ { input: manifest }
129
+ );
130
+ return { podName, namespace };
131
+ }
132
+
133
+ async function waitForPodReady(podName, { namespace = 'default', context, kubeconfig, timeout = 120 } = {}) {
134
+ await kubectl(
135
+ buildKubectlArgs(
136
+ ['wait', '-n', namespace, `pod/${podName}`, '--for=condition=Ready', `--timeout=${timeout}s`],
137
+ { context, kubeconfig }
138
+ ),
139
+ { timeout: (timeout + 10) * 1000 }
140
+ );
141
+ }
142
+
143
+ async function deletePod(podName, { namespace = 'default', context, kubeconfig } = {}) {
144
+ try {
145
+ await kubectl(
146
+ buildKubectlArgs(
147
+ ['delete', 'pod', podName, '-n', namespace, '--ignore-not-found', '--wait=false'],
148
+ { context, kubeconfig }
149
+ )
150
+ );
151
+ } catch {
152
+ /* best effort */
153
+ }
154
+ }
155
+
156
+ /** Run a one-shot command inside the debug pod, returning combined output. */
157
+ async function execInPod(podName, command, { namespace = 'default', context, kubeconfig, timeout = 60000 } = {}) {
158
+ const args = buildKubectlArgs(
159
+ ['exec', '-n', namespace, podName, '--', 'sh', '-c', command],
160
+ { context, kubeconfig }
161
+ );
162
+ try {
163
+ const { stdout, stderr } = await kubectl(args, { timeout });
164
+ return { ok: true, stdout, stderr };
165
+ } catch (err) {
166
+ return {
167
+ ok: false,
168
+ stdout: err.stdout || '',
169
+ stderr: err.stderr || err.message || String(err),
170
+ };
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Spawn a streaming command in the pod. Returns the ChildProcess so callers can
176
+ * pipe stdout/stderr (e.g. tcpdump) over a websocket and kill it on demand.
177
+ */
178
+ function streamInPod(podName, command, { namespace = 'default', context, kubeconfig } = {}) {
179
+ const args = buildKubectlArgs(
180
+ ['exec', '-i', '-n', namespace, podName, '--', 'sh', '-c', command],
181
+ { context, kubeconfig }
182
+ );
183
+ return spawn(KUBECTL, args);
184
+ }
185
+
186
+ module.exports = {
187
+ KUBECTL,
188
+ DEBUG_IMAGE,
189
+ currentContext,
190
+ listNodes,
191
+ createDebugPod,
192
+ waitForPodReady,
193
+ deletePod,
194
+ execInPod,
195
+ streamInPod,
196
+ };
package/src/probes.js ADDED
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Network-debug "probes". Because the debug pod runs with hostNetwork/hostPID,
5
+ * commands like iptables-save / conntrack / ip run directly against the node's
6
+ * host network namespace. Files that kubelet would otherwise override for the
7
+ * pod (resolv.conf) are read from the mounted host root at /host.
8
+ *
9
+ * Each probe declares fallback commands tried in order until one succeeds, so a
10
+ * single id works across legacy-iptables and nft-based nodes.
11
+ */
12
+
13
+ const PROBES = [
14
+ {
15
+ id: 'iptables',
16
+ label: 'iptables',
17
+ group: 'Firewall',
18
+ desc: 'Full iptables ruleset (host network namespace).',
19
+ commands: ['iptables-save', 'iptables-legacy-save', 'iptables -S'],
20
+ },
21
+ {
22
+ id: 'iptables-nat',
23
+ label: 'iptables (nat)',
24
+ group: 'Firewall',
25
+ desc: 'NAT table — where kube-proxy / CNI install service & SNAT rules.',
26
+ commands: ['iptables-save -t nat', 'iptables -t nat -S'],
27
+ },
28
+ {
29
+ id: 'nftables',
30
+ label: 'nftables',
31
+ group: 'Firewall',
32
+ desc: 'nftables ruleset (modern kube-proxy / firewalls).',
33
+ commands: ['nft list ruleset'],
34
+ },
35
+ {
36
+ id: 'ipvs',
37
+ label: 'IPVS',
38
+ group: 'Firewall',
39
+ desc: 'IPVS virtual servers (kube-proxy ipvs mode).',
40
+ commands: ['ipvsadm -ln', 'ipvsadm -L -n'],
41
+ },
42
+ {
43
+ id: 'resolv',
44
+ label: 'resolv.conf',
45
+ group: 'DNS',
46
+ desc: "The node's /etc/resolv.conf (enters host mount namespace via nsenter so symlinks resolve correctly).",
47
+ // On many distros /etc/resolv.conf is a symlink (e.g. → /run/systemd/resolve/stub-resolv.conf).
48
+ // `cat /host/etc/resolv.conf` fails because the kernel resolves that symlink against the
49
+ // container root, not /host. nsenter --mount enters the host mount namespace, so all paths
50
+ // and symlinks resolve exactly as they would on the node itself.
51
+ commands: [
52
+ 'nsenter --mount=/proc/1/ns/mnt -- cat /etc/resolv.conf',
53
+ 'chroot /host cat /etc/resolv.conf',
54
+ 'cat /host/etc/resolv.conf',
55
+ ],
56
+ },
57
+ {
58
+ id: 'nsswitch',
59
+ label: 'nsswitch.conf',
60
+ group: 'DNS',
61
+ desc: 'Host name-resolution order (/etc/nsswitch.conf).',
62
+ commands: [
63
+ 'nsenter --mount=/proc/1/ns/mnt -- cat /etc/nsswitch.conf',
64
+ 'chroot /host cat /etc/nsswitch.conf',
65
+ 'cat /host/etc/nsswitch.conf',
66
+ ],
67
+ },
68
+ {
69
+ id: 'hosts',
70
+ label: '/etc/hosts',
71
+ group: 'DNS',
72
+ desc: 'Static host entries on the node.',
73
+ commands: [
74
+ 'nsenter --mount=/proc/1/ns/mnt -- cat /etc/hosts',
75
+ 'chroot /host cat /etc/hosts',
76
+ 'cat /host/etc/hosts',
77
+ ],
78
+ },
79
+ {
80
+ id: 'conntrack',
81
+ label: 'conntrack table',
82
+ group: 'Conntrack',
83
+ desc: 'Live connection tracking table.',
84
+ commands: ['conntrack -L', 'cat /host/proc/net/nf_conntrack'],
85
+ },
86
+ {
87
+ id: 'conntrack-stats',
88
+ label: 'conntrack stats',
89
+ group: 'Conntrack',
90
+ desc: 'Per-CPU conntrack counters (insert, drop, early_drop...).',
91
+ commands: ['conntrack -S'],
92
+ },
93
+ {
94
+ id: 'conntrack-count',
95
+ label: 'conntrack count / max',
96
+ group: 'Conntrack',
97
+ desc: 'Current entry count and configured maximum.',
98
+ commands: [
99
+ 'echo "count: $(cat /proc/sys/net/netfilter/nf_conntrack_count)"; echo "max: $(cat /proc/sys/net/netfilter/nf_conntrack_max)"',
100
+ ],
101
+ },
102
+ {
103
+ id: 'routes',
104
+ label: 'routes (v4)',
105
+ group: 'Routing',
106
+ desc: 'IPv4 routing table.',
107
+ commands: ['ip route show', 'route -n'],
108
+ },
109
+ {
110
+ id: 'routes6',
111
+ label: 'routes (v6)',
112
+ group: 'Routing',
113
+ desc: 'IPv6 routing table.',
114
+ commands: ['ip -6 route show'],
115
+ },
116
+ {
117
+ id: 'rules',
118
+ label: 'routing rules',
119
+ group: 'Routing',
120
+ desc: 'Policy routing rules (ip rule).',
121
+ commands: ['ip rule show'],
122
+ },
123
+ {
124
+ id: 'interfaces',
125
+ label: 'interfaces',
126
+ group: 'Interfaces',
127
+ desc: 'All network interfaces and addresses.',
128
+ commands: ['ip -d addr show', 'ip addr show'],
129
+ },
130
+ {
131
+ id: 'links',
132
+ label: 'links',
133
+ group: 'Interfaces',
134
+ desc: 'Link layer details and statistics.',
135
+ commands: ['ip -s link show'],
136
+ },
137
+ {
138
+ id: 'neigh',
139
+ label: 'ARP / neighbors',
140
+ group: 'Interfaces',
141
+ desc: 'ARP / neighbor cache.',
142
+ commands: ['ip neigh show'],
143
+ },
144
+ {
145
+ id: 'sockets',
146
+ label: 'listening sockets',
147
+ group: 'Sockets',
148
+ desc: 'TCP/UDP listening sockets with owning process.',
149
+ commands: ['ss -tulpn', 'netstat -tulpn'],
150
+ },
151
+ {
152
+ id: 'sockets-all',
153
+ label: 'all sockets',
154
+ group: 'Sockets',
155
+ desc: 'All TCP/UDP sockets and their states.',
156
+ commands: ['ss -tanp', 'netstat -tanp'],
157
+ },
158
+ {
159
+ id: 'sysctl-net',
160
+ label: 'net sysctls',
161
+ group: 'Kernel',
162
+ desc: 'Key networking sysctls (forwarding, rp_filter, conntrack...).',
163
+ commands: [
164
+ "sysctl net.ipv4.ip_forward net.ipv4.conf.all.rp_filter net.bridge.bridge-nf-call-iptables net.netfilter.nf_conntrack_max net.ipv4.tcp_syncookies 2>/dev/null",
165
+ ],
166
+ },
167
+
168
+ // ── Health ──────────────────────────────────────────────────────────
169
+ {
170
+ id: 'mem-info',
171
+ label: 'Memory',
172
+ group: 'Health',
173
+ desc: 'Node memory usage, buffers, cache, and swap from /proc/meminfo.',
174
+ commands: ['cat /proc/meminfo'],
175
+ },
176
+ {
177
+ id: 'mem-pressure',
178
+ label: 'PSI pressure',
179
+ group: 'Health',
180
+ desc: 'Linux Pressure Stall Information (PSI) for CPU, memory, and I/O — non-zero avg10 indicates resource contention.',
181
+ commands: [
182
+ 'echo "=cpu="; cat /proc/pressure/cpu 2>/dev/null || echo "n/a"; echo "=memory="; cat /proc/pressure/memory 2>/dev/null || echo "n/a"; echo "=io="; cat /proc/pressure/io 2>/dev/null || echo "n/a"',
183
+ ],
184
+ },
185
+ {
186
+ id: 'oom-kills',
187
+ label: 'OOM kills',
188
+ group: 'Health',
189
+ desc: 'OOM kill events from the kernel ring buffer (dmesg). Empty means no OOM events since last boot.',
190
+ commands: [
191
+ 'nsenter --mount=/proc/1/ns/mnt -- dmesg --time-format=iso 2>/dev/null | grep -iE "oom|out of memory|killed process|oom_kill" | tail -60',
192
+ 'nsenter --mount=/proc/1/ns/mnt -- dmesg 2>/dev/null | grep -iE "oom|out of memory|killed process|oom_kill" | tail -60',
193
+ 'dmesg | grep -iE "oom|out of memory|killed process|oom_kill" | tail -60',
194
+ ],
195
+ },
196
+ {
197
+ id: 'kubelet-logs',
198
+ label: 'kubelet logs',
199
+ group: 'Health',
200
+ desc: 'Last 100 kubelet log lines — look for eviction events, node conditions, and errors.',
201
+ commands: [
202
+ 'nsenter --mount=/proc/1/ns/mnt -- journalctl -u kubelet --no-pager -n 100 --output=short-iso 2>/dev/null',
203
+ 'nsenter --mount=/proc/1/ns/mnt -- journalctl -u kubelet --no-pager -n 100 2>/dev/null',
204
+ ],
205
+ },
206
+ {
207
+ id: 'disk-usage',
208
+ label: 'Disk usage',
209
+ group: 'Health',
210
+ desc: 'Filesystem disk usage on the node (df -h). High /var/lib/kubelet or /var/lib/containerd usage triggers disk-pressure eviction.',
211
+ commands: ['df -h', 'df -hT'],
212
+ },
213
+ {
214
+ id: 'cpu-stat',
215
+ label: 'CPU & load',
216
+ group: 'Health',
217
+ desc: 'Load averages, CPU count, and top CPU-consuming processes.',
218
+ commands: [
219
+ 'echo "=loadavg="; cat /proc/loadavg; echo "=nproc="; nproc --all; echo "=cpumodel="; grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2; echo "=procstat="; cat /proc/stat | head -1; echo "=topproc="; ps aux --sort=-%cpu | head -16',
220
+ ],
221
+ },
222
+
223
+ // ── GPU ─────────────────────────────────────────────────────────────
224
+ {
225
+ id: 'gpu-info',
226
+ label: 'GPU status',
227
+ group: 'GPU',
228
+ desc: 'NVIDIA GPU status via nvidia-smi. Only relevant on GPU-enabled nodes.',
229
+ commands: [
230
+ 'nvidia-smi',
231
+ 'nsenter --mount=/proc/1/ns/mnt -- nvidia-smi 2>/dev/null',
232
+ ],
233
+ },
234
+ {
235
+ id: 'gpu-processes',
236
+ label: 'GPU processes',
237
+ group: 'GPU',
238
+ desc: 'Processes currently consuming GPU memory.',
239
+ commands: [
240
+ 'nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader 2>/dev/null | sort -t, -k2 -rn | head -30',
241
+ 'nvidia-smi pmon -s u -c 1 2>/dev/null',
242
+ ],
243
+ },
244
+ {
245
+ id: 'gpu-dcgm',
246
+ label: 'DCGM health',
247
+ group: 'GPU',
248
+ desc: 'DCGM (Data Center GPU Manager) health check. Requires dcgmi to be installed.',
249
+ commands: [
250
+ 'dcgmi health -g 0 -j 2>/dev/null || dcgmi health -g 0 2>/dev/null || echo "dcgmi not available — DCGM is not installed on this node."',
251
+ ],
252
+ },
253
+ ];
254
+
255
+ module.exports = { PROBES };
package/src/server.js ADDED
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const path = require('path');
5
+ const express = require('express');
6
+ const { WebSocketServer } = require('ws');
7
+
8
+ const k8s = require('./k8s');
9
+ const { PROBES } = require('./probes');
10
+
11
+ /**
12
+ * Builds and starts the debugger server. `session` holds the pod that was
13
+ * created for the target node; everything in the UI operates against it.
14
+ */
15
+ function createServer(session) {
16
+ // session: { node, podName, namespace, context, kubeconfig }
17
+ const podOpts = () => ({
18
+ namespace: session.namespace,
19
+ context: session.context,
20
+ kubeconfig: session.kubeconfig,
21
+ });
22
+
23
+ const app = express();
24
+ app.use(express.json());
25
+ app.use(express.static(path.join(__dirname, '..', 'public')));
26
+
27
+ app.get('/api/session', (req, res) => {
28
+ res.json({
29
+ node: session.node,
30
+ podName: session.podName,
31
+ namespace: session.namespace,
32
+ context: session.context || null,
33
+ image: k8s.DEBUG_IMAGE,
34
+ probes: PROBES.map(({ id, label, group, desc }) => ({ id, label, group, desc })),
35
+ });
36
+ });
37
+
38
+ app.get('/api/nodes', async (req, res) => {
39
+ try {
40
+ res.json(await k8s.listNodes(podOpts()));
41
+ } catch (err) {
42
+ res.status(500).json({ error: err.message });
43
+ }
44
+ });
45
+
46
+ // Run a single named probe (with built-in command fallbacks).
47
+ app.get('/api/probe/:id', async (req, res) => {
48
+ const probe = PROBES.find((p) => p.id === req.params.id);
49
+ if (!probe) return res.status(404).json({ error: 'unknown probe' });
50
+
51
+ let last = null;
52
+ for (const command of probe.commands) {
53
+ const result = await k8s.execInPod(session.podName, command, podOpts());
54
+ last = { ...result, command };
55
+ if (result.ok && result.stdout.trim()) break;
56
+ }
57
+ res.json({
58
+ id: probe.id,
59
+ label: probe.label,
60
+ command: last.command,
61
+ ok: last.ok,
62
+ output: last.stdout || '',
63
+ error: last.ok ? last.stderr : last.stderr || last.stdout,
64
+ });
65
+ });
66
+
67
+ // Connectivity prober — runs nc/curl/ping from the debug pod and
68
+ // correlates with conntrack entries for the target IP.
69
+ app.post('/api/connectivity', async (req, res) => {
70
+ const { target, port, protocol = 'tcp' } = req.body || {};
71
+ if (!target) return res.status(400).json({ error: 'target required' });
72
+
73
+ const opts = { ...podOpts(), timeout: 30000 };
74
+ const portArg = port ? String(port) : '';
75
+ const results = {};
76
+
77
+ // Ping (ICMP reachability)
78
+ const ping = await k8s.execInPod(session.podName,
79
+ `ping -c 3 -W 2 ${target} 2>&1`, opts);
80
+ results.ping = { ok: ping.ok, output: ping.stdout + ping.stderr };
81
+
82
+ // TCP / HTTP / HTTPS
83
+ if (protocol === 'http' || protocol === 'https') {
84
+ const url = `${protocol}://${target}${portArg ? ':' + portArg : ''}`;
85
+ const curl = await k8s.execInPod(session.podName,
86
+ `curl -sv --connect-timeout 5 --max-time 10 "${url}" 2>&1 | head -80`, opts);
87
+ results.curl = { ok: curl.ok, output: curl.stdout + curl.stderr, url };
88
+ } else if (portArg) {
89
+ const nc = await k8s.execInPod(session.podName,
90
+ `nc -zv -w 5 ${target} ${portArg} 2>&1`, opts);
91
+ results.nc = { ok: nc.ok, output: nc.stdout + nc.stderr };
92
+ }
93
+
94
+ // DNS resolution
95
+ const dns = await k8s.execInPod(session.podName,
96
+ `getent hosts ${target} 2>&1 || nslookup ${target} 2>&1 | head -20`, opts);
97
+ results.dns = { ok: dns.ok, output: dns.stdout + dns.stderr };
98
+
99
+ // Matching conntrack entries
100
+ const ct = await k8s.execInPod(session.podName,
101
+ `conntrack -L 2>/dev/null | grep -F "${target}" | head -20`, opts);
102
+ results.conntrack = { ok: ct.ok, output: ct.stdout };
103
+
104
+ // Trace route (best-effort)
105
+ const tr = await k8s.execInPod(session.podName,
106
+ `traceroute -n -m 10 -w 1 ${target} 2>&1 || tracepath -n ${target} 2>&1 | head -20`, opts);
107
+ results.traceroute = { ok: tr.ok, output: tr.stdout + tr.stderr };
108
+
109
+ res.json({ target, port: portArg || null, protocol, results });
110
+ });
111
+
112
+ // Arbitrary command execution from the UI.
113
+ app.post('/api/exec', async (req, res) => {
114
+ const command = (req.body && req.body.command || '').trim();
115
+ if (!command) return res.status(400).json({ error: 'command required' });
116
+ const result = await k8s.execInPod(session.podName, command, {
117
+ ...podOpts(),
118
+ timeout: 120000,
119
+ });
120
+ res.json({
121
+ command,
122
+ ok: result.ok,
123
+ output: result.stdout || '',
124
+ error: result.stderr || '',
125
+ });
126
+ });
127
+
128
+ const server = http.createServer(app);
129
+
130
+ // ---- Streaming terminal over WebSocket ----------------------------------
131
+ // Each connection runs one command at a time; long-running commands
132
+ // (tcpdump, conntrack -E, ping) stream until the client sends a signal.
133
+ const wss = new WebSocketServer({ server, path: '/ws/term' });
134
+ wss.on('connection', (ws) => {
135
+ let current = null;
136
+
137
+ const send = (type, data) => {
138
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type, data }));
139
+ };
140
+
141
+ ws.on('message', (raw) => {
142
+ let msg;
143
+ try {
144
+ msg = JSON.parse(raw.toString());
145
+ } catch {
146
+ return;
147
+ }
148
+
149
+ if (msg.type === 'run') {
150
+ const command = (msg.command || '').trim();
151
+ if (!command) return;
152
+ if (current) {
153
+ send('stderr', '\r\n[a command is already running — interrupt it first]\r\n');
154
+ return;
155
+ }
156
+ send('started', command);
157
+ const child = k8s.streamInPod(session.podName, command, podOpts());
158
+ current = child;
159
+ child.stdout.on('data', (d) => send('stdout', d.toString()));
160
+ child.stderr.on('data', (d) => send('stderr', d.toString()));
161
+ child.on('close', (code) => {
162
+ current = null;
163
+ send('exit', code);
164
+ });
165
+ child.on('error', (err) => {
166
+ current = null;
167
+ send('stderr', `\r\n[exec error] ${err.message}\r\n`);
168
+ send('exit', -1);
169
+ });
170
+ } else if (msg.type === 'signal') {
171
+ if (current) {
172
+ current.kill(msg.signal === 'SIGKILL' ? 'SIGKILL' : 'SIGINT');
173
+ }
174
+ } else if (msg.type === 'stdin') {
175
+ if (current && current.stdin.writable) current.stdin.write(msg.data);
176
+ }
177
+ });
178
+
179
+ ws.on('close', () => {
180
+ if (current) current.kill('SIGKILL');
181
+ });
182
+ });
183
+
184
+ return server;
185
+ }
186
+
187
+ module.exports = { createServer };