github Harry-kp/vortix v0.4.0
0.4.0 - 2026-06-11

7 hours ago

Release Notes

Highlights

  • Run multiple VPNs at the same time. Connect to several WireGuard / OpenVPN profiles concurrently; one owns the kernel default route (the primary), the rest are split tunnels reachable on their declared AllowedIPs.
  • Friendlier kill switch. Modes are now off, block-on-drop, and vpn-only — the same words in the TUI, the CLI, and the JSON output. The vpn-only mode actually stays engaged whether the VPN is up or down (the v0.3.x AlwaysOn mode sat unarmed while the VPN was up, leaving a leak window between a drop and reconnect).
  • systemd-resolved-native DNS on Linux (#190). On distros where resolved manages DNS (Arch / Omarchy, NixOS-with-resolved, default Fedora Workstation), vortix now registers per-link DNS via resolvectl directly — no systemd-resolvconf / openresolv shim package required.
  • OpenVPN inline 2FA / static-challenge (#191). Profiles with static-challenge "<prompt>" 1 now prompt for the TOTP/PIN at connect time and feed it via the OpenVPN management socket. TUI gets a 3-field form-style auth overlay; CLI prompts inline.
  • Polished Security Guard panel. New Identity / Defense layout, calmer colour treatment, and the encryption row now grades your cipher (modern AEAD, strong, deprecated, or INSECURE) instead of just printing the name.

Added

  • Connect to multiple VPN profiles concurrently. Each gets its own retry budget and reconnect schedule.
  • Auto-adopt: tunnels you started outside vortix (e.g. wg-quick up corp from another terminal) appear in the TUI within a second.
  • Takeover overlay: when a second tunnel wants the default route, choose [S]witch (replace), [B]oth (keep both up, the new one becomes the exit), or [N]o.
  • Auto-promote banner: if the primary drops and a secondary takes over, a one-line banner explains what happened and offers [u] to revert.
  • Cipher strength annotation on the Security Guard Encryption row, with bright alarms for INSECURE ciphers (BF-CBC, DES, RC4, NULL, CAST5, IDEA, RC2).
  • vortix up <name> --yes to skip the takeover prompt for scripts and CI.
  • Multi-tunnel keybindings: Shift+D (disconnect every active tunnel with confirm), c (cancel an in-flight connect from Connection Details), B (Both from the takeover overlay), u (revert auto-promote).
  • JSON status reports every connected tunnel in data.connections[] plus data.primary. The legacy data.connection field stays populated when only one tunnel is up (back-compat for v1 consumers).
  • Sigil legend (✓ ✗ ⚠ ─) in the ? help overlay.
  • OpenVPN inline 2FA / static-challenge support (#191). Profiles that declare static-challenge "<prompt>" 1 now prompt for the TOTP/PIN at connect time and feed it to the server via the OpenVPN management socket using the SCRV1 envelope (SCRV1:base64(password):base64(otp)). Works for both TUI (3-field auth overlay) and CLI (vortix up <profile> adds a masked OTP prompt below the password). The static-challenge 0 variant (cleartext echo) is intentionally unsupported.
  • Auth overlay redesigned as a form: fixed-width label column, single focus marker, and Up/Down arrows now cycle between Username / Password / OTP / Save-checkbox in a circular loop (Tab/BackTab still work for muscle-memory). Empty values render an em-dash placeholder; passwords and OTPs mask to filled-circle dots.
  • systemd-resolved DNS integration on Linux (#190). When is_systemd_resolved() is detected and resolvectl works, vortix calls resolvectl dns <iface> <ips> (and resolvectl domain <iface> ~. for primary tunnels) directly after wg-quick up succeeds. The connect path strips DNS = … from the wg-quick-fed temp config so wg-quick never tries its own resolvconf path. Result: a fresh Arch / Omarchy / NixOS-with-resolved / default-Fedora host now connects WG-with-DNS without needing the systemd-resolvconf or openresolv shim package — the historic "Missing dependencies: resolvconf (systemd)" wall is gone. Secondary tunnels also get per-link DNS registered (non-authoritative — DNS reachable on the link but doesn't claim the catchall), strictly better than v0.3.x's "strip-and-discard" behaviour.

Changed

  • Breaking — CLI killswitch verbs. vortix killswitch off | block-on-drop | vpn-only replaces off | auto | always | always-on. The old verbs are no longer accepted; the parser rejects them with Use: off, block-on-drop, vpn-only.
  • Breaking — JSON killswitch values. data.security.killswitch_mode now emits off / block-on-drop / vpn-only instead of off / auto / alwayson. data.security.killswitch_state emits Inactive / Watching / Blocking instead of disabled / armed / blocking. Scripts parsing the old values need to switch.
  • Kill switch vpn-only stays engaged whether the VPN is up or down. In v0.3.x the AlwaysOn + Connected combination resolved to Armed with no actual firewall enforcement, so a drop between checks could leak. Now: default-DROP egress + per-tunnel ACCEPT rules are in place at all times when this mode is selected.
  • vortix down with no profile argument now disconnects every active tunnel (was single-tunnel only). vortix down <name> keeps the per-tunnel behaviour.
  • vortix reconnect cycles every currently-connected tunnel.
  • Telemetry switched from reqwest / curl / ping shell-outs to in-process HTTP (ureq) + raw-ICMP (socket2). Smaller binary, faster startup, no transient child processes.
  • Interface and process lookups on Linux / macOS go through libc directly (getifaddrs, sysctlbyname, kill) instead of parsing ip addr show / ifconfig / ps output. Fewer locale-dependent parser bugs.
  • Default OpenVPN connect timeout bumped from 20s → 35s to accommodate the static-challenge MFA flow (TLS handshake + PAM verification + PUSH_REPLY can comfortably exceed 20s on geographically distant servers).
  • MSRV bumped from 1.75 to 1.85. A transitive dep (idna_adapter) requires edition2024, which Rust 1.75 doesn't support. Distros shipping older Rust — notably Ubuntu 24.04's apt — will need rustup for source builds; curl | sh installer users get a prebuilt binary as before.
  • Missing-dependency error formatting. When the WG dep-check fires, wg and wg-quick now report under a single label (wireguard-tools) and produce one install hint instead of duplicating the per-distro lines. Same for OpenVPN — gets a proper three-distro hint instead of falling through to the apt-or-dnf-only fallback. Vestigial curl binary check removed from startup (telemetry has been in-process HTTP since the ureq/socket2 switch above).

Fixed

  • CLI's vortix up <name> now refuses pre-2.4 OpenVPN before attempting to connect (matches what the TUI already did in v0.3.x; pre-fix the CLI would proceed and could leak pushed DNS through the primary's resolver).
  • TUI no longer freezes when connecting to OpenVPN — even on misbehaving / slow / broken servers. Four interacting bugs would each park the UI thread for seconds at a time on a connect; together they could lock the panel for 30+ seconds and queue keystrokes. Fixed by removing every synchronous subprocess call from the UI thread's connect-success path:
    • openvpn --daemon forks + detaches, but the daemonized grandchild inherited vortix's stdout/stderr pipes — wait_with_output() blocked forever waiting for pipe EOF that never came. Subprocesses can now declare themselves as daemonizing; the runner routes their stdio to /dev/null and uses child.wait() instead.
    • openvpn --version dependency probe ran synchronously on the UI thread with no timeout. A slow first-run probe (Gatekeeper / Spotlight / antivirus on macOS) froze the panel until it returned. The probe now has a 10-second cap.
    • route get default / ip route show default ran inline on the UI thread every time the registry's recompute_primary fired (which is every connect, every disconnect, and every scanner tick). Right after a VPN claims the default route, the macOS kernel takes up to 30 seconds to answer that query. The query now lives in the scanner's background thread; its result is fed into a registry-side cache that the UI thread reads instantly. Subprocess timeout is also bounded at 1 second as defense in depth.
    • handle_connect_result dropped its own success result as "stale" if the scanner had already adopted the tunnel as Connected by the time the connect thread reported back (~1s race window). The post-connect bookkeeping — last_used timestamp, kill-switch sync, STATUS: Connected log line — was silently skipped, leaving the UI in a half-connecting state. Now accepts Connected{this profile} as a non-stale arrival.
  • TUI stays responsive on broken VPN servers. When a tunnel comes up but its server's routing is misconfigured (everything behind the VPN times out), the scanner and network-monitor threads were both probing the kernel's default-route every 1-2 seconds and each hitting their 1s timeout — burning two tokio runtime workers continuously and starving the scanner of cycles to do useful session work. The probe now shares a process-wide failure backoff: first 1-2 failures retry immediately, 3-5 cool down 5s, 6-10 cool down 15s, 10+ cap at 60s. Reset on any successful probe.
  • Aggressive scroll-spam in the v config viewer no longer wedges the TUI. Two compounding causes: (1) every keystroke ran content.lines().count() for scroll bounds AND a full per-line re-parse + re-highlight for the render. On a multi-thousand-line .ovpn (typical when certs/keys are inlined), that was ~4N string-iterations per arrow-key. (2) Mouse wheels emit 30+ events per second; the event loop processed one event per render, so a fast scroll burst queued hundreds of events and the TUI ground through them long after the user stopped scrolling. Fix: a CachedConfigView is built once when the viewer opens (pre-counted line total + pre-highlighted Vec<Line>); the main event loop now drains every queued event into state before rendering, so a 100-event scroll burst lands in one render frame at the final position.
  • Connection-Details "Role" now correctly shows Primary for OpenVPN profiles whose server pushes redirect-gateway at runtime. The previous logic only inspected the client .ovpn (which has no redirect-gateway directive — the server pushes it via PUSH_REPLY after handshake), so every OpenVPN connect rendered as Split tunnel regardless of whether the tunnel actually owned the kernel default route. Now the kernel routing table is the source of truth: any profile whose interface owns the default route renders as Primary regardless of what its static config declared.
  • New observability: any Message handler that holds the UI thread for more than 50ms emits a tracing::warn (silent by default; turn on with RUST_LOG=vortix::app=warn). Future regressions of this class surface immediately instead of being chased down with ad-hoc instrumentation.
  • CLI → TUI handoff for OpenVPN tunnels (#191). When a profile was connected via vortix up and the TUI then opened, the sidebar sigil flashed grey (external — i.e. "not started by vortix") because the scanner couldn't authoritatively resolve the kernel interface from the running openvpn process. The scanner now reads the per-profile log file as Method 0 (platform-neutral, runs ahead of the lsof/ifconfig methods) and extracts the kernel interface via the existing parse_kernel_interface parser. Result: vortix-started tunnels render with the correct vortix-owned sigil regardless of which entry point launched them.
  • Manage-credentials save-only path no longer writes a plaintext-OTP .scrv1.auth bundle to disk. The dead static_challenge_prompt field is now explicitly cleared in the ManageAuth handler so the save path takes the username/password-only branch even when the profile declares a static-challenge directive.
  • TUI rendering no longer scrambled by config-ownership notes (#222). fix_ownership() was writing Note: could not set ownership of … directly to stderr via eprintln!, which corrupted ratatui's alternate-screen rendering when running as direct root (no sudo). Now routes through tracing::debug! (SUDO_UID-unset case — chown is structurally impossible, no operator action needed) or tracing::warn! (real chown failures), both via the existing tracing infrastructure.

Removed

  • The Security Guard panel no longer renders the sigil legend inline (moved to the ? help overlay) or the Real IP: <ip> (hidden) sub-bullet (avoids leaking the real IP in screenshots of an otherwise-clean panel).
  • Approach A dead code from the early static-challenge design (#191). An exploratory path tried to embed the SCRV1 envelope as a third line of the --auth-user-pass file, but OpenVPN 2.7 does not consult that file for static-challenge responses. Removed: the SCRV1 branch in format_openvpn_auth_body, the otp: Option<&str> parameter on write_openvpn_auth_file, the dead branch in scrub_stale_scrv1_auth_files, and 5 dead tests covering that path. The shipping flow drives the management socket exclusively.

Install vortix 0.4.0

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Harry-kp/vortix/releases/download/v0.4.0/vortix-installer.sh | sh

Install prebuilt binaries via Homebrew

brew install Harry-kp/tap/vortix

Install prebuilt binaries into your npm project

npm install @harry-kp/vortix@0.4.0

Download vortix 0.4.0

File Platform Checksum
vortix-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
vortix-x86_64-apple-darwin.tar.xz Intel macOS checksum
vortix-aarch64-unknown-linux-gnu.tar.xz ARM64 Linux checksum
vortix-x86_64-unknown-linux-gnu.tar.xz x64 Linux checksum
vortix-aarch64-unknown-linux-musl.tar.xz ARM64 MUSL Linux checksum
vortix-x86_64-unknown-linux-musl.tar.xz x64 MUSL Linux checksum

Don't miss a new vortix release

NewReleases is sending notifications on new releases.