Added usermanagement in TG admin

This commit is contained in:
AB from home.homenet
2025-10-24 18:11:34 +03:00
parent c6892b1a73
commit 78bf75b24e
89 changed files with 4389 additions and 2419 deletions

2
Cargo.lock generated
View File

@@ -5158,7 +5158,7 @@ dependencies = [
[[package]] [[package]]
name = "xray-admin" name = "xray-admin"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "xray-admin" name = "xray-admin"
version = "0.1.0" version = "0.1.1"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -51,10 +51,14 @@ mod tests {
fn test_args_parsing() { fn test_args_parsing() {
let args = Args::try_parse_from(&[ let args = Args::try_parse_from(&[
"xray-admin", "xray-admin",
"--config", "test.toml", "--config",
"--port", "9090", "test.toml",
"--log-level", "debug" "--port",
]).unwrap(); "9090",
"--log-level",
"debug",
])
.unwrap();
assert_eq!(args.config, Some(PathBuf::from("test.toml"))); assert_eq!(args.config, Some(PathBuf::from("test.toml")));
assert_eq!(args.port, Some(9090)); assert_eq!(args.port, Some(9090));

View File

@@ -43,21 +43,24 @@ impl EnvVars {
/// Get database URL from environment /// Get database URL from environment
#[allow(dead_code)] #[allow(dead_code)]
pub fn database_url() -> Option<String> { pub fn database_url() -> Option<String> {
env::var("DATABASE_URL").ok() env::var("DATABASE_URL")
.ok()
.or_else(|| env::var("XRAY_ADMIN__DATABASE__URL").ok()) .or_else(|| env::var("XRAY_ADMIN__DATABASE__URL").ok())
} }
/// Get telegram bot token from environment /// Get telegram bot token from environment
#[allow(dead_code)] #[allow(dead_code)]
pub fn telegram_token() -> Option<String> { pub fn telegram_token() -> Option<String> {
env::var("TELEGRAM_BOT_TOKEN").ok() env::var("TELEGRAM_BOT_TOKEN")
.ok()
.or_else(|| env::var("XRAY_ADMIN__TELEGRAM__BOT_TOKEN").ok()) .or_else(|| env::var("XRAY_ADMIN__TELEGRAM__BOT_TOKEN").ok())
} }
/// Get JWT secret from environment /// Get JWT secret from environment
#[allow(dead_code)] #[allow(dead_code)]
pub fn jwt_secret() -> Option<String> { pub fn jwt_secret() -> Option<String> {
env::var("JWT_SECRET").ok() env::var("JWT_SECRET")
.ok()
.or_else(|| env::var("XRAY_ADMIN__WEB__JWT_SECRET").ok()) .or_else(|| env::var("XRAY_ADMIN__WEB__JWT_SECRET").ok())
} }
@@ -66,14 +69,29 @@ impl EnvVars {
tracing::debug!("Environment information:"); tracing::debug!("Environment information:");
tracing::debug!(" RUST_ENV: {:?}", env::var("RUST_ENV")); tracing::debug!(" RUST_ENV: {:?}", env::var("RUST_ENV"));
tracing::debug!(" ENVIRONMENT: {:?}", env::var("ENVIRONMENT")); tracing::debug!(" ENVIRONMENT: {:?}", env::var("ENVIRONMENT"));
tracing::debug!(" DATABASE_URL: {}", tracing::debug!(
if env::var("DATABASE_URL").is_ok() { "set" } else { "not set" } " DATABASE_URL: {}",
if env::var("DATABASE_URL").is_ok() {
"set"
} else {
"not set"
}
); );
tracing::debug!(" TELEGRAM_BOT_TOKEN: {}", tracing::debug!(
if env::var("TELEGRAM_BOT_TOKEN").is_ok() { "set" } else { "not set" } " TELEGRAM_BOT_TOKEN: {}",
if env::var("TELEGRAM_BOT_TOKEN").is_ok() {
"set"
} else {
"not set"
}
); );
tracing::debug!(" JWT_SECRET: {}", tracing::debug!(
if env::var("JWT_SECRET").is_ok() { "set" } else { "not set" } " JWT_SECRET: {}",
if env::var("JWT_SECRET").is_ok() {
"set"
} else {
"not set"
}
); );
} }
} }

View File

@@ -15,8 +15,12 @@ impl ConfigFile {
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?; .with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
let config: AppConfig = toml::from_str(&content) let config: AppConfig = toml::from_str(&content).with_context(|| {
.with_context(|| format!("Failed to parse TOML config file: {}", path.as_ref().display()))?; format!(
"Failed to parse TOML config file: {}",
path.as_ref().display()
)
})?;
Ok(config) Ok(config)
} }
@@ -26,8 +30,12 @@ impl ConfigFile {
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?; .with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
let config: AppConfig = serde_yaml::from_str(&content) let config: AppConfig = serde_yaml::from_str(&content).with_context(|| {
.with_context(|| format!("Failed to parse YAML config file: {}", path.as_ref().display()))?; format!(
"Failed to parse YAML config file: {}",
path.as_ref().display()
)
})?;
Ok(config) Ok(config)
} }
@@ -37,8 +45,12 @@ impl ConfigFile {
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?; .with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
let config: AppConfig = serde_json::from_str(&content) let config: AppConfig = serde_json::from_str(&content).with_context(|| {
.with_context(|| format!("Failed to parse JSON config file: {}", path.as_ref().display()))?; format!(
"Failed to parse JSON config file: {}",
path.as_ref().display()
)
})?;
Ok(config) Ok(config)
} }
@@ -68,8 +80,8 @@ impl ConfigFile {
/// Save configuration to TOML file /// Save configuration to TOML file
pub fn save_toml<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<()> { pub fn save_toml<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<()> {
let content = toml::to_string_pretty(config) let content =
.context("Failed to serialize config to TOML")?; toml::to_string_pretty(config).context("Failed to serialize config to TOML")?;
fs::write(&path, content) fs::write(&path, content)
.with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?; .with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?;
@@ -79,8 +91,8 @@ impl ConfigFile {
/// Save configuration to YAML file /// Save configuration to YAML file
pub fn save_yaml<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<()> { pub fn save_yaml<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<()> {
let content = serde_yaml::to_string(config) let content =
.context("Failed to serialize config to YAML")?; serde_yaml::to_string(config).context("Failed to serialize config to YAML")?;
fs::write(&path, content) fs::write(&path, content)
.with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?; .with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?;
@@ -90,8 +102,8 @@ impl ConfigFile {
/// Save configuration to JSON file /// Save configuration to JSON file
pub fn save_json<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<()> { pub fn save_json<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<()> {
let content = serde_json::to_string_pretty(config) let content =
.context("Failed to serialize config to JSON")?; serde_json::to_string_pretty(config).context("Failed to serialize config to JSON")?;
fs::write(&path, content) fs::write(&path, content)
.with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?; .with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?;
@@ -102,7 +114,11 @@ impl ConfigFile {
/// Check if config file exists and is readable /// Check if config file exists and is readable
pub fn exists_and_readable<P: AsRef<Path>>(path: P) -> bool { pub fn exists_and_readable<P: AsRef<Path>>(path: P) -> bool {
let path = path.as_ref(); let path = path.as_ref();
path.exists() && path.is_file() && fs::metadata(path).map(|m| !m.permissions().readonly()).unwrap_or(false) path.exists()
&& path.is_file()
&& fs::metadata(path)
.map(|m| !m.permissions().readonly())
.unwrap_or(false)
} }
/// Find default config file in common locations /// Find default config file in common locations
@@ -145,7 +161,10 @@ mod tests {
let loaded_config = ConfigFile::load_toml(temp_file.path())?; let loaded_config = ConfigFile::load_toml(temp_file.path())?;
assert_eq!(config.web.port, loaded_config.web.port); assert_eq!(config.web.port, loaded_config.web.port);
assert_eq!(config.database.max_connections, loaded_config.database.max_connections); assert_eq!(
config.database.max_connections,
loaded_config.database.max_connections
);
Ok(()) Ok(())
} }

View File

@@ -163,7 +163,7 @@ impl AppConfig {
builder = builder.add_source( builder = builder.add_source(
config::Environment::with_prefix("XRAY_ADMIN") config::Environment::with_prefix("XRAY_ADMIN")
.separator("__") .separator("__")
.try_parsing(true) .try_parsing(true),
); );
// Override with command line arguments // Override with command line arguments
@@ -196,8 +196,18 @@ impl AppConfig {
tracing::info!(" Database URL: {}", mask_sensitive(&self.database.url)); tracing::info!(" Database URL: {}", mask_sensitive(&self.database.url));
tracing::info!(" Web server: {}:{}", self.web.host, self.web.port); tracing::info!(" Web server: {}:{}", self.web.host, self.web.port);
tracing::info!(" Log level: {}", self.logging.level); tracing::info!(" Log level: {}", self.logging.level);
tracing::info!(" Telegram bot: {}", if self.telegram.bot_token.is_empty() { "disabled" } else { "enabled" }); tracing::info!(
tracing::info!(" Xray config path: {}", self.xray.config_template_path.display()); " Telegram bot: {}",
if self.telegram.bot_token.is_empty() {
"disabled"
} else {
"enabled"
}
);
tracing::info!(
" Xray config path: {}",
self.xray.config_template_path.display()
);
} }
} }

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait}; use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
@@ -67,7 +67,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self, mut self,
_db: &'life0 C, _db: &'life0 C,
insert: bool, insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>> ) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where where
'life0: 'async_trait, 'life0: 'async_trait,
C: 'async_trait + ConnectionTrait, C: 'async_trait + ConnectionTrait,

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait}; use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@@ -40,7 +40,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self, mut self,
_db: &'life0 C, _db: &'life0 C,
insert: bool, insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>> ) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where where
'life0: 'async_trait, 'life0: 'async_trait,
C: 'async_trait + ConnectionTrait, C: 'async_trait + ConnectionTrait,

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait}; use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -60,7 +60,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self, mut self,
_db: &'life0 C, _db: &'life0 C,
insert: bool, insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>> ) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where where
'life0: 'async_trait, 'life0: 'async_trait,
C: 'async_trait + ConnectionTrait, C: 'async_trait + ConnectionTrait,
@@ -187,8 +189,8 @@ impl From<Model> for InboundTemplateResponse {
impl From<CreateInboundTemplateDto> for ActiveModel { impl From<CreateInboundTemplateDto> for ActiveModel {
fn from(dto: CreateInboundTemplateDto) -> Self { fn from(dto: CreateInboundTemplateDto) -> Self {
// Parse config_template as JSON or use default // Parse config_template as JSON or use default
let config_json: Value = serde_json::from_str(&dto.config_template) let config_json: Value =
.unwrap_or_else(|_| serde_json::json!({})); serde_json::from_str(&dto.config_template).unwrap_or_else(|_| serde_json::json!({}));
Self { Self {
name: Set(dto.name), name: Set(dto.name),
@@ -212,7 +214,10 @@ impl Model {
} }
#[allow(dead_code)] #[allow(dead_code)]
pub fn apply_variables(&self, values: &serde_json::Map<String, Value>) -> Result<(Value, Value), String> { pub fn apply_variables(
&self,
values: &serde_json::Map<String, Value>,
) -> Result<(Value, Value), String> {
let base_settings = self.base_settings.clone(); let base_settings = self.base_settings.clone();
let stream_settings = self.stream_settings.clone(); let stream_settings = self.stream_settings.clone();
@@ -267,7 +272,8 @@ impl Model {
active_model.requires_domain = Set(requires_domain); active_model.requires_domain = Set(requires_domain);
} }
if let Some(variables) = dto.variables { if let Some(variables) = dto.variables {
active_model.variables = Set(serde_json::to_value(variables).unwrap_or(Value::Array(vec![]))); active_model.variables =
Set(serde_json::to_value(variables).unwrap_or(Value::Array(vec![])));
} }
if let Some(is_active) = dto.is_active { if let Some(is_active) = dto.is_active {
active_model.is_active = Set(is_active); active_model.is_active = Set(is_active);

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait}; use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@@ -71,7 +71,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self, mut self,
_db: &'life0 C, _db: &'life0 C,
insert: bool, insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>> ) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where where
'life0: 'async_trait, 'life0: 'async_trait,
C: 'async_trait + ConnectionTrait, C: 'async_trait + ConnectionTrait,
@@ -102,8 +104,8 @@ impl CreateInboundUserDto {
/// Generate random password (for trojan/shadowsocks) /// Generate random password (for trojan/shadowsocks)
pub fn generate_password(&self) -> String { pub fn generate_password(&self) -> String {
use rand::prelude::*;
use rand::distributions::Alphanumeric; use rand::distributions::Alphanumeric;
use rand::prelude::*;
thread_rng() thread_rng()
.sample_iter(&Alphanumeric) .sample_iter(&Alphanumeric)

View File

@@ -1,23 +1,23 @@
pub mod user;
pub mod certificate; pub mod certificate;
pub mod dns_provider; pub mod dns_provider;
pub mod inbound_template; pub mod inbound_template;
pub mod inbound_users;
pub mod server; pub mod server;
pub mod server_inbound; pub mod server_inbound;
pub mod user_access;
pub mod inbound_users;
pub mod telegram_config; pub mod telegram_config;
pub mod user;
pub mod user_access;
pub mod user_request; pub mod user_request;
pub mod prelude { pub mod prelude {
pub use super::user::Entity as User;
pub use super::certificate::Entity as Certificate; pub use super::certificate::Entity as Certificate;
pub use super::dns_provider::Entity as DnsProvider; pub use super::dns_provider::Entity as DnsProvider;
pub use super::inbound_template::Entity as InboundTemplate; pub use super::inbound_template::Entity as InboundTemplate;
pub use super::inbound_users::Entity as InboundUsers;
pub use super::server::Entity as Server; pub use super::server::Entity as Server;
pub use super::server_inbound::Entity as ServerInbound; pub use super::server_inbound::Entity as ServerInbound;
pub use super::user_access::Entity as UserAccess;
pub use super::inbound_users::Entity as InboundUsers;
pub use super::telegram_config::Entity as TelegramConfig; pub use super::telegram_config::Entity as TelegramConfig;
pub use super::user::Entity as User;
pub use super::user_access::Entity as UserAccess;
pub use super::user_request::Entity as UserRequest; pub use super::user_request::Entity as UserRequest;
} }

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait}; use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
@@ -67,7 +67,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self, mut self,
_db: &'life0 C, _db: &'life0 C,
insert: bool, insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>> ) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where where
'life0: 'async_trait, 'life0: 'async_trait,
C: 'async_trait + ConnectionTrait, C: 'async_trait + ConnectionTrait,
@@ -213,10 +215,17 @@ impl Model {
pub fn get_grpc_endpoint(&self) -> String { pub fn get_grpc_endpoint(&self) -> String {
let hostname = if self.grpc_hostname.is_empty() { let hostname = if self.grpc_hostname.is_empty() {
tracing::debug!("Using public hostname '{}' for gRPC (grpc_hostname is empty)", self.hostname); tracing::debug!(
"Using public hostname '{}' for gRPC (grpc_hostname is empty)",
self.hostname
);
&self.hostname &self.hostname
} else { } else {
tracing::debug!("Using dedicated gRPC hostname '{}' (different from public hostname '{}')", self.grpc_hostname, self.hostname); tracing::debug!(
"Using dedicated gRPC hostname '{}' (different from public hostname '{}')",
self.grpc_hostname,
self.hostname
);
&self.grpc_hostname &self.grpc_hostname
}; };
let endpoint = format!("{}:{}", hostname, self.grpc_port); let endpoint = format!("{}:{}", hostname, self.grpc_port);

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait}; use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -82,7 +82,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self, mut self,
_db: &'life0 C, _db: &'life0 C,
insert: bool, insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>> ) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where where
'life0: 'async_trait, 'life0: 'async_trait,
C: 'async_trait + ConnectionTrait, C: 'async_trait + ConnectionTrait,
@@ -95,7 +97,6 @@ impl ActiveModelBehavior for ActiveModel {
Ok(self) Ok(self)
}) })
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -145,7 +146,7 @@ impl From<Model> for ServerInboundResponse {
is_active: inbound.is_active, is_active: inbound.is_active,
created_at: inbound.created_at, created_at: inbound.created_at,
updated_at: inbound.updated_at, updated_at: inbound.updated_at,
template_name: None, // Will be filled by repository if needed template_name: None, // Will be filled by repository if needed
certificate_name: None, // Will be filled by repository if needed certificate_name: None, // Will be filled by repository if needed
} }
} }

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait}; use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
@@ -40,7 +40,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self, mut self,
_db: &'life0 C, _db: &'life0 C,
insert: bool, insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>> ) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where where
'life0: 'async_trait, 'life0: 'async_trait,
C: 'async_trait + ConnectionTrait, C: 'async_trait + ConnectionTrait,

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait}; use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
@@ -48,7 +48,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self, mut self,
_db: &'life0 C, _db: &'life0 C,
insert: bool, insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>> ) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where where
'life0: 'async_trait, 'life0: 'async_trait,
C: 'async_trait + ConnectionTrait, C: 'async_trait + ConnectionTrait,
@@ -149,7 +151,10 @@ mod tests {
let active_model: ActiveModel = dto.into(); let active_model: ActiveModel = dto.into();
assert_eq!(active_model.name.unwrap(), "Test User"); assert_eq!(active_model.name.unwrap(), "Test User");
assert_eq!(active_model.comment.unwrap(), Some("Test comment".to_string())); assert_eq!(
active_model.comment.unwrap(),
Some("Test comment".to_string())
);
assert_eq!(active_model.telegram_id.unwrap(), Some(123456789)); assert_eq!(active_model.telegram_id.unwrap(), Some(123456789));
} }

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait}; use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
@@ -90,7 +90,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self, mut self,
_db: &'life0 C, _db: &'life0 C,
insert: bool, insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>> ) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where where
'life0: 'async_trait, 'life0: 'async_trait,
C: 'async_trait + ConnectionTrait, C: 'async_trait + ConnectionTrait,
@@ -103,7 +105,6 @@ impl ActiveModelBehavior for ActiveModel {
Ok(self) Ok(self)
}) })
} }
} }
/// User access creation data transfer object /// User access creation data transfer object

View File

@@ -90,7 +90,9 @@ impl Model {
parts.push(last.clone()); parts.push(last.clone());
} }
if parts.is_empty() { if parts.is_empty() {
self.telegram_username.clone().unwrap_or_else(|| format!("User {}", self.telegram_id)) self.telegram_username
.clone()
.unwrap_or_else(|| format!("User {}", self.telegram_id))
} else { } else {
parts.join(" ") parts.join(" ")
} }

View File

@@ -12,27 +12,10 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(Users::Table) .table(Users::Table)
.if_not_exists() .if_not_exists()
.col( .col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
ColumnDef::new(Users::Id) .col(ColumnDef::new(Users::Name).string_len(255).not_null())
.uuid() .col(ColumnDef::new(Users::Comment).text().null())
.not_null() .col(ColumnDef::new(Users::TelegramId).big_integer().null())
.primary_key(),
)
.col(
ColumnDef::new(Users::Name)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(Users::Comment)
.text()
.null(),
)
.col(
ColumnDef::new(Users::TelegramId)
.big_integer()
.null(),
)
.col( .col(
ColumnDef::new(Users::CreatedAt) ColumnDef::new(Users::CreatedAt)
.timestamp_with_time_zone() .timestamp_with_time_zone()
@@ -108,12 +91,7 @@ impl MigrationTrait for Migration {
.await?; .await?;
manager manager
.drop_index( .drop_index(Index::drop().if_exists().name("idx_users_name").to_owned())
Index::drop()
.if_exists()
.name("idx_users_name")
.to_owned(),
)
.await?; .await?;
// Drop table // Drop table

View File

@@ -32,21 +32,9 @@ impl MigrationTrait for Migration {
.string_len(255) .string_len(255)
.not_null(), .not_null(),
) )
.col( .col(ColumnDef::new(Certificates::CertData).blob().not_null())
ColumnDef::new(Certificates::CertData) .col(ColumnDef::new(Certificates::KeyData).blob().not_null())
.blob() .col(ColumnDef::new(Certificates::ChainData).blob().null())
.not_null(),
)
.col(
ColumnDef::new(Certificates::KeyData)
.blob()
.not_null(),
)
.col(
ColumnDef::new(Certificates::ChainData)
.blob()
.null(),
)
.col( .col(
ColumnDef::new(Certificates::ExpiresAt) ColumnDef::new(Certificates::ExpiresAt)
.timestamp_with_time_zone() .timestamp_with_time_zone()

View File

@@ -22,11 +22,7 @@ impl MigrationTrait for Migration {
.string_len(255) .string_len(255)
.not_null(), .not_null(),
) )
.col( .col(ColumnDef::new(InboundTemplates::Description).text().null())
ColumnDef::new(InboundTemplates::Description)
.text()
.null(),
)
.col( .col(
ColumnDef::new(InboundTemplates::Protocol) ColumnDef::new(InboundTemplates::Protocol)
.string_len(50) .string_len(50)

View File

@@ -11,44 +11,23 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(Servers::Table) .table(Servers::Table)
.if_not_exists() .if_not_exists()
.col( .col(ColumnDef::new(Servers::Id).uuid().not_null().primary_key())
ColumnDef::new(Servers::Id) .col(ColumnDef::new(Servers::Name).string_len(255).not_null())
.uuid() .col(ColumnDef::new(Servers::Hostname).string_len(255).not_null())
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(Servers::Name)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(Servers::Hostname)
.string_len(255)
.not_null(),
)
.col( .col(
ColumnDef::new(Servers::GrpcPort) ColumnDef::new(Servers::GrpcPort)
.integer() .integer()
.default(2053) .default(2053)
.not_null(), .not_null(),
) )
.col( .col(ColumnDef::new(Servers::ApiCredentials).text().null())
ColumnDef::new(Servers::ApiCredentials)
.text()
.null(),
)
.col( .col(
ColumnDef::new(Servers::Status) ColumnDef::new(Servers::Status)
.string_len(50) .string_len(50)
.default("unknown") .default("unknown")
.not_null(), .not_null(),
) )
.col( .col(ColumnDef::new(Servers::DefaultCertificateId).uuid().null())
ColumnDef::new(Servers::DefaultCertificateId)
.uuid()
.null(),
)
.col( .col(
ColumnDef::new(Servers::CreatedAt) ColumnDef::new(Servers::CreatedAt)
.timestamp_with_time_zone() .timestamp_with_time_zone()

View File

@@ -17,16 +17,8 @@ impl MigrationTrait for Migration {
.not_null() .not_null()
.primary_key(), .primary_key(),
) )
.col( .col(ColumnDef::new(ServerInbounds::ServerId).uuid().not_null())
ColumnDef::new(ServerInbounds::ServerId) .col(ColumnDef::new(ServerInbounds::TemplateId).uuid().not_null())
.uuid()
.not_null(),
)
.col(
ColumnDef::new(ServerInbounds::TemplateId)
.uuid()
.not_null(),
)
.col( .col(
ColumnDef::new(ServerInbounds::Tag) ColumnDef::new(ServerInbounds::Tag)
.string_len(255) .string_len(255)
@@ -37,11 +29,7 @@ impl MigrationTrait for Migration {
.integer() .integer()
.null(), .null(),
) )
.col( .col(ColumnDef::new(ServerInbounds::CertificateId).uuid().null())
ColumnDef::new(ServerInbounds::CertificateId)
.uuid()
.null(),
)
.col( .col(
ColumnDef::new(ServerInbounds::VariableValues) ColumnDef::new(ServerInbounds::VariableValues)
.json() .json()

View File

@@ -17,41 +17,17 @@ impl MigrationTrait for Migration {
.not_null() .not_null()
.primary_key(), .primary_key(),
) )
.col( .col(ColumnDef::new(UserAccess::UserId).uuid().not_null())
ColumnDef::new(UserAccess::UserId) .col(ColumnDef::new(UserAccess::ServerId).uuid().not_null())
.uuid()
.not_null(),
)
.col(
ColumnDef::new(UserAccess::ServerId)
.uuid()
.not_null(),
)
.col( .col(
ColumnDef::new(UserAccess::ServerInboundId) ColumnDef::new(UserAccess::ServerInboundId)
.uuid() .uuid()
.not_null(), .not_null(),
) )
.col( .col(ColumnDef::new(UserAccess::XrayUserId).string().not_null())
ColumnDef::new(UserAccess::XrayUserId) .col(ColumnDef::new(UserAccess::XrayEmail).string().not_null())
.string() .col(ColumnDef::new(UserAccess::Level).integer().not_null())
.not_null(), .col(ColumnDef::new(UserAccess::IsActive).boolean().not_null())
)
.col(
ColumnDef::new(UserAccess::XrayEmail)
.string()
.not_null(),
)
.col(
ColumnDef::new(UserAccess::Level)
.integer()
.not_null(),
)
.col(
ColumnDef::new(UserAccess::IsActive)
.boolean()
.not_null(),
)
.col( .col(
ColumnDef::new(UserAccess::CreatedAt) ColumnDef::new(UserAccess::CreatedAt)
.timestamp_with_time_zone() .timestamp_with_time_zone()

View File

@@ -22,21 +22,9 @@ impl MigrationTrait for Migration {
.uuid() .uuid()
.not_null(), .not_null(),
) )
.col( .col(ColumnDef::new(InboundUsers::Username).string().not_null())
ColumnDef::new(InboundUsers::Username) .col(ColumnDef::new(InboundUsers::Email).string().not_null())
.string() .col(ColumnDef::new(InboundUsers::XrayUserId).string().not_null())
.not_null(),
)
.col(
ColumnDef::new(InboundUsers::Email)
.string()
.not_null(),
)
.col(
ColumnDef::new(InboundUsers::XrayUserId)
.string()
.not_null(),
)
.col( .col(
ColumnDef::new(InboundUsers::Level) ColumnDef::new(InboundUsers::Level)
.integer() .integer()

View File

@@ -36,7 +36,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(InboundUsers::UserId) ColumnDef::new(InboundUsers::UserId)
.uuid() .uuid()
.not_null() .not_null()
.default(Expr::val("00000000-0000-0000-0000-000000000000")) .default(Expr::val("00000000-0000-0000-0000-000000000000")),
) )
.to_owned(), .to_owned(),
) )
@@ -47,11 +47,7 @@ impl MigrationTrait for Migration {
.alter_table( .alter_table(
Table::alter() Table::alter()
.table(InboundUsers::Table) .table(InboundUsers::Table)
.add_column( .add_column(ColumnDef::new(InboundUsers::Password).string().null())
ColumnDef::new(InboundUsers::Password)
.string()
.null()
)
.to_owned(), .to_owned(),
) )
.await?; .await?;
@@ -83,7 +79,7 @@ impl MigrationTrait for Migration {
.from(InboundUsers::Table, InboundUsers::UserId) .from(InboundUsers::Table, InboundUsers::UserId)
.to(Users::Table, Users::Id) .to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::Cascade) .on_delete(ForeignKeyAction::Cascade)
.to_owned() .to_owned(),
) )
.await?; .await?;
@@ -153,7 +149,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(InboundUsers::Username) ColumnDef::new(InboundUsers::Username)
.string() .string()
.not_null() .not_null()
.default("") .default(""),
) )
.to_owned(), .to_owned(),
) )
@@ -167,7 +163,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(InboundUsers::Email) ColumnDef::new(InboundUsers::Email)
.string() .string()
.not_null() .not_null()
.default("") .default(""),
) )
.to_owned(), .to_owned(),
) )

View File

@@ -27,11 +27,7 @@ impl MigrationTrait for Migration {
.string_len(50) .string_len(50)
.not_null(), .not_null(),
) )
.col( .col(ColumnDef::new(DnsProviders::ApiToken).text().not_null())
ColumnDef::new(DnsProviders::ApiToken)
.text()
.not_null(),
)
.col( .col(
ColumnDef::new(DnsProviders::IsActive) ColumnDef::new(DnsProviders::IsActive)
.boolean() .boolean()

View File

@@ -11,23 +11,29 @@ impl MigrationTrait for Migration {
Table::create() Table::create()
.table(TelegramConfig::Table) .table(TelegramConfig::Table)
.if_not_exists() .if_not_exists()
.col(ColumnDef::new(TelegramConfig::Id) .col(
.uuid() ColumnDef::new(TelegramConfig::Id)
.not_null() .uuid()
.primary_key()) .not_null()
.col(ColumnDef::new(TelegramConfig::BotToken) .primary_key(),
.string() )
.not_null()) .col(ColumnDef::new(TelegramConfig::BotToken).string().not_null())
.col(ColumnDef::new(TelegramConfig::IsActive) .col(
.boolean() ColumnDef::new(TelegramConfig::IsActive)
.not_null() .boolean()
.default(false)) .not_null()
.col(ColumnDef::new(TelegramConfig::CreatedAt) .default(false),
.timestamp_with_time_zone() )
.not_null()) .col(
.col(ColumnDef::new(TelegramConfig::UpdatedAt) ColumnDef::new(TelegramConfig::CreatedAt)
.timestamp_with_time_zone() .timestamp_with_time_zone()
.not_null()) .not_null(),
)
.col(
ColumnDef::new(TelegramConfig::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(), .to_owned(),
) )
.await .await

View File

@@ -14,7 +14,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(Users::IsTelegramAdmin) ColumnDef::new(Users::IsTelegramAdmin)
.boolean() .boolean()
.not_null() .not_null()
.default(false) .default(false),
) )
.to_owned(), .to_owned(),
) )

View File

@@ -20,9 +20,7 @@ impl MigrationTrait for Migration {
.default(Expr::cust("gen_random_uuid()")), .default(Expr::cust("gen_random_uuid()")),
) )
.col( .col(
ColumnDef::new(UserRequests::UserId) ColumnDef::new(UserRequests::UserId).uuid().null(), // Can be null if user doesn't exist yet
.uuid()
.null(), // Can be null if user doesn't exist yet
) )
.col( .col(
ColumnDef::new(UserRequests::TelegramId) ColumnDef::new(UserRequests::TelegramId)
@@ -51,16 +49,8 @@ impl MigrationTrait for Migration {
.not_null() .not_null()
.default("pending"), // pending, approved, declined .default("pending"), // pending, approved, declined
) )
.col( .col(ColumnDef::new(UserRequests::RequestMessage).text().null())
ColumnDef::new(UserRequests::RequestMessage) .col(ColumnDef::new(UserRequests::ResponseMessage).text().null())
.text()
.null(),
)
.col(
ColumnDef::new(UserRequests::ResponseMessage)
.text()
.null(),
)
.col( .col(
ColumnDef::new(UserRequests::ProcessedByUserId) ColumnDef::new(UserRequests::ProcessedByUserId)
.uuid() .uuid()

View File

@@ -14,7 +14,7 @@ impl MigrationTrait for Migration {
.add_column( .add_column(
ColumnDef::new(UserRequests::Language) ColumnDef::new(UserRequests::Language)
.string() .string()
.default("en") // Default to English .default("en"), // Default to English
) )
.to_owned(), .to_owned(),
) )

View File

@@ -1,5 +1,7 @@
use anyhow::Result; use anyhow::Result;
use sea_orm::{Database, DatabaseConnection, ConnectOptions, Statement, DatabaseBackend, ConnectionTrait}; use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, Statement,
};
use sea_orm_migration::MigratorTrait; use sea_orm_migration::MigratorTrait;
use std::time::Duration; use std::time::Duration;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -106,7 +108,8 @@ impl DatabaseManager {
// URL-encode the password part only // URL-encode the password part only
let encoded_password = urlencoding::encode(password); let encoded_password = urlencoding::encode(password);
let encoded_url = format!("{}{}:{}{}", scheme, user, encoded_password, host_db); let encoded_url =
format!("{}{}:{}{}", scheme, user, encoded_password, host_db);
return Ok(encoded_url); return Ok(encoded_url);
} }
@@ -132,7 +135,10 @@ mod tests {
let normal_url = "postgresql://user:password@localhost:5432/db"; let normal_url = "postgresql://user:password@localhost:5432/db";
let encoded_normal = DatabaseManager::encode_database_url(normal_url).unwrap(); let encoded_normal = DatabaseManager::encode_database_url(normal_url).unwrap();
assert_eq!(encoded_normal, "postgresql://user:password@localhost:5432/db"); assert_eq!(
encoded_normal,
"postgresql://user:password@localhost:5432/db"
);
} }
#[tokio::test] #[tokio::test]

View File

@@ -1,6 +1,6 @@
use sea_orm::*;
use crate::database::entities::{certificate, prelude::*}; use crate::database::entities::{certificate, prelude::*};
use anyhow::Result; use anyhow::Result;
use sea_orm::*;
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone)] #[derive(Clone)]
@@ -13,7 +13,10 @@ impl CertificateRepository {
Self { db } Self { db }
} }
pub async fn create(&self, cert_data: certificate::CreateCertificateDto) -> Result<certificate::Model> { pub async fn create(
&self,
cert_data: certificate::CreateCertificateDto,
) -> Result<certificate::Model> {
let cert = certificate::ActiveModel::from(cert_data); let cert = certificate::ActiveModel::from(cert_data);
let result = Certificate::insert(cert).exec(&self.db).await?; let result = Certificate::insert(cert).exec(&self.db).await?;
@@ -48,7 +51,11 @@ impl CertificateRepository {
.await?) .await?)
} }
pub async fn update(&self, id: Uuid, cert_data: certificate::UpdateCertificateDto) -> Result<certificate::Model> { pub async fn update(
&self,
id: Uuid,
cert_data: certificate::UpdateCertificateDto,
) -> Result<certificate::Model> {
let cert = Certificate::find_by_id(id) let cert = Certificate::find_by_id(id)
.one(&self.db) .one(&self.db)
.await? .await?
@@ -79,7 +86,7 @@ impl CertificateRepository {
id: Uuid, id: Uuid,
cert_pem: &str, cert_pem: &str,
key_pem: &str, key_pem: &str,
expires_at: chrono::DateTime<chrono::Utc> expires_at: chrono::DateTime<chrono::Utc>,
) -> Result<certificate::Model> { ) -> Result<certificate::Model> {
let mut cert: certificate::ActiveModel = Certificate::find_by_id(id) let mut cert: certificate::ActiveModel = Certificate::find_by_id(id)
.one(&self.db) .one(&self.db)

View File

@@ -1,9 +1,12 @@
use anyhow::Result; use anyhow::Result;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter, Set, PaginatorTrait}; use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
Set,
};
use uuid::Uuid; use uuid::Uuid;
use crate::database::entities::dns_provider::{ use crate::database::entities::dns_provider::{
Entity, Model, ActiveModel, CreateDnsProviderDto, UpdateDnsProviderDto, Column, DnsProviderType ActiveModel, Column, CreateDnsProviderDto, DnsProviderType, Entity, Model, UpdateDnsProviderDto,
}; };
pub struct DnsProviderRepository { pub struct DnsProviderRepository {
@@ -121,7 +124,10 @@ impl DnsProviderRepository {
} }
/// Get the first active provider of a specific type /// Get the first active provider of a specific type
pub async fn get_active_provider_by_type(&self, provider_type: DnsProviderType) -> Result<Option<Model>> { pub async fn get_active_provider_by_type(
&self,
provider_type: DnsProviderType,
) -> Result<Option<Model>> {
let provider = Entity::find() let provider = Entity::find()
.filter(Column::ProviderType.eq(provider_type.as_str())) .filter(Column::ProviderType.eq(provider_type.as_str()))
.filter(Column::IsActive.eq(true)) .filter(Column::IsActive.eq(true))

View File

@@ -1,6 +1,6 @@
use sea_orm::*;
use crate::database::entities::{inbound_template, prelude::*}; use crate::database::entities::{inbound_template, prelude::*};
use anyhow::Result; use anyhow::Result;
use sea_orm::*;
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone)] #[derive(Clone)]
@@ -14,7 +14,10 @@ impl InboundTemplateRepository {
Self { db } Self { db }
} }
pub async fn create(&self, template_data: inbound_template::CreateInboundTemplateDto) -> Result<inbound_template::Model> { pub async fn create(
&self,
template_data: inbound_template::CreateInboundTemplateDto,
) -> Result<inbound_template::Model> {
let template = inbound_template::ActiveModel::from(template_data); let template = inbound_template::ActiveModel::from(template_data);
let result = InboundTemplate::insert(template).exec(&self.db).await?; let result = InboundTemplate::insert(template).exec(&self.db).await?;
@@ -47,7 +50,11 @@ impl InboundTemplateRepository {
.await?) .await?)
} }
pub async fn update(&self, id: Uuid, template_data: inbound_template::UpdateInboundTemplateDto) -> Result<inbound_template::Model> { pub async fn update(
&self,
id: Uuid,
template_data: inbound_template::UpdateInboundTemplateDto,
) -> Result<inbound_template::Model> {
let template = InboundTemplate::find_by_id(id) let template = InboundTemplate::find_by_id(id)
.one(&self.db) .one(&self.db)
.await? .await?

View File

@@ -1,9 +1,9 @@
use anyhow::Result; use anyhow::Result;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use uuid::Uuid; use uuid::Uuid;
use crate::database::entities::inbound_users::{ use crate::database::entities::inbound_users::{
Entity, Model, ActiveModel, CreateInboundUserDto, UpdateInboundUserDto, Column ActiveModel, Column, CreateInboundUserDto, Entity, Model, UpdateInboundUserDto,
}; };
use crate::services::uri_generator::ClientConfigData; use crate::services::uri_generator::ClientConfigData;
@@ -46,7 +46,11 @@ impl InboundUsersRepository {
} }
/// Find user by user_id and inbound (for uniqueness check - one user per inbound) /// Find user by user_id and inbound (for uniqueness check - one user per inbound)
pub async fn find_by_user_and_inbound(&self, user_id: Uuid, inbound_id: Uuid) -> Result<Option<Model>> { pub async fn find_by_user_and_inbound(
&self,
user_id: Uuid,
inbound_id: Uuid,
) -> Result<Option<Model>> {
let user = Entity::find() let user = Entity::find()
.filter(Column::UserId.eq(user_id)) .filter(Column::UserId.eq(user_id))
.filter(Column::ServerInboundId.eq(inbound_id)) .filter(Column::ServerInboundId.eq(inbound_id))
@@ -126,15 +130,23 @@ impl InboundUsersRepository {
} }
/// Check if user already has access to this inbound /// Check if user already has access to this inbound
pub async fn user_has_access_to_inbound(&self, user_id: Uuid, inbound_id: Uuid) -> Result<bool> { pub async fn user_has_access_to_inbound(
&self,
user_id: Uuid,
inbound_id: Uuid,
) -> Result<bool> {
let exists = self.find_by_user_and_inbound(user_id, inbound_id).await?; let exists = self.find_by_user_and_inbound(user_id, inbound_id).await?;
Ok(exists.is_some()) Ok(exists.is_some())
} }
/// Get complete client configuration data for URI generation /// Get complete client configuration data for URI generation
pub async fn get_client_config_data(&self, user_id: Uuid, server_inbound_id: Uuid) -> Result<Option<ClientConfigData>> { pub async fn get_client_config_data(
&self,
user_id: Uuid,
server_inbound_id: Uuid,
) -> Result<Option<ClientConfigData>> {
use crate::database::entities::{ use crate::database::entities::{
user, server, server_inbound, inbound_template, certificate certificate, inbound_template, server, server_inbound, user,
}; };
// Get the inbound_user record first // Get the inbound_user record first
@@ -153,10 +165,11 @@ impl InboundUsersRepository {
.ok_or_else(|| anyhow::anyhow!("User not found"))?; .ok_or_else(|| anyhow::anyhow!("User not found"))?;
// Get server inbound info // Get server inbound info
let server_inbound_entity = server_inbound::Entity::find_by_id(inbound_user.server_inbound_id) let server_inbound_entity =
.one(&self.db) server_inbound::Entity::find_by_id(inbound_user.server_inbound_id)
.await? .one(&self.db)
.ok_or_else(|| anyhow::anyhow!("Server inbound not found"))?; .await?
.ok_or_else(|| anyhow::anyhow!("Server inbound not found"))?;
// Get server info // Get server info
let server_entity = server::Entity::find_by_id(server_inbound_entity.server_id) let server_entity = server::Entity::find_by_id(server_inbound_entity.server_id)
@@ -165,10 +178,11 @@ impl InboundUsersRepository {
.ok_or_else(|| anyhow::anyhow!("Server not found"))?; .ok_or_else(|| anyhow::anyhow!("Server not found"))?;
// Get template info // Get template info
let template_entity = inbound_template::Entity::find_by_id(server_inbound_entity.template_id) let template_entity =
.one(&self.db) inbound_template::Entity::find_by_id(server_inbound_entity.template_id)
.await? .one(&self.db)
.ok_or_else(|| anyhow::anyhow!("Template not found"))?; .await?
.ok_or_else(|| anyhow::anyhow!("Template not found"))?;
// Get certificate info (optional) // Get certificate info (optional)
let certificate_domain = if let Some(cert_id) = server_inbound_entity.certificate_id { let certificate_domain = if let Some(cert_id) = server_inbound_entity.certificate_id {
@@ -186,7 +200,9 @@ impl InboundUsersRepository {
password: inbound_user.password, password: inbound_user.password,
level: inbound_user.level, level: inbound_user.level,
hostname: server_entity.hostname, hostname: server_entity.hostname,
port: server_inbound_entity.port_override.unwrap_or(template_entity.default_port), port: server_inbound_entity
.port_override
.unwrap_or(template_entity.default_port),
protocol: template_entity.protocol, protocol: template_entity.protocol,
stream_settings: template_entity.stream_settings, stream_settings: template_entity.stream_settings,
base_settings: template_entity.base_settings, base_settings: template_entity.base_settings,
@@ -205,7 +221,10 @@ impl InboundUsersRepository {
} }
/// Get all client configuration data for a user /// Get all client configuration data for a user
pub async fn get_all_client_configs_for_user(&self, user_id: Uuid) -> Result<Vec<ClientConfigData>> { pub async fn get_all_client_configs_for_user(
&self,
user_id: Uuid,
) -> Result<Vec<ClientConfigData>> {
// Get all active inbound users for this user // Get all active inbound users for this user
let inbound_users = Entity::find() let inbound_users = Entity::find()
.filter(Column::UserId.eq(user_id)) .filter(Column::UserId.eq(user_id))
@@ -217,7 +236,10 @@ impl InboundUsersRepository {
for inbound_user in inbound_users { for inbound_user in inbound_users {
// Get the client config data for each inbound // Get the client config data for each inbound
if let Ok(Some(config)) = self.get_client_config_data(user_id, inbound_user.server_inbound_id).await { if let Ok(Some(config)) = self
.get_client_config_data(user_id, inbound_user.server_inbound_id)
.await
{
configs.push(config); configs.push(config);
} }
} }

View File

@@ -1,21 +1,21 @@
pub mod user;
pub mod certificate; pub mod certificate;
pub mod dns_provider; pub mod dns_provider;
pub mod inbound_template; pub mod inbound_template;
pub mod inbound_users;
pub mod server; pub mod server;
pub mod server_inbound; pub mod server_inbound;
pub mod user_access;
pub mod inbound_users;
pub mod telegram_config; pub mod telegram_config;
pub mod user;
pub mod user_access;
pub mod user_request; pub mod user_request;
pub use user::UserRepository;
pub use certificate::CertificateRepository; pub use certificate::CertificateRepository;
pub use dns_provider::DnsProviderRepository; pub use dns_provider::DnsProviderRepository;
pub use inbound_template::InboundTemplateRepository; pub use inbound_template::InboundTemplateRepository;
pub use inbound_users::InboundUsersRepository;
pub use server::ServerRepository; pub use server::ServerRepository;
pub use server_inbound::ServerInboundRepository; pub use server_inbound::ServerInboundRepository;
pub use user_access::UserAccessRepository;
pub use inbound_users::InboundUsersRepository;
pub use telegram_config::TelegramConfigRepository; pub use telegram_config::TelegramConfigRepository;
pub use user::UserRepository;
pub use user_access::UserAccessRepository;
pub use user_request::UserRequestRepository; pub use user_request::UserRequestRepository;

View File

@@ -1,6 +1,6 @@
use sea_orm::*; use crate::database::entities::{prelude::*, server};
use crate::database::entities::{server, prelude::*};
use anyhow::Result; use anyhow::Result;
use sea_orm::*;
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone)] #[derive(Clone)]
@@ -54,7 +54,11 @@ impl ServerRepository {
.await?) .await?)
} }
pub async fn update(&self, id: Uuid, server_data: server::UpdateServerDto) -> Result<server::Model> { pub async fn update(
&self,
id: Uuid,
server_data: server::UpdateServerDto,
) -> Result<server::Model> {
let server = Server::find_by_id(id) let server = Server::find_by_id(id)
.one(&self.db) .one(&self.db)
.await? .await?
@@ -71,7 +75,9 @@ impl ServerRepository {
} }
pub async fn get_grpc_endpoint(&self, id: Uuid) -> Result<String> { pub async fn get_grpc_endpoint(&self, id: Uuid) -> Result<String> {
let server = self.find_by_id(id).await? let server = self
.find_by_id(id)
.await?
.ok_or_else(|| anyhow::anyhow!("Server not found"))?; .ok_or_else(|| anyhow::anyhow!("Server not found"))?;
Ok(server.get_grpc_endpoint()) Ok(server.get_grpc_endpoint())

View File

@@ -1,6 +1,6 @@
use sea_orm::*; use crate::database::entities::{prelude::*, server_inbound};
use crate::database::entities::{server_inbound, prelude::*};
use anyhow::Result; use anyhow::Result;
use sea_orm::*;
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone)] #[derive(Clone)]
@@ -14,7 +14,11 @@ impl ServerInboundRepository {
Self { db } Self { db }
} }
pub async fn create(&self, server_id: Uuid, inbound_data: server_inbound::CreateServerInboundDto) -> Result<server_inbound::Model> { pub async fn create(
&self,
server_id: Uuid,
inbound_data: server_inbound::CreateServerInboundDto,
) -> Result<server_inbound::Model> {
let mut inbound: server_inbound::ActiveModel = inbound_data.into(); let mut inbound: server_inbound::ActiveModel = inbound_data.into();
inbound.id = Set(Uuid::new_v4()); inbound.id = Set(Uuid::new_v4());
inbound.server_id = Set(server_id); inbound.server_id = Set(server_id);
@@ -29,7 +33,12 @@ impl ServerInboundRepository {
.ok_or_else(|| anyhow::anyhow!("Failed to retrieve created server inbound")) .ok_or_else(|| anyhow::anyhow!("Failed to retrieve created server inbound"))
} }
pub async fn create_with_protocol(&self, server_id: Uuid, inbound_data: server_inbound::CreateServerInboundDto, protocol: &str) -> Result<server_inbound::Model> { pub async fn create_with_protocol(
&self,
server_id: Uuid,
inbound_data: server_inbound::CreateServerInboundDto,
protocol: &str,
) -> Result<server_inbound::Model> {
let mut inbound: server_inbound::ActiveModel = inbound_data.into(); let mut inbound: server_inbound::ActiveModel = inbound_data.into();
inbound.id = Set(Uuid::new_v4()); inbound.id = Set(Uuid::new_v4());
inbound.server_id = Set(server_id); inbound.server_id = Set(server_id);
@@ -63,8 +72,11 @@ impl ServerInboundRepository {
.await?) .await?)
} }
pub async fn find_by_server_id_with_template(&self, server_id: Uuid) -> Result<Vec<server_inbound::ServerInboundResponse>> { pub async fn find_by_server_id_with_template(
use crate::database::entities::{inbound_template, certificate}; &self,
server_id: Uuid,
) -> Result<Vec<server_inbound::ServerInboundResponse>> {
use crate::database::entities::{certificate, inbound_template};
let inbounds = ServerInbound::find() let inbounds = ServerInbound::find()
.filter(server_inbound::Column::ServerId.eq(server_id)) .filter(server_inbound::Column::ServerId.eq(server_id))
@@ -76,13 +88,17 @@ impl ServerInboundRepository {
let mut response = server_inbound::ServerInboundResponse::from(inbound.clone()); let mut response = server_inbound::ServerInboundResponse::from(inbound.clone());
// Load template information // Load template information
if let Ok(Some(template)) = InboundTemplate::find_by_id(inbound.template_id).one(&self.db).await { if let Ok(Some(template)) = InboundTemplate::find_by_id(inbound.template_id)
.one(&self.db)
.await
{
response.template_name = Some(template.name); response.template_name = Some(template.name);
} }
// Load certificate information // Load certificate information
if let Some(cert_id) = inbound.certificate_id { if let Some(cert_id) = inbound.certificate_id {
if let Ok(Some(certificate)) = Certificate::find_by_id(cert_id).one(&self.db).await { if let Ok(Some(certificate)) = Certificate::find_by_id(cert_id).one(&self.db).await
{
response.certificate_name = Some(certificate.domain); response.certificate_name = Some(certificate.domain);
} }
} }
@@ -93,7 +109,10 @@ impl ServerInboundRepository {
Ok(responses) Ok(responses)
} }
pub async fn find_by_template_id(&self, template_id: Uuid) -> Result<Vec<server_inbound::Model>> { pub async fn find_by_template_id(
&self,
template_id: Uuid,
) -> Result<Vec<server_inbound::Model>> {
Ok(ServerInbound::find() Ok(ServerInbound::find()
.filter(server_inbound::Column::TemplateId.eq(template_id)) .filter(server_inbound::Column::TemplateId.eq(template_id))
.all(&self.db) .all(&self.db)
@@ -107,14 +126,20 @@ impl ServerInboundRepository {
.await?) .await?)
} }
pub async fn find_by_certificate_id(&self, certificate_id: Uuid) -> Result<Vec<server_inbound::Model>> { pub async fn find_by_certificate_id(
&self,
certificate_id: Uuid,
) -> Result<Vec<server_inbound::Model>> {
Ok(ServerInbound::find() Ok(ServerInbound::find()
.filter(server_inbound::Column::CertificateId.eq(certificate_id)) .filter(server_inbound::Column::CertificateId.eq(certificate_id))
.all(&self.db) .all(&self.db)
.await?) .await?)
} }
pub async fn find_active_by_server(&self, server_id: Uuid) -> Result<Vec<server_inbound::Model>> { pub async fn find_active_by_server(
&self,
server_id: Uuid,
) -> Result<Vec<server_inbound::Model>> {
Ok(ServerInbound::find() Ok(ServerInbound::find()
.filter(server_inbound::Column::ServerId.eq(server_id)) .filter(server_inbound::Column::ServerId.eq(server_id))
.filter(server_inbound::Column::IsActive.eq(true)) .filter(server_inbound::Column::IsActive.eq(true))
@@ -122,7 +147,11 @@ impl ServerInboundRepository {
.await?) .await?)
} }
pub async fn update(&self, id: Uuid, inbound_data: server_inbound::UpdateServerInboundDto) -> Result<server_inbound::Model> { pub async fn update(
&self,
id: Uuid,
inbound_data: server_inbound::UpdateServerInboundDto,
) -> Result<server_inbound::Model> {
let inbound = ServerInbound::find_by_id(id) let inbound = ServerInbound::find_by_id(id)
.one(&self.db) .one(&self.db)
.await? .await?

View File

@@ -1,9 +1,11 @@
use anyhow::Result; use anyhow::Result;
use sea_orm::{DatabaseConnection, EntityTrait, ActiveModelTrait, Set, QueryFilter, ColumnTrait, QueryOrder}; use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
};
use uuid::Uuid; use uuid::Uuid;
use crate::database::entities::telegram_config::{ use crate::database::entities::telegram_config::{
self, Model, CreateTelegramConfigDto, UpdateTelegramConfigDto self, CreateTelegramConfigDto, Model, UpdateTelegramConfigDto,
}; };
pub struct TelegramConfigRepository { pub struct TelegramConfigRepository {

View File

@@ -1,9 +1,14 @@
use anyhow::Result; use anyhow::Result;
use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait, QueryOrder, PaginatorTrait, QuerySelect}; use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect,
};
use uuid::Uuid; use uuid::Uuid;
use sea_orm::{Set, ActiveModelTrait}; use crate::database::entities::user::{
use crate::database::entities::user::{Entity as User, Column, Model, ActiveModel, CreateUserDto, UpdateUserDto}; ActiveModel, Column, CreateUserDto, Entity as User, Model, UpdateUserDto,
};
use sea_orm::{ActiveModelTrait, Set};
pub struct UserRepository { pub struct UserRepository {
db: DatabaseConnection, db: DatabaseConnection,
@@ -46,7 +51,12 @@ impl UserRepository {
} }
/// Search users by name (with pagination for backward compatibility) /// Search users by name (with pagination for backward compatibility)
pub async fn search_by_name(&self, query: &str, page: u64, per_page: u64) -> Result<Vec<Model>> { pub async fn search_by_name(
&self,
query: &str,
page: u64,
per_page: u64,
) -> Result<Vec<Model>> {
let users = User::find() let users = User::find()
.filter(Column::Name.contains(query)) .filter(Column::Name.contains(query))
.order_by_desc(Column::CreatedAt) .order_by_desc(Column::CreatedAt)
@@ -89,7 +99,9 @@ impl UserRepository {
/// Create a new user /// Create a new user
pub async fn create(&self, dto: CreateUserDto) -> Result<Model> { pub async fn create(&self, dto: CreateUserDto) -> Result<Model> {
let active_model: ActiveModel = dto.into(); let active_model: ActiveModel = dto.into();
let user = User::insert(active_model).exec_with_returning(&self.db).await?; let user = User::insert(active_model)
.exec_with_returning(&self.db)
.await?;
Ok(user) Ok(user)
} }
@@ -126,7 +138,6 @@ impl UserRepository {
Ok(count > 0) Ok(count > 0)
} }
/// Set user as Telegram admin /// Set user as Telegram admin
pub async fn set_telegram_admin(&self, user_id: Uuid, is_admin: bool) -> Result<Option<Model>> { pub async fn set_telegram_admin(&self, user_id: Uuid, is_admin: bool) -> Result<Option<Model>> {
if let Some(user) = self.get_by_id(user_id).await? { if let Some(user) = self.get_by_id(user_id).await? {
@@ -178,19 +189,36 @@ impl UserRepository {
Ok(admin) Ok(admin)
} }
/// Count total users
pub async fn count_all(&self) -> Result<i64> {
let count = User::find().count(&self.db).await?;
Ok(count as i64)
}
/// Find users with pagination
pub async fn find_paginated(&self, offset: u64, limit: u64) -> Result<Vec<Model>> {
let users = User::find()
.order_by_desc(Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&self.db)
.await?;
Ok(users)
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::database::DatabaseManager;
use crate::config::DatabaseConfig; use crate::config::DatabaseConfig;
use crate::database::DatabaseManager;
async fn setup_test_db() -> Result<UserRepository> { async fn setup_test_db() -> Result<UserRepository> {
let config = DatabaseConfig { let config = DatabaseConfig {
url: std::env::var("DATABASE_URL").unwrap_or_else(|_| url: std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string()),
"sqlite::memory:".to_string()
),
max_connections: 5, max_connections: 5,
connection_timeout: 30, connection_timeout: 30,
auto_migrate: true, auto_migrate: true,

View File

@@ -1,8 +1,10 @@
use anyhow::Result;
use sea_orm::*; use sea_orm::*;
use uuid::Uuid; use uuid::Uuid;
use anyhow::Result;
use crate::database::entities::user_access::{self, Entity as UserAccess, Model, ActiveModel, CreateUserAccessDto, UpdateUserAccessDto}; use crate::database::entities::user_access::{
self, ActiveModel, CreateUserAccessDto, Entity as UserAccess, Model, UpdateUserAccessDto,
};
pub struct UserAccessRepository { pub struct UserAccessRepository {
db: DatabaseConnection, db: DatabaseConnection,
@@ -35,7 +37,11 @@ impl UserAccessRepository {
} }
/// Find user access by server and inbound /// Find user access by server and inbound
pub async fn find_by_server_inbound(&self, server_id: Uuid, server_inbound_id: Uuid) -> Result<Vec<Model>> { pub async fn find_by_server_inbound(
&self,
server_id: Uuid,
server_inbound_id: Uuid,
) -> Result<Vec<Model>> {
let records = UserAccess::find() let records = UserAccess::find()
.filter(user_access::Column::ServerId.eq(server_id)) .filter(user_access::Column::ServerId.eq(server_id))
.filter(user_access::Column::ServerInboundId.eq(server_inbound_id)) .filter(user_access::Column::ServerInboundId.eq(server_inbound_id))
@@ -45,7 +51,12 @@ impl UserAccessRepository {
} }
/// Find active user access for specific user, server and inbound /// Find active user access for specific user, server and inbound
pub async fn find_active_access(&self, user_id: Uuid, server_id: Uuid, server_inbound_id: Uuid) -> Result<Option<Model>> { pub async fn find_active_access(
&self,
user_id: Uuid,
server_id: Uuid,
server_inbound_id: Uuid,
) -> Result<Option<Model>> {
let record = UserAccess::find() let record = UserAccess::find()
.filter(user_access::Column::UserId.eq(user_id)) .filter(user_access::Column::UserId.eq(user_id))
.filter(user_access::Column::ServerId.eq(server_id)) .filter(user_access::Column::ServerId.eq(server_id))
@@ -83,18 +94,26 @@ impl UserAccessRepository {
/// Enable user access (set is_active = true) /// Enable user access (set is_active = true)
pub async fn enable(&self, id: Uuid) -> Result<Option<Model>> { pub async fn enable(&self, id: Uuid) -> Result<Option<Model>> {
self.update(id, UpdateUserAccessDto { self.update(
is_active: Some(true), id,
level: None, UpdateUserAccessDto {
}).await is_active: Some(true),
level: None,
},
)
.await
} }
/// Disable user access (set is_active = false) /// Disable user access (set is_active = false)
pub async fn disable(&self, id: Uuid) -> Result<Option<Model>> { pub async fn disable(&self, id: Uuid) -> Result<Option<Model>> {
self.update(id, UpdateUserAccessDto { self.update(
is_active: Some(false), id,
level: None, UpdateUserAccessDto {
}).await is_active: Some(false),
level: None,
},
)
.await
} }
/// Get all active access for a user /// Get all active access for a user

View File

@@ -1,9 +1,12 @@
use anyhow::Result;
use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, DatabaseConnection, QueryOrder, PaginatorTrait, QuerySelect};
use uuid::Uuid;
use crate::database::entities::user_request::{ use crate::database::entities::user_request::{
self, Model, ActiveModel, CreateUserRequestDto, UpdateUserRequestDto, RequestStatus self, ActiveModel, CreateUserRequestDto, Model, RequestStatus, UpdateUserRequestDto,
}; };
use anyhow::Result;
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect,
};
use uuid::Uuid;
pub struct UserRequestRepository { pub struct UserRequestRepository {
db: DatabaseConnection, db: DatabaseConnection,
@@ -38,9 +41,7 @@ impl UserRequestRepository {
} }
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<Model>> { pub async fn find_by_id(&self, id: Uuid) -> Result<Option<Model>> {
let request = user_request::Entity::find_by_id(id) let request = user_request::Entity::find_by_id(id).one(&self.db).await?;
.one(&self.db)
.await?;
Ok(request) Ok(request)
} }
@@ -73,6 +74,25 @@ impl UserRequestRepository {
Ok(request) Ok(request)
} }
/// Count total requests
pub async fn count_all(&self) -> Result<i64> {
let count = user_request::Entity::find().count(&self.db).await?;
Ok(count as i64)
}
/// Find requests with pagination
pub async fn find_paginated(&self, offset: u64, limit: u64) -> Result<Vec<Model>> {
let requests = user_request::Entity::find()
.order_by_desc(user_request::Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&self.db)
.await?;
Ok(requests)
}
pub async fn create(&self, dto: CreateUserRequestDto) -> Result<Model> { pub async fn create(&self, dto: CreateUserRequestDto) -> Result<Model> {
use sea_orm::ActiveModelTrait; use sea_orm::ActiveModelTrait;
let active_model: ActiveModel = dto.into(); let active_model: ActiveModel = dto.into();
@@ -80,10 +100,13 @@ impl UserRequestRepository {
Ok(request) Ok(request)
} }
pub async fn update(&self, id: Uuid, dto: UpdateUserRequestDto, processed_by: Uuid) -> Result<Option<Model>> { pub async fn update(
let model = user_request::Entity::find_by_id(id) &self,
.one(&self.db) id: Uuid,
.await?; dto: UpdateUserRequestDto,
processed_by: Uuid,
) -> Result<Option<Model>> {
let model = user_request::Entity::find_by_id(id).one(&self.db).await?;
match model { match model {
Some(model) => { Some(model) => {
@@ -96,7 +119,12 @@ impl UserRequestRepository {
} }
} }
pub async fn approve(&self, id: Uuid, response_message: Option<String>, processed_by: Uuid) -> Result<Option<Model>> { pub async fn approve(
&self,
id: Uuid,
response_message: Option<String>,
processed_by: Uuid,
) -> Result<Option<Model>> {
let dto = UpdateUserRequestDto { let dto = UpdateUserRequestDto {
status: Some(RequestStatus::Approved.as_str().to_string()), status: Some(RequestStatus::Approved.as_str().to_string()),
response_message, response_message,
@@ -105,7 +133,12 @@ impl UserRequestRepository {
self.update(id, dto, processed_by).await self.update(id, dto, processed_by).await
} }
pub async fn decline(&self, id: Uuid, response_message: Option<String>, processed_by: Uuid) -> Result<Option<Model>> { pub async fn decline(
&self,
id: Uuid,
response_message: Option<String>,
processed_by: Uuid,
) -> Result<Option<Model>> {
let dto = UpdateUserRequestDto { let dto = UpdateUserRequestDto {
status: Some(RequestStatus::Declined.as_str().to_string()), status: Some(RequestStatus::Declined.as_str().to_string()),
response_message, response_message,

View File

@@ -7,9 +7,9 @@ mod database;
mod services; mod services;
mod web; mod web;
use config::{AppConfig, args::parse_args}; use config::{args::parse_args, AppConfig};
use database::DatabaseManager; use database::DatabaseManager;
use services::{TaskScheduler, XrayService, TelegramService}; use services::{TaskScheduler, TelegramService, XrayService};
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@@ -24,7 +24,6 @@ async fn main() -> Result<()> {
// Initialize logging early with basic configuration // Initialize logging early with basic configuration
init_logging(&args.log_level.as_deref().unwrap_or("info"))?; init_logging(&args.log_level.as_deref().unwrap_or("info"))?;
// Handle special flags // Handle special flags
if args.print_default_config { if args.print_default_config {
print_default_config()?; print_default_config()?;
@@ -33,9 +32,7 @@ async fn main() -> Result<()> {
// Load configuration // Load configuration
let config = match AppConfig::load() { let config = match AppConfig::load() {
Ok(config) => { Ok(config) => config,
config
}
Err(e) => { Err(e) => {
tracing::error!("Failed to load configuration: {}", e); tracing::error!("Failed to load configuration: {}", e);
if args.validate_config { if args.validate_config {
@@ -58,12 +55,9 @@ async fn main() -> Result<()> {
config::env::EnvVars::print_env_info(); config::env::EnvVars::print_env_info();
} }
// Initialize database connection // Initialize database connection
let db = match DatabaseManager::new(&config.database).await { let db = match DatabaseManager::new(&config.database).await {
Ok(db) => { Ok(db) => db,
db
}
Err(e) => { Err(e) => {
tracing::error!("Failed to initialize database: {}", e); tracing::error!("Failed to initialize database: {}", e);
return Err(e); return Err(e);
@@ -123,12 +117,12 @@ fn init_logging(level: &str) -> Result<()> {
.with(filter) .with(filter)
.with( .with(
tracing_subscriber::fmt::layer() tracing_subscriber::fmt::layer()
.with_target(true) // Show module names .with_target(true) // Show module names
.with_thread_ids(false) .with_thread_ids(false)
.with_thread_names(false) .with_thread_names(false)
.with_file(false) .with_file(false)
.with_line_number(false) .with_line_number(false)
.compact() .compact(),
) )
.try_init()?; .try_init()?;

View File

@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::services::acme::{CloudflareClient, AcmeError}; use crate::services::acme::{AcmeError, CloudflareClient};
pub struct AcmeClient { pub struct AcmeClient {
cloudflare: CloudflareClient, cloudflare: CloudflareClient,
@@ -47,17 +47,24 @@ impl AcmeClient {
}) })
} }
pub async fn get_certificate(&mut self, domain: &str, base_domain: &str) -> Result<(String, String), AcmeError> { pub async fn get_certificate(
&mut self,
domain: &str,
base_domain: &str,
) -> Result<(String, String), AcmeError> {
info!("Starting certificate request for domain: {}", domain); info!("Starting certificate request for domain: {}", domain);
// Validate domain // Validate domain
if domain.is_empty() || base_domain.is_empty() { if domain.is_empty() || base_domain.is_empty() {
return Err(AcmeError::InvalidDomain("Domain cannot be empty".to_string())); return Err(AcmeError::InvalidDomain(
"Domain cannot be empty".to_string(),
));
} }
// Create a new order // Create a new order
let identifiers = vec![Identifier::Dns(domain.to_string())]; let identifiers = vec![Identifier::Dns(domain.to_string())];
let mut order = self.account let mut order = self
.account
.new_order(&NewOrder::new(&identifiers)) .new_order(&NewOrder::new(&identifiers))
.await .await
.map_err(|e| AcmeError::OrderCreation(e.to_string()))?; .map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
@@ -68,8 +75,7 @@ impl AcmeClient {
let mut authorizations = order.authorizations(); let mut authorizations = order.authorizations();
while let Some(authz_result) = authorizations.next().await { while let Some(authz_result) = authorizations.next().await {
let mut authz = authz_result let mut authz = authz_result.map_err(|e| AcmeError::Challenge(e.to_string()))?;
.map_err(|e| AcmeError::Challenge(e.to_string()))?;
let identifier = format!("{:?}", authz.identifier()); let identifier = format!("{:?}", authz.identifier());
@@ -93,7 +99,8 @@ impl AcmeClient {
// Create DNS record // Create DNS record
let challenge_domain = format!("_acme-challenge.{}", domain); let challenge_domain = format!("_acme-challenge.{}", domain);
let record_id = self.cloudflare let record_id = self
.cloudflare
.create_txt_record(base_domain, &challenge_domain, &challenge_value) .create_txt_record(base_domain, &challenge_domain, &challenge_value)
.await?; .await?;
@@ -105,7 +112,9 @@ impl AcmeClient {
// Submit challenge // Submit challenge
info!("Submitting challenge..."); info!("Submitting challenge...");
challenge.set_ready().await challenge
.set_ready()
.await
.map_err(|e| AcmeError::Challenge(e.to_string()))?; .map_err(|e| AcmeError::Challenge(e.to_string()))?;
(challenge_value, record_id) (challenge_value, record_id)
@@ -129,7 +138,9 @@ impl AcmeClient {
return Err(AcmeError::Challenge("Order processing timeout".to_string())); return Err(AcmeError::Challenge("Order processing timeout".to_string()));
} }
order.refresh().await order
.refresh()
.await
.map_err(|e| AcmeError::OrderCreation(e.to_string()))?; .map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
match order.state().status { match order.state().status {
@@ -166,12 +177,15 @@ impl AcmeClient {
// Generate CSR using rcgen certificate // Generate CSR using rcgen certificate
let cert = rcgen::Certificate::from_params(params) let cert = rcgen::Certificate::from_params(params)
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
let csr_der = cert.serialize_request_der() let csr_der = cert
.serialize_request_der()
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
// Finalize order with CSR // Finalize order with CSR
info!("Finalizing order with CSR..."); info!("Finalizing order with CSR...");
order.finalize_csr(&csr_der).await order
.finalize_csr(&csr_der)
.await
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
// Wait for certificate to be ready // Wait for certificate to be ready
@@ -181,28 +195,43 @@ impl AcmeClient {
let cert_chain_pem = loop { let cert_chain_pem = loop {
if start.elapsed() > timeout { if start.elapsed() > timeout {
return Err(AcmeError::CertificateGeneration("Certificate generation timeout".to_string())); return Err(AcmeError::CertificateGeneration(
"Certificate generation timeout".to_string(),
));
} }
order.refresh().await order
.refresh()
.await
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
match order.state().status { match order.state().status {
OrderStatus::Valid => { OrderStatus::Valid => {
info!("Certificate is ready!"); info!("Certificate is ready!");
break order.certificate().await break order
.certificate()
.await
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))? .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?
.ok_or_else(|| AcmeError::CertificateGeneration("Certificate not available".to_string()))?; .ok_or_else(|| {
AcmeError::CertificateGeneration(
"Certificate not available".to_string(),
)
})?;
} }
OrderStatus::Invalid => { OrderStatus::Invalid => {
return Err(AcmeError::CertificateGeneration("Order became invalid during certificate generation".to_string())); return Err(AcmeError::CertificateGeneration(
"Order became invalid during certificate generation".to_string(),
));
} }
OrderStatus::Processing => { OrderStatus::Processing => {
debug!("Certificate still being processed, waiting..."); debug!("Certificate still being processed, waiting...");
sleep(Duration::from_secs(3)).await; sleep(Duration::from_secs(3)).await;
} }
_ => { _ => {
debug!("Waiting for certificate, order status: {:?}", order.state().status); debug!(
"Waiting for certificate, order status: {:?}",
order.state().status
);
sleep(Duration::from_secs(3)).await; sleep(Duration::from_secs(3)).await;
} }
} }
@@ -214,7 +243,11 @@ impl AcmeClient {
Ok((cert_chain_pem, private_key_pem)) Ok((cert_chain_pem, private_key_pem))
} }
async fn wait_for_dns_propagation(&self, record_name: &str, expected_value: &str) -> Result<(), AcmeError> { async fn wait_for_dns_propagation(
&self,
record_name: &str,
expected_value: &str,
) -> Result<(), AcmeError> {
info!("Checking DNS propagation for: {}", record_name); info!("Checking DNS propagation for: {}", record_name);
let start = Instant::now(); let start = Instant::now();
@@ -241,7 +274,11 @@ impl AcmeClient {
Ok(()) Ok(())
} }
async fn check_dns_txt_record(&self, record_name: &str, expected_value: &str) -> Result<bool, AcmeError> { async fn check_dns_txt_record(
&self,
record_name: &str,
expected_value: &str,
) -> Result<bool, AcmeError> {
use std::process::Command; use std::process::Command;
let output = Command::new("dig") let output = Command::new("dig")
@@ -268,7 +305,11 @@ impl AcmeClient {
} }
async fn cleanup_dns_record(&self, base_domain: &str, record_id: &str) { async fn cleanup_dns_record(&self, base_domain: &str, record_id: &str) {
if let Err(e) = self.cloudflare.delete_txt_record(base_domain, record_id).await { if let Err(e) = self
.cloudflare
.delete_txt_record(base_domain, record_id)
.await
{
warn!("Failed to cleanup DNS record {}: {:?}", record_id, e); warn!("Failed to cleanup DNS record {}: {:?}", record_id, e);
} }
} }
@@ -277,7 +318,9 @@ impl AcmeClient {
pub fn get_base_domain(domain: &str) -> Result<String, AcmeError> { pub fn get_base_domain(domain: &str) -> Result<String, AcmeError> {
let parts: Vec<&str> = domain.split('.').collect(); let parts: Vec<&str> = domain.split('.').collect();
if parts.len() < 2 { if parts.len() < 2 {
return Err(AcmeError::InvalidDomain("Domain must have at least 2 parts".to_string())); return Err(AcmeError::InvalidDomain(
"Domain must have at least 2 parts".to_string(),
));
} }
// Take the last two parts for base domain // Take the last two parts for base domain

View File

@@ -77,7 +77,8 @@ impl CloudflareClient {
let url = format!("https://api.cloudflare.com/client/v4/zones?name={}", domain); let url = format!("https://api.cloudflare.com/client/v4/zones?name={}", domain);
let response = self.client let response = self
.client
.get(&url) .get(&url)
.header("Authorization", format!("Bearer {}", self.api_token)) .header("Authorization", format!("Bearer {}", self.api_token))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
@@ -87,7 +88,10 @@ impl CloudflareClient {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
return Err(AcmeError::CloudflareApi(format!("HTTP {}: {}", status, body))); return Err(AcmeError::CloudflareApi(format!(
"HTTP {}: {}",
status, body
)));
} }
let zones: CloudflareZonesResponse = response.json().await?; let zones: CloudflareZonesResponse = response.json().await?;
@@ -95,17 +99,28 @@ impl CloudflareClient {
if !zones.success { if !zones.success {
let errors = zones.errors.unwrap_or_default(); let errors = zones.errors.unwrap_or_default();
let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect(); let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
return Err(AcmeError::CloudflareApi(format!("API errors: {}", error_messages.join(", ")))); return Err(AcmeError::CloudflareApi(format!(
"API errors: {}",
error_messages.join(", ")
)));
} }
zones.result zones
.result
.into_iter() .into_iter()
.find(|z| z.name == domain) .find(|z| z.name == domain)
.map(|z| z.id) .map(|z| z.id)
.ok_or_else(|| AcmeError::CloudflareApi(format!("Zone not found for domain: {}", domain))) .ok_or_else(|| {
AcmeError::CloudflareApi(format!("Zone not found for domain: {}", domain))
})
} }
pub async fn create_txt_record(&self, domain: &str, record_name: &str, content: &str) -> Result<String, AcmeError> { pub async fn create_txt_record(
&self,
domain: &str,
record_name: &str,
content: &str,
) -> Result<String, AcmeError> {
let zone_id = self.get_zone_id(domain).await?; let zone_id = self.get_zone_id(domain).await?;
info!("Creating TXT record {} in zone {}", record_name, domain); info!("Creating TXT record {} in zone {}", record_name, domain);
@@ -116,9 +131,13 @@ impl CloudflareClient {
ttl: 120, // 2 minutes TTL for quick propagation ttl: 120, // 2 minutes TTL for quick propagation
}; };
let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records", zone_id); let url = format!(
"https://api.cloudflare.com/client/v4/zones/{}/dns_records",
zone_id
);
let response = self.client let response = self
.client
.post(&url) .post(&url)
.header("Authorization", format!("Bearer {}", self.api_token)) .header("Authorization", format!("Bearer {}", self.api_token))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
@@ -129,7 +148,10 @@ impl CloudflareClient {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
return Err(AcmeError::CloudflareApi(format!("Failed to create DNS record ({}): {}", status, body))); return Err(AcmeError::CloudflareApi(format!(
"Failed to create DNS record ({}): {}",
status, body
)));
} }
let result: CreateDnsRecordResponse = response.json().await?; let result: CreateDnsRecordResponse = response.json().await?;
@@ -137,7 +159,10 @@ impl CloudflareClient {
if !result.success { if !result.success {
let errors = result.errors.unwrap_or_default(); let errors = result.errors.unwrap_or_default();
let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect(); let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
return Err(AcmeError::CloudflareApi(format!("Failed to create record: {}", error_messages.join(", ")))); return Err(AcmeError::CloudflareApi(format!(
"Failed to create record: {}",
error_messages.join(", ")
)));
} }
debug!("Created DNS record with ID: {}", result.result.id); debug!("Created DNS record with ID: {}", result.result.id);
@@ -148,9 +173,13 @@ impl CloudflareClient {
let zone_id = self.get_zone_id(domain).await?; let zone_id = self.get_zone_id(domain).await?;
info!("Deleting TXT record {} from zone {}", record_id, domain); info!("Deleting TXT record {} from zone {}", record_id, domain);
let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}", zone_id, record_id); let url = format!(
"https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}",
zone_id, record_id
);
let response = self.client let response = self
.client
.delete(&url) .delete(&url)
.header("Authorization", format!("Bearer {}", self.api_token)) .header("Authorization", format!("Bearer {}", self.api_token))
.send() .send()
@@ -159,14 +188,21 @@ impl CloudflareClient {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
return Err(AcmeError::CloudflareApi(format!("Failed to delete DNS record ({}): {}", status, body))); return Err(AcmeError::CloudflareApi(format!(
"Failed to delete DNS record ({}): {}",
status, body
)));
} }
info!("Successfully deleted DNS record"); info!("Successfully deleted DNS record");
Ok(()) Ok(())
} }
pub async fn find_txt_record(&self, domain: &str, record_name: &str) -> Result<Option<String>, AcmeError> { pub async fn find_txt_record(
&self,
domain: &str,
record_name: &str,
) -> Result<Option<String>, AcmeError> {
let zone_id = self.get_zone_id(domain).await?; let zone_id = self.get_zone_id(domain).await?;
let url = format!( let url = format!(
@@ -174,7 +210,8 @@ impl CloudflareClient {
zone_id, record_name zone_id, record_name
); );
let response = self.client let response = self
.client
.get(&url) .get(&url)
.header("Authorization", format!("Bearer {}", self.api_token)) .header("Authorization", format!("Bearer {}", self.api_token))
.send() .send()
@@ -183,7 +220,10 @@ impl CloudflareClient {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
return Err(AcmeError::CloudflareApi(format!("Failed to list DNS records ({}): {}", status, body))); return Err(AcmeError::CloudflareApi(format!(
"Failed to list DNS records ({}): {}",
status, body
)));
} }
let records: CloudflareDnsRecordsResponse = response.json().await?; let records: CloudflareDnsRecordsResponse = response.json().await?;
@@ -191,7 +231,10 @@ impl CloudflareClient {
if !records.success { if !records.success {
let errors = records.errors.unwrap_or_default(); let errors = records.errors.unwrap_or_default();
let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect(); let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
return Err(AcmeError::CloudflareApi(format!("Failed to list records: {}", error_messages.join(", ")))); return Err(AcmeError::CloudflareApi(format!(
"Failed to list records: {}",
error_messages.join(", ")
)));
} }
Ok(records.result.first().map(|r| r.id.clone())) Ok(records.result.first().map(|r| r.id.clone()))

View File

@@ -1,10 +1,13 @@
use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType, KeyPair, PKCS_ECDSA_P256_SHA256}; use rcgen::{
Certificate, CertificateParams, DistinguishedName, DnType, KeyPair, SanType,
PKCS_ECDSA_P256_SHA256,
};
use std::net::IpAddr; use std::net::IpAddr;
use time::{Duration, OffsetDateTime}; use time::{Duration, OffsetDateTime};
use uuid::Uuid; use uuid::Uuid;
use crate::database::repository::DnsProviderRepository;
use crate::database::entities::dns_provider::DnsProviderType; use crate::database::entities::dns_provider::DnsProviderType;
use crate::database::repository::DnsProviderRepository;
use crate::services::acme::{AcmeClient, AcmeError}; use crate::services::acme::{AcmeClient, AcmeError};
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
@@ -60,7 +63,9 @@ impl CertificateService {
} }
// Always add localhost IP for local testing // Always add localhost IP for local testing
san_list.push(SanType::IpAddress(IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)))); san_list.push(SanType::IpAddress(IpAddr::V4(std::net::Ipv4Addr::new(
127, 0, 0, 1,
))));
// If domain is not an IP, also add wildcard subdomain // If domain is not an IP, also add wildcard subdomain
if domain.parse::<IpAddr>().is_err() && !domain.starts_with("*.") { if domain.parse::<IpAddr>().is_err() && !domain.starts_with("*.") {
@@ -84,7 +89,9 @@ impl CertificateService {
let key_pem = cert.serialize_private_key_pem(); let key_pem = cert.serialize_private_key_pem();
// Validate PEM format // Validate PEM format
if !cert_pem.starts_with("-----BEGIN CERTIFICATE-----") || !cert_pem.ends_with("-----END CERTIFICATE-----\n") { if !cert_pem.starts_with("-----BEGIN CERTIFICATE-----")
|| !cert_pem.ends_with("-----END CERTIFICATE-----\n")
{
return Err(anyhow::anyhow!("Invalid certificate PEM format")); return Err(anyhow::anyhow!("Invalid certificate PEM format"));
} }
@@ -97,7 +104,6 @@ impl CertificateService {
Ok((cert_pem, key_pem)) Ok((cert_pem, key_pem))
} }
/// Generate Let's Encrypt certificate using DNS challenge /// Generate Let's Encrypt certificate using DNS challenge
pub async fn generate_letsencrypt_certificate( pub async fn generate_letsencrypt_certificate(
&self, &self,
@@ -106,22 +112,30 @@ impl CertificateService {
acme_email: &str, acme_email: &str,
staging: bool, staging: bool,
) -> Result<(String, String), AcmeError> { ) -> Result<(String, String), AcmeError> {
tracing::info!("Generating Let's Encrypt certificate for domain: {} using DNS challenge", domain); tracing::info!(
"Generating Let's Encrypt certificate for domain: {} using DNS challenge",
domain
);
// Get database connection // Get database connection
let db = self.db.as_ref() let db = self
.db
.as_ref()
.ok_or_else(|| AcmeError::DnsProviderNotFound)?; .ok_or_else(|| AcmeError::DnsProviderNotFound)?;
// Get DNS provider // Get DNS provider
let dns_repo = DnsProviderRepository::new(db.clone()); let dns_repo = DnsProviderRepository::new(db.clone());
let dns_provider = dns_repo.find_by_id(dns_provider_id) let dns_provider = dns_repo
.find_by_id(dns_provider_id)
.await .await
.map_err(|_| AcmeError::DnsProviderNotFound)? .map_err(|_| AcmeError::DnsProviderNotFound)?
.ok_or_else(|| AcmeError::DnsProviderNotFound)?; .ok_or_else(|| AcmeError::DnsProviderNotFound)?;
// Verify provider is Cloudflare (only supported provider for now) // Verify provider is Cloudflare (only supported provider for now)
if dns_provider.provider_type != DnsProviderType::Cloudflare.as_str() { if dns_provider.provider_type != DnsProviderType::Cloudflare.as_str() {
return Err(AcmeError::CloudflareApi("Only Cloudflare provider is supported".to_string())); return Err(AcmeError::CloudflareApi(
"Only Cloudflare provider is supported".to_string(),
));
} }
if !dns_provider.is_active { if !dns_provider.is_active {
@@ -140,32 +154,41 @@ impl CertificateService {
dns_provider.api_token.clone(), dns_provider.api_token.clone(),
acme_email, acme_email,
directory_url.to_string(), directory_url.to_string(),
).await?; )
.await?;
// Get base domain for DNS operations // Get base domain for DNS operations
let base_domain = AcmeClient::get_base_domain(domain)?; let base_domain = AcmeClient::get_base_domain(domain)?;
// Generate certificate // Generate certificate
let (cert_pem, key_pem) = acme_client let (cert_pem, key_pem) = acme_client.get_certificate(domain, &base_domain).await?;
.get_certificate(domain, &base_domain)
.await?;
tracing::info!("Successfully generated Let's Encrypt certificate for domain: {}", domain); tracing::info!(
"Successfully generated Let's Encrypt certificate for domain: {}",
domain
);
Ok((cert_pem, key_pem)) Ok((cert_pem, key_pem))
} }
/// Renew certificate by ID (used for manual renewal) /// Renew certificate by ID (used for manual renewal)
pub async fn renew_certificate_by_id(&self, cert_id: Uuid) -> anyhow::Result<(String, String)> { pub async fn renew_certificate_by_id(&self, cert_id: Uuid) -> anyhow::Result<(String, String)> {
let db = self.db.as_ref() let db = self
.db
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Database connection not available"))?; .ok_or_else(|| anyhow::anyhow!("Database connection not available"))?;
// Get the certificate from database // Get the certificate from database
let cert_repo = crate::database::repository::CertificateRepository::new(db.clone()); let cert_repo = crate::database::repository::CertificateRepository::new(db.clone());
let certificate = cert_repo.find_by_id(cert_id) let certificate = cert_repo
.find_by_id(cert_id)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("Certificate not found"))?; .ok_or_else(|| anyhow::anyhow!("Certificate not found"))?;
tracing::info!("Renewing certificate '{}' for domain: {}", certificate.name, certificate.domain); tracing::info!(
"Renewing certificate '{}' for domain: {}",
certificate.name,
certificate.domain
);
match certificate.cert_type.as_str() { match certificate.cert_type.as_str() {
"letsencrypt" => { "letsencrypt" => {
@@ -175,27 +198,33 @@ impl CertificateService {
let providers = dns_repo.find_active_by_type("cloudflare").await?; let providers = dns_repo.find_active_by_type("cloudflare").await?;
if providers.is_empty() { if providers.is_empty() {
return Err(anyhow::anyhow!("No active Cloudflare DNS provider found for Let's Encrypt renewal")); return Err(anyhow::anyhow!(
"No active Cloudflare DNS provider found for Let's Encrypt renewal"
));
} }
let dns_provider = &providers[0]; let dns_provider = &providers[0];
let acme_email = "admin@example.com"; // TODO: Store this with certificate let acme_email = "admin@example.com"; // TODO: Store this with certificate
// Generate new certificate // Generate new certificate
let (cert_pem, key_pem) = self.generate_letsencrypt_certificate( let (cert_pem, key_pem) = self
&certificate.domain, .generate_letsencrypt_certificate(
dns_provider.id, &certificate.domain,
acme_email, dns_provider.id,
false, // Production acme_email,
).await?; false, // Production
)
.await?;
// Update in database // Update in database
cert_repo.update_certificate_data( cert_repo
cert_id, .update_certificate_data(
&cert_pem, cert_id,
&key_pem, &cert_pem,
chrono::Utc::now() + chrono::Duration::days(90), &key_pem,
).await?; chrono::Utc::now() + chrono::Duration::days(90),
)
.await?;
Ok((cert_pem, key_pem)) Ok((cert_pem, key_pem))
} }
@@ -204,18 +233,20 @@ impl CertificateService {
let (cert_pem, key_pem) = self.generate_self_signed(&certificate.domain).await?; let (cert_pem, key_pem) = self.generate_self_signed(&certificate.domain).await?;
// Update in database // Update in database
cert_repo.update_certificate_data( cert_repo
cert_id, .update_certificate_data(
&cert_pem, cert_id,
&key_pem, &cert_pem,
chrono::Utc::now() + chrono::Duration::days(365), &key_pem,
).await?; chrono::Utc::now() + chrono::Duration::days(365),
)
.await?;
Ok((cert_pem, key_pem)) Ok((cert_pem, key_pem))
} }
_ => { _ => Err(anyhow::anyhow!(
Err(anyhow::anyhow!("Cannot renew imported certificates automatically")) "Cannot renew imported certificates automatically"
} )),
} }
} }

View File

@@ -4,7 +4,7 @@ use uuid::Uuid;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum SyncEvent { pub enum SyncEvent {
InboundChanged(Uuid), // server_id InboundChanged(Uuid), // server_id
UserAccessChanged(Uuid), // server_id UserAccessChanged(Uuid), // server_id
} }

View File

@@ -1,13 +1,13 @@
pub mod xray;
pub mod acme; pub mod acme;
pub mod certificates; pub mod certificates;
pub mod events; pub mod events;
pub mod tasks; pub mod tasks;
pub mod uri_generator;
pub mod telegram; pub mod telegram;
pub mod uri_generator;
pub mod xray;
pub use xray::XrayService;
pub use tasks::TaskScheduler;
pub use uri_generator::UriGeneratorService;
pub use certificates::CertificateService; pub use certificates::CertificateService;
pub use tasks::TaskScheduler;
pub use telegram::TelegramService; pub use telegram::TelegramService;
pub use uri_generator::UriGeneratorService;
pub use xray::XrayService;

View File

@@ -1,18 +1,21 @@
use anyhow::Result;
use tokio_cron_scheduler::{JobScheduler, Job};
use tracing::{info, error, warn, debug};
use crate::database::DatabaseManager;
use crate::database::repository::{ServerRepository, ServerInboundRepository, InboundTemplateRepository, InboundUsersRepository, CertificateRepository, UserRepository};
use crate::database::entities::inbound_users; use crate::database::entities::inbound_users;
use crate::services::XrayService; use crate::database::repository::{
CertificateRepository, InboundTemplateRepository, InboundUsersRepository,
ServerInboundRepository, ServerRepository, UserRepository,
};
use crate::database::DatabaseManager;
use crate::services::events::SyncEvent; use crate::services::events::SyncEvent;
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, RelationTrait, JoinType}; use crate::services::XrayService;
use uuid::Uuid; use anyhow::Result;
use chrono::{DateTime, Utc};
use sea_orm::{ColumnTrait, EntityTrait, JoinType, QueryFilter, RelationTrait};
use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use chrono::{DateTime, Utc}; use tokio_cron_scheduler::{Job, JobScheduler};
use serde::{Serialize, Deserialize}; use tracing::{debug, error, info, warn};
use uuid::Uuid;
pub struct TaskScheduler { pub struct TaskScheduler {
scheduler: JobScheduler, scheduler: JobScheduler,
@@ -47,7 +50,10 @@ impl TaskScheduler {
pub async fn new() -> Result<Self> { pub async fn new() -> Result<Self> {
let scheduler = JobScheduler::new().await?; let scheduler = JobScheduler::new().await?;
let task_status = Arc::new(RwLock::new(HashMap::new())); let task_status = Arc::new(RwLock::new(HashMap::new()));
Ok(Self { scheduler, task_status }) Ok(Self {
scheduler,
task_status,
})
} }
/// Get current status of all tasks /// Get current status of all tasks
@@ -56,15 +62,20 @@ impl TaskScheduler {
} }
/// Start event-driven sync handler /// Start event-driven sync handler
pub async fn start_event_handler(db: DatabaseManager, mut event_receiver: tokio::sync::broadcast::Receiver<SyncEvent>) { pub async fn start_event_handler(
db: DatabaseManager,
mut event_receiver: tokio::sync::broadcast::Receiver<SyncEvent>,
) {
let xray_service = XrayService::new(); let xray_service = XrayService::new();
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(event) = event_receiver.recv().await { while let Ok(event) = event_receiver.recv().await {
match event { match event {
SyncEvent::InboundChanged(server_id) | SyncEvent::UserAccessChanged(server_id) => { SyncEvent::InboundChanged(server_id)
if let Err(e) = sync_single_server_by_id(&xray_service, &db, server_id).await { | SyncEvent::UserAccessChanged(server_id) => {
if let Err(e) =
sync_single_server_by_id(&xray_service, &db, server_id).await
{
error!("Failed to sync server {} from event: {}", server_id, e); error!("Failed to sync server {} from event: {}", server_id, e);
} }
} }
@@ -74,23 +85,25 @@ impl TaskScheduler {
} }
pub async fn start(&mut self, db: DatabaseManager, xray_service: XrayService) -> Result<()> { pub async fn start(&mut self, db: DatabaseManager, xray_service: XrayService) -> Result<()> {
// Initialize task status // Initialize task status
{ {
let mut status = self.task_status.write().unwrap(); let mut status = self.task_status.write().unwrap();
status.insert("xray_sync".to_string(), TaskStatus { status.insert(
name: "Xray Synchronization".to_string(), "xray_sync".to_string(),
description: "Synchronizes database state with xray servers".to_string(), TaskStatus {
schedule: "0 * * * * * (every minute)".to_string(), name: "Xray Synchronization".to_string(),
status: TaskState::Idle, description: "Synchronizes database state with xray servers".to_string(),
last_run: None, schedule: "0 * * * * * (every minute)".to_string(),
next_run: Some(Utc::now() + chrono::Duration::minutes(1)), status: TaskState::Idle,
total_runs: 0, last_run: None,
success_count: 0, next_run: Some(Utc::now() + chrono::Duration::minutes(1)),
error_count: 0, total_runs: 0,
last_error: None, success_count: 0,
last_duration_ms: None, error_count: 0,
}); last_error: None,
last_duration_ms: None,
},
);
} }
// Run initial sync in background to avoid blocking startup // Run initial sync in background to avoid blocking startup
@@ -123,7 +136,7 @@ impl TaskScheduler {
task.last_error = None; task.last_error = None;
} }
info!("Initial xray sync completed successfully in {}ms", duration); info!("Initial xray sync completed successfully in {}ms", duration);
}, }
Err(e) => { Err(e) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64; let duration = (Utc::now() - start_time).num_milliseconds() as u64;
let mut status = task_status_initial.write().unwrap(); let mut status = task_status_initial.write().unwrap();
@@ -172,7 +185,7 @@ impl TaskScheduler {
task.last_duration_ms = Some(duration); task.last_duration_ms = Some(duration);
task.last_error = None; task.last_error = None;
} }
}, }
Err(e) => { Err(e) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64; let duration = (Utc::now() - start_time).num_milliseconds() as u64;
let mut status = task_status.write().unwrap(); let mut status = task_status.write().unwrap();
@@ -197,19 +210,23 @@ impl TaskScheduler {
// Initialize certificate renewal task status // Initialize certificate renewal task status
{ {
let mut status = self.task_status.write().unwrap(); let mut status = self.task_status.write().unwrap();
status.insert("cert_renewal".to_string(), TaskStatus { status.insert(
name: "Certificate Renewal".to_string(), "cert_renewal".to_string(),
description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(), TaskStatus {
schedule: "0 0 2 * * * (daily at 2 AM)".to_string(), name: "Certificate Renewal".to_string(),
status: TaskState::Idle, description: "Renews Let's Encrypt certificates that expire within 15 days"
last_run: None, .to_string(),
next_run: Some(Utc::now() + chrono::Duration::days(1)), schedule: "0 0 2 * * * (daily at 2 AM)".to_string(),
total_runs: 0, status: TaskState::Idle,
success_count: 0, last_run: None,
error_count: 0, next_run: Some(Utc::now() + chrono::Duration::days(1)),
last_error: None, total_runs: 0,
last_duration_ms: None, success_count: 0,
}); error_count: 0,
last_error: None,
last_duration_ms: None,
},
);
} }
let cert_renewal_job = Job::new_async("0 0 2 * * *", move |_uuid, _l| { let cert_renewal_job = Job::new_async("0 0 2 * * *", move |_uuid, _l| {
@@ -239,7 +256,7 @@ impl TaskScheduler {
task.last_duration_ms = Some(duration); task.last_duration_ms = Some(duration);
task.last_error = None; task.last_error = None;
} }
}, }
Err(e) => { Err(e) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64; let duration = (Utc::now() - start_time).num_milliseconds() as u64;
let mut status = task_status.write().unwrap(); let mut status = task_status.write().unwrap();
@@ -281,7 +298,12 @@ impl TaskScheduler {
} }
} }
fn update_task_status_with_error(&self, task_id: &str, error: String, duration_ms: Option<u64>) { fn update_task_status_with_error(
&self,
task_id: &str,
error: String,
duration_ms: Option<u64>,
) {
let mut status = self.task_status.write().unwrap(); let mut status = self.task_status.write().unwrap();
if let Some(task) = status.get_mut(task_id) { if let Some(task) = status.get_mut(task_id) {
task.status = TaskState::Error; task.status = TaskState::Error;
@@ -301,7 +323,6 @@ impl TaskScheduler {
/// Synchronize xray server state with database state /// Synchronize xray server state with database state
async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Result<()> { async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Result<()> {
let server_repo = ServerRepository::new(db.connection().clone()); let server_repo = ServerRepository::new(db.connection().clone());
let inbound_repo = ServerInboundRepository::new(db.connection().clone()); let inbound_repo = ServerInboundRepository::new(db.connection().clone());
let template_repo = InboundTemplateRepository::new(db.connection().clone()); let template_repo = InboundTemplateRepository::new(db.connection().clone());
@@ -315,17 +336,18 @@ async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Resu
} }
}; };
for server in servers { for server in servers {
let endpoint = server.get_grpc_endpoint(); let endpoint = server.get_grpc_endpoint();
// Test connection first // Test connection first
match xray_service.test_connection(server.id, &endpoint).await { match xray_service.test_connection(server.id, &endpoint).await {
Ok(false) => { Ok(false) => {
warn!("Cannot connect to server {} at {}, skipping", server.name, endpoint); warn!(
"Cannot connect to server {} at {}, skipping",
server.name, endpoint
);
continue; continue;
}, }
Err(e) => { Err(e) => {
error!("Error testing connection to server {}: {}", server.name, e); error!("Error testing connection to server {}: {}", server.name, e);
continue; continue;
@@ -334,22 +356,22 @@ async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Resu
} }
// Get desired inbounds from database // Get desired inbounds from database
let desired_inbounds = match get_desired_inbounds_from_db(&db, &server, &inbound_repo, &template_repo).await { let desired_inbounds =
Ok(inbounds) => inbounds, match get_desired_inbounds_from_db(&db, &server, &inbound_repo, &template_repo).await {
Err(e) => { Ok(inbounds) => inbounds,
error!("Failed to get desired inbounds for server {}: {}", server.name, e); Err(e) => {
continue; error!(
} "Failed to get desired inbounds for server {}: {}",
}; server.name, e
);
continue;
}
};
// Synchronize inbounds // Synchronize inbounds
if let Err(e) = sync_server_inbounds( if let Err(e) =
&xray_service, sync_server_inbounds(&xray_service, server.id, &endpoint, &desired_inbounds).await
server.id, {
&endpoint,
&desired_inbounds
).await {
error!("Failed to sync inbounds for server {}: {}", server.name, e); error!("Failed to sync inbounds for server {}: {}", server.name, e);
} }
} }
@@ -357,7 +379,6 @@ async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Resu
Ok(()) Ok(())
} }
/// Get desired inbounds configuration from database /// Get desired inbounds configuration from database
async fn get_desired_inbounds_from_db( async fn get_desired_inbounds_from_db(
db: &DatabaseManager, db: &DatabaseManager,
@@ -365,7 +386,6 @@ async fn get_desired_inbounds_from_db(
inbound_repo: &ServerInboundRepository, inbound_repo: &ServerInboundRepository,
template_repo: &InboundTemplateRepository, template_repo: &InboundTemplateRepository,
) -> Result<HashMap<String, DesiredInbound>> { ) -> Result<HashMap<String, DesiredInbound>> {
// Get all inbounds for this server // Get all inbounds for this server
let inbounds = inbound_repo.find_by_server_id(server.id).await?; let inbounds = inbound_repo.find_by_server_id(server.id).await?;
let mut desired_inbounds = HashMap::new(); let mut desired_inbounds = HashMap::new();
@@ -375,7 +395,10 @@ async fn get_desired_inbounds_from_db(
let template = match template_repo.find_by_id(inbound.template_id).await? { let template = match template_repo.find_by_id(inbound.template_id).await? {
Some(template) => template, Some(template) => template,
None => { None => {
warn!("Template {} not found for inbound {}, skipping", inbound.template_id, inbound.tag); warn!(
"Template {} not found for inbound {}, skipping",
inbound.template_id, inbound.tag
);
continue; continue;
} }
}; };
@@ -383,7 +406,6 @@ async fn get_desired_inbounds_from_db(
// Get users for this inbound // Get users for this inbound
let users = get_users_for_inbound(db, inbound.id).await?; let users = get_users_for_inbound(db, inbound.id).await?;
// Get port from template or override // Get port from template or override
let port = inbound.port_override.unwrap_or(template.default_port); let port = inbound.port_override.unwrap_or(template.default_port);
@@ -391,12 +413,20 @@ async fn get_desired_inbounds_from_db(
let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id { let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id {
match load_certificate_from_db(db, inbound.certificate_id).await { match load_certificate_from_db(db, inbound.certificate_id).await {
Ok((cert, key)) => { Ok((cert, key)) => {
info!("Loaded certificate {} for inbound {}, has_cert={}, has_key={}", info!(
cert_id, inbound.tag, cert.is_some(), key.is_some()); "Loaded certificate {} for inbound {}, has_cert={}, has_key={}",
cert_id,
inbound.tag,
cert.is_some(),
key.is_some()
);
(cert, key) (cert, key)
}, }
Err(e) => { Err(e) => {
warn!("Failed to load certificate {} for inbound {}: {}", cert_id, inbound.tag, e); warn!(
"Failed to load certificate {} for inbound {}: {}",
cert_id, inbound.tag, e
);
(None, None) (None, None)
} }
} }
@@ -426,7 +456,9 @@ async fn get_desired_inbounds_from_db(
async fn get_users_for_inbound(db: &DatabaseManager, inbound_id: Uuid) -> Result<Vec<XrayUser>> { async fn get_users_for_inbound(db: &DatabaseManager, inbound_id: Uuid) -> Result<Vec<XrayUser>> {
let inbound_users_repo = InboundUsersRepository::new(db.connection().clone()); let inbound_users_repo = InboundUsersRepository::new(db.connection().clone());
let inbound_users = inbound_users_repo.find_active_by_inbound_id(inbound_id).await?; let inbound_users = inbound_users_repo
.find_active_by_inbound_id(inbound_id)
.await?;
// Get user details to generate emails // Get user details to generate emails
let user_repo = UserRepository::new(db.connection().clone()); let user_repo = UserRepository::new(db.connection().clone());
@@ -447,7 +479,10 @@ async fn get_users_for_inbound(db: &DatabaseManager, inbound_id: Uuid) -> Result
} }
/// Load certificate from database /// Load certificate from database
async fn load_certificate_from_db(db: &DatabaseManager, cert_id: Option<Uuid>) -> Result<(Option<String>, Option<String>)> { async fn load_certificate_from_db(
db: &DatabaseManager,
cert_id: Option<Uuid>,
) -> Result<(Option<String>, Option<String>)> {
let cert_id = match cert_id { let cert_id = match cert_id {
Some(id) => id, Some(id) => id,
None => return Ok((None, None)), None => return Ok((None, None)),
@@ -456,9 +491,7 @@ async fn load_certificate_from_db(db: &DatabaseManager, cert_id: Option<Uuid>) -
let cert_repo = CertificateRepository::new(db.connection().clone()); let cert_repo = CertificateRepository::new(db.connection().clone());
match cert_repo.find_by_id(cert_id).await? { match cert_repo.find_by_id(cert_id).await? {
Some(cert) => { Some(cert) => Ok((Some(cert.certificate_pem()), Some(cert.private_key_pem()))),
Ok((Some(cert.certificate_pem()), Some(cert.private_key_pem())))
},
None => { None => {
warn!("Certificate {} not found", cert_id); warn!("Certificate {} not found", cert_id);
Ok((None, None)) Ok((None, None))
@@ -474,7 +507,9 @@ async fn sync_server_inbounds(
desired_inbounds: &HashMap<String, DesiredInbound>, desired_inbounds: &HashMap<String, DesiredInbound>,
) -> Result<()> { ) -> Result<()> {
// Use optimized batch sync with single client // Use optimized batch sync with single client
xray_service.sync_server_inbounds_optimized(server_id, endpoint, desired_inbounds).await xray_service
.sync_server_inbounds_optimized(server_id, endpoint, desired_inbounds)
.await
} }
/// Sync a single server by ID (for event-driven sync) /// Sync a single server by ID (for event-driven sync)
@@ -499,7 +534,8 @@ async fn sync_single_server_by_id(
// For now, sync all servers (can add active/inactive flag later) // For now, sync all servers (can add active/inactive flag later)
// Get desired inbounds from database // Get desired inbounds from database
let desired_inbounds = get_desired_inbounds_from_db(db, &server, &inbound_repo, &template_repo).await?; let desired_inbounds =
get_desired_inbounds_from_db(db, &server, &inbound_repo, &template_repo).await?;
// Build endpoint // Build endpoint
let endpoint = server.get_grpc_endpoint(); let endpoint = server.get_grpc_endpoint();
@@ -510,7 +546,6 @@ async fn sync_single_server_by_id(
Ok(()) Ok(())
} }
/// Represents desired inbound configuration from database /// Represents desired inbound configuration from database
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DesiredInbound { pub struct DesiredInbound {
@@ -534,8 +569,8 @@ pub struct XrayUser {
/// Check and renew certificates that expire within 15 days /// Check and renew certificates that expire within 15 days
async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> { async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> {
use crate::services::certificates::CertificateService;
use crate::database::repository::DnsProviderRepository; use crate::database::repository::DnsProviderRepository;
use crate::services::certificates::CertificateService;
info!("Starting certificate renewal check..."); info!("Starting certificate renewal check...");
@@ -583,20 +618,26 @@ async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> {
let acme_email = "admin@example.com"; // TODO: Store this with certificate let acme_email = "admin@example.com"; // TODO: Store this with certificate
// Attempt to renew the certificate // Attempt to renew the certificate
match cert_service.generate_letsencrypt_certificate( match cert_service
&cert.domain, .generate_letsencrypt_certificate(
dns_provider.id, &cert.domain,
acme_email, dns_provider.id,
false, // Use production Let's Encrypt acme_email,
).await { false, // Use production Let's Encrypt
)
.await
{
Ok((new_cert_pem, new_key_pem)) => { Ok((new_cert_pem, new_key_pem)) => {
// Update the certificate in database // Update the certificate in database
match cert_repo.update_certificate_data( match cert_repo
cert.id, .update_certificate_data(
&new_cert_pem, cert.id,
&new_key_pem, &new_cert_pem,
chrono::Utc::now() + chrono::Duration::days(90), // Let's Encrypt certs are valid for 90 days &new_key_pem,
).await { chrono::Utc::now() + chrono::Duration::days(90), // Let's Encrypt certs are valid for 90 days
)
.await
{
Ok(_) => { Ok(_) => {
info!("Successfully renewed certificate '{}'", cert.name); info!("Successfully renewed certificate '{}'", cert.name);
renewed_count += 1; renewed_count += 1;
@@ -608,7 +649,10 @@ async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> {
} }
} }
Err(e) => { Err(e) => {
error!("Failed to save renewed certificate '{}' to database: {}", cert.name, e); error!(
"Failed to save renewed certificate '{}' to database: {}",
cert.name, e
);
} }
} }
} }
@@ -650,7 +694,10 @@ async fn trigger_cert_renewal_sync(db: &DatabaseManager, cert_id: Uuid) -> Resul
// Trigger sync for each server // Trigger sync for each server
for server_id in server_ids { for server_id in server_ids {
info!("Triggering sync for server {} after certificate renewal", server_id); info!(
"Triggering sync for server {} after certificate renewal",
server_id
);
send_sync_event(SyncEvent::InboundChanged(server_id)); send_sync_event(SyncEvent::InboundChanged(server_id));
} }

View File

@@ -1,9 +1,9 @@
use teloxide::{Bot, prelude::*}; use teloxide::{prelude::*, Bot};
use tokio::sync::oneshot; use tokio::sync::oneshot;
use crate::database::DatabaseManager;
use crate::config::AppConfig;
use super::handlers::{self, Command}; use super::handlers::{self, Command};
use crate::config::AppConfig;
use crate::database::DatabaseManager;
/// Run the bot polling loop /// Run the bot polling loop
pub async fn run_polling( pub async fn run_polling(
@@ -20,16 +20,11 @@ pub async fn run_polling(
.branch( .branch(
dptree::entry() dptree::entry()
.filter_command::<Command>() .filter_command::<Command>()
.endpoint(handlers::handle_command) .endpoint(handlers::handle_command),
)
.branch(
dptree::endpoint(handlers::handle_message)
) )
.branch(dptree::endpoint(handlers::handle_message)),
) )
.branch( .branch(Update::filter_callback_query().endpoint(handlers::handle_callback_query));
Update::filter_callback_query()
.endpoint(handlers::handle_callback_query)
);
let mut dispatcher = Dispatcher::builder(bot.clone(), handler) let mut dispatcher = Dispatcher::builder(bot.clone(), handler)
.dependencies(dptree::deps![db, app_config]) .dependencies(dptree::deps![db, app_config])

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,15 @@
pub mod admin; pub mod admin;
pub mod user;
pub mod types; pub mod types;
pub mod user;
// Re-export main handler functions for easier access // Re-export main handler functions for easier access
pub use admin::*; pub use admin::*;
pub use user::*;
pub use types::*; pub use types::*;
pub use user::*;
use teloxide::{prelude::*, types::CallbackQuery};
use crate::database::DatabaseManager;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::database::DatabaseManager;
use teloxide::{prelude::*, types::CallbackQuery};
/// Handle bot commands /// Handle bot commands
pub async fn handle_command( pub async fn handle_command(
@@ -30,16 +30,23 @@ pub async fn handle_command(
} }
Command::Requests => { Command::Requests => {
// Check if user is admin // Check if user is admin
if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { if user_repo
.is_telegram_id_admin(telegram_id)
.await
.unwrap_or(false)
{
// Create a fake callback query for admin requests // Create a fake callback query for admin requests
// This is a workaround since the admin_requests function expects a callback query // This is a workaround since the admin_requests function expects a callback query
// In practice, we could refactor this to not need a callback query // In practice, we could refactor this to not need a callback query
tracing::info!("Admin {} requested to view requests", telegram_id); tracing::info!("Admin {} requested to view requests", telegram_id);
let message = "📋 Use the inline keyboard to view recent requests."; let message = "📋 Use the inline keyboard to view recent requests.";
let keyboard = teloxide::types::InlineKeyboardMarkup::new(vec![ let keyboard = teloxide::types::InlineKeyboardMarkup::new(vec![vec![
vec![teloxide::types::InlineKeyboardButton::callback("📋 Recent Requests", "admin_requests")], teloxide::types::InlineKeyboardButton::callback(
]); "📋 Recent Requests",
"admin_requests",
),
]]);
bot.send_message(chat_id, message) bot.send_message(chat_id, message)
.reply_markup(keyboard) .reply_markup(keyboard)
@@ -47,27 +54,38 @@ pub async fn handle_command(
} else { } else {
let lang = get_user_language(from); let lang = get_user_language(from);
let l10n = super::localization::LocalizationService::new(); let l10n = super::localization::LocalizationService::new();
bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?; bot.send_message(chat_id, l10n.get(lang, "unauthorized"))
.await?;
} }
} }
Command::Stats => { Command::Stats => {
// Check if user is admin // Check if user is admin
if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { if user_repo
.is_telegram_id_admin(telegram_id)
.await
.unwrap_or(false)
{
handle_stats(bot, chat_id, &db).await?; handle_stats(bot, chat_id, &db).await?;
} else { } else {
let lang = get_user_language(from); let lang = get_user_language(from);
let l10n = super::localization::LocalizationService::new(); let l10n = super::localization::LocalizationService::new();
bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?; bot.send_message(chat_id, l10n.get(lang, "unauthorized"))
.await?;
} }
} }
Command::Broadcast { message } => { Command::Broadcast { message } => {
// Check if user is admin // Check if user is admin
if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { if user_repo
.is_telegram_id_admin(telegram_id)
.await
.unwrap_or(false)
{
handle_broadcast(bot, chat_id, message, &user_repo).await?; handle_broadcast(bot, chat_id, message, &user_repo).await?;
} else { } else {
let lang = get_user_language(from); let lang = get_user_language(from);
let l10n = super::localization::LocalizationService::new(); let l10n = super::localization::LocalizationService::new();
bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?; bot.send_message(chat_id, l10n.get(lang, "unauthorized"))
.await?;
} }
} }
} }
@@ -100,66 +118,118 @@ pub async fn handle_callback_query(
db: DatabaseManager, db: DatabaseManager,
app_config: AppConfig, app_config: AppConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(data) = &q.data { // Wrap all callback handling in a try-catch to send main menu on any error
if let Some(callback_data) = CallbackData::parse(data) { let result = async {
match callback_data { if let Some(data) = &q.data {
CallbackData::RequestAccess => { if let Some(callback_data) = CallbackData::parse(data) {
handle_request_access(bot, &q, &db).await?; match callback_data {
} CallbackData::RequestAccess => {
CallbackData::MyConfigs => { handle_request_access(bot.clone(), &q, &db).await?;
handle_my_configs_edit(bot, &q, &db).await?; }
} CallbackData::MyConfigs => {
CallbackData::SubscriptionLink => { handle_my_configs_edit(bot.clone(), &q, &db).await?;
handle_subscription_link(bot, &q, &db, &app_config).await?; }
} CallbackData::SubscriptionLink => {
CallbackData::Support => { handle_subscription_link(bot.clone(), &q, &db, &app_config).await?;
handle_support(bot, &q).await?; }
} CallbackData::Support => {
CallbackData::AdminRequests => { handle_support(bot.clone(), &q).await?;
handle_admin_requests_edit(bot, &q, &db).await?; }
} CallbackData::AdminRequests => {
CallbackData::ApproveRequest(request_id) => { handle_admin_requests_edit(bot.clone(), &q, &db).await?;
handle_approve_request(bot, &q, &request_id, &db).await?; }
} CallbackData::RequestList(page) => {
CallbackData::DeclineRequest(request_id) => { handle_request_list(bot.clone(), &q, &db, page).await?;
handle_decline_request(bot, &q, &request_id, &db).await?; }
} CallbackData::ApproveRequest(request_id) => {
CallbackData::ViewRequest(request_id) => { handle_approve_request(bot.clone(), &q, &request_id, &db).await?;
handle_view_request(bot, &q, &request_id, &db).await?; }
} CallbackData::DeclineRequest(request_id) => {
CallbackData::ShowServerConfigs(encoded_server_name) => { handle_decline_request(bot.clone(), &q, &request_id, &db).await?;
handle_show_server_configs(bot, &q, &encoded_server_name, &db).await?; }
} CallbackData::ViewRequest(request_id) => {
CallbackData::SelectServerAccess(request_id) => { handle_view_request(bot.clone(), &q, &request_id, &db).await?;
// The request_id is now the full UUID from the mapping }
let short_id = types::generate_short_request_id(&request_id); CallbackData::ShowServerConfigs(encoded_server_name) => {
handle_select_server_access(bot, &q, &short_id, &db).await?; handle_show_server_configs(bot.clone(), &q, &encoded_server_name, &db).await?;
} }
CallbackData::ToggleServer(request_id, server_id) => { CallbackData::SelectServerAccess(request_id) => {
// Both IDs are now full UUIDs from the mapping // The request_id is now the full UUID from the mapping
let short_request_id = types::generate_short_request_id(&request_id); let short_id = types::generate_short_request_id(&request_id);
let short_server_id = types::generate_short_server_id(&server_id); handle_select_server_access(bot.clone(), &q, &short_id, &db).await?;
handle_toggle_server(bot, &q, &short_request_id, &short_server_id, &db).await?; }
} CallbackData::ToggleServer(request_id, server_id) => {
CallbackData::ApplyServerAccess(request_id) => { // Both IDs are now full UUIDs from the mapping
// The request_id is now the full UUID from the mapping let short_request_id = types::generate_short_request_id(&request_id);
let short_id = types::generate_short_request_id(&request_id); let short_server_id = types::generate_short_server_id(&server_id);
handle_apply_server_access(bot, &q, &short_id, &db).await?; handle_toggle_server(bot.clone(), &q, &short_request_id, &short_server_id, &db).await?;
} }
CallbackData::Back => { CallbackData::ApplyServerAccess(request_id) => {
// Back to main menu - edit the existing message // The request_id is now the full UUID from the mapping
handle_start_edit(bot, &q, &db).await?; let short_id = types::generate_short_request_id(&request_id);
} handle_apply_server_access(bot.clone(), &q, &short_id, &db).await?;
CallbackData::BackToConfigs => { }
handle_my_configs_edit(bot, &q, &db).await?; CallbackData::Back => {
} // Back to main menu - edit the existing message
CallbackData::BackToRequests => { handle_start_edit(bot.clone(), &q, &db).await?;
handle_admin_requests_edit(bot, &q, &db).await?; }
CallbackData::BackToConfigs => {
handle_my_configs_edit(bot.clone(), &q, &db).await?;
}
CallbackData::BackToRequests => {
handle_admin_requests_edit(bot.clone(), &q, &db).await?;
}
CallbackData::ManageUsers => {
handle_manage_users(bot.clone(), &q, &db).await?;
}
CallbackData::UserList(page) => {
handle_user_list(bot.clone(), &q, &db, page).await?;
}
CallbackData::UserDetails(user_id) => {
handle_user_details(bot.clone(), &q, &db, &user_id).await?;
}
CallbackData::UserManageAccess(user_id) => {
handle_user_manage_access(bot.clone(), &q, &db, &user_id).await?;
}
CallbackData::UserToggleServer(user_id, server_id) => {
handle_user_toggle_server(bot.clone(), &q, &db, &user_id, &server_id).await?;
}
CallbackData::UserApplyAccess(user_id) => {
handle_user_apply_access(bot.clone(), &q, &db, &user_id).await?;
}
CallbackData::BackToUsers(page) => {
handle_user_list(bot.clone(), &q, &db, page).await?;
}
CallbackData::BackToMenu => {
handle_start_edit(bot.clone(), &q, &db).await?;
}
} }
} else {
tracing::warn!("Unknown callback data: {}", data);
return Err("Invalid callback data".into());
}
}
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}.await;
// If any error occurred, send main menu and answer callback query
if let Err(e) = result {
tracing::warn!("Error handling callback query '{}': {}", q.data.as_deref().unwrap_or("None"), e);
// Answer the callback query first to remove loading state
let _ = bot.answer_callback_query(q.id.clone()).await;
// Try to send main menu
if let Some(message) = q.message {
let chat_id = message.chat().id;
let from = &q.from;
let telegram_id = from.id.0 as i64;
let user_repo = crate::database::repository::UserRepository::new(db.connection());
// Try to send main menu - if this fails too, just log it
if let Err(menu_error) = handle_start(bot, chat_id, telegram_id, from, &user_repo, &db).await {
tracing::error!("Failed to send main menu after callback error: {}", menu_error);
} }
} else {
tracing::warn!("Unknown callback data: {}", data);
bot.answer_callback_query(q.id.clone()).await?;
} }
} }

View File

@@ -1,7 +1,7 @@
use teloxide::utils::command::BotCommands;
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, User}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, User};
use teloxide::utils::command::BotCommands;
use super::super::localization::{LocalizationService, Language}; use super::super::localization::{Language, LocalizationService};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
@@ -27,16 +27,25 @@ pub enum CallbackData {
SubscriptionLink, SubscriptionLink,
Support, Support,
AdminRequests, AdminRequests,
ApproveRequest(String), // request_id RequestList(u32), // page number
DeclineRequest(String), // request_id ApproveRequest(String), // request_id
ViewRequest(String), // request_id DeclineRequest(String), // request_id
ViewRequest(String), // request_id
ShowServerConfigs(String), // server_name encoded ShowServerConfigs(String), // server_name encoded
Back, Back,
BackToConfigs, // Back to configs list from server view BackToConfigs, // Back to configs list from server view
BackToRequests, // Back to requests list from request view BackToRequests, // Back to requests list from request view
SelectServerAccess(String), // request_id - show server selection after approval SelectServerAccess(String), // request_id - show server selection after approval
ToggleServer(String, String), // request_id, server_id - toggle server selection ToggleServer(String, String), // request_id, server_id - toggle server selection
ApplyServerAccess(String), // request_id - apply selected servers ApplyServerAccess(String), // request_id - apply selected servers
ManageUsers,
UserList(u32), // page number
UserDetails(String), // user_id
UserManageAccess(String), // user_id
UserToggleServer(String, String), // user_id, server_id
UserApplyAccess(String), // user_id
BackToUsers(u32), // page number
BackToMenu,
} }
impl CallbackData { impl CallbackData {
@@ -47,9 +56,11 @@ impl CallbackData {
"subscription_link" => Some(CallbackData::SubscriptionLink), "subscription_link" => Some(CallbackData::SubscriptionLink),
"support" => Some(CallbackData::Support), "support" => Some(CallbackData::Support),
"admin_requests" => Some(CallbackData::AdminRequests), "admin_requests" => Some(CallbackData::AdminRequests),
"manage_users" => Some(CallbackData::ManageUsers),
"back" => Some(CallbackData::Back), "back" => Some(CallbackData::Back),
"back_to_configs" => Some(CallbackData::BackToConfigs), "back_to_configs" => Some(CallbackData::BackToConfigs),
"back_to_requests" => Some(CallbackData::BackToRequests), "back_to_requests" => Some(CallbackData::BackToRequests),
"back_to_menu" => Some(CallbackData::BackToMenu),
_ => { _ => {
if let Some(id) = data.strip_prefix("approve:") { if let Some(id) = data.strip_prefix("approve:") {
Some(CallbackData::ApproveRequest(id.to_string())) Some(CallbackData::ApproveRequest(id.to_string()))
@@ -64,7 +75,9 @@ impl CallbackData {
} else if let Some(rest) = data.strip_prefix("t:") { } else if let Some(rest) = data.strip_prefix("t:") {
let parts: Vec<&str> = rest.split(':').collect(); let parts: Vec<&str> = rest.split(':').collect();
if parts.len() == 2 { if parts.len() == 2 {
if let (Some(request_id), Some(server_id)) = (get_full_request_id(parts[0]), get_full_server_id(parts[1])) { if let (Some(request_id), Some(server_id)) =
(get_full_request_id(parts[0]), get_full_server_id(parts[1]))
{
Some(CallbackData::ToggleServer(request_id, server_id)) Some(CallbackData::ToggleServer(request_id, server_id))
} else { } else {
None None
@@ -74,6 +87,31 @@ impl CallbackData {
} }
} else if let Some(short_id) = data.strip_prefix("a:") { } else if let Some(short_id) = data.strip_prefix("a:") {
get_full_request_id(short_id).map(CallbackData::ApplyServerAccess) get_full_request_id(short_id).map(CallbackData::ApplyServerAccess)
} else if let Some(page_str) = data.strip_prefix("request_list:") {
page_str.parse::<u32>().ok().map(CallbackData::RequestList)
} else if let Some(page_str) = data.strip_prefix("user_list:") {
page_str.parse::<u32>().ok().map(CallbackData::UserList)
} else if let Some(short_user_id) = data.strip_prefix("user_details:") {
get_full_user_id(short_user_id).map(CallbackData::UserDetails)
} else if let Some(short_user_id) = data.strip_prefix("user_manage:") {
get_full_user_id(short_user_id).map(CallbackData::UserManageAccess)
} else if let Some(rest) = data.strip_prefix("user_toggle:") {
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() == 2 {
if let (Some(user_id), Some(server_id)) =
(get_full_user_id(parts[0]), get_full_server_id(parts[1]))
{
Some(CallbackData::UserToggleServer(user_id, server_id))
} else {
None
}
} else {
None
}
} else if let Some(short_user_id) = data.strip_prefix("user_apply:") {
get_full_user_id(short_user_id).map(CallbackData::UserApplyAccess)
} else if let Some(page_str) = data.strip_prefix("back_users:") {
page_str.parse::<u32>().ok().map(CallbackData::BackToUsers)
} else { } else {
None None
} }
@@ -93,6 +131,10 @@ static REQUEST_COUNTER: OnceLock<Arc<Mutex<u32>>> = OnceLock::new();
static SERVER_ID_MAP: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new(); static SERVER_ID_MAP: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new();
static SERVER_COUNTER: OnceLock<Arc<Mutex<u32>>> = OnceLock::new(); static SERVER_COUNTER: OnceLock<Arc<Mutex<u32>>> = OnceLock::new();
// Global storage for user ID mappings (short ID -> full UUID)
static USER_ID_MAP: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new();
static USER_COUNTER: OnceLock<Arc<Mutex<u32>>> = OnceLock::new();
pub fn get_selected_servers() -> &'static Arc<Mutex<HashMap<String, Vec<String>>>> { pub fn get_selected_servers() -> &'static Arc<Mutex<HashMap<String, Vec<String>>>> {
SELECTED_SERVERS.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) SELECTED_SERVERS.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
} }
@@ -113,6 +155,14 @@ pub fn get_server_counter() -> &'static Arc<Mutex<u32>> {
SERVER_COUNTER.get_or_init(|| Arc::new(Mutex::new(0))) SERVER_COUNTER.get_or_init(|| Arc::new(Mutex::new(0)))
} }
pub fn get_user_id_map() -> &'static Arc<Mutex<HashMap<String, String>>> {
USER_ID_MAP.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
pub fn get_user_counter() -> &'static Arc<Mutex<u32>> {
USER_COUNTER.get_or_init(|| Arc::new(Mutex::new(0)))
}
/// Generate a short ID for a request UUID and store the mapping /// Generate a short ID for a request UUID and store the mapping
pub fn generate_short_request_id(request_uuid: &str) -> String { pub fn generate_short_request_id(request_uuid: &str) -> String {
let mut counter = get_request_counter().lock().unwrap(); let mut counter = get_request_counter().lock().unwrap();
@@ -165,6 +215,32 @@ pub fn get_full_server_id(short_id: &str) -> Option<String> {
map.get(short_id).cloned() map.get(short_id).cloned()
} }
/// Generate a short ID for a user UUID and store the mapping
pub fn generate_short_user_id(user_uuid: &str) -> String {
let mut counter = get_user_counter().lock().unwrap();
let mut map = get_user_id_map().lock().unwrap();
// Check if we already have a short ID for this UUID
for (short_id, uuid) in map.iter() {
if uuid == user_uuid {
return short_id.clone();
}
}
// Generate new short ID
*counter += 1;
let short_id = format!("u{}", counter);
map.insert(short_id.clone(), user_uuid.to_string());
short_id
}
/// Get full user UUID from short ID
pub fn get_full_user_id(short_id: &str) -> Option<String> {
let map = get_user_id_map().lock().unwrap();
map.get(short_id).cloned()
}
/// Helper function to get user language from Telegram user data /// Helper function to get user language from Telegram user data
pub fn get_user_language(user: &User) -> Language { pub fn get_user_language(user: &User) -> Language {
Language::from_telegram_code(user.language_code.as_deref()) Language::from_telegram_code(user.language_code.as_deref())
@@ -175,13 +251,29 @@ pub fn get_main_keyboard(is_admin: bool, lang: Language) -> InlineKeyboardMarkup
let l10n = LocalizationService::new(); let l10n = LocalizationService::new();
let mut keyboard = vec![ let mut keyboard = vec![
vec![InlineKeyboardButton::callback("🔗 Subscription Link", "subscription_link")], vec![InlineKeyboardButton::callback(
vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "my_configs"), "my_configs")], l10n.get(lang.clone(), "subscription_link"),
vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "support"), "support")], "subscription_link",
)],
vec![InlineKeyboardButton::callback(
l10n.get(lang.clone(), "my_configs"),
"my_configs",
)],
vec![InlineKeyboardButton::callback(
l10n.get(lang.clone(), "support"),
"support",
)],
]; ];
if is_admin { if is_admin {
keyboard.push(vec![InlineKeyboardButton::callback(l10n.get(lang, "user_requests"), "admin_requests")]); keyboard.push(vec![InlineKeyboardButton::callback(
l10n.get(lang.clone(), "user_requests"),
"admin_requests",
)]);
keyboard.push(vec![InlineKeyboardButton::callback(
l10n.get(lang, "manage_users"),
"manage_users",
)]);
} }
InlineKeyboardMarkup::new(keyboard) InlineKeyboardMarkup::new(keyboard)
@@ -191,9 +283,10 @@ pub fn get_main_keyboard(is_admin: bool, lang: Language) -> InlineKeyboardMarkup
pub fn get_new_user_keyboard(lang: Language) -> InlineKeyboardMarkup { pub fn get_new_user_keyboard(lang: Language) -> InlineKeyboardMarkup {
let l10n = LocalizationService::new(); let l10n = LocalizationService::new();
InlineKeyboardMarkup::new(vec![ InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback(
vec![InlineKeyboardButton::callback(l10n.get(lang, "get_vpn_access"), "request_access")], l10n.get(lang, "get_vpn_access"),
]) "request_access",
)]])
} }
/// Restore UUID from compact format (without dashes) /// Restore UUID from compact format (without dashes)

View File

@@ -1,11 +1,14 @@
use teloxide::{prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup}}; use base64::{engine::general_purpose, Engine};
use base64::{Engine, engine::general_purpose}; use teloxide::{
prelude::*,
types::{InlineKeyboardButton, InlineKeyboardMarkup},
};
use crate::database::DatabaseManager; use super::super::localization::{Language, LocalizationService};
use crate::database::repository::{UserRepository, UserRequestRepository}; use super::types::{get_main_keyboard, get_new_user_keyboard, get_user_language};
use crate::database::entities::user_request::{CreateUserRequestDto, RequestStatus}; use crate::database::entities::user_request::{CreateUserRequestDto, RequestStatus};
use super::super::localization::{LocalizationService, Language}; use crate::database::repository::{UserRepository, UserRequestRepository};
use super::types::{get_user_language, get_main_keyboard, get_new_user_keyboard}; use crate::database::DatabaseManager;
/// Handle start command and main menu /// Handle start command and main menu
pub async fn handle_start( pub async fn handle_start(
@@ -40,8 +43,9 @@ pub async fn handle_start_edit(
&user_repo, &user_repo,
db, db,
Some(regular_msg.id), Some(regular_msg.id),
Some(q.id.clone()) Some(q.id.clone()),
).await?; )
.await?;
} }
} }
@@ -66,23 +70,39 @@ async fn handle_start_impl(
match user_repo.get_by_telegram_id(telegram_id).await { match user_repo.get_by_telegram_id(telegram_id).await {
Ok(Some(user)) => { Ok(Some(user)) => {
// Check if user is admin // Check if user is admin
let is_admin = user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false); let is_admin = user_repo
.is_telegram_id_admin(telegram_id)
.await
.unwrap_or(false);
// Check if user has any pending requests // Check if user has any pending requests
let request_repo = UserRequestRepository::new(db.connection().clone()); let request_repo = UserRequestRepository::new(db.connection().clone());
// Check for existing requests // Check for existing requests
if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await { if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await {
if let Some(latest_request) = existing_requests.into_iter() if let Some(latest_request) = existing_requests
.filter(|r| r.status == "pending" || r.status == "approved" || r.status == "declined") .into_iter()
.max_by_key(|r| r.created_at) { .filter(|r| {
r.status == "pending" || r.status == "approved" || r.status == "declined"
})
.max_by_key(|r| r.created_at)
{
match latest_request.status.as_str() { match latest_request.status.as_str() {
"pending" => { "pending" => {
let message = l10n.format(lang.clone(), "request_pending", &[ let message = l10n.format(
("status", "⏳ pending"), lang.clone(),
("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) "request_pending",
]); &[
("status", "⏳ pending"),
(
"date",
&latest_request
.created_at
.format("%Y-%m-%d %H:%M UTC")
.to_string(),
),
],
);
let keyboard = get_new_user_keyboard(lang); let keyboard = get_new_user_keyboard(lang);
@@ -104,10 +124,20 @@ async fn handle_start_impl(
return Ok(()); return Ok(());
} }
"declined" => { "declined" => {
let message = l10n.format(lang.clone(), "request_pending", &[ let message = l10n.format(
("status", &l10n.get(lang.clone(), "request_declined_status")), lang.clone(),
("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) "request_pending",
]); &[
("status", &l10n.get(lang.clone(), "request_declined_status")),
(
"date",
&latest_request
.created_at
.format("%Y-%m-%d %H:%M UTC")
.to_string(),
),
],
);
let keyboard = get_new_user_keyboard(lang); let keyboard = get_new_user_keyboard(lang);
@@ -190,18 +220,24 @@ pub async fn handle_request_access(
let lang = get_user_language(from); let lang = get_user_language(from);
let l10n = LocalizationService::new(); let l10n = LocalizationService::new();
let telegram_id = from.id.0 as i64; let telegram_id = from.id.0 as i64;
let chat_id = q.message.as_ref().and_then(|m| { let chat_id = q
match m { .message
.as_ref()
.and_then(|m| match m {
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
_ => None, _ => None,
} })
}).ok_or("No chat ID")?; .ok_or("No chat ID")?;
let user_repo = UserRepository::new(db.connection()); let user_repo = UserRepository::new(db.connection());
let request_repo = UserRequestRepository::new(db.connection().clone()); let request_repo = UserRequestRepository::new(db.connection().clone());
// Check if user already exists // Check if user already exists
if let Some(_) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { if let Some(_) = user_repo
.get_by_telegram_id(telegram_id)
.await
.unwrap_or(None)
{
bot.answer_callback_query(q.id.clone()) bot.answer_callback_query(q.id.clone())
.text(l10n.get(lang, "already_approved")) .text(l10n.get(lang, "already_approved"))
.await?; .await?;
@@ -210,23 +246,35 @@ pub async fn handle_request_access(
// Check for existing requests // Check for existing requests
if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await { if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await {
if let Some(latest_request) = existing_requests.iter() if let Some(latest_request) = existing_requests
.iter()
.filter(|r| r.status == "pending") .filter(|r| r.status == "pending")
.max_by_key(|r| r.created_at) { .max_by_key(|r| r.created_at)
{
// Show pending status in the message instead of just an alert // Show pending status in the message instead of just an alert
let message = l10n.format(lang.clone(), "request_pending", &[ let message = l10n.format(
("status", "⏳ pending"), lang.clone(),
("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) "request_pending",
]); &[
("status", "⏳ pending"),
(
"date",
&latest_request
.created_at
.format("%Y-%m-%d %H:%M UTC")
.to_string(),
),
],
);
if let Some(message_ref) = &q.message { if let Some(message_ref) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message_ref { if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message_ref {
let _ = bot.edit_message_text(chat_id, msg.id, message) let _ = bot
.edit_message_text(chat_id, msg.id, message)
.parse_mode(teloxide::types::ParseMode::Html) .parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(InlineKeyboardMarkup::new(vec![ .reply_markup(InlineKeyboardMarkup::new(vec![vec![
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], InlineKeyboardButton::callback(l10n.get(lang, "back"), "back"),
])) ]]))
.await; .await;
} }
} }
@@ -236,8 +284,7 @@ pub async fn handle_request_access(
} }
// Check for declined requests - allow new request after decline // Check for declined requests - allow new request after decline
let _has_declined = existing_requests.iter() let _has_declined = existing_requests.iter().any(|r| r.status == "declined");
.any(|r| r.status == "declined");
} }
// Create new access request // Create new access request
@@ -255,10 +302,15 @@ pub async fn handle_request_access(
// Edit message to show success // Edit message to show success
if let Some(message) = &q.message { if let Some(message) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message { if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message {
let _ = bot.edit_message_text(chat_id, msg.id, l10n.get(lang.clone(), "request_submitted")) let _ = bot
.reply_markup(InlineKeyboardMarkup::new(vec![ .edit_message_text(
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], chat_id,
])) msg.id,
l10n.get(lang.clone(), "request_submitted"),
)
.reply_markup(InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback(l10n.get(lang, "back"), "back"),
]]))
.await; .await;
} }
} }
@@ -289,30 +341,44 @@ pub async fn handle_my_configs_edit(
let lang = get_user_language(from); let lang = get_user_language(from);
let l10n = LocalizationService::new(); let l10n = LocalizationService::new();
let telegram_id = from.id.0 as i64; let telegram_id = from.id.0 as i64;
let chat_id = q.message.as_ref().and_then(|m| { let chat_id = q
match m { .message
.as_ref()
.and_then(|m| match m {
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
_ => None, _ => None,
} })
}).ok_or("No chat ID")?; .ok_or("No chat ID")?;
let user_repo = UserRepository::new(db.connection()); let user_repo = UserRepository::new(db.connection());
let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone()); let inbound_users_repo =
crate::database::repository::InboundUsersRepository::new(db.connection().clone());
let uri_service = crate::services::UriGeneratorService::new(); let uri_service = crate::services::UriGeneratorService::new();
if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { if let Some(user) = user_repo
.get_by_telegram_id(telegram_id)
.await
.unwrap_or(None)
{
// Get all active inbound users for this user // Get all active inbound users for this user
let inbound_users = inbound_users_repo.find_by_user_id(user.id).await.unwrap_or_default(); let inbound_users = inbound_users_repo
.find_by_user_id(user.id)
.await
.unwrap_or_default();
if inbound_users.is_empty() { if inbound_users.is_empty() {
// Edit message to show no configs available // Edit message to show no configs available
if let Some(msg) = &q.message { if let Some(msg) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang.clone(), "no_configs_available")) bot.edit_message_text(
.reply_markup(InlineKeyboardMarkup::new(vec![ chat_id,
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], regular_msg.id,
])) l10n.get(lang.clone(), "no_configs_available"),
.await?; )
.reply_markup(InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback(l10n.get(lang, "back"), "back"),
]]))
.await?;
} }
} }
bot.answer_callback_query(q.id.clone()).await?; bot.answer_callback_query(q.id.clone()).await?;
@@ -327,7 +393,8 @@ pub async fn handle_my_configs_edit(
} }
// Group configurations by server name // Group configurations by server name
let mut servers: std::collections::HashMap<String, Vec<ConfigWithInbound>> = std::collections::HashMap::new(); let mut servers: std::collections::HashMap<String, Vec<ConfigWithInbound>> =
std::collections::HashMap::new();
for inbound_user in inbound_users { for inbound_user in inbound_users {
if !inbound_user.is_active { if !inbound_user.is_active {
@@ -335,7 +402,10 @@ pub async fn handle_my_configs_edit(
} }
// Get client config data for this specific inbound // Get client config data for this specific inbound
if let Ok(Some(config_data)) = inbound_users_repo.get_client_config_data(user.id, inbound_user.server_inbound_id).await { if let Ok(Some(config_data)) = inbound_users_repo
.get_client_config_data(user.id, inbound_user.server_inbound_id)
.await
{
match uri_service.generate_client_config(user.id, &config_data) { match uri_service.generate_client_config(user.id, &config_data) {
Ok(client_config) => { Ok(client_config) => {
let config_with_inbound = ConfigWithInbound { let config_with_inbound = ConfigWithInbound {
@@ -343,10 +413,11 @@ pub async fn handle_my_configs_edit(
server_inbound_id: inbound_user.server_inbound_id, server_inbound_id: inbound_user.server_inbound_id,
}; };
servers.entry(client_config.server_name.clone()) servers
.entry(client_config.server_name.clone())
.or_insert_with(Vec::new) .or_insert_with(Vec::new)
.push(config_with_inbound); .push(config_with_inbound);
}, }
Err(e) => { Err(e) => {
tracing::warn!("Failed to generate client config: {}", e); tracing::warn!("Failed to generate client config: {}", e);
continue; continue;
@@ -372,45 +443,69 @@ pub async fn handle_my_configs_edit(
let server_word = match lang { let server_word = match lang {
Language::Russian => { Language::Russian => {
if server_count == 1 { "сервер" } if server_count == 1 {
else if server_count < 5 { "сервера" } "сервер"
else { "серверов" } } else if server_count < 5 {
}, "сервера"
} else {
"серверов"
}
}
Language::English => { Language::English => {
if server_count == 1 { "server" } if server_count == 1 {
else { "servers" } "server"
} else {
"servers"
}
} }
}; };
let config_word = match lang { let config_word = match lang {
Language::Russian => { Language::Russian => {
if total_configs == 1 { "конфигурация" } if total_configs == 1 {
else if total_configs < 5 { "конфигурации" } "конфигурация"
else { "конфигураций" } } else if total_configs < 5 {
}, "конфигурации"
} else {
"конфигураций"
}
}
Language::English => { Language::English => {
if total_configs == 1 { "configuration" } if total_configs == 1 {
else { "configurations" } "configuration"
} else {
"configurations"
}
} }
}; };
let protocol_word = match lang { let protocol_word = match lang {
Language::Russian => { Language::Russian => {
if protocols.len() == 1 { "протокол" } if protocols.len() == 1 {
else if protocols.len() < 5 { "протокола" } "протокол"
else { "протоколов" } } else if protocols.len() < 5 {
}, "протокола"
} else {
"протоколов"
}
}
Language::English => { Language::English => {
if protocols.len() == 1 { "protocol" } if protocols.len() == 1 {
else { "protocols" } "protocol"
} else {
"protocols"
}
} }
}; };
message_lines.push(format!( message_lines.push(format!(
"\n📊 {} {}{} {}{} {}", "\n📊 {} {}{} {}{} {}",
server_count, server_word, server_count,
total_configs, config_word, server_word,
protocols.len(), protocol_word total_configs,
config_word,
protocols.len(),
protocol_word
)); ));
// Create keyboard with buttons for each server // Create keyboard with buttons for each server
@@ -430,7 +525,7 @@ pub async fn handle_my_configs_edit(
} else { } else {
"ов" "ов"
} }
}, }
Language::English => { Language::English => {
if config_count == 1 { if config_count == 1 {
"" ""
@@ -445,17 +540,19 @@ pub async fn handle_my_configs_edit(
Language::English => "config", Language::English => "config",
}; };
keyboard_buttons.push(vec![ keyboard_buttons.push(vec![InlineKeyboardButton::callback(
InlineKeyboardButton::callback( format!(
format!("🖥️ {} ({} {}{})", server_name, config_count, config_word, config_suffix), "🖥️ {} ({} {}{})",
format!("server_configs:{}", encoded_server_name) server_name, config_count, config_word, config_suffix
) ),
]); format!("server_configs:{}", encoded_server_name),
)]);
} }
keyboard_buttons.push(vec![ keyboard_buttons.push(vec![InlineKeyboardButton::callback(
InlineKeyboardButton::callback(l10n.get(lang, "back"), "back") l10n.get(lang, "back"),
]); "back",
)]);
let message = message_lines.join("\n"); let message = message_lines.join("\n");
@@ -486,12 +583,14 @@ pub async fn handle_show_server_configs(
let lang = get_user_language(from); let lang = get_user_language(from);
let l10n = LocalizationService::new(); let l10n = LocalizationService::new();
let telegram_id = from.id.0 as i64; let telegram_id = from.id.0 as i64;
let chat_id = q.message.as_ref().and_then(|m| { let chat_id = q
match m { .message
.as_ref()
.and_then(|m| match m {
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
_ => None, _ => None,
} })
}).ok_or("No chat ID")?; .ok_or("No chat ID")?;
// Decode server name // Decode server name
let server_name = match general_purpose::STANDARD.decode(encoded_server_name) { let server_name = match general_purpose::STANDARD.decode(encoded_server_name) {
@@ -500,13 +599,21 @@ pub async fn handle_show_server_configs(
}; };
let user_repo = UserRepository::new(db.connection()); let user_repo = UserRepository::new(db.connection());
let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone()); let inbound_users_repo =
crate::database::repository::InboundUsersRepository::new(db.connection().clone());
let uri_service = crate::services::UriGeneratorService::new(); let uri_service = crate::services::UriGeneratorService::new();
// Get user from telegram_id // Get user from telegram_id
if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { if let Some(user) = user_repo
.get_by_telegram_id(telegram_id)
.await
.unwrap_or(None)
{
// Get all active inbound users for this user // Get all active inbound users for this user
let inbound_users = inbound_users_repo.find_by_user_id(user.id).await.unwrap_or_default(); let inbound_users = inbound_users_repo
.find_by_user_id(user.id)
.await
.unwrap_or_default();
let mut server_configs = Vec::new(); let mut server_configs = Vec::new();
@@ -516,12 +623,15 @@ pub async fn handle_show_server_configs(
} }
// Get client config data for this specific inbound // Get client config data for this specific inbound
if let Ok(Some(config_data)) = inbound_users_repo.get_client_config_data(user.id, inbound_user.server_inbound_id).await { if let Ok(Some(config_data)) = inbound_users_repo
.get_client_config_data(user.id, inbound_user.server_inbound_id)
.await
{
if config_data.server_name == server_name { if config_data.server_name == server_name {
match uri_service.generate_client_config(user.id, &config_data) { match uri_service.generate_client_config(user.id, &config_data) {
Ok(client_config) => { Ok(client_config) => {
server_configs.push(client_config); server_configs.push(client_config);
}, }
Err(e) => { Err(e) => {
tracing::warn!("Failed to generate client config: {}", e); tracing::warn!("Failed to generate client config: {}", e);
continue; continue;
@@ -539,9 +649,11 @@ pub async fn handle_show_server_configs(
} }
// Build message with all configs for this server // Build message with all configs for this server
let mut message_lines = vec![ let mut message_lines = vec![l10n.format(
l10n.format(lang.clone(), "server_configs_title", &[("server_name", &server_name)]) lang.clone(),
]; "server_configs_title",
&[("server_name", &server_name)],
)];
for config in &server_configs { for config in &server_configs {
let protocol_emoji = match config.protocol.as_str() { let protocol_emoji = match config.protocol.as_str() {
@@ -549,7 +661,7 @@ pub async fn handle_show_server_configs(
"vmess" => "🟢", "vmess" => "🟢",
"trojan" => "🔴", "trojan" => "🔴",
"shadowsocks" => "🟡", "shadowsocks" => "🟡",
_ => "" _ => "",
}; };
message_lines.push(format!( message_lines.push(format!(
@@ -564,9 +676,10 @@ pub async fn handle_show_server_configs(
} }
// Create back button // Create back button
let keyboard = InlineKeyboardMarkup::new(vec![ let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback(
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back_to_configs")], l10n.get(lang, "back"),
]); "back_to_configs",
)]]);
let message = message_lines.join("\n"); let message = message_lines.join("\n");
@@ -598,16 +711,19 @@ pub async fn handle_support(
let from = &q.from; let from = &q.from;
let lang = get_user_language(from); let lang = get_user_language(from);
let l10n = LocalizationService::new(); let l10n = LocalizationService::new();
let chat_id = q.message.as_ref().and_then(|m| { let chat_id = q
match m { .message
.as_ref()
.and_then(|m| match m {
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
_ => None, _ => None,
} })
}).ok_or("No chat ID")?; .ok_or("No chat ID")?;
let keyboard = InlineKeyboardMarkup::new(vec![ let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback(
vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back")], l10n.get(lang.clone(), "back"),
]); "back",
)]]);
// Edit the existing message instead of sending a new one // Edit the existing message instead of sending a new one
if let Some(msg) = &q.message { if let Some(msg) = &q.message {
@@ -639,25 +755,46 @@ async fn notify_admins_new_request(
let lang = Language::English; // Default admin language let lang = Language::English; // Default admin language
let l10n = LocalizationService::new(); let l10n = LocalizationService::new();
let message = l10n.format(lang.clone(), "new_access_request", &[ let message = l10n.format(
("first_name", &request.telegram_first_name.as_deref().unwrap_or("")), lang.clone(),
("last_name", &request.telegram_last_name.as_deref().unwrap_or("")), "new_access_request",
("username", &request.telegram_username.as_deref().unwrap_or("unknown")), &[
]); (
"first_name",
&request.telegram_first_name.as_deref().unwrap_or(""),
),
(
"last_name",
&request.telegram_last_name.as_deref().unwrap_or(""),
),
(
"username",
&request.telegram_username.as_deref().unwrap_or("unknown"),
),
],
);
let keyboard = InlineKeyboardMarkup::new(vec![ let keyboard = InlineKeyboardMarkup::new(vec![
vec![ vec![
InlineKeyboardButton::callback(l10n.get(lang.clone(), "approve"), format!("approve:{}", request.id)), InlineKeyboardButton::callback(
InlineKeyboardButton::callback(l10n.get(lang.clone(), "decline"), format!("decline:{}", request.id)), l10n.get(lang.clone(), "approve"),
], format!("approve:{}", request.id),
vec![ ),
InlineKeyboardButton::callback("📋 All Requests", "back_to_requests"), InlineKeyboardButton::callback(
l10n.get(lang.clone(), "decline"),
format!("decline:{}", request.id),
),
], ],
vec![InlineKeyboardButton::callback(
"📋 All Requests",
"back_to_requests",
)],
]); ]);
for admin in admins { for admin in admins {
if let Some(telegram_id) = admin.telegram_id { if let Some(telegram_id) = admin.telegram_id {
let _ = bot.send_message(ChatId(telegram_id), &message) let _ = bot
.send_message(ChatId(telegram_id), &message)
.parse_mode(teloxide::types::ParseMode::Html) .parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard.clone()) .reply_markup(keyboard.clone())
.await; .await;
@@ -695,7 +832,7 @@ pub async fn handle_subscription_link(
💡 <i>Эта ссылка содержит все ваши конфигурации и автоматически обновляется при изменениях</i>", 💡 <i>Эта ссылка содержит все ваши конфигурации и автоматически обновляется при изменениях</i>",
subscription_url subscription_url
) )
}, }
Language::English => { Language::English => {
format!( format!(
"🔗 <b>Your Subscription Link</b>\n\n\ "🔗 <b>Your Subscription Link</b>\n\n\
@@ -707,9 +844,10 @@ pub async fn handle_subscription_link(
} }
}; };
let keyboard = InlineKeyboardMarkup::new(vec![ let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback(
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], l10n.get(lang, "back"),
]); "back",
)]]);
// Edit the existing message // Edit the existing message
if let Some(msg) = &q.message { if let Some(msg) = &q.message {

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Language { pub enum Language {
@@ -84,6 +84,27 @@ pub struct Translations {
pub config_not_found: String, pub config_not_found: String,
pub server_configs_title: String, pub server_configs_title: String,
// Subscription
pub subscription_link: String,
// User Management
pub manage_users: String,
pub user_list: String,
pub user_details: String,
pub manage_access: String,
pub remove_access: String,
pub grant_access: String,
pub user_info: String,
pub no_users_found: String,
pub page_info: String,
pub next_page: String,
pub prev_page: String,
pub back_to_users: String,
pub back_to_menu: String,
pub access_updated: String,
pub access_removed: String,
pub access_granted: String,
// Errors // Errors
pub error_occurred: String, pub error_occurred: String,
pub admin_not_found: String, pub admin_not_found: String,
@@ -109,7 +130,9 @@ impl LocalizationService {
} }
pub fn get(&self, lang: Language, key: &str) -> String { pub fn get(&self, lang: Language, key: &str) -> String {
let translations = self.translations.get(&lang) let translations = self
.translations
.get(&lang)
.unwrap_or_else(|| self.translations.get(&Language::English).unwrap()); .unwrap_or_else(|| self.translations.get(&Language::English).unwrap());
match key { match key {
@@ -157,6 +180,23 @@ impl LocalizationService {
"config_copied" => translations.config_copied.clone(), "config_copied" => translations.config_copied.clone(),
"config_not_found" => translations.config_not_found.clone(), "config_not_found" => translations.config_not_found.clone(),
"server_configs_title" => translations.server_configs_title.clone(), "server_configs_title" => translations.server_configs_title.clone(),
"subscription_link" => translations.subscription_link.clone(),
"manage_users" => translations.manage_users.clone(),
"user_list" => translations.user_list.clone(),
"user_details" => translations.user_details.clone(),
"manage_access" => translations.manage_access.clone(),
"remove_access" => translations.remove_access.clone(),
"grant_access" => translations.grant_access.clone(),
"user_info" => translations.user_info.clone(),
"no_users_found" => translations.no_users_found.clone(),
"page_info" => translations.page_info.clone(),
"next_page" => translations.next_page.clone(),
"prev_page" => translations.prev_page.clone(),
"back_to_users" => translations.back_to_users.clone(),
"back_to_menu" => translations.back_to_menu.clone(),
"access_updated" => translations.access_updated.clone(),
"access_removed" => translations.access_removed.clone(),
"access_granted" => translations.access_granted.clone(),
"error_occurred" => translations.error_occurred.clone(), "error_occurred" => translations.error_occurred.clone(),
"admin_not_found" => translations.admin_not_found.clone(), "admin_not_found" => translations.admin_not_found.clone(),
"request_not_found" => translations.request_not_found.clone(), "request_not_found" => translations.request_not_found.clone(),
@@ -183,7 +223,7 @@ impl LocalizationService {
get_vpn_access: "🚀 Get VPN Access".to_string(), get_vpn_access: "🚀 Get VPN Access".to_string(),
my_configs: "📋 My Configs".to_string(), my_configs: "📋 My Configs".to_string(),
support: "💬 Support".to_string(), support: "💬 Support".to_string(),
user_requests: "👥 User Requests".to_string(), user_requests: " User Requests".to_string(),
back: "🔙 Back".to_string(), back: "🔙 Back".to_string(),
approve: "✅ Approve".to_string(), approve: "✅ Approve".to_string(),
decline: "❌ Decline".to_string(), decline: "❌ Decline".to_string(),
@@ -201,13 +241,13 @@ impl LocalizationService {
new_access_request: "🔔 <b>New Access Request</b>\n\n👤 Name: {first_name} {last_name}\n🆔 Username: @{username}\n\nUse /requests to review".to_string(), new_access_request: "🔔 <b>New Access Request</b>\n\n👤 Name: {first_name} {last_name}\n🆔 Username: @{username}\n\nUse /requests to review".to_string(),
no_pending_requests: "No pending access requests".to_string(), no_pending_requests: "No pending access requests".to_string(),
access_request_details: "📋 <b>Access Request</b>\n\n👤 Name: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Requested: {date}\n\nMessage: {message}".to_string(), access_request_details: " <b>Access Request</b>\n\n👤 Name: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Requested: {date}\n\nMessage: {message}".to_string(),
unauthorized: "❌ You are not authorized to use this command".to_string(), unauthorized: "❌ You are not authorized to use this command".to_string(),
request_approved_admin: "✅ Request approved".to_string(), request_approved_admin: "✅ Request approved".to_string(),
request_declined_admin: "❌ Request declined".to_string(), request_declined_admin: "❌ Request declined".to_string(),
user_creation_failed: "❌ Failed to create user account: {error}\n\nPlease try again or contact technical support.".to_string(), user_creation_failed: "❌ Failed to create user account: {error}\n\nPlease try again or contact technical support.".to_string(),
support_info: "💬 <b>Support Information</b>\n\nIf you need help, please contact the administrators.\n\nYou can also check the documentation at:\nhttps://github.com/OutFleet".to_string(), support_info: "💬 <b>Support Information</b>\n\n📱 <b>How to connect:</b>\n1. Download v2raytun app for Android or iOS from:\n https://v2raytun.com/\n\n2. Add your subscription link from \"🔗 Subscription Link\" menu\n OR\n Add individual server links from \"📋 My Configs\"\n\n3. Connect and enjoy secure VPN!\n\n❓ If you need help, please contact the administrators.".to_string(),
statistics: "📊 <b>Statistics</b>\n\n👥 Total Users: {users}\n🖥️ Total Servers: {servers}\n📡 Total Inbounds: {inbounds}\n⏳ Pending Requests: {pending}".to_string(), statistics: "📊 <b>Statistics</b>\n\n👥 Total Users: {users}\n🖥️ Total Servers: {servers}\n📡 Total Inbounds: {inbounds}\n⏳ Pending Requests: {pending}".to_string(),
total_users: "👥 Total Users".to_string(), total_users: "👥 Total Users".to_string(),
@@ -227,6 +267,25 @@ impl LocalizationService {
config_not_found: "❌ Configuration not found".to_string(), config_not_found: "❌ Configuration not found".to_string(),
server_configs_title: "🖥️ <b>{server_name}</b> - Connection Links".to_string(), server_configs_title: "🖥️ <b>{server_name}</b> - Connection Links".to_string(),
subscription_link: "🔗 Subscription Link".to_string(),
manage_users: "👥 Manage Users".to_string(),
user_list: "👥 User List".to_string(),
user_details: "👤 User Details".to_string(),
manage_access: "🔧 Manage Access".to_string(),
remove_access: "❌ Remove Access".to_string(),
grant_access: "✅ Grant Access".to_string(),
user_info: "User Information".to_string(),
no_users_found: "No users found".to_string(),
page_info: "Page {page} of {total}".to_string(),
next_page: "Next →".to_string(),
prev_page: "← Previous".to_string(),
back_to_users: "👥 Back to Users".to_string(),
back_to_menu: "🏠 Main Menu".to_string(),
access_updated: "✅ Access updated successfully".to_string(),
access_removed: "❌ Access removed successfully".to_string(),
access_granted: "✅ Access granted successfully".to_string(),
error_occurred: "An error occurred".to_string(), error_occurred: "An error occurred".to_string(),
admin_not_found: "Admin not found".to_string(), admin_not_found: "Admin not found".to_string(),
request_not_found: "Request not found".to_string(), request_not_found: "Request not found".to_string(),
@@ -244,7 +303,7 @@ impl LocalizationService {
get_vpn_access: "🚀 Получить доступ к VPN".to_string(), get_vpn_access: "🚀 Получить доступ к VPN".to_string(),
my_configs: "📋 Мои конфигурации".to_string(), my_configs: "📋 Мои конфигурации".to_string(),
support: "💬 Поддержка".to_string(), support: "💬 Поддержка".to_string(),
user_requests: "👥 Запросы пользователей".to_string(), user_requests: " Запросы пользователей".to_string(),
back: "🔙 Назад".to_string(), back: "🔙 Назад".to_string(),
approve: "✅ Одобрить".to_string(), approve: "✅ Одобрить".to_string(),
decline: "❌ Отклонить".to_string(), decline: "❌ Отклонить".to_string(),
@@ -262,13 +321,13 @@ impl LocalizationService {
new_access_request: "🔔 <b>Новый запрос на доступ</b>\n\n👤 Имя: {first_name} {last_name}\n🆔 Имя пользователя: @{username}\n\nИспользуйте /requests для просмотра".to_string(), new_access_request: "🔔 <b>Новый запрос на доступ</b>\n\n👤 Имя: {first_name} {last_name}\n🆔 Имя пользователя: @{username}\n\nИспользуйте /requests для просмотра".to_string(),
no_pending_requests: "Нет ожидающих запросов на доступ".to_string(), no_pending_requests: "Нет ожидающих запросов на доступ".to_string(),
access_request_details: "📋 <b>Запрос на доступ</b>\n\n👤 Имя: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Запрошено: {date}\n\nСообщение: {message}".to_string(), access_request_details: " <b>Запрос на доступ</b>\n\n👤 Имя: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Запрошено: {date}\n\nСообщение: {message}".to_string(),
unauthorized: "У вас нет прав для использования этой команды".to_string(), unauthorized: "У вас нет прав для использования этой команды".to_string(),
request_approved_admin: "✅ Запрос одобрен".to_string(), request_approved_admin: "✅ Запрос одобрен".to_string(),
request_declined_admin: "❌ Запрос отклонен".to_string(), request_declined_admin: "❌ Запрос отклонен".to_string(),
user_creation_failed: "Не удалось создать аккаунт пользователя: {error}\n\nПожалуйста, попробуйте еще раз или обратитесь в техническую поддержку.".to_string(), user_creation_failed: "Не удалось создать аккаунт пользователя: {error}\n\nПожалуйста, попробуйте еще раз или обратитесь в техническую поддержку.".to_string(),
support_info: "💬 <b>Информация о поддержке</b>\n\nЕсли вам нужна помощь, пожалуйста, свяжитесь с администраторами.\n\nВы также можете ознакомиться с документацией по адресу:\nhttps://github.com/OutFleet".to_string(), support_info: "💬 <b>Информация о поддержке</b>\n\n📱 <b>Как подключиться:</b>\n1. Скачайте приложение v2raytun для Android или iOS с сайта:\n https://v2raytun.com/\n\n2. Добавьте ссылку подписки из меню \"🔗 Ссылка подписки\"\n ИЛИ\n Добавьте отдельные ссылки серверов из \"📋 Мои конфигурации\"\n\n3. Подключайтесь и наслаждайтесь безопасным VPN!\n\n❓ Если нужна помощь, обратитесь к администраторам.".to_string(),
statistics: "📊 <b>Статистика</b>\n\n👥 Всего пользователей: {users}\n🖥️ Всего серверов: {servers}\n📡 Всего входящих подключений: {inbounds}\n⏳ Ожидающих запросов: {pending}".to_string(), statistics: "📊 <b>Статистика</b>\n\n👥 Всего пользователей: {users}\n🖥️ Всего серверов: {servers}\n📡 Всего входящих подключений: {inbounds}\n⏳ Ожидающих запросов: {pending}".to_string(),
total_users: "👥 Всего пользователей".to_string(), total_users: "👥 Всего пользователей".to_string(),
@@ -288,6 +347,25 @@ impl LocalizationService {
config_not_found: "❌ Конфигурация не найдена".to_string(), config_not_found: "❌ Конфигурация не найдена".to_string(),
server_configs_title: "🖥️ <b>{server_name}</b> - Ссылки для подключения".to_string(), server_configs_title: "🖥️ <b>{server_name}</b> - Ссылки для подключения".to_string(),
subscription_link: "🔗 Ссылка подписки".to_string(),
manage_users: "👥 Управление пользователями".to_string(),
user_list: "👥 Список пользователей".to_string(),
user_details: "👤 Данные пользователя".to_string(),
manage_access: "🔧 Управление доступом".to_string(),
remove_access: "❌ Убрать доступ".to_string(),
grant_access: "✅ Предоставить доступ".to_string(),
user_info: "Информация о пользователе".to_string(),
no_users_found: "Пользователи не найдены".to_string(),
page_info: "Страница {page} из {total}".to_string(),
next_page: "Далее →".to_string(),
prev_page: "← Назад".to_string(),
back_to_users: "👥 К пользователям".to_string(),
back_to_menu: "🏠 Главное меню".to_string(),
access_updated: "✅ Доступ успешно обновлен".to_string(),
access_removed: "❌ Доступ успешно убран".to_string(),
access_granted: "✅ Доступ успешно предоставлен".to_string(),
error_occurred: "Произошла ошибка".to_string(), error_occurred: "Произошла ошибка".to_string(),
admin_not_found: "Администратор не найден".to_string(), admin_not_found: "Администратор не найден".to_string(),
request_not_found: "Запрос не найден".to_string(), request_not_found: "Запрос не найден".to_string(),

View File

@@ -1,17 +1,17 @@
use anyhow::Result; use anyhow::Result;
use std::sync::Arc; use std::sync::Arc;
use teloxide::{Bot, prelude::*}; use teloxide::{prelude::*, Bot};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use uuid::Uuid; use uuid::Uuid;
use crate::database::DatabaseManager;
use crate::database::repository::TelegramConfigRepository;
use crate::database::entities::telegram_config::Model as TelegramConfig;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::database::entities::telegram_config::Model as TelegramConfig;
use crate::database::repository::TelegramConfigRepository;
use crate::database::DatabaseManager;
pub mod bot; pub mod bot;
pub mod handlers;
pub mod error; pub mod error;
pub mod handlers;
pub mod localization; pub mod localization;
pub use error::TelegramError; pub use error::TelegramError;
@@ -150,7 +150,12 @@ impl TelegramService {
} }
/// Send message to user with inline keyboard /// Send message to user with inline keyboard
pub async fn send_message_with_keyboard(&self, chat_id: i64, text: String, keyboard: teloxide::types::InlineKeyboardMarkup) -> Result<()> { pub async fn send_message_with_keyboard(
&self,
chat_id: i64,
text: String,
keyboard: teloxide::types::InlineKeyboardMarkup,
) -> Result<()> {
let bot_guard = self.bot.read().await; let bot_guard = self.bot.read().await;
if let Some(bot) = bot_guard.as_ref() { if let Some(bot) = bot_guard.as_ref() {

View File

@@ -1,14 +1,14 @@
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub mod shadowsocks;
pub mod trojan;
pub mod vless; pub mod vless;
pub mod vmess; pub mod vmess;
pub mod trojan;
pub mod shadowsocks;
pub use shadowsocks::ShadowsocksUriBuilder;
pub use trojan::TrojanUriBuilder;
pub use vless::VlessUriBuilder; pub use vless::VlessUriBuilder;
pub use vmess::VmessUriBuilder; pub use vmess::VmessUriBuilder;
pub use trojan::TrojanUriBuilder;
pub use shadowsocks::ShadowsocksUriBuilder;
/// Common trait for all URI builders /// Common trait for all URI builders
pub trait UriBuilder { pub trait UriBuilder {
@@ -18,13 +18,19 @@ pub trait UriBuilder {
/// Validate configuration for this protocol /// Validate configuration for this protocol
fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> { fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> {
if config.hostname.is_empty() { if config.hostname.is_empty() {
return Err(UriGeneratorError::MissingRequiredField("hostname".to_string())); return Err(UriGeneratorError::MissingRequiredField(
"hostname".to_string(),
));
} }
if config.port <= 0 || config.port > 65535 { if config.port <= 0 || config.port > 65535 {
return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string())); return Err(UriGeneratorError::InvalidConfiguration(
"Invalid port number".to_string(),
));
} }
if config.xray_user_id.is_empty() { if config.xray_user_id.is_empty() {
return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string())); return Err(UriGeneratorError::MissingRequiredField(
"xray_user_id".to_string(),
));
} }
Ok(()) Ok(())
} }
@@ -32,9 +38,9 @@ pub trait UriBuilder {
/// Helper functions for URI building /// Helper functions for URI building
pub mod utils { pub mod utils {
use std::collections::HashMap;
use serde_json::Value;
use crate::services::uri_generator::error::UriGeneratorError; use crate::services::uri_generator::error::UriGeneratorError;
use serde_json::Value;
use std::collections::HashMap;
/// URL encode a string safely /// URL encode a string safely
pub fn url_encode(input: &str) -> String { pub fn url_encode(input: &str) -> String {
@@ -105,12 +111,16 @@ pub mod utils {
} }
/// Extract TLS SNI from stream settings /// Extract TLS SNI from stream settings
pub fn extract_tls_sni(stream_settings: &Value, certificate_domain: Option<&str>) -> Option<String> { pub fn extract_tls_sni(
stream_settings: &Value,
certificate_domain: Option<&str>,
) -> Option<String> {
// Try stream settings first // Try stream settings first
if let Some(sni) = stream_settings if let Some(sni) = stream_settings
.get("tlsSettings") .get("tlsSettings")
.and_then(|tls| tls.get("serverName")) .and_then(|tls| tls.get("serverName"))
.and_then(|sni| sni.as_str()) { .and_then(|sni| sni.as_str())
{
return Some(sni.to_string()); return Some(sni.to_string());
} }

View File

@@ -1,8 +1,8 @@
use base64::{Engine as _, engine::general_purpose}; use base64::{engine::general_purpose, Engine as _};
use serde_json::Value; use serde_json::Value;
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; use super::{utils, UriBuilder};
use super::{UriBuilder, utils}; use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub struct ShadowsocksUriBuilder; pub struct ShadowsocksUriBuilder;
@@ -19,7 +19,9 @@ impl ShadowsocksUriBuilder {
"AES_128_GCM" | "aes-128-gcm" => "aes-128-gcm", "AES_128_GCM" | "aes-128-gcm" => "aes-128-gcm",
// ChaCha20 variants // ChaCha20 variants
"CHACHA20_POLY1305" | "chacha20-ietf-poly1305" | "chacha20-poly1305" => "chacha20-ietf-poly1305", "CHACHA20_POLY1305" | "chacha20-ietf-poly1305" | "chacha20-poly1305" => {
"chacha20-ietf-poly1305"
}
// AES CFB variants // AES CFB variants
"AES_256_CFB" | "aes-256-cfb" => "aes-256-cfb", "AES_256_CFB" | "aes-256-cfb" => "aes-256-cfb",
@@ -34,7 +36,6 @@ impl ShadowsocksUriBuilder {
_ => "aes-256-gcm", _ => "aes-256-gcm",
} }
} }
} }
impl UriBuilder for ShadowsocksUriBuilder { impl UriBuilder for ShadowsocksUriBuilder {
@@ -42,7 +43,8 @@ impl UriBuilder for ShadowsocksUriBuilder {
self.validate_config(config)?; self.validate_config(config)?;
// Get cipher type from base_settings and map to Shadowsocks method // Get cipher type from base_settings and map to Shadowsocks method
let cipher = config.base_settings let cipher = config
.base_settings
.get("cipherType") .get("cipherType")
.and_then(|c| c.as_str()) .and_then(|c| c.as_str())
.or_else(|| config.base_settings.get("method").and_then(|m| m.as_str())) .or_else(|| config.base_settings.get("method").and_then(|m| m.as_str()))
@@ -73,13 +75,19 @@ impl UriBuilder for ShadowsocksUriBuilder {
fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> { fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> {
// Basic validation // Basic validation
if config.hostname.is_empty() { if config.hostname.is_empty() {
return Err(UriGeneratorError::MissingRequiredField("hostname".to_string())); return Err(UriGeneratorError::MissingRequiredField(
"hostname".to_string(),
));
} }
if config.port <= 0 || config.port > 65535 { if config.port <= 0 || config.port > 65535 {
return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string())); return Err(UriGeneratorError::InvalidConfiguration(
"Invalid port number".to_string(),
));
} }
if config.xray_user_id.is_empty() { if config.xray_user_id.is_empty() {
return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string())); return Err(UriGeneratorError::MissingRequiredField(
"xray_user_id".to_string(),
));
} }
// Shadowsocks uses xray_user_id as password, already validated above // Shadowsocks uses xray_user_id as password, already validated above
@@ -93,4 +101,3 @@ impl Default for ShadowsocksUriBuilder {
Self::new() Self::new()
} }
} }

View File

@@ -1,8 +1,8 @@
use std::collections::HashMap;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; use super::{utils, UriBuilder};
use super::{UriBuilder, utils}; use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub struct TrojanUriBuilder; pub struct TrojanUriBuilder;
@@ -50,32 +50,35 @@ impl UriBuilder for TrojanUriBuilder {
if let Some(host) = utils::extract_ws_host(&stream_settings) { if let Some(host) = utils::extract_ws_host(&stream_settings) {
params.insert("host".to_string(), host); params.insert("host".to_string(), host);
} }
}, }
"grpc" => { "grpc" => {
if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) {
params.insert("serviceName".to_string(), service_name); params.insert("serviceName".to_string(), service_name);
} }
// gRPC mode for Trojan // gRPC mode for Trojan
params.insert("mode".to_string(), "gun".to_string()); params.insert("mode".to_string(), "gun".to_string());
}, }
"tcp" => { "tcp" => {
// Check for HTTP header type // Check for HTTP header type
if let Some(header_type) = stream_settings if let Some(header_type) = stream_settings
.get("tcpSettings") .get("tcpSettings")
.and_then(|tcp| tcp.get("header")) .and_then(|tcp| tcp.get("header"))
.and_then(|header| header.get("type")) .and_then(|header| header.get("type"))
.and_then(|t| t.as_str()) { .and_then(|t| t.as_str())
{
if header_type != "none" { if header_type != "none" {
params.insert("headerType".to_string(), header_type.to_string()); params.insert("headerType".to_string(), header_type.to_string());
} }
} }
}, }
_ => {} // Other transport types _ => {} // Other transport types
} }
// TLS/Security specific parameters // TLS/Security specific parameters
if security == "tls" || security == "reality" { if security == "tls" || security == "reality" {
if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { if let Some(sni) =
utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref())
{
params.insert("sni".to_string(), sni); params.insert("sni".to_string(), sni);
} }
@@ -83,7 +86,8 @@ impl UriBuilder for TrojanUriBuilder {
if let Some(fp) = stream_settings if let Some(fp) = stream_settings
.get("tlsSettings") .get("tlsSettings")
.and_then(|tls| tls.get("fingerprint")) .and_then(|tls| tls.get("fingerprint"))
.and_then(|fp| fp.as_str()) { .and_then(|fp| fp.as_str())
{
params.insert("fp".to_string(), fp.to_string()); params.insert("fp".to_string(), fp.to_string());
} }
@@ -91,7 +95,8 @@ impl UriBuilder for TrojanUriBuilder {
if let Some(alpn) = stream_settings if let Some(alpn) = stream_settings
.get("tlsSettings") .get("tlsSettings")
.and_then(|tls| tls.get("alpn")) .and_then(|tls| tls.get("alpn"))
.and_then(|alpn| alpn.as_array()) { .and_then(|alpn| alpn.as_array())
{
let alpn_str = alpn let alpn_str = alpn
.iter() .iter()
.filter_map(|v| v.as_str()) .filter_map(|v| v.as_str())
@@ -106,7 +111,8 @@ impl UriBuilder for TrojanUriBuilder {
if let Some(allow_insecure) = stream_settings if let Some(allow_insecure) = stream_settings
.get("tlsSettings") .get("tlsSettings")
.and_then(|tls| tls.get("allowInsecure")) .and_then(|tls| tls.get("allowInsecure"))
.and_then(|ai| ai.as_bool()) { .and_then(|ai| ai.as_bool())
{
if allow_insecure { if allow_insecure {
params.insert("allowInsecure".to_string(), "1".to_string()); params.insert("allowInsecure".to_string(), "1".to_string());
} }
@@ -117,23 +123,23 @@ impl UriBuilder for TrojanUriBuilder {
if let Some(pbk) = stream_settings if let Some(pbk) = stream_settings
.get("realitySettings") .get("realitySettings")
.and_then(|reality| reality.get("publicKey")) .and_then(|reality| reality.get("publicKey"))
.and_then(|pbk| pbk.as_str()) { .and_then(|pbk| pbk.as_str())
{
params.insert("pbk".to_string(), pbk.to_string()); params.insert("pbk".to_string(), pbk.to_string());
} }
if let Some(sid) = stream_settings if let Some(sid) = stream_settings
.get("realitySettings") .get("realitySettings")
.and_then(|reality| reality.get("shortId")) .and_then(|reality| reality.get("shortId"))
.and_then(|sid| sid.as_str()) { .and_then(|sid| sid.as_str())
{
params.insert("sid".to_string(), sid.to_string()); params.insert("sid".to_string(), sid.to_string());
} }
} }
} }
// Flow control for XTLS (if supported) // Flow control for XTLS (if supported)
if let Some(flow) = stream_settings if let Some(flow) = stream_settings.get("flow").and_then(|f| f.as_str()) {
.get("flow")
.and_then(|f| f.as_str()) {
params.insert("flow".to_string(), flow.to_string()); params.insert("flow".to_string(), flow.to_string());
} }
@@ -166,13 +172,19 @@ impl UriBuilder for TrojanUriBuilder {
fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> { fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> {
// Basic validation // Basic validation
if config.hostname.is_empty() { if config.hostname.is_empty() {
return Err(UriGeneratorError::MissingRequiredField("hostname".to_string())); return Err(UriGeneratorError::MissingRequiredField(
"hostname".to_string(),
));
} }
if config.port <= 0 || config.port > 65535 { if config.port <= 0 || config.port > 65535 {
return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string())); return Err(UriGeneratorError::InvalidConfiguration(
"Invalid port number".to_string(),
));
} }
if config.xray_user_id.is_empty() { if config.xray_user_id.is_empty() {
return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string())); return Err(UriGeneratorError::MissingRequiredField(
"xray_user_id".to_string(),
));
} }
// Trojan uses xray_user_id as password, already validated above // Trojan uses xray_user_id as password, already validated above

View File

@@ -1,8 +1,8 @@
use std::collections::HashMap;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; use super::{utils, UriBuilder};
use super::{UriBuilder, utils}; use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub struct VlessUriBuilder; pub struct VlessUriBuilder;
@@ -49,32 +49,35 @@ impl UriBuilder for VlessUriBuilder {
if let Some(host) = utils::extract_ws_host(&stream_settings) { if let Some(host) = utils::extract_ws_host(&stream_settings) {
params.insert("host".to_string(), host); params.insert("host".to_string(), host);
} }
}, }
"grpc" => { "grpc" => {
if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) {
params.insert("serviceName".to_string(), service_name); params.insert("serviceName".to_string(), service_name);
} }
// Default gRPC mode // Default gRPC mode
params.insert("mode".to_string(), "gun".to_string()); params.insert("mode".to_string(), "gun".to_string());
}, }
"tcp" => { "tcp" => {
// Check for HTTP header type // Check for HTTP header type
if let Some(header_type) = stream_settings if let Some(header_type) = stream_settings
.get("tcpSettings") .get("tcpSettings")
.and_then(|tcp| tcp.get("header")) .and_then(|tcp| tcp.get("header"))
.and_then(|header| header.get("type")) .and_then(|header| header.get("type"))
.and_then(|t| t.as_str()) { .and_then(|t| t.as_str())
{
if header_type != "none" { if header_type != "none" {
params.insert("headerType".to_string(), header_type.to_string()); params.insert("headerType".to_string(), header_type.to_string());
} }
} }
}, }
_ => {} // Other transport types can be added as needed _ => {} // Other transport types can be added as needed
} }
// TLS/Security specific parameters // TLS/Security specific parameters
if security == "tls" || security == "reality" { if security == "tls" || security == "reality" {
if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { if let Some(sni) =
utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref())
{
params.insert("sni".to_string(), sni); params.insert("sni".to_string(), sni);
} }
@@ -82,7 +85,8 @@ impl UriBuilder for VlessUriBuilder {
if let Some(fp) = stream_settings if let Some(fp) = stream_settings
.get("tlsSettings") .get("tlsSettings")
.and_then(|tls| tls.get("fingerprint")) .and_then(|tls| tls.get("fingerprint"))
.and_then(|fp| fp.as_str()) { .and_then(|fp| fp.as_str())
{
params.insert("fp".to_string(), fp.to_string()); params.insert("fp".to_string(), fp.to_string());
} }
@@ -91,23 +95,23 @@ impl UriBuilder for VlessUriBuilder {
if let Some(pbk) = stream_settings if let Some(pbk) = stream_settings
.get("realitySettings") .get("realitySettings")
.and_then(|reality| reality.get("publicKey")) .and_then(|reality| reality.get("publicKey"))
.and_then(|pbk| pbk.as_str()) { .and_then(|pbk| pbk.as_str())
{
params.insert("pbk".to_string(), pbk.to_string()); params.insert("pbk".to_string(), pbk.to_string());
} }
if let Some(sid) = stream_settings if let Some(sid) = stream_settings
.get("realitySettings") .get("realitySettings")
.and_then(|reality| reality.get("shortId")) .and_then(|reality| reality.get("shortId"))
.and_then(|sid| sid.as_str()) { .and_then(|sid| sid.as_str())
{
params.insert("sid".to_string(), sid.to_string()); params.insert("sid".to_string(), sid.to_string());
} }
} }
} }
// Flow control for XTLS // Flow control for XTLS
if let Some(flow) = stream_settings if let Some(flow) = stream_settings.get("flow").and_then(|f| f.as_str()) {
.get("flow")
.and_then(|f| f.as_str()) {
params.insert("flow".to_string(), flow.to_string()); params.insert("flow".to_string(), flow.to_string());
} }

View File

@@ -1,9 +1,9 @@
use base64::{engine::general_purpose, Engine as _};
use serde_json::{json, Value};
use std::collections::HashMap; use std::collections::HashMap;
use serde_json::{Value, json};
use base64::{Engine as _, engine::general_purpose};
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; use super::{utils, UriBuilder};
use super::{UriBuilder, utils}; use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub struct VmessUriBuilder; pub struct VmessUriBuilder;
@@ -13,7 +13,10 @@ impl VmessUriBuilder {
} }
/// Build VMess URI in Base64 JSON format (following Marzban approach) /// Build VMess URI in Base64 JSON format (following Marzban approach)
fn build_base64_json_uri(&self, config: &ClientConfigData) -> Result<String, UriGeneratorError> { fn build_base64_json_uri(
&self,
config: &ClientConfigData,
) -> Result<String, UriGeneratorError> {
// Apply variable substitution to stream settings // Apply variable substitution to stream settings
let stream_settings = if !config.variable_values.is_null() { let stream_settings = if !config.variable_values.is_null() {
apply_variables(&config.stream_settings, &config.variable_values)? apply_variables(&config.stream_settings, &config.variable_values)?
@@ -50,21 +53,22 @@ impl VmessUriBuilder {
if let Some(host) = utils::extract_ws_host(&stream_settings) { if let Some(host) = utils::extract_ws_host(&stream_settings) {
vmess_config["host"] = Value::String(host); vmess_config["host"] = Value::String(host);
} }
}, }
"grpc" => { "grpc" => {
if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) {
vmess_config["path"] = Value::String(service_name); vmess_config["path"] = Value::String(service_name);
} }
// For gRPC in VMess, use "gun" type // For gRPC in VMess, use "gun" type
vmess_config["type"] = Value::String("gun".to_string()); vmess_config["type"] = Value::String("gun".to_string());
}, }
"tcp" => { "tcp" => {
// Check for HTTP header type // Check for HTTP header type
if let Some(header_type) = stream_settings if let Some(header_type) = stream_settings
.get("tcpSettings") .get("tcpSettings")
.and_then(|tcp| tcp.get("header")) .and_then(|tcp| tcp.get("header"))
.and_then(|header| header.get("type")) .and_then(|header| header.get("type"))
.and_then(|t| t.as_str()) { .and_then(|t| t.as_str())
{
vmess_config["type"] = Value::String(header_type.to_string()); vmess_config["type"] = Value::String(header_type.to_string());
// If HTTP headers, get host and path // If HTTP headers, get host and path
@@ -77,7 +81,8 @@ impl VmessUriBuilder {
.and_then(|headers| headers.get("Host")) .and_then(|headers| headers.get("Host"))
.and_then(|host| host.as_array()) .and_then(|host| host.as_array())
.and_then(|arr| arr.first()) .and_then(|arr| arr.first())
.and_then(|h| h.as_str()) { .and_then(|h| h.as_str())
{
vmess_config["host"] = Value::String(host.to_string()); vmess_config["host"] = Value::String(host.to_string());
} }
@@ -88,18 +93,21 @@ impl VmessUriBuilder {
.and_then(|request| request.get("path")) .and_then(|request| request.get("path"))
.and_then(|path| path.as_array()) .and_then(|path| path.as_array())
.and_then(|arr| arr.first()) .and_then(|arr| arr.first())
.and_then(|p| p.as_str()) { .and_then(|p| p.as_str())
{
vmess_config["path"] = Value::String(path.to_string()); vmess_config["path"] = Value::String(path.to_string());
} }
} }
} }
}, }
_ => {} // Other transport types _ => {} // Other transport types
} }
// TLS settings // TLS settings
if security != "none" { if security != "none" {
if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { if let Some(sni) =
utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref())
{
vmess_config["sni"] = Value::String(sni); vmess_config["sni"] = Value::String(sni);
} }
@@ -107,7 +115,8 @@ impl VmessUriBuilder {
if let Some(fp) = stream_settings if let Some(fp) = stream_settings
.get("tlsSettings") .get("tlsSettings")
.and_then(|tls| tls.get("fingerprint")) .and_then(|tls| tls.get("fingerprint"))
.and_then(|fp| fp.as_str()) { .and_then(|fp| fp.as_str())
{
vmess_config["fp"] = Value::String(fp.to_string()); vmess_config["fp"] = Value::String(fp.to_string());
} }
@@ -115,7 +124,8 @@ impl VmessUriBuilder {
if let Some(alpn) = stream_settings if let Some(alpn) = stream_settings
.get("tlsSettings") .get("tlsSettings")
.and_then(|tls| tls.get("alpn")) .and_then(|tls| tls.get("alpn"))
.and_then(|alpn| alpn.as_array()) { .and_then(|alpn| alpn.as_array())
{
let alpn_str = alpn let alpn_str = alpn
.iter() .iter()
.filter_map(|v| v.as_str()) .filter_map(|v| v.as_str())
@@ -135,7 +145,10 @@ impl VmessUriBuilder {
} }
/// Build VMess URI in query parameter format (alternative) /// Build VMess URI in query parameter format (alternative)
fn build_query_param_uri(&self, config: &ClientConfigData) -> Result<String, UriGeneratorError> { fn build_query_param_uri(
&self,
config: &ClientConfigData,
) -> Result<String, UriGeneratorError> {
// Apply variable substitution to stream settings // Apply variable substitution to stream settings
let stream_settings = if !config.variable_values.is_null() { let stream_settings = if !config.variable_values.is_null() {
apply_variables(&config.stream_settings, &config.variable_values)? apply_variables(&config.stream_settings, &config.variable_values)?
@@ -170,26 +183,29 @@ impl VmessUriBuilder {
if let Some(host) = utils::extract_ws_host(&stream_settings) { if let Some(host) = utils::extract_ws_host(&stream_settings) {
params.insert("host".to_string(), host); params.insert("host".to_string(), host);
} }
}, }
"grpc" => { "grpc" => {
if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) {
params.insert("serviceName".to_string(), service_name); params.insert("serviceName".to_string(), service_name);
} }
params.insert("mode".to_string(), "gun".to_string()); params.insert("mode".to_string(), "gun".to_string());
}, }
_ => {} _ => {}
} }
// TLS specific parameters // TLS specific parameters
if security != "none" { if security != "none" {
if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { if let Some(sni) =
utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref())
{
params.insert("sni".to_string(), sni); params.insert("sni".to_string(), sni);
} }
if let Some(fp) = stream_settings if let Some(fp) = stream_settings
.get("tlsSettings") .get("tlsSettings")
.and_then(|tls| tls.get("fingerprint")) .and_then(|tls| tls.get("fingerprint"))
.and_then(|fp| fp.as_str()) { .and_then(|fp| fp.as_str())
{
params.insert("fp".to_string(), fp.to_string()); params.insert("fp".to_string(), fp.to_string());
} }
} }

View File

@@ -6,7 +6,9 @@ use uuid::Uuid;
pub mod builders; pub mod builders;
pub mod error; pub mod error;
use builders::{UriBuilder, VlessUriBuilder, VmessUriBuilder, TrojanUriBuilder, ShadowsocksUriBuilder}; use builders::{
ShadowsocksUriBuilder, TrojanUriBuilder, UriBuilder, VlessUriBuilder, VmessUriBuilder,
};
use error::UriGeneratorError; use error::UriGeneratorError;
/// Complete client configuration data aggregated from database /// Complete client configuration data aggregated from database
@@ -69,25 +71,29 @@ impl UriGeneratorService {
"vless" => { "vless" => {
let builder = VlessUriBuilder::new(); let builder = VlessUriBuilder::new();
builder.build_uri(config) builder.build_uri(config)
}, }
"vmess" => { "vmess" => {
let builder = VmessUriBuilder::new(); let builder = VmessUriBuilder::new();
builder.build_uri(config) builder.build_uri(config)
}, }
"trojan" => { "trojan" => {
let builder = TrojanUriBuilder::new(); let builder = TrojanUriBuilder::new();
builder.build_uri(config) builder.build_uri(config)
}, }
"shadowsocks" => { "shadowsocks" => {
let builder = ShadowsocksUriBuilder::new(); let builder = ShadowsocksUriBuilder::new();
builder.build_uri(config) builder.build_uri(config)
}, }
_ => Err(UriGeneratorError::UnsupportedProtocol(protocol.to_string())), _ => Err(UriGeneratorError::UnsupportedProtocol(protocol.to_string())),
} }
} }
/// Generate complete client configuration /// Generate complete client configuration
pub fn generate_client_config(&self, user_id: Uuid, config: &ClientConfigData) -> Result<ClientConfig, UriGeneratorError> { pub fn generate_client_config(
&self,
user_id: Uuid,
config: &ClientConfigData,
) -> Result<ClientConfig, UriGeneratorError> {
let uri = self.generate_uri(config)?; let uri = self.generate_uri(config)?;
Ok(ClientConfig { Ok(ClientConfig {
@@ -102,7 +108,11 @@ impl UriGeneratorService {
} }
/// Apply variable substitution to JSON values /// Apply variable substitution to JSON values
pub fn apply_variable_substitution(&self, template: &Value, variables: &Value) -> Result<Value, UriGeneratorError> { pub fn apply_variable_substitution(
&self,
template: &Value,
variables: &Value,
) -> Result<Value, UriGeneratorError> {
let template_str = template.to_string(); let template_str = template.to_string();
let mut result = template_str; let mut result = template_str;

View File

@@ -1,12 +1,12 @@
use anyhow::{Result, anyhow}; use anyhow::{anyhow, Result};
use serde_json::Value; use serde_json::Value;
use xray_core::Client;
use std::sync::Arc; use std::sync::Arc;
use tokio::time::{timeout, Duration}; use tokio::time::{timeout, Duration};
use xray_core::Client;
// Import submodules from the same directory // Import submodules from the same directory
use super::stats::StatsClient;
use super::inbounds::InboundClient; use super::inbounds::InboundClient;
use super::stats::StatsClient;
use super::users::UserClient; use super::users::UserClient;
/// Xray gRPC client wrapper /// Xray gRPC client wrapper
@@ -24,18 +24,15 @@ impl XrayClient {
let connect_future = Client::from_url(endpoint); let connect_future = Client::from_url(endpoint);
match timeout(Duration::from_secs(5), connect_future).await { match timeout(Duration::from_secs(5), connect_future).await {
Ok(Ok(client)) => { Ok(Ok(client)) => Ok(Self {
Ok(Self { endpoint: endpoint.to_string(),
endpoint: endpoint.to_string(), client: Arc::new(client),
client: Arc::new(client), }),
}) Ok(Err(e)) => Err(anyhow!("Failed to connect to Xray at {}: {}", endpoint, e)),
}, Err(_) => Err(anyhow!(
Ok(Err(e)) => { "Connection to Xray at {} timed out after 5 seconds",
Err(anyhow!("Failed to connect to Xray at {}: {}", endpoint, e)) endpoint
}, )),
Err(_) => {
Err(anyhow!("Connection to Xray at {} timed out after 5 seconds", endpoint))
}
} }
} }
@@ -52,7 +49,10 @@ impl XrayClient {
} }
/// Restart Xray with new configuration /// Restart Xray with new configuration
pub async fn restart_with_config(&self, config: &crate::services::xray::XrayConfig) -> Result<()> { pub async fn restart_with_config(
&self,
config: &crate::services::xray::XrayConfig,
) -> Result<()> {
let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client); let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client);
inbound_client.restart_with_config(config).await inbound_client.restart_with_config(config).await
} }
@@ -64,15 +64,30 @@ impl XrayClient {
} }
/// Add inbound configuration with TLS certificate /// Add inbound configuration with TLS certificate
pub async fn add_inbound_with_certificate(&self, inbound: &Value, cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { pub async fn add_inbound_with_certificate(
&self,
inbound: &Value,
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client); let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client);
inbound_client.add_inbound_with_certificate(inbound, None, cert_pem, key_pem).await inbound_client
.add_inbound_with_certificate(inbound, None, cert_pem, key_pem)
.await
} }
/// Add inbound configuration with users and TLS certificate /// Add inbound configuration with users and TLS certificate
pub async fn add_inbound_with_users_and_certificate(&self, inbound: &Value, users: &[Value], cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { pub async fn add_inbound_with_users_and_certificate(
&self,
inbound: &Value,
users: &[Value],
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client); let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client);
inbound_client.add_inbound_with_certificate(inbound, Some(users), cert_pem, key_pem).await inbound_client
.add_inbound_with_certificate(inbound, Some(users), cert_pem, key_pem)
.await
} }
/// Remove inbound by tag /// Remove inbound by tag

View File

@@ -171,25 +171,26 @@ impl XrayConfig {
dns: None, dns: None,
routing: Some(RoutingConfig { routing: Some(RoutingConfig {
domain_strategy: Some("IPIfNonMatch".to_string()), domain_strategy: Some("IPIfNonMatch".to_string()),
rules: vec![ rules: vec![RoutingRule {
RoutingRule { rule_type: "field".to_string(),
rule_type: "field".to_string(), domain: None,
domain: None, ip: Some(vec!["geoip:private".to_string()]),
ip: Some(vec!["geoip:private".to_string()]), port: None,
port: None, outbound_tag: "direct".to_string(),
outbound_tag: "direct".to_string(), }],
}
],
}), }),
policy: Some(PolicyConfig { policy: Some(PolicyConfig {
levels: { levels: {
let mut levels = HashMap::new(); let mut levels = HashMap::new();
levels.insert("0".to_string(), PolicyLevel { levels.insert(
handshake_timeout: Some(4), "0".to_string(),
conn_idle: Some(300), PolicyLevel {
uplink_only: Some(2), handshake_timeout: Some(4),
downlink_only: Some(5), conn_idle: Some(300),
}); uplink_only: Some(2),
downlink_only: Some(5),
},
);
levels levels
}, },
system: Some(SystemPolicy { system: Some(SystemPolicy {

View File

@@ -1,33 +1,34 @@
use anyhow::{Result, anyhow}; use anyhow::{anyhow, Result};
use prost::Message;
use serde_json::Value; use serde_json::Value;
use uuid; use uuid;
use xray_core::{ use xray_core::{
tonic::Request,
app::proxyman::command::{AddInboundRequest, RemoveInboundRequest}, app::proxyman::command::{AddInboundRequest, RemoveInboundRequest},
core::InboundHandlerConfig,
common::serial::TypedMessage,
common::protocol::User,
app::proxyman::ReceiverConfig, app::proxyman::ReceiverConfig,
common::net::{PortList, PortRange, IpOrDomain, ip_or_domain::Address, Network}, common::net::{ip_or_domain::Address, IpOrDomain, Network, PortList, PortRange},
transport::internet::StreamConfig, common::protocol::User,
transport::internet::tls::{Config as TlsConfig, Certificate as TlsCertificate}, common::serial::TypedMessage,
core::InboundHandlerConfig,
prost_types,
proxy::shadowsocks::ServerConfig as ShadowsocksServerConfig,
proxy::shadowsocks::{Account as ShadowsocksAccount, CipherType},
proxy::trojan::Account as TrojanAccount,
proxy::trojan::ServerConfig as TrojanServerConfig,
proxy::vless::inbound::Config as VlessInboundConfig, proxy::vless::inbound::Config as VlessInboundConfig,
proxy::vless::Account as VlessAccount, proxy::vless::Account as VlessAccount,
proxy::vmess::inbound::Config as VmessInboundConfig, proxy::vmess::inbound::Config as VmessInboundConfig,
proxy::vmess::Account as VmessAccount, proxy::vmess::Account as VmessAccount,
proxy::trojan::ServerConfig as TrojanServerConfig, tonic::Request,
proxy::trojan::Account as TrojanAccount, transport::internet::tls::{Certificate as TlsCertificate, Config as TlsConfig},
proxy::shadowsocks::ServerConfig as ShadowsocksServerConfig, transport::internet::StreamConfig,
proxy::shadowsocks::{Account as ShadowsocksAccount, CipherType},
Client, Client,
prost_types,
}; };
use prost::Message;
/// Convert PEM format to DER (x509) format /// Convert PEM format to DER (x509) format
fn pem_to_der(pem_data: &str) -> Result<Vec<u8>> { fn pem_to_der(pem_data: &str) -> Result<Vec<u8>> {
// Remove PEM headers and whitespace, then decode base64 // Remove PEM headers and whitespace, then decode base64
let base64_data: String = pem_data.lines() let base64_data: String = pem_data
.lines()
.filter(|line| !line.starts_with("-----") && !line.trim().is_empty()) .filter(|line| !line.starts_with("-----") && !line.trim().is_empty())
.map(|line| line.trim()) .map(|line| line.trim())
.collect::<Vec<&str>>() .collect::<Vec<&str>>()
@@ -35,8 +36,9 @@ fn pem_to_der(pem_data: &str) -> Result<Vec<u8>> {
tracing::debug!("PEM to DER conversion: {} bytes", base64_data.len()); tracing::debug!("PEM to DER conversion: {} bytes", base64_data.len());
use base64::{Engine as _, engine::general_purpose}; use base64::{engine::general_purpose, Engine as _};
general_purpose::STANDARD.decode(&base64_data) general_purpose::STANDARD
.decode(&base64_data)
.map_err(|e| anyhow!("Failed to decode base64 PEM data: {}", e)) .map_err(|e| anyhow!("Failed to decode base64 PEM data: {}", e))
} }
@@ -52,11 +54,18 @@ impl<'a> InboundClient<'a> {
/// Add inbound configuration /// Add inbound configuration
pub async fn add_inbound(&self, inbound: &Value) -> Result<()> { pub async fn add_inbound(&self, inbound: &Value) -> Result<()> {
self.add_inbound_with_certificate(inbound, None, None, None).await self.add_inbound_with_certificate(inbound, None, None, None)
.await
} }
/// Add inbound configuration with TLS certificate and users /// Add inbound configuration with TLS certificate and users
pub async fn add_inbound_with_certificate(&self, inbound: &Value, users: Option<&[Value]>, cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { pub async fn add_inbound_with_certificate(
&self,
inbound: &Value,
users: Option<&[Value]>,
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
let tag = inbound["tag"].as_str().unwrap_or("").to_string(); let tag = inbound["tag"].as_str().unwrap_or("").to_string();
let port = inbound["port"].as_u64().unwrap_or(8080) as u32; let port = inbound["port"].as_u64().unwrap_or(8080) as u32;
let protocol = inbound["protocol"].as_str().unwrap_or("vless"); let protocol = inbound["protocol"].as_str().unwrap_or("vless");
@@ -64,10 +73,13 @@ impl<'a> InboundClient<'a> {
tracing::info!( tracing::info!(
"Adding inbound '{}' with protocol={}, port={}, has_cert={}, has_key={}", "Adding inbound '{}' with protocol={}, port={}, has_cert={}, has_key={}",
tag, protocol, port, cert_pem.is_some(), key_pem.is_some() tag,
protocol,
port,
cert_pem.is_some(),
key_pem.is_some()
); );
// Create receiver configuration (port binding) - use simple port number // Create receiver configuration (port binding) - use simple port number
let port_list = PortList { let port_list = PortList {
range: vec![PortRange { range: vec![PortRange {
@@ -86,11 +98,11 @@ impl<'a> InboundClient<'a> {
certificate: cert_pem.as_bytes().to_vec(), // PEM content as bytes like working example certificate: cert_pem.as_bytes().to_vec(), // PEM content as bytes like working example
key: key_pem.as_bytes().to_vec(), // PEM content as bytes like working example key: key_pem.as_bytes().to_vec(), // PEM content as bytes like working example
usage: 0, usage: 0,
ocsp_stapling: 3600, // From working example ocsp_stapling: 3600, // From working example
one_time_loading: true, // From working example one_time_loading: true, // From working example
build_chain: false, build_chain: false,
certificate_path: "".to_string(), // Empty paths since we use content certificate_path: "".to_string(), // Empty paths since we use content
key_path: "".to_string(), // Empty paths since we use content key_path: "".to_string(), // Empty paths since we use content
}; };
// Create TLS config with proper fields like working example // Create TLS config with proper fields like working example
@@ -106,13 +118,16 @@ impl<'a> InboundClient<'a> {
value: tls_config.encode_to_vec(), value: tls_config.encode_to_vec(),
}; };
tracing::debug!("TLS config: server_name={}, protocols={:?}", tracing::debug!(
tls_config.server_name, tls_config.next_protocol); "TLS config: server_name={}, protocols={:?}",
tls_config.server_name,
tls_config.next_protocol
);
// Create StreamConfig like working example // Create StreamConfig like working example
Some(StreamConfig { Some(StreamConfig {
address: None, // No address in streamSettings according to working example address: None, // No address in streamSettings according to working example
port: 0, // No port in working example streamSettings port: 0, // No port in working example streamSettings
protocol_name: "tcp".to_string(), protocol_name: "tcp".to_string(),
transport_settings: vec![], transport_settings: vec![],
security_type: "xray.transport.internet.tls.Config".to_string(), // Full type like working example security_type: "xray.transport.internet.tls.Config".to_string(), // Full type like working example
@@ -126,7 +141,7 @@ impl<'a> InboundClient<'a> {
let receiver_config = ReceiverConfig { let receiver_config = ReceiverConfig {
port_list: Some(port_list), port_list: Some(port_list),
listen: Some(IpOrDomain { listen: Some(IpOrDomain {
address: Some(Address::Ip(vec![0, 0, 0, 0])) // "0.0.0.0" as IPv4 bytes address: Some(Address::Ip(vec![0, 0, 0, 0])), // "0.0.0.0" as IPv4 bytes
}), }),
allocation_strategy: None, allocation_strategy: None,
stream_settings: stream_settings, stream_settings: stream_settings,
@@ -176,7 +191,7 @@ impl<'a> InboundClient<'a> {
r#type: "xray.proxy.vless.inbound.Config".to_string(), r#type: "xray.proxy.vless.inbound.Config".to_string(),
value: vless_config.encode_to_vec(), value: vless_config.encode_to_vec(),
} }
}, }
"vmess" => { "vmess" => {
let mut vmess_users = vec![]; let mut vmess_users = vec![];
if let Some(users) = users { if let Some(users) = users {
@@ -225,19 +240,21 @@ impl<'a> InboundClient<'a> {
r#type: "xray.proxy.vmess.inbound.Config".to_string(), r#type: "xray.proxy.vmess.inbound.Config".to_string(),
value: vmess_config.encode_to_vec(), value: vmess_config.encode_to_vec(),
} }
}, }
"trojan" => { "trojan" => {
let mut trojan_users = vec![]; let mut trojan_users = vec![];
if let Some(users) = users { if let Some(users) = users {
for user in users { for user in users {
let password = user["password"].as_str().or_else(|| user["id"].as_str()).unwrap_or("").to_string(); let password = user["password"]
.as_str()
.or_else(|| user["id"].as_str())
.unwrap_or("")
.to_string();
let email = user["email"].as_str().unwrap_or("").to_string(); let email = user["email"].as_str().unwrap_or("").to_string();
let level = user["level"].as_u64().unwrap_or(0) as u32; let level = user["level"].as_u64().unwrap_or(0) as u32;
if !password.is_empty() && !email.is_empty() { if !password.is_empty() && !email.is_empty() {
let account = TrojanAccount { let account = TrojanAccount { password };
password,
};
trojan_users.push(User { trojan_users.push(User {
email, email,
level, level,
@@ -258,21 +275,24 @@ impl<'a> InboundClient<'a> {
r#type: "xray.proxy.trojan.ServerConfig".to_string(), r#type: "xray.proxy.trojan.ServerConfig".to_string(),
value: trojan_config.encode_to_vec(), value: trojan_config.encode_to_vec(),
} }
}, }
"shadowsocks" => { "shadowsocks" => {
let mut ss_users = vec![]; let mut ss_users = vec![];
if let Some(users) = users { if let Some(users) = users {
for user in users { for user in users {
let password = user["password"].as_str().or_else(|| user["id"].as_str()).unwrap_or("").to_string(); let password = user["password"]
.as_str()
.or_else(|| user["id"].as_str())
.unwrap_or("")
.to_string();
let email = user["email"].as_str().unwrap_or("").to_string(); let email = user["email"].as_str().unwrap_or("").to_string();
let level = user["level"].as_u64().unwrap_or(0) as u32; let level = user["level"].as_u64().unwrap_or(0) as u32;
if !password.is_empty() && !email.is_empty() { if !password.is_empty() && !email.is_empty() {
let account = ShadowsocksAccount { let account = ShadowsocksAccount {
password, password,
cipher_type: CipherType::Aes256Gcm as i32, // Use AES-256-GCM cipher cipher_type: CipherType::Aes256Gcm as i32, // Use AES-256-GCM cipher
iv_check: false, // Default IV check iv_check: false, // Default IV check
}; };
ss_users.push(User { ss_users.push(User {
email: email.clone(), email: email.clone(),
@@ -294,7 +314,7 @@ impl<'a> InboundClient<'a> {
r#type: "xray.proxy.shadowsocks.ServerConfig".to_string(), r#type: "xray.proxy.shadowsocks.ServerConfig".to_string(),
value: shadowsocks_config.encode_to_vec(), value: shadowsocks_config.encode_to_vec(),
} }
}, }
_ => { _ => {
return Err(anyhow!("Unsupported protocol: {}", protocol)); return Err(anyhow!("Unsupported protocol: {}", protocol));
} }
@@ -333,7 +353,7 @@ impl<'a> InboundClient<'a> {
Ok(_) => { Ok(_) => {
tracing::info!("Removed inbound '{}' from {}", tag, self.endpoint); tracing::info!("Removed inbound '{}' from {}", tag, self.endpoint);
Ok(()) Ok(())
}, }
Err(e) => { Err(e) => {
tracing::error!("Failed to remove inbound '{}': {}", tag, e); tracing::error!("Failed to remove inbound '{}': {}", tag, e);
Err(anyhow!("Failed to remove inbound: {}", e)) Err(anyhow!("Failed to remove inbound: {}", e))
@@ -342,8 +362,14 @@ impl<'a> InboundClient<'a> {
} }
/// Restart Xray with new configuration /// Restart Xray with new configuration
pub async fn restart_with_config(&self, config: &crate::services::xray::XrayConfig) -> Result<()> { pub async fn restart_with_config(
tracing::debug!("Restarting Xray server at {} with new config", self.endpoint); &self,
config: &crate::services::xray::XrayConfig,
) -> Result<()> {
tracing::debug!(
"Restarting Xray server at {} with new config",
self.endpoint
);
// TODO: Implement restart with config using xray-core // TODO: Implement restart with config using xray-core
// For now just return success // For now just return success

View File

@@ -1,16 +1,16 @@
use anyhow::Result; use anyhow::Result;
use serde_json::Value; use serde_json::Value;
use uuid::Uuid;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio::time::{Duration, Instant, timeout}; use tokio::time::{timeout, Duration, Instant};
use tracing::{error, warn}; use tracing::{error, warn};
use uuid::Uuid;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod stats;
pub mod inbounds; pub mod inbounds;
pub mod stats;
pub mod users; pub mod users;
pub use client::XrayClient; pub use client::XrayClient;
@@ -77,7 +77,6 @@ impl XrayService {
Ok(client) Ok(client)
} }
/// Test connection to Xray server with timeout /// Test connection to Xray server with timeout
pub async fn test_connection(&self, _server_id: Uuid, endpoint: &str) -> Result<bool> { pub async fn test_connection(&self, _server_id: Uuid, endpoint: &str) -> Result<bool> {
// Apply a 3-second timeout to the entire test operation // Apply a 3-second timeout to the entire test operation
@@ -85,12 +84,12 @@ impl XrayService {
Ok(Ok(_client)) => { Ok(Ok(_client)) => {
// Connection successful // Connection successful
Ok(true) Ok(true)
}, }
Ok(Err(e)) => { Ok(Err(e)) => {
// Connection failed with error // Connection failed with error
warn!("Failed to connect to Xray at {}: {}", endpoint, e); warn!("Failed to connect to Xray at {}: {}", endpoint, e);
Ok(false) Ok(false)
}, }
Err(_) => { Err(_) => {
// Operation timed out // Operation timed out
warn!("Connection test to Xray at {} timed out", endpoint); warn!("Connection test to Xray at {} timed out", endpoint);
@@ -100,7 +99,12 @@ impl XrayService {
} }
/// Apply full configuration to Xray server /// Apply full configuration to Xray server
pub async fn apply_config(&self, _server_id: Uuid, endpoint: &str, config: &XrayConfig) -> Result<()> { pub async fn apply_config(
&self,
_server_id: Uuid,
endpoint: &str,
config: &XrayConfig,
) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?; let client = self.get_or_create_client(endpoint).await?;
client.restart_with_config(config).await client.restart_with_config(config).await
} }
@@ -125,7 +129,8 @@ impl XrayService {
"streamSettings": stream_settings "streamSettings": stream_settings
}); });
self.add_inbound(_server_id, endpoint, &inbound_config).await self.add_inbound(_server_id, endpoint, &inbound_config)
.await
} }
/// Create inbound from template with TLS certificate /// Create inbound from template with TLS certificate
@@ -150,25 +155,50 @@ impl XrayService {
"streamSettings": stream_settings "streamSettings": stream_settings
}); });
self.add_inbound_with_certificate(_server_id, endpoint, &inbound_config, cert_pem, key_pem).await self.add_inbound_with_certificate(_server_id, endpoint, &inbound_config, cert_pem, key_pem)
.await
} }
/// Add inbound to running Xray instance /// Add inbound to running Xray instance
pub async fn add_inbound(&self, _server_id: Uuid, endpoint: &str, inbound: &Value) -> Result<()> { pub async fn add_inbound(
&self,
_server_id: Uuid,
endpoint: &str,
inbound: &Value,
) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?; let client = self.get_or_create_client(endpoint).await?;
client.add_inbound(inbound).await client.add_inbound(inbound).await
} }
/// Add inbound with certificate to running Xray instance /// Add inbound with certificate to running Xray instance
pub async fn add_inbound_with_certificate(&self, _server_id: Uuid, endpoint: &str, inbound: &Value, cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { pub async fn add_inbound_with_certificate(
&self,
_server_id: Uuid,
endpoint: &str,
inbound: &Value,
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?; let client = self.get_or_create_client(endpoint).await?;
client.add_inbound_with_certificate(inbound, cert_pem, key_pem).await client
.add_inbound_with_certificate(inbound, cert_pem, key_pem)
.await
} }
/// Add inbound with users and certificate to running Xray instance /// Add inbound with users and certificate to running Xray instance
pub async fn add_inbound_with_users_and_certificate(&self, _server_id: Uuid, endpoint: &str, inbound: &Value, users: &[Value], cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { pub async fn add_inbound_with_users_and_certificate(
&self,
_server_id: Uuid,
endpoint: &str,
inbound: &Value,
users: &[Value],
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?; let client = self.get_or_create_client(endpoint).await?;
client.add_inbound_with_users_and_certificate(inbound, users, cert_pem, key_pem).await client
.add_inbound_with_users_and_certificate(inbound, users, cert_pem, key_pem)
.await
} }
/// Remove inbound from running Xray instance /// Remove inbound from running Xray instance
@@ -178,8 +208,13 @@ impl XrayService {
} }
/// Add user to inbound by recreating the inbound with updated user list /// Add user to inbound by recreating the inbound with updated user list
pub async fn add_user(&self, _server_id: Uuid, endpoint: &str, inbound_tag: &str, user: &Value) -> Result<()> { pub async fn add_user(
&self,
_server_id: Uuid,
endpoint: &str,
inbound_tag: &str,
user: &Value,
) -> Result<()> {
// TODO: Implement inbound recreation approach: // TODO: Implement inbound recreation approach:
// 1. Get current inbound configuration from database // 1. Get current inbound configuration from database
// 2. Get existing users from database // 2. Get existing users from database
@@ -204,7 +239,6 @@ impl XrayService {
cert_pem: Option<&str>, cert_pem: Option<&str>,
key_pem: Option<&str>, key_pem: Option<&str>,
) -> Result<()> { ) -> Result<()> {
// Build inbound configuration with users // Build inbound configuration with users
let mut inbound_config = serde_json::json!({ let mut inbound_config = serde_json::json!({
"tag": tag, "tag": tag,
@@ -220,30 +254,46 @@ impl XrayService {
match protocol { match protocol {
"vless" | "vmess" => { "vless" | "vmess" => {
settings["clients"] = serde_json::Value::Array(users.to_vec()); settings["clients"] = serde_json::Value::Array(users.to_vec());
}, }
"trojan" => { "trojan" => {
settings["clients"] = serde_json::Value::Array(users.to_vec()); settings["clients"] = serde_json::Value::Array(users.to_vec());
}, }
"shadowsocks" => { "shadowsocks" => {
// For shadowsocks, users are handled differently // For shadowsocks, users are handled differently
if let Some(user) = users.first() { if let Some(user) = users.first() {
settings["password"] = user["password"].clone(); settings["password"] = user["password"].clone();
} }
}, }
_ => { _ => {
return Err(anyhow::anyhow!("Unsupported protocol for users: {}", protocol)); return Err(anyhow::anyhow!(
"Unsupported protocol for users: {}",
protocol
));
} }
} }
inbound_config["settings"] = settings; inbound_config["settings"] = settings;
} }
// Use the new method with users support // Use the new method with users support
self.add_inbound_with_users_and_certificate(_server_id, endpoint, &inbound_config, users, cert_pem, key_pem).await self.add_inbound_with_users_and_certificate(
_server_id,
endpoint,
&inbound_config,
users,
cert_pem,
key_pem,
)
.await
} }
/// Remove user from inbound /// Remove user from inbound
pub async fn remove_user(&self, _server_id: Uuid, endpoint: &str, inbound_tag: &str, email: &str) -> Result<()> { pub async fn remove_user(
&self,
_server_id: Uuid,
endpoint: &str,
inbound_tag: &str,
email: &str,
) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?; let client = self.get_or_create_client(endpoint).await?;
client.remove_user(inbound_tag, email).await client.remove_user(inbound_tag, email).await
} }
@@ -255,7 +305,13 @@ impl XrayService {
} }
/// Query specific statistics /// Query specific statistics
pub async fn query_stats(&self, _server_id: Uuid, endpoint: &str, pattern: &str, reset: bool) -> Result<Value> { pub async fn query_stats(
&self,
_server_id: Uuid,
endpoint: &str,
pattern: &str,
reset: bool,
) -> Result<Value> {
let client = self.get_or_create_client(endpoint).await?; let client = self.get_or_create_client(endpoint).await?;
client.query_stats(pattern, reset).await client.query_stats(pattern, reset).await
} }
@@ -276,13 +332,17 @@ impl XrayService {
let _ = client.remove_inbound(tag).await; let _ = client.remove_inbound(tag).await;
// Create inbound with users // Create inbound with users
let users_json: Vec<Value> = desired.users.iter().map(|user| { let users_json: Vec<Value> = desired
serde_json::json!({ .users
"id": user.id, .iter()
"email": user.email, .map(|user| {
"level": user.level serde_json::json!({
"id": user.id,
"email": user.email,
"level": user.level
})
}) })
}).collect(); .collect();
// Build inbound config // Build inbound config
let inbound_config = serde_json::json!({ let inbound_config = serde_json::json!({
@@ -293,12 +353,15 @@ impl XrayService {
"streamSettings": desired.stream_settings "streamSettings": desired.stream_settings
}); });
match client.add_inbound_with_users_and_certificate( match client
&inbound_config, .add_inbound_with_users_and_certificate(
&users_json, &inbound_config,
desired.cert_pem.as_deref(), &users_json,
desired.key_pem.as_deref(), desired.cert_pem.as_deref(),
).await { desired.key_pem.as_deref(),
)
.await
{
Err(e) => { Err(e) => {
error!("Failed to create inbound {}: {}", tag, e); error!("Failed to create inbound {}: {}", tag, e);
} }

View File

@@ -1,8 +1,8 @@
use anyhow::{Result, anyhow}; use anyhow::{anyhow, Result};
use serde_json::Value; use serde_json::Value;
use xray_core::{ use xray_core::{
tonic::Request,
app::stats::command::{GetStatsRequest, QueryStatsRequest}, app::stats::command::{GetStatsRequest, QueryStatsRequest},
tonic::Request,
Client, Client,
}; };
@@ -44,7 +44,12 @@ impl<'a> StatsClient<'a> {
/// Query specific statistics with pattern /// Query specific statistics with pattern
pub async fn query_stats(&self, pattern: &str, reset: bool) -> Result<Value> { pub async fn query_stats(&self, pattern: &str, reset: bool) -> Result<Value> {
tracing::info!("Querying stats with pattern '{}', reset: {} from {}", pattern, reset, self.endpoint); tracing::info!(
"Querying stats with pattern '{}', reset: {} from {}",
pattern,
reset,
self.endpoint
);
let request = Request::new(QueryStatsRequest { let request = Request::new(QueryStatsRequest {
pattern: pattern.to_string(), pattern: pattern.to_string(),

View File

@@ -1,16 +1,16 @@
use anyhow::{Result, anyhow}; use anyhow::{anyhow, Result};
use prost::Message;
use serde_json::Value; use serde_json::Value;
use xray_core::{ use xray_core::{
tonic::Request, app::proxyman::command::{AddUserOperation, AlterInboundRequest, RemoveUserOperation},
app::proxyman::command::{AlterInboundRequest, AddUserOperation, RemoveUserOperation},
common::serial::TypedMessage,
common::protocol::User, common::protocol::User,
common::serial::TypedMessage,
proxy::trojan::Account as TrojanAccount,
proxy::vless::Account as VlessAccount, proxy::vless::Account as VlessAccount,
proxy::vmess::Account as VmessAccount, proxy::vmess::Account as VmessAccount,
proxy::trojan::Account as TrojanAccount, tonic::Request,
Client, Client,
}; };
use prost::Message;
pub struct UserClient<'a> { pub struct UserClient<'a> {
endpoint: String, endpoint: String,
@@ -45,7 +45,7 @@ impl<'a> UserClient<'a> {
r#type: "xray.proxy.vless.Account".to_string(), r#type: "xray.proxy.vless.Account".to_string(),
value: account.encode_to_vec(), value: account.encode_to_vec(),
} }
}, }
"vmess" => { "vmess" => {
let account = VmessAccount { let account = VmessAccount {
id: user_id, id: user_id,
@@ -56,7 +56,7 @@ impl<'a> UserClient<'a> {
r#type: "xray.proxy.vmess.Account".to_string(), r#type: "xray.proxy.vmess.Account".to_string(),
value: account.encode_to_vec(), value: account.encode_to_vec(),
} }
}, }
"trojan" => { "trojan" => {
let account = TrojanAccount { let account = TrojanAccount {
password: user_id, // For trojan, use password instead of UUID password: user_id, // For trojan, use password instead of UUID
@@ -65,7 +65,7 @@ impl<'a> UserClient<'a> {
r#type: "xray.proxy.trojan.Account".to_string(), r#type: "xray.proxy.trojan.Account".to_string(),
value: account.encode_to_vec(), value: account.encode_to_vec(),
} }
}, }
_ => { _ => {
return Err(anyhow!("Unsupported protocol for user: {}", protocol)); return Err(anyhow!("Unsupported protocol for user: {}", protocol));
} }
@@ -94,7 +94,6 @@ impl<'a> UserClient<'a> {
operation: Some(typed_message), operation: Some(typed_message),
}); });
let mut handler_client = self.client.handler(); let mut handler_client = self.client.handler();
match handler_client.alter_inbound(request).await { match handler_client.alter_inbound(request).await {
Ok(response) => { Ok(response) => {
@@ -102,16 +101,25 @@ impl<'a> UserClient<'a> {
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
tracing::error!("gRPC error adding user '{}' to inbound '{}': status={}, message={}", tracing::error!(
email, inbound_tag, e.code(), e.message()); "gRPC error adding user '{}' to inbound '{}': status={}, message={}",
Err(anyhow!("Failed to add user '{}' to inbound '{}': {}", email, inbound_tag, e)) email,
inbound_tag,
e.code(),
e.message()
);
Err(anyhow!(
"Failed to add user '{}' to inbound '{}': {}",
email,
inbound_tag,
e
))
} }
} }
} }
/// Remove user from inbound /// Remove user from inbound
pub async fn remove_user(&self, inbound_tag: &str, email: &str) -> Result<()> { pub async fn remove_user(&self, inbound_tag: &str, email: &str) -> Result<()> {
// Build the RemoveUserOperation // Build the RemoveUserOperation
let remove_user_op = RemoveUserOperation { let remove_user_op = RemoveUserOperation {
email: email.to_string(), email: email.to_string(),
@@ -129,12 +137,20 @@ impl<'a> UserClient<'a> {
let mut handler_client = self.client.handler(); let mut handler_client = self.client.handler();
match handler_client.alter_inbound(request).await { match handler_client.alter_inbound(request).await {
Ok(_) => { Ok(_) => Ok(()),
Ok(())
}
Err(e) => { Err(e) => {
tracing::error!("Failed to remove user '{}' from inbound '{}': {}", email, inbound_tag, e); tracing::error!(
Err(anyhow!("Failed to remove user '{}' from inbound '{}': {}", email, inbound_tag, e)) "Failed to remove user '{}' from inbound '{}': {}",
email,
inbound_tag,
e
);
Err(anyhow!(
"Failed to remove user '{}' from inbound '{}': {}",
email,
inbound_tag,
e
))
} }
} }
} }

View File

@@ -1,3 +1,8 @@
use crate::{
database::{entities::certificate, repository::CertificateRepository},
services::certificates::CertificateService,
web::AppState,
};
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
@@ -6,14 +11,6 @@ use axum::{
}; };
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
use crate::{
database::{
entities::certificate,
repository::CertificateRepository,
},
services::certificates::CertificateService,
web::AppState,
};
/// List all certificates /// List all certificates
pub async fn list_certificates( pub async fn list_certificates(
@@ -23,10 +20,8 @@ pub async fn list_certificates(
match repo.find_all().await { match repo.find_all().await {
Ok(certificates) => { Ok(certificates) => {
let responses: Vec<certificate::CertificateResponse> = certificates let responses: Vec<certificate::CertificateResponse> =
.into_iter() certificates.into_iter().map(|c| c.into()).collect();
.map(|c| c.into())
.collect();
Ok(Json(responses)) Ok(Json(responses))
} }
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -72,56 +67,78 @@ pub async fn create_certificate(
// Generate certificate based on type // Generate certificate based on type
let (cert_pem, private_key) = match cert_data.cert_type.as_str() { let (cert_pem, private_key) = match cert_data.cert_type.as_str() {
"self_signed" => { "self_signed" => cert_service
cert_service.generate_self_signed(&cert_data.domain).await .generate_self_signed(&cert_data.domain)
.map_err(|e| { .await
tracing::error!("Failed to generate self-signed certificate: {:?}", e); .map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ tracing::error!("Failed to generate self-signed certificate: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Failed to generate self-signed certificate", "error": "Failed to generate self-signed certificate",
"details": format!("{:?}", e) "details": format!("{:?}", e)
}))) })),
})? )
} })?,
"letsencrypt" => { "letsencrypt" => {
// Validate required fields for Let's Encrypt // Validate required fields for Let's Encrypt
let dns_provider_id = cert_data.dns_provider_id let dns_provider_id = cert_data.dns_provider_id.ok_or((
.ok_or((StatusCode::BAD_REQUEST, Json(json!({ StatusCode::BAD_REQUEST,
Json(json!({
"error": "DNS provider ID is required for Let's Encrypt certificates" "error": "DNS provider ID is required for Let's Encrypt certificates"
}))))?; })),
let acme_email = cert_data.acme_email ))?;
.as_ref() let acme_email = cert_data.acme_email.as_ref().ok_or((
.ok_or((StatusCode::BAD_REQUEST, Json(json!({ StatusCode::BAD_REQUEST,
Json(json!({
"error": "ACME email is required for Let's Encrypt certificates" "error": "ACME email is required for Let's Encrypt certificates"
}))))?; })),
))?;
let cert_service = CertificateService::with_db(app_state.db.connection().clone()); let cert_service = CertificateService::with_db(app_state.db.connection().clone());
cert_service.generate_letsencrypt_certificate( cert_service
&cert_data.domain, .generate_letsencrypt_certificate(
dns_provider_id, &cert_data.domain,
acme_email, dns_provider_id,
false // production by default acme_email,
).await false, // production by default
)
.await
.map_err(|e| { .map_err(|e| {
tracing::error!("Failed to generate Let's Encrypt certificate: {:?}", e); tracing::error!("Failed to generate Let's Encrypt certificate: {:?}", e);
// Return a more detailed error response // Return a more detailed error response
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ (
"error": "Failed to generate Let's Encrypt certificate", StatusCode::INTERNAL_SERVER_ERROR,
"details": format!("{:?}", e) Json(json!({
}))) "error": "Failed to generate Let's Encrypt certificate",
"details": format!("{:?}", e)
})),
)
})? })?
} }
"imported" => { "imported" => {
// For imported certificates, use provided PEM data // For imported certificates, use provided PEM data
if cert_data.certificate_pem.is_empty() || cert_data.private_key.is_empty() { if cert_data.certificate_pem.is_empty() || cert_data.private_key.is_empty() {
return Err((StatusCode::BAD_REQUEST, Json(json!({ return Err((
"error": "Certificate PEM and private key are required for imported certificates" StatusCode::BAD_REQUEST,
})))); Json(json!({
"error": "Certificate PEM and private key are required for imported certificates"
})),
));
} }
(cert_data.certificate_pem.clone(), cert_data.private_key.clone()) (
cert_data.certificate_pem.clone(),
cert_data.private_key.clone(),
)
}
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "Invalid certificate type. Supported types: self_signed, letsencrypt, imported"
})),
))
} }
_ => return Err((StatusCode::BAD_REQUEST, Json(json!({
"error": "Invalid certificate type. Supported types: self_signed, letsencrypt, imported"
})))),
}; };
// Create certificate with generated data // Create certificate with generated data
@@ -133,10 +150,13 @@ pub async fn create_certificate(
Ok(certificate) => Ok(Json(certificate.into())), Ok(certificate) => Ok(Json(certificate.into())),
Err(e) => { Err(e) => {
tracing::error!("Failed to save certificate to database: {:?}", e); tracing::error!("Failed to save certificate to database: {:?}", e);
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ Err((
"error": "Failed to save certificate to database", StatusCode::INTERNAL_SERVER_ERROR,
"details": format!("{:?}", e) Json(json!({
})))) "error": "Failed to save certificate to database",
"details": format!("{:?}", e)
})),
))
} }
} }
} }
@@ -178,10 +198,8 @@ pub async fn get_expiring_certificates(
// Get certificates expiring in next 30 days // Get certificates expiring in next 30 days
match repo.find_expiring_soon(30).await { match repo.find_expiring_soon(30).await {
Ok(certificates) => { Ok(certificates) => {
let responses: Vec<certificate::CertificateResponse> = certificates let responses: Vec<certificate::CertificateResponse> =
.into_iter() certificates.into_iter().map(|c| c.into()).collect();
.map(|c| c.into())
.collect();
Ok(Json(responses)) Ok(Json(responses))
} }
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),

View File

@@ -36,14 +36,16 @@ pub async fn get_user_inbound_config(
let uri_service = UriGeneratorService::new(); let uri_service = UriGeneratorService::new();
// Get client configuration data // Get client configuration data
let config_data = repo.get_client_config_data(user_id, inbound_id) let config_data = repo
.get_client_config_data(user_id, inbound_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let config_data = config_data.ok_or(StatusCode::NOT_FOUND)?; let config_data = config_data.ok_or(StatusCode::NOT_FOUND)?;
// Generate URI // Generate URI
let client_config = uri_service.generate_client_config(user_id, &config_data) let client_config = uri_service
.generate_client_config(user_id, &config_data)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let response = ClientConfigResponse { let response = ClientConfigResponse {
@@ -67,7 +69,8 @@ pub async fn get_user_configs(
let uri_service = UriGeneratorService::new(); let uri_service = UriGeneratorService::new();
// Get all client configuration data for user // Get all client configuration data for user
let configs_data = repo.get_all_client_configs_for_user(user_id) let configs_data = repo
.get_all_client_configs_for_user(user_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -84,7 +87,7 @@ pub async fn get_user_configs(
uri: client_config.uri, uri: client_config.uri,
qr_code: client_config.qr_code, qr_code: client_config.qr_code,
}); });
}, }
Err(_) => { Err(_) => {
// Log error but continue with other configs // Log error but continue with other configs
continue; continue;
@@ -104,7 +107,8 @@ pub async fn get_inbound_configs(
let uri_service = UriGeneratorService::new(); let uri_service = UriGeneratorService::new();
// Get all users for this inbound // Get all users for this inbound
let inbound_users = repo.find_active_by_inbound_id(inbound_id) let inbound_users = repo
.find_active_by_inbound_id(inbound_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -112,7 +116,10 @@ pub async fn get_inbound_configs(
for inbound_user in inbound_users { for inbound_user in inbound_users {
// Get client configuration data for each user // Get client configuration data for each user
if let Ok(Some(config_data)) = repo.get_client_config_data(inbound_user.user_id, inbound_id).await { if let Ok(Some(config_data)) = repo
.get_client_config_data(inbound_user.user_id, inbound_id)
.await
{
match uri_service.generate_client_config(inbound_user.user_id, &config_data) { match uri_service.generate_client_config(inbound_user.user_id, &config_data) {
Ok(client_config) => { Ok(client_config) => {
responses.push(ClientConfigResponse { responses.push(ClientConfigResponse {
@@ -123,7 +130,7 @@ pub async fn get_inbound_configs(
uri: client_config.uri, uri: client_config.uri,
qr_code: client_config.qr_code, qr_code: client_config.qr_code,
}); });
}, }
Err(_) => { Err(_) => {
// Log error but continue with other configs // Log error but continue with other configs
continue; continue;

View File

@@ -8,7 +8,7 @@ use uuid::Uuid;
use crate::{ use crate::{
database::{ database::{
entities::dns_provider::{ entities::dns_provider::{
CreateDnsProviderDto, UpdateDnsProviderDto, DnsProviderResponseDto, CreateDnsProviderDto, DnsProviderResponseDto, UpdateDnsProviderDto,
}, },
repository::DnsProviderRepository, repository::DnsProviderRepository,
}, },
@@ -34,10 +34,8 @@ pub async fn list_dns_providers(
match repo.find_all().await { match repo.find_all().await {
Ok(providers) => { Ok(providers) => {
let responses: Vec<DnsProviderResponseDto> = providers let responses: Vec<DnsProviderResponseDto> =
.into_iter() providers.into_iter().map(|p| p.to_response_dto()).collect();
.map(|p| p.to_response_dto())
.collect();
Ok(Json(responses)) Ok(Json(responses))
} }
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -91,10 +89,8 @@ pub async fn list_active_cloudflare_providers(
match repo.find_active_by_type("cloudflare").await { match repo.find_active_by_type("cloudflare").await {
Ok(providers) => { Ok(providers) => {
let responses: Vec<DnsProviderResponseDto> = providers let responses: Vec<DnsProviderResponseDto> =
.into_iter() providers.into_iter().map(|p| p.to_response_dto()).collect();
.map(|p| p.to_response_dto())
.collect();
Ok(Json(responses)) Ok(Json(responses))
} }
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),

View File

@@ -1,21 +1,21 @@
pub mod users;
pub mod servers;
pub mod certificates; pub mod certificates;
pub mod templates;
pub mod client_configs; pub mod client_configs;
pub mod dns_providers; pub mod dns_providers;
pub mod servers;
pub mod subscription;
pub mod tasks; pub mod tasks;
pub mod telegram; pub mod telegram;
pub mod templates;
pub mod user_requests; pub mod user_requests;
pub mod subscription; pub mod users;
pub use users::*;
pub use servers::*;
pub use certificates::*; pub use certificates::*;
pub use templates::*;
pub use client_configs::*; pub use client_configs::*;
pub use dns_providers::*; pub use dns_providers::*;
pub use servers::*;
pub use subscription::*;
pub use tasks::*; pub use tasks::*;
pub use telegram::*; pub use telegram::*;
pub use templates::*;
pub use user_requests::*; pub use user_requests::*;
pub use subscription::*; pub use users::*;

View File

@@ -1,3 +1,13 @@
use crate::{
database::{
entities::{server, server_inbound},
repository::{
CertificateRepository, InboundTemplateRepository, InboundUsersRepository,
ServerInboundRepository, ServerRepository, UserRepository,
},
},
web::AppState,
};
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
@@ -5,13 +15,6 @@ use axum::{
Json as JsonExtractor, Json as JsonExtractor,
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{
database::{
entities::{server, server_inbound},
repository::{ServerRepository, ServerInboundRepository, InboundTemplateRepository, CertificateRepository, InboundUsersRepository, UserRepository},
},
web::AppState,
};
/// List all servers /// List all servers
pub async fn list_servers( pub async fn list_servers(
@@ -21,10 +24,8 @@ pub async fn list_servers(
match repo.find_all().await { match repo.find_all().await {
Ok(servers) => { Ok(servers) => {
let responses: Vec<server::ServerResponse> = servers let responses: Vec<server::ServerResponse> =
.into_iter() servers.into_iter().map(|s| s.into()).collect();
.map(|s| s.into())
.collect();
Ok(Json(responses)) Ok(Json(responses))
} }
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -121,7 +122,7 @@ pub async fn test_server_connection(
"connected": connected, "connected": connected,
"endpoint": endpoint "endpoint": endpoint
}))) })))
}, }
Err(e) => { Err(e) => {
// Update status to error // Update status to error
let update_dto = server::UpdateServerDto { let update_dto = server::UpdateServerDto {
@@ -141,7 +142,7 @@ pub async fn test_server_connection(
"endpoint": endpoint, "endpoint": endpoint,
"error": e.to_string() "error": e.to_string()
}))) })))
}, }
} }
} }
@@ -207,14 +208,17 @@ pub async fn create_server_inbound(
}; };
// Create inbound in database first with protocol-aware tag // Create inbound in database first with protocol-aware tag
let inbound = match inbound_repo.create_with_protocol(server_id, inbound_data, &template.protocol).await { let inbound = match inbound_repo
.create_with_protocol(server_id, inbound_data, &template.protocol)
.await
{
Ok(inbound) => { Ok(inbound) => {
// Send sync event for immediate synchronization // Send sync event for immediate synchronization
crate::services::events::send_sync_event( crate::services::events::send_sync_event(
crate::services::events::SyncEvent::InboundChanged(server_id) crate::services::events::SyncEvent::InboundChanged(server_id),
); );
inbound inbound
}, }
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
}; };
@@ -224,13 +228,11 @@ pub async fn create_server_inbound(
// Get certificate data if certificate is specified // Get certificate data if certificate is specified
let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id { let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id {
match cert_repo.find_by_id(cert_id).await { match cert_repo.find_by_id(cert_id).await {
Ok(Some(cert)) => { Ok(Some(cert)) => (Some(cert.certificate_pem()), Some(cert.private_key_pem())),
(Some(cert.certificate_pem()), Some(cert.private_key_pem()))
},
Ok(None) => { Ok(None) => {
tracing::warn!("Certificate {} not found", cert_id); tracing::warn!("Certificate {} not found", cert_id);
(None, None) (None, None)
}, }
Err(e) => { Err(e) => {
tracing::error!("Error fetching certificate {}: {}", cert_id, e); tracing::error!("Error fetching certificate {}: {}", cert_id, e);
(None, None) (None, None)
@@ -240,22 +242,31 @@ pub async fn create_server_inbound(
(None, None) (None, None)
}; };
match app_state.xray_service.create_inbound_with_certificate( match app_state
server_id, .xray_service
&endpoint, .create_inbound_with_certificate(
&inbound.tag, server_id,
inbound.port_override.unwrap_or(template.default_port), &endpoint,
&template.protocol, &inbound.tag,
template.base_settings.clone(), inbound.port_override.unwrap_or(template.default_port),
template.stream_settings.clone(), &template.protocol,
cert_pem.as_deref(), template.base_settings.clone(),
key_pem.as_deref(), template.stream_settings.clone(),
).await { cert_pem.as_deref(),
key_pem.as_deref(),
)
.await
{
Ok(_) => { Ok(_) => {
tracing::info!("Created inbound '{}' on {}", inbound.tag, endpoint); tracing::info!("Created inbound '{}' on {}", inbound.tag, endpoint);
}, }
Err(e) => { Err(e) => {
tracing::error!("Failed to create inbound '{}' on {}: {}", inbound.tag, endpoint, e); tracing::error!(
"Failed to create inbound '{}' on {}: {}",
inbound.tag,
endpoint,
e
);
// Note: We don't fail the request since the inbound is already in DB // Note: We don't fail the request since the inbound is already in DB
// The user can manually sync or retry later // The user can manually sync or retry later
} }
@@ -273,7 +284,11 @@ pub async fn update_server_inbound(
Path((server_id, inbound_id)): Path<(Uuid, Uuid)>, Path((server_id, inbound_id)): Path<(Uuid, Uuid)>,
JsonExtractor(inbound_data): JsonExtractor<server_inbound::UpdateServerInboundDto>, JsonExtractor(inbound_data): JsonExtractor<server_inbound::UpdateServerInboundDto>,
) -> Result<Json<server_inbound::ServerInboundResponse>, StatusCode> { ) -> Result<Json<server_inbound::ServerInboundResponse>, StatusCode> {
tracing::debug!("Updating server inbound {} for server {}", inbound_id, server_id); tracing::debug!(
"Updating server inbound {} for server {}",
inbound_id,
server_id
);
let server_repo = ServerRepository::new(app_state.db.connection().clone()); let server_repo = ServerRepository::new(app_state.db.connection().clone());
let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone()); let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone());
@@ -303,12 +318,24 @@ pub async fn update_server_inbound(
// Handle xray server changes based on active status change // Handle xray server changes based on active status change
if old_is_active && !new_is_active { if old_is_active && !new_is_active {
// Becoming inactive - remove from xray server // Becoming inactive - remove from xray server
match app_state.xray_service.remove_inbound(server_id, &endpoint, &current_inbound.tag).await { match app_state
.xray_service
.remove_inbound(server_id, &endpoint, &current_inbound.tag)
.await
{
Ok(_) => { Ok(_) => {
tracing::info!("Deactivated inbound '{}' on {}", current_inbound.tag, endpoint); tracing::info!(
}, "Deactivated inbound '{}' on {}",
current_inbound.tag,
endpoint
);
}
Err(e) => { Err(e) => {
tracing::error!("Failed to deactivate inbound '{}': {}", current_inbound.tag, e); tracing::error!(
"Failed to deactivate inbound '{}': {}",
current_inbound.tag,
e
);
// Continue with database update even if xray removal fails // Continue with database update even if xray removal fails
} }
} }
@@ -323,19 +350,23 @@ pub async fn update_server_inbound(
}; };
// Use updated port if provided, otherwise keep current // Use updated port if provided, otherwise keep current
let port = inbound_data.port_override.unwrap_or(current_inbound.port_override.unwrap_or(template.default_port)); let port = inbound_data.port_override.unwrap_or(
current_inbound
.port_override
.unwrap_or(template.default_port),
);
// Get certificate data if certificate is specified (could be updated) // Get certificate data if certificate is specified (could be updated)
let certificate_id = inbound_data.certificate_id.or(current_inbound.certificate_id); let certificate_id = inbound_data
.certificate_id
.or(current_inbound.certificate_id);
let (cert_pem, key_pem) = if let Some(cert_id) = certificate_id { let (cert_pem, key_pem) = if let Some(cert_id) = certificate_id {
match cert_repo.find_by_id(cert_id).await { match cert_repo.find_by_id(cert_id).await {
Ok(Some(cert)) => { Ok(Some(cert)) => (Some(cert.certificate_pem()), Some(cert.private_key_pem())),
(Some(cert.certificate_pem()), Some(cert.private_key_pem()))
},
Ok(None) => { Ok(None) => {
tracing::warn!("Certificate {} not found", cert_id); tracing::warn!("Certificate {} not found", cert_id);
(None, None) (None, None)
}, }
Err(e) => { Err(e) => {
tracing::error!("Error fetching certificate {}: {}", cert_id, e); tracing::error!("Error fetching certificate {}: {}", cert_id, e);
(None, None) (None, None)
@@ -345,22 +376,34 @@ pub async fn update_server_inbound(
(None, None) (None, None)
}; };
match app_state.xray_service.create_inbound_with_certificate( match app_state
server_id, .xray_service
&endpoint, .create_inbound_with_certificate(
&current_inbound.tag, server_id,
port, &endpoint,
&template.protocol, &current_inbound.tag,
template.base_settings.clone(), port,
template.stream_settings.clone(), &template.protocol,
cert_pem.as_deref(), template.base_settings.clone(),
key_pem.as_deref(), template.stream_settings.clone(),
).await { cert_pem.as_deref(),
key_pem.as_deref(),
)
.await
{
Ok(_) => { Ok(_) => {
tracing::info!("Activated inbound '{}' on {}", current_inbound.tag, endpoint); tracing::info!(
}, "Activated inbound '{}' on {}",
current_inbound.tag,
endpoint
);
}
Err(e) => { Err(e) => {
tracing::error!("Failed to activate inbound '{}': {}", current_inbound.tag, e); tracing::error!(
"Failed to activate inbound '{}': {}",
current_inbound.tag,
e
);
// Continue with database update even if xray creation fails // Continue with database update even if xray creation fails
} }
} }
@@ -371,10 +414,10 @@ pub async fn update_server_inbound(
Ok(updated_inbound) => { Ok(updated_inbound) => {
// Send sync event for immediate synchronization // Send sync event for immediate synchronization
crate::services::events::send_sync_event( crate::services::events::send_sync_event(
crate::services::events::SyncEvent::InboundChanged(server_id) crate::services::events::SyncEvent::InboundChanged(server_id),
); );
Ok(Json(updated_inbound.into())) Ok(Json(updated_inbound.into()))
}, }
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
} }
} }
@@ -388,9 +431,7 @@ pub async fn get_server_inbound(
// Verify the inbound belongs to the server // Verify the inbound belongs to the server
match repo.find_by_id(inbound_id).await { match repo.find_by_id(inbound_id).await {
Ok(Some(inbound)) if inbound.server_id == server_id => { Ok(Some(inbound)) if inbound.server_id == server_id => Ok(Json(inbound.into())),
Ok(Json(inbound.into()))
}
Ok(Some(_)) => Err(StatusCode::BAD_REQUEST), Ok(Some(_)) => Err(StatusCode::BAD_REQUEST),
Ok(None) => Err(StatusCode::NOT_FOUND), Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -422,12 +463,21 @@ pub async fn delete_server_inbound(
// Try to remove inbound from xray server first // Try to remove inbound from xray server first
let endpoint = server.get_grpc_endpoint(); let endpoint = server.get_grpc_endpoint();
match app_state.xray_service.remove_inbound(server_id, &endpoint, &inbound.tag).await { match app_state
.xray_service
.remove_inbound(server_id, &endpoint, &inbound.tag)
.await
{
Ok(_) => { Ok(_) => {
tracing::info!("Removed inbound '{}' from {}", inbound.tag, endpoint); tracing::info!("Removed inbound '{}' from {}", inbound.tag, endpoint);
}, }
Err(e) => { Err(e) => {
tracing::error!("Failed to remove inbound '{}' from {}: {}", inbound.tag, endpoint, e); tracing::error!(
"Failed to remove inbound '{}' from {}: {}",
inbound.tag,
endpoint,
e
);
// Continue with database deletion even if xray removal fails // Continue with database deletion even if xray removal fails
} }
} }
@@ -437,10 +487,10 @@ pub async fn delete_server_inbound(
Ok(true) => { Ok(true) => {
// Send sync event for immediate synchronization // Send sync event for immediate synchronization
crate::services::events::send_sync_event( crate::services::events::send_sync_event(
crate::services::events::SyncEvent::InboundChanged(server_id) crate::services::events::SyncEvent::InboundChanged(server_id),
); );
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
}, }
Ok(false) => Err(StatusCode::NOT_FOUND), Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
} }
@@ -479,16 +529,17 @@ pub async fn add_user_to_inbound(
// Extract user data // Extract user data
let user_name = user_data["name"].as_str() let user_name = user_data["name"]
.as_str()
.or_else(|| user_data["username"].as_str()) .or_else(|| user_data["username"].as_str())
.or_else(|| user_data["email"].as_str()) .or_else(|| user_data["email"].as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|| { .unwrap_or_else(|| format!("user_{}", Uuid::new_v4().to_string()[..8].to_string()));
format!("user_{}", Uuid::new_v4().to_string()[..8].to_string())
});
let level = user_data["level"].as_u64().unwrap_or(0) as i32; let level = user_data["level"].as_u64().unwrap_or(0) as i32;
let user_id = user_data["user_id"].as_str().and_then(|s| Uuid::parse_str(s).ok()); let user_id = user_data["user_id"]
.as_str()
.and_then(|s| Uuid::parse_str(s).ok());
// Get or create user // Get or create user
let user = if let Some(uid) = user_id { let user = if let Some(uid) = user_id {
@@ -520,7 +571,11 @@ pub async fn add_user_to_inbound(
let inbound_users_repo = InboundUsersRepository::new(app_state.db.connection().clone()); let inbound_users_repo = InboundUsersRepository::new(app_state.db.connection().clone());
// Check if user already has access to this inbound // Check if user already has access to this inbound
if inbound_users_repo.user_has_access_to_inbound(user.id, inbound_id).await.unwrap_or(false) { if inbound_users_repo
.user_has_access_to_inbound(user.id, inbound_id)
.await
.unwrap_or(false)
{
tracing::warn!("User '{}' already has access to inbound", user.name); tracing::warn!("User '{}' already has access to inbound", user.name);
return Err(StatusCode::CONFLICT); return Err(StatusCode::CONFLICT);
} }
@@ -535,16 +590,19 @@ pub async fn add_user_to_inbound(
// Grant access in database // Grant access in database
match inbound_users_repo.create(inbound_user_dto).await { match inbound_users_repo.create(inbound_user_dto).await {
Ok(created_access) => { Ok(created_access) => {
tracing::info!("Granted user '{}' access to inbound (xray_id={})", tracing::info!(
user.name, created_access.xray_user_id); "Granted user '{}' access to inbound (xray_id={})",
user.name,
created_access.xray_user_id
);
// Send sync event for immediate synchronization // Send sync event for immediate synchronization
crate::services::events::send_sync_event( crate::services::events::send_sync_event(
crate::services::events::SyncEvent::UserAccessChanged(server_id) crate::services::events::SyncEvent::UserAccessChanged(server_id),
); );
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
}, }
Err(e) => { Err(e) => {
tracing::error!("Failed to grant user '{}' access: {}", user.name, e); tracing::error!("Failed to grant user '{}' access: {}", user.name, e);
Err(StatusCode::INTERNAL_SERVER_ERROR) Err(StatusCode::INTERNAL_SERVER_ERROR)
@@ -589,11 +647,15 @@ pub async fn remove_user_from_inbound(
let inbound_tag = &inbound.tag; let inbound_tag = &inbound.tag;
// Remove user from xray server // Remove user from xray server
match app_state.xray_service.remove_user(server_id, &server.get_grpc_endpoint(), &inbound_tag, &email).await { match app_state
.xray_service
.remove_user(server_id, &server.get_grpc_endpoint(), &inbound_tag, &email)
.await
{
Ok(_) => { Ok(_) => {
tracing::info!("Removed user '{}' from inbound", email); tracing::info!("Removed user '{}' from inbound", email);
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
}, }
Err(e) => { Err(e) => {
tracing::error!("Failed to remove user '{}' from inbound: {}", email, e); tracing::error!("Failed to remove user '{}' from inbound: {}", email, e);
Err(StatusCode::INTERNAL_SERVER_ERROR) Err(StatusCode::INTERNAL_SERVER_ERROR)

View File

@@ -1,13 +1,13 @@
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::{StatusCode, HeaderMap, HeaderValue}, http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use base64::{Engine, engine::general_purpose}; use base64::{engine::general_purpose, Engine};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database::repository::{UserRepository, InboundUsersRepository}, database::repository::{InboundUsersRepository, UserRepository},
services::uri_generator::UriGeneratorService, services::uri_generator::UriGeneratorService,
web::AppState, web::AppState,
}; };
@@ -30,7 +30,10 @@ pub async fn get_user_subscription(
}; };
// Get all client config data for the user (this gets all active inbound accesses) // Get all client config data for the user (this gets all active inbound accesses)
let all_configs = match inbound_users_repo.get_all_client_configs_for_user(user_id).await { let all_configs = match inbound_users_repo
.get_all_client_configs_for_user(user_id)
.await
{
Ok(configs) => configs, Ok(configs) => configs,
Err(e) => { Err(e) => {
tracing::error!("Failed to get client configs for user {}: {}", user_id, e); tracing::error!("Failed to get client configs for user {}: {}", user_id, e);
@@ -45,7 +48,8 @@ pub async fn get_user_subscription(
StatusCode::OK, StatusCode::OK,
[("content-type", "text/plain; charset=utf-8")], [("content-type", "text/plain; charset=utf-8")],
response_base64, response_base64,
).into_response()); )
.into_response());
} }
let mut config_lines = Vec::new(); let mut config_lines = Vec::new();
@@ -57,12 +61,20 @@ pub async fn get_user_subscription(
match uri_generator.generate_client_config(user_id, &config_data) { match uri_generator.generate_client_config(user_id, &config_data) {
Ok(client_config) => { Ok(client_config) => {
config_lines.push(client_config.uri); config_lines.push(client_config.uri);
tracing::debug!("Generated {} config for user {}: {}", tracing::debug!(
config_data.protocol.to_uppercase(), user.name, config_data.template_name); "Generated {} config for user {}: {}",
config_data.protocol.to_uppercase(),
user.name,
config_data.template_name
);
} }
Err(e) => { Err(e) => {
tracing::warn!("Failed to generate connection string for user {} template {}: {}", tracing::warn!(
user.name, config_data.template_name, e); "Failed to generate connection string for user {} template {}: {}",
user.name,
config_data.template_name,
e
);
continue; continue;
} }
} }
@@ -75,7 +87,8 @@ pub async fn get_user_subscription(
StatusCode::OK, StatusCode::OK,
[("content-type", "text/plain; charset=utf-8")], [("content-type", "text/plain; charset=utf-8")],
response_base64, response_base64,
).into_response()); )
.into_response());
} }
// Join all URIs with newlines (like Django implementation) // Join all URIs with newlines (like Django implementation)
@@ -88,22 +101,42 @@ pub async fn get_user_subscription(
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
// Add headers required by VPN clients // Add headers required by VPN clients
headers.insert("content-type", HeaderValue::from_static("text/plain; charset=utf-8")); headers.insert(
headers.insert("content-disposition", HeaderValue::from_str(&format!("attachment; filename=\"{}\"", user.name)).unwrap()); "content-type",
HeaderValue::from_static("text/plain; charset=utf-8"),
);
headers.insert(
"content-disposition",
HeaderValue::from_str(&format!("attachment; filename=\"{}\"", user.name)).unwrap(),
);
headers.insert("cache-control", HeaderValue::from_static("no-cache")); headers.insert("cache-control", HeaderValue::from_static("no-cache"));
// Profile information // Profile information
let profile_title = general_purpose::STANDARD.encode("OutFleet VPN"); let profile_title = general_purpose::STANDARD.encode("OutFleet VPN");
headers.insert("profile-title", HeaderValue::from_str(&format!("base64:{}", profile_title)).unwrap()); headers.insert(
"profile-title",
HeaderValue::from_str(&format!("base64:{}", profile_title)).unwrap(),
);
headers.insert("profile-update-interval", HeaderValue::from_static("24")); headers.insert("profile-update-interval", HeaderValue::from_static("24"));
headers.insert("profile-web-page-url", HeaderValue::from_str(&format!("{}/u/{}", state.config.web.base_url, user_id)).unwrap()); headers.insert(
headers.insert("support-url", HeaderValue::from_str(&format!("{}/admin/", state.config.web.base_url)).unwrap()); "profile-web-page-url",
HeaderValue::from_str(&format!("{}/u/{}", state.config.web.base_url, user_id)).unwrap(),
);
headers.insert(
"support-url",
HeaderValue::from_str(&format!("{}/admin/", state.config.web.base_url)).unwrap(),
);
// Subscription info (unlimited service) // Subscription info (unlimited service)
let expire_timestamp = chrono::Utc::now().timestamp() + (365 * 24 * 60 * 60); // 1 year from now let expire_timestamp = chrono::Utc::now().timestamp() + (365 * 24 * 60 * 60); // 1 year from now
headers.insert("subscription-userinfo", headers.insert(
HeaderValue::from_str(&format!("upload=0; download=0; total=1099511627776; expire={}", expire_timestamp)).unwrap()); "subscription-userinfo",
HeaderValue::from_str(&format!(
"upload=0; download=0; total=1099511627776; expire={}",
expire_timestamp
))
.unwrap(),
);
Ok((StatusCode::OK, headers, response_base64).into_response()) Ok((StatusCode::OK, headers, response_base64).into_response())
} }

View File

@@ -1,8 +1,4 @@
use axum::{ use axum::{extract::State, http::StatusCode, response::Json};
extract::State,
http::StatusCode,
response::Json,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -58,8 +54,16 @@ pub async fn get_tasks_status(
description: "Synchronizes database state with xray servers".to_string(), description: "Synchronizes database state with xray servers".to_string(),
schedule: "0 */5 * * * * (every 5 minutes)".to_string(), schedule: "0 */5 * * * * (every 5 minutes)".to_string(),
status: "Success".to_string(), status: "Success".to_string(),
last_run: Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string()), last_run: Some(
next_run: Some((chrono::Utc::now() + chrono::Duration::minutes(5)).format("%Y-%m-%d %H:%M:%S UTC").to_string()), chrono::Utc::now()
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
),
next_run: Some(
(chrono::Utc::now() + chrono::Duration::minutes(5))
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
),
total_runs: 120, total_runs: 120,
success_count: 118, success_count: 118,
error_count: 2, error_count: 2,
@@ -72,8 +76,16 @@ pub async fn get_tasks_status(
description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(), description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(),
schedule: "0 0 2 * * * (daily at 2 AM)".to_string(), schedule: "0 0 2 * * * (daily at 2 AM)".to_string(),
status: "Idle".to_string(), status: "Idle".to_string(),
last_run: Some((chrono::Utc::now() - chrono::Duration::hours(8)).format("%Y-%m-%d %H:%M:%S UTC").to_string()), last_run: Some(
next_run: Some((chrono::Utc::now() + chrono::Duration::hours(16)).format("%Y-%m-%d %H:%M:%S UTC").to_string()), (chrono::Utc::now() - chrono::Duration::hours(8))
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
),
next_run: Some(
(chrono::Utc::now() + chrono::Duration::hours(16))
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
),
total_runs: 5, total_runs: 5,
success_count: 5, success_count: 5,
error_count: 0, error_count: 0,
@@ -122,14 +134,10 @@ pub async fn trigger_task(
// In a real implementation, you'd trigger the actual task // In a real implementation, you'd trigger the actual task
// For now, return a success response // For now, return a success response
match task_id.as_str() { match task_id.as_str() {
"xray_sync" | "cert_renewal" => { "xray_sync" | "cert_renewal" => Ok(Json(serde_json::json!({
Ok(Json(serde_json::json!({ "success": true,
"success": true, "message": format!("Task '{}' has been triggered", task_id)
"message": format!("Task '{}' has been triggered", task_id) }))),
}))) _ => Err(StatusCode::NOT_FOUND),
}
_ => {
Err(StatusCode::NOT_FOUND)
}
} }
} }

View File

@@ -1,14 +1,16 @@
use axum::{ use axum::{
extract::{State, Path, Json}, extract::{Json, Path, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::database::entities::telegram_config::{
CreateTelegramConfigDto, UpdateTelegramConfigDto,
};
use crate::database::repository::{TelegramConfigRepository, UserRepository};
use crate::web::AppState; use crate::web::AppState;
use crate::database::repository::{UserRepository, TelegramConfigRepository};
use crate::database::entities::telegram_config::{CreateTelegramConfigDto, UpdateTelegramConfigDto};
/// Response for Telegram config /// Response for Telegram config
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -27,9 +29,7 @@ pub struct BotInfo {
} }
/// Get current Telegram configuration /// Get current Telegram configuration
pub async fn get_telegram_config( pub async fn get_telegram_config(State(state): State<AppState>) -> impl IntoResponse {
State(state): State<AppState>,
) -> impl IntoResponse {
let repo = TelegramConfigRepository::new(state.db.connection()); let repo = TelegramConfigRepository::new(state.db.connection());
match repo.get_latest().await { match repo.get_latest().await {
@@ -51,9 +51,7 @@ pub async fn get_telegram_config(
Json(response).into_response() Json(response).into_response()
} }
Ok(None) => { Ok(None) => StatusCode::NOT_FOUND.into_response(),
StatusCode::NOT_FOUND.into_response()
}
Err(e) => { Err(e) => {
tracing::error!("Failed to get telegram config: {}", e); tracing::error!("Failed to get telegram config: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response() StatusCode::INTERNAL_SERVER_ERROR.into_response()
@@ -103,9 +101,7 @@ pub async fn update_telegram_config(
Json(config).into_response() Json(config).into_response()
} }
Ok(None) => { Ok(None) => StatusCode::NOT_FOUND.into_response(),
StatusCode::NOT_FOUND.into_response()
}
Err(e) => { Err(e) => {
tracing::error!("Failed to update telegram config: {}", e); tracing::error!("Failed to update telegram config: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response() StatusCode::INTERNAL_SERVER_ERROR.into_response()
@@ -172,9 +168,7 @@ async fn get_bot_status(state: &AppState) -> Result<BotStatusResponse, String> {
} }
} }
pub async fn get_telegram_status( pub async fn get_telegram_status(State(state): State<AppState>) -> impl IntoResponse {
State(state): State<AppState>,
) -> impl IntoResponse {
match get_bot_status(&state).await { match get_bot_status(&state).await {
Ok(status) => Json(status).into_response(), Ok(status) => Json(status).into_response(),
Err(e) => { Err(e) => {
@@ -192,9 +186,7 @@ pub struct TelegramAdmin {
pub telegram_id: Option<i64>, pub telegram_id: Option<i64>,
} }
pub async fn get_telegram_admins( pub async fn get_telegram_admins(State(state): State<AppState>) -> impl IntoResponse {
State(state): State<AppState>,
) -> impl IntoResponse {
let repo = UserRepository::new(state.db.connection()); let repo = UserRepository::new(state.db.connection());
match repo.get_telegram_admins().await { match repo.get_telegram_admins().await {
@@ -229,18 +221,18 @@ pub async fn add_telegram_admin(
// Notify via Telegram if bot is running // Notify via Telegram if bot is running
if let Some(telegram_service) = &state.telegram_service { if let Some(telegram_service) = &state.telegram_service {
if let Some(telegram_id) = user.telegram_id { if let Some(telegram_id) = user.telegram_id {
let _ = telegram_service.send_message( let _ = telegram_service
telegram_id, .send_message(
"✅ You have been granted admin privileges!".to_string() telegram_id,
).await; "✅ You have been granted admin privileges!".to_string(),
)
.await;
} }
} }
Json(user).into_response() Json(user).into_response()
} }
Ok(None) => { Ok(None) => StatusCode::NOT_FOUND.into_response(),
StatusCode::NOT_FOUND.into_response()
}
Err(e) => { Err(e) => {
tracing::error!("Failed to add telegram admin: {}", e); tracing::error!("Failed to add telegram admin: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response() StatusCode::INTERNAL_SERVER_ERROR.into_response()
@@ -260,18 +252,18 @@ pub async fn remove_telegram_admin(
// Notify via Telegram if bot is running // Notify via Telegram if bot is running
if let Some(telegram_service) = &state.telegram_service { if let Some(telegram_service) = &state.telegram_service {
if let Some(telegram_id) = user.telegram_id { if let Some(telegram_id) = user.telegram_id {
let _ = telegram_service.send_message( let _ = telegram_service
telegram_id, .send_message(
"❌ Your admin privileges have been revoked.".to_string() telegram_id,
).await; "❌ Your admin privileges have been revoked.".to_string(),
)
.await;
} }
} }
Json(user).into_response() Json(user).into_response()
} }
Ok(None) => { Ok(None) => StatusCode::NOT_FOUND.into_response(),
StatusCode::NOT_FOUND.into_response()
}
Err(e) => { Err(e) => {
tracing::error!("Failed to remove telegram admin: {}", e); tracing::error!("Failed to remove telegram admin: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response() StatusCode::INTERNAL_SERVER_ERROR.into_response()

View File

@@ -1,3 +1,7 @@
use crate::{
database::{entities::inbound_template, repository::InboundTemplateRepository},
web::AppState,
};
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
@@ -5,13 +9,6 @@ use axum::{
Json as JsonExtractor, Json as JsonExtractor,
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{
database::{
entities::inbound_template,
repository::InboundTemplateRepository,
},
web::AppState,
};
/// List all inbound templates /// List all inbound templates
pub async fn list_templates( pub async fn list_templates(
@@ -21,10 +18,8 @@ pub async fn list_templates(
match repo.find_all().await { match repo.find_all().await {
Ok(templates) => { Ok(templates) => {
let responses: Vec<inbound_template::InboundTemplateResponse> = templates let responses: Vec<inbound_template::InboundTemplateResponse> =
.into_iter() templates.into_iter().map(|t| t.into()).collect();
.map(|t| t.into())
.collect();
Ok(Json(responses)) Ok(Json(responses))
} }
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),

View File

@@ -1,16 +1,16 @@
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
Json,
http::StatusCode, http::StatusCode,
Json,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database::entities::user_request::{CreateUserRequestDto, UpdateUserRequestDto, RequestStatus}, database::entities::user_request::{CreateUserRequestDto, RequestStatus, UpdateUserRequestDto},
database::repository::UserRequestRepository, database::repository::UserRequestRepository,
services::telegram::localization::{Language, LocalizationService},
web::AppState, web::AppState,
services::telegram::localization::{LocalizationService, Language},
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -23,8 +23,12 @@ pub struct RequestsQuery {
status: Option<String>, status: Option<String>,
} }
fn default_page() -> u64 { 1 } fn default_page() -> u64 {
fn default_per_page() -> u64 { 20 } 1
}
fn default_per_page() -> u64 {
20
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct RequestsResponse { pub struct RequestsResponse {
@@ -85,11 +89,20 @@ pub async fn get_requests(
let (items, total) = if let Some(status) = query.status { let (items, total) = if let Some(status) = query.status {
// Filter by status // Filter by status
match status.as_str() { match status.as_str() {
"pending" => request_repo.find_pending(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, "pending" => request_repo
_ => request_repo.find_all(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, .find_pending(query.page, query.per_page)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
_ => request_repo
.find_all(query.page, query.per_page)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
} }
} else { } else {
request_repo.find_all(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? request_repo
.find_all(query.page, query.per_page)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
}; };
let items: Vec<UserRequestResponse> = items.into_iter().map(Into::into).collect(); let items: Vec<UserRequestResponse> = items.into_iter().map(Into::into).collect();
@@ -164,7 +177,10 @@ pub async fn approve_request(
}; };
// Approve the request // Approve the request
let approved = match request_repo.approve(id, dto.response_message, admin_id).await { let approved = match request_repo
.approve(id, dto.response_message, admin_id)
.await
{
Ok(Some(approved)) => approved, Ok(Some(approved)) => approved,
Ok(None) => return Err(StatusCode::NOT_FOUND), Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -181,14 +197,29 @@ pub async fn approve_request(
// Build main menu keyboard // Build main menu keyboard
let keyboard = if is_admin { let keyboard = if is_admin {
vec![ vec![
vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "my_configs"), "my_configs")], vec![teloxide::types::InlineKeyboardButton::callback(
vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "support"), "support")], l10n.get(user_lang.clone(), "my_configs"),
vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "user_requests"), "admin_requests")], "my_configs",
)],
vec![teloxide::types::InlineKeyboardButton::callback(
l10n.get(user_lang.clone(), "support"),
"support",
)],
vec![teloxide::types::InlineKeyboardButton::callback(
l10n.get(user_lang.clone(), "user_requests"),
"admin_requests",
)],
] ]
} else { } else {
vec![ vec![
vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "my_configs"), "my_configs")], vec![teloxide::types::InlineKeyboardButton::callback(
vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "support"), "support")], l10n.get(user_lang.clone(), "my_configs"),
"my_configs",
)],
vec![teloxide::types::InlineKeyboardButton::callback(
l10n.get(user_lang.clone(), "support"),
"support",
)],
] ]
}; };
@@ -196,14 +227,14 @@ pub async fn approve_request(
let message = l10n.format(user_lang, "welcome_back", &[("name", &new_user.name)]); let message = l10n.format(user_lang, "welcome_back", &[("name", &new_user.name)]);
// Send message with keyboard // Send message with keyboard
let _ = telegram_service.send_message_with_keyboard(request.telegram_id, message, keyboard_markup).await; let _ = telegram_service
.send_message_with_keyboard(request.telegram_id, message, keyboard_markup)
.await;
} }
Ok(Json(UserRequestResponse::from(approved))) Ok(Json(UserRequestResponse::from(approved)))
} }
Err(_) => { Err(_) => Err(StatusCode::BAD_REQUEST),
Err(StatusCode::BAD_REQUEST)
}
} }
} }
@@ -243,7 +274,10 @@ pub async fn decline_request(
}; };
// Decline the request // Decline the request
let declined = match request_repo.decline(id, dto.response_message, admin_id).await { let declined = match request_repo
.decline(id, dto.response_message, admin_id)
.await
{
Ok(Some(declined)) => declined, Ok(Some(declined)) => declined,
Ok(None) => return Err(StatusCode::NOT_FOUND), Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -256,7 +290,9 @@ pub async fn decline_request(
let user_message = l10n.get(user_lang, "request_declined_notification"); let user_message = l10n.get(user_lang, "request_declined_notification");
// Send notification (ignore errors - don't fail the request) // Send notification (ignore errors - don't fail the request)
let _ = telegram_service.send_message(request.telegram_id, user_message).await; let _ = telegram_service
.send_message(request.telegram_id, user_message)
.await;
} }
Ok(Json(UserRequestResponse::from(declined))) Ok(Json(UserRequestResponse::from(declined)))
@@ -270,7 +306,9 @@ pub async fn delete_request(
let request_repo = UserRequestRepository::new(state.db.connection()); let request_repo = UserRequestRepository::new(state.db.connection());
match request_repo.delete(id).await { match request_repo.delete(id).await {
Ok(true) => Ok(Json(serde_json::json!({ "message": "User request deleted" }))), Ok(true) => Ok(Json(
serde_json::json!({ "message": "User request deleted" }),
)),
Ok(false) => Err(StatusCode::NOT_FOUND), Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
} }

View File

@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
use crate::database::entities::user::{CreateUserDto, UpdateUserDto, Model as UserModel}; use crate::database::entities::user::{CreateUserDto, Model as UserModel, UpdateUserDto};
use crate::database::repository::UserRepository; use crate::database::repository::UserRepository;
use crate::web::AppState; use crate::web::AppState;
@@ -45,8 +45,12 @@ pub struct UserResponse {
pub updated_at: chrono::DateTime<chrono::Utc>, pub updated_at: chrono::DateTime<chrono::Utc>,
} }
fn default_page() -> u64 { 1 } fn default_page() -> u64 {
fn default_per_page() -> u64 { 20 } 1
}
fn default_per_page() -> u64 {
20
}
impl From<UserModel> for UserResponse { impl From<UserModel> for UserResponse {
fn from(user: UserModel) -> Self { fn from(user: UserModel) -> Self {
@@ -68,11 +72,13 @@ pub async fn get_users(
) -> Result<Json<UsersResponse>, StatusCode> { ) -> Result<Json<UsersResponse>, StatusCode> {
let repo = UserRepository::new(app_state.db.connection().clone()); let repo = UserRepository::new(app_state.db.connection().clone());
let users = repo.get_all(query.page, query.per_page) let users = repo
.get_all(query.page, query.per_page)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let total = repo.count() let total = repo
.count()
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -114,7 +120,8 @@ pub async fn get_user(
) -> Result<Json<UserResponse>, StatusCode> { ) -> Result<Json<UserResponse>, StatusCode> {
let repo = UserRepository::new(app_state.db.connection().clone()); let repo = UserRepository::new(app_state.db.connection().clone());
let user = repo.get_by_id(id) let user = repo
.get_by_id(id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -133,7 +140,8 @@ pub async fn create_user(
// Check if telegram ID is already in use // Check if telegram ID is already in use
if let Some(telegram_id) = dto.telegram_id { if let Some(telegram_id) = dto.telegram_id {
let exists = repo.telegram_id_exists(telegram_id) let exists = repo
.telegram_id_exists(telegram_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -142,7 +150,8 @@ pub async fn create_user(
} }
} }
let user = repo.create(dto) let user = repo
.create(dto)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -159,15 +168,19 @@ pub async fn update_user(
// Check if telegram ID is already in use by another user // Check if telegram ID is already in use by another user
if let Some(telegram_id) = dto.telegram_id { if let Some(telegram_id) = dto.telegram_id {
if let Some(existing_user) = repo.get_by_telegram_id(telegram_id).await if let Some(existing_user) = repo
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? { .get_by_telegram_id(telegram_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
if existing_user.id != id { if existing_user.id != id {
return Err(StatusCode::CONFLICT); return Err(StatusCode::CONFLICT);
} }
} }
} }
let user = repo.update(id, dto) let user = repo
.update(id, dto)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -184,7 +197,8 @@ pub async fn delete_user(
) -> Result<Json<Value>, StatusCode> { ) -> Result<Json<Value>, StatusCode> {
let repo = UserRepository::new(app_state.db.connection().clone()); let repo = UserRepository::new(app_state.db.connection().clone());
let deleted = repo.delete(id) let deleted = repo
.delete(id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -230,13 +244,17 @@ pub async fn get_user_access(
if access.is_active { if access.is_active {
if let Ok(Some(config_data)) = inbound_users_repo if let Ok(Some(config_data)) = inbound_users_repo
.get_client_config_data(user_id, access.server_inbound_id) .get_client_config_data(user_id, access.server_inbound_id)
.await { .await
{
if let Ok(client_config) = uri_service.generate_client_config(user_id, &config_data) { if let Ok(client_config) =
uri_service.generate_client_config(user_id, &config_data)
{
access_json["uri"] = serde_json::Value::String(client_config.uri); access_json["uri"] = serde_json::Value::String(client_config.uri);
access_json["protocol"] = serde_json::Value::String(client_config.protocol); access_json["protocol"] = serde_json::Value::String(client_config.protocol);
access_json["server_name"] = serde_json::Value::String(client_config.server_name); access_json["server_name"] =
access_json["inbound_tag"] = serde_json::Value::String(client_config.inbound_tag); serde_json::Value::String(client_config.server_name);
access_json["inbound_tag"] =
serde_json::Value::String(client_config.inbound_tag);
} }
} }
} }
@@ -246,14 +264,16 @@ pub async fn get_user_access(
} else { } else {
response = access_list response = access_list
.into_iter() .into_iter()
.map(|access| serde_json::json!({ .map(|access| {
"id": access.id, serde_json::json!({
"user_id": access.user_id, "id": access.id,
"server_inbound_id": access.server_inbound_id, "user_id": access.user_id,
"xray_user_id": access.xray_user_id, "server_inbound_id": access.server_inbound_id,
"level": access.level, "xray_user_id": access.xray_user_id,
"is_active": access.is_active, "level": access.level,
})) "is_active": access.is_active,
})
})
.collect(); .collect();
} }

View File

@@ -1,11 +1,5 @@
use anyhow::Result; use anyhow::Result;
use axum::{ use axum::{http::StatusCode, response::Json, routing::get, serve, Router};
Router,
routing::get,
http::StatusCode,
response::Json,
serve,
};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::net::SocketAddr; use std::net::SocketAddr;
use tokio::net::TcpListener; use tokio::net::TcpListener;
@@ -13,10 +7,10 @@ use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tracing::info; use tracing::info;
use std::sync::Arc; use crate::config::{AppConfig, WebConfig};
use crate::config::{WebConfig, AppConfig};
use crate::database::DatabaseManager; use crate::database::DatabaseManager;
use crate::services::{XrayService, TelegramService}; use crate::services::{TelegramService, XrayService};
use std::sync::Arc;
pub mod handlers; pub mod handlers;
pub mod routes; pub mod routes;
@@ -33,7 +27,11 @@ pub struct AppState {
} }
/// Start the web server /// Start the web server
pub async fn start_server(db: DatabaseManager, config: AppConfig, telegram_service: Option<Arc<TelegramService>>) -> Result<()> { pub async fn start_server(
db: DatabaseManager,
config: AppConfig,
telegram_service: Option<Arc<TelegramService>>,
) -> Result<()> {
let xray_service = XrayService::new(); let xray_service = XrayService::new();
let app_state = AppState { let app_state = AppState {

View File

@@ -1,9 +1,9 @@
use axum::{ use axum::{
routing::{delete, get, post, put},
Router, Router,
routing::{get, post, put, delete},
}; };
use crate::web::{AppState, handlers}; use crate::web::{handlers, AppState};
pub mod servers; pub mod servers;
@@ -25,22 +25,37 @@ fn user_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(handlers::get_users).post(handlers::create_user)) .route("/", get(handlers::get_users).post(handlers::create_user))
.route("/search", get(handlers::search_users)) .route("/search", get(handlers::search_users))
.route("/:id", get(handlers::get_user) .route(
.put(handlers::update_user) "/:id",
.delete(handlers::delete_user)) get(handlers::get_user)
.put(handlers::update_user)
.delete(handlers::delete_user),
)
.route("/:id/access", get(handlers::get_user_access)) .route("/:id/access", get(handlers::get_user_access))
.route("/:user_id/configs", get(handlers::get_user_configs)) .route("/:user_id/configs", get(handlers::get_user_configs))
.route("/:user_id/access/:inbound_id/config", get(handlers::get_user_inbound_config)) .route(
"/:user_id/access/:inbound_id/config",
get(handlers::get_user_inbound_config),
)
} }
/// DNS Provider management routes /// DNS Provider management routes
fn dns_provider_routes() -> Router<AppState> { fn dns_provider_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(handlers::list_dns_providers).post(handlers::create_dns_provider)) .route(
.route("/:id", get(handlers::get_dns_provider) "/",
.put(handlers::update_dns_provider) get(handlers::list_dns_providers).post(handlers::create_dns_provider),
.delete(handlers::delete_dns_provider)) )
.route("/cloudflare/active", get(handlers::list_active_cloudflare_providers)) .route(
"/:id",
get(handlers::get_dns_provider)
.put(handlers::update_dns_provider)
.delete(handlers::delete_dns_provider),
)
.route(
"/cloudflare/active",
get(handlers::list_active_cloudflare_providers),
)
} }
/// Task management routes /// Task management routes
@@ -53,17 +68,22 @@ fn task_routes() -> Router<AppState> {
/// Telegram bot management routes /// Telegram bot management routes
fn telegram_routes() -> Router<AppState> { fn telegram_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/config", get(handlers::get_telegram_config) .route(
.post(handlers::create_telegram_config)) "/config",
.route("/config/:id", get(handlers::get_telegram_config).post(handlers::create_telegram_config),
)
.route(
"/config/:id",
get(handlers::get_telegram_config) get(handlers::get_telegram_config)
.put(handlers::update_telegram_config) .put(handlers::update_telegram_config)
.delete(handlers::delete_telegram_config)) .delete(handlers::delete_telegram_config),
)
.route("/status", get(handlers::get_telegram_status)) .route("/status", get(handlers::get_telegram_status))
.route("/admins", get(handlers::get_telegram_admins)) .route("/admins", get(handlers::get_telegram_admins))
.route("/admins/:user_id", .route(
post(handlers::add_telegram_admin) "/admins/:user_id",
.delete(handlers::remove_telegram_admin)) post(handlers::add_telegram_admin).delete(handlers::remove_telegram_admin),
)
.route("/send", post(handlers::send_test_message)) .route("/send", post(handlers::send_test_message))
} }
@@ -71,7 +91,10 @@ fn telegram_routes() -> Router<AppState> {
fn user_request_routes() -> Router<AppState> { fn user_request_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(handlers::get_requests)) .route("/", get(handlers::get_requests))
.route("/:id", get(handlers::get_request).delete(handlers::delete_request)) .route(
"/:id",
get(handlers::get_request).delete(handlers::delete_request),
)
.route("/:id/approve", post(handlers::approve_request)) .route("/:id/approve", post(handlers::approve_request))
.route("/:id/decline", post(handlers::decline_request)) .route("/:id/decline", post(handlers::decline_request))
} }

View File

@@ -1,41 +1,77 @@
use crate::web::{handlers, AppState};
use axum::{ use axum::{
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use crate::{
web::{AppState, handlers},
};
pub fn server_routes() -> Router<AppState> { pub fn server_routes() -> Router<AppState> {
Router::new() Router::new()
// Server management // Server management
.route("/", get(handlers::list_servers).post(handlers::create_server)) .route(
.route("/:id", get(handlers::get_server).put(handlers::update_server).delete(handlers::delete_server)) "/",
get(handlers::list_servers).post(handlers::create_server),
)
.route(
"/:id",
get(handlers::get_server)
.put(handlers::update_server)
.delete(handlers::delete_server),
)
.route("/:id/test", post(handlers::test_server_connection)) .route("/:id/test", post(handlers::test_server_connection))
.route("/:id/stats", get(handlers::get_server_stats)) .route("/:id/stats", get(handlers::get_server_stats))
// Server inbounds // Server inbounds
.route("/:server_id/inbounds", get(handlers::list_server_inbounds).post(handlers::create_server_inbound)) .route(
.route("/:server_id/inbounds/:inbound_id", get(handlers::get_server_inbound).put(handlers::update_server_inbound).delete(handlers::delete_server_inbound)) "/:server_id/inbounds",
get(handlers::list_server_inbounds).post(handlers::create_server_inbound),
)
.route(
"/:server_id/inbounds/:inbound_id",
get(handlers::get_server_inbound)
.put(handlers::update_server_inbound)
.delete(handlers::delete_server_inbound),
)
// User management for inbounds // User management for inbounds
.route("/:server_id/inbounds/:inbound_id/users", post(handlers::add_user_to_inbound)) .route(
.route("/:server_id/inbounds/:inbound_id/users/:email", axum::routing::delete(handlers::remove_user_from_inbound)) "/:server_id/inbounds/:inbound_id/users",
post(handlers::add_user_to_inbound),
)
.route(
"/:server_id/inbounds/:inbound_id/users/:email",
axum::routing::delete(handlers::remove_user_from_inbound),
)
// Client configurations for inbounds // Client configurations for inbounds
.route("/:server_id/inbounds/:inbound_id/configs", get(handlers::get_inbound_configs)) .route(
"/:server_id/inbounds/:inbound_id/configs",
get(handlers::get_inbound_configs),
)
} }
pub fn certificate_routes() -> Router<AppState> { pub fn certificate_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(handlers::list_certificates).post(handlers::create_certificate)) .route(
.route("/:id", get(handlers::get_certificate).put(handlers::update_certificate).delete(handlers::delete_certificate)) "/",
get(handlers::list_certificates).post(handlers::create_certificate),
)
.route(
"/:id",
get(handlers::get_certificate)
.put(handlers::update_certificate)
.delete(handlers::delete_certificate),
)
.route("/:id/details", get(handlers::get_certificate_details)) .route("/:id/details", get(handlers::get_certificate_details))
.route("/expiring", get(handlers::get_expiring_certificates)) .route("/expiring", get(handlers::get_expiring_certificates))
} }
pub fn template_routes() -> Router<AppState> { pub fn template_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(handlers::list_templates).post(handlers::create_template)) .route(
.route("/:id", get(handlers::get_template).put(handlers::update_template).delete(handlers::delete_template)) "/",
get(handlers::list_templates).post(handlers::create_template),
)
.route(
"/:id",
get(handlers::get_template)
.put(handlers::update_template)
.delete(handlers::delete_template),
)
} }