URI works on android. Shadowsocks doesn't work on iPhone. it's ok - will be fixed.

This commit is contained in:
Ultradesu
2025-09-23 16:50:12 +01:00
parent 572b5e19c0
commit 59b8cbb582
15 changed files with 1382 additions and 15 deletions

58
API.md
View File

@@ -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

View File

@@ -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<Option<ClientConfigData>> {
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<Vec<ClientConfigData>> {
// 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)
}
}

View File

@@ -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;
pub use tasks::TaskScheduler;
pub use uri_generator::UriGeneratorService;

View File

@@ -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<String, UriGeneratorError>;
/// 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, String>) -> String {
let mut query_parts: Vec<String> = 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<String> {
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<String> {
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<String> {
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<String> {
// 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)
}
}

View File

@@ -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<String, UriGeneratorError> {
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()
}
}

View File

@@ -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<String, UriGeneratorError> {
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::<Vec<_>>()
.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(&params);
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<Value, UriGeneratorError> {
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()))
}

View File

@@ -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<String, UriGeneratorError> {
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(&params);
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<Value, UriGeneratorError> {
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()))
}

View File

@@ -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<String, UriGeneratorError> {
// 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::<Vec<_>>()
.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<String, UriGeneratorError> {
// 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(&params);
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<String, UriGeneratorError> {
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<Value, UriGeneratorError> {
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()))
}

View File

@@ -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<serde_json::Error> 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<urlencoding::EncodingError> for UriGeneratorError {
// fn from(err: urlencoding::EncodingError) -> Self {
// UriGeneratorError::UriEncoding(err.to_string())
// }
// }

View File

@@ -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<String>,
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<String>,
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<String>,
}
/// 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<String, UriGeneratorError> {
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<ClientConfig, UriGeneratorError> {
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<Value, UriGeneratorError> {
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()
}
}

View File

@@ -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<String>,
}
/// Generate URI for specific user and inbound
pub async fn get_user_inbound_config(
State(app_state): State<AppState>,
Path((user_id, inbound_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ClientConfigResponse>, 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<AppState>,
Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<ClientConfigResponse>>, 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<AppState>,
Path((server_id, inbound_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Vec<ClientConfigResponse>>, 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))
}

View File

@@ -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::*;
pub use templates::*;
pub use client_configs::*;

View File

@@ -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<AppState>,
Path(user_id): Path<Uuid>,
Query(query): Query<IncludeUrisQuery>,
) -> Result<Json<Vec<serde_json::Value>>, 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<serde_json::Value> = 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<serde_json::Value> = 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))
}

View File

@@ -25,4 +25,6 @@ fn user_routes() -> Router<AppState> {
.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))
}

View File

@@ -21,6 +21,9 @@ pub fn server_routes() -> Router<AppState> {
// 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<AppState> {