From 20a6025075079abc3e3e8aa4ef7ed121c99dba67 Mon Sep 17 00:00:00 2001 From: Mg Pig Date: Sat, 7 Jun 2025 22:05:47 +0800 Subject: [PATCH] Added RPC portal whitelist function, allowing only local access by default to enhance security (#929) --- .../frontend-lib/src/components/Config.vue | 9 + easytier-web/frontend-lib/src/locales/cn.yaml | 1 + easytier-web/frontend-lib/src/locales/en.yaml | 1 + .../frontend-lib/src/types/network.ts | 3 + easytier/locales/app.yml | 3 + easytier/src/common/config.rs | 13 ++ easytier/src/easytier-core.rs | 11 + .../instance/dns_server/server_instance.rs | 7 +- easytier/src/instance/instance.rs | 189 +++++++++++++++++- easytier/src/launcher.rs | 14 ++ easytier/src/proto/rpc_impl/standalone.rs | 15 +- easytier/src/proto/web.proto | 2 + 12 files changed, 260 insertions(+), 8 deletions(-) diff --git a/easytier-web/frontend-lib/src/components/Config.vue b/easytier-web/frontend-lib/src/components/Config.vue index 5b4d1f7..e38fa3e 100644 --- a/easytier-web/frontend-lib/src/components/Config.vue +++ b/easytier-web/frontend-lib/src/components/Config.vue @@ -304,6 +304,15 @@ const bool_flags: BoolFlag[] = [ +
+
+ + +
+
+
diff --git a/easytier-web/frontend-lib/src/locales/cn.yaml b/easytier-web/frontend-lib/src/locales/cn.yaml index e0e1671..9c3d241 100644 --- a/easytier-web/frontend-lib/src/locales/cn.yaml +++ b/easytier-web/frontend-lib/src/locales/cn.yaml @@ -18,6 +18,7 @@ advanced_settings: 高级设置 basic_settings: 基础设置 listener_urls: 监听地址 rpc_port: RPC端口 +rpc_portal_whitelists: RPC白名单 config_network: 配置网络 running: 运行中 error_msg: 错误信息 diff --git a/easytier-web/frontend-lib/src/locales/en.yaml b/easytier-web/frontend-lib/src/locales/en.yaml index 1d6e167..bf9629f 100644 --- a/easytier-web/frontend-lib/src/locales/en.yaml +++ b/easytier-web/frontend-lib/src/locales/en.yaml @@ -18,6 +18,7 @@ advanced_settings: Advanced Settings basic_settings: Basic Settings listener_urls: Listener URLs rpc_port: RPC Port +rpc_portal_whitelists: RPC Whitelist config_network: Config Network running: Running error_msg: Error Message diff --git a/easytier-web/frontend-lib/src/types/network.ts b/easytier-web/frontend-lib/src/types/network.ts index 6f1af40..421d61f 100644 --- a/easytier-web/frontend-lib/src/types/network.ts +++ b/easytier-web/frontend-lib/src/types/network.ts @@ -65,6 +65,8 @@ export interface NetworkConfig { enable_magic_dns?: boolean enable_private_mode?: boolean + + rpc_portal_whitelists: string[] } export function DEFAULT_NETWORK_CONFIG(): NetworkConfig { @@ -123,6 +125,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig { mapped_listeners: [], enable_magic_dns: false, enable_private_mode: false, + rpc_portal_whitelists: [], } } diff --git a/easytier/locales/app.yml b/easytier/locales/app.yml index e68a0e9..e5377c3 100644 --- a/easytier/locales/app.yml +++ b/easytier/locales/app.yml @@ -37,6 +37,9 @@ core_clap: rpc_portal: en: "rpc portal address to listen for management. 0 means random port, 12345 means listen on 12345 of localhost, 0.0.0.0:12345 means listen on 12345 of all interfaces. default is 0 and will try 15888 first" zh-CN: "用于管理的RPC门户地址。0表示随机端口,12345表示在localhost的12345上监听,0.0.0.0:12345表示在所有接口的12345上监听。默认是0,首先尝试15888" + rpc_portal_whitelist: + en: "rpc portal whitelist, only allow these addresses to access rpc portal, e.g.: 127.0.0.1,127.0.0.0/8,::1/128" + zh-CN: "RPC门户白名单,仅允许这些地址访问RPC门户,例如:127.0.0.1/32,127.0.0.0/8,::1/128" listeners: en: |+ listeners to accept connections, allow format: diff --git a/easytier/src/common/config.rs b/easytier/src/common/config.rs index cdb8be6..9798b32 100644 --- a/easytier/src/common/config.rs +++ b/easytier/src/common/config.rs @@ -5,6 +5,7 @@ use std::{ }; use anyhow::Context; +use cidr::IpCidr; use serde::{Deserialize, Serialize}; use crate::{ @@ -87,6 +88,9 @@ pub trait ConfigLoader: Send + Sync { fn get_rpc_portal(&self) -> Option; fn set_rpc_portal(&self, addr: SocketAddr); + fn get_rpc_portal_whitelist(&self) -> Option>; + fn set_rpc_portal_whitelist(&self, whitelist: Option>); + fn get_vpn_portal_config(&self) -> Option; fn set_vpn_portal_config(&self, config: VpnPortalConfig); @@ -243,6 +247,7 @@ struct Config { console_logger: Option, rpc_portal: Option, + rpc_portal_whitelist: Option>, vpn_portal_config: Option, @@ -544,6 +549,14 @@ impl ConfigLoader for TomlConfigLoader { self.config.lock().unwrap().rpc_portal = Some(addr); } + fn get_rpc_portal_whitelist(&self) -> Option> { + self.config.lock().unwrap().rpc_portal_whitelist.clone() + } + + fn set_rpc_portal_whitelist(&self, whitelist: Option>) { + self.config.lock().unwrap().rpc_portal_whitelist = whitelist; + } + fn get_vpn_portal_config(&self) -> Option { self.config.lock().unwrap().vpn_portal_config.clone() } diff --git a/easytier/src/easytier-core.rs b/easytier/src/easytier-core.rs index fa058e4..e209f37 100644 --- a/easytier/src/easytier-core.rs +++ b/easytier/src/easytier-core.rs @@ -11,6 +11,7 @@ use std::{ }; use anyhow::Context; +use cidr::IpCidr; use clap::Parser; use easytier::{ @@ -176,6 +177,14 @@ struct Cli { )] rpc_portal: Option, + #[arg( + long, + env = "ET_RPC_PORTAL_WHITELIST", + value_delimiter = ',', + help = t!("core_clap.rpc_portal_whitelist").to_string(), + )] + rpc_portal_whitelist: Option>, + #[arg( short, long, @@ -616,6 +625,8 @@ impl TryFrom<&Cli> for TomlConfigLoader { }; cfg.set_rpc_portal(rpc_portal); + cfg.set_rpc_portal_whitelist(cli.rpc_portal_whitelist.clone()); + if let Some(external_nodes) = cli.external_node.as_ref() { let mut old_peers = cfg.get_peers(); old_peers.push(PeerConfig { diff --git a/easytier/src/instance/dns_server/server_instance.rs b/easytier/src/instance/dns_server/server_instance.rs index f769549..1e1f8f0 100644 --- a/easytier/src/instance/dns_server/server_instance.rs +++ b/easytier/src/instance/dns_server/server_instance.rs @@ -298,12 +298,13 @@ impl NicPacketFilter for MagicDnsServerInstanceData { #[async_trait::async_trait] impl RpcServerHook for MagicDnsServerInstanceData { - async fn on_new_client(&self, tunnel_info: Option) { - println!("New client connected: {:?}", tunnel_info); + async fn on_new_client(&self, tunnel_info: Option)-> Result, anyhow::Error> { + tracing::info!(?tunnel_info, "New client connected"); + Ok(tunnel_info) } async fn on_client_disconnected(&self, tunnel_info: Option) { - println!("Client disconnected: {:?}", tunnel_info); + tracing::info!(?tunnel_info, "Client disconnected"); let Some(tunnel_info) = tunnel_info else { return; }; diff --git a/easytier/src/instance/instance.rs b/easytier/src/instance/instance.rs index 0b3d455..e241f8f 100644 --- a/easytier/src/instance/instance.rs +++ b/easytier/src/instance/instance.rs @@ -1,11 +1,11 @@ use std::any::Any; use std::collections::HashSet; -use std::net::Ipv4Addr; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Weak}; use anyhow::Context; -use cidr::Ipv4Inet; +use cidr::{IpCidr, Ipv4Inet}; use tokio::task::JoinHandle; use tokio::{sync::Mutex, task::JoinSet}; @@ -29,8 +29,9 @@ use crate::peers::rpc_service::PeerManagerRpcService; use crate::peers::{create_packet_recv_chan, recv_packet_from_chan, PacketRecvChanReceiver}; use crate::proto::cli::VpnPortalRpc; use crate::proto::cli::{GetVpnPortalInfoRequest, GetVpnPortalInfoResponse, VpnPortalInfo}; +use crate::proto::common::TunnelInfo; use crate::proto::peer_rpc::PeerCenterRpcServer; -use crate::proto::rpc_impl::standalone::StandAloneServer; +use crate::proto::rpc_impl::standalone::{RpcServerHook, StandAloneServer}; use crate::proto::rpc_types; use crate::proto::rpc_types::controller::BaseController; use crate::tunnel::tcp::TcpTunnelListener; @@ -155,6 +156,58 @@ impl NicCtxContainer { type ArcNicCtx = Arc>>; +pub struct InstanceRpcServerHook { + rpc_portal_whitelist: Vec, +} + +impl InstanceRpcServerHook { + pub fn new(rpc_portal_whitelist: Option>) -> Self { + let rpc_portal_whitelist = rpc_portal_whitelist + .unwrap_or_else(|| vec!["127.0.0.0/8".parse().unwrap(), "::1/128".parse().unwrap()]); + InstanceRpcServerHook { + rpc_portal_whitelist, + } + } +} + +#[async_trait::async_trait] +impl RpcServerHook for InstanceRpcServerHook { + async fn on_new_client( + &self, + tunnel_info: Option, + ) -> Result, anyhow::Error> { + let tunnel_info = tunnel_info.ok_or_else(|| anyhow::anyhow!("tunnel info is None"))?; + + let remote_url = tunnel_info + .remote_addr + .clone() + .ok_or_else(|| anyhow::anyhow!("remote_addr is None"))?; + + let url_str = &remote_url.url; + let url = url::Url::parse(url_str) + .map_err(|e| anyhow::anyhow!("Failed to parse remote URL '{}': {}", url_str, e))?; + + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("No host found in remote URL '{}'", url_str))?; + + let ip_addr: IpAddr = host + .parse() + .map_err(|e| anyhow::anyhow!("Failed to parse IP address '{}': {}", host, e))?; + + for cidr in &self.rpc_portal_whitelist { + if cidr.contains(&ip_addr) { + return Ok(Some(tunnel_info)); + } + } + return Err(anyhow::anyhow!( + "Rpc portal client IP {} not in whitelist: {:?}, ignoring client.", + ip_addr, + self.rpc_portal_whitelist + )); + } +} + pub struct Instance { inst_name: String, @@ -674,6 +727,10 @@ impl Instance { ); } + s.set_hook(Arc::new(InstanceRpcServerHook::new( + self.global_ctx.config.get_rpc_portal_whitelist(), + ))); + let _g = self.global_ctx.net_ns.guard(); Ok(s.serve().await.with_context(|| "rpc server start failed")?) } @@ -726,3 +783,129 @@ impl Instance { Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::{instance::instance::InstanceRpcServerHook, proto::rpc_impl::standalone::RpcServerHook}; + + + #[tokio::test] + async fn test_rpc_portal_whitelist() { + use cidr::IpCidr; + + struct TestCase { + remote_url: String, + whitelist: Option>, + expected_result: bool, + } + + let test_cases:Vec = vec![ + // Test default whitelist (127.0.0.0/8, ::1/128) + TestCase { + remote_url: "tcp://127.0.0.1:15888".to_string(), + whitelist: None, + expected_result: true, + }, + TestCase { + remote_url: "tcp://127.1.2.3:15888".to_string(), + whitelist: None, + expected_result: true, + }, + TestCase { + remote_url: "tcp://192.168.1.1:15888".to_string(), + whitelist: None, + expected_result: false, + }, + + // Test custom whitelist + TestCase { + remote_url: "tcp://192.168.1.10:15888".to_string(), + whitelist: Some(vec![ + "192.168.1.0/24".parse().unwrap(), + "10.0.0.0/8".parse().unwrap(), + ]), + expected_result: true, + }, + TestCase { + remote_url: "tcp://10.1.2.3:15888".to_string(), + whitelist: Some(vec![ + "192.168.1.0/24".parse().unwrap(), + "10.0.0.0/8".parse().unwrap(), + ]), + expected_result: true, + }, + TestCase { + remote_url: "tcp://172.16.0.1:15888".to_string(), + whitelist: Some(vec![ + "192.168.1.0/24".parse().unwrap(), + "10.0.0.0/8".parse().unwrap(), + ]), + expected_result: false, + }, + + // Test empty whitelist (should reject all connections) + TestCase { + remote_url: "tcp://127.0.0.1:15888".to_string(), + whitelist: Some(vec![]), + expected_result: false, + }, + + // Test broad whitelist (0.0.0.0/0 and ::/0 accept all IP addresses) + TestCase { + remote_url: "tcp://8.8.8.8:15888".to_string(), + whitelist: Some(vec![ + "0.0.0.0/0".parse().unwrap(), + ]), + expected_result: true, + }, + + // Test edge case: specific IP whitelist + TestCase { + remote_url: "tcp://192.168.1.5:15888".to_string(), + whitelist: Some(vec![ + "192.168.1.5/32".parse().unwrap(), + ]), + expected_result: true, + }, + TestCase { + remote_url: "tcp://192.168.1.6:15888".to_string(), + whitelist: Some(vec![ + "192.168.1.5/32".parse().unwrap(), + ]), + expected_result: false, + }, + + // Test invalid URL (this case will fail during URL parsing) + TestCase { + remote_url: "invalid-url".to_string(), + whitelist: None, + expected_result: false, + }, + + // Test URL without IP address (this case will fail during IP parsing) + TestCase { + remote_url: "tcp://localhost:15888".to_string(), + whitelist: None, + expected_result: false, + }, + ]; + + for case in test_cases { + let hook = InstanceRpcServerHook::new(case.whitelist.clone()); + let tunnel_info = Some(crate::proto::common::TunnelInfo { + remote_addr: Some(crate::proto::common::Url { + url: case.remote_url.clone(), + }), + ..Default::default() + }); + + let result = hook.on_new_client(tunnel_info).await; + if case.expected_result { + assert!(result.is_ok(), "Expected success for remote_url:{},whitelist:{:?},but got: {:?}", case.remote_url, case.whitelist, result); + } else { + assert!(result.is_err(), "Expected failure for remote_url:{},whitelist:{:?},but got: {:?}", case.remote_url, case.whitelist, result); + } + } + + } +} \ No newline at end of file diff --git a/easytier/src/launcher.rs b/easytier/src/launcher.rs index 1465eeb..bd5ba72 100644 --- a/easytier/src/launcher.rs +++ b/easytier/src/launcher.rs @@ -527,6 +527,20 @@ impl NetworkConfig { .with_context(|| format!("failed to parse rpc portal port: {:?}", self.rpc_port))?, ); + if self.rpc_portal_whitelists.is_empty() { + cfg.set_rpc_portal_whitelist(None); + } else { + cfg.set_rpc_portal_whitelist(Some( + self.rpc_portal_whitelists + .iter() + .map(|s| { + s.parse() + .with_context(|| format!("failed to parse rpc portal whitelist: {}", s)) + }) + .collect::, _>>()?, + )); + } + if self.enable_vpn_portal.unwrap_or_default() { let cidr = format!( "{}/{}", diff --git a/easytier/src/proto/rpc_impl/standalone.rs b/easytier/src/proto/rpc_impl/standalone.rs index 54ee94f..32200ab 100644 --- a/easytier/src/proto/rpc_impl/standalone.rs +++ b/easytier/src/proto/rpc_impl/standalone.rs @@ -21,7 +21,12 @@ use super::service_registry::ServiceRegistry; #[async_trait::async_trait] #[auto_impl::auto_impl(Arc, Box)] pub trait RpcServerHook: Send + Sync { - async fn on_new_client(&self, _tunnel_info: Option) {} + async fn on_new_client( + &self, + tunnel_info: Option, + ) -> Result, anyhow::Error> { + Ok(tunnel_info) + } async fn on_client_disconnected(&self, _tunnel_info: Option) {} } @@ -72,7 +77,13 @@ impl StandAloneServer { let inflight_server = inflight.clone(); let hook = hook.clone(); - hook.on_new_client(tunnel_info.clone()).await; + let tunnel_info = match hook.on_new_client(tunnel_info).await { + Ok(info) => info, + Err(e) => { + tracing::warn!(?e, "standalone hook.on_new_client failed"); + continue; + } + }; inflight_server.fetch_add(1, std::sync::atomic::Ordering::Relaxed); tasks.lock().unwrap().spawn(async move { diff --git a/easytier/src/proto/web.proto b/easytier/src/proto/web.proto index c0b4f37..c909ea5 100644 --- a/easytier/src/proto/web.proto +++ b/easytier/src/proto/web.proto @@ -66,6 +66,8 @@ message NetworkConfig { optional bool enable_magic_dns = 42; optional bool enable_private_mode = 43; + + repeated string rpc_portal_whitelists = 44; } message MyNodeInfo {