@mark1russell7/harness 0.1.0 → 0.2.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 +11 -0
- package/build/index.d.ts +102 -11
- package/build/index.js +193 -29
- 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 +212 -35
- package/package.json +1 -1
|
@@ -1,14 +1,28 @@
|
|
|
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.
|
|
12
26
|
//!
|
|
13
27
|
//! `--at NS` holds the commit until that instant on CLOCK_MONOTONIC, the clock every stamp is on.
|
|
14
28
|
//! A stall's end is timed that way, from inside the VM: the exec only has to arrive before it, and
|
|
@@ -46,46 +60,209 @@ fn until(at: u64) -> Result<(), Box<dyn Error>> {
|
|
|
46
60
|
}
|
|
47
61
|
}
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
let
|
|
63
|
+
/// The options after the positional arguments: `--at NS`, `--direction D`, `--code C`, `--above N`.
|
|
64
|
+
#[derive(Default)]
|
|
65
|
+
struct Options {
|
|
66
|
+
at: Option<u64>,
|
|
67
|
+
direction: Option<String>,
|
|
68
|
+
code: Option<String>,
|
|
69
|
+
above: Option<u32>,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn options(args: &[String]) -> Result<Options, Box<dyn Error>> {
|
|
73
|
+
let mut out = Options::default();
|
|
74
|
+
let mut rest = args.iter();
|
|
75
|
+
while let Some(flag) = rest.next() {
|
|
76
|
+
let value = rest.next().ok_or_else(|| format!("{flag} takes a value"))?;
|
|
77
|
+
match flag.as_str() {
|
|
78
|
+
"--at" => out.at = Some(value.parse()?),
|
|
79
|
+
"--direction" => out.direction = Some(value.clone()),
|
|
80
|
+
"--code" => out.code = Some(value.clone()),
|
|
81
|
+
"--above" => out.above = Some(value.parse()?),
|
|
82
|
+
other => return Err(format!("unknown option {other}").into()),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
Ok(out)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// The iptables rules of an action that is a filter rule, or None for an action that is not.
|
|
89
|
+
fn rules(action: &str, peer: Ipv4Addr, opts: &Options) -> Result<String, Box<dyn Error>> {
|
|
90
|
+
let op = |set: &str| if action == set { "-I" } else { "-D" };
|
|
91
|
+
Ok(match action {
|
|
60
92
|
"blackhole" | "clear" => {
|
|
61
|
-
let op =
|
|
62
|
-
|
|
93
|
+
let op = op("blackhole");
|
|
94
|
+
let direction = opts.direction.as_deref().unwrap_or("both");
|
|
95
|
+
let input = format!("{op} INPUT -s {peer}/32 -j DROP\n");
|
|
96
|
+
let output = format!("{op} OUTPUT -d {peer}/32 -j DROP\n");
|
|
97
|
+
match direction {
|
|
98
|
+
"both" => format!("{input}{output}"),
|
|
99
|
+
"in" => input,
|
|
100
|
+
"out" => output,
|
|
101
|
+
other => return Err(format!("unknown direction {other}").into()),
|
|
102
|
+
}
|
|
63
103
|
}
|
|
64
104
|
"rst" | "clear-rst" => {
|
|
65
|
-
let op =
|
|
105
|
+
let op = op("rst");
|
|
66
106
|
format!("{op} INPUT -s {peer}/32 -p tcp -j REJECT --reject-with tcp-reset\n")
|
|
67
107
|
}
|
|
108
|
+
"reject" | "clear-reject" => {
|
|
109
|
+
let op = op("reject");
|
|
110
|
+
let with = match opts.code.as_deref().ok_or("--code host|net|port|admin")? {
|
|
111
|
+
"host" => "icmp-host-unreachable",
|
|
112
|
+
"net" => "icmp-net-unreachable",
|
|
113
|
+
"port" => "icmp-port-unreachable",
|
|
114
|
+
"admin" => "icmp-admin-prohibited",
|
|
115
|
+
other => return Err(format!("unknown code {other}").into()),
|
|
116
|
+
};
|
|
117
|
+
format!("{op} INPUT -s {peer}/32 -p tcp -j REJECT --reject-with {with}\n")
|
|
118
|
+
}
|
|
119
|
+
// the long segments drop where they arrive: in the sender's own namespace, an OUTPUT drop
|
|
120
|
+
// would fail its sends with EPERM rather than lose them on the way
|
|
121
|
+
"mtu" | "clear-mtu" => {
|
|
122
|
+
let op = op("mtu");
|
|
123
|
+
let above = opts.above.ok_or("--above N")?;
|
|
124
|
+
format!("{op} INPUT -s {peer}/32 -p tcp -m length --length {}:65535 -j DROP\n", above + 1)
|
|
125
|
+
}
|
|
68
126
|
other => return Err(format!("unknown action {other}").into()),
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// a token of a netem specification: a word, or a number with an optional unit
|
|
131
|
+
fn netem_token(token: &str) -> bool {
|
|
132
|
+
let word = !token.is_empty() && token.chars().all(|c| c.is_ascii_lowercase());
|
|
133
|
+
let number = token
|
|
134
|
+
.trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '%')
|
|
135
|
+
.parse::<f64>()
|
|
136
|
+
.is_ok()
|
|
137
|
+
&& token
|
|
138
|
+
.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.')
|
|
139
|
+
.chars()
|
|
140
|
+
.all(|c| c.is_ascii_alphabetic() || c == '%');
|
|
141
|
+
word || number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// a device name: letters, digits, dots, dashes
|
|
145
|
+
fn device(name: &str) -> Result<&str, Box<dyn Error>> {
|
|
146
|
+
if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
|
|
147
|
+
Ok(name)
|
|
148
|
+
} else {
|
|
149
|
+
Err(format!("not a device: {name}").into())
|
|
73
150
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
let
|
|
79
|
-
if !status.success() {
|
|
80
|
-
return Err(format!(
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// runs a command and fails with its stderr when it fails
|
|
154
|
+
fn command(program: &str, args: &[&str]) -> Result<(), Box<dyn Error>> {
|
|
155
|
+
let out = Command::new(program).args(args).output()?;
|
|
156
|
+
if !out.status.success() {
|
|
157
|
+
return Err(format!(
|
|
158
|
+
"{program} {} exited {}: {}",
|
|
159
|
+
args.join(" "),
|
|
160
|
+
out.status,
|
|
161
|
+
String::from_utf8_lossy(&out.stderr).trim()
|
|
162
|
+
)
|
|
163
|
+
.into());
|
|
81
164
|
}
|
|
82
|
-
println!("{}", json!({ "action": action, "peer": peer.to_string(), "t0": t0, "t1": t1 }));
|
|
83
165
|
Ok(())
|
|
84
166
|
}
|
|
85
167
|
|
|
168
|
+
fn run() -> Result<(), Box<dyn Error>> {
|
|
169
|
+
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();
|
|
171
|
+
match action {
|
|
172
|
+
"netem" | "clear-netem" | "offload" => {
|
|
173
|
+
let dev = device(args.get(1).ok_or("and a device")?)?;
|
|
174
|
+
let rest: Vec<&str> = args[2..].iter().map(String::as_str).collect();
|
|
175
|
+
let (spec, at) = match rest.iter().position(|token| *token == "--at") {
|
|
176
|
+
Some(index) => (
|
|
177
|
+
rest[..index].to_vec(),
|
|
178
|
+
Some(rest.get(index + 1).ok_or("--at takes an instant in ns")?.parse::<u64>()?),
|
|
179
|
+
),
|
|
180
|
+
None => (rest, None),
|
|
181
|
+
};
|
|
182
|
+
if let Some(at) = at {
|
|
183
|
+
until(at)?;
|
|
184
|
+
}
|
|
185
|
+
let t0 = monotonic_ns();
|
|
186
|
+
match action {
|
|
187
|
+
"netem" => {
|
|
188
|
+
if spec.is_empty() || !spec.iter().all(|token| netem_token(token)) {
|
|
189
|
+
return Err(format!("not a netem specification: {}", spec.join(" ")).into());
|
|
190
|
+
}
|
|
191
|
+
let mut tc = vec!["qdisc", "replace", "dev", dev, "root", "netem"];
|
|
192
|
+
tc.extend(spec.iter().copied());
|
|
193
|
+
command("tc", &tc)?;
|
|
194
|
+
}
|
|
195
|
+
"clear-netem" => command("tc", &["qdisc", "del", "dev", dev, "root"])?,
|
|
196
|
+
_ => {
|
|
197
|
+
let state = match spec.as_slice() {
|
|
198
|
+
["on"] => "on",
|
|
199
|
+
["off"] => "off",
|
|
200
|
+
_ => return Err("offload takes on or off".into()),
|
|
201
|
+
};
|
|
202
|
+
command("ethtool", &["-K", dev, "tso", state, "gso", state, "gro", state])?;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
let t1 = monotonic_ns();
|
|
206
|
+
println!("{}", json!({ "action": action, "device": dev, "spec": spec, "t0": t0, "t1": t1 }));
|
|
207
|
+
Ok(())
|
|
208
|
+
}
|
|
209
|
+
_ => {
|
|
210
|
+
// parsed, never pasted: only an address reaches the rules
|
|
211
|
+
let peer: Ipv4Addr = args.get(1).ok_or("and the peer's address")?.parse()?;
|
|
212
|
+
let opts = options(&args[2..])?;
|
|
213
|
+
let script = format!("*filter\n{}COMMIT\n", rules(action, peer, &opts)?);
|
|
214
|
+
if let Some(at) = opts.at {
|
|
215
|
+
until(at)?;
|
|
216
|
+
}
|
|
217
|
+
let t0 = monotonic_ns();
|
|
218
|
+
let mut child =
|
|
219
|
+
Command::new("iptables-restore").arg("--noflush").stdin(Stdio::piped()).spawn()?;
|
|
220
|
+
child.stdin.take().ok_or("iptables-restore took no stdin")?.write_all(script.as_bytes())?;
|
|
221
|
+
let status = child.wait()?;
|
|
222
|
+
let t1 = monotonic_ns();
|
|
223
|
+
if !status.success() {
|
|
224
|
+
return Err(format!("iptables-restore exited {status}").into());
|
|
225
|
+
}
|
|
226
|
+
println!("{}", json!({ "action": action, "peer": peer.to_string(), "t0": t0, "t1": t1 }));
|
|
227
|
+
Ok(())
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
86
232
|
fn main() {
|
|
87
233
|
if let Err(error) = run() {
|
|
88
234
|
eprintln!("inject: {error}");
|
|
89
235
|
std::process::exit(1);
|
|
90
236
|
}
|
|
91
237
|
}
|
|
238
|
+
|
|
239
|
+
#[cfg(test)]
|
|
240
|
+
mod tests {
|
|
241
|
+
use super::*;
|
|
242
|
+
|
|
243
|
+
#[test]
|
|
244
|
+
fn netem_specifications_are_words_and_numbers_with_units() {
|
|
245
|
+
for token in ["delay", "100ms", "10ms", "loss", "5%", "25.5%", "distribution", "normal", "1"] {
|
|
246
|
+
assert!(netem_token(token), "{token}");
|
|
247
|
+
}
|
|
248
|
+
for token in ["", ";", "100ms;rm", "$(x)", "-1", "a b"] {
|
|
249
|
+
assert!(!netem_token(token), "{token}");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn rules_cover_each_direction_code_and_length() {
|
|
255
|
+
let peer: Ipv4Addr = "10.0.0.2".parse().unwrap();
|
|
256
|
+
let with = |direction: &str| Options { direction: Some(direction.into()), ..Options::default() };
|
|
257
|
+
assert_eq!(rules("blackhole", peer, &with("in")).unwrap(), "-I INPUT -s 10.0.0.2/32 -j DROP\n");
|
|
258
|
+
assert_eq!(rules("clear", peer, &with("out")).unwrap(), "-D OUTPUT -d 10.0.0.2/32 -j DROP\n");
|
|
259
|
+
let code = Options { code: Some("net".into()), ..Options::default() };
|
|
260
|
+
assert!(rules("reject", peer, &code).unwrap().contains("icmp-net-unreachable"));
|
|
261
|
+
let above = Options { above: Some(1200), ..Options::default() };
|
|
262
|
+
assert_eq!(
|
|
263
|
+
rules("mtu", peer, &above).unwrap(),
|
|
264
|
+
"-I INPUT -s 10.0.0.2/32 -p tcp -m length --length 1201:65535 -j DROP\n"
|
|
265
|
+
);
|
|
266
|
+
assert!(rules("reject", peer, &Options::default()).is_err());
|
|
267
|
+
}
|
|
268
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@mark1russell7/harness",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
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.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"fault-injection",
|