diff --git a/Sources/VPNBypassCore/MenuBarViews.swift b/Sources/VPNBypassCore/MenuBarViews.swift index 81db1ac..6b3f98c 100644 --- a/Sources/VPNBypassCore/MenuBarViews.swift +++ b/Sources/VPNBypassCore/MenuBarViews.swift @@ -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 { diff --git a/Sources/VPNBypassCore/Resources/en.lproj/Localizable.strings b/Sources/VPNBypassCore/Resources/en.lproj/Localizable.strings index d747eba..bb93a13 100644 --- a/Sources/VPNBypassCore/Resources/en.lproj/Localizable.strings +++ b/Sources/VPNBypassCore/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/Sources/VPNBypassCore/Resources/es.lproj/Localizable.strings b/Sources/VPNBypassCore/Resources/es.lproj/Localizable.strings index 9380946..6a12613 100644 --- a/Sources/VPNBypassCore/Resources/es.lproj/Localizable.strings +++ b/Sources/VPNBypassCore/Resources/es.lproj/Localizable.strings @@ -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"; diff --git a/Sources/VPNBypassCore/Resources/fr.lproj/Localizable.strings b/Sources/VPNBypassCore/Resources/fr.lproj/Localizable.strings index ede40df..cd1c6a3 100644 --- a/Sources/VPNBypassCore/Resources/fr.lproj/Localizable.strings +++ b/Sources/VPNBypassCore/Resources/fr.lproj/Localizable.strings @@ -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"; diff --git a/Sources/VPNBypassCore/RouteManager.swift b/Sources/VPNBypassCore/RouteManager.swift index c901093..43126ae 100644 --- a/Sources/VPNBypassCore/RouteManager.swift +++ b/Sources/VPNBypassCore/RouteManager.swift @@ -1432,9 +1432,9 @@ 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 @@ -1442,11 +1442,11 @@ final class RouteManager: ObservableObject { 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] { diff --git a/Sources/VPNBypassCore/StatusTab.swift b/Sources/VPNBypassCore/StatusTab.swift index ca4ae8f..22e5244 100644 --- a/Sources/VPNBypassCore/StatusTab.swift +++ b/Sources/VPNBypassCore/StatusTab.swift @@ -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. @@ -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…")) } diff --git a/Tests/VPNBypassTests/RoutedAddressCountTests.swift b/Tests/VPNBypassTests/RoutedAddressCountTests.swift index d1e6d15..b31a7e8 100644 --- a/Tests/VPNBypassTests/RoutedAddressCountTests.swift +++ b/Tests/VPNBypassTests/RoutedAddressCountTests.swift @@ -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, @@ -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() { @@ -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. diff --git a/Tests/VPNBypassTests/StatusPageTests.swift b/Tests/VPNBypassTests/StatusPageTests.swift index 360bc8f..40e96a7 100644 --- a/Tests/VPNBypassTests/StatusPageTests.swift +++ b/Tests/VPNBypassTests/StatusPageTests.swift @@ -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() { @@ -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() { @@ -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", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 22065bb..1003a21 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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. diff --git a/docs/usage.md b/docs/usage.md index 0662e86..c3222ca 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -11,7 +11,7 @@ Click the VPN Bypass mark in the menu bar: two lines and a bar, with an arrow he - on a fresh install in Bypass mode, in place of everything down to the gear: the question "What should skip the VPN?", six common services with their switches, a row that opens Settings on the Services page, a field to add a site, and a line that offers VPN Only. Once a service is on or a site is on the list, the normal dropdown below takes its place at once; - the Mode control, with Bypass, VPN Only and Custom in every mode. Picking another mode asks first, and the selection moves only after you confirm. Entering Custom turns your lists into rules here too, when Custom has no rules yet and the current mode's list has an entry switched on, and the question says so only then, as the Settings sheet does; - a field to add a domain to the current mode's list; -- what your lists route, one row per service, domain or IP range you added, with how many addresses it routes, under Skipping the VPN in Bypass or Through the VPN in VPN Only. Hover over a row to see its addresses. A row that routes no address while no apply is running or waiting after a reconnect gets an amber mark and "no addresses". In VPN Only the app's own catch-alls are one line, "Everything else: direct", and the counts leave them out: the card, the header's Addresses fact, the line under the buttons, the Status page's Addresses section and the Addresses Routed notification all count only the addresses your lists route. The one exception is Addresses owned under Tunnels on the Status page, a kernel tally of every route that carries the app's tag, catch-alls included. With only the catch-alls in place, the pill still reads ON, since everything else is routed direct. In Custom mode, Routes In Use comes first, then Routed by your rules lists the rules that routed an address. Addresses that no entry on your lists owns, such as those of an entry you just removed, are one "Left from earlier" line; +- what your lists route, one row per service, domain or IP range you added, with how many addresses it routes, under Skipping the VPN in Bypass or Through the VPN in VPN Only. Hover over a row to see its addresses. A row that routes no address while no apply is running or waiting after a reconnect gets an amber mark and "no addresses". In VPN Only the app's own catch-alls are one line, "Everything else: direct", and the counts leave them out: the card, the header's Addresses fact, the line under the buttons, the Status page's Addresses section and the Addresses Routed notification all count only the addresses your lists route. Addresses owned under Tunnels on the Status page counts the kernel's routes that carry the app's tag by the same rule and names the catch-alls apart, as in "2, plus 4 catch-alls". With only the catch-alls in place, the pill still reads ON, since everything else is routed direct. In Custom mode, Routes In Use comes first, then Routed by your rules lists the rules that routed an address. Addresses that no entry on your lists owns, such as those of an entry you just removed, are one "Left from earlier" line; - Refresh Routes, a Verify Routes icon, and a "…" menu with Refresh Routes, Verify Routes, Re-resolve DNS Now, Open Logs, Settings…, Quit VPN Bypass and Remove All Routes…, which asks before it removes anything; - after Verify Routes, a Route check card above the buttons. Verify pings at most 10 of the routed addresses: single addresses only, because ping cannot test a range, and the first 10 in sort order. The card says how many of how many addresses it checked, for example "Checked 10 of 62 addresses (single addresses only).", lists the addresses that did not answer first, up to three with what each is routed for and why it failed and then "+ N more", then one line for the rest with their response times. "Show all 10 results in Logs" opens Settings on the Logs page, where each address has its own line. With only address ranges routed, the card says there was nothing ping could check; - one line under the buttons with the result of the last apply, for example "62 addresses routed 23 s ago, none failed"; @@ -54,7 +54,7 @@ Status: whether the app is working right now, on one page. A line at the top giv - Connection: the VPN and its interface, the normal connection (Wi-Fi network and gateway), and which interface carries the default route. - Addresses: what the last apply routed ("62 addresses, 23 s ago, none failed"), what they come from ("4 services, 2 domains"), and the last Verify Routes ("10 of 62 checked, all reachable, 4 min ago"), with a Verify button. While a check runs, whether it started from this button, from the dropdown or after an apply, the row reads "Checking now…" with a spinner beside a greyed-out Verify. - DNS: the resolver used outside the VPN, when DNS was last refreshed and when it is next due, with Refresh Now. -- Tunnels: every tunnel that is up, each with one sentence ("The app acts on this one. It carries the default route.", "Never touched." for Tailscale), the Act on menu that pins one tunnel, and Addresses owned, how many kernel entries carry the app's tag. The tunnels are read when the page opens, when the VPN changes, and on Refresh. +- Tunnels: every tunnel that is up, each with one sentence ("The app acts on this one. It carries the default route.", "Never touched." for Tailscale), the Act on menu that pins one tunnel, and Addresses owned, how many kernel entries carry the app's tag. In VPN Only the catch-alls are counted apart ("2, plus 4 catch-alls"), so the first number matches Routed while the kernel holds what the app installed; in Bypass and Custom it is one number. The tunnels are read when the page opens, when the VPN changes, and on Refresh. - Recent warnings: the three newest warnings and errors. Show in Log opens Logs with Warnings selected. General: settings only. Launch at login, auto-apply on connect, `/etc/hosts` management, route verification, the DNS refresh schedule, fallback DNS, notification preferences, the SOCKS5 proxy, and import/export.