@mark1russell7/harness 0.2.0 → 0.4.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/README.md +76 -19
- package/build/index.d.ts +446 -31
- package/build/index.js +1106 -336
- package/build/index.js.map +1 -1
- package/image/native/src/bin/client.rs +63 -3
- package/image/native/src/bin/inject.rs +119 -7
- package/image/router/Dockerfile +8 -0
- package/image/router/ctwatch.py +66 -0
- package/image/router/mbx.sh +61 -0
- package/image/router/resetter.py +104 -0
- package/package.json +7 -3
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
//! timeout makes the server answer only when the timeout runs out, so its reply can be the one in
|
|
16
16
|
//! flight when a fault lands.
|
|
17
17
|
//! stream: then wait for --go, and send the request --count times, each when the reply to the one
|
|
18
|
-
//! before it
|
|
19
|
-
//! whose outcome is one (loss, reordering).
|
|
18
|
+
//! before it is whole and --interval-ms has passed: a distribution of reply times, for a fault
|
|
19
|
+
//! whose outcome is one (loss, reordering). A reply is whole at its CRLF, or for a bulk string
|
|
20
|
+
//! after its length: so `GET big` keeps a long reply's many segments in flight, and the replied
|
|
21
|
+
//! line stamps its last byte. It ends with a done line.
|
|
20
22
|
|
|
21
23
|
use prober::{errno_name, monotonic_ns, quantity};
|
|
22
24
|
use serde_json::{json, Value};
|
|
@@ -87,6 +89,40 @@ fn readable(fd: i32, ms: i32) -> std::io::Result<bool> {
|
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
/// The whole length of a Redis reply from its first bytes, or None while its first line is not
|
|
93
|
+
/// all there: a simple string, an error or an integer ends at its CRLF, and a bulk string after
|
|
94
|
+
/// its length and one more CRLF (a null one, of length -1, at its first CRLF).
|
|
95
|
+
fn reply_length(head: &[u8]) -> Option<usize> {
|
|
96
|
+
let line = head.windows(2).position(|pair| pair == b"\r\n")? + 2;
|
|
97
|
+
if head.first() != Some(&b'$') {
|
|
98
|
+
return Some(line);
|
|
99
|
+
}
|
|
100
|
+
let length: i64 = std::str::from_utf8(&head[1..line - 2]).ok()?.parse().ok()?;
|
|
101
|
+
Some(if length < 0 { line } else { line + length as usize + 2 })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// The reply a stream waits for: its first bytes until its length is known, and how much came.
|
|
105
|
+
#[derive(Default)]
|
|
106
|
+
struct Reply {
|
|
107
|
+
head: Vec<u8>,
|
|
108
|
+
length: Option<usize>,
|
|
109
|
+
got: usize,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
impl Reply {
|
|
113
|
+
/// Takes the bytes of one read, and says whether the reply is whole.
|
|
114
|
+
fn take(&mut self, bytes: &[u8]) -> bool {
|
|
115
|
+
self.got += bytes.len();
|
|
116
|
+
if self.length.is_none() {
|
|
117
|
+
// a bulk string's first line is its length: 64 bytes hold any
|
|
118
|
+
let room = 64usize.saturating_sub(self.head.len());
|
|
119
|
+
self.head.extend_from_slice(&bytes[..bytes.len().min(room)]);
|
|
120
|
+
self.length = reply_length(&self.head);
|
|
121
|
+
}
|
|
122
|
+
matches!(self.length, Some(length) if self.got >= length)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
/// what in TCP_INFO is worth a line when it changes: the timers' own state, never the ages
|
|
91
127
|
fn watched(info: &libc::tcp_info) -> [u32; 7] {
|
|
92
128
|
[
|
|
@@ -186,6 +222,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|
|
186
222
|
// stream mode: requests sent, whether one waits for its reply, and when the next may go
|
|
187
223
|
let mut sent: u32 = 0;
|
|
188
224
|
let mut awaiting = false;
|
|
225
|
+
let mut reply = Reply::default();
|
|
189
226
|
let mut next_at: u64 = 0;
|
|
190
227
|
loop {
|
|
191
228
|
if readable(fd, tick)? {
|
|
@@ -196,8 +233,10 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|
|
196
233
|
}
|
|
197
234
|
Ok(n) => {
|
|
198
235
|
emit("data", json!({ "n": n, "text": String::from_utf8_lossy(&buf[..n.min(40)]) }));
|
|
199
|
-
if awaiting {
|
|
236
|
+
if awaiting && reply.take(&buf[..n]) {
|
|
237
|
+
emit("replied", json!({ "n": quantity(reply.got as u32, "B") }));
|
|
200
238
|
awaiting = false;
|
|
239
|
+
reply = Reply::default();
|
|
201
240
|
next_at = monotonic_ns() + args.interval.as_nanos() as u64;
|
|
202
241
|
}
|
|
203
242
|
}
|
|
@@ -261,3 +300,24 @@ fn main() {
|
|
|
261
300
|
std::process::exit(1);
|
|
262
301
|
}
|
|
263
302
|
}
|
|
303
|
+
|
|
304
|
+
#[cfg(test)]
|
|
305
|
+
mod tests {
|
|
306
|
+
use super::*;
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn a_reply_is_whole_at_its_crlf_or_after_its_bulk_length() {
|
|
310
|
+
assert_eq!(reply_length(b"+PONG\r\n"), Some(7));
|
|
311
|
+
assert_eq!(reply_length(b"+PON"), None);
|
|
312
|
+
assert_eq!(reply_length(b"$5\r\nhello\r\n"), Some(11));
|
|
313
|
+
assert_eq!(reply_length(b"$-1\r\n"), Some(5));
|
|
314
|
+
assert_eq!(reply_length(b"$1000000\r\nxx"), Some(1_000_012));
|
|
315
|
+
// a long reply in reads of any size: whole only at its last byte
|
|
316
|
+
let mut reply = Reply::default();
|
|
317
|
+
assert!(!reply.take(b"$10\r"));
|
|
318
|
+
assert!(!reply.take(b"\n01234"));
|
|
319
|
+
assert!(!reply.take(b"56789\r"));
|
|
320
|
+
assert!(reply.take(b"\n"));
|
|
321
|
+
assert_eq!(reply.got, 17);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -23,10 +23,19 @@
|
|
|
23
23
|
//! `netem eth0 delay 100ms 10ms` or `netem eth0 loss 5%`.
|
|
24
24
|
//! - `offload DEV on|off`: the device's TSO, GSO and GRO, so that netem and length matches act on
|
|
25
25
|
//! segments rather than on GSO super-packets of up to 64 KB.
|
|
26
|
+
//! - `http METHOD ADDR:PORT PATH [BODY]`: one HTTP/1.1 request to an API in the VM, for example a
|
|
27
|
+
//! toxic of Toxiproxy. The stamps bracket the request and its answer, after the connect, so the
|
|
28
|
+
//! commit of the toxic is known on the same clock as the kernel's records.
|
|
26
29
|
//!
|
|
27
30
|
//! `--at NS` holds the commit until that instant on CLOCK_MONOTONIC, the clock every stamp is on.
|
|
28
31
|
//! A stall's end is timed that way, from inside the VM: the exec only has to arrive before it, and
|
|
29
32
|
//! its 190 ms never reach the stall's length.
|
|
33
|
+
//!
|
|
34
|
+
//! `--via DEV` puts a filter rule in the FORWARD chain, for a router: its INPUT and OUTPUT see only
|
|
35
|
+
//! what the router itself sends and receives. The rule faces one side by the device toward it:
|
|
36
|
+
//! what comes from the peer arrives on DEV (`-i DEV -s PEER`), and what goes to it leaves on DEV
|
|
37
|
+
//! (`-o DEV -d PEER`). A REJECT then answers from the router: a reset spoofs the address the
|
|
38
|
+
//! segment was going to, and an ICMP error comes from the router's own address on DEV.
|
|
30
39
|
|
|
31
40
|
use prober::monotonic_ns;
|
|
32
41
|
use serde_json::json;
|
|
@@ -60,13 +69,15 @@ fn until(at: u64) -> Result<(), Box<dyn Error>> {
|
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
|
|
63
|
-
/// The options after the positional arguments: `--at NS`, `--direction D`, `--code C`, `--above N
|
|
72
|
+
/// The options after the positional arguments: `--at NS`, `--direction D`, `--code C`, `--above N`,
|
|
73
|
+
/// `--via DEV`.
|
|
64
74
|
#[derive(Default)]
|
|
65
75
|
struct Options {
|
|
66
76
|
at: Option<u64>,
|
|
67
77
|
direction: Option<String>,
|
|
68
78
|
code: Option<String>,
|
|
69
79
|
above: Option<u32>,
|
|
80
|
+
via: Option<String>,
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
fn options(args: &[String]) -> Result<Options, Box<dyn Error>> {
|
|
@@ -79,6 +90,7 @@ fn options(args: &[String]) -> Result<Options, Box<dyn Error>> {
|
|
|
79
90
|
"--direction" => out.direction = Some(value.clone()),
|
|
80
91
|
"--code" => out.code = Some(value.clone()),
|
|
81
92
|
"--above" => out.above = Some(value.parse()?),
|
|
93
|
+
"--via" => out.via = Some(device(value)?.to_string()),
|
|
82
94
|
other => return Err(format!("unknown option {other}").into()),
|
|
83
95
|
}
|
|
84
96
|
}
|
|
@@ -88,12 +100,18 @@ fn options(args: &[String]) -> Result<Options, Box<dyn Error>> {
|
|
|
88
100
|
/// The iptables rules of an action that is a filter rule, or None for an action that is not.
|
|
89
101
|
fn rules(action: &str, peer: Ipv4Addr, opts: &Options) -> Result<String, Box<dyn Error>> {
|
|
90
102
|
let op = |set: &str| if action == set { "-I" } else { "-D" };
|
|
103
|
+
// what comes from the peer, and what goes to it: at an end, the INPUT and OUTPUT of its own
|
|
104
|
+
// sockets; at a router, what it forwards in and out of the device toward the peer
|
|
105
|
+
let (from, to) = match opts.via.as_deref() {
|
|
106
|
+
None => (format!("INPUT -s {peer}/32"), format!("OUTPUT -d {peer}/32")),
|
|
107
|
+
Some(dev) => (format!("FORWARD -i {dev} -s {peer}/32"), format!("FORWARD -o {dev} -d {peer}/32")),
|
|
108
|
+
};
|
|
91
109
|
Ok(match action {
|
|
92
110
|
"blackhole" | "clear" => {
|
|
93
111
|
let op = op("blackhole");
|
|
94
112
|
let direction = opts.direction.as_deref().unwrap_or("both");
|
|
95
|
-
let input = format!("{op}
|
|
96
|
-
let output = format!("{op}
|
|
113
|
+
let input = format!("{op} {from} -j DROP\n");
|
|
114
|
+
let output = format!("{op} {to} -j DROP\n");
|
|
97
115
|
match direction {
|
|
98
116
|
"both" => format!("{input}{output}"),
|
|
99
117
|
"in" => input,
|
|
@@ -103,7 +121,7 @@ fn rules(action: &str, peer: Ipv4Addr, opts: &Options) -> Result<String, Box<dyn
|
|
|
103
121
|
}
|
|
104
122
|
"rst" | "clear-rst" => {
|
|
105
123
|
let op = op("rst");
|
|
106
|
-
format!("{op}
|
|
124
|
+
format!("{op} {from} -p tcp -j REJECT --reject-with tcp-reset\n")
|
|
107
125
|
}
|
|
108
126
|
"reject" | "clear-reject" => {
|
|
109
127
|
let op = op("reject");
|
|
@@ -114,14 +132,14 @@ fn rules(action: &str, peer: Ipv4Addr, opts: &Options) -> Result<String, Box<dyn
|
|
|
114
132
|
"admin" => "icmp-admin-prohibited",
|
|
115
133
|
other => return Err(format!("unknown code {other}").into()),
|
|
116
134
|
};
|
|
117
|
-
format!("{op}
|
|
135
|
+
format!("{op} {from} -p tcp -j REJECT --reject-with {with}\n")
|
|
118
136
|
}
|
|
119
137
|
// the long segments drop where they arrive: in the sender's own namespace, an OUTPUT drop
|
|
120
138
|
// would fail its sends with EPERM rather than lose them on the way
|
|
121
139
|
"mtu" | "clear-mtu" => {
|
|
122
140
|
let op = op("mtu");
|
|
123
141
|
let above = opts.above.ok_or("--above N")?;
|
|
124
|
-
format!("{op}
|
|
142
|
+
format!("{op} {from} -p tcp -m length --length {}:65535 -j DROP\n", above + 1)
|
|
125
143
|
}
|
|
126
144
|
other => return Err(format!("unknown action {other}").into()),
|
|
127
145
|
})
|
|
@@ -165,9 +183,63 @@ fn command(program: &str, args: &[&str]) -> Result<(), Box<dyn Error>> {
|
|
|
165
183
|
Ok(())
|
|
166
184
|
}
|
|
167
185
|
|
|
186
|
+
/// an HTTP path: a slash, then letters, digits and `/ - _ .` only
|
|
187
|
+
fn http_path(path: &str) -> Result<&str, Box<dyn Error>> {
|
|
188
|
+
if path.starts_with('/')
|
|
189
|
+
&& path.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
|
|
190
|
+
{
|
|
191
|
+
Ok(path)
|
|
192
|
+
} else {
|
|
193
|
+
Err(format!("not an HTTP path: {path}").into())
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// the head of one HTTP/1.1 request, with its body
|
|
198
|
+
fn http_request(method: &str, host: &str, path: &str, body: &str) -> Result<String, Box<dyn Error>> {
|
|
199
|
+
if !matches!(method, "GET" | "POST" | "DELETE") {
|
|
200
|
+
return Err(format!("not a method: {method}").into());
|
|
201
|
+
}
|
|
202
|
+
Ok(format!(
|
|
203
|
+
"{method} {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
204
|
+
body.len()
|
|
205
|
+
))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// one request to an API: the status, and the stamps around the request and its answer
|
|
209
|
+
fn http(args: &[String]) -> Result<(), Box<dyn Error>> {
|
|
210
|
+
use std::io::Read;
|
|
211
|
+
use std::net::{SocketAddrV4, TcpStream};
|
|
212
|
+
let method = args.get(1).ok_or("http METHOD ADDR:PORT PATH [BODY]")?.as_str();
|
|
213
|
+
let addr: SocketAddrV4 = args.get(2).ok_or("and the address and port")?.parse()?;
|
|
214
|
+
let path = http_path(args.get(3).ok_or("and the path")?)?;
|
|
215
|
+
let body = args.get(4).map_or("", String::as_str);
|
|
216
|
+
let request = http_request(method, &addr.to_string(), path, body)?;
|
|
217
|
+
let mut stream = TcpStream::connect(addr)?;
|
|
218
|
+
let t0 = monotonic_ns();
|
|
219
|
+
stream.write_all(request.as_bytes())?;
|
|
220
|
+
let mut answer = Vec::new();
|
|
221
|
+
stream.read_to_end(&mut answer)?;
|
|
222
|
+
let t1 = monotonic_ns();
|
|
223
|
+
let text = String::from_utf8_lossy(&answer);
|
|
224
|
+
let status: u16 = text
|
|
225
|
+
.split_whitespace()
|
|
226
|
+
.nth(1)
|
|
227
|
+
.ok_or("no status in the answer")?
|
|
228
|
+
.parse()?;
|
|
229
|
+
if !(200..300).contains(&status) {
|
|
230
|
+
let detail = text.split("\r\n\r\n").nth(1).unwrap_or("").trim();
|
|
231
|
+
return Err(format!("{method} {path} answered {status}: {detail}").into());
|
|
232
|
+
}
|
|
233
|
+
println!("{}", json!({ "action": "http", "method": method, "path": path, "status": status, "t0": t0, "t1": t1 }));
|
|
234
|
+
Ok(())
|
|
235
|
+
}
|
|
236
|
+
|
|
168
237
|
fn run() -> Result<(), Box<dyn Error>> {
|
|
169
238
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
170
|
-
let action = args.first().ok_or("say an action: blackhole, clear, rst, reject, mtu, netem, offload …")?.as_str();
|
|
239
|
+
let action = args.first().ok_or("say an action: blackhole, clear, rst, reject, mtu, netem, offload, http …")?.as_str();
|
|
240
|
+
if action == "http" {
|
|
241
|
+
return http(&args);
|
|
242
|
+
}
|
|
171
243
|
match action {
|
|
172
244
|
"netem" | "clear-netem" | "offload" => {
|
|
173
245
|
let dev = device(args.get(1).ok_or("and a device")?)?;
|
|
@@ -265,4 +337,44 @@ mod tests {
|
|
|
265
337
|
);
|
|
266
338
|
assert!(rules("reject", peer, &Options::default()).is_err());
|
|
267
339
|
}
|
|
340
|
+
|
|
341
|
+
#[test]
|
|
342
|
+
fn a_router_gets_forward_rules_that_face_one_device() {
|
|
343
|
+
let peer: Ipv4Addr = "10.0.0.2".parse().unwrap();
|
|
344
|
+
let via = |direction: Option<&str>| Options {
|
|
345
|
+
direction: direction.map(Into::into),
|
|
346
|
+
via: Some("eth0".into()),
|
|
347
|
+
..Options::default()
|
|
348
|
+
};
|
|
349
|
+
assert_eq!(
|
|
350
|
+
rules("blackhole", peer, &via(None)).unwrap(),
|
|
351
|
+
"-I FORWARD -i eth0 -s 10.0.0.2/32 -j DROP\n-I FORWARD -o eth0 -d 10.0.0.2/32 -j DROP\n"
|
|
352
|
+
);
|
|
353
|
+
assert_eq!(rules("clear", peer, &via(Some("out"))).unwrap(), "-D FORWARD -o eth0 -d 10.0.0.2/32 -j DROP\n");
|
|
354
|
+
assert_eq!(
|
|
355
|
+
rules("rst", peer, &via(None)).unwrap(),
|
|
356
|
+
"-I FORWARD -i eth0 -s 10.0.0.2/32 -p tcp -j REJECT --reject-with tcp-reset\n"
|
|
357
|
+
);
|
|
358
|
+
let host = Options { code: Some("host".into()), ..via(None) };
|
|
359
|
+
assert_eq!(
|
|
360
|
+
rules("clear-reject", peer, &host).unwrap(),
|
|
361
|
+
"-D FORWARD -i eth0 -s 10.0.0.2/32 -p tcp -j REJECT --reject-with icmp-host-unreachable\n"
|
|
362
|
+
);
|
|
363
|
+
// a device name is letters, digits, dots and dashes: nothing else reaches the rules
|
|
364
|
+
assert!(options(&["--via".into(), "eth0;x".into()]).is_err());
|
|
365
|
+
assert_eq!(options(&["--via".into(), "eth1".into()]).unwrap().via.as_deref(), Some("eth1"));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#[test]
|
|
369
|
+
fn http_requests_take_only_a_method_a_path_and_a_length() {
|
|
370
|
+
assert!(http_path("/proxies/redis/toxics").is_ok());
|
|
371
|
+
for path in ["proxies", "/a b", "/a;b", "/a?b=1", "/$(x)"] {
|
|
372
|
+
assert!(http_path(path).is_err(), "{path}");
|
|
373
|
+
}
|
|
374
|
+
let request = http_request("POST", "10.0.0.3:8474", "/proxies/redis/toxics", "{}").unwrap();
|
|
375
|
+
assert!(request.starts_with("POST /proxies/redis/toxics HTTP/1.1\r\n"));
|
|
376
|
+
assert!(request.contains("Content-Length: 2\r\n"));
|
|
377
|
+
assert!(request.ends_with("\r\n\r\n{}"));
|
|
378
|
+
assert!(http_request("PUT", "h", "/", "").is_err());
|
|
379
|
+
}
|
|
268
380
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# The router of the harness: a NAT box with conntrack, the middle of a stateful middlebox chain. It
|
|
2
|
+
# forgets an idle flow at its idle timeout, and its form tells what a late packet then meets (mbx.sh).
|
|
3
|
+
# lab.ts stages the build context: this folder, under harness/image/router. The base is pinned by
|
|
4
|
+
# digest, and the manifest of each run records the packages that apk installs.
|
|
5
|
+
FROM alpine:3.23@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62
|
|
6
|
+
RUN apk add --no-cache conntrack-tools nftables iproute2 python3
|
|
7
|
+
COPY harness/image/router/mbx.sh harness/image/router/ctwatch.py harness/image/router/resetter.py /opt/mbx/
|
|
8
|
+
ENTRYPOINT ["/bin/sh", "/opt/mbx/mbx.sh"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The conntrack events of the router, as JSON lines on the monotonic clock.
|
|
3
|
+
|
|
4
|
+
Each line is {"ev": "ct", "t": <CLOCK_MONOTONIC ns>, "type": "NEW|UPDATE|DESTROY", "state": ...,
|
|
5
|
+
"orig": "src:sport>dst:dport", "reply": "..."}. The stamp is the time that this process read the
|
|
6
|
+
event, which is within a millisecond of the kernel. A listing every PUMP seconds evicts the expired
|
|
7
|
+
entries that it walks past, thus a DESTROY follows an expiry within PUMP plus two listings. A
|
|
8
|
+
listing that takes longer than SLOW seconds writes a "pump-slow" line with its start and its
|
|
9
|
+
length: near it, that bound of the lag does not hold.
|
|
10
|
+
|
|
11
|
+
ctwatch.py [--pump SECONDS] [--slow SECONDS]
|
|
12
|
+
"""
|
|
13
|
+
import argparse, json, re, subprocess, sys, threading, time
|
|
14
|
+
|
|
15
|
+
p = argparse.ArgumentParser()
|
|
16
|
+
p.add_argument("--pump", type=float, default=0.1)
|
|
17
|
+
p.add_argument("--slow", type=float, default=0.02)
|
|
18
|
+
a = p.parse_args()
|
|
19
|
+
|
|
20
|
+
PAT = re.compile(
|
|
21
|
+
r"\[(?P<type>NEW|UPDATE|DESTROY)\]\s+tcp\s+\d+\s+(?:\d+\s+)?(?P<state>[A-Z_]+)?\s*"
|
|
22
|
+
r"src=(?P<s>\S+) dst=(?P<d>\S+) sport=(?P<sp>\d+) dport=(?P<dp>\d+).*?"
|
|
23
|
+
r"src=(?P<rs>\S+) dst=(?P<rd>\S+) sport=(?P<rsp>\d+) dport=(?P<rdp>\d+)"
|
|
24
|
+
)
|
|
25
|
+
lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def now():
|
|
29
|
+
return time.clock_gettime_ns(time.CLOCK_MONOTONIC)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit(**fields):
|
|
33
|
+
with lock:
|
|
34
|
+
print(json.dumps({"t": now(), **fields}), flush=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pump():
|
|
38
|
+
while True:
|
|
39
|
+
start = now()
|
|
40
|
+
subprocess.run(["conntrack", "-L", "-p", "tcp"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
41
|
+
took = (now() - start) / 1e9
|
|
42
|
+
if took > a.slow:
|
|
43
|
+
with lock:
|
|
44
|
+
print(json.dumps({"ev": "pump-slow", "t": start, "took": took}), flush=True)
|
|
45
|
+
time.sleep(a.pump)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if a.pump > 0:
|
|
49
|
+
threading.Thread(target=pump, daemon=True).start()
|
|
50
|
+
proc = subprocess.Popen(
|
|
51
|
+
["conntrack", "-E", "-p", "tcp"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1
|
|
52
|
+
)
|
|
53
|
+
emit(ev="ct-watch", pump=a.pump, slow=a.slow)
|
|
54
|
+
for line in proc.stdout:
|
|
55
|
+
m = PAT.search(line)
|
|
56
|
+
if m is None:
|
|
57
|
+
emit(ev="ct-unparsed", line=line.strip())
|
|
58
|
+
continue
|
|
59
|
+
emit(
|
|
60
|
+
ev="ct",
|
|
61
|
+
type=m["type"],
|
|
62
|
+
state=m["state"],
|
|
63
|
+
orig=f'{m["s"]}:{m["sp"]}>{m["d"]}:{m["dp"]}',
|
|
64
|
+
reply=f'{m["rs"]}:{m["rsp"]}>{m["rd"]}:{m["rdp"]}',
|
|
65
|
+
)
|
|
66
|
+
sys.exit(proc.wait())
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The router of a middlebox chain: one image for the forms of a middlebox at its idle expiry.
|
|
3
|
+
#
|
|
4
|
+
# FORM=reset-both|reset-next|silent-drop|recreate|none IDLE=<seconds> [LAN=eth0] [WAN=eth1] [PUMP=0.1] [SLOW=0.02]
|
|
5
|
+
#
|
|
6
|
+
# The container is privileged, so it sets the sysctls of its own namespace. Docker Desktop keeps
|
|
7
|
+
# /proc/sys read-only in a container that is not. The network of the side of the client is LAN,
|
|
8
|
+
# and the harness connects the network of the side of the target (WAN) after the start.
|
|
9
|
+
#
|
|
10
|
+
# FORM at the expiry a late packet from inside a late packet from outside
|
|
11
|
+
# reset-both a reset to the two ends dropped dropped
|
|
12
|
+
# reset-next nothing a reset to its sender dropped
|
|
13
|
+
# silent-drop nothing dropped dropped
|
|
14
|
+
# recreate nothing passes, and starts the flow dropped
|
|
15
|
+
# none nothing passes (a plain NAT router) a reset from the router
|
|
16
|
+
#
|
|
17
|
+
# A lookup treats an expired entry as gone, so the router forgets a flow at its timeout to the
|
|
18
|
+
# jiffy. The DESTROY event of conntrack waits for the garbage collector or a lookup: ctwatch.py
|
|
19
|
+
# lists the table every PUMP seconds, so the event follows the expiry within PUMP and two listings.
|
|
20
|
+
# A listing longer than SLOW seconds writes a pump-slow line: near it, that bound does not hold.
|
|
21
|
+
set -eu
|
|
22
|
+
FORM=${FORM:-none}; LAN=${LAN:-eth0}; WAN=${WAN:-eth1}; PUMP=${PUMP:-0.1}; SLOW=${SLOW:-0.02}; IDLE=${IDLE:-432000}
|
|
23
|
+
sysctl -q -w net.ipv4.ip_forward=1
|
|
24
|
+
# the conntrack sysctls of a namespace appear when it first uses conntrack: a listing does that
|
|
25
|
+
conntrack -L >/dev/null 2>&1 || true
|
|
26
|
+
sysctl -q -w net.netfilter.nf_conntrack_events=1
|
|
27
|
+
sysctl -q -w "net.netfilter.nf_conntrack_tcp_timeout_established=$IDLE"
|
|
28
|
+
i=0
|
|
29
|
+
while ! ip link show "$WAN" >/dev/null 2>&1; do
|
|
30
|
+
i=$((i + 1)); [ "$i" -gt 600 ] && { echo "mbx: no $WAN" >&2; exit 65; }; sleep 0.1
|
|
31
|
+
done
|
|
32
|
+
case $FORM in
|
|
33
|
+
reset-both) INSIDE=drop; OUTSIDE=drop ;;
|
|
34
|
+
reset-next) INSIDE=rst; OUTSIDE=drop ;;
|
|
35
|
+
silent-drop) INSIDE=drop; OUTSIDE=drop ;;
|
|
36
|
+
recreate) INSIDE=pass; OUTSIDE=drop ;;
|
|
37
|
+
none) INSIDE=pass; OUTSIDE=rst ;;
|
|
38
|
+
*) echo "mbx: unknown FORM=$FORM" >&2; exit 64 ;;
|
|
39
|
+
esac
|
|
40
|
+
verdict() { case $1 in rst) echo "reject with tcp reset" ;; drop) echo drop ;; *) echo accept ;; esac; }
|
|
41
|
+
# OUTSIDE=rst: accept, and the stack of the router answers the unknown port with an exact reset
|
|
42
|
+
outv=$(verdict "$OUTSIDE"); [ "$OUTSIDE" = rst ] && outv=accept
|
|
43
|
+
nft -f - <<EOF
|
|
44
|
+
table ip mbx {
|
|
45
|
+
chain mbx_post { type nat hook postrouting priority srcnat; policy accept; oifname "$WAN" masquerade; }
|
|
46
|
+
chain mbx_fwd {
|
|
47
|
+
type filter hook forward priority filter; policy accept;
|
|
48
|
+
iifname "$LAN" meta l4proto tcp ct state new,invalid tcp flags & (syn|ack) != syn counter $(verdict "$INSIDE")
|
|
49
|
+
}
|
|
50
|
+
chain mbx_in {
|
|
51
|
+
type filter hook input priority filter; policy accept;
|
|
52
|
+
iifname "$WAN" meta l4proto tcp ct state new,invalid tcp flags & (syn|ack) != syn counter $outv
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
EOF
|
|
56
|
+
echo "{\"ev\":\"mbx-ready\",\"t\":0,\"form\":\"$FORM\",\"idle\":$(cat /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established),\"inside\":\"$INSIDE\",\"outside\":\"$OUTSIDE\"}"
|
|
57
|
+
if [ "$FORM" = reset-both ]; then
|
|
58
|
+
python3 /opt/mbx/ctwatch.py --pump "$PUMP" --slow "$SLOW" &
|
|
59
|
+
exec python3 /opt/mbx/resetter.py "$LAN" "$WAN"
|
|
60
|
+
fi
|
|
61
|
+
exec python3 /opt/mbx/ctwatch.py --pump "$PUMP" --slow "$SLOW"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The reset at expiry: the form of a middlebox that resets the two ends when it forgets a flow.
|
|
3
|
+
|
|
4
|
+
It listens to the DESTROY events of conntrack in this namespace. For each destroyed TCP flow that
|
|
5
|
+
it saw carry traffic, and never saw closed (FIN or RST), it sends a reset to each end. Each reset
|
|
6
|
+
has the sequence number that its end expects: the last acknowledgment number that the end sent,
|
|
7
|
+
read by a raw sniffer on the interfaces, because conntrack events carry no sequence numbers.
|
|
8
|
+
Each line is JSON on the monotonic clock: {"ev": "reset", "t": ..., "flow": ..., "to": [...]}.
|
|
9
|
+
|
|
10
|
+
resetter.py IFACE [IFACE...]
|
|
11
|
+
"""
|
|
12
|
+
import argparse, json, re, socket, struct, subprocess, threading, time
|
|
13
|
+
|
|
14
|
+
p = argparse.ArgumentParser()
|
|
15
|
+
p.add_argument("ifaces", nargs="+")
|
|
16
|
+
a = p.parse_args()
|
|
17
|
+
|
|
18
|
+
plock = threading.Lock()
|
|
19
|
+
flock = threading.Lock()
|
|
20
|
+
flows = {} # (src, sport, dst, dport) -> {"ack", "closed"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def emit(**fields):
|
|
24
|
+
with plock:
|
|
25
|
+
print(json.dumps({"t": time.clock_gettime_ns(time.CLOCK_MONOTONIC), **fields}), flush=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sniff(ifname):
|
|
29
|
+
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0800))
|
|
30
|
+
s.bind((ifname, 0))
|
|
31
|
+
while True:
|
|
32
|
+
pkt = s.recv(65535)
|
|
33
|
+
ip = pkt[14:]
|
|
34
|
+
if len(ip) < 40 or ip[0] >> 4 != 4 or ip[9] != 6:
|
|
35
|
+
continue
|
|
36
|
+
ihl = (ip[0] & 0xF) * 4
|
|
37
|
+
src, dst = socket.inet_ntoa(ip[12:16]), socket.inet_ntoa(ip[16:20])
|
|
38
|
+
tcp = ip[ihl:]
|
|
39
|
+
sport, dport, _seq, ack = struct.unpack("!HHII", tcp[:12])
|
|
40
|
+
fl = tcp[13]
|
|
41
|
+
with flock:
|
|
42
|
+
f = flows.setdefault((src, sport, dst, dport), {"ack": None, "closed": False})
|
|
43
|
+
if fl & 0x10:
|
|
44
|
+
f["ack"] = ack
|
|
45
|
+
if fl & 0x05:
|
|
46
|
+
f["closed"] = True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
raw = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def csum(b):
|
|
53
|
+
if len(b) % 2:
|
|
54
|
+
b += b"\0"
|
|
55
|
+
s = sum(struct.unpack("!%dH" % (len(b) // 2), b))
|
|
56
|
+
while s >> 16:
|
|
57
|
+
s = (s & 0xFFFF) + (s >> 16)
|
|
58
|
+
return ~s & 0xFFFF
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def send_rst(src, sport, dst, dport, seq):
|
|
62
|
+
tcp = struct.pack("!HHIIBBHHH", sport, dport, seq, 0, 5 << 4, 0x04, 0, 0, 0)
|
|
63
|
+
pseudo = socket.inet_aton(src) + socket.inet_aton(dst) + struct.pack("!BBH", 0, 6, len(tcp))
|
|
64
|
+
tcp = tcp[:16] + struct.pack("!H", csum(pseudo + tcp)) + tcp[18:]
|
|
65
|
+
iph = struct.pack(
|
|
66
|
+
"!BBHHHBBH4s4s", 0x45, 0, 20 + len(tcp), 0, 0x4000, 64, 6, 0, socket.inet_aton(src), socket.inet_aton(dst)
|
|
67
|
+
)
|
|
68
|
+
raw.sendto(iph + tcp, (dst, 0))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
PAT = re.compile(r"src=(\S+) dst=(\S+) sport=(\d+) dport=(\d+) .*?src=(\S+) dst=(\S+) sport=(\d+) dport=(\d+)")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main():
|
|
75
|
+
for i in a.ifaces:
|
|
76
|
+
threading.Thread(target=sniff, args=(i,), daemon=True).start()
|
|
77
|
+
proc = subprocess.Popen(
|
|
78
|
+
["conntrack", "-E", "-e", "DESTROY", "-p", "tcp"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1
|
|
79
|
+
)
|
|
80
|
+
emit(ev="resetter", ifaces=a.ifaces)
|
|
81
|
+
for line in proc.stdout:
|
|
82
|
+
m = PAT.search(line)
|
|
83
|
+
if not m:
|
|
84
|
+
continue
|
|
85
|
+
c, s, cp, sp, s2, r, sp2, rp = m.groups()
|
|
86
|
+
cp, sp, sp2, rp = int(cp), int(sp), int(sp2), int(rp)
|
|
87
|
+
k_cs, k_sr = (c, cp, s, sp), (s2, sp2, r, rp)
|
|
88
|
+
with flock:
|
|
89
|
+
fs = {k: dict(flows[k]) for k in (k_cs, (s, sp, c, cp), (r, rp, s2, sp2), k_sr) if k in flows}
|
|
90
|
+
if not fs or any(f["closed"] for f in fs.values()):
|
|
91
|
+
continue
|
|
92
|
+
seq_c = fs.get(k_cs, {}).get("ack") # the RCV.NXT of the client
|
|
93
|
+
seq_s = fs.get(k_sr, {}).get("ack") # the RCV.NXT of the server, on the translated side
|
|
94
|
+
sent = []
|
|
95
|
+
if seq_c is not None:
|
|
96
|
+
send_rst(s, sp, c, cp, seq_c)
|
|
97
|
+
sent.append("client")
|
|
98
|
+
if seq_s is not None:
|
|
99
|
+
send_rst(r, rp, s2, sp2, seq_s)
|
|
100
|
+
sent.append("server")
|
|
101
|
+
emit(ev="reset", flow=f"{c}:{cp}>{s}:{sp}", nat=f"{r}:{rp}", to=sent)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@mark1russell7/harness",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "A fault-injection harness for connection timers: a cell runs a client against a stock target in Docker, injects a fault, measures the kernel's own records and judges a prediction.",
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"description": "A fault-injection harness for connection timers: a cell runs a client against a stock target in Docker, through a topology of routers and proxies, injects a fault, measures the kernel's own records and judges a prediction.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"fault-injection",
|
|
8
8
|
"chaos-engineering",
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"image/node/client-ioredis.mjs",
|
|
46
46
|
"image/node/package.json",
|
|
47
47
|
"image/node/npm-lock.json",
|
|
48
|
+
"image/router/Dockerfile",
|
|
49
|
+
"image/router/mbx.sh",
|
|
50
|
+
"image/router/ctwatch.py",
|
|
51
|
+
"image/router/resetter.py",
|
|
48
52
|
"README.md",
|
|
49
53
|
"LICENSE"
|
|
50
54
|
],
|
|
@@ -52,7 +56,7 @@
|
|
|
52
56
|
"node": ">=22.19"
|
|
53
57
|
},
|
|
54
58
|
"dependencies": {
|
|
55
|
-
"@mark1russell7/prober": "^0.
|
|
59
|
+
"@mark1russell7/prober": "^0.2.0",
|
|
56
60
|
"@mark1russell7/quantity": "^0.1.0",
|
|
57
61
|
"testcontainers": "12.2.0"
|
|
58
62
|
},
|