Skip to content

Commit c1ce9ef

Browse files
committed
fix: remove wildcard domain feature (macOS routing is IP-based)
macOS routing tables and /etc/hosts do not support domain wildcards. The *.example.com feature only resolved the base domain's IPs, not actual subdomains — misleading users into thinking subdomain routing worked. Removed all wildcard code; kept isWildcard field for Codable backward compatibility.
1 parent 36ab79e commit c1ce9ef

8 files changed

Lines changed: 34 additions & 131 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ After CI completes: `brew update && brew upgrade --cask vpn-bypass` to install l
9292
- **CI handles releases end-to-end** — pushing a `v*` tag triggers `.github/workflows/release.yml` which builds the DMG, creates the GitHub release, AND updates the Homebrew cask. Do NOT manually create releases or update the cask — CI will overwrite them. Just commit, tag, push.
9393
- **Test the stale-helper upgrade path after release** — especially with VPN already connected and an older helper still installed. Expected flow: helper preflight on startup, admin prompt if needed, helper update, route apply, and DNS refresh timer start automatically.
9494
- **Some VPNs route via interface link, not IP gateway** — Cisco Secure Client sets the default route to `link#N` (an interface reference) instead of an IP address. `route -n get default` shows `interface: utunX` with no `gateway:` line. VPN Only mode handles this via `iface:<interface>` convention: the helper uses `route add -host <dest> -interface utunX` instead of an IP gateway. See #26.
95+
- **Wildcard domains (`*.example.com`) are impossible at the macOS routing level.** macOS routing tables are IP-based — you can only route specific IPs or CIDR ranges, not domain patterns. `/etc/hosts` also does not support wildcards. Any wildcard implementation can only resolve the base domain's IPs, not actual subdomains with different IPs. Don't reintroduce this feature.
9596
- **Helper launchd plist MUST have `RunAtLoad: true`** — without it, the daemon relies on on-demand XPC activation, which macOS blocks when the Login Items toggle is disabled. Homebrew cask upgrades re-sign the app, causing macOS to reset the toggle, which re-breaks the helper on every boot. `RunAtLoad: true` makes the daemon start unconditionally — no Login Items dependency. NEVER set `RunAtLoad` back to `false`. See #25.
9697

9798
## Self-Improving Configuration

Casks/vpn-bypass.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Or if using local tap: brew install --cask --no-quarantine ./Casks/vpn-bypass.rb
44

55
cask "vpn-bypass" do
6-
version "2.6.0"
6+
version "2.6.1"
77
sha256 "8da2ba2e2073f8dbcd9d413658e995d244b52adc94559aecc31690448a9acf42"
88

99
url "https://github.com/GeiserX/VPN-Bypass/releases/download/v#{version}/VPN-Bypass-#{version}.dmg"

Info.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<key>CFBundlePackageType</key>
2424
<string>APPL</string>
2525
<key>CFBundleShortVersionString</key>
26-
<string>2.6.0</string><!-- VERSION -->
26+
<string>2.6.1</string><!-- VERSION -->
2727
<key>CFBundleVersion</key>
2828
<string>22</string>
2929
<key>LSMinimumSystemVersion</key>

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ Click the shield icon in the menu bar to:
111111
Click the gear icon to access settings:
112112

113113
**Domains Tab**
114-
- Add custom domains to bypass (supports wildcards: `*.example.com` matches all subdomains)
114+
- Add custom domains to bypass
115115
- Enable/disable individual domains
116116
- See resolved IPs
117117

Sources/RouteManager.swift

Lines changed: 20 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -340,10 +340,6 @@ final class RouteManager: ObservableObject {
340340
self.isWildcard = isWildcard
341341
}
342342

343-
var resolvableDomain: String {
344-
isWildcard ? String(domain.dropFirst()) : domain
345-
}
346-
347343
init(from decoder: Decoder) throws {
348344
let container = try decoder.container(keyedBy: CodingKeys.self)
349345
id = try container.decode(UUID.self, forKey: .id)
@@ -1242,14 +1238,14 @@ final class RouteManager: ObservableObject {
12421238
if domain.isCIDR {
12431239
inverseCIDRs.append(domain.domain)
12441240
} else {
1245-
let resolvable = domain.resolvableDomain
1241+
let resolvable = domain.domain
12461242
allDomains.append((resolvable, domain.domain))
12471243
}
12481244
}
12491245
} else {
12501246
// Bypass mode: resolve bypass domains + service domains
12511247
for domain in config.domains where domain.enabled {
1252-
let resolvable = domain.resolvableDomain
1248+
let resolvable = domain.domain
12531249
allDomains.append((resolvable, domain.domain))
12541250
}
12551251
for service in config.services where service.enabled {
@@ -1605,7 +1601,7 @@ final class RouteManager: ObservableObject {
16051601
routesToAdd.append((destination: cidr, gateway: routeGateway, isNetwork: true, source: cidr))
16061602
}
16071603
} else {
1608-
let cacheKey = domain.resolvableDomain
1604+
let cacheKey = domain.domain
16091605
if let cachedIPs = dnsDiskCache[cacheKey] {
16101606
for ip in cachedIPs {
16111607
let key = "\(domain.domain)|\(ip)"
@@ -1659,7 +1655,7 @@ final class RouteManager: ObservableObject {
16591655
}
16601656

16611657
for domain in config.domains where domain.enabled {
1662-
let cacheKey = domain.resolvableDomain
1658+
let cacheKey = domain.domain
16631659
if let cachedIPs = dnsDiskCache[cacheKey] {
16641660
for ip in cachedIPs {
16651661
let key = "\(domain.domain)|\(ip)"
@@ -1790,7 +1786,7 @@ final class RouteManager: ObservableObject {
17901786
if isInverse {
17911787
for domain in config.inverseDomains where domain.enabled {
17921788
if !domain.isCIDR {
1793-
let resolvable = domain.resolvableDomain
1789+
let resolvable = domain.domain
17941790
domainsToResolve.append((resolvable, domain.domain))
17951791
}
17961792
}
@@ -1801,7 +1797,7 @@ final class RouteManager: ObservableObject {
18011797
}
18021798
}
18031799
for domain in config.domains where domain.enabled {
1804-
let resolvable = domain.resolvableDomain
1800+
let resolvable = domain.domain
18051801
domainsToResolve.append((resolvable, domain.domain))
18061802
}
18071803
}
@@ -2102,13 +2098,13 @@ final class RouteManager: ObservableObject {
21022098
// CIDR entries: preserve as static routes, no DNS resolution
21032099
expectedEntries.insert(SourceDest(source: domain.domain, destination: domain.domain))
21042100
} else {
2105-
let resolvable = domain.resolvableDomain
2101+
let resolvable = domain.domain
21062102
domainsToResolve.append((resolvable, domain.domain))
21072103
}
21082104
}
21092105
} else {
21102106
for domain in config.domains where domain.enabled {
2111-
let resolvable = domain.resolvableDomain
2107+
let resolvable = domain.domain
21122108
domainsToResolve.append((resolvable, domain.domain))
21132109
}
21142110
for service in config.services where service.enabled {
@@ -2358,25 +2354,17 @@ final class RouteManager: ObservableObject {
23582354

23592355
func addDomain(_ domain: String) {
23602356
let trimmed = domain.trimmingCharacters(in: .whitespacesAndNewlines)
2361-
let isWildcard = trimmed.hasPrefix("*.")
2362-
let cleaned: String
2363-
if isWildcard {
2364-
let suffix = cleanDomain(String(trimmed.dropFirst(2)))
2365-
guard !suffix.isEmpty else { return }
2366-
cleaned = "." + suffix
2367-
} else {
2368-
cleaned = cleanDomain(trimmed)
2369-
}
2357+
let cleaned = cleanDomain(trimmed)
23702358
guard !cleaned.isEmpty else { return }
23712359
guard !config.domains.contains(where: { $0.domain == cleaned }) else {
23722360
log(.warning, "Domain \(cleaned) already exists")
23732361
return
23742362
}
23752363

2376-
let entry = DomainEntry(domain: cleaned, isWildcard: isWildcard)
2364+
let entry = DomainEntry(domain: cleaned)
23772365
config.domains.append(entry)
23782366
saveConfig()
2379-
log(.success, "Added \(isWildcard ? "wildcard " : "")domain: \(cleaned)")
2367+
log(.success, "Added domain: \(cleaned)")
23802368

23812369
if isVPNConnected && acquireRouteOperation() {
23822370
Task {
@@ -2386,7 +2374,7 @@ final class RouteManager: ObservableObject {
23862374
log(.error, "Cannot route \(cleaned): no local gateway detected. Try Refresh Routes.")
23872375
return
23882376
}
2389-
if let routes = await applyRoutesForDomain(entry.resolvableDomain, gateway: gateway, source: cleaned) {
2377+
if let routes = await applyRoutesForDomain(entry.domain, gateway: gateway, source: cleaned) {
23902378
guard routeEpoch == epoch else { return }
23912379
activeRoutes.append(contentsOf: routes)
23922380
if config.manageHostsFile {
@@ -2434,7 +2422,7 @@ final class RouteManager: ObservableObject {
24342422
let epoch = routeEpoch
24352423

24362424
log(.info, "Retrying DNS for \(domain)...")
2437-
if let routes = await applyRoutesForDomain(entry.resolvableDomain, gateway: gateway, source: domain) {
2425+
if let routes = await applyRoutesForDomain(entry.domain, gateway: gateway, source: domain) {
24382426
guard routeEpoch == epoch else { return }
24392427
activeRoutes.append(contentsOf: routes)
24402428
if config.manageHostsFile {
@@ -2490,7 +2478,7 @@ final class RouteManager: ObservableObject {
24902478
log(.error, "Cannot route \(domain.domain): no local gateway detected")
24912479
return
24922480
}
2493-
let resolvable = domain.resolvableDomain
2481+
let resolvable = domain.domain
24942482
if let routes = await applyRoutesForDomain(resolvable, gateway: gateway, source: domain.domain) {
24952483
guard routeEpoch == epoch else { return }
24962484
activeRoutes.append(contentsOf: routes)
@@ -2531,7 +2519,7 @@ final class RouteManager: ObservableObject {
25312519
for domain in domainsToChange {
25322520
guard routeEpoch == epoch else { return }
25332521
if enabled, let gw = gateway {
2534-
let resolvable = domain.resolvableDomain
2522+
let resolvable = domain.domain
25352523
if let routes = await applyRoutesForDomain(resolvable, gateway: gw, source: domain.domain, persistCache: false) {
25362524
guard routeEpoch == epoch else { return }
25372525
activeRoutes.append(contentsOf: routes)
@@ -2597,17 +2585,12 @@ final class RouteManager: ObservableObject {
25972585

25982586
// Detect CIDR input (e.g., "192.168.1.0/24") — bypass domain cleaning
25992587
let cidr = isValidCIDR(trimmed)
2600-
let isWildcard = trimmed.hasPrefix("*.")
26012588
let cleaned: String
26022589
if cidr {
26032590
cleaned = trimmed
26042591
} else if trimmed.contains("/") {
26052592
log(.warning, "Invalid CIDR notation: \(trimmed)")
26062593
return
2607-
} else if isWildcard {
2608-
let suffix = cleanDomain(String(trimmed.dropFirst(2)))
2609-
guard !suffix.isEmpty else { return }
2610-
cleaned = "." + suffix
26112594
} else {
26122595
cleaned = cleanDomain(trimmed)
26132596
guard !cleaned.isEmpty else { return }
@@ -2617,10 +2600,10 @@ final class RouteManager: ObservableObject {
26172600
log(.warning, "VPN Only entry \(cleaned) already exists")
26182601
return
26192602
}
2620-
let inverseEntry = DomainEntry(domain: cleaned, isCIDR: cidr, isWildcard: isWildcard)
2603+
let inverseEntry = DomainEntry(domain: cleaned, isCIDR: cidr)
26212604
config.inverseDomains.append(inverseEntry)
26222605
saveConfig()
2623-
log(.success, "Added VPN Only \(cidr ? "CIDR" : isWildcard ? "wildcard " : "")domain: \(cleaned)")
2606+
log(.success, "Added VPN Only \(cidr ? "CIDR" : "")domain: \(cleaned)")
26242607

26252608
if isVPNConnected && config.routingMode == .vpnOnly && acquireRouteOperation() {
26262609
Task {
@@ -2641,7 +2624,7 @@ final class RouteManager: ObservableObject {
26412624
))
26422625
log(.success, "Routed CIDR \(cleaned) through VPN")
26432626
}
2644-
} else if let routes = await applyRoutesForDomain(inverseEntry.resolvableDomain, gateway: gw, source: cleaned) {
2627+
} else if let routes = await applyRoutesForDomain(inverseEntry.domain, gateway: gw, source: cleaned) {
26452628
guard routeEpoch == epoch else { return }
26462629
activeRoutes.append(contentsOf: routes)
26472630
if config.manageHostsFile { await updateHostsFile() }
@@ -2693,7 +2676,7 @@ final class RouteManager: ObservableObject {
26932676
))
26942677
}
26952678
} else {
2696-
let resolvable = domain.resolvableDomain
2679+
let resolvable = domain.domain
26972680
if let routes = await applyRoutesForDomain(resolvable, gateway: gw, source: domain.domain) {
26982681
guard routeEpoch == epoch else { return }
26992682
activeRoutes.append(contentsOf: routes)
@@ -3552,7 +3535,7 @@ final class RouteManager: ObservableObject {
35523535
guard !domain.isCIDR else { continue }
35533536
// Include enabled domains AND disabled domains that still have active kernel routes
35543537
guard domain.enabled || activeRoutes.contains(where: { $0.source == domain.domain }) else { continue }
3555-
let lookupDomain = domain.resolvableDomain
3538+
let lookupDomain = domain.domain
35563539
if let ip = firstRoutedIP(for: lookupDomain, in: routedDestinations) {
35573540
entries.append((lookupDomain, ip))
35583541
}

Sources/SettingsView.swift

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ struct DomainsTab: View {
199199
.font(.system(size: 12))
200200
.foregroundColor(Theme.textSecondary)
201201

202-
TextField(isInverse ? "e.g., example.com, *.example.com, or 10.0.0.0/24" : "e.g., example.com or *.example.com", text: $newDomain)
202+
TextField(isInverse ? "e.g., example.com or 10.0.0.0/24" : "e.g., example.com", text: $newDomain)
203203
.textFieldStyle(.plain)
204204
.font(.system(size: 13))
205205
.focused($isInputFocused)
@@ -386,15 +386,6 @@ struct DomainRow: View {
386386
.background(Theme.warning.opacity(0.15))
387387
.cornerRadius(4)
388388
}
389-
if domain.isWildcard {
390-
Text("Wildcard")
391-
.font(.system(size: 9, weight: .bold))
392-
.foregroundColor(Theme.cyan)
393-
.padding(.horizontal, 5)
394-
.padding(.vertical, 1)
395-
.background(Theme.cyan.opacity(0.15))
396-
.cornerRadius(4)
397-
}
398389
}
399390
}
400391

Tests/VPNBypassTests/VPNBypassTests.swift

Lines changed: 0 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -312,10 +312,6 @@ final class DomainEntryCodableTests: XCTestCase {
312312
var isCIDR: Bool
313313
var isWildcard: Bool
314314

315-
var resolvableDomain: String {
316-
isWildcard ? String(domain.dropFirst()) : domain
317-
}
318-
319315
init(domain: String, enabled: Bool = true, isCIDR: Bool = false, isWildcard: Bool = false) {
320316
self.id = UUID()
321317
self.domain = domain
@@ -452,79 +448,6 @@ final class DomainEntryCodableTests: XCTestCase {
452448
XCTAssertEqual(entry.domain, "10.0.0.0/8")
453449
}
454450

455-
// MARK: - Wildcard support
456-
457-
func testDomainEntryInitDefaultsWildcardToFalse() {
458-
let entry = DomainEntry(domain: "google.com")
459-
XCTAssertEqual(entry.isWildcard, false)
460-
}
461-
462-
func testDomainEntryInitExplicitWildcard() {
463-
let entry = DomainEntry(domain: ".google.com", isWildcard: true)
464-
XCTAssertEqual(entry.isWildcard, true)
465-
XCTAssertEqual(entry.domain, ".google.com")
466-
}
467-
468-
func testDecodeOldJSONWithoutWildcardFieldDefaultsToFalse() throws {
469-
let id = UUID()
470-
let json: [String: Any] = [
471-
"id": id.uuidString,
472-
"domain": "telegram.org",
473-
"enabled": true
474-
]
475-
let data = try JSONSerialization.data(withJSONObject: json)
476-
let entry = try JSONDecoder().decode(DomainEntry.self, from: data)
477-
XCTAssertEqual(entry.isWildcard, false)
478-
}
479-
480-
func testDecodeJSONWithWildcardTrue() throws {
481-
let id = UUID()
482-
let json: [String: Any] = [
483-
"id": id.uuidString,
484-
"domain": ".google.com",
485-
"enabled": true,
486-
"isWildcard": true
487-
]
488-
let data = try JSONSerialization.data(withJSONObject: json)
489-
let entry = try JSONDecoder().decode(DomainEntry.self, from: data)
490-
XCTAssertEqual(entry.domain, ".google.com")
491-
XCTAssertEqual(entry.isWildcard, true)
492-
}
493-
494-
func testRoundTripWildcardEntry() throws {
495-
let original = DomainEntry(domain: ".example.com", isWildcard: true)
496-
let data = try JSONEncoder().encode(original)
497-
let decoded = try JSONDecoder().decode(DomainEntry.self, from: data)
498-
XCTAssertEqual(decoded.domain, original.domain)
499-
XCTAssertEqual(decoded.isWildcard, original.isWildcard)
500-
XCTAssertEqual(decoded.id, original.id)
501-
}
502-
503-
func testWildcardResolvableDomainStripsLeadingDot() {
504-
let entry = DomainEntry(domain: ".google.com", isWildcard: true)
505-
XCTAssertEqual(entry.resolvableDomain, "google.com")
506-
}
507-
508-
func testNonWildcardResolvableDomainUnchanged() {
509-
let entry = DomainEntry(domain: "google.com")
510-
XCTAssertEqual(entry.resolvableDomain, "google.com")
511-
}
512-
513-
func testResolvableDomainConsistentAsCacheKey() {
514-
let wildcard = DomainEntry(domain: ".example.com", isWildcard: true)
515-
let plain = DomainEntry(domain: "example.com")
516-
XCTAssertEqual(wildcard.resolvableDomain, plain.resolvableDomain)
517-
}
518-
519-
func testResolvableDomainWithNestedWildcard() {
520-
let entry = DomainEntry(domain: ".sub.example.com", isWildcard: true)
521-
XCTAssertEqual(entry.resolvableDomain, "sub.example.com")
522-
}
523-
524-
func testCIDREntryResolvableDomainUnchanged() {
525-
let entry = DomainEntry(domain: "10.0.0.0/8", isCIDR: true)
526-
XCTAssertEqual(entry.resolvableDomain, "10.0.0.0/8")
527-
}
528451
}
529452

530453
// MARK: - AddInverseDomain Logic Tests

0 commit comments

Comments
 (0)