A desktop GUI for Holesail — the peer-to-peer TCP/UDP tunnel. Share any local port with the world, or connect to someone else's tunnel, from a friendly window instead of the CLI.
Built with Tauri v2 (Rust + system webview) and a plain-Node service worker. Packaged builds bundle the Bare runtime, so end users don't need Node.js installed.
Contents: What is this? · Why a GUI? · Benefits · Platforms · Architecture · Requirements · Run · Test · Build · Using the app · Android · Changelog · Known issues · FAQ · Security · License
Holesail GUI is a window over holesail: a peer-to-peer tunnel that exposes any local service (a web app, an API, a game server, a NAS, anything on a TCP/UDP port) to other devices — over the internet — without port forwarding, a static IP, or a middleman server.
The phone in your pocket can reach your home PC's services from anywhere, and your PC can reach services on your phone, through an encrypted connection that goes directly peer-to-peer over the public HyperDHT network.
The three usual headaches
Exposing a local service to the internet usually means one of these headaches:
- Port forwarding — requires router admin access, a public/static IP, and it punches a hole in your home network's firewall
- CGNAT — many ISPs (especially mobile and home fiber in some regions) don't give you a public IPv4 at all, so port forwarding is impossible
- Tunneling services (ngrok/cloudflare tunnels) — work, but route all your traffic through a third-party server, add latency, and put a single company between you and your data
holesail sidesteps all three: both ends connect outward to the DHT (no inbound ports, works behind any NAT), find each other by key, and then talk directly peer-to-peer with end-to-end encryption. No relay server, no open firewall ports, no static IP.
What the GUI adds over the CLI
The CLI works, but tunnels are a continuous thing, not a one-shot command. The GUI adds what the terminal can't:
- Permanent tunnels — a fixed key that never changes and auto-restarts with the app, so your phone always knows where to find your PC
- Saved connections — one tap to reconnect to a key you use often
- Background operation — the app lives in the system tray; tunnels keep running when the window is closed (desktop) and survive device reboots (Android boot receiver, desktop login autostart)
- A phone app — the same codebase runs on Android, so a phone can be a tunnel server too (e.g. share Termux/HTTP servers outward)
Real-world scenarios
- Reach your home PC's apps (SearXNG, Jellyfin, dev servers) from your phone on mobile data
- Share a local dev server with a colleague — no ngrok, no deploy
- Access a service on your phone (e.g. an on-device web server) from your PC
- A private, key-based alternative to exposing services — nobody can connect without the connection string
Why use this over the alternatives
- No server in the middle — traffic isn't routed through a third-party relay you have to trust (ngrok/cloudflare-style); it rides the public HyperDHT
- No router access, no static IP, no port forwarding — works behind CGNAT and any NAT
- End-to-end encrypted in secure mode (
hs://s000…); the key is the address — nobody can connect without it - Zero-config for end users — packaged builds bundle the Bare runtime, no Node.js install
- Permanent tunnels with fixed keys — set once, auto-restart on app launch, login, and device boot
- One codebase, four platforms — Linux/Windows/macOS desktop + Android phone (phone can be a tunnel server too)
- Private-by-default UI — keys in the OS keychain, sandboxed renderer, no telemetry
| Platform | Status | Deliverables |
|---|---|---|
| Linux | ✅ fully working | .deb, .rpm, .AppImage, pacman, .flatpak |
| Windows | ✅ builds via CI | .msi, .exe (NSIS) — bare runtime bundled, no Node needed |
| Android | ✅ backend works (bare runtime) — arm64 APK | debug APK — see the Android section |
| macOS | ✅ builds via CI (untested on real hardware) | .dmg/.app — bare runtime bundled, no Node needed |
Every platform ships a flatpak bundle from CI alongside the desktop installers; see Build a release bundle.
All four targets are built automatically by the GitHub Actions workflow in
.github/workflows/build.yml (artifacts on every push / workflow_dispatch);
macOS just hasn't been verified end-to-end on real hardware the way Linux and
Android have. The flatpak job builds the GNOME-platform bundle in CI too
(artifacts on every push; verified release flow ships it with releases).
How the pieces fit
┌──────────────────────────┐ ┌──────────────────────────────┐
│ Webview (renderer/) │ IPC │ Rust backend (src-tauri/) │
│ HTML/CSS/JS UI │ ─────▶ │ tauri commands + events │
└──────────────────────────┘ └──────────────┬───────────────┘
│ stdio JSON-RPC
┌──────────────▼───────────────┐
│ service-worker.js (plain JS, │
│ Node or Bare — NOT Electron) │
│ └── holesail npm package │
└──────────────────────────────┘
Why a separate worker process? holesail depends on native addons
(sodium-native, udx-native) that are prebuilt per-runtime-ABI. Running them
inside a webview/Electron process breaks on ABI mismatch. The worker runs
under its own process (Node in dev, the bundled Bare runtime in packaged
builds — see below), so the addons load as-is; the Rust backend only proxies
JSON-RPC over stdio.
Dev + end-user needs
- Node.js 18+ (
nodeon PATH) — for development (npm run dev) on every platform. Packaged installers for all four targets (Linux, Windows, macOS, Android) embed the Bare runtime instead, so end users don't need Node.js installed at all — see Build a release bundle. - Rust toolchain (
cargo) for Tauri - Linux:
webkit2gtk-4.1+gtk3dev packages (Tauri prerequisites; see Tauri docs)
npm install # installs holesail (worker dep) + @tauri-apps/cli
npm run dev # tauri dev — builds the Rust backend and opens the windownpm test # E2E: spawns the real service worker, starts a server on
# the DHT, connects a client, stops both, asserts protocolThe test talks to the exact same service-worker.js the GUI uses, so a green
npm test verifies the full backend chain (validation → holesail → hyperdht →
real tunnel).
Linux (.deb/.rpm/AppImage/pacman/Flatpak)
npm run build # prepares resources, then tauri build (deb + rpm + AppImage on Linux)build first runs scripts/prepare-resources.mjs, which assembles a lean
dist-resources/ folder (the Node service worker + a production-only
node_modules with holesail and its native addons, pruned to the current
platform's prebuilds, ~38 MB) and then bundles it into the installers:
-
Linux:
.deband.rpmput the binary in/usr/binand the resources in/usr/lib/holesail-gui/—find_workerresolves them viaresource_dir(). The AppImage target needs FUSE; on FUSE-less machines (containers/CI) and on distros whose newer toolchain trips linuxdeploy's bundledstrip(.relr.dynerrors), build it with:NO_STRIP=1 APPIMAGE_EXTRACT_AND_RUN=1 npx tauri build --bundles appimage
Or skip it entirely with
npx tauri build --bundles deb,rpm.
Arch Linux (pacman) — verified build in packaging/arch/:
./packaging/arch/build.sh # -> packaging/arch/holesail-gui-<ver>-1-x86_64.pkg.tar.zst
sudo pacman -U packaging/arch/holesail-gui-*.pkg.tar.zstInstalls the binary + worker + node_modules into /usr/lib/holesail-gui/ with
a launcher at /usr/bin/holesail-gui (binary and resources in the same dir, so
Tauri's resource_dir() resolves without bundler patching).
Flatpak — manifest in packaging/flatpak/ (built on the GNOME platform,
which ships WebKitGTK 4.1 — the freedesktop webkit2gtk-4.1 extension does
not exist on flathub). The manifest builds libayatana-appindicator from
source (intltool → libdbusmenu → ayatana-ido → libayatana-indicator →
appindicator 0.5.94, the flathub shared-modules recipe) because the GNOME
runtime does not ship the tray library and the app panics without it:
flatpak install flathub org.gnome.Platform//47 org.gnome.Sdk//47
./packaging/flatpak/build.sh # builds + installs io.holesail.gui (user)
flatpak run io.holesail.guiThe flatpak job builds this bundle in CI (GNOME Platform//47) and ships it with releases; the build.sh flow above is for local/manual builds. Runtime behavior on real desktops is still being validated.
macOS / Windows
- macOS / Windows:
npm run buildproduces.dmg/.appand.msi/.exerespectively with the same resource layout —tauri.macos.conf.jsonandtauri.windows.conf.jsonbundle the platform'sbare/bare.exebinary as a resource alongside the prunednode_modules.
Packaged-app requirements: every platform's installer bundles the
Bare runtime (same engine used for Android) instead of relying on system
Node — worker_command() in src-tauri/src/lib.rs prefers the bundled
bare/bare.exe next to service-worker.js and only falls back to spawning
node from PATH when that bundle is missing (e.g. an unpackaged dev
checkout, or a build produced without --bare). End users on Linux, Windows,
macOS, and Android do not need Node.js installed. macOS bundling is
CI-verified but not yet confirmed on real Apple hardware.
Share, connect, saved tunnels, deep links, tray
Share a port — pick a local port (e.g. 3000), optionally a custom 32+ hex char key,
toggle private/public. A session card appears with the hs://s000… connection
string — hit Copy and send it to whoever needs access. Server cards also show
a Copy LAN URL row (http://<lan-ip>:<port>) — a phone on the same network
can reach the service directly, no DHT involved.
Connect — paste a connection string (hs://s000… private or hs://0000…
public; secure mode is auto-detected from the prefix). The tunnel is exposed on
your localhost port.
Sessions can be paused/resumed/stopped; the event log at the bottom shows what the worker is doing.
Temporary vs Permanent — the Tunnel type selector on the Share tab chooses between a one-off key (new random key each start) and a permanent tunnel: fixed key, named, saved, and auto-restarted whenever the app (re)starts. Connect has a matching Save this connection checkbox for keys you use often.
Saved tab — every permanent tunnel and saved connection is listed here with Start/Stop, an Auto-start toggle, Rename, Duplicate, Export (to clipboard) and Delete (two-tap confirm). Import merges exported JSON back in. On desktop, Auto-start tunnels also enable login autostart, so they survive PC reboots; on Android, a boot receiver restores them after device reboots.
Deep links (hs://) — clicking a connection string link (or running xdg-open "hs://…") opens the app with the Connect form pre-filled, even if it was hidden in the tray. The app registers itself as the handler for the hs:// scheme on Linux/Windows at first run; on Android, the scheme is baked into the APK manifest. A second app launch while one is running routes to the existing instance instead of duplicating.
System tray — closing the window hides the app (tunnels keep running) and a tray icon appears with Show / Stop all tunnels / Quit. Quit from the tray is the only way to fully exit while tunnels are active.
APK build
An Android project is scaffolded with tauri android init (already done — see
src-tauri/gen/android/, regenerated on demand; the mobile capability lives in
src-tauri/capabilities/mobile.json).
Build an APK (on a machine with Android Studio / the SDK+NDK):
rustup target add aarch64-linux-android # or all four ABIs
ANDROID_HOME=$HOME/Android/Sdk npm run build:android # prepare bundle + glue + build
# or step by step:
node scripts/prepare-resources.mjs --bare --target android-arm64 --out dist-resources-android
node scripts/android-glue.mjs
ANDROID_HOME=$HOME/Android/Sdk npx tauri android build --apk --debugHow the backend works on Android. The worker is the exact same
service-worker.js, but instead of the system node binary it runs under
Bare, holepunch's JS runtime (the same one upstream holesail uses for its
own Android build):
prepare-resources.mjs --bare --target android-arm64assemblesdist-resources-android/: the worker + a productionnode_modulespruned to theandroid-arm64.bareprebuilds ofsodium-native/udx-native, plus the prebuiltbareruntime binary (fetched from thebare-runtime-android-arm64npm package — no cross-compiling needed).scripts/android-glue.mjswires everything into the generated project:- copies the bundle into the APK assets (
app/src/main/assets/bare/) and injectsBareAssets.kt, a small Kotlin extractor that copies the assets intofilesDir/bareon first launch (Android assets are not real filesystem paths); - ships
bareas a jniLibs library (libholesail_bare.so) — SELinux forbids apps (targetSdk ≥ 26) from exec'ing files in their own data dir, but does allow exec of the extracted APK lib dir (apk_data_file); - bundles
libc++_shared.so(theudx-nativeaddon links the C++ STL, which is not present on Android 10+) and forcesextractNativeLibs="true"so those files land on disk at install.
- copies the bundle into the APK assets (
- In Rust,
worker_command()undercfg(target_os = "android")locates the native lib dir via/proc/self/maps, spawns<libdir>/libholesail_bare.so service-worker.jswithLD_LIBRARY_PATHpointing at the bundle — same JSON-RPC over stdio, same protocol, same UI. If the bundle is missing the app still renders with a "worker offline" banner instead of crashing. - A tiny foreground service (
HoleService, injected by the glue) keeps the app process — and therefore the worker — alive when the app is backgrounded; without it Android freezes backgrounded apps and silently kills active tunnels. The service is started while the UI is visible, stopped when the task is swiped away, and shows a low-importance "Tunnel worker active" notification (notification permission is requested on Android 13+).
npm test runs the protocol test under node; npm run test:bare runs the
same test suite against a linux-x64 bare bundle, verifying the whole chain
(worker → holesail → addons → real DHT tunnel) on the bare runtime.
Verified end-to-end on an emulator (x86_64, API 35) and on real arm64 hardware (OnePlus 13R): the app spawns the bare worker, the UI shows "worker online", server sessions started from the app's own UI are reachable from a desktop client over the public DHT, and data sent through the tunnel arrives on the device — in both directions.
The SDK installed in this repo's sandbox lives at
/home/chethan/.reasonix/global-workspace/android-sdk(not in$HOME, which is read-only).ANDROID_USER_HOMEmust also point at a writable dir orsdkmanager/gradle will fail.
v0.7.1 — reliability fixes: append-only event log, updater progress, payload hygiene
- Append-only event log —
log_appendno longer reads + rewrites the whole file on every log line; it appends and only trims once the 64 KiB cap is exceeded (was O(n²) on long sessions). - Updater progress fixed — the download percentage now accumulates chunk
lengths across
Progressevents instead of reporting each chunk as a fraction of the total (it previously jumped straight to ~99%). - Sessions payload strip —
sessions:listno longer serializes the Livefiles file-server instance alongside the holesail engine; non-serializable engine graphs stay out of every RPC response. - Filemanager port guard — a
0/negative/NaN port now consistently falls back to the default 5409 instead of misbehaving. - Android glue drift guard — the boot/foreground-service patch now fails
loudly if the Tauri template drifted, instead of silently rewriting
MainActivity.ktunchanged while claiming success. - Shared error-wrap helper —
rpc()and everysaved-*call now go through oneinvokeWrapped()that normalizes Tauri's raw-string rejections.
v0.7.0 — modular codebase + CI guardrails + UI/security polish
- Modular codebase — the renderer (1,791-line monolith), Rust backend
(1,694-line
lib.rs), and service worker (769-line file) are each split into focused acyclic modules (15 renderer ES modules, 7 Rust modules, 10 worker CommonJS modules). Same behavior, far easier to fix and extend. - CI guardrails — ESLint (
npm run lint) andrustfmt --checknow run in CI; Prettier added as an opt-in formatter. - Public-mode warning — unchecking "Private mode" (or pasting a public
hs://0000…key) shows an amber "no encryption" banner. - Plain-English errors — worker/engine errors are mapped to readable messages with a hint instead of raw strings; friendlier empty state with a CTA.
- Drag-and-drop folder sharing — drop a folder on the window to share it.
- Live per-session traffic stats: every tunnel card now shows cumulative upload/download bytes, live connection count, and a rolling throughput sparkline (teal = upload, yellow = download), refreshed ~2×/s in place.
- Peer notifications: when someone connects to one of your server tunnels, the app logs it and shows a toast (rate-limited) with a total connection count.
- Relay-routing badge: if the DHT can't hole-punch and falls back to a relay, the session card shows a "⇄ via relay" badge (higher latency) and the peer's address is logged.
- Per-session bandwidth cap: Speed limit (KB/s) on Share, Share-a-folder and Connect forms caps combined upload+download throughput (token bucket); persisted on saved tunnels; shown as "⏱ cap" on the card.
- App icon replaced with a custom flat two-color mark (teal + yellow, transparent background).
v0.6.0 — security hardening + flatpak restored
- Worker-side broad-path guard: filemanager refuses
/,~, home children (defense-in-depth behind the renderer confirm) - Flatpak fixed (ayatana-ido pc-file cleanup) — CI job restored, bundle ships with releases again
livefilesdeclared as a direct dependency (was hoisting-dependent)rust-version = 1.91pinned; saved-import capped at 100 tunnels (+test)- Fixed:
confirmInlinecrash (both guardrails were dead code), ghost saved tunnels on failed start, filemanager password masking,logAppendunhandled rejection, saved-tunnel 90s timeout,find_nodeon Windows - README overhaul: TOC, benefits, known issues, FAQ — all collapsible
v0.5.0 — permanent folder shares + security hardening
- Permanent folder shares: "Permanent" toggle on the Share-a-folder form (fixed key, saved with the tunnel, auto-restarts with the app)
- Saved tunnels (keys + filemanager creds) now live in the OS keychain (0600 file fallback), not plaintext JSON
- Broad-path guardrail: sharing
/,~, or a home dir child asks for confirmation - Worker session cap (50) + RPC method allowlist
- Bare runtime bundled on Windows/macOS too — no Node.js needed anywhere
v0.4.0 — DHT preflight + UI polish
- Connect flow checks the DHT first — clear "no tunnel found" feedback instead of hanging
- Saved-tab online/offline status badges, LAN URL row on server cards
- Fixed reconnect routing for saved sessions,
hs://0000display for insecure keys - UI polish: dark-mode safe-area fixes, badges, focus states
Current rough edges (honest list)
- Public mode (
hs://0000…) has no encryption — treat it as an unauthenticated TCP relay; anyone with the key can connect - No TCP-over-DHT portability guarantee — like upstream holesail, tunnels are UDP-DHT based; some restrictive networks still block UDP hole-punching (rare; falls back through DHT relays automatically)
- macOS untested on real hardware — builds green in CI, never launched on Apple silicon
- Flatpak — ships with releases again (CI job restored in v0.6.0); runtime behavior on real desktops is still being validated
- Bandwidth caps are per-session only — the Speed-limit (KB/s) cap applies to a single tunnel's combined up+down; there's no global shaping or per-direction control yet
- File manager sharing is basic — single root path, one role/username/password pair per tunnel; no multi-user ACLs
- Session cap is 50 — intentional, prevents fd exhaustion; raise in
service-worker.jsif you truly need more - AGPL-3.0 implications for the bundled holesail engine if you redistribute commercially (see License)
How do I reach my PC from my phone?
Share the port on the PC, copy the hs://s000… string, paste it into Connect on the phone (same app on Android, or the holesail CLI). The phone finds the PC over the DHT and tunnels the port — no port forwarding.
Is the traffic really peer-to-peer?
Yes — both ends register on the public HyperDHT and exchange connection info; the data path is direct. If a direct connection is impossible (double NAT), the DHT relays packets, still encrypted in secure mode.
What does "secure" mode actually encrypt?
hs://s000… keys derive an encryption key; the tunnel payloads are end-to-end encrypted. hs://0000… (public) is plaintext — only use it for services you'd expose publicly anyway.
Why is there a separate worker process?
The holesail engine uses native addons prebuilt for a specific runtime ABI. Running them inside a webview crashes on ABI mismatch. The worker process (Node in dev, bundled Bare runtime in packages) keeps them stable; the GUI just talks JSON-RPC over stdio.
Does the tunnel survive app restart / reboot?
Permanent tunnels: yes — desktop login autostart and the Android boot receiver restore them. Temporary tunnels get a fresh key each start and are not restored.
What are the system requirements for end users?
None beyond the OS — packaged builds embed the Bare runtime, so no Node.js. (Development needs Node 18+ and Rust.)
- Private connection strings are credentials — treat them like SSH keys.
- The GUI runs a local service worker; the renderer is sandboxed
(
withGlobalTauri, CSPdefault-src 'self',style-src 'unsafe-inline') and can only talk to it through the whitelistedrpccommand. Untrusted values (keys, hosts, logs) are rendered withtextContent, never injected as HTML. - Recent keys live in the OS keychain on desktop (Secret Service / Keychain / Credential Manager, falling back to a 0600 file if no keychain daemon is reachable) and a 0600 file on Android — never in web storage. The "clear" button wipes the backing store.
- holesail — the peer-to-peer TCP/UDP tunnel engine this app is a GUI for (AGPL-3.0 — see the license note below).
- Bare — holepunch's JavaScript runtime; powers the Android and embedded-Linux backends so end users don't need Node.js (Apache-2.0).
- HyperDHT, udx-native and sodium-native — the encrypted DHT and networking stack underneath (MIT/Apache-2.0).
- Tauri — the desktop/mobile framework (MIT/Apache-2.0).
- The app icon is a custom flat two-color mark (teal + yellow interlocking
shapes on transparent), designed for this project; master artwork in
src-tauri/icons/source.png, regenerable viatauri icon.
MIT © 2026 chethan62 — for this project's own code.
Note: the bundled holesail engine (the service worker) is
AGPL-3.0. Distributing an app that
embeds AGPL code carries source-availability obligations for the combined
work; the GUI's own source is here, so this is effectively satisfied, but if
you intend commercial redistribution, review AGPL implications or contact the
upstream maintainers.