init
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
use clap::Parser;
|
||||
use color_eyre::Result;
|
||||
|
||||
use bore::client::{ClientArgs, run};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
if std::env::var_os("RUST_LOG").is_some() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
}
|
||||
|
||||
let args = ClientArgs::parse();
|
||||
run(args).await
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use clap::Parser;
|
||||
use color_eyre::Result;
|
||||
|
||||
use bore::server::{ServerArgs, run};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = ServerArgs::parse();
|
||||
run(args).await
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
pub mod relay;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use color_eyre::Result;
|
||||
use owo_colors::OwoColorize;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::protocol::{
|
||||
ClientMessage, ServerMessage, TunnelProtocol, read_server_message, write_client_message,
|
||||
};
|
||||
use crate::tunnel::TunnelInfo;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ClientArgs {
|
||||
/// Server address (host:port)
|
||||
#[arg(long, env = "BORE_SERVER")]
|
||||
pub server: String,
|
||||
|
||||
/// TCP forwards: [HOST:]PORT:REMOTE_PORT
|
||||
#[arg(long = "tcp", value_name = "[HOST:]PORT:REMOTE", env = "BORE_TCP")]
|
||||
pub tcp_forwards: Vec<String>,
|
||||
|
||||
/// UDP forwards: [HOST:]PORT:REMOTE_PORT
|
||||
#[arg(long = "udp", value_name = "[HOST:]PORT:REMOTE", env = "BORE_UDP")]
|
||||
pub udp_forwards: Vec<String>,
|
||||
|
||||
/// HTTP forwards: [HOST:]PORT[:SUBDOMAIN]
|
||||
#[arg(
|
||||
long = "http",
|
||||
value_name = "[HOST:]PORT[:SUBDOMAIN]",
|
||||
env = "BORE_HTTP"
|
||||
)]
|
||||
pub http_forwards: Vec<String>,
|
||||
}
|
||||
|
||||
struct ForwardSpec {
|
||||
protocol: TunnelProtocol,
|
||||
/// Target address to connect to, e.g. "127.0.0.1:8080" or "10.0.4.2:3000"
|
||||
target: String,
|
||||
/// Port to tell the server (extracted from target)
|
||||
local_port: u16,
|
||||
remote_port: Option<u16>,
|
||||
subdomain: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a target spec like "8080", "10.0.4.2:8080" into (host, port).
|
||||
/// Bare port defaults host to "127.0.0.1".
|
||||
fn parse_host_port(s: &str) -> Result<(String, u16)> {
|
||||
if let Ok(port) = s.parse::<u16>() {
|
||||
return Ok(("127.0.0.1".to_string(), port));
|
||||
}
|
||||
// Try as host:port
|
||||
let (host, port_str) = s
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("invalid address: {s}"))?;
|
||||
let port: u16 = port_str.parse()?;
|
||||
Ok((host.to_string(), port))
|
||||
}
|
||||
|
||||
fn parse_forwards(args: &ClientArgs) -> Result<Vec<ForwardSpec>> {
|
||||
let mut specs = Vec::new();
|
||||
|
||||
// TCP/UDP: last segment is always REMOTE_PORT, everything before is [HOST:]PORT
|
||||
// e.g. "8080:9000", "10.0.4.2:8080:9000"
|
||||
for s in &args.tcp_forwards {
|
||||
let (target_part, remote_str) = s.rsplit_once(':').ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!("invalid TCP forward: {s} (expected [HOST:]PORT:REMOTE)")
|
||||
})?;
|
||||
let remote_port: u16 = remote_str.parse()?;
|
||||
let (host, port) = parse_host_port(target_part)?;
|
||||
specs.push(ForwardSpec {
|
||||
protocol: TunnelProtocol::Tcp,
|
||||
target: format!("{host}:{port}"),
|
||||
local_port: port,
|
||||
remote_port: Some(remote_port),
|
||||
subdomain: None,
|
||||
});
|
||||
}
|
||||
|
||||
for s in &args.udp_forwards {
|
||||
let (target_part, remote_str) = s.rsplit_once(':').ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!("invalid UDP forward: {s} (expected [HOST:]PORT:REMOTE)")
|
||||
})?;
|
||||
let remote_port: u16 = remote_str.parse()?;
|
||||
let (host, port) = parse_host_port(target_part)?;
|
||||
specs.push(ForwardSpec {
|
||||
protocol: TunnelProtocol::Udp,
|
||||
target: format!("{host}:{port}"),
|
||||
local_port: port,
|
||||
remote_port: Some(remote_port),
|
||||
subdomain: None,
|
||||
});
|
||||
}
|
||||
|
||||
// HTTP: split from the right — last segment that's NOT a valid port is subdomain
|
||||
// e.g. "3000", "3000:myapp", "10.0.4.2:3000", "10.0.4.2:3000:myapp"
|
||||
for s in &args.http_forwards {
|
||||
let parts: Vec<&str> = s.split(':').collect();
|
||||
match parts.len() {
|
||||
// "3000"
|
||||
1 => {
|
||||
let port: u16 = parts[0].parse()?;
|
||||
specs.push(ForwardSpec {
|
||||
protocol: TunnelProtocol::Http,
|
||||
target: format!("127.0.0.1:{port}"),
|
||||
local_port: port,
|
||||
remote_port: None,
|
||||
subdomain: None,
|
||||
});
|
||||
}
|
||||
// "3000:myapp" or "10.0.4.2:3000"
|
||||
2 => {
|
||||
if let Ok(port) = parts[1].parse::<u16>() {
|
||||
// "10.0.4.2:3000"
|
||||
specs.push(ForwardSpec {
|
||||
protocol: TunnelProtocol::Http,
|
||||
target: format!("{}:{port}", parts[0]),
|
||||
local_port: port,
|
||||
remote_port: None,
|
||||
subdomain: None,
|
||||
});
|
||||
} else {
|
||||
// "3000:myapp"
|
||||
let port: u16 = parts[0].parse()?;
|
||||
specs.push(ForwardSpec {
|
||||
protocol: TunnelProtocol::Http,
|
||||
target: format!("127.0.0.1:{port}"),
|
||||
local_port: port,
|
||||
remote_port: None,
|
||||
subdomain: Some(parts[1].to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
// "10.0.4.2:3000:myapp"
|
||||
3 => {
|
||||
let port: u16 = parts[1].parse()?;
|
||||
specs.push(ForwardSpec {
|
||||
protocol: TunnelProtocol::Http,
|
||||
target: format!("{}:{port}", parts[0]),
|
||||
local_port: port,
|
||||
remote_port: None,
|
||||
subdomain: Some(parts[2].to_string()),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
return Err(color_eyre::eyre::eyre!(
|
||||
"invalid HTTP forward: {s} (expected [HOST:]PORT[:SUBDOMAIN])"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
pub async fn run(args: ClientArgs) -> Result<()> {
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
let forwards = parse_forwards(&args)?;
|
||||
if forwards.is_empty() {
|
||||
return Err(color_eyre::eyre::eyre!("no forwards specified"));
|
||||
}
|
||||
|
||||
// Append default port if not specified
|
||||
let server_str = if args.server.contains(':') {
|
||||
args.server.clone()
|
||||
} else {
|
||||
format!("{}:4843", args.server)
|
||||
};
|
||||
|
||||
// Parse server address
|
||||
let server_addr: SocketAddr = tokio::net::lookup_host(&server_str)
|
||||
.await?
|
||||
.next()
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("could not resolve server address: {server_str}"))?;
|
||||
|
||||
let server_host = server_str.split(':').next().unwrap_or("localhost");
|
||||
|
||||
// Create QUIC endpoint with TOFU certificate verification
|
||||
let (endpoint, tofu_state) =
|
||||
crate::quic::make_tofu_client_endpoint("0.0.0.0:0".parse()?, &server_str)?;
|
||||
|
||||
// Resolve secret: saved > prompt
|
||||
let secret = if let Some(s) = crate::config::load_secret(&server_str)? {
|
||||
s
|
||||
} else {
|
||||
tokio::task::spawn_blocking(|| inquire::Text::new("Secret:").prompt())
|
||||
.await?
|
||||
.map_err(|e| color_eyre::eyre::eyre!("prompt cancelled: {e}"))?
|
||||
};
|
||||
|
||||
eprintln!("{}", format!("Connecting to {server_addr}...").dimmed());
|
||||
let connection = endpoint.connect(server_addr, server_host)?.await?;
|
||||
|
||||
// Persist TOFU fingerprint now that the connection succeeded
|
||||
tofu_state.save_if_new()?;
|
||||
|
||||
// Open control stream
|
||||
let (mut send, mut recv) = connection.open_bi().await?;
|
||||
|
||||
// Authenticate
|
||||
write_client_message(
|
||||
&mut send,
|
||||
&ClientMessage::Auth {
|
||||
secret: secret.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let reply = read_server_message(&mut recv).await?;
|
||||
match reply {
|
||||
ServerMessage::AuthOk => {
|
||||
crate::config::save_secret(&server_str, &secret)?;
|
||||
}
|
||||
ServerMessage::Error { message } => {
|
||||
return Err(color_eyre::eyre::eyre!("authentication failed: {message}"));
|
||||
}
|
||||
_ => {
|
||||
return Err(color_eyre::eyre::eyre!("unexpected response to auth"));
|
||||
}
|
||||
}
|
||||
|
||||
// Request tunnels and collect tunnel_id -> TunnelInfo mapping
|
||||
let mut tunnel_map: HashMap<u64, TunnelInfo> = HashMap::new();
|
||||
|
||||
eprintln!();
|
||||
for spec in &forwards {
|
||||
write_client_message(
|
||||
&mut send,
|
||||
&ClientMessage::RequestTunnel {
|
||||
protocol: spec.protocol,
|
||||
local_port: spec.local_port,
|
||||
remote_port: spec.remote_port,
|
||||
subdomain: spec.subdomain.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let reply = read_server_message(&mut recv).await?;
|
||||
match reply {
|
||||
ServerMessage::TunnelCreated {
|
||||
tunnel_id,
|
||||
protocol,
|
||||
assigned_port,
|
||||
assigned_subdomain,
|
||||
} => {
|
||||
match protocol {
|
||||
TunnelProtocol::Tcp => {
|
||||
eprintln!(
|
||||
"{} {} {} server:{}",
|
||||
"tcp ".green().bold(),
|
||||
spec.target,
|
||||
"<-".dimmed(),
|
||||
assigned_port.unwrap_or(0),
|
||||
);
|
||||
}
|
||||
TunnelProtocol::Udp => {
|
||||
eprintln!(
|
||||
"{} {} {} server:{}",
|
||||
"udp ".yellow().bold(),
|
||||
spec.target,
|
||||
"<-".dimmed(),
|
||||
assigned_port.unwrap_or(0),
|
||||
);
|
||||
}
|
||||
TunnelProtocol::Http => {
|
||||
eprintln!(
|
||||
"{} {} {} {}",
|
||||
"http".cyan().bold(),
|
||||
spec.target,
|
||||
"<-".dimmed(),
|
||||
assigned_subdomain.as_deref().unwrap_or("unknown"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tunnel_map.insert(
|
||||
tunnel_id,
|
||||
TunnelInfo {
|
||||
id: tunnel_id,
|
||||
protocol,
|
||||
target: spec.target.clone(),
|
||||
remote_port: assigned_port,
|
||||
subdomain: assigned_subdomain,
|
||||
},
|
||||
);
|
||||
}
|
||||
ServerMessage::Error { message } => {
|
||||
eprintln!("{} {message}", "error".red().bold());
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{} unexpected server response", "error".red().bold());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tunnel_map.is_empty() {
|
||||
return Err(color_eyre::eyre::eyre!("no tunnels were created"));
|
||||
}
|
||||
|
||||
let tunnel_map = Arc::new(tunnel_map);
|
||||
|
||||
eprintln!();
|
||||
eprintln!("{}", "Ready. Press Ctrl+C to exit.".dimmed());
|
||||
relay::accept_streams(connection, tunnel_map, cancel).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use color_eyre::Result;
|
||||
use owo_colors::OwoColorize;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::protocol::{TunnelProtocol, read_stream_header, read_udp_frame, write_udp_frame};
|
||||
use crate::relay::{QuicBiStream, relay};
|
||||
use crate::tunnel::TunnelInfo;
|
||||
|
||||
static NEXT_CONN_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Accept incoming QUIC bidirectional streams from the server and relay to local services.
|
||||
pub async fn accept_streams(
|
||||
connection: quinn::Connection,
|
||||
tunnel_map: Arc<HashMap<u64, TunnelInfo>>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
result = connection.accept_bi() => {
|
||||
let (send, recv) = match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
debug!("connection closed: {e}");
|
||||
eprintln!("{}", "Connection to server lost.".red());
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let conn_id = NEXT_CONN_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let tunnel_map = tunnel_map.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_stream(conn_id, send, recv, tunnel_map).await {
|
||||
debug!("stream relay error: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_stream(
|
||||
conn_id: u64,
|
||||
quic_send: quinn::SendStream,
|
||||
mut quic_recv: quinn::RecvStream,
|
||||
tunnel_map: Arc<HashMap<u64, TunnelInfo>>,
|
||||
) -> Result<()> {
|
||||
let header = read_stream_header(&mut quic_recv).await?;
|
||||
|
||||
let info = tunnel_map
|
||||
.get(&header.tunnel_id)
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("unknown tunnel id {}", header.tunnel_id))?;
|
||||
|
||||
let label = match info.protocol {
|
||||
TunnelProtocol::Tcp => "tcp ".green().bold().to_string(),
|
||||
TunnelProtocol::Http => "http".cyan().bold().to_string(),
|
||||
TunnelProtocol::Udp => "udp ".yellow().bold().to_string(),
|
||||
};
|
||||
let id = format!("#{conn_id}");
|
||||
let peer = &header.peer_addr;
|
||||
let target = &info.target;
|
||||
|
||||
eprintln!(
|
||||
"{label} {} {} {} {target}",
|
||||
id.dimmed(),
|
||||
peer.dimmed(),
|
||||
"->".dimmed(),
|
||||
);
|
||||
|
||||
let result = match info.protocol {
|
||||
TunnelProtocol::Tcp | TunnelProtocol::Http => {
|
||||
relay_tcp_stream(target, quic_send, quic_recv).await
|
||||
}
|
||||
TunnelProtocol::Udp => relay_udp_stream(target, quic_send, quic_recv).await,
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"{label} {} {} {} {target}",
|
||||
id.dimmed(),
|
||||
peer.dimmed(),
|
||||
"x-".dimmed(),
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
async fn relay_tcp_stream(
|
||||
target: &str,
|
||||
quic_send: quinn::SendStream,
|
||||
quic_recv: quinn::RecvStream,
|
||||
) -> Result<()> {
|
||||
let tcp_stream = TcpStream::connect(target).await?;
|
||||
debug!(target, "connected to local service");
|
||||
|
||||
let quic_stream = QuicBiStream {
|
||||
send: quic_send,
|
||||
recv: quic_recv,
|
||||
};
|
||||
relay(tcp_stream, quic_stream).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn relay_udp_stream(
|
||||
target: &str,
|
||||
mut quic_send: quinn::SendStream,
|
||||
mut quic_recv: quinn::RecvStream,
|
||||
) -> Result<()> {
|
||||
let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.connect(target).await?;
|
||||
debug!(target, "connected to local service");
|
||||
|
||||
let socket = Arc::new(socket);
|
||||
|
||||
let socket_tx = socket.clone();
|
||||
let quic_to_udp = async move {
|
||||
while let Ok(data) = read_udp_frame(&mut quic_recv).await {
|
||||
if let Err(e) = socket_tx.send(&data).await {
|
||||
warn!("UDP send error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let udp_to_quic = async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
match socket.recv(&mut buf).await {
|
||||
Ok(n) => {
|
||||
if let Err(e) = write_udp_frame(&mut quic_send, &buf[..n]).await {
|
||||
warn!("QUIC write error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("UDP recv error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = quic_to_udp => {},
|
||||
_ = udp_to_quic => {},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use color_eyre::Result;
|
||||
|
||||
/// ~/.bore directory.
|
||||
pub fn bore_dir() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir()
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("could not determine home directory"))?;
|
||||
Ok(home.join(".bore"))
|
||||
}
|
||||
|
||||
fn secrets_path() -> Result<PathBuf> {
|
||||
Ok(bore_dir()?.join("secrets"))
|
||||
}
|
||||
|
||||
pub fn load_secret(server_key: &str) -> Result<Option<String>> {
|
||||
let path = secrets_path()?;
|
||||
load_entry(&path, server_key)
|
||||
}
|
||||
|
||||
pub fn save_secret(server_key: &str, secret: &str) -> Result<()> {
|
||||
let path = secrets_path()?;
|
||||
save_entry(&path, server_key, secret)
|
||||
}
|
||||
|
||||
pub fn load_entry(path: &Path, key: &str) -> Result<Option<String>> {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
for line in contents.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once(' ')
|
||||
&& k == key
|
||||
{
|
||||
return Ok(Some(v.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn save_entry(path: &Path, key: &str, value: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c
|
||||
.lines()
|
||||
.filter(|l| l.split_once(' ').is_none_or(|(k, _)| k != key))
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
lines.push(format!("{key} {value}"));
|
||||
std::fs::write(path, lines.join("\n") + "\n")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod protocol;
|
||||
pub mod quic;
|
||||
pub mod relay;
|
||||
pub mod server;
|
||||
pub mod tunnel;
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
use rkyv::Archive;
|
||||
|
||||
use crate::tunnel::TunnelId;
|
||||
|
||||
/// Protocol for tunnel types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Archive, rkyv::Serialize, rkyv::Deserialize)]
|
||||
pub enum TunnelProtocol {
|
||||
Tcp,
|
||||
Udp,
|
||||
Http,
|
||||
}
|
||||
|
||||
/// Messages sent from client to server on the control stream.
|
||||
#[derive(Debug, Archive, rkyv::Serialize, rkyv::Deserialize)]
|
||||
pub enum ClientMessage {
|
||||
Auth {
|
||||
secret: String,
|
||||
},
|
||||
RequestTunnel {
|
||||
protocol: TunnelProtocol,
|
||||
local_port: u16,
|
||||
remote_port: Option<u16>,
|
||||
subdomain: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Messages sent from server to client on the control stream.
|
||||
#[derive(Debug, Archive, rkyv::Serialize, rkyv::Deserialize)]
|
||||
pub enum ServerMessage {
|
||||
AuthOk,
|
||||
TunnelCreated {
|
||||
tunnel_id: TunnelId,
|
||||
protocol: TunnelProtocol,
|
||||
assigned_port: Option<u16>,
|
||||
assigned_subdomain: Option<String>,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
async fn write_framed(send: &mut quinn::SendStream, bytes: &[u8]) -> color_eyre::Result<()> {
|
||||
let len = (bytes.len() as u32).to_be_bytes();
|
||||
send.write_all(&len).await?;
|
||||
send.write_all(bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_framed(recv: &mut quinn::RecvStream) -> color_eyre::Result<Vec<u8>> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
recv.read_exact(&mut len_buf).await?;
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > 1024 * 1024 {
|
||||
return Err(color_eyre::eyre::eyre!("message too large: {len} bytes"));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
recv.read_exact(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub async fn write_client_message(
|
||||
send: &mut quinn::SendStream,
|
||||
msg: &ClientMessage,
|
||||
) -> color_eyre::Result<()> {
|
||||
let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(msg)?;
|
||||
write_framed(send, &bytes).await
|
||||
}
|
||||
|
||||
pub async fn read_client_message(
|
||||
recv: &mut quinn::RecvStream,
|
||||
) -> color_eyre::Result<ClientMessage> {
|
||||
let buf = read_framed(recv).await?;
|
||||
let msg = rkyv::from_bytes::<ClientMessage, rkyv::rancor::Error>(&buf)?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
pub async fn write_server_message(
|
||||
send: &mut quinn::SendStream,
|
||||
msg: &ServerMessage,
|
||||
) -> color_eyre::Result<()> {
|
||||
let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(msg)?;
|
||||
write_framed(send, &bytes).await
|
||||
}
|
||||
|
||||
pub async fn read_server_message(
|
||||
recv: &mut quinn::RecvStream,
|
||||
) -> color_eyre::Result<ServerMessage> {
|
||||
let buf = read_framed(recv).await?;
|
||||
let msg = rkyv::from_bytes::<ServerMessage, rkyv::rancor::Error>(&buf)?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Data stream header: tunnel ID + peer address.
|
||||
pub struct StreamHeader {
|
||||
pub tunnel_id: TunnelId,
|
||||
pub peer_addr: String,
|
||||
}
|
||||
|
||||
/// Write a data stream header: `[u64 BE tunnel_id][u16 BE addr_len][addr_utf8]`.
|
||||
pub async fn write_stream_header(
|
||||
send: &mut quinn::SendStream,
|
||||
header: &StreamHeader,
|
||||
) -> color_eyre::Result<()> {
|
||||
send.write_all(&header.tunnel_id.to_be_bytes()).await?;
|
||||
let addr_bytes = header.peer_addr.as_bytes();
|
||||
send.write_all(&(addr_bytes.len() as u16).to_be_bytes())
|
||||
.await?;
|
||||
send.write_all(addr_bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a data stream header.
|
||||
pub async fn read_stream_header(recv: &mut quinn::RecvStream) -> color_eyre::Result<StreamHeader> {
|
||||
let mut id_buf = [0u8; 8];
|
||||
recv.read_exact(&mut id_buf).await?;
|
||||
let tunnel_id = u64::from_be_bytes(id_buf);
|
||||
|
||||
let mut len_buf = [0u8; 2];
|
||||
recv.read_exact(&mut len_buf).await?;
|
||||
let len = u16::from_be_bytes(len_buf) as usize;
|
||||
|
||||
let mut addr_buf = vec![0u8; len];
|
||||
recv.read_exact(&mut addr_buf).await?;
|
||||
let peer_addr = String::from_utf8(addr_buf)?;
|
||||
|
||||
Ok(StreamHeader {
|
||||
tunnel_id,
|
||||
peer_addr,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a UDP datagram with u16 BE length prefix.
|
||||
pub async fn write_udp_frame(send: &mut quinn::SendStream, data: &[u8]) -> color_eyre::Result<()> {
|
||||
let len = (data.len() as u16).to_be_bytes();
|
||||
send.write_all(&len).await?;
|
||||
send.write_all(data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a UDP datagram with u16 BE length prefix.
|
||||
pub async fn read_udp_frame(recv: &mut quinn::RecvStream) -> color_eyre::Result<Vec<u8>> {
|
||||
let mut len_buf = [0u8; 2];
|
||||
recv.read_exact(&mut len_buf).await?;
|
||||
let len = u16::from_be_bytes(len_buf) as usize;
|
||||
let mut buf = vec![0u8; len];
|
||||
recv.read_exact(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use color_eyre::Result;
|
||||
use quinn::VarInt;
|
||||
use quinn::{ClientConfig, Endpoint, ServerConfig, TransportConfig};
|
||||
use rcgen::{CertifiedKey, generate_simple_self_signed};
|
||||
use ring::digest;
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
|
||||
/// Shared transport config: long idle timeout + keep-alive.
|
||||
fn transport_config() -> TransportConfig {
|
||||
let mut transport = TransportConfig::default();
|
||||
transport.max_idle_timeout(Some(Duration::from_secs(300).try_into().unwrap()));
|
||||
transport.keep_alive_interval(Some(Duration::from_secs(5)));
|
||||
transport.max_concurrent_bidi_streams(VarInt::from_u32(4096));
|
||||
transport
|
||||
}
|
||||
|
||||
pub fn load_or_generate_cert(
|
||||
data_dir: &Path,
|
||||
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
||||
std::fs::create_dir_all(data_dir)?;
|
||||
let cert_path = data_dir.join("cert.pem");
|
||||
let key_path = data_dir.join("key.pem");
|
||||
|
||||
if cert_path.exists() && key_path.exists() {
|
||||
tracing::info!("loading TLS cert from {}", cert_path.display());
|
||||
let cert_pem = std::fs::read(&cert_path)?;
|
||||
let key_pem = std::fs::read(&key_path)?;
|
||||
|
||||
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut &cert_pem[..])
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
let key = rustls_pemfile::private_key(&mut &key_pem[..])?.ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!("no private key found in {}", key_path.display())
|
||||
})?;
|
||||
Ok((certs, key))
|
||||
} else {
|
||||
tracing::info!("generating self-signed cert -> {}", cert_path.display());
|
||||
let CertifiedKey { cert, key_pair } =
|
||||
generate_simple_self_signed(vec!["localhost".to_string()])?;
|
||||
|
||||
std::fs::write(&cert_path, cert.pem())?;
|
||||
std::fs::write(&key_path, key_pair.serialize_pem())?;
|
||||
|
||||
let cert_der = CertificateDer::from(cert.der().to_vec());
|
||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
|
||||
Ok((vec![cert_der], key_der))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a QUIC server endpoint with the given cert+key.
|
||||
pub fn make_server_endpoint(
|
||||
bind_addr: std::net::SocketAddr,
|
||||
certs: Vec<CertificateDer<'static>>,
|
||||
key: PrivateKeyDer<'static>,
|
||||
) -> Result<Endpoint> {
|
||||
let mut server_crypto = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
server_crypto.alpn_protocols = vec![b"bore".to_vec()];
|
||||
|
||||
let mut server_config = ServerConfig::with_crypto(Arc::new(
|
||||
quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto)?,
|
||||
));
|
||||
server_config.transport_config(Arc::new(transport_config()));
|
||||
let endpoint = Endpoint::server(server_config, bind_addr)?;
|
||||
tracing::info!("QUIC server listening on {bind_addr}");
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
pub fn cert_fingerprint(cert_der: &[u8]) -> String {
|
||||
let hash = digest::digest(&digest::SHA256, cert_der);
|
||||
let hex: Vec<String> = hash.as_ref().iter().map(|b| format!("{b:02x}")).collect();
|
||||
format!("SHA256:{}", hex.join(":"))
|
||||
}
|
||||
|
||||
pub fn make_tofu_client_endpoint(
|
||||
bind_addr: std::net::SocketAddr,
|
||||
server_key: &str,
|
||||
) -> Result<(Endpoint, Arc<TofuState>)> {
|
||||
let known_hosts_path = known_hosts_path()?;
|
||||
let known_fp = load_known_fingerprint(&known_hosts_path, server_key)?;
|
||||
|
||||
let tofu = Arc::new(TofuState {
|
||||
server_key: server_key.to_string(),
|
||||
known_hosts_path,
|
||||
known_fingerprint: known_fp,
|
||||
accepted: Mutex::new(None),
|
||||
});
|
||||
|
||||
let mut client_crypto = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(TofuVerifier(tofu.clone())))
|
||||
.with_no_client_auth();
|
||||
client_crypto.alpn_protocols = vec![b"bore".to_vec()];
|
||||
|
||||
let mut client_config = ClientConfig::new(Arc::new(
|
||||
quinn::crypto::rustls::QuicClientConfig::try_from(client_crypto)?,
|
||||
));
|
||||
client_config.transport_config(Arc::new(transport_config()));
|
||||
let mut endpoint = Endpoint::client(bind_addr)?;
|
||||
endpoint.set_default_client_config(client_config);
|
||||
Ok((endpoint, tofu))
|
||||
}
|
||||
|
||||
/// Persisted + runtime state for a TOFU handshake.
|
||||
#[derive(Debug)]
|
||||
pub struct TofuState {
|
||||
server_key: String,
|
||||
known_hosts_path: PathBuf,
|
||||
known_fingerprint: Option<String>,
|
||||
/// Set by the verifier when it accepts a previously-unknown cert.
|
||||
accepted: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl TofuState {
|
||||
/// Persist the fingerprint if the verifier accepted a new (unknown) server.
|
||||
/// Call this once after the QUIC connection succeeds.
|
||||
pub fn save_if_new(&self) -> Result<()> {
|
||||
let guard = self.accepted.lock().unwrap();
|
||||
if let Some(ref fp) = *guard {
|
||||
save_known_fingerprint(&self.known_hosts_path, &self.server_key, fp)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn known_hosts_path() -> Result<PathBuf> {
|
||||
Ok(crate::config::bore_dir()?.join("known_hosts"))
|
||||
}
|
||||
|
||||
fn load_known_fingerprint(path: &Path, server_key: &str) -> Result<Option<String>> {
|
||||
crate::config::load_entry(path, server_key)
|
||||
}
|
||||
|
||||
fn save_known_fingerprint(path: &Path, server_key: &str, fingerprint: &str) -> Result<()> {
|
||||
crate::config::save_entry(path, server_key, fingerprint)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TofuVerifier(Arc<TofuState>);
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for TofuVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
let fp = cert_fingerprint(end_entity.as_ref());
|
||||
|
||||
match &self.0.known_fingerprint {
|
||||
Some(known) if *known == fp => {
|
||||
// Known server, cert matches -- all good
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
Some(known) => {
|
||||
// MISMATCH -- possible MITM
|
||||
use owo_colors::OwoColorize;
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"{}",
|
||||
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
|
||||
.red()
|
||||
.bold()
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
"@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @"
|
||||
.red()
|
||||
.bold()
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
|
||||
.red()
|
||||
.bold()
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!");
|
||||
eprintln!("The server certificate fingerprint has changed.");
|
||||
eprintln!();
|
||||
eprintln!(" Server: {}", self.0.server_key);
|
||||
eprintln!(" Expected: {}", known.dimmed());
|
||||
eprintln!(" Got: {}", fp.red().bold());
|
||||
eprintln!();
|
||||
eprintln!("If the server was intentionally re-keyed, remove the old entry from:");
|
||||
eprintln!(" {}", self.0.known_hosts_path.display().underline());
|
||||
eprintln!("and reconnect.");
|
||||
eprintln!();
|
||||
Err(rustls::Error::General(
|
||||
"server certificate fingerprint mismatch".to_string(),
|
||||
))
|
||||
}
|
||||
None => {
|
||||
// Unknown server -- prompt user
|
||||
use owo_colors::OwoColorize;
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"The authenticity of server '{}' can't be established.",
|
||||
self.0.server_key
|
||||
)
|
||||
.yellow(),
|
||||
);
|
||||
eprintln!(" Fingerprint: {}", fp.dimmed());
|
||||
|
||||
let accepted = inquire::Confirm::new("Trust this server?")
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.unwrap_or(false);
|
||||
|
||||
if !accepted {
|
||||
return Err(rustls::Error::General(
|
||||
"server certificate rejected by user".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
*self.0.accepted.lock().unwrap() = Some(fp);
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
ALL_SCHEMES.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_SCHEMES: &[rustls::SignatureScheme] = &[
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::ED448,
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
use color_eyre::Result;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, copy_bidirectional};
|
||||
|
||||
/// Bidirectional relay between two async streams.
|
||||
/// Returns when either direction hits EOF or an error.
|
||||
pub async fn relay<A, B>(mut a: A, mut b: B) -> Result<()>
|
||||
where
|
||||
A: AsyncRead + AsyncWrite + Unpin,
|
||||
B: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
copy_bidirectional(&mut a, &mut b).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wrapper to combine a QUIC send+recv into a single AsyncRead+AsyncWrite.
|
||||
pub struct QuicBiStream {
|
||||
pub send: quinn::SendStream,
|
||||
pub recv: quinn::RecvStream,
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for QuicBiStream {
|
||||
fn poll_read(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::pin::Pin::new(&mut self.recv).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for QuicBiStream {
|
||||
fn poll_write(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
std::pin::Pin::new(&mut self.send)
|
||||
.poll_write(cx, buf)
|
||||
.map(|r| r.map_err(std::io::Error::other))
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::pin::Pin::new(&mut self.send).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::pin::Pin::new(&mut self.send).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, Request, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use color_eyre::Result;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::protocol::{StreamHeader, write_stream_header};
|
||||
use crate::relay::QuicBiStream;
|
||||
use crate::server::state::ServerState;
|
||||
|
||||
pub async fn run(
|
||||
addr: SocketAddr,
|
||||
state: Arc<ServerState>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
let app = Router::new().fallback(proxy_handler).with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
info!(%addr, "HTTP server listening");
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(async move { cancel.cancelled().await })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn proxy_handler(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
req: Request<Body>,
|
||||
) -> Response {
|
||||
match do_proxy(state, peer, req).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
warn!("HTTP proxy error: {e:#}");
|
||||
(StatusCode::NOT_FOUND, format!("{e}")).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_proxy(
|
||||
state: Arc<ServerState>,
|
||||
peer: SocketAddr,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response> {
|
||||
// Use X-Forwarded-For if present (from Traefik), otherwise direct peer
|
||||
let peer_str = req
|
||||
.headers()
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.split(',').next().unwrap_or(v).trim().to_string())
|
||||
.unwrap_or_else(|| peer.to_string());
|
||||
|
||||
// Extract subdomain from Host header
|
||||
let host_header = req
|
||||
.headers()
|
||||
.get("host")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("missing Host header"))?;
|
||||
|
||||
// Strip port if present (e.g. "myapp.bore.localhost:8080" -> "myapp.bore.localhost")
|
||||
let host = host_header.split(':').next().unwrap_or(host_header);
|
||||
|
||||
let suffix = format!(".{}", state.base_domain);
|
||||
let subdomain = host
|
||||
.strip_suffix(&suffix)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("no tunnel found for host {host}"))?
|
||||
.to_string();
|
||||
|
||||
// Look up the tunnel
|
||||
let tunnel_id = *state
|
||||
.http_routes
|
||||
.get(&subdomain)
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("no tunnel for subdomain {subdomain}"))?;
|
||||
|
||||
let entry = state
|
||||
.tunnels
|
||||
.get(&tunnel_id)
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("tunnel {tunnel_id} not found in state"))?;
|
||||
|
||||
let connection = entry.connection.clone();
|
||||
drop(entry);
|
||||
|
||||
// Open QUIC stream to client
|
||||
let (mut quic_send, quic_recv) = connection.open_bi().await?;
|
||||
write_stream_header(
|
||||
&mut quic_send,
|
||||
&StreamHeader {
|
||||
tunnel_id,
|
||||
peer_addr: peer_str,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Use hyper to proxy the HTTP request over the QUIC stream
|
||||
let io = TokioIo::new(QuicBiStream {
|
||||
send: quic_send,
|
||||
recv: quic_recv,
|
||||
});
|
||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn.await {
|
||||
warn!("HTTP proxy connection error: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
let resp = sender.send_request(req).await?;
|
||||
Ok(resp.map(Body::new))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod http;
|
||||
pub mod tcp;
|
||||
pub mod udp;
|
||||
@@ -0,0 +1,75 @@
|
||||
use color_eyre::Result;
|
||||
use quinn::Connection;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::protocol::{StreamHeader, write_stream_header};
|
||||
use crate::relay::{QuicBiStream, relay};
|
||||
use crate::tunnel::TunnelId;
|
||||
|
||||
/// Bind a TCP listener on the given port and relay each accepted connection
|
||||
/// through a new QUIC bidirectional stream to the client.
|
||||
/// Returns the actually assigned port.
|
||||
pub async fn bind_and_relay(
|
||||
tunnel_id: TunnelId,
|
||||
port: u16,
|
||||
connection: Connection,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<u16> {
|
||||
let listener = TcpListener::bind(("0.0.0.0", port)).await?;
|
||||
let assigned_port = listener.local_addr()?.port();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
accepted = listener.accept() => {
|
||||
let (tcp_stream, peer) = match accepted {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(tunnel_id, "TCP accept error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
info!(tunnel_id, %peer, "accepted TCP connection");
|
||||
|
||||
let connection = connection.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = relay_tcp(tunnel_id, peer, tcp_stream, connection).await {
|
||||
warn!(tunnel_id, %peer, "TCP relay error: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(assigned_port)
|
||||
}
|
||||
|
||||
async fn relay_tcp(
|
||||
tunnel_id: TunnelId,
|
||||
peer: SocketAddr,
|
||||
tcp_stream: tokio::net::TcpStream,
|
||||
connection: Connection,
|
||||
) -> Result<()> {
|
||||
let (mut quic_send, quic_recv) = connection.open_bi().await?;
|
||||
write_stream_header(
|
||||
&mut quic_send,
|
||||
&StreamHeader {
|
||||
tunnel_id,
|
||||
peer_addr: peer.to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let quic_stream = QuicBiStream {
|
||||
send: quic_send,
|
||||
recv: quic_recv,
|
||||
};
|
||||
relay(tcp_stream, quic_stream).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_eyre::Result;
|
||||
use quinn::Connection;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::protocol::{StreamHeader, read_udp_frame, write_stream_header, write_udp_frame};
|
||||
use crate::tunnel::TunnelId;
|
||||
|
||||
/// Bind a UDP socket and relay datagrams through QUIC streams (one stream per source address).
|
||||
/// Returns the actually assigned port.
|
||||
pub async fn bind_and_relay(
|
||||
tunnel_id: TunnelId,
|
||||
port: u16,
|
||||
connection: Connection,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<u16> {
|
||||
let socket = Arc::new(UdpSocket::bind(("0.0.0.0", port)).await?);
|
||||
let assigned_port = socket.local_addr()?.port();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_udp_relay(tunnel_id, socket, connection, cancel).await {
|
||||
warn!(tunnel_id, "UDP relay error: {e:#}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(assigned_port)
|
||||
}
|
||||
|
||||
async fn run_udp_relay(
|
||||
tunnel_id: TunnelId,
|
||||
socket: Arc<UdpSocket>,
|
||||
connection: Connection,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
// Track active sessions: source_addr -> QUIC send stream
|
||||
let sessions: Arc<Mutex<HashMap<SocketAddr, quinn::SendStream>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let mut buf = vec![0u8; 65536];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
result = socket.recv_from(&mut buf) => {
|
||||
let (n, src_addr) = result?;
|
||||
let data = buf[..n].to_vec();
|
||||
|
||||
let mut sessions_guard = sessions.lock().await;
|
||||
if let Some(send) = sessions_guard.get_mut(&src_addr) {
|
||||
// Existing session: send datagram on existing stream
|
||||
if let Err(e) = write_udp_frame(send, &data).await {
|
||||
warn!(tunnel_id, %src_addr, "failed to write to QUIC stream: {e}");
|
||||
sessions_guard.remove(&src_addr);
|
||||
}
|
||||
} else {
|
||||
// New session: open a new QUIC stream
|
||||
match connection.open_bi().await {
|
||||
Ok((mut quic_send, quic_recv)) => {
|
||||
if let Err(e) = write_stream_header(&mut quic_send, &StreamHeader {
|
||||
tunnel_id,
|
||||
peer_addr: src_addr.to_string(),
|
||||
}).await {
|
||||
warn!(tunnel_id, %src_addr, "failed to write stream header: {e}");
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = write_udp_frame(&mut quic_send, &data).await {
|
||||
warn!(tunnel_id, %src_addr, "failed to write first datagram: {e}");
|
||||
continue;
|
||||
}
|
||||
sessions_guard.insert(src_addr, quic_send);
|
||||
info!(tunnel_id, %src_addr, "new UDP session");
|
||||
|
||||
// Spawn task to read replies from client
|
||||
let socket = socket.clone();
|
||||
let sessions = sessions.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_udp_replies(
|
||||
tunnel_id, src_addr, quic_recv, socket, sessions,
|
||||
).await {
|
||||
warn!(tunnel_id, %src_addr, "UDP reply handler error: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(tunnel_id, "failed to open QUIC stream: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_udp_replies(
|
||||
tunnel_id: TunnelId,
|
||||
src_addr: SocketAddr,
|
||||
mut quic_recv: quinn::RecvStream,
|
||||
socket: Arc<UdpSocket>,
|
||||
sessions: Arc<Mutex<HashMap<SocketAddr, quinn::SendStream>>>,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
let data = match read_udp_frame(&mut quic_recv).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => break,
|
||||
};
|
||||
socket.send_to(&data, src_addr).await?;
|
||||
}
|
||||
|
||||
// Clean up session
|
||||
sessions.lock().await.remove(&src_addr);
|
||||
info!(tunnel_id, %src_addr, "UDP session ended");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use color_eyre::Result;
|
||||
use tracing::{info, warn};
|
||||
|
||||
static CHAIN_CREATED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const CHAIN: &str = "BORE";
|
||||
|
||||
fn iptables(args: &[&str]) -> Result<()> {
|
||||
let output = Command::new("iptables").args(args).output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(color_eyre::eyre::eyre!(
|
||||
"iptables {} failed: {}",
|
||||
args.join(" "),
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chain_exists() -> bool {
|
||||
Command::new("iptables")
|
||||
.args(["-n", "-L", CHAIN])
|
||||
.output()
|
||||
.is_ok_and(|o| o.status.success())
|
||||
}
|
||||
|
||||
/// Create the BORE chain and jump rule. Flushes any stale rules from a previous run.
|
||||
pub fn init() -> Result<()> {
|
||||
if chain_exists() {
|
||||
iptables(&["-F", CHAIN])?;
|
||||
info!("flushed stale iptables chain {CHAIN}");
|
||||
} else {
|
||||
iptables(&["-N", CHAIN])?;
|
||||
info!("created iptables chain {CHAIN}");
|
||||
}
|
||||
|
||||
// Add jump from INPUT to BORE if not already present
|
||||
let check = Command::new("iptables")
|
||||
.args(["-C", "INPUT", "-j", CHAIN])
|
||||
.output()?;
|
||||
if !check.status.success() {
|
||||
iptables(&["-I", "INPUT", "-j", CHAIN])?;
|
||||
info!("added INPUT -> {CHAIN} jump rule");
|
||||
}
|
||||
|
||||
CHAIN_CREATED.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Allow inbound traffic on a TCP or UDP port.
|
||||
pub fn allow_port(port: u16, proto: &str) {
|
||||
if !CHAIN_CREATED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let port_str = port.to_string();
|
||||
if let Err(e) = iptables(&["-A", CHAIN, "-p", proto, "--dport", &port_str, "-j", "ACCEPT"]) {
|
||||
warn!("failed to add firewall rule for {proto}/{port}: {e}");
|
||||
} else {
|
||||
info!("firewall: opened {proto}/{port}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the allow rule for a TCP or UDP port.
|
||||
pub fn deny_port(port: u16, proto: &str) {
|
||||
if !CHAIN_CREATED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let port_str = port.to_string();
|
||||
if let Err(e) = iptables(&["-D", CHAIN, "-p", proto, "--dport", &port_str, "-j", "ACCEPT"]) {
|
||||
warn!("failed to remove firewall rule for {proto}/{port}: {e}");
|
||||
} else {
|
||||
info!("firewall: closed {proto}/{port}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the BORE chain and remove the jump rule. Call on shutdown.
|
||||
pub fn cleanup() {
|
||||
if !CHAIN_CREATED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Remove jump rule
|
||||
let _ = iptables(&["-D", "INPUT", "-j", CHAIN]);
|
||||
// Flush chain
|
||||
let _ = iptables(&["-F", CHAIN]);
|
||||
// Delete chain
|
||||
let _ = iptables(&["-X", CHAIN]);
|
||||
info!("firewall: cleaned up {CHAIN} chain");
|
||||
CHAIN_CREATED.store(false, Ordering::Relaxed);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
pub mod endpoints;
|
||||
pub mod firewall;
|
||||
pub mod quic_listener;
|
||||
pub mod state;
|
||||
pub mod traefik;
|
||||
|
||||
use endpoints::http;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use color_eyre::Result;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::quic;
|
||||
use state::ServerState;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ServerArgs {
|
||||
/// Address for the QUIC listener
|
||||
#[arg(long, default_value = "0.0.0.0:4843", env = "BORE_LISTEN_ADDR")]
|
||||
pub listen_addr: SocketAddr,
|
||||
|
||||
/// Address for the HTTP tunnel proxy
|
||||
#[arg(long, default_value = "0.0.0.0:8080", env = "BORE_HTTP_ADDR")]
|
||||
pub http_addr: SocketAddr,
|
||||
|
||||
/// Address for the Traefik provider API
|
||||
#[arg(long, default_value = "127.0.0.1:3100", env = "BORE_API_ADDR")]
|
||||
pub api_addr: SocketAddr,
|
||||
|
||||
/// Base domain for HTTP tunnel subdomains (e.g. bore.example.com)
|
||||
#[arg(long, env = "BORE_BASE_DOMAIN")]
|
||||
pub base_domain: String,
|
||||
|
||||
/// Shared secret for client authentication
|
||||
#[arg(long, env = "BORE_SECRET")]
|
||||
pub secret: String,
|
||||
|
||||
/// Traefik entrypoint name for HTTP tunnel routers
|
||||
#[arg(long, default_value = "websecure", env = "BORE_TRAEFIK_ENTRYPOINT")]
|
||||
pub traefik_entrypoint: String,
|
||||
|
||||
/// Traefik TLS cert resolver name (e.g. "letsencrypt"). Omit to disable TLS in generated config.
|
||||
#[arg(long, env = "BORE_TRAEFIK_CERT_RESOLVER")]
|
||||
pub traefik_cert_resolver: Option<String>,
|
||||
|
||||
/// Manage iptables rules for tunnel ports (requires root/CAP_NET_ADMIN)
|
||||
#[arg(long, default_value_t = false, env = "BORE_MANAGE_FIREWALL")]
|
||||
pub manage_firewall: bool,
|
||||
|
||||
/// Directory for persistent data (TLS cert/key)
|
||||
#[arg(long, default_value = "./bore-data", env = "BORE_DATA_DIR")]
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
pub async fn run(args: ServerArgs) -> Result<()> {
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
if args.manage_firewall {
|
||||
firewall::init()?;
|
||||
}
|
||||
|
||||
let state = Arc::new(ServerState::new(
|
||||
args.secret.clone(),
|
||||
args.base_domain.clone(),
|
||||
args.http_addr,
|
||||
args.traefik_entrypoint.clone(),
|
||||
args.traefik_cert_resolver.clone(),
|
||||
args.manage_firewall,
|
||||
));
|
||||
|
||||
let (certs, key) = quic::load_or_generate_cert(&args.data_dir)?;
|
||||
tracing::info!(
|
||||
"server cert fingerprint: {}",
|
||||
quic::cert_fingerprint(certs[0].as_ref())
|
||||
);
|
||||
let endpoint = quic::make_server_endpoint(args.listen_addr, certs, key)?;
|
||||
|
||||
let quic_handle = {
|
||||
let state = state.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::spawn(async move { quic_listener::run(endpoint, state, cancel).await })
|
||||
};
|
||||
|
||||
let http_handle = {
|
||||
let state = state.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::spawn(async move { http::run(args.http_addr, state, cancel).await })
|
||||
};
|
||||
|
||||
let api_handle = {
|
||||
let state = state.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::spawn(async move { traefik::run(args.api_addr, state, cancel).await })
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
res = quic_handle => {
|
||||
tracing::error!("QUIC listener exited: {res:?}");
|
||||
}
|
||||
res = http_handle => {
|
||||
tracing::error!("HTTP server exited: {res:?}");
|
||||
}
|
||||
res = api_handle => {
|
||||
tracing::error!("Traefik API exited: {res:?}");
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("shutting down");
|
||||
cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
if args.manage_firewall {
|
||||
firewall::cleanup();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_eyre::Result;
|
||||
use quinn::Endpoint;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::protocol::{
|
||||
ClientMessage, ServerMessage, TunnelProtocol, read_client_message, write_server_message,
|
||||
};
|
||||
use crate::server::endpoints::{tcp, udp};
|
||||
use crate::tunnel::TunnelInfo;
|
||||
|
||||
use super::state::ServerState;
|
||||
|
||||
pub async fn run(
|
||||
endpoint: Endpoint,
|
||||
state: Arc<ServerState>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
incoming = endpoint.accept() => {
|
||||
let Some(incoming) = incoming else { break };
|
||||
let state = state.clone();
|
||||
let cancel = cancel.child_token();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(incoming, state, cancel).await {
|
||||
warn!("connection error: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
incoming: quinn::Incoming,
|
||||
state: Arc<ServerState>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
let connection = incoming.await?;
|
||||
let remote = connection.remote_address();
|
||||
info!(%remote, "new QUIC connection");
|
||||
|
||||
let connection_id = state.next_connection_id();
|
||||
|
||||
// Spawn watcher to clean up when connection closes
|
||||
{
|
||||
let state = state.clone();
|
||||
let conn = connection.clone();
|
||||
tokio::spawn(async move {
|
||||
conn.closed().await;
|
||||
info!(%remote, "connection closed, cleaning up");
|
||||
state.remove_connection(connection_id);
|
||||
});
|
||||
}
|
||||
|
||||
// Accept the control stream (first bidirectional stream)
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
|
||||
// Authenticate
|
||||
let msg = read_client_message(&mut recv).await?;
|
||||
match msg {
|
||||
ClientMessage::Auth { secret } => {
|
||||
if secret != state.secret {
|
||||
write_server_message(
|
||||
&mut send,
|
||||
&ServerMessage::Error {
|
||||
message: "invalid secret".to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
write_server_message(&mut send, &ServerMessage::AuthOk).await?;
|
||||
info!(%remote, "authenticated");
|
||||
}
|
||||
_ => {
|
||||
write_server_message(
|
||||
&mut send,
|
||||
&ServerMessage::Error {
|
||||
message: "expected Auth message".to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Process tunnel requests
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
msg = read_client_message(&mut recv) => {
|
||||
let msg = match msg {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
info!(%remote, "control stream closed: {e:#}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
ClientMessage::RequestTunnel { protocol, local_port, remote_port, subdomain } => {
|
||||
let tunnel_id = state.next_tunnel_id();
|
||||
let tunnel_cancel = cancel.child_token();
|
||||
|
||||
match protocol {
|
||||
TunnelProtocol::Tcp => {
|
||||
let port = remote_port.unwrap_or(0);
|
||||
match tcp::bind_and_relay(
|
||||
tunnel_id,
|
||||
port,
|
||||
connection.clone(),
|
||||
tunnel_cancel,
|
||||
).await {
|
||||
Ok(assigned_port) => {
|
||||
let info = TunnelInfo {
|
||||
id: tunnel_id,
|
||||
protocol,
|
||||
target: format!("client:{local_port}"),
|
||||
remote_port: Some(assigned_port),
|
||||
subdomain: None,
|
||||
};
|
||||
state.register_tunnel(connection_id, info, connection.clone());
|
||||
write_server_message(&mut send, &ServerMessage::TunnelCreated {
|
||||
tunnel_id,
|
||||
protocol,
|
||||
assigned_port: Some(assigned_port),
|
||||
assigned_subdomain: None,
|
||||
}).await?;
|
||||
tracing::info!(tunnel_id, assigned_port, "TCP tunnel created");
|
||||
}
|
||||
Err(e) => {
|
||||
write_server_message(&mut send, &ServerMessage::Error {
|
||||
message: format!("failed to bind TCP: {e}"),
|
||||
}).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
TunnelProtocol::Udp => {
|
||||
let port = remote_port.unwrap_or(0);
|
||||
match udp::bind_and_relay(
|
||||
tunnel_id,
|
||||
port,
|
||||
connection.clone(),
|
||||
tunnel_cancel,
|
||||
).await {
|
||||
Ok(assigned_port) => {
|
||||
let info = TunnelInfo {
|
||||
id: tunnel_id,
|
||||
protocol,
|
||||
target: format!("client:{local_port}"),
|
||||
remote_port: Some(assigned_port),
|
||||
subdomain: None,
|
||||
};
|
||||
state.register_tunnel(connection_id, info, connection.clone());
|
||||
write_server_message(&mut send, &ServerMessage::TunnelCreated {
|
||||
tunnel_id,
|
||||
protocol,
|
||||
assigned_port: Some(assigned_port),
|
||||
assigned_subdomain: None,
|
||||
}).await?;
|
||||
tracing::info!(tunnel_id, assigned_port, "UDP tunnel created");
|
||||
}
|
||||
Err(e) => {
|
||||
write_server_message(&mut send, &ServerMessage::Error {
|
||||
message: format!("failed to bind UDP: {e}"),
|
||||
}).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
TunnelProtocol::Http => {
|
||||
let subdomain = subdomain.unwrap_or_else(|| {
|
||||
uuid::Uuid::new_v4().to_string()[..8].to_string()
|
||||
});
|
||||
let fqdn = format!("{}.{}", subdomain, state.base_domain);
|
||||
let info = TunnelInfo {
|
||||
id: tunnel_id,
|
||||
protocol,
|
||||
target: format!("client:{local_port}"),
|
||||
remote_port: None,
|
||||
subdomain: Some(subdomain.clone()),
|
||||
};
|
||||
state.register_tunnel(connection_id, info, connection.clone());
|
||||
let url = format!("http://{fqdn}");
|
||||
write_server_message(&mut send, &ServerMessage::TunnelCreated {
|
||||
tunnel_id,
|
||||
protocol,
|
||||
assigned_port: None,
|
||||
assigned_subdomain: Some(url),
|
||||
}).await?;
|
||||
tracing::info!(tunnel_id, %fqdn, "HTTP tunnel created");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
write_server_message(&mut send, &ServerMessage::Error {
|
||||
message: "unexpected message on control stream".to_string(),
|
||||
}).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use quinn::Connection;
|
||||
|
||||
use crate::protocol::TunnelProtocol;
|
||||
use crate::tunnel::{TunnelId, TunnelInfo};
|
||||
use super::firewall;
|
||||
|
||||
/// A registered tunnel on the server.
|
||||
pub struct TunnelEntry {
|
||||
pub info: TunnelInfo,
|
||||
/// The QUIC connection to the client that owns this tunnel.
|
||||
pub connection: Connection,
|
||||
}
|
||||
|
||||
/// Shared server state.
|
||||
pub struct ServerState {
|
||||
pub secret: String,
|
||||
pub base_domain: String,
|
||||
pub http_addr: SocketAddr,
|
||||
pub traefik_entrypoint: String,
|
||||
pub traefik_cert_resolver: Option<String>,
|
||||
pub manage_firewall: bool,
|
||||
/// tunnel_id -> TunnelEntry
|
||||
pub tunnels: DashMap<TunnelId, TunnelEntry>,
|
||||
/// subdomain -> tunnel_id (for HTTP routing)
|
||||
pub http_routes: DashMap<String, TunnelId>,
|
||||
/// connection_id -> list of tunnel_ids (for cleanup)
|
||||
pub connection_tunnels: DashMap<usize, Vec<TunnelId>>,
|
||||
next_tunnel_id: AtomicU64,
|
||||
next_connection_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new(
|
||||
secret: String,
|
||||
base_domain: String,
|
||||
http_addr: SocketAddr,
|
||||
traefik_entrypoint: String,
|
||||
traefik_cert_resolver: Option<String>,
|
||||
manage_firewall: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
secret,
|
||||
base_domain,
|
||||
http_addr,
|
||||
traefik_entrypoint,
|
||||
traefik_cert_resolver,
|
||||
manage_firewall,
|
||||
tunnels: DashMap::new(),
|
||||
http_routes: DashMap::new(),
|
||||
connection_tunnels: DashMap::new(),
|
||||
next_tunnel_id: AtomicU64::new(1),
|
||||
next_connection_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_tunnel_id(&self) -> TunnelId {
|
||||
self.next_tunnel_id.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn next_connection_id(&self) -> usize {
|
||||
self.next_connection_id.fetch_add(1, Ordering::Relaxed) as usize
|
||||
}
|
||||
|
||||
pub fn register_tunnel(&self, connection_id: usize, info: TunnelInfo, connection: Connection) {
|
||||
let tunnel_id = info.id;
|
||||
|
||||
if info.protocol == TunnelProtocol::Http
|
||||
&& let Some(ref subdomain) = info.subdomain
|
||||
{
|
||||
self.http_routes.insert(subdomain.clone(), tunnel_id);
|
||||
}
|
||||
|
||||
if self.manage_firewall {
|
||||
if let Some(port) = info.remote_port {
|
||||
let proto = match info.protocol {
|
||||
TunnelProtocol::Tcp => "tcp",
|
||||
TunnelProtocol::Udp => "udp",
|
||||
TunnelProtocol::Http => "",
|
||||
};
|
||||
if !proto.is_empty() {
|
||||
firewall::allow_port(port, proto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.tunnels
|
||||
.insert(tunnel_id, TunnelEntry { info, connection });
|
||||
self.connection_tunnels
|
||||
.entry(connection_id)
|
||||
.or_default()
|
||||
.push(tunnel_id);
|
||||
}
|
||||
|
||||
pub fn remove_connection(&self, connection_id: usize) {
|
||||
if let Some((_, tunnel_ids)) = self.connection_tunnels.remove(&connection_id) {
|
||||
for tid in tunnel_ids {
|
||||
if let Some((_, entry)) = self.tunnels.remove(&tid) {
|
||||
if entry.info.protocol == TunnelProtocol::Http
|
||||
&& let Some(ref subdomain) = entry.info.subdomain
|
||||
{
|
||||
self.http_routes.remove(subdomain);
|
||||
}
|
||||
|
||||
if self.manage_firewall {
|
||||
if let Some(port) = entry.info.remote_port {
|
||||
let proto = match entry.info.protocol {
|
||||
TunnelProtocol::Tcp => "tcp",
|
||||
TunnelProtocol::Udp => "udp",
|
||||
TunnelProtocol::Http => "",
|
||||
};
|
||||
if !proto.is_empty() {
|
||||
firewall::deny_port(port, proto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(tunnel_id = tid, "removed tunnel");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::{Json, Router};
|
||||
use color_eyre::Result;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
use super::state::ServerState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TraefikConfig {
|
||||
http: TraefikHttp,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TraefikHttp {
|
||||
routers: HashMap<String, TraefikRouter>,
|
||||
services: HashMap<String, TraefikService>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TraefikRouter {
|
||||
rule: String,
|
||||
service: String,
|
||||
entry_points: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tls: Option<TraefikTls>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TraefikTls {
|
||||
cert_resolver: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TraefikService {
|
||||
load_balancer: TraefikLoadBalancer,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TraefikLoadBalancer {
|
||||
servers: Vec<TraefikServer>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TraefikServer {
|
||||
url: String,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
addr: SocketAddr,
|
||||
state: Arc<ServerState>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
let app = Router::new()
|
||||
.route("/api/traefik", axum::routing::get(handler))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
info!(%addr, "Traefik API listening");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move { cancel.cancelled().await })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handler(State(state): State<Arc<ServerState>>) -> Json<TraefikConfig> {
|
||||
let mut routers = HashMap::new();
|
||||
let mut services = HashMap::new();
|
||||
|
||||
let bore_url = format!("http://{}", state.http_addr);
|
||||
|
||||
for entry in state.http_routes.iter() {
|
||||
let subdomain = entry.key();
|
||||
let name = format!("bore-{subdomain}");
|
||||
let fqdn = format!("{subdomain}.{}", state.base_domain);
|
||||
|
||||
routers.insert(
|
||||
name.clone(),
|
||||
TraefikRouter {
|
||||
rule: format!("Host(`{fqdn}`)"),
|
||||
service: name.clone(),
|
||||
entry_points: vec![state.traefik_entrypoint.clone()],
|
||||
tls: state.traefik_cert_resolver.as_ref().map(|r| TraefikTls {
|
||||
cert_resolver: r.clone(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
services.insert(
|
||||
name,
|
||||
TraefikService {
|
||||
load_balancer: TraefikLoadBalancer {
|
||||
servers: vec![TraefikServer {
|
||||
url: bore_url.clone(),
|
||||
}],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Json(TraefikConfig {
|
||||
http: TraefikHttp { routers, services },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::protocol::TunnelProtocol;
|
||||
|
||||
pub type TunnelId = u64;
|
||||
|
||||
/// Metadata about an active tunnel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TunnelInfo {
|
||||
pub id: TunnelId,
|
||||
pub protocol: TunnelProtocol,
|
||||
/// Target address to connect to (e.g. "127.0.0.1:8080" or "10.0.4.2:3000")
|
||||
pub target: String,
|
||||
pub remote_port: Option<u16>,
|
||||
pub subdomain: Option<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user