mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-12-16 17:37:51 +00:00
Compare commits
18 Commits
rust-react
...
RUST
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cabdd3aad3 | ||
|
|
8d10a4deef | ||
|
|
eaf7f95cd3 | ||
|
|
b96c520f9d | ||
|
|
656209ee6e | ||
|
|
c189562ac2 | ||
|
|
856dcc9f44 | ||
|
|
5d826545b0 | ||
|
|
b9f0687788 | ||
|
|
2efd5873d5 | ||
|
|
c05d2f6223 | ||
|
|
7e8831b89e | ||
|
|
78bf75b24e | ||
|
|
c6892b1a73 | ||
|
|
dae787657c | ||
|
|
d80ac56b83 | ||
|
|
d972f10f83 | ||
|
|
42c8016d9c |
61
.github/workflows/rust.yml
vendored
Normal file
61
.github/workflows/rust.yml
vendored
Normal 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
174
API.md
@@ -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
593
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
14
Cargo.toml
14
Cargo.toml
@@ -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"
|
||||
|
||||
64
Dockerfile
64
Dockerfile
@@ -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"]
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
96
src/database/entities/telegram_config.rs
Normal file
96
src/database/entities/telegram_config.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
174
src/database/entities/user_request.rs
Normal file
174
src/database/entities/user_request.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
169
src/database/repository/telegram_config.rs
Normal file
169
src/database/repository/telegram_config.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
164
src/database/repository/user_request.rs
Normal file
164
src/database/repository/user_request.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
37
src/main.rs
37
src/main.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ pub mod error;
|
||||
|
||||
pub use client::AcmeClient;
|
||||
pub use cloudflare::CloudflareClient;
|
||||
pub use error::AcmeError;
|
||||
pub use error::AcmeError;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
43
src/services/telegram/bot.rs
Normal file
43
src/services/telegram/bot.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/services/telegram/error.rs
Normal file
46
src/services/telegram/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
1657
src/services/telegram/handlers/admin.rs
Normal file
1657
src/services/telegram/handlers/admin.rs
Normal file
File diff suppressed because it is too large
Load Diff
256
src/services/telegram/handlers/mod.rs
Normal file
256
src/services/telegram/handlers/mod.rs
Normal 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(())
|
||||
}
|
||||
291
src/services/telegram/handlers/types.rs
Normal file
291
src/services/telegram/handlers/types.rs
Normal 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",
|
||||
)]])
|
||||
}
|
||||
|
||||
872
src/services/telegram/handlers/user.rs
Normal file
872
src/services/telegram/handlers/user.rs
Normal 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(())
|
||||
}
|
||||
373
src/services/telegram/localization/mod.rs
Normal file
373
src/services/telegram/localization/mod.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/services/telegram/mod.rs
Normal file
199
src/services/telegram/mod.rs
Normal 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>,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¶ms);
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms);
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms);
|
||||
let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag);
|
||||
|
||||
let uri = if query_string.is_empty() {
|
||||
format!(
|
||||
"vmess://{}@{}:{}#{}",
|
||||
config.xray_user_id,
|
||||
config.hostname,
|
||||
config.port,
|
||||
utils::url_encode(&alias)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"vmess://{}@{}:{}?{}#{}",
|
||||
config.xray_user_id,
|
||||
config.hostname,
|
||||
config.port,
|
||||
query_string,
|
||||
utils::url_encode(&alias)
|
||||
)
|
||||
};
|
||||
|
||||
Ok(uri)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl UriBuilder for VmessUriBuilder {
|
||||
fn build_uri(&self, config: &ClientConfigData) -> Result<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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,4 @@ impl From<serde_json::Error> for UriGeneratorError {
|
||||
// fn from(err: urlencoding::EncodingError) -> Self {
|
||||
// UriGeneratorError::UriEncoding(err.to_string())
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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, ¤t_inbound.tag).await {
|
||||
match app_state
|
||||
.xray_service
|
||||
.remove_inbound(server_id, &endpoint, ¤t_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,
|
||||
¤t_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,
|
||||
¤t_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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
142
src/web/handlers/subscription.rs
Normal file
142
src/web/handlers/subscription.rs
Normal 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())
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
296
src/web/handlers/telegram.rs
Normal file
296
src/web/handlers/telegram.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
314
src/web/handlers/user_requests.rs
Normal file
314
src/web/handlers/user_requests.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
1096
static/admin.html
1096
static/admin.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user