DEV Community

Cover image for Build your own tunnel in Rust: Expose local sites to the Web with blazing performance
Bohdan
Bohdan

Posted on

Build your own tunnel in Rust: Expose local sites to the Web with blazing performance

The Sidecar pattern is supported by Tauri to allow applications to leverage external binaries written in other languages (Go, Python, C++) or to utilize closed-source tools.

The cloudflared executable is included in the application's distribution resources. To support cross-platform deployment, multiple versions of the binary are bundled, and Tauri's resource manager resolves the appropriate binary at runtime based on the host architecture.

When the Spot Serve invokes cloudflared tunnel --url http://localhost:8080, the following sequence occurs:

  1. Transport Negotiation: cloudflared initiates an outbound connection to Cloudflare's edge network (Argo). By default, it attempts to use the QUIC protocol (based on UDP) for its performance benefits (reduced latency, congestion control). If QUIC fails (e.g., due to strict UDP firewalls), it falls back to HTTP/2 over TCP.

  2. Authentication: For "Quick Tunnels" (TryCloudflare), no pre-authentication is required. The edge network assigns a temporary, random DNS record under trycloudflare.com.

  3. Tunnel Establishment: A persistent session is created. Cloudflare's edge acts as the ingress point.

  4. Reverse Proxying:

  • Ingress: A user makes an HTTPS request to https://random-name.trycloudflare.com.
  • Routing: Cloudflare terminates the TLS at the edge, inspects the Host header, and routes the request into the established tunnel connection.
  • Egress: cloudflared receives the request via the tunnel protocol (Capsule), reconstructs it, and forwards it to localhost:8080.
  • Response: The local server's response follows the reverse path.

This mechanism completely bypasses the need for inbound firewall rules, as the connection is strictly outbound.

Sidecar tunnel

Code Walkthrough

Defining the Sidecar Configuration

In the tauri.conf.json file, the external binary is registered. This tells the bundler to include the binary in the final build artifact.

{
  "tauri": {
    "bundle": {
      "externalBin": [
        "binaries/cloudflared"
      ]
    },
    "allowlist": {
      "shell": {
        "all": false,
        "execute": true,
        "sidecar": true
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This configuration necessitates that the binaries (e.g., cloudflared-x86_64-unknown-linux-gnu) exist in the src-tauri/binaries directory.

The core logic resides in a Rust command module. We must handle the asynchronous spawning of the process and the non-blocking reading of its output streams.

First, we define a structure to hold the active child process. Since Tauri commands are stateless functions, we use tauri::State to manage persistence across calls.

use std::sync::Mutex;
use tauri::command;
use tauri::api::process::{Command, CommandEvent};

pub struct TunnelState {
    // Mutex to safely share the process handle across threads
    pub child_process: Mutex<Option<tauri::api::process::CommandChild>>,
}

impl Default for TunnelState {
    fn default() -> Self {
        Self {
            child_process: Mutex::new(None),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Spawning the Tunnel

The start_tunnel function orchestrates the sidecar. Note the use of Command::new_sidecar which handles the path resolution automatically.

#[command]
pub fn start_tunnel(
    app_handle: tauri::AppHandle,
    port: u16,
    state: tauri::State<TunnelState>,
) -> Result<(), String> {
    let mut state_guard = state.child_process.lock().map_err(|_| "Failed to lock state")?;

    if state_guard.is_some() {
        return Err("Tunnel is already running".into());
    }

    // Configure the sidecar command: cloudflared tunnel --url http://localhost:PORT
    let (mut rx, child) = Command::new_sidecar("cloudflared")
       .map_err(|e| format!("Failed to create sidecar command: {}", e))?
       .args(&["tunnel", "--url", &format!("http://localhost:{}", port)])
       .spawn()
       .map_err(|e| format!("Failed to spawn sidecar: {}", e))?;

    // Store the child handle for later termination
    *state_guard = Some(child);

    // Spawn an async task to listen to stdout/stderr events
    tauri::async_runtime::spawn(async move {
        // Regex to find the TryCloudflare URL
        // Pattern matches: https://[random-subdomain].trycloudflare.com
        let url_regex = regex::Regex::new(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com").unwrap();

        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stderr(line) | CommandEvent::Stdout(line) => {
                    // Log the raw output for debugging
                    println!("[cloudflared]: {}", line);

                    // Check for the URL
                    if let Some(mat) = url_regex.find(&line) {
                        let public_url = mat.as_str().to_string();
                        println!("Tunnel Established: {}", public_url);

                        // Emit the URL back to the frontend
                        app_handle.emit_all("tunnel-url", public_url).unwrap();
                    }
                }
                CommandEvent::Terminated(payload) => {
                    println!("Tunnel terminated: {:?}", payload);
                    break;
                }
                _ => {}
            }
        }
    });

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Stopping the tunnel is as critical as starting it. If the user closes the app, the cloudflared process must die.

#[command]
pub fn stop_tunnel(state: tauri::State<TunnelState>) -> Result<(), String> {
    let mut state_guard = state.child_process.lock().map_err(|_| "Failed to lock state")?;

    if let Some(child) = state_guard.take() {
        child.kill().map_err(|e| format!("Failed to kill process: {}", e))?;
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Crucially, in a production app, this logic should also be hooked into the application's global WindowEvent::Destroyed or exit handler to prevent zombie processes.


Repository: https://github.com/explicit-logic/spot-serve-gui

Top comments (0)