CH-J ToolsSSH를 통한 서버 관리
메뉴
공개된 소스

CH-J Tools

특정 애플리케이션 릴리스의 디렉터리와 파일을 살펴보세요.

소스 ZIP 다운로드
CH-J / Server Manager 1704 개 파일
0.1.2-beta 추가됨: · 빌드됨:
inventory.rs 추가됨: 날짜 기록 없음 54.4 KB · 1457 줄
코드 파일 다운로드
1use serde_json::{json, Value};
2use std::collections::{BTreeMap, BTreeSet};
3use std::net::IpAddr;
5pub fn sections(stdout: &str) -> Vec<(String, String)> {
6 let mut result: Vec<(String, String)> = Vec::new();
7 for line in stdout.split_inclusive('\n') {
8 if let Some(name) = line
9 .trim_end_matches(['\r', '\n'])
10 .strip_prefix("\x1eCHJ_SECTION=")
11 {
12 result.push((name.to_owned(), String::new()));
13 } else if let Some((_, body)) = result.last_mut() {
14 body.push_str(line);
15 }
16 }
17 result
20pub(crate) fn body<'a>(parts: &'a [(String, String)], name: &str) -> &'a str {
21 parts
22 .iter()
23 .find(|(n, _)| n == name)
24 .map(|(_, b)| b.as_str())
25 .unwrap_or("")
28pub(crate) fn clean(s: &str) -> &str {
29 s.trim().trim_matches(['\'', '"'])
32fn scalar(s: &str) -> Value {
33 if s.is_empty() {
34 Value::Null
35 } else {
36 json!(s)
37 }
40pub(crate) fn pairs(text: &str) -> BTreeMap<String, String> {
41 text.lines()
42 .filter(|l| !l.trim_start().starts_with('#'))
43 .filter_map(|l| {
44 let (k, v) = l.split_once('=')?;
45 Some((k.trim().to_owned(), clean(v).to_owned()))
46 })
47 .collect()
50pub(crate) fn ini(text: &str) -> BTreeMap<String, String> {
51 let mut result = BTreeMap::new();
52 let mut group = "";
53 for l in text
54 .lines()
55 .map(str::trim)
56 .filter(|l| !l.starts_with(['#', ';']))
57 {
58 if l.starts_with('[') && l.ends_with(']') {
59 group = &l[1..l.len() - 1];
60 } else if let Some((k, v)) = l.split_once('=') {
61 let key = format!("{}.{}", group, k.trim());
62 result
63 .entry(key)
64 .and_modify(|old: &mut String| {
65 old.push(' ');
66 old.push_str(clean(v));
67 })
68 .or_insert_with(|| clean(v).to_owned());
69 }
70 }
71 result
74pub(crate) fn words(s: &str) -> Vec<String> {
75 s.split([' ', '\t', ';', ','])
76 .map(clean)
77 .filter(|s| !s.is_empty())
78 .map(str::to_owned)
79 .collect()
82pub(crate) fn finding(level: &str, code: &str, params: Value) -> Value {
83 json!({"level": level, "code": code, "params": params})
86#[derive(Default, Debug)]
87pub(crate) struct Uci {
88 pub kind: String,
89 pub name: String,
90 pub fields: BTreeMap<String, String>,
91 pub raw: String,
94pub(crate) fn uci(text: &str) -> Vec<Uci> {
95 let mut blocks: Vec<Uci> = Vec::new();
96 for line in text.split_inclusive('\n') {
97 let t = line.trim();
98 if t.is_empty() || t.starts_with('#') {
99 if let Some(b) = blocks.last_mut() {
100 b.raw.push_str(line);
101 }
102 continue;
103 }
104 if let Some(rest) = t.strip_prefix("config ") {
105 let mut w = rest.split_whitespace();
106 blocks.push(Uci {
107 kind: w.next().unwrap_or("").to_owned(),
108 name: clean(w.next().unwrap_or("")).to_owned(),
109 raw: line.to_owned(),
110 ..Uci::default()
111 });
112 } else if t.starts_with("option ")
113 || t.starts_with("list ")
114 || t.starts_with("option\t")
115 || t.starts_with("list\t")
116 {
117 if let Some(b) = blocks.last_mut() {
118 b.raw.push_str(line);
119 let rest = t
120 .split_once(char::is_whitespace)
121 .map(|(_, v)| v.trim())
122 .unwrap_or("");
123 if let Some((k, v)) = rest.split_once(char::is_whitespace) {
124 let v = clean(v).to_owned();
125 if t.starts_with("list") {
126 b.fields
127 .entry(k.to_owned())
128 .and_modify(|old| {
129 old.push(' ');
130 old.push_str(&v);
131 })
132 .or_insert(v);
133 } else {
134 b.fields.insert(k.to_owned(), v);
135 }
136 }
137 }
138 } else if let Some((left, right)) = t.split_once('=') {
139 let mut p = left.splitn(3, '.');
140 let _package = p.next();
141 let name = p.next().unwrap_or("");
142 let key = p.next();
143 let idx = blocks
144 .iter()
145 .position(|b| b.name == name)
146 .unwrap_or_else(|| {
147 blocks.push(Uci {
148 name: name.to_owned(),
149 ..Uci::default()
150 });
151 blocks.len() - 1
152 });
153 let b = &mut blocks[idx];
154 b.raw.push_str(line);
155 if let Some(k) = key {
156 b.fields.insert(
157 k.to_owned(),
158 right
159 .split_whitespace()
160 .map(clean)
161 .collect::<Vec<_>>()
162 .join(" "),
163 );
164 } else {
165 b.kind = clean(right).to_owned();
166 }
167 }
168 }
169 blocks
172fn item<'a>(m: &'a BTreeMap<String, String>, k: &str) -> &'a str {
173 m.get(k).map(String::as_str).unwrap_or("")
175fn after<'a>(w: &[&'a str], k: &str) -> Option<&'a str> {
176 w.windows(2).find(|p| p[0] == k).map(|p| p[1])
178fn number(s: &str) -> Value {
179 s.parse::<u64>().ok().map_or(Value::Null, |n| json!(n))
181fn flag(v: &Value, name: &str) -> bool {
182 v["flags"]
183 .as_array()
184 .is_some_and(|a| a.iter().any(|x| x == name))
187fn interface(name: &str) -> Value {
188 json!({"name": name, "kind": "other", "physical": null, "state": "unknown", "carrier": null,
189 "speedMbps": null, "duplex": null, "mtu": null, "mac": null, "master": null, "vlanId": null,
190 "parent": null, "addresses": [], "ipv4Method": "unknown", "ipv6Method": "unknown",
191 "methodSource": null, "configuredIn": null, "managedBy": null, "gateway4": null,
192 "gateway6": null, "metric": null, "dns": [], "ssh": false})
195fn address(v: &Value) -> Option<Value> {
196 let family = match v["family"].as_str()? {
197 "inet" => 4,
198 "inet6" => 6,
199 _ => return None,
200 };
201 let addr = v["local"].as_str()?;
202 Some(
203 json!({"family": family, "address": addr, "prefix": v["prefixlen"], "scope": v["scope"],
204 "dynamic": v["dynamic"].as_bool().unwrap_or(false), "secondary": v["secondary"].as_bool().unwrap_or(false),
205 "temporary": v["temporary"].as_bool().unwrap_or(false), "lifetimeValid": v["valid_life_time"]}),
206 )
209fn link(v: &Value, iface: &mut Value) {
210 for (src, dst) in [("mtu", "mtu"), ("address", "mac"), ("master", "master")] {
211 if !v[src].is_null() {
212 iface[dst] = v[src].clone();
213 }
214 }
215 iface["state"] = json!(match v["operstate"]
216 .as_str()
217 .unwrap_or("")
218 .to_ascii_lowercase()
219 .as_str()
220 {
221 "up" => "up",
222 "down" => "down",
223 _ => "unknown",
224 });
225 let kind = v["linkinfo"]["info_kind"].as_str().unwrap_or("");
226 iface["kind"] = json!(match kind {
227 "bridge" | "bond" | "vlan" | "wireguard" | "tun" | "veth" | "dummy" => kind,
228 _ if v["link_type"] == "loopback" || flag(v, "LOOPBACK") => "loopback",
229 _ if v["link_type"] == "ether" => "ethernet",
230 _ => "other",
231 });
232 if !v["linkinfo"]["info_data"]["id"].is_null() {
233 iface["vlanId"] = v["linkinfo"]["info_data"]["id"].clone();
234 }
235 if !v["link"].is_null() {
236 iface["parent"] = v["link"].clone();
237 }
238 if !v["link_index"].is_null() {
239 iface["parent"] = v["link_index"].clone();
240 }
243fn interfaces(parts: &[(String, String)]) -> Vec<Value> {
244 let mut result: Vec<Value> = Vec::new();
245 let mut indexes = BTreeMap::new();
246 for section in ["addr-json", "link-json"] {
247 if let Ok(Value::Array(list)) = serde_json::from_str::<Value>(body(parts, section)) {
248 for v in list {
249 let Some(name) = v["ifname"].as_str() else {
250 continue;
251 };
252 if let Some(idx) = v["ifindex"].as_u64() {
253 indexes.insert(idx, name.to_owned());
254 }
255 let idx = ensure(&mut result, name);
256 link(&v, &mut result[idx]);
257 if let Some(addrs) = v["addr_info"].as_array() {
258 result[idx]["addresses"] =
259 json!(addrs.iter().filter_map(address).collect::<Vec<_>>());
260 }
261 }
262 }
263 }
264 for section in ["addr-text", "link-text"] {
265 text_interfaces(body(parts, section), &mut result);
266 }
267 for (name, text) in parts {
268 let Some(name) = name.strip_prefix("sys:") else {
269 continue;
270 };
271 let m = pairs(text);
272 // bonding_masters is a sysfs control file, not a network device.
273 if item(&m, "mtu").is_empty() && item(&m, "address").is_empty() {
274 continue;
275 }
276 let idx = ensure(&mut result, name);
277 let iface = &mut result[idx];
278 iface["physical"] = match item(&m, "physical") {
279 "1" => json!(true),
280 "0" => json!(false),
281 _ => Value::Null,
282 };
283 iface["carrier"] = match item(&m, "carrier") {
284 "1" => json!(true),
285 "0" => json!(false),
286 _ => Value::Null,
287 };
288 iface["speedMbps"] = item(&m, "speed")
289 .parse::<u64>()
290 .ok()
291 .filter(|n| *n > 0)
292 .map_or(Value::Null, |n| json!(n));
293 iface["duplex"] = match item(&m, "duplex") {
294 "full" => json!("full"),
295 "half" => json!("half"),
296 _ => Value::Null,
297 };
298 if !item(&m, "mtu").is_empty() {
299 iface["mtu"] = number(item(&m, "mtu"));
300 }
301 if !item(&m, "address").is_empty() {
302 iface["mac"] = json!(item(&m, "address"));
303 }
304 if !item(&m, "operstate").is_empty() {
305 iface["state"] = json!(match item(&m, "operstate") {
306 "up" => "up",
307 "down" => "down",
308 _ => "unknown",
309 });
310 }
311 }
312 for iface in &mut result {
313 for field in ["master", "parent"] {
314 if let Some(idx) = iface[field].as_u64() {
315 iface[field] = indexes.get(&idx).map_or(Value::Null, |s| json!(s));
316 }
317 }
318 }
319 result
322fn ensure(list: &mut Vec<Value>, name: &str) -> usize {
323 if let Some(idx) = list.iter().position(|v| v["name"] == name) {
324 idx
325 } else {
326 list.push(interface(name));
327 list.len() - 1
328 }
331fn text_interfaces(text: &str, list: &mut Vec<Value>) {
332 let mut current = None;
333 for line in text.lines() {
334 let w: Vec<_> = line.split_whitespace().collect();
335 if w.len() >= 2 && w[0].trim_end_matches(':').parse::<u32>().is_ok() {
336 let named = w[1].trim_end_matches(':');
337 let (name, parent) = named
338 .split_once('@')
339 .map_or((named, None), |(n, p)| (n, Some(p)));
340 let idx = ensure(list, name);
341 current = Some(idx);
342 let v = &mut list[idx];
343 if let Some(p) = parent {
344 v["parent"] = json!(p);
345 }
346 if let Some(mtu) = after(&w, "mtu") {
347 v["mtu"] = number(mtu);
348 }
349 if let Some(master) = after(&w, "master") {
350 v["master"] = json!(master);
351 }
352 if let Some(state) = after(&w, "state") {
353 v["state"] = json!(match state {
354 "UP" => "up",
355 "DOWN" => "down",
356 _ => "unknown",
357 });
358 }
359 if line.contains("LOOPBACK") {
360 v["kind"] = json!("loopback");
361 }
362 } else if let Some(idx) = current {
363 let v = &mut list[idx];
364 match w.first().copied().unwrap_or("") {
365 "link/ether" | "link/loopback" if w.len() > 1 => {
366 v["mac"] = json!(w[1]);
367 if v["kind"] == "other" {
368 v["kind"] = json!("ethernet");
369 }
370 }
371 "inet" | "inet6" if w.len() > 1 => {
372 if let Some((addr, prefix)) = w[1].split_once('/') {
373 let a = json!({"family": if w[0] == "inet" {4} else {6}, "address": addr, "prefix": number(prefix),
374 "scope": after(&w, "scope"), "dynamic": w.contains(&"dynamic"), "secondary": w.contains(&"secondary"),
375 "temporary": w.contains(&"temporary"), "lifetimeValid": null});
376 if let Some(addrs) = v["addresses"].as_array_mut() {
377 if !addrs.iter().any(|old| old["address"] == addr) {
378 addrs.push(a);
379 }
380 }
381 }
382 }
383 "valid_lft" if w.len() > 1 => {
384 if let Some(a) = v["addresses"].as_array_mut().and_then(|a| a.last_mut()) {
385 a["lifetimeValid"] = if w[1] == "forever" {
386 json!("forever")
387 } else {
388 number(w[1].trim_end_matches("sec"))
389 };
390 }
391 }
392 _ => {}
393 }
394 }
395 }
398fn routes(parts: &[(String, String)]) -> Vec<Value> {
399 let mut result = Vec::new();
400 for family in [4, 6] {
401 let j = body(parts, &format!("route{}-json", family));
402 if let Ok(Value::Array(list)) = serde_json::from_str::<Value>(j) {
403 for v in list {
404 if !v.is_object() {
405 continue;
406 }
407 let dst = v["dst"].as_str();
408 result.push(json!({"family": family, "destination": dst, "gateway": v["gateway"], "device": v["dev"],
409 "metric": v["metric"], "protocol": v["protocol"], "scope": v["scope"],
410 "default": matches!(dst, Some("default" | "0.0.0.0/0" | "::/0"))}));
411 }
412 } else {
413 for line in body(parts, &format!("route{}-text", family)).lines() {
414 let w: Vec<_> = line.split_whitespace().collect();
415 if w.is_empty() || (!w.contains(&"dev") && !w.contains(&"via")) {
416 continue;
417 }
418 result.push(json!({"family": family, "destination": w[0], "gateway": after(&w, "via"), "device": after(&w, "dev"),
419 "metric": after(&w, "metric").map(number), "protocol": after(&w, "proto"), "scope": after(&w, "scope"),
420 "default": matches!(w[0], "default" | "0.0.0.0/0" | "::/0")}));
421 }
422 }
423 }
424 result
427#[derive(Default, Debug)]
428struct Config {
429 name: String,
430 mac: String,
431 path: String,
432 manager: String,
433 renderer: Option<String>,
434 v4: Option<String>,
435 v6: Option<String>,
436 dns: Vec<String>,
437 kind: Option<String>,
438 parent: Option<String>,
439 vlan: Option<u64>,
440 members: Vec<String>,
441 priority: u8,
444fn yaml_entries(text: &str) -> Vec<(Vec<String>, String)> {
445 let mut result = Vec::new();
446 let mut stack: Vec<(usize, String)> = Vec::new();
447 for line in text.lines() {
448 let t = line.trim();
449 if t.is_empty() || t.starts_with('#') {
450 continue;
451 }
452 let indent = line.len() - line.trim_start().len();
453 if let Some(value) = t.strip_prefix("- ") {
454 result.push((
455 stack.iter().map(|(_, k)| k.clone()).collect(),
456 clean(value).to_owned(),
457 ));
458 continue;
459 }
460 let Some((key, value)) = t.split_once(':') else {
461 continue;
462 };
463 while stack.last().is_some_and(|(i, _)| *i >= indent) {
464 stack.pop();
465 }
466 let mut path: Vec<_> = stack.iter().map(|(_, k)| k.clone()).collect();
467 path.push(clean(key).to_owned());
468 let value = clean(value.split(" #").next().unwrap_or(""));
469 result.push((path, value.to_owned()));
470 if value.is_empty() {
471 stack.push((indent, clean(key).to_owned()));
472 }
473 }
474 result
477fn yaml_configs(path: &str, text: &str) -> Vec<Config> {
478 let entries = yaml_entries(text);
479 let mut result = Vec::new();
480 let renderer = entries
481 .iter()
482 .find(|(p, _)| p == &["network", "renderer"])
483 .map(|(_, v)| v.clone());
484 for (base, _) in &entries {
485 if base.len() != 3 || base[0] != "network" {
486 continue;
487 }
488 let cat = base[1].as_str();
489 if !matches!(
490 cat,
491 "ethernets" | "bridges" | "bonds" | "vlans" | "wifis" | "tunnels" | "dummy-devices"
492 ) || base[2] == "renderer"
493 {
494 continue;
495 }
496 let get = |suffix: &[&str]| -> Option<&str> {
497 entries
498 .iter()
499 .find(|(p, _)| {
500 p.len() == base.len() + suffix.len()
501 && p.starts_with(base)
502 && p[base.len()..]
503 .iter()
504 .map(String::as_str)
505 .eq(suffix.iter().copied())
506 })
507 .map(|(_, v)| v.as_str())
508 };
509 let list = |suffix: &[&str]| -> Vec<String> {
510 entries
511 .iter()
512 .filter(|(p, _)| {
513 p.len() == base.len() + suffix.len()
514 && p.starts_with(base)
515 && p[base.len()..]
516 .iter()
517 .map(String::as_str)
518 .eq(suffix.iter().copied())
519 })
520 .flat_map(|(_, v)| yaml_values(v))
521 .collect()
522 };
523 let mut c = Config {
524 name: get(&["set-name"]).unwrap_or(&base[2]).to_owned(),
525 mac: get(&["match", "macaddress"]).unwrap_or("").to_owned(),
526 path: path.to_owned(),
527 manager: "netplan".into(),
528 renderer: get(&["renderer"])
529 .map(str::to_owned)
530 .or_else(|| renderer.clone()),
531 priority: 30,
532 ..Config::default()
533 };
534 if let Some(name) = get(&["match", "name"]) {
535 if get(&["set-name"]).is_none() {
536 c.name = name.into();
537 }
538 }
539 // A definition ID with a MAC match is not necessarily a kernel interface name.
540 if !c.mac.is_empty() && get(&["set-name"]).is_none() && get(&["match", "name"]).is_none() {
541 c.name.clear();
542 }
543 c.v4 = match get(&["dhcp4"]) {
544 Some("true" | "yes") => Some("dhcp".into()),
545 Some("false" | "no") => Some("disabled".into()),
546 _ => None,
547 };
548 let dhcp6 = matches!(get(&["dhcp6"]), Some("true" | "yes"));
549 let ra = get(&["accept-ra"]);
550 c.v6 = if dhcp6 {
551 Some(
552 if ra == Some("false") {
553 "dhcpv6"
554 } else {
555 "slaac-dhcpv6"
556 }
557 .into(),
558 )
559 } else if ra == Some("true") {
560 Some("slaac".into())
561 } else if get(&["dhcp6"]) == Some("false") && ra == Some("false") {
562 Some("disabled".into())
563 } else {
564 None
565 };
566 for a in list(&["addresses"]) {
567 set_static(&mut c, &a);
568 }
569 c.dns = list(&["nameservers", "addresses"]);
570 c.kind = match cat {
571 "bridges" => Some("bridge"),
572 "bonds" => Some("bond"),
573 "vlans" => Some("vlan"),
574 "wifis" => Some("wifi"),
575 "dummy-devices" => Some("dummy"),
576 _ => None,
577 }
578 .map(str::to_owned);
579 c.parent = get(&["link"]).map(str::to_owned);
580 c.vlan = get(&["id"]).and_then(|s| s.parse().ok());
581 c.members = list(&["interfaces"]);
582 result.push(c);
583 }
584 result
587fn yaml_values(s: &str) -> Vec<String> {
588 s.trim_matches(['[', ']'])
589 .split(',')
590 .map(clean)
591 .filter(|s| !s.is_empty())
592 .map(str::to_owned)
593 .collect()
595fn set_static(c: &mut Config, a: &str) {
596 if let Ok(ip) = a.split('/').next().unwrap_or("").parse::<IpAddr>() {
597 if ip.is_ipv4() {
598 if c.v4.as_deref() != Some("dhcp") {
599 c.v4 = Some("static".into());
600 }
601 } else if !matches!(c.v6.as_deref(), Some("dhcpv6" | "slaac" | "slaac-dhcpv6")) {
602 c.v6 = Some("static".into());
603 }
604 }
607fn ifup_configs(path: &str, text: &str, manager: &str) -> Vec<Config> {
608 let mut result: Vec<Config> = Vec::new();
609 for l in text.lines().map(str::trim).filter(|l| !l.starts_with('#')) {
610 let w: Vec<_> = l.split_whitespace().collect();
611 if w.len() >= 4 && w[0] == "iface" {
612 let method = match w[3] {
613 "dhcp" if w[2] == "inet6" => "dhcpv6",
614 "dhcp" => "dhcp",
615 "static" | "loopback" => "static",
616 "auto" if w[2] == "inet6" => "slaac",
617 _ => "unknown",
618 };
619 let mut c = Config {
620 name: w[1].into(),
621 path: path.into(),
622 manager: manager.into(),
623 priority: if w[3] == "manual" { 5 } else { 25 },
624 ..Config::default()
625 };
626 if w[2] == "inet6" {
627 c.v6 = (method != "unknown").then(|| method.into());
628 } else {
629 c.v4 = (method != "unknown").then(|| method.into());
630 }
631 result.push(c);
632 } else if let Some(c) = result.last_mut() {
633 if w.len() < 2 {
634 continue;
635 }
636 match w[0] {
637 "dns-nameservers" => c.dns.extend(w[1..].iter().map(|s| s.to_string())),
638 "bridge-ports" => {
639 c.kind = Some("bridge".into());
640 c.members = w[1..].iter().map(|s| s.to_string()).collect();
641 }
642 "bond-slaves" => {
643 c.kind = Some("bond".into());
644 c.members = w[1..].iter().map(|s| s.to_string()).collect();
645 }
646 "vlan-raw-device" => {
647 c.kind = Some("vlan".into());
648 c.parent = Some(w[1].into());
649 }
650 _ => {}
651 }
652 }
653 }
654 result
657fn configs(parts: &[(String, String)], ifup: &str) -> Vec<Config> {
658 let mut result = Vec::new();
659 for (section, text) in parts {
660 let Some(path) = section.strip_prefix("file:") else {
661 continue;
662 };
663 if path.starts_with("/etc/netplan/") {
664 result.extend(yaml_configs(path, text));
665 } else if path == "/etc/network/interfaces"
666 || path.starts_with("/etc/network/interfaces.d/")
667 {
668 result.extend(ifup_configs(path, text, ifup));
669 } else if path.ends_with(".netdev") {
670 let m = ini(text);
671 let kind = item(&m, "NetDev.Kind");
672 result.push(Config {
673 name: item(&m, "NetDev.Name").into(),
674 path: path.into(),
675 manager: "networkd".into(),
676 priority: 1,
677 kind: matches!(
678 kind,
679 "bridge" | "bond" | "vlan" | "wireguard" | "tun" | "veth" | "dummy"
680 )
681 .then(|| kind.to_owned()),
682 vlan: item(&m, "VLAN.Id").parse().ok(),
683 ..Config::default()
684 });
685 } else if path.ends_with(".network") {
686 let m = ini(text);
687 let mut c = Config {
688 name: item(&m, "Match.Name").into(),
689 mac: item(&m, "Match.MACAddress").into(),
690 path: path.into(),
691 manager: "networkd".into(),
692 priority: 20,
693 ..Config::default()
694 };
695 if c.mac.is_empty() {
696 c.mac = item(&m, "Match.PermanentMACAddress").into();
697 }
698 let dhcp = item(&m, "Network.DHCP");
699 c.v4 = match dhcp {
700 "yes" | "true" | "ipv4" => Some("dhcp".into()),
701 "no" | "false" | "ipv6" => Some("disabled".into()),
702 _ => None,
703 };
704 let ra = item(&m, "Network.IPv6AcceptRA");
705 c.v6 = if matches!(dhcp, "yes" | "true" | "ipv6") {
706 Some(
707 if matches!(ra, "no" | "false") {
708 "dhcpv6"
709 } else {
710 "slaac-dhcpv6"
711 }
712 .into(),
713 )
714 } else if matches!(ra, "yes" | "true") {
715 Some("slaac".into())
716 } else if matches!(ra, "no" | "false") && matches!(dhcp, "no" | "false" | "ipv4") {
717 Some("disabled".into())
718 } else {
719 None
720 };
721 for a in words(item(&m, "Network.Address"))
722 .into_iter()
723 .chain(words(item(&m, "Address.Address")))
724 {
725 set_static(&mut c, &a);
726 }
727 c.dns = words(item(&m, "Network.DNS"));
728 result.push(c);
729 } else if path.ends_with(".nmconnection") {
730 let m = ini(text);
731 if item(&m, ".nmmeta.external") == "true" {
732 continue;
733 }
734 let mut name = item(&m, "connection.interface-name").to_owned();
735 if name.is_empty() {
736 for l in body(parts, "nm-connections").lines() {
737 let w: Vec<_> = l.split(':').collect();
738 if w.len() >= 5 && w[4] == path {
739 name = w[3].into();
740 break;
741 }
742 }
743 }
744 let mut c = Config {
745 name,
746 mac: item(&m, "ethernet.mac-address").into(),
747 path: path.into(),
748 manager: "networkmanager".into(),
749 priority: 25,
750 ..Config::default()
751 };
752 c.v4 = nm_method(item(&m, "ipv4.method"), false);
753 c.v6 = nm_method(item(&m, "ipv6.method"), true);
754 c.dns = words(item(&m, "ipv4.dns"));
755 c.dns.extend(words(item(&m, "ipv6.dns")));
756 if item(&m, "connection.type") == "wifi"
757 || item(&m, "connection.type") == "802-11-wireless"
758 {
759 c.kind = Some("wifi".into());
760 }
761 result.push(c);
762 }
763 }
764 let u = if !body(parts, "uci-network").is_empty() {
765 uci(body(parts, "uci-network"))
766 } else {
767 uci(body(parts, "file:/etc/config/network"))
768 };
769 for b in u {
770 let m = &b.fields;
771 let mut c = Config {
772 name: item(m, if b.kind == "device" { "name" } else { "device" }).into(),
773 path: "/etc/config/network".into(),
774 manager: "netifd".into(),
775 priority: 25,
776 ..Config::default()
777 };
778 if c.name.is_empty() {
779 c.name = item(m, "ifname").into();
780 }
781 if b.kind == "interface" {
782 c.v4 = match item(m, "proto") {
783 "dhcp" => Some("dhcp".into()),
784 "static" => Some("static".into()),
785 "none" => Some("disabled".into()),
786 _ => None,
787 };
788 c.v6 = match item(m, "proto") {
789 "dhcpv6" => Some("dhcpv6".into()),
790 _ => None,
791 };
792 c.dns = words(item(m, "dns"));
793 if !item(m, "ip6addr").is_empty() {
794 c.v6 = Some("static".into());
795 }
796 if item(m, "ipv6") == "0" {
797 c.v6 = Some("disabled".into());
798 }
799 } else if b.kind == "device" {
800 c.kind = match item(m, "type") {
801 "bridge" => Some("bridge".into()),
802 "8021q" | "8021ad" => Some("vlan".into()),
803 _ => None,
804 };
805 c.members = words(item(m, "ports"));
806 c.parent = m.get("ifname").cloned();
807 c.vlan = item(m, "vid").parse().ok();
808 c.priority = 10;
809 } else {
810 continue;
811 }
812 result.push(c);
813 }
814 result
817fn nm_method(method: &str, v6: bool) -> Option<String> {
818 match method {
819 "auto" => Some(if v6 { "slaac-dhcpv6" } else { "dhcp" }.into()),
820 "manual" => Some("static".into()),
821 "disabled" => Some("disabled".into()),
822 "dhcp" if v6 => Some("dhcpv6".into()),
823 _ => None,
824 }
827fn matches(c: &Config, iface: &Value) -> bool {
828 let name = iface["name"].as_str().unwrap_or("");
829 let mac = iface["mac"].as_str().unwrap_or("");
830 if c.name.is_empty() && c.mac.is_empty() {
831 return false;
832 }
833 (c.name.is_empty() || c.name.split_whitespace().any(|p| glob(p, name)))
834 && (c.mac.is_empty() || c.mac.eq_ignore_ascii_case(mac))
837fn glob(pattern: &str, text: &str) -> bool {
838 if pattern == "*" {
839 return true;
840 }
841 if let Some((a, b)) = pattern.split_once('*') {
842 text.starts_with(a) && text.ends_with(b)
843 } else {
844 pattern == text
845 }
848fn manager_info(parts: &[(String, String)], configs: &[Config], ifup: &str) -> Value {
849 let mut managers = Vec::new();
850 let services = body(parts, "services");
851 for (name, service) in [
852 ("networkd", "systemd-networkd"),
853 ("networkmanager", "NetworkManager"),
854 (ifup, "networking"),
855 ("netifd", "network"),
856 ] {
857 let evidence: Vec<_> = services
858 .lines()
859 .filter(|l| {
860 let w: Vec<_> = l.split_whitespace().collect();
861 w.len() >= 2 && w[0] == service && w[1] == "active"
862 })
863 .map(|l| json!({"section": "services", "value": l}))
864 .collect();
865 if !evidence.is_empty() {
866 managers.push(json!({"name": name, "evidence": evidence}));
867 }
868 }
869 let has_plan = parts
870 .iter()
871 .any(|(s, _)| s.starts_with("file:/etc/netplan/"));
872 let tools = body(parts, "tools");
873 if has_plan
874 && tools.lines().any(|l| l == "netplan")
875 && managers
876 .iter()
877 .any(|m| m["name"] == "networkd" || m["name"] == "networkmanager")
878 {
879 managers.insert(0, json!({"name": "netplan", "evidence": [{"section": "tools", "value": "netplan"}, {"section": "files", "value": "/etc/netplan/"}]}));
880 }
881 let primary = if managers.iter().any(|m| m["name"] == ifup) && ifup == "ifupdown2" {
882 ifup
883 } else {
884 managers
885 .first()
886 .and_then(|m| m["name"].as_str())
887 .unwrap_or("unknown")
888 };
889 let renderer = if has_plan {
890 configs.iter().find_map(|c| c.renderer.clone()).or_else(|| {
891 if managers.iter().any(|m| m["name"] == "networkd") {
892 Some("networkd".into())
893 } else if managers.iter().any(|m| m["name"] == "networkmanager") {
894 Some("NetworkManager".into())
895 } else {
896 None
897 }
898 })
899 } else {
900 None
901 };
902 let mut files = Vec::new();
903 let mut seen = BTreeSet::new();
904 for (s, _) in parts {
905 if let Some(path) = s.strip_prefix("file:") {
906 let kind = if path.starts_with("/etc/netplan/") {
907 "netplan"
908 } else if path.ends_with(".network") || path.ends_with(".netdev") {
909 "networkd"
910 } else if path.contains("NetworkManager/") {
911 "networkmanager"
912 } else if path.starts_with("/etc/network/interfaces") {
913 ifup
914 } else if path == "/etc/config/network" {
915 "uci"
916 } else if path.contains("/ifcfg-") {
917 "ifcfg"
918 } else {
919 continue;
920 };
921 if seen.insert(path) {
922 files.push(json!({"path": path, "kind": kind}));
923 }
924 }
925 }
926 json!({"manager": primary, "renderer": renderer, "managers": managers, "files": files})
929fn apply_configs(
930 ifaces: &mut [Value],
931 configs: &[Config],
932 routes: &[Value],
933 info: &Value,
934 parts: &[(String, String)],
935) {
936 for iface in ifaces.iter_mut() {
937 let mut selected: Vec<_> = configs.iter().filter(|c| matches(c, iface)).collect();
938 selected.sort_by_key(|c| std::cmp::Reverse(c.priority));
939 if let Some(c) = selected.first() {
940 iface["configuredIn"] = json!(c.path);
941 iface["managedBy"] = json!(c.manager);
942 let mut source = None;
943 for c in &selected {
944 if iface["ipv4Method"] == "unknown" {
945 if let Some(v) = &c.v4 {
946 iface["ipv4Method"] = json!(v);
947 source.get_or_insert(&c.path);
948 }
949 }
950 if iface["ipv6Method"] == "unknown" {
951 if let Some(v) = &c.v6 {
952 iface["ipv6Method"] = json!(v);
953 source.get_or_insert(&c.path);
954 }
955 }
956 if let Some(k) = &c.kind {
957 iface["kind"] = json!(k);
958 }
959 if let Some(p) = &c.parent {
960 iface["parent"] = json!(p);
961 }
962 if let Some(v) = c.vlan {
963 iface["vlanId"] = json!(v);
964 }
965 }
966 iface["methodSource"] = json!(source);
967 let dns: BTreeSet<_> = selected
968 .iter()
969 .flat_map(|c| c.dns.iter().cloned())
970 .collect();
971 iface["dns"] = json!(dns);
972 } else {
973 let name = iface["name"].as_str().unwrap_or("");
974 if body(parts, "nm-devices").lines().any(|l| {
975 l.starts_with(&format!("{}:", name))
976 && !l.contains(":unmanaged:")
977 && !l.contains("(externally)")
978 }) {
979 iface["managedBy"] = json!("networkmanager");
980 } else if body(parts, "networkd").lines().any(|l| {
981 let w: Vec<_> = l.split_whitespace().collect();
982 w.len() > 4 && w[1] == name && w[4] == "configured"
983 }) {
984 iface["managedBy"] = json!(if info["manager"] == "netplan" {
985 "netplan"
986 } else {
987 "networkd"
988 });
989 }
990 }
991 let name = iface["name"].clone();
992 let mut defaults: Vec<_> = routes
993 .iter()
994 .filter(|r| r["device"] == name && r["default"] == true)
995 .collect();
996 defaults.sort_by_key(|r| r["metric"].as_u64().unwrap_or(0));
997 for family in [4, 6] {
998 if let Some(r) = defaults.iter().find(|r| r["family"] == family) {
999 iface[if family == 4 { "gateway4" } else { "gateway6" }] = r["gateway"].clone();
1000 if iface["metric"].is_null() {
1001 iface["metric"] = r["metric"].clone();
1004 let field = if family == 4 {
1005 "ipv4Method"
1006 } else {
1007 "ipv6Method"
1008 };
1009 if iface[field] == "unknown" {
1010 let addrs = iface["addresses"].as_array().cloned().unwrap_or_default();
1011 let global: Vec<_> = addrs
1012 .iter()
1013 .filter(|a| a["family"] == family && a["scope"] == "global")
1014 .collect();
1015 let dhcp = routes.iter().any(|r| {
1016 r["device"] == name && r["family"] == family && r["protocol"] == "dhcp"
1017 });
1018 let ra = routes
1019 .iter()
1020 .any(|r| r["device"] == name && r["family"] == family && r["protocol"] == "ra");
1021 let method = if dhcp && ra && family == 6 {
1022 "slaac-dhcpv6"
1023 } else if dhcp {
1024 if family == 4 {
1025 "dhcp"
1026 } else {
1027 "dhcpv6"
1029 } else if family == 6 && ra {
1030 "slaac"
1031 } else if family == 4 && global.iter().any(|a| a["dynamic"] == true) {
1032 "dhcp"
1033 } else if !global.is_empty() && global.iter().all(|a| a["dynamic"] == false) {
1034 "static"
1035 } else {
1036 "unknown"
1037 };
1038 iface[field] = json!(method);
1042 for c in configs {
1043 for member in &c.members {
1044 if let Some(v) = ifaces.iter_mut().find(|v| v["name"] == *member) {
1045 v["master"] = json!(c.name);
1046 if v["managedBy"].is_null() {
1047 v["managedBy"] = json!(c.manager);
1054fn dns(parts: &[(String, String)], ifaces: &mut [Value]) -> Value {
1055 let mut servers = Vec::<String>::new();
1056 let mut search = Vec::<String>::new();
1057 let mut link_name = None;
1058 let mut continuation = false;
1059 for line in body(parts, "resolved").lines() {
1060 let t = line.trim();
1061 if t.starts_with("Link ") {
1062 link_name = t
1063 .split_once('(')
1064 .and_then(|(_, r)| r.split_once(')'))
1065 .map(|(n, _)| n.to_owned());
1067 let (is_dns, values) = if let Some((key, value)) = t
1068 .split_once(": ")
1069 .or_else(|| t.strip_suffix(':').map(|k| (k, "")))
1071 continuation = key.trim() == "DNS Servers";
1072 (continuation, value.trim())
1073 } else {
1074 (continuation && !t.is_empty(), t)
1075 };
1076 if is_dns {
1077 for token in values.split_whitespace() {
1078 let addr = token.split('#').next().unwrap_or("");
1079 if addr.parse::<IpAddr>().is_err() {
1080 continue;
1082 push_unique(&mut servers, addr);
1083 if let Some(iface) = ifaces
1084 .iter_mut()
1085 .find(|i| Some(i["name"].as_str().unwrap_or("")) == link_name.as_deref())
1087 if let Some(a) = iface["dns"].as_array_mut() {
1088 if !a.iter().any(|v| v == addr) {
1089 a.push(json!(addr));
1095 if let Some((key, value)) = t.split_once(": ") {
1096 if key.trim() == "DNS Domain" {
1097 for s in value.split_whitespace() {
1098 if s != "." {
1099 push_unique(&mut search, s);
1105 let resolved = !servers.is_empty();
1106 for line in body(parts, "file:/etc/resolv.conf").lines().map(str::trim) {
1107 let w: Vec<_> = line.split_whitespace().collect();
1108 if w.len() >= 2 && w[0] == "nameserver" && !resolved {
1109 if w[1].parse::<IpAddr>().is_ok() {
1110 push_unique(&mut servers, w[1]);
1113 if w.len() >= 2 && matches!(w[0], "search" | "domain") {
1114 for s in &w[1..] {
1115 if *s != "." {
1116 push_unique(&mut search, s);
1121 json!({"servers": servers, "search": search, "source": if resolved { Some("resolved") } else if !body(parts, "file:/etc/resolv.conf").is_empty() { Some("/etc/resolv.conf") } else { None }})
1123fn push_unique(v: &mut Vec<String>, s: &str) {
1124 if !v.iter().any(|x| x == s) {
1125 v.push(s.to_owned());
1129fn listening(text: &str) -> Vec<Value> {
1130 text.lines().filter_map(|l| {
1131 let w: Vec<_> = l.split_whitespace().collect();
1132 if w.len() < 5 || !matches!(w[0], "tcp" | "udp" | "tcp6" | "udp6") { return None; }
1133 // netstat has the queues right after the protocol and the address one column earlier than ss.
1134 let netstat = w[1].parse::<u32>().is_ok();
1135 let (addr, port) = w[if netstat { 3 } else { 4 }].rsplit_once(':')?;
1136 let port = port.parse::<u16>().ok()?;
1137 Some(json!({"protocol": w[0], "address": addr.trim_matches(['[', ']']).split('%').next(), "port": port, "processes": listening_processes(l, netstat)}))
1138 }).collect()
1141/// Only root sees the programs of other users, so this may well be empty.
1142fn listening_processes(line: &str, netstat: bool) -> Vec<String> {
1143 let mut names: Vec<String> = Vec::new();
1144 if netstat {
1145 // "2654/sshd [listener] 0 of 10" on BusyBox, and the name may end in a colon.
1146 let owner = line.split_whitespace().filter_map(|word| word.split_once('/')).find(|(pid, name)| !pid.is_empty() && !name.is_empty() && pid.bytes().all(|byte| byte.is_ascii_digit()));
1147 if let Some((_, name)) = owner {
1148 names.push(name.trim_end_matches(':').to_string());
1150 } else if let Some((_, users)) = line.split_once("users:(") {
1151 for part in users.split("(\"").skip(1) {
1152 if let Some((name, _)) = part.split_once('"') {
1153 if !names.iter().any(|known| known == name) {
1154 names.push(name.to_string());
1159 names
1162fn ssh(text: &str, ifaces: &mut [Value]) -> Value {
1163 let w: Vec<_> = text.split_whitespace().collect();
1164 let mut result = json!({"clientAddress": null, "clientPort": null, "serverAddress": null, "serverPort": null, "family": null, "interface": null});
1165 if w.len() != 4 {
1166 return result;
1168 let (Ok(client), Ok(cport), Ok(server), Ok(sport)) = (
1169 w[0].parse::<IpAddr>(),
1170 w[1].parse::<u16>(),
1171 w[2].parse::<IpAddr>(),
1172 w[3].parse::<u16>(),
1173 ) else {
1174 return result;
1175 };
1176 result["clientAddress"] = json!(client.to_string());
1177 result["clientPort"] = json!(cport);
1178 result["serverAddress"] = json!(server.to_string());
1179 result["serverPort"] = json!(sport);
1180 result["family"] = json!(if server.is_ipv4() { 4 } else { 6 });
1181 for iface in ifaces {
1182 if iface["addresses"].as_array().is_some_and(|a| {
1183 a.iter().any(|a| {
1184 a["address"].as_str().and_then(|s| s.parse::<IpAddr>().ok()) == Some(server)
1185 })
1186 }) {
1187 iface["ssh"] = json!(true);
1188 result["interface"] = iface["name"].clone();
1189 break;
1192 result
1195fn inventory_findings(
1196 config: &Value,
1197 ifaces: &[Value],
1198 routes: &[Value],
1199 firewall: &Value,
1200) -> Vec<Value> {
1201 let mut findings = Vec::new();
1202 // Netplan and its renderer are one management stack, not competing managers.
1203 let managers = config["managers"].as_array().cloned().unwrap_or_default();
1204 let has_plan = managers.iter().any(|m| m["name"] == "netplan");
1205 let renderer = match config["renderer"].as_str() {
1206 Some("networkd") => "networkd",
1207 Some("NetworkManager") => "networkmanager",
1208 _ => "",
1209 };
1210 let independent = managers
1211 .iter()
1212 .filter(|m| !(has_plan && m["name"] == renderer))
1213 .count();
1214 if independent > 1 {
1215 findings.push(finding(
1216 "warning",
1217 "several-network-managers",
1218 json!({"managers": managers.iter().map(|m| m["name"].clone()).collect::<Vec<_>>()}),
1219 ));
1221 if !routes
1222 .iter()
1223 .any(|r| r["family"] == 4 && r["default"] == true)
1225 findings.push(finding("warning", "no-default-route", json!({})));
1227 if ifaces.iter().any(|i| {
1228 i["addresses"]
1229 .as_array()
1230 .is_some_and(|a| a.iter().any(|a| a["family"] == 6 && a["scope"] == "global"))
1231 }) && !routes
1232 .iter()
1233 .any(|r| r["family"] == 6 && r["default"] == true)
1235 findings.push(finding("warning", "no-default-route-v6", json!({})));
1237 for iface in ifaces {
1238 if iface["carrier"] == false && iface["kind"] != "loopback" {
1239 findings.push(finding(
1240 "warning",
1241 "interface-no-carrier",
1242 json!({"interface": iface["name"]}),
1243 ));
1246 if firewall["readable"] == false {
1247 findings.push(finding(
1248 "warning",
1249 "needs-root",
1250 json!({"component": "firewall"}),
1251 ));
1253 if let Some(f) = firewall["findings"].as_array() {
1254 findings.extend(f.iter().cloned());
1256 findings
1259pub fn parse(stdout: &str) -> Value {
1260 let parts = sections(stdout);
1261 let system = pairs(body(&parts, "system"));
1262 let uid = item(&system, "uid").parse::<u64>().ok();
1263 let tools = body(&parts, "tools");
1264 let ifup = if tools.lines().any(|s| s == "ifreload") {
1265 "ifupdown2"
1266 } else if tools.lines().any(|s| s == "ifupdown-ng")
1267 || parts.iter().any(|(_, s)| s.contains("ifupdown-ng"))
1269 "ifupdown-ng"
1270 } else {
1271 "ifupdown"
1272 };
1273 let configs = configs(&parts, ifup);
1274 let config = manager_info(&parts, &configs, ifup);
1275 let mut ifaces = interfaces(&parts);
1276 let routes = routes(&parts);
1277 apply_configs(&mut ifaces, &configs, &routes, &config, &parts);
1278 let dns = dns(&parts, &mut ifaces);
1279 let ssh = ssh(item(&system, "ssh"), &mut ifaces);
1280 let firewall = super::firewall::parse(
1281 &parts,
1282 ssh["serverPort"]
1283 .as_u64()
1284 .and_then(|n| u16::try_from(n).ok()),
1285 );
1286 let findings = inventory_findings(&config, &ifaces, &routes, &firewall);
1287 json!({"system": {"uid": uid, "root": uid.map(|u| u == 0), "hostname": scalar(item(&system, "hostname")), "kernel": scalar(item(&system, "kernel")),
1288 "osId": scalar(item(&system, "ID")), "osVersion": scalar(item(&system, "VERSION_ID")), "osName": scalar(item(&system, "PRETTY_NAME"))},
1289 "config": config, "interfaces": ifaces, "routes": routes, "dns": dns, "ssh": ssh,
1290 "listening": listening(body(&parts, "listening")), "firewall": firewall, "findings": findings})
1293#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 fn input(parts: &[(&str, &str)]) -> String {
1297 parts
1298 .iter()
1299 .map(|(n, b)| format!("\x1eCHJ_SECTION={}\n{}\n", n, b))
1300 .collect()
1302 fn eth(v: &Value) -> &Value {
1303 v["interfaces"]
1304 .as_array()
1305 .unwrap()
1306 .iter()
1307 .find(|i| i["name"] == "eth0")
1308 .unwrap()
1310 const ADDR: &str = r#"[{"ifindex":2,"ifname":"eth0","mtu":1500,"operstate":"UP","link_type":"ether","address":"52:54:00:00:00:01","addr_info":[{"family":"inet","local":"192.0.2.10","prefixlen":24,"scope":"global","dynamic":true,"valid_life_time":42000},{"family":"inet6","local":"2001:db8::10","prefixlen":64,"scope":"global","temporary":true}]}]"#;
1312 #[test]
1313 fn section_order_duplicates_and_boundaries() {
1314 assert_eq!(
1315 sections("noise\n\x1eCHJ_SECTION=a\r\nx\r\n\x1eCHJ_SECTION=a\ny\n\x1eCHJ_SECTION=b"),
1316 vec![
1317 ("a".into(), "x\r\n".into()),
1318 ("a".into(), "y\n".into()),
1319 ("b".into(), "".into())
1321 );
1322 assert!(sections("x\x1eCHJ_SECTION=a").is_empty());
1324 #[test]
1325 fn netplan_precedes_address_flags_and_generated_file() {
1326 let s = input(&[("addr-json", ADDR), ("tools", "netplan\nnetworkctl"), ("services", "systemd-networkd active enabled"),
1327 ("file:/etc/netplan/test.yaml", "network:\n version: 2\n renderer: networkd\n ethernets:\n eth0:\n dhcp4: false\n addresses:\n - 192.0.2.10/24\n - 2001:db8::10/64\n nameservers:\n addresses: [192.0.2.53, '2001:db8::53']"),
1328 ("file:/run/systemd/network/10-netplan-eth0.network", "[Match]\nName=eth0\n[Network]\nDHCP=ipv4")]);
1329 let v = parse(&s);
1330 let e = eth(&v);
1331 assert_eq!(e["ipv4Method"], "static");
1332 assert_eq!(e["ipv6Method"], "static");
1333 assert_eq!(e["methodSource"], "/etc/netplan/test.yaml");
1334 assert_eq!(e["dns"].as_array().unwrap().len(), 2);
1335 assert_eq!(v["config"]["renderer"], "networkd");
1336 assert!(!v["findings"]
1337 .as_array()
1338 .unwrap()
1339 .iter()
1340 .any(|f| f["code"] == "several-network-managers"));
1342 #[test]
1343 fn busybox_text_addresses_routes_and_lifetimes() {
1344 let s = input(&[("addr-text", "2: eth0: <BROADCAST,UP,LOWER_UP> mtu 1500 state UP\n link/ether 52:54:00:00:00:01 brd ff:ff:ff:ff:ff:ff\n inet 192.0.2.10/24 scope global secondary dynamic eth0\n valid_lft 300sec preferred_lft 200sec\n inet6 2001:db8::10/64 scope global temporary dynamic\n valid_lft forever preferred_lft forever"),
1345 ("route4-text", "default via 192.0.2.1 dev eth0 proto dhcp metric 42\n192.0.2.0/24 dev eth0 scope link"),
1346 ("route6-text", "default via 2001:db8::1 dev eth0 proto ra metric 100"),
1347 ("sys:eth0", "physical=1\ncarrier=0\nspeed=1000\nduplex=full\nmtu=1500\naddress=52:54:00:00:00:01\noperstate=down")]);
1348 let v = parse(&s);
1349 let e = eth(&v);
1350 assert_eq!(e["ipv4Method"], "dhcp");
1351 assert_eq!(e["ipv6Method"], "slaac");
1352 assert_eq!(e["gateway4"], "192.0.2.1");
1353 assert_eq!(e["metric"], 42);
1354 assert_eq!(e["state"], "down");
1355 assert_eq!(e["speedMbps"], 1000);
1356 assert_eq!(e["addresses"][0]["lifetimeValid"], 300);
1357 assert_eq!(e["addresses"][0]["secondary"], true);
1358 assert_eq!(e["addresses"][1]["temporary"], true);
1359 assert_eq!(e["addresses"][1]["lifetimeValid"], "forever");
1360 assert!(v["findings"]
1361 .as_array()
1362 .unwrap()
1363 .iter()
1364 .any(|f| f["code"] == "interface-no-carrier"));
1366 #[test]
1367 fn ssh_ipv6_and_missing_version() {
1368 let v = parse(&input(&[
1370 "system",
1371 "uid=1001\nID=arch\nssh=2001:db8::20 12345 2001:db8::10 2222",
1372 ),
1373 ("addr-json", ADDR),
1374 ]));
1375 assert_eq!(v["ssh"]["family"], 6);
1376 assert_eq!(v["ssh"]["interface"], "eth0");
1377 assert_eq!(eth(&v)["ssh"], true);
1378 assert!(v["system"]["osVersion"].is_null());
1379 assert_eq!(v["system"]["root"], false);
1381 #[test]
1382 fn networkd_and_nm_match_mac() {
1383 for (path, config, expected) in [
1384 ("/etc/systemd/network/test.network", "[Match]\nMACAddress=52:54:00:00:00:01\n[Network]\nDHCP=ipv6\nIPv6AcceptRA=no\nAddress=192.0.2.10/24", "networkd"),
1385 ("/etc/NetworkManager/system-connections/test.nmconnection", "[connection]\ntype=ethernet\n[ethernet]\nmac-address=52:54:00:00:00:01\n[ipv4]\nmethod=manual\n[ipv6]\nmethod=dhcp", "networkmanager"),
1386 ] {
1387 let v = parse(&input(&[("addr-json", ADDR), (&format!("file:{path}"), config)]));
1388 assert_eq!(eth(&v)["ipv4Method"], "static"); assert_eq!(eth(&v)["ipv6Method"], "dhcpv6"); assert_eq!(eth(&v)["managedBy"], expected);
1391 #[test]
1392 fn bridge_vlan_and_ifupdown2_ownership() {
1393 let v = parse(&input(&[("addr-json", ADDR), ("addr-text", "3: vmbr0: <UP> mtu 1500 state DOWN\n4: eth0.42@eth0: <UP> mtu 1500 state DOWN"),
1394 ("tools", "ifup\nifreload\nnetplan\nnetworkctl"), ("services", "networking active enabled\nsystemd-networkd active enabled"),
1395 ("file:/etc/network/interfaces", "iface eth0 inet manual\nauto vmbr0\niface vmbr0 inet static\n address 192.0.2.11/24\n bridge-ports eth0\niface eth0.42 inet manual\n vlan-raw-device eth0"),
1396 ("file:/etc/netplan/test.yaml", "network:\n ethernets:\n eth0:\n dhcp4: true")]));
1397 let list = v["interfaces"].as_array().unwrap();
1398 assert_eq!(v["config"]["manager"], "ifupdown2");
1399 assert_eq!(eth(&v)["managedBy"], "netplan");
1400 assert_eq!(eth(&v)["master"], "vmbr0");
1401 let bridge = list.iter().find(|i| i["name"] == "vmbr0").unwrap();
1402 assert_eq!(bridge["managedBy"], "ifupdown2");
1403 assert_eq!(bridge["kind"], "bridge");
1404 assert_eq!(
1405 list.iter().find(|i| i["name"] == "eth0.42").unwrap()["parent"],
1406 "eth0"
1407 );
1409 #[test]
1410 fn listening_names_the_programs_from_ss_and_netstat() {
1411 let v = parse(&input(&[("addr-json", ADDR), ("listening", "Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process\ntcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:((\"nginx\",pid=12,fd=6),(\"nginx\",pid=11,fd=6))\ntcp LISTEN 0 511 *:8080 *:* users:((\"apache2\",pid=20,fd=4),(\"httpd\",pid=21,fd=4))\ntcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\ntcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 33/nginx\nudp 0 0 :::53 :::* 40/dnsmasq:\ntcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 2654/sshd [listener\ntcp 0 0 0.0.0.0:9 0.0.0.0:* LISTEN -")]));
1412 let list = v["listening"].as_array().unwrap();
1413 assert_eq!(list[0]["port"], 80);
1414 assert_eq!(list[0]["processes"], json!(["nginx"]));
1415 assert_eq!(list[1]["processes"], json!(["apache2", "httpd"]));
1416 assert_eq!(list[2]["processes"], json!([]));
1417 assert_eq!((&list[3]["port"], &list[3]["processes"]), (&json!(443), &json!(["nginx"])));
1418 assert_eq!((&list[4]["port"], &list[4]["processes"]), (&json!(53), &json!(["dnsmasq"])));
1419 assert_eq!(list[5]["processes"], json!(["sshd"]));
1420 assert_eq!(list[6]["processes"], json!([]));
1422 #[test]
1423 fn resolved_uses_uplink_and_listening_handles_ipv6() {
1424 let v = parse(&input(&[("addr-json", ADDR), ("file:/etc/resolv.conf", "nameserver 127.0.0.53\nsearch example.test"),
1425 ("resolved", "Link 2 (eth0)\n DNS Servers: 192.0.2.53\n 2001:db8::53\n DNS Domain: example.test"),
1426 ("listening", "Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\nudp UNCONN 0 0 192.0.2.10%eth0:68 0.0.0.0:*\ntcp LISTEN 0 128 [::]:2222 [::]:*\ntcp LISTEN 0 128 *:9090 *:*")]));
1427 assert_eq!(v["dns"]["servers"], json!(["192.0.2.53", "2001:db8::53"]));
1428 assert_eq!(eth(&v)["dns"].as_array().unwrap().len(), 2);
1429 assert_eq!(v["listening"][1]["address"], "::");
1430 assert_eq!(v["listening"][1]["port"], 2222);
1432 #[test]
1433 fn hostile_input_and_nulls() {
1434 for s in [
1435 "".into(),
1436 "garbage".into(),
1437 input(&[
1438 ("addr-json", "[{\"ifname\":"),
1439 ("link-json", "null"),
1440 ("route4-json", "[null,3,{}]"),
1441 ]),
1442 "x".repeat(1_000_000),
1443 input(&[
1444 ("addr-text", &"é".repeat(100_000)),
1445 ("system", "uid=invalid\nssh=broken"),
1446 ]),
1447 ] {
1448 let v = parse(&s);
1449 assert!(v.is_object());
1450 assert!(v["interfaces"].is_array());
1452 let v = parse("");
1453 assert!(v["system"]["uid"].is_null());
1454 assert!(v["ssh"]["interface"].is_null());
1455 assert_eq!(v["config"]["manager"], "unknown");
SHA-256b93c0ebb181573a688cfe76795a9cda9c000b17801399f62e87f01dfeec9121e

CH-J Proprietary Software License 1.14 · 소스는 CH-J Proprietary Software License 1.14에 따라 제공됩니다. 소스를 공개한다고 해서 라이선스 조항이 바뀌거나 추가 권리가 부여되지는 않습니다.

압축 파일 SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
파일