88use crate :: cli:: verbosity:: Verbosity ;
99use crate :: killswitch:: is_private_ip;
1010use anyhow:: { Context , Result , bail} ;
11- use std:: net:: IpAddr ;
11+ use std:: net:: { IpAddr , ToSocketAddrs } ;
1212use std:: process:: Command ;
1313
1414// ============================================================================
@@ -228,18 +228,30 @@ fn detect_peer_from_scutil(verbose: Verbosity) -> Result<String> {
228228
229229 let detail = String :: from_utf8_lossy ( & show_output. stdout ) ;
230230
231- // Look for "RemoteAddress : <ip> "
231+ // Look for "RemoteAddress : <host>[:<port>] "
232232 for detail_line in detail. lines ( ) {
233233 let trimmed = detail_line. trim ( ) ;
234- if let Some ( ip) = trimmed. strip_prefix ( "RemoteAddress : " ) {
235- let ip = ip. trim ( ) ;
236- if is_valid_vpn_peer ( ip) {
234+ if let Some ( raw) = trimmed. strip_prefix ( "RemoteAddress : " ) {
235+ let raw = raw. trim ( ) ;
236+ let host = strip_port ( raw) ;
237+
238+ // Resolve hostname to IP if needed
239+ let resolved = if host. parse :: < IpAddr > ( ) . is_ok ( ) {
240+ host. to_string ( )
241+ } else {
242+ match resolve_hostname_v4 ( host, verbose) {
243+ Some ( ip) => ip,
244+ None => continue ,
245+ }
246+ } ;
247+
248+ if is_valid_vpn_peer ( & resolved) {
237249 if verbose. is_verbose ( ) {
238- eprintln ! ( " Detected VPN peer via scutil: {ip }" ) ;
250+ eprintln ! ( " Detected VPN peer via scutil: {resolved }" ) ;
239251 }
240- return Ok ( ip . to_string ( ) ) ;
252+ return Ok ( resolved ) ;
241253 } else if verbose. is_debug ( ) {
242- eprintln ! ( " Skipping non-public RemoteAddress: {ip }" ) ;
254+ eprintln ! ( " Skipping non-public RemoteAddress: {resolved }" ) ;
243255 }
244256 }
245257 }
@@ -248,6 +260,48 @@ fn detect_peer_from_scutil(verbose: Verbosity) -> Result<String> {
248260 bail ! ( "No VPN peer found via scutil" )
249261}
250262
263+ /// Strip optional port suffix from a remote address.
264+ ///
265+ /// Handles `host:port`, `ip:port`, and `[ipv6]:port` forms.
266+ /// Returns the bare host/IP.
267+ fn strip_port ( raw : & str ) -> & str {
268+ if let Some ( rest) = raw. strip_prefix ( '[' ) {
269+ // [ipv6]:port — extract content between brackets
270+ rest. split ( ']' ) . next ( ) . unwrap_or ( raw)
271+ } else if raw. matches ( ':' ) . count ( ) == 1 {
272+ // host:port or ipv4:port — split on the single colon
273+ raw. split ( ':' ) . next ( ) . unwrap_or ( raw)
274+ } else {
275+ // bare IP, bare hostname, or bare IPv6 (multiple colons, no brackets)
276+ raw
277+ }
278+ }
279+
280+ /// Resolve a hostname to its first IPv4 address.
281+ fn resolve_hostname_v4 ( host : & str , verbose : Verbosity ) -> Option < String > {
282+ if verbose. is_debug ( ) {
283+ eprintln ! ( " Resolving hostname: {host}" ) ;
284+ }
285+ match format ! ( "{host}:0" ) . to_socket_addrs ( ) {
286+ Ok ( addrs) => {
287+ if let Some ( addr) = addrs. into_iter ( ) . find ( std:: net:: SocketAddr :: is_ipv4) {
288+ Some ( addr. ip ( ) . to_string ( ) )
289+ } else {
290+ if verbose. is_debug ( ) {
291+ eprintln ! ( " No IPv4 address for: {host}" ) ;
292+ }
293+ None
294+ }
295+ }
296+ Err ( e) => {
297+ if verbose. is_debug ( ) {
298+ eprintln ! ( " DNS resolution failed for {host}: {e}" ) ;
299+ }
300+ None
301+ }
302+ }
303+ }
304+
251305/// Extract destination IP from netstat routing table line.
252306///
253307/// Format: "Destination Gateway Flags Netif Expire"
@@ -592,4 +646,39 @@ mod tests {
592646 assert_eq ! ( hex_to_cidr( "ffffff00" ) , None ) ; // Missing 0x prefix
593647 assert_eq ! ( hex_to_cidr( "" ) , None ) ;
594648 }
649+
650+ // -------------------------------------------------------------------------
651+ // Port stripping tests
652+ // -------------------------------------------------------------------------
653+
654+ #[ test]
655+ fn test_strip_port_bare_ipv4 ( ) {
656+ assert_eq ! ( strip_port( "1.2.3.4" ) , "1.2.3.4" ) ;
657+ }
658+
659+ #[ test]
660+ fn test_strip_port_ipv4_with_port ( ) {
661+ assert_eq ! ( strip_port( "1.2.3.4:51820" ) , "1.2.3.4" ) ;
662+ }
663+
664+ #[ test]
665+ fn test_strip_port_hostname_with_port ( ) {
666+ assert_eq ! ( strip_port( "myvpn.example.com:51820" ) , "myvpn.example.com" ) ;
667+ }
668+
669+ #[ test]
670+ fn test_strip_port_bare_hostname ( ) {
671+ assert_eq ! ( strip_port( "myvpn.example.com" ) , "myvpn.example.com" ) ;
672+ }
673+
674+ #[ test]
675+ fn test_strip_port_ipv6_bracketed_with_port ( ) {
676+ assert_eq ! ( strip_port( "[::1]:51820" ) , "::1" ) ;
677+ }
678+
679+ #[ test]
680+ fn test_strip_port_bare_ipv6 ( ) {
681+ // Bare IPv6 has multiple colons, no brackets — returned as-is
682+ assert_eq ! ( strip_port( "2001:db8::1" ) , "2001:db8::1" ) ;
683+ }
595684}
0 commit comments