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.
This commit is contained in:
Aiden McClelland
2026-02-16 19:22:07 -07:00
parent d97ab59bab
commit cfbace1d91
9 changed files with 176 additions and 45 deletions

View File

@@ -13,6 +13,7 @@ use openssl::hash::MessageDigest;
use patch_db::{HasModel, Value}; use patch_db::{HasModel, Value};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
use url::Url;
use crate::account::AccountInfo; use crate::account::AccountInfo;
use crate::db::DbAccessByKey; use crate::db::DbAccessByKey;
@@ -143,6 +144,7 @@ impl Public {
zram: true, zram: true,
governor: None, governor: None,
smtp: None, smtp: None,
ifconfig_url: default_ifconfig_url(),
ram: 0, ram: 0,
devices: Vec::new(), devices: Vec::new(),
kiosk, kiosk,
@@ -164,6 +166,10 @@ fn get_platform() -> InternedString {
(&*PLATFORM).into() (&*PLATFORM).into()
} }
pub fn default_ifconfig_url() -> Url {
"https://ifconfig.co".parse().unwrap()
}
#[derive(Debug, Deserialize, Serialize, HasModel, TS)] #[derive(Debug, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[model = "Model<Self>"] #[model = "Model<Self>"]
@@ -200,6 +206,9 @@ pub struct ServerInfo {
pub zram: bool, pub zram: bool,
pub governor: Option<Governor>, pub governor: Option<Governor>,
pub smtp: Option<SmtpValue>, pub smtp: Option<SmtpValue>,
#[serde(default = "default_ifconfig_url")]
#[ts(type = "string")]
pub ifconfig_url: Url,
#[ts(type = "number")] #[ts(type = "number")]
pub ram: u64, pub ram: u64,
pub devices: Vec<LshwDevice>, pub devices: Vec<LshwDevice>,

View File

@@ -377,6 +377,13 @@ pub fn server<C: Context>() -> ParentHandler<C> {
"host", "host",
net::host::server_host_api::<C>().with_about("about.commands-host-system-ui"), net::host::server_host_api::<C>().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::<CliContext>(),
)
.subcommand( .subcommand(
"set-keyboard", "set-keyboard",
from_fn_async(system::set_keyboard) from_fn_async(system::set_keyboard)

View File

@@ -20,6 +20,7 @@ use tokio::net::TcpListener;
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use ts_rs::TS; use ts_rs::TS;
use url::Url;
use visit_rs::{Visit, VisitFields}; use visit_rs::{Visit, VisitFields};
use zbus::proxy::{PropertyChanged, PropertyStream, SignalStream}; use zbus::proxy::{PropertyChanged, PropertyStream, SignalStream};
use zbus::zvariant::{ use zbus::zvariant::{
@@ -110,6 +111,13 @@ pub fn gateway_api<C: Context>() -> ParentHandler<C> {
.with_about("about.rename-gateway") .with_about("about.rename-gateway")
.with_call_remote::<CliContext>(), .with_call_remote::<CliContext>(),
) )
.subcommand(
"check-port",
from_fn_async(check_port)
.with_display_serializable()
.with_about("about.check-port-reachability")
.with_call_remote::<CliContext>(),
)
} }
async fn list_interfaces( async fn list_interfaces(
@@ -148,6 +156,73 @@ async fn set_name(
ctx.net_controller.net_iface.set_name(&id, name).await 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<CheckPortRes, Error> {
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( #[proxy(
interface = "org.freedesktop.NetworkManager", interface = "org.freedesktop.NetworkManager",
default_service = "org.freedesktop.NetworkManager", default_service = "org.freedesktop.NetworkManager",
@@ -371,6 +446,7 @@ impl<'a> StubStream<'a> for SignalStream<'a> {
async fn watcher( async fn watcher(
watch_ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>, watch_ip_info: Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>,
watch_activation: Watch<BTreeMap<GatewayId, bool>>, watch_activation: Watch<BTreeMap<GatewayId, bool>>,
db: Option<TypedPatchDb<Database>>,
) { ) {
loop { loop {
let res: Result<(), Error> = async { let res: Result<(), Error> = async {
@@ -444,6 +520,7 @@ async fn watcher(
device_proxy.clone(), device_proxy.clone(),
iface.clone(), iface.clone(),
&watch_ip_info, &watch_ip_info,
db.as_ref(),
))); )));
ifaces.insert(iface); ifaces.insert(iface);
} }
@@ -474,33 +551,34 @@ async fn watcher(
} }
} }
async fn get_wan_ipv4(iface: &str) -> Result<Option<Ipv4Addr>, Error> { async fn get_wan_ipv4(iface: &str, base_url: &Url) -> Result<Option<Ipv4Addr>, Error> {
let client = reqwest::Client::builder(); let client = reqwest::Client::builder();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let client = client.interface(iface); let client = client.interface(iface);
Ok(client let url = base_url.join("/ip").with_kind(ErrorKind::ParseUrl)?;
let text = client
.build()? .build()?
.get("https://ip4only.me/api/") .get(url)
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.send() .send()
.await? .await?
.error_for_status()? .error_for_status()?
.text() .text()
.await? .await?;
.split(",") let trimmed = text.trim();
.skip(1) if trimmed.is_empty() {
.next() return Ok(None);
.filter(|s| !s.is_empty()) }
.map(|s| s.parse()) Ok(Some(trimmed.parse()?))
.transpose()?)
} }
#[instrument(skip(connection, device_proxy, write_to))] #[instrument(skip(connection, device_proxy, write_to, db))]
async fn watch_ip( async fn watch_ip(
connection: &Connection, connection: &Connection,
device_proxy: device::DeviceProxy<'_>, device_proxy: device::DeviceProxy<'_>,
iface: GatewayId, iface: GatewayId,
write_to: &Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>, write_to: &Watch<OrdMap<GatewayId, NetworkInterfaceInfo>>,
db: Option<&TypedPatchDb<Database>>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut until = Until::new() let mut until = Until::new()
.with_stream( .with_stream(
@@ -761,24 +839,28 @@ async fn watch_ip(
.log_err(); .log_err();
} }
// Ensure global CONNMARK restore rule in mangle PREROUTING // Ensure global CONNMARK restore rules in mangle
// (restores fwmark from conntrack mark on reply packets) // PREROUTING (forwarded packets) and OUTPUT (locally-generated replies).
if !Command::new("iptables") // Both are needed: PREROUTING handles DNAT-forwarded traffic,
.arg("-t").arg("mangle") // OUTPUT handles replies from locally-bound listeners (e.g. vhost).
.arg("-C").arg("PREROUTING") for chain in ["PREROUTING", "OUTPUT"] {
.arg("-j").arg("CONNMARK") if !Command::new("iptables")
.arg("--restore-mark")
.status().await
.map_or(false, |s| s.success())
{
Command::new("iptables")
.arg("-t").arg("mangle") .arg("-t").arg("mangle")
.arg("-I").arg("PREROUTING").arg("1") .arg("-C").arg(chain)
.arg("-j").arg("CONNMARK") .arg("-j").arg("CONNMARK")
.arg("--restore-mark") .arg("--restore-mark")
.invoke(ErrorKind::Network) .status().await
.await .map_or(false, |s| s.success())
.log_err(); {
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 // Mark NEW connections arriving on this interface
@@ -827,6 +909,17 @@ async fn watch_ip(
.log_err(); .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() let wan_ip = if !subnets.is_empty()
&& !matches!( && !matches!(
device_type, device_type,
@@ -835,7 +928,7 @@ async fn watch_ip(
| NetworkInterfaceType::Loopback | NetworkInterfaceType::Loopback
) )
) { ) {
match get_wan_ipv4(iface.as_str()).await { match get_wan_ipv4(iface.as_str(), &ifconfig_url).await {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
tracing::error!( tracing::error!(
@@ -947,6 +1040,7 @@ impl NetworkInterfaceWatcher {
pub fn new( pub fn new(
seed: impl Future<Output = OrdMap<GatewayId, NetworkInterfaceInfo>> + Send + Sync + 'static, seed: impl Future<Output = OrdMap<GatewayId, NetworkInterfaceInfo>> + Send + Sync + 'static,
watch_activated: impl IntoIterator<Item = GatewayId>, watch_activated: impl IntoIterator<Item = GatewayId>,
db: TypedPatchDb<Database>,
) -> Self { ) -> Self {
let ip_info = Watch::new(OrdMap::new()); let ip_info = Watch::new(OrdMap::new());
let activated = Watch::new(watch_activated.into_iter().map(|k| (k, false)).collect()); let activated = Watch::new(watch_activated.into_iter().map(|k| (k, false)).collect());
@@ -958,7 +1052,7 @@ impl NetworkInterfaceWatcher {
if !seed.is_empty() { if !seed.is_empty() {
ip_info.send_replace(seed); ip_info.send_replace(seed);
} }
watcher(ip_info, activated).await watcher(ip_info, activated, Some(db)).await
}) })
.into(), .into(),
} }
@@ -1105,6 +1199,7 @@ impl NetworkInterfaceController {
} }
}, },
[InternedString::from_static(START9_BRIDGE_IFACE).into()], [InternedString::from_static(START9_BRIDGE_IFACE).into()],
db.clone(),
); );
let mut ip_info_watch = watcher.subscribe(); let mut ip_info_watch = watcher.subscribe();
ip_info_watch.mark_seen(); ip_info_watch.mark_seen();
@@ -1316,7 +1411,7 @@ impl WildcardListener {
.with_kind(ErrorKind::Network)?; .with_kind(ErrorKind::Network)?;
let ip_info = Watch::new(OrdMap::new()); let ip_info = Watch::new(OrdMap::new());
let watcher_handle = 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 { Ok(Self {
listener, listener,
ip_info, ip_info,

View File

@@ -1095,6 +1095,28 @@ pub async fn clear_system_smtp(ctx: RpcContext) -> Result<(), Error> {
} }
Ok(()) 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)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)]
#[ts(export)] #[ts(export)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@@ -268,7 +268,8 @@ export class InterfaceAddressesComponent {
const [network, portPass] = await Promise.all([ const [network, portPass] = await Promise.all([
firstValueFrom(this.patch.watch$('serverInfo', 'network')), firstValueFrom(this.patch.watch$('serverInfo', 'network')),
this.api this.api
.testPortForward({ gateway: gatewayId, port: 443 }) .checkPort({ gateway: gatewayId, port: 443 })
.then(r => r.reachable)
.catch(() => false), .catch(() => false),
]) ])
const gateway = network.gateways[gatewayId] const gateway = network.gateways[gatewayId]

View File

@@ -250,12 +250,12 @@ export class DomainValidationComponent {
this.portLoading.set(true) this.portLoading.set(true)
try { try {
const result = await this.api.testPortForward({ const result = await this.api.checkPort({
gateway: this.context.data.gateway.id, gateway: this.context.data.gateway.id,
port: this.context.data.port, port: this.context.data.port,
}) })
this.portPass.set(result) this.portPass.set(result.reachable)
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
} finally { } finally {

View File

@@ -128,10 +128,9 @@ export abstract class ApiService {
abstract queryDns(params: T.QueryDnsParams): Promise<string | null> abstract queryDns(params: T.QueryDnsParams): Promise<string | null>
abstract testPortForward(params: { abstract checkPort(
gateway: string params: T.CheckPortParams,
port: number ): Promise<T.CheckPortRes>
}): Promise<boolean>
// smtp // smtp

View File

@@ -276,10 +276,9 @@ export class LiveApiService extends ApiService {
}) })
} }
async testPortForward(params: { async checkPort(
gateway: string params: T.CheckPortParams,
port: number ): Promise<T.CheckPortRes> {
}): Promise<boolean> {
return this.rpcRequest({ return this.rpcRequest({
method: 'net.gateway.check-port', method: 'net.gateway.check-port',
params, params,

View File

@@ -497,13 +497,12 @@ export class MockApiService extends ApiService {
return null return null
} }
async testPortForward(params: { async checkPort(
gateway: string params: T.CheckPortParams,
port: number ): Promise<T.CheckPortRes> {
}): Promise<boolean> {
await pauseFor(2000) await pauseFor(2000)
return false return { ip: '0.0.0.0', port: params.port, reachable: false }
} }
// marketplace URLs // marketplace URLs