Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Sources/VPNBypassCore/MenuBarViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1184,9 +1184,15 @@ enum RoutedBySource {
Set(counted(routes, vpnOnly: vpnOnly).map(\.destination)).count
}

/// How many of VPN Only's catch-alls are installed: the routes `addressCount` leaves out.
/// In another mode `addressCount` counts them as addresses, so this is 0.
static func catchAllCount(_ routes: [InstalledRoute], vpnOnly: Bool) -> Int {
vpnOnly ? Set(routes.filter(isCatchAll).map(\.destination)).count : 0
}

/// VPN Only's catch-alls are installed: everything not listed goes direct.
static func everythingElseDirect(_ routes: [InstalledRoute], vpnOnly: Bool) -> Bool {
vpnOnly && routes.contains(where: isCatchAll)
catchAllCount(routes, vpnOnly: vpnOnly) > 0
}

static func title(_ mode: DropdownCopy.Mode) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@
"Automatic (recommended)" = "Automatic (recommended)";
"No VPN tunnels are up." = "No VPN tunnels are up.";
"Pinned tunnel %@ is not eligible right now — acting on %@ automatically." = "Pinned tunnel %1$@ is not eligible right now — acting on %2$@ automatically.";
"%lld, plus 1 catch-all" = "%lld, plus 1 catch-all";
"%lld, plus %lld catch-alls" = "%1$lld, plus %2$lld catch-alls";
"Addresses owned (kernel-tagged)" = "Addresses owned (kernel-tagged)";
"Reading network state…" = "Reading network state…";
"Recent warnings" = "Recent warnings";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@
"Automatic (recommended)" = "Automático (recomendado)";
"No VPN tunnels are up." = "No hay ningún túnel VPN activo.";
"Pinned tunnel %@ is not eligible right now — acting on %@ automatically." = "El túnel fijado %1$@ no se puede usar ahora; se actúa sobre %2$@ automáticamente.";
"%lld, plus 1 catch-all" = "%lld, más 1 ruta general";
"%lld, plus %lld catch-alls" = "%1$lld, más %2$lld rutas generales";
"Addresses owned (kernel-tagged)" = "Direcciones propias (marcadas en el kernel)";
"Reading network state…" = "Leyendo el estado de la red…";
"Recent warnings" = "Avisos recientes";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@
"Automatic (recommended)" = "Automatique (recommandé)";
"No VPN tunnels are up." = "Aucun tunnel VPN n’est actif.";
"Pinned tunnel %@ is not eligible right now — acting on %@ automatically." = "Le tunnel épinglé %1$@ n’est pas utilisable pour l’instant ; l’app agit sur %2$@ automatiquement.";
"%lld, plus 1 catch-all" = "%lld, plus 1 route générale";
"%lld, plus %lld catch-alls" = "%1$lld, plus %2$lld routes générales";
"Addresses owned (kernel-tagged)" = "Adresses possédées (marquées dans le noyau)";
"Reading network state…" = "Lecture de l’état du réseau…";
"Recent warnings" = "Alertes récentes";
Expand Down
10 changes: 5 additions & 5 deletions Sources/VPNBypassCore/RouteManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1432,21 +1432,21 @@ final class RouteManager: ObservableObject {
let links: [VPNLink]
let selectedInterface: String?
let defaultRouteInterface: String?
/// Kernel routes carrying OUR ownership tag (RTF_PROTO1) — ground truth of what this
/// app currently owns, read silently from the table itself.
let taggedRouteCount: Int
/// Destinations of the kernel routes carrying OUR ownership tag (RTF_PROTO1) — ground
/// truth of what this app currently owns, read silently from the table itself.
let taggedDestinations: [String]
}

/// On-demand only — never call from a timer. `listVPNLinks` spawns `ifconfig` and
/// `tailscale status`, which the hot paths deliberately avoid (see the needsLinks guard).
func coexistenceSnapshot() async -> CoexistenceSnapshot {
let links = await listVPNLinks()
let defaultIface = await currentDefaultRouteInterface()
let tagged = RouteKernel.currentTable()?.filter { $0.isOurs }.count ?? 0
let tagged = RouteKernel.currentTable()?.filter { $0.isOurs }.map(\.destinationString) ?? []
return CoexistenceSnapshot(links: links,
selectedInterface: vpnInterface,
defaultRouteInterface: defaultIface,
taggedRouteCount: tagged)
taggedDestinations: tagged)
}

func listVPNLinks() async -> [VPNLink] {
Expand Down
23 changes: 22 additions & 1 deletion Sources/VPNBypassCore/StatusTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,25 @@ enum StatusPage {
return role + " " + String(localized: "It carries the default route.", bundle: bundle)
}

/// The Addresses owned row: the kernel's tagged routes, counted by the rule every other
/// count uses (`RoutedBySource`). In VPN Only the catch-alls are named apart, so while the
/// kernel holds what the app recorded, the first number is the Routed row's. A tagged
/// destination the app has no record of counts as an address.
static func ownedLine(tagged: [String], installed: [RoutedBySource.InstalledRoute], vpnOnly: Bool,
bundle: Bundle = .main) -> Line {
let inKernel = Set(tagged)
let recorded = installed.filter { inKernel.contains($0.destination) }
let unrecorded = inKernel.subtracting(recorded.map(\.destination))
.map { RoutedBySource.InstalledRoute(destination: $0, source: "") }
let routes = recorded + unrecorded
let addresses = RoutedBySource.addressCount(routes, vpnOnly: vpnOnly)
switch RoutedBySource.catchAllCount(routes, vpnOnly: vpnOnly) {
case 0: return Line(text: "\(addresses)")
case 1: return Line(text: String(localized: "\(addresses), plus 1 catch-all", bundle: bundle))
case let n: return Line(text: String(localized: "\(addresses), plus \(n) catch-alls", bundle: bundle))
}
}

// MARK: Recent warnings

/// The newest warnings and errors, newest first, as the log keeps them.
Expand Down Expand Up @@ -531,7 +550,9 @@ struct StatusTab: View {
}
StatusDivider()
StatusLineRow(label: String(localized: "Addresses owned (kernel-tagged)"),
line: StatusPage.Line(text: "\(snapshot.taggedRouteCount)"))
line: StatusPage.ownedLine(tagged: snapshot.taggedDestinations,
installed: routeManager.installedRoutes,
vpnOnly: routeManager.config.routingMode == .vpnOnly))
} else {
StatusPlainRow(text: String(localized: "Reading network state…"))
}
Expand Down
4 changes: 4 additions & 0 deletions Tests/VPNBypassTests/RoutedAddressCountTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ final class RoutedAddressCountTests: XCTestCase {
XCTAssertEqual(RoutedBySource.addressCount(routes, vpnOnly: true), 2)
XCTAssertTrue(RoutedBySource.everythingElseDirect(routes, vpnOnly: true))
XCTAssertEqual(RoutedBySource.addressCount(catchAllRoutes, vpnOnly: true), 0)
XCTAssertEqual(RoutedBySource.catchAllCount(routes, vpnOnly: true), 4)
XCTAssertEqual(RoutedBySource.catchAllCount([route("140.82.112.4", "github.com")], vpnOnly: true), 0)
}

/// Bypass has no catch-alls; left installed after a switch they are routes like any other,
Expand All @@ -36,6 +38,7 @@ final class RoutedAddressCountTests: XCTestCase {
XCTAssertEqual(RoutedBySource.addressCount(routes, vpnOnly: false), 2)
XCTAssertEqual(RoutedBySource.addressCount(routes + catchAllRoutes, vpnOnly: false), 6)
XCTAssertFalse(RoutedBySource.everythingElseDirect(routes + catchAllRoutes, vpnOnly: false))
XCTAssertEqual(RoutedBySource.catchAllCount(routes + catchAllRoutes, vpnOnly: false), 0)
}

func testCustomCountsWhatItsRulesInstalled() {
Expand All @@ -47,6 +50,7 @@ final class RoutedAddressCountTests: XCTestCase {
/// same range on the user's own list is one of the user's destinations, and counts.
func testTheSameRangeOnTheUsersListCounts() {
XCTAssertEqual(RoutedBySource.addressCount([route("0.0.0.0/2", "0.0.0.0/2")], vpnOnly: true), 1)
XCTAssertEqual(RoutedBySource.catchAllCount([route("0.0.0.0/2", "0.0.0.0/2")], vpnOnly: true), 0)
}

/// The card's own count is this function, in every mode.
Expand Down
79 changes: 78 additions & 1 deletion Tests/VPNBypassTests/StatusPageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ final class StatusPageTests: XCTestCase {
private let other = RouteManager.VPNLink(interface: "utun7", addresses: ["10.8.0.2"], label: "OpenVPN", isTailscale: false)

private func snapshot(default iface: String?, selected: String? = "utun4") -> RouteManager.CoexistenceSnapshot {
.init(links: [wireguard, tailscale, other], selectedInterface: selected, defaultRouteInterface: iface, taggedRouteCount: 62)
.init(links: [wireguard, tailscale, other], selectedInterface: selected, defaultRouteInterface: iface,
taggedDestinations: (1...62).map { "198.51.100.\($0)" })
}

func testDefaultRouteLine() {
Expand All @@ -160,6 +161,80 @@ final class StatusPageTests: XCTestCase {
XCTAssertEqual(StatusPage.defaultRouteLine(snapshot(default: "en0")).text, "en0, outside the VPN")
}

// MARK: Addresses owned

private func owned(_ destination: String, _ source: String) -> RoutedBySource.InstalledRoute {
.init(destination: destination, source: source)
}

private var catchAlls: [RoutedBySource.InstalledRoute] {
["0.0.0.0/2", "64.0.0.0/2", "128.0.0.0/2", "192.0.0.0/2"].map { owned($0, ClassicRouteCompiler.catchAllSource) }
}

/// The row as the page builds it when the kernel holds exactly what the app recorded.
private func ownedText(_ installed: [RoutedBySource.InstalledRoute], vpnOnly: Bool, bundle: Bundle = .main) -> String {
StatusPage.ownedLine(tagged: installed.map(\.destination), installed: installed, vpnOnly: vpnOnly, bundle: bundle).text
}

/// The problem this row had: in VPN Only it read 6 while Routed above read 2, because it
/// counted the 4 catch-alls as addresses.
func testVPNOnlyNamesTheCatchAllsApartFromTheAddresses() {
let installed = [owned("10.20.0.0/16", "10.20.0.0/16"), owned("140.82.112.4", "github.com")] + catchAlls
XCTAssertEqual(ownedText(installed, vpnOnly: true), "2, plus 4 catch-alls")
XCTAssertEqual(ownedText([owned("140.82.112.4", "github.com"), catchAlls[0]], vpnOnly: true), "1, plus 1 catch-all")
}

/// With nothing on the VPN Only list the catch-alls are all the app owns.
func testVPNOnlyWithNoEntriesOwnsOnlyTheCatchAlls() {
XCTAssertEqual(ownedText(catchAlls, vpnOnly: true), "0, plus 4 catch-alls")
XCTAssertEqual(ownedText([], vpnOnly: true), "0")
}

/// Bypass has no catch-alls: the row is the bare number. The catch-all ranges left from a
/// switch out of VPN Only are routes like any other, as the card and Routed count them.
func testBypassIsTheBareNumber() {
let installed = [owned("91.108.4.0/22", "Telegram"), owned("91.108.4.0/22", "telegram.org"),
owned("142.250.1.1", "YouTube")]
XCTAssertEqual(ownedText(installed, vpnOnly: false), "2")
XCTAssertEqual(ownedText(installed + catchAlls, vpnOnly: false), "6")
}

func testCustomIsTheBareNumber() {
let installed = [owned("10.9.0.0/16", "10.9.0.0/16"), owned("1.2.3.4", "b.example")]
XCTAssertEqual(ownedText(installed, vpnOnly: false), "2")
}

/// The first number is the Routed row's, read through the same function, in every mode.
func testTheFirstNumberIsTheRoutedCount() {
let installed = [owned("10.20.0.0/16", "10.20.0.0/16"), owned("140.82.112.4", "github.com"),
owned("140.82.112.4", "api.github.com")] + catchAlls
for vpnOnly in [true, false] {
let routed = RoutedBySource.addressCount(installed, vpnOnly: vpnOnly)
XCTAssertTrue(ownedText(installed, vpnOnly: vpnOnly).hasPrefix("\(routed)"), "vpnOnly: \(vpnOnly)")
}
}

/// The row still reads the kernel: a tagged route the app has no record of is an address,
/// and a recorded route the kernel no longer holds is not counted. A user's own entry for
/// a catch-all range is an address, as the card counts it.
func testTheRowCountsWhatTheKernelHolds() {
let installed = [owned("140.82.112.4", "github.com"), owned("140.82.112.5", "github.com"),
owned("0.0.0.0/2", "0.0.0.0/2")] + catchAlls.dropFirst()
// Two unrecorded addresses against one missing, and a recorded catch-all the kernel lost,
// so a row that read the app's records instead of the kernel would say "3, plus 3".
let tagged = ["140.82.112.4", "203.0.113.9", "203.0.113.10", "0.0.0.0/2", "128.0.0.0/2", "192.0.0.0/2"]
XCTAssertEqual(StatusPage.ownedLine(tagged: tagged, installed: installed, vpnOnly: true).text, "4, plus 2 catch-alls")
}

func testTheCatchAllsAreNamedInSpanishAndFrench() throws {
let es = try lproj("es"), fr = try lproj("fr")
let installed = [owned("140.82.112.4", "github.com"), owned("140.82.112.5", "github.com")] + catchAlls
XCTAssertEqual(ownedText(installed, vpnOnly: true, bundle: es), "2, más 4 rutas generales")
XCTAssertEqual(ownedText(installed, vpnOnly: true, bundle: fr), "2, plus 4 routes générales")
XCTAssertEqual(ownedText([catchAlls[0]], vpnOnly: true, bundle: es), "0, más 1 ruta general")
XCTAssertEqual(ownedText([catchAlls[0]], vpnOnly: true, bundle: fr), "0, plus 1 route générale")
}

// MARK: Routes

func testAppliedLine() {
Expand Down Expand Up @@ -392,6 +467,8 @@ final class StatusPageTests: XCTestCase {
StatusPage.tunnelLine(wireguard, snapshot: s, bundle: bundle),
StatusPage.tunnelLine(tailscale, snapshot: s, bundle: bundle),
StatusPage.tunnelLine(other, snapshot: s, bundle: bundle),
ownedText([catchAlls[0]], vpnOnly: true, bundle: bundle),
ownedText(catchAlls, vpnOnly: true, bundle: bundle),
]
for key in ["Status", "Helper", "Privileged helper", "Connection", "Normal connection", "Default route",
"Addresses", "Routed", "From", "Last check", "Verify", "Resolver", "Refreshed", "Refresh", "Act on",
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **An edit to a list the current mode does not route leaves the kernel alone.** The settings window shows only the list the current mode routes, but the socket can edit either one. A Bypass entry or a service changed while VPN Only or Custom is active, or a VPN Only entry removed in Bypass mode, no longer touches the kernel, and removing a Bypass entry there keeps a pending DNS retry of the same name, which belongs to a Custom rule. Without this, a script could install a bypass route that VPN Only or Custom does not want, or delete a route that belongs to an entry of the same name on the other list.

### Fixed
- **Settings > Status's Addresses owned row names VPN Only's catch-alls apart.** With two entries on the VPN Only list, Routed read 2 and Addresses owned, under Tunnels, read 6, because the row counted the 4 catch-all routes the app installs to send everything else direct as addresses. The row now reads "2, plus 4 catch-alls", and "0, plus 4 catch-alls" when the list is empty. It still counts the routes in the kernel that carry the app's tag, by the rule the routes card and Routed use, so the first number matches Routed while the kernel holds what the app installed. Bypass and Custom show one number, as before. The new text is in English, Spanish and French.
- **A built-in service that an update adds with the name of one of your custom services no longer shares its routes.** The app tracks a service's routes by its name. If a new version added a built-in service, or renamed one, to a name a custom service already had, ignoring case and spaces, both routed under that name once you turned the built-in one on, and removing one removed the other's routes. On launch the app now turns the built-in service off while the custom one has its name. Its switch, Turn All On and `vpnb service.enable` leave it off, and `vpnb` returns `already_exists`. Your custom service keeps its name, its switch and its routes. Its row on the Services page shows the editor's red line asking for another name, and the built-in row says which custom service to rename. Once you rename it, the built-in service turns on as usual. In VPN Only and Custom modes, deleting a custom service no longer takes routes of the same name out of the kernel: those belong to a VPN Only entry, the VPN Only catch-all or a rule, since services route only in Bypass. The new line is in English, Spanish and French.
- **Re-rendering the UI proposal images crops the old screenshots again.** 22 of the mockups in `docs/design/proposals/ui/` build their "before" half by cropping the screenshots in `docs/images/screenshots/` at fixed offsets. Those files now show the 5.0 app, so running `render.sh` again would have cropped the wrong picture at the old offsets. The mockups now crop their own copies of the screenshots from `ddd1cb0`, in `docs/design/proposals/ui/before/`. The committed images are unchanged.
- **The Rules page's badges and route chips, the proxy test result and the route check's failure reason are in Spanish and French.** These texts went through a plain `String`, which SwiftUI's `Text` shows as typed, so a Mac set to Spanish or French saw them in English: the match badges ("DOMAIN", "SUFFIX", "SERVICE", "PROCESS"), the route chip of a rule with no route ("Choose Route") or sent direct ("Direct"), the General page's SOCKS5 proxy test result ("Connection timeout", "Connected to …") and the reason a route check failed ("Ping timed out", "Host unreachable"). A VPN the app does not recognise showed as "Unknown VPN" in the rule chips, on the Routes page, in the dropdown, in Settings > Status's tunnel list and in both VPN pickers; it now shows "VPN". The Routes page's type badge and status line ("Direct", "primary VPN", "direct") are looked up too. [`scripts/check-localizations.py`](https://github.com/GeiserX/VPN-Bypass/blob/main/scripts/check-localizations.py) now also fails when the English, Spanish or French file has a key that no source file uses. A translated string changed back to a plain literal leaves its key unused when no other source uses that key, so the check catches it. A key used in several places, such as "Direct" or "VPN", stays in use, so the labels above that share one have a test in `LocalizationCoverageTests` instead. The 43 keys it found unused, left from screens 5.0 removed, are gone from all three files.
Expand Down
Loading
Loading