Skip to content

Commit 0b1ece8

Browse files
democlaude
andcommitted
fix: v0.2.3 — unconditional pre-connect route flush
The pre-connect cleanup previously only deleted the cached host route to the VPN gateway when it looked obviously stale (nexthop differed from the current default gateway). That skipped the failure mode a colleague hit: after the gateway's IP rotated during a VPN upgrade, the cached route pointed at the new IP via the current default gateway (so it "looked correct"), yet traffic still failed with "HTTPS connection failed" 260ms after openconnect spawn. Since this cleanup runs before start() — when no active tunnel exists — deleting is always safe: a healthy route is rebuilt by the kernel on the next packet via the default gateway. Always flushing closes the gap. Each connect now logs the flush outcome so "stuck" connections are diagnosable from ~/Library/Logs/VPNMenuBar/vpnmenubar.log without command-line tools. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c64005d commit 0b1ece8

7 files changed

Lines changed: 40 additions & 33 deletions

File tree

VPNMenuBar-0.2.3.zip

1.69 MB
Binary file not shown.

VPNMenuBar.app/Contents/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@
2323
<key>CFBundlePackageType</key>
2424
<string>APPL</string>
2525
<key>CFBundleShortVersionString</key>
26-
<string>0.2.2</string>
26+
<string>0.2.3</string>
2727
<key>CFBundleSupportedPlatforms</key>
2828
<array>
2929
<string>MacOSX</string>
3030
</array>
3131
<key>CFBundleVersion</key>
32-
<string>4</string>
32+
<string>5</string>
3333
<key>DTCompiler</key>
3434
<string>com.apple.compilers.llvm.clang.1_0</string>
3535
<key>DTPlatformBuild</key>
-144 Bytes
Binary file not shown.

VPNMenuBar/Core/OpenConnectProcess.swift

Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -210,54 +210,41 @@ final class OpenConnectProcess: OpenConnectProcessRunning {
210210
}
211211
}
212212

213-
// MARK: - stale host-route cleanup
214-
215-
/// Inspect the kernel routing table for a host route to the VPN gateway
216-
/// whose nexthop is no longer reachable (typical after switching WiFi
217-
/// while openconnect was killed without running vpnc-script's disconnect
218-
/// phase) and delete it. Best-effort and silent: any failure here just
219-
/// lets openconnect proceed and surface its native error.
213+
// MARK: - pre-connect host-route cleanup
214+
215+
/// Unconditionally delete any cached host route to the VPN gateway before
216+
/// spawning openconnect. Called at the top of `start()` — at that point no
217+
/// active tunnel exists, so deleting is always safe: if the route was
218+
/// correct the kernel rebuilds it on the next packet via the default
219+
/// gateway; if it was stale (left over from a different network after a
220+
/// SIGKILL/WiFi-switch without disconnect-phase), this is what unblocks
221+
/// the connect. Best-effort — any failure is logged and openconnect
222+
/// proceeds to surface its native error.
220223
private func cleanupStaleHostRoute(forGateway gateway: String) {
221224
let host = Self.extractHost(from: gateway)
222225
guard !host.isEmpty else { return }
223226

224-
// Current default route's nexthop — what a freshly-added host route
225-
// *should* point at on this network.
226-
let defaultRoute = (try? processRunner.run(
227-
executable: "/sbin/route",
228-
arguments: ["-n", "get", "default"],
229-
timeoutSeconds: 2
230-
)) ?? ProcessResult(exitCode: -1, stdout: "", stderr: "")
231-
guard defaultRoute.succeeded,
232-
let defaultGateway = Self.parseRouteField("gateway", from: defaultRoute.stdout) else {
233-
return
234-
}
235-
236-
// Existing route to the VPN host (does its own DNS resolution).
227+
// Route lookup (does its own DNS resolution).
237228
let hostRoute = (try? processRunner.run(
238229
executable: "/sbin/route",
239230
arguments: ["-n", "get", host],
240231
timeoutSeconds: 2
241232
)) ?? ProcessResult(exitCode: -1, stdout: "", stderr: "")
242233
guard hostRoute.succeeded,
243-
let routeGateway = Self.parseRouteField("gateway", from: hostRoute.stdout),
244234
let destinationIP = Self.parseRouteField("destination", from: hostRoute.stdout) else {
245235
return
246236
}
237+
let currentGateway = Self.parseRouteField("gateway", from: hostRoute.stdout) ?? "?"
247238

248-
// If the route falls through to the current default gateway, it's
249-
// already correct — touching it would break a healthy connection.
250-
guard routeGateway != defaultGateway else { return }
251-
252-
AppLogger.shared.warn("stale host route to \(destinationIP) via \(routeGateway) detected (default gw \(defaultGateway)) — deleting")
239+
AppLogger.shared.info("pre-connect: flushing cached route to \(destinationIP) (was via \(currentGateway))")
253240

254241
let result = (try? processRunner.run(
255242
executable: "/usr/bin/sudo",
256243
arguments: ["-n", "/sbin/route", "-n", "delete", destinationIP],
257244
timeoutSeconds: 3
258245
)) ?? ProcessResult(exitCode: -1, stdout: "", stderr: "")
259246
if !result.succeeded {
260-
AppLogger.shared.error("route delete failed (sudoers may be missing /sbin/route — re-run install-deps.sh): \(result.stderr)")
247+
AppLogger.shared.error("pre-connect route flush failed (sudoers may be missing /sbin/route — re-run install-deps.sh): \(result.stderr)")
261248
}
262249
}
263250

VPNMenuBar/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
<key>CFBundlePackageType</key>
1818
<string>APPL</string>
1919
<key>CFBundleShortVersionString</key>
20-
<string>0.2.2</string>
20+
<string>0.2.3</string>
2121
<key>CFBundleVersion</key>
22-
<string>4</string>
22+
<string>5</string>
2323
<key>LSMinimumSystemVersion</key>
2424
<string>13.0</string>
2525
<key>LSUIElement</key>

appcast.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@
55
<link>https://github.com/CoderZCC/VPNMenuBar</link>
66
<description>VPN MenuBar update feed</description>
77
<language>en</language>
8+
<item>
9+
<title>Version 0.2.3</title>
10+
<sparkle:version>5</sparkle:version>
11+
<sparkle:shortVersionString>0.2.3</sparkle:shortVersionString>
12+
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
13+
<description><![CDATA[
14+
<ul>
15+
<li>Pre-connect route flush is now unconditional — fixes the case where a cached host route to the VPN gateway looks "correct" (matches the default gateway) but traffic still fails to route after a WiFi/network change</li>
16+
<li>Previously the cleanup only deleted routes that looked obviously stale; now it always flushes before spawning openconnect, because at that moment no active tunnel exists and the kernel rebuilds the route on the next packet</li>
17+
<li>Each connect attempt now logs the pre-connect flush result (route IP, previous gateway, delete success/failure) so "stuck" connections are diagnosable from the log</li>
18+
</ul>
19+
]]></description>
20+
<pubDate>Fri, 24 Apr 2026 17:05:00 +0800</pubDate>
21+
<enclosure
22+
url="https://github.com/CoderZCC/VPNMenuBar/releases/download/v0.2.3/VPNMenuBar-0.2.3.zip"
23+
type="application/octet-stream"
24+
sparkle:edSignature="oxb55TSmBF/M0nCgr76OpA0n2/s1Qn2lH/iAwGFfzgnlieaYm4jDNNVKBWO80dB9iZFDRtyY6et1Rhyy2BBuDw=="
25+
length="1773592"
26+
/>
27+
</item>
828
<item>
929
<title>Version 0.2.2</title>
1030
<sparkle:version>4</sparkle:version>

project.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ targets:
3636
properties:
3737
CFBundleName: VPNMenuBar
3838
CFBundleDisplayName: VPN MenuBar
39-
CFBundleShortVersionString: "0.2.2"
40-
CFBundleVersion: "4"
39+
CFBundleShortVersionString: "0.2.3"
40+
CFBundleVersion: "5"
4141
LSMinimumSystemVersion: "13.0"
4242
LSUIElement: true
4343
NSUserNotificationAlertStyle: alert

0 commit comments

Comments
 (0)