Skip to content

feat: split-tunneling domain rules (bypass / only)#76

Merged
dantraynor merged 4 commits into
mainfrom
issue-74
May 21, 2026
Merged

feat: split-tunneling domain rules (bypass / only)#76
dantraynor merged 4 commits into
mainfrom
issue-74

Conversation

@dantraynor

@dantraynor dantraynor commented May 20, 2026

Copy link
Copy Markdown
Owner

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

  • New DomainSplitConfig ({ mode, domains[] }) persisted under chrome.storage.local["domainSplitConfig"] and threaded through TailscaleState.
  • ChromeProxyManager regenerates 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.resolveProxy applies the same logic per request and persists the config to browser.storage.session so 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, mirroring dnsDomainIs (entering microsoft.com matches both microsoft.com and *.microsoft.com).
  • Rules are inert when no exit node is selected (the catch-all is DIRECT anyway).

Test

  • Open Split tunneling, add microsoft.com in Bypass mode, save, pick a Mullvad exit node. Visit https://teams.microsoft.com — should not show as VPN-originating; visit https://example.com — should still come from the Mullvad IP.
  • Switch the mode to Only with work.example.com saved. https://work.example.com goes through the exit, everything else goes DIRECT, but MagicDNS / Tailscale device IPs still resolve and connect.
  • Reload the extension; saved rules persist.
  • Paste a junk entry ("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 typecheck
  • pnpm build:chrome and pnpm build:firefox
  • pnpm e2e:chrome --suite=full and pnpm e2e:firefox --suite=full (new split-tunneling scenario passes; only the pre-existing exit-nodes flake fails — it expects a suggest-exit-node request that no source currently dispatches, unrelated to this change)
  • Tested in Chrome (manual smoke pending)
  • Tested in Firefox (manual smoke pending)
  • Update README with feature inclusion

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.

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).
@dantraynor dantraynor added enhancement New feature or request help wanted Extra attention is needed labels May 20, 2026
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.
@dantraynor dantraynor marked this pull request as ready for review May 21, 2026 02:42
@dantraynor dantraynor merged commit db4d3e7 into main May 21, 2026
5 checks passed
@dantraynor dantraynor deleted the issue-74 branch May 21, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request help wanted Extra attention is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

White/black domain list for spliting

1 participant