feat: tunnel TS exports, port forward labels, and db migrations

- Add TS derive and type annotations to all tunnel API param structs
- Export tunnel bindings to a tunnel/ subdirectory with index generation
- Change port forward label from String to Option<String>
- Add TunnelDatabase::init() with default subnet creation
- Add tunnel migration framework with m_00_port_forward_entry migration
  to convert legacy string-only port forwards to the new entry format
This commit is contained in:
Aiden McClelland
2026-03-12 13:38:01 -06:00
parent fd54e9ca91
commit c485edfa12
32 changed files with 288 additions and 39 deletions

View File

@@ -5,6 +5,7 @@ use imbl_value::InternedString;
use ipnet::Ipv4Net;
use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::context::CliContext;
use crate::db::model::public::NetworkInterfaceType;
@@ -90,9 +91,10 @@ pub fn tunnel_api<C: Context>() -> ParentHandler<C> {
)
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct SubnetParams {
#[ts(type = "string")]
subnet: Ipv4Net,
}
@@ -168,7 +170,7 @@ pub fn device_api<C: Context>() -> ParentHandler<C> {
)
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct AddSubnetParams {
name: InternedString,
@@ -293,11 +295,13 @@ pub async fn remove_subnet(
Ok(())
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct AddDeviceParams {
#[ts(type = "string")]
subnet: Ipv4Net,
name: InternedString,
#[ts(type = "string | null")]
ip: Option<Ipv4Addr>,
}
@@ -354,10 +358,12 @@ pub async fn add_device(
server.sync().await
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct RemoveDeviceParams {
#[ts(type = "string")]
subnet: Ipv4Net,
#[ts(type = "string")]
ip: Ipv4Addr,
}
@@ -383,9 +389,10 @@ pub async fn remove_device(
ctx.gc_forwards(&keep).await
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct ListDevicesParams {
#[ts(type = "string")]
subnet: Ipv4Net,
}
@@ -403,14 +410,18 @@ pub async fn list_devices(
.de()
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct ShowConfigParams {
#[ts(type = "string")]
subnet: Ipv4Net,
#[ts(type = "string")]
ip: Ipv4Addr,
#[ts(type = "string | null")]
wan_addr: Option<IpAddr>,
#[serde(rename = "__ConnectInfo_local_addr")]
#[arg(skip)]
#[ts(skip)]
local_addr: Option<SocketAddr>,
}
@@ -465,13 +476,15 @@ pub async fn show_config(
.to_string())
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct AddPortForwardParams {
#[ts(type = "string")]
source: SocketAddrV4,
#[ts(type = "string")]
target: SocketAddrV4,
#[arg(long)]
label: String,
label: Option<String>,
}
pub async fn add_forward(
@@ -505,7 +518,11 @@ pub async fn add_forward(
m.insert(source, rc);
});
let entry = PortForwardEntry { target, label, enabled: true };
let entry = PortForwardEntry {
target,
label,
enabled: true,
};
ctx.db
.mutate(|db| {
@@ -528,9 +545,10 @@ pub async fn add_forward(
Ok(())
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct RemovePortForwardParams {
#[ts(type = "string")]
source: SocketAddrV4,
}
@@ -549,11 +567,12 @@ pub async fn remove_forward(
Ok(())
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePortForwardLabelParams {
#[ts(type = "string")]
source: SocketAddrV4,
label: String,
label: Option<String>,
}
pub async fn update_forward_label(
@@ -569,7 +588,7 @@ pub async fn update_forward_label(
ErrorKind::NotFound,
)
})?;
entry.label = label.clone();
entry.label = label;
Ok(())
})
})
@@ -577,9 +596,10 @@ pub async fn update_forward_label(
.result
}
#[derive(Deserialize, Serialize, Parser)]
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct SetPortForwardEnabledParams {
#[ts(type = "string")]
source: SocketAddrV4,
enabled: bool,
}

View File

@@ -10,8 +10,8 @@ use http::HeaderMap;
use imbl::OrdMap;
use imbl_value::InternedString;
use include_dir::Dir;
use ipnet::Ipv4Net;
use patch_db::PatchDb;
use patch_db::json_ptr::ROOT;
use rpc_toolkit::yajrc::RpcError;
use rpc_toolkit::{CallRemote, Context, Empty, ParentHandler};
use serde::{Deserialize, Serialize};
@@ -34,7 +34,8 @@ use crate::rpc_continuations::{OpenAuthedContinuations, RpcContinuations};
use crate::tunnel::TUNNEL_DEFAULT_LISTEN;
use crate::tunnel::api::tunnel_api;
use crate::tunnel::db::TunnelDatabase;
use crate::tunnel::wg::{WIREGUARD_INTERFACE_NAME, WgSubnetConfig};
use crate::tunnel::migrations::run_migrations;
use crate::tunnel::wg::WIREGUARD_INTERFACE_NAME;
use crate::util::collections::OrdMapIterMut;
use crate::util::io::read_file_to_string;
use crate::util::sync::{SyncMutex, Watch};
@@ -98,21 +99,11 @@ impl TunnelContext {
tokio::fs::create_dir_all(&datadir).await?;
}
let db_path = datadir.join("tunnel.db");
let db = TypedPatchDb::<TunnelDatabase>::load_or_init(
PatchDb::open(&db_path).await?,
|| async {
let mut db = TunnelDatabase::default();
db.wg.subnets.0.insert(
Ipv4Net::new_assert([10, 59, rand::random(), 1].into(), 24),
WgSubnetConfig {
name: "Default Subnet".into(),
..Default::default()
},
);
Ok(db)
},
)
.await?;
let db = TypedPatchDb::<TunnelDatabase>::load_unchecked(PatchDb::open(&db_path).await?);
if db.dump(&ROOT).await.value.is_null() {
db.put(&ROOT, &TunnelDatabase::init()).await?;
}
db.mutate(|db| run_migrations(db)).await.result?;
let listen = config.tunnel_listen.unwrap_or(TUNNEL_DEFAULT_LISTEN);
let ip_info = crate::net::utils::load_ip_info().await?;
let net_iface = db

View File

@@ -7,6 +7,7 @@ use axum::extract::ws;
use clap::Parser;
use imbl::{HashMap, OrdMap};
use imbl_value::InternedString;
use ipnet::Ipv4Net;
use itertools::Itertools;
use patch_db::Dump;
use patch_db::json_ptr::{JsonPointer, ROOT};
@@ -25,25 +26,49 @@ use crate::rpc_continuations::{Guid, RpcContinuation};
use crate::sign::AnyVerifyingKey;
use crate::tunnel::auth::SignerInfo;
use crate::tunnel::context::TunnelContext;
use crate::tunnel::migrations;
use crate::tunnel::web::WebserverInfo;
use crate::tunnel::wg::WgServer;
use crate::tunnel::wg::{WgServer, WgSubnetConfig};
use crate::util::serde::{HandlerExtSerde, apply_expr};
#[derive(Default, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
pub struct TunnelDatabase {
#[serde(default)]
#[ts(skip)]
pub migrations: BTreeSet<InternedString>,
pub webserver: WebserverInfo,
pub sessions: Sessions,
pub password: Option<String>,
#[ts(as = "std::collections::HashMap::<AnyVerifyingKey, SignerInfo>")]
pub auth_pubkeys: HashMap<AnyVerifyingKey, SignerInfo>,
#[ts(as = "std::collections::BTreeMap::<AnyVerifyingKey, SignerInfo>")]
#[ts(as = "std::collections::BTreeMap::<GatewayId, NetworkInterfaceInfo>")]
pub gateways: OrdMap<GatewayId, NetworkInterfaceInfo>,
pub wg: WgServer,
pub port_forwards: PortForwards,
}
impl TunnelDatabase {
pub fn init() -> Self {
let mut db = Self {
migrations: migrations::MIGRATIONS
.iter()
.map(|m| m.name().into())
.collect(),
..Default::default()
};
db.wg.subnets.0.insert(
Ipv4Net::new_assert([10, 59, rand::random(), 1].into(), 24),
WgSubnetConfig {
name: "Default Subnet".into(),
..Default::default()
},
);
db
}
}
impl Model<TunnelDatabase> {
pub fn gc_forwards(&mut self) -> Result<BTreeSet<SocketAddrV4>, Error> {
let mut keep_sources = BTreeSet::new();
@@ -67,15 +92,30 @@ impl Model<TunnelDatabase> {
#[test]
fn export_bindings_tunnel_db() {
use crate::tunnel::api::*;
use crate::tunnel::auth::{AddKeyParams, RemoveKeyParams, SetPasswordParams};
TunnelDatabase::export_all_to("bindings/tunnel").unwrap();
SubnetParams::export_all_to("bindings/tunnel").unwrap();
AddSubnetParams::export_all_to("bindings/tunnel").unwrap();
AddDeviceParams::export_all_to("bindings/tunnel").unwrap();
RemoveDeviceParams::export_all_to("bindings/tunnel").unwrap();
ListDevicesParams::export_all_to("bindings/tunnel").unwrap();
ShowConfigParams::export_all_to("bindings/tunnel").unwrap();
AddPortForwardParams::export_all_to("bindings/tunnel").unwrap();
RemovePortForwardParams::export_all_to("bindings/tunnel").unwrap();
UpdatePortForwardLabelParams::export_all_to("bindings/tunnel").unwrap();
SetPortForwardEnabledParams::export_all_to("bindings/tunnel").unwrap();
AddKeyParams::export_all_to("bindings/tunnel").unwrap();
RemoveKeyParams::export_all_to("bindings/tunnel").unwrap();
SetPasswordParams::export_all_to("bindings/tunnel").unwrap();
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct PortForwardEntry {
pub target: SocketAddrV4,
#[serde(default)]
pub label: String,
pub label: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}

View File

@@ -0,0 +1,20 @@
use imbl_value::json;
use super::TunnelMigration;
use crate::prelude::*;
pub struct PortForwardEntry;
impl TunnelMigration for PortForwardEntry {
fn action(&self, db: &mut Value) -> Result<(), Error> {
for (_, value) in db["portForwards"].as_object_mut().unwrap().iter_mut() {
if value.is_string() {
*value = json!({
"target": value.clone(),
"label": null,
"enabled": true,
});
}
}
Ok(())
}
}

View File

@@ -0,0 +1,34 @@
use patch_db::ModelExt;
use crate::prelude::*;
use crate::tunnel::db::TunnelDatabase;
mod m_00_port_forward_entry;
pub trait TunnelMigration {
fn name(&self) -> &'static str {
let val = std::any::type_name_of_val(self);
val.rsplit_once("::").map_or(val, |v| v.1)
}
fn action(&self, db: &mut Value) -> Result<(), Error>;
}
pub const MIGRATIONS: &[&dyn TunnelMigration] = &[
&m_00_port_forward_entry::PortForwardEntry,
];
#[instrument(skip_all)]
pub fn run_migrations(db: &mut Model<TunnelDatabase>) -> Result<(), Error> {
let mut migrations = db.as_migrations().de().unwrap_or_default();
for migration in MIGRATIONS {
let name = migration.name();
if !migrations.contains(name) {
migration.action(ModelExt::as_value_mut(db))?;
migrations.insert(name.into());
}
}
let mut db_deser = db.de()?;
db_deser.migrations = migrations;
db.ser(&db_deser)?;
Ok(())
}

View File

@@ -9,6 +9,7 @@ pub mod api;
pub mod auth;
pub mod context;
pub mod db;
pub(crate) mod migrations;
pub mod update;
pub mod web;
pub mod wg;