---
title: "Rewriting ZeroTier-GUI in Rust + GPUI"
date: "2026-11-13T22:00:06+07:00"
summary: "The Python/Tkinter app was dormant, so I rebuilt it as a native Rust desktop app with GPUI, cross-platform CI and a Linux .deb."
url: "https://blog.s3na.xyz/2026/11/rewriting-zerotier-gui-in-rust-gpui/"
markdown: "https://blog.s3na.xyz/2026/11/rewriting-zerotier-gui-in-rust-gpui/index.md"
author: "senasgr"
tags: ["rust","gpui","zerotier","linux","rewrite"]
projects: ["zerotier-gui-rs"]
images: ["/uploads/2026/11/677f38b58f1f6dd9.jpg","/uploads/2026/11/d385bada10e5b157.jpg"]
---


## Why

The original **ZeroTier-GUI** by Tomás Ralph was a Python/Tkinter desktop app. It did its job, but it had been dormant for years and I wanted a native version I could keep building on.

The original is GPL-3.0, so the rewrite stays GPL-3.0, keeps the attribution, and lives in a separate repository:

- Code: [github.com/senasgr-eth/zerotier-gui-rs](https://github.com/senasgr-eth/zerotier-gui-rs)
- Release: [v2.0.0](https://github.com/senasgr-eth/zerotier-gui-rs/releases/tag/v2.0.0)

This is an unofficial project, not affiliated with ZeroTier, Inc.

## What the app does

I kept the original feature set and moved it into a single GPUI window:

- list joined networks with ID, name and controller status
- join and leave networks, with a persistent network history
- inspect assigned addresses, type, interface, MAC, MTU, DHCP and bridge
- toggle `allowDefault`, `allowGlobal`, `allowManaged` and `allowDNS`
- browse peers with role, latency and path count
- inspect active, expired and preferred paths
- start/stop the `zerotier-one` service on Linux
- bring a ZeroTier interface up/down through `pkexec` on Linux
- guided first-run setup for the auth token and group permissions

## Stack

- Rust 2024 edition, MSRV 1.88
- [GPUI](https://gpui.rs/) 0.2.2
- `serde` / `serde_json` for the ZeroTier JSON output
- `chrono` for history timestamps
- `open` for the ZeroTier Central link
- Linux system tools: `zerotier-cli`, `systemctl`, `pkexec`

## Layout

```text
src/
├── main.rs       window setup
├── app.rs        app state, screens and actions
├── zerotier.rs   typed wrapper around zerotier-cli
├── history.rs    persistent network history
└── theme.rs      colors, fonts and shared widgets
```

The window is created with a normal GPUI `Application`:

```rust
Application::new().run(|cx: &mut App| {
    let bounds = Bounds::centered(None, size(px(960.), px(680.)), cx);
    cx.open_window(WindowOptions {
        window_bounds: Some(WindowBounds::Windowed(bounds)),
        window_min_size: Some(size(px(800.), px(560.))),
        app_id: Some("zerotier-gui".into()),
        ..Default::default()
    }, |_, cx| cx.new(ZeroTierGui::new)).unwrap();
    cx.activate(true);
});
```

Shell commands run on GPUI's background executor, then the result is applied on the foreground:

```rust
fn spawn_blocking<T, F, D>(cx: &mut Context<Self>, work: F, done: D)
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,
    D: FnOnce(&mut Self, T, &mut Context<Self>) + 'static,
{
    cx.spawn(async move |this, cx| {
        let value = cx.background_executor().spawn(async move { work() }).await;
        this.update(cx, |this, cx| {
            done(this, value, cx);
            cx.notify();
        }).ok();
    }).detach();
}
```

That keeps refreshes and service actions off the UI thread.

## Talking to ZeroTier

All network and peer data comes from the CLI's JSON output:

```rust
pub fn list_networks() -> Result<Vec<Network>, CliError> {
    let raw = run_cli(&["-j", "listnetworks"])?;
    serde_json::from_str(&raw).map_err(|error| {
        CliError::new(
            CliErrorKind::Parse,
            format!("could not parse zerotier-cli output: {error}"),
        )
    })
}
```

Exit codes are mapped to the situations the UI can explain:

- `1` → service not running
- `2` → no access to the auth token
- `127` → `zerotier-cli` not installed

Interface state is read from `/sys/class/net/<iface>/operstate`. TUN/TAP devices often report `unknown` even when they are up, so I fall back to the interface flags and check `IFF_UP`.

Service state comes from `systemctl show --property=ActiveState`, and interface changes go through `pkexec ip link set`.

The Linux-only actions are compiled behind `cfg(target_os = "linux")`. On macOS and Windows the network, peer and path views still work through `zerotier-cli`; the systemd/polkit controls are hidden.

## Network history

The history file stays compatible with the Python app:

```text
~/.local/share/zerotier-gui/network_history.json
```

On macOS and Windows the path follows the platform data directory. A successful join writes the network ID, name and a `YYYY/MM/DD HH:MM` timestamp. The same entry is updated when the controller returns the real network name.

## Build and test

On Debian/Ubuntu, GPUI needs the XKB development packages:

```sh
sudo apt install build-essential libxkbcommon-dev libxkbcommon-x11-dev libxcb1-dev
cargo build --release
cargo test
cargo clippy --all-targets -- -D warnings
```

The binary is `target/release/zerotier-gui`.

## CI and release

GitHub Actions builds and tests the project on Linux, macOS and Windows. The Linux job also runs `cargo fmt --check` and Clippy. The release workflow is attached to tags:

- Linux: portable `.tar.gz` plus a Debian `.deb`
- macOS: `.zip` with an app bundle
- Windows: `.zip` with the executable

The v2.0.0 release is the first build from the rewrite.

## Pitfalls worth remembering

- **`libc` pin.** GPUI 0.2.2 pulls `zed-async-tar`, which pulls `xattr 0.2.3`. That version references `libc::ENOATTR`, which libc 0.2.186 removed on Linux. I pinned `libc = "=0.2.185"` until the chain is updated.
- **XKB libraries.** GPUI links `-lxkbcommon` and `-lxkbcommon-x11`. The runtime libraries were present but the dev symlinks were not; installing `libxkbcommon-dev` and `libxkbcommon-x11-dev` fixed the link step.
- **Scrollable GPUI elements.** `overflow_y_scroll` belongs to `StatefulInteractiveElement`. The element must get an `.id()` before calling it.
- **Windows attribute.** `windows_subsystem` should only be applied on Windows: `cfg_attr(all(windows, not(debug_assertions)), ...)`.
- **Cross-platform commands.** `systemctl`, `pkexec` and sysfs are Linux-only. They compile behind cfg gates, and the UI only exposes those buttons on Linux.

## Result

The rewrite is packaged and published. The source is GPL-3.0, the original Python/Tkinter project is credited, and the app runs as a native Rust binary on the three desktop platforms.

Next I want to keep testing it against more ZeroTier network types and tighten the join/history flow.

## Screenshots

![ZeroTier-GUI network list](/uploads/2026/11/677f38b58f1f6dd9.jpg "Joined networks and interface status")

![ZeroTier-GUI peers](/uploads/2026/11/d385bada10e5b157.jpg "Peers with role, latency and path count")

