mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
feat: 新增 frp 远程访问扩展
This commit is contained in:
@@ -20,6 +20,7 @@ desktop = [
|
|||||||
"dep:sqlx",
|
"dep:sqlx",
|
||||||
"dep:serde",
|
"dep:serde",
|
||||||
"dep:serde_json",
|
"dep:serde_json",
|
||||||
|
"dep:toml_edit",
|
||||||
"dep:tracing",
|
"dep:tracing",
|
||||||
"dep:tracing-subscriber",
|
"dep:tracing-subscriber",
|
||||||
"dep:thiserror",
|
"dep:thiserror",
|
||||||
@@ -38,6 +39,7 @@ desktop = [
|
|||||||
"dep:axum-server",
|
"dep:axum-server",
|
||||||
"dep:clap",
|
"dep:clap",
|
||||||
"dep:time",
|
"dep:time",
|
||||||
|
"dep:tempfile",
|
||||||
"dep:bytes",
|
"dep:bytes",
|
||||||
"dep:bytemuck",
|
"dep:bytemuck",
|
||||||
"dep:xxhash-rust",
|
"dep:xxhash-rust",
|
||||||
@@ -98,6 +100,7 @@ android = [
|
|||||||
"dep:sdp-types",
|
"dep:sdp-types",
|
||||||
"dep:serde",
|
"dep:serde",
|
||||||
"dep:serde_json",
|
"dep:serde_json",
|
||||||
|
"dep:toml_edit",
|
||||||
"dep:serialport",
|
"dep:serialport",
|
||||||
"dep:sha2",
|
"dep:sha2",
|
||||||
"dep:sodiumoxide",
|
"dep:sodiumoxide",
|
||||||
@@ -106,6 +109,7 @@ android = [
|
|||||||
"dep:audiopus",
|
"dep:audiopus",
|
||||||
"dep:thiserror",
|
"dep:thiserror",
|
||||||
"dep:time",
|
"dep:time",
|
||||||
|
"dep:tempfile",
|
||||||
"dep:tokio",
|
"dep:tokio",
|
||||||
"dep:tokio-tungstenite",
|
"dep:tokio-tungstenite",
|
||||||
"dep:tokio-util",
|
"dep:tokio-util",
|
||||||
@@ -143,6 +147,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = tru
|
|||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"], optional = true }
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
serde_json = { version = "1", optional = true }
|
serde_json = { version = "1", optional = true }
|
||||||
|
toml_edit = { version = "0.25", optional = true }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = { version = "0.1", optional = true }
|
tracing = { version = "0.1", optional = true }
|
||||||
@@ -160,6 +165,7 @@ rand = { version = "0.9", optional = true }
|
|||||||
# Utilities
|
# Utilities
|
||||||
uuid = { version = "1", features = ["v4", "serde"], optional = true }
|
uuid = { version = "1", features = ["v4", "serde"], optional = true }
|
||||||
base64 = { version = "0.22", optional = true }
|
base64 = { version = "0.22", optional = true }
|
||||||
|
tempfile = { version = "3", optional = true }
|
||||||
|
|
||||||
# HTTP client (for URL downloads)
|
# HTTP client (for URL downloads)
|
||||||
# Use rustls by default, but allow native-tls for systems with older GLIBC
|
# Use rustls by default, but allow native-tls for systems with older GLIBC
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use toml_edit::DocumentMut;
|
||||||
|
|
||||||
use super::types::*;
|
use super::types::*;
|
||||||
use crate::events::EventBus;
|
use crate::events::EventBus;
|
||||||
|
|
||||||
const LOG_BUFFER_SIZE: usize = 200;
|
const LOG_BUFFER_SIZE: usize = 200;
|
||||||
const LOG_BATCH_SIZE: usize = 16;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub const TTYD_SOCKET_PATH: &str = "/var/run/one-kvm/ttyd.sock";
|
pub const TTYD_SOCKET_PATH: &str = "/var/run/one-kvm/ttyd.sock";
|
||||||
@@ -25,6 +27,12 @@ const TTYD_TCP_PORT: &str = "7681";
|
|||||||
struct ExtensionProcess {
|
struct ExtensionProcess {
|
||||||
child: Child,
|
child: Child,
|
||||||
logs: Arc<RwLock<VecDeque<String>>>,
|
logs: Arc<RwLock<VecDeque<String>>>,
|
||||||
|
_temp_dir: Option<TempDir>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExtensionLaunch {
|
||||||
|
args: Vec<String>,
|
||||||
|
temp_dir: Option<TempDir>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ExtensionManager {
|
pub struct ExtensionManager {
|
||||||
@@ -82,6 +90,17 @@ impl ExtensionManager {
|
|||||||
ExtensionId::Easytier => {
|
ExtensionId::Easytier => {
|
||||||
config.easytier.enabled && !config.easytier.network_name.is_empty()
|
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();
|
self.stop(id).await.ok();
|
||||||
|
|
||||||
let args = self.build_args(id, config).await?;
|
let launch = self.build_launch(id, config).await?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Starting extension {}: {} {}",
|
"Starting extension {}: {} {}",
|
||||||
id,
|
id,
|
||||||
id.binary_path().display(),
|
id.binary_path().display(),
|
||||||
Self::redact_args_for_log(&args).join(" ")
|
launch.args.join(" ")
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut child = Command::new(id.binary_path())
|
let mut child = Command::new(id.binary_path())
|
||||||
.args(&args)
|
.args(&launch.args)
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true)
|
||||||
@@ -172,9 +191,21 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
tracing::info!("Extension {} started with PID {:?}", id, pid);
|
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;
|
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);
|
drop(processes);
|
||||||
self.mark_ttyd_status_dirty(id).await;
|
self.mark_ttyd_status_dirty(id).await;
|
||||||
|
|
||||||
@@ -212,22 +243,14 @@ impl ExtensionManager {
|
|||||||
) {
|
) {
|
||||||
let reader = BufReader::new(reader);
|
let reader = BufReader::new(reader);
|
||||||
let mut lines = reader.lines();
|
let mut lines = reader.lines();
|
||||||
let mut local_buffer = Vec::with_capacity(LOG_BATCH_SIZE);
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match lines.next_line().await {
|
match lines.next_line().await {
|
||||||
Ok(Some(line)) => {
|
Ok(Some(line)) => {
|
||||||
tracing::info!("[{}] {}", id, line);
|
tracing::info!("[{}] {}", id, line);
|
||||||
local_buffer.push(line);
|
Self::push_log(&logs, line).await;
|
||||||
|
|
||||||
if local_buffer.len() >= LOG_BATCH_SIZE {
|
|
||||||
Self::flush_logs(&logs, &mut local_buffer).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
if !local_buffer.is_empty() {
|
|
||||||
Self::flush_logs(&logs, &mut local_buffer).await;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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;
|
let mut logs = logs.write().await;
|
||||||
for line in buffer.drain(..) {
|
|
||||||
if logs.len() >= LOG_BUFFER_SIZE {
|
if logs.len() >= LOG_BUFFER_SIZE {
|
||||||
logs.pop_front();
|
logs.pop_front();
|
||||||
}
|
}
|
||||||
logs.push_back(line);
|
logs.push_back(line);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async fn build_args(
|
async fn build_launch(
|
||||||
&self,
|
&self,
|
||||||
id: ExtensionId,
|
id: ExtensionId,
|
||||||
config: &ExtensionsConfig,
|
config: &ExtensionsConfig,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<ExtensionLaunch, String> {
|
||||||
match id {
|
let args = match id {
|
||||||
ExtensionId::Ttyd => {
|
ExtensionId::Ttyd => {
|
||||||
let c = &config.ttyd;
|
let c = &config.ttyd;
|
||||||
|
|
||||||
let mut args = Self::build_ttyd_listen_args().await?;
|
let mut args = Self::build_ttyd_listen_args().await?;
|
||||||
|
|
||||||
args.push(c.shell.clone());
|
args.push(c.shell.clone());
|
||||||
Ok(args)
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
ExtensionId::Gostc => {
|
ExtensionId::Gostc => {
|
||||||
@@ -282,7 +303,7 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
args.extend(["-key".to_string(), c.key.clone()]);
|
args.extend(["-key".to_string(), c.key.clone()]);
|
||||||
|
|
||||||
Ok(args)
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
ExtensionId::Easytier => {
|
ExtensionId::Easytier => {
|
||||||
@@ -314,9 +335,153 @@ impl ExtensionManager {
|
|||||||
args.push("-d".to_string());
|
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)]
|
#[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)]
|
#[cfg(unix)]
|
||||||
async fn prepare_ttyd_socket() -> Result<(), String> {
|
async fn prepare_ttyd_socket() -> Result<(), String> {
|
||||||
let socket_path = std::path::Path::new(TTYD_SOCKET_PATH);
|
let socket_path = std::path::Path::new(TTYD_SOCKET_PATH);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub fn default_binary_path(id: ExtensionId) -> &'static str {
|
|||||||
ExtensionId::Ttyd => "/usr/bin/ttyd",
|
ExtensionId::Ttyd => "/usr/bin/ttyd",
|
||||||
ExtensionId::Gostc => "/usr/bin/gostc",
|
ExtensionId::Gostc => "/usr/bin/gostc",
|
||||||
ExtensionId::Easytier => "/usr/bin/easytier-core",
|
ExtensionId::Easytier => "/usr/bin/easytier-core",
|
||||||
|
ExtensionId::Frpc => "/usr/bin/frpc",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub fn default_binary_path(id: ExtensionId) -> &'static str {
|
|||||||
ExtensionId::Ttyd => "ttyd.win32.exe",
|
ExtensionId::Ttyd => "ttyd.win32.exe",
|
||||||
ExtensionId::Gostc => "gostc.exe",
|
ExtensionId::Gostc => "gostc.exe",
|
||||||
ExtensionId::Easytier => "easytier-core.exe",
|
ExtensionId::Easytier => "easytier-core.exe",
|
||||||
|
ExtensionId::Frpc => "frpc.exe",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub enum ExtensionId {
|
|||||||
Ttyd,
|
Ttyd,
|
||||||
Gostc,
|
Gostc,
|
||||||
Easytier,
|
Easytier,
|
||||||
|
Frpc,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExtensionId {
|
impl ExtensionId {
|
||||||
@@ -18,7 +19,7 @@ impl ExtensionId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn all() -> &'static [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::Ttyd => write!(f, "ttyd"),
|
||||||
Self::Gostc => write!(f, "gostc"),
|
Self::Gostc => write!(f, "gostc"),
|
||||||
Self::Easytier => write!(f, "easytier"),
|
Self::Easytier => write!(f, "easytier"),
|
||||||
|
Self::Frpc => write!(f, "frpc"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +42,7 @@ impl std::str::FromStr for ExtensionId {
|
|||||||
"ttyd" => Ok(Self::Ttyd),
|
"ttyd" => Ok(Self::Ttyd),
|
||||||
"gostc" => Ok(Self::Gostc),
|
"gostc" => Ok(Self::Gostc),
|
||||||
"easytier" => Ok(Self::Easytier),
|
"easytier" => Ok(Self::Easytier),
|
||||||
|
"frpc" => Ok(Self::Frpc),
|
||||||
_ => Err(format!("Unknown extension: {}", s)),
|
_ => Err(format!("Unknown extension: {}", s)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +117,85 @@ pub struct EasytierConfig {
|
|||||||
pub virtual_ip: Option<String>,
|
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]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -121,6 +203,7 @@ pub struct ExtensionsConfig {
|
|||||||
pub ttyd: TtydConfig,
|
pub ttyd: TtydConfig,
|
||||||
pub gostc: GostcConfig,
|
pub gostc: GostcConfig,
|
||||||
pub easytier: EasytierConfig,
|
pub easytier: EasytierConfig,
|
||||||
|
pub frpc: FrpcConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
@@ -154,12 +237,21 @@ pub struct EasytierInfo {
|
|||||||
pub config: EasytierConfig,
|
pub config: EasytierConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FrpcInfo {
|
||||||
|
pub available: bool,
|
||||||
|
pub status: ExtensionStatus,
|
||||||
|
pub config: FrpcConfig,
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ExtensionsStatus {
|
pub struct ExtensionsStatus {
|
||||||
pub ttyd: TtydInfo,
|
pub ttyd: TtydInfo,
|
||||||
pub gostc: GostcInfo,
|
pub gostc: GostcInfo,
|
||||||
pub easytier: EasytierInfo,
|
pub easytier: EasytierInfo,
|
||||||
|
pub frpc: FrpcInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use toml_edit::DocumentMut;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::extensions::{
|
use crate::extensions::{
|
||||||
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus,
|
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus,
|
||||||
GostcConfig, GostcInfo, TtydConfig, TtydInfo,
|
FrpProxyType, FrpcConfig, FrpcConfigMode, FrpcInfo, GostcConfig, GostcInfo, TtydConfig,
|
||||||
|
TtydInfo,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -34,6 +36,46 @@ fn validate_easytier_enabled(config: &EasytierConfig) -> Result<()> {
|
|||||||
Ok(())
|
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> {
|
pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<ExtensionsStatus> {
|
||||||
let config = state.config.get();
|
let config = state.config.get();
|
||||||
let mgr = &state.extensions;
|
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,
|
status: mgr.status(ExtensionId::Easytier).await,
|
||||||
config: config.extensions.easytier.clone(),
|
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>,
|
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(
|
pub async fn update_ttyd_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<TtydConfigUpdate>,
|
Json(req): Json<TtydConfigUpdate>,
|
||||||
@@ -295,3 +361,81 @@ pub async fn update_easytier_config(
|
|||||||
|
|
||||||
Ok(Json(new_config.extensions.easytier.clone()))
|
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()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -205,6 +205,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
"/extensions/easytier/config",
|
"/extensions/easytier/config",
|
||||||
patch(handlers::extensions::update_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
|
// Terminal (ttyd) reverse proxy - WebSocket and HTTP
|
||||||
.route("/terminal", get(handlers::terminal::terminal_index))
|
.route("/terminal", get(handlers::terminal::terminal_index))
|
||||||
.route("/terminal/", get(handlers::terminal::terminal_index))
|
.route("/terminal/", get(handlers::terminal::terminal_index))
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import type {
|
|||||||
GostcConfigUpdate,
|
GostcConfigUpdate,
|
||||||
EasytierConfig,
|
EasytierConfig,
|
||||||
EasytierConfigUpdate,
|
EasytierConfigUpdate,
|
||||||
|
FrpcConfig,
|
||||||
|
FrpcConfigUpdate,
|
||||||
WebConfigResponse,
|
WebConfigResponse,
|
||||||
WebConfigUpdate,
|
WebConfigUpdate,
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
@@ -159,6 +161,12 @@ export const extensionsApi = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateFrpc: (config: FrpcConfigUpdate) =>
|
||||||
|
request<FrpcConfig>('/extensions/frpc/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RustDeskConfigResponse {
|
export interface RustDeskConfigResponse {
|
||||||
|
|||||||
@@ -522,6 +522,7 @@ export default {
|
|||||||
extRustdeskSubtitle: 'Remote graphical access via RustDesk',
|
extRustdeskSubtitle: 'Remote graphical access via RustDesk',
|
||||||
extRtspSubtitle: 'Provide an RTSP video stream for external clients',
|
extRtspSubtitle: 'Provide an RTSP video stream for external clients',
|
||||||
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
||||||
|
extFrpcSubtitle: 'NAT traversal through the FRP client',
|
||||||
aboutDesc: 'Open and Lightweight IP-KVM Solution',
|
aboutDesc: 'Open and Lightweight IP-KVM Solution',
|
||||||
deviceInfo: 'Device Info',
|
deviceInfo: 'Device Info',
|
||||||
deviceInfoDesc: 'Host system information',
|
deviceInfoDesc: 'Host system information',
|
||||||
@@ -956,7 +957,7 @@ export default {
|
|||||||
binaryNotFound: '{path} not found, please install the required program',
|
binaryNotFound: '{path} not found, please install the required program',
|
||||||
remoteAccess: {
|
remoteAccess: {
|
||||||
title: 'Remote Access',
|
title: 'Remote Access',
|
||||||
desc: 'GOSTC NAT traversal and Easytier networking',
|
desc: 'GOSTC/FRPC NAT traversal and Easytier networking',
|
||||||
},
|
},
|
||||||
ttyd: {
|
ttyd: {
|
||||||
title: 'Ttyd Web Terminal',
|
title: 'Ttyd Web Terminal',
|
||||||
@@ -987,6 +988,33 @@ export default {
|
|||||||
virtualIp: 'Virtual IP',
|
virtualIp: 'Virtual IP',
|
||||||
virtualIpHint: 'Leave empty for DHCP, or specify with CIDR (e.g., 10.0.0.1/24)',
|
virtualIpHint: 'Leave empty for DHCP, or specify with CIDR (e.g., 10.0.0.1/24)',
|
||||||
},
|
},
|
||||||
|
frpc: {
|
||||||
|
title: 'FRPC NAT Traversal',
|
||||||
|
desc: 'Connect to an frps server through the FRP client',
|
||||||
|
quickConfig: 'Quick Config',
|
||||||
|
fullConfig: 'Full Config',
|
||||||
|
fullConfigHint: 'Paste the provider TOML configuration file here',
|
||||||
|
fullConfigRequired: 'Enter the full frpc.toml configuration',
|
||||||
|
proxyType: 'Proxy Type',
|
||||||
|
proxyName: 'Proxy Name',
|
||||||
|
proxyNamePlaceholder: 'one-kvm-ssh',
|
||||||
|
proxyNameRequired: 'Enter the FRPC proxy name',
|
||||||
|
serverAddr: 'Server Address',
|
||||||
|
serverAddrPlaceholder: 'frps.example.com',
|
||||||
|
serverAddrRequired: 'Enter the FRPC server address',
|
||||||
|
serverPort: 'Server Port',
|
||||||
|
token: 'Token',
|
||||||
|
tokenRequired: 'Enter the FRPC token',
|
||||||
|
localIp: 'Local Address',
|
||||||
|
localIpRequired: 'Enter the FRPC local address',
|
||||||
|
localPort: 'Local Port',
|
||||||
|
remotePort: 'Remote Port',
|
||||||
|
remotePortRequired: 'TCP/UDP proxies require a remote port',
|
||||||
|
customDomain: 'Custom Domain',
|
||||||
|
customDomainPlaceholder: 'kvm.example.com',
|
||||||
|
secretKey: 'Secret Key',
|
||||||
|
tls: 'Enable TLS',
|
||||||
|
},
|
||||||
rustdesk: {
|
rustdesk: {
|
||||||
title: 'RustDesk Remote',
|
title: 'RustDesk Remote',
|
||||||
desc: 'Remote access via RustDesk client',
|
desc: 'Remote access via RustDesk client',
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ export default {
|
|||||||
extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问',
|
extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问',
|
||||||
extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流',
|
extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流',
|
||||||
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
||||||
|
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
|
||||||
aboutDesc: '开放轻量的 IP-KVM 解决方案',
|
aboutDesc: '开放轻量的 IP-KVM 解决方案',
|
||||||
deviceInfo: '设备信息',
|
deviceInfo: '设备信息',
|
||||||
deviceInfoDesc: '主机系统信息',
|
deviceInfoDesc: '主机系统信息',
|
||||||
@@ -955,7 +956,7 @@ export default {
|
|||||||
binaryNotFound: '未找到 {path},请先安装对应程序',
|
binaryNotFound: '未找到 {path},请先安装对应程序',
|
||||||
remoteAccess: {
|
remoteAccess: {
|
||||||
title: '远程访问',
|
title: '远程访问',
|
||||||
desc: 'GOSTC 内网穿透与 Easytier 组网',
|
desc: 'GOSTC/FRPC 内网穿透与 Easytier 组网',
|
||||||
},
|
},
|
||||||
ttyd: {
|
ttyd: {
|
||||||
title: 'Ttyd 网页终端',
|
title: 'Ttyd 网页终端',
|
||||||
@@ -986,6 +987,33 @@ export default {
|
|||||||
virtualIp: '虚拟 IP',
|
virtualIp: '虚拟 IP',
|
||||||
virtualIpHint: '留空则自动分配,手动指定需包含网段(如 10.0.0.1/24)',
|
virtualIpHint: '留空则自动分配,手动指定需包含网段(如 10.0.0.1/24)',
|
||||||
},
|
},
|
||||||
|
frpc: {
|
||||||
|
title: 'FRPC 内网穿透',
|
||||||
|
desc: '通过 FRP 客户端连接 frps 服务',
|
||||||
|
quickConfig: '快速配置',
|
||||||
|
fullConfig: '完整配置',
|
||||||
|
fullConfigHint: '可在此粘贴供应商 TOML 配置文件',
|
||||||
|
fullConfigRequired: '请填写完整 frpc.toml 配置',
|
||||||
|
proxyType: '代理类型',
|
||||||
|
proxyName: '代理名称',
|
||||||
|
proxyNamePlaceholder: 'one-kvm-ssh',
|
||||||
|
proxyNameRequired: '请填写 FRPC 代理名称',
|
||||||
|
serverAddr: '服务器地址',
|
||||||
|
serverAddrPlaceholder: 'frps.example.com',
|
||||||
|
serverAddrRequired: '请填写 FRPC 服务器地址',
|
||||||
|
serverPort: '服务器端口',
|
||||||
|
token: '认证令牌',
|
||||||
|
tokenRequired: '请填写 FRPC 认证令牌',
|
||||||
|
localIp: '本地地址',
|
||||||
|
localIpRequired: '请填写 FRPC 本地地址',
|
||||||
|
localPort: '本地端口',
|
||||||
|
remotePort: '远程端口',
|
||||||
|
remotePortRequired: 'TCP/UDP 代理需要填写远程端口',
|
||||||
|
customDomain: '自定义域名',
|
||||||
|
customDomainPlaceholder: 'kvm.example.com',
|
||||||
|
secretKey: '访问密钥',
|
||||||
|
tls: '启用 TLS',
|
||||||
|
},
|
||||||
rustdesk: {
|
rustdesk: {
|
||||||
title: 'RustDesk 远程',
|
title: 'RustDesk 远程',
|
||||||
desc: '使用 RustDesk 客户端进行远程访问',
|
desc: '使用 RustDesk 客户端进行远程访问',
|
||||||
|
|||||||
@@ -175,10 +175,43 @@ export interface EasytierConfig {
|
|||||||
virtual_ip?: string;
|
virtual_ip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum FrpProxyType {
|
||||||
|
Tcp = "tcp",
|
||||||
|
Udp = "udp",
|
||||||
|
Http = "http",
|
||||||
|
Https = "https",
|
||||||
|
Stcp = "stcp",
|
||||||
|
Sudp = "sudp",
|
||||||
|
Xtcp = "xtcp",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FrpcConfigMode {
|
||||||
|
Quick = "quick",
|
||||||
|
Full = "full",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrpcConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
config_mode: FrpcConfigMode;
|
||||||
|
proxy_name: string;
|
||||||
|
proxy_type: FrpProxyType;
|
||||||
|
server_addr: string;
|
||||||
|
server_port: number;
|
||||||
|
token: string;
|
||||||
|
local_ip: string;
|
||||||
|
local_port: number;
|
||||||
|
remote_port?: number;
|
||||||
|
custom_domain?: string;
|
||||||
|
secret_key: string;
|
||||||
|
tls: boolean;
|
||||||
|
custom_toml: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExtensionsConfig {
|
export interface ExtensionsConfig {
|
||||||
ttyd: TtydConfig;
|
ttyd: TtydConfig;
|
||||||
gostc: GostcConfig;
|
gostc: GostcConfig;
|
||||||
easytier: EasytierConfig;
|
easytier: EasytierConfig;
|
||||||
|
frpc: FrpcConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RustDeskConfig {
|
export interface RustDeskConfig {
|
||||||
@@ -277,6 +310,23 @@ export interface EasytierConfigUpdate {
|
|||||||
virtual_ip?: string;
|
virtual_ip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FrpcConfigUpdate {
|
||||||
|
enabled?: boolean;
|
||||||
|
config_mode?: FrpcConfigMode;
|
||||||
|
proxy_name?: string;
|
||||||
|
proxy_type?: FrpProxyType;
|
||||||
|
server_addr?: string;
|
||||||
|
server_port?: number;
|
||||||
|
token?: string;
|
||||||
|
local_ip?: string;
|
||||||
|
local_port?: number;
|
||||||
|
remote_port?: number;
|
||||||
|
custom_domain?: string;
|
||||||
|
secret_key?: string;
|
||||||
|
tls?: boolean;
|
||||||
|
custom_toml?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ExtensionStatus =
|
export type ExtensionStatus =
|
||||||
| { state: "unavailable", data?: undefined }
|
| { state: "unavailable", data?: undefined }
|
||||||
| { state: "stopped", data?: undefined }
|
| { state: "stopped", data?: undefined }
|
||||||
@@ -299,6 +349,7 @@ export enum ExtensionId {
|
|||||||
Ttyd = "ttyd",
|
Ttyd = "ttyd",
|
||||||
Gostc = "gostc",
|
Gostc = "gostc",
|
||||||
Easytier = "easytier",
|
Easytier = "easytier",
|
||||||
|
Frpc = "frpc",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionLogs {
|
export interface ExtensionLogs {
|
||||||
@@ -318,10 +369,17 @@ export interface GostcInfo {
|
|||||||
config: GostcConfig;
|
config: GostcConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FrpcInfo {
|
||||||
|
available: boolean;
|
||||||
|
status: ExtensionStatus;
|
||||||
|
config: FrpcConfig;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExtensionsStatus {
|
export interface ExtensionsStatus {
|
||||||
ttyd: TtydInfo;
|
ttyd: TtydInfo;
|
||||||
gostc: GostcInfo;
|
gostc: GostcInfo;
|
||||||
easytier: EasytierInfo;
|
easytier: EasytierInfo;
|
||||||
|
frpc: FrpcInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GostcConfigUpdate {
|
export interface GostcConfigUpdate {
|
||||||
@@ -597,4 +655,3 @@ export enum CanonicalKey {
|
|||||||
AltRight = "AltRight",
|
AltRight = "AltRight",
|
||||||
MetaRight = "MetaRight",
|
MetaRight = "MetaRight",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import type {
|
|||||||
OtgHidProfile,
|
OtgHidProfile,
|
||||||
OtgHidFunctions,
|
OtgHidFunctions,
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
|
import { FrpProxyType, FrpcConfigMode } from '@/types/generated'
|
||||||
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { getVideoFormatState } from '@/lib/video-format-support'
|
import { getVideoFormatState } from '@/lib/video-format-support'
|
||||||
@@ -218,6 +219,7 @@ function selectSection(id: string) {
|
|||||||
function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
|
function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
|
||||||
if (typeof value !== 'string') return null
|
if (typeof value !== 'string') return null
|
||||||
if (value === 'access-control') return 'account'
|
if (value === 'access-control') return 'account'
|
||||||
|
if (value === 'ext-frpc') return 'ext-remote-access'
|
||||||
return isSettingsSectionId(value) ? value : null
|
return isSettingsSectionId(value) ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,11 +317,13 @@ const extensionLogs = ref<Record<string, string[]>>({
|
|||||||
ttyd: [],
|
ttyd: [],
|
||||||
gostc: [],
|
gostc: [],
|
||||||
easytier: [],
|
easytier: [],
|
||||||
|
frpc: [],
|
||||||
})
|
})
|
||||||
const showLogs = ref<Record<string, boolean>>({
|
const showLogs = ref<Record<string, boolean>>({
|
||||||
ttyd: false,
|
ttyd: false,
|
||||||
gostc: false,
|
gostc: false,
|
||||||
easytier: false,
|
easytier: false,
|
||||||
|
frpc: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const showTerminalDialog = ref(false)
|
const showTerminalDialog = ref(false)
|
||||||
@@ -328,6 +332,22 @@ const extConfig = ref({
|
|||||||
ttyd: { enabled: false, shell: '/bin/bash' },
|
ttyd: { enabled: false, shell: '/bin/bash' },
|
||||||
gostc: { enabled: false, addr: '', key: '', tls: true },
|
gostc: { enabled: false, addr: '', key: '', tls: true },
|
||||||
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
|
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
|
||||||
|
frpc: {
|
||||||
|
enabled: false,
|
||||||
|
config_mode: FrpcConfigMode.Quick,
|
||||||
|
proxy_name: '',
|
||||||
|
proxy_type: FrpProxyType.Tcp,
|
||||||
|
server_addr: '',
|
||||||
|
server_port: 7000,
|
||||||
|
token: '',
|
||||||
|
local_ip: '127.0.0.1',
|
||||||
|
local_port: 22,
|
||||||
|
remote_port: undefined as number | undefined,
|
||||||
|
custom_domain: '',
|
||||||
|
secret_key: '',
|
||||||
|
tls: true,
|
||||||
|
custom_toml: '',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const gostcValidationMessage = computed(() => {
|
const gostcValidationMessage = computed(() => {
|
||||||
@@ -341,6 +361,25 @@ const easytierValidationMessage = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const frpcRemotePortRequired = computed(() => ['tcp', 'udp'].includes(extConfig.value.frpc.proxy_type))
|
||||||
|
const showFrpcRemotePort = computed(() => ['tcp', 'udp', 'stcp', 'sudp', 'xtcp'].includes(extConfig.value.frpc.proxy_type))
|
||||||
|
const showFrpcCustomDomain = computed(() => ['http', 'https'].includes(extConfig.value.frpc.proxy_type))
|
||||||
|
const showFrpcSecretKey = computed(() => ['stcp', 'sudp', 'xtcp'].includes(extConfig.value.frpc.proxy_type))
|
||||||
|
const frpcQuickMode = computed(() => extConfig.value.frpc.config_mode === FrpcConfigMode.Quick)
|
||||||
|
|
||||||
|
const frpcValidationMessage = computed(() => {
|
||||||
|
if (extConfig.value.frpc.config_mode === FrpcConfigMode.Full) {
|
||||||
|
if (!extConfig.value.frpc.custom_toml?.trim()) return t('extensions.frpc.fullConfigRequired')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (!extConfig.value.frpc.proxy_name?.trim()) return t('extensions.frpc.proxyNameRequired')
|
||||||
|
if (!extConfig.value.frpc.server_addr?.trim()) return t('extensions.frpc.serverAddrRequired')
|
||||||
|
if (!extConfig.value.frpc.token) return t('extensions.frpc.tokenRequired')
|
||||||
|
if (!extConfig.value.frpc.local_ip?.trim()) return t('extensions.frpc.localIpRequired')
|
||||||
|
if (frpcRemotePortRequired.value && !extConfig.value.frpc.remote_port) return t('extensions.frpc.remotePortRequired')
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
const rustdeskConfig = ref<RustDeskConfigResponse | null>(null)
|
const rustdeskConfig = ref<RustDeskConfigResponse | null>(null)
|
||||||
const rustdeskStatus = ref<RustDeskStatusResponse | null>(null)
|
const rustdeskStatus = ref<RustDeskStatusResponse | null>(null)
|
||||||
const rustdeskPassword = ref<RustDeskPasswordResponse | null>(null)
|
const rustdeskPassword = ref<RustDeskPasswordResponse | null>(null)
|
||||||
@@ -1373,6 +1412,23 @@ async function loadExtensions() {
|
|||||||
peer_urls: easytier.peer_urls || [],
|
peer_urls: easytier.peer_urls || [],
|
||||||
virtual_ip: easytier.virtual_ip || '',
|
virtual_ip: easytier.virtual_ip || '',
|
||||||
}
|
}
|
||||||
|
const frpc = extensions.value.frpc.config
|
||||||
|
extConfig.value.frpc = {
|
||||||
|
enabled: frpc.enabled,
|
||||||
|
config_mode: frpc.config_mode || FrpcConfigMode.Quick,
|
||||||
|
proxy_name: frpc.proxy_name,
|
||||||
|
proxy_type: frpc.proxy_type,
|
||||||
|
server_addr: frpc.server_addr,
|
||||||
|
server_port: frpc.server_port,
|
||||||
|
token: frpc.token,
|
||||||
|
local_ip: frpc.local_ip,
|
||||||
|
local_port: frpc.local_port,
|
||||||
|
remote_port: frpc.remote_port,
|
||||||
|
custom_domain: frpc.custom_domain || '',
|
||||||
|
secret_key: frpc.secret_key,
|
||||||
|
tls: frpc.tls,
|
||||||
|
custom_toml: frpc.custom_toml || '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1380,8 +1436,11 @@ async function loadExtensions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startExtension(id: 'ttyd' | 'gostc' | 'easytier') {
|
type ExtensionConfigId = 'ttyd' | 'gostc' | 'easytier' | 'frpc'
|
||||||
if ((id === 'gostc' || id === 'easytier') && !validateExtensionConfig(id)) return
|
type ValidatedExtensionConfigId = Exclude<ExtensionConfigId, 'ttyd'>
|
||||||
|
|
||||||
|
async function startExtension(id: ExtensionConfigId) {
|
||||||
|
if (id !== 'ttyd' && !validateExtensionConfig(id)) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await extensionsApi.start(id)
|
await extensionsApi.start(id)
|
||||||
@@ -1390,7 +1449,7 @@ async function startExtension(id: 'ttyd' | 'gostc' | 'easytier') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopExtension(id: 'ttyd' | 'gostc' | 'easytier') {
|
async function stopExtension(id: ExtensionConfigId) {
|
||||||
try {
|
try {
|
||||||
await extensionsApi.stop(id)
|
await extensionsApi.stop(id)
|
||||||
await loadExtensions()
|
await loadExtensions()
|
||||||
@@ -1398,7 +1457,7 @@ async function stopExtension(id: 'ttyd' | 'gostc' | 'easytier') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshExtensionLogs(id: 'ttyd' | 'gostc' | 'easytier') {
|
async function refreshExtensionLogs(id: ExtensionConfigId) {
|
||||||
try {
|
try {
|
||||||
const result = await extensionsApi.logs(id, 100)
|
const result = await extensionsApi.logs(id, 100)
|
||||||
extensionLogs.value[id] = result.logs
|
extensionLogs.value[id] = result.logs
|
||||||
@@ -1406,8 +1465,12 @@ async function refreshExtensionLogs(id: 'ttyd' | 'gostc' | 'easytier') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveExtensionConfig(id: 'ttyd' | 'gostc' | 'easytier') {
|
async function saveExtensionConfig(id: ExtensionConfigId) {
|
||||||
if ((id === 'gostc' || id === 'easytier') && extConfig.value[id].enabled && !validateExtensionConfig(id)) return
|
if (id !== 'ttyd') {
|
||||||
|
const shouldValidate = extConfig.value[id].enabled
|
||||||
|
|| (id === 'frpc' && extConfig.value.frpc.config_mode === FrpcConfigMode.Full)
|
||||||
|
if (shouldValidate && !validateExtensionConfig(id)) return
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -1417,6 +1480,14 @@ async function saveExtensionConfig(id: 'ttyd' | 'gostc' | 'easytier') {
|
|||||||
await extensionsApi.updateGostc(extConfig.value.gostc)
|
await extensionsApi.updateGostc(extConfig.value.gostc)
|
||||||
} else if (id === 'easytier') {
|
} else if (id === 'easytier') {
|
||||||
await extensionsApi.updateEasytier(extConfig.value.easytier)
|
await extensionsApi.updateEasytier(extConfig.value.easytier)
|
||||||
|
} else if (id === 'frpc') {
|
||||||
|
const frpc = extConfig.value.frpc
|
||||||
|
await extensionsApi.updateFrpc({
|
||||||
|
...frpc,
|
||||||
|
remote_port: frpcQuickMode.value && showFrpcRemotePort.value ? frpc.remote_port : undefined,
|
||||||
|
custom_domain: frpcQuickMode.value && showFrpcCustomDomain.value ? frpc.custom_domain || undefined : undefined,
|
||||||
|
secret_key: frpcQuickMode.value && showFrpcSecretKey.value ? frpc.secret_key : '',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await loadExtensions()
|
await loadExtensions()
|
||||||
saved.value = true
|
saved.value = true
|
||||||
@@ -1662,10 +1733,15 @@ function showValidationError(message: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateExtensionConfig(id: 'gostc' | 'easytier'): boolean {
|
function validateExtensionConfig(id: ValidatedExtensionConfigId): boolean {
|
||||||
const message = id === 'gostc'
|
let message = ''
|
||||||
? gostcValidationMessage.value
|
if (id === 'gostc') {
|
||||||
: easytierValidationMessage.value
|
message = gostcValidationMessage.value
|
||||||
|
} else if (id === 'easytier') {
|
||||||
|
message = easytierValidationMessage.value
|
||||||
|
} else {
|
||||||
|
message = frpcValidationMessage.value
|
||||||
|
}
|
||||||
|
|
||||||
return !message || showValidationError(message)
|
return !message || showValidationError(message)
|
||||||
}
|
}
|
||||||
@@ -3982,6 +4058,183 @@ watch(isWindows, () => {
|
|||||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- FRPC -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<CardTitle>{{ t('extensions.frpc.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('extensions.frpc.desc') }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge :variant="extensions?.frpc?.available ? 'default' : 'destructive'">
|
||||||
|
{{ extensions?.frpc?.available ? t('extensions.available') : t('extensions.unavailable') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div v-if="!extensions?.frpc?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
|
||||||
|
{{ t('extensions.binaryNotFound', { path: isWindows ? 'frpc.exe' : '/usr/bin/frpc' }) }}
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div :class="['w-2 h-2 rounded-full', getExtStatusClass(extensions?.frpc?.status)]" />
|
||||||
|
<span class="text-sm">{{ getExtStatusText(extensions?.frpc?.status) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="!isExtRunning(extensions?.frpc?.status)"
|
||||||
|
size="sm"
|
||||||
|
@click="startExtension('frpc')"
|
||||||
|
:disabled="extensionsLoading || !!frpcValidationMessage"
|
||||||
|
>
|
||||||
|
<Play class="h-4 w-4 mr-1" />
|
||||||
|
{{ t('extensions.start') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="stopExtension('frpc')"
|
||||||
|
:disabled="extensionsLoading"
|
||||||
|
>
|
||||||
|
<Square class="h-4 w-4 mr-1" />
|
||||||
|
{{ t('extensions.stop') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||||
|
<Switch v-model="extConfig.frpc.enabled" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 rounded-md bg-muted p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'rounded-sm px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
frpcQuickMode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
]"
|
||||||
|
:disabled="isExtRunning(extensions?.frpc?.status)"
|
||||||
|
@click="extConfig.frpc.config_mode = FrpcConfigMode.Quick"
|
||||||
|
>
|
||||||
|
{{ t('extensions.frpc.quickConfig') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'rounded-sm px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
!frpcQuickMode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
]"
|
||||||
|
:disabled="isExtRunning(extensions?.frpc?.status)"
|
||||||
|
@click="extConfig.frpc.config_mode = FrpcConfigMode.Full"
|
||||||
|
>
|
||||||
|
{{ t('extensions.frpc.fullConfig') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="frpcQuickMode">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.proxyType') }}</Label>
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<RadioGroup v-model="extConfig.frpc.proxy_type" class="flex flex-wrap gap-4" :disabled="isExtRunning(extensions?.frpc?.status)">
|
||||||
|
<div v-for="type in ['tcp', 'udp', 'http', 'https', 'stcp', 'sudp', 'xtcp']" :key="type" class="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem :value="type" :id="`frpc-${type}`" />
|
||||||
|
<Label :for="`frpc-${type}`" class="cursor-pointer uppercase">{{ type }}</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.proxyName') }}</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-1">
|
||||||
|
<Input v-model="extConfig.frpc.proxy_name" :placeholder="t('extensions.frpc.proxyNamePlaceholder')" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.proxy_name?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.proxyNameRequired') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.serverAddr') }}</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-1">
|
||||||
|
<Input v-model="extConfig.frpc.server_addr" :placeholder="t('extensions.frpc.serverAddrPlaceholder')" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.server_addr?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.serverAddrRequired') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.serverPort') }}</Label>
|
||||||
|
<Input v-model.number="extConfig.frpc.server_port" class="sm:col-span-3" type="number" min="1" max="65535" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.token') }}</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-1">
|
||||||
|
<Input v-model="extConfig.frpc.token" type="password" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.token" class="text-xs text-destructive">{{ t('extensions.frpc.tokenRequired') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.localIp') }}</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-1">
|
||||||
|
<Input v-model="extConfig.frpc.local_ip" placeholder="127.0.0.1" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
<p v-if="extConfig.frpc.enabled && !extConfig.frpc.local_ip?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.localIpRequired') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.localPort') }}</Label>
|
||||||
|
<Input v-model.number="extConfig.frpc.local_port" class="sm:col-span-3" type="number" min="1" max="65535" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
</div>
|
||||||
|
<div v-if="showFrpcRemotePort" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.remotePort') }}</Label>
|
||||||
|
<div class="sm:col-span-3 space-y-1">
|
||||||
|
<Input v-model.number="extConfig.frpc.remote_port" type="number" min="1" max="65535" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
<p v-if="extConfig.frpc.enabled && frpcRemotePortRequired && !extConfig.frpc.remote_port" class="text-xs text-destructive">{{ t('extensions.frpc.remotePortRequired') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showFrpcCustomDomain" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.customDomain') }}</Label>
|
||||||
|
<Input v-model="extConfig.frpc.custom_domain" class="sm:col-span-3" :placeholder="t('extensions.frpc.customDomainPlaceholder')" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
</div>
|
||||||
|
<div v-if="showFrpcSecretKey" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.secretKey') }}</Label>
|
||||||
|
<Input v-model="extConfig.frpc.secret_key" class="sm:col-span-3" type="password" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
|
<Label class="sm:text-right">{{ t('extensions.frpc.tls') }}</Label>
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<Switch v-model="extConfig.frpc.tls" :disabled="isExtRunning(extensions?.frpc?.status)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="space-y-1">
|
||||||
|
<Textarea
|
||||||
|
v-model="extConfig.frpc.custom_toml"
|
||||||
|
class="min-h-[300px] font-mono text-xs"
|
||||||
|
spellcheck="false"
|
||||||
|
:disabled="isExtRunning(extensions?.frpc?.status)"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('extensions.frpc.fullConfigHint') }}</p>
|
||||||
|
<p v-if="!extConfig.frpc.custom_toml?.trim()" class="text-xs text-destructive">{{ t('extensions.frpc.fullConfigRequired') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button type="button" @click="showLogs.frpc = !showLogs.frpc; if (showLogs.frpc) refreshExtensionLogs('frpc')" class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ChevronRight :class="['h-4 w-4 transition-transform', showLogs.frpc ? 'rotate-90' : '']" />
|
||||||
|
{{ t('extensions.viewLogs') }}
|
||||||
|
</button>
|
||||||
|
<div v-if="showLogs.frpc" class="space-y-2">
|
||||||
|
<pre class="p-3 bg-muted rounded-md text-xs max-h-48 overflow-auto font-mono">{{ (extensionLogs.frpc || []).join('\n') || t('extensions.noLogs') }}</pre>
|
||||||
|
<Button variant="ghost" size="sm" @click="refreshExtensionLogs('frpc')">
|
||||||
|
<RefreshCw class="h-3 w-3 mr-1" />
|
||||||
|
{{ t('common.refresh') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div v-if="extensions?.frpc?.available" class="flex justify-end">
|
||||||
|
<Button :disabled="loading || isExtRunning(extensions?.frpc?.status)" @click="saveExtensionConfig('frpc')">
|
||||||
|
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RTSP Section -->
|
<!-- RTSP Section -->
|
||||||
|
|||||||
Reference in New Issue
Block a user