@mark1russell7/harness 0.1.0 → 0.3.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.
@@ -1,5 +1,5 @@
1
- //! client HOST PORT --mode idle|inflight [--keepalive] [--user-timeout-ms N] [--go PATH]
2
- //! [--request TEXT] [--sample-ms N] [--max-s N]
1
+ //! client HOST PORT --mode idle|inflight|stream [--keepalive] [--user-timeout-ms N] [--go PATH]
2
+ //! [--request TEXT] [--count N] [--interval-ms N] [--sample-ms N] [--max-s N]
3
3
  //!
4
4
  //! A cell's TCP client: one connection, every event a JSON line stamped on CLOCK_MONOTONIC. It
5
5
  //! sets no per-socket TCP timer unless the cell asks, so the namespace's sysctls are what gets
@@ -14,6 +14,9 @@
14
14
  //! names that second request, a Redis inline command: PING unless it says otherwise. BLPOP with a
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
+ //! stream: then wait for --go, and send the request --count times, each when the reply to the one
18
+ //! before it has come and --interval-ms has passed: a distribution of reply times, for a fault
19
+ //! whose outcome is one (loss, reordering). It ends with a done line.
17
20
 
18
21
  use prober::{errno_name, monotonic_ns, quantity};
19
22
  use serde_json::{json, Value};
@@ -101,6 +104,9 @@ struct Args {
101
104
  host: String,
102
105
  port: u16,
103
106
  inflight: bool,
107
+ /// in stream mode, how many requests, and the pause after each reply
108
+ stream: Option<u32>,
109
+ interval: Duration,
104
110
  keepalive: bool,
105
111
  user_timeout_ms: Option<i32>,
106
112
  go: String,
@@ -113,14 +119,17 @@ struct Args {
113
119
  fn args() -> Result<Args, Box<dyn Error>> {
114
120
  let raw: Vec<String> = std::env::args().skip(1).collect();
115
121
  let value = |flag: &str| raw.iter().position(|arg| arg == flag).and_then(|at| raw.get(at + 1)).cloned();
116
- let mode = value("--mode").ok_or("--mode idle|inflight is required")?;
117
- if mode != "idle" && mode != "inflight" {
118
- return Err(format!("--mode is idle or inflight, not {mode}").into());
122
+ let mode = value("--mode").ok_or("--mode idle|inflight|stream is required")?;
123
+ if mode != "idle" && mode != "inflight" && mode != "stream" {
124
+ return Err(format!("--mode is idle, inflight or stream, not {mode}").into());
119
125
  }
126
+ let count: u32 = value("--count").map(|v| v.parse()).transpose()?.unwrap_or(100);
120
127
  Ok(Args {
121
128
  host: raw.first().ok_or("the target's host comes first")?.clone(),
122
129
  port: raw.get(1).ok_or("then its port")?.parse()?,
123
- inflight: mode == "inflight",
130
+ inflight: mode != "idle",
131
+ stream: (mode == "stream").then_some(count),
132
+ interval: Duration::from_millis(value("--interval-ms").map(|v| v.parse()).transpose()?.unwrap_or(20)),
124
133
  keepalive: raw.iter().any(|arg| arg == "--keepalive"),
125
134
  user_timeout_ms: value("--user-timeout-ms").map(|v| v.parse()).transpose()?,
126
135
  go: value("--go").unwrap_or_else(|| "/tmp/go".into()),
@@ -133,7 +142,12 @@ fn args() -> Result<Args, Box<dyn Error>> {
133
142
  fn run() -> Result<(), Box<dyn Error>> {
134
143
  let args = args()?;
135
144
  let boot_id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")?.trim().to_string();
136
- emit("start", json!({ "boot_id": boot_id, "mode": if args.inflight { "inflight" } else { "idle" } }));
145
+ let mode = match (args.inflight, args.stream) {
146
+ (_, Some(_)) => "stream",
147
+ (true, None) => "inflight",
148
+ (false, None) => "idle",
149
+ };
150
+ emit("start", json!({ "boot_id": boot_id, "mode": mode }));
137
151
  let deadline = monotonic_ns() + args.max.as_nanos() as u64;
138
152
 
139
153
  let mut stream = TcpStream::connect((args.host.as_str(), args.port))?;
@@ -169,6 +183,10 @@ fn run() -> Result<(), Box<dyn Error>> {
169
183
  let mut waiting = args.inflight;
170
184
  let mut seen: Option<[u32; 7]> = None;
171
185
  let mut age = info.tcpi_last_ack_recv;
186
+ // stream mode: requests sent, whether one waits for its reply, and when the next may go
187
+ let mut sent: u32 = 0;
188
+ let mut awaiting = false;
189
+ let mut next_at: u64 = 0;
172
190
  loop {
173
191
  if readable(fd, tick)? {
174
192
  match stream.read(&mut buf) {
@@ -176,7 +194,13 @@ fn run() -> Result<(), Box<dyn Error>> {
176
194
  emit("eof", json!({}));
177
195
  return Ok(());
178
196
  }
179
- Ok(n) => emit("data", json!({ "n": n, "text": String::from_utf8_lossy(&buf[..n.min(40)]) })),
197
+ Ok(n) => {
198
+ emit("data", json!({ "n": n, "text": String::from_utf8_lossy(&buf[..n.min(40)]) }));
199
+ if awaiting {
200
+ awaiting = false;
201
+ next_at = monotonic_ns() + args.interval.as_nanos() as u64;
202
+ }
203
+ }
180
204
  // nothing after all; ETIMEDOUT is the connection's own end
181
205
  Err(error) if error.kind() == ErrorKind::WouldBlock || error.kind() == ErrorKind::Interrupted => {}
182
206
  Err(error) => {
@@ -208,6 +232,21 @@ fn run() -> Result<(), Box<dyn Error>> {
208
232
  if waiting && Path::new(&args.go).exists() {
209
233
  send(&mut stream, &args.request)?;
210
234
  waiting = false;
235
+ sent = 1;
236
+ awaiting = args.stream.is_some();
237
+ }
238
+ if let Some(count) = args.stream {
239
+ if !waiting && !awaiting {
240
+ if sent >= count {
241
+ emit("done", json!({ "requests": sent }));
242
+ return Ok(());
243
+ }
244
+ if monotonic_ns() >= next_at {
245
+ send(&mut stream, &args.request)?;
246
+ sent += 1;
247
+ awaiting = true;
248
+ }
249
+ }
211
250
  }
212
251
  if monotonic_ns() > deadline {
213
252
  emit("deadline", json!({}));
@@ -1,14 +1,31 @@
1
- //! inject blackhole PEER | inject clear PEER [--at NS] | inject rst PEER | inject clear-rst PEER
1
+ //! inject ACTION ARGS... [--at NS]
2
2
  //!
3
- //! A cell's fault, applied inside the network namespace this sidecar shares with the target, so
4
- //! the target's image stays stock. Each lands in one iptables-restore commit,
5
- //! bracketed by CLOCK_MONOTONIC stamps taken in the VM: a docker exec from Windows lands 190 ms late
6
- //! at the median, and the bracket does not care.
3
+ //! A cell's fault, applied inside the network namespace this sidecar shares with the target (or,
4
+ //! for the server's side, with the client), so the target's image stays stock. Each lands in one
5
+ //! commit, bracketed by CLOCK_MONOTONIC stamps taken in the VM: a docker exec from Windows lands
6
+ //! 190 ms late at the median, and the bracket does not care.
7
7
  //!
8
- //! - blackhole: both directions drop, so nothing comes back;
9
- //! - rst: the peer's next segment is answered with a reset, so its next send fails at once. A
10
- //! REJECT resets only the sender of the matched segment: the target's process
11
- //! never hears of it.
8
+ //! The actions, each with its lift:
9
+ //!
10
+ //! - `blackhole PEER [--direction both|in|out]` / `clear PEER [--direction …]`: the peer's
11
+ //! segments drop, in both directions or one. `in` drops what comes from the peer, `out` what
12
+ //! goes to it: a simplex partition.
13
+ //! - `rst PEER` / `clear-rst PEER`: the peer's next segment is answered with a reset, so its next
14
+ //! send fails at once. A REJECT resets only the sender of the matched segment: the target's
15
+ //! process never hears of it.
16
+ //! - `reject PEER --code host|net|port|admin` / `clear-reject PEER --code …`: each segment from the
17
+ //! peer is answered with that ICMP unreachable. On an established socket Linux keeps it as a
18
+ //! soft error (tcp_v4_err) and goes on retransmitting.
19
+ //! - `mtu PEER --above N` / `clear-mtu PEER --above N`: each segment from the peer longer than N
20
+ //! bytes drops, as a path MTU black hole does, in the namespace that receives it. It acts on
21
+ //! segments only when the sender's TSO and GSO and the receiver's GRO are off (`offload`).
22
+ //! - `netem DEV SPEC…` / `clear-netem DEV`: tc netem on the device's egress, for example
23
+ //! `netem eth0 delay 100ms 10ms` or `netem eth0 loss 5%`.
24
+ //! - `offload DEV on|off`: the device's TSO, GSO and GRO, so that netem and length matches act on
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.
12
29
  //!
13
30
  //! `--at NS` holds the commit until that instant on CLOCK_MONOTONIC, the clock every stamp is on.
14
31
  //! A stall's end is timed that way, from inside the VM: the exec only has to arrive before it, and
@@ -46,46 +63,276 @@ fn until(at: u64) -> Result<(), Box<dyn Error>> {
46
63
  }
47
64
  }
48
65
 
49
- fn run() -> Result<(), Box<dyn Error>> {
50
- let args: Vec<String> = std::env::args().skip(1).collect();
51
- let action = args.first().ok_or("say blackhole, clear, rst or clear-rst")?.as_str();
52
- // parsed, never pasted: only an address reaches the rules
53
- let peer: Ipv4Addr = args.get(1).ok_or("and the peer's address")?.parse()?;
54
- let at: Option<u64> = match args.get(2).map(String::as_str) {
55
- None => None,
56
- Some("--at") => Some(args.get(3).ok_or("--at takes an instant in ns")?.parse()?),
57
- Some(other) => return Err(format!("unknown option {other}").into()),
58
- };
59
- let rules = match action {
66
+ /// The options after the positional arguments: `--at NS`, `--direction D`, `--code C`, `--above N`.
67
+ #[derive(Default)]
68
+ struct Options {
69
+ at: Option<u64>,
70
+ direction: Option<String>,
71
+ code: Option<String>,
72
+ above: Option<u32>,
73
+ }
74
+
75
+ fn options(args: &[String]) -> Result<Options, Box<dyn Error>> {
76
+ let mut out = Options::default();
77
+ let mut rest = args.iter();
78
+ while let Some(flag) = rest.next() {
79
+ let value = rest.next().ok_or_else(|| format!("{flag} takes a value"))?;
80
+ match flag.as_str() {
81
+ "--at" => out.at = Some(value.parse()?),
82
+ "--direction" => out.direction = Some(value.clone()),
83
+ "--code" => out.code = Some(value.clone()),
84
+ "--above" => out.above = Some(value.parse()?),
85
+ other => return Err(format!("unknown option {other}").into()),
86
+ }
87
+ }
88
+ Ok(out)
89
+ }
90
+
91
+ /// The iptables rules of an action that is a filter rule, or None for an action that is not.
92
+ fn rules(action: &str, peer: Ipv4Addr, opts: &Options) -> Result<String, Box<dyn Error>> {
93
+ let op = |set: &str| if action == set { "-I" } else { "-D" };
94
+ Ok(match action {
60
95
  "blackhole" | "clear" => {
61
- let op = if action == "blackhole" { "-I" } else { "-D" };
62
- format!("{op} INPUT -s {peer}/32 -j DROP\n{op} OUTPUT -d {peer}/32 -j DROP\n")
96
+ let op = op("blackhole");
97
+ let direction = opts.direction.as_deref().unwrap_or("both");
98
+ let input = format!("{op} INPUT -s {peer}/32 -j DROP\n");
99
+ let output = format!("{op} OUTPUT -d {peer}/32 -j DROP\n");
100
+ match direction {
101
+ "both" => format!("{input}{output}"),
102
+ "in" => input,
103
+ "out" => output,
104
+ other => return Err(format!("unknown direction {other}").into()),
105
+ }
63
106
  }
64
107
  "rst" | "clear-rst" => {
65
- let op = if action == "rst" { "-I" } else { "-D" };
108
+ let op = op("rst");
66
109
  format!("{op} INPUT -s {peer}/32 -p tcp -j REJECT --reject-with tcp-reset\n")
67
110
  }
111
+ "reject" | "clear-reject" => {
112
+ let op = op("reject");
113
+ let with = match opts.code.as_deref().ok_or("--code host|net|port|admin")? {
114
+ "host" => "icmp-host-unreachable",
115
+ "net" => "icmp-net-unreachable",
116
+ "port" => "icmp-port-unreachable",
117
+ "admin" => "icmp-admin-prohibited",
118
+ other => return Err(format!("unknown code {other}").into()),
119
+ };
120
+ format!("{op} INPUT -s {peer}/32 -p tcp -j REJECT --reject-with {with}\n")
121
+ }
122
+ // the long segments drop where they arrive: in the sender's own namespace, an OUTPUT drop
123
+ // would fail its sends with EPERM rather than lose them on the way
124
+ "mtu" | "clear-mtu" => {
125
+ let op = op("mtu");
126
+ let above = opts.above.ok_or("--above N")?;
127
+ format!("{op} INPUT -s {peer}/32 -p tcp -m length --length {}:65535 -j DROP\n", above + 1)
128
+ }
68
129
  other => return Err(format!("unknown action {other}").into()),
69
- };
70
- let script = format!("*filter\n{rules}COMMIT\n");
71
- if let Some(at) = at {
72
- until(at)?;
130
+ })
131
+ }
132
+
133
+ /// a token of a netem specification: a word, or a number with an optional unit
134
+ fn netem_token(token: &str) -> bool {
135
+ let word = !token.is_empty() && token.chars().all(|c| c.is_ascii_lowercase());
136
+ let number = token
137
+ .trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '%')
138
+ .parse::<f64>()
139
+ .is_ok()
140
+ && token
141
+ .trim_start_matches(|c: char| c.is_ascii_digit() || c == '.')
142
+ .chars()
143
+ .all(|c| c.is_ascii_alphabetic() || c == '%');
144
+ word || number
145
+ }
146
+
147
+ /// a device name: letters, digits, dots, dashes
148
+ fn device(name: &str) -> Result<&str, Box<dyn Error>> {
149
+ if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
150
+ Ok(name)
151
+ } else {
152
+ Err(format!("not a device: {name}").into())
153
+ }
154
+ }
155
+
156
+ /// runs a command and fails with its stderr when it fails
157
+ fn command(program: &str, args: &[&str]) -> Result<(), Box<dyn Error>> {
158
+ let out = Command::new(program).args(args).output()?;
159
+ if !out.status.success() {
160
+ return Err(format!(
161
+ "{program} {} exited {}: {}",
162
+ args.join(" "),
163
+ out.status,
164
+ String::from_utf8_lossy(&out.stderr).trim()
165
+ )
166
+ .into());
167
+ }
168
+ Ok(())
169
+ }
170
+
171
+ /// an HTTP path: a slash, then letters, digits and `/ - _ .` only
172
+ fn http_path(path: &str) -> Result<&str, Box<dyn Error>> {
173
+ if path.starts_with('/')
174
+ && path.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
175
+ {
176
+ Ok(path)
177
+ } else {
178
+ Err(format!("not an HTTP path: {path}").into())
73
179
  }
180
+ }
181
+
182
+ /// the head of one HTTP/1.1 request, with its body
183
+ fn http_request(method: &str, host: &str, path: &str, body: &str) -> Result<String, Box<dyn Error>> {
184
+ if !matches!(method, "GET" | "POST" | "DELETE") {
185
+ return Err(format!("not a method: {method}").into());
186
+ }
187
+ Ok(format!(
188
+ "{method} {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
189
+ body.len()
190
+ ))
191
+ }
192
+
193
+ /// one request to an API: the status, and the stamps around the request and its answer
194
+ fn http(args: &[String]) -> Result<(), Box<dyn Error>> {
195
+ use std::io::Read;
196
+ use std::net::{SocketAddrV4, TcpStream};
197
+ let method = args.get(1).ok_or("http METHOD ADDR:PORT PATH [BODY]")?.as_str();
198
+ let addr: SocketAddrV4 = args.get(2).ok_or("and the address and port")?.parse()?;
199
+ let path = http_path(args.get(3).ok_or("and the path")?)?;
200
+ let body = args.get(4).map_or("", String::as_str);
201
+ let request = http_request(method, &addr.to_string(), path, body)?;
202
+ let mut stream = TcpStream::connect(addr)?;
74
203
  let t0 = monotonic_ns();
75
- let mut child = Command::new("iptables-restore").arg("--noflush").stdin(Stdio::piped()).spawn()?;
76
- child.stdin.take().ok_or("iptables-restore took no stdin")?.write_all(script.as_bytes())?;
77
- let status = child.wait()?;
204
+ stream.write_all(request.as_bytes())?;
205
+ let mut answer = Vec::new();
206
+ stream.read_to_end(&mut answer)?;
78
207
  let t1 = monotonic_ns();
79
- if !status.success() {
80
- return Err(format!("iptables-restore exited {status}").into());
208
+ let text = String::from_utf8_lossy(&answer);
209
+ let status: u16 = text
210
+ .split_whitespace()
211
+ .nth(1)
212
+ .ok_or("no status in the answer")?
213
+ .parse()?;
214
+ if !(200..300).contains(&status) {
215
+ let detail = text.split("\r\n\r\n").nth(1).unwrap_or("").trim();
216
+ return Err(format!("{method} {path} answered {status}: {detail}").into());
81
217
  }
82
- println!("{}", json!({ "action": action, "peer": peer.to_string(), "t0": t0, "t1": t1 }));
218
+ println!("{}", json!({ "action": "http", "method": method, "path": path, "status": status, "t0": t0, "t1": t1 }));
83
219
  Ok(())
84
220
  }
85
221
 
222
+ fn run() -> Result<(), Box<dyn Error>> {
223
+ let args: Vec<String> = std::env::args().skip(1).collect();
224
+ let action = args.first().ok_or("say an action: blackhole, clear, rst, reject, mtu, netem, offload, http …")?.as_str();
225
+ if action == "http" {
226
+ return http(&args);
227
+ }
228
+ match action {
229
+ "netem" | "clear-netem" | "offload" => {
230
+ let dev = device(args.get(1).ok_or("and a device")?)?;
231
+ let rest: Vec<&str> = args[2..].iter().map(String::as_str).collect();
232
+ let (spec, at) = match rest.iter().position(|token| *token == "--at") {
233
+ Some(index) => (
234
+ rest[..index].to_vec(),
235
+ Some(rest.get(index + 1).ok_or("--at takes an instant in ns")?.parse::<u64>()?),
236
+ ),
237
+ None => (rest, None),
238
+ };
239
+ if let Some(at) = at {
240
+ until(at)?;
241
+ }
242
+ let t0 = monotonic_ns();
243
+ match action {
244
+ "netem" => {
245
+ if spec.is_empty() || !spec.iter().all(|token| netem_token(token)) {
246
+ return Err(format!("not a netem specification: {}", spec.join(" ")).into());
247
+ }
248
+ let mut tc = vec!["qdisc", "replace", "dev", dev, "root", "netem"];
249
+ tc.extend(spec.iter().copied());
250
+ command("tc", &tc)?;
251
+ }
252
+ "clear-netem" => command("tc", &["qdisc", "del", "dev", dev, "root"])?,
253
+ _ => {
254
+ let state = match spec.as_slice() {
255
+ ["on"] => "on",
256
+ ["off"] => "off",
257
+ _ => return Err("offload takes on or off".into()),
258
+ };
259
+ command("ethtool", &["-K", dev, "tso", state, "gso", state, "gro", state])?;
260
+ }
261
+ }
262
+ let t1 = monotonic_ns();
263
+ println!("{}", json!({ "action": action, "device": dev, "spec": spec, "t0": t0, "t1": t1 }));
264
+ Ok(())
265
+ }
266
+ _ => {
267
+ // parsed, never pasted: only an address reaches the rules
268
+ let peer: Ipv4Addr = args.get(1).ok_or("and the peer's address")?.parse()?;
269
+ let opts = options(&args[2..])?;
270
+ let script = format!("*filter\n{}COMMIT\n", rules(action, peer, &opts)?);
271
+ if let Some(at) = opts.at {
272
+ until(at)?;
273
+ }
274
+ let t0 = monotonic_ns();
275
+ let mut child =
276
+ Command::new("iptables-restore").arg("--noflush").stdin(Stdio::piped()).spawn()?;
277
+ child.stdin.take().ok_or("iptables-restore took no stdin")?.write_all(script.as_bytes())?;
278
+ let status = child.wait()?;
279
+ let t1 = monotonic_ns();
280
+ if !status.success() {
281
+ return Err(format!("iptables-restore exited {status}").into());
282
+ }
283
+ println!("{}", json!({ "action": action, "peer": peer.to_string(), "t0": t0, "t1": t1 }));
284
+ Ok(())
285
+ }
286
+ }
287
+ }
288
+
86
289
  fn main() {
87
290
  if let Err(error) = run() {
88
291
  eprintln!("inject: {error}");
89
292
  std::process::exit(1);
90
293
  }
91
294
  }
295
+
296
+ #[cfg(test)]
297
+ mod tests {
298
+ use super::*;
299
+
300
+ #[test]
301
+ fn netem_specifications_are_words_and_numbers_with_units() {
302
+ for token in ["delay", "100ms", "10ms", "loss", "5%", "25.5%", "distribution", "normal", "1"] {
303
+ assert!(netem_token(token), "{token}");
304
+ }
305
+ for token in ["", ";", "100ms;rm", "$(x)", "-1", "a b"] {
306
+ assert!(!netem_token(token), "{token}");
307
+ }
308
+ }
309
+
310
+ #[test]
311
+ fn rules_cover_each_direction_code_and_length() {
312
+ let peer: Ipv4Addr = "10.0.0.2".parse().unwrap();
313
+ let with = |direction: &str| Options { direction: Some(direction.into()), ..Options::default() };
314
+ assert_eq!(rules("blackhole", peer, &with("in")).unwrap(), "-I INPUT -s 10.0.0.2/32 -j DROP\n");
315
+ assert_eq!(rules("clear", peer, &with("out")).unwrap(), "-D OUTPUT -d 10.0.0.2/32 -j DROP\n");
316
+ let code = Options { code: Some("net".into()), ..Options::default() };
317
+ assert!(rules("reject", peer, &code).unwrap().contains("icmp-net-unreachable"));
318
+ let above = Options { above: Some(1200), ..Options::default() };
319
+ assert_eq!(
320
+ rules("mtu", peer, &above).unwrap(),
321
+ "-I INPUT -s 10.0.0.2/32 -p tcp -m length --length 1201:65535 -j DROP\n"
322
+ );
323
+ assert!(rules("reject", peer, &Options::default()).is_err());
324
+ }
325
+
326
+ #[test]
327
+ fn http_requests_take_only_a_method_a_path_and_a_length() {
328
+ assert!(http_path("/proxies/redis/toxics").is_ok());
329
+ for path in ["proxies", "/a b", "/a;b", "/a?b=1", "/$(x)"] {
330
+ assert!(http_path(path).is_err(), "{path}");
331
+ }
332
+ let request = http_request("POST", "10.0.0.3:8474", "/proxies/redis/toxics", "{}").unwrap();
333
+ assert!(request.starts_with("POST /proxies/redis/toxics HTTP/1.1\r\n"));
334
+ assert!(request.contains("Content-Length: 2\r\n"));
335
+ assert!(request.ends_with("\r\n\r\n{}"));
336
+ assert!(http_request("PUT", "h", "/", "").is_err());
337
+ }
338
+ }
@@ -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"