feat(extensions): 恢复 GOSTC 支持并保留 EasyFrp

This commit is contained in:
mofeng-git
2026-10-31 09:57:38 +08:00
parent 499a273df8
commit d564d8baee
19 changed files with 493 additions and 15 deletions

View File

@@ -72,7 +72,7 @@ Replace the version and architecture in the filename with your actual file name.
Images: Images:
- **one-kvm** — main app + ttyd - **one-kvm** — main app + ttyd
- **one-kvm-full** — same plus optional extras (e.g. gostc, easytier-core) - **one-kvm-full** — same plus optional extras (gostc, easyfrpc, easytier-core, easytier-cli, frpc)
Example: Example:

View File

@@ -69,7 +69,7 @@ sudo apt install ./one-kvm_0.x.x_<arch>.deb
### 使用 Docker ### 使用 Docker
镜像分为 **one-kvm**(One-KVM 主程序 + ttyd)与 **one-kvm-full**(另含 gostc、easytier-core 等可选扩展),按需选用。 镜像分为 **one-kvm**(One-KVM 主程序 + ttyd)与 **one-kvm-full**(另含 gostc、easyfrpc、easytier-core、easytier-cli、frpc 等可选扩展),按需选用。
```bash ```bash
docker run --name one-kvm -itd \ docker run --name one-kvm -itd \

View File

@@ -52,7 +52,7 @@ RUN if [ -n "$APT_MIRROR" ]; then \
COPY --chmod=755 init.sh /init.sh COPY --chmod=755 init.sh /init.sh
# Copy binaries (these are placed by the build script) # Copy binaries (these are placed by the build script)
COPY --chmod=755 one-kvm ttyd easyfrpc easytier-core easytier-cli frpc /usr/bin/ COPY --chmod=755 one-kvm ttyd gostc easyfrpc easytier-core easytier-cli frpc /usr/bin/
# Copy ventoy resources if they exist # Copy ventoy resources if they exist
COPY ventoy/ /etc/one-kvm/ventoy/ COPY ventoy/ /etc/one-kvm/ventoy/

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Fetch the pinned EasyFrp, EasyTier and frp clients for full Docker images.""" """Fetch the pinned GOSTC, EasyFrp, EasyTier and frp clients for full Docker images."""
import argparse import argparse
import hashlib import hashlib
import json import json

View File

@@ -118,7 +118,7 @@ while [[ $# -gt 0 ]]; do
echo " # Build for current platform and load locally" echo " # Build for current platform and load locally"
echo " $0 --platform linux/arm64 --load" echo " $0 --platform linux/arm64 --load"
echo "" echo ""
echo " # Build full image (includes EasyFrp + EasyTier + frpc)" echo " # Build full image (includes GOSTC + EasyFrp + EasyTier + frpc)"
echo " $0 --variant full --platform linux/arm64 --load" echo " $0 --variant full --platform linux/arm64 --load"
echo "" echo ""
echo " # Build and push single platform" echo " # Build and push single platform"

View File

@@ -1,5 +1,28 @@
{ {
"tools": { "tools": {
"gostc": {
"version": "v2.0.9",
"binaries": [
"gostc"
],
"assets": {
"arm": {
"archive": "gostc_linux_arm_7.tar.gz",
"url": "https://github.com/SianHH/gostc-open/releases/download/v2.0.9/gostc_linux_arm_7.tar.gz",
"sha256": "abca04c0503245ad5238d4482111b4a0ca2959daf460cd2abba47f3fce292a6b"
},
"aarch64": {
"archive": "gostc_linux_arm64_v8.0.tar.gz",
"url": "https://github.com/SianHH/gostc-open/releases/download/v2.0.9/gostc_linux_arm64_v8.0.tar.gz",
"sha256": "2a100487a4f3ccd27ad82132c4f8e6ac253c9696428f0d3fd4c53170e1c3682e"
},
"x86_64": {
"archive": "gostc_linux_amd64_v1.tar.gz",
"url": "https://github.com/SianHH/gostc-open/releases/download/v2.0.9/gostc_linux_amd64_v1.tar.gz",
"sha256": "6285506b54bfece2af2636ceacd4e40fefb28fcfbe1b7ba072854b08166549d7"
}
}
},
"easyfrp": { "easyfrp": {
"version": "v3.1.1", "version": "v3.1.1",
"binaries": [ "binaries": [

View File

@@ -9,7 +9,9 @@ use tokio::sync::RwLock;
use super::protected_config::ProtectedConfigFile; use super::protected_config::ProtectedConfigFile;
use super::types::*; use super::types::*;
use super::validation::{validate_easyfrp_config, validate_easytier_config, validate_frpc_config}; use super::validation::{
validate_easyfrp_config, validate_easytier_config, validate_frpc_config, validate_gostc_config,
};
use crate::events::EventBus; use crate::events::EventBus;
const LOG_BUFFER_SIZE: usize = 200; const LOG_BUFFER_SIZE: usize = 200;
@@ -82,6 +84,9 @@ impl ExtensionManager {
fn is_enabled_for_config(id: ExtensionId, config: &ExtensionsConfig) -> bool { fn is_enabled_for_config(id: ExtensionId, config: &ExtensionsConfig) -> bool {
match id { match id {
ExtensionId::Ttyd => config.ttyd.enabled, ExtensionId::Ttyd => config.ttyd.enabled,
ExtensionId::Gostc => {
config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok()
}
ExtensionId::Easyfrp => { ExtensionId::Easyfrp => {
config.easyfrp.enabled && validate_easyfrp_config(&config.easyfrp).is_ok() config.easyfrp.enabled && validate_easyfrp_config(&config.easyfrp).is_ok()
} }
@@ -267,6 +272,20 @@ impl ExtensionManager {
args args
} }
ExtensionId::Gostc => {
let c = &config.gostc;
validate_gostc_config(c)?;
// gostc defaults to TLS, so disabling it must be explicit.
let mut args = vec![format!("--tls={}", c.tls)];
args.extend(["-addr".to_string(), c.addr.trim().to_string()]);
args.extend(["-key".to_string(), c.key.clone()]);
args
}
ExtensionId::Easyfrp => { ExtensionId::Easyfrp => {
let c = &config.easyfrp; let c = &config.easyfrp;
validate_easyfrp_config(c)?; validate_easyfrp_config(c)?;
@@ -570,6 +589,54 @@ impl ExtensionManager {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn gostc_launch_explicitly_sets_tls_and_preserves_key_argument() {
let mut config = ExtensionsConfig::default();
config.gostc.addr = " example.com:443 ".to_string();
config.gostc.key = "key with spaces".to_string();
for tls in [true, false] {
config.gostc.tls = tls;
let launch = ExtensionManager::new()
.build_launch(ExtensionId::Gostc, &config)
.await
.unwrap();
assert_eq!(
launch.args,
[
format!("--tls={tls}"),
"-addr".to_string(),
"example.com:443".to_string(),
"-key".to_string(),
"key with spaces".to_string(),
]
);
}
}
#[tokio::test]
async fn gostc_invalid_config_cannot_launch_or_autostart() {
let mut config = ExtensionsConfig::default();
config.gostc.enabled = true;
assert!(!ExtensionManager::is_enabled_for_config(
ExtensionId::Gostc,
&config
));
assert!(ExtensionManager::new()
.build_launch(ExtensionId::Gostc, &config)
.await
.is_err());
config.gostc.addr = "example.com".to_string();
config.gostc.key = "client-key".to_string();
assert!(ExtensionManager::is_enabled_for_config(
ExtensionId::Gostc,
&config
));
assert!(!ExtensionManager::is_enabled_for_config(
ExtensionId::Easyfrp,
&config
));
}
#[tokio::test] #[tokio::test]
async fn easyfrp_launch_uses_control_urls_as_separate_arguments() { async fn easyfrp_launch_uses_control_urls_as_separate_arguments() {
let mut config = ExtensionsConfig::default(); let mut config = ExtensionsConfig::default();

View File

@@ -12,5 +12,5 @@ pub use manager::TTYD_TCP_ADDR;
pub use types::*; pub use types::*;
pub(crate) use validation::{ pub(crate) use validation::{
validate_easyfrp_config, validate_easytier_config, validate_extension_config, validate_easyfrp_config, validate_easytier_config, validate_extension_config,
validate_frpc_config, validate_frpc_config, validate_gostc_config,
}; };

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ use super::software;
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ExtensionId { pub enum ExtensionId {
Ttyd, Ttyd,
Gostc,
Easyfrp, Easyfrp,
Easytier, Easytier,
Frpc, Frpc,
@@ -19,7 +20,13 @@ impl ExtensionId {
} }
pub fn all() -> &'static [ExtensionId] { pub fn all() -> &'static [ExtensionId] {
&[Self::Ttyd, Self::Easyfrp, Self::Easytier, Self::Frpc] &[
Self::Ttyd,
Self::Gostc,
Self::Easyfrp,
Self::Easytier,
Self::Frpc,
]
} }
} }
@@ -27,6 +34,7 @@ impl std::fmt::Display for ExtensionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Ttyd => write!(f, "ttyd"), Self::Ttyd => write!(f, "ttyd"),
Self::Gostc => write!(f, "gostc"),
Self::Easyfrp => write!(f, "easyfrp"), Self::Easyfrp => write!(f, "easyfrp"),
Self::Easytier => write!(f, "easytier"), Self::Easytier => write!(f, "easytier"),
Self::Frpc => write!(f, "frpc"), Self::Frpc => write!(f, "frpc"),
@@ -40,6 +48,7 @@ impl std::str::FromStr for ExtensionId {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"ttyd" => Ok(Self::Ttyd), "ttyd" => Ok(Self::Ttyd),
"gostc" => Ok(Self::Gostc),
"easyfrp" => Ok(Self::Easyfrp), "easyfrp" => Ok(Self::Easyfrp),
"easytier" => Ok(Self::Easytier), "easytier" => Ok(Self::Easytier),
"frpc" => Ok(Self::Frpc), "frpc" => Ok(Self::Frpc),
@@ -80,6 +89,28 @@ impl Default for TtydConfig {
} }
} }
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GostcConfig {
pub enabled: bool,
pub addr: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub key: String,
pub tls: bool,
}
impl Default for GostcConfig {
fn default() -> Self {
Self {
enabled: false,
addr: String::new(),
key: String::new(),
tls: true,
}
}
}
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)] #[serde(default)]
@@ -203,6 +234,7 @@ impl Default for FrpcConfig {
#[serde(default)] #[serde(default)]
pub struct ExtensionsConfig { pub struct ExtensionsConfig {
pub ttyd: TtydConfig, pub ttyd: TtydConfig,
pub gostc: GostcConfig,
pub easyfrp: EasyfrpConfig, pub easyfrp: EasyfrpConfig,
pub easytier: EasytierConfig, pub easytier: EasytierConfig,
pub frpc: FrpcConfig, pub frpc: FrpcConfig,
@@ -223,6 +255,14 @@ pub struct TtydInfo {
pub config: TtydConfig, pub config: TtydConfig,
} }
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GostcInfo {
pub available: bool,
pub status: ExtensionStatus,
pub config: GostcConfig,
}
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EasyfrpInfo { pub struct EasyfrpInfo {
@@ -251,6 +291,7 @@ pub struct FrpcInfo {
#[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 easyfrp: EasyfrpInfo, pub easyfrp: EasyfrpInfo,
pub easytier: EasytierInfo, pub easytier: EasytierInfo,
pub frpc: FrpcInfo, pub frpc: FrpcInfo,
@@ -268,11 +309,43 @@ mod tests {
use super::{EasytierConfig, EasytierConfigMode, ExtensionsConfig}; use super::{EasytierConfig, EasytierConfigMode, ExtensionsConfig};
#[test] #[test]
fn replacing_legacy_gostc_requires_new_easyfrp_configuration() { fn existing_easyfrp_config_leaves_gostc_disabled_with_tls_default() {
let config: ExtensionsConfig = serde_json::from_str(
r#"{"easyfrp":{"enabled":true,"control_urls":["wss://example.com/key"]}}"#,
)
.unwrap();
assert!(!config.gostc.enabled);
assert!(config.gostc.tls);
assert!(config.easyfrp.enabled);
assert_eq!(config.easyfrp.control_urls, ["wss://example.com/key"]);
}
#[test]
fn gostc_and_easyfrp_configs_round_trip_independently() {
let config: ExtensionsConfig = serde_json::from_str(
r#"{"gostc":{"enabled":true,"addr":"old.example.com","key":"old-key","tls":false},"easyfrp":{"enabled":true,"control_urls":["wss://example.com/new-key"]}}"#,
).unwrap();
let saved = serde_json::to_string(&config).unwrap();
let restored: ExtensionsConfig = serde_json::from_str(&saved).unwrap();
assert!(restored.gostc.enabled);
assert!(!restored.gostc.tls);
assert_eq!(restored.gostc.key, "old-key");
assert!(restored.easyfrp.enabled);
assert_eq!(restored.easyfrp.control_urls, ["wss://example.com/new-key"]);
}
#[test]
fn legacy_gostc_configuration_is_preserved_independently_of_easyfrp() {
let config: ExtensionsConfig = serde_json::from_str( let config: ExtensionsConfig = serde_json::from_str(
r#"{"gostc":{"enabled":true,"addr":"old.example.com","key":"old-key","tls":true}}"#, r#"{"gostc":{"enabled":true,"addr":"old.example.com","key":"old-key","tls":true}}"#,
) )
.unwrap(); .unwrap();
assert!(config.gostc.enabled);
assert_eq!(config.gostc.addr, "old.example.com");
assert_eq!(config.gostc.key, "old-key");
assert!(config.gostc.tls);
let saved = serde_json::to_value(&config).unwrap();
assert_eq!(saved["gostc"]["key"], "old-key");
assert!(!config.easyfrp.enabled); assert!(!config.easyfrp.enabled);
assert!(config.easyfrp.control_urls.is_empty()); assert!(config.easyfrp.control_urls.is_empty());
} }

View File

@@ -2,7 +2,7 @@ use toml_edit::DocumentMut;
use super::types::{ use super::types::{
EasyfrpConfig, EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, EasyfrpConfig, EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType,
FrpcConfig, FrpcConfigMode, FrpcConfig, FrpcConfigMode, GostcConfig,
}; };
pub(crate) fn validate_extension_config( pub(crate) fn validate_extension_config(
@@ -11,12 +11,18 @@ pub(crate) fn validate_extension_config(
) -> Result<(), String> { ) -> Result<(), String> {
match id { match id {
ExtensionId::Ttyd => Ok(()), ExtensionId::Ttyd => Ok(()),
ExtensionId::Gostc => validate_gostc_config(&config.gostc),
ExtensionId::Easyfrp => validate_easyfrp_config(&config.easyfrp), ExtensionId::Easyfrp => validate_easyfrp_config(&config.easyfrp),
ExtensionId::Easytier => validate_easytier_config(&config.easytier), ExtensionId::Easytier => validate_easytier_config(&config.easytier),
ExtensionId::Frpc => validate_frpc_config(&config.frpc), ExtensionId::Frpc => validate_frpc_config(&config.frpc),
} }
} }
pub(crate) fn validate_gostc_config(config: &GostcConfig) -> Result<(), String> {
require_non_empty(config.addr.trim(), "GOSTC server address is required")?;
require_non_empty(config.key.as_str(), "GOSTC client key is required")
}
pub(crate) fn validate_easyfrp_config(config: &EasyfrpConfig) -> Result<(), String> { pub(crate) fn validate_easyfrp_config(config: &EasyfrpConfig) -> Result<(), String> {
if config.control_urls.is_empty() { if config.control_urls.is_empty() {
return Err("EasyFrp control URL is required".to_string()); return Err("EasyFrp control URL is required".to_string());
@@ -101,6 +107,18 @@ fn validate_full_toml(extension_name: &str, config: &str) -> Result<(), String>
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn gostc_requires_both_address_and_key() {
let mut config = GostcConfig::default();
assert!(validate_gostc_config(&config).is_err());
config.addr = "example.com".to_string();
assert!(validate_gostc_config(&config).is_err());
config.key = "client-key".to_string();
assert!(validate_gostc_config(&config).is_ok());
config.addr = " \t ".to_string();
assert!(validate_gostc_config(&config).is_err());
}
#[test] #[test]
fn easyfrp_accepts_documented_control_protocols() { fn easyfrp_accepts_documented_control_protocols() {
for scheme in ["ws", "wss", "http", "https", "txt"] { for scheme in ["ws", "wss", "http", "https", "txt"] {

View File

@@ -9,9 +9,10 @@ use typeshare::typeshare;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::extensions::{ use crate::extensions::{
validate_easyfrp_config, validate_easytier_config, validate_extension_config, validate_easyfrp_config, validate_easytier_config, validate_extension_config,
validate_frpc_config, EasyfrpConfig, EasyfrpInfo, EasytierConfig, EasytierConfigMode, validate_frpc_config, validate_gostc_config, EasyfrpConfig, EasyfrpInfo, EasytierConfig,
EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType, EasytierConfigMode, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus,
FrpcConfig, FrpcConfigMode, FrpcInfo, TtydConfig, TtydInfo, FrpProxyType, FrpcConfig, FrpcConfigMode, FrpcInfo, GostcConfig, GostcInfo, TtydConfig,
TtydInfo,
}; };
use crate::state::AppState; use crate::state::AppState;
@@ -29,6 +30,11 @@ pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<Extensi
status: mgr.status(ExtensionId::Ttyd).await, status: mgr.status(ExtensionId::Ttyd).await,
config: config.extensions.ttyd.clone(), config: config.extensions.ttyd.clone(),
}, },
gostc: GostcInfo {
available: mgr.check_available(ExtensionId::Gostc),
status: mgr.status(ExtensionId::Gostc).await,
config: config.extensions.gostc.clone(),
},
easyfrp: EasyfrpInfo { easyfrp: EasyfrpInfo {
available: mgr.check_available(ExtensionId::Easyfrp), available: mgr.check_available(ExtensionId::Easyfrp),
status: mgr.status(ExtensionId::Easyfrp).await, status: mgr.status(ExtensionId::Easyfrp).await,
@@ -132,6 +138,15 @@ pub struct TtydConfigUpdate {
pub shell: Option<String>, pub shell: Option<String>,
} }
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct GostcConfigUpdate {
pub enabled: Option<bool>,
pub addr: Option<String>,
pub key: Option<String>,
pub tls: Option<bool>,
}
#[typeshare] #[typeshare]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct EasyfrpConfigUpdate { pub struct EasyfrpConfigUpdate {
@@ -207,6 +222,54 @@ pub async fn update_ttyd_config(
Ok(Json(new_config.extensions.ttyd.clone())) Ok(Json(new_config.extensions.ttyd.clone()))
} }
pub async fn update_gostc_config(
State(state): State<Arc<AppState>>,
Json(req): Json<GostcConfigUpdate>,
) -> Result<Json<GostcConfig>> {
let current_config = state.config.get();
let was_enabled = current_config.extensions.gostc.enabled;
let mut next_gostc = current_config.extensions.gostc.clone();
if let Some(enabled) = req.enabled {
next_gostc.enabled = enabled;
}
if let Some(ref addr) = req.addr {
next_gostc.addr = addr.clone();
}
if let Some(ref key) = req.key {
next_gostc.key = key.clone();
}
if let Some(tls) = req.tls {
next_gostc.tls = tls;
}
if next_gostc.enabled {
bad_request(validate_gostc_config(&next_gostc))?;
}
state
.config
.update(|config| {
config.extensions.gostc = next_gostc.clone();
})
.await?;
let new_config = state.config.get();
let is_enabled = new_config.extensions.gostc.enabled;
if was_enabled && !is_enabled {
state.extensions.stop(ExtensionId::Gostc).await.ok();
} else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Gostc) {
state
.extensions
.start(ExtensionId::Gostc, &new_config.extensions)
.await
.ok();
}
Ok(Json(new_config.extensions.gostc.clone()))
}
pub async fn update_easyfrp_config( pub async fn update_easyfrp_config(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<EasyfrpConfigUpdate>, Json(req): Json<EasyfrpConfigUpdate>,

View File

@@ -252,6 +252,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/extensions/ttyd/config", "/extensions/ttyd/config",
patch(handlers::extensions::update_ttyd_config), patch(handlers::extensions::update_ttyd_config),
) )
.route(
"/extensions/gostc/config",
patch(handlers::extensions::update_gostc_config),
)
.route( .route(
"/extensions/easyfrp/config", "/extensions/easyfrp/config",
patch(handlers::extensions::update_easyfrp_config), patch(handlers::extensions::update_easyfrp_config),

View File

@@ -26,6 +26,8 @@ import type {
ExtensionLogs, ExtensionLogs,
TtydConfig, TtydConfig,
TtydConfigUpdate, TtydConfigUpdate,
GostcConfig,
GostcConfigUpdate,
EasyfrpConfig, EasyfrpConfig,
EasyfrpConfigUpdate, EasyfrpConfigUpdate,
EasytierConfig, EasytierConfig,
@@ -201,6 +203,12 @@ export const extensionsApi = {
body: JSON.stringify(config), body: JSON.stringify(config),
}), }),
updateGostc: (config: GostcConfigUpdate) =>
request<GostcConfig>('/extensions/gostc/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),
updateEasyfrp: (config: EasyfrpConfigUpdate) => updateEasyfrp: (config: EasyfrpConfigUpdate) =>
request<EasyfrpConfig>('/extensions/easyfrp/config', { request<EasyfrpConfig>('/extensions/easyfrp/config', {
method: 'PATCH', method: 'PATCH',

View File

@@ -1128,6 +1128,17 @@ export default {
openInNewTab: 'Open in New Tab', openInNewTab: 'Open in New Tab',
shell: 'Shell', shell: 'Shell',
}, },
gostc: {
title: 'GOSTC NAT Traversal',
desc: 'NAT traversal via GOSTC',
addr: 'Server Address',
addrPlaceholder: 'Hostname or IP (required)',
addrRequired: 'Enter the GOSTC server address',
key: 'Client Key',
keyPlaceholder: 'Client key (required)',
keyRequired: 'Enter the GOSTC client key',
tls: 'Enable TLS',
},
easyfrp: { easyfrp: {
title: 'EasyFrp NAT Traversal', title: 'EasyFrp NAT Traversal',
desc: 'Connect to a tunnel service with the EasyFrp client', desc: 'Connect to a tunnel service with the EasyFrp client',

View File

@@ -1127,6 +1127,17 @@ export default {
openInNewTab: '在新标签页打开', openInNewTab: '在新标签页打开',
shell: 'Shell', shell: 'Shell',
}, },
gostc: {
title: 'GOSTC 内网穿透',
desc: '通过 GOSTC 实现内网穿透',
addr: '服务器地址',
addrPlaceholder: '主机名或 IP(必填)',
addrRequired: '请填写 GOSTC 服务器地址',
key: '客户端密钥',
keyPlaceholder: '客户端密钥(必填)',
keyRequired: '请填写 GOSTC 客户端密钥',
tls: '启用 TLS',
},
easyfrp: { easyfrp: {
title: 'EasyFrp 内网穿透', title: 'EasyFrp 内网穿透',
desc: '使用 EasyFrp 客户端连接穿透服务', desc: '使用 EasyFrp 客户端连接穿透服务',

View File

@@ -197,6 +197,13 @@ export interface TtydConfig {
shell: string; shell: string;
} }
export interface GostcConfig {
enabled: boolean;
addr: string;
key: string;
tls: boolean;
}
export interface EasyfrpConfig { export interface EasyfrpConfig {
enabled: boolean; enabled: boolean;
control_urls: string[]; control_urls: string[];
@@ -251,6 +258,7 @@ export interface FrpcConfig {
export interface ExtensionsConfig { export interface ExtensionsConfig {
ttyd: TtydConfig; ttyd: TtydConfig;
gostc: GostcConfig;
easyfrp: EasyfrpConfig; easyfrp: EasyfrpConfig;
easytier: EasytierConfig; easytier: EasytierConfig;
frpc: FrpcConfig; frpc: FrpcConfig;
@@ -436,6 +444,7 @@ export interface ExtensionInfo {
export enum ExtensionId { export enum ExtensionId {
Ttyd = "ttyd", Ttyd = "ttyd",
Gostc = "gostc",
Easyfrp = "easyfrp", Easyfrp = "easyfrp",
Easytier = "easytier", Easytier = "easytier",
Frpc = "frpc", Frpc = "frpc",
@@ -452,6 +461,12 @@ export interface TtydInfo {
config: TtydConfig; config: TtydConfig;
} }
export interface GostcInfo {
available: boolean;
status: ExtensionStatus;
config: GostcConfig;
}
export interface EasyfrpInfo { export interface EasyfrpInfo {
available: boolean; available: boolean;
status: ExtensionStatus; status: ExtensionStatus;
@@ -466,6 +481,7 @@ export interface FrpcInfo {
export interface ExtensionsStatus { export interface ExtensionsStatus {
ttyd: TtydInfo; ttyd: TtydInfo;
gostc: GostcInfo;
easyfrp: EasyfrpInfo; easyfrp: EasyfrpInfo;
easytier: EasytierInfo; easytier: EasytierInfo;
frpc: FrpcInfo; frpc: FrpcInfo;
@@ -488,6 +504,13 @@ export interface FrpcConfigUpdate {
custom_toml?: string; custom_toml?: string;
} }
export interface GostcConfigUpdate {
enabled?: boolean;
addr?: string;
key?: string;
tls?: boolean;
}
export interface EasyfrpConfigUpdate { export interface EasyfrpConfigUpdate {
enabled?: boolean; enabled?: boolean;
control_urls?: string[]; control_urls?: string[];

View File

@@ -446,12 +446,14 @@ const extensions = ref<ExtensionsStatus | null>(null)
const extensionsLoading = ref(false) const extensionsLoading = ref(false)
const extensionLogs = ref<Record<string, string[]>>({ const extensionLogs = ref<Record<string, string[]>>({
ttyd: [], ttyd: [],
gostc: [],
easyfrp: [], easyfrp: [],
easytier: [], easytier: [],
frpc: [], frpc: [],
}) })
const showLogs = ref<Record<string, boolean>>({ const showLogs = ref<Record<string, boolean>>({
ttyd: false, ttyd: false,
gostc: false,
easyfrp: false, easyfrp: false,
easytier: false, easytier: false,
frpc: false, frpc: false,
@@ -461,6 +463,7 @@ const showTerminalDialog = ref(false)
const extConfig = ref({ const extConfig = ref({
ttyd: { enabled: false, shell: '/bin/bash' }, ttyd: { enabled: false, shell: '/bin/bash' },
gostc: { enabled: false, addr: '', key: '', tls: true },
easyfrp: { enabled: false, control_urls: [] as string[] }, easyfrp: { enabled: false, control_urls: [] as string[] },
easytier: { easytier: {
enabled: false, enabled: false,
@@ -489,6 +492,12 @@ const extConfig = ref({
}, },
}) })
const gostcValidationMessage = computed(() => {
if (!extConfig.value.gostc.addr.trim()) return t('extensions.gostc.addrRequired')
if (!extConfig.value.gostc.key) return t('extensions.gostc.keyRequired')
return ''
})
const easyfrpUrlsText = computed({ const easyfrpUrlsText = computed({
get: () => extConfig.value.easyfrp.control_urls.join('\n'), get: () => extConfig.value.easyfrp.control_urls.join('\n'),
set: (value: string) => { set: (value: string) => {
@@ -1771,6 +1780,11 @@ async function loadExtensions(only?: ExtensionConfigId) {
enabled: ttyd.enabled, enabled: ttyd.enabled,
shell: ttyd.shell, shell: ttyd.shell,
} }
if (!only || only === 'gostc')
extConfig.value.gostc = {
...extensions.value.gostc.config,
key: extensions.value.gostc.config.key || '',
}
if (!only || only === 'easyfrp') if (!only || only === 'easyfrp')
extConfig.value.easyfrp = { ...extensions.value.easyfrp.config } extConfig.value.easyfrp = { ...extensions.value.easyfrp.config }
const easytier = extensions.value.easytier.config const easytier = extensions.value.easytier.config
@@ -1809,11 +1823,12 @@ async function loadExtensions(only?: ExtensionConfigId) {
} }
} }
type ExtensionConfigId = 'ttyd' | 'easyfrp' | 'easytier' | 'frpc' type ExtensionConfigId = 'ttyd' | 'gostc' | 'easyfrp' | 'easytier' | 'frpc'
type ValidatedExtensionConfigId = Exclude<ExtensionConfigId, 'ttyd'> type ValidatedExtensionConfigId = Exclude<ExtensionConfigId, 'ttyd'>
const extensionBusy = ref<Record<ExtensionConfigId, boolean>>({ const extensionBusy = ref<Record<ExtensionConfigId, boolean>>({
ttyd: false, ttyd: false,
gostc: false,
easyfrp: false, easyfrp: false,
easytier: false, easytier: false,
frpc: false, frpc: false,
@@ -1855,6 +1870,8 @@ async function refreshExtensionLogs(id: ExtensionConfigId) {
async function persistExtensionConfig(id: ExtensionConfigId) { async function persistExtensionConfig(id: ExtensionConfigId) {
if (id === 'ttyd') { if (id === 'ttyd') {
await extensionsApi.updateTtyd(extConfig.value.ttyd) await extensionsApi.updateTtyd(extConfig.value.ttyd)
} else if (id === 'gostc') {
await extensionsApi.updateGostc(extConfig.value.gostc)
} else if (id === 'easyfrp') { } else if (id === 'easyfrp') {
await extensionsApi.updateEasyfrp(extConfig.value.easyfrp) await extensionsApi.updateEasyfrp(extConfig.value.easyfrp)
} else if (id === 'easytier') { } else if (id === 'easytier') {
@@ -2084,7 +2101,9 @@ function showValidationError(message: string): boolean {
function validateExtensionConfig(id: ValidatedExtensionConfigId): boolean { function validateExtensionConfig(id: ValidatedExtensionConfigId): boolean {
let message = '' let message = ''
if (id === 'easyfrp') { if (id === 'gostc') {
message = gostcValidationMessage.value
} else if (id === 'easyfrp') {
message = easyfrpValidationMessage.value message = easyfrpValidationMessage.value
} else if (id === 'easytier') { } else if (id === 'easytier') {
message = easytierValidationMessage.value message = easytierValidationMessage.value
@@ -4747,6 +4766,162 @@ watch(isWindows, () => {
<!-- Remote Access Section --> <!-- Remote Access Section -->
<div v-show="activeSection === 'ext-remote-access'" class="settings-section-services"> <div v-show="activeSection === 'ext-remote-access'" class="settings-section-services">
<Card>
<CardHeader class="settings-card-heading"
><CardTitle :help="t('extensions.gostc.desc')">{{
t('extensions.gostc.title')
}}</CardTitle>
<div class="settings-card-actions">
<div class="settings-service-actions" v-if="extensions?.gostc?.available">
<Button
variant="outline"
:aria-label="t('extensions.start') + ' ' + t('extensions.gostc.title')"
:disabled="
extensionsLoading ||
!!gostcValidationMessage ||
isExtRunning(extensions?.gostc?.status) ||
extensionBusy.gostc
"
size="sm"
@click="startExtension('gostc')"
>
<Play class="size-4 mr-1" />
{{ t('extensions.start') }}
</Button>
<Button
:aria-label="t('extensions.stop') + ' ' + t('extensions.gostc.title')"
:disabled="
extensionsLoading ||
!isExtRunning(extensions?.gostc?.status) ||
extensionBusy.gostc
"
size="sm"
variant="outline"
@click="stopExtension('gostc')"
>
<Square class="size-4 mr-1" />
{{ t('extensions.stop') }}
</Button>
</div>
<div class="settings-save-action" v-if="extensions?.gostc?.available">
<Button
:disabled="
loading ||
isExtRunning(extensions?.gostc?.status) ||
extensionBusy.gostc
"
@click="saveExtensionConfig('gostc')"
>
<Loader2 v-if="extensionBusy.gostc" class="size-4 mr-2 animate-spin" /><Save v-else class="size-4 mr-2" />{{ t('common.save') }}
</Button>
</div>
</div></CardHeader
>
<CardContent class="space-y-4">
<div
v-if="!extensions?.gostc?.available"
class="text-sm text-muted-foreground bg-muted p-3 rounded-md"
>
{{
t('extensions.binaryNotFound', {
path: isWindows ? 'gostc.exe' : '/usr/bin/gostc',
})
}}
</div>
<template v-else>
<!-- Status and controls -->
<!-- Config -->
<div class="settings-field-list grid gap-4">
<div class="settings-toggle flex items-center justify-between">
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch
v-model="extConfig.gostc.enabled"
:disabled="isExtRunning(extensions?.gostc?.status)"
/>
</div>
<div class="settings-field grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
<div class="sm:col-span-3 space-y-1">
<Input
v-model="extConfig.gostc.addr"
type="text"
autocomplete="off"
:placeholder="t('extensions.gostc.addrPlaceholder')"
:disabled="isExtRunning(extensions?.gostc?.status)"
/>
<p
v-if="extConfig.gostc.enabled && !extConfig.gostc.addr.trim()"
class="text-xs text-destructive"
>
{{ t('extensions.gostc.addrRequired') }}
</p>
</div>
</div>
<div class="settings-field grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
<div class="sm:col-span-3 space-y-1">
<Input
v-model="extConfig.gostc.key"
type="password"
autocomplete="off"
:placeholder="t('extensions.gostc.keyPlaceholder')"
:disabled="isExtRunning(extensions?.gostc?.status)"
/>
<p
v-if="extConfig.gostc.enabled && !extConfig.gostc.key"
class="text-xs text-destructive"
>
{{ t('extensions.gostc.keyRequired') }}
</p>
</div>
</div>
<div class="settings-toggle flex items-center justify-between">
<Label>{{ t('extensions.gostc.tls') }}</Label>
<Switch
v-model="extConfig.gostc.tls"
:disabled="isExtRunning(extensions?.gostc?.status)"
/>
</div>
</div>
<!-- Logs -->
<div class="space-y-2">
<Collapsible
v-model:open="showLogs.gostc"
@update:open="(open) => open && refreshExtensionLogs('gostc')"
>
<CollapsibleTrigger as-child
><Button
type="button"
variant="ghost"
size="sm"
class="gap-2 text-muted-foreground"
><ChevronRight
:class="[
'size-4 transition-transform',
showLogs.gostc ? 'rotate-90' : '',
]"
/>{{ t('extensions.viewLogs') }}</Button
></CollapsibleTrigger
>
<CollapsibleContent class="space-y-2 pt-2">
<pre
class="p-3 bg-muted rounded-md text-xs max-h-48 overflow-auto font-mono"
>{{
(extensionLogs.gostc || []).join('\n') || t('extensions.noLogs')
}}</pre
>
<Button variant="ghost" size="sm" @click="refreshExtensionLogs('gostc')">
<RefreshCw class="size-3 mr-1" />
{{ t('common.refresh') }}
</Button>
</CollapsibleContent>
</Collapsible>
</div>
</template>
</CardContent>
</Card>
<Card> <Card>
<CardHeader class="settings-card-heading" <CardHeader class="settings-card-heading"
><CardTitle :help="t('extensions.easyfrp.desc')">{{ ><CardTitle :help="t('extensions.easyfrp.desc')">{{