Releases: DaveBugg/PiTun
Release list
v1.4.9
Full Changelog: v1.4.8...v1.4.9
v1.4.8
PiTun now updates itself from the web UI — Settings → Updates checks GitHub, shows what is new and applies it with live progress, fetching through the active node so a throttled or blocked direct route is not a problem. The pinned 3x-ui panel version moves from v3.1.0 to v3.6.0, with the install pipeline hardened by sha256 content verification of the upstream installer scripts and both panel generations managed side by side. The rest of the release is a sweep of logic and frontend↔backend interaction bugs found in a full audit — most visibly, a deleted active node no longer sends the whole LAN out unproxied, a speed test no longer loses its result when you paginate or leave the page, and a chain no longer kills every ordinary inbound on its relay panel.
✨ What's new
⬆️ Updates from the web UI
Settings → Updates is the whole feature: check, read the release notes, press update, watch the progress bar.
The interesting part is what it deliberately does not do. The backend cannot apply an update to itself — that restarts the very container serving the request, killing the response mid-flight. So it writes a request file to the shared volume, and a systemd path unit on the host (pitun-update.sh --agent) picks it up and does the work. Progress travels back through a file for the same reason: it is the only channel that outlives the restart, which is why the panel keeps reporting correctly straight through it.
- The network path is the point. This box TPROXYs its own traffic, so its route to GitHub runs through the tunnel. The check tries xray's local SOCKS inbound first — that proves the active node carries traffic — and falls back to the default route when it does not. An update must never be blocked by the tunnel it might be fixing.
- "Could not reach GitHub" never renders as "you are up to date." A dead tunnel takes GitHub with it, so the answer names the route that responded:
active node,direct, orunreachable. - Downgrades are called out before they happen. Installing anything older than
1.4.8removes this panel; the warning says so and prints the shell command to come back. - Re-installing the current version is offered as the repair path.
- A failed poll mid-update reads as "restarting", not as an error — the backend being briefly absent is the update working.
scripts/pitun-update.sh also stands alone: --check reports without touching anything (exit 10 when an update is available), --force re-applies, --install-timer adds a daily systemd timer that reports by default and only applies with --apply.
🧹 Housekeeping that actually reclaims space
After a verified-healthy update — not merely "the installer exited 0" — superseded pitun-backend / pitun-naive images are removed and only the 3 most recent DB snapshots are kept. On a box that has been through a few releases this is measured in gigabytes; on a 32 GB SD card it is the difference between a working update and a full disk.
Neither step runs when an update fails. That is precisely when the old image and the older snapshots are worth having.
📌 3x-ui v3.6.0 pin — with content-hash verification
setup-xui-server.sh now installs 3x-ui v3.6.0 in both modes (bare and x-ui-pro). The upstream installer scripts were already fetched at immutable commit SHAs; they are now also checked against pinned sha256 hashes before a single line of them executes (fetch_pinned). A force-pushed tag, a compromised upstream commit, or a tampered download now aborts the install with a clear error instead of running silently.
The install also switched to v3.6.0's native non-interactive mode — XUI_NONINTERACTIVE=1, XUI_SSL_MODE=none, XUI_DB_TYPE=sqlite are exported by both install branches (and inherited by x-ui-pro's inner installer call), replacing the old prompt-feeding pipe.
🔁 One client, two panel generations
The API surface PiTun relies on (/panel/api/inbounds/*, /panel/api/clients/*, Bearer auth) is unchanged between v3.1.0 and v3.6.0 — but v3.6.0 moved its UI-internal controllers (/panel/setting/*, /panel/xray/*) under /panel/api/..., and its new React SPA answers the old paths with an HTML shell or 404. XuiClient now probes the new mount first and falls back to the old one, caching the result per client — API-token bootstrap and proxy-chain template pushes work against either panel generation.
🔗 Chains no longer break the relay panel's other inbounds
Creating a chain used to leave every ordinary inbound on the relay panel dead — connections established, TLS completed, and then zero bytes moved, while the chain node itself ran at full speed. The generated xrayTemplateConfig declared an outbound tagged api and placed it first; Xray's Commander already owns that tag, and outbounds[0] is where traffic matching no routing rule goes. Plain inbounds have no rule by design, so their traffic was handed to the API handler and dropped.
The template now declares only direct (first, as the default egress) and blocked — the same layout as the stock 3x-ui template. Re-saving an existing chain pushes a corrected template; nothing else is needed.
⛓️💥 A broken chain now says it is broken
chain_node_id carries no foreign key, so a pointer at a deleted node survived deletion happily — and the chained node then stopped working with nothing to explain it: xray skipped the outbound, health probes followed the dead pointer, speed tests came back empty, and the list still showed a cheerful chained badge.
Deleting a subscription with its nodes, or a refresh dropping nodes that vanished from the panel, now clears those links and records an event naming the affected nodes. Every node read also reports chain_orphan, computed rather than stored, so rows broken by an older version or an external DB edit surface too — the UI marks them chain broken and keeps the original link visible so it can be repaired rather than guessed at.
🛠️ Install / UX polish
- Bare-mode installs no longer pipe canned answers into the upstream installer's prompts; the env-driven flow is deterministic and immune to upstream prompt reordering.
- A failed API-token bootstrap now reports which endpoint mounts were probed, instead of dumping raw HTML into the error.
- Errors from system mutations are visible at last. Start / stop / mode / active-node / settings had no error handling anywhere, so a
400explaining a rejectedinbound_mode, or a503asking you to wait and retry, produced nothing on screen at all. - A failed install no longer shows a green "Install succeeded". A script exiting non-zero still finalizes the job as
succeeded(the runner returns the failure as a result), and the deploy/uninstall modals rendered that raw status; they now use the same projection as the tasks page. - A dropped WebSocket no longer freezes the deploy modal forever — treating the drop as "finished" also switched off the polling fallback that would have recovered.
- Two open Logs tabs no longer split the xray stream between them. One shared queue handed each line to exactly one reader, so a backgrounded tab quietly ate half the lines.
🔧 Under the hood
Dataplane — routing that silently did not apply
- Deleting the active node left
active_node_iddangling. The next config regeneration — any rule, DNS or settings edit — quietly produced a config with no proxy outbound, so everything meant for the tunnel went out direct. The health checker stayed silent because there was no node left to check. Deletion now re-points to a surviving node (or stops the proxy and says so) and re-applies the dataplane. - MAC rules never reached nftables. They are invisible to xray by design — nftables owns L2 — but a rule change only reloaded xray. A new MAC bypass did nothing, and a deleted one kept bypassing until the next restart.
POST /system/modeonly wrote a setting. Switching to Bypass left nftables TPROXYing and xray on the old config while the UI reported the new mode.- Subscription refresh never reloaded xray. A panel rotating a Reality key or an SNI updates the row in place — the fingerprint still matches — while the running xray kept dialling with stale crypto. Health checks agreed, because they connect to the address from the fresh row.
/system/startand/system/restartapplied nftables before xray and never rolled back, so a failed start left the LAN redirected into a TPROXY port with nothing listening.- A circle's balancer stayed on its cold-start
randomafter any reload, spreading connections across every member — including ones a failover had just rejected — while the UI showed one specific node. The gRPC pin now retries until xray's API is up, and warns instead of failing silently. - Failover could overwrite a manual node switch made while it was still probing candidates.
- Config writes are atomic (temp file + rename, one writer at a time). Overlapping writers truncated the same file in place, so a reader — or
xray run -test— could see half a document.
Speed test
- The result survived neither navigation nor pagination: it lived in page state and was written from per-mutation callbacks, so leaving the page threw it away, a second test stranded the first row on "testing…" forever, and the spinner tracked the wrong node.
- A pinned active node never showed its result at all — the row that renders it existed only inside the list.
- Speed All dies at the reverse proxy's 120s ceiling on a large node set; it now says so instead of stopping silently.
- A leaked xray process on startup timeout: the timeout branch returned a 3-tuple where the caller unpacked four, so cleanup ran on nothing and the process outlived the request, holding a temp config with the node's credentials. Ports ar...
v1.4.7
The User-Agent a subscription fetches with is no longer baked into the source. UA templates move the whole catalogue into an editable table you manage from the Subscriptions page, and each template can now carry custom request headers for panels that check more than the UA string. Templates export and import as JSON, and existing subscriptions come through the upgrade sending a byte-identical request.
✨ What's new
🪪 User-Agent templates
Until now the subscription UA picker was a fixed list of nine presets living in two Python dicts inside app/api/subscriptions.py. When a panel started rejecting Happ/2.7.0 because it wanted a newer app version, or a CDN began filtering the pinned Chrome build, the only fix was to edit the source and redeploy.
Those nine presets are now rows in a new useragenttemplate table, seeded by migration 018 with exactly the values that used to be hardcoded. Subscriptions → UA templates opens a table of them with add / edit / delete, and an editor for the display name, the key, the User-Agent string, a description and the dropdown ordering.
- Seeded rows are marked default in the table, but they are ordinary rows — fully editable and deletable. Nothing re-seeds them, so a template you delete stays deleted.
- The In use column shows how many subscriptions point at each template.
- The subscription form's dropdown is fed from this list, and shows the resolved UA string beneath it so you can see what actually goes on the wire.
🧩 Custom request headers per template
Some panels gate on more than the User-Agent — an API key, a specific Referer, a device fingerprint. A template can now declare extra headers that are merged into the request alongside its UA.
- Header names are validated as RFC 7230 tokens.
User-Agent(it has its own field),Host,Content-Length,Transfer-Encoding,Connection,UpgradeandExpectare rejected — those belong to the transport. - An empty value removes the header rather than sending it blank. This is the supported way to drop
Accept-Encodingon a panel that mishandles gzip and returns a truncated body. - Merging is case-insensitive, so a template setting
accept-encodingreplaces the defaultAccept-Encodinginstead of sending both. - Template headers are applied last and can override anything chosen for you, including the Happ
X-*bundle.
Happ's X-Device-* / X-Hwid headers are still generated in code, not stored: X-Hwid is derived from the machine id per request and re-rolled when a subscription has Rotate HWID on. A value frozen in the database would break both behaviours.
📦 Export / import
The Subscriptions header gained a download and an upload button next to UA templates, so a fingerprint catalogue can move between installs.
| Mode | Behaviour | Best for |
|---|---|---|
| Default | Additive. A key that already exists is skipped. | Merging someone else's templates into yours |
overwrite |
A matching key is updated in place, keeping its row id — subscriptions stay attached. | Re-syncing a catalogue you edited elsewhere |
replace |
Wipes the table first, then inserts. | Restoring a full backup |
The UI only asks about overwriting when the file actually collides with something you already have. A malformed row lands in an error list and the rest of the bundle still imports.
🛠️ Install / UX polish
- Renaming a template's
keyre-points every subscription using it in the same transaction. Without this the subscriptions would quietly fall back to a built-in UA and start presenting a different fingerprint on their next refresh. The editor warns you how many will move before you save. - Deleting a template that is still in use returns a
409naming the affected subscriptions, with an explicit "delete anyway" confirmation rather than a silent cascade. - Subscription cards now show
+Nhwhen the selected template adds headers, and(overridden)when a per-subscription custom UA is winning over the template. - Esc no longer collapses a whole modal stack.
useEscapeKeylistens ondocument, so opening the templates dialog over the subscription form meant one keypress closed both and discarded the half-filled form.ModalShellgained acloseOnEscapeprop for nested dialogs.
🔧 Under the hood
- New Alembic migration
018—useragenttemplatetable plus its seed rows. Head moves017→018. - New
app/core/ua_templates.pyowns header assembly, the validation ruleset and the seed data;app/api/user_agents.pyis the CRUD + export/import router at/api/user-agents.subscriptions.pynow callsbuild_subscription_headers()instead of carrying its own UA map. - Header-value validation closes an injection path. httpx forwards a
CR/LFinside a header value unchanged, which would smuggle an extra header (CWE-93); a non-ASCII value instead raises at send time and would have surfaced hours later as an opaquelast_error. Both are now rejected on save, in the UI and at the API. - Per-row import errors report the row's raw
key(control characters stripped) and the exception type only — a validation message would echo the input back, and a template's headers can hold a panel API key. - Migration
018inlines its seed rows instead of importing them from application code. A migration is a historical snapshot, and the backend bind-mountsapp/andalembic/as separate volumes — an import would crash-loop the container on a deploy that updated one before the other. A test pins the inlined copy against the runtime one so the two cannot drift. - New
frontend/src/lib/apiError.tsnormalises both FastAPIdetailshapes (our own string messages, and the pydantic 422 array) so validation errors render usefully instead of as a generic fallback.
⚠️ Notes on upgrading
- No breaking changes, and no change to what goes on the wire. Migration
018seeds the presets under the same slugs already stored insubscription.ua, with the same User-Agent strings. A regression test replays the v1.4.6 resolution logic and asserts the resulting header set is byte-identical for every preset, including thecustom_uapaths — so existing subscriptions keep their exact fingerprint. - First boot runs the migration automatically via
entrypoint.sh. Deploybackend/appandbackend/alembictogether, then restart the backend container. If new code somehow runs before the migration, the template lookup degrades to the built-in UA map and logs a warning instead of failing the refresh. subscription.uais deliberately not a foreign key. An unknown key falls back to the built-in map, so a deleted or renamed template degrades rather than breaking a refresh or blocking the delete.- Rolling the code back to v1.4.6 without rolling back the database is safe — the extra table is simply ignored. A full rollback is
alembic downgrade 017. - No new dependencies; image size unchanged.
📊 Stats
- 21 files changed, ~3 900 insertions.
- Backend 866 tests passing (97 new); frontend 49 passing (24 new);
tscclean,vite buildgreen. - New migration
018. No new runtime dependencies.
Full Changelog: v1.4.6...v1.4.7
v1.4.6
Two field-tested fixes for multi-hop chaining: removing a node's chain or Server link now persists, and a failed speed test now reports the real reason instead of a blank "couldn't start". No schema changes, no breaking changes.
🐛 Fixes
🔗 Removing a chain (or Server link) now saves
Clearing a node's "Chain via" relay — or its optional Server link — never
persisted: you could set a chain but never remove it. The form sent the cleared field
as undefined, and JSON.stringify silently drops undefined keys, so the field
never reached the PATCH body. The backend's model_dump(exclude_unset=True) then
read "absent" as "don't touch" and kept the stale value.
The form now sends an explicit null when a field is cleared, which the backend
writes as NULL. Setting a chain always worked; now removing one does too.
- Affects
chain_node_idandserver_id. - Frontend-only fix — the deployed backend already distinguished "omitted" from
"null" (verified against the live API), so no server change was needed.
🩺 Speed test now shows the real xray error
A node speed test that couldn't start its throwaway xray always returned a generic
"Failed to start temp xray", hiding the actual cause. The real reason was in the
logs the whole time — an invalid REALITY shortId, an empty publicKey, an
unbuildable outbound — but never reached the UI.
_start_temp_xray now captures xray's own error and propagates it into the speed-test
result, so the UI shows the actionable tail, for example:
xray: invalid "shortId": <value>xray: empty "password"— an empty REALITY publicKey on the client nodechain build error: …
⚠️ Notes on upgrading
- No breaking changes. No schema migration — alembic head is unchanged.
- One frontend component + one type file + one backend file; no new dependencies.
- No first-boot side effects.
📊 Stats
- 3 files changed, +18 / −10.
- Frontend
tsc+ build clean; backend imports clean (nothing calls_start_temp_xray
outsidespeedtest_node, so the added return value is contained).
Full Changelog: v1.4.5...v1.4.6
v1.4.5
v1.4.5 makes NodeCircle rotation seamless — a circle of WireGuard (or any) nodes now rotates without restarting xray, so live connections aren't torn on every switch. It also fixes a bug where switching the active node never actually took effect, and adds node-import quality-of-life (drag & drop, WireGuard .conf, name-from-filename).
🐛 Fixes
Switching the active node now actually applies
POST /api/system/active-node only wrote the active_node_id row to the DB — it never regenerated the xray config or reloaded xray. So activating a node (a WireGuard chain especially) flipped the UI but left traffic exiting the previous node. It now regenerates the config, re-applies nftables, and hot-reloads — the switch takes effect immediately.
Balancer override was a silent no-op
The runtime xray api bo call passed the balancer tag positionally instead of via the required -b <tag> flag, so every balancer override failed with "balancer tag not specified". Fixed — runtime balancer control now works.
✨ What's new
🔄 Seamless NodeCircle rotation (no dropped connections)
An enabled NodeCircle now routes proxy traffic at a per-circle xray balancer over all of its members. Every member's outbound (with its stream relay) is preloaded, and rotation simply hot-swaps the selected member through the gRPC balancerOverride API.
The win: no xray restart on rotation. xray picks the outbound per new connection, so existing connections finish on their current node while new ones move to the rotated node. Previously each rotation reloaded (restarted) xray and tore every live connection. Manual active-node switches into a circle pin the balancer the same way.
- Verified live: pinning the balancer to a member changes the exit IP instantly with zero restart of the xray process.
📥 Node import — drag & drop, .conf, name-from-filename
The Upload File tab of the node importer got three fixes:
- Drag & drop now works — the drop zone was previously click-only despite looking droppable.
- More formats in the file picker — WireGuard
.conf(and.ini) alongside the existing.txt/.yaml/.yml/.json. (PiTun already parsed WG.confcontent; the picker just hid the files.) - "Name from filename" toggle — when you import a single-config file (e.g. a WireGuard
.conf), the node is named after the file instead of a genericwg-<host>:<port>.
🔧 Under the hood
config_gengainedresolve_active_circle()+ per-circle balancer emission; the active proxy route usesbalancerTagwhen a circle is active.circle_scheduler._seamless_rotaterewritten aroundbalancerOverride(falls back to a full reload only if the API is unavailable or the balancer isn't yet materialized).
⚠️ Notes on chaining
- WireGuard remains an exit-only hop in a chain — it can't carry transit as a relay (re-verified live this cycle: a mid-chain WireGuard forwards 0 bytes). The circle balancer preloads each member together with its stream relay, so a circle of WireGuard-over-VLESS nodes rotates correctly.
- No schema migration. Alembic head unchanged.
📊 Stats
- Backend test suite: 766 passing (incl. new active-node-applies regression + NodeCircle-balancer routing tests).
- Frontend: type-checks + builds clean (Rolldown / Vite 8).
Full Changelog: v1.4.4...v1.4.5
v1.4.4
Multi-hop node chaining that actually wires every hop, a Route Explainer that
accepts pasted URLs, and a frontend toolchain refresh (Vite 5→8 / Rolldown) that
drops the vulnerable esbuild dependency. Plus security bumps for react-router and
build-time transitives.
Added
- Recursive multi-hop node chaining.
config_gennow followschain_node_id
transitively, wiring proxySettings (→sockopt.dialerProxy) at every hop with
cycle detection and a depth cap. A 3-node chain (exit → mid → entry) generates
fully; previously only the first link was wired and deeper relays silently
dialed direct, collapsing the chain.
Changed
- WireGuard can only be a chain's exit hop. xray can't tunnel traffic THROUGH
a WireGuard outbound — as a relay it forwards 0 bytes (config is accepted, xray
starts, traffic dies; verified live: WG-over-VLESS works, VLESS-over-WG and
WG-over-WG = 0 B). Enforced three ways: the nodes API rejects pointing a chain
at a WireGuard node (400), config_gen skips a WG-relay link with a warning, and
the Node form omits WireGuard from the "chain via" dropdown. - Frontend build moved to Vite 8 / Rolldown (
@vitejs/plugin-react4→6),
which removes the bundled esbuild entirely.
Fixed
- Route Explainer accepts a full URL. Pasting
https://host/pathresolved the
whole string as a domain → confusing NXDOMAIN. It now extracts the bare host
(strips scheme / userinfo / path / port, leaves bare IPv6 intact).
Security / dependencies
- Dropping esbuild (via the Vite 8 bump) closes 2 dev-scope esbuild advisories
(Deno integrity GHSA-gv7w-rqvm-qjhr; Windows dev-server file read
GHSA-g7r4-m6w7-qqqr) and the Vite ≤6.4.1 path-traversal (GHSA-4w7w-66w2-5vf9). react-router/react-router-dom7.15.0 → 7.18.0 — CSRF via PUT/PATCH/DELETE
document requests (GHSA-84g9-w2xq-vcv6).- Build-time transitives patched via
npm audit fix:@babel/core(arbitrary
file read),form-data(CRLF injection),js-yaml(DoS).npm audit→ 0.
Notes
- No breaking changes, no schema migration (alembic head stays at
017). - The frontend now builds with Rolldown — output is functionally identical;
verified in a browser (authenticated, 0 console errors). - Backend suite 762 passing; 25 frontend tests passing;
npm auditclean.
Full Changelog: v1.4.3...v1.4.4
v1.4.3
v1.4.3 makes routing rules portable — export any mix of Global and per-set rules, then re-import them into Global, an existing set, or a brand-new one with full duplicate/conflict detection. It also fixes DNS-over-HTTPS resolution against RFC 8484 servers (AdGuard et al.) and makes set deletion do what you'd expect: delete the rules with the set by default.
✨ What's new
🔀 Set-aware rule export / import
Routing export and import now understand routing sets, and round-trip every PiTun rule field.
- Export — tick the scopes you want (Global and/or each set) and download them merged into one file or as separate per-scope files. The output is a native PiTun envelope (
format: "pitun-routing") that preservesmac/geositerules andnode:/balancer:actions — all of which the old V2Ray dump silently dropped. - Import — choose the destination: Global, an existing set, or a new set created on the fly. A server-side preview reports what would be added vs. skipped, and surfaces action conflicts — the same match with a different action, the kind of contradiction that makes xray's first-match-wins evaluation ambiguous — for you to resolve per-rule (keep existing / use imported) before anything is written.
- Resilient by design — identical rules are skipped (no duplicates), and rules that reference a node, balancer, or geo tag absent on this box are dropped (and counted) so the imported set can't break xray.
- Reads the new native format, a legacy V2RayN / Shadowrocket JSON array, or a raw rule array — auto-detected.
🗂️ Set deletion: delete the rules, or keep them
Deleting a routing set used to silently move all its rules to Global. Now the delete dialog leads with "Delete set + rules" (the default) and offers "Move to Global" as the secondary choice. Assigned devices always fall back to Global — a physical device entry is never deleted.
🛠️ Install / UX polish
- The legacy client-side V2Ray export button and the standalone V2Ray import dialog are gone from the Routing toolbar — replaced by the unified, set-aware Export / Import flow above. (The
import-v2rayAPI endpoint remains for any external tooling that calls it.) - The Routing help panel documents the new Export / Import behaviour.
🔧 Under the hood
- Fix — DoH resolver uses RFC 8484 wire format.
_resolve_dohwas issuing the Google/Cloudflare JSON convenience query (?name=&type=A,application/dns-json), which AdGuard's/dns-queryrejects with HTTP 400 — so any DNS rule pointing at AdGuard-over-DoH made the Route Explainer's resolution and reachability stages fail. It now POSTs the binaryapplication/dns-messagequery and parses the wire response; the query builder is shared with the UDP path. - New endpoints
POST /api/routing/import/preview+/import/commit;DELETE /api/routing-sets/{id}gains?cascade=delete. - 3 CodeQL alerts on
routing_sets.pydismissed as false positives (int-typed log arguments; an intentional bind-and-close port-availability probe).
⚠️ Notes on upgrading
- No breaking changes. No schema migration (Alembic head stays at
017). - The removed V2Ray export/import buttons are functionally superseded — the new Import dialog still reads old V2Ray files.
📊 Stats
- Backend test suite: 745 passing (+15 for import/export and set-delete cascade).
- Frontend type-checks and builds clean.
- No new runtime dependencies.
🤖 CI / dependency bumps
aiohttp3.13.5 → 3.14.0,asyncssh2.22.0 → 2.23.0 (#35) — closes 3 Dependabot advisories (neither package is reachable from untrusted input in PiTun, bumped for hygiene)uvicorn0.46.0 → 0.48.0 (#34)pydantic-settings2.14.0 → 2.14.1 (#18)pytest-asyncio1.3.0 → 1.4.0 (#33, dev)actions/checkout6.0.2 → 6.0.3 (#36)docker/setup-buildx-action4.0.0 → 4.1.0 (#32)
Full Changelog: v1.4.2...v1.4.3
v1.4.2
v1.4.2 adds the Route Explainer — a two-layer diagnostic that shows exactly where traffic to any domain or IP goes, which DNS server resolves it, and (optionally) whether it actually connects. It also brings first-class host-DNS controls with a shared in-UI explainer, closes an IPv6 DNS-leak path, and ships two crash/correctness fixes.
✨ What's new
🔍 Route Explainer (Diagnostics)
Type a domain or IP, a port, and a protocol — PiTun tells you the full path the packet would take: the matched DNS rule and resolver, the matched routing rule and its outbound, and optionally whether the destination is actually reachable through that path.
It works in two layers that mirror how the xray config is actually built:
- Offline matcher — a pure-Python pass replays the exact DNS + routing rule ordering
config_genproduces (RFC1918 bypass →port 53guard → per-set rules → global rules → catch-all). For literal matchers (domain:/full:/keyword:/regexp:, CIDR, port) it is 100% accurate with no xray needed. - Live xray probe (ground truth) — for
geosite:/geoip:categories the offline pass cannot resolve (membership lives in the.datfiles), an opt-in checkbox spins a throwaway xray instance that mirrors the real rules + outbounds, sends one probe connection, and reads the chosen outbound straight from the access log. This is the exact decision the running xray would make. - Reachability (optional) — dials the decided path (direct via
SO_MARKbypass, or through the live SOCKS inbound for a proxied verdict) and reports the result.
Per-device context is supported too: pass a device MAC and the explainer evaluates the rules of that device's routing set first, exactly as the dataplane does.
🌐 Host resolver controls + a shared DNS explainer
PiTun has always had three independent DNS resolvers, and the one for the box's own outbound lookups (subscriptions, geo files, x-ui panels, health checks) was previously implicit — it just used whatever the router handed out. If the router's DNS flaked, all of that broke even though LAN clients kept working.
- A new Host resolver (this box only) block on the DNS page sets additive fallback DNS for the box itself, applied through
systemd-resolvedFallbackDNS=, NetworkManageripv4.dns, or aresolv.confappend — idempotent and re-applied on boot. The router stays primary; these are a safety net. - A shared "What is this?" popover now appears on both Settings → Host network and DNS → Host resolver, explaining the difference between the host's primary gateway + DNS and this fallback resolver — and making clear that neither changes what LAN devices resolve (that's the DNS Rules + Primary upstream, handled by xray).
🧱 IPv4-only DNS resolution — closes an IPv6 bypass leak
xray's DNS engine now runs with queryStrategy: UseIPv4. Previously, AAAA answers could hand a client an IPv6 destination that routed around PiTun's IPv4-only TPROXY via the client's router-provided IPv6 default route — a silent bypass of all routing rules. Resolving A-records only keeps destinations on the intercepted IPv4 path. The strategy is configurable on the DNS page (UseIPv4 / UseIP / UseIPv6) for operators who genuinely need IPv6 destinations.
🛠️ Install / UX polish
- Honest DoT labels. xray has no native DNS-over-TLS — at runtime a
tls://server is treated as a plain hostname. The UI no longer impliestls://is encrypted; it's labelled as DNS-over-TCP/53 (plaintext), with DoH called out as the encrypted option. - DNS settings & rules now auto-apply. Creating, editing, deleting, reordering a DNS rule — or changing DNS settings — now regenerates the config and reloads xray automatically, matching the routing endpoints. Previously these needed a manual restart to take effect.
disable_ipv6relabeled "host only" with a tooltip, to make clear it disables IPv6 on the PiTun box, not on LAN clients.- The host-network form no longer flags a router-provided IPv6 RA nameserver red — that entry is managed by RA, not by the IPv4-only apply path, so it's excluded from the editable list instead of blocking Apply.
🔧 Under the hood
- Fix — device scanner crash. A MAC appearing twice in a single ARP sweep (a device answering on two IPs, a duplicate ARP entry, a dual-homed NIC) queued a second
Devicerow with the same MAC, so the whole scan rolled back on aUNIQUE constraint failed: device.macevery ~60 s — and because the device never persisted, it stayed "new" and failed every subsequent scan. Freshly-created rows are now registered in-batch so a repeat MAC updates instead of re-inserting. - Fix — Route Explainer probe merge. When the live xray probe overrode the offline best-guess, only the outbound was updated while the action and matched-rule stayed from the
geositecandidate — so the UI could show the contradictionaction: direct / outbound: node-2. The action is now re-derived from the real outbound and the stale candidate is dropped (the access log exposes only the outbound, not the rule). - New backend modules
core/route_explain.py+core/route_explain_probe.py, endpointPOST /api/diagnostics/explain, and a reusableHostDnsHelpfrontend component.
⚠️ Notes on upgrading
- No breaking changes. No schema migration.
- New settings
dns_query_strategy(defaultUseIPv4) andhost_fallback_dnspopulate on first boot. - The
UseIPv4default meansAAAA/IPv6 destinations are no longer resolved by the box's DNS engine. If you specifically rely on IPv6 destinations, switch the query strategy toUseIPon the DNS page. - The live-xray-probe option in the Route Explainer requires the
xraybinary present in the backend container (it always is in the standard image).
📊 Stats
- 24 files changed, +2498 / −22
- Backend test suite: 727 passing (+6); device-scanner and probe fixes each have a regression test proven to fail without the fix.
- Frontend type-checks and builds clean.
New Contributors
Still no new contributors in this release.🥲
Full Changelog: v1.4.1...v1.4.2
v1.4.1
PiTun v1.4.1 — DNS rules hotfix for Routing Sets
Patch release. Highly recommended for anyone running v1.4.0
with at least one Routing Set + at least one custom DNS Rule.
Without this fix, your DNS Rules (per-domain upstream override,
DoT, DoH) silently do NOT apply to devices that are members of a
routing set — they get bypassed entirely and the LAN client's
chosen upstream (8.8.8.8 / 1.1.1.1 / etc.) is what actually serves
the query.Hot-upgrade safe. No migrations, no breaking config changes. Set-less
installs (everyone on v1.3.x semantics) see byte-for-byte identical
behaviour — only the nft rule ordering grows one trailingaccept
per DNS redirect.
🐛 Headline — DNS Rules silently bypassed for set members
Caught by an operator on live LAN: a DNS Rule routing
*.youtube.com through a custom DoT upstream (94.140.14.14) was
working for every unassigned device, but not for the device
that had been moved into a "test" routing set. The same query from
the same device on a different policy resolved correctly.
Why it happened
The bug lives in the nftables PREROUTING chain ordering, which had
shipped in v1.4.0 as:
1. (bypass private / bypass MAC — returns)
2. ip protocol udp udp dport 53 tproxy ip to 127.0.0.1:5353 ← no accept
3. ip protocol tcp tcp dport 53 tproxy ip to 127.0.0.1:5353 ← no accept
4. ether saddr @rset_X_mac ip protocol udp tproxy ... :65500 accept ← per-set
5. ether saddr @rset_X_mac ip protocol tcp tproxy ... :65500 accept ← per-set
6. ip protocol tcp tproxy ... :7893 ← default
7. ip protocol udp tproxy ... :7894 ← default
tproxy is not a chain-terminating verb. For a DNS packet from
a device whose MAC is in @rset_X_mac:
- Rules 2/3 matched → packet was marked for delivery on
127.0.0.1:5353,
then kept flowing through the chain. - Rules 4/5 matched → packet was re-marked for
127.0.0.1:65500,
the trailingacceptterminated evaluation. - Last writer won. The packet landed on the per-set
tproxy-set-X
xray inbound — not ondns-in.
xray's DNS routing rule is gated on
inboundTag: ["dns-in", "dns-in-53"]. A DNS packet that arrives on a
per-set inbound bypasses the DNS engine entirely → no upstream
override, no per-domain DoT/DoH, no FakeDNS. The query gets proxied
through whatever outbound xray picks for the destination as opaque
UDP, and the LAN client effectively talks to its originally requested
DNS server (8.8.8.8 / 1.1.1.1 / whatever the device was configured
with) through the VPN.
The fix
Both DNS redirect rules now end with a trailing accept, terminating
the chain before per-set redirects can grab the packet:
2. ip protocol udp udp dport 53 tproxy ip to 127.0.0.1:5353 accept ← terminates here
3. ip protocol tcp tcp dport 53 tproxy ip to 127.0.0.1:5353 accept ← terminates here
4. ether saddr @rset_X_mac ip protocol udp tproxy ... :65500 accept
…
This is exactly the same bug class as the v1.4.0 trailing-accept
fix on per-set rules themselves (cross-rule pair: per-set ↔ default
TPROXY), just on a different rule pair (DNS ↔ per-set). DNS rules
are global by design in v1.4 — set members share the same DNS
engine pass — so terminating at DNS for everyone is the intended
shape.
What works after the fix
- DNS query from set-member device → matches DNS redirect → lands on
dns-inxray inbound → DNS engine applies your DNS Rules →
per-domain upstream is honoured (DoT, DoH, plain — all back to
working). - Non-DNS traffic from set-member device → falls through DNS rules
(port ≠ 53) → matches per-set redirect → lands ontproxy-set-X
inbound → per-set routing rules apply as before. - Unassigned devices: behaviour identical to v1.4.0 (and v1.3.x).
The extraaccepton DNS is a strict-stop for a path that
already only feddns-in.
⚠️ Upgrade notes
- Hot-upgrade safe. No migrations, no schema change, no API
change. The only on-disk delta isbackend/app/core/nftables.py
(twoaccepttokens) and one new regression test.install.sh
needs no special flags. - No frontend change. UI bundle is regenerated only because of
the version bump — visual diff is zero. You can skip the
frontend swap if you want and only update backend. - Effective immediately on backend restart.
_apply_nftables()
rebuilds the table from scratch on/system/start; restarting
the backend triggers that path. No manualnftediting needed. - No data loss risk. RoutingSet rows, Device assignments, DNS
Rules, RoutingRules — all untouched.
📊 Stats
- 2 files changed (
backend/app/core/nftables.py+ 1 new test),
+44 / -3 lines. - New regression:
test_dns_redirect_accept_terminates_chainpins
the trailingaccepton both DNS redirects AND the ordering
invariant (DNS rule before per-set rule). - 103 backend tests on the routing-sets / config-gen / nftables
surfaces green; full suite remains green.
Full Changelog: v1.4.0 → v1.4.1
v1.4.0
PiTun v1.4.0 — Routing Sets (per-device-group rules)
The headline feature of this release: Routing Sets. Until now
every device on your LAN shared one global rule list. Now you can
define named rule groups — a "Kids" set that blocks gambling and
forces a parental-control node, a "Work" laptop set that routes
corporate domains direct — and assign devices to them individually
or in bulk. Per-set rules apply first; traffic then falls through to
your global rules. Fully DHCP-resistant.Minor release, additive only. One Alembic migration (
017) that
install.shapplies automatically. Zero behaviour change for
existing installs until you create your first set — every existing
rule and device stays "global / unassigned".
🆕 Headline — Routing Sets
A RoutingSet is a named list of routing rules that applies only to
a chosen group of devices, layered on top of your global rules.
What you can do
- Create named sets on the Routing page (sub-tab bar →
+ New set). - Add rules while a set's tab is active — they apply only to that
set's devices. - Assign devices on the Devices page: per-row Set dropdown, or
multi-select + Move to set bulk action. - Filter rules and devices by set; jump from a rule's set badge straight
to that set's tab. - Global rules (the
Globaltab, or any rule with no set) still apply
to every device — per-set rules match first, then fall through.
How it works under the hood
Per-set isolation lives at the nftables + xray-inbound layer, not in
application logic:
- Each set gets a dedicated loopback TPROXY port (range
65500..65535, one port serves both TCP and UDP via xray's
dokodemo-doorwithnetwork: "tcp,udp"). - nftables matches the device MAC in
PREROUTINGand TPROXYs its
traffic into the set's port — before the default TPROXY rule, so
first-match-wins routes set members into their own inbound. - xray's router matches
inboundTagto decide which rules apply.
Because membership is keyed on the MAC address, not the IP, it is
DHCP-resistant — a device's lease can change without breaking its
set assignment or needing any reconfiguration. This is why we chose
inbound-tag routing over a sourceIP approach (xray's router has no
fwmark matcher, and sourceIP rules silently stop matching after a
DHCP renew).
Design choices for V1
- One device → one set. A device's traffic is evaluated against its
set's rules plus globals. Many-to-many is deferred. - Per-set node is available today via a
node:<id>action inside
the set's rules (force "Kids" through node #3, etc.) — no separate
first-class field needed. - 36-set limit (one per port in the reserved range). The
+ New setbutton disables at the limit; a capacity counter
(5/36 used) sits in the Routing header. The backend returns a clean
409 if the limit is somehow bypassed. - Deleting a set moves its devices and rules back to Global (their
routing_set_idbecomesnull) — reversible, with an explicit
confirm. - Excluded devices (Devices → policy "exclude") bypass TPROXY
entirely, so their set assignment is moot — the Set dropdown is
greyed out for them.
🔧 Implementation notes
- New table
routingset+ nullablerouting_set_idFK on both
routingruleanddevice. NULL everywhere = "global rule /
unassigned device" = the pre-1.4 behaviour, preserved with no
backfill. - New API under
/api/routing-sets/: CRUD,/capacity,
/devices/bulk(bulk-assign with atomic rollback on any bad id).
Existing/routing/rulesand/devicesgained arouting_set_id
filter param. - config_gen.py emits one extra xray inbound per non-empty set and
tags that set's rules withinboundTag. Empty sets are skipped
entirely — both inbound and rules — so a rule in a set with no
devices is a no-op until you assign one. - nftables.py renders one MAC set + one TCP/UDP TPROXY redirect per
active set, ahead of the default redirect. - All five config-regeneration call-sites (start, settings change,
circle rotation, failover, mode-change pre-flight) plus the xray
watchdog/boot path all share a single
collect_routing_set_context()helper so the per-set state stays
consistent across every reload path — including crash recovery and
reboot.
🔬 Data-plane hardening (caught in live smoke)
Per-set routing crosses three layers (nftables PREROUTING, xray
inbound, xray router) that have to stay perfectly aligned. Unit tests
and control-plane verification went green, but live LAN traffic on a
real device caught four silent data-plane bugs, all now fixed and
pinned by regression tests:
-
Per-set TPROXY redirect must
accept-terminate the nft chain.
Without the trailingaccept, the default TPROXY rule later in the
same chain re-wrote the redirect target and the packet landed on
the default inbound — set rules never fired.nft monitor trace
showed each packet getting verdictcontinuefrom both tproxy
verbs, last writer winning. Pinned by
test_single_set_renders_mac_set_and_tproxy_rule. -
Per-set inbound must
listen: "0.0.0.0", not127.0.0.1.
TPROXY does not rewrite destination — packets arrive with their
original dst (e.g. a real Google IP). A loopback-bound socket with
IP_TRANSPARENTsilently drops those packets; only a wildcard
listener accepts them. Pinned by
test_set_with_devices_gets_inbound_wildcard_bound. -
Device-set membership change must reload BOTH xray AND nftables,
not just xray. Single-device PATCH (drop-down on Devices page),
bulk-assign, and set deletion now all run through one
_auto_reload_dataplane()helper that re-applies the nft table
alongside regenerating the xray config. Pinned by four tests in
TestDataplaneReload. -
xray crash / RPi reboot must restore per-set nft, not just xray.
The watchdog/boot path previously rebuilt the xray config (with the
per-set inbound) but reapplied nftables without per-set MAC sets →
set members silently fell through to the default inbound until a
manual/system/start. Fixed by routing the watchdog through the
samecollect_routing_set_context()+RoutingSetSpecplumbing.
Take-away (recorded in the design doc): any TPROXY-adjacent change
needs validation with real LAN traffic AND nft monitor trace —
counters and unit tests alone can't see whether a packet actually
arrived at the listener it was redirected to.
⚠️ Upgrade notes
- Hot-upgrade safe. One additive migration (
017), no breaking
config changes.install.shrunsalembic upgrade headon its own. - Nothing changes until you opt in. With zero routing sets, the
generated xray config and nftables ruleset are byte-for-byte the
v1.3.9 shape. The Devices "Set" column and the Routing sub-tabs only
appear once at least one set exists. - Reserved ports. Sets allocate ports
65500..65535on
0.0.0.0(required for TPROXY — see data-plane section above).
Despite the wildcard bind, only TPROXY-marked packets ever reach
the listener — a direct LAN connect toRPi:<port>has nothing to
deliver and falls through to the standard accept path. The
allocator runs a bind-probe on each candidate so it skips any port
already in use by another host service. - exclude + set is rejected explicitly. A device with
routing_policy: excludebypasses TPROXY entirely, so a set
assignment on it would never apply. The API now returns 400 on
the conflicting PATCH, and bulk-policy → exclude auto-clears the
set assignment. UI greys out the dropdown for excluded rows.
📊 Stats
- One Alembic migration (
017), backward-compatible (NULL = global). - ~60 new backend tests across routing-sets CRUD + capacity +
bulk-assign + port allocation, config_gen per-set inbound/rule
emission, nftables per-set rendering withaccept-pin,
data-plane reload (xray + nft synchronization), and
exclude-policy conflict guards. - Frontend 25/25 green,
tsc && vite buildclean. Index chunk
~165 KB gzipped (+~4 KB for the new sets UI). - New KnowledgeBase section (EN + RU) documenting the feature.
Full Changelog: v1.3.9 → v1.4.0
Full Changelog: v1.3.9...v1.4.0