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 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<Self>"]
@@ -200,6 +206,9 @@ pub struct ServerInfo {
pub zram: bool,
pub governor: Option<Governor>,
pub smtp: Option<SmtpValue>,
#[serde(default = "default_ifconfig_url")]
#[ts(type = "string")]
pub ifconfig_url: Url,
#[ts(type = "number")]
pub ram: u64,
pub devices: Vec<LshwDevice>,

View File

@@ -377,6 +377,13 @@ pub fn server<C: Context>() -> ParentHandler<C> {
"host",
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(
"set-keyboard",
from_fn_async(system::set_keyboard)

View File

@@ -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<C: Context>() -> ParentHandler<C> {
.with_about("about.rename-gateway")
.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(
@@ -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<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(
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<OrdMap<GatewayId, NetworkInterfaceInfo>>,
watch_activation: Watch<BTreeMap<GatewayId, bool>>,
db: Option<TypedPatchDb<Database>>,
) {
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<Option<Ipv4Addr>, Error> {
async fn get_wan_ipv4(iface: &str, base_url: &Url) -> Result<Option<Ipv4Addr>, 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<OrdMap<GatewayId, NetworkInterfaceInfo>>,
db: Option<&TypedPatchDb<Database>>,
) -> 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<Output = OrdMap<GatewayId, NetworkInterfaceInfo>> + Send + Sync + 'static,
watch_activated: impl IntoIterator<Item = GatewayId>,
db: TypedPatchDb<Database>,
) -> 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,

View File

@@ -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")]

View File

@@ -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]

View File

@@ -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 {

View File

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

View File

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

View File

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