This commit is contained in:
2026-03-26 21:23:22 +04:00
commit 41f4fa65b5
25 changed files with 5009 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+4
View File
@@ -0,0 +1,4 @@
/.direnv
/bore-data
/target
/result
Generated
+2724
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
cargo-features = ["codegen-backend"]
[package]
name = "bore"
version = "0.1.0"
edition = "2024"
[profile.dev]
codegen-backend = "cranelift"
opt-level = 0
[profile.dev.package."*"]
codegen-backend = "llvm"
opt-level = 3
[[bin]]
name = "bore-server"
path = "src/bin/bore_server.rs"
[[bin]]
name = "bore-client"
path = "src/bin/bore_client.rs"
[dependencies]
quinn = "0.11"
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive", "env"] }
axum = { version = "0.8", features = ["macros"] }
hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
rkyv = { version = "0.8", features = ["bytecheck"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4"] }
rcgen = "0.14"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
color-eyre = "0.6"
thiserror = "2"
dashmap = "6"
bytes = "1"
tokio-util = { version = "0.7", features = ["io"] }
http = "1"
http-body-util = "0.1"
rustls-pemfile = "2"
ring = "0.17"
dirs = "6"
owo-colors = "4"
inquire = "0.9"
Generated
+98
View File
@@ -0,0 +1,98 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1774313767,
"narHash": "sha256-hy0XTQND6avzGEUFrJtYBBpFa/POiiaGBr2vpU6Y9tY=",
"owner": "ipetkov",
"repo": "crane",
"rev": "3d9df76e29656c679c744968b17fbaf28f0e923d",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1774106199,
"narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1774408260,
"narHash": "sha256-Jn9d9r85dmf3gTMnSRt6t+DP2nQ5uJns/MMXg2FpzfM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "d6471ee5a8f470251e6e5b83a20a182eb6c46c9b",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+100
View File
@@ -0,0 +1,100 @@
{
description = "bore - QUIC tunnel service";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
nixpkgs,
crane,
flake-utils,
rust-overlay,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [
(import rust-overlay)
];
};
inherit (pkgs) lib;
craneLib = (crane.mkLib pkgs).overrideToolchain (
p:
p.rust-bin.nightly.latest.default.override {
extensions = [
"rustc-codegen-cranelift-preview"
"rust-analyzer"
"rust-src"
];
}
);
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
};
bore = craneLib.buildPackage (
commonArgs
// {
inherit cargoArtifacts;
}
);
bore-server = pkgs.stdenv.mkDerivation {
name = "bore-server";
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin
ln -s ${bore}/bin/bore-server $out/bin/bore-server
'';
meta = {
description = "bore server binary";
mainProgram = "bore-server";
};
};
bore-client = pkgs.stdenv.mkDerivation {
name = "bore-client";
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin
ln -s ${bore}/bin/bore-client $out/bin/bore-client
'';
meta = {
description = "bore client binary";
mainProgram = "bore-client";
};
};
in
{
packages = {
default = bore-client;
inherit bore-client bore-server;
};
devShells.default = craneLib.devShell {
RUSTFLAGS = "-Clinker=clang -Clink-arg=-fuse-ld=mold -Z macro-backtrace";
RUST_LOG = "debug";
packages = with pkgs; [
mold
llvmPackages.clang
llvmPackages.lld
watchexec
cargo-edit
];
};
}
);
}
+17
View File
@@ -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
}
+17
View File
@@ -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
}
+312
View File
@@ -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(())
}
+154
View File
@@ -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(())
}
+63
View File
@@ -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(())
}
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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,
];
+55
View File
@@ -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)
}
}
+120
View File
@@ -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))
}
+3
View File
@@ -0,0 +1,3 @@
pub mod http;
pub mod tcp;
pub mod udp;
+75
View File
@@ -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(())
}
+120
View File
@@ -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(())
}
+93
View File
@@ -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);
}
+120
View File
@@ -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(())
}
+211
View File
@@ -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(())
}
+126
View File
@@ -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");
}
}
}
}
}
+113
View File
@@ -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 },
})
}
+14
View File
@@ -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>,
}