18 Commits

Author SHA1 Message Date
AB from home.homenet
cabdd3aad3 Drop bad ci
All checks were successful
Rust Docker Build / build (push) Successful in 5m51s
2025-10-27 09:57:48 +02:00
AB from home.homenet
8d10a4deef Drop bad ci 2025-10-27 09:53:37 +02:00
AB from home.homenet
eaf7f95cd3 Drop bad ci 2025-10-27 09:49:12 +02:00
AB from home.homenet
b96c520f9d Drop bad ci 2025-10-27 09:46:39 +02:00
AB from home.homenet
656209ee6e Drop bad ci
Some checks failed
Rust Docker Build / build (push) Failing after 24s
2025-10-26 17:08:32 +02:00
AB from home.homenet
c189562ac2 Drop bad ci
Some checks failed
Rust Docker Build / docker (push) Has been cancelled
2025-10-24 21:06:45 +03:00
AB from home.homenet
856dcc9f44 Drop bad ci 2025-10-24 21:05:07 +03:00
AB from home.homenet
5d826545b0 Drop bad ci 2025-10-24 21:03:23 +03:00
Alexandr Bogomyakov
b9f0687788 Update rust.yml 2025-10-24 20:59:26 +03:00
AB from home.homenet
2efd5873d5 Bump rust to 1.90
All checks were successful
Rust Docker Build / test-build (push) Successful in 7m27s
Rust Docker Build / docker (push) Successful in 1h12m55s
2025-10-24 19:37:17 +03:00
AB from home.homenet
c05d2f6223 Improve CI and lint code 2025-10-24 19:34:56 +03:00
AB from home.homenet
7e8831b89e Added TG user admin. Improved logging and TG UI 2025-10-24 18:45:04 +03:00
AB from home.homenet
78bf75b24e Added usermanagement in TG admin 2025-10-24 18:11:34 +03:00
AB from home.homenet
c6892b1a73 Made subs works
All checks were successful
Rust Docker Build / docker (push) Successful in 1h11m56s
2025-10-19 15:23:17 +03:00
AB from home.homenet
dae787657c Made subs works maybe 2025-10-19 05:27:55 +03:00
AB from home.homenet
d80ac56b83 Made subs 2025-10-19 05:06:38 +03:00
AB from home.homenet
d972f10f83 TG almost works 2025-10-19 04:13:36 +03:00
Ultradesu
42c8016d9c Added telegram 2025-10-18 15:49:49 +03:00
93 changed files with 10305 additions and 1988 deletions

61
.github/workflows/rust.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Rust Docker Build
on:
push:
branches:
- 'RUST'
env:
REGISTRY: docker.io
IMAGE_NAME: ultradesu/outfleet
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract version from Cargo.toml
id: extract_version
run: |
VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "cargo_version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
- name: Set build variables
id: vars
run: |
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "sha_full=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
echo "branch_name=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
GIT_COMMIT=${{ steps.vars.outputs.sha_full }}
GIT_COMMIT_SHORT=${{ steps.vars.outputs.sha_short }}
BUILD_DATE=${{ steps.vars.outputs.build_date }}
BRANCH_NAME=${{ steps.vars.outputs.branch_name }}
CARGO_VERSION=${{ steps.extract_version.outputs.cargo_version }}
tags: |
${{ env.IMAGE_NAME }}:rs-${{ steps.extract_version.outputs.cargo_version }}
${{ env.IMAGE_NAME }}:rs-${{ steps.extract_version.outputs.cargo_version }}-${{ steps.vars.outputs.sha_short }}
${{ env.IMAGE_NAME }}:rust-latest

174
API.md
View File

@@ -19,6 +19,34 @@ Complete API documentation for OutFleet - a web admin panel for managing xray-co
}
```
### User Subscription
- `GET /sub/{user_id}` - Get all user configuration links (subscription endpoint)
**Description:** Returns all VPN configuration links for a specific user, one per line. This endpoint is designed for VPN clients that support subscription URLs for automatic configuration updates.
**Path Parameters:**
- `user_id` (UUID) - The user's unique identifier
**Response:**
- **Content-Type:** `text/plain; charset=utf-8`
- **Success (200):** Base64 encoded string containing configuration URIs (one per line when decoded)
- **Not Found (404):** User doesn't exist
- **No Content:** Returns base64 encoded comment if no configurations available
**Example Response:**
```
dm1lc3M6Ly9leUoySWpvaU1pSXNJbkJ6SWpvaVUyVnlkbVZ5TVNJc0ltRmtaQ0k2SWpFeU55NHdMakF1TVM0eElpd2ljRzl5ZENJNklqUTBNeUlzSWxsa0lqb2lNVEl6TkRVMk56Z3RNVEl6TkMwMU5qYzRMVGxoWW1NdE1USXpORFUyTnpnNVlXSmpJaXdpWVdsa0lqb2lNQ0lzSW5Oamj0SWpvaVlYVjBieUlzSW01bGRDSTZJblJqY0NJc0luUjVjR1VpT2lKdWIyNWxJaXdpYUc5emRDSTZJaUlzSW5CaGRHZ2lPaUlpTEhKMGJITWlPaUowYkhNaUxGTnVhU0k2SWlKOQ0Kdmxlc3M6Ly91dWlkQGhvc3RuYW1lOnBvcnQ/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXRscyZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUjU2VydmVyTmFtZQ0Kc3M6Ly9ZV1Z6TFRJMk5TMW5ZMjFBY0dGemMzZHZjbVE2TVRJNExqQXVNQzR5T2pnd09EQT0jU2VydmVyMg0K
```
**Decoded Example:**
```
vmess://eyJ2IjoiMiIsInBzIjoiU2VydmVyMSIsImFkZCI6IjEyNy4wLjAuMSIsInBvcnQiOiI0NDMiLCJpZCI6IjEyMzQ1Njc4LTEyMzQtNTY3OC05YWJjLTEyMzQ1Njc4OWFiYyIsImFpZCI6IjAiLCJzY3kiOiJhdXRvIiwibmV0IjoidGNwIiwidHlwZSI6Im5vbmUiLCJob3N0IjoiIiwicGF0aCI6IiIsInRscyI6InRscyIsInNuaSI6IiJ9
vless://uuid@hostname:port?encryption=none&security=tls&type=tcp&headerType=none#ServerName
ss://YWVzLTI1Ni1nY21AcGFzc3dvcmQ6MTI3LjAuMC4xOjgwODA=#Server2
```
**Usage:** This endpoint is intended for VPN client applications that support subscription URLs. Users can add this URL to their VPN client to automatically receive all their configurations and get updates when configurations change.
## API Endpoints
All API endpoints are prefixed with `/api`.
@@ -504,4 +532,148 @@ All API endpoints are prefixed with `/api`.
"code": "ERROR_CODE",
"details": "Additional error details"
}
```
```
## Telegram Bot Integration
OutFleet includes a Telegram bot for user management and configuration access.
### User Management Endpoints
#### List User Requests
- `GET /api/user-requests` - Get all user access requests
- `GET /api/user-requests?status=pending` - Get pending requests only
**Response:**
```json
{
"items": [
{
"id": "uuid",
"user_id": "uuid|null",
"telegram_id": 123456789,
"telegram_username": "username",
"telegram_first_name": "John",
"telegram_last_name": "Doe",
"full_name": "John Doe",
"telegram_link": "@username",
"status": "pending|approved|declined",
"request_message": "Access request message",
"response_message": "Admin response",
"processed_by_user_id": "uuid|null",
"processed_at": "timestamp|null",
"created_at": "timestamp",
"updated_at": "timestamp"
}
],
"total": 50,
"page": 1,
"per_page": 20
}
```
#### Get User Request
- `GET /api/user-requests/{id}` - Get specific user request
#### Approve User Request
- `POST /api/user-requests/{id}/approve` - Approve user access request
**Request:**
```json
{
"response_message": "Welcome! Your access has been approved."
}
```
**Response:** Updated user request object
**Side Effects:**
- Creates a new user account
- Sends Telegram notification with main menu to the user
#### Decline User Request
- `POST /api/user-requests/{id}/decline` - Decline user access request
**Request:**
```json
{
"response_message": "Sorry, your request has been declined."
}
```
**Response:** Updated user request object
**Side Effects:**
- Sends Telegram notification to the user
#### Delete User Request
- `DELETE /api/user-requests/{id}` - Delete user request
### Telegram Bot Configuration
#### Get Telegram Status
- `GET /api/telegram/status` - Get bot status and configuration
**Response:**
```json
{
"is_running": true,
"config": {
"id": "uuid",
"name": "Bot Name",
"bot_token": "masked",
"is_active": true,
"created_at": "timestamp",
"updated_at": "timestamp"
}
}
```
#### Create/Update Telegram Config
- `POST /api/telegram/config` - Create new bot configuration
- `PUT /api/telegram/config/{id}` - Update bot configuration
**Request:**
```json
{
"name": "OutFleet Bot",
"bot_token": "bot_token_from_botfather",
"is_active": true
}
```
#### Telegram Admin Management
- `GET /api/telegram/admins` - Get all Telegram admins
- `POST /api/telegram/admins/{user_id}` - Add user as Telegram admin
- `DELETE /api/telegram/admins/{user_id}` - Remove user from Telegram admins
### Telegram Bot Features
#### User Flow
1. **Request Access**: Users send `/start` to the bot and request VPN access
2. **Admin Approval**: Admins receive notifications and can approve/decline via Telegram or web interface
3. **Configuration Access**: Approved users get access to:
- **🔗 Subscription Link**: Personal subscription URL (`/sub/{user_id}`)
- **⚙️ My Configs**: Individual configuration management
- **💬 Support**: Contact support
#### Admin Features
- **📋 User Requests**: View and manage pending access requests
- **📊 Statistics**: View system statistics
- **📢 Broadcast**: Send messages to all users
- **Approval Workflow**: Approve/decline requests with server selection
#### Subscription Link Integration
When users click "🔗 Subscription Link" in the Telegram bot, they receive:
- Personal subscription URL: `{BASE_URL}/sub/{user_id}`
- Instructions in their preferred language (Russian/English)
- Automatic updates when configurations change
**Environment Variables:**
- `BASE_URL` - Base URL for subscription links (default: `http://localhost:8080`)
### Bot Commands
- `/start` - Start bot and show main menu
- `/requests` - [Admin] View pending user requests
- `/stats` - [Admin] Show system statistics
- `/broadcast <message>` - [Admin] Send message to all users

593
Cargo.lock generated
View File

@@ -126,6 +126,20 @@ version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]]
name = "aquamarine"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21cc1548309245035eb18aa7f0967da6bc65587005170c56e6ef2788a4cf3f4e"
dependencies = [
"include_dir",
"itertools 0.10.5",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
@@ -138,6 +152,16 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -186,6 +210,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-future"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373"
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -283,6 +313,35 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "axum-test"
version = "14.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167294800740b4b6bc7bfbccbf3a1d50a6c6e097342580ec4c11d1672e456292"
dependencies = [
"anyhow",
"async-trait",
"auto-future",
"axum",
"bytes",
"cookie",
"http 1.3.1",
"http-body-util",
"hyper 1.7.0",
"hyper-util",
"mime",
"pretty_assertions",
"reserve-port",
"rust-multipart-rfc7578_2",
"serde",
"serde_json",
"serde_urlencoded",
"smallvec",
"tokio",
"tower 0.4.13",
"url",
]
[[package]]
name = "backtrace"
version = "0.3.75"
@@ -532,7 +591,7 @@ dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
"strsim 0.11.1",
]
[[package]]
@@ -594,7 +653,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf"
dependencies = [
"async-trait",
"convert_case",
"convert_case 0.6.0",
"json5",
"nom",
"pathdiff",
@@ -632,6 +691,12 @@ dependencies = [
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "convert_case"
version = "0.6.0"
@@ -641,6 +706,16 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"time",
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -733,14 +808,38 @@ dependencies = [
"typenum",
]
[[package]]
name = "darling"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [
"darling_core 0.13.4",
"darling_macro 0.13.4",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling_core"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn 1.0.109",
]
[[package]]
@@ -753,21 +852,50 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
dependencies = [
"darling_core 0.13.4",
"quote",
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"darling_core 0.20.11",
"quote",
"syn 2.0.106",
]
[[package]]
name = "deadpool"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
dependencies = [
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "der"
version = "0.7.10"
@@ -789,6 +917,25 @@ dependencies = [
"serde",
]
[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case 0.4.0",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.106",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -827,6 +974,21 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
[[package]]
name = "dptree"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81175dab5ec79c30e0576df2ed2c244e1721720c302000bb321b107e82e265c"
dependencies = [
"futures",
]
[[package]]
name = "dunce"
version = "1.0.5"
@@ -857,6 +1019,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erasable"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "437cfb75878119ed8265685c41a115724eae43fb7cc5a0bf0e4ecc3b803af1c4"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -864,7 +1036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.0",
]
[[package]]
@@ -954,6 +1126,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fragile"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -974,6 +1152,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
@@ -1024,6 +1203,17 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -1042,8 +1232,10 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
@@ -1195,6 +1387,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1575,6 +1773,25 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -1655,6 +1872,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -1885,6 +2111,33 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "mockall"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48"
dependencies = [
"cfg-if",
"downcast",
"fragile",
"lazy_static",
"mockall_derive",
"predicates",
"predicates-tree",
]
[[package]]
name = "mockall_derive"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2"
dependencies = [
"cfg-if",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "multimap"
version = "0.10.1"
@@ -2001,6 +2254,16 @@ dependencies = [
"libm",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "object"
version = "0.36.7"
@@ -2315,6 +2578,42 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"predicates-core",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -2525,6 +2824,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rc-box"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897fecc9fac6febd4408f9e935e86df739b0023b625e610e0357535b9c8adad0"
dependencies = [
"erasable",
]
[[package]]
name = "rcgen"
version = "0.12.1"
@@ -2618,6 +2926,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"once_cell",
"percent-encoding",
@@ -2632,15 +2941,26 @@ dependencies = [
"tokio",
"tokio-native-tls",
"tokio-rustls 0.24.1",
"tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 0.25.4",
"winreg",
]
[[package]]
name = "reserve-port"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356"
dependencies = [
"thiserror 2.0.16",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -2726,6 +3046,22 @@ dependencies = [
"ordered-multimap",
]
[[package]]
name = "rust-multipart-rfc7578_2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57"
dependencies = [
"bytes",
"futures-core",
"futures-util",
"http 0.2.12",
"mime",
"mime_guess",
"rand",
"thiserror 1.0.69",
]
[[package]]
name = "rust_decimal"
version = "1.38.0"
@@ -2754,6 +3090,15 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.2"
@@ -2764,7 +3109,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.60.2",
"windows-sys 0.61.0",
]
[[package]]
@@ -2895,6 +3240,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.28"
@@ -2920,6 +3274,12 @@ dependencies = [
"untrusted 0.9.0",
]
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "sea-bae"
version = "0.2.1"
@@ -3047,7 +3407,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
dependencies = [
"darling",
"darling 0.20.11",
"heck 0.4.1",
"proc-macro2",
"quote",
@@ -3120,6 +3480,12 @@ dependencies = [
"libc",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.225"
@@ -3195,6 +3561,28 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff"
dependencies = [
"serde",
"serde_with_macros",
]
[[package]]
name = "serde_with_macros"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082"
dependencies = [
"darling 0.13.4",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -3208,6 +3596,31 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "serial_test"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
dependencies = [
"futures",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -3219,6 +3632,12 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3556,6 +3975,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3640,12 +4065,92 @@ dependencies = [
"libc",
]
[[package]]
name = "take_mut"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60"
[[package]]
name = "takecell"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20f34339676cdcab560c9a82300c4c2581f68b9369aedf0fae86f2ff9565ff3e"
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "teloxide"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f79dd283eb21b90451c03fa7c7f83b9985130efb876b33bad89a2c208ccbc16"
dependencies = [
"aquamarine",
"bytes",
"derive_more",
"dptree",
"either",
"futures",
"log",
"mime",
"pin-project",
"serde",
"serde_json",
"teloxide-core",
"teloxide-macros",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tokio-util",
"url",
]
[[package]]
name = "teloxide-core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1642a7ef10e7af63b8298c8d13c0f986d4fc646d42649ff060359607f62f69"
dependencies = [
"bitflags 1.3.2",
"bytes",
"chrono",
"derive_more",
"either",
"futures",
"log",
"mime",
"once_cell",
"pin-project",
"rc-box",
"reqwest",
"serde",
"serde_json",
"serde_with",
"take_mut",
"takecell",
"thiserror 1.0.69",
"tokio",
"tokio-util",
"url",
"uuid",
]
[[package]]
name = "teloxide-macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e2d33d809c3e7161a9ab18bedddf98821245014f0a78fa4d2c9430b2ec018c1"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "tempfile"
version = "3.22.0"
@@ -3656,9 +4161,15 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.60.2",
"windows-sys 0.61.0",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -3859,6 +4370,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-test"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
dependencies = [
"async-stream",
"bytes",
"futures-core",
"tokio",
"tokio-stream",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@@ -4236,6 +4760,7 @@ dependencies = [
"getrandom 0.3.3",
"js-sys",
"serde",
"sha1_smol",
"wasm-bindgen",
]
@@ -4261,7 +4786,7 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10"
dependencies = [
"darling",
"darling 0.20.11",
"once_cell",
"proc-macro-error",
"proc-macro2",
@@ -4408,6 +4933,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.80"
@@ -4467,7 +5005,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.0",
]
[[package]]
@@ -4851,6 +5389,29 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "wiremock"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64 0.22.1",
"deadpool",
"futures",
"http 1.3.1",
"http-body-util",
"hyper 1.7.0",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"
@@ -4874,11 +5435,12 @@ dependencies = [
[[package]]
name = "xray-admin"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"async-trait",
"axum",
"axum-test",
"base64 0.21.7",
"chrono",
"clap",
@@ -4886,6 +5448,7 @@ dependencies = [
"hyper 1.7.0",
"instant-acme",
"log",
"mockall",
"pem",
"prost",
"rand",
@@ -4898,11 +5461,14 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"teloxide",
"tempfile",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-cron-scheduler",
"tokio-test",
"toml",
"tonic",
"tower 0.4.13",
@@ -4913,6 +5479,7 @@ dependencies = [
"urlencoding",
"uuid",
"validator",
"wiremock",
"xray-core",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "xray-admin"
version = "0.1.0"
version = "0.1.3"
edition = "2021"
[dependencies]
@@ -37,7 +37,7 @@ sea-orm = { version = "1.0", features = ["sqlx-postgres", "runtime-tokio-rustls"
sea-orm-migration = "1.0"
# Additional utilities
uuid = { version = "1.0", features = ["v4", "serde"] }
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
async-trait = "0.1"
log = "0.4"
@@ -65,5 +65,13 @@ rustls = { version = "0.23", features = ["aws-lc-rs"] } # TLS library with aws-
ring = "0.17" # Crypto for ACME
pem = "3.0" # PEM format support
# Telegram bot support
teloxide = { version = "0.13", features = ["macros"] }
[dev-dependencies]
tempfile = "3.0"
tempfile = "3.0"
tokio-test = "0.4"
wiremock = "0.6"
axum-test = "14.0"
serial_test = "3.0"
mockall = "0.12"

View File

@@ -1,5 +1,5 @@
# Build stage
FROM rust:latest as builder
# Build stage with Rust
FROM rust:1.90-bookworm AS builder
WORKDIR /app
@@ -10,26 +10,65 @@ RUN apt-get update && apt-get install -y \
protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
# Copy dependency files
# Build arguments
ARG GIT_COMMIT="development"
ARG GIT_COMMIT_SHORT="dev"
ARG BUILD_DATE="unknown"
ARG BRANCH_NAME="unknown"
ARG CARGO_VERSION="0.1.0"
# Environment variables from build args
ENV GIT_COMMIT=${GIT_COMMIT}
ENV GIT_COMMIT_SHORT=${GIT_COMMIT_SHORT}
ENV BUILD_DATE=${BUILD_DATE}
ENV BRANCH_NAME=${BRANCH_NAME}
ENV CARGO_VERSION=${CARGO_VERSION}
# Copy dependency files first for caching
COPY Cargo.toml Cargo.lock ./
# Copy source code
# Create dummy source files to build dependencies
RUN mkdir -p src && \
echo "fn main() {}" > src/main.rs && \
echo "pub fn lib() {}" > src/lib.rs
# Build dependencies (this layer will be cached)
RUN cargo build --release && \
rm -rf src target/release/deps/xray_admin* target/release/xray-admin*
# Copy actual source code
COPY src ./src
COPY static ./static
# Build the application
RUN cargo build --release
RUN cargo build --release --locked
# Runtime stage
FROM ubuntu:24.04
# Runtime stage - Ubuntu for glibc compatibility
FROM ubuntu:24.04 AS runtime
# Build arguments (needed for runtime stage)
ARG GIT_COMMIT="development"
ARG GIT_COMMIT_SHORT="dev"
ARG BUILD_DATE="unknown"
ARG BRANCH_NAME="unknown"
ARG CARGO_VERSION="0.1.0"
# Environment variables from build args
ENV GIT_COMMIT=${GIT_COMMIT}
ENV GIT_COMMIT_SHORT=${GIT_COMMIT_SHORT}
ENV BUILD_DATE=${BUILD_DATE}
ENV BRANCH_NAME=${BRANCH_NAME}
ENV CARGO_VERSION=${CARGO_VERSION}
WORKDIR /app
# Install runtime dependencies
# Install minimal runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
libprotobuf32 \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Copy the binary from builder
COPY --from=builder /app/target/release/xray-admin /app/xray-admin
@@ -40,6 +79,11 @@ COPY --from=builder /app/static ./static
# Copy config file
COPY config.docker.toml ./config.toml
# Create non-root user for security
RUN groupadd -r outfleet && useradd -r -g outfleet -s /bin/false outfleet
RUN chown -R outfleet:outfleet /app
USER outfleet
EXPOSE 8081
CMD ["/app/xray-admin", "--host", "0.0.0.0"]
CMD ["/app/xray-admin", "--host", "0.0.0.0"]

View File

@@ -26,6 +26,9 @@ pub struct Args {
#[arg(long, default_value = "info")]
pub log_level: Option<String>,
/// Base URL for the application (used in subscription links and Telegram messages)
#[arg(long, env = "BASE_URL")]
pub base_url: Option<String>,
/// Validate configuration and exit
#[arg(long)]
@@ -48,13 +51,17 @@ mod tests {
fn test_args_parsing() {
let args = Args::try_parse_from(&[
"xray-admin",
"--config", "test.toml",
"--port", "9090",
"--log-level", "debug"
]).unwrap();
"--config",
"test.toml",
"--port",
"9090",
"--log-level",
"debug",
])
.unwrap();
assert_eq!(args.config, Some(PathBuf::from("test.toml")));
assert_eq!(args.port, Some(9090));
assert_eq!(args.log_level, Some("debug".to_string()));
}
}
}

View File

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

View File

@@ -14,10 +14,14 @@ impl ConfigFile {
pub fn load_toml<P: AsRef<Path>>(path: P) -> Result<AppConfig> {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
let config: AppConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse TOML config file: {}", path.as_ref().display()))?;
let config: AppConfig = toml::from_str(&content).with_context(|| {
format!(
"Failed to parse TOML config file: {}",
path.as_ref().display()
)
})?;
Ok(config)
}
@@ -25,10 +29,14 @@ impl ConfigFile {
pub fn load_yaml<P: AsRef<Path>>(path: P) -> Result<AppConfig> {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
let config: AppConfig = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML config file: {}", path.as_ref().display()))?;
let config: AppConfig = serde_yaml::from_str(&content).with_context(|| {
format!(
"Failed to parse YAML config file: {}",
path.as_ref().display()
)
})?;
Ok(config)
}
@@ -36,17 +44,21 @@ impl ConfigFile {
pub fn load_json<P: AsRef<Path>>(path: P) -> Result<AppConfig> {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
let config: AppConfig = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON config file: {}", path.as_ref().display()))?;
let config: AppConfig = serde_json::from_str(&content).with_context(|| {
format!(
"Failed to parse JSON config file: {}",
path.as_ref().display()
)
})?;
Ok(config)
}
/// Auto-detect format and load configuration file
pub fn load_auto<P: AsRef<Path>>(path: P) -> Result<AppConfig> {
let path = path.as_ref();
match path.extension().and_then(|ext| ext.to_str()) {
Some("toml") => Self::load_toml(path),
Some("yaml") | Some("yml") => Self::load_yaml(path),
@@ -68,41 +80,45 @@ impl ConfigFile {
/// Save configuration to TOML file
pub fn save_toml<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<()> {
let content = toml::to_string_pretty(config)
.context("Failed to serialize config to TOML")?;
let content =
toml::to_string_pretty(config).context("Failed to serialize config to TOML")?;
fs::write(&path, content)
.with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?;
Ok(())
}
/// Save configuration to YAML file
pub fn save_yaml<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<()> {
let content = serde_yaml::to_string(config)
.context("Failed to serialize config to YAML")?;
let content =
serde_yaml::to_string(config).context("Failed to serialize config to YAML")?;
fs::write(&path, content)
.with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?;
Ok(())
}
/// Save configuration to JSON file
pub fn save_json<P: AsRef<Path>>(config: &AppConfig, path: P) -> Result<()> {
let content = serde_json::to_string_pretty(config)
.context("Failed to serialize config to JSON")?;
let content =
serde_json::to_string_pretty(config).context("Failed to serialize config to JSON")?;
fs::write(&path, content)
.with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?;
Ok(())
}
/// Check if config file exists and is readable
pub fn exists_and_readable<P: AsRef<Path>>(path: P) -> bool {
let path = path.as_ref();
path.exists() && path.is_file() && fs::metadata(path).map(|m| !m.permissions().readonly()).unwrap_or(false)
path.exists()
&& path.is_file()
&& fs::metadata(path)
.map(|m| !m.permissions().readonly())
.unwrap_or(false)
}
/// Find default config file in common locations
@@ -140,26 +156,29 @@ mod tests {
fn test_save_and_load_toml() -> Result<()> {
let config = AppConfig::default();
let temp_file = NamedTempFile::new()?;
ConfigFile::save_toml(&config, temp_file.path())?;
let loaded_config = ConfigFile::load_toml(temp_file.path())?;
assert_eq!(config.web.port, loaded_config.web.port);
assert_eq!(config.database.max_connections, loaded_config.database.max_connections);
assert_eq!(
config.database.max_connections,
loaded_config.database.max_connections
);
Ok(())
}
#[test]
fn test_auto_detect_format() -> Result<()> {
let config = AppConfig::default();
// Test with .toml extension
let temp_file = NamedTempFile::with_suffix(".toml")?;
ConfigFile::save_toml(&config, temp_file.path())?;
let loaded_config = ConfigFile::load_auto(temp_file.path())?;
assert_eq!(config.web.port, loaded_config.web.port);
Ok(())
}
}
}

View File

@@ -37,6 +37,10 @@ pub struct WebConfig {
pub jwt_secret: String,
#[validate(range(min = 3600))]
pub jwt_expiry: u64,
/// Base URL for the application (used in subscription links and Telegram messages)
/// Example: "https://vpn.hexor.cy"
#[validate(url)]
pub base_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
@@ -84,6 +88,7 @@ impl Default for WebConfig {
cors_origins: vec!["http://localhost:3000".to_string()],
jwt_secret: "your-secret-key-change-in-production".to_string(),
jwt_expiry: 86400, // 24 hours
base_url: "http://localhost:8080".to_string(),
}
}
}
@@ -142,7 +147,7 @@ impl AppConfig {
/// 4. Default values (lowest)
pub fn load() -> Result<Self> {
let args = args::parse_args();
let mut builder = config::Config::builder()
// Start with defaults
.add_source(config::Config::try_from(&AppConfig::default())?);
@@ -158,7 +163,7 @@ impl AppConfig {
builder = builder.add_source(
config::Environment::with_prefix("XRAY_ADMIN")
.separator("__")
.try_parsing(true)
.try_parsing(true),
);
// Override with command line arguments
@@ -174,12 +179,15 @@ impl AppConfig {
if let Some(log_level) = &args.log_level {
builder = builder.set_override("logging.level", log_level.as_str())?;
}
if let Some(base_url) = &args.base_url {
builder = builder.set_override("web.base_url", base_url.as_str())?;
}
let config: AppConfig = builder.build()?.try_deserialize()?;
// Validate configuration
config.validate()?;
Ok(config)
}
@@ -188,8 +196,18 @@ impl AppConfig {
tracing::info!(" Database URL: {}", mask_sensitive(&self.database.url));
tracing::info!(" Web server: {}:{}", self.web.host, self.web.port);
tracing::info!(" Log level: {}", self.logging.level);
tracing::info!(" Telegram bot: {}", if self.telegram.bot_token.is_empty() { "disabled" } else { "enabled" });
tracing::info!(" Xray config path: {}", self.xray.config_template_path.display());
tracing::info!(
" Telegram bot: {}",
if self.telegram.bot_token.is_empty() {
"disabled"
} else {
"enabled"
}
);
tracing::info!(
" Xray config path: {}",
self.xray.config_template_path.display()
);
}
}
@@ -208,7 +226,7 @@ fn mask_sensitive(url: &str) -> String {
}
}
}
// Fallback to URL parsing if simple approach fails
if let Ok(parsed) = url::Url::parse(url) {
if parsed.password().is_some() {
@@ -241,4 +259,4 @@ mod tests {
assert!(masked.contains("***"));
assert!(!masked.contains("password"));
}
}
}

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait};
use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
@@ -7,29 +7,29 @@ use serde::{Deserialize, Serialize};
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub name: String,
#[sea_orm(column_name = "cert_type")]
pub cert_type: String,
pub domain: String,
#[serde(skip_serializing)]
pub cert_data: Vec<u8>,
#[serde(skip_serializing)]
pub key_data: Vec<u8>,
#[serde(skip_serializing)]
pub chain_data: Option<Vec<u8>>,
pub expires_at: DateTimeUtc,
pub auto_renew: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
@@ -67,7 +67,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>>
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
@@ -180,7 +182,7 @@ impl From<Model> for CertificateDetailsResponse {
fn from(cert: Model) -> Self {
let certificate_pem = cert.certificate_pem();
let has_private_key = !cert.key_data.is_empty();
Self {
id: cert.id,
name: cert.name,
@@ -220,14 +222,14 @@ impl Model {
pub fn apply_update(self, dto: UpdateCertificateDto) -> ActiveModel {
let mut active_model: ActiveModel = self.into();
if let Some(name) = dto.name {
active_model.name = Set(name);
}
if let Some(auto_renew) = dto.auto_renew {
active_model.auto_renew = Set(auto_renew);
}
active_model
}
}
@@ -246,4 +248,4 @@ impl From<CreateCertificateDto> for ActiveModel {
..Self::new()
}
}
}
}

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait};
use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -8,18 +8,18 @@ use uuid::Uuid;
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub name: String,
pub provider_type: String, // "cloudflare", "route53", etc.
#[serde(skip_serializing)]
pub api_token: String, // Encrypted storage in production
pub is_active: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
@@ -40,7 +40,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>>
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
@@ -100,7 +102,7 @@ impl Model {
/// Update this model with data from UpdateDnsProviderDto
pub fn apply_update(self, dto: UpdateDnsProviderDto) -> ActiveModel {
let mut active_model: ActiveModel = self.into();
if let Some(name) = dto.name {
active_model.name = Set(name);
}
@@ -110,11 +112,11 @@ impl Model {
if let Some(is_active) = dto.is_active {
active_model.is_active = Set(is_active);
}
active_model.updated_at = Set(chrono::Utc::now());
active_model
}
/// Convert to response DTO (without exposing API token)
pub fn to_response_dto(&self) -> DnsProviderResponseDto {
DnsProviderResponseDto {
@@ -142,15 +144,15 @@ impl DnsProviderType {
DnsProviderType::Cloudflare => "cloudflare",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"cloudflare" => Some(DnsProviderType::Cloudflare),
_ => None,
}
}
pub fn all() -> Vec<Self> {
vec![DnsProviderType::Cloudflare]
}
}
}

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait};
use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -8,29 +8,29 @@ use serde_json::Value;
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub protocol: String,
pub default_port: i32,
pub base_settings: Value,
pub stream_settings: Value,
pub requires_tls: bool,
pub requires_domain: bool,
pub variables: Value,
pub is_active: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
@@ -60,7 +60,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>>
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
@@ -187,9 +189,9 @@ impl From<Model> for InboundTemplateResponse {
impl From<CreateInboundTemplateDto> for ActiveModel {
fn from(dto: CreateInboundTemplateDto) -> Self {
// Parse config_template as JSON or use default
let config_json: Value = serde_json::from_str(&dto.config_template)
.unwrap_or_else(|_| serde_json::json!({}));
let config_json: Value =
serde_json::from_str(&dto.config_template).unwrap_or_else(|_| serde_json::json!({}));
Self {
name: Set(dto.name),
description: Set(None),
@@ -212,17 +214,20 @@ impl Model {
}
#[allow(dead_code)]
pub fn apply_variables(&self, values: &serde_json::Map<String, Value>) -> Result<(Value, Value), String> {
pub fn apply_variables(
&self,
values: &serde_json::Map<String, Value>,
) -> Result<(Value, Value), String> {
let base_settings = self.base_settings.clone();
let stream_settings = self.stream_settings.clone();
// Replace variables in JSON using simple string replacement
let base_str = base_settings.to_string();
let stream_str = stream_settings.to_string();
let mut result_base = base_str;
let mut result_stream = stream_str;
for (key, value) in values {
let placeholder = format!("${{{}}}", key);
let replacement = match value {
@@ -233,18 +238,18 @@ impl Model {
result_base = result_base.replace(&placeholder, &replacement);
result_stream = result_stream.replace(&placeholder, &replacement);
}
let final_base: Value = serde_json::from_str(&result_base)
.map_err(|e| format!("Invalid base settings after variable substitution: {}", e))?;
let final_stream: Value = serde_json::from_str(&result_stream)
.map_err(|e| format!("Invalid stream settings after variable substitution: {}", e))?;
Ok((final_base, final_stream))
}
pub fn apply_update(self, dto: UpdateInboundTemplateDto) -> ActiveModel {
let mut active_model: ActiveModel = self.into();
if let Some(name) = dto.name {
active_model.name = Set(name);
}
@@ -267,12 +272,13 @@ impl Model {
active_model.requires_domain = Set(requires_domain);
}
if let Some(variables) = dto.variables {
active_model.variables = Set(serde_json::to_value(variables).unwrap_or(Value::Array(vec![])));
active_model.variables =
Set(serde_json::to_value(variables).unwrap_or(Value::Array(vec![])));
}
if let Some(is_active) = dto.is_active {
active_model.is_active = Set(is_active);
}
active_model
}
}
}

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait};
use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -8,24 +8,24 @@ use uuid::Uuid;
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
/// Reference to the actual user
pub user_id: Uuid,
pub server_inbound_id: Uuid,
/// Generated xray user ID (UUID for protocols like vmess/vless)
pub xray_user_id: String,
/// Generated password for protocols like trojan/shadowsocks
pub password: Option<String>,
pub level: i32,
pub is_active: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
@@ -71,7 +71,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>>
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
@@ -99,12 +101,12 @@ impl CreateInboundUserDto {
pub fn generate_xray_user_id(&self) -> String {
Uuid::new_v4().to_string()
}
/// Generate random password (for trojan/shadowsocks)
pub fn generate_password(&self) -> String {
use rand::prelude::*;
use rand::distributions::Alphanumeric;
use rand::prelude::*;
thread_rng()
.sample_iter(&Alphanumeric)
.take(24)
@@ -123,7 +125,7 @@ pub struct UpdateInboundUserDto {
impl From<CreateInboundUserDto> for ActiveModel {
fn from(dto: CreateInboundUserDto) -> Self {
let xray_user_id = dto.generate_xray_user_id();
Self {
user_id: Set(dto.user_id),
server_inbound_id: Set(dto.server_inbound_id),
@@ -140,17 +142,17 @@ impl Model {
/// Update this model with data from UpdateInboundUserDto
pub fn apply_update(self, dto: UpdateInboundUserDto) -> ActiveModel {
let mut active_model: ActiveModel = self.into();
if let Some(level) = dto.level {
active_model.level = Set(level);
}
if let Some(is_active) = dto.is_active {
active_model.is_active = Set(is_active);
}
active_model
}
/// Generate email for xray client based on user information
pub fn generate_client_email(&self, username: &str) -> String {
format!("{}@OutFleet", username)
@@ -185,4 +187,4 @@ impl From<Model> for InboundUserResponse {
updated_at: model.updated_at.to_rfc3339(),
}
}
}
}

View File

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

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait};
use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
@@ -7,24 +7,24 @@ use serde::{Deserialize, Serialize};
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub name: String,
pub hostname: String,
pub grpc_hostname: String,
pub grpc_port: i32,
#[serde(skip_serializing)]
pub api_credentials: Option<String>,
pub status: String,
pub default_certificate_id: Option<Uuid>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
@@ -67,7 +67,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>>
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
@@ -185,7 +187,7 @@ impl From<Model> for ServerResponse {
impl Model {
pub fn apply_update(self, dto: UpdateServerDto) -> ActiveModel {
let mut active_model: ActiveModel = self.into();
if let Some(name) = dto.name {
active_model.name = Set(name);
}
@@ -207,16 +209,23 @@ impl Model {
if let Some(default_certificate_id) = dto.default_certificate_id {
active_model.default_certificate_id = Set(Some(default_certificate_id));
}
active_model
}
pub fn get_grpc_endpoint(&self) -> String {
let hostname = if self.grpc_hostname.is_empty() {
tracing::debug!("Using public hostname '{}' for gRPC (grpc_hostname is empty)", self.hostname);
tracing::debug!(
"Using public hostname '{}' for gRPC (grpc_hostname is empty)",
self.hostname
);
&self.hostname
} else {
tracing::debug!("Using dedicated gRPC hostname '{}' (different from public hostname '{}')", self.grpc_hostname, self.hostname);
tracing::debug!(
"Using dedicated gRPC hostname '{}' (different from public hostname '{}')",
self.grpc_hostname,
self.hostname
);
&self.grpc_hostname
};
let endpoint = format!("{}:{}", hostname, self.grpc_port);
@@ -228,4 +237,4 @@ impl Model {
pub fn get_status(&self) -> ServerStatus {
self.status.clone().into()
}
}
}

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait};
use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -8,23 +8,23 @@ use serde_json::Value;
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub server_id: Uuid,
pub template_id: Uuid,
pub tag: String,
pub port_override: Option<i32>,
pub certificate_id: Option<Uuid>,
pub variable_values: Value,
pub is_active: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
@@ -82,7 +82,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>>
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
@@ -95,7 +97,6 @@ impl ActiveModelBehavior for ActiveModel {
Ok(self)
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -145,7 +146,7 @@ impl From<Model> for ServerInboundResponse {
is_active: inbound.is_active,
created_at: inbound.created_at,
updated_at: inbound.updated_at,
template_name: None, // Will be filled by repository if needed
template_name: None, // Will be filled by repository if needed
certificate_name: None, // Will be filled by repository if needed
}
}
@@ -154,7 +155,7 @@ impl From<Model> for ServerInboundResponse {
impl Model {
pub fn apply_update(self, dto: UpdateServerInboundDto) -> ActiveModel {
let mut active_model: ActiveModel = self.into();
if let Some(tag) = dto.tag {
active_model.tag = Set(tag);
}
@@ -170,7 +171,7 @@ impl Model {
if let Some(is_active) = dto.is_active {
active_model.is_active = Set(is_active);
}
active_model
}
@@ -201,4 +202,4 @@ impl From<CreateServerInboundDto> for ActiveModel {
..Self::new()
}
}
}
}

View File

@@ -0,0 +1,96 @@
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "telegram_config")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
/// Telegram bot token (encrypted in production)
pub bot_token: String,
/// Whether the bot is active
pub is_active: bool,
/// When the config was created
pub created_at: DateTimeUtc,
/// Last time config was updated
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {
/// Called before insert and update
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
created_at: Set(chrono::Utc::now()),
updated_at: Set(chrono::Utc::now()),
..ActiveModelTrait::default()
}
}
/// Called before update
fn before_save<'life0, 'async_trait, C>(
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
Self: 'async_trait,
{
Box::pin(async move {
if !insert {
self.updated_at = Set(chrono::Utc::now());
} else if self.id.is_not_set() {
self.id = Set(Uuid::new_v4());
}
if self.created_at.is_not_set() {
self.created_at = Set(chrono::Utc::now());
}
if self.updated_at.is_not_set() {
self.updated_at = Set(chrono::Utc::now());
}
Ok(self)
})
}
}
/// DTO for creating a new Telegram configuration
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateTelegramConfigDto {
pub bot_token: String,
pub is_active: bool,
}
/// DTO for updating Telegram configuration
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateTelegramConfigDto {
pub bot_token: Option<String>,
pub is_active: Option<bool>,
}
impl Model {
/// Convert to ActiveModel for updates
pub fn into_active_model(self) -> ActiveModel {
ActiveModel {
id: Set(self.id),
bot_token: Set(self.bot_token),
is_active: Set(self.is_active),
created_at: Set(self.created_at),
updated_at: Set(self.updated_at),
}
}
}

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait};
use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
@@ -7,20 +7,23 @@ use serde::{Deserialize, Serialize};
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
/// User display name
pub name: String,
/// Optional comment/description about the user
#[sea_orm(column_type = "Text")]
pub comment: Option<String>,
/// Optional Telegram user ID for bot integration
pub telegram_id: Option<i64>,
/// Whether the user is a Telegram admin
pub is_telegram_admin: bool,
/// When the user was registered/created
pub created_at: DateTimeUtc,
/// Last time user record was updated
pub updated_at: DateTimeUtc,
}
@@ -33,6 +36,7 @@ impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
is_telegram_admin: Set(false),
created_at: Set(chrono::Utc::now()),
updated_at: Set(chrono::Utc::now()),
..ActiveModelTrait::default()
@@ -44,7 +48,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>>
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
@@ -65,6 +71,8 @@ pub struct CreateUserDto {
pub name: String,
pub comment: Option<String>,
pub telegram_id: Option<i64>,
#[serde(default)]
pub is_telegram_admin: bool,
}
/// User update data transfer object
@@ -73,6 +81,7 @@ pub struct UpdateUserDto {
pub name: Option<String>,
pub comment: Option<String>,
pub telegram_id: Option<i64>,
pub is_telegram_admin: Option<bool>,
}
impl From<CreateUserDto> for ActiveModel {
@@ -81,6 +90,7 @@ impl From<CreateUserDto> for ActiveModel {
name: Set(dto.name),
comment: Set(dto.comment),
telegram_id: Set(dto.telegram_id),
is_telegram_admin: Set(dto.is_telegram_admin),
..Self::new()
}
}
@@ -90,7 +100,7 @@ impl Model {
/// Update this model with data from UpdateUserDto
pub fn apply_update(self, dto: UpdateUserDto) -> ActiveModel {
let mut active_model: ActiveModel = self.into();
if let Some(name) = dto.name {
active_model.name = Set(name);
}
@@ -103,7 +113,10 @@ impl Model {
if dto.telegram_id.is_some() {
active_model.telegram_id = Set(dto.telegram_id);
}
if let Some(is_admin) = dto.is_telegram_admin {
active_model.is_telegram_admin = Set(is_admin);
}
active_model
}
@@ -133,12 +146,16 @@ mod tests {
name: "Test User".to_string(),
comment: Some("Test comment".to_string()),
telegram_id: Some(123456789),
is_telegram_admin: false,
};
let active_model: ActiveModel = dto.into();
assert_eq!(active_model.name.unwrap(), "Test User");
assert_eq!(active_model.comment.unwrap(), Some("Test comment".to_string()));
assert_eq!(
active_model.comment.unwrap(),
Some("Test comment".to_string())
);
assert_eq!(active_model.telegram_id.unwrap(), Some(123456789));
}
@@ -149,6 +166,7 @@ mod tests {
name: "John Doe".to_string(),
comment: Some("Admin user".to_string()),
telegram_id: None,
is_telegram_admin: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
@@ -170,6 +188,7 @@ mod tests {
name: "User".to_string(),
comment: None,
telegram_id: Some(123456789),
is_telegram_admin: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
@@ -182,4 +201,4 @@ mod tests {
assert!(user_with_telegram.has_telegram());
assert!(!user_without_telegram.has_telegram());
}
}
}

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait};
use sea_orm::{ActiveModelTrait, Set};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
@@ -7,31 +7,31 @@ use serde::{Deserialize, Serialize};
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
/// User ID this access is for
pub user_id: Uuid,
/// Server ID this access applies to
pub server_id: Uuid,
/// Server inbound ID this access applies to
pub server_inbound_id: Uuid,
/// User's unique identifier in xray (UUID for VLESS/VMess, password for Trojan)
pub xray_user_id: String,
/// User's email in xray
pub xray_email: String,
/// User level in xray (0-255)
pub level: i32,
/// Whether this access is currently active
pub is_active: bool,
/// When this access was created
pub created_at: DateTimeUtc,
/// Last time this access was updated
pub updated_at: DateTimeUtc,
}
@@ -46,7 +46,7 @@ pub enum Relation {
User,
#[sea_orm(
belongs_to = "super::server::Entity",
from = "Column::ServerId",
from = "Column::ServerId",
to = "super::server::Column::Id"
)]
Server,
@@ -90,7 +90,9 @@ impl ActiveModelBehavior for ActiveModel {
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>>
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>,
>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
@@ -103,7 +105,6 @@ impl ActiveModelBehavior for ActiveModel {
Ok(self)
})
}
}
/// User access creation data transfer object
@@ -143,14 +144,14 @@ impl Model {
/// Update this model with data from UpdateUserAccessDto
pub fn apply_update(self, dto: UpdateUserAccessDto) -> ActiveModel {
let mut active_model: ActiveModel = self.into();
if let Some(is_active) = dto.is_active {
active_model.is_active = Set(is_active);
}
if let Some(level) = dto.level {
active_model.level = Set(level);
}
active_model
}
}
@@ -185,4 +186,4 @@ impl From<Model> for UserAccessResponse {
updated_at: model.updated_at.to_rfc3339(),
}
}
}
}

View File

@@ -0,0 +1,174 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user_requests")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub user_id: Option<Uuid>,
pub telegram_id: i64,
pub telegram_username: Option<String>,
pub telegram_first_name: Option<String>,
pub telegram_last_name: Option<String>,
pub status: String, // pending, approved, declined
pub request_message: Option<String>,
pub response_message: Option<String>,
pub processed_by_user_id: Option<Uuid>,
pub processed_at: Option<DateTimeWithTimeZone>,
pub language: String, // User's language preference (en, ru)
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
User,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::ProcessedByUserId",
to = "super::user::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
ProcessedByUser,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
// Request status enum
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RequestStatus {
Pending,
Approved,
Declined,
}
impl RequestStatus {
pub fn as_str(&self) -> &'static str {
match self {
RequestStatus::Pending => "pending",
RequestStatus::Approved => "approved",
RequestStatus::Declined => "declined",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"pending" => Some(RequestStatus::Pending),
"approved" => Some(RequestStatus::Approved),
"declined" => Some(RequestStatus::Declined),
_ => None,
}
}
}
impl Model {
pub fn get_status(&self) -> RequestStatus {
RequestStatus::from_str(&self.status).unwrap_or(RequestStatus::Pending)
}
pub fn get_full_name(&self) -> String {
let mut parts = vec![];
if let Some(first) = &self.telegram_first_name {
parts.push(first.clone());
}
if let Some(last) = &self.telegram_last_name {
parts.push(last.clone());
}
if parts.is_empty() {
self.telegram_username
.clone()
.unwrap_or_else(|| format!("User {}", self.telegram_id))
} else {
parts.join(" ")
}
}
pub fn get_telegram_link(&self) -> String {
if let Some(username) = &self.telegram_username {
format!("@{}", username)
} else {
format!("tg://user?id={}", self.telegram_id)
}
}
pub fn get_language(&self) -> String {
self.language.clone()
}
}
// DTOs for creating and updating user requests
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateUserRequestDto {
pub telegram_id: i64,
pub telegram_username: Option<String>,
pub telegram_first_name: Option<String>,
pub telegram_last_name: Option<String>,
pub request_message: Option<String>,
pub language: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateUserRequestDto {
pub status: Option<String>,
pub response_message: Option<String>,
pub processed_by_user_id: Option<Uuid>,
}
impl From<CreateUserRequestDto> for ActiveModel {
fn from(dto: CreateUserRequestDto) -> Self {
use sea_orm::ActiveValue::*;
ActiveModel {
id: Set(Uuid::new_v4()),
user_id: Set(None),
telegram_id: Set(dto.telegram_id),
telegram_username: Set(dto.telegram_username),
telegram_first_name: Set(dto.telegram_first_name),
telegram_last_name: Set(dto.telegram_last_name),
status: Set("pending".to_string()),
request_message: Set(dto.request_message),
response_message: Set(None),
processed_by_user_id: Set(None),
processed_at: Set(None),
language: Set(dto.language),
created_at: Set(chrono::Utc::now().into()),
updated_at: Set(chrono::Utc::now().into()),
}
}
}
impl Model {
pub fn apply_update(self, dto: UpdateUserRequestDto, processed_by: Uuid) -> ActiveModel {
use sea_orm::ActiveValue::*;
let mut active: ActiveModel = self.into();
if let Some(status) = dto.status {
active.status = Set(status);
active.processed_by_user_id = Set(Some(processed_by));
active.processed_at = Set(Some(chrono::Utc::now().into()));
}
if let Some(response) = dto.response_message {
active.response_message = Set(Some(response));
}
active.updated_at = Set(chrono::Utc::now().into());
active
}
}

View File

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

View File

@@ -32,21 +32,9 @@ impl MigrationTrait for Migration {
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(Certificates::CertData)
.blob()
.not_null(),
)
.col(
ColumnDef::new(Certificates::KeyData)
.blob()
.not_null(),
)
.col(
ColumnDef::new(Certificates::ChainData)
.blob()
.null(),
)
.col(ColumnDef::new(Certificates::CertData).blob().not_null())
.col(ColumnDef::new(Certificates::KeyData).blob().not_null())
.col(ColumnDef::new(Certificates::ChainData).blob().null())
.col(
ColumnDef::new(Certificates::ExpiresAt)
.timestamp_with_time_zone()
@@ -117,4 +105,4 @@ enum Certificates {
AutoRenew,
CreatedAt,
UpdatedAt,
}
}

View File

@@ -22,11 +22,7 @@ impl MigrationTrait for Migration {
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(InboundTemplates::Description)
.text()
.null(),
)
.col(ColumnDef::new(InboundTemplates::Description).text().null())
.col(
ColumnDef::new(InboundTemplates::Protocol)
.string_len(50)
@@ -152,4 +148,4 @@ enum InboundTemplates {
IsActive,
CreatedAt,
UpdatedAt,
}
}

View File

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

View File

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

View File

@@ -17,41 +17,17 @@ impl MigrationTrait for Migration {
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(UserAccess::UserId)
.uuid()
.not_null(),
)
.col(
ColumnDef::new(UserAccess::ServerId)
.uuid()
.not_null(),
)
.col(ColumnDef::new(UserAccess::UserId).uuid().not_null())
.col(ColumnDef::new(UserAccess::ServerId).uuid().not_null())
.col(
ColumnDef::new(UserAccess::ServerInboundId)
.uuid()
.not_null(),
)
.col(
ColumnDef::new(UserAccess::XrayUserId)
.string()
.not_null(),
)
.col(
ColumnDef::new(UserAccess::XrayEmail)
.string()
.not_null(),
)
.col(
ColumnDef::new(UserAccess::Level)
.integer()
.not_null(),
)
.col(
ColumnDef::new(UserAccess::IsActive)
.boolean()
.not_null(),
)
.col(ColumnDef::new(UserAccess::XrayUserId).string().not_null())
.col(ColumnDef::new(UserAccess::XrayEmail).string().not_null())
.col(ColumnDef::new(UserAccess::Level).integer().not_null())
.col(ColumnDef::new(UserAccess::IsActive).boolean().not_null())
.col(
ColumnDef::new(UserAccess::CreatedAt)
.timestamp_with_time_zone()
@@ -193,4 +169,4 @@ enum Servers {
enum ServerInbounds {
Table,
Id,
}
}

View File

@@ -22,21 +22,9 @@ impl MigrationTrait for Migration {
.uuid()
.not_null(),
)
.col(
ColumnDef::new(InboundUsers::Username)
.string()
.not_null(),
)
.col(
ColumnDef::new(InboundUsers::Email)
.string()
.not_null(),
)
.col(
ColumnDef::new(InboundUsers::XrayUserId)
.string()
.not_null(),
)
.col(ColumnDef::new(InboundUsers::Username).string().not_null())
.col(ColumnDef::new(InboundUsers::Email).string().not_null())
.col(ColumnDef::new(InboundUsers::XrayUserId).string().not_null())
.col(
ColumnDef::new(InboundUsers::Level)
.integer()
@@ -122,4 +110,4 @@ enum InboundUsers {
enum ServerInbounds {
Table,
Id,
}
}

View File

@@ -36,22 +36,18 @@ impl MigrationTrait for Migration {
ColumnDef::new(InboundUsers::UserId)
.uuid()
.not_null()
.default(Expr::val("00000000-0000-0000-0000-000000000000"))
.default(Expr::val("00000000-0000-0000-0000-000000000000")),
)
.to_owned(),
)
.await?;
// Add password column
// Add password column
manager
.alter_table(
Table::alter()
.table(InboundUsers::Table)
.add_column(
ColumnDef::new(InboundUsers::Password)
.string()
.null()
)
.add_column(ColumnDef::new(InboundUsers::Password).string().null())
.to_owned(),
)
.await?;
@@ -83,7 +79,7 @@ impl MigrationTrait for Migration {
.from(InboundUsers::Table, InboundUsers::UserId)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned()
.to_owned(),
)
.await?;
@@ -153,7 +149,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(InboundUsers::Username)
.string()
.not_null()
.default("")
.default(""),
)
.to_owned(),
)
@@ -167,7 +163,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(InboundUsers::Email)
.string()
.not_null()
.default("")
.default(""),
)
.to_owned(),
)
@@ -239,4 +235,4 @@ enum InboundUsers {
enum Users {
Table,
Id,
}
}

View File

@@ -22,7 +22,7 @@ impl MigrationTrait for Migration {
// 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")
@@ -47,4 +47,4 @@ impl MigrationTrait for Migration {
enum Servers {
Table,
GrpcHostname,
}
}

View File

@@ -27,11 +27,7 @@ impl MigrationTrait for Migration {
.string_len(50)
.not_null(),
)
.col(
ColumnDef::new(DnsProviders::ApiToken)
.text()
.not_null(),
)
.col(ColumnDef::new(DnsProviders::ApiToken).text().not_null())
.col(
ColumnDef::new(DnsProviders::IsActive)
.boolean()
@@ -93,4 +89,4 @@ enum DnsProviders {
IsActive,
CreatedAt,
UpdatedAt,
}
}

View File

@@ -0,0 +1,57 @@
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
.create_table(
Table::create()
.table(TelegramConfig::Table)
.if_not_exists()
.col(
ColumnDef::new(TelegramConfig::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(TelegramConfig::BotToken).string().not_null())
.col(
ColumnDef::new(TelegramConfig::IsActive)
.boolean()
.not_null()
.default(false),
)
.col(
ColumnDef::new(TelegramConfig::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(TelegramConfig::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(TelegramConfig::Table).to_owned())
.await
}
}
#[derive(Iden)]
pub enum TelegramConfig {
Table,
Id,
BotToken,
IsActive,
CreatedAt,
UpdatedAt,
}

View File

@@ -0,0 +1,40 @@
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(Users::Table)
.add_column(
ColumnDef::new(Users::IsTelegramAdmin)
.boolean()
.not_null()
.default(false),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Users::Table)
.drop_column(Users::IsTelegramAdmin)
.to_owned(),
)
.await
}
}
#[derive(Iden)]
enum Users {
Table,
IsTelegramAdmin,
}

View File

@@ -0,0 +1,183 @@
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> {
// Create user_requests table
manager
.create_table(
Table::create()
.table(UserRequests::Table)
.if_not_exists()
.col(
ColumnDef::new(UserRequests::Id)
.uuid()
.not_null()
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.col(
ColumnDef::new(UserRequests::UserId).uuid().null(), // Can be null if user doesn't exist yet
)
.col(
ColumnDef::new(UserRequests::TelegramId)
.big_integer()
.not_null()
.unique_key(),
)
.col(
ColumnDef::new(UserRequests::TelegramUsername)
.string()
.null(),
)
.col(
ColumnDef::new(UserRequests::TelegramFirstName)
.string()
.null(),
)
.col(
ColumnDef::new(UserRequests::TelegramLastName)
.string()
.null(),
)
.col(
ColumnDef::new(UserRequests::Status)
.string()
.not_null()
.default("pending"), // pending, approved, declined
)
.col(ColumnDef::new(UserRequests::RequestMessage).text().null())
.col(ColumnDef::new(UserRequests::ResponseMessage).text().null())
.col(
ColumnDef::new(UserRequests::ProcessedByUserId)
.uuid()
.null(),
)
.col(
ColumnDef::new(UserRequests::ProcessedAt)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(UserRequests::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(UserRequests::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_user_requests_user")
.from(UserRequests::Table, UserRequests::UserId)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_user_requests_processed_by")
.from(UserRequests::Table, UserRequests::ProcessedByUserId)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// Create index on telegram_id for faster lookups
manager
.create_index(
Index::create()
.name("idx_user_requests_telegram_id")
.table(UserRequests::Table)
.col(UserRequests::TelegramId)
.to_owned(),
)
.await?;
// Create index on status for filtering
manager
.create_index(
Index::create()
.name("idx_user_requests_status")
.table(UserRequests::Table)
.col(UserRequests::Status)
.to_owned(),
)
.await?;
// Create trigger to update updated_at timestamp
manager
.get_connection()
.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION update_user_requests_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_requests_updated_at
BEFORE UPDATE ON user_requests
FOR EACH ROW
EXECUTE FUNCTION update_user_requests_updated_at();
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Drop trigger and function
manager
.get_connection()
.execute_unprepared(
r#"
DROP TRIGGER IF EXISTS user_requests_updated_at ON user_requests;
DROP FUNCTION IF EXISTS update_user_requests_updated_at();
"#,
)
.await?;
// Drop table
manager
.drop_table(Table::drop().table(UserRequests::Table).to_owned())
.await
}
}
#[derive(Iden)]
enum UserRequests {
Table,
Id,
UserId,
TelegramId,
TelegramUsername,
TelegramFirstName,
TelegramLastName,
Status,
RequestMessage,
ResponseMessage,
ProcessedByUserId,
ProcessedAt,
CreatedAt,
UpdatedAt,
}
#[derive(Iden)]
enum Users {
Table,
Id,
}

View File

@@ -0,0 +1,38 @@
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> {
// Drop the unique constraint on telegram_id
// This allows users to have multiple requests (e.g., if one was declined)
manager
.get_connection()
.execute_unprepared(
r#"
ALTER TABLE user_requests
DROP CONSTRAINT IF EXISTS user_requests_telegram_id_key;
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Re-add the unique constraint
manager
.get_connection()
.execute_unprepared(
r#"
ALTER TABLE user_requests
ADD CONSTRAINT user_requests_telegram_id_key UNIQUE (telegram_id);
"#,
)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,41 @@
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> {
// Add language column to user_requests table
manager
.alter_table(
Table::alter()
.table(UserRequests::Table)
.add_column(
ColumnDef::new(UserRequests::Language)
.string()
.default("en"), // Default to English
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Remove language column from user_requests table
manager
.alter_table(
Table::alter()
.table(UserRequests::Table)
.drop_column(UserRequests::Language)
.to_owned(),
)
.await
}
}
#[derive(Iden)]
enum UserRequests {
Table,
Language,
}

View File

@@ -10,6 +10,11 @@ mod m20241201_000007_create_inbound_users_table;
mod m20250919_000001_update_inbound_users_schema;
mod m20250922_000001_add_grpc_hostname_to_servers;
mod m20250923_000001_create_dns_providers_table;
mod m20250929_000001_create_telegram_config_table;
mod m20250929_000002_add_telegram_admin_to_users;
mod m20251018_000001_create_user_requests_table;
mod m20251018_000002_remove_unique_telegram_id;
mod m20251018_000003_add_language_to_user_requests;
pub struct Migrator;
@@ -27,6 +32,11 @@ impl MigratorTrait for Migrator {
Box::new(m20250919_000001_update_inbound_users_schema::Migration),
Box::new(m20250922_000001_add_grpc_hostname_to_servers::Migration),
Box::new(m20250923_000001_create_dns_providers_table::Migration),
Box::new(m20250929_000001_create_telegram_config_table::Migration),
Box::new(m20250929_000002_add_telegram_admin_to_users::Migration),
Box::new(m20251018_000001_create_user_requests_table::Migration),
Box::new(m20251018_000002_remove_unique_telegram_id::Migration),
Box::new(m20251018_000003_add_language_to_user_requests::Migration),
]
}
}
}

View File

@@ -1,5 +1,7 @@
use anyhow::Result;
use sea_orm::{Database, DatabaseConnection, ConnectOptions, Statement, DatabaseBackend, ConnectionTrait};
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, Statement,
};
use sea_orm_migration::MigratorTrait;
use std::time::Duration;
use tracing::{info, warn};
@@ -22,10 +24,10 @@ impl DatabaseManager {
/// Create a new database connection
pub async fn new(config: &DatabaseConfig) -> Result<Self> {
info!("Connecting to database...");
// URL-encode the connection string to handle special characters in passwords
let encoded_url = Self::encode_database_url(&config.url)?;
let mut opt = ConnectOptions::new(&encoded_url);
opt.max_connections(config.max_connections)
.min_connections(1)
@@ -37,28 +39,28 @@ impl DatabaseManager {
.sqlx_logging_level(log::LevelFilter::Debug);
let connection = Database::connect(opt).await?;
info!("Database connection established successfully");
let manager = Self { connection };
// Run migrations if auto_migrate is enabled
if config.auto_migrate {
manager.migrate().await?;
}
Ok(manager)
}
/// Get database connection
pub fn connection(&self) -> &DatabaseConnection {
&self.connection
pub fn connection(&self) -> DatabaseConnection {
self.connection.clone()
}
/// Run database migrations
pub async fn migrate(&self) -> Result<()> {
info!("Running database migrations...");
match Migrator::up(&self.connection, None).await {
Ok(_) => {
info!("Database migrations completed successfully");
@@ -99,21 +101,22 @@ impl DatabaseManager {
let scheme = &url[..scheme_end + 3];
let user_pass = &url[scheme_end + 3..at_pos];
let host_db = &url[at_pos..];
if let Some(user_colon) = user_pass.find(':') {
let user = &user_pass[..user_colon];
let password = &user_pass[user_colon + 1..];
// URL-encode the password part only
let encoded_password = urlencoding::encode(password);
let encoded_url = format!("{}{}:{}{}", scheme, user, encoded_password, host_db);
let encoded_url =
format!("{}{}:{}{}", scheme, user, encoded_password, host_db);
return Ok(encoded_url);
}
}
}
}
// If parsing fails, return original URL
Ok(url.to_string())
}
@@ -132,7 +135,10 @@ mod tests {
let normal_url = "postgresql://user:password@localhost:5432/db";
let encoded_normal = DatabaseManager::encode_database_url(normal_url).unwrap();
assert_eq!(encoded_normal, "postgresql://user:password@localhost:5432/db");
assert_eq!(
encoded_normal,
"postgresql://user:password@localhost:5432/db"
);
}
#[tokio::test]
@@ -158,4 +164,4 @@ mod tests {
assert!(health.is_ok());
}
}
}
}

View File

@@ -1,6 +1,6 @@
use sea_orm::*;
use crate::database::entities::{certificate, prelude::*};
use anyhow::Result;
use sea_orm::*;
use uuid::Uuid;
#[derive(Clone)]
@@ -13,11 +13,14 @@ impl CertificateRepository {
Self { db }
}
pub async fn create(&self, cert_data: certificate::CreateCertificateDto) -> Result<certificate::Model> {
pub async fn create(
&self,
cert_data: certificate::CreateCertificateDto,
) -> Result<certificate::Model> {
let cert = certificate::ActiveModel::from(cert_data);
let result = Certificate::insert(cert).exec(&self.db).await?;
Certificate::find_by_id(result.last_insert_id)
.one(&self.db)
.await?
@@ -48,7 +51,11 @@ impl CertificateRepository {
.await?)
}
pub async fn update(&self, id: Uuid, cert_data: certificate::UpdateCertificateDto) -> Result<certificate::Model> {
pub async fn update(
&self,
id: Uuid,
cert_data: certificate::UpdateCertificateDto,
) -> Result<certificate::Model> {
let cert = Certificate::find_by_id(id)
.one(&self.db)
.await?
@@ -66,7 +73,7 @@ impl CertificateRepository {
pub async fn find_expiring_soon(&self, days: i64) -> Result<Vec<certificate::Model>> {
let threshold = chrono::Utc::now() + chrono::Duration::days(days);
Ok(Certificate::find()
.filter(certificate::Column::ExpiresAt.lt(threshold))
.all(&self.db)
@@ -75,11 +82,11 @@ impl CertificateRepository {
/// Update certificate data (cert and key) and expiration date
pub async fn update_certificate_data(
&self,
id: Uuid,
cert_pem: &str,
&self,
id: Uuid,
cert_pem: &str,
key_pem: &str,
expires_at: chrono::DateTime<chrono::Utc>
expires_at: chrono::DateTime<chrono::Utc>,
) -> Result<certificate::Model> {
let mut cert: certificate::ActiveModel = Certificate::find_by_id(id)
.one(&self.db)
@@ -94,4 +101,4 @@ impl CertificateRepository {
Ok(cert.update(&self.db).await?)
}
}
}

View File

@@ -1,9 +1,12 @@
use anyhow::Result;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter, Set, PaginatorTrait};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
Set,
};
use uuid::Uuid;
use crate::database::entities::dns_provider::{
Entity, Model, ActiveModel, CreateDnsProviderDto, UpdateDnsProviderDto, Column, DnsProviderType
ActiveModel, Column, CreateDnsProviderDto, DnsProviderType, Entity, Model, UpdateDnsProviderDto,
};
pub struct DnsProviderRepository {
@@ -89,7 +92,7 @@ impl DnsProviderRepository {
let mut active_model: ActiveModel = provider.into();
active_model.is_active = Set(true);
active_model.updated_at = Set(chrono::Utc::now());
let updated_provider = active_model.update(&self.db).await?;
Ok(Some(updated_provider))
}
@@ -103,7 +106,7 @@ impl DnsProviderRepository {
let mut active_model: ActiveModel = provider.into();
active_model.is_active = Set(false);
active_model.updated_at = Set(chrono::Utc::now());
let updated_provider = active_model.update(&self.db).await?;
Ok(Some(updated_provider))
}
@@ -111,17 +114,20 @@ impl DnsProviderRepository {
/// Check if a provider name already exists
pub async fn name_exists(&self, name: &str, exclude_id: Option<Uuid>) -> Result<bool> {
let mut query = Entity::find().filter(Column::Name.eq(name));
if let Some(id) = exclude_id {
query = query.filter(Column::Id.ne(id));
}
let count = query.count(&self.db).await?;
Ok(count > 0)
}
/// Get the first active provider of a specific type
pub async fn get_active_provider_by_type(&self, provider_type: DnsProviderType) -> Result<Option<Model>> {
pub async fn get_active_provider_by_type(
&self,
provider_type: DnsProviderType,
) -> Result<Option<Model>> {
let provider = Entity::find()
.filter(Column::ProviderType.eq(provider_type.as_str()))
.filter(Column::IsActive.eq(true))
@@ -129,4 +135,4 @@ impl DnsProviderRepository {
.await?;
Ok(provider)
}
}
}

View File

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

View File

@@ -1,9 +1,9 @@
use anyhow::Result;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter, Set};
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::database::entities::inbound_users::{
Entity, Model, ActiveModel, CreateInboundUserDto, UpdateInboundUserDto, Column
ActiveModel, Column, CreateInboundUserDto, Entity, Model, UpdateInboundUserDto,
};
use crate::services::uri_generator::ClientConfigData;
@@ -46,7 +46,11 @@ impl InboundUsersRepository {
}
/// Find user by user_id and inbound (for uniqueness check - one user per inbound)
pub async fn find_by_user_and_inbound(&self, user_id: Uuid, inbound_id: Uuid) -> Result<Option<Model>> {
pub async fn find_by_user_and_inbound(
&self,
user_id: Uuid,
inbound_id: Uuid,
) -> Result<Option<Model>> {
let user = Entity::find()
.filter(Column::UserId.eq(user_id))
.filter(Column::ServerInboundId.eq(inbound_id))
@@ -96,7 +100,7 @@ impl InboundUsersRepository {
let mut active_model: ActiveModel = user.into();
active_model.is_active = Set(true);
active_model.updated_at = Set(chrono::Utc::now());
let updated_user = active_model.update(&self.db).await?;
Ok(Some(updated_user))
}
@@ -111,7 +115,7 @@ impl InboundUsersRepository {
let mut active_model: ActiveModel = user.into();
active_model.is_active = Set(false);
active_model.updated_at = Set(chrono::Utc::now());
let updated_user = active_model.update(&self.db).await?;
Ok(Some(updated_user))
}
@@ -126,17 +130,25 @@ impl InboundUsersRepository {
}
/// Check if user already has access to this inbound
pub async fn user_has_access_to_inbound(&self, user_id: Uuid, inbound_id: Uuid) -> Result<bool> {
pub async fn user_has_access_to_inbound(
&self,
user_id: Uuid,
inbound_id: Uuid,
) -> Result<bool> {
let exists = self.find_by_user_and_inbound(user_id, inbound_id).await?;
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>> {
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
certificate, inbound_template, server, server_inbound, user,
};
// Get the inbound_user record first
let inbound_user = Entity::find()
.filter(Column::UserId.eq(user_id))
@@ -144,32 +156,34 @@ impl InboundUsersRepository {
.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"))?;
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"))?;
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)
@@ -179,14 +193,16 @@ impl InboundUsersRepository {
} 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),
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,
@@ -195,8 +211,9 @@ impl InboundUsersRepository {
variable_values: server_inbound_entity.variable_values,
server_name: server_entity.name,
inbound_tag: server_inbound_entity.tag,
template_name: template_entity.name,
};
Ok(Some(config))
} else {
Ok(None)
@@ -204,23 +221,29 @@ impl InboundUsersRepository {
}
/// Get all client configuration data for a user
pub async fn get_all_client_configs_for_user(&self, user_id: Uuid) -> Result<Vec<ClientConfigData>> {
pub async fn get_all_client_configs_for_user(
&self,
user_id: Uuid,
) -> Result<Vec<ClientConfigData>> {
// Get all active inbound users for this user
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 {
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

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

View File

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

View File

@@ -1,6 +1,6 @@
use sea_orm::*;
use crate::database::entities::{server_inbound, prelude::*};
use crate::database::entities::{prelude::*, server_inbound};
use anyhow::Result;
use sea_orm::*;
use uuid::Uuid;
#[derive(Clone)]
@@ -14,7 +14,11 @@ impl ServerInboundRepository {
Self { db }
}
pub async fn create(&self, server_id: Uuid, inbound_data: server_inbound::CreateServerInboundDto) -> Result<server_inbound::Model> {
pub async fn create(
&self,
server_id: Uuid,
inbound_data: server_inbound::CreateServerInboundDto,
) -> Result<server_inbound::Model> {
let mut inbound: server_inbound::ActiveModel = inbound_data.into();
inbound.id = Set(Uuid::new_v4());
inbound.server_id = Set(server_id);
@@ -22,26 +26,31 @@ impl ServerInboundRepository {
inbound.updated_at = Set(chrono::Utc::now());
let result = ServerInbound::insert(inbound).exec(&self.db).await?;
ServerInbound::find_by_id(result.last_insert_id)
.one(&self.db)
.await?
.ok_or_else(|| anyhow::anyhow!("Failed to retrieve created server inbound"))
}
pub async fn create_with_protocol(&self, server_id: Uuid, inbound_data: server_inbound::CreateServerInboundDto, protocol: &str) -> Result<server_inbound::Model> {
pub async fn create_with_protocol(
&self,
server_id: Uuid,
inbound_data: server_inbound::CreateServerInboundDto,
protocol: &str,
) -> Result<server_inbound::Model> {
let mut inbound: server_inbound::ActiveModel = inbound_data.into();
inbound.id = Set(Uuid::new_v4());
inbound.server_id = Set(server_id);
inbound.created_at = Set(chrono::Utc::now());
inbound.updated_at = Set(chrono::Utc::now());
// Override tag with protocol prefix
let id = inbound.id.as_ref();
inbound.tag = Set(format!("{}-inbound-{}", protocol, id));
let result = ServerInbound::insert(inbound).exec(&self.db).await?;
ServerInbound::find_by_id(result.last_insert_id)
.one(&self.db)
.await?
@@ -63,9 +72,11 @@ impl ServerInboundRepository {
.await?)
}
pub async fn find_by_server_id_with_template(&self, server_id: Uuid) -> Result<Vec<server_inbound::ServerInboundResponse>> {
use crate::database::entities::{inbound_template, certificate};
pub async fn find_by_server_id_with_template(
&self,
server_id: Uuid,
) -> Result<Vec<server_inbound::ServerInboundResponse>> {
let inbounds = ServerInbound::find()
.filter(server_inbound::Column::ServerId.eq(server_id))
.all(&self.db)
@@ -74,26 +85,33 @@ impl ServerInboundRepository {
let mut responses = Vec::new();
for inbound in inbounds {
let mut response = server_inbound::ServerInboundResponse::from(inbound.clone());
// Load template information
if let Ok(Some(template)) = InboundTemplate::find_by_id(inbound.template_id).one(&self.db).await {
if let Ok(Some(template)) = InboundTemplate::find_by_id(inbound.template_id)
.one(&self.db)
.await
{
response.template_name = Some(template.name);
}
// Load certificate information
if let Some(cert_id) = inbound.certificate_id {
if let Ok(Some(certificate)) = Certificate::find_by_id(cert_id).one(&self.db).await {
if let Ok(Some(certificate)) = Certificate::find_by_id(cert_id).one(&self.db).await
{
response.certificate_name = Some(certificate.domain);
}
}
responses.push(response);
}
Ok(responses)
}
pub async fn find_by_template_id(&self, template_id: Uuid) -> Result<Vec<server_inbound::Model>> {
pub async fn find_by_template_id(
&self,
template_id: Uuid,
) -> Result<Vec<server_inbound::Model>> {
Ok(ServerInbound::find()
.filter(server_inbound::Column::TemplateId.eq(template_id))
.all(&self.db)
@@ -107,14 +125,20 @@ impl ServerInboundRepository {
.await?)
}
pub async fn find_by_certificate_id(&self, certificate_id: Uuid) -> Result<Vec<server_inbound::Model>> {
pub async fn find_by_certificate_id(
&self,
certificate_id: Uuid,
) -> Result<Vec<server_inbound::Model>> {
Ok(ServerInbound::find()
.filter(server_inbound::Column::CertificateId.eq(certificate_id))
.all(&self.db)
.await?)
}
pub async fn find_active_by_server(&self, server_id: Uuid) -> Result<Vec<server_inbound::Model>> {
pub async fn find_active_by_server(
&self,
server_id: Uuid,
) -> Result<Vec<server_inbound::Model>> {
Ok(ServerInbound::find()
.filter(server_inbound::Column::ServerId.eq(server_id))
.filter(server_inbound::Column::IsActive.eq(true))
@@ -122,7 +146,11 @@ impl ServerInboundRepository {
.await?)
}
pub async fn update(&self, id: Uuid, inbound_data: server_inbound::UpdateServerInboundDto) -> Result<server_inbound::Model> {
pub async fn update(
&self,
id: Uuid,
inbound_data: server_inbound::UpdateServerInboundDto,
) -> Result<server_inbound::Model> {
let inbound = ServerInbound::find_by_id(id)
.one(&self.db)
.await?
@@ -163,4 +191,16 @@ impl ServerInboundRepository {
Ok(inbound.update(&self.db).await?)
}
}
pub async fn find_by_user_id(&self, _user_id: Uuid) -> Result<Vec<server_inbound::Model>> {
// This would need a join with user_access table
// For now, returning empty vec as placeholder
// TODO: Implement proper join query
Ok(vec![])
}
pub async fn count(&self) -> Result<u64> {
let count = ServerInbound::find().count(&self.db).await?;
Ok(count)
}
}

View File

@@ -0,0 +1,169 @@
use anyhow::Result;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
};
use uuid::Uuid;
use crate::database::entities::telegram_config::{
self, CreateTelegramConfigDto, Model, UpdateTelegramConfigDto,
};
pub struct TelegramConfigRepository {
db: DatabaseConnection,
}
impl TelegramConfigRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
/// Get the current active configuration (should be only one)
pub async fn get_active(&self) -> Result<Option<Model>> {
Ok(telegram_config::Entity::find()
.filter(telegram_config::Column::IsActive.eq(true))
.one(&self.db)
.await?)
}
/// Get configuration by ID
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<Model>> {
Ok(telegram_config::Entity::find_by_id(id)
.one(&self.db)
.await?)
}
/// Get the latest configuration (active or not)
pub async fn get_latest(&self) -> Result<Option<Model>> {
Ok(telegram_config::Entity::find()
.order_by_desc(telegram_config::Column::CreatedAt)
.one(&self.db)
.await?)
}
/// Create new configuration (deactivates previous if exists)
pub async fn create(&self, dto: CreateTelegramConfigDto) -> Result<Model> {
// If is_active is true, deactivate all other configs
if dto.is_active {
self.deactivate_all().await?;
}
let model = telegram_config::ActiveModel {
id: Set(Uuid::new_v4()),
bot_token: Set(dto.bot_token),
is_active: Set(dto.is_active),
created_at: Set(chrono::Utc::now()),
updated_at: Set(chrono::Utc::now()),
};
Ok(model.insert(&self.db).await?)
}
/// Update configuration
pub async fn update(&self, id: Uuid, dto: UpdateTelegramConfigDto) -> Result<Option<Model>> {
let model = telegram_config::Entity::find_by_id(id)
.one(&self.db)
.await?;
let Some(model) = model else {
return Ok(None);
};
// If activating this config, deactivate others
if dto.is_active == Some(true) {
self.deactivate_all_except(id).await?;
}
let mut active_model = model.into_active_model();
if let Some(bot_token) = dto.bot_token {
active_model.bot_token = Set(bot_token);
}
if let Some(is_active) = dto.is_active {
active_model.is_active = Set(is_active);
}
active_model.updated_at = Set(chrono::Utc::now());
Ok(Some(active_model.update(&self.db).await?))
}
/// Activate a configuration (deactivates all others)
pub async fn activate(&self, id: Uuid) -> Result<Option<Model>> {
self.deactivate_all_except(id).await?;
let model = telegram_config::Entity::find_by_id(id)
.one(&self.db)
.await?;
let Some(model) = model else {
return Ok(None);
};
let mut active_model = model.into_active_model();
active_model.is_active = Set(true);
active_model.updated_at = Set(chrono::Utc::now());
Ok(Some(active_model.update(&self.db).await?))
}
/// Deactivate a configuration
pub async fn deactivate(&self, id: Uuid) -> Result<Option<Model>> {
let model = telegram_config::Entity::find_by_id(id)
.one(&self.db)
.await?;
let Some(model) = model else {
return Ok(None);
};
let mut active_model = model.into_active_model();
active_model.is_active = Set(false);
active_model.updated_at = Set(chrono::Utc::now());
Ok(Some(active_model.update(&self.db).await?))
}
/// Delete configuration
pub async fn delete(&self, id: Uuid) -> Result<bool> {
let result = telegram_config::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
Ok(result.rows_affected > 0)
}
/// Deactivate all configurations
async fn deactivate_all(&self) -> Result<()> {
let configs = telegram_config::Entity::find()
.filter(telegram_config::Column::IsActive.eq(true))
.all(&self.db)
.await?;
for config in configs {
let mut active_model = config.into_active_model();
active_model.is_active = Set(false);
active_model.updated_at = Set(chrono::Utc::now());
active_model.update(&self.db).await?;
}
Ok(())
}
/// Deactivate all configurations except one
async fn deactivate_all_except(&self, except_id: Uuid) -> Result<()> {
let configs = telegram_config::Entity::find()
.filter(telegram_config::Column::IsActive.eq(true))
.filter(telegram_config::Column::Id.ne(except_id))
.all(&self.db)
.await?;
for config in configs {
let mut active_model = config.into_active_model();
active_model.is_active = Set(false);
active_model.updated_at = Set(chrono::Utc::now());
active_model.update(&self.db).await?;
}
Ok(())
}
}

View File

@@ -1,8 +1,14 @@
use anyhow::Result;
use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait, QueryOrder, PaginatorTrait, QuerySelect};
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect,
};
use uuid::Uuid;
use crate::database::entities::user::{Entity as User, Column, Model, ActiveModel, CreateUserDto, UpdateUserDto};
use crate::database::entities::user::{
ActiveModel, Column, CreateUserDto, Entity as User, Model, UpdateUserDto,
};
use sea_orm::{ActiveModelTrait, Set};
pub struct UserRepository {
db: DatabaseConnection,
@@ -45,7 +51,12 @@ impl UserRepository {
}
/// Search users by name (with pagination for backward compatibility)
pub async fn search_by_name(&self, query: &str, page: u64, per_page: u64) -> Result<Vec<Model>> {
pub async fn search_by_name(
&self,
query: &str,
page: u64,
per_page: u64,
) -> Result<Vec<Model>> {
let users = User::find()
.filter(Column::Name.contains(query))
.order_by_desc(Column::CreatedAt)
@@ -59,22 +70,22 @@ impl UserRepository {
/// Universal search - searches by name, telegram_id, or user_id
pub async fn search(&self, query: &str) -> Result<Vec<Model>> {
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::<i64>() {
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)
@@ -88,7 +99,9 @@ impl UserRepository {
/// Create a new user
pub async fn create(&self, dto: CreateUserDto) -> Result<Model> {
let active_model: ActiveModel = dto.into();
let user = User::insert(active_model).exec_with_returning(&self.db).await?;
let user = User::insert(active_model)
.exec_with_returning(&self.db)
.await?;
Ok(user)
}
@@ -124,19 +137,88 @@ impl UserRepository {
.await?;
Ok(count > 0)
}
/// Set user as Telegram admin
pub async fn set_telegram_admin(&self, user_id: Uuid, is_admin: bool) -> Result<Option<Model>> {
if let Some(user) = self.get_by_id(user_id).await? {
let mut active_model: ActiveModel = user.into();
active_model.is_telegram_admin = Set(is_admin);
active_model.updated_at = Set(chrono::Utc::now());
let updated = active_model.update(&self.db).await?;
Ok(Some(updated))
} else {
Ok(None)
}
}
/// Check if user is Telegram admin
pub async fn is_telegram_admin(&self, user_id: Uuid) -> Result<bool> {
if let Some(user) = self.get_by_id(user_id).await? {
Ok(user.is_telegram_admin)
} else {
Ok(false)
}
}
/// Check if telegram_id is admin
pub async fn is_telegram_id_admin(&self, telegram_id: i64) -> Result<bool> {
if let Some(user) = self.get_by_telegram_id(telegram_id).await? {
Ok(user.is_telegram_admin)
} else {
Ok(false)
}
}
/// Get all Telegram admins
pub async fn get_telegram_admins(&self) -> Result<Vec<Model>> {
let admins = User::find()
.filter(Column::IsTelegramAdmin.eq(true))
.filter(Column::TelegramId.is_not_null())
.all(&self.db)
.await?;
Ok(admins)
}
/// Get the first admin user (for system operations)
pub async fn get_first_admin(&self) -> Result<Option<Model>> {
let admin = User::find()
.filter(Column::IsTelegramAdmin.eq(true))
.one(&self.db)
.await?;
Ok(admin)
}
/// Count total users
pub async fn count_all(&self) -> Result<i64> {
let count = User::find().count(&self.db).await?;
Ok(count as i64)
}
/// Find users with pagination
pub async fn find_paginated(&self, offset: u64, limit: u64) -> Result<Vec<Model>> {
let users = User::find()
.order_by_desc(Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&self.db)
.await?;
Ok(users)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::DatabaseManager;
use crate::config::DatabaseConfig;
use crate::database::DatabaseManager;
async fn setup_test_db() -> Result<UserRepository> {
let config = DatabaseConfig {
url: std::env::var("DATABASE_URL").unwrap_or_else(|_|
"sqlite::memory:".to_string()
),
url: std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string()),
max_connections: 5,
connection_timeout: 30,
auto_migrate: true,
@@ -158,6 +240,7 @@ mod tests {
name: "Test User".to_string(),
comment: Some("Test comment".to_string()),
telegram_id: Some(123456789),
is_telegram_admin: false,
};
let created_user = repo.create(create_dto).await.unwrap();
@@ -174,6 +257,7 @@ mod tests {
name: Some("Updated User".to_string()),
comment: None,
telegram_id: None,
is_telegram_admin: None,
};
let updated_user = repo.update(created_user.id, update_dto).await.unwrap();
@@ -188,4 +272,4 @@ mod tests {
let deleted_user = repo.get_by_id(created_user.id).await.unwrap();
assert!(deleted_user.is_none());
}
}
}

View File

@@ -1,118 +0,0 @@
use sea_orm::*;
use uuid::Uuid;
use anyhow::Result;
use crate::database::entities::user_access::{self, Entity as UserAccess, Model, ActiveModel, CreateUserAccessDto, UpdateUserAccessDto};
pub struct UserAccessRepository {
db: DatabaseConnection,
}
impl UserAccessRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
/// Find all user access records
pub async fn find_all(&self) -> Result<Vec<Model>> {
let records = UserAccess::find().all(&self.db).await?;
Ok(records)
}
/// Find user access by ID
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<Model>> {
let record = UserAccess::find_by_id(id).one(&self.db).await?;
Ok(record)
}
/// Find user access by user ID
pub async fn find_by_user_id(&self, user_id: Uuid) -> Result<Vec<Model>> {
let records = UserAccess::find()
.filter(user_access::Column::UserId.eq(user_id))
.all(&self.db)
.await?;
Ok(records)
}
/// Find user access by server and inbound
pub async fn find_by_server_inbound(&self, server_id: Uuid, server_inbound_id: Uuid) -> Result<Vec<Model>> {
let records = UserAccess::find()
.filter(user_access::Column::ServerId.eq(server_id))
.filter(user_access::Column::ServerInboundId.eq(server_inbound_id))
.all(&self.db)
.await?;
Ok(records)
}
/// Find active user access for specific user, server and inbound
pub async fn find_active_access(&self, user_id: Uuid, server_id: Uuid, server_inbound_id: Uuid) -> Result<Option<Model>> {
let record = UserAccess::find()
.filter(user_access::Column::UserId.eq(user_id))
.filter(user_access::Column::ServerId.eq(server_id))
.filter(user_access::Column::ServerInboundId.eq(server_inbound_id))
.filter(user_access::Column::IsActive.eq(true))
.one(&self.db)
.await?;
Ok(record)
}
/// Create new user access
pub async fn create(&self, dto: CreateUserAccessDto) -> Result<Model> {
let active_model: ActiveModel = dto.into();
let model = active_model.insert(&self.db).await?;
Ok(model)
}
/// Update user access
pub async fn update(&self, id: Uuid, dto: UpdateUserAccessDto) -> Result<Option<Model>> {
let existing = match self.find_by_id(id).await? {
Some(model) => model,
None => return Ok(None),
};
let active_model = existing.apply_update(dto);
let updated = active_model.update(&self.db).await?;
Ok(Some(updated))
}
/// Delete user access
pub async fn delete(&self, id: Uuid) -> Result<bool> {
let result = UserAccess::delete_by_id(id).exec(&self.db).await?;
Ok(result.rows_affected > 0)
}
/// Enable user access (set is_active = true)
pub async fn enable(&self, id: Uuid) -> Result<Option<Model>> {
self.update(id, UpdateUserAccessDto {
is_active: Some(true),
level: None,
}).await
}
/// Disable user access (set is_active = false)
pub async fn disable(&self, id: Uuid) -> Result<Option<Model>> {
self.update(id, UpdateUserAccessDto {
is_active: Some(false),
level: None,
}).await
}
/// Get all active access for a user
pub async fn find_active_for_user(&self, user_id: Uuid) -> Result<Vec<Model>> {
let records = UserAccess::find()
.filter(user_access::Column::UserId.eq(user_id))
.filter(user_access::Column::IsActive.eq(true))
.all(&self.db)
.await?;
Ok(records)
}
/// Remove all access for a specific server inbound
pub async fn remove_all_for_inbound(&self, server_inbound_id: Uuid) -> Result<u64> {
let result = UserAccess::delete_many()
.filter(user_access::Column::ServerInboundId.eq(server_inbound_id))
.exec(&self.db)
.await?;
Ok(result.rows_affected)
}
}

View File

@@ -0,0 +1,164 @@
use crate::database::entities::user_request::{
self, ActiveModel, CreateUserRequestDto, Model, RequestStatus, UpdateUserRequestDto,
};
use anyhow::Result;
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect,
};
use uuid::Uuid;
pub struct UserRequestRepository {
db: DatabaseConnection,
}
impl UserRequestRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_all(&self, page: u64, per_page: u64) -> Result<(Vec<Model>, u64)> {
let paginator = user_request::Entity::find()
.order_by_desc(user_request::Column::CreatedAt)
.paginate(&self.db, per_page);
let total = paginator.num_items().await?;
let items = paginator.fetch_page(page - 1).await?;
Ok((items, total))
}
pub async fn find_pending(&self, page: u64, per_page: u64) -> Result<(Vec<Model>, u64)> {
let paginator = user_request::Entity::find()
.filter(user_request::Column::Status.eq("pending"))
.order_by_desc(user_request::Column::CreatedAt)
.paginate(&self.db, per_page);
let total = paginator.num_items().await?;
let items = paginator.fetch_page(page - 1).await?;
Ok((items, total))
}
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<Model>> {
let request = user_request::Entity::find_by_id(id).one(&self.db).await?;
Ok(request)
}
pub async fn find_by_telegram_id(&self, telegram_id: i64) -> Result<Vec<Model>> {
let requests = user_request::Entity::find()
.filter(user_request::Column::TelegramId.eq(telegram_id))
.order_by_desc(user_request::Column::CreatedAt)
.all(&self.db)
.await?;
Ok(requests)
}
/// Find recent user requests (ordered by creation date)
pub async fn find_recent(&self, limit: u64) -> Result<Vec<Model>> {
let requests = user_request::Entity::find()
.order_by_desc(user_request::Column::CreatedAt)
.limit(limit)
.all(&self.db)
.await?;
Ok(requests)
}
pub async fn find_pending_by_telegram_id(&self, telegram_id: i64) -> Result<Option<Model>> {
let request = user_request::Entity::find()
.filter(user_request::Column::TelegramId.eq(telegram_id))
.filter(user_request::Column::Status.eq("pending"))
.order_by_desc(user_request::Column::CreatedAt)
.one(&self.db)
.await?;
Ok(request)
}
/// Count total requests
pub async fn count_all(&self) -> Result<i64> {
let count = user_request::Entity::find().count(&self.db).await?;
Ok(count as i64)
}
/// Find requests with pagination
pub async fn find_paginated(&self, offset: u64, limit: u64) -> Result<Vec<Model>> {
let requests = user_request::Entity::find()
.order_by_desc(user_request::Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&self.db)
.await?;
Ok(requests)
}
pub async fn create(&self, dto: CreateUserRequestDto) -> Result<Model> {
use sea_orm::ActiveModelTrait;
let active_model: ActiveModel = dto.into();
let request = active_model.insert(&self.db).await?;
Ok(request)
}
pub async fn update(
&self,
id: Uuid,
dto: UpdateUserRequestDto,
processed_by: Uuid,
) -> Result<Option<Model>> {
let model = user_request::Entity::find_by_id(id).one(&self.db).await?;
match model {
Some(model) => {
use sea_orm::ActiveModelTrait;
let active_model = model.apply_update(dto, processed_by);
let updated = active_model.update(&self.db).await?;
Ok(Some(updated))
}
None => Ok(None),
}
}
pub async fn approve(
&self,
id: Uuid,
response_message: Option<String>,
processed_by: Uuid,
) -> Result<Option<Model>> {
let dto = UpdateUserRequestDto {
status: Some(RequestStatus::Approved.as_str().to_string()),
response_message,
processed_by_user_id: None,
};
self.update(id, dto, processed_by).await
}
pub async fn decline(
&self,
id: Uuid,
response_message: Option<String>,
processed_by: Uuid,
) -> Result<Option<Model>> {
let dto = UpdateUserRequestDto {
status: Some(RequestStatus::Declined.as_str().to_string()),
response_message,
processed_by_user_id: None,
};
self.update(id, dto, processed_by).await
}
pub async fn delete(&self, id: Uuid) -> Result<bool> {
let result = user_request::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
Ok(result.rows_affected > 0)
}
pub async fn count_by_status(&self, status: RequestStatus) -> Result<u64> {
let count = user_request::Entity::find()
.filter(user_request::Column::Status.eq(status.as_str()))
.count(&self.db)
.await?;
Ok(count)
}
}

View File

@@ -1,4 +1,5 @@
use anyhow::Result;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod config;
@@ -6,9 +7,9 @@ mod database;
mod services;
mod web;
use config::{AppConfig, args::parse_args};
use config::{args::parse_args, AppConfig};
use database::DatabaseManager;
use services::{TaskScheduler, XrayService};
use services::{TaskScheduler, TelegramService, XrayService};
#[tokio::main]
async fn main() -> Result<()> {
@@ -23,7 +24,6 @@ async fn main() -> Result<()> {
// Initialize logging early with basic configuration
init_logging(&args.log_level.as_deref().unwrap_or("info"))?;
// Handle special flags
if args.print_default_config {
print_default_config()?;
@@ -32,9 +32,7 @@ async fn main() -> Result<()> {
// Load configuration
let config = match AppConfig::load() {
Ok(config) => {
config
}
Ok(config) => config,
Err(e) => {
tracing::error!("Failed to load configuration: {}", e);
if args.validate_config {
@@ -57,12 +55,9 @@ async fn main() -> Result<()> {
config::env::EnvVars::print_env_info();
}
// Initialize database connection
let db = match DatabaseManager::new(&config.database).await {
Ok(db) => {
db
}
Ok(db) => db,
Err(e) => {
tracing::error!("Failed to initialize database: {}", e);
return Err(e);
@@ -81,7 +76,7 @@ async fn main() -> Result<()> {
// Initialize xray service
let xray_service = XrayService::new();
// Initialize and start task scheduler with dependencies
let mut task_scheduler = TaskScheduler::new().await?;
task_scheduler.start(db.clone(), xray_service).await?;
@@ -89,10 +84,16 @@ async fn main() -> Result<()> {
// Start event-driven sync handler with the receiver
TaskScheduler::start_event_handler(db.clone(), event_receiver).await;
// Initialize Telegram service if needed
let telegram_service = Arc::new(TelegramService::new(db.clone(), config.clone()));
if let Err(e) = telegram_service.initialize().await {
tracing::warn!("Failed to initialize Telegram service: {}", e);
}
// Start web server with task scheduler
tokio::select! {
result = web::start_server(db, config.web.clone()) => {
result = web::start_server(db, config.clone(), Some(telegram_service.clone())) => {
match result {
Err(e) => tracing::error!("Web server error: {}", e),
_ => {}
@@ -116,12 +117,12 @@ fn init_logging(level: &str) -> Result<()> {
.with(filter)
.with(
tracing_subscriber::fmt::layer()
.with_target(true) // Show module names
.with_target(true) // Show module names
.with_thread_ids(false)
.with_thread_names(false)
.with_file(false)
.with_line_number(false)
.compact()
.compact(),
)
.try_init()?;
@@ -131,11 +132,11 @@ fn init_logging(level: &str) -> Result<()> {
fn print_default_config() -> Result<()> {
let default_config = AppConfig::default();
let toml_content = toml::to_string_pretty(&default_config)?;
println!("# Default configuration for Xray Admin Panel");
println!("# Save this to config.toml and modify as needed\n");
println!("{}", toml_content);
Ok(())
}
@@ -172,4 +173,4 @@ mod tests {
let masked = mask_url(url);
assert_eq!(masked, url);
}
}
}

View File

@@ -6,12 +6,11 @@ use std::time::{Duration, Instant};
use tokio::time::sleep;
use tracing::{debug, info, warn};
use crate::services::acme::{CloudflareClient, AcmeError};
use crate::services::acme::{AcmeError, CloudflareClient};
pub struct AcmeClient {
cloudflare: CloudflareClient,
account: Account,
directory_url: String,
}
impl AcmeClient {
@@ -21,7 +20,7 @@ impl AcmeClient {
directory_url: String,
) -> Result<Self, AcmeError> {
info!("Creating ACME client for directory: {}", directory_url);
let cloudflare = CloudflareClient::new(cloudflare_token)?;
// Create Let's Encrypt account
@@ -43,21 +42,27 @@ impl AcmeClient {
Ok(Self {
cloudflare,
account,
directory_url,
})
}
pub async fn get_certificate(&mut self, domain: &str, base_domain: &str) -> Result<(String, String), AcmeError> {
pub async fn get_certificate(
&mut self,
domain: &str,
base_domain: &str,
) -> Result<(String, String), AcmeError> {
info!("Starting certificate request for domain: {}", domain);
// Validate domain
if domain.is_empty() || base_domain.is_empty() {
return Err(AcmeError::InvalidDomain("Domain cannot be empty".to_string()));
return Err(AcmeError::InvalidDomain(
"Domain cannot be empty".to_string(),
));
}
// Create a new order
let identifiers = vec![Identifier::Dns(domain.to_string())];
let mut order = self.account
let mut order = self
.account
.new_order(&NewOrder::new(&identifiers))
.await
.map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
@@ -66,20 +71,19 @@ impl AcmeClient {
// Process authorizations
let mut authorizations = order.authorizations();
while let Some(authz_result) = authorizations.next().await {
let mut authz = authz_result
.map_err(|e| AcmeError::Challenge(e.to_string()))?;
let mut authz = authz_result.map_err(|e| AcmeError::Challenge(e.to_string()))?;
let identifier = format!("{:?}", authz.identifier());
if authz.status == AuthorizationStatus::Valid {
info!("Authorization already valid for: {:?}", identifier);
continue;
}
// Get challenge value and record ID first
let (challenge_value, record_id) = {
let (_challenge_value, record_id) = {
// Find DNS challenge
let mut challenge = authz
.challenge(ChallengeType::Dns01)
@@ -93,7 +97,8 @@ impl AcmeClient {
// Create DNS record
let challenge_domain = format!("_acme-challenge.{}", domain);
let record_id = self.cloudflare
let record_id = self
.cloudflare
.create_txt_record(base_domain, &challenge_domain, &challenge_value)
.await?;
@@ -105,9 +110,11 @@ impl AcmeClient {
// Submit challenge
info!("Submitting challenge...");
challenge.set_ready().await
challenge
.set_ready()
.await
.map_err(|e| AcmeError::Challenge(e.to_string()))?;
(challenge_value, record_id)
};
@@ -129,7 +136,9 @@ impl AcmeClient {
return Err(AcmeError::Challenge("Order processing timeout".to_string()));
}
order.refresh().await
order
.refresh()
.await
.map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
match order.state().status {
@@ -154,55 +163,73 @@ impl AcmeClient {
// Generate CSR
info!("Generating certificate signing request...");
let mut params = CertificateParams::new(vec![domain.to_string()]);
params.distinguished_name = DistinguishedName::new();
let key_pair = KeyPair::generate(&rcgen::PKCS_ECDSA_P256_SHA256)
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
// Set the key pair for CSR generation
params.key_pair = Some(key_pair);
// Generate CSR using rcgen certificate
let cert = rcgen::Certificate::from_params(params)
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
let csr_der = cert.serialize_request_der()
let csr_der = cert
.serialize_request_der()
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
// Finalize order with CSR
info!("Finalizing order with CSR...");
order.finalize_csr(&csr_der).await
order
.finalize_csr(&csr_der)
.await
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
// Wait for certificate to be ready
info!("Waiting for certificate to be generated...");
let start = Instant::now();
let timeout = Duration::from_secs(300); // 5 minutes
let cert_chain_pem = loop {
if start.elapsed() > timeout {
return Err(AcmeError::CertificateGeneration("Certificate generation timeout".to_string()));
return Err(AcmeError::CertificateGeneration(
"Certificate generation timeout".to_string(),
));
}
order.refresh().await
order
.refresh()
.await
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
match order.state().status {
OrderStatus::Valid => {
info!("Certificate is ready!");
break order.certificate().await
break order
.certificate()
.await
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?
.ok_or_else(|| AcmeError::CertificateGeneration("Certificate not available".to_string()))?;
.ok_or_else(|| {
AcmeError::CertificateGeneration(
"Certificate not available".to_string(),
)
})?;
}
OrderStatus::Invalid => {
return Err(AcmeError::CertificateGeneration("Order became invalid during certificate generation".to_string()));
return Err(AcmeError::CertificateGeneration(
"Order became invalid during certificate generation".to_string(),
));
}
OrderStatus::Processing => {
debug!("Certificate still being processed, waiting...");
sleep(Duration::from_secs(3)).await;
}
_ => {
debug!("Waiting for certificate, order status: {:?}", order.state().status);
debug!(
"Waiting for certificate, order status: {:?}",
order.state().status
);
sleep(Duration::from_secs(3)).await;
}
}
@@ -214,12 +241,16 @@ impl AcmeClient {
Ok((cert_chain_pem, private_key_pem))
}
async fn wait_for_dns_propagation(&self, record_name: &str, expected_value: &str) -> Result<(), AcmeError> {
async fn wait_for_dns_propagation(
&self,
record_name: &str,
expected_value: &str,
) -> Result<(), AcmeError> {
info!("Checking DNS propagation for: {}", record_name);
let start = Instant::now();
let timeout = Duration::from_secs(120); // 2 minutes
while start.elapsed() < timeout {
match self.check_dns_txt_record(record_name, expected_value).await {
Ok(true) => {
@@ -233,17 +264,21 @@ impl AcmeClient {
debug!("DNS check failed: {:?}", e);
}
}
sleep(Duration::from_secs(10)).await;
}
warn!("DNS propagation timeout, but continuing anyway");
Ok(())
}
async fn check_dns_txt_record(&self, record_name: &str, expected_value: &str) -> Result<bool, AcmeError> {
async fn check_dns_txt_record(
&self,
record_name: &str,
expected_value: &str,
) -> Result<bool, AcmeError> {
use std::process::Command;
let output = Command::new("dig")
.args(&["+short", "TXT", record_name])
.output()
@@ -268,7 +303,11 @@ impl AcmeClient {
}
async fn cleanup_dns_record(&self, base_domain: &str, record_id: &str) {
if let Err(e) = self.cloudflare.delete_txt_record(base_domain, record_id).await {
if let Err(e) = self
.cloudflare
.delete_txt_record(base_domain, record_id)
.await
{
warn!("Failed to cleanup DNS record {}: {:?}", record_id, e);
}
}
@@ -277,11 +316,13 @@ impl AcmeClient {
pub fn get_base_domain(domain: &str) -> Result<String, AcmeError> {
let parts: Vec<&str> = domain.split('.').collect();
if parts.len() < 2 {
return Err(AcmeError::InvalidDomain("Domain must have at least 2 parts".to_string()));
return Err(AcmeError::InvalidDomain(
"Domain must have at least 2 parts".to_string(),
));
}
// Take the last two parts for base domain
let base_domain = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
Ok(base_domain)
}
}
}

View File

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

View File

@@ -4,37 +4,37 @@ use thiserror::Error;
pub enum AcmeError {
#[error("ACME account creation failed: {0}")]
AccountCreation(String),
#[error("ACME order creation failed: {0}")]
OrderCreation(String),
#[error("ACME challenge failed: {0}")]
Challenge(String),
#[error("DNS propagation timeout")]
DnsPropagationTimeout,
#[error("Certificate generation failed: {0}")]
CertificateGeneration(String),
#[error("Cloudflare API error: {0}")]
CloudflareApi(String),
#[error("DNS provider not found")]
DnsProviderNotFound,
#[error("Invalid domain: {0}")]
InvalidDomain(String),
#[error("HTTP request failed: {0}")]
HttpRequest(#[from] reqwest::Error),
#[error("JSON parsing failed: {0}")]
JsonParsing(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Instant ACME error: {0}")]
InstantAcme(String),
}
}

View File

@@ -4,4 +4,4 @@ pub mod error;
pub use client::AcmeClient;
pub use cloudflare::CloudflareClient;
pub use error::AcmeError;
pub use error::AcmeError;

View File

@@ -1,10 +1,13 @@
use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType, KeyPair, PKCS_ECDSA_P256_SHA256};
use rcgen::{
Certificate, CertificateParams, DistinguishedName, DnType, KeyPair, SanType,
PKCS_ECDSA_P256_SHA256,
};
use std::net::IpAddr;
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
use crate::database::repository::DnsProviderRepository;
use crate::database::entities::dns_provider::DnsProviderType;
use crate::database::repository::DnsProviderRepository;
use crate::services::acme::{AcmeClient, AcmeError};
use sea_orm::DatabaseConnection;
@@ -19,7 +22,7 @@ impl CertificateService {
pub fn new() -> Self {
Self { db: None }
}
pub fn with_db(db: DatabaseConnection) -> Self {
Self { db: Some(db) }
}
@@ -27,17 +30,17 @@ impl CertificateService {
/// Generate self-signed certificate optimized for Xray
pub async fn generate_self_signed(&self, domain: &str) -> anyhow::Result<(String, String)> {
tracing::info!("Generating self-signed certificate for domain: {}", domain);
// Create certificate parameters with ECDSA (recommended for Xray)
let mut params = CertificateParams::new(vec![domain.to_string()]);
// Use ECDSA P-256 which is recommended for Xray (equivalent to RSA-3072 in strength)
params.alg = &PKCS_ECDSA_P256_SHA256;
// Generate ECDSA key pair
let key_pair = KeyPair::generate(&PKCS_ECDSA_P256_SHA256)?;
params.key_pair = Some(key_pair);
// Set certificate subject with proper fields
let mut distinguished_name = DistinguishedName::new();
distinguished_name.push(DnType::CommonName, domain);
@@ -47,57 +50,60 @@ impl CertificateService {
distinguished_name.push(DnType::StateOrProvinceName, "State");
distinguished_name.push(DnType::LocalityName, "City");
params.distinguished_name = distinguished_name;
// Add comprehensive Subject Alternative Names for better compatibility
let mut san_list = vec![
SanType::DnsName(domain.to_string()),
SanType::DnsName("localhost".to_string()),
];
// Add IP addresses if domain looks like an IP
if let Ok(ip) = domain.parse::<IpAddr>() {
san_list.push(SanType::IpAddress(ip));
}
// Always add localhost IP for local testing
san_list.push(SanType::IpAddress(IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))));
san_list.push(SanType::IpAddress(IpAddr::V4(std::net::Ipv4Addr::new(
127, 0, 0, 1,
))));
// If domain is not an IP, also add wildcard subdomain
if domain.parse::<IpAddr>().is_err() && !domain.starts_with("*.") {
san_list.push(SanType::DnsName(format!("*.{}", domain)));
}
params.subject_alt_names = san_list;
// Set validity period (1 year as recommended)
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + Duration::days(365);
// Set serial number
params.serial_number = Some(rcgen::SerialNumber::from_slice(&[1, 2, 3, 4]));
// Generate certificate
let cert = Certificate::from_params(params)?;
// Get PEM format with proper formatting
let cert_pem = cert.serialize_pem()?;
let key_pem = cert.serialize_private_key_pem();
// Validate PEM format
if !cert_pem.starts_with("-----BEGIN CERTIFICATE-----") || !cert_pem.ends_with("-----END CERTIFICATE-----\n") {
if !cert_pem.starts_with("-----BEGIN CERTIFICATE-----")
|| !cert_pem.ends_with("-----END CERTIFICATE-----\n")
{
return Err(anyhow::anyhow!("Invalid certificate PEM format"));
}
if !key_pem.starts_with("-----BEGIN") || !key_pem.contains("PRIVATE KEY-----") {
return Err(anyhow::anyhow!("Invalid private key PEM format"));
}
tracing::debug!("Generated ECDSA P-256 certificate for domain: {}", domain);
Ok((cert_pem, key_pem))
}
/// Generate Let's Encrypt certificate using DNS challenge
pub async fn generate_letsencrypt_certificate(
&self,
@@ -106,123 +112,148 @@ impl CertificateService {
acme_email: &str,
staging: bool,
) -> Result<(String, String), AcmeError> {
tracing::info!("Generating Let's Encrypt certificate for domain: {} using DNS challenge", domain);
tracing::info!(
"Generating Let's Encrypt certificate for domain: {} using DNS challenge",
domain
);
// Get database connection
let db = self.db.as_ref()
let db = self
.db
.as_ref()
.ok_or_else(|| AcmeError::DnsProviderNotFound)?;
// Get DNS provider
let dns_repo = DnsProviderRepository::new(db.clone());
let dns_provider = dns_repo.find_by_id(dns_provider_id)
let dns_provider = dns_repo
.find_by_id(dns_provider_id)
.await
.map_err(|_| AcmeError::DnsProviderNotFound)?
.ok_or_else(|| AcmeError::DnsProviderNotFound)?;
// Verify provider is Cloudflare (only supported provider for now)
if dns_provider.provider_type != DnsProviderType::Cloudflare.as_str() {
return Err(AcmeError::CloudflareApi("Only Cloudflare provider is supported".to_string()));
return Err(AcmeError::CloudflareApi(
"Only Cloudflare provider is supported".to_string(),
));
}
if !dns_provider.is_active {
return Err(AcmeError::DnsProviderNotFound);
}
// Determine ACME directory URL
let directory_url = if staging {
"https://acme-staging-v02.api.letsencrypt.org/directory"
} else {
"https://acme-v02.api.letsencrypt.org/directory"
};
// Create ACME client
let mut acme_client = AcmeClient::new(
dns_provider.api_token.clone(),
acme_email,
directory_url.to_string(),
).await?;
)
.await?;
// Get base domain for DNS operations
let base_domain = AcmeClient::get_base_domain(domain)?;
// Generate certificate
let (cert_pem, key_pem) = acme_client
.get_certificate(domain, &base_domain)
.await?;
tracing::info!("Successfully generated Let's Encrypt certificate for domain: {}", domain);
let (cert_pem, key_pem) = acme_client.get_certificate(domain, &base_domain).await?;
tracing::info!(
"Successfully generated Let's Encrypt certificate for domain: {}",
domain
);
Ok((cert_pem, key_pem))
}
/// Renew certificate by ID (used for manual renewal)
pub async fn renew_certificate_by_id(&self, cert_id: Uuid) -> anyhow::Result<(String, String)> {
let db = self.db.as_ref()
let db = self
.db
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Database connection not available"))?;
// Get the certificate from database
let cert_repo = crate::database::repository::CertificateRepository::new(db.clone());
let certificate = cert_repo.find_by_id(cert_id)
let certificate = cert_repo
.find_by_id(cert_id)
.await?
.ok_or_else(|| anyhow::anyhow!("Certificate not found"))?;
tracing::info!("Renewing certificate '{}' for domain: {}", certificate.name, certificate.domain);
tracing::info!(
"Renewing certificate '{}' for domain: {}",
certificate.name,
certificate.domain
);
match certificate.cert_type.as_str() {
"letsencrypt" => {
// For Let's Encrypt, we need to regenerate using ACME
// Find an active Cloudflare DNS provider
let dns_repo = crate::database::repository::DnsProviderRepository::new(db.clone());
let providers = dns_repo.find_active_by_type("cloudflare").await?;
if providers.is_empty() {
return Err(anyhow::anyhow!("No active Cloudflare DNS provider found for Let's Encrypt renewal"));
return Err(anyhow::anyhow!(
"No active Cloudflare DNS provider found for Let's Encrypt renewal"
));
}
let dns_provider = &providers[0];
let acme_email = "admin@example.com"; // TODO: Store this with certificate
// Generate new certificate
let (cert_pem, key_pem) = self.generate_letsencrypt_certificate(
&certificate.domain,
dns_provider.id,
acme_email,
false, // Production
).await?;
let (cert_pem, key_pem) = self
.generate_letsencrypt_certificate(
&certificate.domain,
dns_provider.id,
acme_email,
false, // Production
)
.await?;
// Update in database
cert_repo.update_certificate_data(
cert_id,
&cert_pem,
&key_pem,
chrono::Utc::now() + chrono::Duration::days(90),
).await?;
cert_repo
.update_certificate_data(
cert_id,
&cert_pem,
&key_pem,
chrono::Utc::now() + chrono::Duration::days(90),
)
.await?;
Ok((cert_pem, key_pem))
}
"self_signed" => {
// For self-signed, generate a new one
let (cert_pem, key_pem) = self.generate_self_signed(&certificate.domain).await?;
// Update in database
cert_repo.update_certificate_data(
cert_id,
&cert_pem,
&key_pem,
chrono::Utc::now() + chrono::Duration::days(365),
).await?;
cert_repo
.update_certificate_data(
cert_id,
&cert_pem,
&key_pem,
chrono::Utc::now() + chrono::Duration::days(365),
)
.await?;
Ok((cert_pem, key_pem))
}
_ => {
Err(anyhow::anyhow!("Cannot renew imported certificates automatically"))
}
_ => Err(anyhow::anyhow!(
"Cannot renew imported certificates automatically"
)),
}
}
/// Renew certificate (legacy method for backward compatibility)
pub async fn renew_certificate(&self, domain: &str) -> anyhow::Result<(String, String)> {
tracing::info!("Renewing certificate for domain: {}", domain);
// For backward compatibility, just generate a new self-signed certificate
self.generate_self_signed(domain).await
}
@@ -232,4 +263,4 @@ impl Default for CertificateService {
fn default() -> Self {
Self::new()
}
}
}

View File

@@ -4,7 +4,7 @@ use uuid::Uuid;
#[derive(Clone, Debug)]
pub enum SyncEvent {
InboundChanged(Uuid), // server_id
InboundChanged(Uuid), // server_id
UserAccessChanged(Uuid), // server_id
}
@@ -27,4 +27,4 @@ pub fn send_sync_event(event: SyncEvent) {
} else {
tracing::error!("Event bus not initialized");
}
}
}

View File

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

View File

@@ -1,18 +1,19 @@
use anyhow::Result;
use tokio_cron_scheduler::{JobScheduler, Job};
use tracing::{info, error, warn, debug};
use crate::database::repository::{
CertificateRepository, InboundTemplateRepository, InboundUsersRepository,
ServerInboundRepository, ServerRepository, UserRepository,
};
use crate::database::DatabaseManager;
use crate::database::repository::{ServerRepository, ServerInboundRepository, InboundTemplateRepository, InboundUsersRepository, CertificateRepository, UserRepository};
use crate::database::entities::inbound_users;
use crate::services::XrayService;
use crate::services::events::SyncEvent;
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, RelationTrait, JoinType};
use uuid::Uuid;
use crate::services::XrayService;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use chrono::{DateTime, Utc};
use serde::{Serialize, Deserialize};
use tokio_cron_scheduler::{Job, JobScheduler};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
pub struct TaskScheduler {
scheduler: JobScheduler,
@@ -47,7 +48,10 @@ impl TaskScheduler {
pub async fn new() -> Result<Self> {
let scheduler = JobScheduler::new().await?;
let task_status = Arc::new(RwLock::new(HashMap::new()));
Ok(Self { scheduler, task_status })
Ok(Self {
scheduler,
task_status,
})
}
/// Get current status of all tasks
@@ -56,16 +60,30 @@ impl TaskScheduler {
}
/// Start event-driven sync handler
pub async fn start_event_handler(db: DatabaseManager, mut event_receiver: tokio::sync::broadcast::Receiver<SyncEvent>) {
pub async fn start_event_handler(
db: DatabaseManager,
mut event_receiver: tokio::sync::broadcast::Receiver<SyncEvent>,
) {
let xray_service = XrayService::new();
tokio::spawn(async move {
while let Ok(event) = event_receiver.recv().await {
match event {
SyncEvent::InboundChanged(server_id) | SyncEvent::UserAccessChanged(server_id) => {
if let Err(e) = sync_single_server_by_id(&xray_service, &db, server_id).await {
error!("Failed to sync server {} from event: {}", server_id, e);
SyncEvent::InboundChanged(server_id)
| SyncEvent::UserAccessChanged(server_id) => {
if let Err(e) =
sync_single_server_by_id(&xray_service, &db, server_id).await
{
// Get server name for better logging
let server_repo = ServerRepository::new(db.connection().clone());
let server_name = match server_repo.find_by_id(server_id).await {
Ok(Some(server)) => server.name,
_ => server_id.to_string(),
};
error!(
"Failed to sync server '{}' ({}) from event: {}",
server_name, server_id, e
);
}
}
}
@@ -74,54 +92,85 @@ impl TaskScheduler {
}
pub async fn start(&mut self, db: DatabaseManager, xray_service: XrayService) -> Result<()> {
// Initialize task status
{
let mut status = self.task_status.write().unwrap();
status.insert("xray_sync".to_string(), TaskStatus {
name: "Xray Synchronization".to_string(),
description: "Synchronizes database state with xray servers".to_string(),
schedule: "0 * * * * * (every minute)".to_string(),
status: TaskState::Idle,
last_run: None,
next_run: Some(Utc::now() + chrono::Duration::minutes(1)),
total_runs: 0,
success_count: 0,
error_count: 0,
last_error: None,
last_duration_ms: None,
});
status.insert(
"xray_sync".to_string(),
TaskStatus {
name: "Xray Synchronization".to_string(),
description: "Synchronizes database state with xray servers".to_string(),
schedule: "0 * * * * * (every minute)".to_string(),
status: TaskState::Idle,
last_run: None,
next_run: Some(Utc::now() + chrono::Duration::minutes(1)),
total_runs: 0,
success_count: 0,
error_count: 0,
last_error: None,
last_duration_ms: None,
},
);
}
// Run initial sync on startup
let start_time = Utc::now();
self.update_task_status("xray_sync", TaskState::Running, None);
match sync_xray_state(db.clone(), xray_service.clone()).await {
Ok(_) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
self.update_task_status("xray_sync", TaskState::Success, Some(duration));
},
Err(e) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
self.update_task_status_with_error("xray_sync", e.to_string(), Some(duration));
error!("Initial xray sync failed: {}", e);
// Run initial sync in background to avoid blocking startup
let db_initial = db.clone();
let xray_service_initial = xray_service.clone();
let task_status_initial = self.task_status.clone();
tokio::spawn(async move {
info!("Starting initial xray sync in background...");
let start_time = Utc::now();
// Update status to running
{
let mut status = task_status_initial.write().unwrap();
if let Some(task) = status.get_mut("xray_sync") {
task.status = TaskState::Running;
task.last_run = Some(start_time);
task.total_runs += 1;
}
}
}
match sync_xray_state(db_initial, xray_service_initial).await {
Ok(_) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
let mut status = task_status_initial.write().unwrap();
if let Some(task) = status.get_mut("xray_sync") {
task.status = TaskState::Success;
task.success_count += 1;
task.last_duration_ms = Some(duration);
task.last_error = None;
}
info!("Initial xray sync completed successfully in {}ms", duration);
}
Err(e) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
let mut status = task_status_initial.write().unwrap();
if let Some(task) = status.get_mut("xray_sync") {
task.status = TaskState::Error;
task.error_count += 1;
task.last_duration_ms = Some(duration);
task.last_error = Some(e.to_string());
}
error!("Initial xray sync failed: {}", e);
}
}
});
// Add synchronization task that runs every minute
let db_clone = db.clone();
let xray_service_clone = xray_service.clone();
let task_status_clone = self.task_status.clone();
let sync_job = Job::new_async("0 */5 * * * *", move |_uuid, _l| {
let db = db_clone.clone();
let xray_service = xray_service_clone.clone();
let task_status = task_status_clone.clone();
Box::pin(async move {
let start_time = Utc::now();
// Update status to running
{
let mut status = task_status.write().unwrap();
@@ -132,7 +181,7 @@ impl TaskScheduler {
task.next_run = Some(start_time + chrono::Duration::minutes(1));
}
}
match sync_xray_state(db, xray_service).await {
Ok(_) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
@@ -143,7 +192,7 @@ impl TaskScheduler {
task.last_duration_ms = Some(duration);
task.last_error = None;
}
},
}
Err(e) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
let mut status = task_status.write().unwrap();
@@ -158,38 +207,42 @@ impl TaskScheduler {
}
})
})?;
self.scheduler.add(sync_job).await?;
// Add certificate renewal task that runs once a day at 2 AM
let db_clone_cert = db.clone();
let task_status_cert = self.task_status.clone();
// Initialize certificate renewal task status
{
let mut status = self.task_status.write().unwrap();
status.insert("cert_renewal".to_string(), TaskStatus {
name: "Certificate Renewal".to_string(),
description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(),
schedule: "0 0 2 * * * (daily at 2 AM)".to_string(),
status: TaskState::Idle,
last_run: None,
next_run: Some(Utc::now() + chrono::Duration::days(1)),
total_runs: 0,
success_count: 0,
error_count: 0,
last_error: None,
last_duration_ms: None,
});
status.insert(
"cert_renewal".to_string(),
TaskStatus {
name: "Certificate Renewal".to_string(),
description: "Renews Let's Encrypt certificates that expire within 15 days"
.to_string(),
schedule: "0 0 2 * * * (daily at 2 AM)".to_string(),
status: TaskState::Idle,
last_run: None,
next_run: Some(Utc::now() + chrono::Duration::days(1)),
total_runs: 0,
success_count: 0,
error_count: 0,
last_error: None,
last_duration_ms: None,
},
);
}
let cert_renewal_job = Job::new_async("0 0 2 * * *", move |_uuid, _l| {
let db = db_clone_cert.clone();
let task_status = task_status_cert.clone();
Box::pin(async move {
let start_time = Utc::now();
// Update task status to running
{
let mut status = task_status.write().unwrap();
@@ -199,7 +252,7 @@ impl TaskScheduler {
task.total_runs += 1;
}
}
match check_and_renew_certificates(&db).await {
Ok(_) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
@@ -210,7 +263,7 @@ impl TaskScheduler {
task.last_duration_ms = Some(duration);
task.last_error = None;
}
},
}
Err(e) => {
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
let mut status = task_status.write().unwrap();
@@ -225,9 +278,9 @@ impl TaskScheduler {
}
})
})?;
self.scheduler.add(cert_renewal_job).await?;
// Also run certificate check on startup
info!("Running initial certificate renewal check...");
tokio::spawn(async move {
@@ -235,7 +288,7 @@ impl TaskScheduler {
error!("Initial certificate renewal check failed: {}", e);
}
});
self.scheduler.start().await?;
Ok(())
}
@@ -252,7 +305,12 @@ impl TaskScheduler {
}
}
fn update_task_status_with_error(&self, task_id: &str, error: String, duration_ms: Option<u64>) {
fn update_task_status_with_error(
&self,
task_id: &str,
error: String,
duration_ms: Option<u64>,
) {
let mut status = self.task_status.write().unwrap();
if let Some(task) = status.get_mut(task_id) {
task.status = TaskState::Error;
@@ -272,11 +330,10 @@ impl TaskScheduler {
/// Synchronize xray server state with database state
async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Result<()> {
let server_repo = ServerRepository::new(db.connection().clone());
let inbound_repo = ServerInboundRepository::new(db.connection().clone());
let template_repo = InboundTemplateRepository::new(db.connection().clone());
// Get all servers from database
let servers = match server_repo.find_all().await {
Ok(servers) => servers,
@@ -285,50 +342,50 @@ async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Resu
return Err(e.into());
}
};
for server in servers {
let endpoint = server.get_grpc_endpoint();
// Test connection first
match xray_service.test_connection(server.id, &endpoint).await {
Ok(false) => {
warn!("Cannot connect to server {} at {}, skipping", server.name, endpoint);
warn!(
"Cannot connect to server {} at {}, skipping",
server.name, endpoint
);
continue;
},
}
Err(e) => {
error!("Error testing connection to server {}: {}", server.name, e);
continue;
}
_ => {}
}
// Get desired inbounds from database
let desired_inbounds = match get_desired_inbounds_from_db(&db, &server, &inbound_repo, &template_repo).await {
Ok(inbounds) => inbounds,
Err(e) => {
error!("Failed to get desired inbounds for server {}: {}", server.name, e);
continue;
}
};
let desired_inbounds =
match get_desired_inbounds_from_db(&db, &server, &inbound_repo, &template_repo).await {
Ok(inbounds) => inbounds,
Err(e) => {
error!(
"Failed to get desired inbounds for server {}: {}",
server.name, e
);
continue;
}
};
// Synchronize inbounds
if let Err(e) = sync_server_inbounds(
&xray_service,
server.id,
&endpoint,
&desired_inbounds
).await {
if let Err(e) =
sync_server_inbounds(&xray_service, server.id, &endpoint, &desired_inbounds).await
{
error!("Failed to sync inbounds for server {}: {}", server.name, e);
}
}
Ok(())
}
/// Get desired inbounds configuration from database
async fn get_desired_inbounds_from_db(
db: &DatabaseManager,
@@ -336,46 +393,72 @@ async fn get_desired_inbounds_from_db(
inbound_repo: &ServerInboundRepository,
template_repo: &InboundTemplateRepository,
) -> Result<HashMap<String, DesiredInbound>> {
// Get all inbounds for this server
let inbounds = inbound_repo.find_by_server_id(server.id).await?;
let mut desired_inbounds = HashMap::new();
for inbound in inbounds {
// Get template for this inbound
let template = match template_repo.find_by_id(inbound.template_id).await? {
Some(template) => template,
None => {
warn!("Template {} not found for inbound {}, skipping", inbound.template_id, inbound.tag);
warn!(
"Template {} not found for inbound {}, skipping",
inbound.template_id, inbound.tag
);
continue;
}
};
// Get users for this inbound
let users = get_users_for_inbound(db, inbound.id).await?;
// Get port from template or override
let port = inbound.port_override.unwrap_or(template.default_port);
// Get certificate if specified
let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id {
match load_certificate_from_db(db, inbound.certificate_id).await {
Ok((cert, key)) => {
info!("Loaded certificate {} for inbound {}, has_cert={}, has_key={}",
cert_id, inbound.tag, cert.is_some(), key.is_some());
// Get certificate name for better logging
let cert_repo = CertificateRepository::new(db.connection().clone());
let cert_name = match cert_repo.find_by_id(cert_id).await {
Ok(Some(cert)) => cert.name,
_ => cert_id.to_string(),
};
info!(
"Loaded certificate '{}' ({}) for inbound '{}' on server '{}', has_cert={}, has_key={}",
cert_name,
cert_id,
inbound.tag,
server.name,
cert.is_some(),
key.is_some()
);
(cert, key)
},
}
Err(e) => {
warn!("Failed to load certificate {} for inbound {}: {}", cert_id, inbound.tag, e);
// Get certificate name for better logging
let cert_repo = CertificateRepository::new(db.connection().clone());
let cert_name = match cert_repo.find_by_id(cert_id).await {
Ok(Some(cert)) => cert.name,
_ => cert_id.to_string(),
};
warn!(
"Failed to load certificate '{}' ({}) for inbound '{}' on server '{}': {}",
cert_name, cert_id, inbound.tag, server.name, e
);
(None, None)
}
}
} else {
debug!("No certificate configured for inbound {}", inbound.tag);
debug!(
"No certificate configured for inbound '{}' on server '{}'",
inbound.tag, server.name
);
(None, None)
};
let desired_inbound = DesiredInbound {
tag: inbound.tag.clone(),
port,
@@ -386,22 +469,24 @@ async fn get_desired_inbounds_from_db(
cert_pem,
key_pem,
};
desired_inbounds.insert(inbound.tag.clone(), desired_inbound);
}
Ok(desired_inbounds)
}
/// Get users for specific inbound from database
async fn get_users_for_inbound(db: &DatabaseManager, inbound_id: Uuid) -> Result<Vec<XrayUser>> {
let inbound_users_repo = InboundUsersRepository::new(db.connection().clone());
let inbound_users = inbound_users_repo.find_active_by_inbound_id(inbound_id).await?;
let inbound_users = inbound_users_repo
.find_active_by_inbound_id(inbound_id)
.await?;
// Get user details to generate emails
let user_repo = UserRepository::new(db.connection().clone());
let mut users: Vec<XrayUser> = Vec::new();
for inbound_user in inbound_users {
if let Some(user) = user_repo.find_by_id(inbound_user.user_id).await? {
@@ -413,23 +498,30 @@ async fn get_users_for_inbound(db: &DatabaseManager, inbound_id: Uuid) -> Result
});
}
}
Ok(users)
}
/// Load certificate from database
async fn load_certificate_from_db(db: &DatabaseManager, cert_id: Option<Uuid>) -> Result<(Option<String>, Option<String>)> {
async fn load_certificate_from_db(
db: &DatabaseManager,
cert_id: Option<Uuid>,
) -> Result<(Option<String>, Option<String>)> {
let cert_id = match cert_id {
Some(id) => id,
None => return Ok((None, None)),
};
let cert_repo = CertificateRepository::new(db.connection().clone());
match cert_repo.find_by_id(cert_id).await? {
Some(cert) => {
debug!(
"Loaded certificate '{}' ({}) successfully",
cert.name, cert.id
);
Ok((Some(cert.certificate_pem()), Some(cert.private_key_pem())))
},
}
None => {
warn!("Certificate {} not found", cert_id);
Ok((None, None))
@@ -445,7 +537,9 @@ async fn sync_server_inbounds(
desired_inbounds: &HashMap<String, DesiredInbound>,
) -> Result<()> {
// Use optimized batch sync with single client
xray_service.sync_server_inbounds_optimized(server_id, endpoint, desired_inbounds).await
xray_service
.sync_server_inbounds_optimized(server_id, endpoint, desired_inbounds)
.await
}
/// Sync a single server by ID (for event-driven sync)
@@ -457,7 +551,7 @@ async fn sync_single_server_by_id(
let server_repo = ServerRepository::new(db.connection().clone());
let inbound_repo = ServerInboundRepository::new(db.connection().clone());
let template_repo = InboundTemplateRepository::new(db.connection().clone());
// Get server
let server = match server_repo.find_by_id(server_id).await? {
Some(server) => server,
@@ -466,22 +560,22 @@ async fn sync_single_server_by_id(
return Ok(());
}
};
// For now, sync all servers (can add active/inactive flag later)
// Get desired inbounds from database
let desired_inbounds = get_desired_inbounds_from_db(db, &server, &inbound_repo, &template_repo).await?;
let desired_inbounds =
get_desired_inbounds_from_db(db, &server, &inbound_repo, &template_repo).await?;
// Build endpoint
let endpoint = server.get_grpc_endpoint();
// Sync server
sync_server_inbounds(xray_service, server_id, &endpoint, &desired_inbounds).await?;
Ok(())
}
/// Represents desired inbound configuration from database
#[derive(Debug, Clone)]
pub struct DesiredInbound {
@@ -505,73 +599,79 @@ pub struct XrayUser {
/// Check and renew certificates that expire within 15 days
async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> {
use crate::services::certificates::CertificateService;
use crate::database::repository::DnsProviderRepository;
use crate::services::certificates::CertificateService;
info!("Starting certificate renewal check...");
let cert_repo = CertificateRepository::new(db.connection().clone());
let dns_repo = DnsProviderRepository::new(db.connection().clone());
let cert_service = CertificateService::with_db(db.connection().clone());
// Get all certificates
let certificates = cert_repo.find_all().await?;
let mut renewed_count = 0;
let mut checked_count = 0;
for cert in certificates {
// Only check Let's Encrypt certificates with auto_renew enabled
if cert.cert_type != "letsencrypt" || !cert.auto_renew {
continue;
}
checked_count += 1;
// Check if certificate expires within 15 days
if cert.expires_soon(15) {
info!(
"Certificate '{}' (ID: {}) expires at {} - renewing...",
"Certificate '{}' (ID: {}) expires at {} - renewing...",
cert.name, cert.id, cert.expires_at
);
// Find the DNS provider used for this certificate
// For now, we'll use the first active Cloudflare provider
// In production, you might want to store the provider ID with the certificate
let providers = dns_repo.find_active_by_type("cloudflare").await?;
if providers.is_empty() {
error!(
"Cannot renew certificate '{}': No active Cloudflare DNS provider found",
"Cannot renew certificate '{}': No active Cloudflare DNS provider found",
cert.name
);
continue;
}
let dns_provider = &providers[0];
// Need to get the ACME email - for now using a default
// In production, this should be stored with the certificate
let acme_email = "admin@example.com"; // TODO: Store this with certificate
// Attempt to renew the certificate
match cert_service.generate_letsencrypt_certificate(
&cert.domain,
dns_provider.id,
acme_email,
false, // Use production Let's Encrypt
).await {
match cert_service
.generate_letsencrypt_certificate(
&cert.domain,
dns_provider.id,
acme_email,
false, // Use production Let's Encrypt
)
.await
{
Ok((new_cert_pem, new_key_pem)) => {
// Update the certificate in database
match cert_repo.update_certificate_data(
cert.id,
&new_cert_pem,
&new_key_pem,
chrono::Utc::now() + chrono::Duration::days(90), // Let's Encrypt certs are valid for 90 days
).await {
match cert_repo
.update_certificate_data(
cert.id,
&new_cert_pem,
&new_key_pem,
chrono::Utc::now() + chrono::Duration::days(90), // Let's Encrypt certs are valid for 90 days
)
.await
{
Ok(_) => {
info!("Successfully renewed certificate '{}'", cert.name);
renewed_count += 1;
// Trigger sync for all servers using this certificate
// This will be done via the event system
if let Err(e) = trigger_cert_renewal_sync(db, cert.id).await {
@@ -579,7 +679,10 @@ async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> {
}
}
Err(e) => {
error!("Failed to save renewed certificate '{}' to database: {}", cert.name, e);
error!(
"Failed to save renewed certificate '{}' to database: {}",
cert.name, e
);
}
}
}
@@ -589,17 +692,17 @@ async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> {
}
} else {
debug!(
"Certificate '{}' expires at {} - no renewal needed yet",
"Certificate '{}' expires at {} - no renewal needed yet",
cert.name, cert.expires_at
);
}
}
info!(
"Certificate renewal check completed: checked {}, renewed {}",
"Certificate renewal check completed: checked {}, renewed {}",
checked_count, renewed_count
);
Ok(())
}
@@ -607,23 +710,32 @@ async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> {
async fn trigger_cert_renewal_sync(db: &DatabaseManager, cert_id: Uuid) -> Result<()> {
use crate::services::events::send_sync_event;
use crate::services::events::SyncEvent;
let inbound_repo = ServerInboundRepository::new(db.connection().clone());
// Find all server inbounds that use this certificate
let inbounds = inbound_repo.find_by_certificate_id(cert_id).await?;
// Collect unique server IDs
let mut server_ids = std::collections::HashSet::new();
for inbound in inbounds {
server_ids.insert(inbound.server_id);
}
// Trigger sync for each server
for server_id in server_ids {
info!("Triggering sync for server {} after certificate renewal", server_id);
// Get server name for better logging
let server_repo = ServerRepository::new(db.connection().clone());
let server_name = match server_repo.find_by_id(server_id).await {
Ok(Some(server)) => server.name,
_ => server_id.to_string(),
};
info!(
"Triggering sync for server '{}' ({}) after certificate renewal",
server_name, server_id
);
send_sync_event(SyncEvent::InboundChanged(server_id));
}
Ok(())
}
}

View File

@@ -0,0 +1,43 @@
use teloxide::{prelude::*, Bot};
use tokio::sync::oneshot;
use super::handlers::{self, Command};
use crate::config::AppConfig;
use crate::database::DatabaseManager;
/// Run the bot polling loop
pub async fn run_polling(
bot: Bot,
db: DatabaseManager,
app_config: AppConfig,
shutdown_rx: oneshot::Receiver<()>,
) {
tracing::info!("Starting Telegram bot polling...");
let handler = dptree::entry()
.branch(
Update::filter_message()
.branch(
dptree::entry()
.filter_command::<Command>()
.endpoint(handlers::handle_command),
)
.branch(dptree::endpoint(handlers::handle_message)),
)
.branch(Update::filter_callback_query().endpoint(handlers::handle_callback_query));
let mut dispatcher = Dispatcher::builder(bot.clone(), handler)
.dependencies(dptree::deps![db, app_config])
.enable_ctrlc_handler()
.build();
// Run dispatcher with shutdown signal
tokio::select! {
_ = dispatcher.dispatch() => {
tracing::info!("Telegram bot polling stopped");
}
_ = shutdown_rx => {
tracing::info!("Telegram bot received shutdown signal");
}
}
}

View File

@@ -0,0 +1,46 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum TelegramError {
#[error("Bot is not configured")]
NotConfigured,
#[error("Bot is not running")]
NotRunning,
#[error("Invalid bot token")]
InvalidToken,
#[error("User not found")]
UserNotFound,
#[error("User is not authorized")]
Unauthorized,
#[error("Database error: {0}")]
Database(String),
#[error("Telegram API error: {0}")]
TelegramApi(String),
#[error("Other error: {0}")]
Other(String),
}
impl From<teloxide::RequestError> for TelegramError {
fn from(err: teloxide::RequestError) -> Self {
Self::TelegramApi(err.to_string())
}
}
impl From<sea_orm::DbErr> for TelegramError {
fn from(err: sea_orm::DbErr) -> Self {
Self::Database(err.to_string())
}
}
impl From<anyhow::Error> for TelegramError {
fn from(err: anyhow::Error) -> Self {
Self::Other(err.to_string())
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,291 @@
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, User};
use teloxide::utils::command::BotCommands;
use super::super::localization::{Language, LocalizationService};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
/// Available bot commands - keeping only admin commands
#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Admin commands:")]
pub enum Command {
#[command(description = "Start the bot")]
Start,
#[command(description = "[Admin] Manage user requests")]
Requests,
#[command(description = "[Admin] Show statistics")]
Stats,
#[command(description = "[Admin] Broadcast message", parse_with = "split")]
Broadcast { message: String },
}
/// Callback data for inline keyboard buttons
#[derive(Debug, Clone)]
pub enum CallbackData {
RequestAccess,
MyConfigs,
SubscriptionLink,
Support,
AdminRequests,
RequestList(u32), // page number
ApproveRequest(String), // request_id
DeclineRequest(String), // request_id
ViewRequest(String), // request_id
ShowServerConfigs(String), // server_name encoded
Back,
BackToConfigs, // Back to configs list from server view
BackToRequests, // Back to requests list from request view
SelectServerAccess(String), // request_id - show server selection after approval
ToggleServer(String, String), // request_id, server_id - toggle server selection
ApplyServerAccess(String), // request_id - apply selected servers
ManageUsers,
UserList(u32), // page number
UserDetails(String), // user_id
UserManageAccess(String), // user_id
UserToggleServer(String, String), // user_id, server_id
UserApplyAccess(String), // user_id
BackToUsers(u32), // page number
BackToMenu,
}
impl CallbackData {
pub fn parse(data: &str) -> Option<Self> {
match data {
"request_access" => Some(CallbackData::RequestAccess),
"my_configs" => Some(CallbackData::MyConfigs),
"subscription_link" => Some(CallbackData::SubscriptionLink),
"support" => Some(CallbackData::Support),
"admin_requests" => Some(CallbackData::AdminRequests),
"manage_users" => Some(CallbackData::ManageUsers),
"back" => Some(CallbackData::Back),
"back_to_configs" => Some(CallbackData::BackToConfigs),
"back_to_requests" => Some(CallbackData::BackToRequests),
"back_to_menu" => Some(CallbackData::BackToMenu),
_ => {
if let Some(id) = data.strip_prefix("approve:") {
Some(CallbackData::ApproveRequest(id.to_string()))
} else if let Some(id) = data.strip_prefix("decline:") {
Some(CallbackData::DeclineRequest(id.to_string()))
} else if let Some(id) = data.strip_prefix("view_request:") {
Some(CallbackData::ViewRequest(id.to_string()))
} else if let Some(server_name) = data.strip_prefix("server_configs:") {
Some(CallbackData::ShowServerConfigs(server_name.to_string()))
} else if let Some(short_id) = data.strip_prefix("s:") {
get_full_request_id(short_id).map(CallbackData::SelectServerAccess)
} else if let Some(rest) = data.strip_prefix("t:") {
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() == 2 {
if let (Some(request_id), Some(server_id)) =
(get_full_request_id(parts[0]), get_full_server_id(parts[1]))
{
Some(CallbackData::ToggleServer(request_id, server_id))
} else {
None
}
} else {
None
}
} else if let Some(short_id) = data.strip_prefix("a:") {
get_full_request_id(short_id).map(CallbackData::ApplyServerAccess)
} else if let Some(page_str) = data.strip_prefix("request_list:") {
page_str.parse::<u32>().ok().map(CallbackData::RequestList)
} else if let Some(page_str) = data.strip_prefix("user_list:") {
page_str.parse::<u32>().ok().map(CallbackData::UserList)
} else if let Some(short_user_id) = data.strip_prefix("user_details:") {
get_full_user_id(short_user_id).map(CallbackData::UserDetails)
} else if let Some(short_user_id) = data.strip_prefix("user_manage:") {
get_full_user_id(short_user_id).map(CallbackData::UserManageAccess)
} else if let Some(rest) = data.strip_prefix("user_toggle:") {
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() == 2 {
if let (Some(user_id), Some(server_id)) =
(get_full_user_id(parts[0]), get_full_server_id(parts[1]))
{
Some(CallbackData::UserToggleServer(user_id, server_id))
} else {
None
}
} else {
None
}
} else if let Some(short_user_id) = data.strip_prefix("user_apply:") {
get_full_user_id(short_user_id).map(CallbackData::UserApplyAccess)
} else if let Some(page_str) = data.strip_prefix("back_users:") {
page_str.parse::<u32>().ok().map(CallbackData::BackToUsers)
} else {
None
}
}
}
}
}
// Global storage for selected servers per request
static SELECTED_SERVERS: OnceLock<Arc<Mutex<HashMap<String, Vec<String>>>>> = OnceLock::new();
// Global storage for request ID mappings (short ID -> full UUID)
static REQUEST_ID_MAP: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new();
static REQUEST_COUNTER: OnceLock<Arc<Mutex<u32>>> = OnceLock::new();
// Global storage for server ID mappings (short ID -> full UUID)
static SERVER_ID_MAP: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new();
static SERVER_COUNTER: OnceLock<Arc<Mutex<u32>>> = OnceLock::new();
// Global storage for user ID mappings (short ID -> full UUID)
static USER_ID_MAP: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new();
static USER_COUNTER: OnceLock<Arc<Mutex<u32>>> = OnceLock::new();
pub fn get_selected_servers() -> &'static Arc<Mutex<HashMap<String, Vec<String>>>> {
SELECTED_SERVERS.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
pub fn get_request_id_map() -> &'static Arc<Mutex<HashMap<String, String>>> {
REQUEST_ID_MAP.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
pub fn get_request_counter() -> &'static Arc<Mutex<u32>> {
REQUEST_COUNTER.get_or_init(|| Arc::new(Mutex::new(0)))
}
pub fn get_server_id_map() -> &'static Arc<Mutex<HashMap<String, String>>> {
SERVER_ID_MAP.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
pub fn get_server_counter() -> &'static Arc<Mutex<u32>> {
SERVER_COUNTER.get_or_init(|| Arc::new(Mutex::new(0)))
}
pub fn get_user_id_map() -> &'static Arc<Mutex<HashMap<String, String>>> {
USER_ID_MAP.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
pub fn get_user_counter() -> &'static Arc<Mutex<u32>> {
USER_COUNTER.get_or_init(|| Arc::new(Mutex::new(0)))
}
/// Generate a short ID for a request UUID and store the mapping
pub fn generate_short_request_id(request_uuid: &str) -> String {
let mut counter = get_request_counter().lock().unwrap();
let mut map = get_request_id_map().lock().unwrap();
// Check if we already have a short ID for this UUID
for (short_id, uuid) in map.iter() {
if uuid == request_uuid {
return short_id.clone();
}
}
// Generate new short ID
*counter += 1;
let short_id = format!("r{}", counter);
map.insert(short_id.clone(), request_uuid.to_string());
short_id
}
/// Get full UUID from short ID
pub fn get_full_request_id(short_id: &str) -> Option<String> {
let map = get_request_id_map().lock().unwrap();
map.get(short_id).cloned()
}
/// Generate a short ID for a server UUID and store the mapping
pub fn generate_short_server_id(server_uuid: &str) -> String {
let mut counter = get_server_counter().lock().unwrap();
let mut map = get_server_id_map().lock().unwrap();
// Check if we already have a short ID for this UUID
for (short_id, uuid) in map.iter() {
if uuid == server_uuid {
return short_id.clone();
}
}
// Generate new short ID
*counter += 1;
let short_id = format!("s{}", counter);
map.insert(short_id.clone(), server_uuid.to_string());
short_id
}
/// Get full server UUID from short ID
pub fn get_full_server_id(short_id: &str) -> Option<String> {
let map = get_server_id_map().lock().unwrap();
map.get(short_id).cloned()
}
/// Generate a short ID for a user UUID and store the mapping
pub fn generate_short_user_id(user_uuid: &str) -> String {
let mut counter = get_user_counter().lock().unwrap();
let mut map = get_user_id_map().lock().unwrap();
// Check if we already have a short ID for this UUID
for (short_id, uuid) in map.iter() {
if uuid == user_uuid {
return short_id.clone();
}
}
// Generate new short ID
*counter += 1;
let short_id = format!("u{}", counter);
map.insert(short_id.clone(), user_uuid.to_string());
short_id
}
/// Get full user UUID from short ID
pub fn get_full_user_id(short_id: &str) -> Option<String> {
let map = get_user_id_map().lock().unwrap();
map.get(short_id).cloned()
}
/// Helper function to get user language from Telegram user data
pub fn get_user_language(user: &User) -> Language {
Language::from_telegram_code(user.language_code.as_deref())
}
/// Main keyboard for registered users
pub fn get_main_keyboard(is_admin: bool, lang: Language) -> InlineKeyboardMarkup {
let l10n = LocalizationService::new();
let mut keyboard = vec![
vec![InlineKeyboardButton::callback(
l10n.get(lang.clone(), "subscription_link"),
"subscription_link",
)],
vec![InlineKeyboardButton::callback(
l10n.get(lang.clone(), "my_configs"),
"my_configs",
)],
vec![InlineKeyboardButton::callback(
l10n.get(lang.clone(), "support"),
"support",
)],
];
if is_admin {
keyboard.push(vec![InlineKeyboardButton::callback(
l10n.get(lang.clone(), "user_requests"),
"admin_requests",
)]);
keyboard.push(vec![InlineKeyboardButton::callback(
l10n.get(lang, "manage_users"),
"manage_users",
)]);
}
InlineKeyboardMarkup::new(keyboard)
}
/// Keyboard for new users
pub fn get_new_user_keyboard(lang: Language) -> InlineKeyboardMarkup {
let l10n = LocalizationService::new();
InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback(
l10n.get(lang, "get_vpn_access"),
"request_access",
)]])
}

View File

@@ -0,0 +1,872 @@
use base64::{engine::general_purpose, Engine};
use teloxide::{
prelude::*,
types::{InlineKeyboardButton, InlineKeyboardMarkup},
};
use super::super::localization::{Language, LocalizationService};
use super::types::{get_main_keyboard, get_new_user_keyboard, get_user_language};
use crate::database::entities::user_request::CreateUserRequestDto;
use crate::database::repository::{UserRepository, UserRequestRepository};
use crate::database::DatabaseManager;
/// Handle start command and main menu
pub async fn handle_start(
bot: Bot,
chat_id: ChatId,
telegram_id: i64,
from: &teloxide::types::User,
user_repo: &UserRepository,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
handle_start_impl(bot, chat_id, telegram_id, from, user_repo, db, None, None).await
}
/// Handle start with message editing support
pub async fn handle_start_edit(
bot: Bot,
q: &CallbackQuery,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let from = &q.from;
let telegram_id = from.id.0 as i64;
let user_repo = UserRepository::new(db.connection());
if let Some(msg) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
let chat_id = regular_msg.chat.id;
handle_start_impl(
bot.clone(),
chat_id,
telegram_id,
from,
&user_repo,
db,
Some(regular_msg.id),
Some(q.id.clone()),
)
.await?;
}
}
Ok(())
}
/// Internal implementation of handle_start with optional message editing
async fn handle_start_impl(
bot: Bot,
chat_id: ChatId,
telegram_id: i64,
from: &teloxide::types::User,
user_repo: &UserRepository,
db: &DatabaseManager,
edit_message_id: Option<teloxide::types::MessageId>,
callback_query_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let lang = get_user_language(from);
let l10n = LocalizationService::new();
// Check if user exists in our database
match user_repo.get_by_telegram_id(telegram_id).await {
Ok(Some(user)) => {
// Check if user is admin
let is_admin = user_repo
.is_telegram_id_admin(telegram_id)
.await
.unwrap_or(false);
// Check if user has any pending requests
let request_repo = UserRequestRepository::new(db.connection().clone());
// Check for existing requests
if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await {
if let Some(latest_request) = existing_requests
.into_iter()
.filter(|r| {
r.status == "pending" || r.status == "approved" || r.status == "declined"
})
.max_by_key(|r| r.created_at)
{
match latest_request.status.as_str() {
"pending" => {
let message = l10n.format(
lang.clone(),
"request_pending",
&[
("status", "⏳ pending"),
(
"date",
&latest_request
.created_at
.format("%Y-%m-%d %H:%M UTC")
.to_string(),
),
],
);
let keyboard = get_new_user_keyboard(lang);
if let Some(msg_id) = edit_message_id {
bot.edit_message_text(chat_id, msg_id, message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard)
.await?;
if let Some(cb_id) = callback_query_id {
bot.answer_callback_query(cb_id).await?;
}
} else {
bot.send_message(chat_id, message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard)
.await?;
}
return Ok(());
}
"declined" => {
let message = l10n.format(
lang.clone(),
"request_pending",
&[
("status", &l10n.get(lang.clone(), "request_declined_status")),
(
"date",
&latest_request
.created_at
.format("%Y-%m-%d %H:%M UTC")
.to_string(),
),
],
);
let keyboard = get_new_user_keyboard(lang);
if let Some(msg_id) = edit_message_id {
bot.edit_message_text(chat_id, msg_id, message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard)
.await?;
if let Some(cb_id) = callback_query_id {
bot.answer_callback_query(cb_id).await?;
}
} else {
bot.send_message(chat_id, message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard)
.await?;
}
return Ok(());
}
_ => {} // approved - continue with normal flow
}
}
}
// Existing user - show main menu
let message = l10n.format(lang.clone(), "welcome_back", &[("name", &user.name)]);
let keyboard = get_main_keyboard(is_admin, lang);
if let Some(msg_id) = edit_message_id {
bot.edit_message_text(chat_id, msg_id, message)
.reply_markup(keyboard)
.await?;
if let Some(cb_id) = callback_query_id {
bot.answer_callback_query(cb_id).await?;
}
} else {
bot.send_message(chat_id, message)
.reply_markup(keyboard)
.await?;
}
}
Ok(None) => {
// New user - show access request
let username = from.username.as_deref().unwrap_or("Unknown");
let message = l10n.format(lang.clone(), "welcome_new_user", &[("username", username)]);
let keyboard = get_new_user_keyboard(lang);
if let Some(msg_id) = edit_message_id {
bot.edit_message_text(chat_id, msg_id, message)
.reply_markup(keyboard)
.await?;
if let Some(cb_id) = callback_query_id {
bot.answer_callback_query(cb_id).await?;
}
} else {
bot.send_message(chat_id, message)
.reply_markup(keyboard)
.await?;
}
}
Err(e) => {
tracing::error!("Database error: {}", e);
bot.send_message(chat_id, "Database error occurred").await?;
}
}
Ok(())
}
/// Handle access request
pub async fn handle_request_access(
bot: Bot,
q: &CallbackQuery,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let from = &q.from;
let lang = get_user_language(from);
let l10n = LocalizationService::new();
let telegram_id = from.id.0 as i64;
let chat_id = q
.message
.as_ref()
.and_then(|m| match m {
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
_ => None,
})
.ok_or("No chat ID")?;
let user_repo = UserRepository::new(db.connection());
let request_repo = UserRequestRepository::new(db.connection().clone());
// Check if user already exists
if let Some(_) = user_repo
.get_by_telegram_id(telegram_id)
.await
.unwrap_or(None)
{
bot.answer_callback_query(q.id.clone())
.text(l10n.get(lang, "already_approved"))
.await?;
return Ok(());
}
// Check for existing requests
if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await {
if let Some(latest_request) = existing_requests
.iter()
.filter(|r| r.status == "pending")
.max_by_key(|r| r.created_at)
{
// Show pending status in the message instead of just an alert
let message = l10n.format(
lang.clone(),
"request_pending",
&[
("status", "⏳ pending"),
(
"date",
&latest_request
.created_at
.format("%Y-%m-%d %H:%M UTC")
.to_string(),
),
],
);
if let Some(message_ref) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message_ref {
let _ = bot
.edit_message_text(chat_id, msg.id, message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback(l10n.get(lang, "back"), "back"),
]]))
.await;
}
}
bot.answer_callback_query(q.id.clone()).await?;
return Ok(());
}
// Check for declined requests - allow new request after decline
let _has_declined = existing_requests.iter().any(|r| r.status == "declined");
}
// Create new access request
let dto = CreateUserRequestDto {
telegram_id,
telegram_first_name: Some(from.first_name.clone()),
telegram_last_name: from.last_name.clone(),
telegram_username: from.username.clone(),
request_message: Some("Access request via Telegram bot".to_string()),
language: lang.code().to_string(),
};
match request_repo.create(dto).await {
Ok(request) => {
// Edit message to show success
if let Some(message) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message {
let _ = bot
.edit_message_text(
chat_id,
msg.id,
l10n.get(lang.clone(), "request_submitted"),
)
.reply_markup(InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback(l10n.get(lang, "back"), "back"),
]]))
.await;
}
}
// Notify admins
notify_admins_new_request(&bot, &request, db).await?;
bot.answer_callback_query(q.id.clone()).await?;
}
Err(e) => {
tracing::error!("Failed to create request: {}", e);
bot.answer_callback_query(q.id.clone())
.text(l10n.format(lang, "request_submit_failed", &[("error", &e.to_string())]))
.await?;
}
}
Ok(())
}
/// Handle my configs with message editing
pub async fn handle_my_configs_edit(
bot: Bot,
q: &CallbackQuery,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let from = &q.from;
let lang = get_user_language(from);
let l10n = LocalizationService::new();
let telegram_id = from.id.0 as i64;
let chat_id = q
.message
.as_ref()
.and_then(|m| match m {
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
_ => None,
})
.ok_or("No chat ID")?;
let user_repo = UserRepository::new(db.connection());
let inbound_users_repo =
crate::database::repository::InboundUsersRepository::new(db.connection().clone());
let uri_service = crate::services::UriGeneratorService::new();
if let Some(user) = user_repo
.get_by_telegram_id(telegram_id)
.await
.unwrap_or(None)
{
// Get all active inbound users for this user
let inbound_users = inbound_users_repo
.find_by_user_id(user.id)
.await
.unwrap_or_default();
if inbound_users.is_empty() {
// Edit message to show no configs available
if let Some(msg) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
bot.edit_message_text(
chat_id,
regular_msg.id,
l10n.get(lang.clone(), "no_configs_available"),
)
.reply_markup(InlineKeyboardMarkup::new(vec![vec![
InlineKeyboardButton::callback(l10n.get(lang, "back"), "back"),
]]))
.await?;
}
}
bot.answer_callback_query(q.id.clone()).await?;
return Ok(());
}
// Structure to hold config with inbound_id
#[derive(Debug, Clone)]
struct ConfigWithInbound {
client_config: crate::services::uri_generator::ClientConfig,
server_inbound_id: uuid::Uuid,
}
// Group configurations by server name
let mut servers: std::collections::HashMap<String, Vec<ConfigWithInbound>> =
std::collections::HashMap::new();
for inbound_user in inbound_users {
if !inbound_user.is_active {
continue;
}
// Get client config data for this specific inbound
if let Ok(Some(config_data)) = inbound_users_repo
.get_client_config_data(user.id, inbound_user.server_inbound_id)
.await
{
match uri_service.generate_client_config(user.id, &config_data) {
Ok(client_config) => {
let config_with_inbound = ConfigWithInbound {
client_config: client_config.clone(),
server_inbound_id: inbound_user.server_inbound_id,
};
servers
.entry(client_config.server_name.clone())
.or_insert_with(Vec::new)
.push(config_with_inbound);
}
Err(e) => {
tracing::warn!("Failed to generate client config: {}", e);
continue;
}
}
}
}
// Build message with statistics only
let mut message_lines = vec![l10n.get(lang.clone(), "your_configurations")];
// Calculate statistics
let server_count = servers.len();
let total_configs = servers.values().map(|configs| configs.len()).sum::<usize>();
// Count unique protocols
let mut protocols = std::collections::HashSet::new();
for configs in servers.values() {
for config_with_inbound in configs {
protocols.insert(config_with_inbound.client_config.protocol.clone());
}
}
let server_word = match lang {
Language::Russian => {
if server_count == 1 {
"сервер"
} else if server_count < 5 {
"сервера"
} else {
"серверов"
}
}
Language::English => {
if server_count == 1 {
"server"
} else {
"servers"
}
}
};
let config_word = match lang {
Language::Russian => {
if total_configs == 1 {
"конфигурация"
} else if total_configs < 5 {
"конфигурации"
} else {
"конфигураций"
}
}
Language::English => {
if total_configs == 1 {
"configuration"
} else {
"configurations"
}
}
};
let protocol_word = match lang {
Language::Russian => {
if protocols.len() == 1 {
"протокол"
} else if protocols.len() < 5 {
"протокола"
} else {
"протоколов"
}
}
Language::English => {
if protocols.len() == 1 {
"protocol"
} else {
"protocols"
}
}
};
message_lines.push(format!(
"\n📊 {} {}{} {}{} {}",
server_count,
server_word,
total_configs,
config_word,
protocols.len(),
protocol_word
));
// Create keyboard with buttons for each server
let mut keyboard_buttons = vec![];
for (server_name, configs) in servers.iter() {
// Encode server name to avoid issues with special characters
let encoded_server_name = general_purpose::STANDARD.encode(server_name.as_bytes());
let config_count = configs.len();
let config_suffix = match lang {
Language::Russian => {
if config_count == 1 {
""
} else if config_count < 5 {
"а"
} else {
"ов"
}
}
Language::English => {
if config_count == 1 {
""
} else {
"s"
}
}
};
let config_word = match lang {
Language::Russian => "конфиг",
Language::English => "config",
};
keyboard_buttons.push(vec![InlineKeyboardButton::callback(
format!(
"🖥️ {} ({} {}{})",
server_name, config_count, config_word, config_suffix
),
format!("server_configs:{}", encoded_server_name),
)]);
}
keyboard_buttons.push(vec![InlineKeyboardButton::callback(
l10n.get(lang, "back"),
"back",
)]);
let message = message_lines.join("\n");
// Edit the existing message instead of sending a new one
if let Some(msg) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
bot.edit_message_text(chat_id, regular_msg.id, message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(InlineKeyboardMarkup::new(keyboard_buttons))
.await?;
}
}
bot.answer_callback_query(q.id.clone()).await?;
}
Ok(())
}
/// Handle show server configs callback
pub async fn handle_show_server_configs(
bot: Bot,
q: &CallbackQuery,
encoded_server_name: &str,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let from = &q.from;
let lang = get_user_language(from);
let l10n = LocalizationService::new();
let telegram_id = from.id.0 as i64;
let chat_id = q
.message
.as_ref()
.and_then(|m| match m {
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
_ => None,
})
.ok_or("No chat ID")?;
// Decode server name
let server_name = match general_purpose::STANDARD.decode(encoded_server_name) {
Ok(bytes) => String::from_utf8(bytes).map_err(|_| "Invalid server name encoding")?,
Err(_) => return Ok(()), // Invalid encoding, ignore
};
let user_repo = UserRepository::new(db.connection());
let inbound_users_repo =
crate::database::repository::InboundUsersRepository::new(db.connection().clone());
let uri_service = crate::services::UriGeneratorService::new();
// Get user from telegram_id
if let Some(user) = user_repo
.get_by_telegram_id(telegram_id)
.await
.unwrap_or(None)
{
// Get all active inbound users for this user
let inbound_users = inbound_users_repo
.find_by_user_id(user.id)
.await
.unwrap_or_default();
let mut server_configs = Vec::new();
for inbound_user in inbound_users {
if !inbound_user.is_active {
continue;
}
// Get client config data for this specific inbound
if let Ok(Some(config_data)) = inbound_users_repo
.get_client_config_data(user.id, inbound_user.server_inbound_id)
.await
{
if config_data.server_name == server_name {
match uri_service.generate_client_config(user.id, &config_data) {
Ok(client_config) => {
server_configs.push(client_config);
}
Err(e) => {
tracing::warn!("Failed to generate client config: {}", e);
continue;
}
}
}
}
}
if server_configs.is_empty() {
bot.answer_callback_query(q.id.clone())
.text(l10n.get(lang, "config_not_found"))
.await?;
return Ok(());
}
// Build message with all configs for this server
let mut message_lines = vec![l10n.format(
lang.clone(),
"server_configs_title",
&[("server_name", &server_name)],
)];
for config in &server_configs {
let protocol_emoji = match config.protocol.as_str() {
"vless" => "🔵",
"vmess" => "🟢",
"trojan" => "🔴",
"shadowsocks" => "🟡",
_ => "",
};
message_lines.push(format!(
"\n{} <b>{} - {}</b> ({})",
protocol_emoji,
config.server_name,
config.template_name,
config.protocol.to_uppercase()
));
message_lines.push(format!("<code>{}</code>", config.uri));
}
// Create back button
let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback(
l10n.get(lang, "back"),
"back_to_configs",
)]]);
let message = message_lines.join("\n");
// Edit the existing message instead of sending a new one
if let Some(msg) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
bot.edit_message_text(chat_id, regular_msg.id, message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard)
.await?;
}
}
bot.answer_callback_query(q.id.clone()).await?;
} else {
bot.answer_callback_query(q.id.clone())
.text(l10n.get(lang, "unauthorized"))
.await?;
}
Ok(())
}
/// Handle support button
pub async fn handle_support(
bot: Bot,
q: &CallbackQuery,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let from = &q.from;
let lang = get_user_language(from);
let l10n = LocalizationService::new();
let chat_id = q
.message
.as_ref()
.and_then(|m| match m {
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
_ => None,
})
.ok_or("No chat ID")?;
let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback(
l10n.get(lang.clone(), "back"),
"back",
)]]);
// Edit the existing message instead of sending a new one
if let Some(msg) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang, "support_info"))
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard)
.await?;
}
}
bot.answer_callback_query(q.id.clone()).await?;
Ok(())
}
/// Notify admins about new access request
async fn notify_admins_new_request(
bot: &Bot,
request: &crate::database::entities::user_request::Model,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let user_repo = UserRepository::new(db.connection());
// Get all admins
let admins = user_repo.get_telegram_admins().await.unwrap_or_default();
if !admins.is_empty() {
let lang = Language::English; // Default admin language
let l10n = LocalizationService::new();
let message = l10n.format(
lang.clone(),
"new_access_request",
&[
(
"first_name",
&request.telegram_first_name.as_deref().unwrap_or(""),
),
(
"last_name",
&request.telegram_last_name.as_deref().unwrap_or(""),
),
(
"username",
&request.telegram_username.as_deref().unwrap_or("unknown"),
),
],
);
let keyboard = InlineKeyboardMarkup::new(vec![
vec![
InlineKeyboardButton::callback(
l10n.get(lang.clone(), "approve"),
format!("approve:{}", request.id),
),
InlineKeyboardButton::callback(
l10n.get(lang.clone(), "decline"),
format!("decline:{}", request.id),
),
],
vec![InlineKeyboardButton::callback(
"📋 All Requests",
"back_to_requests",
)],
]);
for admin in admins {
if let Some(telegram_id) = admin.telegram_id {
let _ = bot
.send_message(ChatId(telegram_id), &message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard.clone())
.await;
}
}
}
Ok(())
}
/// Handle subscription link request
pub async fn handle_subscription_link(
bot: Bot,
q: &CallbackQuery,
db: &DatabaseManager,
app_config: &crate::config::AppConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let from = q.from.clone();
let telegram_id = from.id.0 as i64;
let lang = get_user_language(&from);
let l10n = LocalizationService::new();
// Get user from database
let user_repo = UserRepository::new(db.connection());
if let Ok(Some(user)) = user_repo.get_by_telegram_id(telegram_id).await {
// Generate subscription URL
let subscription_url = format!("{}/sub/{}", app_config.web.base_url, user.id);
let message = match lang {
Language::Russian => {
format!(
"🔗 <b>Ваша ссылка подписки</b>\n\n\
Скопируйте эту ссылку и добавьте её в ваш VPN-клиент:\n\n\
<code>{}</code>\n\n\
💡 <i>Эта ссылка содержит все ваши конфигурации и автоматически обновляется при изменениях</i>",
subscription_url
)
}
Language::English => {
format!(
"🔗 <b>Your Subscription Link</b>\n\n\
Copy this link and add it to your VPN client:\n\n\
<code>{}</code>\n\n\
💡 <i>This link contains all your configurations and updates automatically when changes are made</i>",
subscription_url
)
}
};
let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback(
l10n.get(lang, "back"),
"back",
)]]);
// Edit the existing message
if let Some(msg) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
let chat_id = regular_msg.chat.id;
bot.edit_message_text(chat_id, regular_msg.id, message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard)
.await?;
}
}
} else {
// User not found - this shouldn't happen for registered users
bot.answer_callback_query(q.id.clone())
.text("User not found")
.await?;
return Ok(());
}
bot.answer_callback_query(q.id.clone()).await?;
Ok(())
}

View File

@@ -0,0 +1,373 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Language {
Russian,
English,
}
impl Language {
pub fn from_telegram_code(code: Option<&str>) -> Self {
match code {
Some("ru") | Some("by") | Some("kk") | Some("uk") => Self::Russian,
_ => Self::English, // Default to English
}
}
pub fn code(&self) -> &'static str {
match self {
Self::Russian => "ru",
Self::English => "en",
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Translations {
pub welcome_new_user: String,
pub welcome_back: String,
pub request_pending: String,
pub request_approved_status: String,
pub request_declined_status: String,
pub get_vpn_access: String,
pub my_configs: String,
pub support: String,
pub user_requests: String,
pub back: String,
pub approve: String,
pub decline: String,
// Request handling
pub already_pending: String,
pub already_approved: String,
pub already_declined: String,
pub request_submitted: String,
pub request_submit_failed: String,
// Approval/Decline messages
pub request_approved: String,
pub request_declined: String,
pub request_approved_notification: String,
pub request_declined_notification: String,
// Admin messages
pub new_access_request: String,
pub no_pending_requests: String,
pub access_request_details: String,
pub unauthorized: String,
pub request_approved_admin: String,
pub request_declined_admin: String,
pub user_creation_failed: String,
// Support
pub support_info: String,
// Stats
pub statistics: String,
pub total_users: String,
pub total_servers: String,
pub total_inbounds: String,
pub pending_requests: String,
// Broadcast
pub broadcast_complete: String,
pub sent: String,
pub failed: String,
// Configs
pub configs_coming_soon: String,
pub your_configurations: String,
pub no_configs_available: String,
pub config_copy_message: String,
pub config_copied: String,
pub config_not_found: String,
pub server_configs_title: String,
// Subscription
pub subscription_link: String,
// User Management
pub manage_users: String,
pub user_list: String,
pub user_details: String,
pub manage_access: String,
pub remove_access: String,
pub grant_access: String,
pub user_info: String,
pub no_users_found: String,
pub page_info: String,
pub next_page: String,
pub prev_page: String,
pub back_to_users: String,
pub back_to_menu: String,
pub access_updated: String,
pub access_removed: String,
pub access_granted: String,
// Errors
pub error_occurred: String,
pub admin_not_found: String,
pub request_not_found: String,
pub invalid_request_id: String,
}
pub struct LocalizationService {
translations: HashMap<Language, Translations>,
}
impl LocalizationService {
pub fn new() -> Self {
let mut translations = HashMap::new();
// Load English translations
translations.insert(Language::English, Self::load_english());
// Load Russian translations
translations.insert(Language::Russian, Self::load_russian());
Self { translations }
}
pub fn get(&self, lang: Language, key: &str) -> String {
let translations = self
.translations
.get(&lang)
.unwrap_or_else(|| self.translations.get(&Language::English).unwrap());
match key {
"welcome_new_user" => translations.welcome_new_user.clone(),
"welcome_back" => translations.welcome_back.clone(),
"request_pending" => translations.request_pending.clone(),
"request_approved_status" => translations.request_approved_status.clone(),
"request_declined_status" => translations.request_declined_status.clone(),
"get_vpn_access" => translations.get_vpn_access.clone(),
"my_configs" => translations.my_configs.clone(),
"support" => translations.support.clone(),
"user_requests" => translations.user_requests.clone(),
"back" => translations.back.clone(),
"approve" => translations.approve.clone(),
"decline" => translations.decline.clone(),
"already_pending" => translations.already_pending.clone(),
"already_approved" => translations.already_approved.clone(),
"already_declined" => translations.already_declined.clone(),
"request_submitted" => translations.request_submitted.clone(),
"request_submit_failed" => translations.request_submit_failed.clone(),
"request_approved" => translations.request_approved.clone(),
"request_declined" => translations.request_declined.clone(),
"request_approved_notification" => translations.request_approved_notification.clone(),
"request_declined_notification" => translations.request_declined_notification.clone(),
"new_access_request" => translations.new_access_request.clone(),
"no_pending_requests" => translations.no_pending_requests.clone(),
"access_request_details" => translations.access_request_details.clone(),
"unauthorized" => translations.unauthorized.clone(),
"request_approved_admin" => translations.request_approved_admin.clone(),
"request_declined_admin" => translations.request_declined_admin.clone(),
"user_creation_failed" => translations.user_creation_failed.clone(),
"support_info" => translations.support_info.clone(),
"statistics" => translations.statistics.clone(),
"total_users" => translations.total_users.clone(),
"total_servers" => translations.total_servers.clone(),
"total_inbounds" => translations.total_inbounds.clone(),
"pending_requests" => translations.pending_requests.clone(),
"broadcast_complete" => translations.broadcast_complete.clone(),
"sent" => translations.sent.clone(),
"failed" => translations.failed.clone(),
"configs_coming_soon" => translations.configs_coming_soon.clone(),
"your_configurations" => translations.your_configurations.clone(),
"no_configs_available" => translations.no_configs_available.clone(),
"config_copy_message" => translations.config_copy_message.clone(),
"config_copied" => translations.config_copied.clone(),
"config_not_found" => translations.config_not_found.clone(),
"server_configs_title" => translations.server_configs_title.clone(),
"subscription_link" => translations.subscription_link.clone(),
"manage_users" => translations.manage_users.clone(),
"user_list" => translations.user_list.clone(),
"user_details" => translations.user_details.clone(),
"manage_access" => translations.manage_access.clone(),
"remove_access" => translations.remove_access.clone(),
"grant_access" => translations.grant_access.clone(),
"user_info" => translations.user_info.clone(),
"no_users_found" => translations.no_users_found.clone(),
"page_info" => translations.page_info.clone(),
"next_page" => translations.next_page.clone(),
"prev_page" => translations.prev_page.clone(),
"back_to_users" => translations.back_to_users.clone(),
"back_to_menu" => translations.back_to_menu.clone(),
"access_updated" => translations.access_updated.clone(),
"access_removed" => translations.access_removed.clone(),
"access_granted" => translations.access_granted.clone(),
"error_occurred" => translations.error_occurred.clone(),
"admin_not_found" => translations.admin_not_found.clone(),
"request_not_found" => translations.request_not_found.clone(),
"invalid_request_id" => translations.invalid_request_id.clone(),
_ => format!("Missing translation: {}", key),
}
}
pub fn format(&self, lang: Language, template: &str, args: &[(&str, &str)]) -> String {
let mut result = self.get(lang, template);
for (placeholder, value) in args {
result = result.replace(&format!("{{{}}}", placeholder), value);
}
result
}
fn load_english() -> Translations {
Translations {
welcome_new_user: "👋 Welcome, {username}!\n\nI'm the OutFleet VPN bot. To get started, you'll need to request access.\n\nClick the button below to submit your access request:".to_string(),
welcome_back: "👋 Welcome back, {name}!\n\nWhat would you like to do?".to_string(),
request_pending: "👋 Hello!\n\nYour access request is currently <b>{status}</b>.\n\nRequest submitted: {date}".to_string(),
request_approved_status: "✅ approved".to_string(),
request_declined_status: "❌ declined".to_string(),
get_vpn_access: "🚀 Get VPN Access".to_string(),
my_configs: "📋 My Configs".to_string(),
support: "💬 Support".to_string(),
user_requests: "❔ User Requests".to_string(),
back: "🔙 Back".to_string(),
approve: "✅ Approve".to_string(),
decline: "❌ Decline".to_string(),
already_pending: "⏳ You already have a pending access request. Please wait for admin review.".to_string(),
already_approved: "✅ Your access request has already been approved. Use /start to access the main menu.".to_string(),
already_declined: "❌ Your previous access request was declined. Please contact administrators if you believe this is a mistake.".to_string(),
request_submitted: "✅ Your access request has been submitted!\n\nAn administrator will review your request soon. You'll receive a notification once it's processed.".to_string(),
request_submit_failed: "❌ Failed to submit request: {error}".to_string(),
request_approved: "✅ Request approved".to_string(),
request_declined: "❌ Request declined".to_string(),
request_approved_notification: "🎉 <b>Your access request has been approved!</b>\n\nWelcome to OutFleet VPN! Your account has been created.\n\nUser ID: <code>{user_id}</code>\n\nYou can now use /start to access the main menu.".to_string(),
request_declined_notification: "❌ Your access request has been declined.\n\nIf you believe this is a mistake, please contact the administrators.".to_string(),
new_access_request: "🔔 <b>New Access Request</b>\n\n👤 Name: {first_name} {last_name}\n🆔 Username: @{username}\n\nUse /requests to review".to_string(),
no_pending_requests: "No pending access requests".to_string(),
access_request_details: "❔ <b>Access Request</b>\n\n👤 Name: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Requested: {date}\n\nMessage: {message}".to_string(),
unauthorized: "❌ You are not authorized to use this command".to_string(),
request_approved_admin: "✅ Request approved".to_string(),
request_declined_admin: "❌ Request declined".to_string(),
user_creation_failed: "❌ Failed to create user account: {error}\n\nPlease try again or contact technical support.".to_string(),
support_info: "💬 <b>Support Information</b>\n\n📱 <b>How to connect:</b>\n1. Download v2raytun app for Android or iOS from:\n https://v2raytun.com/\n\n2. Add your subscription link from \"🔗 Subscription Link\" menu\n OR\n Add individual server links from \"📋 My Configs\"\n\n3. Connect and enjoy secure VPN!\n\n❓ If you need help, please contact the administrators.".to_string(),
statistics: "📊 <b>Statistics</b>\n\n👥 Total Users: {users}\n🖥️ Total Servers: {servers}\n📡 Total Inbounds: {inbounds}\n⏳ Pending Requests: {pending}".to_string(),
total_users: "👥 Total Users".to_string(),
total_servers: "🖥️ Total Servers".to_string(),
total_inbounds: "📡 Total Inbounds".to_string(),
pending_requests: "⏳ Pending Requests".to_string(),
broadcast_complete: "✅ Broadcast complete\nSent: {sent}\nFailed: {failed}".to_string(),
sent: "Sent".to_string(),
failed: "Failed".to_string(),
configs_coming_soon: "📋 Your configurations will be shown here (coming soon)".to_string(),
your_configurations: "📋 <b>Your Configurations</b>".to_string(),
no_configs_available: "📋 No configurations available\n\nYou don't have access to any VPN configurations yet. Please contact an administrator to get access.".to_string(),
config_copy_message: "📋 <b>{server_name}</b> - {inbound_tag} ({protocol})\n\nConnection URI:".to_string(),
config_copied: "✅ Configuration copied to clipboard".to_string(),
config_not_found: "❌ Configuration not found".to_string(),
server_configs_title: "🖥️ <b>{server_name}</b> - Connection Links".to_string(),
subscription_link: "🔗 Subscription Link".to_string(),
manage_users: "👥 Manage Users".to_string(),
user_list: "👥 User List".to_string(),
user_details: "👤 User Details".to_string(),
manage_access: "🔧 Manage Access".to_string(),
remove_access: "❌ Remove Access".to_string(),
grant_access: "✅ Grant Access".to_string(),
user_info: "User Information".to_string(),
no_users_found: "No users found".to_string(),
page_info: "Page {page} of {total}".to_string(),
next_page: "Next →".to_string(),
prev_page: "← Previous".to_string(),
back_to_users: "👥 Back to Users".to_string(),
back_to_menu: "🏠 Main Menu".to_string(),
access_updated: "✅ Access updated successfully".to_string(),
access_removed: "❌ Access removed successfully".to_string(),
access_granted: "✅ Access granted successfully".to_string(),
error_occurred: "An error occurred".to_string(),
admin_not_found: "Admin not found".to_string(),
request_not_found: "Request not found".to_string(),
invalid_request_id: "Invalid request ID".to_string(),
}
}
fn load_russian() -> Translations {
Translations {
welcome_new_user: "👋 Добро пожаловать, {username}!\n\nЯ бот OutFleet VPN. Чтобы начать работу, вам необходимо запросить доступ.\n\nНажмите кнопку ниже, чтобы отправить запрос на доступ:".to_string(),
welcome_back: "👋 С возвращением, {name}!\n\nЧто вы хотите сделать?".to_string(),
request_pending: "👋 Привет!\n\nВаш запрос на доступ в настоящее время <b>{status}</b>.\n\nЗапрос отправлен: {date}".to_string(),
request_approved_status: "✅ одобрен".to_string(),
request_declined_status: "❌ отклонен".to_string(),
get_vpn_access: "🚀 Получить доступ к VPN".to_string(),
my_configs: "📋 Мои конфигурации".to_string(),
support: "💬 Поддержка".to_string(),
user_requests: "❔ Запросы пользователей".to_string(),
back: "🔙 Назад".to_string(),
approve: "✅ Одобрить".to_string(),
decline: "❌ Отклонить".to_string(),
already_pending: "У вас уже есть ожидающий рассмотрения запрос на доступ. Пожалуйста, дождитесь проверки администратором.".to_string(),
already_approved: "✅ Ваш запрос на доступ уже был одобрен. Используйте /start для доступа к главному меню.".to_string(),
already_declined: "❌ Ваш предыдущий запрос на доступ был отклонен. Пожалуйста, свяжитесь с администраторами, если считаете, что это ошибка.".to_string(),
request_submitted: "✅ Ваш запрос на доступ отправлен!\n\nАдминистратор скоро рассмотрит ваш запрос. Вы получите уведомление после обработки.".to_string(),
request_submit_failed: "Не удалось отправить запрос: {error}".to_string(),
request_approved: "✅ Запрос одобрен".to_string(),
request_declined: "❌ Запрос отклонен".to_string(),
request_approved_notification: "🎉 <b>Ваш запрос на доступ одобрен!</b>\n\nДобро пожаловать в OutFleet VPN! Ваш аккаунт создан.\n\nID пользователя: <code>{user_id}</code>\n\nТеперь вы можете использовать /start для доступа к главному меню.".to_string(),
request_declined_notification: "❌ Ваш запрос на доступ отклонен.\n\nЕсли вы считаете, что это ошибка, пожалуйста, свяжитесь с администраторами.".to_string(),
new_access_request: "🔔 <b>Новый запрос на доступ</b>\n\n👤 Имя: {first_name} {last_name}\n🆔 Имя пользователя: @{username}\n\nИспользуйте /requests для просмотра".to_string(),
no_pending_requests: "Нет ожидающих запросов на доступ".to_string(),
access_request_details: "❔ <b>Запрос на доступ</b>\n\n👤 Имя: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Запрошено: {date}\n\nСообщение: {message}".to_string(),
unauthorized: "У вас нет прав для использования этой команды".to_string(),
request_approved_admin: "✅ Запрос одобрен".to_string(),
request_declined_admin: "❌ Запрос отклонен".to_string(),
user_creation_failed: "Не удалось создать аккаунт пользователя: {error}\n\nПожалуйста, попробуйте еще раз или обратитесь в техническую поддержку.".to_string(),
support_info: "💬 <b>Информация о поддержке</b>\n\n📱 <b>Как подключиться:</b>\n1. Скачайте приложение v2raytun для Android или iOS с сайта:\n https://v2raytun.com/\n\n2. Добавьте ссылку подписки из меню \"🔗 Ссылка подписки\"\n ИЛИ\n Добавьте отдельные ссылки серверов из \"📋 Мои конфигурации\"\n\n3. Подключайтесь и наслаждайтесь безопасным VPN!\n\n❓ Если нужна помощь, обратитесь к администраторам.".to_string(),
statistics: "📊 <b>Статистика</b>\n\n👥 Всего пользователей: {users}\n🖥️ Всего серверов: {servers}\n📡 Всего входящих подключений: {inbounds}\n⏳ Ожидающих запросов: {pending}".to_string(),
total_users: "👥 Всего пользователей".to_string(),
total_servers: "🖥️ Всего серверов".to_string(),
total_inbounds: "📡 Всего входящих подключений".to_string(),
pending_requests: "⏳ Ожидающих запросов".to_string(),
broadcast_complete: "✅ Рассылка завершена\nОтправлено: {sent}\nНе удалось: {failed}".to_string(),
sent: "Отправлено".to_string(),
failed: "Не удалось".to_string(),
configs_coming_soon: "📋 Ваши конфигурации будут показаны здесь (скоро)".to_string(),
your_configurations: "📋 <b>Ваши конфигурации</b>".to_string(),
no_configs_available: "📋 Нет доступных конфигураций\n\nУ вас пока нет доступа к конфигурациям VPN. Пожалуйста, обратитесь к администратору для получения доступа.".to_string(),
config_copy_message: "📋 <b>{server_name}</b> - {inbound_tag} ({protocol})\n\nСсылка для подключения:".to_string(),
config_copied: "✅ Конфигурация скопирована в буфер обмена".to_string(),
config_not_found: "❌ Конфигурация не найдена".to_string(),
server_configs_title: "🖥️ <b>{server_name}</b> - Ссылки для подключения".to_string(),
subscription_link: "🔗 Ссылка подписки".to_string(),
manage_users: "👥 Управление пользователями".to_string(),
user_list: "👥 Список пользователей".to_string(),
user_details: "👤 Данные пользователя".to_string(),
manage_access: "🔧 Управление доступом".to_string(),
remove_access: "❌ Убрать доступ".to_string(),
grant_access: "✅ Предоставить доступ".to_string(),
user_info: "Информация о пользователе".to_string(),
no_users_found: "Пользователи не найдены".to_string(),
page_info: "Страница {page} из {total}".to_string(),
next_page: "Далее →".to_string(),
prev_page: "← Назад".to_string(),
back_to_users: "👥 К пользователям".to_string(),
back_to_menu: "🏠 Главное меню".to_string(),
access_updated: "✅ Доступ успешно обновлен".to_string(),
access_removed: "❌ Доступ успешно убран".to_string(),
access_granted: "✅ Доступ успешно предоставлен".to_string(),
error_occurred: "Произошла ошибка".to_string(),
admin_not_found: "Администратор не найден".to_string(),
request_not_found: "Запрос не найден".to_string(),
invalid_request_id: "Неверный ID запроса".to_string(),
}
}
}

View File

@@ -0,0 +1,199 @@
use anyhow::Result;
use std::sync::Arc;
use teloxide::{prelude::*, Bot};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::config::AppConfig;
use crate::database::entities::telegram_config::Model as TelegramConfig;
use crate::database::repository::TelegramConfigRepository;
use crate::database::DatabaseManager;
pub mod bot;
pub mod error;
pub mod handlers;
pub mod localization;
/// Main Telegram service that manages the bot lifecycle
pub struct TelegramService {
db: DatabaseManager,
app_config: AppConfig,
bot: Arc<RwLock<Option<Bot>>>,
config: Arc<RwLock<Option<TelegramConfig>>>,
shutdown_signal: Arc<RwLock<Option<tokio::sync::oneshot::Sender<()>>>>,
}
impl TelegramService {
/// Create a new Telegram service
pub fn new(db: DatabaseManager, app_config: AppConfig) -> Self {
Self {
db,
app_config,
bot: Arc::new(RwLock::new(None)),
config: Arc::new(RwLock::new(None)),
shutdown_signal: Arc::new(RwLock::new(None)),
}
}
/// Initialize and start the bot if active configuration exists
pub async fn initialize(&self) -> Result<()> {
let repo = TelegramConfigRepository::new(self.db.connection());
// Get active configuration
if let Some(config) = repo.get_active().await? {
self.start_with_config(config).await?;
}
Ok(())
}
/// Start bot with specific configuration
pub async fn start_with_config(&self, config: TelegramConfig) -> Result<()> {
// Stop existing bot if running
self.stop().await?;
// Create new bot instance
let bot = Bot::new(&config.bot_token);
// Verify token by calling getMe
match bot.get_me().await {
Ok(me) => {
let username = me.user.username.unwrap_or_default();
tracing::info!("Telegram bot started: @{}", username);
}
Err(e) => {
return Err(anyhow::anyhow!("Invalid bot token: {}", e));
}
}
// Store bot and config
*self.bot.write().await = Some(bot.clone());
*self.config.write().await = Some(config.clone());
// Start polling in background
if config.is_active {
self.start_polling(bot).await?;
}
Ok(())
}
/// Start polling for updates
async fn start_polling(&self, bot: Bot) -> Result<()> {
let (tx, rx) = tokio::sync::oneshot::channel();
*self.shutdown_signal.write().await = Some(tx);
let db = self.db.clone();
let app_config = self.app_config.clone();
// Spawn polling task
tokio::spawn(async move {
bot::run_polling(bot, db, app_config, rx).await;
});
Ok(())
}
/// Stop the bot
pub async fn stop(&self) -> Result<()> {
// Send shutdown signal if polling is running
if let Some(tx) = self.shutdown_signal.write().await.take() {
let _ = tx.send(()); // Ignore error if receiver is already dropped
}
// Clear bot and config
*self.bot.write().await = None;
*self.config.write().await = None;
tracing::info!("Telegram bot stopped");
Ok(())
}
/// Update configuration and restart if needed
pub async fn update_config(&self, config_id: Uuid) -> Result<()> {
let repo = TelegramConfigRepository::new(self.db.connection());
if let Some(config) = repo.find_by_id(config_id).await? {
if config.is_active {
self.start_with_config(config).await?;
} else {
self.stop().await?;
}
}
Ok(())
}
/// Get current bot status
pub async fn get_status(&self) -> BotStatus {
let bot_guard = self.bot.read().await;
let config_guard = self.config.read().await;
BotStatus {
is_running: bot_guard.is_some(),
config: config_guard.clone(),
}
}
/// Send message to user
pub async fn send_message(&self, chat_id: i64, text: String) -> Result<()> {
let bot_guard = self.bot.read().await;
if let Some(bot) = bot_guard.as_ref() {
bot.send_message(ChatId(chat_id), text).await?;
Ok(())
} else {
Err(anyhow::anyhow!("Bot is not running"))
}
}
/// Send message to user with inline keyboard
pub async fn send_message_with_keyboard(
&self,
chat_id: i64,
text: String,
keyboard: teloxide::types::InlineKeyboardMarkup,
) -> Result<()> {
let bot_guard = self.bot.read().await;
if let Some(bot) = bot_guard.as_ref() {
bot.send_message(ChatId(chat_id), text)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard)
.await?;
Ok(())
} else {
Err(anyhow::anyhow!("Bot is not running"))
}
}
/// Send message to all admins
pub async fn broadcast_to_admins(&self, text: String) -> Result<()> {
let bot_guard = self.bot.read().await;
if let Some(bot) = bot_guard.as_ref() {
let user_repo = crate::database::repository::UserRepository::new(self.db.connection());
let admins = user_repo.get_telegram_admins().await?;
for admin in admins {
if let Some(telegram_id) = admin.telegram_id {
if let Err(e) = bot.send_message(ChatId(telegram_id), text.clone()).await {
tracing::warn!("Failed to send message to admin {}: {}", telegram_id, e);
}
}
}
Ok(())
} else {
Err(anyhow::anyhow!("Bot is not running"))
}
}
}
/// Bot status information
#[derive(Debug, Clone, serde::Serialize)]
pub struct BotStatus {
pub is_running: bool,
pub config: Option<TelegramConfig>,
}

View File

@@ -1,30 +1,36 @@
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError};
use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub mod shadowsocks;
pub mod trojan;
pub mod vless;
pub mod vmess;
pub mod trojan;
pub mod shadowsocks;
pub use shadowsocks::ShadowsocksUriBuilder;
pub use trojan::TrojanUriBuilder;
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()));
return Err(UriGeneratorError::MissingRequiredField(
"hostname".to_string(),
));
}
if config.port <= 0 || config.port > 65535 {
return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string()));
return Err(UriGeneratorError::InvalidConfiguration(
"Invalid port number".to_string(),
));
}
if config.xray_user_id.is_empty() {
return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string()));
return Err(UriGeneratorError::MissingRequiredField(
"xray_user_id".to_string(),
));
}
Ok(())
}
@@ -32,28 +38,27 @@ pub trait UriBuilder {
/// Helper functions for URI building
pub mod utils {
use std::collections::HashMap;
use serde_json::Value;
use crate::services::uri_generator::error::UriGeneratorError;
use std::collections::HashMap;
/// 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
@@ -62,7 +67,7 @@ pub mod utils {
.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 {
@@ -75,7 +80,7 @@ pub mod utils {
"none".to_string()
}
}
/// Extract WebSocket path from stream settings
pub fn extract_ws_path(stream_settings: &Value) -> Option<String> {
stream_settings
@@ -84,7 +89,7 @@ pub mod utils {
.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
@@ -94,7 +99,7 @@ pub mod utils {
.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
@@ -103,23 +108,27 @@ pub mod utils {
.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> {
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()) {
.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)
pub fn generate_alias(server_name: &str, template_name: &str) -> String {
format!("{} - {}", server_name, template_name)
}
}
}

View File

@@ -1,8 +1,7 @@
use base64::{Engine as _, engine::general_purpose};
use serde_json::Value;
use base64::{engine::general_purpose, Engine as _};
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError};
use super::{UriBuilder, utils};
use super::{utils, UriBuilder};
use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub struct ShadowsocksUriBuilder;
@@ -10,54 +9,56 @@ 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",
// 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
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);
let alias = utils::generate_alias(&config.server_name, &config.template_name);
// Build simple SIP002 URI (no plugin parameters for standard Shadowsocks)
let uri = format!(
"ss://{}@{}:{}#{}",
@@ -66,24 +67,30 @@ impl UriBuilder for ShadowsocksUriBuilder {
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()));
return Err(UriGeneratorError::MissingRequiredField(
"hostname".to_string(),
));
}
if config.port <= 0 || config.port > 65535 {
return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string()));
return Err(UriGeneratorError::InvalidConfiguration(
"Invalid port number".to_string(),
));
}
if config.xray_user_id.is_empty() {
return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string()));
return Err(UriGeneratorError::MissingRequiredField(
"xray_user_id".to_string(),
));
}
// Shadowsocks uses xray_user_id as password, already validated above
Ok(())
}
}
@@ -93,4 +100,3 @@ impl Default for ShadowsocksUriBuilder {
Self::new()
}
}

View File

@@ -1,8 +1,8 @@
use std::collections::HashMap;
use serde_json::Value;
use std::collections::HashMap;
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError};
use super::{UriBuilder, utils};
use super::{utils, UriBuilder};
use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub struct TrojanUriBuilder;
@@ -15,32 +15,32 @@ impl TrojanUriBuilder {
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" => {
@@ -50,48 +50,53 @@ impl UriBuilder for TrojanUriBuilder {
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()) {
.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()) {
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()) {
.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()) {
.and_then(|alpn| alpn.as_array())
{
let alpn_str = alpn
.iter()
.filter_map(|v| v.as_str())
@@ -101,46 +106,47 @@ impl UriBuilder for TrojanUriBuilder {
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()) {
.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()) {
.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()) {
.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()) {
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 alias = utils::generate_alias(&config.server_name, &config.template_name);
let uri = if query_string.is_empty() {
format!(
"trojan://{}@{}:{}#{}",
@@ -159,24 +165,30 @@ impl UriBuilder for TrojanUriBuilder {
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()));
return Err(UriGeneratorError::MissingRequiredField(
"hostname".to_string(),
));
}
if config.port <= 0 || config.port > 65535 {
return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string()));
return Err(UriGeneratorError::InvalidConfiguration(
"Invalid port number".to_string(),
));
}
if config.xray_user_id.is_empty() {
return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string()));
return Err(UriGeneratorError::MissingRequiredField(
"xray_user_id".to_string(),
));
}
// Trojan uses xray_user_id as password, already validated above
Ok(())
}
}
@@ -191,7 +203,7 @@ impl Default for TrojanUriBuilder {
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);
@@ -204,7 +216,7 @@ fn apply_variables(template: &Value, variables: &Value) -> Result<Value, UriGene
result = result.replace(&placeholder, &replacement);
}
}
serde_json::from_str(&result)
.map_err(|e| UriGeneratorError::VariableSubstitution(e.to_string()))
}
}

View File

@@ -1,8 +1,8 @@
use std::collections::HashMap;
use serde_json::Value;
use std::collections::HashMap;
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError};
use super::{UriBuilder, utils};
use super::{utils, UriBuilder};
use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub struct VlessUriBuilder;
@@ -15,7 +15,7 @@ impl VlessUriBuilder {
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
@@ -23,23 +23,23 @@ impl UriBuilder for VlessUriBuilder {
} 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" => {
@@ -49,72 +49,76 @@ impl UriBuilder for VlessUriBuilder {
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()) {
.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()) {
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()) {
.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()) {
.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()) {
.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()) {
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 alias = utils::generate_alias(&config.server_name, &config.template_name);
let uri = if query_string.is_empty() {
format!(
"vless://{}@{}:{}#{}",
@@ -133,7 +137,7 @@ impl UriBuilder for VlessUriBuilder {
utils::url_encode(&alias)
)
};
Ok(uri)
}
}
@@ -148,7 +152,7 @@ impl Default for VlessUriBuilder {
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);
@@ -161,7 +165,7 @@ fn apply_variables(template: &Value, variables: &Value) -> Result<Value, UriGene
result = result.replace(&placeholder, &replacement);
}
}
serde_json::from_str(&result)
.map_err(|e| UriGeneratorError::VariableSubstitution(e.to_string()))
}
}

View File

@@ -1,9 +1,8 @@
use std::collections::HashMap;
use serde_json::{Value, json};
use base64::{Engine as _, engine::general_purpose};
use base64::{engine::general_purpose, Engine as _};
use serde_json::{json, Value};
use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError};
use super::{UriBuilder, utils};
use super::{utils, UriBuilder};
use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData};
pub struct VmessUriBuilder;
@@ -11,20 +10,23 @@ 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> {
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,
@@ -34,13 +36,13 @@ impl VmessUriBuilder {
"net": transport_type,
"path": "",
"port": config.port,
"ps": utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag),
"ps": utils::generate_alias(&config.server_name, &config.template_name),
"scy": "auto",
"tls": if security == "none" { "none" } else { &security },
"type": "none",
"v": "2"
});
// Transport-specific settings
match transport_type.as_str() {
"ws" => {
@@ -50,23 +52,24 @@ impl VmessUriBuilder {
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()) {
.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
@@ -77,10 +80,11 @@ impl VmessUriBuilder {
.and_then(|headers| headers.get("Host"))
.and_then(|host| host.as_array())
.and_then(|arr| arr.first())
.and_then(|h| h.as_str()) {
.and_then(|h| h.as_str())
{
vmess_config["host"] = Value::String(host.to_string());
}
if let Some(path) = stream_settings
.get("tcpSettings")
.and_then(|tcp| tcp.get("header"))
@@ -88,34 +92,39 @@ impl VmessUriBuilder {
.and_then(|request| request.get("path"))
.and_then(|path| path.as_array())
.and_then(|arr| arr.first())
.and_then(|p| p.as_str()) {
.and_then(|p| p.as_str())
{
vmess_config["path"] = Value::String(path.to_string());
}
}
}
},
}
_ => {} // Other transport types
}
// TLS settings
if security != "none" {
if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) {
if let Some(sni) =
utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref())
{
vmess_config["sni"] = Value::String(sni);
}
// TLS fingerprint
if let Some(fp) = stream_settings
.get("tlsSettings")
.and_then(|tls| tls.get("fingerprint"))
.and_then(|fp| fp.as_str()) {
.and_then(|fp| fp.as_str())
{
vmess_config["fp"] = Value::String(fp.to_string());
}
// ALPN
if let Some(alpn) = stream_settings
.get("tlsSettings")
.and_then(|tls| tls.get("alpn"))
.and_then(|alpn| alpn.as_array()) {
.and_then(|alpn| alpn.as_array())
{
let alpn_str = alpn
.iter()
.filter_map(|v| v.as_str())
@@ -126,105 +135,20 @@ impl VmessUriBuilder {
}
}
}
// 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)
}
@@ -240,7 +164,7 @@ impl Default for VmessUriBuilder {
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);
@@ -253,7 +177,7 @@ fn apply_variables(template: &Value, variables: &Value) -> Result<Value, UriGene
result = result.replace(&placeholder, &replacement);
}
}
serde_json::from_str(&result)
.map_err(|e| UriGeneratorError::VariableSubstitution(e.to_string()))
}
}

View File

@@ -48,4 +48,4 @@ impl From<serde_json::Error> for UriGeneratorError {
// fn from(err: urlencoding::EncodingError) -> Self {
// UriGeneratorError::UriEncoding(err.to_string())
// }
// }
// }

View File

@@ -6,7 +6,9 @@ use uuid::Uuid;
pub mod builders;
pub mod error;
use builders::{UriBuilder, VlessUriBuilder, VmessUriBuilder, TrojanUriBuilder, ShadowsocksUriBuilder};
use builders::{
ShadowsocksUriBuilder, TrojanUriBuilder, UriBuilder, VlessUriBuilder, VmessUriBuilder,
};
use error::UriGeneratorError;
/// Complete client configuration data aggregated from database
@@ -17,26 +19,27 @@ pub struct ClientConfigData {
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,
pub template_name: String,
}
/// Generated client configuration
@@ -45,6 +48,7 @@ pub struct ClientConfig {
pub user_id: Uuid,
pub server_name: String,
pub inbound_tag: String,
pub template_name: String,
pub protocol: String,
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -58,51 +62,61 @@ 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> {
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(),
template_name: config.template_name.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> {
/// Apply variable substitution to JSON values (for testing)
#[cfg(test)]
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);
@@ -115,14 +129,276 @@ impl UriGeneratorService {
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()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use uuid::Uuid;
fn create_test_config(protocol: &str) -> ClientConfigData {
ClientConfigData {
user_name: "testuser".to_string(),
xray_user_id: "test-uuid-123".to_string(),
password: Some("test-password".to_string()),
level: 0,
hostname: "example.com".to_string(),
port: 8443,
protocol: protocol.to_string(),
stream_settings: json!({
"network": "tcp",
"security": "tls"
}),
base_settings: json!({
"clients": []
}),
certificate_domain: Some("example.com".to_string()),
requires_tls: true,
variable_values: json!({
"domain": "example.com",
"port": "8443"
}),
server_name: "test-server".to_string(),
inbound_tag: "test-inbound".to_string(),
template_name: "test-template".to_string(),
}
}
#[test]
fn test_uri_generator_service_creation() {
let service = UriGeneratorService::new();
// Service should be created successfully
assert_eq!(std::mem::size_of_val(&service), 0); // Zero-sized struct
}
#[test]
fn test_generate_uri_vless() {
let service = UriGeneratorService::new();
let config = create_test_config("vless");
let result = service.generate_uri(&config);
assert!(result.is_ok());
let uri = result.unwrap();
assert!(uri.starts_with("vless://"));
assert!(uri.contains("test-uuid-123"));
assert!(uri.contains("example.com:8443"));
}
#[test]
fn test_generate_uri_vmess() {
let service = UriGeneratorService::new();
let config = create_test_config("vmess");
let result = service.generate_uri(&config);
assert!(result.is_ok());
let uri = result.unwrap();
assert!(uri.starts_with("vmess://"));
}
#[test]
fn test_generate_uri_trojan() {
let service = UriGeneratorService::new();
let config = create_test_config("trojan");
let result = service.generate_uri(&config);
assert!(result.is_ok());
let uri = result.unwrap();
assert!(uri.starts_with("trojan://"));
assert!(uri.contains("test-uuid-123")); // trojan uses xray_user_id as password
assert!(uri.contains("example.com:8443"));
}
#[test]
fn test_generate_uri_shadowsocks() {
let service = UriGeneratorService::new();
let config = create_test_config("shadowsocks");
let result = service.generate_uri(&config);
assert!(result.is_ok());
let uri = result.unwrap();
assert!(uri.starts_with("ss://"));
}
#[test]
fn test_generate_uri_unsupported_protocol() {
let service = UriGeneratorService::new();
let config = create_test_config("unsupported");
let result = service.generate_uri(&config);
assert!(result.is_err());
match result.unwrap_err() {
UriGeneratorError::UnsupportedProtocol(protocol) => {
assert_eq!(protocol, "unsupported");
}
_ => panic!("Expected UnsupportedProtocol error"),
}
}
#[test]
fn test_generate_client_config() {
let service = UriGeneratorService::new();
let config_data = create_test_config("vless");
let user_id = Uuid::new_v4();
let result = service.generate_client_config(user_id, &config_data);
assert!(result.is_ok());
let client_config = result.unwrap();
assert_eq!(client_config.user_id, user_id);
assert_eq!(client_config.server_name, "test-server");
assert_eq!(client_config.inbound_tag, "test-inbound");
assert_eq!(client_config.template_name, "test-template");
assert_eq!(client_config.protocol, "vless");
assert!(client_config.uri.starts_with("vless://"));
assert!(client_config.qr_code.is_none());
}
#[test]
fn test_apply_variable_substitution() {
let service = UriGeneratorService::new();
let template = json!({
"hostname": "${domain}",
"port": "${port}",
"fixed": "value"
});
let variables = json!({
"domain": "test.example.com",
"port": "9443"
});
let result = service.apply_variable_substitution(&template, &variables);
assert!(result.is_ok());
let substituted = result.unwrap();
assert_eq!(substituted["hostname"], "test.example.com");
assert_eq!(substituted["port"], "9443");
assert_eq!(substituted["fixed"], "value");
}
#[test]
fn test_apply_variable_substitution_no_variables() {
let service = UriGeneratorService::new();
let template = json!({
"hostname": "static.example.com",
"port": "8443"
});
let variables = json!({});
let result = service.apply_variable_substitution(&template, &variables);
assert!(result.is_ok());
let substituted = result.unwrap();
assert_eq!(substituted["hostname"], "static.example.com");
assert_eq!(substituted["port"], "8443");
}
#[test]
fn test_apply_variable_substitution_partial_match() {
let service = UriGeneratorService::new();
let template = json!({
"hostname": "${domain}",
"port": "${unknown_var}",
"static": "value"
});
let variables = json!({
"domain": "test.example.com"
});
let result = service.apply_variable_substitution(&template, &variables);
assert!(result.is_ok());
let substituted = result.unwrap();
assert_eq!(substituted["hostname"], "test.example.com");
assert_eq!(substituted["port"], "${unknown_var}"); // Should remain unchanged
assert_eq!(substituted["static"], "value");
}
#[test]
fn test_client_config_data_fields() {
let config = create_test_config("vless");
assert_eq!(config.user_name, "testuser");
assert_eq!(config.xray_user_id, "test-uuid-123");
assert_eq!(config.password, Some("test-password".to_string()));
assert_eq!(config.level, 0);
assert_eq!(config.hostname, "example.com");
assert_eq!(config.port, 8443);
assert_eq!(config.protocol, "vless");
assert_eq!(config.certificate_domain, Some("example.com".to_string()));
assert!(config.requires_tls);
assert_eq!(config.server_name, "test-server");
assert_eq!(config.inbound_tag, "test-inbound");
assert_eq!(config.template_name, "test-template");
}
#[test]
fn test_client_config_serialization() {
let user_id = Uuid::new_v4();
let client_config = ClientConfig {
user_id,
server_name: "test-server".to_string(),
inbound_tag: "test-inbound".to_string(),
template_name: "test-template".to_string(),
protocol: "vless".to_string(),
uri: "vless://test-uri".to_string(),
qr_code: Some("qr-code-data".to_string()),
};
// Test serialization
let serialized = serde_json::to_string(&client_config);
assert!(serialized.is_ok());
// Test deserialization
let deserialized: Result<ClientConfig, _> = serde_json::from_str(&serialized.unwrap());
assert!(deserialized.is_ok());
let config = deserialized.unwrap();
assert_eq!(config.user_id, user_id);
assert_eq!(config.server_name, "test-server");
assert_eq!(config.protocol, "vless");
assert_eq!(config.uri, "vless://test-uri");
assert_eq!(config.qr_code, Some("qr-code-data".to_string()));
}
#[test]
fn test_client_config_qr_code_optional() {
let user_id = Uuid::new_v4();
let client_config = ClientConfig {
user_id,
server_name: "test-server".to_string(),
inbound_tag: "test-inbound".to_string(),
template_name: "test-template".to_string(),
protocol: "vless".to_string(),
uri: "vless://test-uri".to_string(),
qr_code: None,
};
let serialized = serde_json::to_string(&client_config).unwrap();
// QR code field should be omitted when None due to skip_serializing_if
assert!(!serialized.contains("qr_code"));
}
}

View File

@@ -1,11 +1,12 @@
use anyhow::{Result, anyhow};
use anyhow::{anyhow, Result};
use serde_json::Value;
use xray_core::Client;
use std::sync::Arc;
use tokio::time::{timeout, Duration};
use xray_core::Client;
// Import submodules from the same directory
use super::stats::StatsClient;
use super::inbounds::InboundClient;
use super::stats::StatsClient;
use super::users::UserClient;
/// Xray gRPC client wrapper
@@ -17,17 +18,22 @@ pub struct XrayClient {
#[allow(dead_code)]
impl XrayClient {
/// Connect to Xray gRPC server
/// Connect to Xray gRPC server with timeout
pub async fn connect(endpoint: &str) -> Result<Self> {
let client = Client::from_url(endpoint).await
.map_err(|e| anyhow!("Failed to connect to Xray at {}: {}", endpoint, e))?;
// Apply a 5-second timeout to the connection attempt
let connect_future = Client::from_url(endpoint);
// Don't clone - we'll use &self.client when calling methods
Ok(Self {
endpoint: endpoint.to_string(),
client: Arc::new(client),
})
match timeout(Duration::from_secs(5), connect_future).await {
Ok(Ok(client)) => Ok(Self {
endpoint: endpoint.to_string(),
client: Arc::new(client),
}),
Ok(Err(e)) => Err(anyhow!("Failed to connect to Xray at {}: {}", endpoint, e)),
Err(_) => Err(anyhow!(
"Connection to Xray at {} timed out after 5 seconds",
endpoint
)),
}
}
/// Get server statistics
@@ -43,7 +49,10 @@ impl XrayClient {
}
/// Restart Xray with new configuration
pub async fn restart_with_config(&self, config: &crate::services::xray::XrayConfig) -> Result<()> {
pub async fn restart_with_config(
&self,
config: &crate::services::xray::XrayConfig,
) -> Result<()> {
let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client);
inbound_client.restart_with_config(config).await
}
@@ -55,15 +64,30 @@ impl XrayClient {
}
/// Add inbound configuration with TLS certificate
pub async fn add_inbound_with_certificate(&self, inbound: &Value, cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> {
pub async fn add_inbound_with_certificate(
&self,
inbound: &Value,
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client);
inbound_client.add_inbound_with_certificate(inbound, None, cert_pem, key_pem).await
inbound_client
.add_inbound_with_certificate(inbound, None, cert_pem, key_pem)
.await
}
/// Add inbound configuration with users and TLS certificate
pub async fn add_inbound_with_users_and_certificate(&self, inbound: &Value, users: &[Value], cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> {
pub async fn add_inbound_with_users_and_certificate(
&self,
inbound: &Value,
users: &[Value],
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client);
inbound_client.add_inbound_with_certificate(inbound, Some(users), cert_pem, key_pem).await
inbound_client
.add_inbound_with_certificate(inbound, Some(users), cert_pem, key_pem)
.await
}
/// Remove inbound by tag
@@ -88,4 +112,4 @@ impl XrayClient {
pub fn endpoint(&self) -> &str {
&self.endpoint
}
}
}

View File

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

View File

@@ -1,44 +1,28 @@
use anyhow::{Result, anyhow};
use anyhow::{anyhow, Result};
use prost::Message;
use serde_json::Value;
use uuid;
use xray_core::{
tonic::Request,
app::proxyman::command::{AddInboundRequest, RemoveInboundRequest},
core::InboundHandlerConfig,
common::serial::TypedMessage,
common::protocol::User,
app::proxyman::ReceiverConfig,
common::net::{PortList, PortRange, IpOrDomain, ip_or_domain::Address, Network},
transport::internet::StreamConfig,
transport::internet::tls::{Config as TlsConfig, Certificate as TlsCertificate},
common::net::{ip_or_domain::Address, IpOrDomain, Network, PortList, PortRange},
common::protocol::User,
common::serial::TypedMessage,
core::InboundHandlerConfig,
proxy::shadowsocks::ServerConfig as ShadowsocksServerConfig,
proxy::shadowsocks::{Account as ShadowsocksAccount, CipherType},
proxy::trojan::Account as TrojanAccount,
proxy::trojan::ServerConfig as TrojanServerConfig,
proxy::vless::inbound::Config as VlessInboundConfig,
proxy::vless::Account as VlessAccount,
proxy::vmess::inbound::Config as VmessInboundConfig,
proxy::vmess::Account as VmessAccount,
proxy::trojan::ServerConfig as TrojanServerConfig,
proxy::trojan::Account as TrojanAccount,
proxy::shadowsocks::ServerConfig as ShadowsocksServerConfig,
proxy::shadowsocks::{Account as ShadowsocksAccount, CipherType},
tonic::Request,
transport::internet::tls::{Certificate as TlsCertificate, Config as TlsConfig},
transport::internet::StreamConfig,
Client,
prost_types,
};
use prost::Message;
/// Convert PEM format to DER (x509) format
fn pem_to_der(pem_data: &str) -> Result<Vec<u8>> {
// Remove PEM headers and whitespace, then decode base64
let base64_data: String = pem_data.lines()
.filter(|line| !line.starts_with("-----") && !line.trim().is_empty())
.map(|line| line.trim())
.collect::<Vec<&str>>()
.join("");
tracing::debug!("PEM to DER conversion: {} bytes", base64_data.len());
use base64::{Engine as _, engine::general_purpose};
general_purpose::STANDARD.decode(&base64_data)
.map_err(|e| anyhow!("Failed to decode base64 PEM data: {}", e))
}
pub struct InboundClient<'a> {
endpoint: String,
@@ -52,22 +36,32 @@ impl<'a> InboundClient<'a> {
/// Add inbound configuration
pub async fn add_inbound(&self, inbound: &Value) -> Result<()> {
self.add_inbound_with_certificate(inbound, None, None, None).await
self.add_inbound_with_certificate(inbound, None, None, None)
.await
}
/// Add inbound configuration with TLS certificate and users
pub async fn add_inbound_with_certificate(&self, inbound: &Value, users: Option<&[Value]>, cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> {
pub async fn add_inbound_with_certificate(
&self,
inbound: &Value,
users: Option<&[Value]>,
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
let tag = inbound["tag"].as_str().unwrap_or("").to_string();
let port = inbound["port"].as_u64().unwrap_or(8080) as u32;
let protocol = inbound["protocol"].as_str().unwrap_or("vless");
let _user_count = users.map_or(0, |u| u.len());
tracing::info!(
"Adding inbound '{}' with protocol={}, port={}, has_cert={}, has_key={}",
tag, protocol, port, cert_pem.is_some(), key_pem.is_some()
"Adding inbound '{}' with protocol={}, port={}, has_cert={}, has_key={}",
tag,
protocol,
port,
cert_pem.is_some(),
key_pem.is_some()
);
// Create receiver configuration (port binding) - use simple port number
let port_list = PortList {
range: vec![PortRange {
@@ -80,39 +74,42 @@ impl<'a> InboundClient<'a> {
let stream_settings = if cert_pem.is_some() && key_pem.is_some() {
let cert_pem = cert_pem.unwrap();
let key_pem = key_pem.unwrap();
// Create TLS certificate exactly like working example - PEM content as bytes
let tls_cert = TlsCertificate {
certificate: cert_pem.as_bytes().to_vec(), // PEM content as bytes like working example
key: key_pem.as_bytes().to_vec(), // PEM content as bytes like working example
usage: 0,
ocsp_stapling: 3600, // From working example
ocsp_stapling: 3600, // From working example
one_time_loading: true, // From working example
build_chain: false,
certificate_path: "".to_string(), // Empty paths since we use content
key_path: "".to_string(), // Empty paths since we use content
key_path: "".to_string(), // Empty paths since we use content
};
// Create TLS config with proper fields like working example
let mut tls_config = TlsConfig::default();
tls_config.certificate = vec![tls_cert];
tls_config.next_protocol = vec!["h2".to_string(), "http/1.1".to_string()]; // From working example
tls_config.server_name = "localhost".to_string(); // From working example
tls_config.min_version = "1.2".to_string(); // From Marzban examples
// Create TypedMessage for TLS config
let tls_message = TypedMessage {
r#type: "xray.transport.internet.tls.Config".to_string(),
value: tls_config.encode_to_vec(),
};
tracing::debug!("TLS config: server_name={}, protocols={:?}",
tls_config.server_name, tls_config.next_protocol);
tracing::debug!(
"TLS config: server_name={}, protocols={:?}",
tls_config.server_name,
tls_config.next_protocol
);
// Create StreamConfig like working example
Some(StreamConfig {
address: None, // No address in streamSettings according to working example
port: 0, // No port in working example streamSettings
port: 0, // No port in working example streamSettings
protocol_name: "tcp".to_string(),
transport_settings: vec![],
security_type: "xray.transport.internet.tls.Config".to_string(), // Full type like working example
@@ -125,8 +122,8 @@ impl<'a> InboundClient<'a> {
let receiver_config = ReceiverConfig {
port_list: Some(port_list),
listen: Some(IpOrDomain {
address: Some(Address::Ip(vec![0, 0, 0, 0])) // "0.0.0.0" as IPv4 bytes
listen: Some(IpOrDomain {
address: Some(Address::Ip(vec![0, 0, 0, 0])), // "0.0.0.0" as IPv4 bytes
}),
allocation_strategy: None,
stream_settings: stream_settings,
@@ -138,7 +135,7 @@ impl<'a> InboundClient<'a> {
r#type: "xray.app.proxyman.ReceiverConfig".to_string(),
value: receiver_config.encode_to_vec(),
};
// Create proxy configuration based on protocol with users
let proxy_message = match protocol {
"vless" => {
@@ -148,7 +145,7 @@ impl<'a> InboundClient<'a> {
let user_id = user["id"].as_str().unwrap_or("").to_string();
let email = user["email"].as_str().unwrap_or("").to_string();
let level = user["level"].as_u64().unwrap_or(0) as u32;
if !user_id.is_empty() && !email.is_empty() {
let account = VlessAccount {
id: user_id,
@@ -166,7 +163,7 @@ impl<'a> InboundClient<'a> {
}
}
}
let vless_config = VlessInboundConfig {
clients,
decryption: "none".to_string(),
@@ -176,7 +173,7 @@ impl<'a> InboundClient<'a> {
r#type: "xray.proxy.vless.inbound.Config".to_string(),
value: vless_config.encode_to_vec(),
}
},
}
"vmess" => {
let mut vmess_users = vec![];
if let Some(users) = users {
@@ -184,18 +181,18 @@ impl<'a> InboundClient<'a> {
let user_id = user["id"].as_str().unwrap_or("").to_string();
let email = user["email"].as_str().unwrap_or("").to_string();
let level = user["level"].as_u64().unwrap_or(0) as u32;
// Validate required fields
if user_id.is_empty() || email.is_empty() {
tracing::warn!("Skipping VMess user: missing id or email");
continue;
}
// Validate UUID format
if uuid::Uuid::parse_str(&user_id).is_err() {
tracing::warn!("VMess user '{}' has invalid UUID format", user_id);
}
if !user_id.is_empty() && !email.is_empty() {
let account = VmessAccount {
id: user_id.clone(),
@@ -203,7 +200,7 @@ impl<'a> InboundClient<'a> {
tests_enabled: "".to_string(), // Keep empty as in examples
};
let account_bytes = account.encode_to_vec();
vmess_users.push(User {
email: email.clone(),
level,
@@ -215,7 +212,7 @@ impl<'a> InboundClient<'a> {
}
}
}
let vmess_config = VmessInboundConfig {
user: vmess_users,
default: None,
@@ -225,19 +222,21 @@ impl<'a> InboundClient<'a> {
r#type: "xray.proxy.vmess.inbound.Config".to_string(),
value: vmess_config.encode_to_vec(),
}
},
}
"trojan" => {
let mut trojan_users = vec![];
if let Some(users) = users {
for user in users {
let password = user["password"].as_str().or_else(|| user["id"].as_str()).unwrap_or("").to_string();
let password = user["password"]
.as_str()
.or_else(|| user["id"].as_str())
.unwrap_or("")
.to_string();
let email = user["email"].as_str().unwrap_or("").to_string();
let level = user["level"].as_u64().unwrap_or(0) as u32;
if !password.is_empty() && !email.is_empty() {
let account = TrojanAccount {
password,
};
let account = TrojanAccount { password };
trojan_users.push(User {
email,
level,
@@ -249,7 +248,7 @@ impl<'a> InboundClient<'a> {
}
}
}
let trojan_config = TrojanServerConfig {
users: trojan_users,
fallbacks: vec![],
@@ -258,21 +257,24 @@ impl<'a> InboundClient<'a> {
r#type: "xray.proxy.trojan.ServerConfig".to_string(),
value: trojan_config.encode_to_vec(),
}
},
}
"shadowsocks" => {
let mut ss_users = vec![];
if let Some(users) = users {
for user in users {
let password = user["password"].as_str().or_else(|| user["id"].as_str()).unwrap_or("").to_string();
let password = user["password"]
.as_str()
.or_else(|| user["id"].as_str())
.unwrap_or("")
.to_string();
let email = user["email"].as_str().unwrap_or("").to_string();
let level = user["level"].as_u64().unwrap_or(0) as u32;
if !password.is_empty() && !email.is_empty() {
let account = ShadowsocksAccount {
password,
cipher_type: CipherType::Aes256Gcm as i32, // Use AES-256-GCM cipher
iv_check: false, // Default IV check
iv_check: false, // Default IV check
};
ss_users.push(User {
email: email.clone(),
@@ -285,7 +287,7 @@ impl<'a> InboundClient<'a> {
}
}
}
let shadowsocks_config = ShadowsocksServerConfig {
users: ss_users,
network: vec![Network::Tcp as i32, Network::Udp as i32], // Support TCP and UDP
@@ -294,7 +296,7 @@ impl<'a> InboundClient<'a> {
r#type: "xray.proxy.shadowsocks.ServerConfig".to_string(),
value: shadowsocks_config.encode_to_vec(),
}
},
}
_ => {
return Err(anyhow!("Unsupported protocol: {}", protocol));
}
@@ -328,12 +330,12 @@ impl<'a> InboundClient<'a> {
let request = Request::new(RemoveInboundRequest {
tag: tag.to_string(),
});
match handler_client.remove_inbound(request).await {
Ok(_) => {
tracing::info!("Removed inbound '{}' from {}", tag, self.endpoint);
Ok(())
},
}
Err(e) => {
tracing::error!("Failed to remove inbound '{}': {}", tag, e);
Err(anyhow!("Failed to remove inbound: {}", e))
@@ -342,11 +344,17 @@ impl<'a> InboundClient<'a> {
}
/// Restart Xray with new configuration
pub async fn restart_with_config(&self, config: &crate::services::xray::XrayConfig) -> Result<()> {
tracing::debug!("Restarting Xray server at {} with new config", self.endpoint);
pub async fn restart_with_config(
&self,
_config: &crate::services::xray::XrayConfig,
) -> Result<()> {
tracing::debug!(
"Restarting Xray server at {} with new config",
self.endpoint
);
// TODO: Implement restart with config using xray-core
// For now just return success
Ok(())
}
}
}

View File

@@ -1,16 +1,16 @@
use anyhow::Result;
use serde_json::Value;
use uuid::Uuid;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::time::{Duration, Instant};
use tracing::error;
use tokio::time::{timeout, Duration, Instant};
use tracing::warn;
use uuid::Uuid;
pub mod client;
pub mod config;
pub mod stats;
pub mod inbounds;
pub mod stats;
pub mod users;
pub use client::XrayClient;
@@ -30,7 +30,7 @@ impl CachedConnection {
created_at: Instant::now(),
}
}
fn is_expired(&self, ttl: Duration) -> bool {
self.created_at.elapsed() > ttl
}
@@ -51,7 +51,15 @@ impl XrayService {
connection_ttl: Duration::from_secs(300), // 5 minutes TTL
}
}
/// Create service with custom TTL for testing
pub fn with_ttl(ttl: Duration) -> Self {
Self {
connection_cache: Arc::new(RwLock::new(HashMap::new())),
connection_ttl: ttl,
}
}
/// Get or create cached client for endpoint
async fn get_or_create_client(&self, endpoint: &str) -> Result<XrayClient> {
// Check cache first
@@ -63,246 +71,375 @@ impl XrayService {
}
}
}
// Create new connection
let client = XrayClient::connect(endpoint).await?;
let cached_connection = CachedConnection::new(client.clone());
// Update cache
{
let mut cache = self.connection_cache.write().await;
cache.insert(endpoint.to_string(), cached_connection);
}
Ok(client)
}
/// Test connection to Xray server
/// Test connection to Xray server with timeout
pub async fn test_connection(&self, _server_id: Uuid, endpoint: &str) -> Result<bool> {
match self.get_or_create_client(endpoint).await {
Ok(_client) => {
// Instead of getting stats (which might fail), just test connection
// If we successfully created the client, connection is working
// Apply a 3-second timeout to the entire test operation
match timeout(Duration::from_secs(3), self.get_or_create_client(endpoint)).await {
Ok(Ok(_client)) => {
// Connection successful
Ok(true)
},
Err(_) => Ok(false),
}
}
/// Apply full configuration to Xray server
pub async fn apply_config(&self, _server_id: Uuid, endpoint: &str, config: &XrayConfig) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.restart_with_config(config).await
}
/// Create inbound from template
pub async fn create_inbound(
&self,
_server_id: Uuid,
endpoint: &str,
tag: &str,
port: i32,
protocol: &str,
base_settings: Value,
stream_settings: Value,
) -> Result<()> {
// Build inbound configuration from template
let inbound_config = serde_json::json!({
"tag": tag,
"port": port,
"protocol": protocol,
"settings": base_settings,
"streamSettings": stream_settings
});
self.add_inbound(_server_id, endpoint, &inbound_config).await
}
/// Create inbound from template with TLS certificate
pub async fn create_inbound_with_certificate(
&self,
_server_id: Uuid,
endpoint: &str,
tag: &str,
port: i32,
protocol: &str,
base_settings: Value,
stream_settings: Value,
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
// Build inbound configuration from template
let inbound_config = serde_json::json!({
"tag": tag,
"port": port,
"protocol": protocol,
"settings": base_settings,
"streamSettings": stream_settings
});
self.add_inbound_with_certificate(_server_id, endpoint, &inbound_config, cert_pem, key_pem).await
}
/// Add inbound to running Xray instance
pub async fn add_inbound(&self, _server_id: Uuid, endpoint: &str, inbound: &Value) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.add_inbound(inbound).await
}
/// Add inbound with certificate to running Xray instance
pub async fn add_inbound_with_certificate(&self, _server_id: Uuid, endpoint: &str, inbound: &Value, cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.add_inbound_with_certificate(inbound, cert_pem, key_pem).await
}
/// Add inbound with users and certificate to running Xray instance
pub async fn add_inbound_with_users_and_certificate(&self, _server_id: Uuid, endpoint: &str, inbound: &Value, users: &[Value], cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.add_inbound_with_users_and_certificate(inbound, users, cert_pem, key_pem).await
}
/// Remove inbound from running Xray instance
pub async fn remove_inbound(&self, _server_id: Uuid, endpoint: &str, tag: &str) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.remove_inbound(tag).await
}
/// Add user to inbound by recreating the inbound with updated user list
pub async fn add_user(&self, _server_id: Uuid, endpoint: &str, inbound_tag: &str, user: &Value) -> Result<()> {
// TODO: Implement inbound recreation approach:
// 1. Get current inbound configuration from database
// 2. Get existing users from database
// 3. Remove old inbound from xray
// 4. Create new inbound with all users (existing + new)
// For now, return error to indicate this needs to be implemented
Err(anyhow::anyhow!("User addition requires inbound recreation - not yet implemented. Use web interface to recreate inbound with users."))
}
/// Create inbound with users list (for inbound recreation approach)
pub async fn create_inbound_with_users(
&self,
_server_id: Uuid,
endpoint: &str,
tag: &str,
port: i32,
protocol: &str,
base_settings: Value,
stream_settings: Value,
users: &[Value],
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
// Build inbound configuration with users
let mut inbound_config = serde_json::json!({
"tag": tag,
"port": port,
"protocol": protocol,
"settings": base_settings,
"streamSettings": stream_settings
});
// Add users to settings based on protocol
if !users.is_empty() {
let mut settings = inbound_config["settings"].clone();
match protocol {
"vless" | "vmess" => {
settings["clients"] = serde_json::Value::Array(users.to_vec());
},
"trojan" => {
settings["clients"] = serde_json::Value::Array(users.to_vec());
},
"shadowsocks" => {
// For shadowsocks, users are handled differently
if let Some(user) = users.first() {
settings["password"] = user["password"].clone();
}
},
_ => {
return Err(anyhow::anyhow!("Unsupported protocol for users: {}", protocol));
}
}
inbound_config["settings"] = settings;
Ok(Err(e)) => {
// Connection failed with error
warn!("Failed to connect to Xray at {}: {}", endpoint, e);
Ok(false)
}
Err(_) => {
// Operation timed out
warn!("Connection test to Xray at {} timed out", endpoint);
Ok(false)
}
}
// Use the new method with users support
self.add_inbound_with_users_and_certificate(_server_id, endpoint, &inbound_config, users, cert_pem, key_pem).await
}
/// Remove user from inbound
pub async fn remove_user(&self, _server_id: Uuid, endpoint: &str, inbound_tag: &str, email: &str) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.remove_user(inbound_tag, email).await
}
/// Get server statistics
pub async fn get_stats(&self, _server_id: Uuid, endpoint: &str) -> Result<Value> {
/// Get statistics from Xray server
pub async fn get_stats(&self, endpoint: &str) -> Result<Value> {
let client = self.get_or_create_client(endpoint).await?;
client.get_stats().await
}
/// Query specific statistics
pub async fn query_stats(&self, _server_id: Uuid, endpoint: &str, pattern: &str, reset: bool) -> Result<Value> {
/// Query specific statistics with pattern
pub async fn query_stats(&self, endpoint: &str, pattern: &str, reset: bool) -> Result<Value> {
let client = self.get_or_create_client(endpoint).await?;
client.query_stats(pattern, reset).await
}
/// Sync entire server with batch operations using single client
pub async fn sync_server_inbounds_optimized(
/// Add user to server with specific inbound and configuration
pub async fn add_user(&self, endpoint: &str, inbound_tag: &str, user: &Value) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.add_user(inbound_tag, user).await
}
/// Remove user from server
pub async fn remove_user(
&self,
endpoint: &str,
inbound_tag: &str,
user_email: &str,
) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.remove_user(inbound_tag, user_email).await
}
/// Remove user from server (with server_id parameter for compatibility)
pub async fn remove_user_with_server_id(
&self,
_server_id: Uuid,
endpoint: &str,
inbound_tag: &str,
user_email: &str,
) -> Result<()> {
self.remove_user(endpoint, inbound_tag, user_email).await
}
/// Create new inbound on server
pub async fn create_inbound(&self, endpoint: &str, inbound: &Value) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.add_inbound(inbound).await
}
/// Create inbound with certificate (legacy interface for compatibility)
pub async fn create_inbound_with_certificate(
&self,
_server_id: Uuid,
endpoint: &str,
_tag: &str,
_port: i32,
_protocol: &str,
_base_settings: Value,
_stream_settings: Value,
cert_pem: Option<&str>,
key_pem: Option<&str>,
) -> Result<()> {
// For now, create a basic inbound structure
// In real implementation, this would build the inbound from the parameters
let inbound = serde_json::json!({
"tag": _tag,
"port": _port,
"protocol": _protocol,
"settings": _base_settings,
"streamSettings": _stream_settings
});
let client = self.get_or_create_client(endpoint).await?;
client
.add_inbound_with_certificate(&inbound, cert_pem, key_pem)
.await
}
/// Update existing inbound on server
pub async fn update_inbound(&self, endpoint: &str, inbound: &Value) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.add_inbound(inbound).await // For now, just add - update logic would be more complex
}
/// Delete inbound from server
pub async fn delete_inbound(&self, endpoint: &str, tag: &str) -> Result<()> {
let client = self.get_or_create_client(endpoint).await?;
client.remove_inbound(tag).await
}
/// Remove inbound from server (alias for delete_inbound)
pub async fn remove_inbound(&self, _server_id: Uuid, endpoint: &str, tag: &str) -> Result<()> {
self.delete_inbound(endpoint, tag).await
}
/// Get cache statistics for monitoring
pub async fn get_cache_stats(&self) -> (usize, usize) {
let cache = self.connection_cache.read().await;
let total = cache.len();
let expired = cache
.values()
.filter(|conn| conn.is_expired(self.connection_ttl))
.count();
(total, expired)
}
/// Clear expired connections from cache
pub async fn clear_expired_connections(&self) {
let mut cache = self.connection_cache.write().await;
cache.retain(|_, conn| !conn.is_expired(self.connection_ttl));
}
/// Clear all connections from cache
pub async fn clear_cache(&self) {
let mut cache = self.connection_cache.write().await;
cache.clear();
}
}
// Additional methods that were in the original file but truncated
#[allow(dead_code)]
impl XrayService {
/// Generic method to execute operations on client with retry
async fn execute_with_retry<F, R>(&self, endpoint: &str, operation: F) -> Result<R>
where
F: Fn(XrayClient) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<R>> + Send>>,
{
let client = self.get_or_create_client(endpoint).await?;
operation(client).await
}
/// Sync user with Xray server - ensures user exists with correct config
pub async fn sync_user(
&self,
server_id: Uuid,
endpoint: &str,
desired_inbounds: &HashMap<String, crate::services::tasks::DesiredInbound>,
inbound_tag: &str,
user: &Value,
) -> Result<()> {
// Get single client for all operations
let client = self.get_or_create_client(endpoint).await?;
// Perform all operations with the same client
for (tag, desired) in desired_inbounds {
// Always try to remove inbound first (ignore errors if it doesn't exist)
let _ = client.remove_inbound(tag).await;
// Create inbound with users
let users_json: Vec<Value> = desired.users.iter().map(|user| {
serde_json::json!({
"id": user.id,
"email": user.email,
"level": user.level
})
}).collect();
// Build inbound config
let inbound_config = serde_json::json!({
"tag": desired.tag,
"port": desired.port,
"protocol": desired.protocol,
"settings": desired.settings,
"streamSettings": desired.stream_settings
});
match client.add_inbound_with_users_and_certificate(
&inbound_config,
&users_json,
desired.cert_pem.as_deref(),
desired.key_pem.as_deref(),
).await {
Err(e) => {
error!("Failed to create inbound {}: {}", tag, e);
}
_ => {}
}
let _server_id = server_id;
let _endpoint = endpoint;
let _inbound_tag = inbound_tag;
let _user = user;
// Implementation would go here
Ok(())
}
/// Batch operation to sync multiple users
pub async fn sync_users(
&self,
endpoint: &str,
inbound_tag: &str,
users: Vec<&Value>,
) -> Result<Vec<Result<()>>> {
let mut results = Vec::new();
for user in users {
let result = self.add_user(endpoint, inbound_tag, user).await;
results.push(result);
}
Ok(results)
}
/// Get user statistics for specific user
pub async fn get_user_stats(&self, endpoint: &str, user_email: &str) -> Result<Value> {
let pattern = format!("user>>>{}>>>traffic", user_email);
self.query_stats(endpoint, &pattern, false).await
}
/// Reset user statistics
pub async fn reset_user_stats(&self, endpoint: &str, user_email: &str) -> Result<Value> {
let pattern = format!("user>>>{}>>>traffic", user_email);
self.query_stats(endpoint, &pattern, true).await
}
/// Health check for server
pub async fn health_check(&self, endpoint: &str) -> Result<bool> {
match self.get_stats(endpoint).await {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
/// Sync server inbounds optimized (placeholder implementation)
pub async fn sync_server_inbounds_optimized(
&self,
_server_id: Uuid,
_endpoint: &str,
_desired_inbounds: &std::collections::HashMap<
String,
crate::services::tasks::DesiredInbound,
>,
) -> Result<()> {
// Placeholder implementation for tasks.rs compatibility
// In real implementation, this would:
// 1. Get current inbounds from server
// 2. Compare with desired inbounds
// 3. Add/remove/update as needed
Ok(())
}
}
impl Default for XrayService {
fn default() -> Self {
Self::new()
#[cfg(test)]
mod tests {
use super::*;
use tokio::time::Duration;
use uuid::Uuid;
#[tokio::test]
async fn test_xray_service_creation() {
let service = XrayService::new();
let (total, expired) = service.get_cache_stats().await;
assert_eq!(total, 0);
assert_eq!(expired, 0);
}
}
#[tokio::test]
async fn test_xray_service_with_custom_ttl() {
let custom_ttl = Duration::from_millis(100);
let service = XrayService::with_ttl(custom_ttl);
assert_eq!(service.connection_ttl, custom_ttl);
}
#[tokio::test]
async fn test_cache_expiration() {
let service = XrayService::with_ttl(Duration::from_millis(50));
// This test doesn't actually connect since we don't have a real Xray server
// but tests the caching logic structure
let (total, expired) = service.get_cache_stats().await;
assert_eq!(total, 0);
assert_eq!(expired, 0);
}
#[tokio::test]
async fn test_cache_clearing() {
let service = XrayService::new();
// Clear empty cache
service.clear_cache().await;
let (total, _) = service.get_cache_stats().await;
assert_eq!(total, 0);
// Clear expired connections from empty cache
service.clear_expired_connections().await;
let (total, _) = service.get_cache_stats().await;
assert_eq!(total, 0);
}
#[tokio::test]
async fn test_connection_timeout() {
let service = XrayService::new();
let server_id = Uuid::new_v4();
// Test with invalid endpoint - should return false due to connection failure
let result = service
.test_connection(server_id, "invalid://endpoint")
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), false);
}
#[tokio::test]
async fn test_health_check_with_invalid_endpoint() {
let service = XrayService::new();
// Test health check with invalid endpoint
let result = service.health_check("invalid://endpoint").await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), false);
}
#[test]
fn test_cached_connection_expiration() {
// Create a mock client for testing purposes
// In real tests, we would use a mock framework
let _now = Instant::now();
// Test the expiration logic directly without creating an actual client
let short_ttl = Duration::from_nanos(1);
let long_ttl = Duration::from_secs(1);
// Simulate time passage
let elapsed_short = Duration::from_nanos(10);
let elapsed_long = Duration::from_millis(10);
// Test expiration logic
assert!(elapsed_short > short_ttl);
assert!(elapsed_long < long_ttl);
}
#[tokio::test]
async fn test_user_stats_pattern_generation() {
let service = XrayService::new();
let user_email = "test@example.com";
// We can't test the actual stats call without a real server,
// but we can test that the method doesn't panic and returns an error for invalid endpoint
let result = service
.get_user_stats("invalid://endpoint", user_email)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_sync_users_empty_list() {
let service = XrayService::new();
let users: Vec<&serde_json::Value> = vec![];
let results = service
.sync_users("invalid://endpoint", "test_inbound", users)
.await;
assert!(results.is_ok());
assert_eq!(results.unwrap().len(), 0);
}
// Helper function for creating test user data
fn create_test_user() -> serde_json::Value {
serde_json::json!({
"email": "test@example.com",
"id": "test-user-id",
"level": 0
})
}
#[tokio::test]
async fn test_sync_users_with_data() {
let service = XrayService::new();
let user_data = create_test_user();
let users = vec![&user_data];
// This will fail due to invalid endpoint, but tests the structure
let results = service
.sync_users("invalid://endpoint", "test_inbound", users)
.await;
assert!(results.is_ok());
let results = results.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].is_err()); // Should fail due to invalid endpoint
}
}

View File

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

View File

@@ -1,25 +1,24 @@
use anyhow::{Result, anyhow};
use anyhow::{anyhow, Result};
use prost::Message;
use serde_json::Value;
use xray_core::{
tonic::Request,
app::proxyman::command::{AlterInboundRequest, AddUserOperation, RemoveUserOperation},
common::serial::TypedMessage,
app::proxyman::command::{AddUserOperation, AlterInboundRequest, RemoveUserOperation},
common::protocol::User,
common::serial::TypedMessage,
proxy::trojan::Account as TrojanAccount,
proxy::vless::Account as VlessAccount,
proxy::vmess::Account as VmessAccount,
proxy::trojan::Account as TrojanAccount,
tonic::Request,
Client,
};
use prost::Message;
pub struct UserClient<'a> {
endpoint: String,
client: &'a Client,
}
impl<'a> UserClient<'a> {
pub fn new(endpoint: String, client: &'a Client) -> Self {
Self { endpoint, client }
pub fn new(_endpoint: String, client: &'a Client) -> Self {
Self { client }
}
/// Add user to inbound (simple version that works)
@@ -28,11 +27,11 @@ impl<'a> UserClient<'a> {
let user_id = user["id"].as_str().unwrap_or("").to_string();
let level = user["level"].as_u64().unwrap_or(0) as u32;
let protocol = user["protocol"].as_str().unwrap_or("vless");
if email.is_empty() || user_id.is_empty() {
return Err(anyhow!("User email and id are required"));
}
// Create user account based on protocol
let account_message = match protocol {
"vless" => {
@@ -45,7 +44,7 @@ impl<'a> UserClient<'a> {
r#type: "xray.proxy.vless.Account".to_string(),
value: account.encode_to_vec(),
}
},
}
"vmess" => {
let account = VmessAccount {
id: user_id,
@@ -56,7 +55,7 @@ impl<'a> UserClient<'a> {
r#type: "xray.proxy.vmess.Account".to_string(),
value: account.encode_to_vec(),
}
},
}
"trojan" => {
let account = TrojanAccount {
password: user_id, // For trojan, use password instead of UUID
@@ -65,36 +64,35 @@ impl<'a> UserClient<'a> {
r#type: "xray.proxy.trojan.Account".to_string(),
value: account.encode_to_vec(),
}
},
}
_ => {
return Err(anyhow!("Unsupported protocol for user: {}", protocol));
}
};
// Create user protobuf message
let user_proto = User {
level: level,
email: email.clone(),
account: Some(account_message),
};
// Build the AddUserOperation
let add_user_op = AddUserOperation {
user: Some(user_proto),
};
let typed_message = TypedMessage {
r#type: "xray.app.proxyman.command.AddUserOperation".to_string(),
value: add_user_op.encode_to_vec(),
};
// Build the AlterInboundRequest
let request = Request::new(AlterInboundRequest {
tag: inbound_tag.to_string(),
operation: Some(typed_message),
});
let mut handler_client = self.client.handler();
match handler_client.alter_inbound(request).await {
Ok(response) => {
@@ -102,40 +100,57 @@ impl<'a> UserClient<'a> {
Ok(())
}
Err(e) => {
tracing::error!("gRPC error adding user '{}' to inbound '{}': status={}, message={}",
email, inbound_tag, e.code(), e.message());
Err(anyhow!("Failed to add user '{}' to inbound '{}': {}", email, inbound_tag, e))
tracing::error!(
"gRPC error adding user '{}' to inbound '{}': status={}, message={}",
email,
inbound_tag,
e.code(),
e.message()
);
Err(anyhow!(
"Failed to add user '{}' to inbound '{}': {}",
email,
inbound_tag,
e
))
}
}
}
/// Remove user from inbound
pub async fn remove_user(&self, inbound_tag: &str, email: &str) -> Result<()> {
// Build the RemoveUserOperation
let remove_user_op = RemoveUserOperation {
email: email.to_string(),
};
let typed_message = TypedMessage {
r#type: "xray.app.proxyman.command.RemoveUserOperation".to_string(),
value: remove_user_op.encode_to_vec(),
};
let request = Request::new(AlterInboundRequest {
tag: inbound_tag.to_string(),
operation: Some(typed_message),
});
let mut handler_client = self.client.handler();
match handler_client.alter_inbound(request).await {
Ok(_) => {
Ok(())
}
Ok(_) => Ok(()),
Err(e) => {
tracing::error!("Failed to remove user '{}' from inbound '{}': {}", email, inbound_tag, e);
Err(anyhow!("Failed to remove user '{}' from inbound '{}': {}", email, inbound_tag, e))
tracing::error!(
"Failed to remove user '{}' from inbound '{}': {}",
email,
inbound_tag,
e
);
Err(anyhow!(
"Failed to remove user '{}' from inbound '{}': {}",
email,
inbound_tag,
e
))
}
}
}
}
}

View File

@@ -1,3 +1,8 @@
use crate::{
database::{entities::certificate, repository::CertificateRepository},
services::certificates::CertificateService,
web::AppState,
};
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -6,27 +11,17 @@ use axum::{
};
use serde_json::json;
use uuid::Uuid;
use crate::{
database::{
entities::certificate,
repository::CertificateRepository,
},
services::certificates::CertificateService,
web::AppState,
};
/// List all certificates
pub async fn list_certificates(
State(app_state): State<AppState>,
) -> Result<Json<Vec<certificate::CertificateResponse>>, StatusCode> {
let repo = CertificateRepository::new(app_state.db.connection().clone());
match repo.find_all().await {
Ok(certificates) => {
let responses: Vec<certificate::CertificateResponse> = certificates
.into_iter()
.map(|c| c.into())
.collect();
let responses: Vec<certificate::CertificateResponse> =
certificates.into_iter().map(|c| c.into()).collect();
Ok(Json(responses))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -39,7 +34,7 @@ pub async fn get_certificate(
Path(id): Path<Uuid>,
) -> Result<Json<certificate::CertificateResponse>, StatusCode> {
let repo = CertificateRepository::new(app_state.db.connection().clone());
match repo.find_by_id(id).await {
Ok(Some(certificate)) => Ok(Json(certificate.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
@@ -53,7 +48,7 @@ pub async fn get_certificate_details(
Path(id): Path<Uuid>,
) -> Result<Json<certificate::CertificateDetailsResponse>, StatusCode> {
let repo = CertificateRepository::new(app_state.db.connection().clone());
match repo.find_by_id(id).await {
Ok(Some(certificate)) => Ok(Json(certificate.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
@@ -69,74 +64,99 @@ pub async fn create_certificate(
tracing::info!("Creating certificate: {:?}", cert_data);
let repo = CertificateRepository::new(app_state.db.connection().clone());
let cert_service = CertificateService::new();
// Generate certificate based on type
let (cert_pem, private_key) = match cert_data.cert_type.as_str() {
"self_signed" => {
cert_service.generate_self_signed(&cert_data.domain).await
.map_err(|e| {
tracing::error!("Failed to generate self-signed certificate: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"self_signed" => cert_service
.generate_self_signed(&cert_data.domain)
.await
.map_err(|e| {
tracing::error!("Failed to generate self-signed certificate: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Failed to generate self-signed certificate",
"details": format!("{:?}", e)
})))
})?
}
})),
)
})?,
"letsencrypt" => {
// Validate required fields for Let's Encrypt
let dns_provider_id = cert_data.dns_provider_id
.ok_or((StatusCode::BAD_REQUEST, Json(json!({
let dns_provider_id = cert_data.dns_provider_id.ok_or((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "DNS provider ID is required for Let's Encrypt certificates"
}))))?;
let acme_email = cert_data.acme_email
.as_ref()
.ok_or((StatusCode::BAD_REQUEST, Json(json!({
})),
))?;
let acme_email = cert_data.acme_email.as_ref().ok_or((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "ACME email is required for Let's Encrypt certificates"
}))))?;
})),
))?;
let cert_service = CertificateService::with_db(app_state.db.connection().clone());
cert_service.generate_letsencrypt_certificate(
&cert_data.domain,
dns_provider_id,
acme_email,
false // production by default
).await
cert_service
.generate_letsencrypt_certificate(
&cert_data.domain,
dns_provider_id,
acme_email,
false, // production by default
)
.await
.map_err(|e| {
tracing::error!("Failed to generate Let's Encrypt certificate: {:?}", e);
// Return a more detailed error response
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": "Failed to generate Let's Encrypt certificate",
"details": format!("{:?}", e)
})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Failed to generate Let's Encrypt certificate",
"details": format!("{:?}", e)
})),
)
})?
}
"imported" => {
// For imported certificates, use provided PEM data
if cert_data.certificate_pem.is_empty() || cert_data.private_key.is_empty() {
return Err((StatusCode::BAD_REQUEST, Json(json!({
"error": "Certificate PEM and private key are required for imported certificates"
}))));
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "Certificate PEM and private key are required for imported certificates"
})),
));
}
(cert_data.certificate_pem.clone(), cert_data.private_key.clone())
(
cert_data.certificate_pem.clone(),
cert_data.private_key.clone(),
)
}
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "Invalid certificate type. Supported types: self_signed, letsencrypt, imported"
})),
))
}
_ => return Err((StatusCode::BAD_REQUEST, Json(json!({
"error": "Invalid certificate type. Supported types: self_signed, letsencrypt, imported"
})))),
};
// Create certificate with generated data
let mut create_dto = cert_data;
create_dto.certificate_pem = cert_pem;
create_dto.private_key = private_key;
match repo.create(create_dto).await {
Ok(certificate) => Ok(Json(certificate.into())),
Err(e) => {
tracing::error!("Failed to save certificate to database: {:?}", e);
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": "Failed to save certificate to database",
"details": format!("{:?}", e)
}))))
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Failed to save certificate to database",
"details": format!("{:?}", e)
})),
))
}
}
}
@@ -148,7 +168,7 @@ pub async fn update_certificate(
JsonExtractor(cert_data): JsonExtractor<certificate::UpdateCertificateDto>,
) -> Result<Json<certificate::CertificateResponse>, StatusCode> {
let repo = CertificateRepository::new(app_state.db.connection().clone());
match repo.update(id, cert_data).await {
Ok(certificate) => Ok(Json(certificate.into())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -161,7 +181,7 @@ pub async fn delete_certificate(
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let repo = CertificateRepository::new(app_state.db.connection().clone());
match repo.delete(id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err(StatusCode::NOT_FOUND),
@@ -174,16 +194,14 @@ pub async fn get_expiring_certificates(
State(app_state): State<AppState>,
) -> Result<Json<Vec<certificate::CertificateResponse>>, StatusCode> {
let repo = CertificateRepository::new(app_state.db.connection().clone());
// Get certificates expiring in next 30 days
match repo.find_expiring_soon(30).await {
Ok(certificates) => {
let responses: Vec<certificate::CertificateResponse> = certificates
.into_iter()
.map(|c| c.into())
.collect();
let responses: Vec<certificate::CertificateResponse> =
certificates.into_iter().map(|c| c.into()).collect();
Ok(Json(responses))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
}

View File

@@ -34,18 +34,20 @@ pub async fn get_user_inbound_config(
) -> 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)
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)
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,
@@ -54,7 +56,7 @@ pub async fn get_user_inbound_config(
uri: client_config.uri,
qr_code: client_config.qr_code,
};
Ok(Json(response))
}
@@ -65,14 +67,15 @@ pub async fn get_user_configs(
) -> 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)
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) => {
@@ -84,35 +87,39 @@ pub async fn get_user_configs(
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)>,
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)
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 {
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 {
@@ -123,7 +130,7 @@ pub async fn get_inbound_configs(
uri: client_config.uri,
qr_code: client_config.qr_code,
});
},
}
Err(_) => {
// Log error but continue with other configs
continue;
@@ -131,6 +138,6 @@ pub async fn get_inbound_configs(
}
}
}
Ok(Json(responses))
}
}

View File

@@ -8,7 +8,7 @@ use uuid::Uuid;
use crate::{
database::{
entities::dns_provider::{
CreateDnsProviderDto, UpdateDnsProviderDto, DnsProviderResponseDto,
CreateDnsProviderDto, DnsProviderResponseDto, UpdateDnsProviderDto,
},
repository::DnsProviderRepository,
},
@@ -20,7 +20,7 @@ pub async fn create_dns_provider(
Json(dto): Json<CreateDnsProviderDto>,
) -> Result<Json<DnsProviderResponseDto>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.create(dto).await {
Ok(provider) => Ok(Json(provider.to_response_dto())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -31,13 +31,11 @@ pub async fn list_dns_providers(
State(state): State<AppState>,
) -> Result<Json<Vec<DnsProviderResponseDto>>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.find_all().await {
Ok(providers) => {
let responses: Vec<DnsProviderResponseDto> = providers
.into_iter()
.map(|p| p.to_response_dto())
.collect();
let responses: Vec<DnsProviderResponseDto> =
providers.into_iter().map(|p| p.to_response_dto()).collect();
Ok(Json(responses))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -49,7 +47,7 @@ pub async fn get_dns_provider(
Path(id): Path<Uuid>,
) -> Result<Json<DnsProviderResponseDto>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.find_by_id(id).await {
Ok(Some(provider)) => Ok(Json(provider.to_response_dto())),
Ok(None) => Err(StatusCode::NOT_FOUND),
@@ -63,7 +61,7 @@ pub async fn update_dns_provider(
Json(dto): Json<UpdateDnsProviderDto>,
) -> Result<Json<DnsProviderResponseDto>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.update(id, dto).await {
Ok(Some(updated_provider)) => Ok(Json(updated_provider.to_response_dto())),
Ok(None) => Err(StatusCode::NOT_FOUND),
@@ -76,7 +74,7 @@ pub async fn delete_dns_provider(
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.delete(id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err(StatusCode::NOT_FOUND),
@@ -88,15 +86,13 @@ pub async fn list_active_cloudflare_providers(
State(state): State<AppState>,
) -> Result<Json<Vec<DnsProviderResponseDto>>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.find_active_by_type("cloudflare").await {
Ok(providers) => {
let responses: Vec<DnsProviderResponseDto> = providers
.into_iter()
.map(|p| p.to_response_dto())
.collect();
let responses: Vec<DnsProviderResponseDto> =
providers.into_iter().map(|p| p.to_response_dto()).collect();
Ok(Json(responses))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
}

View File

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

View File

@@ -1,3 +1,13 @@
use crate::{
database::{
entities::{server, server_inbound},
repository::{
CertificateRepository, InboundTemplateRepository, InboundUsersRepository,
ServerInboundRepository, ServerRepository, UserRepository,
},
},
web::AppState,
};
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -5,26 +15,17 @@ use axum::{
Json as JsonExtractor,
};
use uuid::Uuid;
use crate::{
database::{
entities::{server, server_inbound},
repository::{ServerRepository, ServerInboundRepository, InboundTemplateRepository, CertificateRepository, InboundUsersRepository, UserRepository},
},
web::AppState,
};
/// List all servers
pub async fn list_servers(
State(app_state): State<AppState>,
) -> Result<Json<Vec<server::ServerResponse>>, StatusCode> {
let repo = ServerRepository::new(app_state.db.connection().clone());
match repo.find_all().await {
Ok(servers) => {
let responses: Vec<server::ServerResponse> = servers
.into_iter()
.map(|s| s.into())
.collect();
let responses: Vec<server::ServerResponse> =
servers.into_iter().map(|s| s.into()).collect();
Ok(Json(responses))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -37,7 +38,7 @@ pub async fn get_server(
Path(id): Path<Uuid>,
) -> Result<Json<server::ServerResponse>, StatusCode> {
let repo = ServerRepository::new(app_state.db.connection().clone());
match repo.find_by_id(id).await {
Ok(Some(server)) => Ok(Json(server.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
@@ -51,7 +52,7 @@ pub async fn create_server(
Json(server_data): Json<server::CreateServerDto>,
) -> Result<Json<server::ServerResponse>, StatusCode> {
let repo = ServerRepository::new(app_state.db.connection().clone());
match repo.create(server_data).await {
Ok(server) => Ok(Json(server.into())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -65,7 +66,7 @@ pub async fn update_server(
Json(server_data): Json<server::UpdateServerDto>,
) -> Result<Json<server::ServerResponse>, StatusCode> {
let repo = ServerRepository::new(app_state.db.connection().clone());
match repo.update(id, server_data).await {
Ok(server) => Ok(Json(server.into())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -78,7 +79,7 @@ pub async fn delete_server(
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let repo = ServerRepository::new(app_state.db.connection().clone());
match repo.delete(id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err(StatusCode::NOT_FOUND),
@@ -92,7 +93,7 @@ pub async fn test_server_connection(
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let repo = ServerRepository::new(app_state.db.connection().clone());
let server = match repo.find_by_id(id).await {
Ok(Some(server)) => server,
Ok(None) => return Err(StatusCode::NOT_FOUND),
@@ -100,7 +101,7 @@ pub async fn test_server_connection(
};
let endpoint = server.get_grpc_endpoint();
match app_state.xray_service.test_connection(id, &endpoint).await {
Ok(connected) => {
// Update server status based on connection test
@@ -114,14 +115,14 @@ pub async fn test_server_connection(
default_certificate_id: None,
status: Some(new_status.to_string()),
};
let _ = repo.update(id, update_dto).await; // Ignore update errors for now
Ok(Json(serde_json::json!({
"connected": connected,
"endpoint": endpoint
})))
},
}
Err(e) => {
// Update status to error
let update_dto = server::UpdateServerDto {
@@ -133,15 +134,15 @@ pub async fn test_server_connection(
default_certificate_id: None,
status: Some("error".to_string()),
};
let _ = repo.update(id, update_dto).await; // Ignore update errors for now
Ok(Json(serde_json::json!({
"connected": false,
"endpoint": endpoint,
"error": e.to_string()
})))
},
}
}
}
@@ -151,7 +152,7 @@ pub async fn get_server_stats(
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let repo = ServerRepository::new(app_state.db.connection().clone());
let server = match repo.find_by_id(id).await {
Ok(Some(server)) => server,
Ok(None) => return Err(StatusCode::NOT_FOUND),
@@ -159,8 +160,8 @@ pub async fn get_server_stats(
};
let endpoint = server.get_grpc_endpoint();
match app_state.xray_service.get_stats(id, &endpoint).await {
match app_state.xray_service.get_stats(&endpoint).await {
Ok(stats) => Ok(Json(stats)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
@@ -172,7 +173,7 @@ pub async fn list_server_inbounds(
Path(server_id): Path<Uuid>,
) -> Result<Json<Vec<server_inbound::ServerInboundResponse>>, StatusCode> {
let repo = ServerInboundRepository::new(app_state.db.connection().clone());
match repo.find_by_server_id_with_template(server_id).await {
Ok(responses) => Ok(Json(responses)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -186,51 +187,52 @@ pub async fn create_server_inbound(
JsonExtractor(inbound_data): JsonExtractor<server_inbound::CreateServerInboundDto>,
) -> Result<Json<server_inbound::ServerInboundResponse>, StatusCode> {
tracing::debug!("Creating server inbound for server {}", server_id);
let server_repo = ServerRepository::new(app_state.db.connection().clone());
let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone());
let template_repo = InboundTemplateRepository::new(app_state.db.connection().clone());
let cert_repo = CertificateRepository::new(app_state.db.connection().clone());
// Get server info
let server = match server_repo.find_by_id(server_id).await {
Ok(Some(server)) => server,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Get template info
let template = match template_repo.find_by_id(inbound_data.template_id).await {
Ok(Some(template)) => template,
Ok(None) => return Err(StatusCode::BAD_REQUEST),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Create inbound in database first with protocol-aware tag
let inbound = match inbound_repo.create_with_protocol(server_id, inbound_data, &template.protocol).await {
let inbound = match inbound_repo
.create_with_protocol(server_id, inbound_data, &template.protocol)
.await
{
Ok(inbound) => {
// Send sync event for immediate synchronization
crate::services::events::send_sync_event(
crate::services::events::SyncEvent::InboundChanged(server_id)
crate::services::events::SyncEvent::InboundChanged(server_id),
);
inbound
},
}
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Try to create inbound on xray server only if it's active
let endpoint = server.get_grpc_endpoint();
if inbound.is_active {
// Get certificate data if certificate is specified
let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id {
match cert_repo.find_by_id(cert_id).await {
Ok(Some(cert)) => {
(Some(cert.certificate_pem()), Some(cert.private_key_pem()))
},
Ok(Some(cert)) => (Some(cert.certificate_pem()), Some(cert.private_key_pem())),
Ok(None) => {
tracing::warn!("Certificate {} not found", cert_id);
(None, None)
},
}
Err(e) => {
tracing::error!("Error fetching certificate {}: {}", cert_id, e);
(None, None)
@@ -240,22 +242,31 @@ pub async fn create_server_inbound(
(None, None)
};
match app_state.xray_service.create_inbound_with_certificate(
server_id,
&endpoint,
&inbound.tag,
inbound.port_override.unwrap_or(template.default_port),
&template.protocol,
template.base_settings.clone(),
template.stream_settings.clone(),
cert_pem.as_deref(),
key_pem.as_deref(),
).await {
match app_state
.xray_service
.create_inbound_with_certificate(
server_id,
&endpoint,
&inbound.tag,
inbound.port_override.unwrap_or(template.default_port),
&template.protocol,
template.base_settings.clone(),
template.stream_settings.clone(),
cert_pem.as_deref(),
key_pem.as_deref(),
)
.await
{
Ok(_) => {
tracing::info!("Created inbound '{}' on {}", inbound.tag, endpoint);
},
}
Err(e) => {
tracing::error!("Failed to create inbound '{}' on {}: {}", inbound.tag, endpoint, e);
tracing::error!(
"Failed to create inbound '{}' on {}: {}",
inbound.tag,
endpoint,
e
);
// Note: We don't fail the request since the inbound is already in DB
// The user can manually sync or retry later
}
@@ -263,7 +274,7 @@ pub async fn create_server_inbound(
} else {
tracing::debug!("Inbound '{}' created as inactive", inbound.tag);
}
Ok(Json(inbound.into()))
}
@@ -273,20 +284,24 @@ pub async fn update_server_inbound(
Path((server_id, inbound_id)): Path<(Uuid, Uuid)>,
JsonExtractor(inbound_data): JsonExtractor<server_inbound::UpdateServerInboundDto>,
) -> Result<Json<server_inbound::ServerInboundResponse>, StatusCode> {
tracing::debug!("Updating server inbound {} for server {}", inbound_id, server_id);
tracing::debug!(
"Updating server inbound {} for server {}",
inbound_id,
server_id
);
let server_repo = ServerRepository::new(app_state.db.connection().clone());
let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone());
let template_repo = InboundTemplateRepository::new(app_state.db.connection().clone());
let cert_repo = CertificateRepository::new(app_state.db.connection().clone());
// Get server info
let server = match server_repo.find_by_id(server_id).await {
Ok(Some(server)) => server,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Get current inbound state
let current_inbound = match inbound_repo.find_by_id(inbound_id).await {
Ok(Some(inbound)) if inbound.server_id == server_id => inbound,
@@ -294,48 +309,64 @@ pub async fn update_server_inbound(
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Check if is_active status is changing
let old_is_active = current_inbound.is_active;
let new_is_active = inbound_data.is_active.unwrap_or(old_is_active);
let endpoint = server.get_grpc_endpoint();
// Handle xray server changes based on active status change
if old_is_active && !new_is_active {
// Becoming inactive - remove from xray server
match app_state.xray_service.remove_inbound(server_id, &endpoint, &current_inbound.tag).await {
match app_state
.xray_service
.remove_inbound(server_id, &endpoint, &current_inbound.tag)
.await
{
Ok(_) => {
tracing::info!("Deactivated inbound '{}' on {}", current_inbound.tag, endpoint);
},
tracing::info!(
"Deactivated inbound '{}' on {}",
current_inbound.tag,
endpoint
);
}
Err(e) => {
tracing::error!("Failed to deactivate inbound '{}': {}", current_inbound.tag, e);
tracing::error!(
"Failed to deactivate inbound '{}': {}",
current_inbound.tag,
e
);
// Continue with database update even if xray removal fails
}
}
} else if !old_is_active && new_is_active {
// Becoming active - add to xray server
// Get template info for recreation
let template = match template_repo.find_by_id(current_inbound.template_id).await {
Ok(Some(template)) => template,
Ok(None) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Use updated port if provided, otherwise keep current
let port = inbound_data.port_override.unwrap_or(current_inbound.port_override.unwrap_or(template.default_port));
let port = inbound_data.port_override.unwrap_or(
current_inbound
.port_override
.unwrap_or(template.default_port),
);
// Get certificate data if certificate is specified (could be updated)
let certificate_id = inbound_data.certificate_id.or(current_inbound.certificate_id);
let certificate_id = inbound_data
.certificate_id
.or(current_inbound.certificate_id);
let (cert_pem, key_pem) = if let Some(cert_id) = certificate_id {
match cert_repo.find_by_id(cert_id).await {
Ok(Some(cert)) => {
(Some(cert.certificate_pem()), Some(cert.private_key_pem()))
},
Ok(Some(cert)) => (Some(cert.certificate_pem()), Some(cert.private_key_pem())),
Ok(None) => {
tracing::warn!("Certificate {} not found", cert_id);
(None, None)
},
}
Err(e) => {
tracing::error!("Error fetching certificate {}: {}", cert_id, e);
(None, None)
@@ -344,37 +375,49 @@ pub async fn update_server_inbound(
} else {
(None, None)
};
match app_state.xray_service.create_inbound_with_certificate(
server_id,
&endpoint,
&current_inbound.tag,
port,
&template.protocol,
template.base_settings.clone(),
template.stream_settings.clone(),
cert_pem.as_deref(),
key_pem.as_deref(),
).await {
match app_state
.xray_service
.create_inbound_with_certificate(
server_id,
&endpoint,
&current_inbound.tag,
port,
&template.protocol,
template.base_settings.clone(),
template.stream_settings.clone(),
cert_pem.as_deref(),
key_pem.as_deref(),
)
.await
{
Ok(_) => {
tracing::info!("Activated inbound '{}' on {}", current_inbound.tag, endpoint);
},
tracing::info!(
"Activated inbound '{}' on {}",
current_inbound.tag,
endpoint
);
}
Err(e) => {
tracing::error!("Failed to activate inbound '{}': {}", current_inbound.tag, e);
tracing::error!(
"Failed to activate inbound '{}': {}",
current_inbound.tag,
e
);
// Continue with database update even if xray creation fails
}
}
}
// Update database
match inbound_repo.update(inbound_id, inbound_data).await {
Ok(updated_inbound) => {
// Send sync event for immediate synchronization
crate::services::events::send_sync_event(
crate::services::events::SyncEvent::InboundChanged(server_id)
crate::services::events::SyncEvent::InboundChanged(server_id),
);
Ok(Json(updated_inbound.into()))
},
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
@@ -385,12 +428,10 @@ pub async fn get_server_inbound(
Path((server_id, inbound_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<server_inbound::ServerInboundResponse>, StatusCode> {
let repo = ServerInboundRepository::new(app_state.db.connection().clone());
// Verify the inbound belongs to the server
match repo.find_by_id(inbound_id).await {
Ok(Some(inbound)) if inbound.server_id == server_id => {
Ok(Json(inbound.into()))
}
Ok(Some(inbound)) if inbound.server_id == server_id => Ok(Json(inbound.into())),
Ok(Some(_)) => Err(StatusCode::BAD_REQUEST),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -404,14 +445,14 @@ pub async fn delete_server_inbound(
) -> Result<StatusCode, StatusCode> {
let server_repo = ServerRepository::new(app_state.db.connection().clone());
let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone());
// Get server and inbound info
let server = match server_repo.find_by_id(server_id).await {
Ok(Some(server)) => server,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Verify the inbound belongs to the server
let inbound = match inbound_repo.find_by_id(inbound_id).await {
Ok(Some(inbound)) if inbound.server_id == server_id => inbound,
@@ -419,28 +460,37 @@ pub async fn delete_server_inbound(
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Try to remove inbound from xray server first
let endpoint = server.get_grpc_endpoint();
match app_state.xray_service.remove_inbound(server_id, &endpoint, &inbound.tag).await {
match app_state
.xray_service
.remove_inbound(server_id, &endpoint, &inbound.tag)
.await
{
Ok(_) => {
tracing::info!("Removed inbound '{}' from {}", inbound.tag, endpoint);
},
}
Err(e) => {
tracing::error!("Failed to remove inbound '{}' from {}: {}", inbound.tag, endpoint, e);
tracing::error!(
"Failed to remove inbound '{}' from {}: {}",
inbound.tag,
endpoint,
e
);
// Continue with database deletion even if xray removal fails
}
}
// Delete from database
match inbound_repo.delete(inbound_id).await {
Ok(true) => {
// Send sync event for immediate synchronization
crate::services::events::send_sync_event(
crate::services::events::SyncEvent::InboundChanged(server_id)
crate::services::events::SyncEvent::InboundChanged(server_id),
);
Ok(StatusCode::NO_CONTENT)
},
}
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
@@ -454,42 +504,43 @@ pub async fn add_user_to_inbound(
) -> Result<StatusCode, StatusCode> {
use crate::database::entities::inbound_users::CreateInboundUserDto;
use crate::database::entities::user::CreateUserDto;
let server_repo = ServerRepository::new(app_state.db.connection().clone());
let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone());
let user_repo = UserRepository::new(app_state.db.connection().clone());
// Get server and inbound to validate they exist
let _server = match server_repo.find_by_id(server_id).await {
Ok(Some(server)) => server,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let inbound = match inbound_repo.find_by_id(inbound_id).await {
Ok(Some(inbound)) => inbound,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Verify inbound belongs to server
if inbound.server_id != server_id {
return Err(StatusCode::BAD_REQUEST);
}
// Extract user data
let user_name = user_data["name"].as_str()
let user_name = user_data["name"]
.as_str()
.or_else(|| user_data["username"].as_str())
.or_else(|| user_data["email"].as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
format!("user_{}", Uuid::new_v4().to_string()[..8].to_string())
});
.unwrap_or_else(|| format!("user_{}", Uuid::new_v4().to_string()[..8].to_string()));
let level = user_data["level"].as_u64().unwrap_or(0) as i32;
let user_id = user_data["user_id"].as_str().and_then(|s| Uuid::parse_str(s).ok());
let user_id = user_data["user_id"]
.as_str()
.and_then(|s| Uuid::parse_str(s).ok());
// Get or create user
let user = if let Some(uid) = user_id {
// Use existing user
@@ -504,8 +555,9 @@ pub async fn add_user_to_inbound(
name: user_name.clone(),
comment: user_data["comment"].as_str().map(|s| s.to_string()),
telegram_id: user_data["telegram_id"].as_i64(),
is_telegram_admin: false,
};
match user_repo.create(create_user_dto).await {
Ok(user) => user,
Err(e) => {
@@ -514,36 +566,43 @@ pub async fn add_user_to_inbound(
}
}
};
// Create inbound user repository
let inbound_users_repo = InboundUsersRepository::new(app_state.db.connection().clone());
// Check if user already has access to this inbound
if inbound_users_repo.user_has_access_to_inbound(user.id, inbound_id).await.unwrap_or(false) {
if inbound_users_repo
.user_has_access_to_inbound(user.id, inbound_id)
.await
.unwrap_or(false)
{
tracing::warn!("User '{}' already has access to inbound", user.name);
return Err(StatusCode::CONFLICT);
}
// Create inbound access for user
let inbound_user_dto = CreateInboundUserDto {
user_id: user.id,
server_inbound_id: inbound_id,
level: Some(level),
};
// Grant access in database
match inbound_users_repo.create(inbound_user_dto).await {
Ok(created_access) => {
tracing::info!("Granted user '{}' access to inbound (xray_id={})",
user.name, created_access.xray_user_id);
tracing::info!(
"Granted user '{}' access to inbound (xray_id={})",
user.name,
created_access.xray_user_id
);
// Send sync event for immediate synchronization
crate::services::events::send_sync_event(
crate::services::events::SyncEvent::UserAccessChanged(server_id)
crate::services::events::SyncEvent::UserAccessChanged(server_id),
);
Ok(StatusCode::CREATED)
},
}
Err(e) => {
tracing::error!("Failed to grant user '{}' access: {}", user.name, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
@@ -558,44 +617,48 @@ pub async fn remove_user_from_inbound(
) -> Result<StatusCode, StatusCode> {
let server_repo = ServerRepository::new(app_state.db.connection().clone());
let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone());
// Get server and inbound
let server = match server_repo.find_by_id(server_id).await {
Ok(Some(server)) => server,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let inbound = match inbound_repo.find_by_id(inbound_id).await {
Ok(Some(inbound)) => inbound,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Verify inbound belongs to server
if inbound.server_id != server_id {
return Err(StatusCode::BAD_REQUEST);
}
// Get inbound tag
let template_repo = InboundTemplateRepository::new(app_state.db.connection().clone());
let template = match template_repo.find_by_id(inbound.template_id).await {
let _template = match template_repo.find_by_id(inbound.template_id).await {
Ok(Some(template)) => template,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let inbound_tag = &inbound.tag;
// Remove user from xray server
match app_state.xray_service.remove_user(server_id, &server.get_grpc_endpoint(), &inbound_tag, &email).await {
match app_state
.xray_service
.remove_user_with_server_id(server_id, &server.get_grpc_endpoint(), &inbound_tag, &email)
.await
{
Ok(_) => {
tracing::info!("Removed user '{}' from inbound", email);
Ok(StatusCode::NO_CONTENT)
},
}
Err(e) => {
tracing::error!("Failed to remove user '{}' from inbound: {}", email, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}

View File

@@ -0,0 +1,142 @@
use axum::{
extract::{Path, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use base64::{engine::general_purpose, Engine};
use uuid::Uuid;
use crate::{
database::repository::{InboundUsersRepository, UserRepository},
services::uri_generator::UriGeneratorService,
web::AppState,
};
/// Get subscription links for a user by their ID
/// Returns all configuration links for the user, one per line
/// Based on Django implementation for compatibility
pub async fn get_user_subscription(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
) -> Result<Response, StatusCode> {
let user_repo = UserRepository::new(state.db.connection());
let inbound_users_repo = InboundUsersRepository::new(state.db.connection().clone());
// Check if user exists
let user = match user_repo.get_by_id(user_id).await {
Ok(Some(user)) => user,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Get all client config data for the user (this gets all active inbound accesses)
let all_configs = match inbound_users_repo
.get_all_client_configs_for_user(user_id)
.await
{
Ok(configs) => configs,
Err(e) => {
tracing::error!("Failed to get client configs for user {}: {}", user_id, e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
if all_configs.is_empty() {
let response_text = "# No configurations available\n".to_string();
let response_base64 = general_purpose::STANDARD.encode(response_text);
return Ok((
StatusCode::OK,
[("content-type", "text/plain; charset=utf-8")],
response_base64,
)
.into_response());
}
let mut config_lines = Vec::new();
// Generate connection strings for each config using existing UriGeneratorService
let uri_generator = UriGeneratorService::new();
for config_data in all_configs {
match uri_generator.generate_client_config(user_id, &config_data) {
Ok(client_config) => {
config_lines.push(client_config.uri);
tracing::debug!(
"Generated {} config for user {}: {}",
config_data.protocol.to_uppercase(),
user.name,
config_data.template_name
);
}
Err(e) => {
tracing::warn!(
"Failed to generate connection string for user {} template {}: {}",
user.name,
config_data.template_name,
e
);
continue;
}
}
}
if config_lines.is_empty() {
let response_text = "# No valid configurations available\n".to_string();
let response_base64 = general_purpose::STANDARD.encode(response_text);
return Ok((
StatusCode::OK,
[("content-type", "text/plain; charset=utf-8")],
response_base64,
)
.into_response());
}
// Join all URIs with newlines (like Django implementation)
let response_text = config_lines.join("\n") + "\n";
// Encode the entire response in base64 (like Django implementation)
let response_base64 = general_purpose::STANDARD.encode(response_text);
// Build response with subscription headers (like Django)
let mut headers = HeaderMap::new();
// Add headers required by VPN clients
headers.insert(
"content-type",
HeaderValue::from_static("text/plain; charset=utf-8"),
);
headers.insert(
"content-disposition",
HeaderValue::from_str(&format!("attachment; filename=\"{}\"", user.name)).unwrap(),
);
headers.insert("cache-control", HeaderValue::from_static("no-cache"));
// Profile information
let profile_title = general_purpose::STANDARD.encode("OutFleet VPN");
headers.insert(
"profile-title",
HeaderValue::from_str(&format!("base64:{}", profile_title)).unwrap(),
);
headers.insert("profile-update-interval", HeaderValue::from_static("24"));
headers.insert(
"profile-web-page-url",
HeaderValue::from_str(&format!("{}/u/{}", state.config.web.base_url, user_id)).unwrap(),
);
headers.insert(
"support-url",
HeaderValue::from_str(&format!("{}/admin/", state.config.web.base_url)).unwrap(),
);
// Subscription info (unlimited service)
let expire_timestamp = chrono::Utc::now().timestamp() + (365 * 24 * 60 * 60); // 1 year from now
headers.insert(
"subscription-userinfo",
HeaderValue::from_str(&format!(
"upload=0; download=0; total=1099511627776; expire={}",
expire_timestamp
))
.unwrap(),
);
Ok((StatusCode::OK, headers, response_base64).into_response())
}

View File

@@ -1,8 +1,4 @@
use axum::{
extract::State,
http::StatusCode,
response::Json,
};
use axum::{extract::State, http::StatusCode, response::Json};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -40,47 +36,63 @@ pub struct TasksSummary {
/// Get status of all scheduled tasks
pub async fn get_tasks_status(
State(state): State<AppState>,
State(_state): State<AppState>,
) -> Result<Json<TasksStatusResponse>, StatusCode> {
// Get task status from the scheduler
// For now, we'll return a mock response since we need to expose the scheduler
// In a real implementation, you'd store a reference to the TaskScheduler in AppState
let mut tasks = HashMap::new();
let mut running_count = 0;
let mut success_count = 0;
let mut error_count = 0;
let mut idle_count = 0;
// Mock data for demonstration - in real implementation, get from TaskScheduler
let xray_sync_task = TaskStatusResponse {
name: "Xray Synchronization".to_string(),
description: "Synchronizes database state with xray servers".to_string(),
schedule: "0 */5 * * * * (every 5 minutes)".to_string(),
status: "Success".to_string(),
last_run: Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string()),
next_run: Some((chrono::Utc::now() + chrono::Duration::minutes(5)).format("%Y-%m-%d %H:%M:%S UTC").to_string()),
last_run: Some(
chrono::Utc::now()
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
),
next_run: Some(
(chrono::Utc::now() + chrono::Duration::minutes(5))
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
),
total_runs: 120,
success_count: 118,
error_count: 2,
last_error: None,
last_duration_ms: Some(1234),
};
let cert_renewal_task = TaskStatusResponse {
name: "Certificate Renewal".to_string(),
description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(),
schedule: "0 0 2 * * * (daily at 2 AM)".to_string(),
status: "Idle".to_string(),
last_run: Some((chrono::Utc::now() - chrono::Duration::hours(8)).format("%Y-%m-%d %H:%M:%S UTC").to_string()),
next_run: Some((chrono::Utc::now() + chrono::Duration::hours(16)).format("%Y-%m-%d %H:%M:%S UTC").to_string()),
last_run: Some(
(chrono::Utc::now() - chrono::Duration::hours(8))
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
),
next_run: Some(
(chrono::Utc::now() + chrono::Duration::hours(16))
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
),
total_runs: 5,
success_count: 5,
error_count: 0,
last_error: None,
last_duration_ms: Some(567),
};
// Count task statuses
match xray_sync_task.status.as_str() {
"Running" => running_count += 1,
@@ -89,7 +101,7 @@ pub async fn get_tasks_status(
"Idle" => idle_count += 1,
_ => idle_count += 1,
}
match cert_renewal_task.status.as_str() {
"Running" => running_count += 1,
"Success" => success_count += 1,
@@ -97,10 +109,10 @@ pub async fn get_tasks_status(
"Idle" => idle_count += 1,
_ => idle_count += 1,
}
tasks.insert("xray_sync".to_string(), xray_sync_task);
tasks.insert("cert_renewal".to_string(), cert_renewal_task);
let summary = TasksSummary {
total_tasks: tasks.len(),
running_tasks: running_count,
@@ -108,9 +120,9 @@ pub async fn get_tasks_status(
failed_tasks: error_count,
idle_tasks: idle_count,
};
let response = TasksStatusResponse { tasks, summary };
Ok(Json(response))
}
@@ -122,14 +134,10 @@ pub async fn trigger_task(
// In a real implementation, you'd trigger the actual task
// For now, return a success response
match task_id.as_str() {
"xray_sync" | "cert_renewal" => {
Ok(Json(serde_json::json!({
"success": true,
"message": format!("Task '{}' has been triggered", task_id)
})))
}
_ => {
Err(StatusCode::NOT_FOUND)
}
"xray_sync" | "cert_renewal" => Ok(Json(serde_json::json!({
"success": true,
"message": format!("Task '{}' has been triggered", task_id)
}))),
_ => Err(StatusCode::NOT_FOUND),
}
}
}

View File

@@ -0,0 +1,296 @@
use axum::{
extract::{Json, Path, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::database::entities::telegram_config::{
CreateTelegramConfigDto, UpdateTelegramConfigDto,
};
use crate::database::repository::{TelegramConfigRepository, UserRepository};
use crate::web::AppState;
/// Response for Telegram config
#[derive(Debug, Serialize)]
pub struct TelegramConfigResponse {
pub id: Uuid,
pub is_active: bool,
pub bot_info: Option<BotInfo>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize)]
pub struct BotInfo {
pub username: String,
pub first_name: String,
}
/// Get current Telegram configuration
pub async fn get_telegram_config(State(state): State<AppState>) -> impl IntoResponse {
let repo = TelegramConfigRepository::new(state.db.connection());
match repo.get_latest().await {
Ok(Some(config)) => {
let mut response = TelegramConfigResponse {
id: config.id,
is_active: config.is_active,
bot_info: None,
created_at: config.created_at.to_rfc3339(),
updated_at: config.updated_at.to_rfc3339(),
};
// Get bot info if active
if config.is_active {
if let Ok(status) = get_bot_status(&state).await {
response.bot_info = status.bot_info;
}
}
Json(response).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("Failed to get telegram config: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Create new Telegram configuration
pub async fn create_telegram_config(
State(state): State<AppState>,
Json(dto): Json<CreateTelegramConfigDto>,
) -> impl IntoResponse {
let repo = TelegramConfigRepository::new(state.db.connection());
match repo.create(dto).await {
Ok(config) => {
// Initialize telegram service with new config if active
if config.is_active {
if let Some(telegram_service) = &state.telegram_service {
let _ = telegram_service.update_config(config.id).await;
}
}
(StatusCode::CREATED, Json(config)).into_response()
}
Err(e) => {
tracing::error!("Failed to create telegram config: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Update Telegram configuration
pub async fn update_telegram_config(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(dto): Json<UpdateTelegramConfigDto>,
) -> impl IntoResponse {
let repo = TelegramConfigRepository::new(state.db.connection());
match repo.update(id, dto).await {
Ok(Some(config)) => {
// Update telegram service
if let Some(telegram_service) = &state.telegram_service {
let _ = telegram_service.update_config(config.id).await;
}
Json(config).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("Failed to update telegram config: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Delete Telegram configuration
pub async fn delete_telegram_config(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let repo = TelegramConfigRepository::new(state.db.connection());
// Stop bot if this config is active
if let Ok(Some(config)) = repo.find_by_id(id).await {
if config.is_active {
if let Some(telegram_service) = &state.telegram_service {
let _ = telegram_service.stop().await;
}
}
}
match repo.delete(id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("Failed to delete telegram config: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Get Telegram bot status
#[derive(Debug, Serialize)]
pub struct BotStatusResponse {
pub is_running: bool,
pub bot_info: Option<BotInfo>,
}
async fn get_bot_status(state: &AppState) -> Result<BotStatusResponse, String> {
if let Some(telegram_service) = &state.telegram_service {
let status = telegram_service.get_status().await;
let bot_info = if status.is_running {
// In production, you would get this from the bot API
Some(BotInfo {
username: "bot".to_string(),
first_name: "Bot".to_string(),
})
} else {
None
};
Ok(BotStatusResponse {
is_running: status.is_running,
bot_info,
})
} else {
Ok(BotStatusResponse {
is_running: false,
bot_info: None,
})
}
}
pub async fn get_telegram_status(State(state): State<AppState>) -> impl IntoResponse {
match get_bot_status(&state).await {
Ok(status) => Json(status).into_response(),
Err(e) => {
tracing::error!("Failed to get bot status: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Get list of Telegram admins
#[derive(Debug, Serialize)]
pub struct TelegramAdmin {
pub user_id: Uuid,
pub name: String,
pub telegram_id: Option<i64>,
}
pub async fn get_telegram_admins(State(state): State<AppState>) -> impl IntoResponse {
let repo = UserRepository::new(state.db.connection());
match repo.get_telegram_admins().await {
Ok(admins) => {
let response: Vec<TelegramAdmin> = admins
.into_iter()
.map(|u| TelegramAdmin {
user_id: u.id,
name: u.name,
telegram_id: u.telegram_id,
})
.collect();
Json(response).into_response()
}
Err(e) => {
tracing::error!("Failed to get telegram admins: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Add Telegram admin
pub async fn add_telegram_admin(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
) -> impl IntoResponse {
let repo = UserRepository::new(state.db.connection());
match repo.set_telegram_admin(user_id, true).await {
Ok(Some(user)) => {
// Notify via Telegram if bot is running
if let Some(telegram_service) = &state.telegram_service {
if let Some(telegram_id) = user.telegram_id {
let _ = telegram_service
.send_message(
telegram_id,
"✅ You have been granted admin privileges!".to_string(),
)
.await;
}
}
Json(user).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("Failed to add telegram admin: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Remove Telegram admin
pub async fn remove_telegram_admin(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
) -> impl IntoResponse {
let repo = UserRepository::new(state.db.connection());
match repo.set_telegram_admin(user_id, false).await {
Ok(Some(user)) => {
// Notify via Telegram if bot is running
if let Some(telegram_service) = &state.telegram_service {
if let Some(telegram_id) = user.telegram_id {
let _ = telegram_service
.send_message(
telegram_id,
"❌ Your admin privileges have been revoked.".to_string(),
)
.await;
}
}
Json(user).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("Failed to remove telegram admin: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Send test message
#[derive(Debug, Deserialize)]
pub struct SendMessageRequest {
pub chat_id: i64,
pub text: String,
}
pub async fn send_test_message(
State(state): State<AppState>,
Json(req): Json<SendMessageRequest>,
) -> impl IntoResponse {
if let Some(telegram_service) = &state.telegram_service {
match telegram_service.send_message(req.chat_id, req.text).await {
Ok(_) => StatusCode::OK.into_response(),
Err(e) => {
tracing::error!("Failed to send test message: {}", e);
(StatusCode::BAD_REQUEST, e.to_string()).into_response()
}
}
} else {
StatusCode::SERVICE_UNAVAILABLE.into_response()
}
}

View File

@@ -1,3 +1,7 @@
use crate::{
database::{entities::inbound_template, repository::InboundTemplateRepository},
web::AppState,
};
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -5,26 +9,17 @@ use axum::{
Json as JsonExtractor,
};
use uuid::Uuid;
use crate::{
database::{
entities::inbound_template,
repository::InboundTemplateRepository,
},
web::AppState,
};
/// List all inbound templates
pub async fn list_templates(
State(app_state): State<AppState>,
) -> Result<Json<Vec<inbound_template::InboundTemplateResponse>>, StatusCode> {
let repo = InboundTemplateRepository::new(app_state.db.connection().clone());
match repo.find_all().await {
Ok(templates) => {
let responses: Vec<inbound_template::InboundTemplateResponse> = templates
.into_iter()
.map(|t| t.into())
.collect();
let responses: Vec<inbound_template::InboundTemplateResponse> =
templates.into_iter().map(|t| t.into()).collect();
Ok(Json(responses))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -37,7 +32,7 @@ pub async fn get_template(
Path(id): Path<Uuid>,
) -> Result<Json<inbound_template::InboundTemplateResponse>, StatusCode> {
let repo = InboundTemplateRepository::new(app_state.db.connection().clone());
match repo.find_by_id(id).await {
Ok(Some(template)) => Ok(Json(template.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
@@ -52,7 +47,7 @@ pub async fn create_template(
) -> Result<Json<inbound_template::InboundTemplateResponse>, StatusCode> {
tracing::info!("Creating template: {:?}", template_data);
let repo = InboundTemplateRepository::new(app_state.db.connection().clone());
match repo.create(template_data).await {
Ok(template) => Ok(Json(template.into())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -66,7 +61,7 @@ pub async fn update_template(
JsonExtractor(template_data): JsonExtractor<inbound_template::UpdateInboundTemplateDto>,
) -> Result<Json<inbound_template::InboundTemplateResponse>, StatusCode> {
let repo = InboundTemplateRepository::new(app_state.db.connection().clone());
match repo.update(id, template_data).await {
Ok(template) => Ok(Json(template.into())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
@@ -79,10 +74,10 @@ pub async fn delete_template(
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let repo = InboundTemplateRepository::new(app_state.db.connection().clone());
match repo.delete(id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
}

View File

@@ -0,0 +1,314 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
database::repository::UserRequestRepository,
services::telegram::localization::{Language, LocalizationService},
web::AppState,
};
#[derive(Debug, Deserialize)]
pub struct RequestsQuery {
#[serde(default = "default_page")]
page: u64,
#[serde(default = "default_per_page")]
per_page: u64,
#[serde(default)]
status: Option<String>,
}
fn default_page() -> u64 {
1
}
fn default_per_page() -> u64 {
20
}
#[derive(Debug, Serialize)]
pub struct RequestsResponse {
items: Vec<UserRequestResponse>,
total: u64,
page: u64,
per_page: u64,
}
#[derive(Debug, Serialize)]
pub struct UserRequestResponse {
id: Uuid,
user_id: Option<Uuid>,
telegram_id: i64,
telegram_username: Option<String>,
telegram_first_name: Option<String>,
telegram_last_name: Option<String>,
full_name: String,
telegram_link: String,
status: String,
request_message: Option<String>,
response_message: Option<String>,
processed_by_user_id: Option<Uuid>,
processed_at: Option<chrono::DateTime<chrono::Utc>>,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<crate::database::entities::user_request::Model> for UserRequestResponse {
fn from(model: crate::database::entities::user_request::Model) -> Self {
Self {
id: model.id,
user_id: model.user_id,
telegram_id: model.telegram_id,
telegram_username: model.telegram_username.clone(),
telegram_first_name: model.telegram_first_name.clone(),
telegram_last_name: model.telegram_last_name.clone(),
full_name: model.get_full_name(),
telegram_link: model.get_telegram_link(),
status: model.status,
request_message: model.request_message,
response_message: model.response_message,
processed_by_user_id: model.processed_by_user_id,
processed_at: model.processed_at.map(|dt| dt.into()),
created_at: model.created_at.into(),
updated_at: model.updated_at.into(),
}
}
}
/// Get user requests with pagination
pub async fn get_requests(
State(state): State<AppState>,
Query(query): Query<RequestsQuery>,
) -> Result<Json<RequestsResponse>, StatusCode> {
let request_repo = UserRequestRepository::new(state.db.connection());
let (items, total) = if let Some(status) = query.status {
// Filter by status
match status.as_str() {
"pending" => request_repo
.find_pending(query.page, query.per_page)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
_ => request_repo
.find_all(query.page, query.per_page)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
}
} else {
request_repo
.find_all(query.page, query.per_page)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
};
let items: Vec<UserRequestResponse> = items.into_iter().map(Into::into).collect();
Ok(Json(RequestsResponse {
items,
total,
page: query.page,
per_page: query.per_page,
}))
}
/// Get a single user request
pub async fn get_request(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<UserRequestResponse>, StatusCode> {
let request_repo = UserRequestRepository::new(state.db.connection());
match request_repo.find_by_id(id).await {
Ok(Some(request)) => Ok(Json(UserRequestResponse::from(request))),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
#[derive(Debug, Deserialize)]
pub struct ApproveRequestDto {
response_message: Option<String>,
}
/// Approve a user request
pub async fn approve_request(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(dto): Json<ApproveRequestDto>,
) -> Result<Json<UserRequestResponse>, StatusCode> {
let request_repo = UserRequestRepository::new(state.db.connection());
let user_repo = crate::database::repository::UserRepository::new(state.db.connection());
// Get the request
let request = match request_repo.find_by_id(id).await {
Ok(Some(request)) => request,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Check if already processed
if request.status != "pending" {
return Err(StatusCode::BAD_REQUEST);
}
// Create user account
let username = request.telegram_username.as_deref().unwrap_or("Unknown");
let user_dto = crate::database::entities::user::CreateUserDto {
name: request.get_full_name(),
comment: Some(format!("Telegram user: @{}", username)),
telegram_id: Some(request.telegram_id),
is_telegram_admin: false,
};
match user_repo.create(user_dto).await {
Ok(new_user) => {
// Get the first admin user ID (for web approvals we don't have a specific admin)
// In a real application, this would come from the authenticated session
let admin_id = match user_repo.get_first_admin().await {
Ok(Some(admin)) => admin.id,
_ => {
// Use a default ID if no admin found
Uuid::new_v4()
}
};
// Approve the request
let approved = match request_repo
.approve(id, dto.response_message, admin_id)
.await
{
Ok(Some(approved)) => approved,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Send main menu to the user instead of just notification
if let Some(telegram_service) = &state.telegram_service {
let user_lang = Language::from_telegram_code(Some(&request.get_language()));
let l10n = LocalizationService::new();
// Check if user is admin (new users are not admins by default)
let is_admin = false;
// Build main menu keyboard
let keyboard = if is_admin {
vec![
vec![teloxide::types::InlineKeyboardButton::callback(
l10n.get(user_lang.clone(), "my_configs"),
"my_configs",
)],
vec![teloxide::types::InlineKeyboardButton::callback(
l10n.get(user_lang.clone(), "support"),
"support",
)],
vec![teloxide::types::InlineKeyboardButton::callback(
l10n.get(user_lang.clone(), "user_requests"),
"admin_requests",
)],
]
} else {
vec![
vec![teloxide::types::InlineKeyboardButton::callback(
l10n.get(user_lang.clone(), "my_configs"),
"my_configs",
)],
vec![teloxide::types::InlineKeyboardButton::callback(
l10n.get(user_lang.clone(), "support"),
"support",
)],
]
};
let keyboard_markup = teloxide::types::InlineKeyboardMarkup::new(keyboard);
let message = l10n.format(user_lang, "welcome_back", &[("name", &new_user.name)]);
// Send message with keyboard
let _ = telegram_service
.send_message_with_keyboard(request.telegram_id, message, keyboard_markup)
.await;
}
Ok(Json(UserRequestResponse::from(approved)))
}
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
#[derive(Debug, Deserialize)]
pub struct DeclineRequestDto {
response_message: Option<String>,
}
/// Decline a user request
pub async fn decline_request(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(dto): Json<DeclineRequestDto>,
) -> Result<Json<UserRequestResponse>, StatusCode> {
let request_repo = UserRequestRepository::new(state.db.connection());
// Get the request
let request = match request_repo.find_by_id(id).await {
Ok(Some(request)) => request,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Check if already processed
if request.status != "pending" {
return Err(StatusCode::BAD_REQUEST);
}
// Get the first admin user ID (for web declines we don't have a specific admin)
let user_repo = crate::database::repository::UserRepository::new(state.db.connection());
let admin_id = match user_repo.get_first_admin().await {
Ok(Some(admin)) => admin.id,
_ => {
// Use a default ID if no admin found
Uuid::new_v4()
}
};
// Decline the request
let declined = match request_repo
.decline(id, dto.response_message, admin_id)
.await
{
Ok(Some(declined)) => declined,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Send Telegram notification to user
if let Some(telegram_service) = &state.telegram_service {
let user_lang = Language::from_telegram_code(Some(&request.get_language()));
let l10n = LocalizationService::new();
let user_message = l10n.get(user_lang, "request_declined_notification");
// Send notification (ignore errors - don't fail the request)
let _ = telegram_service
.send_message(request.telegram_id, user_message)
.await;
}
Ok(Json(UserRequestResponse::from(declined)))
}
/// Delete a user request
pub async fn delete_request(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let request_repo = UserRequestRepository::new(state.db.connection());
match request_repo.delete(id).await {
Ok(true) => Ok(Json(
serde_json::json!({ "message": "User request deleted" }),
)),
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}

View File

@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use uuid::Uuid;
use crate::database::entities::user::{CreateUserDto, UpdateUserDto, Model as UserModel};
use crate::database::entities::user::{CreateUserDto, Model as UserModel, UpdateUserDto};
use crate::database::repository::UserRepository;
use crate::web::AppState;
@@ -45,8 +45,12 @@ pub struct UserResponse {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
fn default_page() -> u64 { 1 }
fn default_per_page() -> u64 { 20 }
fn default_page() -> u64 {
1
}
fn default_per_page() -> u64 {
20
}
impl From<UserModel> for UserResponse {
fn from(user: UserModel) -> Self {
@@ -67,12 +71,14 @@ pub async fn get_users(
Query(query): Query<PaginationQuery>,
) -> Result<Json<UsersResponse>, StatusCode> {
let repo = UserRepository::new(app_state.db.connection().clone());
let users = repo.get_all(query.page, query.per_page)
let users = repo
.get_all(query.page, query.per_page)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let total = repo.count()
let total = repo
.count()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -92,7 +98,7 @@ pub async fn search_users(
Query(query): Query<SearchQuery>,
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
let repo = UserRepository::new(app_state.db.connection().clone());
let users = if let Some(search_query) = query.q {
// Search by name, telegram_id, or UUID
repo.search(&search_query)
@@ -113,8 +119,9 @@ pub async fn get_user(
Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, StatusCode> {
let repo = UserRepository::new(app_state.db.connection().clone());
let user = repo.get_by_id(id)
let user = repo
.get_by_id(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -130,19 +137,21 @@ pub async fn create_user(
JsonExtractor(dto): JsonExtractor<CreateUserDto>,
) -> Result<Json<UserResponse>, StatusCode> {
let repo = UserRepository::new(app_state.db.connection().clone());
// Check if telegram ID is already in use
if let Some(telegram_id) = dto.telegram_id {
let exists = repo.telegram_id_exists(telegram_id)
let exists = repo
.telegram_id_exists(telegram_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if exists {
return Err(StatusCode::CONFLICT);
}
}
let user = repo.create(dto)
let user = repo
.create(dto)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -156,18 +165,22 @@ pub async fn update_user(
JsonExtractor(dto): JsonExtractor<UpdateUserDto>,
) -> Result<Json<UserResponse>, StatusCode> {
let repo = UserRepository::new(app_state.db.connection().clone());
// Check if telegram ID is already in use by another user
if let Some(telegram_id) = dto.telegram_id {
if let Some(existing_user) = repo.get_by_telegram_id(telegram_id).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? {
if let Some(existing_user) = repo
.get_by_telegram_id(telegram_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
if existing_user.id != id {
return Err(StatusCode::CONFLICT);
}
}
}
let user = repo.update(id, dto)
let user = repo
.update(id, dto)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -183,8 +196,9 @@ pub async fn delete_user(
Path(id): Path<Uuid>,
) -> Result<Json<Value>, StatusCode> {
let repo = UserRepository::new(app_state.db.connection().clone());
let deleted = repo.delete(id)
let deleted = repo
.delete(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -203,19 +217,19 @@ pub async fn get_user_access(
) -> 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());
let access_list = inbound_users_repo
.find_by_user_id(user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
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,
@@ -225,37 +239,43 @@ pub async fn get_user_access(
"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) {
.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);
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,
}))
.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

@@ -1,11 +1,5 @@
use anyhow::Result;
use axum::{
Router,
routing::get,
http::StatusCode,
response::Json,
serve,
};
use axum::{http::StatusCode, response::Json, routing::get, serve, Router};
use serde_json::{json, Value};
use std::net::SocketAddr;
use tokio::net::TcpListener;
@@ -13,9 +7,10 @@ use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use tracing::info;
use crate::config::WebConfig;
use crate::config::AppConfig;
use crate::database::DatabaseManager;
use crate::services::XrayService;
use crate::services::{TelegramService, XrayService};
use std::sync::Arc;
pub mod handlers;
pub mod routes;
@@ -26,19 +21,24 @@ use routes::api_routes;
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseManager,
#[allow(dead_code)]
pub config: WebConfig,
pub config: AppConfig,
pub xray_service: XrayService,
pub telegram_service: Option<Arc<TelegramService>>,
}
/// Start the web server
pub async fn start_server(db: DatabaseManager, config: WebConfig) -> Result<()> {
pub async fn start_server(
db: DatabaseManager,
config: AppConfig,
telegram_service: Option<Arc<TelegramService>>,
) -> Result<()> {
let xray_service = XrayService::new();
let app_state = AppState {
db,
config: config.clone(),
xray_service,
telegram_service,
};
// Serve static files
@@ -46,12 +46,13 @@ pub async fn start_server(db: DatabaseManager, config: WebConfig) -> Result<()>
let app = Router::new()
.route("/health", get(health_check))
.route("/sub/:user_id", get(handlers::get_user_subscription))
.nest("/api", api_routes())
.nest_service("/", serve_dir)
.layer(CorsLayer::permissive())
.with_state(app_state);
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let addr: SocketAddr = format!("{}:{}", config.web.host, config.web.port).parse()?;
info!("Starting web server on {}", addr);
let listener = TcpListener::bind(&addr).await?;
@@ -67,4 +68,4 @@ async fn health_check() -> Result<Json<Value>, StatusCode> {
"service": "xray-admin",
"version": env!("CARGO_PKG_VERSION")
})))
}
}

View File

@@ -1,9 +1,9 @@
use axum::{
Router,
routing::{get, post},
Router,
};
use crate::web::{AppState, handlers};
use crate::web::{handlers, AppState};
pub mod servers;
@@ -16,6 +16,8 @@ pub fn api_routes() -> Router<AppState> {
.nest("/templates", servers::template_routes())
.nest("/dns-providers", dns_provider_routes())
.nest("/tasks", task_routes())
.nest("/telegram", telegram_routes())
.nest("/user-requests", user_request_routes())
}
/// User management routes
@@ -23,22 +25,37 @@ fn user_routes() -> Router<AppState> {
Router::new()
.route("/", get(handlers::get_users).post(handlers::create_user))
.route("/search", get(handlers::search_users))
.route("/:id", get(handlers::get_user)
.put(handlers::update_user)
.delete(handlers::delete_user))
.route(
"/:id",
get(handlers::get_user)
.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))
.route(
"/:user_id/access/:inbound_id/config",
get(handlers::get_user_inbound_config),
)
}
/// DNS Provider management routes
fn dns_provider_routes() -> Router<AppState> {
Router::new()
.route("/", get(handlers::list_dns_providers).post(handlers::create_dns_provider))
.route("/:id", get(handlers::get_dns_provider)
.put(handlers::update_dns_provider)
.delete(handlers::delete_dns_provider))
.route("/cloudflare/active", get(handlers::list_active_cloudflare_providers))
.route(
"/",
get(handlers::list_dns_providers).post(handlers::create_dns_provider),
)
.route(
"/:id",
get(handlers::get_dns_provider)
.put(handlers::update_dns_provider)
.delete(handlers::delete_dns_provider),
)
.route(
"/cloudflare/active",
get(handlers::list_active_cloudflare_providers),
)
}
/// Task management routes
@@ -46,4 +63,38 @@ fn task_routes() -> Router<AppState> {
Router::new()
.route("/", get(handlers::get_tasks_status))
.route("/:id/trigger", post(handlers::trigger_task))
}
}
/// Telegram bot management routes
fn telegram_routes() -> Router<AppState> {
Router::new()
.route(
"/config",
get(handlers::get_telegram_config).post(handlers::create_telegram_config),
)
.route(
"/config/:id",
get(handlers::get_telegram_config)
.put(handlers::update_telegram_config)
.delete(handlers::delete_telegram_config),
)
.route("/status", get(handlers::get_telegram_status))
.route("/admins", get(handlers::get_telegram_admins))
.route(
"/admins/:user_id",
post(handlers::add_telegram_admin).delete(handlers::remove_telegram_admin),
)
.route("/send", post(handlers::send_test_message))
}
/// User request management routes
fn user_request_routes() -> Router<AppState> {
Router::new()
.route("/", get(handlers::get_requests))
.route(
"/:id",
get(handlers::get_request).delete(handlers::delete_request),
)
.route("/:id/approve", post(handlers::approve_request))
.route("/:id/decline", post(handlers::decline_request))
}

View File

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

File diff suppressed because it is too large Load Diff