diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index dd9f4a03..2337ce68 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -558,6 +558,11 @@ impl RpcHandler { self.handle_fips_remove_seed_anchor(&p).await } "fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await, + "fips.ssh-over-mesh.get" => self.handle_fips_ssh_over_mesh_get().await, + "fips.ssh-over-mesh.set" => { + let p = params.unwrap_or(serde_json::json!({})); + self.handle_fips_ssh_over_mesh_set(&p).await + } // System updates "update.check" => self.handle_update_check().await, diff --git a/core/archipelago/src/api/rpc/fips.rs b/core/archipelago/src/api/rpc/fips.rs index 7b2fbed5..03ee6616 100644 --- a/core/archipelago/src/api/rpc/fips.rs +++ b/core/archipelago/src/api/rpc/fips.rs @@ -261,4 +261,51 @@ impl RpcHandler { }).collect::>(), })) } + + /// The SSH-over-mesh toggle state plus sshd preflights (the card explains + /// the rule instead of gating on it — see ssh_mesh.rs). + pub(super) async fn handle_fips_ssh_over_mesh_get(&self) -> Result { + let state = fips::ssh_mesh::load(&self.config.data_dir).await; + let preflights = fips::ssh_mesh::preflights().await; + Ok(serde_json::json!({ + "enabled": state.enabled, + "sources": state.sources, + "scope": if state.sources.is_empty() { "any" } else { "list" }, + "preflights": preflights, + })) + } + + /// Set the toggle. Params: `{ enabled: bool, sources?: string[] }` — + /// an empty/absent source list opens port 22 to every mesh peer (the UI + /// confirms that explicitly before calling with it). + pub(super) async fn handle_fips_ssh_over_mesh_set( + &self, + params: &serde_json::Value, + ) -> Result { + let enabled = params + .get("enabled") + .and_then(|v| v.as_bool()) + .ok_or_else(|| anyhow::anyhow!("missing boolean 'enabled'"))?; + let sources: Vec = params + .get("sources") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|s| s.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let (state, outcome) = + fips::ssh_mesh::set(&self.config.data_dir, enabled, &sources).await?; + let preflights = fips::ssh_mesh::preflights().await; + Ok(serde_json::json!({ + "enabled": state.enabled, + "sources": state.sources, + "scope": if state.sources.is_empty() { "any" } else { "list" }, + "applied": outcome.applied, + "removed": outcome.removed, + "reloaded": outcome.reloaded, + "preflights": preflights, + })) + } } diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index e6bd40a1..a60f192e 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -141,6 +141,16 @@ impl DockerPackageScanner { // Get metadata for this app let metadata = get_app_metadata(&app_id); + // Manifest-owned metadata (icon) wins over the static table: the + // manifest is what the catalog signed and what the App Store shows, + // so it is also what an installed tile must render. + let manifest_icon = real_manifest_metadata(&app_id) + .and_then(|m| { + m.get("icon") + .and_then(|v| v.as_str()) + .map(str::to_string) + }) + .filter(|s| !s.trim().is_empty()); // Resolve UI address: separate UI containers > static map > dynamic ports let lan_address = if app_id == "netbird" { @@ -191,7 +201,7 @@ impl DockerPackageScanner { static_files: StaticFiles { license: "MIT".to_string(), instructions: metadata.description.clone(), - icon: metadata.icon.clone(), + icon: manifest_icon.unwrap_or_else(|| metadata.icon.clone()), }, manifest: Manifest { id: app_id.clone(), @@ -211,28 +221,34 @@ impl DockerPackageScanner { author: Some("Archipelago".to_string()), website: lan_address.clone(), tier: Some(metadata.tier.to_string()), - interfaces: if lan_address.is_some() || tor_address.is_some() { + interfaces: { // `ui` is no longer implied by a published port: a // headless backend with an exposed port is a service, // not a launchable app. ui_detection consults the // manifest declaration first, then HTTP-probes the - // port. Addresses stay present either way so the - // Services tab can still show where a backend lives. + // port. A DECLARED UI classifies the app as launchable + // even when no reachable address was confirmed this + // scan — the launch button falls back to the static + // port map, and burying a manifest-declared UI app + // (Alby Hub) in Services because a probe missed was + // exactly the classification bug this fixes. let has_ui = super::ui_detection::has_web_ui( &app_id, lan_address.as_deref(), package_state == PackageState::Running, ) .await; - Some(Interfaces { - main: Some(MainInterface { - ui: has_ui.then(|| "true".to_string()), - tor_config: tor_address.clone(), - lan_config: None, - }), - }) - } else { - None + if lan_address.is_some() || tor_address.is_some() || has_ui { + Some(Interfaces { + main: Some(MainInterface { + ui: has_ui.then(|| "true".to_string()), + tor_config: tor_address.clone(), + lan_config: None, + }), + }) + } else { + None + } }, }, available_update, @@ -322,6 +338,43 @@ fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool { && right.chars().all(|c| c.is_ascii_lowercase()) } +/// Raw `metadata` block of an installed app's real manifest — catalog overlay +/// first (origin-wins), disk manifest as fallback. Kept as raw JSON because +/// the typed `AppManifest` deliberately does not model `metadata`, yet its +/// `icon` is what makes an installed app's tile render the right icon on +/// every surface (My Apps, Services, launcher, companion) instead of the +/// generic A-mark — the exact regression Cuprate exposed on install. +fn real_manifest_metadata(app_id: &str) -> Option { + for (id, value) in crate::container::app_catalog::catalog_manifest_values() { + if id == app_id { + return value.get("app").and_then(|a| a.get("metadata")).cloned(); + } + } + let mut candidates = Vec::new(); + if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") { + candidates.push( + std::path::PathBuf::from(dir) + .join("../apps") + .join(app_id) + .join("manifest.yml"), + ); + } + candidates.push( + std::path::PathBuf::from("/opt/archipelago/apps") + .join(app_id) + .join("manifest.yml"), + ); + for path in candidates { + let Ok(content) = std::fs::read_to_string(&path) else { continue }; + let Ok(value) = serde_yaml::from_str::(&content) else { continue }; + let meta = value.get("app").and_then(|a| a.get("metadata")).cloned(); + if meta.is_some() { + return meta; + } + } + None +} + fn get_app_metadata(app_id: &str) -> AppMetadata { let mut meta = match app_id { "bitcoin-core" => AppMetadata { diff --git a/core/archipelago/src/fips/config.rs b/core/archipelago/src/fips/config.rs index 01127b91..feddd7fb 100644 --- a/core/archipelago/src/fips/config.rs +++ b/core/archipelago/src/fips/config.rs @@ -305,6 +305,14 @@ pub async fn install(identity_dir: &Path) -> Result<()> { } } + // SSH-over-mesh rides every config install so the on-state survives + // upgrades, reconnects, and the startup self-heal (see ssh_mesh.rs — + // this module owns the 90-ssh.nft slot exclusively). + let ssh_data_dir = identity_dir.parent().unwrap_or(identity_dir); + if let Err(e) = super::ssh_mesh::reconcile(ssh_data_dir).await { + tracing::warn!("ssh-over-mesh reconcile after config install failed (non-fatal): {e:#}"); + } + sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?; // Heal a legacy fips_key.pub that was written as bech32 npub text // (pre-fix identity::write_fips_key_from_seed did this). Upstream diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index 7f367c3c..52a9da6b 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -32,6 +32,7 @@ pub mod dial; pub mod endpoints; pub mod iface; pub mod service; +pub mod ssh_mesh; pub mod telemetry; pub mod update; diff --git a/core/archipelago/src/fips/ssh_mesh.rs b/core/archipelago/src/fips/ssh_mesh.rs new file mode 100644 index 00000000..a8cdfb35 --- /dev/null +++ b/core/archipelago/src/fips/ssh_mesh.rs @@ -0,0 +1,462 @@ +//! SSH over the FIPS mesh — a first-class settings toggle. +//! +//! `fips0` is default-deny inbound: the hardening baseline (`/etc/fips/ +//! fips.nft`) rejects un-allowlisted ports, and the daemon's own drop-ins +//! (`80-web-ui.nft`, `85-app-ports.nft`) do not include 22. That is correct +//! by default — but the user asked to be able to SSH their node from Termux +//! over the phone's FIPS mesh instead of keeping a second VPN around for it, +//! and the mesh path already works end-to-end (verified live: the connect +//! reaches fips0 and gets a RST from the node). +//! +//! This module owns the whole lifecycle of the `90-ssh.nft` drop-in, exactly +//! the way `config.rs` owns `80-web-ui.nft` — a hand-added rule and this +//! feature can never fight over the same slot: +//! +//! * toggle OFF → drop-in removed, port 22 refused again +//! * toggle ON → drop-in written on every toggle change AND on every +//! daemon config install (upgrade, reconnect, self-heal), +//! so the on-state survives reinstalls idempotently +//! * scope → "any" (every mesh peer — a real exposure, gated in the +//! UI behind an explicit confirmation) or an explicit list +//! of mesh addresses +//! +//! Nothing else is touched: `80-web-ui.nft` / `85-app-ports.nft` belong to +//! `config.rs`, and the sshd process itself is entirely the operator's. + +use std::net::Ipv6Addr; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +/// On-disk state under the archipelago data dir. Absent file = disabled, +/// which is the safe default for every node that never touched the toggle. +const STATE_FILE: &str = "fips-ssh-over-mesh.json"; + +/// The drop-in slot this module owns. 90 sorts after the daemon's own +/// drop-ins (80/85) so a human reading the directory sees the deliberate +/// order; the include order does not change semantics for plain accepts. +pub const DROPIN_PATH: &str = "/etc/fips/fips.d/90-ssh.nft"; + +/// The hardening baseline this drop-in hangs off. Same file `config.rs` +/// reloads after its own drop-ins. +const FIPS_NFT: &str = "/etc/fips/fips.nft"; + +/// Persisted toggle state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SshMeshState { + /// Whether port 22 is allowed through the fips0 baseline at all. + #[serde(default)] + pub enabled: bool, + /// Mesh addresses (ULAs) the rule is restricted to. Empty = any mesh + /// peer. Kept as strings as-entered but validated as IPv6 on save. + #[serde(default)] + pub sources: Vec, +} + +fn state_path(data_dir: &Path) -> std::path::PathBuf { + data_dir.join(STATE_FILE) +} + +/// Load the persisted state. Missing file = disabled, no sources — never an +/// error, so a fresh node and a deleted file both mean "off". +pub async fn load(data_dir: &Path) -> SshMeshState { + match tokio::fs::read_to_string(state_path(data_dir)).await { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => SshMeshState::default(), + } +} + +/// Validate and normalise an operator-supplied source list. Every entry must +/// be a parseable IPv6 address (mesh addresses are full ULAs, not CIDRs) — +/// anything else is refused with the offending entry named, so a typo can +/// never silently narrow or widen the rule. +pub fn validate_sources(raw: &[String]) -> Result> { + let mut out = Vec::with_capacity(raw.len()); + for entry in raw { + let trimmed = entry.trim(); + if trimmed.is_empty() { + continue; + } + let addr: Ipv6Addr = trimmed + .parse() + .with_context(|| format!("not a valid mesh (IPv6) address: {trimmed:?}"))?; + out.push(addr.to_string()); + } + out.dedup(); + Ok(out) +} + +/// Render the nft drop-in for a state. The rule shape mirrors the interim +/// manual unblock from the field notes (`ip6 saddr tcp dport 22 +/// accept`) — an unrestricted rule is the same statement without the saddr. +pub fn render_dropin(state: &SshMeshState) -> String { + let mut out = String::from( + "# Written by archipelago — SSH over mesh (Settings → SSH over mesh).\n\ + # Allows sshd (port 22) through the fips0 default-deny inbound\n\ + # baseline. Remove = refused again; never edit 80/85-* by hand.\n", + ); + if state.sources.is_empty() { + out.push_str("tcp dport 22 accept\n"); + } else { + out.push_str(&format!( + "ip6 saddr {{ {} }} tcp dport 22 accept\n", + state.sources.join(", ") + )); + } + out +} + +/// Write or remove the drop-in to match the persisted state, then reload the +/// baseline so the change is live immediately. Returns whether a reload was +/// attempted and succeeded — a node without the hardening baseline has +/// nothing to reload (port 22 is governed by sshd and the host firewall +/// there), which is reported rather than treated as failure. +pub async fn reconcile(data_dir: &Path) -> Result { + let state = load(data_dir).await; + + if !state.enabled { + let removed = remove_dropin().await?; + let reloaded = reload_nft().await; + return Ok(ReconcileOutcome { + applied: false, + removed, + reloaded, + }); + } + + // Ensure /etc/fips/fips.d exists, exactly like config::install. + let out = Command::new("sudo") + .args(["install", "-d", "-m", "0755", "/etc/fips/fips.d"]) + .output() + .await + .context("sudo install -d /etc/fips/fips.d")?; + if !out.status.success() { + anyhow::bail!( + "sudo install -d /etc/fips/fips.d failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + + let dropin = render_dropin(&state); + let stage = std::env::temp_dir().join(format!("fips-ssh-{}.nft", std::process::id())); + tokio::fs::write(&stage, &dropin) + .await + .context("stage ssh nft drop-in")?; + let install = Command::new("sudo") + .args(["install", "-m", "0644"]) + .arg(&stage) + .arg(DROPIN_PATH) + .output() + .await; + let _ = tokio::fs::remove_file(&stage).await; + let install = install?; + if !install.status.success() { + anyhow::bail!( + "install {} failed: {}", + DROPIN_PATH, + String::from_utf8_lossy(&install.stderr).trim() + ); + } + + let reloaded = reload_nft().await; + Ok(ReconcileOutcome { + applied: true, + removed: false, + reloaded, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReconcileOutcome { + /// The allow rule is in place. + pub applied: bool, + /// A previously-written drop-in was removed this call. + pub removed: bool, + /// The hardening baseline existed and `nft -f` succeeded. + pub reloaded: bool, +} + +async fn remove_dropin() -> Result { + match tokio::fs::try_exists(DROPIN_PATH).await { + Ok(true) => {} + _ => return Ok(false), + } + let out = Command::new("sudo") + .args(["rm", "-f", DROPIN_PATH]) + .output() + .await + .context("sudo rm 90-ssh.nft")?; + if !out.status.success() { + anyhow::bail!( + "removing {} failed: {}", + DROPIN_PATH, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + tracing::info!("ssh-over-mesh: drop-in removed — port 22 refused over fips0 again"); + Ok(true) +} + +/// Reload the hardening baseline. Best-effort in the same spirit as +/// `config.rs`: absent baseline (nothing to reload) → Ok(false); a failed +/// reload is Ok(false) with a warn, never an error — the drop-in is on disk +/// either way and the next daemon install reloads it. +async fn reload_nft() -> bool { + match tokio::fs::try_exists(FIPS_NFT).await { + Ok(true) => {} + _ => return false, + } + match Command::new("sudo").args(["nft", "-f", FIPS_NFT]).output().await { + Ok(out) if out.status.success() => true, + Ok(out) => { + tracing::warn!( + "ssh-over-mesh: nft reload failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + false + } + Err(e) => { + tracing::warn!("ssh-over-mesh: nft reload failed: {e}"); + false + } + } +} + +/// Persist new state and reconcile immediately. Validation happens here so +/// an invalid source list can never reach disk, and reconcile reads back +/// exactly what was saved. +pub async fn set(data_dir: &Path, enabled: bool, sources: &[String]) -> Result<(SshMeshState, ReconcileOutcome)> { + let state = SshMeshState { + enabled, + sources: validate_sources(sources)?, + }; + tokio::fs::create_dir_all(data_dir) + .await + .with_context(|| format!("mkdir -p {}", data_dir.display()))?; + tokio::fs::write(state_path(data_dir), serde_json::to_string_pretty(&state)?) + .await + .with_context(|| format!("write {}", state_path(data_dir).display()))?; + let outcome = reconcile(data_dir).await?; + Ok((state, outcome)) +} + +/// Preflights surfaced in the settings card. None of these gate the toggle — +/// they explain it: writing the rule on a node whose sshd doesn't listen on +/// IPv6 simply has no effect until sshd does, and the card says so instead of +/// the user discovering it as a silent connection failure. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SshPreflights { + /// ssh.service (or sshd.service) is active. + pub sshd_active: bool, + /// Something listens on :22 for IPv6 (`[::]:22` or a dual-stack `*:22`). + /// fips0 is IPv6-only, so a 0.0.0.0-bound sshd is unreachable over it. + pub sshd_ipv6_listen: bool, + /// sshd_config's PasswordAuthentication (last directive wins, includes + /// after the main file). None = not found / unreadable. + pub password_auth: Option, +} + +pub async fn preflights() -> SshPreflights { + SshPreflights { + sshd_active: sshd_active().await, + sshd_ipv6_listen: sshd_ipv6_listen().await, + password_auth: password_auth_enabled().await, + } +} + +async fn sshd_active() -> bool { + for unit in ["ssh", "sshd"] { + if let Ok(out) = Command::new("systemctl").args(["is-active", "--quiet", unit]).output().await { + if out.status.success() { + return true; + } + } + } + false +} + +async fn sshd_ipv6_listen() -> bool { + let Ok(out) = Command::new("ss").args(["-H", "-tln"]).output().await else { + return false; + }; + let text = String::from_utf8_lossy(&out.stdout); + text.lines().any(|line| { + let mut cols = line.split_whitespace(); + // -t -l: State Recv-Q Send-Q Local:Port Peer:Port → local is col 4. + let _state = cols.next(); + let _recv = cols.next(); + let _send = cols.next(); + match cols.next() { + Some(local) => { + let port_ok = local.rsplit(':').next() == Some("22"); + let v6 = local.starts_with("[::]") || local.starts_with('*'); + port_ok && v6 + } + None => false, + } + }) +} + +async fn password_auth_enabled() -> Option { + let mut directives: Vec = Vec::new(); + if let Ok(main) = tokio::fs::read_to_string("/etc/ssh/sshd_config").await { + collect_password_auth(&main, &mut directives); + } + if let Ok(includes) = glob_sorted("/etc/ssh/sshd_config.d/*.conf").await { + for path in includes { + if let Ok(content) = tokio::fs::read_to_string(&path).await { + collect_password_auth(&content, &mut directives); + } + } + } + directives.pop() +} + +fn collect_password_auth(content: &str, out: &mut Vec) { + for line in content.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("PasswordAuthentication") { + let rest = rest.trim_start(); + let value = rest.split_whitespace().next().unwrap_or(""); + if value.eq_ignore_ascii_case("yes") { + out.push(true); + } else if value.eq_ignore_ascii_case("no") { + out.push(false); + } + } + } +} + +async fn glob_sorted(pattern: &str) -> Result> { + let dir = std::path::Path::new(pattern).parent().unwrap_or_else(|| Path::new("/")); + let prefix = std::path::Path::new(pattern) + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.split('.').next()) + .unwrap_or("") + .to_string(); + let mut files: Vec = Vec::new(); + let mut entries = tokio::fs::read_dir(dir).await.context("read sshd_config.d")?; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(&prefix) && name.ends_with(".conf") { + files.push(entry.path()); + } + } + files.sort(); + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_is_the_default_and_missing_file_is_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let state = tokio::runtime::Runtime::new().unwrap().block_on(load(dir.path())); + assert!(!state.enabled); + assert!(state.sources.is_empty()); + } + + #[test] + fn any_peer_dropin_is_an_unrestricted_accept() { + let state = SshMeshState { enabled: true, sources: vec![] }; + let out = render_dropin(&state); + assert!(out.contains("tcp dport 22 accept")); + assert!(!out.contains("ip6 saddr"), "no saddr restriction expected"); + } + + #[test] + fn source_list_dropin_restricts_to_those_addresses() { + let state = SshMeshState { + enabled: true, + sources: vec![ + "fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(), + "fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824".to_string(), + ], + }; + let out = render_dropin(&state); + assert!(out.contains("ip6 saddr { fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586, fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824 } tcp dport 22 accept")); + } + + #[test] + fn sources_must_be_ipv6_and_are_normalised() { + let bad = validate_sources(&["192.168.1.5".to_string()]).unwrap_err(); + assert!(bad.to_string().contains("192.168.1.5")); + + let bad = validate_sources(&["not-an-address".to_string()]).unwrap_err(); + assert!(bad.to_string().contains("not-an-address")); + + // Uppercase/whitespace entries normalise to canonical lowercase. + let ok = validate_sources(&[ + " FD68:496D:FE34:A06D:0CF1:06E4:B6A4:3586 ".to_string(), + "fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(), + String::new(), + ]) + .unwrap(); + assert_eq!(ok, vec!["fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string()]); + } + + #[test] + fn state_round_trips_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let state = SshMeshState { + enabled: true, + sources: vec!["fd00::1".to_string()], + }; + std::fs::write(dir.path().join(STATE_FILE), serde_json::to_string(&state).unwrap()).unwrap(); + let loaded = tokio::runtime::Runtime::new().unwrap().block_on(load(dir.path())); + assert_eq!(loaded, state); + } + + #[test] + fn set_validates_before_persisting() { + let dir = tempfile::tempdir().unwrap(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let err = rt + .block_on(set(dir.path(), true, &["bogus".to_string()])) + .unwrap_err(); + assert!(err.to_string().contains("bogus")); + // Nothing was persisted. + let state = rt.block_on(load(dir.path())); + assert!(!state.enabled); + } + + #[test] + fn preflight_parse_helpers_cover_the_directives() { + let mut directives = Vec::new(); + collect_password_auth( + "# comment\nPasswordAuthentication yes\nMatch all\n PasswordAuthentication no\n", + &mut directives, + ); + assert_eq!(directives, vec![true, false]); + } + + #[test] + fn sshd_ipv6_listen_recognises_dual_stack_and_v6_only() { + assert!(line_listens("[::]:22")); + assert!(line_listens("*:22")); + assert!(!line_listens("0.0.0.0:22")); + assert!(!line_listens("[::]:80")); + } + + fn line_listens(local: &str) -> bool { + let line = format!("LISTEN 0 128 {local} 0.0.0.0:*"); + let mut cols = line.split_whitespace(); + cols.next(); + cols.next(); + cols.next(); + match cols.next() { + Some(l) => { + let port_ok = l.rsplit(':').next() == Some("22"); + let v6 = l.starts_with("[::]") || l.starts_with('*'); + port_ok && v6 + } + None => false, + } + } +}