Skip to content

Repository files navigation

Tenebra — sing-box VPN client

CI License: GPL v3 Latest release Platform

A cross-platform VPN client built on sing-box.
Desktop first — Windows is user-ready; macOS and Linux ship but are for advanced users (see below). The same Go core drives an Android client, in alpha and installed by hand; iOS is a scaffold.

A total eclipse: intercepted noise enters the dark, one clean signal leaves it. In tenebris lux.

Project status — early development. The desktop client is the current focus. The core, the control protocol and the UI are in good shape and well tested, and the Windows tunnel path (wintun + sing-box under the service) is exercised against real servers rather than only in tests — but no automated test stands up a real tunnel on any platform, and the macOS and Linux tunnels have had no privileged live run signed off. Treat this as pre-release: not yet "production-ready", and expect things to move around. See Project status for the honest breakdown.

Why another client

Most clients either lock you into a single protocol or are vague about what they do with your traffic. Tenebra:

  • speaks the protocols sing-box supports — VLESS/REALITY, Hysteria2, AmneziaWG, Shadowsocks, Trojan, VMess;
  • routes Russian destinations directly and sends everything else through the tunnel, so latency-sensitive local traffic stays local;
  • falls back between protocols when one gets throttled or blocked, and remembers what worked;
  • ships no telemetry, no accounts and no bundled servers — you import your own subscription.

What it does

Everything below is implemented in this repo today (the UI features are desktop):

  • Many protocols, one model. Import VLESS (incl. REALITY), Hysteria2, AmneziaWG, Shadowsocks, Trojan and VMess. A single normalized node model feeds a from-scratch sing-box config generator. (AmneziaWG links import and connect, but the bundled stock sing-box applies none of the AWG obfuscation parameters — the tunnel runs as plain WireGuard; full AmneziaWG obfuscation is planned and needs a build that links a fork.)
  • Import the way you have it. Subscription URL, a raw share link, a .txt file of links, clipboard paste, or a QR code (image file or pasted image). Subscription bodies handle a Clash/Mihomo YAML config, base64, or plaintext link lists and read the Subscription-Userinfo header for traffic used / total and expiry.
  • Smart RU routing. Smart keeps Russian domains and IPs (and your LAN) direct and tunnels the rest; Global tunnels everything; Direct is the proxy off. Geodata comes from the official public sing-geoip / sing-geosite rule-sets, shipped in the build as local .srs binaries and loaded from disk — never downloaded while you are connecting. If a build is missing them, Smart routes like Global and says so in the log rather than failing to connect.
  • DPI bypass that works on the first connect. Windows only: the client drives zapret so censored services work at their own latency instead of through an exit node. One bundle release is compiled into the build so a censored network cannot leave a fresh install with no bypass at all; newer releases are downloaded as they are published — see DPI bypass.
  • Protocol fallback. A pure state machine walks the last known-good node first, then by protocol preference (REALITY → Hysteria2 → AmneziaWG), so a blocked or throttled protocol is retried as another. The last good node leads the next launch.
  • Per-app split tunnelling. Exclude sends chosen apps around the tunnel; Include sends only chosen apps through it. Matched by executable name and persisted across restarts.
  • Honest leak check. Observes the machine's public IP from redundant echo services and runs a best-effort DNS probe, then reports a verdict that never fakes a pass — it tells you what it could not measure rather than claiming "safe". See docs/control-protocol.md.
  • Desktop niceties. System tray that reflects the connection state (with quick connect/disconnect), desktop notifications on state changes, tenebra:// deep links (import a subscription or connect a profile), launch at login (optionally minimized to the tray), single-instance, live traffic graphs, light/dark themes, and English / Russian UI.

The kill-switch (drop proxied traffic instead of leaking when the tunnel drops) is a UI toggle — best-effort by design, with the exact guarantee described in the changelog; LAN bypass is a core routing option.

DPI bypass

Blocking here is done by inspecting traffic, not by address: YouTube can be unwatchable on a connection that is otherwise fine, and a tunnel handshake can be dropped for looking like a tunnel handshake. On Windows Tenebra answers that with zapret — a separate program that edits packets on the way out (splitting the TLS ClientHello, sending decoys, and so on) until the filter stops matching them. It runs beside the tunnel rather than inside it, so a service the bypass can reach directly stays direct at its own latency instead of taking the round trip through an exit node. There is no equivalent on macOS or Linux; the tunnel there carries everything.

Shipped as a floor, downloaded to stay current. What the bypass needs is the Flowseal/zapret-discord-youtube bundle: zapret's Windows build (winws.exe), the WinDivert packet-interception driver it attaches to, the Cygwin runtime that build needs, and the strategy and host lists around them. Strategies are a moving target — a set that worked in March is a set the filter has since learned — so the current release is fetched from upstream. But a client that can only download one is a client with no bypass on exactly the networks it exists for, so one release is also compiled into the Windows core: the archive upstream published, byte for byte, checked against the checksum this build pins for it. It is the floor, never the ceiling — a newer release replaces it as soon as one is published and pinned. The macOS and Linux binaries carry none of it; there is nothing there that could run a Windows packet filter.

When it happens and where it lands. On the first connect with no bundle present, the core downloads the latest published release and unpacks it into its own data directory — %ProgramData%\Tenebra\data\zapret under the Windows service — then re-checks for a newer one every twelve hours. It is a plain download from the upstream release page; nothing about you goes with the request. When that download cannot deliver a bundle at all — no network, GitHub blocked, a release newer than any checksum this build carries, or an archive that did not match the checksum it does — the compiled-in copy is unpacked into the same place instead, and the next successful check upgrades past it.

How to decline the download. Settings → Censorship bypass → Update the bundle automatically governs what Tenebra fetches: the first-connect download and the twelve-hour re-check alike. Turned off, it asks the release page for nothing — press Update when you want a newer one, or unpack one into the zapret directory above yourself. It does not govern the copy compiled into the build: those bytes need no network and no update, so a first connect with no bundle present still unpacks them and a fresh install is never left with the bypass missing. Deleting the zapret directory removes what is installed; a later connect lays the compiled-in copy back down. Running with no bypass at all means the tunnel carries every service, censored ones included, through the exit node instead of around the filter.

Everything in the bundle, with its license and copyright holder, is listed in THIRD-PARTY-NOTICES.md.

Installing

Windows — from the Windows Package Manager:

winget install Divaaaan.Tenebra

or grab Tenebra_x.y.z_x64-setup.exe from the latest release. Either way the installer sets up the background service and the in-app updater keeps everything current.

macOS — download the universal DMG from the latest release, then read the macOS note first — the build currently needs a hand-installed root daemon.

Getting a server

Tenebra is a client — it ships no servers and hard-codes nothing. You bring your own endpoint and import it as a subscription or a share link. Two ways to get one:

  • Run your own. Any sing-box or Xray server works; point Tenebra at its subscription URL.
  • Use a provider. Any service that hands you a subscription or a share link will do. I run one at vpsxd.pro.

Project status

Area State
Go core (parsing, profiles, routing, config gen, fallback, leak logic) Implemented, unit-tested, no third-party deps
Control protocol (core ↔ UI) Implemented; covered by Go tests and a real-binary e2e
Desktop UI (Tauri 2 + React) Implemented: all screens, reactive tray, notifications, deep links, autostart, i18n, themes
Windows tunnel (wintun + sing-box) Implemented — a background service runs the tunnel, so the app connects without an elevated GUI; installer sets it up, the in-app updater refreshes both app and service
macOS tunnel (utun + sing-box) Builds and runs — universal .app/DMG — but see the macOS note below: it needs a hand-installed root daemon and is not yet a click-to-run product. No live-tunnel sign-off yet
Linux tunnel (/dev/net/tun + sing-box) Builds and runs — a root systemd service owns the tunnel, installed by an Arch package or a sudo script; see the Linux note below. No live-tunnel sign-off yet
Android (VpnService + libbox) Alpha, hand-installed — a Kotlin / Compose client in ui-android/ builds and runs on a device: subscription import, node list with latency badges, an AUTO exit, switching the live exit without a reconnect, connect-on-boot, a Quick Settings tile, in-app logs and crash reports. Routing is Global only and there is no DPI bypass. CI builds a debug APK; a tagged release carries a signed one only once the signing key is in CI secrets
iOS (Network Extension) Scaffold only — none of the Swift under ui-ios/ has been compiled and no framework has been built; the plan is docs/porting/ios.md
Release pipeline Tag-triggered release workflow builds the Windows, macOS and Linux bundles plus the Arch package, minisign-signs the in-app updater artifacts, and publishes the GitHub release once every expected asset is on it; Android is a separate workflow on the same tag
Code-signing Not set up — the Windows installer is Authenticode-unsigned (SmartScreen warns), the macOS build is unsigned/un-notarized (Gatekeeper needs a manual "Open Anyway"), and the Android release APK has no keystore in CI yet

macOS note — read before downloading the DMG

The macOS build is for advanced users right now, not a finished product. Two things are not yet in place, so a plain "download the DMG and drag to Applications" will not give you a working tunnel:

  • The tunnel needs a privileged helper. macOS only lets root open the utun device, so the app talks to a small root LaunchDaemon that owns the tunnel. That daemon is currently installed by hand with a sudo script (scripts/macos/install-daemon.sh) — there is no in-app installer for it yet. Without it, the app runs but cannot connect.
  • The build is unsigned and un-notarized. First launch needs System Settings → Privacy & Security → Open Anyway, and updates to the daemon are a manual step (the in-app updater refreshes only the app, not the root daemon). Since 0.4.4 the app warns with a banner when the daemon has fallen behind it; re-run the install script from your checkout to update: sudo bash scripts/macos/install-daemon.sh --from-app /Applications/Tenebra.app --allow-unsigned.

The click-to-run macOS path — a signed, notarized build with an SMAppService daemon bundled inside the app (so it installs and updates like the Windows service) — needs an Apple Developer ID and is planned, not done. Until then, use the DMG only if you're comfortable running the install script yourself. Windows users are unaffected — the Windows installer sets up the service and the updater keeps everything current automatically.

Linux note — the tunnel needs a root service

Linux is the same shape as macOS: only a privileged process may open /dev/net/tun and install routes, so the app talks to a small root systemd service that owns the tunnel and serves the control protocol on /run/tenebra.sock. The app alone cannot connect. Two ways to set it up:

  • Arch Linux — build the package. packaging/arch/PKGBUILD builds the core, the desktop app and the unit from source and installs them with pacman:

    cd packaging/arch && makepkg -si
    sudo systemctl enable --now tenebra.service
    

    Updates come from pacman, not the in-app updater — it can only replace an AppImage, never files a package manager owns.

  • Any other distribution — the install script. Fetch the bundled resources, then install the daemon from your checkout:

    bash scripts/fetch-resources.sh
    sudo bash scripts/linux/install-daemon.sh --dev
    

    It is safe to re-run to upgrade, rolls back if an upgrade fails, and scripts/linux/uninstall-daemon.sh removes it. The GUI is a separate .deb/AppImage build.

Two limits worth knowing before you install: system-proxy mode does nothing on Linux (it needs per-desktop settings a root daemon cannot reach, so it stays quietly disarmed — tun mode, the default, is unaffected), and the bundled sing-box binaries are glibc-linked, so musl distributions need their own. Full detail, including the systemd sandbox and what is deliberately left out of it, is in docs/porting/linux.md.

If you want to help close the gap, the macOS SMAppService path and the non-desktop adapters are the highest-leverage places — see CONTRIBUTING.md.

Repository layout

tenebra/
├── core/                 Go. Platform-agnostic, stdlib-only, fully unit-tested.
│   ├── model/            Normalized proxy node + config types.
│   ├── subscription/     Parse vless/hysteria2/ss/trojan/vmess links + sub bodies.
│   ├── profile/          Named profiles and their atomic on-disk store.
│   ├── routing/          smart/global/direct + per-app split -> sing-box route/dns.
│   ├── singbox/          Build a full sing-box config as plain JSON (no sing-box dep).
│   ├── fallback/         Pure REALITY->Hysteria2->AmneziaWG fallback state machine.
│   ├── zapret/           Drive the DPI-bypass bundle, embedded + downloaded (Windows).
│   └── control/          The line-delimited JSON protocol + the daemon.
├── core-bridge/          The same generator as a mobile-facing library (no sing-box).
├── mobile/               gomobile wrapper: binds core-bridge + libbox into one artifact.
├── adapters/
│   ├── windows/          Spawn & supervise sing-box; traffic via its clash API.
│   ├── macos/            The same over utun, under the root LaunchDaemon.
│   └── linux/            The same over /dev/net/tun, under the root systemd service.
├── cmd/
│   └── tenebra-core/     The sidecar entry point (talks the protocol on stdin/stdout).
├── ui-desktop/           Tauri 2 app: Rust shell (src-tauri) + React/TS front end (src).
├── ui-android/           Kotlin/Compose client: VpnService + libbox (alpha).
├── ui-ios/               SwiftUI + Network Extension scaffold; never compiled.
├── deploy/               The privileged daemon's service definitions per platform.
├── packaging/
│   └── arch/             PKGBUILD building the whole thing for Arch Linux.
├── scripts/
│   ├── fetch-resources.ps1     Download pinned sing-box + wintun (Windows).
│   ├── fetch-resources.sh      The same for macOS and Linux.
│   ├── build-libbox-android.sh One gomobile bind -> the fused tenebra.aar.
│   ├── build-libbox.sh         The same bind for Apple (xcframework).
│   ├── macos/                  Install/remove the root LaunchDaemon.
│   └── linux/                  Install/remove the root systemd service.
└── docs/                 Architecture, control protocol, the dev guide, porting notes.

Building

Requirements: Go 1.24+, Node 22+, and the Rust toolchain (for the desktop UI). Full walkthrough and troubleshooting in docs/development.md.

Core tests:

go test ./...

Desktop app (Windows):

# fetch the sing-box binary and wintun.dll into src-tauri/resources
powershell -File scripts/fetch-resources.ps1

# build the core sidecar where Tauri bundles it
go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-pc-windows-msvc.exe ./cmd/tenebra-core

# build the bundle
cd ui-desktop
npm install
npm run tauri build

Desktop app (Linux):

# fetch the sing-box binary and the rule-sets into src-tauri/resources
bash scripts/fetch-resources.sh

# build the core sidecar where Tauri bundles it
go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu ./cmd/tenebra-core

# build the .deb and AppImage
cd ui-desktop
npm install
npm run tauri build

On Arch, cd packaging/arch && makepkg -si does all of the above and installs the result — see the Linux note.

Documentation

Support

Tenebra is maintained by one person in their spare time, so please keep support low-friction:

  • Questions or help — start a thread in Discussions.
  • Bugs — file a report through the issue form; it asks for your version, Windows build and logs.
  • Security problems — follow SECURITY.md; please don't open a public issue.

Response times vary — this is a side project, not a supported product. Thanks for your patience.

License

GPLv3 — see LICENSE. sing-box is GPLv3, so Tenebra is too. Bundled third-party components and their licenses are listed in THIRD-PARTY-NOTICES.md.

About

Cross-platform VPN client built on sing-box

Topics

Resources

Contributing

Security policy

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages