@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.
- package/README.md +63 -11
- package/build/index.d.ts +407 -30
- package/build/index.js +979 -271
- package/build/index.js.map +1 -1
- package/image/Dockerfile +2 -1
- package/image/native/src/bin/client.rs +47 -8
- package/image/native/src/bin/inject.rs +280 -33
- 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 +6 -2
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
1
|
+
//! inject ACTION ARGS... [--at NS]
|
|
2
2
|
//!
|
|
3
|
-
//! A cell's fault, applied inside the network namespace this sidecar shares with the target,
|
|
4
|
-
//! the target's image stays stock. Each lands in one
|
|
5
|
-
//! bracketed by CLOCK_MONOTONIC stamps taken in the VM: a docker exec from Windows lands
|
|
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
|
-
//!
|
|
9
|
-
//!
|
|
10
|
-
//!
|
|
11
|
-
//!
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
let
|
|
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 =
|
|
62
|
-
|
|
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 =
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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":
|
|
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"
|