wip refactor

This commit is contained in:
Aiden McClelland
2026-02-12 14:51:33 -07:00
parent 339e5f799a
commit db7f3341ac
21 changed files with 967 additions and 668 deletions

663
core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ PROFILE=${PROFILE:-debug}
if [ "${PROFILE}" = "release" ]; then
BUILD_FLAGS="--release"
else
if [ "$PROFILE" != "debug"]; then
if [ "$PROFILE" != "debug" ]; then
>&2 echo "Unknown profile $PROFILE: falling back to debug..."
PROFILE=debug
fi

View File

@@ -20,8 +20,9 @@ use crate::db::model::Database;
use crate::db::model::package::AllPackageData;
use crate::net::acme::AcmeProvider;
use crate::net::host::Host;
use crate::net::host::binding::{AddSslOptions, BindInfo, BindOptions, Bindings, DerivedAddressInfo, NetInfo};
use crate::net::utils::ipv6_is_local;
use crate::net::host::binding::{
AddSslOptions, BindInfo, BindOptions, Bindings, DerivedAddressInfo, NetInfo,
};
use crate::net::vhost::AlpnInfo;
use crate::prelude::*;
use crate::progress::FullProgress;
@@ -91,7 +92,7 @@ impl Public {
.collect(),
),
public_domains: BTreeMap::new(),
private_domains: BTreeSet::new(),
private_domains: BTreeMap::new(),
},
wifi: WifiInfo {
enabled: true,
@@ -242,44 +243,12 @@ pub struct DnsSettings {
#[ts(export)]
pub struct NetworkInterfaceInfo {
pub name: Option<InternedString>,
#[ts(skip)]
pub public: Option<bool>,
pub secure: Option<bool>,
pub ip_info: Option<Arc<IpInfo>>,
#[serde(default, rename = "type")]
pub gateway_type: Option<GatewayType>,
}
impl NetworkInterfaceInfo {
pub fn public(&self) -> bool {
self.public.unwrap_or_else(|| {
!self.ip_info.as_ref().map_or(true, |ip_info| {
let ip4s = ip_info
.subnets
.iter()
.filter_map(|ipnet| {
if let IpAddr::V4(ip4) = ipnet.addr() {
Some(ip4)
} else {
None
}
})
.collect::<BTreeSet<_>>();
if !ip4s.is_empty() {
return ip4s
.iter()
.all(|ip4| ip4.is_loopback() || ip4.is_private() || ip4.is_link_local());
}
ip_info.subnets.iter().all(|ipnet| {
if let IpAddr::V6(ip6) = ipnet.addr() {
ipv6_is_local(ip6)
} else {
true
}
})
})
})
}
pub fn secure(&self) -> bool {
self.secure.unwrap_or(false)
}
@@ -316,7 +285,20 @@ pub enum NetworkInterfaceType {
Loopback,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS, clap::ValueEnum)]
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
PartialOrd,
Ord,
Deserialize,
Serialize,
TS,
clap::ValueEnum,
)]
#[ts(export)]
#[serde(rename_all = "kebab-case")]
pub enum GatewayType {

View File

@@ -71,7 +71,7 @@ impl SignatureAuthContext for RpcContext {
.as_network()
.as_host()
.as_private_domains()
.de()
.keys()
.map(|k| k.into_iter())
.transpose(),
)

View File

@@ -10,7 +10,7 @@ use color_eyre::eyre::eyre;
use futures::{FutureExt, StreamExt, TryStreamExt};
use hickory_server::authority::{AuthorityObject, Catalog, MessageResponseBuilder};
use hickory_server::proto::op::{Header, ResponseCode};
use hickory_server::proto::rr::{LowerName, Name, Record, RecordType};
use hickory_server::proto::rr::{Name, Record, RecordType};
use hickory_server::resolver::config::{ResolverConfig, ResolverOpts};
use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo};
use hickory_server::store::forwarder::{ForwardAuthority, ForwardConfig};

View File

@@ -56,7 +56,7 @@ pub fn gateway_api<C: Context>() -> ParentHandler<C> {
}
let mut table = Table::new();
table.add_row(row![bc => "INTERFACE", "TYPE", "PUBLIC", "ADDRESSES", "WAN IP"]);
table.add_row(row![bc => "INTERFACE", "TYPE", "ADDRESSES", "WAN IP"]);
for (iface, info) in res {
table.add_row(row![
iface,
@@ -64,7 +64,6 @@ pub fn gateway_api<C: Context>() -> ParentHandler<C> {
.as_ref()
.and_then(|ip_info| ip_info.device_type)
.map_or_else(|| "UNKNOWN".to_owned(), |ty| format!("{ty:?}")),
info.public(),
info.ip_info.as_ref().map_or_else(
|| "<DISCONNECTED>".to_owned(),
|ip_info| ip_info
@@ -94,22 +93,6 @@ pub fn gateway_api<C: Context>() -> ParentHandler<C> {
.with_about("about.show-gateways-startos-can-listen-on")
.with_call_remote::<CliContext>(),
)
.subcommand(
"set-public",
from_fn_async(set_public)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("about.indicate-gateway-inbound-access-from-wan")
.with_call_remote::<CliContext>(),
)
.subcommand(
"unset-public",
from_fn_async(unset_public)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("about.allow-gateway-infer-inbound-access-from-wan")
.with_call_remote::<CliContext>(),
)
.subcommand(
"forget",
from_fn_async(forget_iface)
@@ -134,40 +117,6 @@ async fn list_interfaces(
Ok(ctx.net_controller.net_iface.watcher.ip_info())
}
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
struct NetworkInterfaceSetPublicParams {
#[arg(help = "help.arg.gateway-id")]
gateway: GatewayId,
#[arg(help = "help.arg.is-public")]
public: Option<bool>,
}
async fn set_public(
ctx: RpcContext,
NetworkInterfaceSetPublicParams { gateway, public }: NetworkInterfaceSetPublicParams,
) -> Result<(), Error> {
ctx.net_controller
.net_iface
.set_public(&gateway, Some(public.unwrap_or(true)))
.await
}
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
struct UnsetPublicParams {
#[arg(help = "help.arg.gateway-id")]
gateway: GatewayId,
}
async fn unset_public(
ctx: RpcContext,
UnsetPublicParams { gateway }: UnsetPublicParams,
) -> Result<(), Error> {
ctx.net_controller
.net_iface
.set_public(&gateway, None)
.await
}
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
struct ForgetGatewayParams {
#[arg(help = "help.arg.gateway-id")]
@@ -910,12 +859,11 @@ async fn watch_ip(
write_to.send_if_modified(
|m: &mut OrdMap<GatewayId, NetworkInterfaceInfo>| {
let (name, public, secure, gateway_type, prev_wan_ip) = m
let (name, secure, gateway_type, prev_wan_ip) = m
.get(&iface)
.map_or((None, None, None, None, None), |i| {
.map_or((None, None, None, None), |i| {
(
i.name.clone(),
i.public,
i.secure,
i.gateway_type,
i.ip_info
@@ -929,7 +877,6 @@ async fn watch_ip(
iface.clone(),
NetworkInterfaceInfo {
name,
public,
secure,
ip_info: Some(ip_info.clone()),
gateway_type,
@@ -1192,43 +1139,6 @@ impl NetworkInterfaceController {
}
}
pub async fn set_public(
&self,
interface: &GatewayId,
public: Option<bool>,
) -> Result<(), Error> {
let mut sub = self
.db
.subscribe(
"/public/serverInfo/network/gateways"
.parse::<JsonPointer<_, _>>()
.with_kind(ErrorKind::Database)?,
)
.await;
let mut err = None;
let changed = self.watcher.ip_info.send_if_modified(|ip_info| {
let prev = std::mem::replace(
&mut match ip_info.get_mut(interface).or_not_found(interface) {
Ok(a) => a,
Err(e) => {
err = Some(e);
return false;
}
}
.public,
public,
);
prev != public
});
if let Some(e) = err {
return Err(e);
}
if changed {
sub.recv().await;
}
Ok(())
}
pub async fn forget(&self, interface: &GatewayId) -> Result<(), Error> {
let mut sub = self
.db

View File

@@ -20,7 +20,7 @@ use crate::util::serde::{HandlerExtSerde, display_serializable};
pub struct HostAddress {
pub address: InternedString,
pub public: Option<PublicDomainConfig>,
pub private: bool,
pub private: Option<BTreeSet<GatewayId>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
@@ -53,7 +53,7 @@ fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
for domain in &public {
check_domain(&mut domains, domain.clone())?;
}
for domain in host.as_private_domains().de()? {
for domain in host.as_private_domains().keys()? {
if !public.contains(&domain) {
check_domain(&mut domains, domain)?;
}
@@ -63,13 +63,13 @@ fn handle_duplicates(db: &mut DatabaseModel) -> Result<(), Error> {
host.as_public_domains_mut()
.mutate(|d| Ok(d.retain(|d, _| !domains.contains(d))))?;
host.as_private_domains_mut()
.mutate(|d| Ok(d.retain(|d| !domains.contains(d))))?;
.mutate(|d| Ok(d.retain(|d, _| !domains.contains(d))))?;
let public = host.as_public_domains().keys()?;
for domain in &public {
check_domain(&mut domains, domain.clone())?;
}
for domain in host.as_private_domains().de()? {
for domain in host.as_private_domains().keys()? {
if !public.contains(&domain) {
check_domain(&mut domains, domain)?;
}
@@ -146,21 +146,7 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
}
let mut table = Table::new();
table.add_row(row![bc => "ADDRESS", "PUBLIC", "ACME PROVIDER"]);
for entry in &res {
if let Some(PublicDomainConfig { gateway, acme }) = &entry.public {
table.add_row(row![
entry.address,
&format!(
"{} ({gateway})",
if entry.private { "YES" } else { "ONLY" }
),
acme.as_ref().map(|a| a.0.as_str()).unwrap_or("NONE")
]);
} else {
table.add_row(row![entry.address, &format!("NO"), "N/A"]);
}
}
todo!("find a good way to represent this");
table.print_tty(false)?;
@@ -248,18 +234,20 @@ pub async fn remove_public_domain<Kind: HostApiKind>(
pub struct AddPrivateDomainParams {
#[arg(help = "help.arg.fqdn")]
pub fqdn: InternedString,
pub gateway: GatewayId,
}
pub async fn add_private_domain<Kind: HostApiKind>(
ctx: RpcContext,
AddPrivateDomainParams { fqdn }: AddPrivateDomainParams,
AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams,
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
Kind::host_for(&inheritance, db)?
.as_private_domains_mut()
.mutate(|d| Ok(d.insert(fqdn)))?;
.upsert(&fqdn, || Ok(BTreeSet::new()))?
.mutate(|d| Ok(d.insert(gateway)))?;
handle_duplicates(db)
})
.await

View File

@@ -1,4 +1,5 @@
use std::collections::{BTreeMap, BTreeSet};
use std::net::SocketAddr;
use std::str::FromStr;
use clap::Parser;
@@ -7,6 +8,7 @@ use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_f
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::HostId;
use crate::context::{CliContext, RpcContext};
use crate::db::prelude::Map;
use crate::net::forward::AvailablePorts;
@@ -16,7 +18,6 @@ use crate::net::vhost::AlpnInfo;
use crate::prelude::*;
use crate::util::FromStrParser;
use crate::util::serde::{HandlerExtSerde, display_serializable};
use crate::HostId;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
#[ts(export)]
@@ -49,31 +50,36 @@ impl FromStr for BindId {
#[ts(export)]
#[model = "Model<Self>"]
pub struct DerivedAddressInfo {
/// User-controlled: private addresses the user has disabled
pub private_disabled: BTreeSet<HostnameInfo>,
/// User-controlled: public addresses the user has enabled
pub public_enabled: BTreeSet<HostnameInfo>,
/// User override: enable these addresses (only for public IP & port)
pub enabled: BTreeSet<SocketAddr>,
/// User override: disable these addresses (only for domains and private IP & port)
pub disabled: BTreeSet<(InternedString, u16)>,
/// COMPUTED: NetServiceData::update — all possible addresses for this binding
pub possible: BTreeSet<HostnameInfo>,
pub available: BTreeSet<HostnameInfo>,
}
impl DerivedAddressInfo {
/// Returns addresses that are currently enabled.
/// Private addresses are enabled by default (disabled if in private_disabled).
/// Public addresses are disabled by default (enabled if in public_enabled).
/// Returns addresses that are currently enabled after applying overrides.
/// Default: public IPs are disabled, everything else is enabled.
/// Explicit `enabled`/`disabled` overrides take precedence.
pub fn enabled(&self) -> BTreeSet<&HostnameInfo> {
self.possible
self.available
.iter()
.filter(|h| {
if h.public {
self.public_enabled.contains(h)
if h.public && h.metadata.is_ip() {
// Public IPs: disabled by default, explicitly enabled via SocketAddr
h.to_socket_addr().map_or(
true, // should never happen, but would rather see them if it does
|sa| self.enabled.contains(&sa),
)
} else {
!self.private_disabled.contains(h)
!self
.disabled
.contains(&(h.host.clone(), h.port.unwrap_or_default())) // disablable addresses will always have a port
}
})
.collect()
}
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
@@ -199,9 +205,9 @@ impl BindInfo {
options,
net: lan,
addresses: DerivedAddressInfo {
private_disabled: addresses.private_disabled,
public_enabled: addresses.public_enabled,
possible: BTreeSet::new(),
enabled: addresses.enabled,
disabled: addresses.disabled,
available: BTreeSet::new(),
},
})
}
@@ -328,17 +334,27 @@ pub async fn set_address_enabled<Kind: HostApiKind>(
.as_bindings_mut()
.mutate(|b| {
let bind = b.get_mut(&internal_port).or_not_found(internal_port)?;
if address.public {
if address.public && address.metadata.is_ip() {
// Public IPs: toggle via SocketAddr in `enabled` set
let sa = address.to_socket_addr().ok_or_else(|| {
Error::new(
eyre!("cannot convert address to socket addr"),
ErrorKind::InvalidRequest,
)
})?;
if enabled {
bind.addresses.public_enabled.insert(address.clone());
bind.addresses.enabled.insert(sa);
} else {
bind.addresses.public_enabled.remove(&address);
bind.addresses.enabled.remove(&sa);
}
} else {
// Domains and private IPs: toggle via (host, port) in `disabled` set
let port = address.port.unwrap_or(if address.ssl { 443 } else { 80 });
let key = (address.host.clone(), port);
if enabled {
bind.addresses.private_disabled.remove(&address);
bind.addresses.disabled.remove(&key);
} else {
bind.addresses.private_disabled.insert(address.clone());
bind.addresses.disabled.insert(key);
}
}
Ok(())

View File

@@ -3,19 +3,23 @@ use std::future::Future;
use std::panic::RefUnwindSafe;
use clap::Parser;
use imbl::OrdMap;
use imbl_value::InternedString;
use itertools::Itertools;
use patch_db::DestructureMut;
use rpc_toolkit::{Context, Empty, HandlerExt, OrEmpty, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::context::RpcContext;
use crate::db::model::DatabaseModel;
use crate::db::model::public::NetworkInterfaceInfo;
use crate::net::forward::AvailablePorts;
use crate::net::host::address::{HostAddress, PublicDomainConfig, address_api};
use crate::net::host::binding::{BindInfo, BindOptions, Bindings, binding};
use crate::net::service_interface::{HostnameInfo, HostnameMetadata};
use crate::prelude::*;
use crate::{HostId, PackageId};
use crate::{GatewayId, HostId, PackageId};
pub mod address;
pub mod binding;
@@ -27,7 +31,7 @@ pub mod binding;
pub struct Host {
pub bindings: Bindings,
pub public_domains: BTreeMap<InternedString, PublicDomainConfig>,
pub private_domains: BTreeSet<InternedString>,
pub private_domains: BTreeMap<InternedString, BTreeSet<GatewayId>>,
}
impl AsRef<Host> for Host {
@@ -45,20 +49,183 @@ impl Host {
.map(|(address, config)| HostAddress {
address: address.clone(),
public: Some(config.clone()),
private: self.private_domains.contains(address),
private: self.private_domains.get(address).cloned(),
})
.chain(
self.private_domains
.iter()
.filter(|a| !self.public_domains.contains_key(*a))
.map(|address| HostAddress {
address: address.clone(),
.filter(|(domain, _)| !self.public_domains.contains_key(*domain))
.map(|(domain, gateways)| HostAddress {
address: domain.clone(),
public: None,
private: true,
private: Some(gateways.clone()),
}),
)
}
}
impl Model<Host> {
pub fn update_addresses(
&mut self,
gateways: &OrdMap<GatewayId, NetworkInterfaceInfo>,
available_ports: &AvailablePorts,
) -> Result<(), Error> {
let this = self.destructure_mut();
for (_, bind) in this.bindings.as_entries_mut()? {
let net = bind.as_net().de()?;
let opt = bind.as_options().de()?;
let mut available = BTreeSet::new();
for (gid, g) in gateways {
let Some(ip_info) = &g.ip_info else {
continue;
};
let gateway_secure = g.secure();
for subnet in &ip_info.subnets {
let host = InternedString::from_display(&subnet.addr());
let metadata = if subnet.addr().is_ipv4() {
HostnameMetadata::Ipv4 {
gateway: gid.clone(),
}
} else {
HostnameMetadata::Ipv6 {
gateway: gid.clone(),
scope_id: ip_info.scope_id,
}
};
if let Some(port) = net.assigned_port.filter(|_| {
opt.secure
.map_or(gateway_secure, |s| !(s.ssl && opt.add_ssl.is_some()))
}) {
available.insert(HostnameInfo {
ssl: opt.secure.map_or(false, |s| s.ssl),
public: false,
host: host.clone(),
port: Some(port),
metadata: metadata.clone(),
});
}
if let Some(port) = net.assigned_ssl_port {
available.insert(HostnameInfo {
ssl: true,
public: false,
host: host.clone(),
port: Some(port),
metadata,
});
}
}
if let Some(wan_ip) = &ip_info.wan_ip {
let host = InternedString::from_display(&wan_ip);
let metadata = HostnameMetadata::Ipv4 {
gateway: gid.clone(),
};
if let Some(port) = net.assigned_port.filter(|_| {
opt.secure.map_or(
false, // the public internet is never secure
|s| !(s.ssl && opt.add_ssl.is_some()),
)
}) {
available.insert(HostnameInfo {
ssl: opt.secure.map_or(false, |s| s.ssl),
public: true,
host: host.clone(),
port: Some(port),
metadata: metadata.clone(),
});
}
if let Some(port) = net.assigned_ssl_port {
available.insert(HostnameInfo {
ssl: true,
public: true,
host: host.clone(),
port: Some(port),
metadata,
});
}
}
}
for (domain, info) in this.public_domains.de()? {
let metadata = HostnameMetadata::PublicDomain {
gateway: info.gateway.clone(),
};
if let Some(port) = net.assigned_port.filter(|_| {
opt.secure.map_or(
false, // the public internet is never secure
|s| !(s.ssl && opt.add_ssl.is_some()),
)
}) {
available.insert(HostnameInfo {
ssl: opt.secure.map_or(false, |s| s.ssl),
public: true,
host: domain.clone(),
port: Some(port),
metadata: metadata.clone(),
});
}
if let Some(mut port) = net.assigned_ssl_port {
if let Some(preferred) = opt
.add_ssl
.as_ref()
.map(|s| s.preferred_external_port)
.filter(|p| available_ports.is_ssl(*p))
{
port = preferred;
}
available.insert(HostnameInfo {
ssl: true,
public: true,
host: domain.clone(),
port: Some(port),
metadata,
});
}
}
for (domain, domain_gateways) in this.private_domains.de()? {
if let Some(port) = net.assigned_port.filter(|_| {
opt.secure
.map_or(true, |s| !(s.ssl && opt.add_ssl.is_some()))
}) {
let gateways = if opt.secure.is_some() {
domain_gateways.clone()
} else {
domain_gateways
.iter()
.cloned()
.filter(|g| gateways.get(g).map_or(false, |g| g.secure()))
.collect()
};
available.insert(HostnameInfo {
ssl: opt.secure.map_or(false, |s| s.ssl),
public: true,
host: domain.clone(),
port: Some(port),
metadata: HostnameMetadata::PrivateDomain { gateways },
});
}
if let Some(mut port) = net.assigned_ssl_port {
if let Some(preferred) = opt
.add_ssl
.as_ref()
.map(|s| s.preferred_external_port)
.filter(|p| available_ports.is_ssl(*p))
{
port = preferred;
}
available.insert(HostnameInfo {
ssl: true,
public: true,
host: domain.clone(),
port: Some(port),
metadata: HostnameMetadata::PrivateDomain {
gateways: domain_gateways,
},
});
}
}
bind.as_addresses_mut().as_available_mut().ser(&available)?;
}
Ok(())
}
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[model = "Model<Self>"]

View File

@@ -23,7 +23,7 @@ use crate::net::gateway::NetworkInterfaceController;
use crate::net::host::address::HostAddress;
use crate::net::host::binding::{AddSslOptions, BindId, BindOptions};
use crate::net::host::{Host, Hosts, host_for};
use crate::net::service_interface::{GatewayInfo, HostnameInfo, IpHostname};
use crate::net::service_interface::{HostnameInfo, HostnameMetadata};
use crate::net::socks::SocksController;
use crate::net::utils::ipv6_is_local;
use crate::net::vhost::{AlpnInfo, DynVHostTarget, ProxyTarget, VHostController};
@@ -261,10 +261,7 @@ impl NetServiceData {
let mut private_dns: BTreeSet<InternedString> = BTreeSet::new();
let binds = self.binds.entry(id.clone()).or_default();
let peek = ctrl.db.peek().await;
let server_info = peek.as_public().as_server_info();
let net_ifaces = ctrl.net_iface.watcher.ip_info();
let hostname = server_info.as_hostname().de()?;
let host_addresses: Vec<_> = host.addresses().collect();
// Collect private DNS entries (domains without public config)
@@ -277,7 +274,7 @@ impl NetServiceData {
}
}
// ── Phase 1: Compute possible addresses ──
// ── Phase 1: Compute available addresses ──
for (_port, bind) in host.bindings.iter_mut() {
if !bind.enabled {
continue;
@@ -286,7 +283,83 @@ impl NetServiceData {
continue;
}
bind.addresses.possible.clear();
bind.addresses.available.clear();
// Domain port: non-SSL port for domains (secure-filtered, gateway-independent)
let domain_base_port = bind.net.assigned_port.filter(|_| {
bind.options
.secure
.map_or(false, |s| !s.ssl || bind.options.add_ssl.is_none())
});
let (domain_port, domain_ssl_port) = if bind
.options
.add_ssl
.as_ref()
.map_or(false, |ssl| ssl.preferred_external_port == 443)
{
(None, Some(443))
} else {
(domain_base_port, bind.net.assigned_ssl_port)
};
// Domain addresses
for HostAddress {
address, public, ..
} in host_addresses.iter().cloned()
{
// Public domain entry
if let Some(pub_config) = &public {
let metadata = HostnameMetadata::PublicDomain {
gateway: pub_config.gateway.clone(),
};
if let Some(p) = domain_port {
bind.addresses.available.insert(HostnameInfo {
ssl: false,
public: true,
host: address.clone(),
port: Some(p),
metadata: metadata.clone(),
});
}
if let Some(sp) = domain_ssl_port {
bind.addresses.available.insert(HostnameInfo {
ssl: true,
public: true,
host: address.clone(),
port: Some(sp),
metadata,
});
}
}
// Private domain entry
if let Some(gateways) = host.private_domains.get(&address) {
if !gateways.is_empty() {
let metadata = HostnameMetadata::PrivateDomain {
gateways: gateways.clone(),
};
if let Some(p) = domain_port {
bind.addresses.available.insert(HostnameInfo {
ssl: false,
public: false,
host: address.clone(),
port: Some(p),
metadata: metadata.clone(),
});
}
if let Some(sp) = domain_ssl_port {
bind.addresses.available.insert(HostnameInfo {
ssl: true,
public: false,
host: address.clone(),
port: Some(sp),
metadata,
});
}
}
}
}
// IP addresses (per-gateway)
for (gateway_id, info) in net_ifaces
.iter()
.filter(|(_, info)| {
@@ -296,111 +369,97 @@ impl NetServiceData {
})
.filter(|(_, info)| info.ip_info.is_some())
{
let gateway = GatewayInfo {
id: gateway_id.clone(),
name: info
.name
.clone()
.or_else(|| info.ip_info.as_ref().map(|i| i.name.clone()))
.unwrap_or_else(|| gateway_id.clone().into()),
public: info.public(),
};
let port = bind.net.assigned_port.filter(|_| {
bind.options.secure.map_or(false, |s| {
!(s.ssl && bind.options.add_ssl.is_some()) || info.secure()
})
});
// .local addresses (private only, non-public, non-wireguard gateways)
if !info.public()
&& info.ip_info.as_ref().map_or(false, |i| {
i.device_type != Some(NetworkInterfaceType::Wireguard)
})
{
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public: false,
hostname: IpHostname::Local {
value: InternedString::from_display(&{
let hostname = &hostname;
lazy_format!("{hostname}.local")
}),
port,
ssl_port: bind.net.assigned_ssl_port,
},
});
}
// Domain addresses
for HostAddress {
address,
public,
private,
} in host_addresses.iter().cloned()
{
let private = private && !info.public();
let public =
public.as_ref().map_or(false, |p| &p.gateway == gateway_id);
if public || private {
let (domain_port, domain_ssl_port) = if bind
.options
.add_ssl
.as_ref()
.map_or(false, |ssl| ssl.preferred_external_port == 443)
{
(None, Some(443))
} else {
(port, bind.net.assigned_ssl_port)
};
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public,
hostname: IpHostname::Domain {
value: address.clone(),
port: domain_port,
ssl_port: domain_ssl_port,
},
});
}
}
// IP addresses
if let Some(ip_info) = &info.ip_info {
let public = info.public();
// WAN IP (public)
if let Some(wan_ip) = ip_info.wan_ip {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public: true,
hostname: IpHostname::Ipv4 {
value: wan_ip,
port,
ssl_port: bind.net.assigned_ssl_port,
},
});
let host_str = InternedString::from_display(&wan_ip);
if let Some(p) = port {
bind.addresses.available.insert(HostnameInfo {
ssl: false,
public: true,
host: host_str.clone(),
port: Some(p),
metadata: HostnameMetadata::Ipv4 {
gateway: gateway_id.clone(),
},
});
}
if let Some(sp) = bind.net.assigned_ssl_port {
bind.addresses.available.insert(HostnameInfo {
ssl: true,
public: true,
host: host_str,
port: Some(sp),
metadata: HostnameMetadata::Ipv4 {
gateway: gateway_id.clone(),
},
});
}
}
// Subnet IPs
for ipnet in &ip_info.subnets {
match ipnet {
IpNet::V4(net) => {
if !public {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public,
hostname: IpHostname::Ipv4 {
value: net.addr(),
port,
ssl_port: bind.net.assigned_ssl_port,
},
});
let host_str = InternedString::from_display(&net.addr());
if let Some(p) = port {
bind.addresses.available.insert(HostnameInfo {
ssl: false,
public: false,
host: host_str.clone(),
port: Some(p),
metadata: HostnameMetadata::Ipv4 {
gateway: gateway_id.clone(),
},
});
}
if let Some(sp) = bind.net.assigned_ssl_port {
bind.addresses.available.insert(HostnameInfo {
ssl: true,
public: false,
host: host_str,
port: Some(sp),
metadata: HostnameMetadata::Ipv4 {
gateway: gateway_id.clone(),
},
});
}
}
}
IpNet::V6(net) => {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public: public && !ipv6_is_local(net.addr()),
hostname: IpHostname::Ipv6 {
value: net.addr(),
scope_id: ip_info.scope_id,
port,
ssl_port: bind.net.assigned_ssl_port,
},
});
let is_public = public && !ipv6_is_local(net.addr());
let host_str = InternedString::from_display(&net.addr());
if let Some(p) = port {
bind.addresses.available.insert(HostnameInfo {
ssl: false,
public: is_public,
host: host_str.clone(),
port: Some(p),
metadata: HostnameMetadata::Ipv6 {
gateway: gateway_id.clone(),
scope_id: ip_info.scope_id,
},
});
}
if let Some(sp) = bind.net.assigned_ssl_port {
bind.addresses.available.insert(HostnameInfo {
ssl: true,
public: is_public,
host: host_str,
port: Some(sp),
metadata: HostnameMetadata::Ipv6 {
gateway: gateway_id.clone(),
scope_id: ip_info.scope_id,
},
});
}
}
}
}
@@ -435,11 +494,8 @@ impl NetServiceData {
let server_private_ips: BTreeSet<IpAddr> = enabled_addresses
.iter()
.filter(|a| !a.public)
.filter_map(|a| {
net_ifaces
.get(&a.gateway.id)
.and_then(|info| info.ip_info.as_ref())
})
.flat_map(|a| a.metadata.gateways())
.filter_map(|gw| net_ifaces.get(gw).and_then(|info| info.ip_info.as_ref()))
.flat_map(|ip_info| ip_info.subnets.iter().map(|s| s.addr()))
.collect();
@@ -465,32 +521,36 @@ impl NetServiceData {
// Domain vhosts: group by (domain, ssl_port), merge public/private sets
for addr_info in &enabled_addresses {
if let IpHostname::Domain {
value: domain,
ssl_port: Some(domain_ssl_port),
..
} = &addr_info.hostname
{
let key = (Some(domain.clone()), *domain_ssl_port);
let target = vhosts.entry(key).or_insert_with(|| ProxyTarget {
public: BTreeSet::new(),
private: BTreeSet::new(),
acme: host_addresses
.iter()
.find(|a| &a.address == domain)
.and_then(|a| a.public.as_ref())
.and_then(|p| p.acme.clone()),
addr,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
});
if addr_info.public {
target.public.insert(addr_info.gateway.id.clone());
} else {
// Add interface IPs for this gateway to private set
if let Some(info) = net_ifaces.get(&addr_info.gateway.id) {
if !addr_info.ssl {
continue;
}
match &addr_info.metadata {
HostnameMetadata::PublicDomain { .. }
| HostnameMetadata::PrivateDomain { .. } => {}
_ => continue,
}
let domain = &addr_info.host;
let domain_ssl_port = addr_info.port.unwrap_or(443);
let key = (Some(domain.clone()), domain_ssl_port);
let target = vhosts.entry(key).or_insert_with(|| ProxyTarget {
public: BTreeSet::new(),
private: BTreeSet::new(),
acme: host_addresses
.iter()
.find(|a| a.address == *domain)
.and_then(|a| a.public.as_ref())
.and_then(|p| p.acme.clone()),
addr,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl.clone().map(|_| ctrl.tls_client_config.clone()),
});
if addr_info.public {
for gw in addr_info.metadata.gateways() {
target.public.insert(gw.clone());
}
} else {
for gw in addr_info.metadata.gateways() {
if let Some(info) = net_ifaces.get(gw) {
if let Some(ip_info) = &info.ip_info {
for subnet in &ip_info.subnets {
target.private.insert(subnet.addr());
@@ -512,16 +572,14 @@ impl NetServiceData {
let fwd_public: BTreeSet<GatewayId> = enabled_addresses
.iter()
.filter(|a| a.public)
.map(|a| a.gateway.id.clone())
.flat_map(|a| a.metadata.gateways())
.cloned()
.collect();
let fwd_private: BTreeSet<IpAddr> = enabled_addresses
.iter()
.filter(|a| !a.public)
.filter_map(|a| {
net_ifaces
.get(&a.gateway.id)
.and_then(|i| i.ip_info.as_ref())
})
.flat_map(|a| a.metadata.gateways())
.filter_map(|gw| net_ifaces.get(gw).and_then(|i| i.ip_info.as_ref()))
.flat_map(|ip| ip.subnets.iter().map(|s| s.addr()))
.collect();
forwards.insert(
@@ -634,8 +692,8 @@ impl NetServiceData {
for (port, bind) in host.bindings.0 {
if let Some(b) = bindings.as_idx_mut(&port) {
b.as_addresses_mut()
.as_possible_mut()
.ser(&bind.addresses.possible)?;
.as_available_mut()
.ser(&bind.addresses.available)?;
}
}
Ok(())

View File

@@ -1,22 +1,74 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use std::collections::BTreeSet;
use std::net::SocketAddr;
use imbl_value::InternedString;
use imbl_value::{InOMap, InternedString};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{GatewayId, HostId, ServiceInterfaceId};
use crate::prelude::*;
use crate::{GatewayId, HostId, PackageId, ServiceInterfaceId};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct HostnameInfo {
pub gateway: GatewayInfo,
pub ssl: bool,
pub public: bool,
pub hostname: IpHostname,
pub host: InternedString,
pub port: Option<u16>,
pub metadata: HostnameMetadata,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[ts(export)]
#[serde(rename_all = "kebab-case")]
#[serde(rename_all_fields = "camelCase")]
#[serde(tag = "kind")]
pub enum HostnameMetadata {
Ipv4 {
gateway: GatewayId,
},
Ipv6 {
gateway: GatewayId,
scope_id: u32,
},
PrivateDomain {
gateways: BTreeSet<GatewayId>,
},
PublicDomain {
gateway: GatewayId,
},
Plugin {
package: PackageId,
#[serde(flatten)]
extra: InOMap<InternedString, Value>,
},
}
impl HostnameInfo {
pub fn to_socket_addr(&self) -> Option<SocketAddr> {
let ip = self.host.parse().ok()?;
Some(SocketAddr::new(ip, self.port?))
}
pub fn to_san_hostname(&self) -> InternedString {
self.hostname.to_san_hostname()
self.host.clone()
}
}
impl HostnameMetadata {
pub fn is_ip(&self) -> bool {
matches!(self, Self::Ipv4 { .. } | Self::Ipv6 { .. })
}
pub fn gateways(&self) -> Box<dyn Iterator<Item = &GatewayId> + '_> {
match self {
Self::Ipv4 { gateway }
| Self::Ipv6 { gateway, .. }
| Self::PublicDomain { gateway } => Box::new(std::iter::once(gateway)),
Self::PrivateDomain { gateways } => Box::new(gateways.iter()),
Self::Plugin { .. } => Box::new(std::iter::empty()),
}
}
}
@@ -29,48 +81,6 @@ pub struct GatewayInfo {
pub public: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all_fields = "camelCase")]
#[serde(tag = "kind")]
pub enum IpHostname {
Ipv4 {
value: Ipv4Addr,
port: Option<u16>,
ssl_port: Option<u16>,
},
Ipv6 {
value: Ipv6Addr,
#[serde(default)]
scope_id: u32,
port: Option<u16>,
ssl_port: Option<u16>,
},
Local {
#[ts(type = "string")]
value: InternedString,
port: Option<u16>,
ssl_port: Option<u16>,
},
Domain {
#[ts(type = "string")]
value: InternedString,
port: Option<u16>,
ssl_port: Option<u16>,
},
}
impl IpHostname {
pub fn to_san_hostname(&self) -> InternedString {
match self {
Self::Ipv4 { value, .. } => InternedString::from_display(value),
Self::Ipv6 { value, .. } => InternedString::from_display(value),
Self::Local { value, .. } => value.clone(),
Self::Domain { value, .. } => value.clone(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]

View File

@@ -82,7 +82,6 @@ pub async fn add_tunnel(
iface.clone(),
NetworkInterfaceInfo {
name: Some(name),
public: None,
secure: None,
ip_info: None,
gateway_type,
@@ -193,15 +192,12 @@ pub async fn remove_tunnel(
.mutate(|db| {
for host in all_hosts(db) {
let host = host?;
host.as_bindings_mut().mutate(|b| {
Ok(b.values_mut().for_each(|v| {
v.addresses
.private_disabled
.retain(|h| h.gateway.id != id);
v.addresses
.public_enabled
.retain(|h| h.gateway.id != id);
}))
host.as_private_domains_mut().mutate(|d| {
for gateways in d.values_mut() {
gateways.remove(&id);
}
d.retain(|_, gateways| !gateways.is_empty());
Ok(())
})?;
}

View File

@@ -58,12 +58,12 @@ pub async fn get_ssl_certificate(
Ok(m.as_public_domains()
.keys()?
.into_iter()
.chain(m.as_private_domains().de()?)
.chain(m.as_private_domains().keys()?)
.chain(
m.as_bindings()
.de()?
.values()
.flat_map(|b| b.addresses.possible.iter().cloned())
.flat_map(|b| b.addresses.available.iter().cloned())
.map(|h| h.to_san_hostname()),
)
.collect::<Vec<InternedString>>())
@@ -182,12 +182,12 @@ pub async fn get_ssl_key(
Ok(m.as_public_domains()
.keys()?
.into_iter()
.chain(m.as_private_domains().de()?)
.chain(m.as_private_domains().keys()?)
.chain(
m.as_bindings()
.de()?
.values()
.flat_map(|b| b.addresses.possible.iter().cloned())
.flat_map(|b| b.addresses.available.iter().cloned())
.map(|h| h.to_san_hostname()),
)
.collect::<Vec<InternedString>>())

View File

@@ -164,6 +164,19 @@ fn migrate_host(host: Option<&mut Value>) {
// Remove hostnameInfo from host
host.remove("hostnameInfo");
// Migrate privateDomains from array to object (BTreeSet -> BTreeMap<_, BTreeSet<GatewayId>>)
if let Some(private_domains) = host.get("privateDomains").and_then(|v| v.as_array()).cloned() {
let mut new_pd: Value = serde_json::json!({}).into();
for domain in private_domains {
if let Some(d) = domain.as_str() {
if let Some(obj) = new_pd.as_object_mut() {
obj.insert(d.into(), serde_json::json!([]).into());
}
}
}
host.insert("privateDomains".into(), new_pd);
}
// For each binding: add "addresses" field, remove gateway-level fields from "net"
if let Some(bindings) = host.get_mut("bindings").and_then(|b| b.as_object_mut()) {
for (_, binding) in bindings.iter_mut() {
@@ -173,9 +186,9 @@ fn migrate_host(host: Option<&mut Value>) {
binding_obj.insert(
"addresses".into(),
serde_json::json!({
"privateDisabled": [],
"publicEnabled": [],
"possible": []
"enabled": [],
"disabled": [],
"available": []
})
.into(),
);

View File

@@ -5,11 +5,11 @@ export type DerivedAddressInfo = {
/**
* User-controlled: private addresses the user has disabled
*/
privateDisabled: Array<HostnameInfo>
enabled: Array<HostnameInfo>
/**
* User-controlled: public addresses the user has enabled
*/
publicEnabled: Array<HostnameInfo>
disabled: Array<HostnameInfo>
/**
* COMPUTED: NetServiceData::update — all possible addresses for this binding
*/

View File

@@ -226,12 +226,16 @@ function filterRec(
return hostnames
}
function isDefaultEnabled(h: HostnameInfo): boolean {
return !(h.public && (h.hostname.kind === 'ipv4' || h.hostname.kind === 'ipv6'))
}
function enabledAddresses(addr: DerivedAddressInfo): HostnameInfo[] {
return addr.possible.filter((h) =>
h.public
? addr.publicEnabled.some((e) => deepEqual(e, h))
: !addr.privateDisabled.some((d) => deepEqual(d, h)),
)
return addr.possible.filter((h) => {
if (addr.enabled.some((e) => deepEqual(e, h))) return true
if (addr.disabled.some((d) => deepEqual(d, h))) return false
return isDefaultEnabled(h)
})
}
export const filledAddress = (

View File

@@ -257,9 +257,9 @@ export class InterfaceService {
if (!binding) return []
const addr = binding.addresses
const enabled = addr.possible.filter(h =>
h.public
? addr.publicEnabled.some(e => utils.deepEqual(e, h))
: !addr.privateDisabled.some(d => utils.deepEqual(d, h)),
addr.enabled.some(e => utils.deepEqual(e, h)) ||
(!addr.disabled.some(d => utils.deepEqual(d, h)) &&
!(h.public && (h.hostname.kind === 'ipv4' || h.hostname.kind === 'ipv6'))),
)
return enabled.filter(
h =>

View File

@@ -134,9 +134,12 @@ export default class ServiceInterfaceRoute {
gateways:
gateways.map(g => ({
enabled:
(g.public
? binding?.addresses.publicEnabled.some(a => a.gateway.id === g.id)
: !binding?.addresses.privateDisabled.some(a => a.gateway.id === g.id)) ?? false,
(binding?.addresses.enabled.some(a => a.gateway.id === g.id) ||
(!binding?.addresses.disabled.some(a => a.gateway.id === g.id) &&
binding?.addresses.possible.some(a =>
a.gateway.id === g.id &&
!(a.public && (a.hostname.kind === 'ipv4' || a.hostname.kind === 'ipv6'))
))) ?? false,
...g,
})) || [],
publicDomains: getPublicDomains(host.publicDomains, gateways),

View File

@@ -95,9 +95,12 @@ export default class StartOsUiComponent {
),
gateways: gateways.map(g => ({
enabled:
(g.public
? binding?.addresses.publicEnabled.some(a => a.gateway.id === g.id)
: !binding?.addresses.privateDisabled.some(a => a.gateway.id === g.id)) ?? false,
(binding?.addresses.enabled.some(a => a.gateway.id === g.id) ||
(!binding?.addresses.disabled.some(a => a.gateway.id === g.id) &&
binding?.addresses.possible.some(a =>
a.gateway.id === g.id &&
!(a.public && (a.hostname.kind === 'ipv4' || a.hostname.kind === 'ipv6'))
))) ?? false,
...g,
})),
publicDomains: getPublicDomains(network.host.publicDomains, gateways),

View File

@@ -2128,8 +2128,8 @@ export namespace Mock {
assignedSslPort: 443,
},
addresses: {
privateDisabled: [],
publicEnabled: [],
enabled: [],
disabled: [],
possible: [
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
@@ -2214,8 +2214,8 @@ export namespace Mock {
assignedSslPort: null,
},
addresses: {
privateDisabled: [],
publicEnabled: [],
enabled: [],
disabled: [],
possible: [],
},
options: {
@@ -2237,8 +2237,8 @@ export namespace Mock {
assignedSslPort: null,
},
addresses: {
privateDisabled: [],
publicEnabled: [],
enabled: [],
disabled: [],
possible: [],
},
options: {

View File

@@ -40,8 +40,8 @@ export const mockPatchData: DataModel = {
assignedSslPort: 443,
},
addresses: {
privateDisabled: [],
publicEnabled: [],
enabled: [],
disabled: [],
possible: [
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
@@ -516,8 +516,8 @@ export const mockPatchData: DataModel = {
assignedSslPort: 443,
},
addresses: {
privateDisabled: [],
publicEnabled: [],
enabled: [],
disabled: [],
possible: [
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
@@ -602,8 +602,8 @@ export const mockPatchData: DataModel = {
assignedSslPort: null,
},
addresses: {
privateDisabled: [],
publicEnabled: [],
enabled: [],
disabled: [],
possible: [],
},
options: {
@@ -625,8 +625,8 @@ export const mockPatchData: DataModel = {
assignedSslPort: null,
},
addresses: {
privateDisabled: [],
publicEnabled: [],
enabled: [],
disabled: [],
possible: [],
},
options: {