This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a geo-distributed tunnel library written in Rust that enables developers to expose local servers to the internet through geographically distributed exit nodes. The system supports multiple protocols (TCP, TLS with SNI, HTTP, HTTPS) with automatic HTTPS certificates, wildcard domains, and QUIC-based multiplexing.
Key Concept: The system uses QUIC multiplexing to handle multiple logical streams over a single physical connection, reducing overhead and improving performance.
This is a Rust workspace with 12 focused crates, each with a single responsibility:
localup-proto: Protocol definitions, message types, frame format, codeclocalup-client: Public client library API (main entry point for users)localup-connection: Connection management, QUIC transport, multiplexing, reconnectionlocalup-auth: Authentication and JWT handlinglocalup-router: Routing logic (TCP port-based, SNI-based, HTTP host-based)localup-server-tcp: TCP tunnel server implementationlocalup-server-tls: TLS/SNI tunnel server with passthrough (no termination)localup-server-https: HTTPS server with TLS termination and HTTP/1.1, HTTP/2localup-cert: Certificate management, ACME/Let's Encrypt integrationlocalup-control: Control plane orchestration, tunnel registry, exit node selectionlocalup-exit-node: Exit node orchestrator that coordinates all server typeslocalup-cli: Command-line tool for userslocalup-relay-db: Database layer using SeaORM for request/response storage and traffic inspectionlocalup-api: REST API with OpenAPI documentation for managing tunnels and viewing traffic
# Build entire workspace
cargo build
# Build specific crate
cargo build -p localup-client
# Build with release optimizations
cargo build --release# Run all tests in workspace
cargo test
# Run tests for specific crate
cargo test -p localup-proto
# Run specific test
cargo test test_tcp_localup_basic
# Run tests with output
cargo test -- --nocapture# Check formatting
cargo fmt --all -- --check
# Format code
cargo fmt --all
# Run clippy (CI configuration - treats warnings as errors)
cargo clippy --all-targets --all-features -- -D warnings
# Fix clippy warnings automatically
cargo clippy --fixIMPORTANT: After modifying any code, you MUST run both linting commands to ensure CI will pass:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warningsThese are the exact commands used in the CI workflow (.github/workflows/ci.yml). All code must pass both checks before committing.
# Run exit node binary (defaults to in-memory SQLite)
cargo run -p localup-exit-node
# Run exit node with persistent SQLite
cargo run -p localup-exit-node -- --database-url "sqlite://./tunnel.db?mode=rwc"
# Run exit node with PostgreSQL
cargo run -p localup-exit-node -- --database-url "postgres://user:pass@localhost/localup_db"
# Run CLI tool
cargo run -p localup-cli -- --helpThe system uses a three-tier architecture:
-
Client (library integrated into user's app)
- Connects to control plane for tunnel registration
- Establishes QUIC connection to assigned exit node
- Multiplexes multiple streams over single connection
- Forwards requests to local server (localhost:PORT)
-
Control Plane (central orchestration)
- Handles tunnel registration and authentication
- Selects optimal exit node based on geo-location
- Manages DNS and subdomain allocation
- Monitors exit node health
-
Exit Node (edge servers)
- Accepts external connections (TCP/TLS/HTTP/HTTPS)
- Routes to appropriate tunnel based on port/SNI/host
- Handles TLS termination (for HTTPS) or passthrough (for TLS)
- Manages ACME certificates automatically
All communication uses QUIC multiplexing where a single QUIC connection carries multiple logical streams:
┌─────────────────────────────────────────────────────────────┐
│ QUIC Connection │
├─────────────────────────────────────────────────────────────┤
│ Stream 0: Control │ Stream 1: TCP │ Stream 2: HTTP│
│ (Connect, Ping) │ (TcpConnect,Data) │ (Request,Resp)│
├───────────────────────┼────────────────────┼────────────────┤
│ Stream 3: TCP │ Stream N... │ │
│ (TcpConnect,Data) │ (one per request) │ │
└─────────────────────────────────────────────────────────────┘
Stream 0 (Control): ONLY for tunnel registration (Connect/Connected) and heartbeat (Ping/Pong). After registration, this stream is used minimally.
Data Streams (1...N): Each TCP connection or HTTP request gets its own independent QUIC stream. This provides:
- ✅ Natural isolation - one slow request doesn't block others
- ✅ No mutexes needed - each stream is independent
- ✅ Better performance - parallel streams utilize available bandwidth
- ✅ Proper flow control - per-stream backpressure
Critical Architecture Notes:
- Exit node calls
connection.open_stream()to create a new stream for each incoming TCP/HTTP connection - Client calls
connection.accept_stream()in a loop to receive and handle these streams - Each stream's lifetime matches the underlying TCP connection or HTTP request/response lifecycle
- DO NOT send data messages (TcpConnect, TcpData, HttpRequest, HttpResponse) on the control stream - they belong on dedicated data streams
TCP: Port-based routing, bidirectional proxy TLS/SNI: SNI extraction from ClientHello, TLS passthrough (no termination), end-to-end encryption HTTP: Plain HTTP, host-based routing HTTPS: TLS termination at exit node, HTTP/1.1 and HTTP/2 support, WebSocket upgrade support
Each protocol (TCP, TLS, HTTPS) has its own crate for:
- Single Responsibility Principle
- Independent development and testing
- Clear protocol-specific optimizations
- Deployment flexibility
QUIC provides:
- Built-in multiplexing (no custom mux layer needed)
- 0-RTT connection establishment
- Better mobile/unreliable network performance
- Built-in TLS 1.3 security
- Per-stream flow control
Trade-off: Some corporate firewalls may block UDP/QUIC.
Use thiserror for custom error types. Each crate defines its own error types with proper context.
Everything uses Tokio. Never use std::sync primitives - use tokio::sync instead.
Protocol messages are defined in localup-proto/src/messages.rs and serialized using bincode. All messages implement Serialize and Deserialize.
Path Parameter Syntax: Axum 0.8+ requires {param} syntax for path parameters, not :param from older versions.
// ✅ Correct (Axum 0.8+)
.route("/api/tunnels/{id}", get(handlers::get_tunnel))
.route("/api/requests/{id}/replay", post(handlers::replay_request))
// ❌ Wrong (will panic with "Path segments must not start with `:`")
.route("/api/tunnels/:id", get(handlers::get_tunnel))
.route("/api/requests/:id/replay", post(handlers::replay_request))Router Composition: When integrating multiple routers (e.g., API + Swagger UI), use .merge():
let api_router = Router::new()
.route("/api/tunnels", get(handlers::list_tunnels))
.with_state(state);
let router = Router::new()
.merge(api_router)
.merge(SwaggerUi::new("/swagger-ui").url("/api/openapi.json", api_doc));Zero Warnings Policy: All code must compile without warnings. Before committing or completing features:
# Verify no warnings across all targets
cargo build --all-targets 2>&1 | grep "warning:" && echo "Fix warnings!" || echo "✓ Clean build"When addressing warnings, always understand why they exist:
- Unused code: Remove if genuinely dead, or prefix with
_if intentionally unused - Placeholder code: Mark future features with
#[allow(dead_code)]or#[allow(unused_imports)]with comments explaining intent - Duplicate code: Refactor to avoid duplication
- Incomplete logic: Fix the underlying issue rather than suppressing the warning
Test Coverage Requirements:
- All crates: ≥75% test coverage (minimum, non-negotiable)
- Core libraries: >90% test coverage (localup-transport, localup-proto, localup-router, localup-auth, localup-relay-db)
-
Unit tests: Per-crate in each
src/file (orsrc/tests.rsmodule)- Test individual functions, methods, and components in isolation
- Use mocks for dependencies
- Fast execution, no I/O
-
Integration tests: In crate
tests/directory (MANDATORY for all crates with public APIs)- Test real user workflows from a client perspective
- Test component interactions end-to-end
- Simulate actual usage scenarios
- Must cover: authentication flows, multi-protocol scenarios, error recovery, concurrent operations
-
Protocol-specific tests:
tcp_server_test.rs,tls_server_test.rs,https_server_test.rs
# Run all tests in workspace
cargo test
# Run tests for specific crate
cargo test -p localup-transport
cargo test -p localup-transport-quic
# Run only unit tests
cargo test --lib -p localup-transport
# Run only integration tests
cargo test --test integration -p localup-transport-quic
# Run with output
cargo test -- --nocapture
# Run single-threaded (for QUIC tests)
cargo test -- --test-threads=1To check test coverage, use cargo-tarpaulin:
# Install tarpaulin
cargo install cargo-tarpaulin
# Check coverage for a specific crate
cargo tarpaulin -p localup-transport --out Stdout
# Check coverage for multiple crates
cargo tarpaulin -p localup-transport -p localup-transport-quic --out Html- All new features must include BOTH unit AND integration tests
- Critical paths should have >90% coverage
- Mock implementations should be in
src/tests.rsortests/common/mod.rs - Integration tests are mandatory for all crates with public APIs
- Must test from user perspective (how would a developer use this crate?)
- Must test real component interactions, not just mocks
- Must cover error scenarios and edge cases
- Example: For
localup-control, test actual TCP connections, message serialization, authentication flows
- Use
#[tokio::test]for async tests - For QUIC tests, certificates in workspace root (
cert.pem,key.pem) are used
Every crate with a public API must have integration tests in tests/ directory covering:
-
Happy Path Scenarios
- Basic successful operation
- Multiple protocol/configuration variants
- Concurrent operations
-
Authentication & Authorization (if applicable)
- Valid credentials → success
- Invalid credentials → rejection
- Token expiration → proper error handling
-
Error Recovery & Edge Cases
- Network failures (connection refused, timeout, reset)
- Invalid input (malformed messages, wrong types)
- Resource exhaustion (port conflicts, memory limits)
- Concurrent access (race conditions, deadlocks)
-
Real Component Integration
- Actual TCP/HTTP connections, not mocks
- Real message serialization/deserialization
- Real database operations (with in-memory DB)
- Real async runtime behavior
Example Structure (crates/localup-control/tests/integration.rs):
#[tokio::test]
async fn test_basic_http_localup_connection() {
// Setup: Start real TCP server
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// Act: Connect as client and send real messages
let mut client = TcpStream::connect(addr).await.unwrap();
send_message(&mut client, TunnelMessage::Connect { ... }).await;
// Assert: Verify real responses
let response = recv_message(&mut client).await.unwrap();
assert!(matches!(response, TunnelMessage::Connected { .. }));
}What NOT to do:
- ❌ Only mocking all dependencies (that's a unit test)
- ❌ Only testing internal implementation details
- ❌ Ignoring error paths
- ❌ Skipping integration tests because "unit tests pass"
Always test error recovery paths with custom service errors. This is non-negotiable for production readiness.
Error scenarios to test:
-
Network Errors
- Connection timeout
- Connection refused
- Connection lost/reset
- Partial writes
- Read errors
-
Protocol Errors
- Invalid message format
- Message too large
- Unexpected message type
- Protocol version mismatch
-
Resource Errors
- Out of memory
- File descriptor exhaustion
- Port already in use
- Certificate not found
-
Application Errors
- Invalid configuration
- Authentication failure
- Authorization failure
- Rate limit exceeded
-
Recovery Behaviors
- Graceful degradation
- Retry with backoff
- Circuit breaker patterns
- Cleanup on failure
Example error recovery test:
#[tokio::test]
async fn test_connection_recovery_on_timeout() {
let config = ClientConfig::default()
.with_timeout(Duration::from_millis(100));
let client = Client::new(config);
// Simulate slow server that times out
let result = client.connect("slow-server:8080").await;
// Verify timeout error
assert!(matches!(result, Err(TransportError::Timeout)));
// Verify client can recover and retry
let result = client.connect("fast-server:8080").await;
assert!(result.is_ok());
// Verify no resource leaks
assert_eq!(client.active_connections(), 1);
}
#[tokio::test]
async fn test_graceful_degradation_on_cert_error() {
let config = ServerConfig::default()
.with_cert_path("nonexistent.pem");
// Should fail gracefully with clear error
let result = Server::new(config);
assert!(matches!(result, Err(ServerError::CertificateNotFound(_))));
// Error should be descriptive
let err = result.unwrap_err();
assert!(err.to_string().contains("nonexistent.pem"));
}Define domain-specific error types with context:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum TunnelError {
#[error("Connection timeout after {timeout:?}: {context}")]
Timeout {
timeout: Duration,
context: String
},
#[error("Certificate error: {0}")]
Certificate(#[from] CertificateError),
#[error("Transport error: {0}")]
Transport(#[from] TransportError),
#[error("Retry exhausted after {attempts} attempts: {last_error}")]
RetryExhausted {
attempts: u32,
last_error: Box<dyn Error + Send + Sync>,
},
}// Unit tests in src/module.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_functionality() {
// Test here
}
#[tokio::test]
async fn test_async_functionality() {
// Async test here
}
}
// Integration tests in tests/integration.rs
use localup_transport::*;
#[tokio::test]
async fn test_end_to_end_flow() {
// Full integration test
}Different crate types have different coverage requirements based on their criticality:
Tier 1: Core Infrastructure (≥80% required)
localup-transport- Transport abstraction layerlocalup-proto- Protocol definitionslocalup-router- Routing logiclocalup-auth- Authentication/authorizationlocalup-relay-db- Database layer (CRITICAL: currently 0%)localup-exit-node- Orchestration (CRITICAL: currently 0%)
Tier 2: Server Components (≥60% required)
localup-server-tcp- TCP serverlocalup-server-tls- TLS/SNI serverlocalup-server-https- HTTPS serverlocalup-control- Control planelocalup-connection- Connection management
Tier 3: Client & Tools (≥50% required)
localup-client- Client librarylocalup-cli- CLI toollocalup-cert- Certificate managementlocalup-api- REST API
Current Status (as of last check):
| Crate | Current | Target | Status |
|---|---|---|---|
| localup-transport | 95% | 80% | ✅ Exceeds |
| localup-transport-quic | 72% | 75% | |
| localup-router | 80% | 80% | ✅ Meets |
| localup-proto | 75% | 80% | |
| localup-auth | 80% | 80% | ✅ Meets |
| localup-cli | 85% | 50% | ✅ Exceeds |
| localup-cert | 70% | 50% | ✅ Exceeds |
| localup-relay-db | 0% | 80% | ❌ BLOCKER |
| localup-exit-node | 0% | 80% | ❌ BLOCKER |
| localup-control | 20% | 60% | ❌ Insufficient |
| localup-connection | 15% | 60% | ❌ Insufficient |
| localup-client | 50% | 50% | ✅ Meets |
| localup-api | 25% | 50% | ❌ Insufficient |
Total workspace tests: 103 (102 passing, 1 failing benchmark)
PROTOCOL_VERSION: Current protocol version (defined inlocalup-proto)MAX_FRAME_SIZE: 16MB maximum frame sizeCONTROL_STREAM_ID: Stream 0 reserved for control messages
Key dependencies used across workspace:
- Async:
tokio,futures,async-trait - Networking:
hyper,quinn(QUIC),rustls(TLS) - Serialization:
serde,bincode,serde_json - ACME:
instant-acme(Let's Encrypt integration) - Auth:
jsonwebtoken,base64 - Database:
sea-orm(PostgreSQL, SQLite3),sea-orm-migration - Web/API:
axum,tower,tower-http,utoipa,utoipa-swagger-ui - Utilities:
bytes,thiserror,anyhow,tracing
The system uses SeaORM for database operations, supporting multiple backends:
- PostgreSQL with TimescaleDB (recommended): Optimized for time-series data
--database-url "postgres://user:pass@localhost/localup_db" - PostgreSQL: Standard relational database without TimescaleDB
- SQLite3: Lightweight option for development or small deployments
--database-url "sqlite://./tunnel.db?mode=rwc"
- In-memory SQLite (default): No persistence, data lost on restart
# Automatic if --database-url not specified cargo run -p localup-exit-node
- Ephemeral SQLite: In-memory storage for local request history
"sqlite::memory:"
The localup-relay-db crate contains:
- Entities: SeaORM models (e.g.,
CapturedRequest) - Migrations: Automatic schema setup with
sea-orm-migration - TimescaleDB support: Automatic hypertable creation for PostgreSQL (if extension available)
Migrations run automatically on startup. The captured_requests table stores:
- Full HTTP request/response data (headers, body, status)
- Timestamps for time-series queries
- Latency metrics
- Indexes on
localup_idandcreated_at
Both port allocations (TCP) and route registrations (HTTP/HTTPS subdomains) use a reservation system with TTL:
- On disconnect: Resources are marked as "reserved" (default: 5 minutes TTL)
- On reconnect: If the same
localup_idreconnects within the TTL window, it receives the same port/subdomain - After TTL expires: A background cleanup task frees the resources for reuse
This ensures clients can reconnect with the same public URLs after temporary network interruptions.
- Identify which crate(s) the feature belongs to
- Update protocol messages if needed (
localup-proto) - Implement in appropriate crate(s)
- Add unit tests in the same file
- Add integration tests in
tests/directory - Update documentation
- Define message types in
localup-proto/src/messages.rs - Add routing logic in
localup-router - Create new server crate
localup-server-{protocol} - Integrate with exit node orchestrator
- Add client-side support in
localup-client
The project includes web-based dashboards and management interfaces built with modern web technologies.
webapps/
├── dashboard/ # Main tunnel management dashboard
└── [future-apps]/ # Additional web applications
All web applications must use:
- Package Manager: Bun (not npm or yarn)
- Framework: React 19+ with TypeScript
- Build Tool: Vite 7+
- Styling: Tailwind CSS v4 (with
@tailwindcss/viteplugin) - API Client:
@hey-api/openapi-tsfor type-safe API generation
cd webapps/dashboard
bun installThe backend must expose OpenAPI spec at /api/openapi.json. Generate the TypeScript client:
bun run generate:apiThis creates type-safe API clients in src/api/generated/.
bun run dev # Start dev server (port 3000)
bun run type-check # Type checking
bun run lint # Linting
bun run build # Production buildFor webapps to work, the Rust backend must:
- Use
utoipawith Axum 0.8+ for OpenAPI documentation - Expose OpenAPI spec at
/api/openapi.json - Serve API on port 8080 (configurable)
- CORS configuration for development (allow localhost:3000)
Example Rust setup:
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
#[derive(OpenApi)]
#[openapi(
paths(list_tunnels, create_tunnel),
components(schemas(Tunnel, TunnelConfig))
)]
struct ApiDoc;
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(list_tunnels, create_tunnel))
.split_for_parts();
// Serve OpenAPI spec
let app = router.route("/api/openapi.json", get(|| async {
Json(api)
}));src/
├── api/
│ ├── generated/ # Auto-generated (DO NOT EDIT)
│ └── client.ts # Client configuration
├── components/ # React components
│ ├── TunnelList.tsx
│ └── TrafficInspector.tsx
├── hooks/ # Custom hooks
│ ├── useTunnels.ts
│ └── useWebSocket.ts
├── types/ # Additional TypeScript types
├── App.tsx # Root component
├── main.tsx # Entry point
└── index.css # Tailwind imports only
- Strict mode: Enabled
- No
anytypes: Use proper types orunknown - Import paths: Relative or via
@/alias
- Use Tailwind utilities: Avoid custom CSS
- Responsive design: Use Tailwind breakpoints (
sm:,md:,lg:) - Dark mode: Use Tailwind's dark mode classes when needed
- Components: Extract repeated patterns into React components
// ✅ Good: Use generated types
import { TunnelService, type Tunnel } from './api/generated';
const tunnels: Tunnel[] = await TunnelService.listTunnels();
// ❌ Bad: Manual fetch without types
const response = await fetch('/api/tunnels');
const tunnels = await response.json();- Local state:
useStatefor component-local state - Server state: React Query or similar (when needed)
- WebSocket: Custom hooks for real-time updates
- Development: Served by Vite dev server (port 3000)
- Production: Built to
dist/, embedded in Rust binary usinginclude_bytes!
- Create directory:
webapps/new-app/ - Initialize with Vite:
cd webapps bun create vite new-app --template react-ts cd new-app bun install bun add -d @tailwindcss/vite@next tailwindcss@next @hey-api/openapi-ts bun add @hey-api/client-fetch
- Configure Tailwind in
vite.config.ts - Create
openapi-ts.config.ts - Update
src/index.cssto@import "tailwindcss"; - Follow the structure and standards above
Web applications can be deployed in multiple ways:
-
Embedded (recommended for self-hosted):
- Build assets are bundled into Rust binary
- Served directly by Axum from memory
- Zero external dependencies
-
Separate hosting:
- Build
dist/and deploy to Vercel/Netlify/Cloudflare Pages - Configure API URL via environment variables
- Build
-
Docker:
- Multi-stage build: frontend + backend
- Serve static files from Rust or nginx
This project is in active development. The core crates have been scaffolded with basic structure. Refer to SPEC.md for the complete technical specification and implementation phases.
Current milestone: Phase 1-2 (Core protocol and TCP tunnel implementation)
- All public-facing connections use TLS 1.3
- Tunnel connections use QUIC (built-in TLS 1.3)
- Authentication uses JWT tokens
- ACME integration for automatic certificate management
- Rate limiting and IP allowlisting supported
- Additional latency: < 50ms for same-region routing
- Tunnel establishment: < 2 seconds
- Support: 1000+ concurrent connections per tunnel
- Throughput: 10,000+ requests/second per exit node
localup-lib is the high-level public API crate for Rust applications that want to integrate tunnel functionality. It re-exports all the focused crates, providing a unified entry point.
- For tunnel clients: Use
TunnelClientdirectly fromlocalup-libinstead of importing fromlocalup-client - For custom relays: Build custom relay servers using the server components (
TunnelHandler,HttpsServer, etc.) - Single dependency: Applications only need to add
localup-libinstead of multiple crate dependencies
IMPORTANT: localup-lib must be kept up-to-date whenever you make changes to other crates. This is a MANDATORY requirement.
- When adding new public types to any crate, add the re-export to localup-lib/src/lib.rs
- When removing/renaming public types, update the re-exports accordingly
- After any API changes, run
cargo build -p localup-libto ensure it compiles - Only re-export public types - do not re-export internal/private types
// localup-lib/src/lib.rs
pub use localup_client::{TunnelClient, TunnelConfig, ...}; // Client API
pub use localup_control::{TunnelHandler, ...}; // Relay API
pub use localup_server_https::{HttpsServer, ...}; // Server components
// ... etc// Cargo.toml
[dependencies]
localup-lib = { path = "../localup-lib" }
// main.rs
use localup_lib::{TunnelClient, ProtocolConfig, TunnelConfig};
let config = TunnelConfig {
relay_addr: "localhost:4443".to_string(),
auth_token: Some("token".to_string()),
protocol: ProtocolConfig::Http {
local_port: 3000,
subdomain: Some("myapp".to_string()),
},
..Default::default()
};
let client = TunnelClient::connect(config).await?;
client.wait().await?;Always verify localup-lib compiles after making changes:
cargo build -p localup-lib
cargo build --all-targets # Ensure entire workspace compilesZero warnings policy applies to localup-lib just like all other crates.
Guideline: All markdown files created during development without explicit user request should be placed in the thoughts/ folder at the repository root.
This keeps the root directory clean while preserving internal documentation and analysis:
localup-dev/
├── thoughts/
│ ├── SNI_ANALYSIS.md # Analysis and research notes
│ ├── ARCHITECTURE_NOTES.md # Architecture discussions
│ ├── IMPLEMENTATION_PLAN.md # Implementation planning
│ ├── TEST_SUMMARY.md # Test documentation
│ └── [other-documentation]/ # Other internal docs
├── docs/ # User-facing documentation
├── README.md # Project readme (root level, explicit)
├── CLAUDE.md # This file (root level, explicit)
└── [source files]/
Exception: User-requested documentation at the repository root (e.g., when user explicitly asks for a README or specific documentation file) may be placed at the root.
Examples:
- ✅ Internal SNI analysis →
thoughts/SNI_ANALYSIS.md - ✅ Test summaries →
thoughts/TEST_SUMMARY.md - ✅ Implementation notes →
thoughts/IMPLEMENTATION_NOTES.md - ✅ Exploration findings →
thoughts/CODEBASE_EXPLORATION.md - ❌ Root-level documentation without explicit request
Docker Files:
Dockerfile(multi-stage build): Compiles Rust binary in builder stage, runs on Ubuntu 24.04 runtimeDockerfile.prebuilt: Alternative build using pre-compiled binary (faster builds)docker-compose.yml: Complete multi-service setup (relay + web + agent) with TLS certificate volumes.dockerignore: Excludes unnecessary files from Docker build context
TLS Certificates:
relay-cert.pem: Self-signed X.509 certificate (CN=localhost, valid 365 days)relay-key.pem: 2048-bit RSA private key for TLS- Generated with:
openssl req -x509 -newkey rsa:2048 -keyout relay-key.pem -out relay-cert.pem -days 365 -nodes -subj "/CN=localhost"
Documentation Updates:
README.md: Added comprehensive Docker sections with HTTPS examplesscripts/install-local-from-source.sh: Updated to install single unifiedlocalupbinary
Standard Port Mapping (used consistently across all Docker examples):
- 4443/UDP: QUIC control plane (relay ↔ clients)
- 18080/TCP: HTTP server (relay)
- 18443/TCP: HTTPS server (relay with TLS certificates)
Rationale: Using ports 18080/18443 avoids conflicts with common local development ports (8080/8443).
-
Docker Build (multi-stage from source)
docker build -f Dockerfile -t localup:latest . -
Relay Server (with HTTPS support)
docker run -d \ -p 4443:4443/udp \ -p 18080:18080 \ -p 18443:18443 \ -v "$(pwd)/relay-cert.pem:/app/relay-cert.pem:ro" \ -v "$(pwd)/relay-key.pem:/app/relay-key.pem:ro" \ localup:latest relay \ --localup-addr 0.0.0.0:4443 \ --http-addr 0.0.0.0:18080 \ --https-addr 0.0.0.0:18443 \ --tls-cert /app/relay-cert.pem \ --tls-key /app/relay-key.pem \ --jwt-secret "my-super-secret-key"
-
Tunnel Creation (standalone mode)
docker run --rm localup:latest \ --port 3000 \ --protocol http \ --relay host.docker.internal:4443 \ --subdomain myapp \ --token "YOUR_JWT_TOKEN" # Access: http://localhost:18080/myapp
-
Docker Compose (complete setup with relay + web + agent)
- Automatic certificate volume mounting
- Health checks on relay service
- Internal Docker network for service communication
- Agent creates HTTP tunnel to web service
-
Volume Mounting for Certificates: Certificates mounted as read-only volumes from host
- Allows easy certificate rotation without rebuilding image
- Secures permissions (
:roflag prevents modification in container)
-
Multi-Stage Build: Compiles binary in builder stage, runs on lightweight Ubuntu 24.04
- Binary ABI compatibility ensured (GLIBC 2.39+ in runtime image)
- Reduced image size (only runtime dependencies in final layer)
- Reproducible builds (everything compiled inside Docker)
-
Health Checks: Relay service checks
localup --helpcommand- Ensures binary works before Docker Compose considers service healthy
- Prevents dependent services (agent) from starting too early
-
Environment Variables: TLS paths set in container (not host)
- Makes Docker examples portable across different hosts
- Follows Docker best practices for path configuration
For different hostnames or Subject Alternative Names:
# Single hostname (localhost)
openssl req -x509 -newkey rsa:2048 -keyout relay-key.pem -out relay-cert.pem \
-days 365 -nodes -subj "/CN=localhost"
# Production hostname
openssl req -x509 -newkey rsa:2048 -keyout relay-key.pem -out relay-cert.pem \
-days 365 -nodes -subj "/CN=relay.example.com"
# With multiple Subject Alternative Names (SANs)
openssl req -x509 -newkey rsa:2048 -keyout relay-key.pem -out relay-cert.pem \
-days 365 -nodes -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,DNS:127.0.0.1,DNS:host.docker.internal"All examples in README are designed to be copy-paste ready:
- Include build step explicitly
- Use
host.docker.internalfor macOS/Windows Docker Desktop - Include port numbers in access URLs
- Show both HTTP and HTTPS access patterns
- Include cleanup commands (docker stop/rm, docker-compose down)
Correction: The relay command uses --tls-cert and --tls-key flags, NOT --cert-path and --key-path.
Correct usage:
localup relay \
--localup-addr 0.0.0.0:4443 \
--http-addr 0.0.0.0:18080 \
--https-addr 0.0.0.0:18443 \
--tls-cert /app/relay-cert.pem \
--tls-key /app/relay-key.pem \
--jwt-secret "my-super-secret-key"All README examples have been corrected to use --tls-cert and --tls-key.
The system uses JWT (JSON Web Tokens) for authentication between clients and the relay server. JWT tokens are signed with a shared secret that must match between token generation and validation.
A JWT token has three parts separated by dots:
header.payload.signature
Example decoded payload:
{
"sub": "myapp", // subject (tunnel ID)
"iat": 1762681328, // issued at (timestamp)
"exp": 1762767728, // expiration (timestamp)
"iss": "localup-relay", // issuer
"aud": "localup-client", // audience
"protocols": [], // protocols allowed
"regions": [] // regions allowed
}Signature-Only Validation: The relay validates JWT tokens by verifying ONLY the signature and expiration, ignoring all claims:
Validated:
- ✅ Signature Verification: The token must be signed with the correct secret (HMAC-SHA256 or RSA-256)
- ✅ Expiration: The token must not be expired (checks
expclaim)
NOT Validated (explicitly disabled):
- ❌ Issuer Claim (
iss): Relay does NOT check who issued the token - ❌ Audience Claim (
aud): Relay does NOT check who the token is for - ❌ Not-Before Claim (
nbf): Relay does NOT check when token becomes valid - ❌ Any Other Claims: Custom claims are not validated
This means you can generate tokens with any issuer/audience/subject values - as long as they're signed with the correct secret and not expired, they'll be accepted.
Implementation (localup-auth/src/jwt.rs:229-234):
pub fn new(secret: &[u8]) -> Self {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true; // Check expiration
validation.validate_aud = false; // Don't check audience
validation.validate_nbf = false; // Don't check not-before
// Signature is always verified (implicit)
Self { decoding_key, validation }
}Use the CLI to generate tokens:
# Generate token for tunnel "myapp" with 24-hour validity
./target/release/localup generate-token \
--secret "my-super-secret-key" \
--localup-id "myapp"Output includes the token and usage instructions:
✅ JWT Token generated successfully!
Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJteWFwcCIsImlhdCI6MTc2MjY4MTMyOCwiZXhwIjoxNzYyNzY3NzI4LCJpc3MiOiJsb2NhbHVwLXJlbGF5IiwiYXVkIjoibG9jYWx1cC1jbGllbnQiLCJwcm90b2NvbHMiOltdLCJyZWdpb25zIjpbXX0.kYFPGNTd9mNHOcA9OFzCkf2jliyLj5sxNY3CZ-NPUVo
Token details:
- Localup ID: myapp
- Expires in: 24 hour(s)
- Expires at: 2025-11-10 10:42:08 +01:00
Pass the token when creating a tunnel:
# CLI mode
./target/release/localup \
--port 3000 \
--relay localhost:4443 \
--token "eyJ0eXAiOiJKV1QiLC..." \
--protocol http
# Docker mode
docker run -e TUNNEL_AUTH_TOKEN="eyJ0eXAiOiJKV1QiLC..." ...Generate with custom validity:
# 48-hour token
./target/release/localup generate-token \
--secret "my-super-secret-key" \
--localup-id "myapp" \
--hours 48
# 1-hour token
./target/release/localup generate-token \
--secret "my-super-secret-key" \
--localup-id "myapp" \
--hours 1The secret must match exactly between token generation and relay validation:
# Token generation
./target/release/localup generate-token \
--secret "my-super-secret-key" # This secret
# Relay validation
docker run ... \
-e LOCALUP_JWT_SECRET="my-super-secret-key" # Must match exactlyIf the secrets don't match, you'll see:
ERROR localup_control::handler: Authentication failed for tunnel ...: JWT verification failed
Token Generation (localup-cli/src/main.rs:1744-1745):
let claims = JwtClaims::new(
localup_id.clone(),
"localup-relay".to_string(), // issuer
"localup-client".to_string(), // audience
Duration::hours(hours),
);
let token = JwtValidator::encode(secret.as_bytes(), &claims)?;Token Validation (localup-lib/src/relay.rs:440-441):
// Only verify JWT signature using the secret - no issuer/audience checks
let jwt_validator = Arc::new(JwtValidator::new(&jwt_secret));Handler Authentication (localup-control/src/handler.rs:225-235):
if let Some(ref validator) = self.jwt_validator {
if let Err(e) = validator.validate(&auth_token) {
error!("Authentication failed for tunnel {}: {}", localup_id, e);
return Err(format!("Authentication failed: {}", e));
}
}- Internal deployments where you control token generation
- Development/testing environments
- Scenarios where all clients trust the same secret
For production deployments with untrusted clients, consider:
- Adding issuer/audience validation for claim verification
- Using RS256 (RSA) instead of HS256 (HMAC) for asymmetric verification
- Implementing token revocation/blacklisting
- Rate limiting on token generation
Authentication errors are automatically communicated from relay server to client:
Server-side (localup-control/src/handler.rs:225-235):
- Relay validates JWT signature and expiration
- If validation fails, relay sends
Disconnect { reason: "..." }message to client - Relay also logs error for debugging
Client-side (localup-client/src/localup.rs:295-303):
- Client receives Disconnect message from relay
- Client checks if reason contains "Authentication failed", "JWT", etc.
- Client displays error in red:
❌ Authentication failed: <reason> - Client exits with error (no retry)
Result: User sees authentication errors printed to stderr immediately when connecting, no need to check server logs. Errors like "JWT verification failed" or "Token expired" appear on the client terminal instantly.