# senasgr / devlog — full text > Working notes by senasgr: networks, infrastructure, Rust, crypto and the things I build. > Canonical: https://blog.s3na.xyz/ > Generated: 2026-11-14T07:32:28+07:00 ===================================================================== POST: Rewriting ZeroTier-GUI in Rust + GPUI 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 DATE: 2026-11-13T22:00:06+07:00 TAGS: rust, gpui, zerotier, linux, rewrite PROJECT: zerotier-gui-rs 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. ===================================================================== ## 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(cx: &mut Context, work: F, done: D) where F: FnOnce() -> T + Send + 'static, T: Send + 'static, D: FnOnce(&mut Self, T, &mut Context) + '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, 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//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") ===================================================================== POST: The devlog is live: how I built it URL: https://blog.s3na.xyz/2026/11/the-devlog-is-live/ MARKDOWN: https://blog.s3na.xyz/2026/11/the-devlog-is-live/index.md DATE: 2026-11-13T22:00:05+07:00 TAGS: meta, devblog, proxmox, hugo, caddy, cloudflare PROJECT: senasgr-devblog SUMMARY: A step-by-step look at the LXC, Caddy, Hugo and publish API behind this site, written so I can rebuild it later. ===================================================================== ## What I wanted I wanted a working notebook, not a portfolio. Something I can rebuild from scratch six months from now, and something another engineer can follow if they want the same setup. This entry is the build log for the site itself. ## TL;DR Proxmox LXC 180 on `pve2` → Debian 12 → Caddy + Hugo + Flask/Gunicorn → Cloudflare Tunnel → `blog.s3na.xyz`. Writes go through `/api/publish`; Hugo rebuilds the static site; Caddy serves it. ## Topology | Role | Host | Address | | --- | --- | --- | | Proxmox node | `pve2` | `192.168.89.102` | | Blog container | `blog` (VMID 180) | `192.168.89.210/24` | | LAN gateway | — | `192.168.89.1` | The public side is a Cloudflare Tunnel pointing at `http://192.168.89.210`. TLS terminates at Cloudflare, so the origin only needs HTTP on the private network. ## 1. Create the LXC I used the Debian 12 template already present on the Proxmox node: ```sh pct create 180 local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst \ --hostname blog \ --cores 2 --memory 1024 --swap 512 \ --rootfs local-zfs:8 \ --net0 "name=eth0,bridge=vmbr0,firewall=0,gw=192.168.89.1,ip=192.168.89.210/24,type=veth" \ --nameserver "" \ --unprivileged 1 --features "nesting=1,keyctl=1" \ --onboot 1 --start 1 ``` Then I added my SSH public key to `/root/.ssh/authorized_keys` and set the timezone: ```sh timedatectl set-timezone Asia/Jakarta ``` The result is a small, unprivileged container with 2 vCPU, 1 GB RAM, 512 MB swap and an 8 GB ZFS volume. ## 2. Install the base packages ```sh apt update apt install -y caddy hugo python3 python3-flask python3-gunicorn \ python3-yaml sqlite3 curl jq rsync git ca-certificates ``` Versions on this box: - Debian 12 (bookworm) - Caddy 2.6.2 - Hugo 0.111.3 extended - Python 3.11 - Flask 2.2 - Gunicorn 20.1 ## 3. Create the layout ```text /srv/blog/ ├── site/ # Hugo project │ ├── content/posts/ # Markdown entries │ ├── layouts/ # theme templates │ ├── static/ # CSS, JS, favicon │ └── hugo.toml ├── api/app.py # publish API ├── public/ # generated static site └── posts.db # SQLite index ``` I use a dedicated system user for the site and the API: ```sh useradd --system --home /srv/blog --shell /usr/sbin/nologin blog mkdir -p /srv/blog/{site,api,public} chown -R blog:blog /srv/blog ``` ## 4. Configure Hugo The important parts of `hugo.toml`: ```toml baseURL = "https://blog.s3na.xyz/" title = "senasgr / devlog" enableRobotsTXT = true [taxonomies] tag = "tags" project = "projects" [permalinks] posts = "/:year/:month/:slug/" [outputs] home = ["HTML", "RSS", "JSON"] section = ["HTML", "RSS"] ``` A first build, as the `blog` user: ```sh runuser -u blog -- env HUGO_ENV=production hugo \ --minify --cleanDestinationDir --noBuildLock \ --destination /srv/blog/public ``` `--noBuildLock` matters later: the systemd service runs with a read-only filesystem outside `/srv/blog`, and Hugo otherwise tries to create `.hugo_build.lock` in the project root. ## 5. Publish API The API is intentionally small. It accepts a JSON document, writes a Markdown file with Hugo front matter, records the post in SQLite, and rebuilds the site. The environment file lives at `/etc/blog/blog.env`: ```sh BLOG_SITE_DIR=/srv/blog/site BLOG_PUBLIC_DIR=/srv/blog/public BLOG_DB=/srv/blog/posts.db BLOG_LOCK=/srv/blog/.build.lock BLOG_BASE_URL=https://blog.s3na.xyz BLOG_TZ=Asia/Jakarta ``` Generate a strong API token once with `openssl rand -hex 32` and add it as `BLOG_API_TOKEN` in that file. Keep the file at mode `0640`, owned by `root:blog`. The service: ```ini [Unit] Description=s3na devlog publish API After=network-online.target [Service] User=blog Group=blog WorkingDirectory=/srv/blog/api EnvironmentFile=/etc/blog/blog.env Environment=PYTHONDONTWRITEBYTECODE=1 ExecStart=/usr/bin/python3 -m gunicorn --workers 1 \ --bind 127.0.0.1:8090 --timeout 120 app:app Restart=on-failure NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/srv/blog ``` Enable it: ```sh systemctl daemon-reload systemctl enable --now blog-api curl -fsS http://127.0.0.1:8090/api/health ``` The API listens only on `127.0.0.1`. Caddy is the only process exposed to the LAN. ## 6. Caddy ```caddyfile :80 { encode gzip root * /srv/blog/public handle /api/* { reverse_proxy 127.0.0.1:8090 } handle { try_files {path} {path}/ =404 file_server } handle_errors { rewrite * /404.html file_server } } ``` Validate and reload: ```sh caddy validate --config /etc/caddy/Caddyfile systemctl reload caddy ``` I deliberately use `:80` instead of `blog.s3na.xyz`: Cloudflare Tunnel connects to the origin over HTTP, and the public certificate is handled at the edge. ## 7. Publish an entry I write Markdown, then send it through the CLI: ```sh blog post \ --title "Rebuilding the lab router" \ --summary "What changed, what broke, and the commands that fixed it." \ --project homelab \ --tags networking,proxmox \ --body-file post.md ``` The API expects JSON, so the same thing can be done from any script: ```json { "title": "Rebuilding the lab router", "summary": "What changed, what broke, and the commands that fixed it.", "body": "## Goal\n...", "tags": ["networking", "proxmox"], "project": "homelab", "status": "published", "session_id": "optional-idempotency-key" } ``` The `session_id` is what makes retries safe: sending the same key updates the existing entry instead of creating a duplicate. ## 8. Verify ```sh curl -fsS https://blog.s3na.xyz/ >/dev/null curl -fsS https://blog.s3na.xyz/api/health curl -sSI https://blog.s3na.xyz/does-not-exist curl -fsS https://blog.s3na.xyz/index.xml >/dev/null ``` Expected: `200` for the home page and health check, `404` for a missing page, and a valid RSS feed. ## Pitfalls I hit - **Zero-byte post pages.** `baseof.html` has to live in `layouts/_default/` for templates under `_default/` to find it. Putting it only at `layouts/baseof.html` worked for the home page but produced empty single pages. - **404 handling.** `try_files {path} {path}/ /404.html` serves the error page with a `200`. Using `=404` plus a `handle_errors` block keeps the correct status. - **Hugo lock file.** The service user could not write `.hugo_build.lock` under the hardened systemd unit, so the build uses `--noBuildLock`. - **Ownership.** Hugo must be able to write `content/posts`, `resources` and `public`; Caddy only needs read access to `public`. - **Port 80 on purpose.** With Cloudflare Tunnel, there is no reason to terminate TLS twice on the origin. ## What is next More notes from the workbench: network changes, LXC builds, Rust experiments, and the occasional post-mortem. The archive and RSS feed are there for anyone who wants to follow along. ## Rebuild checklist - [ ] Create LXC 180 on `pve2` with Debian 12, 2 vCPU, 1 GB RAM, 8 GB disk. - [ ] Install Caddy, Hugo, Python, Flask, Gunicorn and SQLite. - [ ] Create the `blog` user and `/srv/blog` layout. - [ ] Deploy the Hugo site and build it once. - [ ] Configure the publish API and its systemd unit. - [ ] Validate the Caddyfile and reload Caddy. - [ ] Point the Cloudflare Tunnel at `http://192.168.89.210`. - [ ] Publish a test entry and check the home page, RSS and 404.