Skip to content

Commit fa4fd08

Browse files
committed
link: Add support for creating VLAN interfaces
Supports all standard VLAN creation options: id VLAN ID (required) protocol 802.1Q (default) or 802.1ad reorder_hdr on/off gvrp on/off mvrp on/off loose_binding on/off bridge_binding on/off ingress-qos-map from:to priority mapping egress-qos-map from:to priority mapping Integration test cases included. Signed-off-by: Gris Ge <cnfourt@gmail.com>
1 parent 788f3df commit fa4fd08

7 files changed

Lines changed: 688 additions & 18 deletions

File tree

AGENTS.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# iproute-rs
2+
3+
Rust drop-in replacement for Linux `iproute2`'s `ip` command. Uses
4+
`rtnetlink` for Netlink kernel communication. **WIP** — implements
5+
`ip link show/add` and `ip address show`.
6+
7+
## Build & check
8+
9+
```sh
10+
cargo build # debug binary → target/debug/ip
11+
cargo build --release
12+
make check # cargo build + sudo cargo test
13+
```
14+
15+
## Test
16+
17+
Tests **require root** (`sudo`) — `.cargo/config.toml` sets
18+
`runner = 'sudo'` on Linux. They create/modify/delete real kernel
19+
interfaces, so run with care on a dev machine.
20+
21+
The `cargo build` should always be invoked before `cargo test`.
22+
23+
Single test: `cargo test <name>` (runs under sudo automatically).
24+
25+
Integration tests live in `src/ip/{link,address}/tests/`. Pattern:
26+
run the same command against both system `ip` and compiled `ip-rs`,
27+
then compare output via `pretty_assertions`. All test interface
28+
names use unique prefixes (`tdmy`, `test-br`, `tvlan`, etc.) for
29+
parallel safety. Bridge timer values are normalized to avoid
30+
kernel-timing flakiness.
31+
32+
## Lint & format
33+
34+
Uses **Rust nightly** for formatting. CI runs these after
35+
`rustup override set nightly`:
36+
37+
```sh
38+
cargo fmt --all -- --check # nightly required
39+
cargo clippy -- -D warnings
40+
cargo clippy --tests -- -D warnings
41+
```
42+
43+
## Project structure
44+
45+
Single crate, not a workspace.
46+
47+
| Crate | Path | Description |
48+
|-------------------|-------------------|---------------------------------|
49+
| `iproute_rs` (lib) | `src/lib.rs` | Shared: color, error, MAC, ... |
50+
| `ip` (bin) | `src/ip/main.rs` | CLI entrypoint (clap + tokio) |
51+
52+
## Key conventions
53+
54+
- **Edition 2024** with 80-column width, `reorder_imports`,
55+
`group_imports = "StdExternalCrate"`,
56+
`imports_granularity = "Crate"` (see `.rustfmt.toml`).
57+
- All source files carry `// SPDX-License-Identifier: MIT`.
58+
- `rtnetlink` and `netlink-packet-route` pulled from git via
59+
`[patch.crates-io]` (not crates.io).
60+
- Async: `#[tokio::main(flavor = "current_thread")]`
61+
single-threaded.
62+
- CLI with `clap`; command aliases mirror `iproute2`
63+
(e.g. `link``lin`, `li`, `l`).
64+
- `Cargo.lock` is **not** committed (gitignored).
65+
66+
## Netlink connection pattern
67+
68+
```rust
69+
let (connection, handle, _) = rtnetlink::new_connection()?;
70+
tokio::spawn(connection);
71+
// use handle to send Netlink messages
72+
```

src/ip/link/add.rs

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
use std::collections::HashMap;
44

5+
use futures_util::TryStreamExt;
56
use iproute_rs::{CliError, parse_mac_str};
67
use rtnetlink::{
78
LinkDummy, LinkMessageBuilder,
@@ -37,33 +38,36 @@ impl LinkAddCommand {
3738

3839
let base_conf = LinkBaseConf::parse(opts)?;
3940

41+
let (connection, handle, _) = rtnetlink::new_connection()?;
42+
tokio::spawn(connection);
43+
4044
let nl_msg = match base_conf.iface_type {
4145
InfoKind::Dummy => {
4246
base_conf.apply(LinkDummy::new(&base_conf.name))?
4347
}
48+
InfoKind::Vlan => {
49+
base_conf.apply(base_conf.apply_vlan(&handle).await?)?
50+
}
4451
t => {
4552
return Err(CliError::from(format!(
4653
"Unsupported device type: {t}"
4754
)));
4855
}
4956
};
5057

51-
let (connection, handle, _) = rtnetlink::new_connection()?;
52-
53-
tokio::spawn(connection);
5458
handle.link().add(nl_msg).execute().await?;
5559

5660
Ok(vec![])
5761
}
5862
}
5963

6064
#[derive(Debug)]
61-
struct LinkBaseConf {
62-
// link: Option<String>,
63-
name: String,
64-
address: Option<String>,
65-
iface_type: InfoKind,
66-
_iface_specific: Vec<String>,
65+
pub(crate) struct LinkBaseConf {
66+
pub(crate) link: Option<String>,
67+
pub(crate) name: String,
68+
pub(crate) address: Option<String>,
69+
pub(crate) iface_type: InfoKind,
70+
pub(crate) iface_specific: Vec<String>,
6771
}
6872

6973
impl LinkBaseConf {
@@ -77,6 +81,19 @@ impl LinkBaseConf {
7781
Ok(builder.build())
7882
}
7983

84+
pub(crate) async fn get_ifindex_by_name(
85+
&self,
86+
handle: &rtnetlink::Handle,
87+
name: &str,
88+
) -> Result<u32, CliError> {
89+
let mut links =
90+
handle.link().get().match_name(name.to_string()).execute();
91+
let link = links.try_next().await?.ok_or_else(|| {
92+
CliError::from(format!("Device \"{name}\" does not exist"))
93+
})?;
94+
Ok(link.header.index)
95+
}
96+
8097
fn parse(args: Vec<String>) -> Result<Self, CliError> {
8198
if let Some(type_index) =
8299
args.as_slice().iter().position(|a| a.as_str() == "type")
@@ -112,17 +129,19 @@ impl LinkBaseConf {
112129

113130
let address =
114131
base_args_dict.remove("address").map(|s| s.to_string());
132+
let link = base_args_dict.remove("link").map(|s| s.to_string());
115133

116-
let _iface_specific = if args.len() > type_index + 1 {
134+
let iface_specific = if args.len() > type_index + 1 {
117135
args[type_index + 2..].to_vec()
118136
} else {
119137
Vec::new()
120138
};
121139
Ok(Self {
122140
name,
123141
address,
142+
link,
124143
iface_type,
125-
_iface_specific,
144+
iface_specific,
126145
})
127146
} else {
128147
Err(CliError::from(
@@ -131,3 +150,90 @@ impl LinkBaseConf {
131150
}
132151
}
133152
}
153+
154+
#[cfg(test)]
155+
mod tests {
156+
use super::*;
157+
158+
fn args(input: &[&str]) -> Vec<String> {
159+
input.iter().map(|s| s.to_string()).collect()
160+
}
161+
162+
#[test]
163+
fn parse_basic_dummy() {
164+
let conf =
165+
LinkBaseConf::parse(args(&["eth0", "type", "dummy"])).unwrap();
166+
assert_eq!(conf.name, "eth0");
167+
assert_eq!(conf.iface_type, InfoKind::Dummy);
168+
assert!(conf.address.is_none());
169+
assert!(conf.link.is_none());
170+
assert!(conf.iface_specific.is_empty());
171+
}
172+
173+
#[test]
174+
fn parse_with_address() {
175+
let conf = LinkBaseConf::parse(args(&[
176+
"name",
177+
"eth0",
178+
"address",
179+
"00:11:22:33:44:55",
180+
"type",
181+
"dummy",
182+
]))
183+
.unwrap();
184+
assert_eq!(conf.name, "eth0");
185+
assert_eq!(conf.address.as_deref(), Some("00:11:22:33:44:55"));
186+
assert_eq!(conf.iface_type, InfoKind::Dummy);
187+
}
188+
189+
#[test]
190+
fn parse_with_link() {
191+
let conf = LinkBaseConf::parse(args(&[
192+
"link", "eth0", "name", "eth0.1", "type", "vlan", "id", "100",
193+
]))
194+
.unwrap();
195+
assert_eq!(conf.name, "eth0.1");
196+
assert_eq!(conf.link.as_deref(), Some("eth0"));
197+
assert_eq!(conf.iface_type, InfoKind::Vlan);
198+
assert_eq!(conf.iface_specific, vec!["id", "100"]);
199+
}
200+
201+
#[test]
202+
fn parse_link_no_name_fails() {
203+
let err = LinkBaseConf::parse(args(&["link", "eth0", "type", "dummy"]))
204+
.unwrap_err();
205+
assert!(err.msg.contains("name"));
206+
}
207+
208+
#[test]
209+
fn parse_missing_type() {
210+
let err = LinkBaseConf::parse(args(&["eth0"])).unwrap_err();
211+
assert!(err.msg.contains("type"));
212+
}
213+
214+
#[test]
215+
fn parse_type_at_end() {
216+
let err = LinkBaseConf::parse(args(&["eth0", "type"])).unwrap_err();
217+
assert!(err.msg.contains("type"));
218+
}
219+
220+
#[test]
221+
fn parse_empty_args() {
222+
let err = LinkBaseConf::parse(args(&[])).unwrap_err();
223+
assert!(err.msg.contains("type"));
224+
}
225+
226+
#[test]
227+
fn parse_no_name() {
228+
let err = LinkBaseConf::parse(args(&["type", "dummy"])).unwrap_err();
229+
assert!(err.msg.contains("name"));
230+
}
231+
232+
#[test]
233+
fn parse_odd_args_without_link() {
234+
let conf =
235+
LinkBaseConf::parse(args(&["foo", "bar", "baz", "type", "dummy"]))
236+
.unwrap();
237+
assert_eq!(conf.name, "foo");
238+
}
239+
}

0 commit comments

Comments
 (0)