diff --git a/API.md b/API.md index 09cb991..1720585 100644 --- a/API.md +++ b/API.md @@ -77,9 +77,12 @@ All API endpoints are prefixed with `/api`. - `DELETE /users/{id}` - Delete user by ID #### Get User Access -- `GET /users/{id}/access` - Get user access to inbounds +- `GET /users/{id}/access?include_uris=true` - Get user access to inbounds (optionally with client URIs) -**Response:** +**Query Parameters:** +- `include_uris`: boolean (optional) - Include client configuration URIs in response + +**Response (without URIs):** ```json [ { @@ -93,6 +96,40 @@ All API endpoints are prefixed with `/api`. ] ``` +**Response (with URIs):** +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "server_inbound_id": "uuid", + "xray_user_id": "string", + "level": 0, + "is_active": true, + "uri": "vless://uuid@hostname:port?parameters#alias", + "protocol": "vless", + "server_name": "Server Name", + "inbound_tag": "inbound-tag" + } +] +``` + +#### Generate Client Configurations +- `GET /users/{user_id}/configs` - Get all client configuration URIs for a user +- `GET /users/{user_id}/access/{inbound_id}/config` - Get specific client configuration URI + +**Response:** +```json +{ + "user_id": "uuid", + "server_name": "string", + "inbound_tag": "string", + "protocol": "vmess|vless|trojan|shadowsocks", + "uri": "protocol://uri_string", + "qr_code": null +} +``` + ### Servers #### List Servers @@ -243,6 +280,23 @@ All API endpoints are prefixed with `/api`. #### Remove User from Inbound - `DELETE /servers/{server_id}/inbounds/{inbound_id}/users/{email}` - Remove user access +#### Get Inbound Client Configurations +- `GET /servers/{server_id}/inbounds/{inbound_id}/configs` - Get all client configuration URIs for an inbound + +**Response:** +```json +[ + { + "user_id": "uuid", + "server_name": "string", + "inbound_tag": "string", + "protocol": "vmess|vless|trojan|shadowsocks", + "uri": "protocol://uri_string", + "qr_code": null + } +] +``` + ### Certificates #### List Certificates diff --git a/src/database/repository/inbound_users.rs b/src/database/repository/inbound_users.rs index 71e0efd..5b14e97 100644 --- a/src/database/repository/inbound_users.rs +++ b/src/database/repository/inbound_users.rs @@ -5,6 +5,7 @@ use uuid::Uuid; use crate::database::entities::inbound_users::{ Entity, Model, ActiveModel, CreateInboundUserDto, UpdateInboundUserDto, Column }; +use crate::services::uri_generator::ClientConfigData; pub struct InboundUsersRepository { db: DatabaseConnection, @@ -129,4 +130,97 @@ impl InboundUsersRepository { let exists = self.find_by_user_and_inbound(user_id, inbound_id).await?; Ok(exists.is_some()) } + + /// Get complete client configuration data for URI generation + pub async fn get_client_config_data(&self, user_id: Uuid, server_inbound_id: Uuid) -> Result> { + use crate::database::entities::{ + user, server, server_inbound, inbound_template, certificate + }; + + // Get the inbound_user record first + let inbound_user = Entity::find() + .filter(Column::UserId.eq(user_id)) + .filter(Column::ServerInboundId.eq(server_inbound_id)) + .filter(Column::IsActive.eq(true)) + .one(&self.db) + .await?; + + if let Some(inbound_user) = inbound_user { + // Get user info + let user_entity = user::Entity::find_by_id(inbound_user.user_id) + .one(&self.db) + .await? + .ok_or_else(|| anyhow::anyhow!("User not found"))?; + + // Get server inbound info + let server_inbound_entity = server_inbound::Entity::find_by_id(inbound_user.server_inbound_id) + .one(&self.db) + .await? + .ok_or_else(|| anyhow::anyhow!("Server inbound not found"))?; + + // Get server info + let server_entity = server::Entity::find_by_id(server_inbound_entity.server_id) + .one(&self.db) + .await? + .ok_or_else(|| anyhow::anyhow!("Server not found"))?; + + // Get template info + let template_entity = inbound_template::Entity::find_by_id(server_inbound_entity.template_id) + .one(&self.db) + .await? + .ok_or_else(|| anyhow::anyhow!("Template not found"))?; + + // Get certificate info (optional) + let certificate_domain = if let Some(cert_id) = server_inbound_entity.certificate_id { + certificate::Entity::find_by_id(cert_id) + .one(&self.db) + .await? + .map(|cert| cert.domain) + } else { + None + }; + + let config = ClientConfigData { + user_name: user_entity.name, + xray_user_id: inbound_user.xray_user_id, + password: inbound_user.password, + level: inbound_user.level, + hostname: server_entity.hostname, + port: server_inbound_entity.port_override.unwrap_or(template_entity.default_port), + protocol: template_entity.protocol, + stream_settings: template_entity.stream_settings, + base_settings: template_entity.base_settings, + certificate_domain, + requires_tls: template_entity.requires_tls, + variable_values: server_inbound_entity.variable_values, + server_name: server_entity.name, + inbound_tag: server_inbound_entity.tag, + }; + + Ok(Some(config)) + } else { + Ok(None) + } + } + + /// Get all client configuration data for a user + pub async fn get_all_client_configs_for_user(&self, user_id: Uuid) -> Result> { + // Get all active inbound users for this user + let inbound_users = Entity::find() + .filter(Column::UserId.eq(user_id)) + .filter(Column::IsActive.eq(true)) + .all(&self.db) + .await?; + + let mut configs = Vec::new(); + + for inbound_user in inbound_users { + // 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 { + configs.push(config); + } + } + + Ok(configs) + } } \ No newline at end of file diff --git a/src/services/mod.rs b/src/services/mod.rs index c9dbee6..0495bcc 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -2,6 +2,8 @@ pub mod xray; pub mod certificates; pub mod events; pub mod tasks; +pub mod uri_generator; pub use xray::XrayService; -pub use tasks::TaskScheduler; \ No newline at end of file +pub use tasks::TaskScheduler; +pub use uri_generator::UriGeneratorService; \ No newline at end of file diff --git a/src/services/uri_generator/builders/mod.rs b/src/services/uri_generator/builders/mod.rs new file mode 100644 index 0000000..ecd2a78 --- /dev/null +++ b/src/services/uri_generator/builders/mod.rs @@ -0,0 +1,125 @@ +use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; + +pub mod vless; +pub mod vmess; +pub mod trojan; +pub mod shadowsocks; + +pub use vless::VlessUriBuilder; +pub use vmess::VmessUriBuilder; +pub use trojan::TrojanUriBuilder; +pub use shadowsocks::ShadowsocksUriBuilder; + +/// Common trait for all URI builders +pub trait UriBuilder { + /// Build URI string from client configuration data + fn build_uri(&self, config: &ClientConfigData) -> Result; + + /// Validate configuration for this protocol + fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> { + if config.hostname.is_empty() { + return Err(UriGeneratorError::MissingRequiredField("hostname".to_string())); + } + if config.port <= 0 || config.port > 65535 { + return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string())); + } + if config.xray_user_id.is_empty() { + return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string())); + } + Ok(()) + } +} + +/// Helper functions for URI building +pub mod utils { + use std::collections::HashMap; + use serde_json::Value; + use crate::services::uri_generator::error::UriGeneratorError; + + /// URL encode a string safely + pub fn url_encode(input: &str) -> String { + urlencoding::encode(input).to_string() + } + + /// Build query string from parameters + pub fn build_query_string(params: &HashMap) -> String { + let mut query_parts: Vec = Vec::new(); + + for (key, value) in params { + if !value.is_empty() { + query_parts.push(format!("{}={}", url_encode(key), url_encode(value))); + } + } + + query_parts.join("&") + } + + /// Extract transport type from stream settings + pub fn extract_transport_type(stream_settings: &Value) -> String { + stream_settings + .get("network") + .and_then(|v| v.as_str()) + .unwrap_or("tcp") + .to_string() + } + + /// Extract security type from stream settings + pub fn extract_security_type(stream_settings: &Value, has_certificate: bool) -> String { + if has_certificate { + stream_settings + .get("security") + .and_then(|v| v.as_str()) + .unwrap_or("tls") + .to_string() + } else { + "none".to_string() + } + } + + /// Extract WebSocket path from stream settings + pub fn extract_ws_path(stream_settings: &Value) -> Option { + stream_settings + .get("wsSettings") + .and_then(|ws| ws.get("path")) + .and_then(|p| p.as_str()) + .map(|s| s.to_string()) + } + + /// Extract WebSocket host from stream settings + pub fn extract_ws_host(stream_settings: &Value) -> Option { + stream_settings + .get("wsSettings") + .and_then(|ws| ws.get("headers")) + .and_then(|headers| headers.get("Host")) + .and_then(|host| host.as_str()) + .map(|s| s.to_string()) + } + + /// Extract gRPC service name from stream settings + pub fn extract_grpc_service_name(stream_settings: &Value) -> Option { + stream_settings + .get("grpcSettings") + .and_then(|grpc| grpc.get("serviceName")) + .and_then(|name| name.as_str()) + .map(|s| s.to_string()) + } + + /// Extract TLS SNI from stream settings + pub fn extract_tls_sni(stream_settings: &Value, certificate_domain: Option<&str>) -> Option { + // Try stream settings first + if let Some(sni) = stream_settings + .get("tlsSettings") + .and_then(|tls| tls.get("serverName")) + .and_then(|sni| sni.as_str()) { + return Some(sni.to_string()); + } + + // Fall back to certificate domain + certificate_domain.map(|s| s.to_string()) + } + + /// Determine alias for the URI + pub fn generate_alias(user_name: &str, server_name: &str, inbound_tag: &str) -> String { + format!("{}@{}-{}", user_name, server_name, inbound_tag) + } +} \ No newline at end of file diff --git a/src/services/uri_generator/builders/shadowsocks.rs b/src/services/uri_generator/builders/shadowsocks.rs new file mode 100644 index 0000000..eb86016 --- /dev/null +++ b/src/services/uri_generator/builders/shadowsocks.rs @@ -0,0 +1,96 @@ +use base64::{Engine as _, engine::general_purpose}; +use serde_json::Value; + +use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; +use super::{UriBuilder, utils}; + +pub struct ShadowsocksUriBuilder; + +impl ShadowsocksUriBuilder { + pub fn new() -> Self { + Self + } + + /// Map Xray cipher type to Shadowsocks method name + fn map_xray_cipher_to_shadowsocks_method(&self, cipher: &str) -> &str { + match cipher { + // AES GCM variants + "AES_256_GCM" | "aes-256-gcm" => "aes-256-gcm", + "AES_128_GCM" | "aes-128-gcm" => "aes-128-gcm", + + // ChaCha20 variants + "CHACHA20_POLY1305" | "chacha20-ietf-poly1305" | "chacha20-poly1305" => "chacha20-ietf-poly1305", + + // AES CFB variants + "AES_256_CFB" | "aes-256-cfb" => "aes-256-cfb", + "AES_128_CFB" | "aes-128-cfb" => "aes-128-cfb", + + // Legacy ciphers + "RC4_MD5" | "rc4-md5" => "rc4-md5", + "AES_256_CTR" | "aes-256-ctr" => "aes-256-ctr", + "AES_128_CTR" | "aes-128-ctr" => "aes-128-ctr", + + // Default to most secure and widely supported + _ => "aes-256-gcm", + } + } + +} + +impl UriBuilder for ShadowsocksUriBuilder { + fn build_uri(&self, config: &ClientConfigData) -> Result { + self.validate_config(config)?; + + // Get cipher type from base_settings and map to Shadowsocks method + let cipher = config.base_settings + .get("cipherType") + .and_then(|c| c.as_str()) + .or_else(|| config.base_settings.get("method").and_then(|m| m.as_str())) + .unwrap_or("AES_256_GCM"); + + let method = self.map_xray_cipher_to_shadowsocks_method(cipher); + + // Shadowsocks SIP002 format: ss://base64(method:password)@hostname:port#remark + // Use xray_user_id as password (following Marzban approach) + let credentials = format!("{}:{}", method, config.xray_user_id); + let encoded_credentials = general_purpose::STANDARD.encode(credentials.as_bytes()); + + // Generate alias for the URI + let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag); + + // Build simple SIP002 URI (no plugin parameters for standard Shadowsocks) + let uri = format!( + "ss://{}@{}:{}#{}", + encoded_credentials, + config.hostname, + config.port, + utils::url_encode(&alias) + ); + + Ok(uri) + } + + fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> { + // Basic validation + if config.hostname.is_empty() { + return Err(UriGeneratorError::MissingRequiredField("hostname".to_string())); + } + if config.port <= 0 || config.port > 65535 { + return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string())); + } + if config.xray_user_id.is_empty() { + return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string())); + } + + // Shadowsocks uses xray_user_id as password, already validated above + + Ok(()) + } +} + +impl Default for ShadowsocksUriBuilder { + fn default() -> Self { + Self::new() + } +} + diff --git a/src/services/uri_generator/builders/trojan.rs b/src/services/uri_generator/builders/trojan.rs new file mode 100644 index 0000000..96877c2 --- /dev/null +++ b/src/services/uri_generator/builders/trojan.rs @@ -0,0 +1,210 @@ +use std::collections::HashMap; +use serde_json::Value; + +use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; +use super::{UriBuilder, utils}; + +pub struct TrojanUriBuilder; + +impl TrojanUriBuilder { + pub fn new() -> Self { + Self + } +} + +impl UriBuilder for TrojanUriBuilder { + fn build_uri(&self, config: &ClientConfigData) -> Result { + self.validate_config(config)?; + + // Trojan uses xray_user_id as password + let password = &config.xray_user_id; + + // Apply variable substitution to stream settings + let stream_settings = if !config.variable_values.is_null() { + apply_variables(&config.stream_settings, &config.variable_values)? + } else { + config.stream_settings.clone() + }; + + let mut params = HashMap::new(); + + // Determine security layer (Trojan typically uses TLS) + let has_certificate = config.certificate_domain.is_some(); + let security = utils::extract_security_type(&stream_settings, has_certificate); + + // Trojan usually requires TLS, but allow other security types + if security != "none" { + params.insert("security".to_string(), security.clone()); + } + + // Transport type - always specify explicitly + let transport_type = utils::extract_transport_type(&stream_settings); + params.insert("type".to_string(), transport_type.clone()); + + // Transport-specific parameters + match transport_type.as_str() { + "ws" => { + if let Some(path) = utils::extract_ws_path(&stream_settings) { + params.insert("path".to_string(), path); + } + if let Some(host) = utils::extract_ws_host(&stream_settings) { + params.insert("host".to_string(), host); + } + }, + "grpc" => { + if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { + params.insert("serviceName".to_string(), service_name); + } + // gRPC mode for Trojan + params.insert("mode".to_string(), "gun".to_string()); + }, + "tcp" => { + // Check for HTTP header type + if let Some(header_type) = stream_settings + .get("tcpSettings") + .and_then(|tcp| tcp.get("header")) + .and_then(|header| header.get("type")) + .and_then(|t| t.as_str()) { + if header_type != "none" { + params.insert("headerType".to_string(), header_type.to_string()); + } + } + }, + _ => {} // Other transport types + } + + // TLS/Security specific parameters + if security == "tls" || security == "reality" { + if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { + params.insert("sni".to_string(), sni); + } + + // TLS fingerprint + if let Some(fp) = stream_settings + .get("tlsSettings") + .and_then(|tls| tls.get("fingerprint")) + .and_then(|fp| fp.as_str()) { + params.insert("fp".to_string(), fp.to_string()); + } + + // ALPN + if let Some(alpn) = stream_settings + .get("tlsSettings") + .and_then(|tls| tls.get("alpn")) + .and_then(|alpn| alpn.as_array()) { + let alpn_str = alpn + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(","); + if !alpn_str.is_empty() { + params.insert("alpn".to_string(), alpn_str); + } + } + + // Allow insecure connections (optional) + if let Some(allow_insecure) = stream_settings + .get("tlsSettings") + .and_then(|tls| tls.get("allowInsecure")) + .and_then(|ai| ai.as_bool()) { + if allow_insecure { + params.insert("allowInsecure".to_string(), "1".to_string()); + } + } + + // REALITY specific parameters + if security == "reality" { + if let Some(pbk) = stream_settings + .get("realitySettings") + .and_then(|reality| reality.get("publicKey")) + .and_then(|pbk| pbk.as_str()) { + params.insert("pbk".to_string(), pbk.to_string()); + } + + if let Some(sid) = stream_settings + .get("realitySettings") + .and_then(|reality| reality.get("shortId")) + .and_then(|sid| sid.as_str()) { + params.insert("sid".to_string(), sid.to_string()); + } + } + } + + // Flow control for XTLS (if supported) + if let Some(flow) = stream_settings + .get("flow") + .and_then(|f| f.as_str()) { + params.insert("flow".to_string(), flow.to_string()); + } + + // Build the URI + let query_string = utils::build_query_string(¶ms); + let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag); + + let uri = if query_string.is_empty() { + format!( + "trojan://{}@{}:{}#{}", + utils::url_encode(password), + config.hostname, + config.port, + utils::url_encode(&alias) + ) + } else { + format!( + "trojan://{}@{}:{}?{}#{}", + utils::url_encode(password), + config.hostname, + config.port, + query_string, + utils::url_encode(&alias) + ) + }; + + Ok(uri) + } + + fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> { + // Basic validation + if config.hostname.is_empty() { + return Err(UriGeneratorError::MissingRequiredField("hostname".to_string())); + } + if config.port <= 0 || config.port > 65535 { + return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string())); + } + if config.xray_user_id.is_empty() { + return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string())); + } + + // Trojan uses xray_user_id as password, already validated above + + Ok(()) + } +} + +impl Default for TrojanUriBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Apply variable substitution to JSON value +fn apply_variables(template: &Value, variables: &Value) -> Result { + let template_str = template.to_string(); + let mut result = template_str; + + if let Value::Object(var_map) = variables { + for (key, value) in var_map { + let placeholder = format!("${{{}}}", key); + let replacement = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => value.to_string().trim_matches('"').to_string(), + }; + result = result.replace(&placeholder, &replacement); + } + } + + serde_json::from_str(&result) + .map_err(|e| UriGeneratorError::VariableSubstitution(e.to_string())) +} \ No newline at end of file diff --git a/src/services/uri_generator/builders/vless.rs b/src/services/uri_generator/builders/vless.rs new file mode 100644 index 0000000..dbc9672 --- /dev/null +++ b/src/services/uri_generator/builders/vless.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; +use serde_json::Value; + +use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; +use super::{UriBuilder, utils}; + +pub struct VlessUriBuilder; + +impl VlessUriBuilder { + pub fn new() -> Self { + Self + } +} + +impl UriBuilder for VlessUriBuilder { + fn build_uri(&self, config: &ClientConfigData) -> Result { + self.validate_config(config)?; + + // Apply variable substitution to stream settings + let stream_settings = if !config.variable_values.is_null() { + // Simple variable substitution for stream settings + apply_variables(&config.stream_settings, &config.variable_values)? + } else { + config.stream_settings.clone() + }; + + let mut params = HashMap::new(); + + // VLESS always uses no encryption + params.insert("encryption".to_string(), "none".to_string()); + + // Determine security layer + let has_certificate = config.certificate_domain.is_some(); + let security = utils::extract_security_type(&stream_settings, has_certificate); + if security != "none" { + params.insert("security".to_string(), security.clone()); + } + + // Transport type - always specify explicitly + let transport_type = utils::extract_transport_type(&stream_settings); + params.insert("type".to_string(), transport_type.clone()); + + // Transport-specific parameters + match transport_type.as_str() { + "ws" => { + if let Some(path) = utils::extract_ws_path(&stream_settings) { + params.insert("path".to_string(), path); + } + if let Some(host) = utils::extract_ws_host(&stream_settings) { + params.insert("host".to_string(), host); + } + }, + "grpc" => { + if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { + params.insert("serviceName".to_string(), service_name); + } + // Default gRPC mode + params.insert("mode".to_string(), "gun".to_string()); + }, + "tcp" => { + // Check for HTTP header type + if let Some(header_type) = stream_settings + .get("tcpSettings") + .and_then(|tcp| tcp.get("header")) + .and_then(|header| header.get("type")) + .and_then(|t| t.as_str()) { + if header_type != "none" { + params.insert("headerType".to_string(), header_type.to_string()); + } + } + }, + _ => {} // Other transport types can be added as needed + } + + // TLS/Security specific parameters + if security == "tls" || security == "reality" { + if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { + params.insert("sni".to_string(), sni); + } + + // TLS fingerprint + if let Some(fp) = stream_settings + .get("tlsSettings") + .and_then(|tls| tls.get("fingerprint")) + .and_then(|fp| fp.as_str()) { + params.insert("fp".to_string(), fp.to_string()); + } + + // REALITY specific parameters + if security == "reality" { + if let Some(pbk) = stream_settings + .get("realitySettings") + .and_then(|reality| reality.get("publicKey")) + .and_then(|pbk| pbk.as_str()) { + params.insert("pbk".to_string(), pbk.to_string()); + } + + if let Some(sid) = stream_settings + .get("realitySettings") + .and_then(|reality| reality.get("shortId")) + .and_then(|sid| sid.as_str()) { + params.insert("sid".to_string(), sid.to_string()); + } + } + } + + // Flow control for XTLS + if let Some(flow) = stream_settings + .get("flow") + .and_then(|f| f.as_str()) { + params.insert("flow".to_string(), flow.to_string()); + } + + // Build the URI + let query_string = utils::build_query_string(¶ms); + let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag); + + let uri = if query_string.is_empty() { + format!( + "vless://{}@{}:{}#{}", + config.xray_user_id, + config.hostname, + config.port, + utils::url_encode(&alias) + ) + } else { + format!( + "vless://{}@{}:{}?{}#{}", + config.xray_user_id, + config.hostname, + config.port, + query_string, + utils::url_encode(&alias) + ) + }; + + Ok(uri) + } +} + +impl Default for VlessUriBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Apply variable substitution to JSON value +fn apply_variables(template: &Value, variables: &Value) -> Result { + let template_str = template.to_string(); + let mut result = template_str; + + if let Value::Object(var_map) = variables { + for (key, value) in var_map { + let placeholder = format!("${{{}}}", key); + let replacement = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => value.to_string().trim_matches('"').to_string(), + }; + result = result.replace(&placeholder, &replacement); + } + } + + serde_json::from_str(&result) + .map_err(|e| UriGeneratorError::VariableSubstitution(e.to_string())) +} \ No newline at end of file diff --git a/src/services/uri_generator/builders/vmess.rs b/src/services/uri_generator/builders/vmess.rs new file mode 100644 index 0000000..5d9169c --- /dev/null +++ b/src/services/uri_generator/builders/vmess.rs @@ -0,0 +1,259 @@ +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::{UriBuilder, utils}; + +pub struct VmessUriBuilder; + +impl VmessUriBuilder { + pub fn new() -> Self { + Self + } + + /// Build VMess URI in Base64 JSON format (following Marzban approach) + fn build_base64_json_uri(&self, config: &ClientConfigData) -> Result { + // Apply variable substitution to stream settings + let stream_settings = if !config.variable_values.is_null() { + apply_variables(&config.stream_settings, &config.variable_values)? + } else { + config.stream_settings.clone() + }; + + let transport_type = utils::extract_transport_type(&stream_settings); + let has_certificate = config.certificate_domain.is_some(); + let security = utils::extract_security_type(&stream_settings, has_certificate); + + // Build VMess JSON configuration following Marzban structure + let mut vmess_config = json!({ + "add": config.hostname, + "aid": "0", + "host": "", + "id": config.xray_user_id, + "net": transport_type, + "path": "", + "port": config.port, + "ps": utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag), + "scy": "auto", + "tls": if security == "none" { "none" } else { &security }, + "type": "none", + "v": "2" + }); + + // Transport-specific settings + match transport_type.as_str() { + "ws" => { + if let Some(path) = utils::extract_ws_path(&stream_settings) { + vmess_config["path"] = Value::String(path); + } + if let Some(host) = utils::extract_ws_host(&stream_settings) { + vmess_config["host"] = Value::String(host); + } + }, + "grpc" => { + if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { + vmess_config["path"] = Value::String(service_name); + } + // For gRPC in VMess, use "gun" type + vmess_config["type"] = Value::String("gun".to_string()); + }, + "tcp" => { + // Check for HTTP header type + if let Some(header_type) = stream_settings + .get("tcpSettings") + .and_then(|tcp| tcp.get("header")) + .and_then(|header| header.get("type")) + .and_then(|t| t.as_str()) { + vmess_config["type"] = Value::String(header_type.to_string()); + + // If HTTP headers, get host and path + if header_type == "http" { + if let Some(host) = stream_settings + .get("tcpSettings") + .and_then(|tcp| tcp.get("header")) + .and_then(|header| header.get("request")) + .and_then(|request| request.get("headers")) + .and_then(|headers| headers.get("Host")) + .and_then(|host| host.as_array()) + .and_then(|arr| arr.first()) + .and_then(|h| h.as_str()) { + vmess_config["host"] = Value::String(host.to_string()); + } + + if let Some(path) = stream_settings + .get("tcpSettings") + .and_then(|tcp| tcp.get("header")) + .and_then(|header| header.get("request")) + .and_then(|request| request.get("path")) + .and_then(|path| path.as_array()) + .and_then(|arr| arr.first()) + .and_then(|p| p.as_str()) { + vmess_config["path"] = Value::String(path.to_string()); + } + } + } + }, + _ => {} // Other transport types + } + + // TLS settings + if security != "none" { + if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { + vmess_config["sni"] = Value::String(sni); + } + + // TLS fingerprint + if let Some(fp) = stream_settings + .get("tlsSettings") + .and_then(|tls| tls.get("fingerprint")) + .and_then(|fp| fp.as_str()) { + vmess_config["fp"] = Value::String(fp.to_string()); + } + + // ALPN + if let Some(alpn) = stream_settings + .get("tlsSettings") + .and_then(|tls| tls.get("alpn")) + .and_then(|alpn| alpn.as_array()) { + let alpn_str = alpn + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(","); + if !alpn_str.is_empty() { + vmess_config["alpn"] = Value::String(alpn_str); + } + } + } + + // Convert to JSON string and encode in Base64 + let json_string = vmess_config.to_string(); + let encoded = general_purpose::STANDARD.encode(json_string.as_bytes()); + + Ok(format!("vmess://{}", encoded)) + } + + /// Build VMess URI in query parameter format (alternative) + fn build_query_param_uri(&self, config: &ClientConfigData) -> Result { + // Apply variable substitution to stream settings + let stream_settings = if !config.variable_values.is_null() { + apply_variables(&config.stream_settings, &config.variable_values)? + } else { + config.stream_settings.clone() + }; + + let mut params = HashMap::new(); + + // VMess uses auto encryption + params.insert("encryption".to_string(), "auto".to_string()); + + // Determine security layer + let has_certificate = config.certificate_domain.is_some(); + let security = utils::extract_security_type(&stream_settings, has_certificate); + if security != "none" { + params.insert("security".to_string(), security.clone()); + } + + // Transport type + let transport_type = utils::extract_transport_type(&stream_settings); + if transport_type != "tcp" { + params.insert("type".to_string(), transport_type.clone()); + } + + // Transport-specific parameters + match transport_type.as_str() { + "ws" => { + if let Some(path) = utils::extract_ws_path(&stream_settings) { + params.insert("path".to_string(), path); + } + if let Some(host) = utils::extract_ws_host(&stream_settings) { + params.insert("host".to_string(), host); + } + }, + "grpc" => { + if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { + params.insert("serviceName".to_string(), service_name); + } + params.insert("mode".to_string(), "gun".to_string()); + }, + _ => {} + } + + // TLS specific parameters + if security != "none" { + if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { + params.insert("sni".to_string(), sni); + } + + if let Some(fp) = stream_settings + .get("tlsSettings") + .and_then(|tls| tls.get("fingerprint")) + .and_then(|fp| fp.as_str()) { + params.insert("fp".to_string(), fp.to_string()); + } + } + + // Build the URI + let query_string = utils::build_query_string(¶ms); + let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag); + + let uri = if query_string.is_empty() { + format!( + "vmess://{}@{}:{}#{}", + config.xray_user_id, + config.hostname, + config.port, + utils::url_encode(&alias) + ) + } else { + format!( + "vmess://{}@{}:{}?{}#{}", + config.xray_user_id, + config.hostname, + config.port, + query_string, + utils::url_encode(&alias) + ) + }; + + Ok(uri) + } +} + +impl UriBuilder for VmessUriBuilder { + fn build_uri(&self, config: &ClientConfigData) -> Result { + self.validate_config(config)?; + + // Prefer Base64 JSON format as it's more widely supported + self.build_base64_json_uri(config) + } +} + +impl Default for VmessUriBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Apply variable substitution to JSON value +fn apply_variables(template: &Value, variables: &Value) -> Result { + let template_str = template.to_string(); + let mut result = template_str; + + if let Value::Object(var_map) = variables { + for (key, value) in var_map { + let placeholder = format!("${{{}}}", key); + let replacement = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => value.to_string().trim_matches('"').to_string(), + }; + result = result.replace(&placeholder, &replacement); + } + } + + serde_json::from_str(&result) + .map_err(|e| UriGeneratorError::VariableSubstitution(e.to_string())) +} \ No newline at end of file diff --git a/src/services/uri_generator/error.rs b/src/services/uri_generator/error.rs new file mode 100644 index 0000000..0775678 --- /dev/null +++ b/src/services/uri_generator/error.rs @@ -0,0 +1,51 @@ +use std::fmt; + +#[derive(Debug)] +pub enum UriGeneratorError { + UnsupportedProtocol(String), + MissingRequiredField(String), + InvalidConfiguration(String), + VariableSubstitution(String), + JsonParsing(String), + UriEncoding(String), +} + +impl fmt::Display for UriGeneratorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UriGeneratorError::UnsupportedProtocol(protocol) => { + write!(f, "Unsupported protocol: {}", protocol) + } + UriGeneratorError::MissingRequiredField(field) => { + write!(f, "Missing required field: {}", field) + } + UriGeneratorError::InvalidConfiguration(msg) => { + write!(f, "Invalid configuration: {}", msg) + } + UriGeneratorError::VariableSubstitution(msg) => { + write!(f, "Variable substitution error: {}", msg) + } + UriGeneratorError::JsonParsing(msg) => { + write!(f, "JSON parsing error: {}", msg) + } + UriGeneratorError::UriEncoding(msg) => { + write!(f, "URI encoding error: {}", msg) + } + } + } +} + +impl std::error::Error for UriGeneratorError {} + +impl From for UriGeneratorError { + fn from(err: serde_json::Error) -> Self { + UriGeneratorError::JsonParsing(err.to_string()) + } +} + +// Note: urlencoding crate doesn't have EncodingError in current version +// impl From for UriGeneratorError { +// fn from(err: urlencoding::EncodingError) -> Self { +// UriGeneratorError::UriEncoding(err.to_string()) +// } +// } \ No newline at end of file diff --git a/src/services/uri_generator/mod.rs b/src/services/uri_generator/mod.rs new file mode 100644 index 0000000..8b0e02d --- /dev/null +++ b/src/services/uri_generator/mod.rs @@ -0,0 +1,128 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +pub mod builders; +pub mod error; + +use builders::{UriBuilder, VlessUriBuilder, VmessUriBuilder, TrojanUriBuilder, ShadowsocksUriBuilder}; +use error::UriGeneratorError; + +/// Complete client configuration data aggregated from database +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientConfigData { + // User credentials + pub user_name: String, + pub xray_user_id: String, + pub password: Option, + pub level: i32, + + // Server connection + pub hostname: String, + pub port: i32, + + // Protocol & transport + pub protocol: String, + pub stream_settings: Value, + pub base_settings: Value, + + // Security + pub certificate_domain: Option, + pub requires_tls: bool, + + // Variable substitution + pub variable_values: Value, + + // Metadata + pub server_name: String, + pub inbound_tag: String, +} + +/// Generated client configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientConfig { + pub user_id: Uuid, + pub server_name: String, + pub inbound_tag: String, + pub protocol: String, + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub qr_code: Option, +} + +/// URI Generator Service +pub struct UriGeneratorService; + +impl UriGeneratorService { + pub fn new() -> Self { + Self + } + + /// Generate URI for specific protocol and configuration + pub fn generate_uri(&self, config: &ClientConfigData) -> Result { + let protocol = config.protocol.as_str(); + + match protocol { + "vless" => { + let builder = VlessUriBuilder::new(); + builder.build_uri(config) + }, + "vmess" => { + let builder = VmessUriBuilder::new(); + builder.build_uri(config) + }, + "trojan" => { + let builder = TrojanUriBuilder::new(); + builder.build_uri(config) + }, + "shadowsocks" => { + let builder = ShadowsocksUriBuilder::new(); + builder.build_uri(config) + }, + _ => Err(UriGeneratorError::UnsupportedProtocol(protocol.to_string())), + } + } + + /// Generate complete client configuration + pub fn generate_client_config(&self, user_id: Uuid, config: &ClientConfigData) -> Result { + let uri = self.generate_uri(config)?; + + Ok(ClientConfig { + user_id, + server_name: config.server_name.clone(), + inbound_tag: config.inbound_tag.clone(), + protocol: config.protocol.clone(), + uri, + qr_code: None, // TODO: Implement QR code generation if needed + }) + } + + /// Apply variable substitution to JSON values + pub fn apply_variable_substitution(&self, template: &Value, variables: &Value) -> Result { + let template_str = template.to_string(); + let mut result = template_str; + + if let Value::Object(var_map) = variables { + for (key, value) in var_map { + let placeholder = format!("${{{}}}", key); + let replacement = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => value.to_string().trim_matches('"').to_string(), + }; + result = result.replace(&placeholder, &replacement); + } + } + + serde_json::from_str(&result) + .map_err(|e| UriGeneratorError::VariableSubstitution(e.to_string())) + } +} + +impl Default for UriGeneratorService { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/src/web/handlers/client_configs.rs b/src/web/handlers/client_configs.rs new file mode 100644 index 0000000..c9dcb88 --- /dev/null +++ b/src/web/handlers/client_configs.rs @@ -0,0 +1,136 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::database::repository::InboundUsersRepository; +use crate::services::UriGeneratorService; +use crate::web::AppState; + +#[derive(Debug, Deserialize)] +pub struct IncludeUrisQuery { + #[serde(default)] + pub include_uris: bool, +} + +#[derive(Debug, Serialize)] +pub struct ClientConfigResponse { + pub user_id: Uuid, + pub server_name: String, + pub inbound_tag: String, + pub protocol: String, + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub qr_code: Option, +} + +/// Generate URI for specific user and inbound +pub async fn get_user_inbound_config( + State(app_state): State, + Path((user_id, inbound_id)): Path<(Uuid, Uuid)>, +) -> Result, StatusCode> { + let repo = InboundUsersRepository::new(app_state.db.connection().clone()); + let uri_service = UriGeneratorService::new(); + + // Get client configuration data + let config_data = repo.get_client_config_data(user_id, inbound_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let config_data = config_data.ok_or(StatusCode::NOT_FOUND)?; + + // Generate URI + let client_config = uri_service.generate_client_config(user_id, &config_data) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let response = ClientConfigResponse { + user_id: client_config.user_id, + server_name: client_config.server_name, + inbound_tag: client_config.inbound_tag, + protocol: client_config.protocol, + uri: client_config.uri, + qr_code: client_config.qr_code, + }; + + Ok(Json(response)) +} + +/// Generate all URIs for a user +pub async fn get_user_configs( + State(app_state): State, + Path(user_id): Path, +) -> Result>, StatusCode> { + let repo = InboundUsersRepository::new(app_state.db.connection().clone()); + let uri_service = UriGeneratorService::new(); + + // Get all client configuration data for user + let configs_data = repo.get_all_client_configs_for_user(user_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut responses = Vec::new(); + + for config_data in configs_data { + match uri_service.generate_client_config(user_id, &config_data) { + Ok(client_config) => { + responses.push(ClientConfigResponse { + user_id: client_config.user_id, + server_name: client_config.server_name, + inbound_tag: client_config.inbound_tag, + protocol: client_config.protocol, + uri: client_config.uri, + qr_code: client_config.qr_code, + }); + }, + Err(_) => { + // Log error but continue with other configs + continue; + } + } + } + + Ok(Json(responses)) +} + +/// Get all URIs for all users of a specific inbound +pub async fn get_inbound_configs( + State(app_state): State, + Path((server_id, inbound_id)): Path<(Uuid, Uuid)>, +) -> Result>, StatusCode> { + let repo = InboundUsersRepository::new(app_state.db.connection().clone()); + let uri_service = UriGeneratorService::new(); + + // Get all users for this inbound + let inbound_users = repo.find_active_by_inbound_id(inbound_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut responses = Vec::new(); + + for inbound_user in inbound_users { + // 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 { + match uri_service.generate_client_config(inbound_user.user_id, &config_data) { + Ok(client_config) => { + responses.push(ClientConfigResponse { + user_id: client_config.user_id, + server_name: client_config.server_name, + inbound_tag: client_config.inbound_tag, + protocol: client_config.protocol, + uri: client_config.uri, + qr_code: client_config.qr_code, + }); + }, + Err(_) => { + // Log error but continue with other configs + continue; + } + } + } + } + + Ok(Json(responses)) +} \ No newline at end of file diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index d5bdea9..f382883 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -2,8 +2,10 @@ pub mod users; pub mod servers; pub mod certificates; pub mod templates; +pub mod client_configs; pub use users::*; pub use servers::*; pub use certificates::*; -pub use templates::*; \ No newline at end of file +pub use templates::*; +pub use client_configs::*; \ No newline at end of file diff --git a/src/web/handlers/users.rs b/src/web/handlers/users.rs index 384bf4d..b465dd5 100644 --- a/src/web/handlers/users.rs +++ b/src/web/handlers/users.rs @@ -12,6 +12,8 @@ use crate::database::entities::user::{CreateUserDto, UpdateUserDto, Model as Use use crate::database::repository::UserRepository; use crate::web::AppState; +use super::client_configs::IncludeUrisQuery; + #[derive(Debug, Deserialize)] pub struct PaginationQuery { #[serde(default = "default_page")] @@ -197,8 +199,10 @@ pub async fn delete_user( pub async fn get_user_access( State(app_state): State, Path(user_id): Path, + Query(query): Query, ) -> Result>, StatusCode> { use crate::database::repository::InboundUsersRepository; + use crate::services::UriGeneratorService; let inbound_users_repo = InboundUsersRepository::new(app_state.db.connection().clone()); @@ -207,17 +211,51 @@ pub async fn get_user_access( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let response: Vec = access_list - .into_iter() - .map(|access| serde_json::json!({ - "id": access.id, - "user_id": access.user_id, - "server_inbound_id": access.server_inbound_id, - "xray_user_id": access.xray_user_id, - "level": access.level, - "is_active": access.is_active, - })) - .collect(); + let mut response: Vec = Vec::new(); + + if query.include_uris { + let uri_service = UriGeneratorService::new(); + + for access in access_list { + let mut access_json = serde_json::json!({ + "id": access.id, + "user_id": access.user_id, + "server_inbound_id": access.server_inbound_id, + "xray_user_id": access.xray_user_id, + "level": access.level, + "is_active": access.is_active, + }); + + // Try to get client config and generate URI + if access.is_active { + if let Ok(Some(config_data)) = inbound_users_repo + .get_client_config_data(user_id, access.server_inbound_id) + .await { + + 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["protocol"] = serde_json::Value::String(client_config.protocol); + access_json["server_name"] = serde_json::Value::String(client_config.server_name); + access_json["inbound_tag"] = serde_json::Value::String(client_config.inbound_tag); + } + } + } + + response.push(access_json); + } + } else { + response = access_list + .into_iter() + .map(|access| serde_json::json!({ + "id": access.id, + "user_id": access.user_id, + "server_inbound_id": access.server_inbound_id, + "xray_user_id": access.xray_user_id, + "level": access.level, + "is_active": access.is_active, + })) + .collect(); + } Ok(Json(response)) } \ No newline at end of file diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs index bf0e579..8756aea 100644 --- a/src/web/routes/mod.rs +++ b/src/web/routes/mod.rs @@ -25,4 +25,6 @@ fn user_routes() -> Router { .put(handlers::update_user) .delete(handlers::delete_user)) .route("/:id/access", get(handlers::get_user_access)) + .route("/:user_id/configs", get(handlers::get_user_configs)) + .route("/:user_id/access/:inbound_id/config", get(handlers::get_user_inbound_config)) } \ No newline at end of file diff --git a/src/web/routes/servers.rs b/src/web/routes/servers.rs index c598b13..925dbb8 100644 --- a/src/web/routes/servers.rs +++ b/src/web/routes/servers.rs @@ -21,6 +21,9 @@ pub fn server_routes() -> Router { // User management for inbounds .route("/: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 + .route("/:server_id/inbounds/:inbound_id/configs", get(handlers::get_inbound_configs)) } pub fn certificate_routes() -> Router {