Everything you need to build, run and test Tenebra from a fresh checkout. For what the pieces are, read architecture.md; for the core ↔ UI wire format, control-protocol.md. This document is the how.
| Tool | Version | Used for |
|---|---|---|
| Go | 1.24+ | the core and the tenebra-core sidecar |
| Node.js | 22+ (with npm) | the React front end |
| Rust (stable) | latest stable | the Tauri desktop shell |
| PowerShell | Windows built-in / PS 7+ | scripts/fetch-resources.ps1 |
CI builds the core on the exact Go patch in .go-version (currently 1.26.8) and the desktop bundle with Node 24, so those are
known-good; the minimums above are what go.mod and the front end actually
require. The desktop app builds for Windows, macOS and Linux (this guide is
written from the Windows side; the platform-specific parts are in
porting/macos.md and porting/linux.md),
and the Go core builds and tests on every platform — it deliberately avoids
OS-specific imports outside the adapters. The Android client has its own
toolchain (NDK + Gradle) and its own guide,
porting/android.md.
Tauri has its own platform prerequisites (a C toolchain, WebView2 on Windows).
If npm run tauri build complains about a missing system dependency, check the
Tauri 2 prerequisites.
core/ Go, platform-agnostic (Windows service/pipe plumbing aside):
model/ normalized proxy node + config types
subscription/ parse vless/hysteria2/ss/trojan/vmess links and subscription bodies
profile/ named profiles and their atomic on-disk store (profiles.json)
routing/ smart/global/direct + per-app split -> sing-box route/dns blocks
singbox/ assemble a full sing-box config as plain JSON (no sing-box dependency)
fallback/ pure REALITY->Hysteria2->AmneziaWG fallback state machine
zapret/ drive the DPI-bypass bundle, embedded + downloaded (Windows only)
logrot/ size-capped log writer; the Windows service logs through it
control/ the line-delimited JSON protocol and the daemon that drives it
core-bridge/ the same config generator as a library for the mobile clients
mobile/ gomobile wrapper: binds core-bridge + libbox into one artifact
adapters/
windows/ spawn & supervise the sing-box process; read traffic via its clash API
macos/ the same under the root LaunchDaemon (utun)
linux/ the same under the root systemd service (/dev/net/tun)
cmd/
tenebra-core/ the core binary; protocol on stdin/stdout, a named pipe on Windows,
or a unix socket on macOS/Linux
ui-desktop/
src-tauri/ the Rust/Tauri shell (sidecar bridge, tray, autostart)
src/ the React + TypeScript front end
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 core, app and unit for Arch Linux
scripts/
fetch-resources.ps1 download the pinned sing-box binary and wintun.dll
fetch-resources.sh the same for macOS and Linux (plus the rule-sets)
build-libbox-android.sh one gomobile bind -> the fused tenebra.aar
build-libbox.sh the same bind for Apple platforms (xcframework)
macos/, linux/ install/remove the privileged daemon there
The core's only third-party dependencies are go-winio and x/sys, both Windows plumbing (the named-pipe transport and the service entry point); everything else is standard library, and everything in it runs offline. It does not link sing-box; it generates a sing-box config as plain JSON, which the sidecar hands to a real sing-box process at runtime.
To exercise the named-pipe transport without installing a service, run the core
from a console with go run ./cmd/tenebra-core --pipe and connect a client to
\\.\pipe\tenebra; transports and the pipe ACL are documented in
control-protocol.md. The Windows-only paths
(//go:build windows) are covered by go test on a Windows machine — CI's
ubuntu/macos jobs compile around them.
Build and vet:
go build ./...
go vet ./...
Run the tests (this is the command CI runs, minus the flags below):
go test ./...
To mirror CI exactly — race detector on, no result caching:
go test ./... -race -count=1
CI also runs staticcheck. To run it locally:
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
Build just the sidecar (handy for poking at the protocol by hand):
go build -o tenebra-core ./cmd/tenebra-core # or tenebra-core.exe on Windows
The sidecar reads the control protocol on stdin and writes responses and events on stdout; all logs go to stderr. You can drive it from a terminal — type one JSON object per line:
$ ./tenebra-core
{"id":1,"cmd":"status"}
{"id":1,"ok":true,"data":{"state":"idle"}}
It stores profiles under your per-user config dir by default
(%AppData%\tenebra on Windows, ~/.config/tenebra on Linux,
~/Library/Application Support/tenebra on macOS). Override it with
TENEBRA_CONFIG_DIR to keep experiments out of your real config:
TENEBRA_CONFIG_DIR=./scratch ./tenebra-core
| Variable | Effect |
|---|---|
TENEBRA_CONFIG_DIR |
Directory for profiles.json, settings.json, lastgood.json. Defaults to the per-user config dir; as a Windows service, to %ProgramData%\Tenebra\data. |
TENEBRA_SINGBOX |
Path to the sing-box binary to run. Defaults to one resolved next to the executable; as a Windows service, to the bundled copy in resources\ next to tenebra-core.exe. |
TENEBRA_LOG_LEVEL |
Log threshold: debug, info (default), warn, error. Read once at start-up, so raising it means restarting the core. It governs both the log events the UI shows and what reaches the process log on disk. |
The core logs to stderr when it runs as a sidecar or in a console, and to
%ProgramData%\Tenebra\service.log as a Windows service. The service log
rotates by size (8 MiB per file, three rotated generations, 32 MiB for the
whole set — core/logrot); the desktop shell's core.log is rolled over at
spawn when it has grown past 8 MiB.
Debug is off by default, and deliberately not a stored preference: to investigate
something, set TENEBRA_LOG_LEVEL=debug, restart the core, reproduce, then unset
it. At debug the core narrates the reasoning behind each connect decision — the
resolved candidate order with measured round-trips, each transport strategy tried
and how its failure was classified, per-target node-probe stages, what the
tun-conflict guard saw in the route table.
For a report someone else has to read, use collect_diagnostics (Settings →
Diagnostics → Save diagnostics report): it writes one file with the state,
build versions, routes, last connect walk and log tail, with subscription tokens
and node credentials masked.
Report a problem (bottom bar, simple mode, or Settings → Diagnostics) builds on the same bundle: it adds the app's own versions and log console, trims the result to what a GitHub issue body will take, and shows it for the user to copy. It sends nothing — the browser opens only on a second, separate click, and carries the version and OS, never the report.
On Windows the core also runs as a service named tenebra, serving the
control protocol on the named pipe so the tunnel outlives any one UI process.
The installer owns the service's lifecycle through NSIS hooks
(ui-desktop/src-tauri/installer-hooks.nsh):
- Install registers
tenebra(start=auto, image path$INSTDIR\tenebra-core.exe) and starts it. Because a service needs machine scope, the installer is per-machine (installMode: "perMachine") and asks for elevation once per install or update. - Update stops the service before files are replaced, re-points the registration at the (possibly new) install directory, and starts it again. The registration itself survives updates.
- Uninstall stops and deletes the service.
Running as LocalSystem, the service keeps its state in
%ProgramData%\Tenebra: service.log, and the profile store under
data\. The store is created with a protected DACL — full control for SYSTEM
and Administrators, no access for anyone else — because profiles carry
subscription credentials; unprivileged users reach that data through the pipe
protocol (see control-protocol.md), never
the files. Both locations are deliberately left behind on uninstall; delete
%ProgramData%\Tenebra by hand for a full wipe. To remove the service
manually: sc.exe stop tenebra, then sc.exe delete tenebra (elevated).
In service mode the core resolves the bundled sing-box.exe (with
wintun.dll and the .srs rule-sets beside it) from resources\ next to
tenebra-core.exe — the installed layout — falling back to a flat
next-to-the-executable layout. The TENEBRA_CONFIG_DIR / TENEBRA_SINGBOX
variables still win if set machine-wide.
Migration from the per-user installs (≤ 0.2.x). Earlier releases
installed per-user into %LOCALAPPDATA%\Tenebra with their registration in
HKCU; the stock Tauri installer does not reconcile the two scopes. On the
first per-machine install the installer therefore retires the old copy for
the installing user: it removes the HKCU uninstall entry, the autostart Run
value, the per-user tenebra:// handler (which would shadow the machine-wide
one) and the old shortcuts — registry and shortcut surgery only. It
deliberately does not execute the old uninstaller and does not delete
inside %LOCALAPPDATA%\Tenebra: that directory is user-writable, and an
elevated installer executing or recursively deleting through it would hand
its privileges to anything planted there. The leftover files are inert once
their entry points are gone; remove the directory by hand if the disk space
matters. Per-user profile stores (%AppData%\tenebra) are not migrated into
the service store — re-import the subscription in the app.
None of this changes development flows: a console tenebra-core (stdio or
--pipe) keeps its per-user paths, and npm run tauri dev still uses the
stdio sidecar.
All desktop commands run from the ui-desktop/ directory unless noted.
sing-box and wintun.dll are not committed — they are downloaded at pinned
versions so builds stay reproducible. Run this once (and again when the pinned
versions change):
powershell -File scripts/fetch-resources.ps1
It places sing-box.exe and wintun.dll in
ui-desktop/src-tauri/resources/, which Tauri bundles into the installer.
Tauri loads the sidecar as an external binary named for the target triple. Build
it into src-tauri/binaries/:
go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-pc-windows-msvc.exe ./cmd/tenebra-core
(Replace the triple for another target, e.g.
tenebra-core-aarch64-apple-darwin.)
cd ui-desktop
npm install # or `npm ci` for a clean, lockfile-exact install
npm run tauri dev
This compiles the Rust shell, starts Vite, and opens the app. The shell tries to
spawn the real tenebra-core sidecar; if it can't (e.g. you skipped step 2), it
falls back to an in-process mock backend so the UI still runs. To force the
mock for UI-only work — no Go build, no sidecar:
# PowerShell
$env:TENEBRA_MOCK = "1"; npm run tauri dev
The mock serves believable fake profiles and state; it never touches sing-box or a real tunnel.
npm run tauri build
CI builds the NSIS installer specifically:
npm run tauri build -- --bundles nsis
The output lands in ui-desktop/src-tauri/target/release/bundle/. The installer
is unsigned — code signing is not set up yet. It installs per-machine and
manages the tenebra Windows service via the hooks in
src-tauri/installer-hooks.nsh (see The Windows service),
so running it takes one UAC elevation. Building it does not.
The front end has a vitest unit suite alongside the type-check gate.
| Command | What it does |
|---|---|
npm run dev |
Vite dev server (UI only, no Tauri shell) |
npm test |
run the vitest unit suite once |
npm run test:watch |
vitest in watch mode |
npm run typecheck |
tsc --noEmit — the front-end type check |
npm run build |
type-check then build the front-end bundle to dist/ |
npm run preview |
serve the built dist/ locally |
npm run tauri <cmd> |
proxy to the Tauri CLI (dev, build, …) |
There are three test surfaces. The first two are pure and run anywhere; the third is the cross-language check of the wire protocol.
go test ./...
Roughly twenty _test.go files cover subscription parsing, the profile store,
routing/DNS and split-tunnel rule generation, the sing-box config builder, the
fallback walk, the control protocol and the leak-check logic, plus the Windows
runner's process and clash-API handling. All of it is offline — fixtures use
obviously fake hosts and keys.
cd ui-desktop
npm test # vitest unit suite
npm run typecheck # tsc --noEmit
A vitest suite (jsdom, no real Tauri or network) covers the lib helpers, the
Tauri API client, the connection-state hook and the screens. The protocol types
in src/api/types.ts mirror the Go protocol by hand, so a type error here often
means the front end and the core have drifted.
ui-desktop/src-tauri/tests/sidecar_e2e.rs spawns the real tenebra-core
binary and round-trips the line-delimited JSON protocol over its stdin/stdout —
status, import_link, list_profiles, set_split, then leak_check — and
asserts the responses are well-formed, id-correlated, and normalized exactly as
the front end expects. It proves the Rust SidecarBackend and the Go core agree
on the wire format. It uses only fake links and an isolated temp store, so
nothing dials out.
It runs from ui-desktop/src-tauri:
cd ui-desktop/src-tauri
cargo test
The e2e self-skips (passes as a no-op) if the core binary hasn't been built,
so a fresh cargo test stays green. To make it run for real, build the sidecar
into src-tauri/binaries/ first (step 2 above), then run cargo test again.
Standing up an actual tunnel — a tun device + a live sing-box dialing a real server — is not in any automated test, on any platform. It needs administrator rights (root on macOS and Linux) and real server credentials, so it is done by hand; see the maintainer notes for where that has happened and where it has not.
- English for code, comments, identifiers, commit messages and docs.
- Go: keep the core standard-library only — no new module dependencies.
Run
gofmt(orgo fmt ./...); keepgo vet ./...andstaticcheck ./...clean. No real servers, subscription URLs, node IPs or keys anywhere — tests use obviously fake data. - TypeScript: keep
npm run typecheckandnpm testclean. Mirror any protocol change insrc/api/types.ts. - Styling: all colours, spacing, radii and motion go through the design
tokens in
ui-desktop/src/styles/tokens.css(CSS custom properties; dark is the default, light flips via[data-theme="light"]). Don't hard-code hex colours or magic pixel values in component styles. - i18n: user-facing strings live in
ui-desktop/src/i18n/strings.tsand must be provided for both supported languages (English and Russian); the shape is checked at compile time. - No telemetry, ever. This is a VPN — see the hard rules in architecture.md.
fetch-resources.ps1 fails to download. It retries with back-off, but a
blocked or flaky network can still defeat it. The URLs and pinned versions are at
the top of the script — you can download sing-box.exe (from the SagerNet
release) and wintun.dll (from wintun.net) by hand and drop them into
ui-desktop/src-tauri/resources/.
The app starts but shows demo data / says "using demo backend". The shell
couldn't spawn the sidecar and fell back to the mock. Build the core into
ui-desktop/src-tauri/binaries/tenebra-core-<triple>.exe (step 2) and restart.
Make sure TENEBRA_MOCK is not set.
cargo test prints SKIP: tenebra-core binary not built. Expected on a
fresh checkout — the e2e skips itself until the sidecar exists. Build it into
src-tauri/binaries/ to run it for real.
go test passes but the tunnel does nothing. The Go tests never start a real
tunnel. Connecting for real needs the bundled sing-box present and, on Windows,
administrator rights for wintun. Run the app elevated and watch stderr / the
Logs screen.
Windows says it can't create the network adapter / access denied. wintun needs elevation. Launch the app (or the dev build) as administrator.
sing-box isn't found at runtime. The sidecar looks next to its own
executable, then honours TENEBRA_SINGBOX. Point that variable at a known-good
sing-box binary to be sure.
npm run tauri build fails on a system dependency. That's a Tauri host
requirement, not a Tenebra one — see the
Tauri prerequisites.
For contributors deciding where to dig in, the honest open items:
- Live tunnel validation. No automated test stands up a real tunnel on any platform — in tests "connected" is always a fake runner. The Windows path is run by hand against real servers routinely; the macOS and Linux ones have had no privileged live run signed off (see porting/macos.md and porting/linux.md).
- Platform adapters.
adapters/windows,adapters/macosandadapters/linuxall exist, and Android runs libbox in-process throughui-android/rather than an adapter. iOS (Network Extension) is a scaffold that has never been compiled — that is the one still unwritten. - Installer code-signing. The installer is not Authenticode-signed, so Windows
SmartScreen warns on first run. The tagged
releaseworkflow already builds and publishes it and minisign-signs the in-app updater artifacts; Authenticode signing of the initial download is the remaining gap.
See CONTRIBUTING.md for how to pick something up and propose a change.
Release builds use the authenticated per-machine service exclusively. For a
standalone development core, set TENEBRA_PIPE=off with a debug build. A missing
service now leaves the GUI unavailable with repair instructions; it never opens
a different profile store. Installer and beta channel acceptance are documented
in delivery acceptance.