From 572b5e19c0deb361e59f1aec9d6b0eee0ca2dc46 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 23 Sep 2025 14:17:32 +0100 Subject: [PATCH] API works. next: generate URI --- API.md | 396 +++++++++++++++++- URI.md | 151 +++++++ src/database/entities/server.rs | 25 +- ...922_000001_add_grpc_hostname_to_servers.rs | 50 +++ src/database/migrations/mod.rs | 2 + src/database/repository/server.rs | 2 +- src/database/repository/user.rs | 33 +- src/services/tasks.rs | 4 +- src/web/handlers/servers.rs | 4 +- src/web/handlers/users.rs | 55 ++- src/web/routes/mod.rs | 1 + static/admin.html | 392 ++++++++++++++--- 12 files changed, 1026 insertions(+), 89 deletions(-) create mode 100644 URI.md create mode 100644 src/database/migrations/m20250922_000001_add_grpc_hostname_to_servers.rs diff --git a/API.md b/API.md index c5be905..09cb991 100644 --- a/API.md +++ b/API.md @@ -1,11 +1,27 @@ -# User Management API +# OutFleet Xray Admin API -Base URL: `http://localhost:8080/api` +Base URL: `http://localhost:8080` -## Endpoints +## Overview +Complete API documentation for OutFleet - a web admin panel for managing xray-core VPN proxy servers. + +## Base Endpoints ### Health Check -- `GET /` - Service health check +- `GET /health` - Service health check + +**Response:** +```json +{ + "status": "ok", + "service": "xray-admin", + "version": "0.1.0" +} +``` + +## API Endpoints + +All API endpoints are prefixed with `/api`. ### Users @@ -13,7 +29,26 @@ Base URL: `http://localhost:8080/api` - `GET /users?page=1&per_page=20` - Get paginated list of users #### Search Users -- `GET /users/search?q=john&page=1&per_page=20` - Search users by name +- `GET /users/search?q=john` - Universal search for users + +**Search capabilities:** +- By name (partial match, case-insensitive): `?q=john` +- By telegram_id: `?q=123456789` +- By user UUID: `?q=550e8400-e29b-41d4-a716-446655440000` + +**Response:** Array of user objects (limited to 100 results) +```json +[ + { + "id": "uuid", + "name": "string", + "comment": "string|null", + "telegram_id": "number|null", + "created_at": "timestamp", + "updated_at": "timestamp" + } +] +``` #### Get User - `GET /users/{id}` - Get user by ID @@ -41,6 +76,259 @@ Base URL: `http://localhost:8080/api` #### Delete User - `DELETE /users/{id}` - Delete user by ID +#### Get User Access +- `GET /users/{id}/access` - Get user access to inbounds + +**Response:** +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "server_inbound_id": "uuid", + "xray_user_id": "string", + "level": 0, + "is_active": true + } +] +``` + +### Servers + +#### List Servers +- `GET /servers` - Get all servers + +**Response:** +```json +[ + { + "id": "uuid", + "name": "string", + "hostname": "string", + "grpc_hostname": "string", + "grpc_port": 2053, + "status": "online|offline|error|unknown", + "default_certificate_id": "uuid|null", + "created_at": "timestamp", + "updated_at": "timestamp", + "has_credentials": true + } +] +``` + +#### Get Server +- `GET /servers/{id}` - Get server by ID + +#### Create Server +- `POST /servers` - Create new server + +**Request:** +```json +{ + "name": "Server Name", + "hostname": "server.example.com", + "grpc_hostname": "192.168.1.100", // optional, defaults to hostname + "grpc_port": 2053, // optional, defaults to 2053 + "api_credentials": "optional credentials", + "default_certificate_id": "uuid" // optional +} +``` + +#### Update Server +- `PUT /servers/{id}` - Update server + +**Request:** (all fields optional) +```json +{ + "name": "New Server Name", + "hostname": "new.server.com", + "grpc_hostname": "192.168.1.200", + "grpc_port": 2054, + "api_credentials": "new credentials", + "status": "online", + "default_certificate_id": "uuid" +} +``` + +#### Delete Server +- `DELETE /servers/{id}` - Delete server + +#### Test Server Connection +- `POST /servers/{id}/test` - Test connection to server + +**Response:** +```json +{ + "connected": true, + "endpoint": "192.168.1.100:2053" +} +``` + +#### Get Server Statistics +- `GET /servers/{id}/stats` - Get server statistics + +### Server Inbounds + +#### List Server Inbounds +- `GET /servers/{server_id}/inbounds` - Get all inbounds for a server + +**Response:** +```json +[ + { + "id": "uuid", + "server_id": "uuid", + "template_id": "uuid", + "template_name": "string", + "tag": "string", + "port_override": 8080, + "certificate_id": "uuid|null", + "variable_values": {}, + "is_active": true, + "created_at": "timestamp", + "updated_at": "timestamp" + } +] +``` + +#### Get Server Inbound +- `GET /servers/{server_id}/inbounds/{inbound_id}` - Get specific inbound + +#### Create Server Inbound +- `POST /servers/{server_id}/inbounds` - Create new inbound for server + +**Request:** +```json +{ + "template_id": "uuid", + "port": 8080, + "certificate_id": "uuid", // optional + "is_active": true +} +``` + +#### Update Server Inbound +- `PUT /servers/{server_id}/inbounds/{inbound_id}` - Update inbound + +**Request:** (all fields optional) +```json +{ + "tag": "new-tag", + "port_override": 8081, + "certificate_id": "uuid", + "variable_values": {"domain": "example.com"}, + "is_active": false +} +``` + +#### Delete Server Inbound +- `DELETE /servers/{server_id}/inbounds/{inbound_id}` - Delete inbound + +### User-Inbound Management + +#### Add User to Inbound +- `POST /servers/{server_id}/inbounds/{inbound_id}/users` - Grant user access to inbound + +**Request:** +```json +{ + "user_id": "uuid", // optional, will create new user if not provided + "name": "username", + "comment": "User description", // optional + "telegram_id": 123456789, // optional + "level": 0 // optional, defaults to 0 +} +``` + +#### Remove User from Inbound +- `DELETE /servers/{server_id}/inbounds/{inbound_id}/users/{email}` - Remove user access + +### Certificates + +#### List Certificates +- `GET /certificates` - Get all certificates + +#### Get Certificate +- `GET /certificates/{id}` - Get certificate by ID + +#### Create Certificate +- `POST /certificates` - Create new certificate + +**Request:** +```json +{ + "name": "Certificate Name", + "cert_type": "self_signed|letsencrypt", + "domain": "example.com", + "auto_renew": true, + "certificate_pem": "-----BEGIN CERTIFICATE-----...", + "private_key": "-----BEGIN PRIVATE KEY-----..." +} +``` + +#### Update Certificate +- `PUT /certificates/{id}` - Update certificate + +**Request:** (all fields optional) +```json +{ + "name": "New Certificate Name", + "auto_renew": false +} +``` + +#### Delete Certificate +- `DELETE /certificates/{id}` - Delete certificate + +#### Get Certificate Details +- `GET /certificates/{id}/details` - Get detailed certificate information + +#### Get Expiring Certificates +- `GET /certificates/expiring` - Get certificates that are expiring soon + +### Templates + +#### List Templates +- `GET /templates` - Get all inbound templates + +#### Get Template +- `GET /templates/{id}` - Get template by ID + +#### Create Template +- `POST /templates` - Create new inbound template + +**Request:** +```json +{ + "name": "Template Name", + "protocol": "vmess|vless|trojan|shadowsocks", + "default_port": 8080, + "requires_tls": true, + "config_template": "JSON template string" +} +``` + +#### Update Template +- `PUT /templates/{id}` - Update template + +**Request:** (all fields optional) +```json +{ + "name": "New Template Name", + "description": "Template description", + "default_port": 8081, + "base_settings": {}, + "stream_settings": {}, + "requires_tls": false, + "requires_domain": true, + "variables": [], + "is_active": true +} +``` + +#### Delete Template +- `DELETE /templates/{id}` - Delete template + ## Response Format ### User Object @@ -65,9 +353,101 @@ Base URL: `http://localhost:8080/api` } ``` +### Server Object +```json +{ + "id": "uuid", + "name": "string", + "hostname": "string", + "grpc_hostname": "string", + "grpc_port": 2053, + "status": "online|offline|error|unknown", + "default_certificate_id": "uuid|null", + "created_at": "timestamp", + "updated_at": "timestamp", + "has_credentials": true +} +``` + +### Server Inbound Object +```json +{ + "id": "uuid", + "server_id": "uuid", + "template_id": "uuid", + "template_name": "string", + "tag": "string", + "port_override": 8080, + "certificate_id": "uuid|null", + "variable_values": {}, + "is_active": true, + "created_at": "timestamp", + "updated_at": "timestamp" +} +``` + +### Certificate Object +```json +{ + "id": "uuid", + "name": "string", + "cert_type": "self_signed|letsencrypt", + "domain": "string", + "auto_renew": true, + "expires_at": "timestamp|null", + "created_at": "timestamp", + "updated_at": "timestamp" +} +``` + +### Template Object +```json +{ + "id": "uuid", + "name": "string", + "description": "string|null", + "protocol": "vmess|vless|trojan|shadowsocks", + "default_port": 8080, + "base_settings": {}, + "stream_settings": {}, + "requires_tls": true, + "requires_domain": false, + "variables": [], + "is_active": true, + "created_at": "timestamp", + "updated_at": "timestamp" +} +``` + +### Inbound User Object +```json +{ + "id": "uuid", + "user_id": "uuid", + "server_inbound_id": "uuid", + "xray_user_id": "string", + "password": "string|null", + "level": 0, + "is_active": true, + "created_at": "timestamp", + "updated_at": "timestamp" +} +``` + ## Status Codes - `200` - Success -- `201` - Created +- `201` - Created +- `204` - No Content (successful deletion) +- `400` - Bad Request (invalid data) - `404` - Not Found -- `409` - Conflict (duplicate telegram_id) -- `500` - Internal Server Error \ No newline at end of file +- `409` - Conflict (duplicate data, e.g. telegram_id) +- `500` - Internal Server Error + +## Error Response Format +```json +{ + "error": "Error message description", + "code": "ERROR_CODE", + "details": "Additional error details" +} +``` \ No newline at end of file diff --git a/URI.md b/URI.md new file mode 100644 index 0000000..01bf3cc --- /dev/null +++ b/URI.md @@ -0,0 +1,151 @@ +# Xray Client URI Generation + +## VMess URI Format + +VMess URIs use two formats: + +### 1. Query Parameter Format +``` +vmess://uuid@hostname:port?parameters#alias +``` + +**Parameters:** +- `encryption=auto` - Encryption method +- `security=tls|none` - Security layer (TLS or none) +- `sni=domain` - Server Name Indication for TLS +- `fp=chrome|firefox|safari` - TLS fingerprint +- `type=ws|tcp|grpc|http` - Transport type +- `path=/path` - WebSocket/HTTP path +- `host=domain` - Host header for WebSocket + +**Example:** +``` +vmess://2c981164-9b93-4bca-94ff-b78d3f8498d7@v2ray.codefyinc.com:443?encryption=auto&security=tls&sni=example.com&fp=chrome&type=ws&path=/ws&host=v2ray.codefyinc.com#MyServer +``` + +### 2. Base64 JSON Format +``` +vmess://base64(json_config)#alias +``` + +**JSON Structure:** +```json +{ + "v": "2", + "ps": "Server Name", + "add": "hostname", + "port": "443", + "id": "uuid", + "aid": "0", + "scy": "auto", + "net": "ws", + "type": "none", + "host": "domain", + "path": "/path", + "tls": "tls", + "sni": "domain", + "alpn": "", + "fp": "chrome" +} +``` + +## VLESS URI Format + +``` +vless://uuid@hostname:port?parameters#alias +``` + +**Key Parameters:** +- `encryption=none` - VLESS uses no encryption +- `security=tls|reality|none` - Security layer +- `type=ws|tcp|grpc|http|httpupgrade|xhttp` - Transport type +- `flow=xtls-rprx-vision` - Flow control (for XTLS) +- `headerType=none|http` - Header type for TCP +- `mode=auto|gun|stream-one` - Transport mode +- `serviceName=name` - gRPC service name +- `authority=domain` - gRPC authority +- `spx=/path` - Split HTTP path (for xhttp) + +**REALITY Parameters:** +- `pbk=public_key` - Public key +- `sid=short_id` - Short ID +- `fp=chrome|firefox|safari` - TLS fingerprint +- `sni=domain` - Server Name Indication + +**Examples:** +``` +vless://uuid@server.com:443?type=tcp&security=none&headerType=none#Basic +vless://uuid@server.com:443?type=ws&security=tls&path=/ws&host=example.com#WebSocket +vless://uuid@server.com:443?type=grpc&security=reality&serviceName=grpcService&pbk=key&sid=id#gRPC-Reality +``` + +## Generation Algorithm + +1. **UUID**: Use `inbound_users.xray_user_id` +2. **Hostname**: From `servers.hostname` +3. **Port**: From `server_inbounds.port_override` or template default +4. **Transport**: From inbound template `stream_settings` +5. **Security**: Based on certificate configuration +6. **Path**: From WebSocket stream settings +7. **Alias**: User name + server name + +## Shadowsocks URI Format + +``` +ss://password@hostname:port?parameters#alias +``` + +**Parameters:** +- `encryption=none` - Usually none for modern configs +- `security=tls|reality|none` - Security layer +- `type=ws|tcp|grpc|xhttp` - Transport type +- `path=/path` - WebSocket/HTTP path +- `host=domain` - Host header +- `mode=auto|gun|stream-one` - Transport mode +- `headerType=none|http` - Header type for TCP +- `flow=xtls-rprx-vision` - Flow control (for REALITY) +- `pbk=key` - Public key (for REALITY) +- `sid=id` - Short ID (for REALITY) + +**Example:** +``` +ss://my-password@server.com:443?type=ws&security=tls&path=/ws&host=example.com#MyServer +``` + +## Trojan URI Format + +``` +trojan://password@hostname:port?parameters#alias +``` + +**Parameters:** +- `security=tls|reality|none` - Security layer +- `type=ws|tcp|grpc` - Transport type +- `sni=domain` - Server Name Indication +- `fp=chrome|firefox|randomized` - TLS fingerprint +- `flow=xtls-rprx-vision` - Flow control +- `allowInsecure=1` - Allow insecure connections +- `headerType=http|none` - Header type for TCP +- `mode=gun` - gRPC mode +- `serviceName=name` - gRPC service name + +**WebSocket Parameters:** +- `path=/path` - WebSocket path +- `host=domain` - Host header +- `alpn=http/1.1|h2` - ALPN protocols + +**Examples:** +``` +trojan://password@server.com:443?type=tcp&security=tls&sni=example.com#Basic +trojan://password@server.com:443?type=ws&security=tls&path=/ws&host=example.com&sni=example.com#WebSocket +trojan://password@server.com:443?type=grpc&security=tls&serviceName=grpcService&mode=gun&sni=example.com#gRPC +``` + +## Implementation Notes + +- VMess requires `aid=0` for modern clients +- VLESS doesn't use `aid` parameter +- Shadowsocks uses password instead of UUID +- Base64 encoding required for VMess JSON format +- URL encoding needed for special characters in parameters +- REALITY parameters: `pbk`, `sid`, `fp`, `sni` \ No newline at end of file diff --git a/src/database/entities/server.rs b/src/database/entities/server.rs index b38861a..f58c8b4 100644 --- a/src/database/entities/server.rs +++ b/src/database/entities/server.rs @@ -12,6 +12,8 @@ pub struct Model { pub hostname: String, + pub grpc_hostname: String, + pub grpc_port: i32, #[serde(skip_serializing)] @@ -117,6 +119,7 @@ impl From for ServerStatus { pub struct CreateServerDto { pub name: String, pub hostname: String, + pub grpc_hostname: Option, // Optional, defaults to hostname if not provided pub grpc_port: Option, pub api_credentials: Option, pub default_certificate_id: Option, @@ -126,6 +129,7 @@ pub struct CreateServerDto { pub struct UpdateServerDto { pub name: Option, pub hostname: Option, + pub grpc_hostname: Option, pub grpc_port: Option, pub api_credentials: Option, pub status: Option, @@ -137,6 +141,7 @@ pub struct ServerResponse { pub id: Uuid, pub name: String, pub hostname: String, + pub grpc_hostname: String, pub grpc_port: i32, pub status: String, pub default_certificate_id: Option, @@ -148,8 +153,9 @@ pub struct ServerResponse { impl From for ActiveModel { fn from(dto: CreateServerDto) -> Self { Self { - name: Set(dto.name), - hostname: Set(dto.hostname), + name: Set(dto.name.clone()), + hostname: Set(dto.hostname.clone()), + grpc_hostname: Set(dto.grpc_hostname.unwrap_or(dto.hostname)), // Default to hostname if not provided grpc_port: Set(dto.grpc_port.unwrap_or(2053)), api_credentials: Set(dto.api_credentials), status: Set("unknown".to_string()), @@ -165,6 +171,7 @@ impl From for ServerResponse { id: server.id, name: server.name, hostname: server.hostname, + grpc_hostname: server.grpc_hostname, grpc_port: server.grpc_port, status: server.status, default_certificate_id: server.default_certificate_id, @@ -185,6 +192,9 @@ impl Model { if let Some(hostname) = dto.hostname { active_model.hostname = Set(hostname); } + if let Some(grpc_hostname) = dto.grpc_hostname { + active_model.grpc_hostname = Set(grpc_hostname); + } if let Some(grpc_port) = dto.grpc_port { active_model.grpc_port = Set(grpc_port); } @@ -202,7 +212,16 @@ impl Model { } pub fn get_grpc_endpoint(&self) -> String { - format!("{}:{}", self.hostname, self.grpc_port) + let hostname = if self.grpc_hostname.is_empty() { + tracing::debug!("Using public hostname '{}' for gRPC (grpc_hostname is empty)", self.hostname); + &self.hostname + } else { + tracing::debug!("Using dedicated gRPC hostname '{}' (different from public hostname '{}')", self.grpc_hostname, self.hostname); + &self.grpc_hostname + }; + let endpoint = format!("{}:{}", hostname, self.grpc_port); + tracing::info!("gRPC endpoint for server '{}': {}", self.name, endpoint); + endpoint } #[allow(dead_code)] diff --git a/src/database/migrations/m20250922_000001_add_grpc_hostname_to_servers.rs b/src/database/migrations/m20250922_000001_add_grpc_hostname_to_servers.rs new file mode 100644 index 0000000..2484730 --- /dev/null +++ b/src/database/migrations/m20250922_000001_add_grpc_hostname_to_servers.rs @@ -0,0 +1,50 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Servers::Table) + .add_column( + ColumnDef::new(Servers::GrpcHostname) + .string() + .not_null() + .default(""), + ) + .to_owned(), + ) + .await?; + + // Update existing servers: set grpc_hostname to hostname value + let db = manager.get_connection(); + + // Use raw SQL to copy hostname to grpc_hostname for existing records + // Handle both empty strings and default empty values + db.execute_unprepared("UPDATE servers SET grpc_hostname = hostname WHERE grpc_hostname = '' OR grpc_hostname IS NULL") + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Servers::Table) + .drop_column(Servers::GrpcHostname) + .to_owned(), + ) + .await + } +} + +#[derive(Iden)] +enum Servers { + Table, + GrpcHostname, +} \ No newline at end of file diff --git a/src/database/migrations/mod.rs b/src/database/migrations/mod.rs index 98c015e..1491b95 100644 --- a/src/database/migrations/mod.rs +++ b/src/database/migrations/mod.rs @@ -8,6 +8,7 @@ mod m20241201_000005_create_server_inbounds_table; mod m20241201_000006_create_user_access_table; mod m20241201_000007_create_inbound_users_table; mod m20250919_000001_update_inbound_users_schema; +mod m20250922_000001_add_grpc_hostname_to_servers; pub struct Migrator; @@ -23,6 +24,7 @@ impl MigratorTrait for Migrator { Box::new(m20241201_000006_create_user_access_table::Migration), Box::new(m20241201_000007_create_inbound_users_table::Migration), Box::new(m20250919_000001_update_inbound_users_schema::Migration), + Box::new(m20250922_000001_add_grpc_hostname_to_servers::Migration), ] } } \ No newline at end of file diff --git a/src/database/repository/server.rs b/src/database/repository/server.rs index b8c38d2..2e6b9bd 100644 --- a/src/database/repository/server.rs +++ b/src/database/repository/server.rs @@ -74,6 +74,6 @@ impl ServerRepository { let server = self.find_by_id(id).await? .ok_or_else(|| anyhow::anyhow!("Server not found"))?; - Ok(format!("{}:{}", server.hostname, server.grpc_port)) + Ok(server.get_grpc_endpoint()) } } \ No newline at end of file diff --git a/src/database/repository/user.rs b/src/database/repository/user.rs index 0bf64fa..ff449d1 100644 --- a/src/database/repository/user.rs +++ b/src/database/repository/user.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait, QueryOrder, PaginatorTrait}; +use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait, QueryOrder, PaginatorTrait, QuerySelect}; use uuid::Uuid; use crate::database::entities::user::{Entity as User, Column, Model, ActiveModel, CreateUserDto, UpdateUserDto}; @@ -44,7 +44,7 @@ impl UserRepository { Ok(user) } - /// Search users by name + /// Search users by name (with pagination for backward compatibility) pub async fn search_by_name(&self, query: &str, page: u64, per_page: u64) -> Result> { let users = User::find() .filter(Column::Name.contains(query)) @@ -56,6 +56,35 @@ impl UserRepository { Ok(users) } + /// Universal search - searches by name, telegram_id, or user_id + pub async fn search(&self, query: &str) -> Result> { + use sea_orm::Condition; + + let mut condition = Condition::any(); + + // Search by name (case-insensitive partial match) + condition = condition.add(Column::Name.contains(query)); + + // Try to parse as telegram_id (i64) + if let Ok(telegram_id) = query.parse::() { + condition = condition.add(Column::TelegramId.eq(telegram_id)); + } + + // Try to parse as UUID (user_id) + if let Ok(user_id) = Uuid::parse_str(query) { + condition = condition.add(Column::Id.eq(user_id)); + } + + let users = User::find() + .filter(condition) + .order_by_desc(Column::CreatedAt) + .limit(100) // Reasonable limit to prevent huge results + .all(&self.db) + .await?; + + Ok(users) + } + /// Create a new user pub async fn create(&self, dto: CreateUserDto) -> Result { let active_model: ActiveModel = dto.into(); diff --git a/src/services/tasks.rs b/src/services/tasks.rs index 894c5a5..cddc3dc 100644 --- a/src/services/tasks.rs +++ b/src/services/tasks.rs @@ -215,7 +215,7 @@ async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Resu for server in servers { - let endpoint = format!("{}:{}", server.hostname, server.grpc_port); + let endpoint = server.get_grpc_endpoint(); // Test connection first match xray_service.test_connection(server.id, &endpoint).await { @@ -394,7 +394,7 @@ async fn sync_single_server_by_id( let desired_inbounds = get_desired_inbounds_from_db(db, &server, &inbound_repo, &template_repo).await?; // Build endpoint - let endpoint = format!("{}:{}", server.hostname, server.grpc_port); + let endpoint = server.get_grpc_endpoint(); // Sync server sync_server_inbounds(xray_service, server_id, &endpoint, &desired_inbounds).await?; diff --git a/src/web/handlers/servers.rs b/src/web/handlers/servers.rs index 1c003dc..88466ff 100644 --- a/src/web/handlers/servers.rs +++ b/src/web/handlers/servers.rs @@ -108,6 +108,7 @@ pub async fn test_server_connection( let update_dto = server::UpdateServerDto { name: None, hostname: None, + grpc_hostname: None, grpc_port: None, api_credentials: None, default_certificate_id: None, @@ -126,6 +127,7 @@ pub async fn test_server_connection( let update_dto = server::UpdateServerDto { name: None, hostname: None, + grpc_hostname: None, grpc_port: None, api_credentials: None, default_certificate_id: None, @@ -586,7 +588,7 @@ pub async fn remove_user_from_inbound( let inbound_tag = &inbound.tag; // Remove user from xray server - match app_state.xray_service.remove_user(server_id, &format!("{}:{}", server.hostname, server.grpc_port), &inbound_tag, &email).await { + match app_state.xray_service.remove_user(server_id, &server.get_grpc_endpoint(), &inbound_tag, &email).await { Ok(_) => { tracing::info!("Removed user '{}' from inbound", email); Ok(StatusCode::NO_CONTENT) diff --git a/src/web/handlers/users.rs b/src/web/handlers/users.rs index 3990af3..384bf4d 100644 --- a/src/web/handlers/users.rs +++ b/src/web/handlers/users.rs @@ -23,8 +23,6 @@ pub struct PaginationQuery { #[derive(Debug, Deserialize)] pub struct SearchQuery { pub q: Option, - #[serde(flatten)] - pub pagination: PaginationQuery, } #[derive(Debug, Serialize)] @@ -86,34 +84,24 @@ pub async fn get_users( Ok(Json(response)) } -/// Search users by name +/// Search users by name, telegram_id or user_id pub async fn search_users( State(app_state): State, Query(query): Query, -) -> Result, StatusCode> { +) -> Result>, StatusCode> { let repo = UserRepository::new(app_state.db.connection().clone()); let users = if let Some(search_query) = query.q { - repo.search_by_name(&search_query, query.pagination.page, query.pagination.per_page) + // Search by name, telegram_id, or UUID + repo.search(&search_query) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? } else { - repo.get_all(query.pagination.page, query.pagination.per_page) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - }; - - let total = repo.count() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let response = UsersResponse { - users: users.into_iter().map(UserResponse::from).collect(), - total, - page: query.pagination.page, - per_page: query.pagination.per_page, + // If no query, return empty array + Vec::new() }; + let response: Vec = users.into_iter().map(UserResponse::from).collect(); Ok(Json(response)) } @@ -203,4 +191,33 @@ pub async fn delete_user( } else { Err(StatusCode::NOT_FOUND) } +} + +/// Get user access (inbound associations) +pub async fn get_user_access( + State(app_state): State, + Path(user_id): Path, +) -> Result>, StatusCode> { + use crate::database::repository::InboundUsersRepository; + + let inbound_users_repo = InboundUsersRepository::new(app_state.db.connection().clone()); + + let access_list = inbound_users_repo + .find_by_user_id(user_id) + .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(); + + Ok(Json(response)) } \ No newline at end of file diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs index 20526f3..bf0e579 100644 --- a/src/web/routes/mod.rs +++ b/src/web/routes/mod.rs @@ -24,4 +24,5 @@ fn user_routes() -> Router { .route("/:id", get(handlers::get_user) .put(handlers::update_user) .delete(handlers::delete_user)) + .route("/:id/access", get(handlers::get_user_access)) } \ No newline at end of file diff --git a/static/admin.html b/static/admin.html index 5a3349b..769739b 100644 --- a/static/admin.html +++ b/static/admin.html @@ -296,6 +296,85 @@ display: block; } + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + } + + .modal-content { + background: white; + border-radius: 12px; + padding: 24px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + } + + .modal-header h2 { + margin: 0; + } + + .modal-body { + margin-bottom: 20px; + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 20px; + border-top: 1px solid #eee; + } + + .server-section { + margin-bottom: 20px; + padding: 15px; + background: #f8f9fa; + border-radius: 8px; + } + + .server-section h3 { + margin: 0 0 10px 0; + font-size: 14px; + color: #333; + } + + .inbound-item { + margin: 5px 0; + } + + .inbound-item label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + } + + .inbound-item input[type="checkbox"] { + cursor: pointer; + } + + .required { + color: #dc3545; + } + .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); @@ -594,8 +673,14 @@
- + + Hostname that clients will connect to +
+
+ + + Internal address for gRPC API (optional, defaults to public hostname)
@@ -654,6 +739,7 @@
@@ -775,8 +861,8 @@ Name - Hostname - Port + Public Hostname + gRPC Address Status Actions @@ -786,10 +872,11 @@ ${escapeHtml(server.name)} ${escapeHtml(server.hostname)} - ${server.grpc_port} + ${escapeHtml(server.grpc_hostname)}:${server.grpc_port} ${server.status}
+
@@ -958,6 +1045,7 @@ const name = document.getElementById('serverName').value.trim(); const hostname = document.getElementById('serverHostname').value.trim(); + const grpc_hostname = document.getElementById('serverGrpcHostname').value.trim(); const grpc_port = document.getElementById('serverPort').value; const api_credentials = document.getElementById('serverCredentials').value.trim(); @@ -967,6 +1055,7 @@ } const serverData = { name, hostname }; + if (grpc_hostname) serverData.grpc_hostname = grpc_hostname; if (grpc_port) serverData.grpc_port = parseInt(grpc_port); if (api_credentials) serverData.api_credentials = api_credentials; @@ -1005,6 +1094,113 @@ } } + async function editServer(serverId) { + try { + // Fetch server data + const response = await fetch(`${API_BASE}/servers/${serverId}`); + if (!response.ok) throw new Error('Failed to fetch server'); + + const server = await response.json(); + + // Create edit modal + const modalContent = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalContent); + + // Handle form submission + document.getElementById('editServerForm').addEventListener('submit', async (e) => { + e.preventDefault(); + await updateServer(); + }); + + } catch (error) { + showAlert('Error loading server: ' + error.message, 'error'); + } + } + + function closeEditServerModal() { + const modal = document.querySelector('.modal-overlay'); + if (modal) { + modal.remove(); + } + } + + async function updateServer() { + const serverId = document.getElementById('editServerId').value; + const name = document.getElementById('editServerName').value.trim(); + const hostname = document.getElementById('editServerHostname').value.trim(); + const grpc_hostname = document.getElementById('editServerGrpcHostname').value.trim(); + const grpc_port = document.getElementById('editServerPort').value; + const api_credentials = document.getElementById('editServerCredentials').value.trim(); + + if (!name || !hostname) { + showAlert('Name and hostname are required', 'error'); + return; + } + + const serverData = { name, hostname }; + if (grpc_hostname) serverData.grpc_hostname = grpc_hostname; + if (grpc_port) serverData.grpc_port = parseInt(grpc_port); + if (api_credentials) serverData.api_credentials = api_credentials; + + try { + const response = await fetch(`${API_BASE}/servers/${serverId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serverData) + }); + + if (!response.ok) throw new Error('Failed to update server'); + + showAlert('Server updated successfully', 'success'); + closeEditServerModal(); + loadServers(); + + } catch (error) { + showAlert('Error updating server: ' + error.message, 'error'); + } + } + async function deleteServer(serverId, serverName) { if (!confirm(`Are you sure you want to delete server "${serverName}"?`)) return; @@ -1043,6 +1239,10 @@ // User Access Management async function editUserAccess(userId, userName) { try { + // First, load existing user access + const userAccessResponse = await fetch(`${API_BASE}/users/${userId}/access`); + const existingAccess = await userAccessResponse.ok ? await userAccessResponse.json() : []; + // Load servers and their inbounds const serversResponse = await fetch(`${API_BASE}/servers`); const servers = await serversResponse.json(); @@ -1057,12 +1257,14 @@