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.
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.
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.
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
.txtfile 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 theSubscription-Userinfoheader 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
.srsbinaries 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.
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.
Windows — from the Windows Package Manager:
winget install Divaaaan.Tenebraor 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.
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.
| 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 |
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
utundevice, so the app talks to a small root LaunchDaemon that owns the tunnel. That daemon is currently installed by hand with asudoscript (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 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/PKGBUILDbuilds the core, the desktop app and the unit from source and installs them withpacman:cd packaging/arch && makepkg -si sudo systemctl enable --now tenebra.serviceUpdates 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 --devIt is safe to re-run to upgrade, rolls back if an upgrade fails, and
scripts/linux/uninstall-daemon.shremoves 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.
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.
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.
- docs/ — documentation index.
- docs/architecture.md — the layers and how they connect.
- docs/control-protocol.md — the core ↔ UI wire format.
- docs/development.md — set up, build, run and test.
- CONTRIBUTING.md — how to contribute.
- SECURITY.md — reporting a vulnerability and our trust stance.
- CHANGELOG.md — what's changed.
- ROADMAP.md — where the project is headed.
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.
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.