@mark1russell7/kind 0.1.3 → 0.1.5
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/README.md +10 -0
- package/build/index.d.ts +6 -0
- package/build/index.js +41 -1
- package/build/index.js.map +1 -1
- package/image/Dockerfile +1 -1
- package/image/agent.mjs +186 -3
- package/package.json +4 -2
- package/src/conntrack.ts +99 -0
- package/src/drops.ts +63 -0
package/image/Dockerfile
CHANGED
|
@@ -14,5 +14,5 @@ RUN for name in quantity algebra service; do \
|
|
|
14
14
|
&& tar -xzf /tmp/workspace/mark1russell7-$name-*.tgz -C "node_modules/@mark1russell7/$name" --strip-components=1; \
|
|
15
15
|
done \
|
|
16
16
|
&& rm -rf /tmp/workspace
|
|
17
|
-
COPY app.mjs client.mjs agent.mjs watch.mjs compose.ts backends.ts ./
|
|
17
|
+
COPY app.mjs client.mjs agent.mjs watch.mjs compose.ts backends.ts conntrack.ts drops.ts ./
|
|
18
18
|
CMD ["node", "app.mjs"]
|
package/image/agent.mjs
CHANGED
|
@@ -2,15 +2,24 @@
|
|
|
2
2
|
// container, thus it sees the processes of each pod of the node, and PID 1 is the init of the node.
|
|
3
3
|
//
|
|
4
4
|
// node agent.mjs serve polls the rules of kube-proxy in the namespaces of the node, as
|
|
5
|
-
// /tmp/rules.json asks, and prints each change of the backends
|
|
5
|
+
// /tmp/rules.json asks, and prints each change of the backends. It
|
|
6
|
+
// also reads the conntrack table of the node (procfs, or netlink
|
|
7
|
+
// with the conntrack tool of the node). It prints each entry
|
|
8
|
+
// that keeps its NAT to a pod that the rules dropped (`stale`), each
|
|
9
|
+
// handshake that stopped for a second (`stalled`). Each line
|
|
10
|
+
// `rules-alive` also counts the reads of the table (`ct`). Each
|
|
11
|
+
// second it reads the counters of dropped packets of the node and
|
|
12
|
+
// of each client pod, and prints each one that grew (`drops`)
|
|
6
13
|
// node agent.mjs act JSON does one act and prints its stamps on CLOCK_MONOTONIC
|
|
7
14
|
//
|
|
8
15
|
// The acts: signal (a signal to a PID), pause (SIGSTOP, then SIGCONT after ms), write (a file, for
|
|
9
16
|
// example cpu.max or a flag file in the root of a container), remove (a file), stall (the stall
|
|
10
17
|
// seam of the app: the ms in /tmp/lab-stall, then SIGUSR2), and now.
|
|
11
18
|
import { spawnSync } from "node:child_process";
|
|
12
|
-
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
20
|
import { backendsOf } from "./backends.ts";
|
|
21
|
+
import { entriesOf, flowsOf, staleOf, stalledOf } from "./conntrack.ts";
|
|
22
|
+
import { devOf, grownOf, snmpOf, softnetOf, unresolvedOf } from "./drops.ts";
|
|
14
23
|
|
|
15
24
|
const now = () => Number(process.hrtime.bigint()) / 1e6;
|
|
16
25
|
const node = process.env.NODE_NAME ?? "";
|
|
@@ -24,6 +33,117 @@ const DUMP = {
|
|
|
24
33
|
ipvs: ["nsenter", "-t", "1", "-n", "--", "cat", "/proc/net/ip_vs"],
|
|
25
34
|
};
|
|
26
35
|
|
|
36
|
+
/**
|
|
37
|
+
* the TCP lines of the conntrack table of the node's network namespace: those of the ClusterIP and
|
|
38
|
+
* those of a handshake. A kernel without CONFIG_NF_CONNTRACK_PROCFS has no /proc/net/nf_conntrack
|
|
39
|
+
* (the kernel of GitHub's runner, 6.17), thus the agent then asks netlink with the conntrack tool of
|
|
40
|
+
* the node. Both give the same form of line. The first read decides the source. `text` is null when
|
|
41
|
+
* no source answers, and `why` tells why.
|
|
42
|
+
*
|
|
43
|
+
* A dump through netlink walks each bucket of the table of the whole kernel with the softirqs off,
|
|
44
|
+
* whatever its filter. A segment that comes in that walk waits for its end. Thus each read is one
|
|
45
|
+
* dump of TCP, and the agent filters it. The workflow makes the table small (kind.yml)
|
|
46
|
+
*/
|
|
47
|
+
const SOURCES = {
|
|
48
|
+
procfs: (ip) => [["nsenter", "-t", "1", "-n", "--", "grep", "-F", "-e", ` dst=${ip} `, "-e", "[UNREPLIED]", "-e", " SYN_RECV ", "/proc/net/nf_conntrack"]],
|
|
49
|
+
netlink: () => [["nsenter", "-t", "1", "-m", "-n", "--", "conntrack", "-L", "-p", "tcp"]],
|
|
50
|
+
};
|
|
51
|
+
let source = null;
|
|
52
|
+
const conntrackOf = (ip) => {
|
|
53
|
+
const why = [];
|
|
54
|
+
for (const name of source === null ? Object.keys(SOURCES) : [source]) {
|
|
55
|
+
const lines = new Set();
|
|
56
|
+
let ok = true;
|
|
57
|
+
for (const [command, ...args] of SOURCES[name](ip)) {
|
|
58
|
+
const run = spawnSync(command, args, { encoding: "utf8", maxBuffer: 64 << 20 });
|
|
59
|
+
// grep exits 1 when no line matches; conntrack exits 0 with no entry
|
|
60
|
+
if (run.status === 0 || (name === "procfs" && run.status === 1)) {
|
|
61
|
+
for (const line of (run.stdout ?? "").split("\n")) if (line.trim() !== "") lines.add(line);
|
|
62
|
+
} else {
|
|
63
|
+
ok = false;
|
|
64
|
+
why.push(`${name}: ${(run.stderr ?? "").trim().slice(0, 200) || `exit ${run.status ?? run.signal ?? run.error?.message}`}`);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (ok) {
|
|
69
|
+
source = name;
|
|
70
|
+
return { text: [...lines].join("\n"), why: null };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { text: null, why: why.join("; ") };
|
|
74
|
+
};
|
|
75
|
+
/** the PIDs of the clients of the node: the agent is in the PID namespace of the node, thus /proc holds each pod */
|
|
76
|
+
const clientsOf = () => {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const name of readdirSync("/proc")) {
|
|
79
|
+
if (!/^\d+$/.test(name)) continue;
|
|
80
|
+
try {
|
|
81
|
+
if (readFileSync(`/proc/${name}/cmdline`, "utf8").includes("client.mjs")) out.push(name);
|
|
82
|
+
} catch {
|
|
83
|
+
// the process ended in the scan
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* the counters of dropped packets, by name. For the node: the drops of the backlogs and the squeezes
|
|
91
|
+
* of the softirqs (the kernel, all CPUs), and the drops of each veth. For each client pod, through
|
|
92
|
+
* /proc/<pid>/net: the IP discards and the sends with no route, the failed connects, the ICMP
|
|
93
|
+
* errors of an unreachable destination, the drops of each interface, and the neighbors with no address yet
|
|
94
|
+
*/
|
|
95
|
+
const countersOf = () => {
|
|
96
|
+
const values = {};
|
|
97
|
+
const read = (path) => {
|
|
98
|
+
try {
|
|
99
|
+
return readFileSync(path, "utf8");
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const softnet = read("/proc/1/net/softnet_stat");
|
|
105
|
+
if (softnet !== null) {
|
|
106
|
+
const { dropped, squeezed } = softnetOf(softnet);
|
|
107
|
+
values["kernel.backlog-drops"] = dropped;
|
|
108
|
+
values["kernel.squeezes"] = squeezed;
|
|
109
|
+
}
|
|
110
|
+
const dev = read("/proc/1/net/dev");
|
|
111
|
+
if (dev !== null) {
|
|
112
|
+
for (const [name, drops] of Object.entries(devOf(dev))) {
|
|
113
|
+
if (!name.startsWith("veth")) continue;
|
|
114
|
+
values[`node.${name}.rx-drops`] = drops.rx;
|
|
115
|
+
values[`node.${name}.tx-drops`] = drops.tx;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const pid of clientsOf()) {
|
|
119
|
+
const snmp = read(`/proc/${pid}/net/snmp`);
|
|
120
|
+
const own = read(`/proc/${pid}/net/dev`);
|
|
121
|
+
const arp = read(`/proc/${pid}/net/arp`);
|
|
122
|
+
if (snmp === null || own === null || arp === null) continue;
|
|
123
|
+
const ip = snmpOf(snmp, "Ip");
|
|
124
|
+
const tcp = snmpOf(snmp, "Tcp");
|
|
125
|
+
values[`client.${pid}.ip-out-discards`] = ip["OutDiscards"] ?? 0;
|
|
126
|
+
values[`client.${pid}.ip-out-no-routes`] = ip["OutNoRoutes"] ?? 0;
|
|
127
|
+
values[`client.${pid}.tcp-attempt-fails`] = tcp["AttemptFails"] ?? 0;
|
|
128
|
+
// a refused connect is a reset, or an ICMP error such as the port unreachable of a reject rule
|
|
129
|
+
values[`client.${pid}.icmp-in-unreachable`] = snmpOf(snmp, "Icmp")["InDestUnreachs"] ?? 0;
|
|
130
|
+
for (const [name, drops] of Object.entries(devOf(own))) {
|
|
131
|
+
if (name === "lo") continue;
|
|
132
|
+
values[`client.${pid}.${name}.rx-drops`] = drops.rx;
|
|
133
|
+
values[`client.${pid}.${name}.tx-drops`] = drops.tx;
|
|
134
|
+
}
|
|
135
|
+
values[`client.${pid}.unresolved-neighbors`] = unresolvedOf(arp);
|
|
136
|
+
}
|
|
137
|
+
return values;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* how often the agent reads the conntrack table, in ms. Three dumps each 250 ms on each node took
|
|
142
|
+
* three quarters of the CPUs of a runner (run 39066066532), and the lab waited for an idle VM until
|
|
143
|
+
* the end. An entry of a SYN with no reply lives 120 s, thus a read each second sees it
|
|
144
|
+
*/
|
|
145
|
+
const CONNTRACK_EVERY = 1000;
|
|
146
|
+
|
|
27
147
|
const serve = async () => {
|
|
28
148
|
let last = null;
|
|
29
149
|
let previous = null;
|
|
@@ -31,6 +151,15 @@ const serve = async () => {
|
|
|
31
151
|
let spent = 0;
|
|
32
152
|
let beat = now();
|
|
33
153
|
let dumped = null;
|
|
154
|
+
// the conntrack read: its last time, the stale entries and the stalled handshakes that it saw,
|
|
155
|
+
// whether the table exists, and the count of the reads since the last beat
|
|
156
|
+
let read = 0;
|
|
157
|
+
let seen = new Map();
|
|
158
|
+
let stuck = new Map();
|
|
159
|
+
let conntrack = true;
|
|
160
|
+
let count = { reads: 0, ms: 0, lines: 0, vip: 0, states: {}, syn: [], error: null };
|
|
161
|
+
// the counters of dropped packets at the last read, and the count of that last read's values
|
|
162
|
+
let counters = null;
|
|
34
163
|
for (;;) {
|
|
35
164
|
if (!existsSync("/tmp/rules.json")) {
|
|
36
165
|
last = null;
|
|
@@ -58,10 +187,64 @@ const serve = async () => {
|
|
|
58
187
|
say({ ev: "rules", t, prev: previous, took: Math.round(took * 1000) / 1000, b: backends });
|
|
59
188
|
last = key;
|
|
60
189
|
}
|
|
190
|
+
// a new SYN on the port of a stale entry goes to the pod of the entry, not to a backend of the rules
|
|
191
|
+
if (conntrack && t - read >= CONNTRACK_EVERY) {
|
|
192
|
+
read = t;
|
|
193
|
+
try {
|
|
194
|
+
const { text, why } = conntrackOf(rule.ip);
|
|
195
|
+
count.reads += 1;
|
|
196
|
+
count.ms += now() - t;
|
|
197
|
+
if (text === null) {
|
|
198
|
+
// the lab drops the lines before a run, thus each beat tells it again in ct.error
|
|
199
|
+
say({ ev: "conntrack-fail", t, why });
|
|
200
|
+
count.error = why;
|
|
201
|
+
conntrack = false;
|
|
202
|
+
} else {
|
|
203
|
+
const flows = flowsOf(text);
|
|
204
|
+
const entries = entriesOf(text, rule.ip, rule.port);
|
|
205
|
+
count.lines = flows.length;
|
|
206
|
+
count.vip = entries.length;
|
|
207
|
+
count.states = {};
|
|
208
|
+
for (const entry of entries) count.states[entry.state] = (count.states[entry.state] ?? 0) + 1;
|
|
209
|
+
// a sample of the entries in SYN_SENT: a fresh SYN has the life 120, and an old one less
|
|
210
|
+
count.syn = entries.filter((entry) => entry.state === "SYN_SENT").slice(0, 3).map((entry) => ({ client: entry.client, backend: entry.backend, ttl: entry.ttl, unreplied: entry.unreplied }));
|
|
211
|
+
const next = new Map();
|
|
212
|
+
for (const entry of staleOf(entries, backends)) {
|
|
213
|
+
const id = `${entry.client}>${entry.backend}`;
|
|
214
|
+
next.set(id, entry.state);
|
|
215
|
+
// each entry once, and again when its state changes
|
|
216
|
+
if (seen.get(id) !== entry.state) say({ ev: "stale", t, state: entry.state, ttl: entry.ttl, client: entry.client, backend: entry.backend, unreplied: entry.unreplied });
|
|
217
|
+
}
|
|
218
|
+
seen = next;
|
|
219
|
+
// a connection that times out: where its SYN went, and whether an answer came on this node
|
|
220
|
+
const held = new Map();
|
|
221
|
+
for (const flow of stalledOf(flows, 1)) {
|
|
222
|
+
const id = `${flow.from}>${flow.to}`;
|
|
223
|
+
held.set(id, flow.state);
|
|
224
|
+
if (stuck.get(id) !== flow.state) say({ ev: "stalled", t, state: flow.state, ttl: flow.ttl, from: flow.from, to: flow.to, reply: flow.reply, unreplied: flow.unreplied });
|
|
225
|
+
}
|
|
226
|
+
stuck = held;
|
|
227
|
+
}
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// a read that fails stops the reads, and the next beat tells why
|
|
230
|
+
count.error = String(error?.stack ?? error).slice(0, 600);
|
|
231
|
+
conntrack = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
61
234
|
}
|
|
62
235
|
previous = t;
|
|
63
236
|
if (t - beat >= 1000) {
|
|
64
|
-
|
|
237
|
+
// a packet lost between a client and the conntrack of the node leaves a trace in one of these
|
|
238
|
+
const next = countersOf();
|
|
239
|
+
if (counters !== null) {
|
|
240
|
+
const grown = grownOf(counters, next);
|
|
241
|
+
if (Object.keys(grown).length > 0) say({ ev: "drops", t, grown });
|
|
242
|
+
}
|
|
243
|
+
counters = next;
|
|
244
|
+
// the reads of the conntrack table since the last beat: a beat without them did not read it
|
|
245
|
+
const ct = { source, reads: count.reads, ms: count.reads === 0 ? 0 : Math.round((count.ms / count.reads) * 100) / 100, lines: count.lines, vip: count.vip, states: count.states, syn: count.syn, on: conntrack, ...(count.error === null ? {} : { error: count.error }) };
|
|
246
|
+
say({ ev: "rules-alive", t, polls, ms: Math.round((spent / polls) * 100) / 100, ct });
|
|
247
|
+
count = { ...count, reads: 0, ms: 0 };
|
|
65
248
|
beat = t;
|
|
66
249
|
polls = 0;
|
|
67
250
|
spent = 0;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@mark1russell7/kind",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.5",
|
|
5
5
|
"description": "Kubernetes in Docker as a lab for connection timers: a kind cluster from a scenario, faults in the namespaces of its pods, observations on the one monotonic clock of the VM, and predictions from the ordering law. Its judge entry reads and judges a raw record without Docker.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"kubernetes",
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"image/watch.mjs",
|
|
52
52
|
"src/compose.ts",
|
|
53
53
|
"src/backends.ts",
|
|
54
|
+
"src/conntrack.ts",
|
|
55
|
+
"src/drops.ts",
|
|
54
56
|
"README.md",
|
|
55
57
|
"LICENSE"
|
|
56
58
|
],
|
|
@@ -58,7 +60,7 @@
|
|
|
58
60
|
"node": ">=22.19"
|
|
59
61
|
},
|
|
60
62
|
"dependencies": {
|
|
61
|
-
"@mark1russell7/harness": "^0.11.
|
|
63
|
+
"@mark1russell7/harness": "^0.11.1",
|
|
62
64
|
"@mark1russell7/quantity": "^0.2.0",
|
|
63
65
|
"@mark1russell7/service": "^0.4.1",
|
|
64
66
|
"testcontainers": "12.2.0"
|
package/src/conntrack.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The parser of the conntrack table of a node. The agent of each node reads the table with it, and
|
|
2
|
+
// the image copies this file, thus it imports nothing.
|
|
3
|
+
|
|
4
|
+
/** A flow is one conntrack entry of TCP: its state, its life in seconds, its original tuple and the source of its replies. */
|
|
5
|
+
export interface ConntrackFlow {
|
|
6
|
+
readonly state: string;
|
|
7
|
+
readonly ttl: number;
|
|
8
|
+
/** This address is the original source of the flow, as `ip:port`. */
|
|
9
|
+
readonly from: string;
|
|
10
|
+
/** This address is the original destination of the flow, as `ip:port`: a ClusterIP before its DNAT. */
|
|
11
|
+
readonly to: string;
|
|
12
|
+
/** This address is the source of the replies, as `ip:port`: the backend that the DNAT of the entry chose. */
|
|
13
|
+
readonly reply: string;
|
|
14
|
+
/** This flag tells that no packet came back on the flow. */
|
|
15
|
+
readonly unreplied: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** An entry is one conntrack entry of a TCP flow to the ClusterIP. It has a state, a life in seconds, a client and the backend of its NAT. */
|
|
19
|
+
export interface ConntrackEntry {
|
|
20
|
+
readonly state: string;
|
|
21
|
+
readonly ttl: number;
|
|
22
|
+
/** This address is the original source of the flow: the client, as `ip:port`. */
|
|
23
|
+
readonly client: string;
|
|
24
|
+
/** This address is the source of the replies: the backend that the DNAT of the entry chose, as `ip:port`. */
|
|
25
|
+
readonly backend: string;
|
|
26
|
+
/** This flag tells that no packet came back on the flow. */
|
|
27
|
+
readonly unreplied: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* These states keep the NAT of an entry for a new SYN on the same port (nf_conntrack_proto_tcp.c at
|
|
32
|
+
* v6.8). A SYN on an entry in TIME_WAIT removes the entry and looks up again (:1011-1027), thus that
|
|
33
|
+
* SYN gets a backend from the rules. In each other state, the SYN goes to the backend of the entry.
|
|
34
|
+
* A CLOSE that a reset from the server made does not reopen either.
|
|
35
|
+
*/
|
|
36
|
+
export const KEEPS_NAT: ReadonlySet<string> = new Set(["SYN_SENT", "SYN_SENT2", "SYN_RECV", "ESTABLISHED", "FIN_WAIT", "CLOSE_WAIT", "LAST_ACK", "CLOSE"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* These are the lives of an entry in the states of a handshake, in seconds
|
|
40
|
+
* (nf_conntrack_tcp_timeout_syn_sent and _syn_recv). A retransmission of the SYN does not renew the
|
|
41
|
+
* life of SYN_SENT (nf_conntrack_proto_tcp.c at v6.8). Thus the life of such an entry tells the age
|
|
42
|
+
* of its first SYN.
|
|
43
|
+
*/
|
|
44
|
+
export const HANDSHAKE_LIFE: Readonly<Record<string, number>> = { SYN_SENT: 120, SYN_RECV: 60 };
|
|
45
|
+
|
|
46
|
+
/** This form is one TCP line of `/proc/net/nf_conntrack` or `conntrack -L`. It has the timeout and state, the original tuple, the flags and counters, then the reply tuple. */
|
|
47
|
+
const LINE = /\btcp\s+\d+\s+(\d+)\s+(\S+)\s+src=(\S+) dst=(\S+) sport=(\d+) dport=(\d+)(.*?)\bsrc=(\S+) dst=\S+ sport=(\d+) dport=\d+/;
|
|
48
|
+
|
|
49
|
+
/** This function gives the TCP flows of a dump of the conntrack table. */
|
|
50
|
+
export const flowsOf = (text: string): ConntrackFlow[] => {
|
|
51
|
+
const out: ConntrackFlow[] = [];
|
|
52
|
+
for (const line of text.split("\n")) {
|
|
53
|
+
const match = LINE.exec(line);
|
|
54
|
+
if (match === null) continue;
|
|
55
|
+
out.push({
|
|
56
|
+
ttl: Number(match[1]),
|
|
57
|
+
state: match[2]!,
|
|
58
|
+
from: `${match[3]}:${match[5]}`,
|
|
59
|
+
to: `${match[4]}:${match[6]}`,
|
|
60
|
+
reply: `${match[8]}:${match[9]}`,
|
|
61
|
+
unreplied: match[7]!.includes("[UNREPLIED]"),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** This function gives the TCP entries of a dump of the conntrack table whose original destination is the ClusterIP and port. */
|
|
68
|
+
export const entriesOf = (text: string, ip: string, port: number): ConntrackEntry[] =>
|
|
69
|
+
flowsOf(text)
|
|
70
|
+
.filter((flow) => flow.to === `${ip}:${port}`)
|
|
71
|
+
.map((flow) => ({ state: flow.state, ttl: flow.ttl, client: flow.from, backend: flow.reply, unreplied: flow.unreplied }));
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* This function gives the entries that keep their NAT to a pod that is not a backend of the rules.
|
|
75
|
+
* A new SYN from the client port of such an entry goes to that pod, whatever the rules say.
|
|
76
|
+
*/
|
|
77
|
+
export const staleOf = (entries: readonly ConntrackEntry[], backends: readonly string[]): ConntrackEntry[] =>
|
|
78
|
+
entries.filter((entry) => KEEPS_NAT.has(entry.state) && !backends.includes(entry.backend));
|
|
79
|
+
|
|
80
|
+
/** This function gives the count of entries that `conntrack -D` deleted, from its last line: "N flow entries have been deleted". */
|
|
81
|
+
export const deletedOf = (text: string): number => {
|
|
82
|
+
const match = /(\d+) flow entr(?:y|ies) ha(?:s|ve) been deleted/.exec(text);
|
|
83
|
+
if (match === null) throw new Error(`conntrack -D gave no count: ${text.trim().slice(0, 200)}`);
|
|
84
|
+
return Number(match[1]);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* This function gives the flows whose handshake stopped `age` seconds ago or more. Such a flow is
|
|
89
|
+
* a SYN with no reply (SYN_SENT), or a SYN and its SYN-ACK with no ACK (SYN_RECV). It is a
|
|
90
|
+
* connection that times out. On the node of the client, its reply address is the backend of its
|
|
91
|
+
* DNAT. On the node of that backend, a SYN_SENT tells that the backend did not answer. There, a
|
|
92
|
+
* SYN_RECV tells that its answer did not come back.
|
|
93
|
+
*/
|
|
94
|
+
export const stalledOf = (flows: readonly ConntrackFlow[], age: number): ConntrackFlow[] =>
|
|
95
|
+
flows.filter((flow) => {
|
|
96
|
+
const life = HANDSHAKE_LIFE[flow.state];
|
|
97
|
+
if (life === undefined || (flow.state === "SYN_SENT" && !flow.unreplied)) return false;
|
|
98
|
+
return flow.ttl <= life - age;
|
|
99
|
+
});
|
package/src/drops.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The counters of the dropped packets of a node and of its client pods. The agent of each node
|
|
2
|
+
// reads them each second, and the image copies this file, thus it imports nothing.
|
|
3
|
+
|
|
4
|
+
/** This function gives the values of one protocol of `/proc/net/snmp`: a line of names, then a line of values. */
|
|
5
|
+
export const snmpOf = (text: string, protocol: string): Record<string, number> => {
|
|
6
|
+
const lines = text.split("\n").filter((line) => line.startsWith(`${protocol}:`));
|
|
7
|
+
if (lines.length < 2) return {};
|
|
8
|
+
const names = lines[0]!.trim().split(/\s+/).slice(1);
|
|
9
|
+
const values = lines[1]!.trim().split(/\s+/).slice(1).map(Number);
|
|
10
|
+
return Object.fromEntries(names.map((name, at) => [name, values[at] ?? 0]));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** These are the dropped packets of one interface: received and sent. */
|
|
14
|
+
export interface InterfaceDrops {
|
|
15
|
+
readonly rx: number;
|
|
16
|
+
readonly tx: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** This function gives the dropped packets of each interface of `/proc/net/dev`. The fourth field of each direction is its drops. */
|
|
20
|
+
export const devOf = (text: string): Record<string, InterfaceDrops> => {
|
|
21
|
+
const out: Record<string, InterfaceDrops> = {};
|
|
22
|
+
for (const line of text.split("\n").slice(2)) {
|
|
23
|
+
const match = /^\s*([^:\s]+):\s*(.*)$/.exec(line);
|
|
24
|
+
if (match === null) continue;
|
|
25
|
+
const fields = match[2]!.trim().split(/\s+/).map(Number);
|
|
26
|
+
out[match[1]!] = { rx: fields[3] ?? 0, tx: fields[11] ?? 0 };
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This function gives two sums over the CPUs of `/proc/net/softnet_stat`. The first is the packets
|
|
33
|
+
* that a full backlog dropped. The second is the times that a softirq used all of its budget. Each
|
|
34
|
+
* line is one CPU in hexadecimal: the processed packets, the drops, then the squeezes.
|
|
35
|
+
*/
|
|
36
|
+
export const softnetOf = (text: string): { readonly dropped: number; readonly squeezed: number } => {
|
|
37
|
+
let dropped = 0;
|
|
38
|
+
let squeezed = 0;
|
|
39
|
+
for (const line of text.trim().split("\n")) {
|
|
40
|
+
const fields = line.trim().split(/\s+/);
|
|
41
|
+
if (fields.length < 3) continue;
|
|
42
|
+
dropped += parseInt(fields[1]!, 16);
|
|
43
|
+
squeezed += parseInt(fields[2]!, 16);
|
|
44
|
+
}
|
|
45
|
+
return { dropped, squeezed };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** This function counts the entries of `/proc/net/arp` with no address yet (flags 0x0). A packet to such a neighbor waits in a queue, then drops. */
|
|
49
|
+
export const unresolvedOf = (text: string): number =>
|
|
50
|
+
text
|
|
51
|
+
.split("\n")
|
|
52
|
+
.slice(1)
|
|
53
|
+
.filter((line) => line.trim() !== "" && line.trim().split(/\s+/)[2] === "0x0").length;
|
|
54
|
+
|
|
55
|
+
/** This function gives each counter that grew from one read to the next, with its growth. A counter of the second read only is not in it. */
|
|
56
|
+
export const grownOf = (before: Readonly<Record<string, number>>, after: Readonly<Record<string, number>>): Record<string, number> => {
|
|
57
|
+
const out: Record<string, number> = {};
|
|
58
|
+
for (const [name, value] of Object.entries(after)) {
|
|
59
|
+
const last = before[name];
|
|
60
|
+
if (last !== undefined && value > last) out[name] = value - last;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
};
|