feat: split-tunneling domain rules (bypass / only)#76
Merged
Conversation
Adds bypass/only domain lists so the user can keep selected sites off the active exit node. Rules only affect the exit-node catch-all branch — Tailscale-mandatory traffic (CGNAT, MagicDNS, subnets) still proxies so the extension keeps functioning. Stored in chrome.storage.local under domainSplitConfig, threaded through TailscaleState so both the Chrome PAC script and the Firefox onRequest listener pick up changes automatically. UI is an inline expandable section under the Exit Node row in the connected view. Closes #74
The single cast `as ReturnType<typeof vi.fn>` on the LHS plus a casted RHS confused stricter @types/chrome installs; matched the convention already used elsewhere in this file (uiSurface wiring describe).
The mode-button handler was sending the previously-saved domain list along with the new mode, so any unsaved textarea contents were lost once the next state broadcast reset the textarea (focus had moved to the button, so the not-focused sync branch fired). Both the mode buttons and the Save button now go through the same commit() helper that reads ta.value at the moment of the click. Added an e2e regression case: type new domains, click a mode button without clicking Save, assert the PAC reflects the typed domains and the previously-saved list is gone.
Previously, Only mode with no domains entered fell through to the generic exit-node catch-all and proxied every host — the opposite of the intended "only listed domains use the exit node" semantics. The state was reachable by clicking Only before adding rules or by clearing the textarea. Both proxy managers now treat Only-with-empty-list as "nothing should leave through the exit node", emitting DIRECT for the catch-all. Bypass with an empty list is unchanged (no rules to apply == route normally). Tailscale-mandatory traffic (CGNAT, MagicDNS, subnets) still proxies in both cases. Added unit coverage for both managers and an e2e case that clears the textarea, saves in Only mode, and asserts the PAC catch-all is DIRECT.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a per-domain split-tunnel inside the extension: when an exit node is selected, the user can list domains that should either bypass the exit node (go DIRECT from the local network) or be the only domains routed through it.
UI is an inline expandable section under the existing Exit Node row in the connected view — header toggle, mode selector (Bypass / Only), one-domain-per-line textarea, Save.
Why
Closes #74.
Some sites (Microsoft Teams is the example in the issue, but Google, banks, and corp SSO portals do the same) flag traffic originating from VPN exit IPs as suspicious and either block the session or warn the account. Right now once you pick an exit node (e.g. a Mullvad one) all browser traffic goes through it — there's no escape hatch short of toggling the exit node off, which defeats the point.
How
DomainSplitConfig({ mode, domains[] }) persisted underchrome.storage.local["domainSplitConfig"]and threaded throughTailscaleState.ChromeProxyManagerregenerates the PAC script when the config changes; the bypass/only check is injected after the Tailscale-mandatory branches (100.100.100.100, CGNAT, MagicDNS, subnet routes) and before the exit-node catch-all. Mandatory Tailscale traffic always proxies regardless of the rules, so the extension never breaks itself.FirefoxProxyManager.resolveProxyapplies the same logic per request and persists the config tobrowser.storage.sessionso it survives an SW restart.sanitizeDomain(PAC-safe allowlist) is applied at storage write, at PAC generation, and inline in the UI on Save — anything containing characters outside[a-z0-9.-]is dropped with an inline warning. Domain matching is suffix-based, mirroringdnsDomainIs(enteringmicrosoft.commatches bothmicrosoft.comand*.microsoft.com).Test
microsoft.comin Bypass mode, save, pick a Mullvad exit node. Visithttps://teams.microsoft.com— should not show as VPN-originating; visithttps://example.com— should still come from the Mullvad IP.work.example.comsaved.https://work.example.comgoes through the exit, everything else goes DIRECT, but MagicDNS / Tailscale device IPs still resolve and connect."foo bar",evil"); alert("xss); save. The entry is rejected with an inline warning and never reaches the PAC.Checklist
pnpm test(297 unit tests pass — 15 new for sanitize/storage, 10 new for PAC + Firefox routing, 4 new for background startup / onChanged / set-domain-split)pnpm typecheckpnpm build:chromeandpnpm build:firefoxpnpm e2e:chrome --suite=fullandpnpm e2e:firefox --suite=full(newsplit-tunnelingscenario passes; only the pre-existingexit-nodesflake fails — it expects asuggest-exit-noderequest that no source currently dispatches, unrelated to this change)Marking draft while I finish the manual smoke pass. Happy to defer to your branch if you'd rather take it from there, I figured it'd be useful to have something to compare against.