From ecc2a382961a402068043e428a856b6453469101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Thu, 26 Mar 2026 11:32:53 +0100 Subject: [PATCH 1/3] feat: share catalog between linkup server and dns server --- Cargo.lock | 7 +- linkup-cli/src/commands/server.rs | 14 +--- local-server/Cargo.toml | 3 + local-server/src/lib.rs | 121 +++++++++++++++++++++--------- server-tests/tests/helpers.rs | 4 +- 5 files changed, 96 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80f07d7a..d0f65b49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,9 +156,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -1759,6 +1759,7 @@ dependencies = [ name = "linkup-local-server" version = "0.1.0" dependencies = [ + "async-trait", "axum 0.8.1", "axum-server", "futures", @@ -1772,6 +1773,8 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-pemfile", + "serde", + "serde_json", "thiserror 2.0.11", "tokio", "tokio-tungstenite 0.28.0", diff --git a/linkup-cli/src/commands/server.rs b/linkup-cli/src/commands/server.rs index 75213b47..4b1e260b 100644 --- a/linkup-cli/src/commands/server.rs +++ b/linkup-cli/src/commands/server.rs @@ -5,12 +5,6 @@ use linkup::MemoryStringStore; #[derive(clap::Args)] pub struct Args { - #[arg(long)] - session_name: String, - - #[arg(long, value_parser, num_args = 1.., value_delimiter = ',')] - domains: Vec, - #[arg(long)] certs_dir: String, } @@ -19,13 +13,7 @@ pub async fn server(args: &Args) -> Result<()> { let config_store = MemoryStringStore::default(); let https_certs_dir = PathBuf::from(&args.certs_dir); - linkup_local_server::start( - config_store, - &https_certs_dir, - args.session_name.clone(), - args.domains.clone(), - ) - .await; + linkup_local_server::start(config_store, &https_certs_dir).await; Ok(()) } diff --git a/local-server/Cargo.toml b/local-server/Cargo.toml index a476930d..874f7cd2 100644 --- a/local-server/Cargo.toml +++ b/local-server/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] axum = { version = "0.8.1", features = ["http2", "json", "ws"] } axum-server = { version = "0.8.0", features = ["tls-rustls"] } +async-trait = "0.1.43" http = "1.2.0" hickory-server = { version = "0.25.1", features = ["resolver"] } hyper = { version = "1.5.2", features = ["server"] } @@ -22,6 +23,8 @@ futures = "0.3.31" linkup = { path = "../linkup" } rustls = { version = "0.23.37", default-features = false, features = ["ring"] } rustls-native-certs = "0.8.1" +serde = "1.0.217" +serde_json = "1.0.137" thiserror = "2.0.11" tokio = { version = "1.49.0", features = [ "macros", diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index bf199038..26be1c56 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -17,6 +17,7 @@ use hickory_server::{ config::{NameServerConfig, NameServerConfigGroup, ResolverOpts}, name_server::TokioConnectionProvider, }, + server::{RequestHandler, ResponseHandler, ResponseInfo}, store::{ forwarder::{ForwardAuthority, ForwardConfig}, in_memory::InMemoryAuthority, @@ -34,13 +35,15 @@ use linkup::{ Session, SessionAllocator, TargetService, UpdateSessionRequest, }; use rustls::ServerConfig; +use serde::Deserialize; use std::{ net::{Ipv4Addr, SocketAddr}, + ops::Deref, path::PathBuf, str::FromStr, }; use std::{path::Path, sync::Arc}; -use tokio::{net::UdpSocket, select, signal}; +use tokio::{net::UdpSocket, select, signal, sync::RwLock}; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tower::ServiceBuilder; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; @@ -80,14 +83,46 @@ impl IntoResponse for ApiError { } } -pub fn linkup_router(config_store: MemoryStringStore) -> Router { +#[derive(Clone)] +pub struct DnsCatalog(Arc>); + +impl DnsCatalog { + pub fn new() -> Self { + Self(Arc::new(RwLock::new(Catalog::new()))) + } +} + +impl Deref for DnsCatalog { + type Target = Arc>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[async_trait::async_trait] +impl RequestHandler for DnsCatalog { + async fn handle_request( + &self, + request: &hickory_server::server::Request, + response_handle: R, + ) -> ResponseInfo { + let catalog = self.read().await; + + catalog.handle_request(request, response_handle).await + } +} + +pub fn linkup_router(config_store: MemoryStringStore, dns_catalog: DnsCatalog) -> Router { let client = https_client(); Router::new() .route("/linkup/local-session", post(linkup_config_handler)) .route("/linkup/check", get(always_ok)) + .route("/linkup/dns/records", post(dns_create)) // TODO: Modify me .fallback(any(linkup_request_handler)) .layer(Extension(config_store)) + .layer(Extension(dns_catalog)) .layer(Extension(client)) .layer( ServiceBuilder::new() @@ -100,24 +135,21 @@ pub fn linkup_router(config_store: MemoryStringStore) -> Router { ) } -pub async fn start( - config_store: MemoryStringStore, - certs_dir: &Path, - session_name: String, - domains: Vec, -) { +pub async fn start(config_store: MemoryStringStore, certs_dir: &Path) { + let dns_catalog = DnsCatalog::new(); + let http_config_store = config_store.clone(); let https_config_store = config_store.clone(); let https_certs_dir = PathBuf::from(certs_dir); select! { - () = start_server_http(http_config_store) => { + () = start_server_http(http_config_store, dns_catalog.clone()) => { println!("HTTP server shut down"); }, - () = start_server_https(https_config_store, &https_certs_dir) => { + () = start_server_https(https_config_store, &https_certs_dir, dns_catalog.clone()) => { println!("HTTPS server shut down"); }, - () = start_dns_server(session_name, domains) => { + () = start_dns_server(dns_catalog.clone()) => { println!("DNS server shut down"); }, () = shutdown_signal() => { @@ -126,7 +158,11 @@ pub async fn start( } } -async fn start_server_https(config_store: MemoryStringStore, certs_dir: &Path) { +async fn start_server_https( + config_store: MemoryStringStore, + certs_dir: &Path, + dns_catalog: DnsCatalog, +) { let _ = rustls::crypto::ring::default_provider().install_default(); let sni = match certificates::WildcardSniResolver::load_dir(certs_dir) { @@ -145,7 +181,7 @@ async fn start_server_https(config_store: MemoryStringStore, certs_dir: &Path) { .with_cert_resolver(Arc::new(sni)); server_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - let app = linkup_router(config_store); + let app = linkup_router(config_store, dns_catalog); let addr = SocketAddr::from(([0, 0, 0, 0], 443)); println!("HTTPS listening on {}", &addr); @@ -156,8 +192,8 @@ async fn start_server_https(config_store: MemoryStringStore, certs_dir: &Path) { .expect("failed to start HTTPS server"); } -async fn start_server_http(config_store: MemoryStringStore) { - let app = linkup_router(config_store); +async fn start_server_http(config_store: MemoryStringStore, dns_catalog: DnsCatalog) { + let app = linkup_router(config_store, dns_catalog); let addr = SocketAddr::from(([0, 0, 0, 0], 80)); println!("HTTP listening on {}", &addr); @@ -171,25 +207,7 @@ async fn start_server_http(config_store: MemoryStringStore) { .expect("failed to start HTTP server"); } -async fn start_dns_server(linkup_session_name: String, domains: Vec) { - let mut catalog = Catalog::new(); - - for domain in &domains { - let record_name = Name::from_str(&format!("{linkup_session_name}.{domain}.")).unwrap(); - - let authority = InMemoryAuthority::empty(record_name.clone(), ZoneType::Primary, false); - - let record = Record::from_rdata( - record_name.clone(), - 3600, - RData::A(Ipv4Addr::new(127, 0, 0, 1).into()), - ); - - authority.upsert(record, 0).await; - - catalog.upsert(record_name.clone().into(), vec![Arc::new(authority)]); - } - +async fn start_dns_server(dns_catalog: DnsCatalog) { let cf_name_server = NameServerConfig::new("1.1.1.1:53".parse().unwrap(), Protocol::Udp); let forward_config = ForwardConfig { name_servers: NameServerConfigGroup::from(vec![cf_name_server]), @@ -202,12 +220,15 @@ async fn start_dns_server(linkup_session_name: String, domains: Vec) { .build() .unwrap(); - catalog.upsert(Name::root().into(), vec![Arc::new(forwarder)]); + { + let mut catalog = dns_catalog.write().await; + catalog.upsert(Name::root().into(), vec![Arc::new(forwarder)]); + } let addr = SocketAddr::from(([0, 0, 0, 0], 8053)); let sock = UdpSocket::bind(&addr).await.unwrap(); - let mut server = ServerFuture::new(catalog); + let mut server = ServerFuture::new(dns_catalog); server.register_socket(sock); println!("listening on {addr}"); @@ -427,6 +448,34 @@ async fn always_ok() -> &'static str { "OK" } +#[derive(Deserialize)] +pub struct CreateDnsRecord { + pub record: String, +} + +async fn dns_create( + Extension(dns_catalog): Extension, + Json(payload): Json, +) -> impl IntoResponse { + let mut catalog = dns_catalog.write().await; + + let record_name = Name::from_str(&format!("{}.", payload.record)).unwrap(); + + let authority = InMemoryAuthority::empty(record_name.clone(), ZoneType::Primary, false); + + let record = Record::from_rdata( + record_name.clone(), + 3600, + RData::A(Ipv4Addr::new(127, 0, 0, 1).into()), + ); + + authority.upsert(record, 0).await; + + catalog.upsert(record_name.clone().into(), vec![Arc::new(authority)]); + + StatusCode::CREATED.into_response() +} + fn https_client() -> HttpsClient { let _ = rustls::crypto::ring::default_provider().install_default(); diff --git a/server-tests/tests/helpers.rs b/server-tests/tests/helpers.rs index 97407731..b1a39c3b 100644 --- a/server-tests/tests/helpers.rs +++ b/server-tests/tests/helpers.rs @@ -1,7 +1,7 @@ use std::process::Command; use linkup::{Domain, MemoryStringStore, SessionService, UpdateSessionRequest}; -use linkup_local_server::linkup_router; +use linkup_local_server::{linkup_router, DnsCatalog}; use reqwest::Url; use tokio::net::TcpListener; @@ -14,7 +14,7 @@ pub enum ServerKind { pub async fn setup_server(kind: ServerKind) -> String { match kind { ServerKind::Local => { - let app = linkup_router(MemoryStringStore::default()); + let app = linkup_router(MemoryStringStore::default(), DnsCatalog::new()); // Bind to a random port assigned by the OS let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); From 5e66d8e9f9b77550c6d6754ecbbe24cfb214dd2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Thu, 26 Mar 2026 11:49:13 +0100 Subject: [PATCH 2/3] feat: register domains on startup --- linkup-cli/src/services/local_server.rs | 22 ++++++++++++------- linkup-cli/src/worker_client.rs | 28 ++++++++++++++++++------- local-server/src/lib.rs | 4 ++-- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/linkup-cli/src/services/local_server.rs b/linkup-cli/src/services/local_server.rs index ec6222bd..b3c99694 100644 --- a/linkup-cli/src/services/local_server.rs +++ b/linkup-cli/src/services/local_server.rs @@ -13,9 +13,10 @@ use tokio::time::sleep; use url::Url; use crate::{ - linkup_certs_dir_path, linkup_file_path, + linkup_certs_dir_path, linkup_file_path, services, state::{upload_state, State}, - worker_client, Result, + worker_client::{self, WorkerClient}, + Result, }; use super::{BackgroundService, PidError}; @@ -50,7 +51,7 @@ impl LocalServer { Url::parse("http://localhost:80").expect("linkup url invalid") } - fn start(&self, session_name: String, domains: Vec) -> Result<()> { + fn start(&self) -> Result<()> { log::debug!("Starting {}", Self::NAME); let stdout_file = File::create(&self.stdout_file_path)?; @@ -66,10 +67,6 @@ impl LocalServer { command.env("LINKUP_SERVICE_ID", Self::ID); command.args([ "server", - "--session-name", - &session_name, - "--domains", - &domains.join(","), "--certs-dir", linkup_certs_dir_path().to_str().unwrap(), ]); @@ -132,7 +129,7 @@ impl BackgroundService for LocalServer { return Ok(()); } - if let Err(e) = self.start(session_name, domains) { + if let Err(e) = self.start() { self.notify_update_with_details( &status_sender, super::RunStatus::Error, @@ -171,6 +168,15 @@ impl BackgroundService for LocalServer { } } + // TODO(augustoccesar)[2026-03-26]: Maybe send all the domains on one request? + for domain in &domains { + let full_domain = format!("{session_name}.{domain}"); + + WorkerClient::new(&services::LocalServer::url(), "") + .create_dns_record(&full_domain) + .await?; + } + match self.update_state(state).await { Ok(_) => { self.notify_update(&status_sender, super::RunStatus::Started); diff --git a/linkup-cli/src/worker_client.rs b/linkup-cli/src/worker_client.rs index 711cb345..5ec849ad 100644 --- a/linkup-cli/src/worker_client.rs +++ b/linkup-cli/src/worker_client.rs @@ -70,6 +70,21 @@ impl WorkerClient { self.post("/linkup/local-session", params).await } + pub async fn create_dns_record(&self, domain: &str) -> Result<(), Error> { + #[derive(Serialize)] + struct Params { + domain: String, + } + + let params = Params { + domain: domain.to_owned(), + }; + + self.post("/linkup/dns/records", ¶ms).await?; + + Ok(()) + } + pub async fn get_tunnel(&self, session_name: &str) -> Result { let query = GetTunnelParams { session_name: String::from(session_name), @@ -101,15 +116,14 @@ impl WorkerClient { .send() .await?; - match response.status() { - StatusCode::OK => { - let content = response.text().await?; - Ok(content) - } - _ => Err(Error::Response( + if response.status().is_success() { + let content = response.text().await?; + Ok(content) + } else { + Err(Error::Response( response.status(), response.text().await.unwrap_or_else(|_| "".to_string()), - )), + )) } } } diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index 26be1c56..9504b52d 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -450,7 +450,7 @@ async fn always_ok() -> &'static str { #[derive(Deserialize)] pub struct CreateDnsRecord { - pub record: String, + pub domain: String, } async fn dns_create( @@ -459,7 +459,7 @@ async fn dns_create( ) -> impl IntoResponse { let mut catalog = dns_catalog.write().await; - let record_name = Name::from_str(&format!("{}.", payload.record)).unwrap(); + let record_name = Name::from_str(&format!("{}.", payload.domain)).unwrap(); let authority = InMemoryAuthority::empty(record_name.clone(), ZoneType::Primary, false); From c05a5ada315014352e3e95d9a1e29b21e7f46fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Thu, 26 Mar 2026 16:39:21 +0100 Subject: [PATCH 3/3] fix: add default for DnsCatalog --- local-server/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index 9504b52d..dc45c020 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -92,6 +92,12 @@ impl DnsCatalog { } } +impl Default for DnsCatalog { + fn default() -> Self { + Self::new() + } +} + impl Deref for DnsCatalog { type Target = Arc>;