{"id":50672067,"url":"https://github.com/franzos/vpnmux","last_synced_at":"2026-06-08T12:04:14.098Z","repository":{"id":360775558,"uuid":"1251643246","full_name":"franzos/vpnmux","owner":"franzos","description":"Keeps Mullvad and Tailscale from fighting at the netfilter/DNS layer.","archived":false,"fork":false,"pushed_at":"2026-05-27T19:59:57.000Z","size":31,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-05-27T21:22:46.725Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/franzos.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-27T19:27:33.000Z","updated_at":"2026-05-27T20:00:02.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/franzos/vpnmux","commit_stats":null,"previous_names":["franzos/vpnmux"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/franzos/vpnmux","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fvpnmux","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fvpnmux/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fvpnmux/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fvpnmux/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/franzos","download_url":"https://codeload.github.com/franzos/vpnmux/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fvpnmux/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34061125,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-08T02:00:07.615Z","response_time":111,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-08T12:04:11.908Z","updated_at":"2026-06-08T12:04:14.093Z","avatar_url":"https://github.com/franzos.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# vpnmux\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"assets/logo.svg\" alt=\"vpnmux\" width=\"480\"\u003e\n\u003c/p\u003e\n\u003cp align=\"center\"\u003e\n  Keeps Mullvad and Tailscale from fighting at the netfilter/DNS layer.\n\u003c/p\u003e\n\nArbitrates a set of network providers — currently Mullvad and Tailscale — so a\nchosen combination coexists cleanly. The hard part isn't running each one; it's\nkeeping them from fighting at the netfilter/DNS layer (Mullvad's killswitch\ndrops Tailscale, and both daemons claw at `/etc/resolv.conf`). vpnmux keeps that\ntruce continuously.\n\nIt's an operator/control-loop: a root **daemon** reconciles the system to a\ndesired set of providers every couple of seconds; the **CLI** just writes the\ndesired state and reads back status. Single writer, idempotent, std-only Rust\n(no external crates).\n\n**Status:** Linux-only — it drives `nft`/`mullvad`/`tailscale` directly.\n\n## Install\n\n| Method | Command |\n|--------|---------|\n| Debian/Ubuntu | Download [`.deb`](https://github.com/franzos/vpnmux/releases) — `sudo dpkg -i vpnmux_*_amd64.deb` |\n| Fedora/RHEL | Download [`.rpm`](https://github.com/franzos/vpnmux/releases) — `sudo rpm -i vpnmux-*.x86_64.rpm` |\n| Binary | Grab a tarball from [Releases](https://github.com/franzos/vpnmux/releases) (x86_64, aarch64) |\n| From source | `cargo build --release` → `target/release/vpnmux` |\n\nThe `.deb`/`.rpm` ship a systemd unit (`vpnmux.service`, disabled by default).\nEnable it once installed:\n\n```bash\nsudo systemctl enable --now vpnmux\n```\n\n\u003e **Heads up:** I run this primarily on Guix. vpnmux builds and runs natively on\n\u003e Debian 12, where the DNS-backend handling is tested (see\n\u003e [DNS backends](#dns-backends)); it only leans on systemd and the\n\u003e `nft`/`mullvad`/`tailscale` binaries, so it *should* run fine on any systemd\n\u003e distro. The packaged `.deb`/`.rpm` install path and Fedora/RHEL haven't been\n\u003e heavily exercised yet, though\n\n## Run (manual)\n\nRun the daemon as root — it drives `nft`/`mullvad`/`tailscale` and reconciles\nevery ~2s. Keep it in a terminal; add `VPNMUX_LOG=debug` for the full\ncommand-by-command trace (default is a quiet, diff-based change-log):\n\n```bash\nsudo target/release/vpnmux daemon\n```\n\nSwitch state in another shell. If your user is in the `vpnmux` group (see\n**Sudo-less CLI** below) the `sudo` is optional — the CLI only needs write\naccess to `/var/lib/vpnmux/desired`, which the daemon picks up:\n\n```bash\nvpnmux set mullvad tailscale   # both, Tailscale via the tunnel\nvpnmux set mullvad             # Mullvad only\nvpnmux set tailscale           # Tailscale only\nvpnmux set                     # none\nvpnmux status\n```\n\n### Sudo-less CLI\n\nThe daemon mirrors `mullvad-daemon`'s pattern: at startup it chowns\n`/var/lib/vpnmux` and `/run/vpnmux` to `root:vpnmux` (mode `02770`, setgid)\nwhen a `vpnmux` system group exists, so members of that group can drive\n`vpnmux set`/`status` without `sudo`. To enable:\n\n```bash\nsudo groupadd --system vpnmux\nsudo usermod -aG vpnmux \"$USER\"\nsudo systemctl restart vpnmux\n# log out \u0026 back in (or `newgrp vpnmux`) for the group to take effect\n```\n\nOverride the group name with `VPNMUX_GROUP=othergroup` in the unit's\n`Environment=`, or set it empty to opt out and keep the dirs root-only.\n\n\u003e Anyone in the `vpnmux` group can flip providers, including disabling Mullvad\n\u003e while lockdown is on (the `[y/N]` prompt still applies). Same trust model as\n\u003e the `mullvad` group on systems that use one.\n\nSwitching to `none`/`tailscale` while Mullvad lockdown is on warns and prompts\nfirst — it would cut all connectivity (that's the killswitch doing its job).\n\n## States\n\n| State | Mullvad | Tailscale | DNS |\n|-------|---------|-----------|-----|\n| `none` | off | off | system |\n| `mullvad` | connected | off | Mullvad (`10.64.0.1`) |\n| `tailscale` | off | up | MagicDNS |\n| both | connected | up, via the tunnel | Mullvad (MagicDNS off) |\n\nThe daemon never imposes a default: with no desired state set it stays idle and\ntouches nothing.\n\n## DNS backends\n\nvpnmux only touches DNS to fill a gap: when Mullvad disconnects it takes its\n`10.64.0.1` resolver with it, and on a box with no DNS manager nothing else fills\nin. So it detects how your system manages `/etc/resolv.conf` and acts *only* where\nthere's a real gap — on managed systems the resolver manager already keeps a\nworking upstream when Mullvad/Tailscale drop their own links, so vpnmux stays out\nof the way. Either way, `vpnmux status` reports the backend it detected.\n\n| Backend | Default on | What vpnmux does |\n|---------|-----------|------------------|\n| **systemd-resolved** (stub `127.0.0.53`) | Ubuntu, Mint, Pop!_OS, Fedora, NixOS (`services.resolved`) | detect only — resolved keeps upstream DNS; no backfill |\n| **NetworkManager** (writes `resolv.conf` directly) | Debian desktop, RHEL/Rocky/Alma, Arch, Manjaro, Guix System (desktop) | detect only — NM keeps upstream DNS; no backfill |\n| **static `/etc/resolv.conf`** | Debian server/minimal, Guix System (server/DHCP), hand-rolled setups | backfills the default-route resolver when Mullvad leaves, strips it when Mullvad returns |\n| **resolvconf / openresolv** | NixOS (default), legacy / opt-in | backfills via `resolvconf -a vpnmux` (`-d` on the way out) |\n| **netconfig** | openSUSE | detect only — netconfig keeps upstream DNS; no backfill |\n| **other / unknown** | ConnMan, anything else | left alone — never overwrites a managed `resolv.conf` |\n\nSet `VPNMUX_DNS=\u003cip\u003e` to override the backfilled resolver (default: the\ndefault-route gateway). It only applies on the backends vpnmux backfills.\n\nGuix System has no systemd-resolved (it doesn't use systemd), so it lands on\nNetworkManager (default desktop), a static `/etc/resolv.conf` (server/DHCP), or\nConnMan (handled as *other/unknown*). NixOS defaults to openresolv and only uses\nsystemd-resolved if you enable `services.resolved`.\n\n## waybar\n\nA status icon plus a click-to-switch menu that only offers the configurations\nthat are actually engageable right now.\n\n`vpnmux status --json` exposes the daemon's view as machine-readable JSON\n(reading only `/run/vpnmux/status` — it spawns nothing):\n\n```json\n{\"generation\":12,\"active\":[\"mullvad\"],\"available\":[\"mullvad\",\"tailscale\"],\n \"unavailable\":[{\"provider\":\"tailscale\",\"reason\":\"not logged in\"}]}\n```\n\n- `active` — providers currently up.\n- `available` — providers engageable right now (this drives the menu).\n- `unavailable` — providers you *asked for* that couldn't be engaged, with a reason.\n\nTwo scripts under [`packaging/waybar/`](packaging/waybar) wire it up:\n\n- `vpnmux-waybar-status.sh` — maps the JSON to waybar's format (needs `jq`).\n- `vpnmux-waybar-toggle.sh` — builds the available-only menu and applies the\n  choice. Launcher-agnostic: set `VPNMUX_MENU` to any dmenu-compatible command\n  (defaults to `fuzzel --dmenu`), e.g. `VPNMUX_MENU=\"wofi --dmenu\"` or\n  `VPNMUX_MENU=\"rofi -dmenu\"` (the value is word-split, so the launcher binary's\n  path can't contain spaces).\n\nPut both scripts on your `PATH`, then add the module from\n[`packaging/waybar/config.jsonc`](packaging/waybar/config.jsonc) and style it\nwith [`packaging/waybar/style.css`](packaging/waybar/style.css). The toggle sends\n`SIGRTMIN+8` to waybar (`\"signal\": 8`) so the icon refreshes immediately.\n\n\u003e The toggle runs `vpnmux set … --yes`, which **bypasses the lockdown prompt**.\n\u003e Switching off Mullvad from the menu while lockdown is on will cut all\n\u003e connectivity (the killswitch doing its job) — there's no confirmation in the\n\u003e GUI path, unlike the CLI. You'll need to be in the `vpnmux` group (see\n\u003e **Sudo-less CLI**) for the menu to read status and flip providers.\n\n## Environment\n\n| Var | Purpose |\n|-----|---------|\n| `VPNMUX_LOG` | `error` / `info` (default) / `debug` |\n| `VPNMUX_NFT` | absolute path to `nft` (else scans `/gnu/store`) |\n| `VPNMUX_MULLVAD` / `VPNMUX_TAILSCALE` | adapter binary paths |\n| `VPNMUX_DNS` | resolver to backfill on static/resolvconf backends (default: default-route gateway) |\n| `VPNMUX_GROUP` | system group for sudo-less CLI (default: `vpnmux`; empty to opt out) |\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffranzos%2Fvpnmux","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffranzos%2Fvpnmux","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffranzos%2Fvpnmux/lists"}