From cfbace1d91ef4ddc4853a5e7271472eb921bcac1 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 16 Feb 2026 19:22:07 -0700 Subject: [PATCH] fix: add CONNMARK restore-mark to mangle OUTPUT chain The CONNMARK --restore-mark rule was only in PREROUTING, which handles forwarded packets. Locally-bound listeners (e.g. vhost) generate replies through the OUTPUT chain, where the fwmark was never restored. This caused response packets to route via the default table instead of back through the originating interface. --- core/src/db/model/public.rs | 9 ++ core/src/lib.rs | 7 + core/src/net/gateway.rs | 153 ++++++++++++++---- core/src/system/mod.rs | 22 +++ .../addresses/addresses.component.ts | 3 +- .../public-domains/dns.component.ts | 4 +- .../app/services/api/embassy-api.service.ts | 7 +- .../services/api/embassy-live-api.service.ts | 7 +- .../services/api/embassy-mock-api.service.ts | 9 +- 9 files changed, 176 insertions(+), 45 deletions(-) diff --git a/core/src/db/model/public.rs b/core/src/db/model/public.rs index d58caac8c..c66ff8681 100644 --- a/core/src/db/model/public.rs +++ b/core/src/db/model/public.rs @@ -13,6 +13,7 @@ use openssl::hash::MessageDigest; use patch_db::{HasModel, Value}; use serde::{Deserialize, Serialize}; use ts_rs::TS; +use url::Url; use crate::account::AccountInfo; use crate::db::DbAccessByKey; @@ -143,6 +144,7 @@ impl Public { zram: true, governor: None, smtp: None, + ifconfig_url: default_ifconfig_url(), ram: 0, devices: Vec::new(), kiosk, @@ -164,6 +166,10 @@ fn get_platform() -> InternedString { (&*PLATFORM).into() } +pub fn default_ifconfig_url() -> Url { + "https://ifconfig.co".parse().unwrap() +} + #[derive(Debug, Deserialize, Serialize, HasModel, TS)] #[serde(rename_all = "camelCase")] #[model = "Model"] @@ -200,6 +206,9 @@ pub struct ServerInfo { pub zram: bool, pub governor: Option, pub smtp: Option, + #[serde(default = "default_ifconfig_url")] + #[ts(type = "string")] + pub ifconfig_url: Url, #[ts(type = "number")] pub ram: u64, pub devices: Vec, diff --git a/core/src/lib.rs b/core/src/lib.rs index d7cfc79b4..7fc1853e3 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -377,6 +377,13 @@ pub fn server() -> ParentHandler { "host", net::host::server_host_api::().with_about("about.commands-host-system-ui"), ) + .subcommand( + "set-ifconfig-url", + from_fn_async(system::set_ifconfig_url) + .no_display() + .with_about("about.set-ifconfig-url") + .with_call_remote::(), + ) .subcommand( "set-keyboard", from_fn_async(system::set_keyboard) diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index 81a749d18..73d092080 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -20,6 +20,7 @@ use tokio::net::TcpListener; use tokio::process::Command; use tokio::sync::oneshot; use ts_rs::TS; +use url::Url; use visit_rs::{Visit, VisitFields}; use zbus::proxy::{PropertyChanged, PropertyStream, SignalStream}; use zbus::zvariant::{ @@ -110,6 +111,13 @@ pub fn gateway_api() -> ParentHandler { .with_about("about.rename-gateway") .with_call_remote::(), ) + .subcommand( + "check-port", + from_fn_async(check_port) + .with_display_serializable() + .with_about("about.check-port-reachability") + .with_call_remote::(), + ) } async fn list_interfaces( @@ -148,6 +156,73 @@ async fn set_name( ctx.net_controller.net_iface.set_name(&id, name).await } +#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +struct CheckPortParams { + #[arg(help = "help.arg.port")] + port: u16, + #[arg(help = "help.arg.gateway-id")] + gateway: GatewayId, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct CheckPortRes { + pub ip: Ipv4Addr, + pub port: u16, + pub reachable: bool, +} + +async fn check_port( + ctx: RpcContext, + CheckPortParams { port, gateway }: CheckPortParams, +) -> Result { + let db = ctx.db.peek().await; + let base_url = db + .as_public() + .as_server_info() + .as_ifconfig_url() + .de()?; + let gateways = db + .as_public() + .as_server_info() + .as_network() + .as_gateways() + .de()?; + let gw_info = gateways.get(&gateway).ok_or_else(|| { + Error::new( + eyre!("unknown gateway: {gateway}"), + ErrorKind::NotFound, + ) + })?; + let ip_info = gw_info.ip_info.as_ref().ok_or_else(|| { + Error::new( + eyre!("gateway {gateway} has no IP info"), + ErrorKind::NotFound, + ) + })?; + let iface = &*ip_info.name; + + let client = reqwest::Client::builder(); + #[cfg(target_os = "linux")] + let client = client.interface(iface); + let url = base_url + .join(&format!("/port/{port}")) + .with_kind(ErrorKind::ParseUrl)?; + let res: CheckPortRes = client + .build()? + .get(url) + .timeout(Duration::from_secs(10)) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(res) +} + #[proxy( interface = "org.freedesktop.NetworkManager", default_service = "org.freedesktop.NetworkManager", @@ -371,6 +446,7 @@ impl<'a> StubStream<'a> for SignalStream<'a> { async fn watcher( watch_ip_info: Watch>, watch_activation: Watch>, + db: Option>, ) { loop { let res: Result<(), Error> = async { @@ -444,6 +520,7 @@ async fn watcher( device_proxy.clone(), iface.clone(), &watch_ip_info, + db.as_ref(), ))); ifaces.insert(iface); } @@ -474,33 +551,34 @@ async fn watcher( } } -async fn get_wan_ipv4(iface: &str) -> Result, Error> { +async fn get_wan_ipv4(iface: &str, base_url: &Url) -> Result, Error> { let client = reqwest::Client::builder(); #[cfg(target_os = "linux")] let client = client.interface(iface); - Ok(client + let url = base_url.join("/ip").with_kind(ErrorKind::ParseUrl)?; + let text = client .build()? - .get("https://ip4only.me/api/") + .get(url) .timeout(Duration::from_secs(10)) .send() .await? .error_for_status()? .text() - .await? - .split(",") - .skip(1) - .next() - .filter(|s| !s.is_empty()) - .map(|s| s.parse()) - .transpose()?) + .await?; + let trimmed = text.trim(); + if trimmed.is_empty() { + return Ok(None); + } + Ok(Some(trimmed.parse()?)) } -#[instrument(skip(connection, device_proxy, write_to))] +#[instrument(skip(connection, device_proxy, write_to, db))] async fn watch_ip( connection: &Connection, device_proxy: device::DeviceProxy<'_>, iface: GatewayId, write_to: &Watch>, + db: Option<&TypedPatchDb>, ) -> Result<(), Error> { let mut until = Until::new() .with_stream( @@ -761,24 +839,28 @@ async fn watch_ip( .log_err(); } - // Ensure global CONNMARK restore rule in mangle PREROUTING - // (restores fwmark from conntrack mark on reply packets) - if !Command::new("iptables") - .arg("-t").arg("mangle") - .arg("-C").arg("PREROUTING") - .arg("-j").arg("CONNMARK") - .arg("--restore-mark") - .status().await - .map_or(false, |s| s.success()) - { - Command::new("iptables") + // Ensure global CONNMARK restore rules in mangle + // PREROUTING (forwarded packets) and OUTPUT (locally-generated replies). + // Both are needed: PREROUTING handles DNAT-forwarded traffic, + // OUTPUT handles replies from locally-bound listeners (e.g. vhost). + for chain in ["PREROUTING", "OUTPUT"] { + if !Command::new("iptables") .arg("-t").arg("mangle") - .arg("-I").arg("PREROUTING").arg("1") + .arg("-C").arg(chain) .arg("-j").arg("CONNMARK") .arg("--restore-mark") - .invoke(ErrorKind::Network) - .await - .log_err(); + .status().await + .map_or(false, |s| s.success()) + { + Command::new("iptables") + .arg("-t").arg("mangle") + .arg("-I").arg(chain).arg("1") + .arg("-j").arg("CONNMARK") + .arg("--restore-mark") + .invoke(ErrorKind::Network) + .await + .log_err(); + } } // Mark NEW connections arriving on this interface @@ -827,6 +909,17 @@ async fn watch_ip( .log_err(); } } + let ifconfig_url = if let Some(db) = db { + db.peek() + .await + .as_public() + .as_server_info() + .as_ifconfig_url() + .de() + .unwrap_or_else(|_| crate::db::model::public::default_ifconfig_url()) + } else { + crate::db::model::public::default_ifconfig_url() + }; let wan_ip = if !subnets.is_empty() && !matches!( device_type, @@ -835,7 +928,7 @@ async fn watch_ip( | NetworkInterfaceType::Loopback ) ) { - match get_wan_ipv4(iface.as_str()).await { + match get_wan_ipv4(iface.as_str(), &ifconfig_url).await { Ok(a) => a, Err(e) => { tracing::error!( @@ -947,6 +1040,7 @@ impl NetworkInterfaceWatcher { pub fn new( seed: impl Future> + Send + Sync + 'static, watch_activated: impl IntoIterator, + db: TypedPatchDb, ) -> Self { let ip_info = Watch::new(OrdMap::new()); let activated = Watch::new(watch_activated.into_iter().map(|k| (k, false)).collect()); @@ -958,7 +1052,7 @@ impl NetworkInterfaceWatcher { if !seed.is_empty() { ip_info.send_replace(seed); } - watcher(ip_info, activated).await + watcher(ip_info, activated, Some(db)).await }) .into(), } @@ -1105,6 +1199,7 @@ impl NetworkInterfaceController { } }, [InternedString::from_static(START9_BRIDGE_IFACE).into()], + db.clone(), ); let mut ip_info_watch = watcher.subscribe(); ip_info_watch.mark_seen(); @@ -1316,7 +1411,7 @@ impl WildcardListener { .with_kind(ErrorKind::Network)?; let ip_info = Watch::new(OrdMap::new()); let watcher_handle = - tokio::spawn(watcher(ip_info.clone(), Watch::new(BTreeMap::new()))).into(); + tokio::spawn(watcher(ip_info.clone(), Watch::new(BTreeMap::new()), None)).into(); Ok(Self { listener, ip_info, diff --git a/core/src/system/mod.rs b/core/src/system/mod.rs index f6234f78e..9eec913c6 100644 --- a/core/src/system/mod.rs +++ b/core/src/system/mod.rs @@ -1095,6 +1095,28 @@ pub async fn clear_system_smtp(ctx: RpcContext) -> Result<(), Error> { } Ok(()) } + +#[derive(Debug, Clone, Deserialize, Serialize, Parser)] +pub struct SetIfconfigUrlParams { + #[arg(help = "help.arg.ifconfig-url")] + pub url: url::Url, +} + +pub async fn set_ifconfig_url( + ctx: RpcContext, + SetIfconfigUrlParams { url }: SetIfconfigUrlParams, +) -> Result<(), Error> { + ctx.db + .mutate(|db| { + db.as_public_mut() + .as_server_info_mut() + .as_ifconfig_url_mut() + .ser(&url) + }) + .await + .result +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] #[ts(export)] #[serde(rename_all = "camelCase")] diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/addresses.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/addresses.component.ts index 18ebfed59..ede06638e 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/addresses.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/addresses.component.ts @@ -268,7 +268,8 @@ export class InterfaceAddressesComponent { const [network, portPass] = await Promise.all([ firstValueFrom(this.patch.watch$('serverInfo', 'network')), this.api - .testPortForward({ gateway: gatewayId, port: 443 }) + .checkPort({ gateway: gatewayId, port: 443 }) + .then(r => r.reachable) .catch(() => false), ]) const gateway = network.gateways[gatewayId] diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts index 76d118ae5..3b3921a68 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts @@ -250,12 +250,12 @@ export class DomainValidationComponent { this.portLoading.set(true) try { - const result = await this.api.testPortForward({ + const result = await this.api.checkPort({ gateway: this.context.data.gateway.id, port: this.context.data.port, }) - this.portPass.set(result) + this.portPass.set(result.reachable) } catch (e: any) { this.errorService.handleError(e) } finally { diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index 1e224b3a8..06d4d5de7 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -128,10 +128,9 @@ export abstract class ApiService { abstract queryDns(params: T.QueryDnsParams): Promise - abstract testPortForward(params: { - gateway: string - port: number - }): Promise + abstract checkPort( + params: T.CheckPortParams, + ): Promise // smtp diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index bf37e0010..8ba04d82b 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -276,10 +276,9 @@ export class LiveApiService extends ApiService { }) } - async testPortForward(params: { - gateway: string - port: number - }): Promise { + async checkPort( + params: T.CheckPortParams, + ): Promise { return this.rpcRequest({ method: 'net.gateway.check-port', params, diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 1fa4cb828..b59761613 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -497,13 +497,12 @@ export class MockApiService extends ApiService { return null } - async testPortForward(params: { - gateway: string - port: number - }): Promise { + async checkPort( + params: T.CheckPortParams, + ): Promise { await pauseFor(2000) - return false + return { ip: '0.0.0.0', port: params.port, reachable: false } } // marketplace URLs