Skip to content

feat: add CIDR/subnet support in VPN Only mode#34

Merged
GeiserX merged 3 commits intomainfrom
feat/vpn-only-cidr-support
Apr 19, 2026
Merged

feat: add CIDR/subnet support in VPN Only mode#34
GeiserX merged 3 commits intomainfrom
feat/vpn-only-cidr-support

Conversation

@GeiserX
Copy link
Copy Markdown
Owner

@GeiserX GeiserX commented Apr 19, 2026

Summary

  • Adds CIDR/subnet notation support (e.g., 192.168.1.0/24) to VPN Only mode entries, enabling users to route entire subnets through VPN
  • Fixes input validation to reject leading zeros in IP addresses (preventing octal interpretation mismatches)
  • Bumps helper version to 1.5.0 for the validation behavior change

Closes #33

Changes

  • RouteManager.swift: DomainEntry gains isCIDR flag (backward-compatible Codable). CIDR input bypasses cleanDomain() and DNS resolution, routing directly via isNetwork: true. Toggle re-enable fixed for CIDR entries. DNS refresh skips CIDRs. Hosts file skips CIDRs.
  • HelperTool.swift: isValidIP() rejects leading zeros (octal mismatch prevention)
  • HelperProtocol.swift: Helper version 1.4.0 → 1.5.0
  • SettingsView.swift: Placeholder updated, amber "CIDR" badge for subnet entries, updated empty state text
  • MenuBarViews.swift: Stat label "VPN Domains" → "VPN Entries"
  • Tests: 39 new tests — CIDR validation (14), DomainEntry Codable (10), add-inverse-domain logic (15)

How it works

  1. User enters 192.168.1.0/24 in VPN Only domain input
  2. isValidCIDR() validates format (rejects /0 to avoid catch-all conflict)
  3. Entry stored with isCIDR: true, bypasses DNS resolution pipeline
  4. Helper creates network route via -net flag (pre-existing infrastructure)
  5. On VPN connect/disconnect, CIDR routes are applied/removed alongside domain routes

Test plan

  • 68 unit tests pass (39 new)
  • Build succeeds
  • Manual test: add 192.168.1.0/24 in VPN Only mode, verify route appears in netstat -rn
  • Manual test: toggle CIDR entry off/on, verify route removed/re-added
  • Manual test: existing domain entries unaffected by changes
  • Manual test: app upgrade with pre-existing entries (backward compatibility)

Summary by CodeRabbit

  • New Features

    • Added support for IP ranges using CIDR notation in VPN routing configuration, enabling more flexible network control.
    • Updated UI to display IP range entries distinctly and provide guidance for supported formats.
  • Bug Fixes

    • Fixed IPv4 validation to properly reject octets with leading zeros.
  • Chores

    • Bumped version to 1.5.0.

Allow users to enter CIDR ranges (e.g., 192.168.1.0/24) in VPN Only
mode, routing entire subnets through VPN instead of only individual
domain-resolved IPs. This addresses the limitation reported in #33
where users with IKEv2 VPNs could not route their home LAN subnet.

Changes:
- Add isCIDR flag to DomainEntry with backward-compatible decoding
- Detect CIDR input in addInverseDomain, bypass cleanDomain
- Route CIDRs as network routes (isNetwork: true) in all code paths:
  initial apply, cached apply, DNS refresh, toggle, and live add
- Skip CIDR entries from DNS resolution and hosts file management
- Reject /0 mask to prevent conflict with catch-all routes
- Reject leading-zero IP octets (octal interpretation mismatch)
- Add CIDR badge in domain list UI, update placeholder text
- Add 39 new tests for CIDR validation, Codable, and classification
- Bump helper version to 1.5.0 (validation behavior change)

Closes #33
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

Warning

Rate limit exceeded

@GeiserX has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minutes and 51 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 1 minutes and 51 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fb6e6c02-5bbc-4cc5-9bdf-a416391f53e9

📥 Commits

Reviewing files that changed from the base of the PR and between 8f7122a and d2b3620.

📒 Files selected for processing (2)
  • Sources/RouteManager.swift
  • Tests/VPNBypassTests/VPNBypassTests.swift
📝 Walkthrough

Walkthrough

This pull request adds support for CIDR notation and IP range entries in VPN bypass routing, enabling users to specify entire subnets (e.g., 10.0.0.0/24) alongside domain entries. It includes stricter IPv4 validation, version bumps across helper and main app components, UI updates to reflect the new entry type, and comprehensive test coverage.

Changes

Cohort / File(s) Summary
Version Updates
Helper/Info.plist, Sources/HelperProtocol.swift
Updated helper app version from 1.4.0 to 1.5.0 (and Info.plist build number from 2 to 5).
Validation & CIDR Core
Helper/HelperTool.swift, Sources/RouteManager.swift
Tightened IPv4 octet validation to reject leading zeros; added DomainEntry.isCIDR flag; implemented CIDR parsing (isValidCIDR); refactored route construction, DNS refresh, and inverse-domain management to handle CIDR entries as static network routes (bypassing DNS resolution).
UI Updates
Sources/MenuBarViews.swift, Sources/SettingsView.swift
Changed stat label from "VPN Domains" to "VPN Entries"; added conditional CIDR badge rendering in domain rows; updated placeholder text and empty-state messaging to reflect domain-or-IP-range input capability.
Test Coverage
Tests/VPNBypassTests/VPNBypassTests.swift
Added CIDR validation test suites (boundary masks, malformed inputs, slash handling); added DomainEntry Codable tests (backward compatibility, round-tripping); added logic tests for addInverseDomain classification and CIDR deduplication; updated expected helper version constant.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately and concisely describes the main change: adding CIDR/subnet support in VPN Only mode, which is the primary objective of this PR.
Description check ✅ Passed Description covers all essential template sections: summary with bullets, change breakdown, how it works, and test plan. Type of Change (New feature) is implicit. Some checkbox items unchecked but non-critical.
Linked Issues check ✅ Passed PR fully addresses issue #33 by implementing CIDR/subnet routing support in VPN Only mode, enabling users to route entire remote subnets (e.g., 192.168.1.0/24) through VPN instead of only reaching the gateway IP.
Out of Scope Changes check ✅ Passed All changes are directly scoped to issue #33: CIDR support, IPv4 validation tightening, version bump, UI updates for CIDR entries, and comprehensive tests. No unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/vpn-only-cidr-support

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@GeiserX
Copy link
Copy Markdown
Owner Author

GeiserX commented Apr 19, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Helper/Info.plist`:
- Around line 11-14: The helper version is hardcoded in
Sources/HelperProtocol.swift (variable helperVersion) and must be replaced with
a runtime read from the helper bundle's Info.plist CFBundleShortVersionString;
update the code that sets helperVersion to fetch Bundle(for: <type>?) or
Bundle(identifier: "<helper bundle id>") and read object(forInfoDictionaryKey:
"CFBundleShortVersionString") (with a safe fallback) instead of the literal
"1.5.0" so the value stays synchronized with the Helper/Info.plist bump.

In `@Sources/HelperProtocol.swift`:
- Line 81: Replace the hardcoded static let helperVersion = "1.5.0" with a
computed property that reads CFBundleShortVersionString from the helper's bundle
at runtime (e.g., use Bundle(for: HelperProtocol.self) or the appropriate bundle
for the helper target), returning that string and providing a sensible fallback
(like "0.0.0") if the key is missing; update the symbol helperVersion in
HelperProtocol to be a computed String property that fetches Info.plist's
CFBundleShortVersionString instead of a hardcoded value.

In `@Sources/RouteManager.swift`:
- Around line 2566-2577: In addInverseDomain(_:), avoid passing strings that
look like CIDRs (contain "/") into cleanDomain; instead check if the trimmed
input contains "/" and if so validate with isValidCIDR and reject (return) when
isValidCIDR is false; otherwise if contains no "/", call cleanDomain as before
and guard against empty result—use the existing symbols addInverseDomain,
isValidCIDR, and cleanDomain to implement this branch.

In `@Tests/VPNBypassTests/VPNBypassTests.swift`:
- Around line 302-330: Replace the local test-only DomainEntry struct with the
production type so the test validates actual decoding logic: remove the nested
struct in DomainEntryCodableTests and use RouteManager.DomainEntry (or the real
DomainEntry type from the app) instead; ensure the test target imports the app
module (e.g. add `@testable` import YourAppModule) and construct/encode/decode
RouteManager.DomainEntry instances (or add a small test-only initializer/wrapper
if needed) so the test asserts the production DomainEntry's init(from:) handles
decodeIfPresent(.isCIDR) and keys correctly.
- Around line 202-211: The CIDR validation must consistently reject a /0 mask;
update the isValidCIDR function so the mask range is explicitly 1...32 (ensure
the guard uses mask >= 1 && mask <= 32) and mirror this same check where CIDR
parsing/validation is duplicated (the similar helper around the other CIDR
handling block) so default routes (/0) are treated as invalid for VPN-only
input.
- Line 689: The test currently uses a hardcoded string literal ("version") which
makes the assertion meaningless; change the test to read the helper’s real
version (either by referencing the production constant
HelperConstants.helperVersion or by loading the helper bundle’s
CFBundleShortVersionString) instead of the literal, e.g., import the app module
with `@testable` to access HelperConstants or load the helper bundle plist and
compare that value to the code-under-test; update the variable named version in
VPNBypassTests.swift to obtain the runtime version from
HelperConstants.helperVersion or from the helper bundle’s
CFBundleShortVersionString before asserting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7852820b-25d7-448d-9213-e9ae2e89770f

📥 Commits

Reviewing files that changed from the base of the PR and between 4daa16c and 8f7122a.

📒 Files selected for processing (7)
  • Helper/HelperTool.swift
  • Helper/Info.plist
  • Sources/HelperProtocol.swift
  • Sources/MenuBarViews.swift
  • Sources/RouteManager.swift
  • Sources/SettingsView.swift
  • Tests/VPNBypassTests/VPNBypassTests.swift

Comment thread Helper/Info.plist

struct HelperConstants {
static let helperVersion = "1.4.0"
static let helperVersion = "1.5.0"
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Read the helper version from the bundle, not a hardcoded string.

This can drift from Info.plist and break helper readiness/upgrade checks.

Proposed fix
 struct HelperConstants {
-    static let helperVersion = "1.5.0"
+    static var helperVersion: String {
+        Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown"
+    }
     static let bundleID = "com.geiserx.vpnbypass.helper"

As per coding guidelines, **/*.swift: Do NOT hardcode version strings in Swift code. The app must read the version from the bundle's CFBundleShortVersionString at runtime.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static let helperVersion = "1.5.0"
struct HelperConstants {
static var helperVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown"
}
static let bundleID = "com.geiserx.vpnbypass.helper"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/HelperProtocol.swift` at line 81, Replace the hardcoded static let
helperVersion = "1.5.0" with a computed property that reads
CFBundleShortVersionString from the helper's bundle at runtime (e.g., use
Bundle(for: HelperProtocol.self) or the appropriate bundle for the helper
target), returning that string and providing a sensible fallback (like "0.0.0")
if the key is missing; update the symbol helperVersion in HelperProtocol to be a
computed String property that fetches Info.plist's CFBundleShortVersionString
instead of a hardcoded value.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applicable — the helper is a standalone swiftc-compiled binary running as a launchd daemon at /Library/PrivilegedHelperTools/. Bundle.main won't have an Info.plist to read from. The hardcoded constant is intentional — it's shared between the app (which checks it via XPC getVersion) and the helper (which reports it). Both sides need to agree on the version string at compile time.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeiserX, that's a valid and important architectural distinction — acknowledged. A bare swiftc-compiled launchd daemon has no bundle, so Bundle.main is indeed the wrong approach here. The hardcoded constant as a shared compile-time contract between the app and helper over XPC is the correct pattern for this setup. I'll retract the suggestion.


✏️ Learnings added
Learnt from: GeiserX
Repo: GeiserX/VPN-Bypass PR: 34
File: Sources/HelperProtocol.swift:81-81
Timestamp: 2026-04-19T22:30:16.255Z
Learning: In the VPN-Bypass project, `HelperConstants.helperVersion` in `Sources/HelperProtocol.swift` is intentionally hardcoded. The privileged helper is a standalone `swiftc`-compiled binary (not an app bundle) running as a launchd daemon at `/Library/PrivilegedHelperTools/`. `Bundle.main` has no Info.plist in that context, so reading `CFBundleShortVersionString` at runtime is not feasible. The constant is a compile-time contract shared between the main app (which checks it via XPC `getVersion`) and the helper (which reports it). Do NOT suggest replacing it with a bundle read.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: GeiserX/VPN-Bypass PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-16T15:10:58.933Z
Learning: Applies to **/*.swift : Helper readiness must be authoritative everywhere in Swift code—startup, refresh, reroute, DNS refresh, and hosts file updates must all refuse to proceed when the helper is not verified ready

Learnt from: CR
Repo: GeiserX/VPN-Bypass PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-16T15:10:58.933Z
Learning: The privileged helper is auto-installed on first launch and auto-updated on version mismatch. Do not reintroduce helperless fallbacks (direct `/sbin/route`, AppleScript) that bypass the helper—all route-mutating operations require the helper.

Learnt from: CR
Repo: GeiserX/VPN-Bypass PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-16T15:10:58.933Z
Learning: Applies to **/*.swift : Do NOT hardcode version strings in Swift code. The app must read the version from the bundle's `CFBundleShortVersionString` at runtime.

Learnt from: CR
Repo: GeiserX/VPN-Bypass PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-16T15:10:58.933Z
Learning: Applies to **/*.swift : SMAppService.register() does NOT replace existing helper binaries—when updating, always route to the legacy AppleScript path that does actual `cp` + `launchctl bootout/bootstrap`

Learnt from: CR
Repo: GeiserX/VPN-Bypass PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-16T15:10:58.933Z
Learning: Applies to **/*.swift : On macOS Sonoma+, users can disable VPN Bypass's background item in System Settings → General → Login Items & Extensions, which blocks the helper daemon. When disabled, the app will re-prompt for admin on every launch. Detect this via `SMAppService.daemon().status` and show a helpful error message instead of re-prompting.

Learnt from: CR
Repo: GeiserX/VPN-Bypass PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-16T15:10:58.933Z
Learning: Ship universal binaries for both the main app and privileged helper (arm64 + x86_64). Use `swift build --arch arm64 --arch x86_64` for the main app (outputs to `.build/apple/Products/Release`), and separate arch builds plus `lipo` for the helper.

Learnt from: CR
Repo: GeiserX/VPN-Bypass PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-16T15:10:58.933Z
Learning: Applies to **/*.swift : Some VPNs route via interface link (e.g., Cisco Secure Client sets default route to `link#N`) instead of IP gateway. Handle this via `iface:<interface>` convention: use `route add -host <dest> -interface utunX` instead of IP gateway. See VPN Only mode implementation.

Comment thread Sources/RouteManager.swift
Comment thread Tests/VPNBypassTests/VPNBypassTests.swift
Comment on lines +302 to +330
/// Tests for DomainEntry encoding/decoding, especially backward compatibility with isCIDR field.
final class DomainEntryCodableTests: XCTestCase {

// Minimal reimplementation of DomainEntry for Codable testing
struct DomainEntry: Codable, Identifiable, Equatable {
let id: UUID
var domain: String
var enabled: Bool
var resolvedIP: String?
var lastResolved: Date?
var isCIDR: Bool

init(domain: String, enabled: Bool = true, isCIDR: Bool = false) {
self.id = UUID()
self.domain = domain
self.enabled = enabled
self.isCIDR = isCIDR
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
domain = try container.decode(String.self, forKey: .domain)
enabled = try container.decode(Bool.self, forKey: .enabled)
resolvedIP = try container.decodeIfPresent(String.self, forKey: .resolvedIP)
lastResolved = try container.decodeIfPresent(Date.self, forKey: .lastResolved)
isCIDR = try container.decodeIfPresent(Bool.self, forKey: .isCIDR) ?? false
}
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Test the production DomainEntry, not a duplicate struct.

This local reimplementation can pass even if RouteManager.DomainEntry forgets decodeIfPresent(.isCIDR) or encodes different keys. Use the real production type via the test target instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/VPNBypassTests/VPNBypassTests.swift` around lines 302 - 330, Replace
the local test-only DomainEntry struct with the production type so the test
validates actual decoding logic: remove the nested struct in
DomainEntryCodableTests and use RouteManager.DomainEntry (or the real
DomainEntry type from the app) instead; ensure the test target imports the app
module (e.g. add `@testable` import YourAppModule) and construct/encode/decode
RouteManager.DomainEntry instances (or add a small test-only initializer/wrapper
if needed) so the test asserts the production DomainEntry's init(from:) handles
decodeIfPresent(.isCIDR) and keys correctly.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged but not actionable — the test target is standalone with no dependency on the main VPNBypass executable target (see Package.swift). Adding @testable import would require restructuring the target graph to extract DomainEntry into a shared library target, which is out of scope for this feature PR. The local reimplementation mirrors the production struct and validates the same Codable contract.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeiserX, understood — that's a valid constraint. With a standalone test target and no shared library layer, the local reimplementation is the practical approach. I'll note this for future reviews.


✏️ Learnings added
Learnt from: GeiserX
Repo: GeiserX/VPN-Bypass PR: 34
File: Tests/VPNBypassTests/VPNBypassTests.swift:302-330
Timestamp: 2026-04-19T22:30:14.596Z
Learning: In GeiserX/VPN-Bypass, the test target (`Tests/VPNBypassTests`) is standalone with no `testable import` dependency on the main `VPNBypass` executable target. Restructuring the target graph (e.g., extracting types into a shared library) is considered out of scope. Local reimplementations of production types (e.g., `DomainEntry`) in test files are intentional and acceptable given this constraint. Do not flag them as duplicates or suggest `testable import` as a fix.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: GeiserX/VPN-Bypass PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-16T15:10:58.933Z
Learning: Applies to **/*.swift : Some VPNs route via interface link (e.g., Cisco Secure Client sets default route to `link#N`) instead of IP gateway. Handle this via `iface:<interface>` convention: use `route add -host <dest> -interface utunX` instead of IP gateway. See VPN Only mode implementation.

Comment thread Tests/VPNBypassTests/VPNBypassTests.swift
- Reject slash-containing input that fails CIDR validation instead of
  falling through to cleanDomain (which strips the slash and adds the
  bare IP as a domain entry)
- Align test isValidCIDR to reject /0 mask (matching production code),
  while keeping isValidDestination (helper validation) allowing /0
@GeiserX GeiserX merged commit 4f5a618 into main Apr 19, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: "VPN only" only routes to VPN endpoint/router

1 participant