mod health;
mod p2p;
mod plugin;
mod plugin_bridge;
mod registry;
mod store;
mod udp_proxy;
mod visitor;
mod vnet;
mod web;
use std::{sync::Arc, time::Duration};
use anyhow::{anyhow, bail, Context, Result};
use clap::Parser;
use nfrp_common::{
config::{default_config_path, is_kcp, is_quic, ClientConfig, Protocol, ProxyConfig},
frp::{
self,
conn::{self, FrpConn},
msg::{self, FrpMessage, NewProxy, Ping},
mux::{MuxKeepalive, MuxSession},
stream::BoxStream,
tls,
},
proxy_protocol, throttle, util, ws,
};
use tokio::io::AsyncWriteExt;
use tokio::{
net::{TcpSocket, TcpStream, UdpSocket},
time::interval,
};
use tracing::{debug, error, info, warn};
#[derive(Parser, Debug)]
#[command(name = "nfrp-client", version, about = "NFrp 客户端(兼容原版 frp)")]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(short = 'v', long = "frp-version")]
frp_version: bool,
#[arg(short, long, value_name = "PATH")]
config: Option<std::path::PathBuf>,
#[arg(long)]
print_example: bool,
#[arg(long, value_name = "PATH")]
gen_config: Option<std::path::PathBuf>,
#[arg(long, value_name = "LEVEL")]
log_level: Option<String>,
#[arg(long, value_name = "PROTOCOL")]
protocol: Option<String>,
#[arg(long, value_name = "ADDR")]
server_addr: Option<String>,
#[arg(short, long, value_name = "PORT")]
server_port: Option<u16>,
#[arg(short, long, value_name = "TOKEN")]
token: Option<String>,
}
#[derive(clap::Subcommand, Debug)]
enum Command {
Verify(VerifyArgs),
Status(AdminArgs),
Stop(AdminArgs),
}
#[derive(clap::Args, Debug)]
struct VerifyArgs {
#[arg(short, long, value_name = "PATH")]
config: Option<std::path::PathBuf>,
}
#[derive(clap::Args, Debug)]
struct AdminArgs {
#[arg(short, long, value_name = "PATH")]
config: Option<std::path::PathBuf>,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
if cli.frp_version {
println!("{}", nfrp_common::frp::FRP_WIRE_VERSION);
return Ok(());
}
if let Some(cmd) = &cli.command {
return match cmd {
Command::Verify(a) => cmd_verify(a),
Command::Status(a) => cmd_status(a).await,
Command::Stop(a) => cmd_stop(a).await,
};
}
if cli.print_example {
println!("{}", ClientConfig::example_toml());
return Ok(());
}
if let Some(path) = &cli.gen_config {
ClientConfig::write_example(path)?;
println!("已生成示例配置:{}", path.display());
return Ok(());
}
let path = cli
.config
.clone()
.unwrap_or_else(|| default_config_path("client.toml"));
if !path.exists() {
bail!(
"配置文件不存在:{}\n可先执行:{} --gen-config {}",
path.display(),
std::env::args()
.next()
.unwrap_or_else(|| "nfrp-client".into()),
path.display()
);
}
let mut cfg =
ClientConfig::load(&path).with_context(|| format!("读取配置 {} 失败", path.display()))?;
if let Some(p) = &cli.protocol {
cfg.protocol = p.parse::<Protocol>().map_err(anyhow::Error::msg)?;
}
if let Some(a) = &cli.server_addr {
cfg.server_addr = a.clone();
}
if let Some(p) = cli.server_port {
cfg.server_port = p;
}
if let Some(t) = &cli.token {
cfg.token = t.clone();
}
let level = cli
.log_level
.clone()
.unwrap_or_else(|| cfg.log_level.clone());
util::init_tracing(&level, &cfg.log_to, cfg.max_days, cfg.disable_print_color);
if !cfg.unsupported_fields.is_empty() {
warn!(
"配置里有 {} 项字段是官方 frp 支持、但 nfrp **未实现**的,已按默认值忽略:{} \
—— 它们不会生效(尤其是 useEncryption / useCompression:写了也仍是明文、不压缩)",
cfg.unsupported_fields.len(),
cfg.unsupported_fields.join(", ")
);
}
let wire = cfg.protocol.wire_version().ok_or_else(|| {
anyhow!("当前版本尚未实现 NFrp 自研协议,请把 protocol 设为 frp-v1(默认)或 frp-v2")
})?;
if cfg.proxies.is_empty() && cfg.visitors.is_empty() {
warn!("配置里既没有 [[proxies]] 也没有 [[visitors]],客户端不会做任何转发");
}
for p in &cfg.proxies {
if let Some(bad) = p.has_invalid_proxy_protocol_version() {
warn!(
proxy = %p.name,
value = %bad,
"proxyProtocolVersion 只认 \"v1\" / \"v2\"(大小写不敏感),\
其它值一律**不启用** PROXY 协议,已按不启用处理"
);
}
}
cfg.auth.validate()?;
let token_source = match cfg.auth.method {
nfrp_common::security::AuthMethod::Oidc => {
let ts = nfrp_common::auth::oidc::TokenSource::new(cfg.auth.oidc.clone())?;
info!(
endpoint = %cfg.auth.oidc.token_endpoint_url,
"OIDC 认证:连接前用 Client Credentials 换取 access token(带缓存)"
);
Some(ts)
}
nfrp_common::security::AuthMethod::Token => None,
};
let store = Arc::new(store::Store::from_config(&cfg)?);
let config_proxy_names: std::collections::HashSet<String> =
cfg.proxies.iter().map(|p| p.name.clone()).collect();
if store.is_enabled() {
let restored = store.dynamic();
if !restored.is_empty() {
info!(
count = restored.len(),
names = %store.dynamic_names().join(", "),
"从 store 恢复了动态添加的代理"
);
}
cfg.proxies = store::merge_initial(&cfg.proxies, restored);
}
let cfg = Arc::new(cfg);
let health = health::Monitor::start(&cfg);
info!(
"nfrp-client 启动:连接 {}:{}(线协议 {}),共 {} 个代理 / {} 个访客",
cfg.server_addr,
cfg.server_port,
wire,
cfg.proxies.len(),
cfg.visitors.len()
);
let (session_tx, session_rx) = tokio::sync::watch::channel::<Option<Arc<ClientSession>>>(None);
for v in &cfg.visitors {
let name = v.name.clone();
let rx = session_rx.clone();
let v = v.clone();
let user = Arc::new(cfg.user.clone());
tokio::spawn(async move {
if let Err(e) = visitor::run(rx, v, user).await {
error!("visitor [{name}] 退出:{e:#}");
}
});
}
if cfg.virtual_net.is_enabled() {
cfg.virtual_net.validate()?;
if cfg.virtual_net.server_port == 0 {
bail!(
"开了 [virtualNet] 但没写 serverPort —— 需要与服务端的 vnet_port 一致。\
服务端配置里那句 `vnet_port = <端口>` 就是要填进来的值。"
);
}
let c = cfg.clone();
let ts = token_source.clone();
tokio::spawn(async move {
if let Err(e) = vnet::run(c, ts).await {
error!("VirtualNet 已停止:{e:#}");
}
});
}
let proxies = registry::ProxyTable::from_iter(
cfg.proxies
.iter()
.filter(|p| config_proxy_names.contains(&p.name))
.cloned(),
);
proxies.load_dynamic(
cfg.proxies
.iter()
.filter(|p| !config_proxy_names.contains(&p.name))
.cloned(),
);
for p in cfg.proxies.iter() {
if let Some(Err(e)) = plugin::Plugin::from_proxy(p) {
warn!(
proxy = %p.name,
"代理的插件配置有问题,该代理将无法工作:{e:#}"
);
}
}
let hub = if cfg.web_server.is_enabled() {
check_web_server_is_safe(&cfg)?;
let addr = format!("{}:{}", cfg.web_server.addr, cfg.web_server.port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.with_context(|| {
format!("客户端管理界面绑定 {addr} 失败(检查 [webServer] 的 addr / port)")
})?;
let hub = web::Hub::new(
cfg.clone(),
proxies.clone(),
store.clone(),
health.clone(),
session_rx.clone(),
);
let serving = hub.clone();
tokio::spawn(web::run(listener, serving));
Some(hub)
} else {
None
};
let mut stop_rx = hub.as_ref().map(|h| h.stop_receiver());
let shutdown = util::shutdown_signal();
tokio::pin!(shutdown);
let logged_in_once = Arc::new(std::sync::atomic::AtomicBool::new(false));
loop {
let fut = run_session(SessionDeps {
cfg: cfg.clone(),
session_tx: session_tx.clone(),
health: health.clone(),
logged_in_once: logged_in_once.clone(),
token_source: token_source.clone(),
store: store.clone(),
proxies: proxies.clone(),
hub: hub.clone(),
});
tokio::select! {
r = fut => {
match r {
Ok(()) => info!("与控制服务端的会话结束"),
Err(e) => {
if cfg.login_fail_exit
&& !logged_in_once.load(std::sync::atomic::Ordering::SeqCst)
{
return Err(e).with_context(|| {
format!(
"首次登录 {}:{} 失败;已启用 loginFailExit(默认行为),\
不再重试。想让它一直重试请在配置里写 loginFailExit = false",
cfg.server_addr, cfg.server_port
)
});
}
error!("会话出错:{e:#}");
}
}
}
_ = &mut shutdown => {
info!("客户端已停止");
return Ok(());
}
_ = wait_stop(&mut stop_rx) => {
info!("收到 stop 命令(来自本地管理界面),客户端退出");
return Ok(());
}
}
let _ = session_tx.send(None);
info!("{} 秒后重连…", cfg.reconnect_interval);
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(cfg.reconnect_interval)) => {}
_ = &mut shutdown => {
info!("客户端已停止");
return Ok(());
}
_ = wait_stop(&mut stop_rx) => {
info!("收到 stop 命令(来自本地管理界面),客户端退出");
return Ok(());
}
}
}
}
async fn wait_stop(rx: &mut Option<tokio::sync::watch::Receiver<bool>>) {
match rx.as_mut() {
Some(r) => {
if *r.borrow() {
return;
}
let _ = r.changed().await;
}
None => std::future::pending::<()>().await,
}
}
fn cmd_verify(a: &VerifyArgs) -> Result<()> {
let path = a
.config
.clone()
.unwrap_or_else(|| default_config_path("client.toml"));
if !path.exists() {
bail!("配置文件不存在:{}", path.display());
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("读取配置 {} 失败", path.display()))?;
match nfrp_common::config::parse_client(&raw) {
Ok(cfg) => {
if let Err(e) = check_web_server_is_safe(&cfg) {
eprintln!("配置校验失败:{}", path.display());
eprintln!("{e:#}");
std::process::exit(1);
}
println!("配置校验通过:{}", path.display());
println!(" 服务端:{}:{}", cfg.server_addr, cfg.server_port);
println!(
" 代理 {} 个 / 访客 {} 个",
cfg.proxies.len(),
cfg.visitors.len()
);
for p in &cfg.proxies {
if p.plugin.is_empty() {
println!(" - [{}] {} -> {}", p.name, p.proxy_type, p.local_addr);
} else {
let up = if p.plugin_local_addr.is_empty() {
&p.local_addr
} else {
&p.plugin_local_addr
};
println!(
" - [{}] {} + 插件 [{}] -> {}",
p.name, p.proxy_type, p.plugin, up
);
}
}
for v in &cfg.visitors {
println!(
" - [{}] {} -> {}.{}",
v.name, v.visitor_type, v.server_user, v.server_name
);
}
if !cfg.unsupported_fields.is_empty() {
println!(
" 警告:{} 项是官方 frp 支持、但 NFrp 未实现的字段(已忽略):{}",
cfg.unsupported_fields.len(),
cfg.unsupported_fields.join(", ")
);
}
Ok(())
}
Err(e) => {
eprintln!("配置校验失败:{}", path.display());
eprintln!("{e:#}");
std::process::exit(1);
}
}
}
fn check_web_server_is_safe(cfg: &ClientConfig) -> Result<()> {
let has_creds =
nfrp_common::config::has_dashboard_creds(&cfg.web_server.user, &cfg.web_server.password);
if !has_creds
&& !nfrp_common::util::is_loopback_addr(&cfg.web_server.addr)
&& !cfg.web_server.allow_insecure_remote
{
bail!(
"管理界面配置为监听 {}:{} 却没有配置**完整的** user/password(两者都不能为空)—— \
任何能访问这个端口的人都能增删你的隧道、并可直接停止客户端。\n\
请二选一:① 配置 [webServer] user + password;\
② 把 addr 改回 127.0.0.1;\
③ 确实要裸奔就显式写 allowInsecureRemote = true(自担风险)。",
cfg.web_server.addr,
cfg.web_server.port
);
}
Ok(())
}
fn admin_base(cfg: &ClientConfig) -> Result<String> {
if !cfg.web_server.is_enabled() {
bail!(
"配置里没有启用 [webServer],无法定位运行中的客户端。\n\
请在配置里加上:\n [webServer]\n addr = \"127.0.0.1\"\n port = 7400"
);
}
let host = if cfg.web_server.addr.trim().is_empty() {
"127.0.0.1"
} else {
cfg.web_server.addr.trim()
};
Ok(format!("http://{host}:{}", cfg.web_server.port))
}
fn load_admin_base(a: &AdminArgs) -> Result<String> {
let path = a
.config
.clone()
.unwrap_or_else(|| default_config_path("client.toml"));
if !path.exists() {
bail!("配置文件不存在:{}", path.display());
}
let cfg =
ClientConfig::load(&path).with_context(|| format!("读取配置 {} 失败", path.display()))?;
admin_base(&cfg)
}
async fn cmd_status(a: &AdminArgs) -> Result<()> {
let base = load_admin_base(a)?;
let url = format!("{base}/api/status");
let resp = nfrp_common::httpc::get(&url, &[], &Default::default())
.await
.with_context(|| format!("请求 {url} 失败(客户端没在跑?或 [webServer] 地址不对)"))?;
println!("{}", String::from_utf8_lossy(&resp.body));
if resp.status != 200 {
std::process::exit(1);
}
Ok(())
}
async fn cmd_stop(a: &AdminArgs) -> Result<()> {
let base = load_admin_base(a)?;
let url = format!("{base}/api/stop");
let resp = nfrp_common::httpc::request("POST", &url, &[], None, &Default::default())
.await
.with_context(|| format!("请求 {url} 失败(客户端没在跑?或 [webServer] 地址不对)"))?;
println!("{}", String::from_utf8_lossy(&resp.body).trim());
if resp.status != 200 {
std::process::exit(1);
}
Ok(())
}
pub(crate) struct ClientSession {
pub(crate) link: Arc<ServerLink>,
pub(crate) run_id: Arc<String>,
pub(crate) p2p: Option<Arc<p2p::P2PRoute>>,
}
struct ServerLink {
cfg: Arc<ClientConfig>,
wire: frp::WireVersion,
mux: Option<MuxSession>,
quic: Option<(quinn::Endpoint, quinn::Connection)>,
udp_binary: std::sync::atomic::AtomicBool,
}
impl ServerLink {
async fn open(cfg: Arc<ClientConfig>) -> Result<Self> {
let wire = cfg.protocol.wire_version().ok_or_else(|| {
anyhow!("当前版本尚未实现 NFrp 自研协议,请把 protocol 设为 frp-v1 或 frp-v2")
})?;
let quic = if is_quic(&cfg.transport_protocol) {
let addr = quic_server_addr(&cfg).await?;
let (endpoint, conn) = frp::quic::connect(&addr, None).await?;
info!(%addr, "QUIC 传输已建立");
Some((endpoint, conn))
} else {
None
};
let mux = if quic.is_none() && cfg.tcp_mux {
let stream = raw_connect(&cfg).await?;
Some(MuxSession::new(
stream,
MuxKeepalive::from_secs(cfg.tcp_mux_keepalive_interval),
))
} else {
None
};
Ok(Self {
cfg,
wire,
mux,
quic,
udp_binary: std::sync::atomic::AtomicBool::new(false),
})
}
async fn connect(&self) -> Result<BoxStream> {
if let Some((_ep, conn)) = &self.quic {
let (send, recv) = conn.open_bi().await.context("在 QUIC 连接上开双向流失败")?;
return Ok(Box::pin(frp::quic::QuicStream::new(send, recv)));
}
match &self.mux {
Some(m) => m.open_stream().await,
None => raw_connect(&self.cfg).await,
}
}
}
async fn quic_server_addr(cfg: &ClientConfig) -> Result<std::net::SocketAddr> {
let s = format!("{}:{}", cfg.server_addr, cfg.server_port);
util::resolve_addr(&s)
.await
.with_context(|| format!("解析服务端 QUIC 地址 {s} 失败"))
}
async fn dial_server(cfg: &ClientConfig, addr: std::net::SocketAddr) -> Result<TcpStream> {
let fut = async {
if cfg.connect_server_local_ip.trim().is_empty() {
return TcpStream::connect(addr).await.map_err(anyhow::Error::from);
}
let local = parse_local_bind(&cfg.connect_server_local_ip)?;
let socket = match (local, addr) {
(std::net::SocketAddr::V4(_), std::net::SocketAddr::V4(_)) => TcpSocket::new_v4(),
(std::net::SocketAddr::V6(_), std::net::SocketAddr::V6(_)) => TcpSocket::new_v6(),
_ => {
return Err(anyhow!(
"connectServerLocalIP 与远端地址的协议族不一致:本地是 {local},远端是 {addr}"
))
}
}?;
socket
.bind(local)
.with_context(|| format!("绑定本地地址 {local} 失败"))?;
socket.connect(addr).await.map_err(anyhow::Error::from)
};
let stream = if cfg.dial_server_timeout == 0 {
fut.await
} else {
let limit = Duration::from_secs(cfg.dial_server_timeout);
tokio::time::timeout(limit, fut).await.map_err(|_| {
anyhow!(
"等待建连超时(dialServerTimeout = {} 秒;设 0 可关闭该超时)",
cfg.dial_server_timeout
)
})?
}?;
if cfg.dial_server_keepalive > 0 {
if let Err(e) = util::set_tcp_keepalive(&stream, cfg.dial_server_keepalive) {
warn!(
"设置 TCP keepalive 失败(dialServerKeepalive = {} 秒):{e}",
cfg.dial_server_keepalive
);
}
}
Ok(stream)
}
fn parse_local_bind(raw: &str) -> Result<std::net::SocketAddr> {
let s = raw.trim();
if s.is_empty() {
return Err(anyhow!("connectServerLocalIP 是空串"));
}
if let Ok(sa) = s.parse::<std::net::SocketAddr>() {
return Ok(sa);
}
if let Ok(ip) = s.parse::<std::net::IpAddr>() {
return Ok(std::net::SocketAddr::new(ip, 0));
}
Err(anyhow!(
"connectServerLocalIP {s:?} 不是合法地址(应为 `IP` 或 `IP:端口`;\
IPv6 带端口要写方括号,如 `[::1]:0`)"
))
}
async fn raw_connect(cfg: &ClientConfig) -> Result<BoxStream> {
if is_kcp(&cfg.transport_protocol) {
return kcp_connect(cfg).await;
}
let server = format!("{}:{}", cfg.server_addr, cfg.server_port);
let addr = util::resolve_addr(&server)
.await
.with_context(|| format!("解析服务端地址 {server} 失败"))?;
let stream = dial_server(cfg, addr)
.await
.with_context(|| format!("连接 {addr} 失败"))?;
stream.set_nodelay(true).ok();
let name = if cfg.tls_server_name.is_empty() {
&cfg.server_addr
} else {
&cfg.tls_server_name
};
let tls_on = cfg.tls_enable || cfg.websocket_tls();
if cfg.tls_verify_server_cert && !tls_on {
warn!(
"配置里写了 `verifyServerCert = true`,但 **TLS 没有开启** —— \
证书校验**不会生效**,且链路是**明文**:\
登录凭据(privilege_key)可被链路上任何人嗅探并重放。\
请同时配上 TLS:`[transport.tls] enable = true`(或 `tls_enable = true`)。"
);
}
if cfg.tls_verify_server_cert && tls_on {
info!(
server_name = %name,
trusted_ca = %if cfg.tls_trusted_ca_file.is_empty() {
"(未配 trustedCaFile,只用内置根证书)".to_string()
} else {
cfg.tls_trusted_ca_file.clone()
},
"已开启服务端证书校验(verifyServerCert = true)"
);
}
let stream = tls::connect_client(
stream,
tls_on,
name,
!cfg.tls_custom_first_byte,
cfg.tls_verify_server_cert,
&cfg.tls_trusted_ca_file,
)
.await?;
if !cfg.websocket_enabled() {
return Ok(stream);
}
if tls_on && !cfg.websocket_tls() {
warn!(
"同时打开了 transport.tls 与 WebSocket:官方 frps 在 TLS **之前**嗅探 \
WebSocket 前缀,这种组合直连主端口会握手失败;\
如果要 wss,请把 protocol 设为 \"wss\" 并由前置代理(nginx 等)终结 TLS"
);
}
let host = if cfg.websocket.host.trim().is_empty() {
cfg.server_addr.as_str()
} else {
cfg.websocket.host.as_str()
};
let path = cfg.websocket.effective_path();
let ws = ws::connect(stream, host, path, &[])
.await
.with_context(|| format!("WebSocket 握手失败({host}{path})"))?;
info!(%host, %path, tls = tls_on, "控制连接改走 WebSocket 传输");
Ok(Box::pin(ws))
}
async fn kcp_connect(cfg: &ClientConfig) -> Result<BoxStream> {
let server = format!("{}:{}", cfg.server_addr, cfg.server_port);
let addr = util::resolve_addr(&server)
.await
.with_context(|| format!("解析服务端 KCP 地址 {server} 失败"))?;
let sock = Arc::new(
UdpSocket::bind("0.0.0.0:0")
.await
.context("绑定本地 UDP 端口失败(KCP 传输需要)")?,
);
let conv = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
| 1;
let stream = frp::kcp::KcpStream::connect(sock, addr, conv).await;
info!(%addr, conv, "KCP 传输已建立");
Ok(Box::pin(stream))
}
fn resolve_uploaded_proxy(
user: &str,
wire_name: &str,
proxies: ®istry::ProxyTable,
) -> Option<(String, Arc<ProxyConfig>)> {
let raw = util::strip_user_prefix(user, wire_name).to_string();
proxies.get(&raw).map(|p| (raw, p))
}
pub(crate) async fn current_credential(
cfg: &ClientConfig,
token_source: &Option<Arc<nfrp_common::auth::oidc::TokenSource>>,
) -> Result<nfrp_common::security::Credential> {
Ok(match token_source {
Some(src) => nfrp_common::security::Credential::Oidc(
src.token()
.await
.context("向 IdP 换取 OIDC access token 失败")?,
),
None => nfrp_common::security::Credential::Token(cfg.effective_token().to_string()),
})
}
struct SessionDeps {
cfg: Arc<ClientConfig>,
session_tx: tokio::sync::watch::Sender<Option<Arc<ClientSession>>>,
health: Arc<health::Monitor>,
logged_in_once: Arc<std::sync::atomic::AtomicBool>,
token_source: Option<Arc<nfrp_common::auth::oidc::TokenSource>>,
store: Arc<store::Store>,
proxies: registry::ProxyTable,
hub: Option<Arc<web::Hub>>,
}
async fn run_session(deps: SessionDeps) -> Result<()> {
let SessionDeps {
cfg,
session_tx,
health,
logged_in_once,
token_source,
store,
proxies,
hub,
} = deps;
let server = format!("{}:{}", cfg.server_addr, cfg.server_port);
let link = Arc::new(ServerLink::open(cfg.clone()).await?);
info!(%server, "已连接到服务端,开始握手");
let stream = link.connect().await?;
let declared = msg::NfrpCaps {
udp_binary: cfg.private_caps,
server_cmd: cfg.private_caps,
work_conn_cred: true,
};
let cred = current_credential(&cfg, &token_source).await?;
let (mut conn, run_id, udp_binary, caps) = conn::client_handshake(
stream,
link.wire,
&cred,
&cfg.client_id,
&cfg.user,
&cfg.metas,
cfg.pool_count,
declared,
)
.await?;
link.udp_binary
.store(udp_binary, std::sync::atomic::Ordering::Relaxed);
if udp_binary {
debug!("服务端选择了二进制 UDP 报文编码");
}
if caps.server_cmd {
debug!("服务端已启用私有管理命令(面板可增删本端代理)");
}
info!(%run_id, wire = %link.wire, "登录成功(控制通道已加密)");
logged_in_once.store(true, std::sync::atomic::Ordering::SeqCst);
let run_id = Arc::new(run_id);
let (web_tx, mut web_rx) = tokio::sync::mpsc::unbounded_channel::<web::Request>();
let _web_guard = hub
.as_ref()
.map(|h| web::SessionGuard::new(h.clone(), web_tx));
let (p2p_route, mut punch_rx) = match p2p::setup(&cfg).await {
Some((route, rx)) => (Some(route), Some(rx)),
None => (None, None),
};
if p2p_route.is_none() && cfg.proxies.iter().any(|p| p.proxy_type == "xtcp") {
warn!("有 xtcp 代理但未配置 p2p_port,xtcp 将退化为中继(与 stcp 相同)");
}
let _ = session_tx.send(Some(Arc::new(ClientSession {
link: link.clone(),
run_id: run_id.clone(),
p2p: p2p_route.clone(),
})));
for p in &cfg.proxies {
let msg = NewProxy::from_config(p, &cfg.user);
conn.send_msg(&FrpMessage::NewProxy(msg)).await?;
}
let mut pending: std::collections::HashSet<String> =
cfg.proxies.iter().map(|p| p.name.clone()).collect();
while !pending.is_empty() {
let msg = tokio::time::timeout(Duration::from_secs(15), conn.recv_msg())
.await
.context("等待 NewProxyResp 超时")??;
match msg {
Some(FrpMessage::NewProxyResp(r)) => {
let raw = util::strip_user_prefix(&cfg.user, &r.proxy_name).to_string();
pending.remove(&raw);
let local = match resolve_uploaded_proxy(&cfg.user, &r.proxy_name, &proxies) {
Some((_, p)) => p.local_addr.clone(),
None => "?".to_string(),
};
if r.error.is_empty() {
info!(proxy = %raw, remote = %r.remote_addr, local, "代理注册成功");
} else {
error!(proxy = %raw, "代理注册失败:{}", r.error);
}
}
Some(FrpMessage::ReqWorkConn) => {
spawn_work_conn(
link.clone(),
run_id.clone(),
proxies.clone(),
health.clone(),
);
}
Some(other) => debug!("注册期间收到 {},忽略", other.name()),
None => bail!("服务端在代理注册完成前断开"),
}
}
for _ in 0..cfg.pool_count.max(0) {
spawn_work_conn(
link.clone(),
run_id.clone(),
proxies.clone(),
health.clone(),
);
}
let mut ticker = interval(Duration::from_secs(cfg.heartbeat_interval.max(1)));
ticker.tick().await; let mut last_pong = std::time::Instant::now();
loop {
tokio::select! {
msg = conn.recv_msg() => {
let Some(msg) = msg? else {
info!("服务端关闭了控制连接");
if let Some(route) = &p2p_route {
route.bus.clear();
}
break;
};
match msg {
FrpMessage::ReqWorkConn => {
debug!("收到 ReqWorkConn,建立工作连接");
spawn_work_conn(
link.clone(),
run_id.clone(),
proxies.clone(),
health.clone(),
);
}
FrpMessage::Pong(p) => {
if !p.error.is_empty() {
warn!("服务端 Pong 返回错误:{}", p.error);
}
last_pong = std::time::Instant::now();
}
FrpMessage::NewProxyResp(_) => {}
FrpMessage::NatHoleClient(m) => {
debug!(proxy = %m.proxy_name, sid = %m.sid, "收到打洞通知");
match resolve_uploaded_proxy(&cfg.user, &m.proxy_name, &proxies) {
Some((raw, proxy)) => {
debug!(proxy = %raw, "本端作为 provider 参与打洞");
let cfg = cfg.clone();
tokio::spawn(async move {
let proxy = (*proxy).clone();
if let Err(e) = p2p::serve_as_provider(cfg, proxy, m).await {
debug!("xtcp 打洞未成功(访客会回退中继):{e:#}");
}
});
}
None => warn!(proxy = %m.proxy_name, "收到未知代理的打洞通知,忽略"),
}
}
FrpMessage::ServerCmd(cmd) => {
if !caps.server_cmd {
warn!(op = %cmd.op, "收到未协商的私有管理命令,忽略");
continue;
}
let resp = apply_server_cmd(&proxies, &cfg.user, &store, &cmd, &mut conn).await;
if let Err(e) = &resp {
warn!(op = %cmd.op, "回执发送失败:{e:#}");
}
}
FrpMessage::NatHoleResp(r) => match &p2p_route {
Some(route) => route.bus.deliver(r),
None => debug!("未启用 P2P,忽略 NatHoleResp"),
},
other => debug!("忽略消息:{}", other.name()),
}
}
Some(req) = async {
match punch_rx.as_mut() {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
} => {
conn.send_msg(&FrpMessage::NatHoleVisitor(req)).await?;
}
Some(req) = web_rx.recv() => {
web::handle_request(
req,
&mut conn,
&link,
&run_id,
&proxies,
&store,
&health,
&cfg,
)
.await;
}
_ = ticker.tick() => {
conn.send_msg(&FrpMessage::Ping(Ping {
timestamp: util::now_unix_secs() as i64,
..Default::default()
}))
.await?;
if last_pong.elapsed() > Duration::from_secs(cfg.heartbeat_timeout) {
bail!("{} 秒未收到 Pong,判定连接已死", cfg.heartbeat_timeout);
}
}
}
}
Ok(())
}
async fn apply_server_cmd(
proxies: ®istry::ProxyTable,
user: &str,
store: &store::Store,
cmd: &msg::ServerCmd,
conn: &mut FrpConn,
) -> Result<()> {
let result = match cmd.op.as_str() {
msg::CMD_ADD_PROXY => add_proxy_cmd(proxies, store, cmd),
msg::CMD_REMOVE_PROXY => {
let requested = cmd.proxy_name.clone();
if requested.is_empty() {
Err(anyhow!("remove_proxy 缺少 proxy_name"))
} else {
let mut name = requested.clone();
let mut removed = proxies.remove_dynamic(&name);
if matches!(removed, Ok(None)) {
let local = util::strip_user_prefix(user, &requested);
if local != requested {
let local = local.to_string();
let again = proxies.remove_dynamic(&local);
if !matches!(again, Ok(None)) {
name = local;
removed = again;
}
}
}
match removed {
Err(e) => Err(anyhow!(e)),
Ok(None) => {
let have = proxies.names().join(", ");
Err(anyhow!(
"本地没有名为 [{requested}] 的代理(当前有:{})",
if have.is_empty() {
"无".to_string()
} else {
have
}
))
}
Ok(Some(_)) => {
if let Err(e) = store.remove(&name) {
warn!(proxy = %name, error = %e, "从 store 删除失败(内存里已移除)");
}
info!(proxy = %name, total = proxies.len(), reason = %cmd.reason, "按服务端命令移除代理");
Ok(())
}
}
}
}
other => Err(anyhow!("未知命令:{other}")),
};
let resp = msg::ServerCmdResp {
id: cmd.id.clone(),
op: cmd.op.clone(),
proxy_name: cmd.proxy_name.clone(),
error: result
.as_ref()
.map_err(|e| e.to_string())
.err()
.unwrap_or_default(),
};
conn.send_msg(&FrpMessage::ServerCmdResp(resp)).await?;
result.map(|_| ())
}
fn add_proxy_cmd(
proxies: ®istry::ProxyTable,
store: &store::Store,
cmd: &msg::ServerCmd,
) -> Result<()> {
let p: ProxyConfig = cmd.proxy_config().map_err(anyhow::Error::msg)?;
if p.name.is_empty() {
anyhow::bail!("代理配置缺少 name");
}
if p.proxy_type.is_empty() {
anyhow::bail!("代理配置缺少 type");
}
nfrp_common::config::validate_remote_proxy(&p, true)
.map_err(|e| anyhow::anyhow!("服务端下发的代理配置未通过校验:{e}"))?;
let name = p.name.clone();
let existed = proxies
.insert_dynamic(p.clone())
.map_err(|e| anyhow::anyhow!(e))?
.is_some();
if existed {
warn!(proxy = %name, "覆盖了同名的已有代理");
}
if let Err(e) = store.put(&p) {
warn!(proxy = %name, error = %e, "写入 store 失败:重启后这条代理会丢失");
}
info!(proxy = %name, r#type = %p.proxy_type, total = proxies.len(), reason = %cmd.reason, "按服务端命令新增代理");
Ok(())
}
fn spawn_work_conn(
link: Arc<ServerLink>,
run_id: Arc<String>,
proxies: registry::ProxyTable,
health: Arc<health::Monitor>,
) {
tokio::spawn(async move {
if let Err(e) = work_conn_flow(link, run_id, proxies, health).await {
debug!("工作连接结束:{e:#}");
}
});
}
async fn work_conn_flow(
link: Arc<ServerLink>,
run_id: Arc<String>,
proxies: registry::ProxyTable,
health: Arc<health::Monitor>,
) -> Result<()> {
let stream = link.connect().await?;
let ts = util::now_unix_secs() as i64;
let (mut work, leftover, start) =
conn::client_work_conn(stream, link.wire, &run_id, &link.cfg.token, ts).await?;
let (proxy_name, proxy) =
resolve_uploaded_proxy(&link.cfg.user, &start.proxy_name, &proxies)
.ok_or_else(|| anyhow!("服务端指定了未知代理:{}", start.proxy_name))?;
let proxy = proxy.clone();
if !health.is_healthy(&proxy_name) {
anyhow::bail!("代理 [{proxy_name}] 健康检查未通过,暂不提供服务");
}
if matches!(proxy.proxy_type.as_str(), "udp" | "sudp") {
let mut udp_conn = FrpConn::new(work, link.wire);
udp_conn.set_udp_codec(link.udp_binary.load(std::sync::atomic::Ordering::Relaxed));
return udp_proxy::run(udp_conn, proxy.local_addr.clone(), proxy_name.to_string()).await;
}
if let Some(built) = plugin::Plugin::from_proxy(&proxy) {
let plug = built.with_context(|| format!("代理 [{}] 的插件配置有误", proxy.name))?;
debug!(proxy = %start.proxy_name, "工作连接交给插件处理");
let src = if start.src_addr.is_empty() || start.src_port == 0 {
None
} else {
util::resolve_addr(&format!("{}:{}", start.src_addr, start.src_port))
.await
.ok()
};
let ctx = plugin::Ctx {
proxy_name: &start.proxy_name,
src,
};
return plug.serve(work, leftover, &ctx).await;
}
let local_addr = &proxy.local_addr;
let local = util::resolve_addr(local_addr)
.await
.with_context(|| format!("解析内网地址 {local_addr} 失败"))?;
let mut local_stream = TcpStream::connect(local)
.await
.with_context(|| format!("连接内网服务 {local} 失败"))?;
local_stream.set_nodelay(true).ok();
debug!(proxy = %start.proxy_name, %local, "工作连接已建立,开始转发");
if let Some(ver) = proxy.proxy_protocol_version() {
if start.src_addr.is_empty() || start.src_port == 0 {
debug!(
proxy = %start.proxy_name,
"服务端没下发来源地址(官方 frps 才有),PROXY 头跳过"
);
} else {
let dst_ip = if start.dst_addr.trim().is_empty() {
"127.0.0.1".to_string()
} else {
start.dst_addr.clone()
};
let src = util::resolve_addr(&format!("{}:{}", start.src_addr, start.src_port))
.await
.with_context(|| format!("解析 PROXY 头源地址 {} 失败", start.src_addr))?;
let dst = util::resolve_addr(&format!("{}:{}", dst_ip, start.dst_port))
.await
.with_context(|| format!("解析 PROXY 头目的地址 {dst_ip} 失败"))?;
let head = proxy_protocol::encode(&src, &dst, ver)
.with_context(|| format!("生成 PROXY {ver} 头失败"))?;
local_stream
.write_all(&head)
.await
.context("向内网服务写 PROXY 头失败")?;
debug!(
proxy = %start.proxy_name,
version = ver,
%src,
%dst,
"已向内网服务发送 PROXY 协议头"
);
}
}
if !leftover.is_empty() {
local_stream.write_all(&leftover).await?;
}
let limit = throttle::parse_bandwidth(&proxy.bandwidth_limit).unwrap_or(0);
let r = if limit > 0 {
debug!(proxy = %start.proxy_name, limit, "按 {} 字节/秒限速", limit);
throttle::relay_throttled(work, local_stream, limit, limit).await
} else {
util::relay_between(&mut work, &mut local_stream).await
};
if let Err(e) = r {
debug!(proxy = %start.proxy_name, "转发中断:{e}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn dial_server_按配置开_keepalive() {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = l.accept().await {
drop(stream);
}
});
let cfg = ClientConfig {
dial_server_timeout: 5,
dial_server_keepalive: 321,
..Default::default()
};
let s = dial_server(&cfg, addr).await.unwrap();
assert!(
util::tcp_keepalive_enabled(&s).unwrap(),
"dialServerKeepalive > 0 时必须开 SO_KEEPALIVE"
);
#[cfg(unix)]
assert_eq!(
util::tcp_keepalive_idle(&s).unwrap(),
std::time::Duration::from_secs(321),
"空闲时长要等于 dialServerKeepalive"
);
let cfg0 = ClientConfig {
dial_server_timeout: 5,
dial_server_keepalive: 0,
..Default::default()
};
let s2 = dial_server(&cfg0, addr).await.unwrap();
assert!(
!util::tcp_keepalive_enabled(&s2).unwrap(),
"dialServerKeepalive = 0 时不得开"
);
}
#[test]
fn connect_server_local_ip_解析() {
let a = parse_local_bind("192.168.1.5").unwrap();
assert_eq!(a.ip().to_string(), "192.168.1.5");
assert_eq!(a.port(), 0);
let a = parse_local_bind("192.168.1.5:1234").unwrap();
assert_eq!(a.port(), 1234);
let a = parse_local_bind("::1").unwrap();
assert!(a.is_ipv6());
assert_eq!(a.port(), 0);
let a = parse_local_bind("[::1]:0").unwrap();
assert!(a.is_ipv6());
assert_eq!(a.port(), 0);
assert!(parse_local_bind("not-an-ip").is_err());
assert!(parse_local_bind("").is_err());
}
const USER: &str = "alice";
#[test]
fn 管理界面对外监听却不配凭据必须拒绝启动() {
let mut cfg = ClientConfig::default();
cfg.web_server.port = 7400;
cfg.web_server.addr = "0.0.0.0".into();
let e = check_web_server_is_safe(&cfg)
.expect_err("必须拒绝启动")
.to_string();
assert!(e.contains("user"), "错误里要点名该怎么修:{e}");
cfg.web_server.user = "admin".into();
cfg.web_server.password = "pw".into();
assert!(check_web_server_is_safe(&cfg).is_ok());
cfg.web_server.user.clear();
cfg.web_server.allow_insecure_remote = true;
assert!(check_web_server_is_safe(&cfg).is_ok());
}
#[test]
fn 管理界面只监听回环时不配凭据可以启动() {
let mut cfg = ClientConfig::default();
cfg.web_server.port = 7400;
assert_eq!(cfg.web_server.addr, "127.0.0.1");
assert!(check_web_server_is_safe(&cfg).is_ok());
}
fn full_tcp_config() -> ProxyConfig {
ProxyConfig {
proxy_protocol_version: "v2".into(),
transport: Default::default(),
name: "web-a".into(),
proxy_type: "tcp".into(),
local_addr: "127.0.0.1:8080".into(),
remote_port: 6100,
custom_domains: vec!["a.example.com".into()],
subdomain: "sub".into(),
locations: vec!["/api".into()],
http_user: "hu".into(),
http_pwd: "hp".into(),
host_header_rewrite: "backend.internal".into(),
route_by_http_user: "hu-route".into(),
multiplexer: "httpconnect".into(),
bandwidth_limit: "1MB".into(),
bandwidth_limit_mode: "server".into(),
metas: [("pk".to_string(), "pv".to_string())].into_iter().collect(),
annotations: [("example.com/team".to_string(), "infra".to_string())]
.into_iter()
.collect(),
enabled: None,
group: "web".into(),
group_key: "gk".into(),
health_check_type: "http".into(),
health_check_timeout_s: 7,
health_check_max_failed: 5,
health_check_interval_s: 11,
health_check_url: "/healthz".into(),
plugin: "http_proxy".into(),
plugin_local_path: "/tmp/x".into(),
plugin_strip_prefix: "/p".into(),
plugin_user: "pu".into(),
plugin_passwd: "pp".into(),
plugin_local_addr: "127.0.0.1:9000".into(),
plugin_crt_path: "/tmp/c.crt".into(),
plugin_key_path: "/tmp/c.key".into(),
plugin_host_header_rewrite: "plugin.internal".into(),
plugin_request_headers: [("X-Plugin".to_string(), "yes".to_string())]
.into_iter()
.collect(),
secret_key: "sk".into(),
allow_users: vec!["alice".into()],
}
}
#[test]
fn proxy_protocol_版本解析_两种写法都认() {
let official = ProxyConfig {
proxy_protocol_version: String::new(),
transport: nfrp_common::config::ProxyTransportConfig {
proxy_protocol_version: "v2".into(),
},
..full_tcp_config()
};
assert_eq!(official.proxy_protocol_version(), Some("v2"));
let legacy = ProxyConfig {
proxy_protocol_version: "v1".into(),
transport: Default::default(),
..full_tcp_config()
};
assert_eq!(legacy.proxy_protocol_version(), Some("v1"));
let both = ProxyConfig {
proxy_protocol_version: "v1".into(),
transport: nfrp_common::config::ProxyTransportConfig {
proxy_protocol_version: "v2".into(),
},
..full_tcp_config()
};
assert_eq!(both.proxy_protocol_version(), Some("v2"));
}
#[test]
fn proxy_protocol_写错的值宁可不生效() {
for bad in ["false", "2", "v3", "yes"] {
let c = ProxyConfig {
proxy_protocol_version: bad.into(),
..full_tcp_config()
};
assert_eq!(
c.proxy_protocol_version(),
None,
"{bad:?} 不该被当成任何一版 PROXY 协议"
);
assert_eq!(c.has_invalid_proxy_protocol_version(), Some(bad.trim()));
}
let c = ProxyConfig {
proxy_protocol_version: "V2".into(),
..full_tcp_config()
};
assert_eq!(c.proxy_protocol_version(), Some("v2"));
let c = ProxyConfig {
proxy_protocol_version: String::new(),
..full_tcp_config()
};
assert_eq!(c.proxy_protocol_version(), None);
assert_eq!(c.has_invalid_proxy_protocol_version(), None);
}
#[test]
fn tcp_carries_port_and_group() {
let c = full_tcp_config();
let m = NewProxy::from_config(&c, USER);
assert_eq!(
m.proxy_name, "alice.web-a",
"线协议名必须带 `{{user}}.` 前缀 —— 官方 frpc 的 wireName 就是这么算的,\
少了前缀第三方平台按名字查不到隧道"
);
assert_eq!(m.proxy_type, "tcp");
assert_eq!(m.remote_port, 6100);
assert_eq!(m.group, "web", "group 没搬过去的话负载均衡等于没配");
assert_eq!(m.group_key, "gk");
assert!(m.custom_domains.is_empty());
assert!(m.sk.is_empty());
}
#[test]
fn http_carries_routing_fields() {
let c = ProxyConfig {
proxy_type: "http".into(),
..full_tcp_config()
};
let m = NewProxy::from_config(&c, USER);
assert_eq!(m.proxy_name, "alice.web-a");
assert_eq!(m.custom_domains, vec!["a.example.com".to_string()]);
assert_eq!(m.subdomain, "sub");
assert_eq!(m.locations, vec!["/api".to_string()]);
assert_eq!(m.http_user, "hu");
assert_eq!(m.http_pwd, "hp");
assert_eq!(m.host_header_rewrite, "backend.internal");
assert_eq!(m.group, "web", "http 也应当带上 group");
assert_eq!(m.remote_port, 0);
}
#[test]
fn stcp_carries_secret_and_allow_list_only() {
let c = ProxyConfig {
proxy_type: "stcp".into(),
..full_tcp_config()
};
let m = NewProxy::from_config(&c, USER);
assert_eq!(m.proxy_name, "alice.web-a");
assert_eq!(m.sk, "sk");
assert_eq!(m.allow_users, vec!["alice".to_string()]);
assert_eq!(m.remote_port, 0);
assert!(m.group.is_empty(), "stcp 不走端口组,不该带 group");
}
#[test]
fn tcpmux_carries_multiplexer_and_domain_routing() {
let c = ProxyConfig {
proxy_type: "tcpmux".into(),
..full_tcp_config()
};
let m = NewProxy::from_config(&c, USER);
assert_eq!(m.proxy_name, "alice.web-a");
assert_eq!(m.multiplexer, "httpconnect");
assert_eq!(m.custom_domains, vec!["a.example.com".to_string()]);
assert_eq!(m.subdomain, "sub");
assert_eq!(m.http_user, "hu");
assert_eq!(m.http_pwd, "hp");
assert_eq!(m.route_by_http_user, "hu-route");
assert_eq!(m.remote_port, 0);
assert!(
m.locations.is_empty(),
"官方 tcpmux 没有 locations,不能多发"
);
}
#[test]
fn 与官方_frpc_抓包逐字节一致() {
let cfg = nfrp_common::config::parse_client_toml(LOLIA_FRPC).unwrap();
let m = NewProxy::from_config(&cfg.proxies[0], &cfg.user);
assert_eq!(
serde_json::to_string(&m).unwrap(),
concat!(
r#"{"proxy_name":"2569.c0462d9ce7e44bce97e626c4ae880905","#,
r#""proxy_type":"tcp","#,
r#""bandwidth_limit":"25MB","#,
r#""bandwidth_limit_mode":"server","#,
r#""remote_port":38725}"#,
)
);
}
const LOLIA_FRPC: &str = r#"
serverAddr = 'cn-hz-2.qwq.fan'
serverPort = 30000
user = '2569'
[metadatas]
token = 'x8p5mo0u8ips3lmohc67r58mejp7uthf'
[[proxies]]
name = 'c0462d9ce7e44bce97e626c4ae880905'
type = 'tcp'
localIP = '127.0.0.1'
localPort = 25565
remotePort = 38725
[proxies.transport]
bandwidthLimit = '25MB'
bandwidthLimitMode = 'server'
"#;
#[test]
fn 空_user_不加前缀() {
let m = NewProxy::from_config(&full_tcp_config(), "");
assert_eq!(m.proxy_name, "web-a");
}
#[test]
fn 默认限流模式不上报() {
for raw in ["", "client", "CLIENT"] {
let c = ProxyConfig {
bandwidth_limit_mode: raw.into(),
..full_tcp_config()
};
assert!(
NewProxy::from_config(&c, USER)
.bandwidth_limit_mode
.is_empty(),
"值 {raw:?} 应当被归一化成空串(即不发送)"
);
}
let c = ProxyConfig {
bandwidth_limit_mode: "server".into(),
..full_tcp_config()
};
assert_eq!(
NewProxy::from_config(&c, USER).bandwidth_limit_mode,
"server"
);
}
#[test]
fn 代理级_metas_才上进注册报文() {
let cfg = nfrp_common::config::parse_client_toml(LOLIA_FRPC).unwrap();
assert_eq!(
cfg.metas.get("token").map(String::as_str),
Some("x8p5mo0u8ips3lmohc67r58mejp7uthf")
);
let m = NewProxy::from_config(&cfg.proxies[0], &cfg.user);
assert!(
m.metas.is_empty(),
"顶层 [metadatas] 只进登录消息,不该出现在 NewProxy 里"
);
}
#[test]
fn absent_group_stays_empty() {
let c = ProxyConfig {
group: String::new(),
group_key: String::new(),
..full_tcp_config()
};
let m = NewProxy::from_config(&c, USER);
assert!(m.group.is_empty());
assert!(m.group_key.is_empty());
}
#[test]
fn every_shareable_field_is_carried() {
let c = full_tcp_config();
let tcp = NewProxy::from_config(&c, USER);
assert_eq!(tcp.remote_port, c.remote_port);
assert_eq!(tcp.group, c.group);
assert_eq!(tcp.group_key, c.group_key);
assert_eq!(tcp.bandwidth_limit, c.bandwidth_limit);
assert_eq!(tcp.bandwidth_limit_mode, c.bandwidth_limit_mode);
assert_eq!(tcp.metas, c.metas);
assert_eq!(tcp.annotations, c.annotations);
let http = NewProxy::from_config(
&ProxyConfig {
proxy_type: "http".into(),
..c.clone()
},
USER,
);
assert_eq!(http.custom_domains, c.custom_domains);
assert_eq!(http.subdomain, c.subdomain);
assert_eq!(http.locations, c.locations);
assert_eq!(http.http_user, c.http_user);
assert_eq!(http.http_pwd, c.http_pwd);
assert_eq!(http.host_header_rewrite, c.host_header_rewrite);
let stcp = NewProxy::from_config(
&ProxyConfig {
proxy_type: "stcp".into(),
..c.clone()
},
USER,
);
assert_eq!(stcp.sk, c.secret_key);
assert_eq!(stcp.allow_users, c.allow_users);
}
fn local_map(names: &[&str]) -> std::collections::HashMap<String, ProxyConfig> {
names
.iter()
.map(|n| {
let mut cfg = full_tcp_config();
cfg.name = (*n).to_string();
((*n).to_string(), cfg)
})
.collect()
}
#[test]
fn 带前缀的下发名能查到本地代理() {
let m = registry::ProxyTable::from_iter(local_map(&["p2p-echo"]).into_values());
let (raw, p) = resolve_uploaded_proxy("alice", "alice.p2p-echo", &m)
.expect("带 user 前缀的线上全名应当能查到");
assert_eq!(raw, "p2p-echo", "查到的应当是配置里的原始名");
assert_eq!(p.name, "p2p-echo");
}
#[test]
fn 不带前缀的下发名同样能查到() {
let m = registry::ProxyTable::from_iter(local_map(&["p2p-echo"]).into_values());
let (raw, _) = resolve_uploaded_proxy("alice", "p2p-echo", &m).expect("应当能查到");
assert_eq!(raw, "p2p-echo");
}
#[test]
fn 无_user_时能查到() {
let m = registry::ProxyTable::from_iter(local_map(&["web-a"]).into_values());
assert!(resolve_uploaded_proxy("", "web-a", &m).is_some());
}
#[test]
fn 别人的前缀不会被剥掉() {
let m = registry::ProxyTable::from_iter(local_map(&["web-a"]).into_values());
assert!(
resolve_uploaded_proxy("bob", "alice.web-a", &m).is_none(),
"非本用户的代理名必须查不到"
);
}
#[test]
fn 未知代理返回_none() {
let m = registry::ProxyTable::from_iter(local_map(&["web-a"]).into_values());
assert!(resolve_uploaded_proxy("alice", "alice.nope", &m).is_none());
}
fn cmd_pair() -> (FrpConn, FrpConn) {
let (a, b) = tokio::io::duplex(64 * 1024);
(
FrpConn::new(Box::pin(a), frp::WireVersion::V1),
FrpConn::new(Box::pin(b), frp::WireVersion::V1),
)
}
fn add_cmd(name: &str) -> msg::ServerCmd {
let p = ProxyConfig {
name: name.to_string(),
proxy_type: "tcp".to_string(),
local_addr: "127.0.0.1:8080".to_string(),
remote_port: 7000,
..Default::default()
};
msg::ServerCmd {
id: "cmd-1".to_string(),
op: msg::CMD_ADD_PROXY.to_string(),
proxy_name: name.to_string(),
proxy: Some(serde_json::to_value(&p).expect("序列化")),
reason: "dashboard".to_string(),
}
}
fn no_store() -> store::Store {
store::Store::from_config(&ClientConfig::default()).expect("默认配置永远不该失败")
}
fn add_cmd_with(name: &str, f: impl FnOnce(&mut ProxyConfig)) -> msg::ServerCmd {
let mut p = ProxyConfig {
name: name.to_string(),
proxy_type: "tcp".to_string(),
local_addr: "127.0.0.1:8080".to_string(),
remote_port: 7000,
..Default::default()
};
f(&mut p);
msg::ServerCmd {
id: "cmd-1".to_string(),
op: msg::CMD_ADD_PROXY.to_string(),
proxy_name: name.to_string(),
proxy: Some(serde_json::to_value(&p).expect("序列化")),
reason: "dashboard".to_string(),
}
}
fn remove_cmd(name: &str) -> msg::ServerCmd {
msg::ServerCmd {
id: "cmd-rm".to_string(),
op: msg::CMD_REMOVE_PROXY.to_string(),
proxy_name: name.to_string(),
reason: "dashboard".to_string(),
..Default::default()
}
}
#[tokio::test]
async fn 远程下发的_plugin_local_path_必须被拒绝() {
for (field, set) in [
(
"pluginLocalPath",
Box::new(|p: &mut ProxyConfig| p.plugin_local_path = "/etc".to_string())
as Box<dyn FnOnce(&mut ProxyConfig)>,
),
(
"pluginLocalAddr",
Box::new(|p: &mut ProxyConfig| {
p.plugin_local_addr = "169.254.169.254:80".to_string()
}),
),
(
"pluginCrtPath",
Box::new(|p: &mut ProxyConfig| p.plugin_crt_path = "/etc/shadow".to_string()),
),
(
"pluginKeyPath",
Box::new(|p: &mut ProxyConfig| p.plugin_key_path = "/etc/shadow".to_string()),
),
] {
let (mut me, _peer) = cmd_pair();
let table = registry::ProxyTable::default();
let cmd = add_cmd_with("evil", set);
let err = apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect_err(&format!("{field} 必须被拒绝"));
let msg = err.to_string();
assert!(msg.contains(field), "错误里要点名 {field}:{msg}");
assert_eq!(table.len(), 0, "{field} 被拒时不该留下代理");
}
}
#[tokio::test]
async fn 远程下发的_local_addr_只能指向本机() {
for bad in [
"169.254.169.254:80", "10.0.0.5:6379", "192.168.1.1:22", "0.0.0.0:80", "[fd00::1]:80", "example.com:80", ] {
let (mut me, _peer) = cmd_pair();
let table = registry::ProxyTable::default();
let cmd = add_cmd_with("ssrf", |p| p.local_addr = bad.to_string());
let err = apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect_err(&format!("{bad} 必须被拒绝"));
assert!(
err.to_string().contains("local_addr"),
"错误里要点名 local_addr:{err}"
);
assert_eq!(table.len(), 0, "{bad} 被拒时不该留下代理");
}
for ok in ["127.0.0.1:8080", "127.0.0.2:9000", "[::1]:8080"] {
let (mut me, _peer) = cmd_pair();
let table = registry::ProxyTable::default();
let cmd = add_cmd_with("local-ok", |p| p.local_addr = ok.to_string());
apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.unwrap_or_else(|e| panic!("{ok} 是合法的本机地址,不该被拒:{e}"));
assert_eq!(table.len(), 1, "{ok} 应当成功加入");
}
for bad_host in ["localhost:3000", "LocalHost:3000", "LOCALHOST:3000"] {
let (mut me, _peer) = cmd_pair();
let table = registry::ProxyTable::default();
let cmd = add_cmd_with("local-bad", |p| p.local_addr = bad_host.to_string());
let err = apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect_err(&format!(
"{bad_host} 必须被拒(E-1):它的解析结果取决于本机 hosts 与 DNS,\
判据不能靠名字赌"
));
assert!(
err.to_string().contains("local_addr"),
"错误里要点名 local_addr:{err}"
);
assert_eq!(table.len(), 0, "{bad_host} 被拒时不该留下代理");
}
}
#[tokio::test]
async fn 远程下发不得携带插件字段() {
type Case = (&'static str, Box<dyn FnOnce(&mut ProxyConfig)>);
let cases: Vec<Case> = vec![
(
"plugin",
Box::new(|p: &mut ProxyConfig| p.plugin = "http_proxy".to_string()),
),
(
"plugin",
Box::new(|p: &mut ProxyConfig| p.plugin = "socks5".to_string()),
),
(
"plugin",
Box::new(|p: &mut ProxyConfig| p.plugin = "static_file".to_string()),
),
(
"pluginUser",
Box::new(|p: &mut ProxyConfig| p.plugin_user = "u".to_string()),
),
(
"pluginPasswd",
Box::new(|p: &mut ProxyConfig| p.plugin_passwd = "pw".to_string()),
),
(
"pluginHostHeaderRewrite",
Box::new(|p: &mut ProxyConfig| p.plugin_host_header_rewrite = "x".to_string()),
),
(
"pluginStripPrefix",
Box::new(|p: &mut ProxyConfig| p.plugin_strip_prefix = "/a".to_string()),
),
];
for (label, set) in cases {
let (mut me, _peer) = cmd_pair();
let table = registry::ProxyTable::default();
let cmd = add_cmd_with("openproxy", |p| {
p.local_addr = String::new();
set(p);
});
let err = apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect_err(&format!("{label} 必须被拒绝(哪怕 local_addr 为空)"));
assert!(
err.to_string().contains(label),
"错误里要点名 {label}:{err}"
);
assert_eq!(table.len(), 0, "{label} 被拒时不该留下代理");
}
}
#[tokio::test]
async fn 不带插件的远程下发仍然可用() {
let (mut me, _peer) = cmd_pair();
let table = registry::ProxyTable::default();
let cmd = add_cmd_with("normal", |p| {
p.local_addr = "127.0.0.1:8080".to_string();
p.remote_port = 6100;
});
apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect("普通的回环代理应当照常放行");
assert_eq!(table.len(), 1);
}
#[test]
fn 不认识插件类型在解析阶段被拒() {
let mut p = ProxyConfig {
name: "bad-plugin".into(),
proxy_type: "tcp".into(),
local_addr: "127.0.0.1:80".into(),
plugin: "totally_made_up".into(),
..Default::default()
};
let err = nfrp_common::config::validate_remote_proxy(&p, false)
.expect_err("本机配置里的未知插件类型也必须被拒");
assert!(
err.to_string().contains("totally_made_up"),
"错误里要点名插件:{err}"
);
p.plugin = "socks5".into();
let err2 = nfrp_common::config::validate_remote_proxy(&p, true)
.expect_err("远程下发不得携带任何插件");
assert!(err2.to_string().contains("plugin"), "{err2}");
}
#[tokio::test]
async fn 远程下发的不支持代理类型必须被拒绝() {
let (mut me, _peer) = cmd_pair();
let table = registry::ProxyTable::default();
let cmd = add_cmd_with("bad-type", |p| p.proxy_type = "not-a-thing".to_string());
let err = apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect_err("不支持的代理类型必须被拒绝");
assert!(
err.to_string().contains("not-a-thing"),
"错误里要点名类型:{err}"
);
assert_eq!(table.len(), 0);
}
#[tokio::test]
async fn 管理命令_新增代理() {
let (mut me, mut peer) = cmd_pair();
let table = registry::ProxyTable::default();
apply_server_cmd(&table, "", &no_store(), &add_cmd("panel-web"), &mut me)
.await
.expect("新增应当成功");
assert_eq!(table.len(), 1, "代理要真的进到本地表里");
assert_eq!(table.get("panel-web").expect("能查到").remote_port, 7000);
let resp = expect_cmd_resp(&mut peer).await;
assert_eq!(resp.id, "cmd-1", "id 必须原样带回");
assert_eq!(resp.op, msg::CMD_ADD_PROXY);
assert!(resp.error.is_empty(), "不该带错误:{}", resp.error);
}
#[tokio::test]
async fn 管理命令_失败也要回执() {
let (mut me, mut peer) = cmd_pair();
let table = registry::ProxyTable::default();
let cmd = msg::ServerCmd {
id: "cmd-2".to_string(),
op: msg::CMD_REMOVE_PROXY.to_string(),
proxy_name: "nope".to_string(),
..Default::default()
};
apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect_err("移除不存在的代理应当失败");
let resp = expect_cmd_resp(&mut peer).await;
assert_eq!(resp.id, "cmd-2");
assert!(
resp.error.contains("nope"),
"错误里要带上名字方便自查:{}",
resp.error
);
assert!(
resp.error.contains("无"),
"应当顺便列出当前代理:{}",
resp.error
);
}
#[tokio::test]
async fn 管理命令_未知op被拒绝() {
let (mut me, mut peer) = cmd_pair();
let table = registry::ProxyTable::default();
let cmd = msg::ServerCmd {
id: "cmd-3".to_string(),
op: "format_disk".to_string(),
..Default::default()
};
apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect_err("未知命令必须失败");
assert!(expect_cmd_resp(&mut peer).await.error.contains("未知命令"));
}
#[tokio::test]
async fn 管理命令_先加后删() {
let (mut me, mut peer) = cmd_pair();
let table = registry::ProxyTable::from_iter([ProxyConfig {
name: "old".into(),
..Default::default()
}]);
apply_server_cmd(&table, "", &no_store(), &add_cmd("new"), &mut me)
.await
.expect("新增");
let _ = expect_cmd_resp(&mut peer).await;
assert_eq!(table.len(), 2);
let cmd = msg::ServerCmd {
id: "cmd-4".to_string(),
op: msg::CMD_REMOVE_PROXY.to_string(),
proxy_name: "new".to_string(),
..Default::default()
};
apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect("移除动态代理");
let _ = expect_cmd_resp(&mut peer).await;
assert_eq!(table.len(), 1, "删掉之后只剩配置里那条");
assert!(table.get("new").is_none(), "动态那条应当被删掉");
assert!(table.get("old").is_some(), "配置那条必须原样留着(F2)");
let cmd = msg::ServerCmd {
id: "cmd-5".to_string(),
op: msg::CMD_REMOVE_PROXY.to_string(),
proxy_name: "old".to_string(),
..Default::default()
};
apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect_err("删配置代理必须被拒(F2)");
let resp = expect_cmd_resp(&mut peer).await;
assert!(
resp.error.contains("配置文件"),
"回执要说清原因:{}",
resp.error
);
assert!(table.get("old").is_some(), "被拒之后配置代理必须还在");
}
#[tokio::test]
async fn 管理命令_不得覆盖配置里的同名代理() {
let (mut me, mut peer) = cmd_pair();
let mut cfg = ProxyConfig {
name: "my-web".into(),
..Default::default()
};
cfg.remote_port = 18202;
let table = registry::ProxyTable::from_iter([cfg]);
let cmd = add_cmd_with("my-web", |p| {
p.remote_port = 9999;
p.local_addr = "127.0.0.1:9".into();
});
apply_server_cmd(&table, "", &no_store(), &cmd, &mut me)
.await
.expect_err("覆盖配置代理必须被拒(F3)");
let resp = expect_cmd_resp(&mut peer).await;
assert!(
resp.error.contains("配置文件"),
"回执要说清原因:{}",
resp.error
);
assert_eq!(
table.get("my-web").expect("配置代理必须还在").remote_port,
18202,
"配置代理不该被改掉"
);
}
async fn expect_cmd_resp(conn: &mut FrpConn) -> msg::ServerCmdResp {
let got = tokio::time::timeout(Duration::from_secs(5), conn.recv_msg())
.await
.expect("5 秒内必须收到回执 —— 没收到说明发送侧根本没写出去")
.expect("读回执");
match got {
Some(FrpMessage::ServerCmdResp(r)) => r,
other => panic!("应当是 ServerCmdResp,实际:{other:?}"),
}
}
#[tokio::test]
async fn 用线上全名也能删除动态代理() {
let (mut me, _peer) = cmd_pair();
let table = registry::ProxyTable::default();
let user = "probeuser";
apply_server_cmd(&table, user, &no_store(), &add_cmd("web"), &mut me)
.await
.expect("动态添加 web");
apply_server_cmd(&table, user, &no_store(), &add_cmd("web2"), &mut me)
.await
.expect("动态添加 web2");
assert_eq!(table.len(), 2);
apply_server_cmd(
&table,
user,
&no_store(),
&remove_cmd("probeuser.web"),
&mut me,
)
.await
.expect("全名删除必须成功(剥本端 user 前缀兜底)");
assert!(
!table.names().iter().any(|n| n == "web"),
"web 应已从本地表删除"
);
apply_server_cmd(&table, user, &no_store(), &remove_cmd("web2"), &mut me)
.await
.expect("本地名删除应成功");
assert_eq!(table.len(), 0);
let err = apply_server_cmd(&table, user, &no_store(), &remove_cmd("alice.web"), &mut me)
.await
.expect_err("别人的前缀不该被剥");
assert!(
err.to_string().contains("alice.web"),
"错误里保留原名:{err}"
);
}
}