1 Commits

Author SHA1 Message Date
dependabot[bot]
6ea1362183 Bump setuptools from 75.2.0 to 78.1.1
Bumps [setuptools](https://github.com/pypa/setuptools) from 75.2.0 to 78.1.1.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v75.2.0...v78.1.1)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-version: 78.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-08 04:40:12 +00:00
292 changed files with 15299 additions and 32878 deletions

View File

@@ -1,31 +0,0 @@
# Environment Variables Example for Xray Admin Panel
# Copy this file to .env and modify the values as needed
# Database Configuration
DATABASE_URL=postgresql://xray_admin:password@localhost:5432/xray_admin
XRAY_ADMIN__DATABASE__MAX_CONNECTIONS=20
XRAY_ADMIN__DATABASE__CONNECTION_TIMEOUT=30
XRAY_ADMIN__DATABASE__AUTO_MIGRATE=true
# Web Server Configuration
XRAY_ADMIN__WEB__HOST=0.0.0.0
XRAY_ADMIN__WEB__PORT=8080
XRAY_ADMIN__WEB__JWT_SECRET=your-super-secret-jwt-key-change-this
XRAY_ADMIN__WEB__JWT_EXPIRY=86400
# Telegram Bot Configuration
TELEGRAM_BOT_TOKEN=1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi
XRAY_ADMIN__TELEGRAM__WEBHOOK_URL=https://your-domain.com/telegram/webhook
# Xray Configuration
XRAY_ADMIN__XRAY__DEFAULT_API_PORT=62789
XRAY_ADMIN__XRAY__HEALTH_CHECK_INTERVAL=30
# Logging Configuration
XRAY_ADMIN__LOGGING__LEVEL=info
XRAY_ADMIN__LOGGING__FILE_PATH=./logs/xray-admin.log
XRAY_ADMIN__LOGGING__JSON_FORMAT=false
# Runtime Environment
RUST_ENV=development
ENVIRONMENT=development

51
.github/workflows/main.yml vendored Executable file
View File

@@ -0,0 +1,51 @@
name: Docker hub build
on:
push:
branches:
- 'django'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set outputs
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: Check outputs
run: |
echo "Short SHA: ${{ steps.vars.outputs.sha_short }}"
echo "Full SHA: ${{ steps.vars.outputs.sha_full }}"
echo "Build Date: ${{ steps.vars.outputs.build_date }}"
echo "Branch: ${{ steps.vars.outputs.branch_name }}"
-
name: Build and push
uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=registry,ref=ultradesu/outfleet:buildcache
cache-to: type=registry,ref=ultradesu/outfleet:buildcache,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 }}
tags: ultradesu/outfleet:v2,ultradesu/outfleet:${{ steps.vars.outputs.sha_short }}

17
.gitignore vendored
View File

@@ -1,10 +1,21 @@
db.sqlite3
debug.log
*.swp
*.swo
/target/
config.toml
*.pyc
staticfiles/
*.__pycache__.*
celerybeat-schedule*
# macOS system files
._*
.DS_Store
# Virtual environments
venv/
.venv/
env/
# Temporary files
/tmp/
*.tmp

64
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,64 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Django VPN app",
"type": "debugpy",
"request": "launch",
"env": {
"POSTGRES_PORT": "5433",
"DJANGO_SETTINGS_MODULE": "mysite.settings",
"EXTERNAL_ADDRESS": "http://localhost:8000"
},
"args": [
"runserver",
"0.0.0.0:8000"
],
"django": true,
"autoStartBrowser": false,
"program": "${workspaceFolder}/manage.py"
},
{
"name": "Celery Worker",
"type": "debugpy",
"request": "launch",
"module": "celery",
"args": [
"-A", "mysite",
"worker",
"--loglevel=info"
],
"env": {
"POSTGRES_PORT": "5433",
"DJANGO_SETTINGS_MODULE": "mysite.settings"
},
"console": "integratedTerminal"
},
{
"name": "Celery Beat",
"type": "debugpy",
"request": "launch",
"module": "celery",
"args": [
"-A", "mysite",
"beat",
"--loglevel=info"
],
"env": {
"POSTGRES_PORT": "5433",
"DJANGO_SETTINGS_MODULE": "mysite.settings"
},
"console": "integratedTerminal"
}
],
"compounds": [
{
"name": "Run Django, Celery Worker, and Celery Beat",
"configurations": [
"Django VPN app",
"Celery Worker",
"Celery Beat"
]
}
]
}

507
API.md
View File

@@ -1,507 +0,0 @@
# OutFleet Xray Admin API
Base URL: `http://localhost:8080`
## Overview
Complete API documentation for OutFleet - a web admin panel for managing xray-core VPN proxy servers.
## Base Endpoints
### Health Check
- `GET /health` - Service health check
**Response:**
```json
{
"status": "ok",
"service": "xray-admin",
"version": "0.1.0"
}
```
## API Endpoints
All API endpoints are prefixed with `/api`.
### Users
#### List Users
- `GET /users?page=1&per_page=20` - Get paginated list of users
#### Search Users
- `GET /users/search?q=john` - Universal search for users
**Search capabilities:**
- By name (partial match, case-insensitive): `?q=john`
- By telegram_id: `?q=123456789`
- By user UUID: `?q=550e8400-e29b-41d4-a716-446655440000`
**Response:** Array of user objects (limited to 100 results)
```json
[
{
"id": "uuid",
"name": "string",
"comment": "string|null",
"telegram_id": "number|null",
"created_at": "timestamp",
"updated_at": "timestamp"
}
]
```
#### Get User
- `GET /users/{id}` - Get user by ID
#### Create User
- `POST /users` - Create new user
```json
{
"name": "John Doe",
"comment": "Admin user",
"telegram_id": 123456789
}
```
#### Update User
- `PUT /users/{id}` - Update user by ID
```json
{
"name": "Jane Doe",
"comment": null,
"telegram_id": 987654321
}
```
#### Delete User
- `DELETE /users/{id}` - Delete user by ID
#### Get User Access
- `GET /users/{id}/access?include_uris=true` - Get user access to inbounds (optionally with client URIs)
**Query Parameters:**
- `include_uris`: boolean (optional) - Include client configuration URIs in response
**Response (without URIs):**
```json
[
{
"id": "uuid",
"user_id": "uuid",
"server_inbound_id": "uuid",
"xray_user_id": "string",
"level": 0,
"is_active": true
}
]
```
**Response (with URIs):**
```json
[
{
"id": "uuid",
"user_id": "uuid",
"server_inbound_id": "uuid",
"xray_user_id": "string",
"level": 0,
"is_active": true,
"uri": "vless://uuid@hostname:port?parameters#alias",
"protocol": "vless",
"server_name": "Server Name",
"inbound_tag": "inbound-tag"
}
]
```
#### Generate Client Configurations
- `GET /users/{user_id}/configs` - Get all client configuration URIs for a user
- `GET /users/{user_id}/access/{inbound_id}/config` - Get specific client configuration URI
**Response:**
```json
{
"user_id": "uuid",
"server_name": "string",
"inbound_tag": "string",
"protocol": "vmess|vless|trojan|shadowsocks",
"uri": "protocol://uri_string",
"qr_code": null
}
```
### Servers
#### List Servers
- `GET /servers` - Get all servers
**Response:**
```json
[
{
"id": "uuid",
"name": "string",
"hostname": "string",
"grpc_hostname": "string",
"grpc_port": 2053,
"status": "online|offline|error|unknown",
"default_certificate_id": "uuid|null",
"created_at": "timestamp",
"updated_at": "timestamp",
"has_credentials": true
}
]
```
#### Get Server
- `GET /servers/{id}` - Get server by ID
#### Create Server
- `POST /servers` - Create new server
**Request:**
```json
{
"name": "Server Name",
"hostname": "server.example.com",
"grpc_hostname": "192.168.1.100", // optional, defaults to hostname
"grpc_port": 2053, // optional, defaults to 2053
"api_credentials": "optional credentials",
"default_certificate_id": "uuid" // optional
}
```
#### Update Server
- `PUT /servers/{id}` - Update server
**Request:** (all fields optional)
```json
{
"name": "New Server Name",
"hostname": "new.server.com",
"grpc_hostname": "192.168.1.200",
"grpc_port": 2054,
"api_credentials": "new credentials",
"status": "online",
"default_certificate_id": "uuid"
}
```
#### Delete Server
- `DELETE /servers/{id}` - Delete server
#### Test Server Connection
- `POST /servers/{id}/test` - Test connection to server
**Response:**
```json
{
"connected": true,
"endpoint": "192.168.1.100:2053"
}
```
#### Get Server Statistics
- `GET /servers/{id}/stats` - Get server statistics
### Server Inbounds
#### List Server Inbounds
- `GET /servers/{server_id}/inbounds` - Get all inbounds for a server
**Response:**
```json
[
{
"id": "uuid",
"server_id": "uuid",
"template_id": "uuid",
"template_name": "string",
"tag": "string",
"port_override": 8080,
"certificate_id": "uuid|null",
"variable_values": {},
"is_active": true,
"created_at": "timestamp",
"updated_at": "timestamp"
}
]
```
#### Get Server Inbound
- `GET /servers/{server_id}/inbounds/{inbound_id}` - Get specific inbound
#### Create Server Inbound
- `POST /servers/{server_id}/inbounds` - Create new inbound for server
**Request:**
```json
{
"template_id": "uuid",
"port": 8080,
"certificate_id": "uuid", // optional
"is_active": true
}
```
#### Update Server Inbound
- `PUT /servers/{server_id}/inbounds/{inbound_id}` - Update inbound
**Request:** (all fields optional)
```json
{
"tag": "new-tag",
"port_override": 8081,
"certificate_id": "uuid",
"variable_values": {"domain": "example.com"},
"is_active": false
}
```
#### Delete Server Inbound
- `DELETE /servers/{server_id}/inbounds/{inbound_id}` - Delete inbound
### User-Inbound Management
#### Add User to Inbound
- `POST /servers/{server_id}/inbounds/{inbound_id}/users` - Grant user access to inbound
**Request:**
```json
{
"user_id": "uuid", // optional, will create new user if not provided
"name": "username",
"comment": "User description", // optional
"telegram_id": 123456789, // optional
"level": 0 // optional, defaults to 0
}
```
#### Remove User from Inbound
- `DELETE /servers/{server_id}/inbounds/{inbound_id}/users/{email}` - Remove user access
#### Get Inbound Client Configurations
- `GET /servers/{server_id}/inbounds/{inbound_id}/configs` - Get all client configuration URIs for an inbound
**Response:**
```json
[
{
"user_id": "uuid",
"server_name": "string",
"inbound_tag": "string",
"protocol": "vmess|vless|trojan|shadowsocks",
"uri": "protocol://uri_string",
"qr_code": null
}
]
```
### Certificates
#### List Certificates
- `GET /certificates` - Get all certificates
#### Get Certificate
- `GET /certificates/{id}` - Get certificate by ID
#### Create Certificate
- `POST /certificates` - Create new certificate
**Request:**
```json
{
"name": "Certificate Name",
"cert_type": "self_signed|letsencrypt",
"domain": "example.com",
"auto_renew": true,
"certificate_pem": "-----BEGIN CERTIFICATE-----...",
"private_key": "-----BEGIN PRIVATE KEY-----..."
}
```
#### Update Certificate
- `PUT /certificates/{id}` - Update certificate
**Request:** (all fields optional)
```json
{
"name": "New Certificate Name",
"auto_renew": false
}
```
#### Delete Certificate
- `DELETE /certificates/{id}` - Delete certificate
#### Get Certificate Details
- `GET /certificates/{id}/details` - Get detailed certificate information
#### Get Expiring Certificates
- `GET /certificates/expiring` - Get certificates that are expiring soon
### Templates
#### List Templates
- `GET /templates` - Get all inbound templates
#### Get Template
- `GET /templates/{id}` - Get template by ID
#### Create Template
- `POST /templates` - Create new inbound template
**Request:**
```json
{
"name": "Template Name",
"protocol": "vmess|vless|trojan|shadowsocks",
"default_port": 8080,
"requires_tls": true,
"config_template": "JSON template string"
}
```
#### Update Template
- `PUT /templates/{id}` - Update template
**Request:** (all fields optional)
```json
{
"name": "New Template Name",
"description": "Template description",
"default_port": 8081,
"base_settings": {},
"stream_settings": {},
"requires_tls": false,
"requires_domain": true,
"variables": [],
"is_active": true
}
```
#### Delete Template
- `DELETE /templates/{id}` - Delete template
## Response Format
### User Object
```json
{
"id": "uuid",
"name": "string",
"comment": "string|null",
"telegram_id": "number|null",
"created_at": "timestamp",
"updated_at": "timestamp"
}
```
### Users List Response
```json
{
"users": [UserObject],
"total": 100,
"page": 1,
"per_page": 20
}
```
### Server Object
```json
{
"id": "uuid",
"name": "string",
"hostname": "string",
"grpc_hostname": "string",
"grpc_port": 2053,
"status": "online|offline|error|unknown",
"default_certificate_id": "uuid|null",
"created_at": "timestamp",
"updated_at": "timestamp",
"has_credentials": true
}
```
### Server Inbound Object
```json
{
"id": "uuid",
"server_id": "uuid",
"template_id": "uuid",
"template_name": "string",
"tag": "string",
"port_override": 8080,
"certificate_id": "uuid|null",
"variable_values": {},
"is_active": true,
"created_at": "timestamp",
"updated_at": "timestamp"
}
```
### Certificate Object
```json
{
"id": "uuid",
"name": "string",
"cert_type": "self_signed|letsencrypt",
"domain": "string",
"auto_renew": true,
"expires_at": "timestamp|null",
"created_at": "timestamp",
"updated_at": "timestamp"
}
```
### Template Object
```json
{
"id": "uuid",
"name": "string",
"description": "string|null",
"protocol": "vmess|vless|trojan|shadowsocks",
"default_port": 8080,
"base_settings": {},
"stream_settings": {},
"requires_tls": true,
"requires_domain": false,
"variables": [],
"is_active": true,
"created_at": "timestamp",
"updated_at": "timestamp"
}
```
### Inbound User Object
```json
{
"id": "uuid",
"user_id": "uuid",
"server_inbound_id": "uuid",
"xray_user_id": "string",
"password": "string|null",
"level": 0,
"is_active": true,
"created_at": "timestamp",
"updated_at": "timestamp"
}
```
## Status Codes
- `200` - Success
- `201` - Created
- `204` - No Content (successful deletion)
- `400` - Bad Request (invalid data)
- `404` - Not Found
- `409` - Conflict (duplicate data, e.g. telegram_id)
- `500` - Internal Server Error
## Error Response Format
```json
{
"error": "Error message description",
"code": "ERROR_CODE",
"details": "Additional error details"
}
```

5063
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,69 +0,0 @@
[package]
name = "xray-admin"
version = "0.1.0"
edition = "2021"
[dependencies]
# Async runtime
tokio = { version = "1.0", features = ["full"] }
tokio-cron-scheduler = "0.10"
# Serialization/deserialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
toml = "0.8"
# Configuration
config = "0.14"
clap = { version = "4.0", features = ["derive", "env"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Utilities
anyhow = "1.0"
thiserror = "1.0"
# Validation
validator = { version = "0.18", features = ["derive"] }
# URL parsing
url = "2.5"
# Database and ORM
sea-orm = { version = "1.0", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] }
sea-orm-migration = "1.0"
# Additional utilities
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
async-trait = "0.1"
log = "0.4"
urlencoding = "2.1"
rand = "0.8"
# Web server
axum = { version = "0.7", features = ["macros", "json"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs"] }
hyper = { version = "1.0", features = ["full"] }
# Xray integration
xray-core = "0.2.1" # gRPC client for Xray
tonic = "0.12" # gRPC client/server framework
prost = "0.13" # Protocol Buffers implementation
rcgen = { version = "0.12", features = ["pem"] } # For self-signed certificates
time = "0.3" # For certificate date/time handling
base64 = "0.21" # For PEM to DER conversion
# ACME/Let's Encrypt support
instant-acme = "0.8" # ACME client for Let's Encrypt
reqwest = { version = "0.11", features = ["json", "rustls-tls"] } # HTTP client for Cloudflare API
rustls = { version = "0.23", features = ["aws-lc-rs"] } # TLS library with aws-lc-rs crypto provider
ring = "0.17" # Crypto for ACME
pem = "3.0" # PEM format support
[dev-dependencies]
tempfile = "3.0"

View File

@@ -1,45 +1,40 @@
# Build stage
FROM rust:latest as builder
FROM python:3-alpine
# Build arguments
ARG GIT_COMMIT="development"
ARG GIT_COMMIT_SHORT="dev"
ARG BUILD_DATE="unknown"
ARG BRANCH_NAME="unknown"
# 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}
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
# Install system dependencies first (this layer will be cached)
RUN apk update && apk add git curl unzip
# Copy dependency files
COPY Cargo.toml Cargo.lock ./
# Copy and install Python dependencies (this layer will be cached when requirements.txt doesn't change)
COPY ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source code
COPY src ./src
COPY static ./static
# Install Xray-core
RUN XRAY_VERSION=$(curl -s https://api.github.com/repos/XTLS/Xray-core/releases/latest | sed -n 's/.*"tag_name": "\([^"]*\)".*/\1/p') && \
curl -L -o /tmp/xray.zip "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-64.zip" && \
cd /tmp && unzip xray.zip && \
ls -la /tmp/ && \
find /tmp -name "xray" -type f && \
cp xray /usr/local/bin/xray && \
chmod +x /usr/local/bin/xray && \
rm -rf /tmp/xray.zip /tmp/xray
# Build the application
RUN cargo build --release
# Copy the rest of the application code (this layer will change frequently)
COPY . .
# Runtime stage
FROM ubuntu:24.04
# Run collectstatic
RUN python manage.py collectstatic --noinput
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
# Copy the binary from builder
COPY --from=builder /app/target/release/xray-admin /app/xray-admin
# Copy static files
COPY --from=builder /app/static ./static
# Copy config file
COPY config.docker.toml ./config.toml
EXPOSE 8081
CMD ["/app/xray-admin", "--host", "0.0.0.0"]
CMD [ "python", "./manage.py", "runserver", "0.0.0.0:8000" ]

13
LICENSE Executable file
View File

@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@@ -1,132 +0,0 @@
# LLM Project Context - Xray Admin Panel
## Project Overview
Rust-based administration panel for managing xray-core VPN proxy servers. Uses real gRPC integration with xray-core library for server communication.
## Current Architecture
### Core Technologies
- **Language**: Rust (edition 2021)
- **Web Framework**: Axum with tower-http
- **Database**: PostgreSQL with Sea-ORM
- **Xray Integration**: xray-core 0.2.1 library with real gRPC communication
- **Frontend**: Vanilla HTML/CSS/JS with toast notifications
### Module Structure
```
src/
├── config/ # Configuration management (args, env, file)
├── database/ # Sea-ORM entities, repositories, migrations
├── services/ # Business logic (xray gRPC client, certificates)
├── web/ # Axum handlers and routes
└── main.rs # Application entry point
```
## Key Features Implemented
### 1. Database Entities
- **Users**: Basic user management
- **Servers**: Xray server definitions with gRPC endpoints
- **Certificates**: TLS certificates with PEM storage (binary format)
- **InboundTemplates**: Reusable inbound configurations
- **ServerInbounds**: Template bindings to servers with ports/certificates
### 2. Xray gRPC Integration
**Location**: `src/services/xray/client.rs`
- Real xray-core library integration (NOT mock/CLI)
- Methods: `add_inbound_with_certificate()`, `remove_inbound()`, `get_stats()`
- **CRITICAL**: TLS certificate configuration via streamSettings with proper protobuf messages
- Supports VLESS, VMess, Trojan, Shadowsocks protocols
### 3. Certificate Management
**Location**: `src/database/entities/certificate.rs`
- Self-signed certificate generation using rcgen
- Binary storage (cert_data, key_data as Vec<u8>)
- PEM conversion methods: `certificate_pem()`, `private_key_pem()`
- Separate endpoints: `/certificates/{id}` (basic) and `/certificates/{id}/details` (with PEM)
### 4. Template-Based Architecture
Templates define reusable inbound configurations that can be bound to servers with:
- Port overrides
- Certificate assignments
- Active/inactive states
## Current Status & Issues
### ✅ Working Features
- Complete CRUD for all entities
- Real xray gRPC communication with TLS certificate support
- Toast notification system (absolute positioning)
- Modal-based editing interface
- Password masking in database URL logging
- Certificate details display with PEM content
### 🔧 Recent Fixes
- **StreamConfig Integration**: Fixed TLS certificate configuration in xray gRPC calls
- **Certificate Display**: Added `/certificates/{id}/details` endpoint for PEM viewing
- **Active/Inactive Management**: Inbounds automatically added/removed from xray when toggled
### ⚠️ Current Issue
User reported certificate details still showing "Not available" - this was just fixed with the new `/certificates/{id}/details` endpoint.
## API Structure
### Endpoints
```
/api/users/* # User management
/api/servers/* # Server management
/api/servers/{id}/inbounds/* # Server inbound management
/api/certificates/* # Certificate management (basic)
/api/certificates/{id}/details # Certificate details with PEM
/api/templates/* # Template management
```
## Configuration
- **Default port**: 8080 (user tested on 8082)
- **Database**: PostgreSQL with auto-migration
- **Environment variables**: XRAY_ADMIN__* prefix
- **Config file**: config.toml support
## Testing Commands
```bash
# Run application
cargo run -- --host 0.0.0.0 --port 8082
# Test xray integration
xray api lsi --server 100.91.97.36:10085
# Check compilation
cargo check
```
## Key Implementation Details
### Xray TLS Configuration
**Location**: `src/services/xray/client.rs:185-194`
```rust
let stream_config = StreamConfig {
protocol_name: "tcp".to_string(),
security_type: "tls".to_string(),
security_settings: vec![tls_message],
// ... other fields
};
```
### Certificate Data Flow
1. User creates certificate via web interface
2. PEM data stored as binary in database (cert_data, key_data)
3. When creating inbound, certificate fetched and converted back to PEM
4. PEM passed to xray gRPC client for TLS configuration
### Database Migrations
Auto-migration enabled by default. All entities use UUID primary keys with timestamps.
## Development Notes
- **User prefers English in code/comments**
- **No emoji usage unless explicitly requested**
- **Prefer editing existing files over creating new ones**
- **Real xray-core integration required** (user specifically asked not to abandon it)
- **Application tested with actual xray server at 100.91.97.36:10085**
## Last Working State
All features implemented and compiling. StreamConfig properly configured for TLS certificate transmission to xray servers. Certificate viewing endpoint fixed for PEM display.

58
README.md Executable file
View File

@@ -0,0 +1,58 @@
<p align="center">
<h1 align="center">OutFleet: Master Your OutLine VPN</h1>
<p align="center">
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers, users and always-updated Dynamic Access Keys instead of ss:// links
<br/>
<br/>
<a href="https://github.com/house-of-vanity/outfleet/issues">Request Feature</a>
</p>
</p>
![Forks](https://img.shields.io/github/forks/house-of-vanity/outfleet?style=social) ![Stargazers](https://img.shields.io/github/stars/house-of-vanity/outfleet?style=social) ![License](https://img.shields.io/github/license/house-of-vanity/outfleet)
<img width="1454" alt="image" src="https://github.com/user-attachments/assets/20555dd9-54ea-4b95-aa13-a7dd54e34ef4" />
## About The Project
### Key Features
* Centralized Key Management
Administer user keys from one unified dashboard. Add, delete, and allocate users to specific servers effortlessly.
* ![Dynamic Access Keys](https://www.reddit.com/r/outlinevpn/wiki/index/dynamic_access_keys/)
Distribute ssconf:// links that are always up-to-date with your current server configurations. Eliminate the need for manual link updates.
### Why OutFleet?
Tired of juggling multiple home servers and the headache of individually managing users on each? OutFleet was born out of the frustration of not finding a suitable tool for efficiently managing a bunch of home servers.
## Built With
Django, Postgres SQL and hassle-free deployment using Kubernetes or docker-compose
### Installation
#### Docker compose
Docker deploy is easy:
```
docker-compose up -d
```
#### Kubernetes
I use ArgoCD for deployment. [Take a look](https://gt.hexor.cy/ab/homelab/src/branch/main/k8s/apps/vpn) to `outfleet.yaml` file for manifests.
#### Setup sslocal service on Windows
Shadowsocks servers can be used directly with **sslocal**. For automatic and regular password updates, you can create a Task Scheduler job to rotate the passwords when they change, as OutFleet manages the passwords automatically.
You may run script in Admin PowerShell to create Task for autorun **sslocal** and update connection details automatically using Outfleet API
```PowerShell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force; Invoke-Expression (Invoke-WebRequest -Uri "https://raw.githubusercontent.com/house-of-vanity/OutFleet/refs/heads/master/tools/windows-helper.ps1" -UseBasicParsing).Content
```
[Firefox PluginProxy Switcher and Manager](https://addons.mozilla.org/en-US/firefox/addon/proxy-switcher-and-manager/) && [Chrome plugin Proxy Switcher and Manager](https://chromewebstore.google.com/detail/proxy-switcher-and-manage/onnfghpihccifgojkpnnncpagjcdbjod)
Keep in mind that all user keys are stored in a single **config.yaml** file. If this file is lost, user keys will remain on the servers, but OutFleet will lose the ability to manage them. Handle with extreme caution and use backups.
## Authors
* **UltraDesu** - *Humble amateur developer* - [UltraDesu](https://github.com/house-of-vanity) - *Author*
* **Contributors**
* * @Sanapach

21
SECURITY.md Normal file
View File

@@ -0,0 +1,21 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.

151
URI.md
View File

@@ -1,151 +0,0 @@
# Xray Client URI Generation
## VMess URI Format
VMess URIs use two formats:
### 1. Query Parameter Format
```
vmess://uuid@hostname:port?parameters#alias
```
**Parameters:**
- `encryption=auto` - Encryption method
- `security=tls|none` - Security layer (TLS or none)
- `sni=domain` - Server Name Indication for TLS
- `fp=chrome|firefox|safari` - TLS fingerprint
- `type=ws|tcp|grpc|http` - Transport type
- `path=/path` - WebSocket/HTTP path
- `host=domain` - Host header for WebSocket
**Example:**
```
vmess://2c981164-9b93-4bca-94ff-b78d3f8498d7@v2ray.codefyinc.com:443?encryption=auto&security=tls&sni=example.com&fp=chrome&type=ws&path=/ws&host=v2ray.codefyinc.com#MyServer
```
### 2. Base64 JSON Format
```
vmess://base64(json_config)#alias
```
**JSON Structure:**
```json
{
"v": "2",
"ps": "Server Name",
"add": "hostname",
"port": "443",
"id": "uuid",
"aid": "0",
"scy": "auto",
"net": "ws",
"type": "none",
"host": "domain",
"path": "/path",
"tls": "tls",
"sni": "domain",
"alpn": "",
"fp": "chrome"
}
```
## VLESS URI Format
```
vless://uuid@hostname:port?parameters#alias
```
**Key Parameters:**
- `encryption=none` - VLESS uses no encryption
- `security=tls|reality|none` - Security layer
- `type=ws|tcp|grpc|http|httpupgrade|xhttp` - Transport type
- `flow=xtls-rprx-vision` - Flow control (for XTLS)
- `headerType=none|http` - Header type for TCP
- `mode=auto|gun|stream-one` - Transport mode
- `serviceName=name` - gRPC service name
- `authority=domain` - gRPC authority
- `spx=/path` - Split HTTP path (for xhttp)
**REALITY Parameters:**
- `pbk=public_key` - Public key
- `sid=short_id` - Short ID
- `fp=chrome|firefox|safari` - TLS fingerprint
- `sni=domain` - Server Name Indication
**Examples:**
```
vless://uuid@server.com:443?type=tcp&security=none&headerType=none#Basic
vless://uuid@server.com:443?type=ws&security=tls&path=/ws&host=example.com#WebSocket
vless://uuid@server.com:443?type=grpc&security=reality&serviceName=grpcService&pbk=key&sid=id#gRPC-Reality
```
## Generation Algorithm
1. **UUID**: Use `inbound_users.xray_user_id`
2. **Hostname**: From `servers.hostname`
3. **Port**: From `server_inbounds.port_override` or template default
4. **Transport**: From inbound template `stream_settings`
5. **Security**: Based on certificate configuration
6. **Path**: From WebSocket stream settings
7. **Alias**: User name + server name
## Shadowsocks URI Format
```
ss://password@hostname:port?parameters#alias
```
**Parameters:**
- `encryption=none` - Usually none for modern configs
- `security=tls|reality|none` - Security layer
- `type=ws|tcp|grpc|xhttp` - Transport type
- `path=/path` - WebSocket/HTTP path
- `host=domain` - Host header
- `mode=auto|gun|stream-one` - Transport mode
- `headerType=none|http` - Header type for TCP
- `flow=xtls-rprx-vision` - Flow control (for REALITY)
- `pbk=key` - Public key (for REALITY)
- `sid=id` - Short ID (for REALITY)
**Example:**
```
ss://my-password@server.com:443?type=ws&security=tls&path=/ws&host=example.com#MyServer
```
## Trojan URI Format
```
trojan://password@hostname:port?parameters#alias
```
**Parameters:**
- `security=tls|reality|none` - Security layer
- `type=ws|tcp|grpc` - Transport type
- `sni=domain` - Server Name Indication
- `fp=chrome|firefox|randomized` - TLS fingerprint
- `flow=xtls-rprx-vision` - Flow control
- `allowInsecure=1` - Allow insecure connections
- `headerType=http|none` - Header type for TCP
- `mode=gun` - gRPC mode
- `serviceName=name` - gRPC service name
**WebSocket Parameters:**
- `path=/path` - WebSocket path
- `host=domain` - Host header
- `alpn=http/1.1|h2` - ALPN protocols
**Examples:**
```
trojan://password@server.com:443?type=tcp&security=tls&sni=example.com#Basic
trojan://password@server.com:443?type=ws&security=tls&path=/ws&host=example.com&sni=example.com#WebSocket
trojan://password@server.com:443?type=grpc&security=tls&serviceName=grpcService&mode=gun&sni=example.com#gRPC
```
## Implementation Notes
- VMess requires `aid=0` for modern clients
- VLESS doesn't use `aid` parameter
- Shadowsocks uses password instead of UUID
- Base64 encoding required for VMess JSON format
- URL encoding needed for special characters in parameters
- REALITY parameters: `pbk`, `sid`, `fp`, `sni`

5
buildx.yaml Executable file
View File

@@ -0,0 +1,5 @@
platforms:
- name: amd64
architecture: amd64
- name: arm64
architecture: arm64

15
cleanup_analysis.sql Normal file
View File

@@ -0,0 +1,15 @@
-- Проверить количество записей без acl_link_id
SELECT COUNT(*) as total_without_link
FROM vpn_accesslog
WHERE acl_link_id IS NULL OR acl_link_id = '';
-- Проверить общее количество записей
SELECT COUNT(*) as total_records FROM vpn_accesslog;
-- Показать распределение по датам (последние записи без ссылок)
SELECT DATE(timestamp) as date, COUNT(*) as count
FROM vpn_accesslog
WHERE acl_link_id IS NULL OR acl_link_id = ''
GROUP BY DATE(timestamp)
ORDER BY date DESC
LIMIT 10;

35
cleanup_options.sql Normal file
View File

@@ -0,0 +1,35 @@
-- ВАРИАНТ 1: Удалить ВСЕ записи без acl_link_id
-- ОСТОРОЖНО! Это удалит все старые логи
DELETE FROM vpn_accesslog
WHERE acl_link_id IS NULL OR acl_link_id = '';
-- ВАРИАНТ 2: Удалить записи без acl_link_id старше 30 дней
-- Более безопасный вариант
DELETE FROM vpn_accesslog
WHERE (acl_link_id IS NULL OR acl_link_id = '')
AND timestamp < NOW() - INTERVAL 30 DAY;
-- ВАРИАНТ 3: Удалить записи без acl_link_id старше 7 дней
-- Еще более консервативный подход
DELETE FROM vpn_accesslog
WHERE (acl_link_id IS NULL OR acl_link_id = '')
AND timestamp < NOW() - INTERVAL 7 DAY;
-- ВАРИАНТ 4: Оставить только последние 1000 записей без ссылок (для истории)
DELETE FROM vpn_accesslog
WHERE (acl_link_id IS NULL OR acl_link_id = '')
AND id NOT IN (
SELECT id FROM (
SELECT id FROM vpn_accesslog
WHERE acl_link_id IS NULL OR acl_link_id = ''
ORDER BY timestamp DESC
LIMIT 1000
) AS recent_logs
);
-- ВАРИАНТ 5: Поэтапное удаление (для больших БД)
-- Удаляем по 10000 записей за раз
DELETE FROM vpn_accesslog
WHERE (acl_link_id IS NULL OR acl_link_id = '')
AND timestamp < NOW() - INTERVAL 30 DAY
LIMIT 10000;

View File

@@ -1,3 +0,0 @@
VITE_API_BASE=/api
VITE_API_HOST=http://localhost
VITE_API_PORT=8081

View File

@@ -1,3 +0,0 @@
VITE_API_BASE=/api
VITE_API_HOST=https://localhost
VITE_API_PORT=8081

24
client/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1 +0,0 @@
public-hoist-pattern[]=*@heroui/*

View File

@@ -1,23 +0,0 @@
{
"arrowParens": "always",
"bracketSameLine": false,
"objectWrap": "preserve",
"bracketSpacing": true,
"semi": true,
"experimentalOperatorPosition": "end",
"experimentalTernaries": false,
"singleQuote": true,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "all",
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"vueIndentScriptAndStyle": false,
"proseWrap": "preserve",
"insertPragma": false,
"printWidth": 80,
"requirePragma": false,
"tabWidth": 2,
"useTabs": false,
"embeddedLanguageFormatting": "auto"
}

View File

@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<link href="/src/style.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8160
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +0,0 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": "^20"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@heroui/react": "^2.8.5",
"@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/vite": "^4.1.14",
"axios": "^1.12.2",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"motion": "^12.23.24",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.64.0",
"react-redux": "^9.2.0",
"react-router": "^7.9.3",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"lint-staged": "^16.2.3",
"prettier": "^3.6.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,9 +0,0 @@
import axios from 'axios';
const VITE_API_BASE = import.meta.env.VITE_API_BASE;
const VITE_API_HOST = import.meta.env.VITE_API_HOST;
const VITE_API_PORT = import.meta.env.VITE_API_PORT;
export const api = axios.create({
baseURL: `${VITE_API_HOST}:${VITE_API_PORT}${VITE_API_BASE}`,
});

View File

@@ -1 +0,0 @@
export * from './api'

View File

@@ -1,33 +0,0 @@
import {addToast, type ToastProps} from "@heroui/toast";
import { useEffect } from 'react';
import {
appNotificator,
type Notice,
type NoticeType,
} from '../../../utils/notification/app-notificator';
const colorMap = new Map<NoticeType, string>([
['success', 'Success'],
['error', 'Danger'],
['warn', 'Warning'],
]);
const paramsMappers = (notice: Notice): Partial<ToastProps> => {
const { type, message } = notice;
const color = colorMap.get(type);
return {
description: message,
color: color?.toLowerCase() as ToastProps['color'],
};
};
export const ApplyNotificator = () => {
useEffect(() => {
appNotificator.applyProvider({
paramsMappers,
show: (params: Partial<ToastProps>) => addToast(params),
});
}, []);
return <></>;
};

View File

@@ -1 +0,0 @@
export * from './apply-notificator'

View File

@@ -1,2 +0,0 @@
export * from './use-app-dispatch'
export * from './use-app-selector'

View File

@@ -1,4 +0,0 @@
import { useDispatch } from 'react-redux'
import type { AppDispatch } from '../../store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()

View File

@@ -1,4 +0,0 @@
import { useSelector } from 'react-redux'
import type { RootState } from '../../store'
export const useAppSelector = useSelector.withTypes<RootState>()

View File

@@ -1 +0,0 @@
export * from './nav-menu';

View File

@@ -1 +0,0 @@
export * from './nav-menu';

View File

@@ -1,32 +0,0 @@
import { Link, useLocation } from 'react-router';
import { clsx } from 'clsx';
interface NavMenuItems {
href: string;
label: string;
}
interface NavMenuProps {
items: NavMenuItems[];
}
export const NavMenu = (props: NavMenuProps) => {
const { items } = props;
const { pathname } = useLocation();
return (
<div className="tabs">
{items.map(({ href, label }) => (
<Link
key={label}
className={clsx('tab', {
active: href === pathname,
})}
to={href}
>
{label}
</Link>
))}
</div>
);
};

View File

@@ -1,76 +0,0 @@
import type { FC } from 'react';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import type { CertificateDTO } from '../../duck';
export interface CertificateDetailProps {
cetificate: CertificateDTO;
isOpen: boolean;
onOpenChange: () => void;
}
export const CertificateDetail: FC<CertificateDetailProps> = (props) => {
const { cetificate, isOpen, onOpenChange } = props;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div>
<h4>Basic Information</h4>
<p>
<strong>Name:</strong> ${cetificate.name}
</p>
<p>
<strong>Domain:</strong> ${cetificate.domain}
</p>
<p>
<strong>Type:</strong> ${cetificate.cert_type}
</p>
<p>
<strong>Auto Renew:</strong>
{cetificate.auto_renew ? 'Yes' : 'No'}
</p>
<p>
<strong>Created:</strong>
{new Date(cetificate.created_at).toLocaleString()}
</p>
<p>
<strong>Expires:</strong>
{new Date(cetificate.expires_at).toLocaleString()}
</p>
<h4>Certificate PEM</h4>
<div className="cert-details">
{cetificate.certificate_pem || 'Not available'}
</div>
<h4>Private Key</h4>
<div className="cert-details">
{cetificate.has_private_key
? '[Hidden for security]'
: 'Not available'}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};

View File

@@ -1,93 +0,0 @@
import { useEffect, type FC } from 'react';
import { useForm } from 'react-hook-form';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import { useAppDispatch } from '../../../../common/hooks';
import { getCertificate } from '../../duck/api';
import { updateCertificate, type EditCertificateDTO } from '../../duck';
export interface CertificateEditProps {
certificateId: string;
isOpen: boolean;
onOpenChange: () => void;
}
export const CertificateEdit: FC<CertificateEditProps> = (props) => {
const dispatch = useAppDispatch();
const { certificateId, isOpen, onOpenChange } = props;
const { register, handleSubmit, reset } = useForm<EditCertificateDTO>();
useEffect(() => {
getCertificate(certificateId).then((response) => {
const { data } = response;
reset({
...data,
});
});
}, [certificateId]);
const onSubmit = (values: EditCertificateDTO) => {
dispatch(
updateCertificate({
id: certificateId,
certificate: values
}),
).then(() => {
onOpenChange();
});
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<form onSubmit={handleSubmit(onSubmit)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div>
<div className="form-group">
<label>Name:</label>
<input
type="text"
{...register('name', { required: true })}
/>
</div>
<div className="form-group">
<label>Domain:</label>
<input
type="text"
{...register('domain', { required: true })}
/>
</div>
<div className="form-group">
<label>
<input type="checkbox" {...register('auto_renew')} /> Auto
Renew
</label>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" type="submit">
Save
</Button>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</form>
</Modal>
);
};

View File

@@ -1,24 +0,0 @@
import type { FC } from 'react';
import type { CertificateDTO } from '../../duck';
import { CertificateView } from './certificate-view';
export interface CertificateList {
certificates: CertificateDTO[];
}
export const CertificateList: FC<CertificateList> = ({ certificates }) => {
return (
<table>
<tr>
<th>Name</th>
<th>Domain</th>
<th>Type</th>
<th>Expires</th>
<th>Auto Renew</th>
<th>Actions</th>
</tr>
{certificates
.map((certificate)=><CertificateView certificate={certificate} key={certificate.id}/>)}
</table>
);
};

View File

@@ -1,56 +0,0 @@
import type { FC } from 'react';
import { deleteCertificateAction, type CertificateDTO } from '../../duck';
import { useDisclosure } from '@heroui/react';
import { CertificateDetail } from './certificate-details';
import { CertificateEdit } from './certificate-edit';
import { useAppDispatch } from '../../../../common/hooks';
export interface CertificateViewProps {
certificate: CertificateDTO;
}
export const CertificateView: FC<CertificateViewProps> = ({ certificate }) => {
const dispatch = useAppDispatch()
const detailDisclosure = useDisclosure();
const editDisclosure = useDisclosure();
const handleDeleteCertificate = () => {
if (confirm('Delete certificate?')) {
dispatch(deleteCertificateAction(certificate.id));
}
};
return (
<>
<tr>
<td>{certificate.name}</td>
<td>{certificate.domain}</td>
<td>{certificate.cert_type}</td>
<td>{new Date(certificate.expires_at).toLocaleDateString()}</td>
<td>{certificate.auto_renew ? 'Yes' : 'No'}</td>
<td>
<button
className="btn btn-secondary"
onClick={detailDisclosure.onOpenChange}
>
View
</button>
<button
className="btn btn-primary"
onClick={editDisclosure.onOpenChange}
>
Edit
</button>
<button
className="btn btn-danger"
onClick={handleDeleteCertificate}
>
Delete
</button>
</td>
</tr>
<CertificateDetail cetificate={certificate} {...detailDisclosure} />
<CertificateEdit {...editDisclosure} certificateId={certificate.id} />
</>
);
};

View File

@@ -1 +0,0 @@
export * from './certificate-list'

View File

@@ -1,51 +0,0 @@
import type { FC } from 'react';
import { useForm } from 'react-hook-form';
import { createCertificateAction, type CreateCertificateDTO } from '../../duck';
import { useAppDispatch } from '../../../../common/hooks';
export const CreateCertificate: FC = () => {
const { handleSubmit, register, reset } = useForm<CreateCertificateDTO>();
const dispatch = useAppDispatch();
const onSubmit = (values: CreateCertificateDTO) => {
dispatch(createCertificateAction(values)).then(() => {
reset();
});
};
return (
<form id="certificateForm" onSubmit={handleSubmit(onSubmit)}>
<div className="form-group">
<label>Name:</label>
<input type="text" {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Domain:</label>
<input
type="text"
placeholder="example.com"
{...register('domain', { required: true })}
/>
</div>
<div className="form-group">
<label>Certificate Type:</label>
<select id="certType" {...register('cert_type', { required: true })}>
<option value="self_signed">Self-Signed</option>
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
id="certAutoRenew"
{...register('auto_renew')}
/>{' '}
Auto Renew
</label>
</div>
<button type="submit" className="btn btn-primary">
Generate Certificate
</button>
</form>
);
};

View File

@@ -1 +0,0 @@
export * from './create-certificate'

View File

@@ -1 +0,0 @@
export * from './create-certificate'

View File

@@ -1,99 +0,0 @@
import { certificateSlice } from './slice';
import { createCertificate, deleteCertificate, getCertificates, patchCertificate } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getCertificatesState } from './selectors';
import type { RootState } from '../../../store';
import { appNotificator } from '../../../utils/notification/app-notificator';
import type { CreateCertificateDTO, EditCertificateDTO } from './dto';
const PREFFIX = 'certificates';
export const fetchCertificates = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getCertificatesState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(certificateSlice.actions.setLoading(true));
const response = await getCertificates().then(({ data }) => data);
dispatch(certificateSlice.actions.setUsers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(certificateSlice.actions.setError(message));
} finally {
dispatch(certificateSlice.actions.setLoading(false));
}
},
);
export const createCertificateAction = createAsyncThunk(
`${PREFFIX}/createCertificates`,
async (params: CreateCertificateDTO, { dispatch }) => {
try {
await createCertificate(params);
dispatch(fetchCertificates());
} catch (e) {
appNotificator.add({
message:
e instanceof Error
? e.message
: `Unknown error in ${PREFFIX}/createCertificates`,
type: 'error',
});
}
},
);
export const updateCertificate = createAsyncThunk(
`${PREFFIX}/updateCertificate`,
async (
params: {
id: string;
certificate: EditCertificateDTO;
},
{ dispatch },
) => {
try {
await patchCertificate(params.id, params.certificate);
dispatch(fetchCertificates());
appNotificator.add({
message: 'Template updated',
type: 'success',
});
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Error updating: ${e.message}`
: `Unknown error in ${PREFFIX}/updateTemplate`,
});
}
},
);
export const deleteCertificateAction = createAsyncThunk(
`${PREFFIX}/deleteCertificate`,
async (id: string, { dispatch }) => {
try {
await deleteCertificate(id);
appNotificator.add({
message: 'Certificate deleted',
type: 'success',
});
dispatch(fetchCertificates());
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Delete error: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteCertificate`,
});
}
},
);

View File

@@ -1,20 +0,0 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { CertificateDTO, CreateCertificateDTO, EditCertificateDTO } from './dto';
export const getCertificates = () =>
api.get<never, AxiosResponse<CertificateDTO[]>>('/certificates');
export const createCertificate = (params: CreateCertificateDTO) =>
api.post<AxiosResponse>('/certificates', params, {
headers: { 'Content-Type': 'application/json' },
});
export const getCertificate = (id: string) => api.get<never, AxiosResponse<CertificateDTO>>(`/certificates/${id}`)
export const patchCertificate = (id: string, certificate: EditCertificateDTO) =>
api.put(`/certificates/${id}`, certificate, {
headers: { 'Content-Type': 'application/json' },
});
export const deleteCertificate = (id: string) => api.delete(`/certificates/${id}`);

View File

@@ -1,24 +0,0 @@
export interface CertificateDTO {
name: string;
domain: string;
cert_type: string;
expires_at: string;
auto_renew: boolean;
id: string
created_at: string
certificate_pem: string
has_private_key: boolean
}
export interface CreateCertificateDTO {
name: string;
domain: string;
cert_type: string;
auto_renew: boolean;
}
export interface EditCertificateDTO {
name: string
domain: string
auto_renew: boolean
}

View File

@@ -1,4 +0,0 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -1,3 +0,0 @@
import type { RootState } from '../../../store';
export const getCertificatesState = (state: RootState) => state.certificates;

View File

@@ -1,32 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { CertificateDTO } from './dto';
export interface CertificatesState {
loading: boolean;
certificates: CertificateDTO[]
error: null | string;
}
const initialState: CertificatesState = {
loading: false,
certificates: [],
error: null,
};
export const certificateSlice = createSlice({
name: 'certificates',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setUsers: (state, action: PayloadAction<CertificateDTO[]>) => {
state.certificates = action.payload
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -1,2 +0,0 @@
export * from './duck'
export * from './components'

View File

@@ -1,4 +0,0 @@
export * from './servers'
export * from './templates'
export * from './users'
export * from './certificates'

View File

@@ -1,44 +0,0 @@
import { useForm, type SubmitHandler } from 'react-hook-form';
import { createServerAction } from '../../duck';
import { useAppDispatch } from '../../../../common/hooks';
import type { CreateServerForm } from '../../types';
export const AddServer = () => {
const dispatch = useAppDispatch();
const { register, handleSubmit, reset } = useForm<CreateServerForm>();
const onSubmit: SubmitHandler<CreateServerForm> = (values) => {
const data = {
...values,
grpc_port: parseInt(values.grpc_port)
}
dispatch(createServerAction(data)).then(() => {
reset();
});
};
return (
<div className="section">
<h2>Add Server</h2>
<form onSubmit={handleSubmit(onSubmit)} id="serverForm">
<div className="form-group">
<label>Name:</label>
<input {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Hostname:</label>
<input {...register('hostname', { required: true })} />
</div>
<div className="form-group">
<label>gRPC Port:</label>
<input {...register('grpc_port', { required: true })} />
</div>
<button type="submit" className="btn btn-primary">
Add Server
</button>
</form>
</div>
);
};

View File

@@ -1 +0,0 @@
export * from './servers-list'

View File

@@ -1,94 +0,0 @@
import { useEffect, type FC } from 'react';
import { useForm } from 'react-hook-form';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import type { CreateServerForm } from '../../types';
import { getServer } from '../../duck/api';
import { useAppDispatch } from '../../../../common/hooks';
import { updateServer } from '../../duck';
export interface ServerEditProps {
serverId: string;
isOpen: boolean;
onOpenChange: () => void;
}
export const ServerEdit: FC<ServerEditProps> = (props) => {
const dispatch = useAppDispatch();
const { serverId, isOpen, onOpenChange } = props;
const { register, handleSubmit, reset } = useForm<CreateServerForm>();
useEffect(() => {
getServer(serverId).then((response) => {
const { data } = response;
reset({
...data,
grpc_port: String(data.grpc_port),
});
});
}, [serverId]);
const onSubmit = (values: CreateServerForm) => {
const data = {
...values,
grpc_port: parseInt(values.grpc_port),
};
dispatch(
updateServer({
id: serverId,
server: data,
}),
).then(() => {
onOpenChange();
});
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<form onSubmit={handleSubmit(onSubmit)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div className="form-group">
<label>Name:</label>
<input {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Hostname:</label>
<input {...register('hostname', { required: true })} />
</div>
<div className="form-group">
<label>gRPC Port:</label>
<input
type="number"
{...register('grpc_port', { required: true })}
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" type="submit">
Save
</Button>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</form>
</Modal>
);
};

View File

@@ -1,60 +0,0 @@
import type { FC } from 'react';
import { useDisclosure } from '@heroui/react';
import { deleteServerAction, type ServerDTO } from '../../duck';
import { testServer } from '../../duck/api';
import { appNotificator } from '../../../../utils/notification/app-notificator';
import { useAppDispatch } from '../../../../common/hooks';
import { ServerEdit } from './server-edit';
export interface ServerViewProps {
server: ServerDTO;
}
export const ServerView: FC<ServerViewProps> = ({ server }) => {
const dispatch = useAppDispatch();
const handleTestServer = () => {
testServer(server.id).then((result) => {
const { connected } = result.data;
appNotificator.add({
message: connected ? 'Connection OK' : 'Connection failed',
type: connected ? 'success' : 'error',
});
});
};
const handleDeleteServer = () => {
if (confirm('Delete server?')) {
dispatch(deleteServerAction(server.id));
}
};
const { isOpen, onOpen, onOpenChange } = useDisclosure();
return (
<>
<tr>
<td>{server.name}</td>
<td>{server.hostname}</td>
<td>{server.grpc_port}</td>
<td>{server.status}</td>
<td>
<button className="btn btn-success" onClick={handleTestServer}>
Test
</button>
<button className="btn btn-primary" onClick={onOpen}>
Edit
</button>
<button className="btn btn-danger" onClick={handleDeleteServer}>
Delete
</button>
</td>
</tr>
<ServerEdit
serverId={server.id}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
</>
);
};

View File

@@ -1,30 +0,0 @@
import type { FC } from 'react';
import type { ServerDTO } from '../../duck';
import { ServerView } from './server-view';
export interface ServersListProps {
servers: ServerDTO[];
}
export const ServersList: FC<ServersListProps> = (props) => {
const { servers } = props;
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Hostname</th>
<th>Port</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{servers.map((server) => (
<ServerView key={server.id} server={server} />
))}
</tbody>
</table>
);
};

View File

@@ -1,99 +0,0 @@
import { serversSlice } from './slice';
import { createServer, deleteServer, getServers, patchServer } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getServersState } from './selectors';
import type { RootState } from '../../../store';
import type { CreateServerDTO } from './dto';
import { appNotificator } from '../../../utils/notification/app-notificator';
const PREFFIX = 'servers';
export const fetchServers = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getServersState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(serversSlice.actions.setLoading(true));
const response = await getServers().then(({ data }) => data);
dispatch(serversSlice.actions.setServers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(serversSlice.actions.setError(message));
} finally {
dispatch(serversSlice.actions.setLoading(false));
}
},
);
export const createServerAction = createAsyncThunk(
`${PREFFIX}/createServer`,
async (params: CreateServerDTO, { dispatch }) => {
try {
await createServer(params);
dispatch(fetchServers());
} catch (e) {
appNotificator.add({
message:
e instanceof Error
? e.message
: `Unknown error in ${PREFFIX}/createServer`,
type: 'error',
});
}
},
);
export const deleteServerAction = createAsyncThunk(
`${PREFFIX}/deleteServer`,
async (id: string, { dispatch }) => {
try {
await deleteServer(id);
appNotificator.add({
message: 'Server deleted',
type: 'success',
});
dispatch(fetchServers());
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Delete error: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteServer`,
});
}
},
);
export const updateServer = createAsyncThunk(
`${PREFFIX}/updateServer`,
async (
params: {
id: string;
server: CreateServerDTO;
},
{ dispatch },
) => {
try {
await patchServer(params.id, params.server);
dispatch(fetchServers());
appNotificator.add({
message: 'Server updated',
type: 'success',
});
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Error updating: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteServer`,
});
}
},
);

View File

@@ -1,28 +0,0 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { ServerDTO, CreateServerDTO, TestServerDTO } from './dto';
export const getServers = () =>
api.get<never, AxiosResponse<ServerDTO[]>>('/servers');
export const createServer = (params: CreateServerDTO) =>
api.post<AxiosResponse>('servers', params, {
headers: {
'Content-Type': 'application/json',
},
});
export const testServer = (id: string) =>
api.post<TestServerDTO>(`/servers/${id}/test`);
export const deleteServer = (id: string) => api.delete(`/servers/${id}`);
export const getServer = (id: string) =>
api.get<string, AxiosResponse<ServerDTO>>(`/servers/${id}`);
export const patchServer = (id: string, server: CreateServerDTO) =>
api.put(`/servers/${id}`, server, {
headers: { 'Content-Type': 'application/json' },
});

View File

@@ -1,18 +0,0 @@
export interface ServerDTO {
id: string
name: string
hostname: string
grpc_port: number
status: string
}
export interface CreateServerDTO {
name: string;
hostname: string;
grpc_port: number;
}
export interface TestServerDTO {
connected: boolean,
endpoint: string
}

View File

@@ -1,4 +0,0 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -1,3 +0,0 @@
import type { RootState } from '../../../store';
export const getServersState = (state: RootState) => state.servers;

View File

@@ -1,32 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { ServerDTO } from './dto';
export interface ServersState {
loading: boolean;
servers: ServerDTO[];
error: null | string;
}
const initialState: ServersState = {
loading: false,
servers: [],
error: null,
};
export const serversSlice = createSlice({
name: 'servers',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setServers: (state, action: PayloadAction<ServerDTO[]>) => {
state.servers = action.payload
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -1 +0,0 @@
export * from './duck'

View File

@@ -1,5 +0,0 @@
export interface CreateServerForm {
name: string;
hostname: string;
grpc_port: string;
}

View File

@@ -1 +0,0 @@
export * from './form'

View File

@@ -1,57 +0,0 @@
import { useForm, type SubmitHandler } from 'react-hook-form'
import type { CreateTemplateForm } from '../../types';
import { useAppDispatch } from '../../../../common/hooks';
import { protocolOptions } from './util'
import type { CreateTemplateDTO } from '../../duck/dto';
import { createTemplateAction } from '../../duck';
export const AddTemplate = () => {
const dispatch = useAppDispatch()
const { register, handleSubmit, reset } = useForm<CreateTemplateForm>({
defaultValues: {
default_port: '443'
}
});
const onSubmit: SubmitHandler<CreateTemplateForm> = (values) => {
const data: CreateTemplateDTO = {
...values,
default_port: parseInt(values.default_port),
config_template: ''
}
dispatch(createTemplateAction(data)).then(() => {
reset();
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} id="templateForm">
<div className="form-group">
<label>Name:</label>
<input type="text" {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Protocol:</label>
<select {...register('protocol', {required: true})}>
{Object.entries(protocolOptions).map((protocolTupple)=> (
<option value={protocolTupple[0]}>{protocolTupple[1]}</option>
))}
</select>
</div>
<div className="form-group">
<label>Default Port:</label>
<input type="number" {...register('default_port', {required: true})} />
</div>
<div className="form-group">
<label>
<input type="checkbox" {...register('requires_tls')}/> Requires TLS
</label>
</div>
<button type="submit" className="btn btn-primary">
Add Template
</button>
</form>
)
}

View File

@@ -1 +0,0 @@
export * from './add-template'

View File

@@ -1,9 +0,0 @@
import type { Protocol } from '../../duck/dto'
export const protocolOptions: Record<Protocol, string> = {
vless: 'VLESS',
vmess: 'VMess',
trojan: 'Trojan',
shadowsocks: 'Shadowsocks'
}

View File

@@ -1,2 +0,0 @@
export * from './add-template'
export * from './template-list'

View File

@@ -1 +0,0 @@
export * from './template-list'

View File

@@ -1,120 +0,0 @@
import { useEffect, type FC } from 'react';
import { useForm } from 'react-hook-form';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import type { EditTemplateForm } from '../../types';
import { useAppDispatch } from '../../../../common/hooks';
import { getTemplateById } from '../../duck/api';
import { protocolOptions } from '../add-template/util';
import { updateTemplate } from '../../duck';
export interface TemplateEditProps {
templateId: string;
isOpen: boolean;
onOpenChange: () => void;
}
export const TemplateEdit: FC<TemplateEditProps> = (props) => {
const dispatch = useAppDispatch();
const { templateId, isOpen, onOpenChange } = props;
const { register, handleSubmit, reset } = useForm<EditTemplateForm>();
useEffect(() => {
getTemplateById(templateId).then((response) => {
const { data } = response;
reset({
...data,
default_port: String(data.default_port),
});
});
}, [templateId]);
const onSubmit = (values: EditTemplateForm) => {
const data = {
...values,
default_port: parseInt(values.default_port),
};
dispatch(
updateTemplate({
id: templateId,
template: data,
}),
).then(() => {
onOpenChange();
});
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<form onSubmit={handleSubmit(onSubmit)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div>
<div className="form-group">
<label>Name:</label>
<input
type="text"
{...register('name', { required: true })}
/>
</div>
<div className="form-group">
<label>Protocol:</label>
<select
id="editProtocol"
{...register('protocol', { required: true })}
>
{Object.entries(protocolOptions).map((protocolTupple) => (
<option value={protocolTupple[0]}>
{protocolTupple[1]}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Default Port:</label>
<input
type="number"
{...register('default_port', {required: true})}
/>
</div>
<div className="form-group">
<label>
<input type="checkbox" {...register('requires_tls')} />{' '}
Requires TLS
</label>
</div>
<div className="form-group">
<label>
<input type="checkbox" {...register('is_active')} />{' '}
Active
</label>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" type="submit">
Save
</Button>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</form>
</Modal>
);
};

View File

@@ -1,29 +0,0 @@
import type { FC } from 'react';
import type { TemplateDTO } from '../../duck';
import { TemplateView } from './template-view';
export interface TemplateListProps {
templates: TemplateDTO[];
}
export const TemplateList: FC<TemplateListProps> = ({ templates }) => {
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Protocol</th>
<th>Port</th>
<th>TLS</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{templates.map((template) => (
<TemplateView template={template} key={template.id}/>
))}
</tbody>
</table>
);
};

View File

@@ -1,48 +0,0 @@
import type { FC } from 'react';
import { deleteTemplateAction, type TemplateDTO } from '../../duck';
import { useDisclosure } from '@heroui/react';
import { TemplateEdit } from './template-edit';
import { useAppDispatch } from '../../../../common/hooks';
export interface TemplateViewProps {
template: TemplateDTO;
}
export const TemplateView: FC<TemplateViewProps> = ({ template }) => {
const dispatch = useAppDispatch();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const handleDeleteTemplate = () => {
if (confirm('Delete template?')) {
dispatch(deleteTemplateAction(template.id));
}
};
return (
<>
<tr>
<td>{template.name}</td>
<td>{template.protocol}</td>
<td>{template.default_port}</td>
<td>{template.requires_tls ? 'Yes' : 'No'}</td>
<td>{template.is_active ? 'Yes' : 'No'}</td>
<td>
<button className="btn btn-primary" onClick={onOpen}>
Edit
</button>
<button
className="btn btn-danger"
onClick={handleDeleteTemplate}
>
Delete
</button>
</td>
</tr>
<TemplateEdit
templateId={template.id}
onOpenChange={onOpenChange}
isOpen={isOpen}
/>
</>
);
};

View File

@@ -1,102 +0,0 @@
import { templatesSlice } from './slice';
import { getTemplates, createTemplate, patchTemplate, deleteTemplate } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getTemplatesState } from './selectors';
import type { RootState } from '../../../store';
import type { CreateTemplateDTO, EditTemplateDTO } from './dto';
import { appNotificator } from '../../../utils/notification/app-notificator';
const PREFFIX = 'templates';
export const fetchTemplates = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getTemplatesState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(templatesSlice.actions.setLoading(true));
const response = await getTemplates().then(({ data }) => data);
dispatch(templatesSlice.actions.setTemplates(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(templatesSlice.actions.setError(message));
} finally {
dispatch(templatesSlice.actions.setLoading(false));
}
},
);
export const createTemplateAction = createAsyncThunk(
`${PREFFIX}/createTemplate`,
async (params: CreateTemplateDTO, { dispatch }) => {
try {
await createTemplate(params);
dispatch(fetchTemplates());
} catch (e) {
appNotificator.add({
message:
e instanceof Error
? e.message
: `Unknown error in ${PREFFIX}/createTemplate`,
type: 'error',
});
}
},
);
export const updateTemplate = createAsyncThunk(
`${PREFFIX}/updateTemplate`,
async (
params: {
id: string;
template: EditTemplateDTO;
},
{ dispatch },
) => {
try {
await patchTemplate(params.id, params.template);
dispatch(fetchTemplates());
appNotificator.add({
message: 'Template updated',
type: 'success',
});
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Error updating: ${e.message}`
: `Unknown error in ${PREFFIX}/updateTemplate`,
});
}
},
);
export const deleteTemplateAction = createAsyncThunk(
`${PREFFIX}/deleteTemplate`,
async (id: string, { dispatch }) => {
try {
await deleteTemplate(id);
appNotificator.add({
message: 'Template deleted',
type: 'success',
});
dispatch(fetchTemplates());
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Delete error: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteTemplate`,
});
}
},
);

View File

@@ -1,23 +0,0 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { TemplateDTO, CreateTemplateDTO, EditTemplateDTO } from './dto';
export const getTemplates = () =>
api.get<never, AxiosResponse<TemplateDTO[]>>('/templates');
export const createTemplate = (params: CreateTemplateDTO) =>
api.post<AxiosResponse>('templates', params, {
headers: {
'Content-Type': 'application/json',
},
});
export const getTemplateById = (id: string) =>
api.get<string, AxiosResponse<TemplateDTO>>(`/templates/${id}`);
export const patchTemplate = (id: string, template: EditTemplateDTO) =>
api.put(`/templates/${id}`, template, {
headers: { 'Content-Type': 'application/json' },
});
export const deleteTemplate = (id: string) => api.delete(`/templates/${id}`);

View File

@@ -1,33 +0,0 @@
export type Protocol = 'vless' | 'vmess' | 'trojan' | 'shadowsocks';
export interface TemplateDTO {
base_settings: Record<string, unknown>; // TODO define unknown
created_at: string;
default_port: number;
description: string;
id: string;
is_active: boolean;
name: string;
protocol: Protocol;
requires_domain: boolean;
requires_tls: boolean;
stream_settings: Record<string, unknown>; // TOD define unknown
updated_at: string;
variables: unknown[]; // TOD define unknown
}
export interface CreateTemplateDTO {
name: string;
protocol: Protocol;
default_port: number;
requires_tls: boolean;
config_template: '';
}
export interface EditTemplateDTO {
name: string,
protocol: Protocol
default_port: number
requires_tls: boolean
is_active: boolean
}

View File

@@ -1,4 +0,0 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -1,3 +0,0 @@
import type { RootState } from '../../../store';
export const getTemplatesState = (state: RootState) => state.templates;

View File

@@ -1,32 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { TemplateDTO } from './dto';
export interface TemplateState {
loading: boolean;
templates: TemplateDTO[];
error: null | string;
}
const initialState: TemplateState = {
loading: false,
templates: [],
error: null,
};
export const templatesSlice = createSlice({
name: 'templates',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setTemplates: (state, action: PayloadAction<TemplateDTO[]>) => {
state.templates = action.payload
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -1,2 +0,0 @@
export * from './duck'
export * from './components'

View File

@@ -1,16 +0,0 @@
import type { Protocol } from "../duck/dto"
export interface CreateTemplateForm {
name: string,
protocol: Protocol
default_port: string
requires_tls: boolean
}
export interface EditTemplateForm {
name: string,
protocol: Protocol
default_port: string
requires_tls: boolean
is_active: boolean
}

View File

@@ -1 +0,0 @@
export * from './form'

View File

@@ -1,30 +0,0 @@
import { usersSlice } from './slice';
import { getUsers } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getUsersState } from './selectors';
import type { RootState } from '../../../store';
const PREFFIX = 'users'
export const fetchUsers = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getUsersState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(usersSlice.actions.setLoading(true));
const response = await getUsers().then(({ data }) => data);
dispatch(usersSlice.actions.setUsers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(usersSlice.actions.setError(message));
} finally {
dispatch(usersSlice.actions.setLoading(false));
}
},
);

View File

@@ -1,5 +0,0 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { UserDTO } from './dto';
export const getUsers = () => api.get<never, AxiosResponse<UserDTO[]>>('/users');

View File

@@ -1,8 +0,0 @@
export interface User {}
export interface UserDTO {
page: number
per_page: number
total: number
users: User[]
}

View File

@@ -1,4 +0,0 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -1,3 +0,0 @@
import type { RootState } from '../../../store';
export const getUsersState = (state: RootState) => state.users;

View File

@@ -1,32 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { UserDTO, User } from './dto';
export interface UsersState {
loading: boolean;
users: User[]
error: null | string;
}
const initialState: UsersState = {
loading: false,
users: [],
error: null,
};
export const usersSlice = createSlice({
name: 'certificate',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setUsers: (state, action: PayloadAction<UserDTO[]>) => {
state.users = action.payload.users
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -1 +0,0 @@
export * from './duck'

View File

@@ -1,2 +0,0 @@
import { heroui } from "@heroui/react";
export default heroui();

View File

@@ -1,5 +0,0 @@
@import "tailwindcss";
@plugin './hero.ts';
@source '../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@custom-variant dark (&:is(.dark *));

View File

@@ -1,22 +0,0 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { store } from './store/store';
import { Provider } from 'react-redux';
import { HeroUIProvider } from '@heroui/react';
import {ToastProvider} from "@heroui/toast";
import { router } from './router';
import './index.css';
import { ApplyNotificator } from './common/components/apply-notificator';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<HeroUIProvider>
<RouterProvider router={router} />
<ToastProvider/>
<ApplyNotificator/>
</HeroUIProvider>
</Provider>
</StrictMode>,
);

View File

@@ -1,38 +0,0 @@
import type { RouteObject } from 'react-router';
import { useAppDispatch, useAppSelector } from '../../common/hooks';
import { useEffect } from 'react';
import { fetchCertificates, getCertificatesState } from '../../features';
import { CreateCertificate } from '../../features/certificates';
import { CertificateList } from '../../features/certificates/components/certificate-list';
export const Certificates = () => {
const dispatch = useAppDispatch()
const { loading, certificates } = useAppSelector(getCertificatesState)
useEffect(()=>{
dispatch(fetchCertificates())
}, [dispatch])
return (
<div id="certificates" className="tab-content active">
<div className="section">
<h2>Add Certificate</h2>
<CreateCertificate/>
</div>
<div className="section">
<h2>Certificates List</h2>
<div id="certificatesList" className="loading">
{ loading && 'Loading...' }
{ certificates.length ? <CertificateList certificates={certificates}/> : <p>No certificates found</p> }
</div>
</div>
</div>
);
};
export const CertificatesRoute: RouteObject = {
path: '/certificates',
Component: Certificates,
};

View File

@@ -1 +0,0 @@
export * from './certificates';

View File

@@ -1,72 +0,0 @@
import type { RouteObject } from 'react-router';
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../common/hooks';
import {
fetchServers,
getServersState,
fetchTemplates,
getTemplatesState,
fetchUsers,
getUsersState,
getCertificatesState,
fetchCertificates,
} from '../../features';
export const Dashboard = () => {
const dispatch = useAppDispatch();
const { loading: serverLoading, servers } = useAppSelector(getServersState);
const { loading: usersLoading, users } = useAppSelector(getUsersState);
const { loading: certificatesLoading, certificates } =
useAppSelector(getCertificatesState);
const { loading: templatesLoading, templates } =
useAppSelector(getTemplatesState);
useEffect(() => {
dispatch(fetchServers());
dispatch(fetchTemplates());
dispatch(fetchUsers());
dispatch(fetchCertificates());
}, [dispatch]);
return (
<div id="dashboard" className="tab-content active">
<div className="section">
<h2>Statistics</h2>
<p>
Servers:{' '}
<span id="serverCount">
{serverLoading === true && 'Loading...'}
{servers && String(servers.length)}
</span>
</p>
<p>
Templates:{' '}
<span id="templateCount">
{templatesLoading && 'Loading...'}
{templates && String(templates.length)}
</span>
</p>
<p>
Certificates:{' '}
<span id="certCount">
{certificatesLoading && 'Loading...'}
{certificates && String(certificates.length)}
</span>
</p>
<p>
Users:{' '}
<span id="userCount">
{usersLoading && 'Loading...'}
{users && String(users.length)}
</span>
</p>
</div>
</div>
);
};
export const DashboardRoute: RouteObject = {
index: true,
path: '/',
Component: Dashboard,
};

View File

@@ -1 +0,0 @@
export * from './dashboard';

View File

@@ -1,238 +0,0 @@
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.section {
background: white;
margin: 20px 0;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333;
}
h2 {
color: #666;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background: #f9f9f9;
}
.btn {
padding: 6px 12px;
margin: 2px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.form-group {
margin: 10px 0;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input,
.form-group select {
width: 300px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
/* Toast notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
pointer-events: none;
}
.toast {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-bottom: 10px;
padding: 16px 20px;
min-width: 300px;
max-width: 400px;
position: relative;
transform: translateX(100%);
transition:
transform 0.3s ease-in-out,
opacity 0.3s ease-in-out;
opacity: 0;
pointer-events: auto;
border-left: 4px solid #007bff;
}
.toast.show {
transform: translateX(0);
opacity: 1;
}
.toast.success {
border-left-color: #28a745;
}
.toast.error {
border-left-color: #dc3545;
}
.toast-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.toast-title {
font-weight: bold;
color: #333;
}
.toast-close {
background: none;
border: none;
font-size: 18px;
color: #999;
cursor: pointer;
padding: 0;
margin-left: 10px;
}
.toast-close:hover {
color: #666;
}
.toast-body {
color: #666;
line-height: 1.4;
}
.toast.success .toast-title {
color: #155724;
}
.toast.error .toast-title {
color: #721c24;
}
.tabs {
border-bottom: 1px solid #ddd;
margin-bottom: 20px;
}
.tab {
display: inline-block;
padding: 10px 20px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab.active {
border-bottom-color: #007bff;
background: #f8f9fa;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
/* Modal styles */
.modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal.show {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 20px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.modal-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
color: #999;
cursor: pointer;
}
.modal-close:hover {
color: #666;
}
.modal-body {
margin-bottom: 20px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid #eee;
padding-top: 15px;
}
.cert-details {
font-family: monospace;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 15px;
white-space: pre-wrap;
overflow-x: auto;
max-height: 300px;
}

View File

@@ -1,82 +0,0 @@
import { Outlet } from 'react-router';
import './home.css';
import { NavMenu } from '../../components/nav-menu/nav-menu';
import { navItems } from './utils';
export const Home = () => {
return (
<div>
<div className="container">
<h1 className="text-3xl font-bold underline">Xray Admin Panel - Test Interface</h1>
{/* <!-- Toast notifications container --> */}
<div className="toast-container" id="toastContainer"></div>
<NavMenu items={navItems} />
<Outlet />
</div>
{/* <!-- Modal dialogs --> */}
<div id="editModal" className="modal">
<div className="modal-content">
<div className="modal-header">
<div className="modal-title" id="editModalTitle">
Edit Item
</div>
<button
className="modal-close"
// onClick="closeModal('editModal')"
>
&times;
</button>
</div>
<div className="modal-body" id="editModalBody">
{/* <!-- Content will be dynamically loaded --> */}
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
// onClick="closeModal('editModal')"
>
Cancel
</button>
<button
className="btn btn-primary"
id="saveEditBtn"
// onClick="saveEdit()"
>
Save
</button>
</div>
</div>
</div>
<div id="viewModal" className="modal">
<div className="modal-content">
<div className="modal-header">
<div className="modal-title" id="viewModalTitle">
View Details
</div>
<button
className="modal-close"
//onClick="closeModal('viewModal')"
>
&times;
</button>
</div>
<div className="modal-body" id="viewModalBody">
{/* <!-- Content will be dynamically loaded --> */}
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
// onClick="closeModal('viewModal')"
>
Close
</button>
</div>
</div>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More