Added RPC portal whitelist function, allowing only local access by default to enhance security (#929)

This commit is contained in:
Mg Pig
2025-06-07 22:05:47 +08:00
committed by GitHub
parent 707963c0d9
commit 20a6025075
12 changed files with 260 additions and 8 deletions

View File

@@ -304,6 +304,15 @@ const bool_flags: BoolFlag[] = [
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap w-full">
<div class="flex flex-col gap-2 grow p-fluid">
<label for="">{{ t('rpc_portal_whitelists') }}</label>
<AutoComplete id="rpc_portal_whitelists" v-model="curNetwork.rpc_portal_whitelists"
:placeholder="t('chips_placeholder', ['127.0.0.0/8'])" class="w-full" multiple fluid
:suggestions="inetSuggestions" @complete="searchInetSuggestions" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-col gap-2 basis-5/12 grow">
<label for="dev_name">{{ t('dev_name') }}</label>

View File

@@ -18,6 +18,7 @@ advanced_settings: 高级设置
basic_settings: 基础设置
listener_urls: 监听地址
rpc_port: RPC端口
rpc_portal_whitelists: RPC白名单
config_network: 配置网络
running: 运行中
error_msg: 错误信息

View File

@@ -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

View File

@@ -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: [],
}
}

View File

@@ -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:

View File

@@ -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<SocketAddr>;
fn set_rpc_portal(&self, addr: SocketAddr);
fn get_rpc_portal_whitelist(&self) -> Option<Vec<IpCidr>>;
fn set_rpc_portal_whitelist(&self, whitelist: Option<Vec<IpCidr>>);
fn get_vpn_portal_config(&self) -> Option<VpnPortalConfig>;
fn set_vpn_portal_config(&self, config: VpnPortalConfig);
@@ -243,6 +247,7 @@ struct Config {
console_logger: Option<ConsoleLoggerConfig>,
rpc_portal: Option<SocketAddr>,
rpc_portal_whitelist: Option<Vec<IpCidr>>,
vpn_portal_config: Option<VpnPortalConfig>,
@@ -544,6 +549,14 @@ impl ConfigLoader for TomlConfigLoader {
self.config.lock().unwrap().rpc_portal = Some(addr);
}
fn get_rpc_portal_whitelist(&self) -> Option<Vec<IpCidr>> {
self.config.lock().unwrap().rpc_portal_whitelist.clone()
}
fn set_rpc_portal_whitelist(&self, whitelist: Option<Vec<IpCidr>>) {
self.config.lock().unwrap().rpc_portal_whitelist = whitelist;
}
fn get_vpn_portal_config(&self) -> Option<VpnPortalConfig> {
self.config.lock().unwrap().vpn_portal_config.clone()
}

View File

@@ -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<String>,
#[arg(
long,
env = "ET_RPC_PORTAL_WHITELIST",
value_delimiter = ',',
help = t!("core_clap.rpc_portal_whitelist").to_string(),
)]
rpc_portal_whitelist: Option<Vec<IpCidr>>,
#[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 {

View File

@@ -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<TunnelInfo>) {
println!("New client connected: {:?}", tunnel_info);
async fn on_new_client(&self, tunnel_info: Option<TunnelInfo>)-> Result<Option<TunnelInfo>, anyhow::Error> {
tracing::info!(?tunnel_info, "New client connected");
Ok(tunnel_info)
}
async fn on_client_disconnected(&self, tunnel_info: Option<TunnelInfo>) {
println!("Client disconnected: {:?}", tunnel_info);
tracing::info!(?tunnel_info, "Client disconnected");
let Some(tunnel_info) = tunnel_info else {
return;
};

View File

@@ -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<Mutex<Option<NicCtxContainer>>>;
pub struct InstanceRpcServerHook {
rpc_portal_whitelist: Vec<IpCidr>,
}
impl InstanceRpcServerHook {
pub fn new(rpc_portal_whitelist: Option<Vec<IpCidr>>) -> 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<TunnelInfo>,
) -> Result<Option<TunnelInfo>, 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<Vec<IpCidr>>,
expected_result: bool,
}
let test_cases:Vec<TestCase> = 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);
}
}
}
}

View File

@@ -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::<Result<Vec<_>, _>>()?,
));
}
if self.enable_vpn_portal.unwrap_or_default() {
let cidr = format!(
"{}/{}",

View File

@@ -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<TunnelInfo>) {}
async fn on_new_client(
&self,
tunnel_info: Option<TunnelInfo>,
) -> Result<Option<TunnelInfo>, anyhow::Error> {
Ok(tunnel_info)
}
async fn on_client_disconnected(&self, _tunnel_info: Option<TunnelInfo>) {}
}
@@ -72,7 +77,13 @@ impl<L: TunnelListener + 'static> StandAloneServer<L> {
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 {

View File

@@ -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 {