Skip to content

Commit 6c7b26e

Browse files
authored
feat: add additional VPN routes and domains (#98)
* feat: add additional VPN routes and domains * refactor: clarify additional VPN route semantics
1 parent 6d9dbc0 commit 6c7b26e

3 files changed

Lines changed: 187 additions & 9 deletions

File tree

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,21 @@ RUST_LOG=debug ./corplink-rs config.json
197197
// - full: use full-tunnel routes from server
198198
// often combined with "auto_setup_routes": false in container/gateway setups
199199
"route_mode": "split",
200+
// optional CIDRs added to the server-provided routes before filtering. unlike
201+
// vpn_allowed_routes, this can introduce networks not covered by server routes.
202+
// the combined routes are still subject to the allowlist and denylist below.
203+
"vpn_additional_routes": ["20.205.243.160/28"],
204+
// optional exact hostnames resolved on every VPN connection/reconnection.
205+
// each IPv4 result is appended as a /32 route; IPv6 results are appended as
206+
// /128 routes only when the server assigns an IPv6 tunnel address.
207+
"vpn_additional_domains": ["github.com", "api.github.com"],
200208
// optional strict CIDR whitelist. each entry is intersected with the routes
201-
// returned by the server, so it can only narrow the VPN routes. an empty list
202-
// allows no routes; missing/null preserves the server routes. when both lists
203-
// are set, vpn_disallowed_routes is subtracted after this whitelist.
204-
"vpn_allowed_routes": ["192.168.2.0/24"],
209+
// returned by the server plus the additional routes above. an empty list allows
210+
// no routes; missing/null preserves all routes. when both lists are set,
211+
// vpn_disallowed_routes is subtracted after this whitelist. the allowlist must
212+
// also cover every additional route that should be retained, so leave it unset
213+
// when using domain routes whose resolved addresses are not known in advance.
214+
// "vpn_allowed_routes": ["192.168.2.0/24"],
205215
// optional: list of CIDRs to carve out of AllowedIPs (and system routes).
206216
// applied as CIDR subtraction: each entry is subtracted from every route
207217
// returned by the server, so listing a smaller range like "10.68.0.0/16"

src/client.rs

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,88 @@ use crate::utils;
3131
const COOKIE_FILE_SUFFIX: &str = "cookies.json";
3232
const USER_AGENT: &str = "CorpLink/201000 (GooglePixel; Android 10; en)";
3333

34+
fn merge_additional_routes(
35+
mut routes: Vec<String>,
36+
additional_routes: &[String],
37+
has_ipv6_address: bool,
38+
) -> Vec<String> {
39+
for route in additional_routes {
40+
if !crate::utils::is_valid_cidr(route) {
41+
log::warn!("ignoring invalid vpn_additional_routes CIDR: {:?}", route);
42+
continue;
43+
}
44+
if !has_ipv6_address && route.contains(':') {
45+
log::info!(
46+
"ignoring additional IPv6 route {:?} because the server did not assign an IPv6 address",
47+
route
48+
);
49+
continue;
50+
}
51+
if !routes.contains(route) {
52+
routes.push(route.clone());
53+
}
54+
}
55+
routes
56+
}
57+
58+
async fn resolve_additional_domains(
59+
domains: &[String],
60+
has_ipv6_address: bool,
61+
) -> Vec<String> {
62+
let mut routes = Vec::new();
63+
for configured_domain in domains {
64+
let domain = configured_domain.trim();
65+
if domain.is_empty() {
66+
log::warn!("ignoring empty vpn_additional_domains entry");
67+
continue;
68+
}
69+
70+
match tokio::net::lookup_host((domain, 0)).await {
71+
Ok(addresses) => {
72+
let mut domain_routes = Vec::new();
73+
for address in addresses {
74+
let ip = address.ip();
75+
if ip.is_ipv6() && !has_ipv6_address {
76+
continue;
77+
}
78+
let route = match ip {
79+
std::net::IpAddr::V4(_) => format!("{ip}/32"),
80+
std::net::IpAddr::V6(_) => format!("{ip}/128"),
81+
};
82+
if !domain_routes.contains(&route) {
83+
domain_routes.push(route);
84+
}
85+
}
86+
if domain_routes.is_empty() {
87+
log::warn!(
88+
"vpn_additional_domains entry {:?} returned no usable addresses",
89+
domain
90+
);
91+
} else {
92+
log::info!(
93+
"resolved additional VPN domain {:?} to {:?}",
94+
domain,
95+
domain_routes
96+
);
97+
}
98+
for route in domain_routes {
99+
if !routes.contains(&route) {
100+
routes.push(route);
101+
}
102+
}
103+
}
104+
Err(err) => {
105+
log::warn!(
106+
"failed to resolve vpn_additional_domains entry {:?}: {}",
107+
domain,
108+
err
109+
);
110+
}
111+
}
112+
}
113+
routes
114+
}
115+
34116
#[derive(Clone)]
35117
pub struct Client {
36118
conf: Config,
@@ -919,6 +1001,7 @@ impl Client {
9191001
let address6 = (!wg_info.ipv6.is_empty())
9201002
.then_some(format!("{}/128", wg_info.ipv6))
9211003
.unwrap_or("".into());
1004+
let has_ipv6_address = !address6.is_empty();
9221005
let mut allowed_ips = match self.conf.route_mode.clone().unwrap_or_default() {
9231006
crate::config::RouteMode::Split => {
9241007
log::info!("route_mode = split");
@@ -952,9 +1035,32 @@ impl Client {
9521035
}
9531036
};
9541037

955-
// Restrict server routes to the optional whitelist, then carve out the
956-
// optional denylist. A configured empty whitelist intentionally yields
957-
// no AllowedIPs/routes; invalid whitelist entries fail closed.
1038+
let mut additional_routes = self
1039+
.conf
1040+
.vpn_additional_routes
1041+
.clone()
1042+
.unwrap_or_default();
1043+
if let Some(domains) = self.conf.vpn_additional_domains.as_deref() {
1044+
additional_routes
1045+
.extend(resolve_additional_domains(domains, has_ipv6_address).await);
1046+
}
1047+
if !additional_routes.is_empty() {
1048+
let before = allowed_ips.len();
1049+
allowed_ips = merge_additional_routes(
1050+
allowed_ips,
1051+
&additional_routes,
1052+
has_ipv6_address,
1053+
);
1054+
log::info!(
1055+
"additional VPN routes merged: {} -> {} entries",
1056+
before,
1057+
allowed_ips.len()
1058+
);
1059+
}
1060+
1061+
// Restrict server and user-added routes to the optional whitelist, then
1062+
// carve out the optional denylist. A configured empty whitelist
1063+
// intentionally yields no AllowedIPs/routes; invalid entries fail closed.
9581064
if let Some(allowed) = self.conf.vpn_allowed_routes.as_deref() {
9591065
for route in allowed {
9601066
if !crate::utils::is_valid_cidr(route) {
@@ -1151,3 +1257,58 @@ impl Client {
11511257
Ok(())
11521258
}
11531259
}
1260+
1261+
#[cfg(test)]
1262+
mod tests {
1263+
use super::{merge_additional_routes, resolve_additional_domains};
1264+
use crate::utils::apply_route_filters;
1265+
1266+
#[test]
1267+
fn additional_routes_are_validated_deduplicated_and_merged() {
1268+
let routes = merge_additional_routes(
1269+
vec!["10.0.0.0/8".to_string()],
1270+
&[
1271+
"10.0.0.0/8".to_string(),
1272+
"20.205.243.160/28".to_string(),
1273+
"invalid".to_string(),
1274+
"2001:db8::/32".to_string(),
1275+
],
1276+
false,
1277+
);
1278+
1279+
assert_eq!(routes, vec!["10.0.0.0/8", "20.205.243.160/28"]);
1280+
}
1281+
1282+
#[test]
1283+
fn additional_ipv6_routes_are_kept_with_an_ipv6_address() {
1284+
let routes = merge_additional_routes(
1285+
Vec::new(),
1286+
&["2001:db8::/32".to_string()],
1287+
true,
1288+
);
1289+
1290+
assert_eq!(routes, vec!["2001:db8::/32"]);
1291+
}
1292+
1293+
#[test]
1294+
fn additional_routes_are_merged_before_route_filters() {
1295+
let routes = merge_additional_routes(
1296+
vec!["10.0.0.0/8".to_string()],
1297+
&["20.205.243.160/28".to_string()],
1298+
false,
1299+
);
1300+
let allowed = ["20.205.243.160/28".to_string()];
1301+
1302+
assert_eq!(
1303+
apply_route_filters(&routes, Some(&allowed), None),
1304+
vec!["20.205.243.160/28"]
1305+
);
1306+
}
1307+
1308+
#[tokio::test]
1309+
async fn additional_domains_are_resolved_to_host_routes() {
1310+
let routes = resolve_additional_domains(&["127.0.0.1".to_string()], false).await;
1311+
1312+
assert_eq!(routes, vec!["127.0.0.1/32"]);
1313+
}
1314+
}

src/config.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,15 @@ pub struct Config {
7373
pub auto_setup_routes: Option<bool>,
7474
/// "split" (default) or "full". Selects which route list from the server to apply.
7575
pub route_mode: Option<RouteMode>,
76-
/// Optional CIDR whitelist intersected with server routes.
77-
/// Missing/null preserves server routes; an empty list allows no routes.
76+
/// Optional CIDRs added to the server-provided routes before route filters.
77+
/// Unlike `vpn_allowed_routes`, this expands the route set. The combined routes
78+
/// are then restricted by `vpn_allowed_routes` and `vpn_disallowed_routes`.
79+
pub vpn_additional_routes: Option<Vec<String>>,
80+
/// Optional hostnames resolved on every connection. Resolved addresses are appended
81+
/// as host routes before route filters.
82+
pub vpn_additional_domains: Option<Vec<String>>,
83+
/// Optional CIDR whitelist intersected with the server and additional routes.
84+
/// Missing/null preserves the combined routes; an empty list allows no routes.
7885
pub vpn_allowed_routes: Option<Vec<String>>,
7986
/// Optional list of CIDR routes to exclude from AllowedIPs / system routes.
8087
/// Useful in full mode to punch holes for local LAN or the VPN peer IP itself,

0 commit comments

Comments
 (0)