Skip to content

Releases: DaveBugg/PiTun

v1.4.9

Choose a tag to compare

@github-actions github-actions released this 02 Aug 17:31

Full Changelog: v1.4.8...v1.4.9

v1.4.8

Choose a tag to compare

@github-actions github-actions released this 31 Jul 08:33

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, or unreachable.
  • Downgrades are called out before they happen. Installing anything older than 1.4.8 removes 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 400 explaining a rejected inbound_mode, or a 503 asking 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_id dangling. 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/mode only 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/start and /system/restart applied 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 random after 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...
Read more

v1.4.7

Choose a tag to compare

@github-actions github-actions released this 29 Jul 13:04

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, Upgrade and Expect are 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-Encoding on a panel that mishandles gzip and returns a truncated body.
  • Merging is case-insensitive, so a template setting accept-encoding replaces the default Accept-Encoding instead 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 key re-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 409 naming the affected subscriptions, with an explicit "delete anyway" confirmation rather than a silent cascade.
  • Subscription cards now show +Nh when 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. useEscapeKey listens on document, so opening the templates dialog over the subscription form meant one keypress closed both and discarded the half-filled form. ModalShell gained a closeOnEscape prop for nested dialogs.

🔧 Under the hood

  • New Alembic migration 018useragenttemplate table plus its seed rows. Head moves 017018.
  • New app/core/ua_templates.py owns header assembly, the validation ruleset and the seed data; app/api/user_agents.py is the CRUD + export/import router at /api/user-agents. subscriptions.py now calls build_subscription_headers() instead of carrying its own UA map.
  • Header-value validation closes an injection path. httpx forwards a CR/LF inside 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 opaque last_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 018 inlines its seed rows instead of importing them from application code. A migration is a historical snapshot, and the backend bind-mounts app/ and alembic/ 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.ts normalises both FastAPI detail shapes (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 018 seeds the presets under the same slugs already stored in subscription.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 the custom_ua paths — so existing subscriptions keep their exact fingerprint.
  • First boot runs the migration automatically via entrypoint.sh. Deploy backend/app and backend/alembic together, 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.ua is 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); tsc clean, vite build green.
  • New migration 018. No new runtime dependencies.

Full Changelog: v1.4.6...v1.4.7

v1.4.6

Choose a tag to compare

@github-actions github-actions released this 03 Jul 08:41

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_id and server_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 node
  • chain 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
    outside speedtest_node, so the added return value is contained).

Full Changelog: v1.4.5...v1.4.6

v1.4.5

Choose a tag to compare

@github-actions github-actions released this 18 Jun 19:15

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 .conf content; 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 generic wg-<host>:<port>.

🔧 Under the hood

  • config_gen gained resolve_active_circle() + per-circle balancer emission; the active proxy route uses balancerTag when a circle is active.
  • circle_scheduler._seamless_rotate rewritten around balancerOverride (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

Choose a tag to compare

@github-actions github-actions released this 18 Jun 08:56

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_gen now follows chain_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-react 4→6),
    which removes the bundled esbuild entirely.

Fixed

  • Route Explainer accepts a full URL. Pasting https://host/path resolved 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-dom 7.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 audit clean.

Full Changelog: v1.4.3...v1.4.4

v1.4.3

Choose a tag to compare

@github-actions github-actions released this 14 Jun 21:28

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 preserves mac / geosite rules and node: / 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-v2ray API 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_doh was issuing the Google/Cloudflare JSON convenience query (?name=&type=A, application/dns-json), which AdGuard's /dns-query rejects 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 binary application/dns-message query 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.py dismissed 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

  • aiohttp 3.13.5 → 3.14.0, asyncssh 2.22.0 → 2.23.0 (#35) — closes 3 Dependabot advisories (neither package is reachable from untrusted input in PiTun, bumped for hygiene)
  • uvicorn 0.46.0 → 0.48.0 (#34)
  • pydantic-settings 2.14.0 → 2.14.1 (#18)
  • pytest-asyncio 1.3.0 → 1.4.0 (#33, dev)
  • actions/checkout 6.0.2 → 6.0.3 (#36)
  • docker/setup-buildx-action 4.0.0 → 4.1.0 (#32)

Full Changelog: v1.4.2...v1.4.3

v1.4.2

Choose a tag to compare

@github-actions github-actions released this 12 Jun 16:22

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_gen produces (RFC1918 bypass → port 53 guard → 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 .dat files), 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_MARK bypass, 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-resolved FallbackDNS=, NetworkManager ipv4.dns, or a resolv.conf append — 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 implies tls:// 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_ipv6 relabeled "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 Device row with the same MAC, so the whole scan rolled back on a UNIQUE constraint failed: device.mac every ~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 geosite candidate — so the UI could show the contradiction action: 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, endpoint POST /api/diagnostics/explain, and a reusable HostDnsHelp frontend component.

⚠️ Notes on upgrading

  • No breaking changes. No schema migration.
  • New settings dns_query_strategy (default UseIPv4) and host_fallback_dns populate on first boot.
  • The UseIPv4 default means AAAA/IPv6 destinations are no longer resolved by the box's DNS engine. If you specifically rely on IPv6 destinations, switch the query strategy to UseIP on the DNS page.
  • The live-xray-probe option in the Route Explainer requires the xray binary 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

Choose a tag to compare

@DaveBugg DaveBugg released this 03 Jun 05:49

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 trailing accept
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:

  1. Rules 2/3 matched → packet was marked for delivery on 127.0.0.1:5353,
    then kept flowing through the chain.
  2. Rules 4/5 matched → packet was re-marked for 127.0.0.1:65500,
    the trailing accept terminated evaluation.
  3. Last writer won. The packet landed on the per-set tproxy-set-X
    xray inbound — not on dns-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-in xray 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 on tproxy-set-X
    inbound → per-set routing rules apply as before.
  • Unassigned devices: behaviour identical to v1.4.0 (and v1.3.x).
    The extra accept on DNS is a strict-stop for a path that
    already only fed dns-in.

⚠️ Upgrade notes

  • Hot-upgrade safe. No migrations, no schema change, no API
    change. The only on-disk delta is backend/app/core/nftables.py
    (two accept tokens) 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 manual nft editing 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_chain pins
    the trailing accept on 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

Choose a tag to compare

@DaveBugg DaveBugg released this 31 May 20:00

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.sh applies 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 Global tab, 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:

  1. Each set gets a dedicated loopback TPROXY port (range
    65500..65535, one port serves both TCP and UDP via xray's
    dokodemo-door with network: "tcp,udp").
  2. nftables matches the device MAC in PREROUTING and TPROXYs its
    traffic into the set's port — before the default TPROXY rule, so
    first-match-wins routes set members into their own inbound.
  3. xray's router matches inboundTag to 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 set button 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_id becomes null) — 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 + nullable routing_set_id FK on both
    routingrule and device. 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/rules and /devices gained a routing_set_id
    filter param.
  • config_gen.py emits one extra xray inbound per non-empty set and
    tags that set's rules with inboundTag. 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 trailing accept, 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 verdict continue from 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", not 127.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_TRANSPARENT silently 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
    same collect_routing_set_context() + RoutingSetSpec plumbing.

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.sh runs alembic upgrade head on 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..65535 on
    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 to RPi:<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: exclude bypasses 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 with accept-pin,
    data-plane reload (xray + nft synchronization), and
    exclude-policy conflict guards.
  • Frontend 25/25 green, tsc && vite build clean. 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