711 lines
21 KiB
Rust
711 lines
21 KiB
Rust
use axum::{Json, response::IntoResponse, http::StatusCode, extract::Path};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::process::Stdio;
|
|
use tokio::process::Command;
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct IpAddrInfo {
|
|
pub interfaces: Vec<InterfaceInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct InterfaceInfo {
|
|
pub name: String,
|
|
pub index: u32,
|
|
pub state: String,
|
|
pub mac_address: Option<String>,
|
|
pub ipv4_addresses: Vec<Ipv4Address>,
|
|
pub ipv6_addresses: Vec<Ipv6Address>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Ipv4Address {
|
|
pub address: String,
|
|
pub prefix_len: u8,
|
|
pub scope: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Ipv6Address {
|
|
pub address: String,
|
|
pub prefix_len: u8,
|
|
pub scope: String,
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct IpRouteInfo {
|
|
pub routes: Vec<RouteEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct RouteEntry {
|
|
pub destination: Option<String>,
|
|
pub gateway: Option<String>,
|
|
pub device: String,
|
|
pub metric: Option<u32>,
|
|
pub route_type: String,
|
|
}
|
|
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct IpLinkInfo {
|
|
pub links: Vec<LinkInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct LinkInfo {
|
|
pub index: u32,
|
|
pub name: String,
|
|
pub state: String,
|
|
pub mac_address: Option<String>,
|
|
pub mtu: u32,
|
|
pub qdisc: Option<String>,
|
|
pub link_type: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ErrorResponse {
|
|
error: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct OkResponse {
|
|
status: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub(crate) enum AddrAction {
|
|
Add,
|
|
Del,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub(crate) enum RouteAction {
|
|
Add,
|
|
Del,
|
|
Replace,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub(crate) enum IpFamily {
|
|
Inet,
|
|
Inet6,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct LinkMtuRequest {
|
|
pub mtu: u32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct AddrRequest {
|
|
pub action: AddrAction,
|
|
pub dev: String,
|
|
pub address: String,
|
|
pub prefix_len: u8,
|
|
pub family: Option<IpFamily>,
|
|
pub scope: Option<String>,
|
|
pub label: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RouteRequest {
|
|
pub action: RouteAction,
|
|
pub destination: String,
|
|
pub gateway: Option<String>,
|
|
pub dev: Option<String>,
|
|
pub metric: Option<u32>,
|
|
pub table: Option<String>,
|
|
pub proto: Option<String>,
|
|
pub scope: Option<String>,
|
|
}
|
|
pub async fn get_ip_addr() -> impl IntoResponse {
|
|
handle_ip("addr").await
|
|
}
|
|
|
|
pub async fn get_ip_route() -> impl IntoResponse {
|
|
handle_ip("route").await
|
|
}
|
|
|
|
pub async fn get_ip_link() -> impl IntoResponse {
|
|
handle_ip("link").await
|
|
}
|
|
|
|
pub async fn set_link_up(Path(ifname): Path<String>) -> impl IntoResponse {
|
|
handle_link_state(&ifname, "up").await
|
|
}
|
|
|
|
pub async fn set_link_down(Path(ifname): Path<String>) -> impl IntoResponse {
|
|
handle_link_state(&ifname, "down").await
|
|
}
|
|
|
|
pub async fn set_link_mtu(
|
|
Path(ifname): Path<String>,
|
|
Json(payload): Json<LinkMtuRequest>,
|
|
) -> impl IntoResponse {
|
|
if let Err(e) = validate_ifname(&ifname) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ErrorResponse { error: e }),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
if payload.mtu < 68 || payload.mtu > 65535 {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ErrorResponse {
|
|
error: "mtu must be between 68 and 65535".to_string(),
|
|
}),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let args = vec![
|
|
"link".to_string(),
|
|
"set".to_string(),
|
|
"dev".to_string(),
|
|
ifname,
|
|
"mtu".to_string(),
|
|
payload.mtu.to_string(),
|
|
];
|
|
|
|
match run_ip_cmd(&args).await {
|
|
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ErrorResponse { error: e }),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
pub async fn addr_modify(Json(payload): Json<AddrRequest>) -> impl IntoResponse {
|
|
if let Err(e) = validate_ifname(&payload.dev) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ErrorResponse { error: e }),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let family = payload.family.unwrap_or(IpFamily::Inet);
|
|
let max_prefix = match family {
|
|
IpFamily::Inet => 32,
|
|
IpFamily::Inet6 => 128,
|
|
};
|
|
if payload.prefix_len > max_prefix {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ErrorResponse {
|
|
error: format!("prefix_len must be <= {}", max_prefix),
|
|
}),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let mut args: Vec<String> = Vec::new();
|
|
match family {
|
|
IpFamily::Inet => args.push("-4".to_string()),
|
|
IpFamily::Inet6 => args.push("-6".to_string()),
|
|
}
|
|
args.push("addr".to_string());
|
|
args.push(match payload.action {
|
|
AddrAction::Add => "add".to_string(),
|
|
AddrAction::Del => "del".to_string(),
|
|
});
|
|
args.push(format!("{}/{}", payload.address, payload.prefix_len));
|
|
args.push("dev".to_string());
|
|
args.push(payload.dev);
|
|
|
|
if let Some(scope) = payload.scope {
|
|
args.push("scope".to_string());
|
|
args.push(scope);
|
|
}
|
|
if let Some(label) = payload.label {
|
|
args.push("label".to_string());
|
|
args.push(label);
|
|
}
|
|
|
|
match run_ip_cmd(&args).await {
|
|
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ErrorResponse { error: e }),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
pub async fn route_modify(Json(payload): Json<RouteRequest>) -> impl IntoResponse {
|
|
if let Some(dev) = payload.dev.as_ref() {
|
|
if let Err(e) = validate_ifname(dev) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ErrorResponse { error: e }),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
if payload.gateway.is_none() && payload.dev.is_none() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ErrorResponse {
|
|
error: "either gateway or dev must be provided".to_string(),
|
|
}),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let mut args: Vec<String> = Vec::new();
|
|
args.push("route".to_string());
|
|
args.push(match payload.action {
|
|
RouteAction::Add => "add".to_string(),
|
|
RouteAction::Del => "del".to_string(),
|
|
RouteAction::Replace => "replace".to_string(),
|
|
});
|
|
args.push(payload.destination);
|
|
|
|
if let Some(gw) = payload.gateway {
|
|
args.push("via".to_string());
|
|
args.push(gw);
|
|
}
|
|
if let Some(dev) = payload.dev {
|
|
args.push("dev".to_string());
|
|
args.push(dev);
|
|
}
|
|
if let Some(metric) = payload.metric {
|
|
args.push("metric".to_string());
|
|
args.push(metric.to_string());
|
|
}
|
|
if let Some(table) = payload.table {
|
|
args.push("table".to_string());
|
|
args.push(table);
|
|
}
|
|
if let Some(proto) = payload.proto {
|
|
args.push("proto".to_string());
|
|
args.push(proto);
|
|
}
|
|
if let Some(scope) = payload.scope {
|
|
args.push("scope".to_string());
|
|
args.push(scope);
|
|
}
|
|
|
|
match run_ip_cmd(&args).await {
|
|
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ErrorResponse { error: e }),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn handle_ip(subcmd: &str) -> impl IntoResponse {
|
|
match execute_ip_cmd(subcmd).await {
|
|
Ok(data) => Json(data).into_response(),
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ErrorResponse { error: e }),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn handle_link_state(ifname: &str, state: &str) -> impl IntoResponse {
|
|
if let Err(e) = validate_ifname(ifname) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(ErrorResponse { error: e }),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let args = vec![
|
|
"link".to_string(),
|
|
"set".to_string(),
|
|
"dev".to_string(),
|
|
ifname.to_string(),
|
|
state.to_string(),
|
|
];
|
|
|
|
match run_ip_cmd(&args).await {
|
|
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ErrorResponse { error: e }),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn run_ip_cmd(args: &[String]) -> Result<(), String> {
|
|
let output = Command::new("ip")
|
|
.args(args)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Failed to execute ip {}: {}", args.join(" "), e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("ip {} failed: {}", args.join(" "), stderr));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_ifname(name: &str) -> Result<(), String> {
|
|
if name.is_empty() {
|
|
return Err("ifname must not be empty".to_string());
|
|
}
|
|
if name.len() > 64 {
|
|
return Err("ifname is слишком длинное".to_string());
|
|
}
|
|
if !name
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' ))
|
|
{
|
|
return Err("ifname содержит недопустимые символы".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn execute_ip_cmd(subcmd: &str) -> Result<serde_json::Value, String> {
|
|
// Prefer native JSON output from iproute2 for a complete and robust payload.
|
|
let mut json_errors: Vec<String> = Vec::new();
|
|
|
|
match execute_ip_cmd_json(subcmd, true).await {
|
|
Ok(value) => return Ok(value),
|
|
Err(e) => json_errors.push(e),
|
|
}
|
|
|
|
// Some ip versions support JSON but not -details.
|
|
match execute_ip_cmd_json(subcmd, false).await {
|
|
Ok(value) => return Ok(value),
|
|
Err(e) => json_errors.push(e),
|
|
}
|
|
|
|
// Fallback to legacy text parsing for older ip versions without JSON support.
|
|
match execute_ip_cmd_legacy(subcmd).await {
|
|
Ok(value) => Ok(value),
|
|
Err(legacy_err) => {
|
|
let mut msg = String::new();
|
|
if !json_errors.is_empty() {
|
|
msg.push_str(&format!("JSON attempts failed: {}; ", json_errors.join(" | ")));
|
|
}
|
|
msg.push_str(&format!("legacy parsing failed: {}", legacy_err));
|
|
Err(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn execute_ip_cmd_json(subcmd: &str, details: bool) -> Result<serde_json::Value, String> {
|
|
let mut args: Vec<&str> = vec!["-j"];
|
|
if details {
|
|
args.push("-details");
|
|
}
|
|
args.push(subcmd);
|
|
|
|
let output = Command::new("ip")
|
|
.args(args)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Failed to execute ip -j {}: {}", subcmd, e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("ip -j {} failed: {}", subcmd, stderr));
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
serde_json::from_str::<serde_json::Value>(&stdout)
|
|
.map_err(|e| format!("Failed to parse ip -j {} output: {}", subcmd, e))
|
|
}
|
|
|
|
async fn execute_ip_cmd_legacy(subcmd: &str) -> Result<serde_json::Value, String> {
|
|
let output = Command::new("ip")
|
|
.arg(subcmd)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Failed to execute ip {}: {}", subcmd, e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("ip {} failed: {}", subcmd, stderr));
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
let result = match subcmd {
|
|
"addr" => parse_ip_addr(&stdout)?,
|
|
"route" => parse_ip_route(&stdout)?,
|
|
"link" => parse_ip_link(&stdout)?,
|
|
_ => return Err(format!("Unknown subcommand: {}", subcmd)),
|
|
};
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
|
|
|
|
fn parse_ip_addr(output: &str) -> Result<serde_json::Value, String> {
|
|
let mut interfaces = Vec::new();
|
|
let mut current_interface: Option<InterfaceInfo> = None;
|
|
|
|
for line in output.lines() {
|
|
let line = line.trim();
|
|
|
|
// Начало описания интерфейса: "1: lo: <LOOPBACK> ..."
|
|
if let Some(idx) = line.split(':').next() {
|
|
if let Ok(index) = idx.parse::<u32>() {
|
|
if let Some(iface) = current_interface.take() {
|
|
interfaces.push(iface);
|
|
}
|
|
|
|
let parts: Vec<&str> = line.split(':').collect();
|
|
if parts.len() >= 3 {
|
|
let name = parts[1].trim().trim_start_matches('@').to_string();
|
|
let state = if line.contains("<UP>") || line.contains("UP") {
|
|
"UP"
|
|
} else if line.contains("DOWN") {
|
|
"DOWN"
|
|
} else {
|
|
"UNKNOWN"
|
|
}.to_string();
|
|
|
|
current_interface = Some(InterfaceInfo {
|
|
name,
|
|
index,
|
|
state,
|
|
mac_address: None,
|
|
ipv4_addresses: Vec::new(),
|
|
ipv6_addresses: Vec::new(),
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if let Some(ref mut iface) = current_interface {
|
|
// MAC адрес: "link/ether aa:bb:cc:dd:ee:ff"
|
|
if line.starts_with("link/") {
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 2 && parts[1] != "none" {
|
|
iface.mac_address = Some(parts[1].to_string());
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// IPv4 адрес: "inet 192.168.1.1/24 brd ..."
|
|
if line.starts_with("inet ") {
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 2 {
|
|
let addr_parts: Vec<&str> = parts[1].split('/').collect();
|
|
if addr_parts.len() == 2 {
|
|
if let Ok(prefix) = addr_parts[1].parse::<u8>() {
|
|
let scope = if let Some(idx) = line.find("scope") {
|
|
line[idx..].split_whitespace().nth(1).unwrap_or("global").to_string()
|
|
} else {
|
|
"global".to_string()
|
|
};
|
|
iface.ipv4_addresses.push(Ipv4Address {
|
|
address: addr_parts[0].to_string(),
|
|
prefix_len: prefix,
|
|
scope,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// IPv6 адрес: "inet6 ::1/128 scope ..."
|
|
if line.starts_with("inet6 ") {
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 2 {
|
|
let addr_parts: Vec<&str> = parts[1].split('/').collect();
|
|
if addr_parts.len() == 2 {
|
|
if let Ok(prefix) = addr_parts[1].parse::<u8>() {
|
|
let scope = if let Some(idx) = line.find("scope") {
|
|
line[idx..].split_whitespace().nth(1).unwrap_or("global").to_string()
|
|
} else {
|
|
"global".to_string()
|
|
};
|
|
iface.ipv6_addresses.push(Ipv6Address {
|
|
address: addr_parts[0].to_string(),
|
|
prefix_len: prefix,
|
|
scope,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(iface) = current_interface {
|
|
interfaces.push(iface);
|
|
}
|
|
|
|
Ok(serde_json::to_value(IpAddrInfo { interfaces }).unwrap())
|
|
}
|
|
|
|
fn parse_ip_route(output: &str) -> Result<serde_json::Value, String> {
|
|
let mut routes = Vec::new();
|
|
|
|
for line in output.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
let mut route = RouteEntry {
|
|
destination: None,
|
|
gateway: None,
|
|
device: String::new(),
|
|
metric: None,
|
|
route_type: "unicast".to_string(),
|
|
};
|
|
|
|
let mut i = 0;
|
|
while i < parts.len() {
|
|
match parts[i] {
|
|
"default" => {
|
|
route.destination = Some("default".to_string());
|
|
}
|
|
"via" => {
|
|
if i + 1 < parts.len() {
|
|
route.gateway = Some(parts[i + 1].to_string());
|
|
i += 1;
|
|
}
|
|
}
|
|
"dev" => {
|
|
if i + 1 < parts.len() {
|
|
route.device = parts[i + 1].to_string();
|
|
i += 1;
|
|
}
|
|
}
|
|
"metric" => {
|
|
if i + 1 < parts.len() {
|
|
route.metric = parts[i + 1].parse::<u32>().ok();
|
|
i += 1;
|
|
}
|
|
}
|
|
"src" | "proto" | "scope" | "onlink" => {
|
|
// Пропускаем эти параметры
|
|
}
|
|
_ => {
|
|
if route.destination.is_none() && !["via", "dev", "metric", "src", "proto", "scope", "onlink"].contains(&parts[i]) {
|
|
route.destination = Some(parts[i].to_string());
|
|
}
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
if !route.device.is_empty() {
|
|
routes.push(route);
|
|
}
|
|
}
|
|
|
|
Ok(serde_json::to_value(IpRouteInfo { routes }).unwrap())
|
|
}
|
|
|
|
fn parse_ip_link(output: &str) -> Result<serde_json::Value, String> {
|
|
let mut links = Vec::new();
|
|
let mut current_link: Option<LinkInfo> = None;
|
|
|
|
for line in output.lines() {
|
|
let line = line.trim();
|
|
|
|
|
|
if let Some(idx) = line.split(':').next() {
|
|
if let Ok(index) = idx.parse::<u32>() {
|
|
if let Some(link) = current_link.take() {
|
|
links.push(link);
|
|
}
|
|
|
|
let parts: Vec<&str> = line.split(':').collect();
|
|
let name = if parts.len() >= 3 {
|
|
parts[1].trim().trim_start_matches('@').to_string()
|
|
} else {
|
|
"unknown".to_string()
|
|
};
|
|
|
|
let state = if line.contains("<UP>") || line.contains("UP") {
|
|
"UP"
|
|
} else if line.contains("DOWN") {
|
|
"DOWN"
|
|
} else {
|
|
"UNKNOWN"
|
|
}.to_string();
|
|
|
|
current_link = Some(LinkInfo {
|
|
index,
|
|
name,
|
|
state,
|
|
mac_address: None,
|
|
mtu: 1500,
|
|
qdisc: None,
|
|
link_type: "unknown".to_string(),
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if let Some(ref mut link) = current_link {
|
|
// link/ether или link/type
|
|
if line.starts_with("link/") {
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 2 {
|
|
link.link_type = parts[0].trim_start_matches("link/").to_string();
|
|
if parts[1] != "none" {
|
|
link.mac_address = Some(parts[1].to_string());
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// mtu
|
|
if line.starts_with("mtu ") {
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 2 {
|
|
link.mtu = parts[1].parse::<u32>().unwrap_or(1500);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// qdisc
|
|
if let Some(idx) = line.find("qdisc ") {
|
|
let rest = &line[idx + 6..];
|
|
if let Some(qdisc) = rest.split_whitespace().next() {
|
|
link.qdisc = Some(qdisc.to_string());
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(link) = current_link {
|
|
links.push(link);
|
|
}
|
|
|
|
Ok(serde_json::to_value(IpLinkInfo { links }).unwrap())
|
|
}
|