Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 250 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ Localtunnel exposes your localhost endpoint to the world, user cases are:
- multiple devices access to single data store
- peer to peer connection, workaround for NAT hole punching.

## Encrypted tunnel

Since v0.2.0 all tunnel connections between client and server are encrypted
with the [Noise protocol](https://noiseprotocol.org/) (`NK` pattern,
X25519 + ChaCha20-Poly1305), with fresh session keys per connection. The
server hands its public key and a per-tunnel session token to the client in
the registration response, and only connections that complete the handshake
and present the token can join the tunnel pool.

This is a breaking protocol change: v0.2.0 clients and servers do not
interoperate with older releases — upgrade both sides.

## Client Usage

Known issue: *the public proxy server is down, please setup your own server.*
Expand Down Expand Up @@ -40,6 +52,7 @@ let config = ClientConfig {
shutdown_signal: notify_shutdown.clone(),
max_conn: 10,
credential: None,
reregister_after: None,
};
let result = open_tunnel(config).await?;

Expand Down
6 changes: 3 additions & 3 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "localtunnel"
version = "0.1.7"
version = "0.2.0"
edition.workspace = true
description = "A CLI to proxy with localtunnel server."
license.workspace = true
Expand All @@ -10,8 +10,8 @@ repository.workspace = true

[dependencies]
clap = { version = "4.5", features = ["derive"] }
localtunnel-client = { path = "../client", version = "0.1.6" }
localtunnel-server = { path = "../server", version = "0.1.6" }
localtunnel-client = { path = "../client", version = "0.2.0" }
localtunnel-server = { path = "../server", version = "0.2.0" }
tokio = { workspace = true }
log = { workspace = true }
env_logger = "0.11"
Expand Down
4 changes: 3 additions & 1 deletion client/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "localtunnel-client"
version = "0.1.7"
version = "0.2.0"
edition.workspace = true
description = "A client to connect with localtunnel server."
license.workspace = true
Expand All @@ -15,6 +15,8 @@ tokio = { workspace = true }
anyhow = { workspace = true }
log = { workspace = true }
socket2 = { workspace = true }
snowstorm = "0.4"
hex = "0.4"

[features]
default = ["reqwest/default"]
Expand Down
124 changes: 102 additions & 22 deletions client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,29 @@ use std::sync::{
};
use std::time::Instant;

use anyhow::Result;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use snowstorm::{Builder, NoiseStream};
use socket2::{SockRef, TcpKeepalive};
use tokio::io;
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
pub use tokio::sync::broadcast;
use tokio::sync::{mpsc, Semaphore};
use tokio::time::{sleep, Duration};
use tokio::time::{sleep, timeout, Duration};

pub const PROXY_SERVER: &str = "https://your-domain.com";
pub const LOCAL_HOST: &str = "127.0.0.1";

/// Noise protocol parameters for the encrypted tunnel between client and
/// server. Must match the server exactly; NK authenticates the server by its
/// static key (from the registration response) and derives fresh session keys
/// per connection.
pub const NOISE_PARAMS: &str = "Noise_NK_25519_ChaChaPoly_BLAKE2s";

/// How long the tunnel handshake may take before the connection attempt is
/// treated as a remote failure.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);

// See https://tldp.org/HOWTO/html_single/TCP-Keepalive-HOWTO to understand how keepalive work.
const TCP_KEEPALIVE_TIME: Duration = Duration::from_secs(30);
const TCP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(10);
Expand All @@ -36,6 +47,8 @@ struct ProxyResponse {
port: u16,
max_conn_count: u8,
url: String,
server_public_key: String,
session_token: String,
}

/// The server detail for client to connect
Expand All @@ -45,6 +58,11 @@ pub struct TunnelServerInfo {
pub port: u16,
pub max_conn_count: u8,
pub url: String,
/// The server's static Noise public key, used to authenticate the tunnel
/// handshake.
pub server_public_key: Vec<u8>,
/// Per-tunnel token presented on every tunnel connection.
pub session_token: Vec<u8>,
}

pub struct ClientConfig {
Expand Down Expand Up @@ -250,8 +268,7 @@ fn start_tunnel_connections(
max_conn: u8,
health: RoundHealth,
) {
let server_host = server.host.clone();
let server_port = server.port;
let server = Arc::new(server.clone());
let local_host = local_host.unwrap_or_else(|| LOCAL_HOST.to_string());

let count = std::cmp::min(server.max_conn_count, max_conn);
Expand All @@ -271,15 +288,15 @@ fn start_tunnel_connections(
return;
},
};
let server_host = server_host.clone();
let server = server.clone();
let local_host = local_host.clone();
let health = health.clone();
let mut shutdown_receiver = shutdown_signal.subscribe();

tokio::spawn(async move {
tokio::select! {
_ = tunnel_one_connection(
&server_host, server_port,
&server,
&local_host, local_port,
&health,
) => {}
Expand All @@ -301,21 +318,47 @@ fn start_tunnel_connections(
}

async fn tunnel_one_connection(
server_host: &str,
server_port: u16,
server: &TunnelServerInfo,
local_host: &str,
local_port: u16,
health: &RoundHealth,
) {
log::debug!("Connecting to remote: {}:{}", server_host, server_port);
let remote_stream = match TcpStream::connect(format!("{server_host}:{server_port}")).await {
Ok(stream) => {
log::debug!("Connecting to remote: {}:{}", server.host, server.port);
let remote_stream = match TcpStream::connect(format!("{}:{}", server.host, server.port)).await
{
Ok(stream) => stream,
Err(err) => {
let down_for = health.record_failure();
log::error!("Remote connect failed (down for {:?}): {:?}", down_for, err);
sleep(Duration::from_secs(10)).await;
return;
}
};

// Keepalive has to be configured on the raw TCP socket, before the
// encrypted stream takes ownership of it.
if let Err(err) = set_keepalive(&remote_stream) {
log::warn!("failed to enable TCP keepalive: {err:?}");
}

// A completed handshake (server key verified, session token acknowledged)
// is the success signal for re-registration health: a TCP connect alone
// can succeed against a stale endpoint whose key or token no longer match.
let remote_stream = match timeout(HANDSHAKE_TIMEOUT, secure_connect(remote_stream, server)).await
{
Ok(Ok(stream)) => {
health.record_success();
stream
}
Err(err) => {
Ok(Err(err)) => {
let down_for = health.record_failure();
log::error!("Remote connect failed (down for {:?}): {:?}", down_for, err);
log::error!("Tunnel handshake failed (down for {:?}): {:?}", down_for, err);
sleep(Duration::from_secs(10)).await;
return;
}
Err(_) => {
let down_for = health.record_failure();
log::error!("Tunnel handshake timed out (down for {:?})", down_for);
sleep(Duration::from_secs(10)).await;
return;
}
Expand All @@ -334,21 +377,51 @@ async fn tunnel_one_connection(
}
}

async fn proxy_through(
mut remote_stream: TcpStream,
local_host: &str,
local_port: u16,
) -> Result<()> {
log::debug!("Connecting to local: {}:{}", local_host, local_port);
let mut local_stream = TcpStream::connect(format!("{local_host}:{local_port}")).await?;
/// Establish the encrypted tunnel: Noise NK handshake pinned to the server's
/// static public key, then authenticate with the session token and wait for
/// the server's one-byte acknowledgement. Without the ack, a rejected token
/// would only surface later as a mysteriously dead proxied request.
async fn secure_connect(
stream: TcpStream,
server: &TunnelServerInfo,
) -> Result<NoiseStream<TcpStream>> {
let initiator = Builder::new(NOISE_PARAMS.parse()?)
.remote_public_key(&server.server_public_key)
.build_initiator()?;
let mut stream = NoiseStream::handshake(stream, initiator)
.await
.map_err(|err| anyhow!("noise handshake failed: {err:?}"))?;

stream.write_all(&server.session_token).await?;
stream.flush().await?;

let mut ack = [0u8; 1];
stream
.read_exact(&mut ack)
.await
.context("server rejected the session token")?;

Ok(stream)
}

fn set_keepalive(stream: &TcpStream) -> Result<()> {
let ka = TcpKeepalive::new()
.with_time(TCP_KEEPALIVE_TIME)
.with_interval(TCP_KEEPALIVE_INTERVAL);
#[cfg(not(target_os = "windows"))]
let ka = ka.with_retries(TCP_KEEPALIVE_RETRIES);
let sf = SockRef::from(&remote_stream);
let sf = SockRef::from(stream);
sf.set_tcp_keepalive(&ka)?;
Ok(())
}

async fn proxy_through(
mut remote_stream: NoiseStream<TcpStream>,
local_host: &str,
local_port: u16,
) -> Result<()> {
log::debug!("Connecting to local: {}:{}", local_host, local_port);
let mut local_stream = TcpStream::connect(format!("{local_host}:{local_port}")).await?;

io::copy_bidirectional(&mut remote_stream, &mut local_stream).await?;
Ok(())
Expand Down Expand Up @@ -377,11 +450,18 @@ async fn get_tunnel_endpoint(
None => host,
};

let server_public_key = hex::decode(&resp.server_public_key)
.context("invalid server_public_key in registration response")?;
let session_token = hex::decode(&resp.session_token)
.context("invalid session_token in registration response")?;

let tunnel_info = TunnelServerInfo {
host: host.to_string(),
port: resp.port,
max_conn_count: resp.max_conn_count,
url: resp.url,
server_public_key,
session_token,
};

Ok(tunnel_info)
Expand Down
60 changes: 54 additions & 6 deletions client/tests/reregistration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ use std::sync::{
Arc,
};

use localtunnel_client::{broadcast, open_tunnel, ClientConfig};
use localtunnel_client::{broadcast, open_tunnel, ClientConfig, NOISE_PARAMS};
use snowstorm::{Builder, Keypair, NoiseStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::time::{sleep, Duration};

async fn mock_api_server(listener: TcpListener, endpoint_port: Arc<AtomicU16>) {
const SESSION_TOKEN: [u8; 32] = [7u8; 32];

async fn mock_api_server(listener: TcpListener, endpoint_port: Arc<AtomicU16>, public_key: String) {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(v) => v,
Expand All @@ -18,8 +21,9 @@ async fn mock_api_server(listener: TcpListener, endpoint_port: Arc<AtomicU16>) {
let _ = stream.read(&mut buf).await;

let port = endpoint_port.load(Ordering::Relaxed);
let token = hex::encode(SESSION_TOKEN);
let body = format!(
r#"{{"id":"test","port":{port},"max_conn_count":10,"url":"http://test.127.0.0.1:{port}"}}"#,
r#"{{"id":"test","port":{port},"max_conn_count":10,"url":"http://test.127.0.0.1:{port}","server_public_key":"{public_key}","session_token":"{token}"}}"#,
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
Expand All @@ -40,8 +44,44 @@ async fn accept_and_count(listener: TcpListener, counter: Arc<AtomicU32>) {
}
}

// A tunnel endpoint that completes the encrypted handshake (so the client
// counts the endpoint as healthy) and then drops the stream, mirroring the
// old drop-on-accept behaviour of the plain-TCP mock.
async fn noise_accept_and_count(listener: TcpListener, counter: Arc<AtomicU32>, key: Arc<Keypair>) {
loop {
match listener.accept().await {
Ok((stream, _)) => {
counter.fetch_add(1, Ordering::Relaxed);
let key = key.clone();
tokio::spawn(async move {
let responder = Builder::new(NOISE_PARAMS.parse().unwrap())
.local_private_key(&key.private)
.build_responder()
.unwrap();
if let Ok(mut stream) = NoiseStream::handshake(stream, responder).await {
let mut token = [0u8; 32];
if stream.read_exact(&mut token).await.is_ok() {
let _ = stream.write_all(&[1]).await;
let _ = stream.flush().await;
}
}
});
}
Err(_) => return,
}
}
}

#[tokio::test]
async fn reregistration_on_remote_failure() {
// Server-side Noise identity, shared by both mock tunnel endpoints.
let key = Arc::new(
Builder::new(NOISE_PARAMS.parse().unwrap())
.generate_keypair()
.unwrap(),
);
let public_key = hex::encode(&key.public);

// Local server (simulates the application behind the tunnel)
let local = TcpListener::bind("127.0.0.1:0").await.unwrap();
let local_port = local.local_addr().unwrap().port();
Expand All @@ -51,19 +91,27 @@ async fn reregistration_on_remote_failure() {
let remote1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
let remote1_port = remote1.local_addr().unwrap().port();
let remote1_count = Arc::new(AtomicU32::new(0));
let remote1_task = tokio::spawn(accept_and_count(remote1, remote1_count.clone()));
let remote1_task = tokio::spawn(noise_accept_and_count(
remote1,
remote1_count.clone(),
key.clone(),
));

// Remote endpoint 2 (ready before remote1 goes down)
let remote2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
let remote2_port = remote2.local_addr().unwrap().port();
let remote2_count = Arc::new(AtomicU32::new(0));
tokio::spawn(accept_and_count(remote2, remote2_count.clone()));
tokio::spawn(noise_accept_and_count(
remote2,
remote2_count.clone(),
key.clone(),
));

// Mock API server (returns whichever port endpoint_port holds)
let endpoint_port = Arc::new(AtomicU16::new(remote1_port));
let api = TcpListener::bind("127.0.0.1:0").await.unwrap();
let api_port = api.local_addr().unwrap().port();
tokio::spawn(mock_api_server(api, endpoint_port.clone()));
tokio::spawn(mock_api_server(api, endpoint_port.clone(), public_key));

// Start the tunnel client with a zero re-registration window: the first
// remote-connect failure then triggers re-registration immediately, which
Expand Down
Loading
Loading