mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-04-30 01:46:37 +08:00
feat: CLI 改密、自定义 TLS、移动端适配与扩展校验
- 新增 one-kvm user set-password(交互式),改密后吊销该用户全部会话 - /api/config/web 支持 PEM 证书/密钥上传与清除,响应含 has_custom_cert - 移动端:ActionBar 溢出菜单、ATX/粘贴底部 Sheet、BrandMark 与控制台等响应式优化 - GOSTC:校验服务器地址非空,管理器启动条件与 HTTP 热更新一致 - RustDesk:中继密钥 relay_key 校验为标准 Base64 且解码后恰好 32 字节 - StatusCard、InfoBar:合并精简冗余状态信息
This commit is contained in:
@@ -126,6 +126,15 @@ impl SessionStore {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Delete all sessions for a specific user
|
||||
pub async fn delete_by_user_id(&self, user_id: &str) -> Result<u64> {
|
||||
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?1")
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// List all session IDs
|
||||
pub async fn list_ids(&self) -> Result<Vec<String>> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions")
|
||||
|
||||
@@ -703,7 +703,9 @@ impl StreamConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Web server configuration
|
||||
/// Web server configuration persisted in the database (includes on-disk TLS paths).
|
||||
///
|
||||
/// The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
|
||||
@@ -280,6 +280,9 @@ impl ExtensionManager {
|
||||
|
||||
ExtensionId::Gostc => {
|
||||
let c = &config.gostc;
|
||||
if c.addr.trim().is_empty() {
|
||||
return Err("GOSTC server address is required".into());
|
||||
}
|
||||
if c.key.is_empty() {
|
||||
return Err("GOSTC client key is required".into());
|
||||
}
|
||||
@@ -291,10 +294,8 @@ impl ExtensionManager {
|
||||
args.push("--tls=true".to_string());
|
||||
}
|
||||
|
||||
// Add server address
|
||||
if !c.addr.is_empty() {
|
||||
args.extend(["-addr".to_string(), c.addr.clone()]);
|
||||
}
|
||||
// Server address (validated non-empty above)
|
||||
args.extend(["-addr".to_string(), c.addr.trim().to_string()]);
|
||||
|
||||
// Add client key
|
||||
args.extend(["-key".to_string(), c.key.clone()]);
|
||||
@@ -375,7 +376,11 @@ impl ExtensionManager {
|
||||
.filter_map(|id| {
|
||||
let should_run = match id {
|
||||
ExtensionId::Ttyd => config.ttyd.enabled,
|
||||
ExtensionId::Gostc => config.gostc.enabled && !config.gostc.key.is_empty(),
|
||||
ExtensionId::Gostc => {
|
||||
config.gostc.enabled
|
||||
&& !config.gostc.key.is_empty()
|
||||
&& !config.gostc.addr.trim().is_empty()
|
||||
}
|
||||
ExtensionId::Easytier => {
|
||||
config.easytier.enabled && !config.easytier.network_name.is_empty()
|
||||
}
|
||||
@@ -435,6 +440,7 @@ impl ExtensionManager {
|
||||
|
||||
if config.gostc.enabled
|
||||
&& !config.gostc.key.is_empty()
|
||||
&& !config.gostc.addr.trim().is_empty()
|
||||
&& self.check_available(ExtensionId::Gostc)
|
||||
{
|
||||
start_futures.push(Box::pin(async {
|
||||
|
||||
@@ -121,7 +121,7 @@ impl Default for TtydConfig {
|
||||
pub struct GostcConfig {
|
||||
/// Enable auto-start
|
||||
pub enabled: bool,
|
||||
/// Server address (e.g., gostc.mofeng.run)
|
||||
/// Server address (hostname or IP)
|
||||
pub addr: String,
|
||||
/// Client key from GOSTC management panel
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
@@ -134,7 +134,7 @@ impl Default for GostcConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
addr: "gostc.mofeng.run".to_string(),
|
||||
addr: String::new(),
|
||||
key: String::new(),
|
||||
tls: true,
|
||||
}
|
||||
|
||||
108
src/main.rs
108
src/main.rs
@@ -1,10 +1,11 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use clap::{Parser, ValueEnum};
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use futures::{stream::FuturesUnordered, StreamExt};
|
||||
use rustls::crypto::{ring, CryptoProvider};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
@@ -49,6 +50,10 @@ enum LogLevel {
|
||||
#[command(name = "one-kvm")]
|
||||
#[command(version, about = "A open and lightweight IP-KVM solution", long_about = None)]
|
||||
struct CliArgs {
|
||||
/// User management commands
|
||||
#[command(subcommand)]
|
||||
command: Option<CliCommand>,
|
||||
|
||||
/// Listen address (overrides database config)
|
||||
#[arg(short = 'a', long, value_name = "ADDRESS")]
|
||||
address: Option<String>,
|
||||
@@ -86,6 +91,24 @@ struct CliArgs {
|
||||
verbose: u8,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum CliCommand {
|
||||
/// Manage local users
|
||||
User(UserCommand),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct UserCommand {
|
||||
#[command(subcommand)]
|
||||
action: UserAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserAction {
|
||||
/// Set password for the single local user (interactive terminal prompt)
|
||||
SetPassword,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Parse command line arguments
|
||||
@@ -101,9 +124,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Determine data directory (CLI arg takes precedence)
|
||||
let data_dir = args.data_dir.unwrap_or_else(get_data_dir);
|
||||
let data_dir = args.data_dir.clone().unwrap_or_else(get_data_dir);
|
||||
tracing::info!("Data directory: {}", data_dir.display());
|
||||
|
||||
// Run one-off CLI command and exit.
|
||||
if let Some(command) = args.command {
|
||||
run_cli_command(command, data_dir).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
tokio::fs::create_dir_all(&data_dir).await?;
|
||||
|
||||
@@ -765,6 +794,81 @@ fn get_data_dir() -> PathBuf {
|
||||
PathBuf::from("/etc/one-kvm")
|
||||
}
|
||||
|
||||
async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> {
|
||||
tokio::fs::create_dir_all(&data_dir).await?;
|
||||
let db_path = data_dir.join("one-kvm.db");
|
||||
let config_store = ConfigStore::new(&db_path).await?;
|
||||
let users = UserStore::new(config_store.pool().clone());
|
||||
let sessions = SessionStore::new(config_store.pool().clone(), 0);
|
||||
|
||||
match command {
|
||||
CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_user_action(
|
||||
action: UserAction,
|
||||
users: &UserStore,
|
||||
sessions: &SessionStore,
|
||||
) -> anyhow::Result<()> {
|
||||
match action {
|
||||
UserAction::SetPassword => set_user_password(users, sessions).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
|
||||
let all = users.list().await?;
|
||||
let user = match all.len() {
|
||||
0 => anyhow::bail!("No local user exists yet; complete setup in the web UI first."),
|
||||
1 => &all[0],
|
||||
_ => anyhow::bail!(
|
||||
"Expected exactly one local user (single-user design), found {}. Remove extra users from the database or contact support.",
|
||||
all.len()
|
||||
),
|
||||
};
|
||||
|
||||
let new_password = read_new_password_interactive()?;
|
||||
if new_password.len() < 4 {
|
||||
anyhow::bail!("Password must be at least 4 characters");
|
||||
}
|
||||
|
||||
users.update_password(&user.id, &new_password).await?;
|
||||
let revoked = sessions.delete_by_user_id(&user.id).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Password updated for user '{}' and {} sessions revoked",
|
||||
user.username,
|
||||
revoked
|
||||
);
|
||||
println!(
|
||||
"Password updated for user '{}' (revoked {} sessions).",
|
||||
user.username, revoked
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_new_password_interactive() -> anyhow::Result<String> {
|
||||
let once = |label: &str| -> anyhow::Result<String> {
|
||||
print!("{}", label);
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line)?;
|
||||
let s = line.trim_end_matches(['\r', '\n']).to_string();
|
||||
if s.is_empty() {
|
||||
anyhow::bail!("Password cannot be empty");
|
||||
}
|
||||
Ok(s)
|
||||
};
|
||||
|
||||
let a = once("New password: ")?;
|
||||
let b = once("Confirm password: ")?;
|
||||
if a != b {
|
||||
anyhow::bail!("Passwords do not match");
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Resolve bind IPs from config, preferring bind_addresses when set.
|
||||
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
|
||||
let raw_addrs = if !web.bind_addresses.is_empty() {
|
||||
|
||||
@@ -3,7 +3,8 @@ use crate::error::AppError;
|
||||
use crate::rtsp::RtspServiceStatus;
|
||||
use crate::rustdesk::config::RustDeskConfig;
|
||||
use crate::video::encoder::BitratePreset;
|
||||
use serde::Deserialize;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use typeshare::typeshare;
|
||||
|
||||
@@ -660,6 +661,26 @@ impl AudioConfigUpdate {
|
||||
}
|
||||
|
||||
// ===== RustDesk Config =====
|
||||
|
||||
/// hbbs/hbbr `-k` relay key: standard Base64 encoding of exactly 32 bytes (typically 44 chars with padding).
|
||||
fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> {
|
||||
let decoded = STANDARD.decode(key.as_bytes()).map_err(|_| {
|
||||
AppError::BadRequest(
|
||||
"Relay key must be standard Base64 (32 raw bytes, e.g. hbbs/hbbr -k output)".into(),
|
||||
)
|
||||
})?;
|
||||
if decoded.len() != 32 {
|
||||
return Err(AppError::BadRequest(
|
||||
format!(
|
||||
"Relay key must decode to exactly 32 bytes (got {} bytes after Base64 decode)",
|
||||
decoded.len()
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RustDeskConfigUpdate {
|
||||
@@ -698,6 +719,12 @@ impl RustDeskConfigUpdate {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ref key) = self.relay_key {
|
||||
let trimmed = key.trim();
|
||||
if !trimmed.is_empty() {
|
||||
validate_rustdesk_relay_key(trimmed)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -716,10 +743,11 @@ impl RustDeskConfigUpdate {
|
||||
};
|
||||
}
|
||||
if let Some(ref key) = self.relay_key {
|
||||
config.relay_key = if key.is_empty() {
|
||||
let trimmed = key.trim();
|
||||
config.relay_key = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key.clone())
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(ref password) = self.device_password {
|
||||
@@ -849,6 +877,45 @@ impl RtspConfigUpdate {
|
||||
}
|
||||
|
||||
// ===== Web Config =====
|
||||
|
||||
/// Web server settings returned by `GET` / `PATCH /api/config/web`.
|
||||
///
|
||||
/// Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebConfigResponse {
|
||||
pub http_port: u16,
|
||||
pub https_port: u16,
|
||||
pub bind_addresses: Vec<String>,
|
||||
pub bind_address: String,
|
||||
pub https_enabled: bool,
|
||||
/// Whether a custom TLS certificate is active (non-empty cert + key paths in stored config).
|
||||
pub has_custom_cert: bool,
|
||||
}
|
||||
|
||||
impl WebConfigResponse {
|
||||
pub fn from_stored(web: &WebConfig) -> Self {
|
||||
let has_custom_cert = web
|
||||
.ssl_cert_path
|
||||
.as_deref()
|
||||
.map(|p| !p.is_empty())
|
||||
.unwrap_or(false)
|
||||
&& web
|
||||
.ssl_key_path
|
||||
.as_deref()
|
||||
.map(|p| !p.is_empty())
|
||||
.unwrap_or(false);
|
||||
Self {
|
||||
http_port: web.http_port,
|
||||
https_port: web.https_port,
|
||||
bind_addresses: web.bind_addresses.clone(),
|
||||
bind_address: web.bind_address.clone(),
|
||||
https_enabled: web.https_enabled,
|
||||
has_custom_cert,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebConfigUpdate {
|
||||
@@ -857,6 +924,12 @@ pub struct WebConfigUpdate {
|
||||
pub bind_addresses: Option<Vec<String>>,
|
||||
pub bind_address: Option<String>,
|
||||
pub https_enabled: Option<bool>,
|
||||
/// PEM-encoded certificate content (must be provided together with ssl_key_pem)
|
||||
pub ssl_cert_pem: Option<String>,
|
||||
/// PEM-encoded private key content (must be provided together with ssl_cert_pem)
|
||||
pub ssl_key_pem: Option<String>,
|
||||
/// Set to true to remove the custom certificate and revert to self-signed
|
||||
pub clear_custom_cert: Option<bool>,
|
||||
}
|
||||
|
||||
impl WebConfigUpdate {
|
||||
@@ -883,6 +956,22 @@ impl WebConfigUpdate {
|
||||
return Err(AppError::BadRequest("Invalid bind address".into()));
|
||||
}
|
||||
}
|
||||
// Cert and key must be provided together (cryptographic validity is checked in the
|
||||
// handler via `RustlsConfig::from_pem`, same stack as the running HTTPS server).
|
||||
match (&self.ssl_cert_pem, &self.ssl_key_pem) {
|
||||
(Some(_cert), Some(_key)) => {}
|
||||
(Some(_), None) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"ssl_key_pem is required when ssl_cert_pem is provided".into(),
|
||||
));
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"ssl_cert_pem is required when ssl_key_pem is provided".into(),
|
||||
));
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -907,6 +996,8 @@ impl WebConfigUpdate {
|
||||
if let Some(enabled) = self.https_enabled {
|
||||
config.https_enabled = enabled;
|
||||
}
|
||||
// ssl_cert_pem, ssl_key_pem, clear_custom_cert are handled at the handler level
|
||||
// (they require async file I/O before updating config paths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,4 +1079,30 @@ mod tests {
|
||||
|
||||
assert!(update.validate_with_current(¤t).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
|
||||
let update = RustDeskConfigUpdate {
|
||||
enabled: None,
|
||||
rendezvous_server: None,
|
||||
relay_server: None,
|
||||
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
|
||||
device_password: None,
|
||||
};
|
||||
assert!(update.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustdesk_relay_key_rejects_non_32_byte_payload() {
|
||||
// Standard Base64 for 16 zero bytes (not 32).
|
||||
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
|
||||
let update = RustDeskConfigUpdate {
|
||||
enabled: None,
|
||||
rendezvous_server: None,
|
||||
relay_server: None,
|
||||
relay_key: Some(not_32),
|
||||
device_password: None,
|
||||
};
|
||||
assert!(update.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,103 @@
|
||||
//! Web 服务器配置 Handler
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::WebConfig;
|
||||
use crate::error::Result;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::types::WebConfigUpdate;
|
||||
use super::types::{WebConfigResponse, WebConfigUpdate};
|
||||
|
||||
/// 获取 Web 配置
|
||||
pub async fn get_web_config(State(state): State<Arc<AppState>>) -> Json<WebConfig> {
|
||||
Json(state.config.get().web.clone())
|
||||
pub async fn get_web_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<WebConfigResponse> {
|
||||
Json(WebConfigResponse::from_stored(&state.config.get().web))
|
||||
}
|
||||
|
||||
/// 更新 Web 配置
|
||||
/// 更新 Web 配置(支持 PEM 证书上传)
|
||||
pub async fn update_web_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<WebConfigUpdate>,
|
||||
) -> Result<Json<WebConfig>> {
|
||||
) -> Result<Json<WebConfigResponse>> {
|
||||
req.validate()?;
|
||||
|
||||
// Determine certificate path changes (requires async file I/O before config update)
|
||||
// Some(Some((cert, key))) = write new cert
|
||||
// Some(None) = clear custom cert
|
||||
// None = no cert change
|
||||
let cert_path_update: Option<Option<(String, String)>> =
|
||||
if let (Some(cert_pem), Some(key_pem)) = (&req.ssl_cert_pem, &req.ssl_key_pem) {
|
||||
RustlsConfig::from_pem(cert_pem.as_bytes().to_vec(), key_pem.as_bytes().to_vec())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::BadRequest(
|
||||
format!(
|
||||
"Invalid TLS certificate or private key (PEM must match what the HTTPS server can load): {e}"
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let cert_dir = state.data_dir().join("certs");
|
||||
tokio::fs::create_dir_all(&cert_dir)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create cert dir: {e}")))?;
|
||||
let cert_path = cert_dir.join("custom.crt");
|
||||
let key_path = cert_dir.join("custom.key");
|
||||
tokio::fs::write(&cert_path, cert_pem.as_bytes())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to write certificate: {e}")))?;
|
||||
tokio::fs::write(&key_path, key_pem.as_bytes())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to write private key: {e}")))?;
|
||||
Some(Some((
|
||||
cert_path.to_string_lossy().into_owned(),
|
||||
key_path.to_string_lossy().into_owned(),
|
||||
)))
|
||||
} else if req.clear_custom_cert.unwrap_or(false) {
|
||||
let cert_dir = state.data_dir().join("certs");
|
||||
let _ = tokio::fs::remove_file(cert_dir.join("custom.crt")).await;
|
||||
let _ = tokio::fs::remove_file(cert_dir.join("custom.key")).await;
|
||||
Some(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
.update(move |config| {
|
||||
req.apply_to(&mut config.web);
|
||||
match cert_path_update {
|
||||
Some(Some((cert_path, key_path))) => {
|
||||
config.web.ssl_cert_path = Some(cert_path);
|
||||
config.web.ssl_key_path = Some(key_path);
|
||||
}
|
||||
Some(None) => {
|
||||
config.web.ssl_cert_path = None;
|
||||
config.web.ssl_key_path = None;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(Json(state.config.get().web.clone()))
|
||||
Ok(Json(WebConfigResponse::from_stored(&state.config.get().web)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustls::crypto::{ring, CryptoProvider};
|
||||
|
||||
#[tokio::test]
|
||||
async fn rustls_accepts_rcgen_self_signed_pem() {
|
||||
let _ = CryptoProvider::install_default(ring::default_provider());
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
||||
let cert_pem = cert.cert.pem();
|
||||
let key_pem = cert.signing_key.serialize_pem();
|
||||
RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,12 +256,14 @@ pub async fn update_gostc_config(
|
||||
let new_config = state.config.get();
|
||||
let is_enabled = new_config.extensions.gostc.enabled;
|
||||
let has_key = !new_config.extensions.gostc.key.is_empty();
|
||||
let has_addr = !new_config.extensions.gostc.addr.trim().is_empty();
|
||||
|
||||
if was_enabled && !is_enabled {
|
||||
state.extensions.stop(ExtensionId::Gostc).await.ok();
|
||||
} else if !was_enabled
|
||||
&& is_enabled
|
||||
&& has_key
|
||||
&& has_addr
|
||||
&& state.extensions.check_available(ExtensionId::Gostc)
|
||||
{
|
||||
state
|
||||
|
||||
Reference in New Issue
Block a user