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