feat: 新增 frp 远程访问扩展

This commit is contained in:
mofeng-git
2026-06-13 16:05:34 +08:00
parent 4b65eebd5d
commit 5de7ecd4c5
12 changed files with 828 additions and 69 deletions

View File

@@ -1,16 +1,18 @@
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::RwLock;
use toml_edit::DocumentMut;
use super::types::*;
use crate::events::EventBus;
const LOG_BUFFER_SIZE: usize = 200;
const LOG_BATCH_SIZE: usize = 16;
#[cfg(unix)]
pub const TTYD_SOCKET_PATH: &str = "/var/run/one-kvm/ttyd.sock";
@@ -25,6 +27,12 @@ const TTYD_TCP_PORT: &str = "7681";
struct ExtensionProcess {
child: Child,
logs: Arc<RwLock<VecDeque<String>>>,
_temp_dir: Option<TempDir>,
}
struct ExtensionLaunch {
args: Vec<String>,
temp_dir: Option<TempDir>,
}
pub struct ExtensionManager {
@@ -82,6 +90,17 @@ impl ExtensionManager {
ExtensionId::Easytier => {
config.easytier.enabled && !config.easytier.network_name.is_empty()
}
ExtensionId::Frpc => {
config.frpc.enabled
&& match config.frpc.config_mode {
FrpcConfigMode::Quick => {
!config.frpc.proxy_name.trim().is_empty()
&& !config.frpc.server_addr.trim().is_empty()
&& !config.frpc.token.is_empty()
}
FrpcConfigMode::Full => !config.frpc.custom_toml.trim().is_empty(),
}
}
}
}
@@ -135,17 +154,17 @@ impl ExtensionManager {
self.stop(id).await.ok();
let args = self.build_args(id, config).await?;
let launch = self.build_launch(id, config).await?;
tracing::info!(
"Starting extension {}: {} {}",
id,
id.binary_path().display(),
Self::redact_args_for_log(&args).join(" ")
launch.args.join(" ")
);
let mut child = Command::new(id.binary_path())
.args(&args)
.args(&launch.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
@@ -172,9 +191,21 @@ impl ExtensionManager {
let pid = child.id();
tracing::info!("Extension {} started with PID {:?}", id, pid);
Self::push_log(
&logs,
format!("Extension {} started with PID {:?}", id, pid),
)
.await;
let mut processes = self.processes.write().await;
processes.insert(id, ExtensionProcess { child, logs });
processes.insert(
id,
ExtensionProcess {
child,
logs,
_temp_dir: launch.temp_dir,
},
);
drop(processes);
self.mark_ttyd_status_dirty(id).await;
@@ -212,22 +243,14 @@ impl ExtensionManager {
) {
let reader = BufReader::new(reader);
let mut lines = reader.lines();
let mut local_buffer = Vec::with_capacity(LOG_BATCH_SIZE);
loop {
match lines.next_line().await {
Ok(Some(line)) => {
tracing::info!("[{}] {}", id, line);
local_buffer.push(line);
if local_buffer.len() >= LOG_BATCH_SIZE {
Self::flush_logs(&logs, &mut local_buffer).await;
}
Self::push_log(&logs, line).await;
}
Ok(None) => {
if !local_buffer.is_empty() {
Self::flush_logs(&logs, &mut local_buffer).await;
}
break;
}
Err(e) => {
@@ -238,29 +261,27 @@ impl ExtensionManager {
}
}
async fn flush_logs(logs: &RwLock<VecDeque<String>>, buffer: &mut Vec<String>) {
async fn push_log(logs: &RwLock<VecDeque<String>>, line: String) {
let mut logs = logs.write().await;
for line in buffer.drain(..) {
if logs.len() >= LOG_BUFFER_SIZE {
logs.pop_front();
}
logs.push_back(line);
if logs.len() >= LOG_BUFFER_SIZE {
logs.pop_front();
}
logs.push_back(line);
}
async fn build_args(
async fn build_launch(
&self,
id: ExtensionId,
config: &ExtensionsConfig,
) -> Result<Vec<String>, String> {
match id {
) -> Result<ExtensionLaunch, String> {
let args = match id {
ExtensionId::Ttyd => {
let c = &config.ttyd;
let mut args = Self::build_ttyd_listen_args().await?;
args.push(c.shell.clone());
Ok(args)
args
}
ExtensionId::Gostc => {
@@ -282,7 +303,7 @@ impl ExtensionManager {
args.extend(["-key".to_string(), c.key.clone()]);
Ok(args)
args
}
ExtensionId::Easytier => {
@@ -314,9 +335,153 @@ impl ExtensionManager {
args.push("-d".to_string());
}
Ok(args)
args
}
ExtensionId::Frpc => {
return Self::build_frpc_launch(&config.frpc).await;
}
};
Ok(ExtensionLaunch {
args,
temp_dir: None,
})
}
async fn build_frpc_launch(config: &FrpcConfig) -> Result<ExtensionLaunch, String> {
let config_text = match config.config_mode {
FrpcConfigMode::Quick => Self::build_frpc_quick_toml(config)?,
FrpcConfigMode::Full => Self::validate_frpc_full_toml(config)?.to_string(),
};
let temp_dir =
tempfile::tempdir().map_err(|e| format!("Failed to create FRPC config dir: {}", e))?;
let config_path = temp_dir.path().join("frpc.toml");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o700))
.map_err(|e| format!("Failed to protect FRPC config dir: {}", e))?;
}
tokio::fs::write(&config_path, config_text)
.await
.map_err(|e| format!("Failed to write FRPC config: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))
.await
.map_err(|e| format!("Failed to protect FRPC config: {}", e))?;
}
Ok(ExtensionLaunch {
args: vec!["-c".to_string(), Self::path_to_arg(&config_path)],
temp_dir: Some(temp_dir),
})
}
fn validate_frpc_full_toml(config: &FrpcConfig) -> Result<&str, String> {
let trimmed = config.custom_toml.trim();
if trimmed.is_empty() {
return Err("FRPC full configuration is required".into());
}
trimmed
.parse::<DocumentMut>()
.map_err(|e| format!("FRPC full configuration is not valid TOML: {}", e))?;
Ok(config.custom_toml.as_str())
}
fn build_frpc_quick_toml(config: &FrpcConfig) -> Result<String, String> {
if config.proxy_name.trim().is_empty() {
return Err("FRPC proxy name is required".into());
}
if config.server_addr.trim().is_empty() {
return Err("FRPC server address is required".into());
}
if config.token.is_empty() {
return Err("FRPC token is required".into());
}
if config.local_ip.trim().is_empty() {
return Err("FRPC local IP is required".into());
}
let proxy_type = match config.proxy_type {
FrpProxyType::Tcp => "tcp",
FrpProxyType::Udp => "udp",
FrpProxyType::Http => "http",
FrpProxyType::Https => "https",
FrpProxyType::Stcp => "stcp",
FrpProxyType::Sudp => "sudp",
FrpProxyType::Xtcp => "xtcp",
};
let mut toml = String::new();
toml.push_str(&format!(
"serverAddr = {}\nserverPort = {}\n\n",
Self::toml_string(config.server_addr.trim()),
config.server_port
));
toml.push_str("[auth]\n");
toml.push_str("method = \"token\"\n");
toml.push_str(&format!("token = {}\n\n", Self::toml_string(&config.token)));
toml.push_str("[transport]\n");
toml.push_str("protocol = \"tcp\"\n\n");
toml.push_str("[transport.tls]\n");
toml.push_str(&format!("enable = {}\n\n", config.tls));
toml.push_str("[[proxies]]\n");
toml.push_str(&format!(
"name = {}\ntype = {}\nlocalIP = {}\nlocalPort = {}\n",
Self::toml_string(config.proxy_name.trim()),
Self::toml_string(proxy_type),
Self::toml_string(config.local_ip.trim()),
config.local_port
));
match config.proxy_type {
FrpProxyType::Tcp | FrpProxyType::Udp => {
let remote_port = config.remote_port.ok_or_else(|| {
"FRPC remote port is required for TCP/UDP proxies".to_string()
})?;
toml.push_str(&format!("remotePort = {}\n", remote_port));
}
FrpProxyType::Http | FrpProxyType::Https => {
if let Some(domain) = config
.custom_domain
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
toml.push_str(&format!(
"customDomains = [{}]\n",
Self::toml_string(domain)
));
}
}
FrpProxyType::Stcp | FrpProxyType::Sudp | FrpProxyType::Xtcp => {
if !config.secret_key.is_empty() {
toml.push_str(&format!(
"secretKey = {}\n",
Self::toml_string(&config.secret_key)
));
}
}
}
Ok(toml)
}
fn toml_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
fn path_to_arg(path: &PathBuf) -> String {
path.to_string_lossy().to_string()
}
#[cfg(unix)]
@@ -356,34 +521,6 @@ impl ExtensionManager {
])
}
fn redact_args_for_log(args: &[String]) -> Vec<String> {
let mut redacted = Vec::with_capacity(args.len());
let mut redact_next = false;
for arg in args {
if redact_next {
redacted.push("****".to_string());
redact_next = false;
continue;
}
if arg == "-key" || arg == "--key" {
redacted.push(arg.clone());
redact_next = true;
} else if let Some((flag, _)) = arg.split_once('=') {
if flag == "-key" || flag == "--key" {
redacted.push(format!("{}=****", flag));
} else {
redacted.push(arg.clone());
}
} else {
redacted.push(arg.clone());
}
}
redacted
}
#[cfg(unix)]
async fn prepare_ttyd_socket() -> Result<(), String> {
let socket_path = std::path::Path::new(TTYD_SOCKET_PATH);

View File

@@ -7,6 +7,7 @@ pub fn default_binary_path(id: ExtensionId) -> &'static str {
ExtensionId::Ttyd => "/usr/bin/ttyd",
ExtensionId::Gostc => "/usr/bin/gostc",
ExtensionId::Easytier => "/usr/bin/easytier-core",
ExtensionId::Frpc => "/usr/bin/frpc",
}
}

View File

@@ -7,6 +7,7 @@ pub fn default_binary_path(id: ExtensionId) -> &'static str {
ExtensionId::Ttyd => "ttyd.win32.exe",
ExtensionId::Gostc => "gostc.exe",
ExtensionId::Easytier => "easytier-core.exe",
ExtensionId::Frpc => "frpc.exe",
}
}

View File

@@ -10,6 +10,7 @@ pub enum ExtensionId {
Ttyd,
Gostc,
Easytier,
Frpc,
}
impl ExtensionId {
@@ -18,7 +19,7 @@ impl ExtensionId {
}
pub fn all() -> &'static [ExtensionId] {
&[Self::Ttyd, Self::Gostc, Self::Easytier]
&[Self::Ttyd, Self::Gostc, Self::Easytier, Self::Frpc]
}
}
@@ -28,6 +29,7 @@ impl std::fmt::Display for ExtensionId {
Self::Ttyd => write!(f, "ttyd"),
Self::Gostc => write!(f, "gostc"),
Self::Easytier => write!(f, "easytier"),
Self::Frpc => write!(f, "frpc"),
}
}
}
@@ -40,6 +42,7 @@ impl std::str::FromStr for ExtensionId {
"ttyd" => Ok(Self::Ttyd),
"gostc" => Ok(Self::Gostc),
"easytier" => Ok(Self::Easytier),
"frpc" => Ok(Self::Frpc),
_ => Err(format!("Unknown extension: {}", s)),
}
}
@@ -114,6 +117,85 @@ pub struct EasytierConfig {
pub virtual_ip: Option<String>,
}
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FrpProxyType {
Tcp,
Udp,
Http,
Https,
Stcp,
Sudp,
Xtcp,
}
impl Default for FrpProxyType {
fn default() -> Self {
Self::Tcp
}
}
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FrpcConfigMode {
Quick,
Full,
}
impl Default for FrpcConfigMode {
fn default() -> Self {
Self::Quick
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FrpcConfig {
pub enabled: bool,
pub config_mode: FrpcConfigMode,
pub proxy_name: String,
pub proxy_type: FrpProxyType,
pub server_addr: String,
pub server_port: u16,
#[serde(skip_serializing_if = "String::is_empty")]
pub token: String,
pub local_ip: String,
pub local_port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_domain: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub secret_key: String,
pub tls: bool,
#[serde(skip_serializing_if = "String::is_empty")]
pub custom_toml: String,
}
impl Default for FrpcConfig {
fn default() -> Self {
Self {
enabled: false,
config_mode: FrpcConfigMode::Quick,
proxy_name: String::new(),
proxy_type: FrpProxyType::Tcp,
server_addr: String::new(),
server_port: 7000,
token: String::new(),
local_ip: "127.0.0.1".to_string(),
local_port: 22,
remote_port: None,
custom_domain: None,
secret_key: String::new(),
tls: true,
custom_toml: String::new(),
}
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -121,6 +203,7 @@ pub struct ExtensionsConfig {
pub ttyd: TtydConfig,
pub gostc: GostcConfig,
pub easytier: EasytierConfig,
pub frpc: FrpcConfig,
}
#[typeshare]
@@ -154,12 +237,21 @@ pub struct EasytierInfo {
pub config: EasytierConfig,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrpcInfo {
pub available: bool,
pub status: ExtensionStatus,
pub config: FrpcConfig,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionsStatus {
pub ttyd: TtydInfo,
pub gostc: GostcInfo,
pub easytier: EasytierInfo,
pub frpc: FrpcInfo,
}
#[typeshare]

View File

@@ -4,12 +4,14 @@ use axum::{
};
use serde::Deserialize;
use std::sync::Arc;
use toml_edit::DocumentMut;
use typeshare::typeshare;
use crate::error::{AppError, Result};
use crate::extensions::{
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus,
GostcConfig, GostcInfo, TtydConfig, TtydInfo,
FrpProxyType, FrpcConfig, FrpcConfigMode, FrpcInfo, GostcConfig, GostcInfo, TtydConfig,
TtydInfo,
};
use crate::state::AppState;
@@ -34,6 +36,46 @@ fn validate_easytier_enabled(config: &EasytierConfig) -> Result<()> {
Ok(())
}
fn validate_frpc_enabled(config: &FrpcConfig) -> Result<()> {
match config.config_mode {
FrpcConfigMode::Quick => {
if config.proxy_name.trim().is_empty() {
return Err(AppError::BadRequest("FRPC proxy name is required".into()));
}
if config.server_addr.trim().is_empty() {
return Err(AppError::BadRequest(
"FRPC server address is required".into(),
));
}
if config.token.is_empty() {
return Err(AppError::BadRequest("FRPC token is required".into()));
}
if config.local_ip.trim().is_empty() {
return Err(AppError::BadRequest("FRPC local IP is required".into()));
}
if matches!(config.proxy_type, FrpProxyType::Tcp | FrpProxyType::Udp)
&& config.remote_port.is_none()
{
return Err(AppError::BadRequest(
"FRPC remote port is required for TCP/UDP proxies".into(),
));
}
}
FrpcConfigMode::Full => {
let toml = config.custom_toml.trim();
if toml.is_empty() {
return Err(AppError::BadRequest(
"FRPC full configuration is required".into(),
));
}
toml.parse::<DocumentMut>().map_err(|e| {
AppError::BadRequest(format!("FRPC full configuration is not valid TOML: {}", e))
})?;
}
}
Ok(())
}
pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<ExtensionsStatus> {
let config = state.config.get();
let mgr = &state.extensions;
@@ -54,6 +96,11 @@ pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<Extensi
status: mgr.status(ExtensionId::Easytier).await,
config: config.extensions.easytier.clone(),
},
frpc: FrpcInfo {
available: mgr.check_available(ExtensionId::Frpc),
status: mgr.status(ExtensionId::Frpc).await,
config: config.extensions.frpc.clone(),
},
})
}
@@ -159,6 +206,25 @@ pub struct EasytierConfigUpdate {
pub virtual_ip: Option<String>,
}
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct FrpcConfigUpdate {
pub enabled: Option<bool>,
pub config_mode: Option<FrpcConfigMode>,
pub proxy_name: Option<String>,
pub proxy_type: Option<FrpProxyType>,
pub server_addr: Option<String>,
pub server_port: Option<u16>,
pub token: Option<String>,
pub local_ip: Option<String>,
pub local_port: Option<u16>,
pub remote_port: Option<Option<u16>>,
pub custom_domain: Option<Option<String>>,
pub secret_key: Option<String>,
pub tls: Option<bool>,
pub custom_toml: Option<String>,
}
pub async fn update_ttyd_config(
State(state): State<Arc<AppState>>,
Json(req): Json<TtydConfigUpdate>,
@@ -295,3 +361,81 @@ pub async fn update_easytier_config(
Ok(Json(new_config.extensions.easytier.clone()))
}
pub async fn update_frpc_config(
State(state): State<Arc<AppState>>,
Json(req): Json<FrpcConfigUpdate>,
) -> Result<Json<FrpcConfig>> {
let current_config = state.config.get();
let was_enabled = current_config.extensions.frpc.enabled;
let mut next_frpc = current_config.extensions.frpc.clone();
if let Some(enabled) = req.enabled {
next_frpc.enabled = enabled;
}
if let Some(config_mode) = req.config_mode {
next_frpc.config_mode = config_mode;
}
if let Some(ref proxy_name) = req.proxy_name {
next_frpc.proxy_name = proxy_name.clone();
}
if let Some(proxy_type) = req.proxy_type {
next_frpc.proxy_type = proxy_type;
}
if let Some(ref addr) = req.server_addr {
next_frpc.server_addr = addr.clone();
}
if let Some(port) = req.server_port {
next_frpc.server_port = port;
}
if let Some(ref token) = req.token {
next_frpc.token = token.clone();
}
if let Some(ref local_ip) = req.local_ip {
next_frpc.local_ip = local_ip.clone();
}
if let Some(local_port) = req.local_port {
next_frpc.local_port = local_port;
}
if let Some(remote_port) = req.remote_port {
next_frpc.remote_port = remote_port;
}
if let Some(custom_domain) = req.custom_domain {
next_frpc.custom_domain = custom_domain;
}
if let Some(ref secret_key) = req.secret_key {
next_frpc.secret_key = secret_key.clone();
}
if let Some(tls) = req.tls {
next_frpc.tls = tls;
}
if let Some(ref custom_toml) = req.custom_toml {
next_frpc.custom_toml = custom_toml.clone();
}
if next_frpc.enabled || matches!(next_frpc.config_mode, FrpcConfigMode::Full) {
validate_frpc_enabled(&next_frpc)?;
}
state
.config
.update(|config| {
config.extensions.frpc = next_frpc.clone();
})
.await?;
let new_config = state.config.get();
let is_enabled = new_config.extensions.frpc.enabled;
if was_enabled && !is_enabled {
state.extensions.stop(ExtensionId::Frpc).await.ok();
} else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Frpc) {
state
.extensions
.start(ExtensionId::Frpc, &new_config.extensions)
.await
.ok();
}
Ok(Json(new_config.extensions.frpc.clone()))
}

View File

@@ -205,6 +205,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/extensions/easytier/config",
patch(handlers::extensions::update_easytier_config),
)
.route(
"/extensions/frpc/config",
patch(handlers::extensions::update_frpc_config),
)
// Terminal (ttyd) reverse proxy - WebSocket and HTTP
.route("/terminal", get(handlers::terminal::terminal_index))
.route("/terminal/", get(handlers::terminal::terminal_index))