mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-12-16 17:37:51 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3d74ccbd | ||
|
|
c5a94d17dc | ||
|
|
17f9f5c045 | ||
|
|
4f7131ff5a | ||
|
|
fa7ec5a87e | ||
|
|
05d19b88af | ||
|
|
a75d55ac9d | ||
|
|
90001a1d1e | ||
|
|
9325a94cb2 | ||
|
|
8854aacf88 | ||
|
|
3f346bc6c6 | ||
|
|
b4bdffbbe3 | ||
|
|
f5e5298461 | ||
|
|
243a6734fd | ||
|
|
df5493bf14 | ||
|
|
ba62e214ce | ||
|
|
664bafe067 | ||
|
|
6d56eb7eab | ||
|
|
47572d64c6 | ||
|
|
8a521dc12e | ||
|
|
a938dde77c | ||
|
|
7efe87c1d2 | ||
|
|
67f1c4d147 | ||
|
|
2d4c862c5e | ||
|
|
9bd4896040 | ||
|
|
ec869b2974 | ||
|
|
dc6d170f08 | ||
|
|
42a923799b | ||
|
|
9c8f0463a5 | ||
|
|
d57232ac98 | ||
|
|
664c2b5ec4 | ||
|
|
281b8270ce | ||
|
|
10b5e5f86a | ||
|
|
20e322a2e8 | ||
|
|
e77d13ab4e | ||
|
|
cb9be75e90 | ||
|
|
8e378cb787 | ||
|
|
bf4bc505de | ||
|
|
c4dc0a1b42 | ||
|
|
e22b26b1aa | ||
|
|
8d8d6bb671 | ||
|
|
c6da8ea250 | ||
|
|
26a94d0e72 | ||
|
|
72f59563f5 | ||
|
|
fbf5019c32 | ||
|
|
53dcc29dc7 | ||
|
|
89eee8fe3e | ||
|
|
7c47a3935a | ||
|
|
43c86e2075 | ||
|
|
ed8bfe7f06 | ||
|
|
ca463fe5ab | ||
|
|
8f51b4cf9e | ||
|
|
760c1c7647 | ||
|
|
d1908e879b | ||
| c24c35f443 | |||
| 7527ddfcb9 | |||
| d9bf110ba9 | |||
| e3682fd121 | |||
| d02377a270 | |||
| 35e3980487 | |||
| f139e0bcc6 | |||
|
|
b22477b3e2 | ||
|
|
2323151242 | ||
|
|
2ca317a9a2 | ||
|
|
826827f85e | ||
|
|
9763a6e48d | ||
|
|
4fad180ec9 | ||
|
|
7b5f47fa64 | ||
| a790da0793 | |||
| a8ddadbe6d | |||
| 6710cf211c | |||
| 0880401cc4 | |||
| 7cf99af20d | |||
| b6ad6e8578 | |||
| 7585fb94a1 | |||
| d324edec69 | |||
| dda9b4ba5a | |||
| 75126b09ff | |||
| a1ff998b68 | |||
| c4d9254824 | |||
| 2c74667945 | |||
| 538bc2f65e | |||
|
|
bc5f774d9f | ||
|
|
7bf998ece5 |
23
.github/workflows/main.yml
vendored
23
.github/workflows/main.yml
vendored
@@ -3,7 +3,7 @@ name: Docker hub build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'django'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -24,13 +24,28 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Set outputs
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
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 ${{ steps.vars.outputs.sha_short }}
|
||||
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
|
||||
tags: ultradesu/outfleet:latest,ultradesu/outfleet:${{ steps.vars.outputs.sha_short }}
|
||||
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 }}
|
||||
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,9 +1,21 @@
|
||||
config.yaml
|
||||
__pycache__/
|
||||
sync.log
|
||||
main.py
|
||||
.idea/*
|
||||
.vscode/*
|
||||
db.sqlite3
|
||||
debug.log
|
||||
*.swp
|
||||
*.swo
|
||||
*.swn
|
||||
*.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
64
.vscode/launch.json
vendored
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
39
Dockerfile
Executable file → Normal file
39
Dockerfile
Executable file → Normal file
@@ -1,13 +1,40 @@
|
||||
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
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY static static
|
||||
COPY templates templates
|
||||
COPY *.py .
|
||||
# Install system dependencies first (this layer will be cached)
|
||||
RUN apk update && apk add git curl unzip
|
||||
|
||||
# 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
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["python", "main.py"]
|
||||
# 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
|
||||
|
||||
# Copy the rest of the application code (this layer will change frequently)
|
||||
COPY . .
|
||||
|
||||
# Run collectstatic
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
CMD [ "python", "./manage.py", "runserver", "0.0.0.0:8000" ]
|
||||
|
||||
58
README.md
58
README.md
@@ -2,7 +2,7 @@
|
||||
<h1 align="center">OutFleet: Master Your OutLine VPN</h1>
|
||||
|
||||
<p align="center">
|
||||
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers and always-updated Dynamic Access Keys instead of ss:// links
|
||||
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>
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
  
|
||||
|
||||
## About The Project
|
||||
<img width="1454" alt="image" src="https://github.com/user-attachments/assets/20555dd9-54ea-4b95-aa13-a7dd54e34ef4" />
|
||||
|
||||

|
||||
## About The Project
|
||||
|
||||
### Key Features
|
||||
|
||||
@@ -28,60 +28,18 @@ Tired of juggling multiple home servers and the headache of individually managin
|
||||
|
||||
## Built With
|
||||
|
||||
Python, Flask and offer hassle-free deployment.
|
||||
Django, Postgres SQL and hassle-free deployment using Kubernetes or docker-compose
|
||||
|
||||
### Installation
|
||||
|
||||
#### Docker compose
|
||||
Docker deploy is easy:
|
||||
```
|
||||
docker run --restart always -p 5000:5000 -d --name outfleet --mount type=bind,source=/etc/outfleet/config.yaml,target=/usr/local/etc/outfleet/config.yaml ultradesu/outfleet:latest
|
||||
docker-compose up -d
|
||||
```
|
||||
#### Use reverse proxy to secure ALL path of OutFleet except of `/dynamic/*`
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name server.name;
|
||||
|
||||
# Specify SSL config if using a shared one.
|
||||
#include conf.d/ssl/ssl.conf;
|
||||
|
||||
# Allow large attachments
|
||||
client_max_body_size 128M;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/server.name/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/server.name/privkey.pem; # managed by Certbot
|
||||
#### 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.
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
auth_basic "Private Place";
|
||||
auth_basic_user_file /etc/nginx/htpasswd;
|
||||
}
|
||||
|
||||
location /dynamic {
|
||||
auth_basic off;
|
||||
proxy_pass http://localhost:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
access_log /var/log/nginx/server.name.access.log;
|
||||
error_log /var/log/nginx/server.name.error.log;
|
||||
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
server_name server.name;
|
||||
listen [::]:80;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
#### 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.
|
||||
|
||||
21
SECURITY.md
Normal file
21
SECURITY.md
Normal 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.
|
||||
15
cleanup_analysis.sql
Normal file
15
cleanup_analysis.sql
Normal 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
35
cleanup_options.sql
Normal 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;
|
||||
102
docker-compose.yaml
Normal file
102
docker-compose.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
services:
|
||||
web_ui:
|
||||
image: outfleet:local
|
||||
container_name: outfleet-web
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- EXTERNAL_ADDRESS=http://127.0.0.1:8000
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "sleep 1 &&
|
||||
python manage.py makemigrations &&
|
||||
python manage.py migrate &&
|
||||
python manage.py create_admin &&
|
||||
python manage.py runserver 0.0.0.0:8000"
|
||||
|
||||
worker:
|
||||
image: outfleet:local
|
||||
container_name: outfleet-worker
|
||||
build:
|
||||
context: .
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "sleep 3 && celery -A mysite worker"
|
||||
|
||||
beat:
|
||||
image: outfleet:local
|
||||
container_name: outfleet-beat
|
||||
build:
|
||||
context: .
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "sleep 3 && celery -A mysite beat"
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: outfleet
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
BIN
img/servers.png
BIN
img/servers.png
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
127
k8s.py
127
k8s.py
@@ -1,127 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
import yaml
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
import lib
|
||||
from kubernetes import client, config as kube_config
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
log = logging.getLogger("OutFleet.k8s")
|
||||
|
||||
NAMESPACE = False
|
||||
SERVERS = list()
|
||||
CONFIG = None
|
||||
V1 = None
|
||||
K8S_DETECTED = False
|
||||
|
||||
|
||||
def discovery_servers():
|
||||
global CONFIG
|
||||
interval = 60
|
||||
log = logging.getLogger("OutFleet.discovery")
|
||||
|
||||
if K8S_DETECTED:
|
||||
while True:
|
||||
pods = V1.list_namespaced_pod(NAMESPACE, label_selector="app=shadowbox")
|
||||
log.debug(f"Started discovery thread every {interval}")
|
||||
for pod in pods.items:
|
||||
log.debug(f"Found Outline server pod {pod.metadata.name}")
|
||||
container_log = V1.read_namespaced_pod_log(name=pod.metadata.name, namespace=NAMESPACE, container='manager-config-json')
|
||||
secret = json.loads(container_log.replace('\'', '\"'))
|
||||
config = lib.get_config()
|
||||
config_servers = find_server(secret, config["servers"])
|
||||
#log.info(f"config_servers {config_servers}")
|
||||
if len(config_servers) > 0:
|
||||
log.debug(f"Already exist")
|
||||
pass
|
||||
else:
|
||||
with lib.lock:
|
||||
config["servers"][str(uuid.uuid4())] = {
|
||||
"cert": secret["certSha256"],
|
||||
"name": f"{pod.metadata.name}",
|
||||
"comment": f"{pod.spec.node_name}",
|
||||
"url": secret["apiUrl"],
|
||||
}
|
||||
write_config(config)
|
||||
log.info(f"Added discovered server")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def find_server(search_data, servers):
|
||||
found_servers = {}
|
||||
for server_id, server_info in servers.items():
|
||||
if server_info["url"] == search_data["apiUrl"] and server_info["cert"] == search_data["certSha256"]:
|
||||
found_servers[server_id] = server_info
|
||||
return found_servers
|
||||
|
||||
|
||||
|
||||
def write_config(config):
|
||||
config_map = client.V1ConfigMap(
|
||||
api_version="v1",
|
||||
kind="ConfigMap",
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=f"config-outfleet",
|
||||
labels={
|
||||
"app": "outfleet",
|
||||
}
|
||||
),
|
||||
data={"config.yaml": yaml.dump(config)}
|
||||
)
|
||||
try:
|
||||
api_response = V1.create_namespaced_config_map(
|
||||
namespace=NAMESPACE,
|
||||
body=config_map,
|
||||
)
|
||||
except ApiException as e:
|
||||
api_response = V1.patch_namespaced_config_map(
|
||||
name="config-outfleet",
|
||||
namespace=NAMESPACE,
|
||||
body=config_map,
|
||||
)
|
||||
log.info("Updated config in Kubernetes ConfigMap [config-outfleet]")
|
||||
|
||||
|
||||
def reload_config():
|
||||
global CONFIG
|
||||
while True:
|
||||
with lib.lock:
|
||||
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.debug(f"Synced system config with ConfigMap [config-outfleet].")
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
try:
|
||||
kube_config.load_incluster_config()
|
||||
V1 = client.CoreV1Api()
|
||||
if V1 != None:
|
||||
K8S_DETECTED = True
|
||||
try:
|
||||
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f:
|
||||
NAMESPACE = f.read().strip()
|
||||
log.info(f"Found Kubernetes environment. Deployed to namespace '{NAMESPACE}'")
|
||||
try:
|
||||
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.info(f"ConfigMap loaded from Kubernetes API. Servers: {len(CONFIG['servers'])}, Clients: {len(CONFIG['clients'])}. Started monitoring for changes every minute.")
|
||||
except Exception as e:
|
||||
try:
|
||||
write_config({"clients": [], "servers": {}, "ui_hostname": "accessible-address.com"})
|
||||
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.info("Created new ConfigMap [config-outfleet]. Started monitoring for changes every minute.")
|
||||
except Exception as e:
|
||||
log.info(f"Failed to create new ConfigMap [config-outfleet] {e}")
|
||||
thread = threading.Thread(target=reload_config)
|
||||
thread.start()
|
||||
|
||||
except:
|
||||
log.info("Kubernetes environment not detected")
|
||||
except:
|
||||
log.info("Kubernetes environment not detected")
|
||||
|
||||
184
lib.py
184
lib.py
@@ -1,184 +0,0 @@
|
||||
import argparse
|
||||
import logging
|
||||
import threading
|
||||
from typing import TypedDict, List
|
||||
from outline_vpn.outline_vpn import OutlineKey, OutlineVPN
|
||||
import yaml
|
||||
import k8s
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%d-%m-%Y %H:%M:%S",
|
||||
)
|
||||
|
||||
log = logging.getLogger(f'OutFleet.lib')
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
default="/usr/local/etc/outfleet/config.yaml",
|
||||
help="Config file location",
|
||||
)
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
def get_config():
|
||||
if k8s.CONFIG:
|
||||
return k8s.CONFIG
|
||||
else:
|
||||
try:
|
||||
with open(args.config, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
if config == None:
|
||||
config = {
|
||||
"servers": {},
|
||||
"clients": {}
|
||||
}
|
||||
except:
|
||||
try:
|
||||
with open(args.config, "w"):
|
||||
config = {
|
||||
"servers": {},
|
||||
"clients": {}
|
||||
}
|
||||
yaml.safe_dump(config, file)
|
||||
except Exception as exp:
|
||||
log.error(f"Couldn't create config. {exp}")
|
||||
return None
|
||||
return config
|
||||
|
||||
def write_config(config):
|
||||
if k8s.CONFIG:
|
||||
k8s.write_config(config)
|
||||
else:
|
||||
try:
|
||||
with open(args.config, "w") as file:
|
||||
yaml.safe_dump(config, file)
|
||||
except Exception as e:
|
||||
log.error(f"Couldn't write Outfleet config: {e}")
|
||||
|
||||
|
||||
class ServerDict(TypedDict):
|
||||
server_id: str
|
||||
local_server_id: str
|
||||
name: str
|
||||
url: str
|
||||
cert: str
|
||||
comment: str
|
||||
metrics_enabled: str
|
||||
created_timestamp_ms: int
|
||||
version: str
|
||||
port_for_new_access_keys: int
|
||||
hostname_for_access_keys: str
|
||||
keys: List[OutlineKey]
|
||||
|
||||
|
||||
class Server:
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
cert: str,
|
||||
comment: str,
|
||||
# read from config. not the same as real server id you can get from api
|
||||
local_server_id: str,
|
||||
):
|
||||
self.client = OutlineVPN(api_url=url, cert_sha256=cert)
|
||||
self.data: ServerDict = {
|
||||
"local_server_id": local_server_id,
|
||||
"name": self.client.get_server_information()["name"],
|
||||
"url": url,
|
||||
"cert": cert,
|
||||
"comment": comment,
|
||||
"server_id": self.client.get_server_information()["serverId"],
|
||||
"metrics_enabled": self.client.get_server_information()["metricsEnabled"],
|
||||
"created_timestamp_ms": self.client.get_server_information()[
|
||||
"createdTimestampMs"
|
||||
],
|
||||
"version": self.client.get_server_information()["version"],
|
||||
"port_for_new_access_keys": self.client.get_server_information()[
|
||||
"portForNewAccessKeys"
|
||||
],
|
||||
"hostname_for_access_keys": self.client.get_server_information()[
|
||||
"hostnameForAccessKeys"
|
||||
],
|
||||
"keys": self.client.get_keys(),
|
||||
}
|
||||
self.log = logging.getLogger(f'OutFleet.server[{self.data["name"]}]')
|
||||
|
||||
def info(self) -> ServerDict:
|
||||
return self.data
|
||||
|
||||
def check_client(self, name):
|
||||
# Looking for any users with provided name. len(result) != 1 is a problem.
|
||||
result = []
|
||||
for key in self.client.get_keys():
|
||||
if key.name == name:
|
||||
result.append(name)
|
||||
self.log.info(f"check_client found client `{name}` config is correct.")
|
||||
if len(result) != 1:
|
||||
self.log.warning(
|
||||
f"check_client found client `{name}` inconsistent. Found {len(result)} keys."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def apply_config(self, config):
|
||||
if config.get("name"):
|
||||
self.client.set_server_name(config.get("name"))
|
||||
self.log.info(
|
||||
"Changed %s name to '%s'", self.data["local_server_id"], config.get("name")
|
||||
)
|
||||
if config.get("metrics"):
|
||||
self.client.set_metrics_status(
|
||||
True if config.get("metrics") == "True" else False
|
||||
)
|
||||
self.log.info(
|
||||
"Changed %s metrics status to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("metrics"),
|
||||
)
|
||||
if config.get("port_for_new_access_keys"):
|
||||
self.client.set_port_new_for_access_keys(
|
||||
int(config.get("port_for_new_access_keys"))
|
||||
)
|
||||
self.log.info(
|
||||
"Changed %s port_for_new_access_keys to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("port_for_new_access_keys"),
|
||||
)
|
||||
if config.get("hostname_for_access_keys"):
|
||||
self.client.set_hostname(config.get("hostname_for_access_keys"))
|
||||
self.log.info(
|
||||
"Changed %s hostname_for_access_keys to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("hostname_for_access_keys"),
|
||||
)
|
||||
if config.get("comment"):
|
||||
config_file = get_config()
|
||||
config_file["servers"][self.data["local_server_id"]]["comment"] = config.get(
|
||||
"comment"
|
||||
)
|
||||
write_config(config_file)
|
||||
self.log.info(
|
||||
"Changed %s comment to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("comment"),
|
||||
)
|
||||
|
||||
def create_key(self, key_name):
|
||||
self.client.create_key(key_id=key_name, name=key_name)
|
||||
self.log.info("New key created: %s", key_name)
|
||||
return True
|
||||
|
||||
def rename_key(self, key_id, new_name):
|
||||
self.log.info("Key %s renamed: %s", key_id, new_name)
|
||||
return self.client.rename_key(key_id, new_name)
|
||||
|
||||
def delete_key(self, key_id):
|
||||
self.log.info("Key %s deleted", key_id)
|
||||
return self.client.delete_key(key_id)
|
||||
485
main.py
485
main.py
@@ -1,485 +0,0 @@
|
||||
import threading
|
||||
import time
|
||||
import yaml
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import random
|
||||
import string
|
||||
import argparse
|
||||
import uuid
|
||||
import bcrypt
|
||||
|
||||
|
||||
import k8s
|
||||
from flask import Flask, render_template, request, url_for, redirect
|
||||
from flask_cors import CORS
|
||||
from werkzeug.routing import BaseConverter
|
||||
from lib import Server, write_config, get_config, args, lock
|
||||
|
||||
class DuplicateFilter(logging.Filter):
|
||||
|
||||
def filter(self, record):
|
||||
# add other fields if you need more granular comparison, depends on your app
|
||||
current_log = (record.module, record.levelno, record.msg)
|
||||
if current_log != getattr(self, "last_log", None):
|
||||
self.last_log = current_log
|
||||
return True
|
||||
return False
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%d-%m-%Y %H:%M:%S",
|
||||
)
|
||||
|
||||
log = logging.getLogger("OutFleet")
|
||||
file_handler = logging.FileHandler("sync.log")
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
log.addHandler(file_handler)
|
||||
duplicate_filter = DuplicateFilter()
|
||||
log.addFilter(duplicate_filter)
|
||||
|
||||
CFG_PATH = args.config
|
||||
NAMESPACE = k8s.NAMESPACE
|
||||
SERVERS = list()
|
||||
BROKEN_SERVERS = list()
|
||||
CLIENTS = dict()
|
||||
VERSION = '8.1'
|
||||
SECRET_LINK_LENGTH = 8
|
||||
SECRET_LINK_PREFIX = '$2b$12$'
|
||||
SS_PREFIX = "\u0005\u00DC\u005F\u00E0\u0001\u0020"
|
||||
HOSTNAME = ""
|
||||
WRONG_DOOR = "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
|
||||
def format_timestamp(ts):
|
||||
return datetime.fromtimestamp(ts // 1000).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def random_string(length=64):
|
||||
letters = string.ascii_letters + string.digits
|
||||
|
||||
return "".join(random.choice(letters) for i in range(length))
|
||||
|
||||
|
||||
def update_state(timer=40):
|
||||
while True:
|
||||
with lock:
|
||||
global SERVERS
|
||||
global CLIENTS
|
||||
global BROKEN_SERVERS
|
||||
global HOSTNAME
|
||||
config = get_config()
|
||||
|
||||
if config:
|
||||
HOSTNAME = config.get("ui_hostname", "my-own-SSL-ENABLED-domain.com")
|
||||
servers = config.get("servers", dict())
|
||||
_SERVERS = list()
|
||||
for local_server_id, server_config in servers.items():
|
||||
try:
|
||||
server = Server(
|
||||
url=server_config["url"],
|
||||
cert=server_config["cert"],
|
||||
comment=server_config.get("comment", ''),
|
||||
local_server_id=local_server_id,
|
||||
)
|
||||
_SERVERS.append(server)
|
||||
log.debug(
|
||||
"Server state updated: %s, [%s]",
|
||||
server.info()["name"],
|
||||
local_server_id,
|
||||
)
|
||||
except Exception as e:
|
||||
BROKEN_SERVERS.append({
|
||||
"config": server_config,
|
||||
"error": e,
|
||||
"id": local_server_id
|
||||
})
|
||||
log.warning("Can't access server: %s - %s", server_config["url"], e)
|
||||
SERVERS = _SERVERS
|
||||
CLIENTS = config.get("clients", dict())
|
||||
if timer == 0:
|
||||
break
|
||||
time.sleep(40)
|
||||
|
||||
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def index():
|
||||
if request.method == "GET":
|
||||
#if request.args.get("broken") == True:
|
||||
return render_template(
|
||||
"index.html",
|
||||
SERVERS=SERVERS,
|
||||
VERSION=VERSION,
|
||||
K8S_NAMESPACE=k8s.NAMESPACE,
|
||||
BROKEN_SERVERS=BROKEN_SERVERS,
|
||||
nt=request.args.get("nt"),
|
||||
nl=request.args.get("nl"),
|
||||
selected_server=request.args.get("selected_server"),
|
||||
broken=request.args.get("broken", False),
|
||||
add_server=request.args.get("add_server", None),
|
||||
format_timestamp=format_timestamp,
|
||||
)
|
||||
elif request.method == "POST":
|
||||
server = request.form["server_id"]
|
||||
server = next(
|
||||
(item for item in SERVERS if item.info()["local_server_id"] == server), None
|
||||
)
|
||||
server.apply_config(request.form)
|
||||
update_state(timer=0)
|
||||
return redirect(
|
||||
url_for(
|
||||
"index",
|
||||
nt="Updated Outline VPN Server",
|
||||
selected_server=request.args.get("selected_server"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/clients", methods=["GET", "POST"])
|
||||
def clients():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"clients.html",
|
||||
SERVERS=SERVERS,
|
||||
bcrypt=bcrypt,
|
||||
CLIENTS=CLIENTS,
|
||||
VERSION=VERSION,
|
||||
SECRET_LINK_LENGTH=SECRET_LINK_LENGTH,
|
||||
SECRET_LINK_PREFIX=SECRET_LINK_PREFIX,
|
||||
K8S_NAMESPACE=k8s.NAMESPACE,
|
||||
nt=request.args.get("nt"),
|
||||
nl=request.args.get("nl"),
|
||||
selected_client=request.args.get("selected_client"),
|
||||
add_client=request.args.get("add_client", None),
|
||||
format_timestamp=format_timestamp,
|
||||
dynamic_hostname=HOSTNAME,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/add_server", methods=["POST"])
|
||||
def add_server():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
config = get_config()
|
||||
servers = config.get("servers", dict())
|
||||
local_server_id = str(uuid.uuid4())
|
||||
|
||||
new_server = Server(
|
||||
url=request.form["url"],
|
||||
cert=request.form["cert"],
|
||||
comment=request.form["comment"],
|
||||
local_server_id=local_server_id,
|
||||
)
|
||||
|
||||
servers[new_server.data["local_server_id"]] = {
|
||||
"name": new_server.data["name"],
|
||||
"url": new_server.data["url"],
|
||||
"comment": new_server.data["comment"],
|
||||
"cert": request.form["cert"],
|
||||
}
|
||||
config["servers"] = servers
|
||||
write_config(config)
|
||||
log.info("Added server: %s", new_server.data["name"])
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("index", nt="Added Outline VPN Server"))
|
||||
except Exception as e:
|
||||
return redirect(
|
||||
url_for(
|
||||
"index", nt=f"Couldn't access Outline VPN Server: {e}", nl="error"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.route("/del_server", methods=["POST"])
|
||||
def del_server():
|
||||
if request.method == "POST":
|
||||
config = get_config()
|
||||
|
||||
local_server_id = request.form.get("local_server_id")
|
||||
server_name = None
|
||||
try:
|
||||
server_name = config["servers"].pop(local_server_id)["name"]
|
||||
except KeyError as e:
|
||||
pass
|
||||
for client_id, client_config in config["clients"].items():
|
||||
try:
|
||||
client_config["servers"].remove(local_server_id)
|
||||
except ValueError as e:
|
||||
pass
|
||||
write_config(config)
|
||||
log.info("Deleting server %s [%s]", server_name, request.form.get("local_server_id"))
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("index", nt=f"Server {server_name} has been deleted"))
|
||||
|
||||
|
||||
@app.route("/add_client", methods=["POST"])
|
||||
def add_client():
|
||||
if request.method == "POST":
|
||||
config = get_config()
|
||||
|
||||
clients = config.get("clients", dict())
|
||||
user_id = request.form.get("user_id", random_string())
|
||||
|
||||
clients[user_id] = {
|
||||
"name": request.form.get("name"),
|
||||
"comment": request.form.get("comment"),
|
||||
"servers": request.form.getlist("servers"),
|
||||
}
|
||||
config["clients"] = clients
|
||||
write_config(config)
|
||||
log.info("Client %s updated", request.form.get("name"))
|
||||
|
||||
for server in SERVERS:
|
||||
if server.data["local_server_id"] in request.form.getlist("servers"):
|
||||
client = next(
|
||||
(
|
||||
item
|
||||
for item in server.data["keys"]
|
||||
if item.name == request.form.get("old_name")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
if client.name == request.form.get("name"):
|
||||
pass
|
||||
else:
|
||||
server.rename_key(client.key_id, request.form.get("name"))
|
||||
log.info(
|
||||
"Renaming key %s to %s on server %s",
|
||||
request.form.get("old_name"),
|
||||
request.form.get("name"),
|
||||
server.data["name"],
|
||||
)
|
||||
else:
|
||||
server.create_key(request.form.get("name"))
|
||||
log.info(
|
||||
"Creating key %s on server %s",
|
||||
request.form.get("name"),
|
||||
server.data["name"],
|
||||
)
|
||||
else:
|
||||
client = next(
|
||||
(
|
||||
item
|
||||
for item in server.data["keys"]
|
||||
if item.name == request.form.get("old_name")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
server.delete_key(client.key_id)
|
||||
log.info(
|
||||
"Deleting key %s on server %s",
|
||||
request.form.get("name"),
|
||||
server.data["name"],
|
||||
)
|
||||
update_state(timer=0)
|
||||
return redirect(
|
||||
url_for(
|
||||
"clients",
|
||||
nt="Clients updated",
|
||||
selected_client=request.form.get("user_id"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
return redirect(url_for("clients"))
|
||||
|
||||
|
||||
@app.route("/del_client", methods=["POST"])
|
||||
def del_client():
|
||||
if request.method == "POST":
|
||||
config = get_config()
|
||||
clients = config.get("clients", dict())
|
||||
user_id = request.form.get("user_id")
|
||||
if user_id in clients:
|
||||
for server in SERVERS:
|
||||
client = next(
|
||||
(
|
||||
item
|
||||
for item in server.data["keys"]
|
||||
if item.name == request.form.get("name")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
server.delete_key(client.key_id)
|
||||
|
||||
config["clients"].pop(user_id)
|
||||
write_config(config)
|
||||
log.info("Deleting client %s", request.form.get("name"))
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("clients", nt="User has been deleted"))
|
||||
|
||||
|
||||
def append_to_log(log_entry):
|
||||
with open("access_log.log", "a") as log_file:
|
||||
log_file.write(log_entry + "\n")
|
||||
|
||||
@app.route("/dynamic/<path:hash_secret>", methods=["GET"], strict_slashes=False)
|
||||
def dynamic(hash_secret):
|
||||
# Depricated scheme.
|
||||
for server in SERVERS:
|
||||
if hash_secret.startswith(server.data["name"]):
|
||||
log.warning("Deprecated key request")
|
||||
server_name = hash_secret.split('/')[0]
|
||||
client_id = hash_secret.split('/')[1]
|
||||
return dynamic_depticated(server_name, client_id, hash_secret)
|
||||
try:
|
||||
short_hash_server = hash_secret[0:SECRET_LINK_LENGTH]
|
||||
short_hash_client = hash_secret[SECRET_LINK_LENGTH:SECRET_LINK_LENGTH * 2 ]
|
||||
client_provided_secret = hash_secret[SECRET_LINK_LENGTH * 2:]
|
||||
hash_server = None
|
||||
hash_client = None
|
||||
server = None
|
||||
client = None
|
||||
for _server in SERVERS:
|
||||
if _server.data["local_server_id"][:SECRET_LINK_LENGTH] == short_hash_server:
|
||||
hash_server = _server.data["local_server_id"]
|
||||
server = _server
|
||||
|
||||
for client_id, values in CLIENTS.items():
|
||||
if client_id[:SECRET_LINK_LENGTH] == short_hash_client:
|
||||
hash_client = client_id
|
||||
client = CLIENTS[client_id]
|
||||
|
||||
if server and client:
|
||||
append_to_log(f"User: {client["name"]}. Server: {server.data['name']} client secret string: {hash_secret}")
|
||||
client_shadowsocks_key = next(
|
||||
(item for item in server.data["keys"] if item.key_id == client["name"]), None
|
||||
)
|
||||
|
||||
secret_string = hash_server + hash_client
|
||||
check_secret_hash = bcrypt.checkpw(
|
||||
password=secret_string.encode('utf-8'),
|
||||
hashed_password=f"{SECRET_LINK_PREFIX}{client_provided_secret}".encode('utf-8')
|
||||
)
|
||||
if check_secret_hash:
|
||||
log.info(f"Client {client['name']} has been requested ssconf for {server.data['name']}. Bcrypt client hash {client_provided_secret[0:16]}...[FULL HASH SECURED]")
|
||||
return {
|
||||
"server": server.data["hostname_for_access_keys"],
|
||||
"server_port": client_shadowsocks_key.port,
|
||||
"password": client_shadowsocks_key.password,
|
||||
"method": client_shadowsocks_key.method,
|
||||
"prefix": SS_PREFIX,
|
||||
"info": "Managed by OutFleet [github.com/house-of-vanity/OutFleet/]",
|
||||
}
|
||||
else:
|
||||
log.warning(f"Hack attempt! Client secret does not match: {client_provided_secret}")
|
||||
return WRONG_DOOR
|
||||
else:
|
||||
log.warning(f"Hack attempt! Client or server doesn't exist. payload: {hash_secret[0:200]}")
|
||||
return WRONG_DOOR
|
||||
except Exception as e:
|
||||
log.error(f"Dynamic V2 parse error: {e}")
|
||||
return WRONG_DOOR
|
||||
|
||||
|
||||
def dynamic_depticated(server_name, client_id, hash_secret=""):
|
||||
try:
|
||||
client = next(
|
||||
(keys for client, keys in CLIENTS.items() if client == client_id), None
|
||||
)
|
||||
server = next(
|
||||
(item for item in SERVERS if item.info()["name"] == server_name), None
|
||||
)
|
||||
key = next(
|
||||
(item for item in server.data["keys"] if item.key_id == client["name"]), None
|
||||
)
|
||||
if server and client and key:
|
||||
if server.data["local_server_id"] in client["servers"]:
|
||||
log.info(
|
||||
"Client %s has been requested ssconf for %s", client["name"], server.data["name"]
|
||||
)
|
||||
append_to_log(f"User: {client["name"]}. Server: {server.data['name']} client secret string: {hash_secret}")
|
||||
return {
|
||||
"server": server.data["hostname_for_access_keys"],
|
||||
"server_port": key.port,
|
||||
"password": key.password,
|
||||
"method": key.method,
|
||||
"prefix":SS_PREFIX,
|
||||
"info": "Managed by OutFleet [github.com/house-of-vanity/OutFleet/]",
|
||||
}
|
||||
else:
|
||||
log.warning(
|
||||
"Hack attempt! Client %s denied by ACL on %s",
|
||||
client["name"],
|
||||
server.data["name"],
|
||||
)
|
||||
return WRONG_DOOR
|
||||
except:
|
||||
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
|
||||
return WRONG_DOOR
|
||||
|
||||
|
||||
|
||||
@app.route("/dynamic", methods=["GET"], strict_slashes=False)
|
||||
def _dynamic():
|
||||
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
|
||||
return WRONG_DOOR
|
||||
|
||||
|
||||
@app.route("/sync", methods=["GET", "POST"])
|
||||
def sync():
|
||||
if request.method == "GET":
|
||||
try:
|
||||
with open("sync.log", "r") as file:
|
||||
lines = file.readlines()
|
||||
except:
|
||||
lines = []
|
||||
return render_template(
|
||||
"sync.html",
|
||||
SERVERS=SERVERS,
|
||||
CLIENTS=CLIENTS,
|
||||
lines=lines,
|
||||
)
|
||||
if request.method == "POST":
|
||||
with lock:
|
||||
if request.form.get("wipe") == 'all':
|
||||
for server in SERVERS:
|
||||
log.info("Wiping all keys on [%s]", server.data["name"])
|
||||
for client in server.data['keys']:
|
||||
server.delete_key(client.key_id)
|
||||
|
||||
server_hash = {}
|
||||
with lock:
|
||||
for server in SERVERS:
|
||||
server_hash[server.data["local_server_id"]] = server
|
||||
with lock:
|
||||
for key, client in CLIENTS.items():
|
||||
for u_server_id in client["servers"]:
|
||||
if u_server_id in server_hash:
|
||||
if not server_hash[u_server_id].check_client(client["name"]):
|
||||
log.warning(
|
||||
f"Client {client['name']} absent on {server_hash[u_server_id].data['name']}"
|
||||
)
|
||||
server_hash[u_server_id].create_key(client["name"])
|
||||
else:
|
||||
log.info(
|
||||
f"Client {client['name']} already present on {server_hash[u_server_id].data['name']}"
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
f"Client {client['name']} incorrect server_id {u_server_id}"
|
||||
)
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("sync"))
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_state_thread = threading.Thread(target=update_state)
|
||||
update_state_thread.start()
|
||||
|
||||
discovery_servers_thread = threading.Thread(target=k8s.discovery_servers)
|
||||
discovery_servers_thread.start()
|
||||
app.run(host="0.0.0.0")
|
||||
22
manage.py
Executable file
22
manage.py
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
3
mysite/__init__.py
Normal file
3
mysite/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
16
mysite/asgi.py
Normal file
16
mysite/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for mysite project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
40
mysite/celery.py
Normal file
40
mysite/celery.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery import shared_task
|
||||
from celery.schedules import crontab
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
logger = logging.getLogger(__name__)
|
||||
app = Celery('mysite')
|
||||
|
||||
app.conf.beat_schedule = {
|
||||
'periodical_servers_sync': {
|
||||
'task': 'sync_all_servers',
|
||||
'schedule': crontab(minute=0, hour='*/3'), # Every 3 hours
|
||||
},
|
||||
'cleanup_old_task_logs': {
|
||||
'task': 'cleanup_task_logs',
|
||||
'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Additional celery settings for better logging and performance
|
||||
app.conf.update(
|
||||
# Keep detailed results for debugging
|
||||
result_expires=3600, # 1 hour
|
||||
task_always_eager=False,
|
||||
task_eager_propagates=True,
|
||||
# Improve task tracking
|
||||
task_track_started=True,
|
||||
task_send_sent_event=True,
|
||||
# Clean up settings
|
||||
result_backend_cleanup_interval=300, # Clean up every 5 minutes
|
||||
)
|
||||
|
||||
app.autodiscover_tasks()
|
||||
|
||||
42
mysite/context_processors.py
Normal file
42
mysite/context_processors.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from django.conf import settings
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def version_info(request):
|
||||
"""Add version information to template context"""
|
||||
|
||||
git_commit = getattr(settings, 'GIT_COMMIT', None)
|
||||
git_commit_short = getattr(settings, 'GIT_COMMIT_SHORT', None)
|
||||
build_date = getattr(settings, 'BUILD_DATE', None)
|
||||
|
||||
if not git_commit or git_commit == 'development':
|
||||
try:
|
||||
base_dir = getattr(settings, 'BASE_DIR', Path(__file__).resolve().parent.parent)
|
||||
result = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
||||
capture_output=True, text=True, cwd=base_dir, timeout=5)
|
||||
if result.returncode == 0:
|
||||
git_commit = result.stdout.strip()
|
||||
git_commit_short = git_commit[:7]
|
||||
|
||||
date_result = subprocess.run(['git', 'log', '-1', '--format=%ci'],
|
||||
capture_output=True, text=True, cwd=base_dir, timeout=5)
|
||||
if date_result.returncode == 0:
|
||||
build_date = date_result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
if not git_commit:
|
||||
git_commit = 'development'
|
||||
if not git_commit_short:
|
||||
git_commit_short = 'dev'
|
||||
if not build_date:
|
||||
build_date = 'unknown'
|
||||
|
||||
return {
|
||||
'VERSION_INFO': {
|
||||
'git_commit': git_commit,
|
||||
'git_commit_short': git_commit_short,
|
||||
'build_date': build_date,
|
||||
'is_development': git_commit_short == 'dev'
|
||||
}
|
||||
}
|
||||
22
mysite/middleware.py
Normal file
22
mysite/middleware.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
class RequestLogger:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
print(f"Original: {request.build_absolute_uri()}")
|
||||
print(f"Path : {request.path}")
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class AutoLoginMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
user = authenticate(username='admin', password='admin')
|
||||
if user:
|
||||
login(request, user)
|
||||
227
mysite/settings.py
Normal file
227
mysite/settings.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import environ
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
|
||||
ENV = environ.Env(
|
||||
DEBUG=(bool, False)
|
||||
)
|
||||
|
||||
environ.Env.read_env()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY=ENV('SECRET_KEY', default='django-insecure-change-me-in-production')
|
||||
TIME_ZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
||||
EXTERNAL_ADDRESS = ENV('EXTERNAL_ADDRESS', default='https://example.org')
|
||||
|
||||
CELERY_BROKER_URL = ENV('CELERY_BROKER_URL', default='redis://localhost:6379/0')
|
||||
CELERY_RESULT_BACKEND = 'django-db'
|
||||
CELERY_TIMEZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_RESULT_EXTENDED = True
|
||||
|
||||
# Celery Beat Schedule
|
||||
from celery.schedules import crontab
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
'update-user-statistics': {
|
||||
'task': 'update_user_statistics',
|
||||
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
||||
},
|
||||
'cleanup-task-logs': {
|
||||
'task': 'cleanup_task_logs',
|
||||
'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
|
||||
},
|
||||
}
|
||||
|
||||
AUTH_USER_MODEL = "vpn.User"
|
||||
|
||||
DEBUG = ENV('DEBUG')
|
||||
|
||||
ALLOWED_HOSTS = ENV.list('ALLOWED_HOSTS', default=["*"])
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CSRF_TRUSTED_ORIGINS = ENV.list('CSRF_TRUSTED_ORIGINS', default=[])
|
||||
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '[{asctime}] {levelname} {name} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
'simple': {
|
||||
'format': '{levelname} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'file': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': os.path.join(BASE_DIR, 'debug.log'),
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
'vpn': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
'requests': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'urllib3': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'jazzmin',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'polymorphic',
|
||||
'corsheaders',
|
||||
'django_celery_results',
|
||||
'django_celery_beat',
|
||||
'vpn',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'mysite.middleware.AutoLoginMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'mysite.urls'
|
||||
|
||||
GIT_COMMIT = ENV('GIT_COMMIT', default='development')
|
||||
GIT_COMMIT_SHORT = ENV('GIT_COMMIT_SHORT', default='dev')
|
||||
BUILD_DATE = ENV('BUILD_DATE', default='unknown')
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [
|
||||
os.path.join(BASE_DIR, 'templates'),
|
||||
os.path.join(BASE_DIR, 'vpn', 'templates')
|
||||
],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'mysite.context_processors.version_info',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
WSGI_APPLICATION = 'mysite.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
# CREATE USER outfleet WITH PASSWORD 'password';
|
||||
# GRANT ALL PRIVILEGES ON DATABASE outfleet TO outfleet;
|
||||
# ALTER DATABASE outfleet OWNER TO outfleet;
|
||||
|
||||
DATABASES = {
|
||||
'sqlite': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
},
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': ENV('POSTGRES_DB', default="outfleet"),
|
||||
'USER': ENV('POSTGRES_USER', default="outfleet"),
|
||||
'PASSWORD': ENV('POSTGRES_PASSWORD', default="outfleet"),
|
||||
'HOST': ENV('POSTGRES_HOST', default='localhost'),
|
||||
'PORT': ENV('POSTGRES_PORT', default='5432'),
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
30
mysite/urls.py
Normal file
30
mysite/urls.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
URL configuration for mysite project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.1/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.views.generic import RedirectView
|
||||
from vpn.views import shadowsocks, userFrontend, userPortal, xray_subscription
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('ss/<path:link>', shadowsocks, name='shadowsocks'),
|
||||
path('dynamic/<path:link>', shadowsocks, name='shadowsocks'),
|
||||
path('xray/<path:link>', xray_subscription, name='xray_subscription'),
|
||||
path('stat/<path:user_hash>', userFrontend, name='userFrontend'),
|
||||
path('u/<path:user_hash>', userPortal, name='userPortal'),
|
||||
path('', RedirectView.as_view(url='/admin/', permanent=False)),
|
||||
]
|
||||
16
mysite/wsgi.py
Normal file
16
mysite/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for mysite project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
24
requirements.txt
Executable file → Normal file
24
requirements.txt
Executable file → Normal file
@@ -1,6 +1,18 @@
|
||||
outline-vpn-api
|
||||
kubernetes
|
||||
PyYAML>=6.0.1
|
||||
Flask>=2.3.3
|
||||
flask-cors
|
||||
bcrypt
|
||||
django-environ==0.12.0
|
||||
Django==5.1.7
|
||||
celery==5.4.0
|
||||
django-jazzmin==3.0.1
|
||||
django-polymorphic==3.1.0
|
||||
django-cors-headers==4.5.0
|
||||
django-celery-results==2.5.1
|
||||
git+https://github.com/celery/django-celery-beat#egg=django-celery-beat
|
||||
requests==2.32.3
|
||||
PyYaml==6.0.2
|
||||
Markdown==3.7
|
||||
outline-vpn-api==6.3.0
|
||||
Redis==5.2.1
|
||||
whitenoise==6.9.0
|
||||
psycopg2-binary==2.9.10
|
||||
setuptools==75.2.0
|
||||
shortuuid==1.0.13
|
||||
cryptography==45.0.5
|
||||
|
||||
335
static/admin/css/vpn_admin.css
Normal file
335
static/admin/css/vpn_admin.css
Normal file
@@ -0,0 +1,335 @@
|
||||
/* Custom styles for VPN admin interface */
|
||||
|
||||
/* Quick action buttons in server list */
|
||||
.quick-actions .button {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
margin: 0 2px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to bottom, #f8f8f8, #e8e8e8);
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-actions .button:hover {
|
||||
background: linear-gradient(to bottom, #e8e8e8, #d8d8d8);
|
||||
border-color: #bbb;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.quick-actions .button:active {
|
||||
background: linear-gradient(to bottom, #d8d8d8, #e8e8e8);
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Sync button - blue theme */
|
||||
.quick-actions .button[href*="/sync/"] {
|
||||
background: linear-gradient(to bottom, #4a90e2, #357abd);
|
||||
border-color: #2968a3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-actions .button[href*="/sync/"]:hover {
|
||||
background: linear-gradient(to bottom, #357abd, #2968a3);
|
||||
border-color: #1f5582;
|
||||
}
|
||||
|
||||
/* Move clients button - orange theme */
|
||||
.quick-actions .button[href*="/move-clients/"] {
|
||||
background: linear-gradient(to bottom, #f39c12, #e67e22);
|
||||
border-color: #d35400;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-actions .button[href*="/move-clients/"]:hover {
|
||||
background: linear-gradient(to bottom, #e67e22, #d35400);
|
||||
border-color: #bf4f36;
|
||||
}
|
||||
|
||||
/* Status indicators improvements */
|
||||
.server-status-ok {
|
||||
color: #27ae60;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-status-error {
|
||||
color: #e74c3c;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-status-warning {
|
||||
color: #f39c12;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Better spacing for list display */
|
||||
.admin-object-tools {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Improve readability of pre-formatted status */
|
||||
.changelist-results pre {
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Server admin compact styles */
|
||||
.server-stats {
|
||||
max-width: 120px;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.server-activity {
|
||||
max-width: 140px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
max-width: 160px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.server-comment {
|
||||
max-width: 200px;
|
||||
min-width: 100px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Compact server display elements */
|
||||
.changelist-results .server-stats div,
|
||||
.changelist-results .server-activity div,
|
||||
.changelist-results .server-status div {
|
||||
line-height: 1.3;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
/* Status indicator colors */
|
||||
.status-online {
|
||||
color: #16a34a !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #dc2626 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: #f97316 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-unavailable {
|
||||
color: #f97316 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Activity indicators */
|
||||
.activity-high {
|
||||
color: #16a34a !important;
|
||||
}
|
||||
|
||||
.activity-medium {
|
||||
color: #eab308 !important;
|
||||
}
|
||||
|
||||
.activity-low {
|
||||
color: #f97316 !important;
|
||||
}
|
||||
|
||||
.activity-none {
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
|
||||
/* User stats indicators */
|
||||
.users-active {
|
||||
color: #16a34a !important;
|
||||
}
|
||||
|
||||
.users-medium {
|
||||
color: #eab308 !important;
|
||||
}
|
||||
|
||||
.users-low {
|
||||
color: #f97316 !important;
|
||||
}
|
||||
|
||||
.users-none {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Table cell width constraints for better layout */
|
||||
table.changelist-results th:nth-child(1), /* Name */
|
||||
table.changelist-results td:nth-child(1) {
|
||||
width: 180px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(3), /* Comment */
|
||||
table.changelist-results td:nth-child(3) {
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(4), /* User Stats */
|
||||
table.changelist-results td:nth-child(4) {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(5), /* Activity */
|
||||
table.changelist-results td:nth-child(5) {
|
||||
width: 140px;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(6), /* Status */
|
||||
table.changelist-results td:nth-child(6) {
|
||||
width: 160px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
/* Ensure text doesn't overflow in server admin */
|
||||
.changelist-results td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Allow wrapping for multi-line server info displays */
|
||||
.changelist-results td .server-stats,
|
||||
.changelist-results td .server-activity,
|
||||
.changelist-results td .server-status {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Server type icons */
|
||||
.server-type-outline {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.server-type-wireguard {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Tooltip styles for truncated text */
|
||||
[title] {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted #999;
|
||||
}
|
||||
|
||||
/* Form improvements for move clients page */
|
||||
.form-row.field-box {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.form-row.field-box label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-row.field-box .readonly {
|
||||
padding: 5px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.help {
|
||||
background: #e8f4fd;
|
||||
border: 1px solid #b8daff;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.help h3 {
|
||||
margin-top: 0;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.help ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Make user statistics section wider */
|
||||
.field-user_statistics_summary {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field-user_statistics_summary .readonly {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field-user_statistics_summary .user-management-section {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Wider fieldset for statistics */
|
||||
.wide {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.wide .form-row {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Server status button styles */
|
||||
.check-status-btn {
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-status-btn:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.check-status-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Make admin tables more responsive */
|
||||
.changelist-results table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
/* Improve button spacing */
|
||||
.btn-sm-custom {
|
||||
margin: 0 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
203
static/admin/js/generate_link.js
Normal file
203
static/admin/js/generate_link.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// static/admin/js/generate_uuid.js
|
||||
|
||||
function generateLink(button) {
|
||||
let row = button.closest('tr');
|
||||
let inputField = row.querySelector('input[name$="link"]');
|
||||
|
||||
if (inputField) {
|
||||
inputField.value = generateRandomString(16);
|
||||
}
|
||||
}
|
||||
|
||||
function generateRandomString(length) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// OutlineServer JSON Configuration Functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// JSON Import functionality
|
||||
const importJsonBtn = document.getElementById('import-json-btn');
|
||||
const importJsonTextarea = document.getElementById('import-json-config');
|
||||
|
||||
if (importJsonBtn && importJsonTextarea) {
|
||||
// Auto-fill on paste event
|
||||
importJsonTextarea.addEventListener('paste', function(e) {
|
||||
// Small delay to let paste complete
|
||||
setTimeout(() => {
|
||||
tryAutoFillFromJson();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Manual import button
|
||||
importJsonBtn.addEventListener('click', function() {
|
||||
tryAutoFillFromJson();
|
||||
});
|
||||
|
||||
function tryAutoFillFromJson() {
|
||||
try {
|
||||
const jsonText = importJsonTextarea.value.trim();
|
||||
if (!jsonText) {
|
||||
alert('Please enter JSON configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(jsonText);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.apiUrl || !config.certSha256) {
|
||||
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse apiUrl to extract components
|
||||
const url = new URL(config.apiUrl);
|
||||
|
||||
// Fill form fields
|
||||
const adminUrlField = document.getElementById('id_admin_url');
|
||||
const adminCertField = document.getElementById('id_admin_access_cert');
|
||||
const clientHostnameField = document.getElementById('id_client_hostname');
|
||||
const clientPortField = document.getElementById('id_client_port');
|
||||
const nameField = document.getElementById('id_name');
|
||||
const commentField = document.getElementById('id_comment');
|
||||
|
||||
if (adminUrlField) adminUrlField.value = config.apiUrl;
|
||||
if (adminCertField) adminCertField.value = config.certSha256;
|
||||
|
||||
// Use provided hostname or extract from URL
|
||||
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
|
||||
if (clientHostnameField) clientHostnameField.value = hostname;
|
||||
|
||||
// Use provided port or extract from various sources
|
||||
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
|
||||
if (clientPortField) clientPortField.value = clientPort;
|
||||
|
||||
// Generate server name if not provided and field is empty
|
||||
if (nameField && !nameField.value) {
|
||||
const serverName = config.serverName || config.name || `Outline-${hostname}`;
|
||||
nameField.value = serverName;
|
||||
}
|
||||
|
||||
// Fill comment if provided and field exists
|
||||
if (commentField && config.comment) {
|
||||
commentField.value = config.comment;
|
||||
}
|
||||
|
||||
// Clear the JSON input
|
||||
importJsonTextarea.value = '';
|
||||
|
||||
// Show success message
|
||||
showSuccessMessage('✅ Configuration imported successfully!');
|
||||
|
||||
} catch (error) {
|
||||
alert('Invalid JSON format: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy to clipboard functionality
|
||||
window.copyToClipboard = function(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
const text = element.textContent || element.innerText;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopySuccess();
|
||||
}).catch(err => {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function fallbackCopyTextToClipboard(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopySuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
showSuccessMessage('📋 Copied to clipboard!');
|
||||
}
|
||||
|
||||
function showSuccessMessage(message) {
|
||||
const alertHtml = `
|
||||
<div class="alert alert-success alert-dismissible" style="margin: 1rem 0;">
|
||||
${message}
|
||||
<button type="button" class="close" aria-label="Close" onclick="this.parentElement.remove()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Try to find a container for the message
|
||||
const container = document.querySelector('.card-body') || document.querySelector('#content-main');
|
||||
if (container) {
|
||||
container.insertAdjacentHTML('afterbegin', alertHtml);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const alert = document.querySelector('.alert-success');
|
||||
if (alert) alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Sync server button - handle both static and dynamic buttons
|
||||
document.addEventListener('click', async function(e) {
|
||||
if (e.target && (e.target.id === 'sync-server-btn' || e.target.matches('[id="sync-server-btn"]'))) {
|
||||
const syncBtn = e.target;
|
||||
const serverId = syncBtn.dataset.serverId;
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
const originalText = syncBtn.textContent;
|
||||
syncBtn.textContent = '⏳ Syncing...';
|
||||
syncBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/outlineserver/${serverId}/sync/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccessMessage(`✅ ${data.message}`);
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} else {
|
||||
alert('Sync failed: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error: ' + error.message);
|
||||
} finally {
|
||||
syncBtn.textContent = originalText;
|
||||
syncBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
94
static/admin/js/server_status_check.js
Normal file
94
static/admin/js/server_status_check.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Server status check functionality for admin
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add event listeners to all check status buttons
|
||||
document.querySelectorAll('.check-status-btn').forEach(button => {
|
||||
button.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const serverId = this.dataset.serverId;
|
||||
const serverName = this.dataset.serverName;
|
||||
const serverType = this.dataset.serverType;
|
||||
const originalText = this.textContent;
|
||||
const originalColor = this.style.background;
|
||||
|
||||
// Show loading state
|
||||
this.textContent = '⏳ Checking...';
|
||||
this.style.background = '#6c757d';
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
// Try AJAX request first
|
||||
const response = await fetch(`/admin/vpn/server/${serverId}/check-status/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update button based on status
|
||||
if (data.status === 'online') {
|
||||
this.textContent = '✅ Online';
|
||||
this.style.background = '#28a745';
|
||||
} else if (data.status === 'offline') {
|
||||
this.textContent = '❌ Offline';
|
||||
this.style.background = '#dc3545';
|
||||
} else if (data.status === 'error') {
|
||||
this.textContent = '⚠️ Error';
|
||||
this.style.background = '#fd7e14';
|
||||
} else {
|
||||
this.textContent = '❓ Unknown';
|
||||
this.style.background = '#6c757d';
|
||||
}
|
||||
|
||||
// Show additional info if available
|
||||
if (data.message) {
|
||||
this.title = data.message;
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to check status');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking server status:', error);
|
||||
|
||||
// Fallback: show basic server info
|
||||
this.textContent = `📊 ${serverType}`;
|
||||
this.style.background = '#17a2b8';
|
||||
this.title = `Server: ${serverName} (${serverType}) - Status check failed: ${error.message}`;
|
||||
}
|
||||
|
||||
// Reset after 5 seconds in all cases
|
||||
setTimeout(() => {
|
||||
this.textContent = originalText;
|
||||
this.style.background = originalColor;
|
||||
this.title = '';
|
||||
this.disabled = false;
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to get CSRF token
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
289
static/admin/js/xray_inbound_defaults.js
Normal file
289
static/admin/js/xray_inbound_defaults.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// Xray Inbound Auto-Fill Helper
|
||||
console.log('Xray inbound helper script loaded');
|
||||
|
||||
// Protocol configurations based on Xray documentation
|
||||
const protocolConfigs = {
|
||||
'vless': {
|
||||
port: 443,
|
||||
network: 'tcp',
|
||||
security: 'tls',
|
||||
description: 'VLESS - Lightweight protocol with UUID authentication'
|
||||
},
|
||||
'vmess': {
|
||||
port: 443,
|
||||
network: 'ws',
|
||||
security: 'tls',
|
||||
description: 'VMess - V2Ray protocol with encryption and authentication'
|
||||
},
|
||||
'trojan': {
|
||||
port: 443,
|
||||
network: 'tcp',
|
||||
security: 'tls',
|
||||
description: 'Trojan - TLS-based protocol mimicking HTTPS traffic'
|
||||
},
|
||||
'shadowsocks': {
|
||||
port: 8388,
|
||||
network: 'tcp',
|
||||
security: 'none',
|
||||
ss_method: 'aes-256-gcm',
|
||||
description: 'Shadowsocks - SOCKS5 proxy with encryption'
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM ready, initializing Xray helper');
|
||||
|
||||
// Add help text and generate buttons
|
||||
addHelpText();
|
||||
addGenerateButtons();
|
||||
|
||||
// Watch for protocol field changes
|
||||
const protocolField = document.getElementById('id_protocol');
|
||||
if (protocolField) {
|
||||
protocolField.addEventListener('change', function() {
|
||||
handleProtocolChange(this.value);
|
||||
});
|
||||
|
||||
// Auto-fill on initial load if new inbound
|
||||
if (protocolField.value && isNewInbound()) {
|
||||
handleProtocolChange(protocolField.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function isNewInbound() {
|
||||
// Check if this is a new inbound (no port value set)
|
||||
const portField = document.getElementById('id_port');
|
||||
return !portField || !portField.value;
|
||||
}
|
||||
|
||||
function handleProtocolChange(protocol) {
|
||||
if (!protocol || !protocolConfigs[protocol]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = protocolConfigs[protocol];
|
||||
|
||||
// Only auto-fill for new inbounds to avoid overwriting user data
|
||||
if (isNewInbound()) {
|
||||
console.log('Auto-filling fields for new', protocol, 'inbound');
|
||||
autoFillFields(protocol, config);
|
||||
showMessage(`Auto-filled ${protocol.toUpperCase()} configuration`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function autoFillFields(protocol, config) {
|
||||
// Fill basic fields only if they're empty
|
||||
fillIfEmpty('id_port', config.port);
|
||||
fillIfEmpty('id_network', config.network);
|
||||
fillIfEmpty('id_security', config.security);
|
||||
|
||||
// Protocol-specific fields
|
||||
if (config.ss_method && protocol === 'shadowsocks') {
|
||||
fillIfEmpty('id_ss_method', config.ss_method);
|
||||
}
|
||||
|
||||
// Generate helpful JSON configs
|
||||
generateJsonConfigs(protocol, config);
|
||||
}
|
||||
|
||||
function fillIfEmpty(fieldId, value) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field && !field.value && value !== undefined) {
|
||||
field.value = value;
|
||||
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function generateJsonConfigs(protocol, config) {
|
||||
// Generate stream settings
|
||||
const streamField = document.getElementById('id_stream_settings');
|
||||
if (streamField && !streamField.value) {
|
||||
const streamSettings = getStreamSettings(protocol, config.network);
|
||||
if (streamSettings) {
|
||||
streamField.value = JSON.stringify(streamSettings, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate sniffing settings
|
||||
const sniffingField = document.getElementById('id_sniffing_settings');
|
||||
if (sniffingField && !sniffingField.value) {
|
||||
const sniffingSettings = {
|
||||
enabled: true,
|
||||
destOverride: ['http', 'tls'],
|
||||
metadataOnly: false
|
||||
};
|
||||
sniffingField.value = JSON.stringify(sniffingSettings, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function getStreamSettings(protocol, network) {
|
||||
const settings = {};
|
||||
|
||||
switch (network) {
|
||||
case 'ws':
|
||||
settings.wsSettings = {
|
||||
path: '/ws',
|
||||
headers: {
|
||||
Host: 'example.com'
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'grpc':
|
||||
settings.grpcSettings = {
|
||||
serviceName: 'GunService'
|
||||
};
|
||||
break;
|
||||
case 'h2':
|
||||
settings.httpSettings = {
|
||||
host: ['example.com'],
|
||||
path: '/path'
|
||||
};
|
||||
break;
|
||||
case 'tcp':
|
||||
settings.tcpSettings = {
|
||||
header: {
|
||||
type: 'none'
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'kcp':
|
||||
settings.kcpSettings = {
|
||||
mtu: 1350,
|
||||
tti: 50,
|
||||
uplinkCapacity: 5,
|
||||
downlinkCapacity: 20,
|
||||
congestion: false,
|
||||
readBufferSize: 2,
|
||||
writeBufferSize: 2,
|
||||
header: {
|
||||
type: 'none'
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : null;
|
||||
}
|
||||
|
||||
function addHelpText() {
|
||||
// Add help text to complex fields
|
||||
addFieldHelp('id_stream_settings',
|
||||
'Transport settings: TCP (none), WebSocket (path/host), gRPC (serviceName), etc. Format: JSON');
|
||||
|
||||
addFieldHelp('id_sniffing_settings',
|
||||
'Traffic sniffing for routing: enabled, destOverride ["http","tls"], metadataOnly');
|
||||
|
||||
addFieldHelp('id_tls_cert_file',
|
||||
'TLS certificate file path (required for TLS security). Example: /path/to/cert.pem');
|
||||
|
||||
addFieldHelp('id_tls_key_file',
|
||||
'TLS private key file path (required for TLS security). Example: /path/to/key.pem');
|
||||
|
||||
addFieldHelp('id_protocol',
|
||||
'VLESS: lightweight + UUID | VMess: V2Ray encrypted | Trojan: HTTPS-like | Shadowsocks: SOCKS5');
|
||||
|
||||
addFieldHelp('id_network',
|
||||
'Transport: tcp (direct), ws (WebSocket), grpc (HTTP/2), h2 (HTTP/2), kcp (mKCP)');
|
||||
|
||||
addFieldHelp('id_security',
|
||||
'Encryption: none (no TLS), tls (standard TLS), reality (advanced steganography)');
|
||||
}
|
||||
|
||||
function addFieldHelp(fieldId, helpText) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field) return;
|
||||
|
||||
const helpDiv = document.createElement('div');
|
||||
helpDiv.className = 'help';
|
||||
helpDiv.style.cssText = 'font-size: 11px; color: #666; margin-top: 2px; line-height: 1.3;';
|
||||
helpDiv.textContent = helpText;
|
||||
|
||||
field.parentNode.appendChild(helpDiv);
|
||||
}
|
||||
|
||||
function showMessage(message, type = 'info') {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `alert alert-${type}`;
|
||||
messageDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
background: ${type === 'success' ? '#d4edda' : '#cce7ff'};
|
||||
border: 1px solid ${type === 'success' ? '#c3e6cb' : '#b8daff'};
|
||||
color: ${type === 'success' ? '#155724' : '#004085'};
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
`;
|
||||
messageDiv.textContent = message;
|
||||
|
||||
document.body.appendChild(messageDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Helper functions for generating values
|
||||
function generateRandomString(length = 8) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateShortId() {
|
||||
return Math.random().toString(16).substr(2, 8);
|
||||
}
|
||||
|
||||
function suggestPort(protocol) {
|
||||
const ports = {
|
||||
'vless': [443, 8443, 2053, 2083],
|
||||
'vmess': [443, 80, 8080, 8443],
|
||||
'trojan': [443, 8443, 2087],
|
||||
'shadowsocks': [8388, 1080, 8080]
|
||||
};
|
||||
const protocolPorts = ports[protocol] || [443];
|
||||
return protocolPorts[Math.floor(Math.random() * protocolPorts.length)];
|
||||
}
|
||||
|
||||
// Add generate buttons to fields
|
||||
function addGenerateButtons() {
|
||||
console.log('Adding generate buttons');
|
||||
|
||||
// Add tag generator
|
||||
addGenerateButton('id_tag', '🎲', () => `inbound-${generateShortId()}`);
|
||||
|
||||
// Add port suggestion based on protocol
|
||||
addGenerateButton('id_port', '🎯', () => {
|
||||
const protocol = document.getElementById('id_protocol')?.value;
|
||||
return suggestPort(protocol);
|
||||
});
|
||||
}
|
||||
|
||||
function addGenerateButton(fieldId, icon, generator) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field || field.nextElementSibling?.classList.contains('generate-btn')) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'generate-btn btn btn-sm btn-secondary';
|
||||
button.innerHTML = icon;
|
||||
button.title = 'Generate value';
|
||||
button.style.cssText = 'margin-left: 5px; padding: 2px 6px; font-size: 12px;';
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const value = generator();
|
||||
field.value = value;
|
||||
showMessage(`Generated: ${value}`, 'success');
|
||||
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
field.parentNode.insertBefore(button, field.nextSibling);
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
:root {
|
||||
--app-h: 100vh;
|
||||
--app-space-1: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* -- BASE STYLES --
|
||||
* Most of these are inherited from Base, but I want to change a few.
|
||||
*/
|
||||
body {
|
||||
color: #333;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #1b98f8;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* -- HELPER STYLES --
|
||||
* Over-riding some of the .pure-button styles to make my buttons look unique
|
||||
*/
|
||||
.button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
background: #9d2c2c;
|
||||
border: 1px solid #480b0b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/*
|
||||
* -- LAYOUT STYLES --
|
||||
* This layout consists of three main elements, `#nav` (navigation bar), `#list` (server list), and `#main` (server content). All 3 elements are within `#layout`
|
||||
*/
|
||||
#nav,
|
||||
#main {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Make the navigation 100% width on phones */
|
||||
#nav {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
background: rgb(37, 42, 58);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Show the "Menu" button on phones */
|
||||
#nav .nav-menu-button {
|
||||
display: block;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* When "Menu" is clicked, the navbar should be 80% height */
|
||||
#nav.active {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Don't show the navigation items... */
|
||||
.nav-inner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ...until the "Menu" button is clicked */
|
||||
#nav.active .nav-inner {
|
||||
display: block;
|
||||
padding: 2em 0;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* -- NAV BAR STYLES --
|
||||
* Styling the default .pure-menu to look a little more unique.
|
||||
*/
|
||||
#nav .pure-menu {
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#nav .pure-menu-link:hover,
|
||||
#nav .pure-menu-link:focus {
|
||||
background: rgb(55, 60, 90);
|
||||
}
|
||||
|
||||
#nav .pure-menu-link {
|
||||
color: #fff;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
#nav .pure-menu-heading {
|
||||
border-bottom: none;
|
||||
font-size: 110%;
|
||||
color: rgb(75, 113, 151);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* -- server STYLES --
|
||||
* Styles relevant to the server messages, labels, counts, and more.
|
||||
*/
|
||||
.server-count {
|
||||
color: rgb(75, 113, 151);
|
||||
}
|
||||
|
||||
.server-label-personal,
|
||||
.server-label-work,
|
||||
.server-label-travel {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: inline-block;
|
||||
margin-right: 0.5em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.server-label-personal {
|
||||
background: #ffc94c;
|
||||
}
|
||||
|
||||
.server-label-work {
|
||||
background: #41ccb4;
|
||||
}
|
||||
|
||||
.server-label-travel {
|
||||
background: #40c365;
|
||||
}
|
||||
|
||||
|
||||
/* server Item Styles */
|
||||
.server-content-title {
|
||||
padding: var(--app-space-1);
|
||||
/* color: #ffffff; */
|
||||
}
|
||||
|
||||
.server-item {
|
||||
padding: var(--app-space-1);
|
||||
/* color: #ffffff; */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-item:hover {
|
||||
background: #d1d0d0;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.server-name,
|
||||
.server-info {
|
||||
margin: 0;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
color: #999;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.server-comment {
|
||||
font-size: 90%;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.server-add {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 150%;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.server-add:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.server-item-selected {
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.server-item-unread {
|
||||
border-left: 6px solid #1b98f8;
|
||||
}
|
||||
|
||||
.server-item-broken {
|
||||
border-left: 6px solid #880d06;
|
||||
}
|
||||
|
||||
|
||||
/* server Content Styles */
|
||||
.server-content-header,
|
||||
.server-content-body,
|
||||
.server-content-footer {
|
||||
padding: 1em 2em;
|
||||
}
|
||||
|
||||
.server-content-header {
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.server-content-title {
|
||||
margin: 0.5em 0 0;
|
||||
}
|
||||
|
||||
.server-content-subtitle {
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.server-content-subtitle span {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.server-content-controls {
|
||||
margin-top: 2em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.server-content-controls .secondary-button {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.server-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* -- TABLET (AND UP) MEDIA QUERIES --
|
||||
* On tablets and other medium-sized devices, we want to customize some
|
||||
* of the mobile styles.
|
||||
*/
|
||||
@media (min-width: 40em) {
|
||||
|
||||
/* These are position:fixed; elements that will be in the left 500px of the screen */
|
||||
#nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#nav {
|
||||
/* margin-left: -500px; */
|
||||
width: 150px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Show the menu items on the larger screen */
|
||||
.nav-inner {
|
||||
display: block;
|
||||
padding: 2em 0;
|
||||
}
|
||||
|
||||
/* Hide the "Menu" button on larger screens */
|
||||
#nav .nav-menu-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
#main {
|
||||
position: fixed;
|
||||
top: 33%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 150px;
|
||||
overflow: auto;
|
||||
width: auto;
|
||||
/* so that it's not 100% */
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
* -- DESKTOP (AND UP) MEDIA QUERIES --
|
||||
* On desktops and other large-sized devices, we want to customize some
|
||||
* of the mobile styles.
|
||||
*/
|
||||
@media (min-width: 60em) {
|
||||
|
||||
/* This will take up the entire height, and be a little thinner */
|
||||
|
||||
|
||||
/* This will now take up it's own column, so don't need position: fixed; */
|
||||
#main {
|
||||
position: static;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.alert {
|
||||
position: absolute;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
line-height: 1.8;
|
||||
border-radius: 5px;
|
||||
cursor: hand;
|
||||
cursor: pointer;
|
||||
font-family: sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.alertCheckbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:checked+.alert {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alertText {
|
||||
display: table;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
font-size: 150%;
|
||||
}
|
||||
|
||||
.alertClose {
|
||||
float: right;
|
||||
padding-top: 0px;
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: #EEE;
|
||||
border: 1px solid #DDD;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #EFE;
|
||||
border: 1px solid #DED;
|
||||
color: #9A9;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background-color: #EFF;
|
||||
border: 1px solid #DEE;
|
||||
color: #9AA;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: #FDF7DF;
|
||||
border: 1px solid #FEEC6F;
|
||||
color: #C9971C;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #FEE;
|
||||
border: 1px solid #EDD;
|
||||
color: #A66;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"menu entety-menu content";
|
||||
height: var(--app-h);
|
||||
justify-content: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#menu {
|
||||
grid-area: menu;
|
||||
width: 150px;
|
||||
height: 100%;
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
#entety-menu {
|
||||
grid-area: entety-menu;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#content {
|
||||
grid-area: content;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.srcollable-list-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 60em) {
|
||||
#entety-menu {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40em) {
|
||||
#layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"menu"
|
||||
"entety-menu"
|
||||
"content";
|
||||
grid-template-rows: min-content min-content;
|
||||
}
|
||||
|
||||
#menu {
|
||||
width: 100vw;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
#entety-menu {
|
||||
width: 100vw;
|
||||
height: initial;
|
||||
}
|
||||
|
||||
.srcollable-list-content{
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
column-gap: var(--app-space-1);
|
||||
}
|
||||
|
||||
.srcollable-list-content > * {
|
||||
scroll-snap-align: start;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 250px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.server-checkbox {
|
||||
display: flex;
|
||||
|
||||
}
|
||||
|
||||
.server-checkbox input {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.pure-form-message {
|
||||
padding: 0.2em;
|
||||
}
|
||||
#search-form {
|
||||
padding: var(--app-space-1);
|
||||
}
|
||||
#entety-menu-search {
|
||||
width: 100%;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
202
templates/admin/create_xray_inbound.html
Normal file
202
templates/admin/create_xray_inbound.html
Normal file
@@ -0,0 +1,202 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="protocol">Protocol *</label>
|
||||
<select name="protocol" id="protocol" class="form-control" required>
|
||||
<option value="">Select Protocol</option>
|
||||
{% for proto in protocols %}
|
||||
<option value="{{ proto }}">{{ proto|upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="port">Port *</label>
|
||||
<input type="number" name="port" id="port" class="form-control"
|
||||
min="1" max="65535" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="tag">Tag</label>
|
||||
<input type="text" name="tag" id="tag" class="form-control"
|
||||
placeholder="Auto-generated if empty">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="network">Network</label>
|
||||
<select name="network" id="network" class="form-control">
|
||||
{% for net in networks %}
|
||||
<option value="{{ net }}" {% if net == 'tcp' %}selected{% endif %}>
|
||||
{{ net|upper }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="security">Security</label>
|
||||
<select name="security" id="security" class="form-control">
|
||||
{% for sec in securities %}
|
||||
<option value="{{ sec }}" {% if sec == 'none' %}selected{% endif %}>
|
||||
{{ sec|upper }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="alert alert-info">
|
||||
<strong>Note:</strong> The inbound will be created on both the Django database and the Xray server via gRPC API.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-success">
|
||||
➕ Create Inbound
|
||||
</button>
|
||||
<a href="{% url 'admin:vpn_xraycoreserver_change' server.pk %}" class="btn btn-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const protocolField = document.getElementById('protocol');
|
||||
const portField = document.getElementById('port');
|
||||
const tagField = document.getElementById('tag');
|
||||
|
||||
// Auto-suggest ports based on protocol
|
||||
protocolField.addEventListener('change', function() {
|
||||
const protocol = this.value;
|
||||
const ports = {
|
||||
'vless': 443,
|
||||
'vmess': 443,
|
||||
'trojan': 443
|
||||
};
|
||||
|
||||
if (ports[protocol] && !portField.value) {
|
||||
portField.value = ports[protocol];
|
||||
}
|
||||
|
||||
if (protocol && !tagField.value) {
|
||||
tagField.placeholder = `${protocol}-${portField.value || 'PORT'}`;
|
||||
}
|
||||
});
|
||||
|
||||
portField.addEventListener('input', function() {
|
||||
const protocol = protocolField.value;
|
||||
if (protocol && !tagField.value) {
|
||||
tagField.placeholder = `${protocol}-${this.value}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
margin: 0 -10px;
|
||||
}
|
||||
|
||||
.col-md-6 {
|
||||
flex: 0 0 50%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,167 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="entety-menu">
|
||||
<div id="list">
|
||||
<h1 class="server-content-title">Clients</h1>
|
||||
<form id="search-form" class="pure-form">
|
||||
<input placeholder="🔍" type="text" id="entety-menu-search" />
|
||||
</form>
|
||||
<div data-search-box class="srcollable-list-content">
|
||||
{% for client, values in CLIENTS.items() %}
|
||||
<div
|
||||
class="server-item server-item-{% if client == selected_client %}unread{% else %}selected{% endif %} pure-g">
|
||||
<div class="" onclick="location.href='/clients?selected_client={{ client }}';">
|
||||
<h5 data-search class="server-name">{{ values["name"] }}</h5>
|
||||
<h4 class="server-info">{{ values["servers"]|length }} server{% if values["servers"]|length >1
|
||||
%}s{%endif%}</h4>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div onclick="location.href='/clients?add_client=True';" class="server-item server-add pure-g">
|
||||
<div class="pure-u-1">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if add_client %}
|
||||
<div id="content">
|
||||
<div class="">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="">
|
||||
<h1 class="server-content-title">Add new client</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-content-body">
|
||||
<form action="/add_client" class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="" name="name" required placeholder="Name" />
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="" name="comment" placeholder="Comment" />
|
||||
</div>
|
||||
<div class="pure-checkbox">
|
||||
{% for server in SERVERS %}
|
||||
<div class="server-checkbox">
|
||||
<input type="checkbox" id="option{{loop.index0}}" name="servers"
|
||||
value="{{server.info()['local_server_id']}}">
|
||||
<label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["comment"]}}
|
||||
<span class="pure-form-message">ID: {{server.info()['local_server_id'][0:8]}}</span>
|
||||
<span class="pure-form-message">Comment: {{server.info()["name"]}}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if selected_client and not add_client %}
|
||||
{% set client = CLIENTS[selected_client] %}
|
||||
|
||||
<div id="content">
|
||||
<div class="">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="">
|
||||
<h1 class="server-content-title">{{client['name']}}</h1>
|
||||
<h4 class="server-info">{{ client['comment'] }}</h4>
|
||||
<h4 class="server-info">id {{ selected_client }}</h4>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-content-body">
|
||||
<form action="/add_client" class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="pure-u-1" name="name" required value="{{client['name']}}" />
|
||||
<input type="hidden" class="pure-u-1" name="old_name" required value="{{client['name']}}" />
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="pure-u-1" name="comment" value="{{client['comment']}}" />
|
||||
</div>
|
||||
<input type="hidden" class="pure-u-1" name="user_id" value="{{selected_client}}" />
|
||||
|
||||
<div class="pure-checkbox">
|
||||
|
||||
<p>Allow access to:</p>
|
||||
|
||||
{% for server in SERVERS %}
|
||||
<div class="server-checkbox">
|
||||
<input {% if server.info()['local_server_id'] in client['servers'] %}checked{%endif%}
|
||||
type="checkbox" id="option{{loop.index0}}" name="servers"
|
||||
value="{{server.info()['local_server_id']}}">
|
||||
<label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["comment"]}}
|
||||
<span class="pure-form-message">ID: {{server.info()['local_server_id'][0:8]}}</span>
|
||||
<span class="pure-form-message">Comment: {{server.info()["name"]}}</span>
|
||||
<span class="pure-form-message">{% if
|
||||
server.info()['local_server_id'] in client['servers'] %}Usage: {% for key in
|
||||
server.data["keys"] %}{% if key.name == client['name'] %}{{ (key.used_bytes if
|
||||
key.used_bytes else 0) | filesizeformat }}{% endif %}{% endfor
|
||||
%}{%endif%}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="pure-g pure-form pure-form-stacked">
|
||||
<div class="pure-u-1-2">
|
||||
<button type="submit" class="pure-button pure-button-primary button">Save and apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
<form action="/del_client" class="pure-form pure-form-stacked" method="POST">
|
||||
<input type="hidden" name="name" required value="{{client['name']}}" />
|
||||
<input type="hidden" name="user_id" value="{{selected_client}}" />
|
||||
<button type="submit" class="pure-button pure-button-primary delete-button button">Delete
|
||||
Client</button>
|
||||
<input type="checkbox" id="agree" name="agree" required>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<h3>Invite text</h3>
|
||||
<hr>
|
||||
<p>Install Outline VPN. Copy and paste the keys below into the Outline client.
|
||||
The same keys can be used simultaneously on multiple devices.</p>
|
||||
{% for server in SERVERS -%}
|
||||
{% if server.info()['local_server_id'] in client['servers'] %}
|
||||
{% set salt = bcrypt.gensalt() %}
|
||||
{% set secret_string = server.info()['local_server_id'] + selected_client %}
|
||||
{% set hash_secret = bcrypt.hashpw(
|
||||
password=secret_string.encode('utf-8'),
|
||||
salt=salt).decode('utf-8') %}
|
||||
|
||||
<p><b>Server location:</b> {{server.info()['name']}}</p>
|
||||
<p><b>Client link:</b> {% for key in server.data["keys"] %}{% if key.key_id == client['name']
|
||||
%}ssconf://{{ dynamic_hostname
|
||||
}}/dynamic/{{server.info()['local_server_id'][0:SECRET_LINK_LENGTH]}}{{selected_client[0:SECRET_LINK_LENGTH]}}{{hash_secret[SECRET_LINK_PREFIX|length:]}}#{{server.info()['comment']}}{%
|
||||
endif %}{% endfor %}</p>
|
||||
{% endif %}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,159 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div id="entety-menu">
|
||||
<div>
|
||||
<h1 class="server-content-title">Servers</h1>
|
||||
<div class="srcollable-list-content">
|
||||
{% for server in SERVERS %}
|
||||
{% set total_traffic = namespace(total_bytes=0) %}
|
||||
{% for key in server.data["keys"] %}
|
||||
{% if key.used_bytes %}
|
||||
{% set total_traffic.total_bytes = total_traffic.total_bytes + key.used_bytes %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="server-item server-item-{% if loop.index0 == selected_server|int %}unread{% else %}selected{% endif %} pure-g">
|
||||
<div class="pure-u-3-4" onclick="location.href='/?selected_server={{loop.index0}}';">
|
||||
<h5 class="server-name">{{ server.info()["name"] }}</h5>
|
||||
<h4 class="server-info">{{ '/'.join(server.info()["url"].split('/')[0:-1]) }}</h4>
|
||||
<h4 class="server-info">Port {{ server.info()["port_for_new_access_keys"] }}</h4>
|
||||
<h4 class="server-info">Hostname {{ server.info()["hostname_for_access_keys"] }}</h4>
|
||||
<h4 class="server-info">Traffic: {{ total_traffic.total_bytes | filesizeformat }}</h4>
|
||||
<h4 class="server-info">v.{{ server.info()["version"] }}</h4>
|
||||
<p class="server-comment">
|
||||
{{ server.info()["comment"] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div onclick="location.href='/?add_server=True';" class="server-item server-add pure-g">
|
||||
<div class="pure-u-1">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if add_server %}
|
||||
<div id="content">
|
||||
<div >
|
||||
<div class="server-content-header pure-g">
|
||||
<div >
|
||||
<h1 class="server-content-title">Add new server</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-content-body">
|
||||
<form action="/add_server" class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div >
|
||||
<label for="url">Server management URL</label>
|
||||
<input type="text" class="pure-u-1" name="url" placeholder="https://example.com:5743/KSsdywe6Sdb..."/>
|
||||
<label for="cert">Server management Certificate</label>
|
||||
<input type="text" class="pure-u-1" name="cert" placeholder="B5DD2443DAF..."/>
|
||||
<label for="cert">Server Comment
|
||||
<span class="pure-form-message">This will be exposed to client and will be used as "Server name" in client Outline app</span>
|
||||
</label>
|
||||
<input type="text" class="pure-u-1" name="comment" placeholder="e.g. server location"/>
|
||||
</div>
|
||||
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if SERVERS|length != 0 and not add_server %}
|
||||
|
||||
{% if selected_server is none %}
|
||||
{% set server = SERVERS[0] %}
|
||||
{% else %}
|
||||
{% set server = SERVERS[selected_server|int] %}
|
||||
{% endif %}
|
||||
<div id="content">
|
||||
<div >
|
||||
<div class="server-content">
|
||||
<div class="server-content-header pure-g">
|
||||
<div>
|
||||
<h1 class="content-title">{{server.info()["name"]}}</h1>
|
||||
<p class="server-content-subtitle">
|
||||
<span>v.{{server.info()["version"]}} {{server.info()["local_server_id"]}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% set ns = namespace(total_bytes=0) %}
|
||||
{% for key in SERVERS[selected_server|int].data["keys"] %}
|
||||
{% if key.used_bytes %}
|
||||
{% set ns.total_bytes = ns.total_bytes + key.used_bytes %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="server-content-body">
|
||||
<h3>Clients: {{ server.info()['keys']|length }}</h3>
|
||||
<h3>Total traffic: {{ ns.total_bytes | filesizeformat }}</h3>
|
||||
<form class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 ">
|
||||
<label for="name">Server Name
|
||||
<span class="pure-form-message">This will not be exposed to client</span>
|
||||
</label>
|
||||
<input type="text" id="name" class="pure-u-1" name="name" value="{{server.info()['name']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="comment">Comment</br>
|
||||
<span class="pure-form-message">This will be exposed to client and will be used as "Server name" in client Outline app</span></label>
|
||||
<input type="text" id="comment" class="pure-u-1" name="comment" value="{{server.info()['comment']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1">
|
||||
<label for="port_for_new_access_keys">Port For New Access Keys</label>
|
||||
<input type="text" id="port_for_new_access_keys" class="pure-u-1" name="port_for_new_access_keys" value="{{server.info()['port_for_new_access_keys']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="hostname_for_access_keys">Hostname For Access Keys</label>
|
||||
<input type="text" id="hostname_for_access_keys" class="pure-u-1" name="hostname_for_access_keys" value="{{server.info()['hostname_for_access_keys']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="url">Server URL</label>
|
||||
<input type="text" readonly id="url" class="pure-u-1" name="url" value="{{server.info()['url']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="cert">Server Access Certificate</label>
|
||||
<input type="text" readonly id="cert" class="pure-u-1" name="cert" value="{{server.info()['cert']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="created_timestamp_ms">Created</label>
|
||||
<input type="text" readonly id="created_timestamp_ms" class="pure-u-1" name="created_timestamp_ms" value="{{format_timestamp(server.info()['created_timestamp_ms']) }}"/>
|
||||
</div>
|
||||
<input type="hidden" readonly id="server_id" name="server_id" value="{{server.info()['local_server_id']}}"/>
|
||||
</div>
|
||||
<p>Share anonymous metrics</p>
|
||||
<label for="metrics_enabled" class="pure-radio">
|
||||
<input type="radio" id="metrics_enabled" name="metrics" value="True" {% if server.info()['metrics_enabled'] == True %}checked{% endif %} /> Enable
|
||||
</label>
|
||||
<label for="metrics_disabled" class="pure-radio">
|
||||
<input type="radio" id="metrics_disabled" name="metrics" value="False" {% if server.info()['metrics_enabled'] == False %}checked{% endif %} /> Disable
|
||||
</label>
|
||||
<button type="submit" class="pure-button pure-button-primary button">Save and apply</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<form action="/del_server" method="post">
|
||||
<input type="hidden" name="local_server_id" value="{{ server.info()["local_server_id"] }}">
|
||||
<button type="submit" class="pure-button pure-button-primary delete-button button">Delete Server</button>
|
||||
<input type="checkbox" id="agree" name="agree" required>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,17 +0,0 @@
|
||||
<h1>Last sync log</h1>
|
||||
<form action="/sync" class="pure-form pure-form-stacked" method="POST">
|
||||
<p>Wipe ALL keys on ALL servers?</p>
|
||||
<label for="no_wipe" class="pure-radio">
|
||||
<input type="radio" id="no_wipe" name="wipe" value="no_wipe" checked /> No
|
||||
</label>
|
||||
<label for="do_wipe" class="pure-radio">
|
||||
<input type="radio" id="do_wipe" name="wipe" value="all" /> Yes
|
||||
</label>
|
||||
<button type="submit" class="pure-button button-error pure-input-1 ">Sync now</button>
|
||||
</form>
|
||||
|
||||
<pre>
|
||||
<code>
|
||||
{% for line in lines %}{{ line }}{% endfor %}
|
||||
</code>
|
||||
</pre>
|
||||
@@ -1,83 +0,0 @@
|
||||
$url = Read-Host "Please enter the URL for the JSON configuration"
|
||||
$comment = Read-Host "Comment [server, country, etc]"
|
||||
$port = Read-Host "Please enter the port to use for sslocal"
|
||||
|
||||
$version = "1.21.0"
|
||||
$archiveUrl = "https://github.com/shadowsocks/shadowsocks-rust/releases/download/v${version}/shadowsocks-v${version}.x86_64-pc-windows-gnu.zip"
|
||||
$downloadPath = "$HOME\shadowsocks-rust\shadowsocks.zip"
|
||||
$extractPath = "$HOME\shadowsocks-rust"
|
||||
$scriptUrl = "https://raw.githubusercontent.com/house-of-vanity/OutFleet/refs/heads/master/tools/windows_task.ps1"
|
||||
$cmdFilePath = "$extractPath\run_${comment}.cmd"
|
||||
$taskName = "Shadowsocks_Task_${comment}"
|
||||
$logFile = "$extractPath\Log_${comment}.log"
|
||||
|
||||
|
||||
if ($url -notmatch "^[a-z]+://") {
|
||||
$url = "https://$url"
|
||||
} elseif ($url -like "ssconf://*") {
|
||||
$url = $url -replace "^ssconf://", "https://"
|
||||
}
|
||||
|
||||
function Test-Admin {
|
||||
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
if (-Not (Test-Admin)) {
|
||||
Write-Host "Error: This script requires administrator privileges. Please run PowerShell as administrator." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure the extraction directory exists
|
||||
if (-Not (Test-Path -Path $extractPath)) {
|
||||
New-Item -ItemType Directory -Path $extractPath
|
||||
}
|
||||
|
||||
# Download the archive
|
||||
Invoke-WebRequest -Uri $archiveUrl -OutFile $downloadPath
|
||||
|
||||
# Extract the archive
|
||||
Expand-Archive -Path $downloadPath -DestinationPath $extractPath -Force
|
||||
|
||||
# Check if sslocal.exe exists
|
||||
if (-Not (Test-Path -Path "$extractPath\sslocal.exe")) {
|
||||
Write-Host "Error: sslocal.exe not found in $extractPath" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Download the windows_task.ps1 script
|
||||
Invoke-WebRequest -Uri $scriptUrl -OutFile "$extractPath\windows_task.ps1"
|
||||
|
||||
# Build Batch file content
|
||||
$batchContent = @"
|
||||
@echo off
|
||||
set scriptPath=""$extractPath\windows_task.ps1""
|
||||
powershell.exe -ExecutionPolicy Bypass -File %scriptPath% ""$url"" ""$extractPath\sslocal.exe"" ""$port""
|
||||
"@
|
||||
|
||||
|
||||
$batchContent | Set-Content -Path $cmdFilePath
|
||||
|
||||
# Create or update Task Scheduler
|
||||
$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument "/c $cmdFilePath > $logFile"
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
|
||||
# Check if the task already exists
|
||||
$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
|
||||
if ($existingTask) {
|
||||
Write-Host "Task $taskName already exists. Updating the task..."
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
}
|
||||
|
||||
# Register the new or updated task
|
||||
Register-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings -TaskName $taskName
|
||||
|
||||
Write-Host "Task $taskName has been created/updated successfully."
|
||||
|
||||
# Optionally, start the task immediately
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
Write-Host "Task $taskName has been started."
|
||||
@@ -1,100 +0,0 @@
|
||||
if ($args.Count -lt 3) {
|
||||
Write-Host "Usage: windows_task.ps1 <url> <sslocal_path> <local_port>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$url = $args[0]
|
||||
$sslocalPath = $args[1]
|
||||
$localPort = $args[2]
|
||||
|
||||
$localAddr = "localhost"
|
||||
$checkInterval = 60
|
||||
$previousPassword = ""
|
||||
|
||||
# Function to get the process ID of the process listening on a specific port
|
||||
function Get-ProcessByPort {
|
||||
param (
|
||||
[int]$port
|
||||
)
|
||||
|
||||
# Use Get-NetTCPConnection to find the process listening on the given port
|
||||
$connection = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Listen' }
|
||||
|
||||
if ($connection) {
|
||||
# Get the owning process ID (OwningProcess) from the connection
|
||||
$pid = $connection.OwningProcess
|
||||
return Get-Process -Id $pid -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Function to start sslocal
|
||||
function Start-SSLocal {
|
||||
param (
|
||||
[string]$method,
|
||||
[string]$password,
|
||||
[string]$server,
|
||||
[int]$serverPort
|
||||
)
|
||||
|
||||
# Form the Shadowsocks connection string
|
||||
$credentials = "${method}:${password}@${server}:${serverPort}"
|
||||
$encodedCredentials = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($credentials))
|
||||
$ssUrl = "ss://$encodedCredentials"
|
||||
|
||||
# Get the process listening on the specified port and kill it if found
|
||||
$process = Get-ProcessByPort -port $localPort
|
||||
if ($process) {
|
||||
Write-Host "Killing process $($process.Id) using port $localPort"
|
||||
Stop-Process -Id $process.Id -Force
|
||||
}
|
||||
|
||||
# Log the sslocal restart
|
||||
Write-Host "Starting sslocal with method: $method, server: $server, port: $serverPort"
|
||||
|
||||
# Start sslocal with the provided arguments
|
||||
Start-Process -NoNewWindow -FilePath $sslocalPath -ArgumentList "--local-addr ${localAddr}:${localPort} --server-url $ssUrl"
|
||||
}
|
||||
|
||||
# Main loop
|
||||
while ($true) {
|
||||
try {
|
||||
if ($url -notmatch "mode=json") {
|
||||
$delimiter = "?"
|
||||
if ($url -match "\?") {
|
||||
$delimiter = "&"
|
||||
}
|
||||
$url = "$url${delimiter}mode=json"
|
||||
}
|
||||
# Download and parse the JSON
|
||||
$jsonContent = Invoke-WebRequest -Uri $url -UseBasicParsing | Select-Object -ExpandProperty Content
|
||||
$json = $jsonContent | ConvertFrom-Json
|
||||
|
||||
# Extract the necessary fields
|
||||
$method = $json.method
|
||||
$password = $json.password
|
||||
$server = $json.server
|
||||
$serverPort = $json.server_port
|
||||
|
||||
# Log current password and server information
|
||||
Write-Host "Checking server: $server, port: $serverPort"
|
||||
|
||||
# Check if the password has changed
|
||||
if ($password -ne $previousPassword) {
|
||||
# Start/restart sslocal
|
||||
Start-SSLocal -method $method -password $password -server $server -serverPort $serverPort
|
||||
$previousPassword = $password
|
||||
Write-Host "Password changed, restarting sslocal."
|
||||
} else {
|
||||
Write-Host "Password has not changed."
|
||||
}
|
||||
|
||||
} catch {
|
||||
Write-Host "Error occurred: $_"
|
||||
}
|
||||
|
||||
# Wait for the next check
|
||||
Start-Sleep -Seconds $checkInterval
|
||||
}
|
||||
|
||||
0
vpn/__init__.py
Normal file
0
vpn/__init__.py
Normal file
1836
vpn/admin.py
Normal file
1836
vpn/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
6
vpn/apps.py
Normal file
6
vpn/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
class VPN(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'vpn'
|
||||
14
vpn/forms.py
Normal file
14
vpn/forms.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
from .models import User
|
||||
from .server_plugins import Server
|
||||
|
||||
class UserForm(forms.ModelForm):
|
||||
servers = forms.ModelMultipleChoiceField(
|
||||
queryset=Server.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'comment', 'servers']
|
||||
1
vpn/management/__init__.py
Normal file
1
vpn/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Django management commands package\n
|
||||
158
vpn/management/commands/cleanup_access_logs.py
Normal file
158
vpn/management/commands/cleanup_access_logs.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection, transaction
|
||||
from datetime import datetime, timedelta
|
||||
from vpn.models import AccessLog
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up old AccessLog entries without acl_link_id'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--days',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Delete logs older than this many days (default: 30)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--batch-size',
|
||||
type=int,
|
||||
default=10000,
|
||||
help='Number of records to delete in each batch (default: 10000)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be deleted without actually deleting'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--keep-recent',
|
||||
type=int,
|
||||
default=1000,
|
||||
help='Keep this many recent logs even if they have no link (default: 1000)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = options['days']
|
||||
batch_size = options['batch_size']
|
||||
dry_run = options['dry_run']
|
||||
keep_recent = options['keep_recent']
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
self.stdout.write(f"🔍 Analyzing AccessLog cleanup...")
|
||||
self.stdout.write(f" - Delete logs without acl_link_id older than {days} days")
|
||||
self.stdout.write(f" - Keep {keep_recent} most recent logs without links")
|
||||
self.stdout.write(f" - Batch size: {batch_size}")
|
||||
self.stdout.write(f" - Dry run: {dry_run}")
|
||||
|
||||
# Count total records to be deleted
|
||||
with connection.cursor() as cursor:
|
||||
# Count logs without acl_link_id
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
""")
|
||||
total_without_link = cursor.fetchone()[0]
|
||||
|
||||
# Count logs to be deleted (older than cutoff, excluding recent ones to keep)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < %s
|
||||
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 %s
|
||||
) AS recent_logs
|
||||
)
|
||||
""", [cutoff_date, keep_recent])
|
||||
total_to_delete = cursor.fetchone()[0]
|
||||
|
||||
# Count total records
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
total_records = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write(f"📊 Statistics:")
|
||||
self.stdout.write(f" - Total AccessLog records: {total_records:,}")
|
||||
self.stdout.write(f" - Records without acl_link_id: {total_without_link:,}")
|
||||
self.stdout.write(f" - Records to be deleted: {total_to_delete:,}")
|
||||
self.stdout.write(f" - Records to be kept (recent): {keep_recent:,}")
|
||||
|
||||
if total_to_delete == 0:
|
||||
self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {total_to_delete:,} records"))
|
||||
return
|
||||
|
||||
# Confirm deletion
|
||||
if not options.get('verbosity', 1) == 0: # Only ask if not --verbosity=0
|
||||
confirm = input(f"❓ Delete {total_to_delete:,} records? (yes/no): ")
|
||||
if confirm.lower() != 'yes':
|
||||
self.stdout.write("❌ Cancelled.")
|
||||
return
|
||||
|
||||
self.stdout.write(f"🗑️ Starting deletion of {total_to_delete:,} records...")
|
||||
|
||||
deleted_total = 0
|
||||
batch_num = 0
|
||||
|
||||
while True:
|
||||
batch_num += 1
|
||||
|
||||
with transaction.atomic():
|
||||
with connection.cursor() as cursor:
|
||||
# Delete batch
|
||||
cursor.execute("""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < %s
|
||||
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 %s
|
||||
) AS recent_logs
|
||||
)
|
||||
LIMIT %s
|
||||
""", [cutoff_date, keep_recent, batch_size])
|
||||
|
||||
deleted_in_batch = cursor.rowcount
|
||||
|
||||
if deleted_in_batch == 0:
|
||||
break
|
||||
|
||||
deleted_total += deleted_in_batch
|
||||
progress = (deleted_total / total_to_delete) * 100
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num}: Deleted {deleted_in_batch:,} records "
|
||||
f"(Total: {deleted_total:,}/{total_to_delete:,}, {progress:.1f}%)"
|
||||
)
|
||||
|
||||
if deleted_in_batch < batch_size:
|
||||
break
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"✅ Cleanup completed!"))
|
||||
self.stdout.write(f" - Deleted {deleted_total:,} old AccessLog records")
|
||||
self.stdout.write(f" - Kept {keep_recent:,} recent records without links")
|
||||
|
||||
# Show final statistics
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
final_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
""")
|
||||
final_without_link = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write(f"📊 Final statistics:")
|
||||
self.stdout.write(f" - Total AccessLog records: {final_total:,}")
|
||||
self.stdout.write(f" - Records without acl_link_id: {final_without_link:,}")
|
||||
208
vpn/management/commands/cleanup_logs.py
Normal file
208
vpn/management/commands/cleanup_logs.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''
|
||||
Clean up AccessLog entries efficiently using direct SQL.
|
||||
|
||||
Examples:
|
||||
# Delete logs without acl_link_id older than 30 days (recommended)
|
||||
python manage.py cleanup_logs --keep-days=30
|
||||
|
||||
# Keep only last 5000 logs without acl_link_id
|
||||
python manage.py cleanup_logs --keep-count=5000
|
||||
|
||||
# Delete ALL logs older than 7 days (including with acl_link_id)
|
||||
python manage.py cleanup_logs --keep-days=7 --target=all
|
||||
|
||||
# Preview what would be deleted
|
||||
python manage.py cleanup_logs --keep-days=30 --dry-run
|
||||
|
||||
# Force delete without confirmation
|
||||
python manage.py cleanup_logs --keep-days=30 --force
|
||||
'''
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Primary options (mutually exclusive)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
'--keep-days',
|
||||
type=int,
|
||||
help='Keep logs newer than this many days (delete older)'
|
||||
)
|
||||
group.add_argument(
|
||||
'--keep-count',
|
||||
type=int,
|
||||
help='Keep this many most recent logs (delete the rest)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--target',
|
||||
choices=['no-links', 'all'],
|
||||
default='no-links',
|
||||
help='Target: "no-links" = only logs without acl_link_id (default), "all" = all logs'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be deleted without actually deleting'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Skip confirmation prompt'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
keep_days = options.get('keep_days')
|
||||
keep_count = options.get('keep_count')
|
||||
target = options['target']
|
||||
dry_run = options['dry_run']
|
||||
force = options['force']
|
||||
|
||||
# Build SQL conditions
|
||||
if target == 'no-links':
|
||||
base_condition = "(acl_link_id IS NULL OR acl_link_id = '')"
|
||||
target_desc = "logs without acl_link_id"
|
||||
else:
|
||||
base_condition = "1=1"
|
||||
target_desc = "all logs"
|
||||
|
||||
# Get current statistics
|
||||
with connection.cursor() as cursor:
|
||||
# Total records
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
total_records = cursor.fetchone()[0]
|
||||
|
||||
# Target records count
|
||||
cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
|
||||
target_records = cursor.fetchone()[0]
|
||||
|
||||
# Records to delete
|
||||
if keep_days:
|
||||
cutoff_date = timezone.now() - timedelta(days=keep_days)
|
||||
cursor.execute(f"""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE {base_condition} AND timestamp < %s
|
||||
""", [cutoff_date])
|
||||
to_delete = cursor.fetchone()[0]
|
||||
strategy = f"older than {keep_days} days"
|
||||
else: # keep_count
|
||||
to_delete = max(0, target_records - keep_count)
|
||||
strategy = f"keeping only {keep_count} most recent"
|
||||
|
||||
# Print statistics
|
||||
self.stdout.write("🗑️ AccessLog Cleanup" + (" (DRY RUN)" if dry_run else ""))
|
||||
self.stdout.write(f" Target: {target_desc}")
|
||||
self.stdout.write(f" Strategy: {strategy}")
|
||||
self.stdout.write("")
|
||||
self.stdout.write("📊 Statistics:")
|
||||
self.stdout.write(f" Total AccessLog records: {total_records:,}")
|
||||
self.stdout.write(f" Target records: {target_records:,}")
|
||||
self.stdout.write(f" Records to delete: {to_delete:,}")
|
||||
self.stdout.write(f" Records to keep: {target_records - to_delete:,}")
|
||||
|
||||
if total_records > 0:
|
||||
delete_percent = (to_delete / total_records) * 100
|
||||
self.stdout.write(f" Deletion percentage: {delete_percent:.1f}%")
|
||||
|
||||
if to_delete == 0:
|
||||
self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
|
||||
return
|
||||
|
||||
# Show SQL that will be executed
|
||||
if dry_run or not force:
|
||||
self.stdout.write("")
|
||||
self.stdout.write("📝 SQL to execute:")
|
||||
if keep_days:
|
||||
sql_preview = f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition} AND timestamp < '{cutoff_date.strftime('%Y-%m-%d %H:%M:%S')}'
|
||||
"""
|
||||
else: # keep_count
|
||||
sql_preview = f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT {keep_count}
|
||||
) AS recent_logs
|
||||
)
|
||||
"""
|
||||
self.stdout.write(sql_preview.strip())
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {to_delete:,} records"))
|
||||
return
|
||||
|
||||
# Confirm deletion
|
||||
if not force:
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.ERROR(f"⚠️ About to DELETE {to_delete:,} records!"))
|
||||
confirm = input("Type 'DELETE' to confirm: ")
|
||||
if confirm != 'DELETE':
|
||||
self.stdout.write("❌ Cancelled.")
|
||||
return
|
||||
|
||||
# Execute deletion
|
||||
self.stdout.write("")
|
||||
self.stdout.write(f"🗑️ Deleting {to_delete:,} records...")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
if keep_days:
|
||||
# Simple time-based deletion
|
||||
cursor.execute(f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition} AND timestamp < %s
|
||||
""", [cutoff_date])
|
||||
else:
|
||||
# Keep count deletion (more complex)
|
||||
cursor.execute(f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT %s
|
||||
) AS recent_logs
|
||||
)
|
||||
""", [keep_count])
|
||||
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
# Final statistics
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
final_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
|
||||
final_target = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.SUCCESS("✅ Cleanup completed!"))
|
||||
self.stdout.write(f" Deleted: {deleted_count:,} records")
|
||||
self.stdout.write(f" Remaining total: {final_total:,}")
|
||||
|
||||
if target == 'no-links':
|
||||
self.stdout.write(f" Remaining without links: {final_target:,}")
|
||||
|
||||
# Calculate space saved (rough estimate)
|
||||
if deleted_count > 0:
|
||||
# Rough estimate: ~200 bytes per AccessLog record
|
||||
space_saved_mb = (deleted_count * 200) / (1024 * 1024)
|
||||
if space_saved_mb > 1024:
|
||||
space_saved_gb = space_saved_mb / 1024
|
||||
self.stdout.write(f" Estimated space saved: ~{space_saved_gb:.1f} GB")
|
||||
else:
|
||||
self.stdout.write(f" Estimated space saved: ~{space_saved_mb:.1f} MB")
|
||||
17
vpn/management/commands/create_admin.py
Normal file
17
vpn/management/commands/create_admin.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create default admin user'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
User = get_user_model()
|
||||
if not User.objects.filter(username='admin').exists():
|
||||
User.objects.create_superuser(
|
||||
username='admin',
|
||||
password='admin',
|
||||
email='admin@localhost'
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS('Admin user created'))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING('Admin user already exists'))
|
||||
1
vpn/management/commands/init_statistics.py
Normal file
1
vpn/management/commands/init_statistics.py
Normal file
@@ -0,0 +1 @@
|
||||
from django.core.management.base import BaseCommand\nfrom django.utils import timezone\nfrom vpn.models import User, ACLLink, UserStatistics\nfrom vpn.tasks import update_user_statistics\n\n\nclass Command(BaseCommand):\n help = 'Initialize user statistics cache by running the update task'\n \n def add_arguments(self, parser):\n parser.add_argument(\n '--async',\n action='store_true',\n help='Run statistics update as async Celery task (default: sync)',\n )\n parser.add_argument(\n '--force',\n action='store_true',\n help='Force update even if statistics already exist',\n )\n \n def handle(self, *args, **options):\n # Check if statistics already exist\n existing_stats = UserStatistics.objects.count()\n \n if existing_stats > 0 and not options['force']:\n self.stdout.write(\n self.style.WARNING(\n f'Statistics cache already contains {existing_stats} entries. '\n 'Use --force to update anyway.'\n )\n )\n return\n \n # Check if there are users with ACL links\n users_with_links = User.objects.filter(acl__isnull=False).distinct().count()\n total_links = ACLLink.objects.count()\n \n self.stdout.write(\n f'Found {users_with_links} users with {total_links} ACL links total'\n )\n \n if total_links == 0:\n self.stdout.write(\n self.style.WARNING('No ACL links found. Nothing to process.')\n )\n return\n \n if options['async']:\n # Run as async Celery task\n try:\n task = update_user_statistics.delay()\n self.stdout.write(\n self.style.SUCCESS(\n f'Statistics update task started. Task ID: {task.id}'\n )\n )\n self.stdout.write(\n 'Check admin panel Task Execution Logs for progress.'\n )\n except Exception as e:\n self.stdout.write(\n self.style.ERROR(f'Failed to start async task: {e}')\n )\n else:\n # Run synchronously\n self.stdout.write('Starting synchronous statistics update...')\n \n try:\n # Import and call the task function directly\n from vpn.tasks import update_user_statistics\n \n # Create a mock Celery request object for the task\n class MockRequest:\n id = f'manual-{timezone.now().isoformat()}'\n retries = 0\n \n # Create mock task instance\n task_instance = type('MockTask', (), {\n 'request': MockRequest(),\n })()\n \n # Call the task function directly\n result = update_user_statistics(task_instance)\n \n self.stdout.write(\n self.style.SUCCESS(f'Statistics update completed: {result}')\n )\n \n # Show summary\n final_stats = UserStatistics.objects.count()\n self.stdout.write(\n self.style.SUCCESS(\n f'Statistics cache now contains {final_stats} entries'\n )\n )\n \n except Exception as e:\n self.stdout.write(\n self.style.ERROR(f'Statistics update failed: {e}')\n )\n import traceback\n self.stdout.write(traceback.format_exc())\n
|
||||
51
vpn/management/commands/simple_cleanup_logs.py
Normal file
51
vpn/management/commands/simple_cleanup_logs.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from vpn.models import AccessLog
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Simple cleanup of AccessLog entries without acl_link_id using Django ORM'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--days',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Delete logs older than this many days (default: 30)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = options['days']
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Count records to be deleted
|
||||
old_logs = AccessLog.objects.filter(
|
||||
acl_link_id__isnull=True,
|
||||
timestamp__lt=cutoff_date
|
||||
)
|
||||
|
||||
# Also include empty string acl_link_id
|
||||
empty_logs = AccessLog.objects.filter(
|
||||
acl_link_id='',
|
||||
timestamp__lt=cutoff_date
|
||||
)
|
||||
|
||||
total_old = old_logs.count()
|
||||
total_empty = empty_logs.count()
|
||||
total_to_delete = total_old + total_empty
|
||||
|
||||
self.stdout.write(f"Found {total_to_delete:,} old logs without acl_link_id to delete")
|
||||
|
||||
if total_to_delete == 0:
|
||||
self.stdout.write("Nothing to delete.")
|
||||
return
|
||||
|
||||
# Delete in batches to avoid memory issues
|
||||
self.stdout.write("Deleting old logs...")
|
||||
|
||||
deleted_count = 0
|
||||
deleted_count += old_logs._raw_delete(old_logs.db)
|
||||
deleted_count += empty_logs._raw_delete(empty_logs.db)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {deleted_count:,} old AccessLog records"))
|
||||
139
vpn/migrations/0001_initial.py
Normal file
139
vpn/migrations/0001_initial.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# Initial migration
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import shortuuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('comment', models.TextField(blank=True, default='', help_text='Free form user comment')),
|
||||
('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('last_access', models.DateTimeField(blank=True, null=True)),
|
||||
('hash', models.CharField(help_text='Random user hash. It\'s using for client config generation.', max_length=64, unique=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AccessLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user', models.CharField(blank=True, editable=False, max_length=256, null=True)),
|
||||
('server', models.CharField(blank=True, editable=False, max_length=256, null=True)),
|
||||
('action', models.CharField(editable=False, max_length=100)),
|
||||
('data', models.TextField(blank=True, default='', editable=False)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Server',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Server name', max_length=100)),
|
||||
('comment', models.TextField(blank=True, default='')),
|
||||
('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('server_type', models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard')], editable=False, max_length=50)),
|
||||
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Server',
|
||||
'verbose_name_plural': 'Servers',
|
||||
'permissions': [('access_server', 'Can view public status')],
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OutlineServer',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('admin_url', models.URLField(help_text='Management URL')),
|
||||
('admin_access_cert', models.CharField(help_text='Fingerprint', max_length=255)),
|
||||
('client_hostname', models.CharField(help_text='Server address for clients', max_length=255)),
|
||||
('client_port', models.CharField(help_text='Server port for clients', max_length=5)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Outline',
|
||||
'verbose_name_plural': 'Outline',
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WireguardServer',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('address', models.CharField(max_length=100)),
|
||||
('port', models.IntegerField()),
|
||||
('client_private_key', models.CharField(max_length=255)),
|
||||
('server_publick_key', models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Wireguard',
|
||||
'verbose_name_plural': 'Wireguard',
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ACL',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.server')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ACLLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('comment', models.TextField(blank=True, default='', help_text='ACL link comment, device name, etc...')),
|
||||
('link', models.CharField(blank=True, default='', help_text='Access link to get dynamic configuration', max_length=1024, null=True, unique=True, verbose_name='Access link')),
|
||||
('acl', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='vpn.acl')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='servers',
|
||||
field=models.ManyToManyField(blank=True, help_text='Servers user has access to', through='vpn.ACL', to='vpn.server'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='acl',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'server'), name='unique_user_server'),
|
||||
),
|
||||
]
|
||||
51
vpn/migrations/0002_taskexecutionlog.py
Normal file
51
vpn/migrations/0002_taskexecutionlog.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Generated manually to fix migration issue
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
"DROP TABLE IF EXISTS vpn_taskexecutionlog CASCADE;",
|
||||
reverse_sql="-- No reverse operation"
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskExecutionLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('task_id', models.CharField(help_text='Celery task ID', max_length=255)),
|
||||
('task_name', models.CharField(help_text='Task name', max_length=100)),
|
||||
('action', models.CharField(help_text='Action performed', max_length=100)),
|
||||
('status', models.CharField(choices=[('STARTED', 'Started'), ('SUCCESS', 'Success'), ('FAILURE', 'Failure'), ('RETRY', 'Retry')], default='STARTED', max_length=20)),
|
||||
('message', models.TextField(help_text='Detailed execution message')),
|
||||
('execution_time', models.FloatField(blank=True, help_text='Execution time in seconds', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('server', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.server')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Task Execution Log',
|
||||
'verbose_name_plural': 'Task Execution Logs',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
# Create indexes with safe SQL to avoid conflicts
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS vpn_taskexec_task_id_idx ON vpn_taskexecutionlog (task_id);",
|
||||
reverse_sql="DROP INDEX IF EXISTS vpn_taskexec_task_id_idx;"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS vpn_taskexec_created_idx ON vpn_taskexecutionlog (created_at);",
|
||||
reverse_sql="DROP INDEX IF EXISTS vpn_taskexec_created_idx;"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS vpn_taskexec_status_idx ON vpn_taskexecutionlog (status);",
|
||||
reverse_sql="DROP INDEX IF EXISTS vpn_taskexec_status_idx;"
|
||||
),
|
||||
]
|
||||
18
vpn/migrations/0003_acllink_last_access_time.py
Normal file
18
vpn/migrations/0003_acllink_last_access_time.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated migration for adding last_access_time field
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0002_taskexecutionlog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='acllink',
|
||||
name='last_access_time',
|
||||
field=models.DateTimeField(blank=True, help_text='Last time this link was accessed', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 01:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0002_taskexecutionlog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='outlineserver',
|
||||
options={'verbose_name': 'Outline', 'verbose_name_plural': 'Outline'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='server',
|
||||
options={'permissions': [('access_server', 'Can view public status')], 'verbose_name': 'Server', 'verbose_name_plural': 'Servers'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='wireguardserver',
|
||||
options={'verbose_name': 'Wireguard', 'verbose_name_plural': 'Wireguard'},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['user'], name='vpn_accessl_user_05a541_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['server'], name='vpn_accessl_server_0865e6_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['timestamp'], name='vpn_accessl_timesta_480a45_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['action', 'timestamp'], name='vpn_accessl_action_898948_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskexecutionlog',
|
||||
index=models.Index(fields=['task_id'], name='vpn_taskexe_task_id_e7e101_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskexecutionlog',
|
||||
index=models.Index(fields=['created_at'], name='vpn_taskexe_created_b458ed_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskexecutionlog',
|
||||
index=models.Index(fields=['status'], name='vpn_taskexe_status_2f0769_idx'),
|
||||
),
|
||||
]
|
||||
14
vpn/migrations/0004_merge_20250721_1223.py
Normal file
14
vpn/migrations/0004_merge_20250721_1223.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 09:23
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0003_acllink_last_access_time'),
|
||||
('vpn', '0003_alter_outlineserver_options_alter_server_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
45
vpn/migrations/0005_userstatistics.py
Normal file
45
vpn/migrations/0005_userstatistics.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated migration for UserStatistics model
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0004_merge_20250721_1223'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserStatistics',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('server_name', models.CharField(max_length=256)),
|
||||
('acl_link_id', models.CharField(blank=True, help_text='None for server-level stats', max_length=1024, null=True)),
|
||||
('total_connections', models.IntegerField(default=0)),
|
||||
('recent_connections', models.IntegerField(default=0)),
|
||||
('daily_usage', models.JSONField(default=list, help_text='Daily connection counts for last 30 days')),
|
||||
('max_daily', models.IntegerField(default=0)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User Statistics',
|
||||
'verbose_name_plural': 'User Statistics',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='userstatistics',
|
||||
index=models.Index(fields=['user', 'server_name'], name='vpn_usersta_user_id_1c7cd0_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='userstatistics',
|
||||
index=models.Index(fields=['updated_at'], name='vpn_usersta_updated_8e6e9b_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='userstatistics',
|
||||
unique_together={('user', 'server_name', 'acl_link_id')},
|
||||
),
|
||||
]
|
||||
22
vpn/migrations/0006_accesslog_acl_link_id.py
Normal file
22
vpn/migrations/0006_accesslog_acl_link_id.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated migration for AccessLog acl_link_id field
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0005_userstatistics'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accesslog',
|
||||
name='acl_link_id',
|
||||
field=models.CharField(blank=True, editable=False, help_text='ID of the ACL link used', max_length=1024, null=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['acl_link_id'], name='vpn_accessl_acl_lin_b23c6e_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 10:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0005_userstatistics'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='userstatistics',
|
||||
new_name='vpn_usersta_user_id_512036_idx',
|
||||
old_name='vpn_usersta_user_id_1c7cd0_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='userstatistics',
|
||||
new_name='vpn_usersta_updated_5ac650_idx',
|
||||
old_name='vpn_usersta_updated_8e6e9b_idx',
|
||||
),
|
||||
]
|
||||
14
vpn/migrations/0007_merge_20250721_1345.py
Normal file
14
vpn/migrations/0007_merge_20250721_1345.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 10:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0006_accesslog_acl_link_id'),
|
||||
('vpn', '0006_rename_vpn_usersta_user_id_1c7cd0_idx_vpn_usersta_user_id_512036_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 10:54
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0007_merge_20250721_1345'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='accesslog',
|
||||
new_name='vpn_accessl_acl_lin_9f3bc5_idx',
|
||||
old_name='vpn_accessl_acl_lin_b23c6e_idx',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-27 17:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0008_rename_vpn_accessl_acl_lin_b23c6e_idx_vpn_accessl_acl_lin_9f3bc5_idx'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayCoreServer',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('api_address', models.CharField(help_text='Xray Core API address (e.g., http://127.0.0.1:8080)', max_length=255)),
|
||||
('api_port', models.IntegerField(default=8080, help_text='API port for management interface')),
|
||||
('api_token', models.CharField(blank=True, help_text='API authentication token', max_length=255)),
|
||||
('server_address', models.CharField(help_text='Server address for clients to connect', max_length=255)),
|
||||
('server_port', models.IntegerField(default=443, help_text='Server port for client connections')),
|
||||
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('shadowsocks', 'Shadowsocks'), ('trojan', 'Trojan')], default='vless', help_text='Primary protocol for this server', max_length=20)),
|
||||
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY'), ('xtls', 'XTLS')], default='tls', help_text='Security layer configuration', max_length=20)),
|
||||
('transport', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)),
|
||||
('config_json', models.JSONField(blank=True, default=dict, help_text='Complete Xray configuration in JSON format')),
|
||||
('panel_url', models.CharField(blank=True, help_text='Web panel URL if using 3X-UI or similar management panel', max_length=255)),
|
||||
('panel_username', models.CharField(blank=True, help_text='Panel admin username', max_length=100)),
|
||||
('panel_password', models.CharField(blank=True, help_text='Panel admin password', max_length=100)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Core Server',
|
||||
'verbose_name_plural': 'Xray Core Servers',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='server',
|
||||
name='server_type',
|
||||
field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core')], editable=False, max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-28 22:34
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0009_xraycoreserver_alter_server_server_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='api_address',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='api_port',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='api_token',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='config_json',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='panel_password',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='panel_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='panel_username',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='protocol',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='security',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='server_address',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='server_port',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='transport',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='default_protocol',
|
||||
field=models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], default='vless', help_text='Default protocol for new inbounds', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='enable_stats',
|
||||
field=models.BooleanField(default=True, help_text='Enable traffic statistics tracking'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='grpc_address',
|
||||
field=models.CharField(default='127.0.0.1', help_text='Xray Core gRPC API address', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='grpc_port',
|
||||
field=models.IntegerField(default=10085, help_text='gRPC API port (usually 10085)'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='XrayInbound',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('tag', models.CharField(help_text='Unique identifier for this inbound', max_length=100)),
|
||||
('port', models.IntegerField(help_text='Port to listen on')),
|
||||
('listen', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=255)),
|
||||
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], max_length=20)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('is_default', models.BooleanField(default=False, help_text='Use this inbound for new users by default')),
|
||||
('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', max_length=20)),
|
||||
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', max_length=20)),
|
||||
('server_address', models.CharField(blank=True, help_text='Public server address for client connections (if different from listen address)', max_length=255)),
|
||||
('ss_method', models.CharField(blank=True, default='chacha20-ietf-poly1305', help_text='Shadowsocks encryption method', max_length=50)),
|
||||
('ss_password', models.CharField(blank=True, help_text='Shadowsocks password (for single-user mode)', max_length=255)),
|
||||
('tls_cert_file', models.CharField(blank=True, max_length=255)),
|
||||
('tls_key_file', models.CharField(blank=True, max_length=255)),
|
||||
('tls_alpn', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), blank=True, default=list, size=None)),
|
||||
('stream_settings', models.JSONField(blank=True, default=dict)),
|
||||
('sniffing_settings', models.JSONField(blank=True, default=dict)),
|
||||
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inbounds', to='vpn.xraycoreserver')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['port'],
|
||||
'unique_together': {('server', 'port'), ('server', 'tag')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='XrayClient',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
('email', models.CharField(help_text='Email for statistics', max_length=255)),
|
||||
('level', models.IntegerField(default=0)),
|
||||
('enable', models.BooleanField(default=True)),
|
||||
('flow', models.CharField(blank=True, help_text='VLESS flow control', max_length=50)),
|
||||
('alter_id', models.IntegerField(default=0, help_text='VMess alterId')),
|
||||
('password', models.CharField(blank=True, help_text='Password for Trojan/Shadowsocks', max_length=255)),
|
||||
('total_gb', models.IntegerField(blank=True, help_text='Traffic limit in GB', null=True)),
|
||||
('expiry_time', models.DateTimeField(blank=True, help_text='Account expiration time', null=True)),
|
||||
('up', models.BigIntegerField(default=0, help_text='Upload bytes')),
|
||||
('down', models.BigIntegerField(default=0, help_text='Download bytes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clients', to='vpn.xrayinbound')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
'unique_together': {('inbound', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
34
vpn/migrations/0011_xrayinboundproxy_and_more.py
Normal file
34
vpn/migrations/0011_xrayinboundproxy_and_more.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-31 21:52
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0010_remove_xraycoreserver_api_address_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayInboundProxy',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Inbound (Server View)',
|
||||
'verbose_name_plural': 'Xray Inbounds (Server View)',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('vpn.xrayinbound',),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='default_protocol',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xrayinbound',
|
||||
name='is_default',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-31 21:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0011_xrayinboundproxy_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayInboundServer',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('xray_inbound', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='server_proxy', to='vpn.xrayinbound')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Inbound Server',
|
||||
'verbose_name_plural': 'Xray Inbound Servers',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayInboundProxy',
|
||||
),
|
||||
]
|
||||
18
vpn/migrations/0013_add_client_hostname.py
Normal file
18
vpn/migrations/0013_add_client_hostname.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-31 22:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0012_xrayinboundserver_delete_xrayinboundproxy'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='client_hostname',
|
||||
field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections (what clients use to connect)', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-04 22:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0013_add_client_hostname'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='xraycoreserver',
|
||||
name='client_hostname',
|
||||
field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='xrayinbound',
|
||||
name='server_address',
|
||||
field=models.CharField(blank=True, help_text='Public server address for client connections', max_length=255),
|
||||
),
|
||||
]
|
||||
1
vpn/migrations/__init__.py
Normal file
1
vpn/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Migration package
|
||||
169
vpn/models.py
Normal file
169
vpn/models.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import uuid
|
||||
import logging
|
||||
from django.db import models
|
||||
from vpn.tasks import sync_user
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from .server_plugins import Server
|
||||
import shortuuid
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class UserStatistics(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE)
|
||||
server_name = models.CharField(max_length=256)
|
||||
acl_link_id = models.CharField(max_length=1024, null=True, blank=True, help_text="None for server-level stats")
|
||||
|
||||
total_connections = models.IntegerField(default=0)
|
||||
recent_connections = models.IntegerField(default=0)
|
||||
daily_usage = models.JSONField(default=list, help_text="Daily connection counts for last 30 days")
|
||||
max_daily = models.IntegerField(default=0)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['user', 'server_name', 'acl_link_id']
|
||||
verbose_name = 'User Statistics'
|
||||
verbose_name_plural = 'User Statistics'
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'server_name']),
|
||||
models.Index(fields=['updated_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
link_part = f" (link: {self.acl_link_id})" if self.acl_link_id else " (server total)"
|
||||
return f"{self.user.username} - {self.server_name}{link_part}"
|
||||
|
||||
class TaskExecutionLog(models.Model):
|
||||
task_id = models.CharField(max_length=255, help_text="Celery task ID")
|
||||
task_name = models.CharField(max_length=100, help_text="Task name")
|
||||
server = models.ForeignKey('Server', on_delete=models.SET_NULL, null=True, blank=True)
|
||||
user = models.ForeignKey('User', on_delete=models.SET_NULL, null=True, blank=True)
|
||||
action = models.CharField(max_length=100, help_text="Action performed")
|
||||
status = models.CharField(max_length=20, choices=[
|
||||
('STARTED', 'Started'),
|
||||
('SUCCESS', 'Success'),
|
||||
('FAILURE', 'Failure'),
|
||||
('RETRY', 'Retry'),
|
||||
], default='STARTED')
|
||||
message = models.TextField(help_text="Detailed execution message")
|
||||
execution_time = models.FloatField(null=True, blank=True, help_text="Execution time in seconds")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Task Execution Log'
|
||||
verbose_name_plural = 'Task Execution Logs'
|
||||
indexes = [
|
||||
models.Index(fields=['task_id']),
|
||||
models.Index(fields=['created_at']),
|
||||
models.Index(fields=['status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.task_name} - {self.action} ({self.status})"
|
||||
|
||||
|
||||
class AccessLog(models.Model):
|
||||
user = models.CharField(max_length=256, blank=True, null=True, editable=False)
|
||||
server = models.CharField(max_length=256, blank=True, null=True, editable=False)
|
||||
acl_link_id = models.CharField(max_length=1024, blank=True, null=True, editable=False, help_text="ID of the ACL link used")
|
||||
action = models.CharField(max_length=100, editable=False)
|
||||
data = models.TextField(default="", blank=True, editable=False)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['user']),
|
||||
models.Index(fields=['server']),
|
||||
models.Index(fields=['acl_link_id']),
|
||||
models.Index(fields=['timestamp']),
|
||||
models.Index(fields=['action', 'timestamp']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
link_part = f" (link: {self.acl_link_id})" if self.acl_link_id else ""
|
||||
return f"{self.action} {self.user} request for {self.server}{link_part} at {self.timestamp}"
|
||||
|
||||
class User(AbstractUser):
|
||||
#is_active = False
|
||||
comment = models.TextField(default="", blank=True, help_text="Free form user comment")
|
||||
registration_date = models.DateTimeField(auto_now_add=True, verbose_name="Created")
|
||||
servers = models.ManyToManyField('Server', through='ACL', blank=True, help_text="Servers user has access to")
|
||||
last_access = models.DateTimeField(null=True, blank=True)
|
||||
hash = models.CharField(max_length=64, unique=True, help_text="Random user hash. It's using for client config generation.")
|
||||
|
||||
def get_servers(self):
|
||||
return Server.objects.filter(acl__user=self)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.hash:
|
||||
self.hash = shortuuid.ShortUUID().random(length=16)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
|
||||
class ACL(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE)
|
||||
server = models.ForeignKey('Server', on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created")
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'server'], name='unique_user_server')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.server.name}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Check if this is a new ACL and if auto_create_link should be enabled
|
||||
is_new = self.pk is None
|
||||
auto_create_link = kwargs.pop('auto_create_link', True)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Only create default link for new ACLs when auto_create_link is True
|
||||
# This happens when ACL is created through admin interface or initial user setup
|
||||
if is_new and auto_create_link and not self.links.exists():
|
||||
ACLLink.objects.create(acl=self, link=shortuuid.ShortUUID().random(length=16))
|
||||
|
||||
@receiver(post_save, sender=ACL)
|
||||
def acl_created_or_updated(sender, instance, created, **kwargs):
|
||||
try:
|
||||
sync_user.delay_on_commit(instance.user.id, instance.server.id)
|
||||
if created:
|
||||
logger.info(f"Scheduled sync for new ACL: user {instance.user.username} on server {instance.server.name}")
|
||||
else:
|
||||
logger.info(f"Scheduled sync for updated ACL: user {instance.user.username} on server {instance.server.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule sync task for ACL {instance.id}: {e}")
|
||||
# Don't raise exception to avoid blocking ACL creation/update
|
||||
|
||||
@receiver(pre_delete, sender=ACL)
|
||||
def acl_deleted(sender, instance, **kwargs):
|
||||
try:
|
||||
sync_user.delay_on_commit(instance.user.id, instance.server.id)
|
||||
logger.info(f"Scheduled sync for deleted ACL: user {instance.user.username} on server {instance.server.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule sync task for ACL deletion {instance.id}: {e}")
|
||||
# Don't raise exception to avoid blocking ACL deletion
|
||||
|
||||
|
||||
class ACLLink(models.Model):
|
||||
acl = models.ForeignKey(ACL, related_name='links', on_delete=models.CASCADE)
|
||||
comment = models.TextField(default="", blank=True, help_text="ACL link comment, device name, etc...")
|
||||
link = models.CharField(max_length=1024, default="", unique=True, blank=True, null=True, verbose_name="Access link", help_text="Access link to get dynamic configuration")
|
||||
last_access_time = models.DateTimeField(null=True, blank=True, help_text="Last time this link was accessed")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.link == "":
|
||||
self.link = shortuuid.ShortUUID().random(length=16)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.link
|
||||
5
vpn/server_plugins/__init__.py
Normal file
5
vpn/server_plugins/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .generic import Server
|
||||
from .outline import OutlineServer, OutlineServerAdmin
|
||||
from .wireguard import WireguardServer, WireguardServerAdmin
|
||||
from .xray_core import XrayCoreServer, XrayCoreServerAdmin, XrayInbound, XrayClient, XrayInboundServer, XrayInboundServerAdmin
|
||||
from .urls import urlpatterns
|
||||
61
vpn/server_plugins/generic.py
Normal file
61
vpn/server_plugins/generic.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from polymorphic.models import PolymorphicModel
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Server(PolymorphicModel):
|
||||
SERVER_TYPE_CHOICES = (
|
||||
('Outline', 'Outline'),
|
||||
('Wireguard', 'Wireguard'),
|
||||
('xray_core', 'Xray Core'),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=100, help_text="Server name")
|
||||
comment = models.TextField(default="", blank=True)
|
||||
registration_date = models.DateTimeField(auto_now_add=True, verbose_name="Created")
|
||||
server_type = models.CharField(max_length=50, choices=SERVER_TYPE_CHOICES, editable=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Only sync if the server actually exists and is valid
|
||||
is_new = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Schedule sync task for existing servers only
|
||||
if not is_new:
|
||||
try:
|
||||
from vpn.tasks import sync_server
|
||||
sync_server.delay(self.id)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to schedule sync for server {self.name}: {e}")
|
||||
|
||||
def get_server_status(self, *args, **kwargs):
|
||||
return {"name": self.name}
|
||||
|
||||
def sync(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def sync_users(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def add_user(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get_user(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def delete_user(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Server"
|
||||
verbose_name_plural = "Servers"
|
||||
permissions = [
|
||||
("access_server", "Can view public status"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
823
vpn/server_plugins/outline.py
Normal file
823
vpn/server_plugins/outline.py
Normal file
@@ -0,0 +1,823 @@
|
||||
import logging
|
||||
import json
|
||||
import requests
|
||||
from django.db import models
|
||||
from django.shortcuts import render, redirect
|
||||
from django.conf import settings
|
||||
from .generic import Server
|
||||
from urllib3 import PoolManager
|
||||
from outline_vpn.outline_vpn import OutlineVPN, OutlineServerErrorException
|
||||
from polymorphic.admin import PolymorphicChildModelAdmin
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
class OutlineConnectionError(Exception):
|
||||
def __init__(self, message, original_exception=None):
|
||||
super().__init__(message)
|
||||
self.original_exception = original_exception
|
||||
|
||||
class _FingerprintAdapter(requests.adapters.HTTPAdapter):
|
||||
"""
|
||||
This adapter injected into the requests session will check that the
|
||||
fingerprint for the certificate matches for every request
|
||||
"""
|
||||
|
||||
def __init__(self, fingerprint=None, **kwargs):
|
||||
self.fingerprint = str(fingerprint)
|
||||
super(_FingerprintAdapter, self).__init__(**kwargs)
|
||||
|
||||
def init_poolmanager(self, connections, maxsize, block=False):
|
||||
self.poolmanager = PoolManager(
|
||||
num_pools=connections,
|
||||
maxsize=maxsize,
|
||||
block=block,
|
||||
assert_fingerprint=self.fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class OutlineServer(Server):
|
||||
admin_url = models.URLField(help_text="Management URL")
|
||||
admin_access_cert = models.CharField(max_length=255, help_text="Fingerprint")
|
||||
client_hostname = models.CharField(max_length=255, help_text="Server address for clients")
|
||||
client_port = models.CharField(max_length=5, help_text="Server port for clients")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Outline'
|
||||
verbose_name_plural = 'Outline'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.server_type = 'Outline'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self.get_server_status(raw=True)
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
return OutlineVPN(api_url=self.admin_url, cert_sha256=self.admin_access_cert)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.client_hostname}:{self.client_port})"
|
||||
|
||||
def get_server_status(self, raw=False):
|
||||
status = {}
|
||||
|
||||
try:
|
||||
info = self.client.get_server_information()
|
||||
if raw:
|
||||
status = info
|
||||
else:
|
||||
keys = self.client.get_keys()
|
||||
status.update(info)
|
||||
status.update({"keys": len(keys)})
|
||||
status["all_keys"] = []
|
||||
for key in keys:
|
||||
status["all_keys"].append(key.key_id)
|
||||
except Exception as e:
|
||||
status.update({f"error": e})
|
||||
return status
|
||||
|
||||
def sync_users(self):
|
||||
from vpn.models import User, ACL
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"[{self.name}] Sync all users")
|
||||
|
||||
try:
|
||||
keys = self.client.get_keys()
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Failed to get keys from server: {e}")
|
||||
return False
|
||||
|
||||
acls = ACL.objects.filter(server=self)
|
||||
acl_users = set(acl.user for acl in acls)
|
||||
|
||||
# Log user synchronization details
|
||||
user_list = ", ".join([user.username for user in acl_users])
|
||||
logger.info(f"[{self.name}] Syncing {len(acl_users)} users: {user_list[:200]}{'...' if len(user_list) > 200 else ''}")
|
||||
|
||||
for user in User.objects.all():
|
||||
if user in acl_users:
|
||||
try:
|
||||
result = self.add_user(user=user)
|
||||
logger.debug(f"[{self.name}] Added user {user.username}: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Failed to add user {user.username}: {e}")
|
||||
else:
|
||||
try:
|
||||
result = self.delete_user(user=user)
|
||||
if result and 'status' in result and 'deleted' in result['status']:
|
||||
logger.debug(f"[{self.name}] Removed user {user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Failed to remove user {user.username}: {e}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def sync(self):
|
||||
status = {}
|
||||
try:
|
||||
state = self.client.get_server_information()
|
||||
if state["name"] != self.name:
|
||||
self.client.set_server_name(self.name)
|
||||
status["name"] = f"{state['name']} -> {self.name}"
|
||||
elif state["hostnameForAccessKeys"] != self.client_hostname:
|
||||
self.client.set_hostname(self.client_hostname)
|
||||
status["hostnameForAccessKeys"] = f"{state['hostnameForAccessKeys']} -> {self.client_hostname}"
|
||||
elif int(state["portForNewAccessKeys"]) != int(self.client_port):
|
||||
self.client.set_port_new_for_access_keys(int(self.client_port))
|
||||
status["portForNewAccessKeys"] = f"{state['portForNewAccessKeys']} -> {self.client_port}"
|
||||
if len(status) == 0:
|
||||
status = {"status": "Nothing to do"}
|
||||
return status
|
||||
except AttributeError as e:
|
||||
raise OutlineConnectionError("Client error. Can't connect.", original_exception=e)
|
||||
|
||||
def _get_key(self, user):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"[{self.name}] Looking for key for user {user.username}")
|
||||
try:
|
||||
# Try to get key by username first
|
||||
result = self.client.get_key(str(user.username))
|
||||
logger.debug(f"[{self.name}] Found key for user {user.username} by username")
|
||||
return result
|
||||
except OutlineServerErrorException:
|
||||
# If not found by username, search by password (hash)
|
||||
logger.debug(f"[{self.name}] Key not found by username, searching by password")
|
||||
try:
|
||||
keys = self.client.get_keys()
|
||||
for key in keys:
|
||||
if key.password == user.hash:
|
||||
logger.debug(f"[{self.name}] Found key for user {user.username} by password match")
|
||||
return key
|
||||
# No key found
|
||||
logger.debug(f"[{self.name}] No key found for user {user.username}")
|
||||
raise OutlineServerErrorException(f"Key not found for user {user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Error searching for key for user {user.username}: {e}")
|
||||
raise OutlineServerErrorException(f"Error searching for key: {e}")
|
||||
|
||||
def get_user(self, user, raw=False):
|
||||
try:
|
||||
user_info = self._get_key(user)
|
||||
if raw:
|
||||
return user_info
|
||||
else:
|
||||
outline_key_dict = user_info.__dict__
|
||||
|
||||
outline_key_dict = {
|
||||
key: value
|
||||
for key, value in user_info.__dict__.items()
|
||||
if not key.startswith('_') and key not in [] # fields to mask
|
||||
}
|
||||
return outline_key_dict
|
||||
except OutlineServerErrorException as e:
|
||||
# If user key not found, try to create it automatically
|
||||
if "Key not found" in str(e):
|
||||
self.logger.warning(f"[{self.name}] Key not found for user {user.username}, attempting to create")
|
||||
try:
|
||||
self.add_user(user)
|
||||
# Try to get the key again after creation
|
||||
user_info = self._get_key(user)
|
||||
if raw:
|
||||
return user_info
|
||||
else:
|
||||
outline_key_dict = {
|
||||
key: value
|
||||
for key, value in user_info.__dict__.items()
|
||||
if not key.startswith('_') and key not in []
|
||||
}
|
||||
return outline_key_dict
|
||||
except Exception as create_error:
|
||||
self.logger.error(f"[{self.name}] Failed to create missing key for user {user.username}: {create_error}")
|
||||
raise OutlineServerErrorException(f"Failed to get credentials: {e}")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def add_user(self, user):
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
server_user = self._get_key(user)
|
||||
except OutlineServerErrorException as e:
|
||||
server_user = None
|
||||
|
||||
logger.debug(f"[{self.name}] User {str(server_user)}")
|
||||
|
||||
result = {}
|
||||
key = None
|
||||
|
||||
if server_user:
|
||||
# Check if user needs update - but don't delete immediately
|
||||
needs_update = (
|
||||
server_user.method != "chacha20-ietf-poly1305" or
|
||||
server_user.name != user.username or
|
||||
server_user.password != user.hash
|
||||
# Don't check port as Outline can assign different ports automatically
|
||||
)
|
||||
|
||||
if needs_update:
|
||||
# Delete old key before creating new one
|
||||
try:
|
||||
self.client.delete_key(server_user.key_id)
|
||||
logger.debug(f"[{self.name}] Deleted outdated key for user {user.username}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.name}] Failed to delete old key for user {user.username}: {e}")
|
||||
|
||||
# Create new key with correct parameters
|
||||
try:
|
||||
key = self.client.create_key(
|
||||
key_id=user.username,
|
||||
name=user.username,
|
||||
method="chacha20-ietf-poly1305",
|
||||
password=user.hash,
|
||||
data_limit=None
|
||||
# Don't specify port - let server assign automatically
|
||||
)
|
||||
logger.info(f"[{self.name}] User {user.username} updated")
|
||||
except OutlineServerErrorException as e:
|
||||
raise OutlineConnectionError(f"Failed to create updated key for user {user.username}", original_exception=e)
|
||||
else:
|
||||
# User exists and is up to date
|
||||
key = server_user
|
||||
logger.debug(f"[{self.name}] User {user.username} already up to date")
|
||||
else:
|
||||
# User doesn't exist, create new key
|
||||
try:
|
||||
key = self.client.create_key(
|
||||
key_id=user.username,
|
||||
name=user.username,
|
||||
method="chacha20-ietf-poly1305",
|
||||
password=user.hash,
|
||||
data_limit=None
|
||||
# Don't specify port - let server assign automatically
|
||||
)
|
||||
logger.info(f"[{self.name}] User {user.username} created")
|
||||
except OutlineServerErrorException as e:
|
||||
error_message = str(e)
|
||||
if "code\":\"Conflict" in error_message:
|
||||
logger.warning(f"[{self.name}] Conflict for User {user.username}, trying to resolve. {error_message}")
|
||||
# Find conflicting key by password and remove it
|
||||
try:
|
||||
for existing_key in self.client.get_keys():
|
||||
if existing_key.password == user.hash:
|
||||
logger.warning(f"[{self.name}] Found conflicting key {existing_key.key_id} with same password")
|
||||
self.client.delete_key(existing_key.key_id)
|
||||
break
|
||||
# Try to create again after cleanup
|
||||
return self.add_user(user)
|
||||
except Exception as cleanup_error:
|
||||
logger.error(f"[{self.name}] Failed to resolve conflict for user {user.username}: {cleanup_error}")
|
||||
raise OutlineConnectionError(f"Conflict resolution failed for user {user.username}", original_exception=e)
|
||||
else:
|
||||
raise OutlineConnectionError("API Error", original_exception=e)
|
||||
|
||||
# Build result from key object
|
||||
try:
|
||||
if key:
|
||||
result = {
|
||||
'key_id': key.key_id,
|
||||
'name': key.name,
|
||||
'method': key.method,
|
||||
'password': key.password,
|
||||
'data_limit': key.data_limit,
|
||||
'port': key.port
|
||||
}
|
||||
else:
|
||||
result = {"error": "No key object returned"}
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Error building result for user {user.username}: {e}")
|
||||
result = {"error": str(e)}
|
||||
|
||||
return result
|
||||
|
||||
def delete_user(self, user):
|
||||
result = None
|
||||
try:
|
||||
server_user = self._get_key(user)
|
||||
except OutlineServerErrorException as e:
|
||||
return {"status": "User not found on server. Nothing to do."}
|
||||
|
||||
if server_user:
|
||||
self.logger.info(f"Deleting key with key_id: {server_user.key_id}")
|
||||
self.client.delete_key(server_user.key_id)
|
||||
result = {"status": "User was deleted"}
|
||||
self.logger.info(f"[{self.name}] User deleted: {user.username} on server {self.name}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
class OutlineServerAdmin(PolymorphicChildModelAdmin):
|
||||
base_model = OutlineServer
|
||||
show_in_index = False
|
||||
list_display = (
|
||||
'name',
|
||||
'admin_url',
|
||||
'admin_access_cert',
|
||||
'client_hostname',
|
||||
'client_port',
|
||||
'user_count',
|
||||
'server_status_inline',
|
||||
)
|
||||
readonly_fields = ('server_status_full', 'registration_date', 'export_configuration_display', 'server_statistics_display', 'recent_activity_display', 'json_import_field')
|
||||
list_editable = ('admin_url', 'admin_access_cert', 'client_hostname', 'client_port',)
|
||||
exclude = ('server_type',)
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
"""Customize fieldsets based on whether object exists"""
|
||||
if obj is None: # Adding new server
|
||||
return (
|
||||
('JSON Import', {
|
||||
'fields': ('json_import_field',),
|
||||
'description': 'Quick import from Outline server JSON configuration'
|
||||
}),
|
||||
('Server Configuration', {
|
||||
'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port')
|
||||
}),
|
||||
)
|
||||
else: # Editing existing server
|
||||
return (
|
||||
('Server Configuration', {
|
||||
'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port', 'registration_date')
|
||||
}),
|
||||
('Server Status', {
|
||||
'fields': ('server_status_full',)
|
||||
}),
|
||||
('Export Configuration', {
|
||||
'fields': ('export_configuration_display',)
|
||||
}),
|
||||
('Statistics & Users', {
|
||||
'fields': ('server_statistics_display',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Recent Activity', {
|
||||
'fields': ('recent_activity_display',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def get_urls(self):
|
||||
from django.urls import path
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<int:object_id>/sync/', self.admin_site.admin_view(self.sync_server_view), name='outlineserver_sync'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
@admin.display(description='Clients', ordering='user_count')
|
||||
def user_count(self, obj):
|
||||
return obj.user_count
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(user_count=Count('acl__user'))
|
||||
return qs
|
||||
|
||||
def server_status_inline(self, obj):
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
# Преобразуем JSON в красивый формат
|
||||
import json
|
||||
pretty_status = json.dumps(status, indent=4)
|
||||
return mark_safe(f"<pre>{pretty_status}</pre>")
|
||||
server_status_inline.short_description = "Status"
|
||||
|
||||
def server_status_full(self, obj):
|
||||
if obj and obj.pk:
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
import json
|
||||
pretty_status = json.dumps(status, indent=4)
|
||||
return mark_safe(f"<pre>{pretty_status}</pre>")
|
||||
return "N/A"
|
||||
|
||||
server_status_full.short_description = "Server Status"
|
||||
|
||||
def sync_server_view(self, request, object_id):
|
||||
"""AJAX view to sync server settings"""
|
||||
from django.http import JsonResponse
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
server = OutlineServer.objects.get(pk=object_id)
|
||||
result = server.sync()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': f'Server "{server.name}" synchronized successfully',
|
||||
'details': result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
"""Use the default Django admin add view"""
|
||||
return super().add_view(request, form_url, extra_context)
|
||||
|
||||
@admin.display(description='Import JSON Configuration')
|
||||
def json_import_field(self, obj):
|
||||
"""Display JSON import field for new servers only"""
|
||||
if obj and obj.pk:
|
||||
# Hide for existing servers
|
||||
return ''
|
||||
|
||||
html = '''
|
||||
<div style="width: 100%;">
|
||||
<textarea id="import-json-config" class="vLargeTextField" rows="8"
|
||||
placeholder='{
|
||||
"apiUrl": "https://your-server:port/path",
|
||||
"certSha256": "your-certificate-hash",
|
||||
"serverName": "My Outline Server",
|
||||
"clientHostname": "your-server.com",
|
||||
"clientPort": 1257,
|
||||
"comment": "Server description"
|
||||
}' style="font-family: 'Courier New', monospace; font-size: 0.875rem; width: 100%;"></textarea>
|
||||
<div class="help" style="margin-top: 0.5rem;">
|
||||
Paste JSON configuration from your Outline server setup to automatically fill the fields below.
|
||||
</div>
|
||||
<div style="margin-top: 1rem;">
|
||||
<button type="button" id="import-json-btn" class="btn btn-primary" onclick="importJsonConfig()">Import Configuration</button>
|
||||
</div>
|
||||
<script>
|
||||
function importJsonConfig() {
|
||||
const textarea = document.getElementById('import-json-config');
|
||||
try {
|
||||
const jsonText = textarea.value.trim();
|
||||
if (!jsonText) {
|
||||
alert('Please enter JSON configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(jsonText);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.apiUrl || !config.certSha256) {
|
||||
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse apiUrl to extract components
|
||||
const url = new URL(config.apiUrl);
|
||||
|
||||
// Fill form fields
|
||||
const adminUrlField = document.getElementById('id_admin_url');
|
||||
const adminCertField = document.getElementById('id_admin_access_cert');
|
||||
const clientHostnameField = document.getElementById('id_client_hostname');
|
||||
const clientPortField = document.getElementById('id_client_port');
|
||||
const nameField = document.getElementById('id_name');
|
||||
const commentField = document.getElementById('id_comment');
|
||||
|
||||
if (adminUrlField) adminUrlField.value = config.apiUrl;
|
||||
if (adminCertField) adminCertField.value = config.certSha256;
|
||||
|
||||
// Use provided hostname or extract from URL
|
||||
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
|
||||
if (clientHostnameField) clientHostnameField.value = hostname;
|
||||
|
||||
// Use provided port or extract from various sources
|
||||
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
|
||||
if (clientPortField) clientPortField.value = clientPort;
|
||||
|
||||
// Generate server name if not provided and field is empty
|
||||
if (nameField && !nameField.value) {
|
||||
const serverName = config.serverName || config.name || 'Outline-' + hostname;
|
||||
nameField.value = serverName;
|
||||
}
|
||||
|
||||
// Fill comment if provided and field exists
|
||||
if (commentField && config.comment) {
|
||||
commentField.value = config.comment;
|
||||
}
|
||||
|
||||
// Clear the JSON input
|
||||
textarea.value = '';
|
||||
|
||||
alert('Configuration imported successfully! Review the fields below and save.');
|
||||
|
||||
// Click on Server Configuration tab if using Jazzmin
|
||||
const serverTab = document.querySelector('a[href="#server-configuration-tab"]');
|
||||
if (serverTab) {
|
||||
serverTab.click();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
alert('Invalid JSON format: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Add paste event listener
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const textarea = document.getElementById('import-json-config');
|
||||
if (textarea) {
|
||||
textarea.addEventListener('paste', function(e) {
|
||||
setTimeout(importJsonConfig, 100);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
'''
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
@admin.display(description='Server Statistics & Users')
|
||||
def server_statistics_display(self, obj):
|
||||
"""Display server statistics and user management"""
|
||||
if not obj or not obj.pk:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic;">Statistics will be available after saving</div>')
|
||||
|
||||
try:
|
||||
from vpn.models import ACL, AccessLog, UserStatistics
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
# Get user statistics
|
||||
user_count = ACL.objects.filter(server=obj).count()
|
||||
total_links = 0
|
||||
server_keys_count = 0
|
||||
|
||||
try:
|
||||
from vpn.models import ACLLink
|
||||
total_links = ACLLink.objects.filter(acl__server=obj).count()
|
||||
|
||||
# Try to get actual keys count from server
|
||||
server_status = obj.get_server_status()
|
||||
if 'keys' in server_status:
|
||||
server_keys_count = server_status['keys']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get active users count (last 30 days)
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
active_users_count = UserStatistics.objects.filter(
|
||||
server_name=obj.name,
|
||||
recent_connections__gt=0
|
||||
).values('user').distinct().count()
|
||||
|
||||
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: 1rem;">'
|
||||
|
||||
# Overall Statistics
|
||||
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
|
||||
html += '<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
|
||||
html += f'<div><strong>Total Users:</strong> {user_count}</div>'
|
||||
html += f'<div><strong>Active Users (30d):</strong> {active_users_count}</div>'
|
||||
html += f'<div><strong>Total Links:</strong> {total_links}</div>'
|
||||
html += f'<div><strong>Server Keys:</strong> {server_keys_count}</div>'
|
||||
html += '</div>'
|
||||
html += '<div style="margin-top: 8px; font-size: 11px; color: #6c757d;">'
|
||||
html += '📊 User activity data is from cached statistics for fast loading. Status indicators show usage patterns.'
|
||||
html += '</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Get users data with ACL information
|
||||
acls = ACL.objects.filter(server=obj).select_related('user').prefetch_related('links')
|
||||
|
||||
if acls:
|
||||
html += '<h5 style="color: #495057; margin: 16px 0 8px 0;">👥 Users with Access</h5>'
|
||||
|
||||
for acl in acls:
|
||||
user = acl.user
|
||||
links = list(acl.links.all())
|
||||
|
||||
# Get last access time from any link
|
||||
last_access = None
|
||||
for link in links:
|
||||
if link.last_access_time:
|
||||
if last_access is None or link.last_access_time > last_access:
|
||||
last_access = link.last_access_time
|
||||
|
||||
# Use cached statistics instead of live server check for performance
|
||||
user_stats = UserStatistics.objects.filter(user=user, server_name=obj.name)
|
||||
server_key_status = "unknown"
|
||||
total_user_connections = 0
|
||||
recent_user_connections = 0
|
||||
|
||||
if user_stats.exists():
|
||||
# User has cached data, likely has server access
|
||||
total_user_connections = sum(stat.total_connections for stat in user_stats)
|
||||
recent_user_connections = sum(stat.recent_connections for stat in user_stats)
|
||||
|
||||
if total_user_connections > 0:
|
||||
server_key_status = "cached_active"
|
||||
else:
|
||||
server_key_status = "cached_inactive"
|
||||
else:
|
||||
# No cached data - either new user or no access
|
||||
server_key_status = "no_cache"
|
||||
|
||||
html += '<div style="background: #ffffff; border: 1px solid #e9ecef; border-radius: 0.25rem; padding: 0.75rem; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center;">'
|
||||
|
||||
# User info section
|
||||
html += '<div style="flex: 1;">'
|
||||
html += f'<div style="font-weight: 500; font-size: 14px; color: #495057;">{user.username}'
|
||||
if user.comment:
|
||||
html += f' <span style="color: #6c757d; font-size: 12px; font-weight: normal;">- {user.comment}</span>'
|
||||
html += '</div>'
|
||||
html += f'<div style="font-size: 12px; color: #6c757d;">{len(links)} link(s)'
|
||||
if last_access:
|
||||
from django.utils.timezone import localtime
|
||||
local_time = localtime(last_access)
|
||||
html += f' | Last access: {local_time.strftime("%Y-%m-%d %H:%M")}'
|
||||
else:
|
||||
html += ' | Never accessed'
|
||||
|
||||
# Add usage statistics inside the same div
|
||||
if total_user_connections > 0:
|
||||
html += f' | {total_user_connections} total uses'
|
||||
if recent_user_connections > 0:
|
||||
html += f' ({recent_user_connections} recent)'
|
||||
|
||||
html += '</div>' # End user info
|
||||
html += '</div>' # End flex-1 div
|
||||
|
||||
# Status and actions section
|
||||
html += '<div style="display: flex; gap: 8px; align-items: center;">'
|
||||
|
||||
# Status indicator based on cached data
|
||||
if server_key_status == "cached_active":
|
||||
html += '<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">📊 Active User</span>'
|
||||
elif server_key_status == "cached_inactive":
|
||||
html += '<span style="background: #fff3cd; color: #856404; padding: 2px 6px; border-radius: 3px; font-size: 10px;">📊 Inactive</span>'
|
||||
else:
|
||||
html += '<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">❓ No Data</span>'
|
||||
|
||||
html += f'<a href="/admin/vpn/user/{user.id}/change/" class="btn btn-sm btn-outline-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 0.2rem; margin: 0 0.1rem;">👤 Edit</a>'
|
||||
html += '</div>' # End actions div
|
||||
html += '</div>' # End main user div
|
||||
else:
|
||||
html += '<div style="color: #6c757d; font-style: italic; text-align: center; padding: 20px; background: #f9fafb; border-radius: 6px;">'
|
||||
html += 'No users assigned to this server'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<div style="color: #dc3545;">Error loading statistics: {e}</div>')
|
||||
|
||||
@admin.display(description='Export Configuration')
|
||||
def export_configuration_display(self, obj):
|
||||
"""Display JSON export configuration"""
|
||||
if not obj or not obj.pk:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic;">Export will be available after saving</div>')
|
||||
|
||||
try:
|
||||
# Build export data
|
||||
export_data = {
|
||||
'apiUrl': obj.admin_url,
|
||||
'certSha256': obj.admin_access_cert,
|
||||
'serverName': obj.name,
|
||||
'clientHostname': obj.client_hostname,
|
||||
'clientPort': int(obj.client_port),
|
||||
'comment': obj.comment,
|
||||
'serverType': 'outline',
|
||||
'dateCreated': obj.registration_date.isoformat() if obj.registration_date else None,
|
||||
'id': obj.id
|
||||
}
|
||||
|
||||
# Try to get server status
|
||||
try:
|
||||
server_status = obj.get_server_status()
|
||||
if 'error' not in server_status:
|
||||
export_data['serverInfo'] = server_status
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
json_str = json.dumps(export_data, indent=2)
|
||||
# Escape the JSON for HTML
|
||||
from django.utils.html import escape
|
||||
escaped_json = escape(json_str)
|
||||
|
||||
html = '''
|
||||
<div>
|
||||
<textarea id="export-json-config" class="vLargeTextField" rows="10" readonly
|
||||
style="font-family: 'Courier New', monospace; font-size: 0.875rem; background-color: #f8f9fa; width: 100%;">''' + escaped_json + '''</textarea>
|
||||
<div class="help" style="margin-top: 0.5rem;">
|
||||
<strong>Includes:</strong> Server settings, connection details, live server info (if accessible), creation date, and comment.
|
||||
</div>
|
||||
<div style="padding-top: 1rem;">
|
||||
<button type="button" id="copy-export-btn" class="btn btn-sm btn-secondary"
|
||||
onclick="var btn=this; document.getElementById('export-json-config').select(); document.execCommand('copy'); btn.innerHTML='✅ Copied!'; setTimeout(function(){btn.innerHTML='📋 Copy JSON';}, 2000);"
|
||||
style="margin-right: 10px;">📋 Copy JSON</button>
|
||||
<button type="button" id="sync-server-btn" data-server-id="''' + str(obj.id) + '''" class="btn btn-sm btn-primary">🔄 Sync Server Settings</button>
|
||||
<span style="margin-left: 0.5rem; font-size: 0.875rem; color: #6c757d;">
|
||||
Synchronize server name, hostname, and port settings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<div style="color: #dc3545;">Error generating export: {e}</div>')
|
||||
|
||||
@admin.display(description='Recent Activity')
|
||||
def recent_activity_display(self, obj):
|
||||
"""Display recent activity in admin-friendly format"""
|
||||
if not obj or not obj.pk:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic;">Activity will be available after saving</div>')
|
||||
|
||||
try:
|
||||
from vpn.models import AccessLog
|
||||
from django.utils.timezone import localtime
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
# Get recent access logs for this server (last 7 days)
|
||||
seven_days_ago = timezone.now() - timedelta(days=7)
|
||||
recent_logs = AccessLog.objects.filter(
|
||||
server=obj.name,
|
||||
timestamp__gte=seven_days_ago
|
||||
).order_by('-timestamp')[:20]
|
||||
|
||||
if not recent_logs:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic; padding: 12px; background: #f8f9fa; border-radius: 4px;">No recent activity (last 7 days)</div>')
|
||||
|
||||
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 0; max-height: 300px; overflow-y: auto;">'
|
||||
|
||||
# Header
|
||||
html += '<div style="background: #e9ecef; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600; font-size: 12px; color: #495057;">'
|
||||
html += f'📊 Access Log ({recent_logs.count()} entries, last 7 days)'
|
||||
html += '</div>'
|
||||
|
||||
# Activity entries
|
||||
for i, log in enumerate(recent_logs):
|
||||
bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa'
|
||||
local_time = localtime(log.timestamp)
|
||||
|
||||
# Status icon and color
|
||||
if log.action == 'Success':
|
||||
icon = '✅'
|
||||
status_color = '#28a745'
|
||||
elif log.action == 'Failed':
|
||||
icon = '❌'
|
||||
status_color = '#dc3545'
|
||||
else:
|
||||
icon = 'ℹ️'
|
||||
status_color = '#6c757d'
|
||||
|
||||
html += f'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f1f3f4; background: {bg_color};">'
|
||||
|
||||
# Left side - user and link info
|
||||
html += '<div style="display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0;">'
|
||||
html += f'<span style="color: {status_color}; font-size: 14px;">{icon}</span>'
|
||||
html += '<div style="overflow: hidden;">'
|
||||
html += f'<div style="font-weight: 500; font-size: 12px; color: #495057; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{log.user}</div>'
|
||||
|
||||
if log.acl_link_id:
|
||||
link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id
|
||||
html += f'<div style="font-family: monospace; font-size: 10px; color: #6c757d;">{link_short}</div>'
|
||||
|
||||
html += '</div></div>'
|
||||
|
||||
# Right side - timestamp and status
|
||||
html += '<div style="text-align: right; flex-shrink: 0;">'
|
||||
html += f'<div style="font-size: 10px; color: #6c757d;">{local_time.strftime("%m-%d %H:%M")}</div>'
|
||||
html += f'<div style="font-size: 9px; color: {status_color}; font-weight: 500;">{log.action}</div>'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
|
||||
# Footer with summary if there are more entries
|
||||
total_recent = AccessLog.objects.filter(
|
||||
server=obj.name,
|
||||
timestamp__gte=seven_days_ago
|
||||
).count()
|
||||
|
||||
if total_recent > 20:
|
||||
html += f'<div style="background: #e9ecef; padding: 6px 12px; font-size: 11px; color: #6c757d; text-align: center;">'
|
||||
html += f'Showing 20 of {total_recent} entries from last 7 days'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<div style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</div>')
|
||||
|
||||
def get_model_perms(self, request):
|
||||
"""It disables display for sub-model"""
|
||||
return {}
|
||||
|
||||
class Media:
|
||||
js = ('admin/js/generate_link.js',)
|
||||
css = {'all': ('admin/css/vpn_admin.css',)}
|
||||
|
||||
admin.site.register(OutlineServer, OutlineServerAdmin)
|
||||
6
vpn/server_plugins/urls.py
Normal file
6
vpn/server_plugins/urls.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.urls import path
|
||||
from vpn.views import shadowsocks
|
||||
|
||||
urlpatterns = [
|
||||
path('ss/<str:hash_value>/', shadowsocks, name='shadowsocks'),
|
||||
]
|
||||
83
vpn/server_plugins/wireguard.py
Normal file
83
vpn/server_plugins/wireguard.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from .generic import Server
|
||||
from django.db import models
|
||||
from polymorphic.admin import (
|
||||
PolymorphicChildModelAdmin,
|
||||
)
|
||||
from django.contrib import admin
|
||||
from django.db.models import Count
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
class WireguardServer(Server):
|
||||
address = models.CharField(max_length=100)
|
||||
port = models.IntegerField()
|
||||
client_private_key = models.CharField(max_length=255)
|
||||
server_publick_key = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Wireguard'
|
||||
verbose_name_plural = 'Wireguard'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.server_type = 'Wireguard'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.address})"
|
||||
|
||||
def get_server_status(self):
|
||||
status = super().get_server_status()
|
||||
status.update({
|
||||
"address": self.address,
|
||||
"port": self.port,
|
||||
"client_private_key": self.client_private_key,
|
||||
"server_publick_key": self.server_publick_key,
|
||||
})
|
||||
return status
|
||||
|
||||
class WireguardServerAdmin(PolymorphicChildModelAdmin):
|
||||
base_model = WireguardServer
|
||||
show_in_index = False # Не отображать в главном списке админки
|
||||
list_display = (
|
||||
'name',
|
||||
'address',
|
||||
'port',
|
||||
'server_publick_key',
|
||||
'client_private_key',
|
||||
'server_status_inline',
|
||||
'user_count',
|
||||
'registration_date'
|
||||
)
|
||||
readonly_fields = ('server_status_full', )
|
||||
exclude = ('server_type',)
|
||||
|
||||
@admin.display(description='Clients', ordering='user_count')
|
||||
def user_count(self, obj):
|
||||
return obj.user_count
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(user_count=Count('acl__user'))
|
||||
return qs
|
||||
|
||||
def server_status_inline(self, obj):
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
return mark_safe(f"<pre>{status}</pre>")
|
||||
server_status_inline.short_description = "Server Status"
|
||||
|
||||
def server_status_full(self, obj):
|
||||
if obj and obj.pk:
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
return mark_safe(f"<pre>{status}</pre>")
|
||||
return "N/A"
|
||||
|
||||
server_status_full.short_description = "Server Status"
|
||||
|
||||
def get_model_perms(self, request):
|
||||
"""It disables display for sub-model"""
|
||||
return {}
|
||||
|
||||
admin.site.register(WireguardServer, WireguardServerAdmin)
|
||||
2109
vpn/server_plugins/xray_core.py
Normal file
2109
vpn/server_plugins/xray_core.py
Normal file
File diff suppressed because it is too large
Load Diff
569
vpn/tasks.py
Normal file
569
vpn/tasks.py
Normal file
@@ -0,0 +1,569 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from celery import group, shared_task
|
||||
from celery.exceptions import Retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def create_task_log(task_id, task_name, action, status='STARTED', server=None, user=None, message='', execution_time=None):
|
||||
"""Helper function to create task execution log"""
|
||||
try:
|
||||
from .models import TaskExecutionLog
|
||||
TaskExecutionLog.objects.create(
|
||||
task_id=task_id,
|
||||
task_name=task_name,
|
||||
server=server,
|
||||
user=user,
|
||||
action=action,
|
||||
status=status,
|
||||
message=message,
|
||||
execution_time=execution_time
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't fail tasks if logging fails - just log to console
|
||||
logger.error(f"Failed to create task log (task_id: {task_id}, action: {action}): {e}")
|
||||
# If table doesn't exist, just continue without logging to DB
|
||||
if "does not exist" in str(e):
|
||||
logger.info(f"TaskExecutionLog table not found - run migrations. Task: {task_name}, Action: {action}, Status: {status}")
|
||||
|
||||
@shared_task(name="cleanup_task_logs")
|
||||
def cleanup_task_logs():
|
||||
"""Clean up old task execution logs (older than 30 days)"""
|
||||
from .models import TaskExecutionLog
|
||||
|
||||
try:
|
||||
cutoff_date = datetime.now() - timedelta(days=30)
|
||||
old_logs = TaskExecutionLog.objects.filter(created_at__lt=cutoff_date)
|
||||
count = old_logs.count()
|
||||
|
||||
if count > 0:
|
||||
old_logs.delete()
|
||||
logger.info(f"Cleaned up {count} old task execution logs")
|
||||
return f"Cleaned up {count} old task execution logs"
|
||||
else:
|
||||
logger.info("No old task execution logs to clean up")
|
||||
return "No old task execution logs to clean up"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up task logs: {e}")
|
||||
return f"Error cleaning up task logs: {e}"
|
||||
|
||||
|
||||
@shared_task(name="sync_xray_inbounds", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 30})
|
||||
def sync_xray_inbounds(self, server_id):
|
||||
"""Stage 1: Sync inbounds for Xray server."""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
server = None
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
|
||||
if not isinstance(server.get_real_instance(), XrayCoreServer):
|
||||
error_message = f"Server {server.name} is not an Xray server"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_xray_inbounds", "Wrong server type", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
return {"error": error_message}
|
||||
|
||||
create_task_log(task_id, "sync_xray_inbounds", f"Starting inbound sync for {server.name}", 'STARTED', server=server)
|
||||
logger.info(f"Starting inbound sync for Xray server {server.name}")
|
||||
|
||||
real_server = server.get_real_instance()
|
||||
inbound_result = real_server.sync_inbounds()
|
||||
|
||||
success_message = f"Successfully synced inbounds for {server.name}"
|
||||
logger.info(f"{success_message}. Result: {inbound_result}")
|
||||
|
||||
create_task_log(task_id, "sync_xray_inbounds", "Inbound sync completed", 'SUCCESS', server=server, message=f"{success_message}. Result: {inbound_result}", execution_time=time.time() - start_time)
|
||||
|
||||
return inbound_result
|
||||
|
||||
except Server.DoesNotExist:
|
||||
error_message = f"Server with id {server_id} not found"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_xray_inbounds", "Server not found", 'FAILURE', message=error_message, execution_time=time.time() - start_time)
|
||||
return {"error": error_message}
|
||||
except Exception as e:
|
||||
error_message = f"Error syncing inbounds for {server.name if server else server_id}: {e}"
|
||||
logger.error(error_message)
|
||||
|
||||
if self.request.retries < 3:
|
||||
retry_message = f"Retrying inbound sync for {server.name if server else server_id} (attempt {self.request.retries + 1})"
|
||||
logger.info(retry_message)
|
||||
create_task_log(task_id, "sync_xray_inbounds", "Retrying inbound sync", 'RETRY', server=server, message=retry_message)
|
||||
raise self.retry(countdown=30)
|
||||
|
||||
create_task_log(task_id, "sync_xray_inbounds", "Inbound sync failed after retries", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
return {"error": error_message}
|
||||
|
||||
|
||||
@shared_task(name="sync_xray_users", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 30})
|
||||
def sync_xray_users(self, server_id):
|
||||
"""Stage 2: Sync users for Xray server."""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
server = None
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
|
||||
if not isinstance(server.get_real_instance(), XrayCoreServer):
|
||||
error_message = f"Server {server.name} is not an Xray server"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_xray_users", "Wrong server type", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
return {"error": error_message}
|
||||
|
||||
create_task_log(task_id, "sync_xray_users", f"Starting user sync for {server.name}", 'STARTED', server=server)
|
||||
logger.info(f"Starting user sync for Xray server {server.name}")
|
||||
|
||||
real_server = server.get_real_instance()
|
||||
user_result = real_server.sync_users()
|
||||
|
||||
success_message = f"Successfully synced {user_result.get('users_added', 0)} users for {server.name}"
|
||||
logger.info(f"{success_message}. Result: {user_result}")
|
||||
|
||||
create_task_log(task_id, "sync_xray_users", "User sync completed", 'SUCCESS', server=server, message=f"{success_message}. Result: {user_result}", execution_time=time.time() - start_time)
|
||||
|
||||
return user_result
|
||||
|
||||
except Server.DoesNotExist:
|
||||
error_message = f"Server with id {server_id} not found"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_xray_users", "Server not found", 'FAILURE', message=error_message, execution_time=time.time() - start_time)
|
||||
return {"error": error_message}
|
||||
except Exception as e:
|
||||
error_message = f"Error syncing users for {server.name if server else server_id}: {e}"
|
||||
logger.error(error_message)
|
||||
|
||||
if self.request.retries < 3:
|
||||
retry_message = f"Retrying user sync for {server.name if server else server_id} (attempt {self.request.retries + 1})"
|
||||
logger.info(retry_message)
|
||||
create_task_log(task_id, "sync_xray_users", "Retrying user sync", 'RETRY', server=server, message=retry_message)
|
||||
raise self.retry(countdown=30)
|
||||
|
||||
create_task_log(task_id, "sync_xray_users", "User sync failed after retries", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
return {"error": error_message}
|
||||
|
||||
class TaskFailedException(Exception):
|
||||
def __init__(self, message=""):
|
||||
self.message = message
|
||||
super().__init__(f"{self.message}")
|
||||
|
||||
|
||||
@shared_task(name="sync_all_servers", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 60})
|
||||
def sync_all_users(self):
|
||||
from vpn.server_plugins import Server
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
|
||||
create_task_log(task_id, "sync_all_servers", "Starting sync all servers", 'STARTED')
|
||||
|
||||
try:
|
||||
servers = Server.objects.all()
|
||||
|
||||
if not servers.exists():
|
||||
message = "No servers found for synchronization"
|
||||
logger.warning(message)
|
||||
create_task_log(task_id, "sync_all_servers", "No servers to sync", 'SUCCESS', message=message, execution_time=time.time() - start_time)
|
||||
return message
|
||||
|
||||
# Filter out servers that might not exist anymore
|
||||
valid_servers = []
|
||||
for server in servers:
|
||||
try:
|
||||
# Test basic server access
|
||||
server.get_server_status()
|
||||
valid_servers.append(server)
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping server {server.name} (ID: {server.id}) due to connection issues: {e}")
|
||||
create_task_log(task_id, "sync_all_servers", f"Skipped server {server.name}", 'STARTED', server=server, message=f"Connection failed: {e}")
|
||||
|
||||
# Log all servers that will be synced
|
||||
server_list = ", ".join([s.name for s in valid_servers])
|
||||
if valid_servers:
|
||||
create_task_log(task_id, "sync_all_servers", f"Found {len(valid_servers)} valid servers", 'STARTED', message=f"Servers: {server_list}")
|
||||
|
||||
tasks = group(sync_users.s(server.id) for server in valid_servers)
|
||||
result = tasks.apply_async()
|
||||
|
||||
success_message = f"Initiated sync for {len(valid_servers)} servers: {server_list}"
|
||||
else:
|
||||
success_message = "No valid servers found for synchronization"
|
||||
|
||||
create_task_log(task_id, "sync_all_servers", "Sync initiated", 'SUCCESS', message=success_message, execution_time=time.time() - start_time)
|
||||
|
||||
return success_message
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Error initiating sync: {e}"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_all_servers", "Sync failed", 'FAILURE', message=error_message, execution_time=time.time() - start_time)
|
||||
raise
|
||||
|
||||
@shared_task(name="sync_all_users_on_server", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 60})
|
||||
def sync_users(self, server_id):
|
||||
from vpn.server_plugins import Server
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
server = None
|
||||
|
||||
try:
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
error_message = f"Server with id {server_id} not found - may have been deleted"
|
||||
logger.warning(error_message)
|
||||
create_task_log(task_id, "sync_all_users_on_server", "Server not found", 'SUCCESS', message=error_message, execution_time=time.time() - start_time)
|
||||
return error_message # Don't raise exception for deleted servers
|
||||
|
||||
# Test server connectivity before proceeding
|
||||
try:
|
||||
server.get_server_status()
|
||||
except Exception as e:
|
||||
error_message = f"Server {server.name} is not accessible: {e}"
|
||||
logger.warning(error_message)
|
||||
create_task_log(task_id, "sync_all_users_on_server", "Server not accessible", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
return error_message # Don't retry for connectivity issues
|
||||
|
||||
create_task_log(task_id, "sync_all_users_on_server", f"Starting user sync for server {server.name}", 'STARTED', server=server)
|
||||
logger.info(f"Starting user sync for server {server.name}")
|
||||
|
||||
# Get all users for this server
|
||||
from .models import ACL
|
||||
acls = ACL.objects.filter(server=server).select_related('user')
|
||||
user_count = acls.count()
|
||||
user_list = ", ".join([acl.user.username for acl in acls[:10]]) # First 10 users
|
||||
if user_count > 10:
|
||||
user_list += f" and {user_count - 10} more"
|
||||
|
||||
create_task_log(task_id, "sync_all_users_on_server", f"Found {user_count} users to sync", 'STARTED', server=server, message=f"Users: {user_list}")
|
||||
|
||||
# For Xray servers, use separate staged sync tasks
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
if isinstance(server.get_real_instance(), XrayCoreServer):
|
||||
logger.info(f"Performing staged sync for Xray server {server.name}")
|
||||
try:
|
||||
# Stage 1: Sync inbounds first
|
||||
logger.info(f"Stage 1: Syncing inbounds for {server.name}")
|
||||
inbound_task = sync_xray_inbounds.apply_async(args=[server.id])
|
||||
inbound_result = inbound_task.get() # Wait for completion
|
||||
logger.info(f"Inbound sync result for {server.name}: {inbound_result}")
|
||||
|
||||
if "error" in inbound_result:
|
||||
logger.error(f"Inbound sync failed, skipping user sync: {inbound_result['error']}")
|
||||
sync_result = inbound_result
|
||||
else:
|
||||
# Stage 2: Sync users after inbounds are ready
|
||||
logger.info(f"Stage 2: Syncing users for {server.name}")
|
||||
user_task = sync_xray_users.apply_async(args=[server.id])
|
||||
user_result = user_task.get() # Wait for completion
|
||||
logger.info(f"User sync result for {server.name}: {user_result}")
|
||||
|
||||
# Combine results
|
||||
if "error" in user_result:
|
||||
sync_result = {
|
||||
"status": "Staged sync partially failed",
|
||||
"inbounds": inbound_result.get("inbounds", []),
|
||||
"users": f"User sync failed: {user_result['error']}"
|
||||
}
|
||||
else:
|
||||
sync_result = {
|
||||
"status": "Staged sync completed successfully",
|
||||
"inbounds": inbound_result.get("inbounds", []),
|
||||
"users": f"Added {user_result.get('users_added', 0)} users across all inbounds"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Staged sync failed for Xray server {server.name}: {e}")
|
||||
# Fallback to regular user sync only
|
||||
sync_result = server.sync_users()
|
||||
else:
|
||||
# For non-Xray servers, just sync users
|
||||
sync_result = server.sync_users()
|
||||
|
||||
# Check if sync was successful (can be boolean or dict/string)
|
||||
sync_successful = bool(sync_result) and (
|
||||
sync_result is not False and
|
||||
(isinstance(sync_result, str) and "failed" not in sync_result.lower()) or
|
||||
isinstance(sync_result, dict) or
|
||||
sync_result is True
|
||||
)
|
||||
|
||||
if sync_successful:
|
||||
success_message = f"Successfully synced {user_count} users for server {server.name}"
|
||||
if isinstance(sync_result, (str, dict)):
|
||||
success_message += f". Details: {sync_result}"
|
||||
logger.info(success_message)
|
||||
create_task_log(task_id, "sync_all_users_on_server", "User sync completed", 'SUCCESS', server=server, message=success_message, execution_time=time.time() - start_time)
|
||||
return success_message
|
||||
else:
|
||||
error_message = f"Sync failed for server {server.name}. Result: {sync_result}"
|
||||
create_task_log(task_id, "sync_all_users_on_server", "User sync failed", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
raise TaskFailedException(error_message)
|
||||
|
||||
except TaskFailedException:
|
||||
# Don't retry TaskFailedException
|
||||
raise
|
||||
except Exception as e:
|
||||
error_message = f"Error syncing users for server {server.name if server else server_id}: {e}"
|
||||
logger.error(error_message)
|
||||
|
||||
if self.request.retries < 3:
|
||||
retry_message = f"Retrying sync for server {server.name if server else server_id} (attempt {self.request.retries + 1})"
|
||||
logger.info(retry_message)
|
||||
create_task_log(task_id, "sync_all_users_on_server", "Retrying user sync", 'RETRY', server=server, message=retry_message)
|
||||
raise self.retry(countdown=60)
|
||||
|
||||
create_task_log(task_id, "sync_all_users_on_server", "User sync failed after retries", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
raise TaskFailedException(error_message)
|
||||
|
||||
@shared_task(name="sync_server_info", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 30})
|
||||
def sync_server(self, id):
|
||||
from vpn.server_plugins import Server
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
server = None
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=id)
|
||||
|
||||
create_task_log(task_id, "sync_server_info", f"Starting server info sync for {server.name}", 'STARTED', server=server)
|
||||
logger.info(f"Starting server info sync for {server.name}")
|
||||
|
||||
sync_result = server.sync()
|
||||
|
||||
success_message = f"Successfully synced server info for {server.name}"
|
||||
result_details = f"Sync result: {sync_result}"
|
||||
logger.info(f"{success_message}. {result_details}")
|
||||
|
||||
create_task_log(task_id, "sync_server_info", "Server info synced", 'SUCCESS', server=server, message=f"{success_message}. {result_details}", execution_time=time.time() - start_time)
|
||||
|
||||
return {"status": sync_result, "server": server.name}
|
||||
|
||||
except Server.DoesNotExist:
|
||||
error_message = f"Server with id {id} not found"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_server_info", "Server not found", 'FAILURE', message=error_message, execution_time=time.time() - start_time)
|
||||
return {"error": error_message}
|
||||
except Exception as e:
|
||||
error_message = f"Error syncing server info for {server.name if server else id}: {e}"
|
||||
logger.error(error_message)
|
||||
|
||||
if self.request.retries < 3:
|
||||
retry_message = f"Retrying server sync for {server.name if server else id} (attempt {self.request.retries + 1})"
|
||||
logger.info(retry_message)
|
||||
create_task_log(task_id, "sync_server_info", "Retrying server sync", 'RETRY', server=server, message=retry_message)
|
||||
raise self.retry(countdown=30)
|
||||
|
||||
create_task_log(task_id, "sync_server_info", "Server sync failed after retries", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
return {"error": error_message}
|
||||
|
||||
@shared_task(name="update_user_statistics", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 60})
|
||||
def update_user_statistics(self):
|
||||
"""Update cached user statistics from AccessLog data"""
|
||||
from .models import User, AccessLog, UserStatistics, ACLLink
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.db.models import Count, Q
|
||||
from django.db import transaction
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
|
||||
create_task_log(task_id, "update_user_statistics", "Starting statistics update", 'STARTED')
|
||||
|
||||
try:
|
||||
now = timezone.now()
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
|
||||
# Get all users with ACL links
|
||||
users_with_links = User.objects.filter(acl__isnull=False).distinct()
|
||||
total_users = users_with_links.count()
|
||||
|
||||
create_task_log(task_id, "update_user_statistics", f"Found {total_users} users to process", 'STARTED')
|
||||
logger.info(f"Updating statistics for {total_users} users")
|
||||
|
||||
updated_count = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for user in users_with_links:
|
||||
logger.debug(f"Processing user {user.username}")
|
||||
|
||||
# Get all ACL links for this user
|
||||
acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server')
|
||||
|
||||
for link in acl_links:
|
||||
server_name = link.acl.server.name
|
||||
|
||||
# Calculate total connections for this specific link (all time)
|
||||
total_connections = AccessLog.objects.filter(
|
||||
user=user.username,
|
||||
server=server_name,
|
||||
acl_link_id=link.link,
|
||||
action='Success'
|
||||
).count()
|
||||
|
||||
# Calculate recent connections (last 30 days)
|
||||
recent_connections = AccessLog.objects.filter(
|
||||
user=user.username,
|
||||
server=server_name,
|
||||
acl_link_id=link.link,
|
||||
action='Success',
|
||||
timestamp__gte=thirty_days_ago
|
||||
).count()
|
||||
|
||||
# Generate daily usage data for the last 30 days
|
||||
daily_usage = []
|
||||
max_daily = 0
|
||||
|
||||
for i in range(30):
|
||||
day_start = (now - timedelta(days=29-i)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
day_connections = AccessLog.objects.filter(
|
||||
user=user.username,
|
||||
server=server_name,
|
||||
acl_link_id=link.link,
|
||||
action='Success',
|
||||
timestamp__gte=day_start,
|
||||
timestamp__lt=day_end
|
||||
).count()
|
||||
|
||||
daily_usage.append(day_connections)
|
||||
max_daily = max(max_daily, day_connections)
|
||||
|
||||
# Update or create statistics for this link
|
||||
stats, created = UserStatistics.objects.update_or_create(
|
||||
user=user,
|
||||
server_name=server_name,
|
||||
acl_link_id=link.link,
|
||||
defaults={
|
||||
'total_connections': total_connections,
|
||||
'recent_connections': recent_connections,
|
||||
'daily_usage': daily_usage,
|
||||
'max_daily': max_daily,
|
||||
}
|
||||
)
|
||||
|
||||
action = "created" if created else "updated"
|
||||
logger.debug(f"{action} stats for {user.username} on {server_name} (link: {link.link}): {total_connections} total, {recent_connections} recent")
|
||||
|
||||
updated_count += 1
|
||||
|
||||
logger.debug(f"Completed processing user {user.username}")
|
||||
|
||||
success_message = f"Successfully updated statistics for {updated_count} user-server-link combinations"
|
||||
logger.info(success_message)
|
||||
|
||||
create_task_log(
|
||||
task_id,
|
||||
"update_user_statistics",
|
||||
"Statistics update completed",
|
||||
'SUCCESS',
|
||||
message=success_message,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
return success_message
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Error updating user statistics: {e}"
|
||||
logger.error(error_message, exc_info=True)
|
||||
|
||||
if self.request.retries < 3:
|
||||
retry_message = f"Retrying statistics update (attempt {self.request.retries + 1})"
|
||||
logger.info(retry_message)
|
||||
create_task_log(task_id, "update_user_statistics", "Retrying statistics update", 'RETRY', message=retry_message)
|
||||
raise self.retry(countdown=60)
|
||||
|
||||
create_task_log(
|
||||
task_id,
|
||||
"update_user_statistics",
|
||||
"Statistics update failed after retries",
|
||||
'FAILURE',
|
||||
message=error_message,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
raise
|
||||
|
||||
@shared_task(name="sync_user_on_server", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 5, 'countdown': 30})
|
||||
def sync_user(self, user_id, server_id):
|
||||
from .models import User, ACL
|
||||
from vpn.server_plugins import Server
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
errors = {}
|
||||
result = {}
|
||||
user = None
|
||||
server = None
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
server = Server.objects.get(id=server_id)
|
||||
|
||||
create_task_log(task_id, "sync_user_on_server", f"Starting user sync for {user.username} on {server.name}", 'STARTED', server=server, user=user)
|
||||
logger.info(f"Syncing user {user.username} on server {server.name}")
|
||||
|
||||
# Check if ACL exists
|
||||
acl_exists = ACL.objects.filter(user=user, server=server).exists()
|
||||
|
||||
if acl_exists:
|
||||
# User should exist on server
|
||||
action_message = f"Adding/updating user {user.username} on server {server.name}"
|
||||
create_task_log(task_id, "sync_user_on_server", action_message, 'STARTED', server=server, user=user)
|
||||
|
||||
result[server.name] = server.add_user(user)
|
||||
|
||||
success_message = f"Successfully added/updated user {user.username} on server {server.name}"
|
||||
logger.info(success_message)
|
||||
create_task_log(task_id, "sync_user_on_server", "User added/updated", 'SUCCESS', server=server, user=user, message=f"{success_message}. Result: {result[server.name]}", execution_time=time.time() - start_time)
|
||||
else:
|
||||
# User should be removed from server
|
||||
action_message = f"Removing user {user.username} from server {server.name}"
|
||||
create_task_log(task_id, "sync_user_on_server", action_message, 'STARTED', server=server, user=user)
|
||||
|
||||
result[server.name] = server.delete_user(user)
|
||||
|
||||
success_message = f"Successfully removed user {user.username} from server {server.name}"
|
||||
logger.info(success_message)
|
||||
create_task_log(task_id, "sync_user_on_server", "User removed", 'SUCCESS', server=server, user=user, message=f"{success_message}. Result: {result[server.name]}", execution_time=time.time() - start_time)
|
||||
|
||||
except User.DoesNotExist:
|
||||
error_msg = f"User with id {user_id} not found"
|
||||
logger.error(error_msg)
|
||||
errors["user"] = error_msg
|
||||
create_task_log(task_id, "sync_user_on_server", "User not found", 'FAILURE', message=error_msg, execution_time=time.time() - start_time)
|
||||
except Server.DoesNotExist:
|
||||
error_msg = f"Server with id {server_id} not found"
|
||||
logger.error(error_msg)
|
||||
errors["server"] = error_msg
|
||||
create_task_log(task_id, "sync_user_on_server", "Server not found", 'FAILURE', message=error_msg, execution_time=time.time() - start_time)
|
||||
except Exception as e:
|
||||
error_msg = f"Error syncing user {user.username if user else user_id} on server {server.name if server else server_id}: {e}"
|
||||
logger.error(error_msg)
|
||||
errors[f"server_{server_id}"] = error_msg
|
||||
|
||||
# Retry on failure unless it's a permanent error
|
||||
if self.request.retries < 5:
|
||||
retry_message = f"Retrying user sync for user {user.username if user else user_id} on server {server.name if server else server_id} (attempt {self.request.retries + 1})"
|
||||
logger.info(retry_message)
|
||||
create_task_log(task_id, "sync_user_on_server", "Retrying user sync", 'RETRY', server=server, user=user, message=retry_message)
|
||||
raise self.retry(countdown=30)
|
||||
|
||||
create_task_log(task_id, "sync_user_on_server", "User sync failed after retries", 'FAILURE', server=server, user=user, message=error_msg, execution_time=time.time() - start_time)
|
||||
|
||||
if errors:
|
||||
raise TaskFailedException(message=f"Errors during task: {errors}")
|
||||
|
||||
return result
|
||||
37
vpn/templates/admin/base_site.html
Normal file
37
vpn/templates/admin/base_site.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||
|
||||
{% block branding %}
|
||||
<h1 id="site-name"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Django administration') }}</a></h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<div id="footer" style="margin-top: 20px; padding: 10px; border-top: 1px solid #ccc; font-size: 12px; color: #666;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<strong>OutFleet VPN Manager</strong>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
{% if VERSION_INFO %}
|
||||
<div>
|
||||
<strong>Version:</strong>
|
||||
<code>{{ VERSION_INFO.git_commit_short }}</code>
|
||||
{% if VERSION_INFO.is_development %}
|
||||
<span style="color: #e74c3c;">(Development)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not VERSION_INFO.is_development %}
|
||||
<div style="margin-top: 2px;">
|
||||
<strong>Built:</strong> {{ VERSION_INFO.build_date }}
|
||||
</div>
|
||||
<div style="margin-top: 2px;">
|
||||
<strong>Commit:</strong>
|
||||
<code style="font-size: 10px;">{{ VERSION_INFO.git_commit }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
499
vpn/templates/admin/move_clients.html
Normal file
499
vpn/templates/admin/move_clients.html
Normal file
@@ -0,0 +1,499 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content-main">
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<div class="alert alert-info" style="margin: 10px 0; padding: 10px; border-radius: 4px; background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;">
|
||||
<strong>Note:</strong> This operation only affects the database and works even if servers are unreachable.
|
||||
Server connectivity is not required for moving links between servers.
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}" style="margin: 10px 0; padding: 10px; border-radius: 4px;
|
||||
{% if message.tags == 'error' %}background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;
|
||||
{% elif message.tags == 'success' %}background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724;
|
||||
{% elif message.tags == 'warning' %}background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404;
|
||||
{% elif message.tags == 'info' %}background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;
|
||||
{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" id="move-clients-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-row" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; gap: 20px;">
|
||||
<div style="flex: 1;">
|
||||
<label for="source_server"><strong>Source Server:</strong></label>
|
||||
<select id="source_server" name="source_server" style="width: 100%; padding: 5px;" onchange="updateLinksList()">
|
||||
<option value="">-- Select Source Server --</option>
|
||||
{% for server in servers %}
|
||||
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1;">
|
||||
<label for="target_server"><strong>Target Server:</strong></label>
|
||||
<select id="target_server" name="target_server" style="width: 100%; padding: 5px;" onchange="updateSubmitButton()">
|
||||
<option value="">-- Select Target Server --</option>
|
||||
{% for server in all_servers %}
|
||||
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="links-list" style="margin-bottom: 20px;">
|
||||
<h3>Select Client Links to Move:</h3>
|
||||
<div id="links-container">
|
||||
<p style="color: #666;">Please select a source server first.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-bottom: 20px;">
|
||||
<h3>Comment Transformation (Optional)</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="comment_regex"><strong>Regular Expression Pattern:</strong></label>
|
||||
<input type="text" id="comment_regex" name="comment_regex"
|
||||
style="width: 100%; padding: 8px; font-family: monospace;"
|
||||
placeholder="Example: ^(.*)$ -> [OLD_SERVER] $1"
|
||||
title="Use regex pattern -> replacement format">
|
||||
</div>
|
||||
|
||||
<div class="regex-help" style="background-color: #f8f9fa; padding: 10px; border-radius: 5px; border-left: 4px solid #007cba;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #007cba; font-size: 14px;">Regular Expression Help & Examples:</h4>
|
||||
<div style="font-size: 13px;">
|
||||
<p style="margin: 0 0 8px 0;"><strong>Format:</strong> <code>pattern -> replacement</code></p>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<h5 style="margin: 0 0 5px 0; font-size: 13px;">Common Examples:</h5>
|
||||
<ul style="margin: 0; padding-left: 15px; line-height: 1.4;">
|
||||
<li><code>^(.*)$ -> [FROM RU] $1</code> <small>- Add prefix to all comments</small></li>
|
||||
<li><code>^(.*)$ -> $1 (moved)</code> <small>- Add suffix to all comments</small></li>
|
||||
<li><code>^$ -> Default Device</code> <small>- Replace empty comments</small></li>
|
||||
<li><code>phone -> mobile</code> <small>- Replace specific word</small></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<h5 style="margin: 0 0 5px 0; font-size: 13px;">Advanced Examples:</h5>
|
||||
<ul style="margin: 0; padding-left: 15px; line-height: 1.4;">
|
||||
<li><code>^(.+) - (.+)$ -> $2: $1</code> <small>- Swap parts separated by " - "</small></li>
|
||||
<li><code>(\d{4})-(\d{2})-(\d{2}) -> $3/$2/$1</code> <small>- Change date format</small></li>
|
||||
<li><code>^(.{1,20}).*$ -> $1...</code> <small>- Truncate long comments</small></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="padding: 8px; background-color: #fff3cd; border-radius: 3px; margin: 0;">
|
||||
<strong style="font-size: 12px;">Tips:</strong>
|
||||
<ul style="margin: 3px 0 0 0; padding-left: 15px; font-size: 12px; line-height: 1.3;">
|
||||
<li>Use <code>$1, $2, $3...</code> for captured groups (auto-converted to Python)</li>
|
||||
<li>Use <code>^</code> for start, <code>$</code> for end of string</li>
|
||||
<li>Preview shows result; leave empty to keep original comments</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="Move Selected Links" class="default" id="submit-btn" disabled>
|
||||
<a href="{% url 'admin:vpn_server_changelist' %}" class="button cancel">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Client links data for each server
|
||||
var linksByServer = {
|
||||
{% for server, links in links_by_server.items %}
|
||||
"{{ server.id }}": [
|
||||
{% for link in links %}
|
||||
{
|
||||
"id": {{ link.id }},
|
||||
"link": "{{ link.link|escapejs }}",
|
||||
"comment": "{{ link.comment|escapejs }}",
|
||||
"username": "{{ link.acl.user.username|escapejs }}",
|
||||
"user_comment": "{{ link.acl.user.comment|escapejs }}",
|
||||
"created_at": "{{ link.acl.created_at|date:'Y-m-d H:i' }}",
|
||||
"full_url": "{{ EXTERNAL_ADDRESS }}/ss/{{ link.link }}#{{ link.acl.server.name|escapejs }}"
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
function updateLinksList() {
|
||||
var sourceServerId = document.getElementById('source_server').value;
|
||||
var linksContainer = document.getElementById('links-container');
|
||||
var submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
if (!sourceServerId) {
|
||||
linksContainer.innerHTML = '<p style="color: #666;">Please select a source server first.</p>';
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var links = linksByServer[sourceServerId] || [];
|
||||
|
||||
if (links.length === 0) {
|
||||
linksContainer.innerHTML = '<p style="color: #666;">No client links found for this server.</p>';
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group links by user for better organization
|
||||
var linksByUser = {};
|
||||
links.forEach(function(link) {
|
||||
if (!linksByUser[link.username]) {
|
||||
linksByUser[link.username] = {
|
||||
user_comment: link.user_comment,
|
||||
created_at: link.created_at,
|
||||
links: []
|
||||
};
|
||||
}
|
||||
linksByUser[link.username].links.push(link);
|
||||
});
|
||||
|
||||
var html = '<div style="max-height: 500px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">';
|
||||
html += '<div style="margin-bottom: 10px;">';
|
||||
html += '<button type="button" onclick="toggleAllLinks()" style="padding: 5px 10px; margin-right: 10px;">Select All</button>';
|
||||
html += '<button type="button" onclick="toggleAllLinks(false)" style="padding: 5px 10px;">Deselect All</button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<table style="width: 100%; border-collapse: collapse;">';
|
||||
html += '<thead><tr style="background-color: #f5f5f5;">';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd; width: 50px;">Select</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">Username</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">Link Comment</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">Link ID</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">User Comment</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">Created</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
// Sort users alphabetically
|
||||
var sortedUsers = Object.keys(linksByUser).sort();
|
||||
|
||||
sortedUsers.forEach(function(username) {
|
||||
var userData = linksByUser[username];
|
||||
var userLinks = userData.links;
|
||||
|
||||
// Add user row header if user has multiple links
|
||||
if (userLinks.length > 1) {
|
||||
html += '<tr style="background-color: #f9f9f9;">';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">';
|
||||
html += '<input type="checkbox" class="user-checkbox" data-username="' + username + '" onchange="toggleUserLinks(\'' + username + '\')">';
|
||||
html += '</td>';
|
||||
html += '<td colspan="5" style="padding: 8px; border: 1px solid #ddd;"><strong>' + username + '</strong> (' + userLinks.length + ' links)</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
// Add individual link rows
|
||||
userLinks.forEach(function(link) {
|
||||
html += '<tr class="link-row" data-username="' + username + '">';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">';
|
||||
html += '<input type="checkbox" name="selected_links" value="' + link.id + '" class="link-checkbox" data-username="' + username + '" onchange="updateSubmitButton(); updateUserCheckbox(\'' + username + '\')">';
|
||||
html += '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + (userLinks.length === 1 ? '<strong>' + username + '</strong>' : '↳') + '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + (link.comment || '<em>No comment</em>') + '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd; font-family: monospace; font-size: 12px;">' + link.link + '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + (userData.user_comment || '') + '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + userData.created_at + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
linksContainer.innerHTML = html;
|
||||
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function toggleAllLinks(selectAll = true) {
|
||||
var checkboxes = document.getElementsByName('selected_links');
|
||||
var userCheckboxes = document.getElementsByClassName('user-checkbox');
|
||||
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
checkboxes[i].checked = selectAll;
|
||||
}
|
||||
|
||||
for (var i = 0; i < userCheckboxes.length; i++) {
|
||||
userCheckboxes[i].checked = selectAll;
|
||||
}
|
||||
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function toggleUserLinks(username) {
|
||||
var userCheckbox = document.querySelector('.user-checkbox[data-username="' + username + '"]');
|
||||
var userLinkCheckboxes = document.querySelectorAll('.link-checkbox[data-username="' + username + '"]');
|
||||
|
||||
for (var i = 0; i < userLinkCheckboxes.length; i++) {
|
||||
userLinkCheckboxes[i].checked = userCheckbox.checked;
|
||||
}
|
||||
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function updateUserCheckbox(username) {
|
||||
var userLinkCheckboxes = document.querySelectorAll('.link-checkbox[data-username="' + username + '"]');
|
||||
var userCheckbox = document.querySelector('.user-checkbox[data-username="' + username + '"]');
|
||||
|
||||
if (!userCheckbox) return; // No user checkbox for single-link users
|
||||
|
||||
var allChecked = true;
|
||||
var noneChecked = true;
|
||||
|
||||
for (var i = 0; i < userLinkCheckboxes.length; i++) {
|
||||
if (userLinkCheckboxes[i].checked) {
|
||||
noneChecked = false;
|
||||
} else {
|
||||
allChecked = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allChecked) {
|
||||
userCheckbox.checked = true;
|
||||
userCheckbox.indeterminate = false;
|
||||
} else if (noneChecked) {
|
||||
userCheckbox.checked = false;
|
||||
userCheckbox.indeterminate = false;
|
||||
} else {
|
||||
userCheckbox.checked = false;
|
||||
userCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSubmitButton() {
|
||||
var checkboxes = document.getElementsByName('selected_links');
|
||||
var sourceServer = document.getElementById('source_server').value;
|
||||
var targetServer = document.getElementById('target_server').value;
|
||||
var submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
var hasSelected = false;
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
if (checkboxes[i].checked) {
|
||||
hasSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.disabled = !(hasSelected && sourceServer && targetServer && sourceServer !== targetServer);
|
||||
}
|
||||
|
||||
// Form submission confirmation
|
||||
document.getElementById('move-clients-form').addEventListener('submit', function(e) {
|
||||
var checkboxes = document.getElementsByName('selected_links');
|
||||
var selectedCount = 0;
|
||||
var selectedUsers = new Set();
|
||||
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
if (checkboxes[i].checked) {
|
||||
selectedCount++;
|
||||
selectedUsers.add(checkboxes[i].getAttribute('data-username'));
|
||||
}
|
||||
}
|
||||
|
||||
var sourceServerName = document.getElementById('source_server').selectedOptions[0].text;
|
||||
var targetServerName = document.getElementById('target_server').selectedOptions[0].text;
|
||||
var commentRegex = document.getElementById('comment_regex').value.trim();
|
||||
|
||||
var confirmMessage = 'Are you sure you want to move ' + selectedCount + ' link(s) for ' + selectedUsers.size + ' user(s) from "' + sourceServerName + '" to "' + targetServerName + '"?\n\n';
|
||||
confirmMessage += 'This action will:\n';
|
||||
confirmMessage += '- Transfer selected links to target server\n';
|
||||
confirmMessage += '- Create ACLs for users who don\'t have access to target server\n';
|
||||
confirmMessage += '- Remove empty ACLs from source server\n';
|
||||
confirmMessage += '- Preserve all link settings and comments\n';
|
||||
|
||||
if (commentRegex) {
|
||||
confirmMessage += '- Apply regex transformation to comments: "' + commentRegex + '"\n';
|
||||
}
|
||||
|
||||
confirmMessage += '\nThis cannot be undone.';
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Add regex preview functionality
|
||||
document.getElementById('comment_regex').addEventListener('input', function() {
|
||||
updateRegexPreview();
|
||||
});
|
||||
|
||||
function updateRegexPreview() {
|
||||
var regexInput = document.getElementById('comment_regex').value.trim();
|
||||
|
||||
// Remove any existing preview
|
||||
var existingPreview = document.getElementById('regex-preview');
|
||||
if (existingPreview) {
|
||||
existingPreview.remove();
|
||||
}
|
||||
|
||||
if (!regexInput) return;
|
||||
|
||||
// Parse pattern -> replacement format
|
||||
var parts = regexInput.split(' -> ');
|
||||
if (parts.length !== 2) {
|
||||
showRegexError('Invalid format. Use: pattern -> replacement');
|
||||
return;
|
||||
}
|
||||
|
||||
var pattern = parts[0];
|
||||
var replacement = parts[1];
|
||||
|
||||
try {
|
||||
var regex = new RegExp(pattern, 'g');
|
||||
|
||||
// Test with sample comments from currently visible links
|
||||
var sampleComments = getSampleComments();
|
||||
if (sampleComments.length > 0) {
|
||||
showRegexPreview(sampleComments, regex, replacement);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
showRegexError('Invalid regex pattern: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getSampleComments() {
|
||||
var comments = [];
|
||||
var checkboxes = document.getElementsByName('selected_links');
|
||||
|
||||
// Collect actual comments from visible links
|
||||
for (var i = 0; i < checkboxes.length && comments.length < 5; i++) {
|
||||
var row = checkboxes[i].closest('tr');
|
||||
if (row) {
|
||||
var commentCell = row.children[2]; // Link Comment column
|
||||
if (commentCell) {
|
||||
var commentText = commentCell.textContent.trim();
|
||||
if (commentText && commentText !== 'No comment') {
|
||||
comments.push(commentText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add some realistic default samples if no comments found or need more samples
|
||||
var defaultSamples = ['iPhone 13'];
|
||||
for (var i = 0; i < defaultSamples.length && comments.length < 5; i++) {
|
||||
if (comments.indexOf(defaultSamples[i]) === -1) {
|
||||
comments.push(defaultSamples[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return comments.slice(0, 5); // Limit to 5 samples
|
||||
}
|
||||
|
||||
function showRegexPreview(samples, regex, replacement) {
|
||||
var previewHtml = '<div id="regex-preview" style="margin-top: 10px; padding: 10px; background-color: #e8f5e8; border-radius: 3px; border-left: 4px solid #28a745;">';
|
||||
previewHtml += '<h5 style="margin-top: 0; color: #28a745;">Preview (first 5 samples):</h5>';
|
||||
previewHtml += '<table style="width: 100%; font-size: 13px;">';
|
||||
previewHtml += '<tr><th style="text-align: left; padding: 5px;">Original</th><th style="text-align: left; padding: 5px;">→</th><th style="text-align: left; padding: 5px;">Transformed</th></tr>';
|
||||
|
||||
samples.forEach(function(comment) {
|
||||
var original = comment || '(empty)';
|
||||
var transformed;
|
||||
|
||||
try {
|
||||
// Use string replace with regex and replacement string
|
||||
transformed = original.replace(regex, replacement);
|
||||
} catch (e) {
|
||||
transformed = '(error: ' + e.message + ')';
|
||||
}
|
||||
|
||||
var changed = original !== transformed;
|
||||
|
||||
previewHtml += '<tr>';
|
||||
previewHtml += '<td style="padding: 3px 5px; font-family: monospace;">' + escapeHtml(original) + '</td>';
|
||||
previewHtml += '<td style="padding: 3px 5px;">→</td>';
|
||||
previewHtml += '<td style="padding: 3px 5px; font-family: monospace;' + (changed ? ' font-weight: bold; color: #28a745;' : '') + '">' + escapeHtml(transformed) + '</td>';
|
||||
previewHtml += '</tr>';
|
||||
});
|
||||
|
||||
previewHtml += '</table></div>';
|
||||
|
||||
document.querySelector('.regex-help').insertAdjacentHTML('afterend', previewHtml);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showRegexError(message) {
|
||||
var errorHtml = '<div id="regex-preview" style="margin-top: 10px; padding: 10px; background-color: #f8d7da; border-radius: 3px; border-left: 4px solid #dc3545;">';
|
||||
errorHtml += '<h5 style="margin-top: 0; color: #dc3545;">Error:</h5>';
|
||||
errorHtml += '<p style="margin: 0; color: #721c24;">' + message + '</p>';
|
||||
errorHtml += '</div>';
|
||||
|
||||
document.querySelector('.regex-help').insertAdjacentHTML('afterend', errorHtml);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.alert {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.submit-row {
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 20px;
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.submit-row input[type="submit"] {
|
||||
background: #417690;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.submit-row input[type="submit"]:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.submit-row .cancel {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.submit-row .cancel:hover {
|
||||
background: #5a6268;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.link-row:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:indeterminate {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
10
vpn/templates/admin/polls/user/change_form.html
Normal file
10
vpn/templates/admin/polls/user/change_form.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block after_field_sets %}
|
||||
<div>
|
||||
<h2>Create ACLs</h2>
|
||||
{{ adminform.form.servers }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
93
vpn/templates/admin/simple_move_clients.html
Normal file
93
vpn/templates/admin/simple_move_clients.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n admin_urls static admin_list %}
|
||||
|
||||
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/vpn_admin.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url 'admin:vpn_server_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› {{ title }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<div class="module aligned">
|
||||
<div class="form-row">
|
||||
<div class="form-row field-box">
|
||||
<label>Source Server:</label>
|
||||
<div class="readonly"><strong>{{ source_server.name }}</strong> ({{ source_server.server_type }})</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row field-box">
|
||||
<label>Statistics:</label>
|
||||
<div class="readonly">
|
||||
<strong>{{ links_count }}</strong> client link(s) for <strong>{{ users_count }}</strong> user(s)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if links_count == 0 %}
|
||||
<div class="messagelist">
|
||||
<div class="warning">No client links found on this server.</div>
|
||||
</div>
|
||||
<div class="submit-row">
|
||||
<a href="{% url 'admin:vpn_server_changelist' %}" class="default">« Back to server list</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" id="move-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<fieldset class="module aligned">
|
||||
<h2>Move Options</h2>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label for="target_server" class="required">Target Server:</label>
|
||||
<select id="target_server" name="target_server" class="vLargeTextField" required>
|
||||
<option value="">-- Select target server --</option>
|
||||
{% for server in all_servers %}
|
||||
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label for="add_prefix">Add prefix to comments (optional):</label>
|
||||
<input type="text" id="add_prefix" name="add_prefix" class="vTextField"
|
||||
placeholder="e.g. [FROM {{ source_server.name }}]">
|
||||
<p class="help">This prefix will be added to all client link comments</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="Move All Client Links" class="default"
|
||||
onclick="return confirm('Are you sure you want to move ALL {{ links_count }} client link(s) from {{ source_server.name }} to the selected target server?\\n\\nThis action cannot be undone.');">
|
||||
<a href="{% url 'admin:vpn_server_changelist' %}" class="button cancel">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="help">
|
||||
<h3>What will happen:</h3>
|
||||
<ul>
|
||||
<li>All {{ links_count }} client links will be moved from <strong>{{ source_server.name }}</strong> to the target server</li>
|
||||
<li>Users who don't have access to the target server will get new ACL entries created automatically</li>
|
||||
<li>Empty ACL entries on the source server will be cleaned up</li>
|
||||
<li>All link settings and comments will be preserved (with optional prefix)</li>
|
||||
<li>This operation is database-only and doesn't require server connectivity</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
47
vpn/templates/admin/vpn/acllink/change_list.html
Normal file
47
vpn/templates/admin/vpn/acllink/change_list.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1 class="h4 m-0 pr-3 mr-3 border-right">ACL Links & User Statistics</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Compact Statistics Panel -->
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 0 0 16px 0;">
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: center;">
|
||||
<!-- Key Metrics -->
|
||||
<div style="background: #3b82f6; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold;">{{ total_links|default:0 }}</div>
|
||||
<div style="font-size: 11px; opacity: 0.9;">Links</div>
|
||||
</div>
|
||||
<div style="background: #10b981; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold;">{{ total_uses|default:0|floatformat:0 }}</div>
|
||||
<div style="font-size: 11px; opacity: 0.9;">Total Uses</div>
|
||||
</div>
|
||||
<div style="background: #8b5cf6; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold;">{{ recent_uses|default:0|floatformat:0 }}</div>
|
||||
<div style="font-size: 11px; opacity: 0.9;">Recent (30d)</div>
|
||||
</div>
|
||||
|
||||
<!-- Status indicators -->
|
||||
<div style="margin-left: 20px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
{% if never_accessed > 0 %}
|
||||
<span style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">❌ {{ never_accessed }} never used</span>
|
||||
{% endif %}
|
||||
{% if old_links > 0 %}
|
||||
<span style="background: #f97316; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">⚠️ {{ old_links }} old</span>
|
||||
{% endif %}
|
||||
<span style="background: #059669; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">✅ {{ active_links|default:0 }} active</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div style="margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<a href="?last_access_status=never" style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">🔍 Never Used</a>
|
||||
<a href="?last_access_status=old" style="background: #f97316; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">⏰ Old</a>
|
||||
<a href="?last_access_status=week" style="background: #10b981; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">📅 Recent</a>
|
||||
<a href="?" style="background: #6b7280; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">🔄 Clear</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
176
vpn/templates/admin/vpn/outlineserver/add_form.html.backup
Normal file
176
vpn/templates/admin/vpn/outlineserver/add_form.html.backup
Normal file
@@ -0,0 +1,176 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load i18n admin_urls static %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add JSON Import tab
|
||||
const tabList = document.getElementById('jazzy-tabs');
|
||||
const tabContent = document.querySelector('.tab-content');
|
||||
|
||||
if (tabList && tabContent) {
|
||||
// Add new tab
|
||||
const newTab = document.createElement('li');
|
||||
newTab.className = 'nav-item';
|
||||
newTab.innerHTML = `
|
||||
<a class="nav-link" data-toggle="pill" role="tab" aria-controls="json-import-tab" aria-selected="false" href="#json-import-tab">
|
||||
📥 JSON Import
|
||||
</a>
|
||||
`;
|
||||
tabList.insertBefore(newTab, tabList.firstChild);
|
||||
|
||||
// Add tab content
|
||||
const newTabContent = document.createElement('div');
|
||||
newTabContent.id = 'json-import-tab';
|
||||
newTabContent.className = 'tab-pane fade';
|
||||
newTabContent.setAttribute('role', 'tabpanel');
|
||||
newTabContent.setAttribute('aria-labelledby', 'json-import-tab');
|
||||
newTabContent.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="p-5 card-body">
|
||||
<h4 style="color: #007cba; margin-bottom: 1rem;">📥 Quick Import from JSON</h4>
|
||||
<p style="font-size: 0.875rem; color: #6c757d; margin-bottom: 1rem;">
|
||||
Paste the JSON configuration from your Outline server setup to automatically fill the fields:
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="import-json-config">JSON Configuration:</label>
|
||||
<textarea id="import-json-config" class="form-control" rows="8"
|
||||
placeholder='{
|
||||
"apiUrl": "https://your-server:port/path",
|
||||
"certSha256": "your-certificate-hash",
|
||||
"serverName": "My Outline Server",
|
||||
"clientHostname": "your-server.com",
|
||||
"clientPort": 1257,
|
||||
"comment": "Server description"
|
||||
}' style="font-family: 'Courier New', monospace; font-size: 0.875rem;"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="button" id="import-json-btn" class="btn btn-primary">
|
||||
Import Configuration
|
||||
</button>
|
||||
|
||||
<div style="margin-top: 1rem; padding: 0.75rem; background: #e7f3ff; border-left: 4px solid #007cba; border-radius: 4px;">
|
||||
<strong>Required fields:</strong>
|
||||
<ul style="margin: 0.5rem 0; padding-left: 20px;">
|
||||
<li><code>apiUrl</code> - Server management URL</li>
|
||||
<li><code>certSha256</code> - Certificate fingerprint</li>
|
||||
</ul>
|
||||
<strong>Optional fields:</strong>
|
||||
<ul style="margin: 0.5rem 0; padding-left: 20px;">
|
||||
<li><code>serverName</code> - Display name</li>
|
||||
<li><code>clientHostname</code> - Client hostname</li>
|
||||
<li><code>clientPort</code> - Client port</li>
|
||||
<li><code>comment</code> - Description</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
tabContent.insertBefore(newTabContent, tabContent.firstChild);
|
||||
|
||||
// Make first tab (JSON Import) active
|
||||
document.querySelector('#jazzy-tabs .nav-link').classList.remove('active');
|
||||
newTab.querySelector('.nav-link').classList.add('active');
|
||||
document.querySelector('.tab-pane.active').classList.remove('active', 'show');
|
||||
newTabContent.classList.add('active', 'show');
|
||||
}
|
||||
|
||||
// Import functionality
|
||||
function tryAutoFillFromJson() {
|
||||
const importJsonTextarea = document.getElementById('import-json-config');
|
||||
|
||||
try {
|
||||
const jsonText = importJsonTextarea.value.trim();
|
||||
if (!jsonText) {
|
||||
alert('Please enter JSON configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(jsonText);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.apiUrl || !config.certSha256) {
|
||||
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse apiUrl to extract components
|
||||
const url = new URL(config.apiUrl);
|
||||
|
||||
// Fill form fields
|
||||
const adminUrlField = document.getElementById('id_admin_url');
|
||||
const adminCertField = document.getElementById('id_admin_access_cert');
|
||||
const clientHostnameField = document.getElementById('id_client_hostname');
|
||||
const clientPortField = document.getElementById('id_client_port');
|
||||
const nameField = document.getElementById('id_name');
|
||||
const commentField = document.getElementById('id_comment');
|
||||
|
||||
if (adminUrlField) adminUrlField.value = config.apiUrl;
|
||||
if (adminCertField) adminCertField.value = config.certSha256;
|
||||
|
||||
// Use provided hostname or extract from URL
|
||||
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
|
||||
if (clientHostnameField) clientHostnameField.value = hostname;
|
||||
|
||||
// Use provided port or extract from various sources
|
||||
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
|
||||
if (clientPortField) clientPortField.value = clientPort;
|
||||
|
||||
// Generate server name if not provided and field is empty
|
||||
if (nameField && !nameField.value) {
|
||||
const serverName = config.serverName || config.name || `Outline-${hostname}`;
|
||||
nameField.value = serverName;
|
||||
}
|
||||
|
||||
// Fill comment if provided and field exists
|
||||
if (commentField && config.comment) {
|
||||
commentField.value = config.comment;
|
||||
}
|
||||
|
||||
// Clear the JSON input
|
||||
importJsonTextarea.value = '';
|
||||
|
||||
// Show success message
|
||||
alert('✅ Configuration imported successfully! Review the fields and save.');
|
||||
|
||||
// Switch to Server Configuration tab
|
||||
const serverConfigTab = document.querySelector('a[href="#server-configuration-tab"]');
|
||||
if (serverConfigTab) {
|
||||
serverConfigTab.click();
|
||||
}
|
||||
|
||||
// Focus on name field
|
||||
if (nameField) {
|
||||
setTimeout(() => {
|
||||
nameField.focus();
|
||||
nameField.select();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
alert(`Invalid JSON format: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit for DOM to be ready, then add event listeners
|
||||
setTimeout(() => {
|
||||
const importBtn = document.getElementById('import-json-btn');
|
||||
const importTextarea = document.getElementById('import-json-config');
|
||||
|
||||
if (importBtn) {
|
||||
importBtn.addEventListener('click', tryAutoFillFromJson);
|
||||
}
|
||||
|
||||
if (importTextarea) {
|
||||
importTextarea.addEventListener('paste', function(e) {
|
||||
setTimeout(() => {
|
||||
tryAutoFillFromJson();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
23
vpn/templates/admin/vpn/outlineserver/change_form.html
Normal file
23
vpn/templates/admin/vpn/outlineserver/change_form.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1 class="h4 m-0 pr-3 mr-3 border-right">
|
||||
{% if original %}
|
||||
🔵 Outline Server: {{ original.name }}
|
||||
{% else %}
|
||||
🔵 Add Outline Server
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_change_form_document_ready %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
// All JavaScript functionality is now handled by generate_link.js
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block field_sets %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
11
vpn/templates/admin/vpn/server/change_list.html.backup
Normal file
11
vpn/templates/admin/vpn/server/change_list.html.backup
Normal file
@@ -0,0 +1,11 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load admin_list admin_urls %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1>{{ cl.opts.verbose_name_plural|capfirst }}</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% comment %}
|
||||
This template overrides the default changelist to provide a cleaner interface
|
||||
without any bulk operations blocks that might be added by external packages
|
||||
{% endcomment %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1 class="h4 m-0 pr-3 mr-3 border-right">Task Execution Logs</h1>
|
||||
{% endblock %}
|
||||
219
vpn/templates/admin/vpn/user/change_form.html
Normal file
219
vpn/templates/admin/vpn/user/change_form.html
Normal file
@@ -0,0 +1,219 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1 class="h4 m-0 pr-3 mr-3 border-right">
|
||||
{% if original %}
|
||||
👤 User: {{ original.username }}
|
||||
{% else %}
|
||||
👤 Add User
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.user-management-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.user-management-section h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #495057;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-section {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-sm-custom {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.2rem;
|
||||
margin: 0 0.1rem;
|
||||
}
|
||||
|
||||
.readonly .user-management-section {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_change_form_document_ready %}
|
||||
{{ block.super }}
|
||||
{% if original %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userId = {{ original.id }};
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
// Show success/error messages in Django admin style
|
||||
function showMessage(message, type = 'success') {
|
||||
const messageClass = type === 'error' ? 'error' : 'success';
|
||||
const messageHtml = `
|
||||
<div class="alert alert-${messageClass} alert-dismissible" style="margin: 1rem 0;">
|
||||
${message}
|
||||
<button type="button" class="close" aria-label="Close" onclick="this.parentElement.remove()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const target = document.querySelector('.card-body') || document.querySelector('.content');
|
||||
if (target) {
|
||||
target.insertAdjacentHTML('afterbegin', messageHtml);
|
||||
setTimeout(() => {
|
||||
const alert = target.querySelector('.alert');
|
||||
if (alert) alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new link functionality
|
||||
document.querySelectorAll('.add-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const serverId = this.dataset.serverId;
|
||||
const serverName = this.dataset.serverName;
|
||||
|
||||
const comment = prompt(`Add comment for new link on ${serverName} (optional):`, '');
|
||||
if (comment === null) return;
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '⏳ Adding...';
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/user/${userId}/add-link/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: `server_id=${serverId}&comment=${encodeURIComponent(comment)}`
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ New link created successfully: ${data.link}`);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
showMessage(`❌ Error: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ Network error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete link functionality
|
||||
document.querySelectorAll('.delete-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const linkId = this.dataset.linkId;
|
||||
const linkName = this.dataset.linkName;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete link ${linkName}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '⏳ Deleting...';
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/user/${userId}/delete-link/${linkId}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ Link ${linkName} deleted successfully`);
|
||||
this.closest('.link-item')?.remove();
|
||||
} else {
|
||||
showMessage(`❌ Error: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ Network error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add server access functionality
|
||||
document.querySelectorAll('.add-server-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const serverId = this.dataset.serverId;
|
||||
const serverName = this.dataset.serverName;
|
||||
|
||||
if (!confirm(`Add access to server ${serverName}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '⏳ Adding...';
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/user/${userId}/add-server-access/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: `server_id=${serverId}`
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ Access to ${serverName} added successfully`);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
showMessage(`❌ Error: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ Network error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
727
vpn/templates/vpn/user_portal.html
Normal file
727
vpn/templates/vpn/user_portal.html
Normal file
@@ -0,0 +1,727 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VPN Access Portal - {{ user.username }}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #0c0c0c 0%, #1a1a1a 100%);
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #4ade80;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stats-info {
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-info p {
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.servers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 30px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.server-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: #4ade80;
|
||||
box-shadow: 0 20px 40px rgba(74, 222, 128, 0.1);
|
||||
}
|
||||
|
||||
.server-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.connection-count {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(74, 222, 128, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.connection-count:hover {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
border-color: rgba(74, 222, 128, 0.4);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.server-type {
|
||||
background: #4ade80;
|
||||
color: #000;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 25px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.links-container {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.link-item:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(74, 222, 128, 0.5);
|
||||
}
|
||||
|
||||
.link-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.link-info {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.link-comment {
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.link-stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.last-used {
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(156, 163, 175, 0.1);
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(156, 163, 175, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.last-used:hover {
|
||||
background: rgba(156, 163, 175, 0.2);
|
||||
border-color: rgba(156, 163, 175, 0.4);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.usage-count {
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.usage-count:hover {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.recent-count {
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(168, 85, 247, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.recent-count:hover {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
border-color: rgba(168, 85, 247, 0.4);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.usage-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
color: #9ca3af;
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 1px;
|
||||
height: 30px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
background: linear-gradient(to top, #4ade80, #22c55e);
|
||||
width: 4px;
|
||||
border-radius: 1px 1px 0 0;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 2px;
|
||||
opacity: 0.7;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.chart-bar:hover {
|
||||
opacity: 1;
|
||||
transform: scaleY(1.1);
|
||||
background: linear-gradient(to top, #22c55e, #16a34a);
|
||||
}
|
||||
|
||||
.chart-bar.zero {
|
||||
background: rgba(107, 114, 128, 0.3);
|
||||
height: 2px !important;
|
||||
}
|
||||
|
||||
.link-url {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #4ade80;
|
||||
word-break: break-all;
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: #4ade80;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #6b7280;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #4ade80;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.servers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.server-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.link-stats {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-chart {
|
||||
min-width: 160px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
width: 120px;
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-servers {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.no-servers h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚀 VPN Access Portal</h1>
|
||||
<div class="subtitle">Welcome back, <strong>{{ user.username }}</strong></div>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_servers }}</span>
|
||||
<span class="stat-label">Available Servers</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_links }}</span>
|
||||
<span class="stat-label">Active Links</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_connections }}</span>
|
||||
<span class="stat-label">Total Uses</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ recent_connections }}</span>
|
||||
<span class="stat-label">Last 30 Days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
{% if total_connections == 0 and total_links > 0 %}
|
||||
<p>📊 Statistics cache is empty. Run update in Admin → Task Execution Logs</p>
|
||||
{% else %}
|
||||
<p>📊 Statistics are updated every 5 minutes and show your connection history</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Xray Subscription Link -->
|
||||
{% if has_xray_servers and user_links %}
|
||||
<div class="xray-subscription" style="margin-top: 20px; padding: 15px; background: rgba(148, 163, 184, 0.1); border: 1px solid rgba(148, 163, 184, 0.3); border-radius: 12px;">
|
||||
<h3 style="color: #94a3b8; margin-bottom: 10px; font-size: 1.1rem;">🚀 Xray Universal Subscription</h3>
|
||||
<p style="color: #64748b; font-size: 0.9rem; margin-bottom: 10px;">
|
||||
One link for all your Xray protocols (VLESS, VMess, Trojan)
|
||||
</p>
|
||||
<div class="link-url" style="margin-bottom: 0;">
|
||||
{% url 'xray_subscription' user_links.0.link as xray_url %}{{ request.scheme }}://{{ request.get_host }}{{ xray_url }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ request.scheme }}://{{ request.get_host }}{{ xray_url }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if servers_data %}
|
||||
<div class="servers-grid">
|
||||
{% for server_name, server_data in servers_data.items %}
|
||||
<div class="server-card">
|
||||
<div class="server-header">
|
||||
<div class="server-info">
|
||||
<div class="server-name">{{ server_name }}</div>
|
||||
<div class="server-stats">
|
||||
<span class="connection-count">📊 {{ server_data.total_connections }} uses</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-type">{{ server_data.server_type }}</div>
|
||||
</div>
|
||||
|
||||
<div class="server-status">
|
||||
{% if server_data.accessible %}
|
||||
<div class="status-indicator status-online">
|
||||
<div class="status-dot"></div>
|
||||
Online & Ready
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-indicator status-offline">
|
||||
<div class="status-dot"></div>
|
||||
Connection Issues
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="links-container">
|
||||
{% for link_data in server_data.links %}
|
||||
<div class="link-item">
|
||||
<div class="link-header">
|
||||
<div class="link-info">
|
||||
<div class="link-comment">📱 {{ link_data.comment }}</div>
|
||||
<div class="link-stats">
|
||||
<span class="usage-count">✨ {{ link_data.connections }} uses</span>
|
||||
<span class="recent-count">📅 {{ link_data.recent_connections }} last 30 days</span>
|
||||
<span class="last-used">🕒 {{ link_data.last_access_display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-chart" data-usage="{{ link_data.daily_usage|join:',' }}" data-max="{{ link_data.max_daily }}">
|
||||
<div class="chart-title">30-day activity</div>
|
||||
<div class="chart-bars">
|
||||
{% for day_usage in link_data.daily_usage %}
|
||||
<div class="chart-bar" data-height="{{ day_usage }}" data-max="{{ link_data.max_daily }}"></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-url">
|
||||
{{ link_data.url }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ link_data.url }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-servers">
|
||||
<h3>No VPN Access Available</h3>
|
||||
<p>You don't have access to any VPN servers yet. Please contact your administrator.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://github.com/house-of-vanity/OutFleet" target="_blank">OutFleet VPN Manager</a></p>
|
||||
<p>Keep this link secure and don't share it with others</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Copy to clipboard functionality
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
// Visual feedback
|
||||
const event = new CustomEvent('copied');
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Show temporary feedback
|
||||
showCopyFeedback();
|
||||
} catch (err) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
showCopyFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
function showCopyFeedback() {
|
||||
// Create and show a toast notification
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = 'Link copied to clipboard! ✓';
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #4ade80;
|
||||
color: #000;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => document.body.removeChild(toast), 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Add animation styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes barGrow {
|
||||
from { transform: scaleY(0); }
|
||||
to { transform: scaleY(1); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Update page title with username
|
||||
document.title = `VPN Portal - {{ user.username }}`;
|
||||
|
||||
// Add some interactivity on load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize chart bars
|
||||
initializeCharts();
|
||||
|
||||
// Animate cards on load
|
||||
const cards = document.querySelectorAll('.server-card');
|
||||
cards.forEach((card, index) => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
card.style.transition = 'all 0.6s ease';
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'translateY(0)';
|
||||
}, index * 150);
|
||||
});
|
||||
|
||||
// Animate stat numbers
|
||||
const statNumbers = document.querySelectorAll('.stat-number');
|
||||
statNumbers.forEach((stat, index) => {
|
||||
const finalValue = parseInt(stat.textContent);
|
||||
if (finalValue > 0) {
|
||||
stat.textContent = '0';
|
||||
let current = 0;
|
||||
const increment = Math.ceil(finalValue / 20);
|
||||
const timer = setInterval(() => {
|
||||
current += increment;
|
||||
if (current >= finalValue) {
|
||||
stat.textContent = finalValue;
|
||||
clearInterval(timer);
|
||||
} else {
|
||||
stat.textContent = current;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Add pulse animation to connection counts
|
||||
setTimeout(() => {
|
||||
const connectionCounts = document.querySelectorAll('.connection-count, .usage-count, .recent-count');
|
||||
connectionCounts.forEach((count, index) => {
|
||||
setTimeout(() => {
|
||||
count.style.animation = 'pulse 0.6s ease-in-out';
|
||||
}, index * 100);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Animate chart bars
|
||||
setTimeout(() => {
|
||||
const chartBars = document.querySelectorAll('.chart-bar');
|
||||
chartBars.forEach((bar, index) => {
|
||||
setTimeout(() => {
|
||||
bar.style.animation = 'barGrow 0.8s ease-out';
|
||||
}, index * 50);
|
||||
});
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
function initializeCharts() {
|
||||
const charts = document.querySelectorAll('.usage-chart');
|
||||
|
||||
charts.forEach(chart => {
|
||||
const maxValue = parseInt(chart.dataset.max) || 1;
|
||||
const bars = chart.querySelectorAll('.chart-bar');
|
||||
|
||||
bars.forEach(bar => {
|
||||
const height = parseInt(bar.dataset.height) || 0;
|
||||
const maxHeight = parseInt(bar.dataset.max) || 1;
|
||||
|
||||
if (height === 0) {
|
||||
bar.classList.add('zero');
|
||||
bar.style.height = '2px';
|
||||
} else {
|
||||
// Calculate height as percentage of container (30px max)
|
||||
const percentage = Math.max(10, (height / Math.max(maxHeight, 1)) * 100);
|
||||
const pixelHeight = Math.max(3, (percentage / 100) * 28); // 28px max for padding
|
||||
bar.style.height = pixelHeight + 'px';
|
||||
|
||||
// Add tooltip
|
||||
bar.title = `${height} connections`;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
148
vpn/templates/vpn/user_portal_error.html
Normal file
148
vpn/templates/vpn/user_portal_error.html
Normal file
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ error_title }} - VPN Portal</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #0c0c0c 0%, #1a1a1a 100%);
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
max-width: 500px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
animation: fadeInUp 0.6s ease;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
color: #f87171;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #9ca3af;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
background: #4ade80;
|
||||
color: #000;
|
||||
padding: 12px 24px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: #22c55e;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #4ade80;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-card">
|
||||
<div class="error-icon">🚫</div>
|
||||
<h1 class="error-title">{{ error_title }}</h1>
|
||||
<p class="error-message">{{ error_message }}</p>
|
||||
|
||||
<a href="javascript:history.back()" class="back-link">← Go Back</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://github.com/house-of-vanity/OutFleet" target="_blank">OutFleet VPN Manager</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Add some interactivity
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-refresh every 30 seconds for server errors
|
||||
{% if 'Server Error' in error_title %}
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 30000);
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3
vpn/tests.py
Normal file
3
vpn/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
400
vpn/views.py
Normal file
400
vpn/views.py
Normal file
@@ -0,0 +1,400 @@
|
||||
def userPortal(request, user_hash):
|
||||
"""HTML portal for user to view their VPN access links and server information"""
|
||||
from .models import User, ACLLink, UserStatistics, AccessLog
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
user = get_object_or_404(User, hash=user_hash)
|
||||
logger.info(f"User portal accessed for user {user.username}")
|
||||
except Http404:
|
||||
logger.warning(f"User portal access attempt with invalid hash: {user_hash}")
|
||||
return render(request, 'vpn/user_portal_error.html', {
|
||||
'error_title': 'Access Denied',
|
||||
'error_message': 'Invalid access link. Please contact your administrator.'
|
||||
}, status=403)
|
||||
|
||||
try:
|
||||
# Get all ACL links for the user with server information
|
||||
acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server', 'acl')
|
||||
logger.info(f"Found {acl_links.count()} ACL links for user {user.username}")
|
||||
|
||||
# Calculate overall statistics from cached data (only where cache exists)
|
||||
user_stats = UserStatistics.objects.filter(user=user)
|
||||
if user_stats.exists():
|
||||
total_connections = sum(stat.total_connections for stat in user_stats)
|
||||
recent_connections = sum(stat.recent_connections for stat in user_stats)
|
||||
logger.info(f"User {user.username} cached stats: total_connections={total_connections}, recent_connections={recent_connections}")
|
||||
else:
|
||||
# No cache available, set to zero and suggest cache update
|
||||
total_connections = 0
|
||||
recent_connections = 0
|
||||
logger.warning(f"No cached statistics found for user {user.username}. Run statistics update task.")
|
||||
|
||||
# Group links by server
|
||||
servers_data = {}
|
||||
total_links = 0
|
||||
|
||||
for link in acl_links:
|
||||
server = link.acl.server
|
||||
server_name = server.name
|
||||
logger.debug(f"Processing link {link.link} for server {server_name}")
|
||||
|
||||
if server_name not in servers_data:
|
||||
# Get server status and info
|
||||
try:
|
||||
server_status = server.get_server_status()
|
||||
server_accessible = True
|
||||
server_error = None
|
||||
logger.debug(f"Server {server_name} status retrieved successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get status for server {server_name}: {e}")
|
||||
server_status = {}
|
||||
server_accessible = False
|
||||
server_error = str(e)
|
||||
|
||||
# Calculate server-level totals from cached stats (only where cache exists)
|
||||
server_stats = user_stats.filter(server_name=server_name)
|
||||
if server_stats.exists():
|
||||
server_total_connections = sum(stat.total_connections for stat in server_stats)
|
||||
else:
|
||||
server_total_connections = 0
|
||||
|
||||
servers_data[server_name] = {
|
||||
'server': server,
|
||||
'status': server_status,
|
||||
'accessible': server_accessible,
|
||||
'error': server_error,
|
||||
'links': [],
|
||||
'server_type': server.server_type,
|
||||
'total_connections': server_total_connections,
|
||||
}
|
||||
logger.debug(f"Created server data for {server_name} with {server_total_connections} cached connections")
|
||||
|
||||
# Calculate time since last access
|
||||
last_access_display = "Never used"
|
||||
if link.last_access_time:
|
||||
time_diff = timezone.now() - link.last_access_time
|
||||
if time_diff.days > 0:
|
||||
last_access_display = f"{time_diff.days} days ago"
|
||||
elif time_diff.seconds > 3600:
|
||||
hours = time_diff.seconds // 3600
|
||||
last_access_display = f"{hours} hours ago"
|
||||
elif time_diff.seconds > 60:
|
||||
minutes = time_diff.seconds // 60
|
||||
last_access_display = f"{minutes} minutes ago"
|
||||
else:
|
||||
last_access_display = "Just now"
|
||||
|
||||
# Get cached statistics for this specific link
|
||||
try:
|
||||
link_stats = UserStatistics.objects.get(
|
||||
user=user,
|
||||
server_name=server_name,
|
||||
acl_link_id=link.link
|
||||
)
|
||||
logger.debug(f"Found cached stats for link {link.link}: {link_stats.total_connections} connections, max_daily={link_stats.max_daily}")
|
||||
|
||||
link_connections = link_stats.total_connections
|
||||
link_recent_connections = link_stats.recent_connections
|
||||
daily_usage = link_stats.daily_usage or []
|
||||
max_daily = link_stats.max_daily
|
||||
|
||||
except UserStatistics.DoesNotExist:
|
||||
logger.warning(f"No cached stats found for link {link.link} on server {server_name}, using fallback")
|
||||
|
||||
# Fallback: Since AccessLog doesn't track specific links, show zero for link-specific stats
|
||||
# but keep server-level stats for context
|
||||
link_connections = 0
|
||||
link_recent_connections = 0
|
||||
daily_usage = [0] * 30 # Empty 30-day chart
|
||||
max_daily = 0
|
||||
|
||||
logger.warning(f"Using zero stats for uncached link {link.link} - AccessLog doesn't track individual links")
|
||||
|
||||
logger.debug(f"Link {link.link} stats: connections={link_connections}, recent={link_recent_connections}, max_daily={max_daily}")
|
||||
|
||||
# Add link information with statistics
|
||||
link_url = f"{EXTERNAL_ADDRESS}/ss/{link.link}#{server_name}"
|
||||
|
||||
link_data = {
|
||||
'link': link,
|
||||
'url': link_url,
|
||||
'comment': link.comment or 'Default',
|
||||
'last_access': link.last_access_time,
|
||||
'last_access_display': last_access_display,
|
||||
'connections': link_connections,
|
||||
'recent_connections': link_recent_connections,
|
||||
'daily_usage': daily_usage,
|
||||
'max_daily': max_daily,
|
||||
}
|
||||
|
||||
servers_data[server_name]['links'].append(link_data)
|
||||
total_links += 1
|
||||
|
||||
logger.debug(f"Added comprehensive link data for {link.link}")
|
||||
|
||||
logger.info(f"Prepared data for {len(servers_data)} servers and {total_links} total links")
|
||||
logger.info(f"Portal statistics: total_connections={total_connections}, recent_connections={recent_connections}")
|
||||
|
||||
# Check if user has access to any Xray servers
|
||||
from vpn.server_plugins import XrayCoreServer, XrayInboundServer
|
||||
has_xray_servers = any(
|
||||
isinstance(acl_link.acl.server.get_real_instance(), (XrayCoreServer, XrayInboundServer))
|
||||
for acl_link in acl_links
|
||||
)
|
||||
|
||||
context = {
|
||||
'user': user,
|
||||
'user_links': acl_links, # For accessing user's links in template
|
||||
'servers_data': servers_data,
|
||||
'total_servers': len(servers_data),
|
||||
'total_links': total_links,
|
||||
'total_connections': total_connections,
|
||||
'recent_connections': recent_connections,
|
||||
'external_address': EXTERNAL_ADDRESS,
|
||||
'has_xray_servers': has_xray_servers,
|
||||
}
|
||||
|
||||
logger.debug(f"Context prepared with keys: {list(context.keys())}")
|
||||
logger.debug(f"Servers in context: {list(servers_data.keys())}")
|
||||
|
||||
# Log sample server data for debugging
|
||||
for server_name, server_data in servers_data.items():
|
||||
logger.debug(f"Server {server_name}: total_connections={server_data['total_connections']}, links_count={len(server_data['links'])}")
|
||||
for i, link_data in enumerate(server_data['links']):
|
||||
logger.debug(f" Link {i}: connections={link_data['connections']}, recent={link_data['recent_connections']}, last_access='{link_data['last_access_display']}'")
|
||||
|
||||
return render(request, 'vpn/user_portal.html', context)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading user portal for {user.username}: {e}", exc_info=True)
|
||||
return render(request, 'vpn/user_portal_error.html', {
|
||||
'error_title': 'Server Error',
|
||||
'error_message': 'Unable to load your VPN information. Please try again later or contact support.'
|
||||
}, status=500)
|
||||
import yaml
|
||||
import json
|
||||
import logging
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.http import JsonResponse, HttpResponse, Http404
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
|
||||
def userFrontend(request, user_hash):
|
||||
from .models import User, ACLLink
|
||||
try:
|
||||
user = get_object_or_404(User, hash=user_hash)
|
||||
except Http404:
|
||||
return JsonResponse({"error": "Not allowed"}, status=403)
|
||||
|
||||
acl_links = {}
|
||||
for link in ACLLink.objects.filter(acl__user=user).select_related('acl__server'):
|
||||
server_name = link.acl.server.name
|
||||
if server_name not in acl_links:
|
||||
acl_links[server_name] = []
|
||||
acl_links[server_name].append({"link": f"{EXTERNAL_ADDRESS}/ss/{link.link}#{link.acl.server.name}", "comment": link.comment})
|
||||
|
||||
return JsonResponse(acl_links)
|
||||
|
||||
def shadowsocks(request, link):
|
||||
from .models import ACLLink, AccessLog
|
||||
import logging
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
acl_link = get_object_or_404(ACLLink, link=link)
|
||||
acl = acl_link.acl
|
||||
logger.info(f"Found ACL link for user {acl.user.username} on server {acl.server.name}")
|
||||
except Http404:
|
||||
logger.warning(f"ACL link not found: {link}")
|
||||
AccessLog.objects.create(
|
||||
user=None,
|
||||
server="Unknown",
|
||||
acl_link_id=link,
|
||||
action="Failed",
|
||||
data=f"ACL not found for link: {link}"
|
||||
)
|
||||
return JsonResponse({"error": "Not allowed"}, status=403)
|
||||
|
||||
try:
|
||||
server_user = acl.server.get_user(acl.user, raw=True)
|
||||
logger.info(f"Successfully retrieved user credentials for {acl.user.username} from {acl.server.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user credentials for {acl.user.username} from {acl.server.name}: {e}")
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
server=acl.server.name,
|
||||
acl_link_id=acl_link.link,
|
||||
action="Failed",
|
||||
data=f"Failed to get credentials: {e}"
|
||||
)
|
||||
return JsonResponse({"error": f"Couldn't get credentials from server. {e}"}, status=500)
|
||||
|
||||
if request.GET.get('mode') == 'json':
|
||||
config = {
|
||||
"info": "Managed by OutFleet_2 [github.com/house-of-vanity/OutFleet/]",
|
||||
"password": server_user.password,
|
||||
"method": server_user.method,
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001",
|
||||
"server": acl.server.client_hostname,
|
||||
"server_port": server_user.port,
|
||||
"access_url": server_user.access_url,
|
||||
"outfleet": {
|
||||
"acl_link": link,
|
||||
"server_name": acl.server.name,
|
||||
"server_type": acl.server.server_type,
|
||||
}
|
||||
}
|
||||
response = json.dumps(config, indent=2)
|
||||
else:
|
||||
config = {
|
||||
"transport": {
|
||||
"$type": "tcpudp",
|
||||
"tcp": {
|
||||
"$type": "shadowsocks",
|
||||
"endpoint": f"{acl.server.client_hostname}:{server_user.port}",
|
||||
"cipher": f"{server_user.method}",
|
||||
"secret": f"{server_user.password}",
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001"
|
||||
},
|
||||
"udp": {
|
||||
"$type": "shadowsocks",
|
||||
"endpoint": f"{acl.server.client_hostname}:{server_user.port}",
|
||||
"cipher": f"{server_user.method}",
|
||||
"secret": f"{server_user.password}",
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001"
|
||||
}
|
||||
}
|
||||
}
|
||||
response = yaml.dump(config, allow_unicode=True)
|
||||
|
||||
# Update last access time for this specific link
|
||||
acl_link.last_access_time = timezone.now()
|
||||
acl_link.save(update_fields=['last_access_time'])
|
||||
|
||||
# Create AccessLog with specific link tracking
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
server=acl.server.name,
|
||||
acl_link_id=acl_link.link,
|
||||
action="Success",
|
||||
data=response
|
||||
)
|
||||
|
||||
return HttpResponse(response, content_type=f"{ 'application/json; charset=utf-8' if request.GET.get('mode') == 'json' else 'text/html' }")
|
||||
|
||||
|
||||
def xray_subscription(request, link):
|
||||
"""
|
||||
Return Xray subscription with all available protocols for the user.
|
||||
This generates a single subscription link that includes all inbounds the user has access to.
|
||||
"""
|
||||
from .models import ACLLink, AccessLog
|
||||
from vpn.server_plugins import XrayCoreServer, XrayInboundServer
|
||||
import logging
|
||||
from django.utils import timezone
|
||||
import base64
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
acl_link = get_object_or_404(ACLLink, link=link)
|
||||
acl = acl_link.acl
|
||||
logger.info(f"Found ACL link for user {acl.user.username} on server {acl.server.name}")
|
||||
except Http404:
|
||||
logger.warning(f"ACL link not found: {link}")
|
||||
AccessLog.objects.create(
|
||||
user=None,
|
||||
server="Unknown",
|
||||
acl_link_id=link,
|
||||
action="Failed",
|
||||
data=f"ACL not found for link: {link}"
|
||||
)
|
||||
return HttpResponse("Not found", status=404)
|
||||
|
||||
try:
|
||||
# Get all servers this user has access to
|
||||
user_acls = acl.user.acl_set.all()
|
||||
subscription_configs = []
|
||||
|
||||
for user_acl in user_acls:
|
||||
server = user_acl.server.get_real_instance()
|
||||
|
||||
# Handle XrayInboundServer (individual inbounds)
|
||||
if isinstance(server, XrayInboundServer):
|
||||
if server.xray_inbound:
|
||||
config = server.get_user(acl.user, raw=True)
|
||||
if config and 'connection_string' in config:
|
||||
subscription_configs.append(config['connection_string'])
|
||||
logger.info(f"Added XrayInboundServer config for {server.name}")
|
||||
|
||||
# Handle XrayCoreServer (parent server with multiple inbounds)
|
||||
elif isinstance(server, XrayCoreServer):
|
||||
try:
|
||||
# Get all inbounds for this server that have this user
|
||||
for inbound in server.inbounds.filter(enabled=True):
|
||||
# Check if user has a client in this inbound
|
||||
client = inbound.clients.filter(user=acl.user).first()
|
||||
if client:
|
||||
connection_string = server._generate_connection_string(client)
|
||||
if connection_string:
|
||||
subscription_configs.append(connection_string)
|
||||
logger.info(f"Added inbound {inbound.tag} config for user {acl.user.username}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get configs from XrayCoreServer {server.name}: {e}")
|
||||
|
||||
if not subscription_configs:
|
||||
logger.warning(f"No Xray configurations found for user {acl.user.username}")
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
server="Multiple",
|
||||
acl_link_id=acl_link.link,
|
||||
action="Failed",
|
||||
data="No Xray configurations available"
|
||||
)
|
||||
return HttpResponse("No configurations available", status=404)
|
||||
|
||||
# Join all configs with newlines and encode in base64 for subscription format
|
||||
subscription_content = '\n'.join(subscription_configs)
|
||||
logger.info(f"Raw subscription content for {acl.user.username}:\n{subscription_content}")
|
||||
|
||||
subscription_b64 = base64.b64encode(subscription_content.encode('utf-8')).decode('utf-8')
|
||||
logger.info(f"Base64 subscription length: {len(subscription_b64)}")
|
||||
|
||||
# Update last access time
|
||||
acl_link.last_access_time = timezone.now()
|
||||
acl_link.save(update_fields=['last_access_time'])
|
||||
|
||||
# Create access log
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
server="Xray-Subscription",
|
||||
acl_link_id=acl_link.link,
|
||||
action="Success",
|
||||
data=f"Generated subscription with {len(subscription_configs)} configs. Content: {subscription_content[:200]}..."
|
||||
)
|
||||
|
||||
logger.info(f"Generated Xray subscription for {acl.user.username} with {len(subscription_configs)} configs")
|
||||
|
||||
# Return with proper headers for subscription
|
||||
response = HttpResponse(subscription_b64, content_type="text/plain; charset=utf-8")
|
||||
response['Content-Disposition'] = 'attachment; filename="xray_subscription.txt"'
|
||||
response['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate Xray subscription for {acl.user.username}: {e}")
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
server="Xray-Subscription",
|
||||
acl_link_id=acl_link.link,
|
||||
action="Failed",
|
||||
data=f"Failed to generate subscription: {e}"
|
||||
)
|
||||
return HttpResponse(f"Error generating subscription: {e}", status=500)
|
||||
|
||||
23
vpn/xray_api/__init__.py
Normal file
23
vpn/xray_api/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Xray Manager - Python library for managing Xray proxy server via gRPC API.
|
||||
|
||||
Supports VLESS, VMess, and Trojan protocols.
|
||||
"""
|
||||
|
||||
from .client import XrayClient
|
||||
from .models import User, VlessUser, VmessUser, TrojanUser, Stats
|
||||
from .exceptions import XrayError, APIError, InboundNotFoundError, UserNotFoundError
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__all__ = [
|
||||
"XrayClient",
|
||||
"User",
|
||||
"VlessUser",
|
||||
"VmessUser",
|
||||
"TrojanUser",
|
||||
"Stats",
|
||||
"XrayError",
|
||||
"APIError",
|
||||
"InboundNotFoundError",
|
||||
"UserNotFoundError"
|
||||
]
|
||||
577
vpn/xray_api/client.py
Normal file
577
vpn/xray_api/client.py
Normal file
@@ -0,0 +1,577 @@
|
||||
"""
|
||||
Main Xray client for managing proxy server via gRPC API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .exceptions import APIError, InboundNotFoundError, UserNotFoundError
|
||||
from .models import Stats, TrojanUser, User, VlessUser, VmessUser
|
||||
from .protocols import TrojanProtocol, VlessProtocol, VmessProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XrayClient:
|
||||
"""Main client for Xray server management."""
|
||||
|
||||
def __init__(self, server: str):
|
||||
"""
|
||||
Initialize Xray client.
|
||||
|
||||
Args:
|
||||
server: Xray gRPC API server address (host:port)
|
||||
"""
|
||||
self.server = server
|
||||
self.hostname = server.split(':')[0] # Extract hostname for client links
|
||||
|
||||
# Protocol handlers
|
||||
self._protocols = {}
|
||||
|
||||
# Inbound management
|
||||
|
||||
def add_vless_inbound(self, port: int, users: List[VlessUser], tag: Optional[str] = None,
|
||||
listen: str = "0.0.0.0", network: str = "tcp") -> None:
|
||||
"""Add VLESS inbound with users."""
|
||||
protocol = VlessProtocol(port, tag, listen, network)
|
||||
self._protocols[protocol.tag] = protocol
|
||||
config = protocol.create_inbound_config(users)
|
||||
self._add_inbound(config)
|
||||
|
||||
def add_vmess_inbound(self, port: int, users: List[VmessUser], tag: Optional[str] = None,
|
||||
listen: str = "0.0.0.0", network: str = "tcp") -> None:
|
||||
"""Add VMess inbound with users."""
|
||||
protocol = VmessProtocol(port, tag, listen, network)
|
||||
self._protocols[protocol.tag] = protocol
|
||||
config = protocol.create_inbound_config(users)
|
||||
self._add_inbound(config)
|
||||
|
||||
def add_trojan_inbound(self, port: int, users: List[TrojanUser], tag: Optional[str] = None,
|
||||
listen: str = "0.0.0.0", network: str = "tcp",
|
||||
cert_pem: Optional[str] = None, key_pem: Optional[str] = None,
|
||||
hostname: Optional[str] = None) -> None:
|
||||
"""Add Trojan inbound with users and optional custom certificates."""
|
||||
hostname = hostname or self.hostname
|
||||
protocol = TrojanProtocol(port, tag, listen, network, cert_pem, key_pem, hostname)
|
||||
self._protocols[protocol.tag] = protocol
|
||||
config = protocol.create_inbound_config(users)
|
||||
self._add_inbound(config)
|
||||
|
||||
|
||||
def remove_inbound(self, protocol_type_or_tag: str) -> None:
|
||||
"""
|
||||
Remove inbound by protocol type or tag.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
"""
|
||||
# Try to find by protocol type first
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
|
||||
|
||||
config = {"tag": tag}
|
||||
self._remove_inbound(config)
|
||||
|
||||
if tag in self._protocols:
|
||||
del self._protocols[tag]
|
||||
|
||||
def list_inbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List all inbounds."""
|
||||
return self._list_inbounds()
|
||||
|
||||
# User management
|
||||
|
||||
def add_user(self, protocol_type_or_tag: str, user: User) -> None:
|
||||
"""
|
||||
Add user to existing inbound by recreating it with updated users.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
user: User object matching the protocol type
|
||||
"""
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
# Check if it's a protocol type or direct tag
|
||||
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
|
||||
|
||||
# If protocol not registered, we need to get inbound info first
|
||||
if tag not in self._protocols:
|
||||
logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info")
|
||||
|
||||
# Try to get inbound info to determine protocol
|
||||
try:
|
||||
inbounds = self._list_inbounds()
|
||||
if isinstance(inbounds, dict) and 'inbounds' in inbounds:
|
||||
inbound_list = inbounds['inbounds']
|
||||
else:
|
||||
inbound_list = inbounds if isinstance(inbounds, list) else []
|
||||
|
||||
# Find the inbound by tag
|
||||
for inbound in inbound_list:
|
||||
if inbound.get('tag') == tag:
|
||||
# Determine protocol from proxySettings
|
||||
proxy_settings = inbound.get('proxySettings', {})
|
||||
typed_message = proxy_settings.get('_TypedMessage_', '')
|
||||
|
||||
if 'vless' in typed_message.lower():
|
||||
from .protocols import VlessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VlessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'vmess' in typed_message.lower():
|
||||
from .protocols import VmessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VmessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'trojan' in typed_message.lower():
|
||||
from .protocols import TrojanProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname)
|
||||
self._protocols[tag] = protocol
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}")
|
||||
|
||||
if tag not in self._protocols:
|
||||
raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol")
|
||||
|
||||
protocol = self._protocols[tag]
|
||||
|
||||
# Use the recreate method since direct API doesn't work reliably
|
||||
self._recreate_inbound_with_user(protocol, user)
|
||||
|
||||
def remove_user(self, protocol_type_or_tag: str, email: str) -> None:
|
||||
"""
|
||||
Remove user from inbound by recreating it without the user.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
email: User email to remove
|
||||
"""
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
|
||||
|
||||
# Use same logic as add_user to find/register protocol
|
||||
if tag not in self._protocols:
|
||||
logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info")
|
||||
|
||||
# Try to get inbound info to determine protocol
|
||||
try:
|
||||
inbounds = self._list_inbounds()
|
||||
if isinstance(inbounds, dict) and 'inbounds' in inbounds:
|
||||
inbound_list = inbounds['inbounds']
|
||||
else:
|
||||
inbound_list = inbounds if isinstance(inbounds, list) else []
|
||||
|
||||
# Find the inbound by tag
|
||||
for inbound in inbound_list:
|
||||
if inbound.get('tag') == tag:
|
||||
# Determine protocol from proxySettings
|
||||
proxy_settings = inbound.get('proxySettings', {})
|
||||
typed_message = proxy_settings.get('_TypedMessage_', '')
|
||||
|
||||
if 'vless' in typed_message.lower():
|
||||
from .protocols import VlessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VlessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'vmess' in typed_message.lower():
|
||||
from .protocols import VmessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VmessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'trojan' in typed_message.lower():
|
||||
from .protocols import TrojanProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname)
|
||||
self._protocols[tag] = protocol
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}")
|
||||
|
||||
if tag not in self._protocols:
|
||||
raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol")
|
||||
|
||||
protocol = self._protocols[tag]
|
||||
|
||||
# Use the recreate method
|
||||
self._recreate_inbound_without_user(protocol, email)
|
||||
|
||||
# Client link generation
|
||||
|
||||
def generate_client_link(self, protocol_type_or_tag: str, user: User) -> str:
|
||||
"""
|
||||
Generate client connection link.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
user: User object
|
||||
|
||||
Returns:
|
||||
Client connection link (vless://, vmess://, trojan://)
|
||||
"""
|
||||
# First try to find by protocol type
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
# Check if it's a protocol type or direct tag
|
||||
tag = tag_map.get(protocol_type_or_tag)
|
||||
if tag and tag in self._protocols:
|
||||
protocol = self._protocols[tag]
|
||||
elif protocol_type_or_tag in self._protocols:
|
||||
protocol = self._protocols[protocol_type_or_tag]
|
||||
else:
|
||||
# Try to find any protocol matching the type
|
||||
for stored_tag, stored_protocol in self._protocols.items():
|
||||
if protocol_type_or_tag in ['vless', 'vmess', 'trojan']:
|
||||
protocol_class_name = f"{protocol_type_or_tag.title()}Protocol"
|
||||
if stored_protocol.__class__.__name__ == protocol_class_name:
|
||||
protocol = stored_protocol
|
||||
break
|
||||
else:
|
||||
raise InboundNotFoundError(f"Protocol {protocol_type_or_tag} not configured")
|
||||
|
||||
return protocol.generate_client_link(user, self.hostname)
|
||||
|
||||
# Statistics
|
||||
|
||||
def get_server_stats(self) -> Dict[str, Any]:
|
||||
"""Get server system statistics."""
|
||||
return self._get_stats_sys()
|
||||
|
||||
def get_user_stats(self, protocol_type: str, email: str) -> Stats:
|
||||
"""
|
||||
Get user traffic statistics.
|
||||
|
||||
Args:
|
||||
protocol_type: Protocol type
|
||||
email: User email
|
||||
|
||||
Returns:
|
||||
Stats object with uplink/downlink data
|
||||
"""
|
||||
# Implementation would require stats queries
|
||||
# This is a placeholder for the interface
|
||||
return Stats(uplink=0, downlink=0)
|
||||
|
||||
# Private API methods
|
||||
|
||||
def _add_inbound(self, config: Dict[str, Any]) -> None:
|
||||
"""Add inbound via API."""
|
||||
result = self._run_api_command("adi", stdin_data=json.dumps(config))
|
||||
if "error" in result.get("stderr", "").lower():
|
||||
raise APIError(f"Failed to add inbound: {result['stderr']}")
|
||||
|
||||
def _remove_inbound(self, config: Dict[str, Any]) -> None:
|
||||
"""Remove inbound via API."""
|
||||
tag = config.get("tag")
|
||||
if tag:
|
||||
# Use tag directly as argument instead of JSON
|
||||
result = self._run_api_command("rmi", args=[tag])
|
||||
else:
|
||||
# Fallback to JSON if no tag
|
||||
result = self._run_api_command("rmi", stdin_data=json.dumps(config))
|
||||
|
||||
if result["returncode"] != 0 and "no inbound to remove" not in result.get("stderr", ""):
|
||||
raise APIError(f"Failed to remove inbound: {result['stderr']}")
|
||||
|
||||
def _list_inbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List inbounds via API."""
|
||||
result = self._run_api_command("lsi")
|
||||
if result["returncode"] != 0:
|
||||
raise APIError(f"Failed to list inbounds: {result['stderr']}")
|
||||
|
||||
try:
|
||||
return json.loads(result["stdout"])
|
||||
except json.JSONDecodeError:
|
||||
raise APIError("Invalid JSON response from API")
|
||||
|
||||
def _add_user(self, config: Dict[str, Any]) -> None:
|
||||
"""Add user via API."""
|
||||
logger.debug(f"Adding user with config: {json.dumps(config, indent=2)}")
|
||||
result = self._run_api_command("adu", stdin_data=json.dumps(config))
|
||||
|
||||
if result["returncode"] != 0 or "error" in result.get("stderr", "").lower():
|
||||
logger.error(f"Failed to add user. stdout: {result['stdout']}, stderr: {result['stderr']}")
|
||||
raise APIError(f"Failed to add user: {result['stderr'] or result['stdout']}")
|
||||
|
||||
logger.info(f"User {config.get('email')} added successfully to inbound {config.get('tag')}")
|
||||
|
||||
def _remove_user(self, inbound_tag: str, email: str) -> None:
|
||||
"""Remove user via API."""
|
||||
result = self._run_api_command("rmu", args=[f"--email={email}", inbound_tag])
|
||||
if "error" in result.get("stderr", "").lower():
|
||||
raise APIError(f"Failed to remove user: {result['stderr']}")
|
||||
|
||||
def _get_stats_sys(self) -> Dict[str, Any]:
|
||||
"""Get system stats via API."""
|
||||
result = self._run_api_command("statssys")
|
||||
if result["returncode"] != 0:
|
||||
raise APIError(f"Failed to get stats: {result['stderr']}")
|
||||
|
||||
try:
|
||||
return json.loads(result["stdout"])
|
||||
except json.JSONDecodeError:
|
||||
raise APIError("Invalid JSON response from API")
|
||||
|
||||
def _build_user_config(self, tag: str, user: User, protocol) -> Dict[str, Any]:
|
||||
"""
|
||||
Build user configuration for Xray API.
|
||||
|
||||
Args:
|
||||
tag: Inbound tag
|
||||
user: User object (VlessUser, VmessUser, or TrojanUser)
|
||||
protocol: Protocol handler
|
||||
|
||||
Returns:
|
||||
User configuration dict for Xray API
|
||||
"""
|
||||
from .models import VlessUser, VmessUser, TrojanUser
|
||||
|
||||
base_config = {
|
||||
"tag": tag,
|
||||
"email": user.email
|
||||
}
|
||||
|
||||
if isinstance(user, VlessUser):
|
||||
base_config["account"] = {
|
||||
"_TypedMessage_": "xray.proxy.vless.Account",
|
||||
"id": user.uuid
|
||||
}
|
||||
elif isinstance(user, VmessUser):
|
||||
base_config["account"] = {
|
||||
"_TypedMessage_": "xray.proxy.vmess.Account",
|
||||
"id": user.uuid,
|
||||
"alterId": getattr(user, 'alter_id', 0)
|
||||
}
|
||||
elif isinstance(user, TrojanUser):
|
||||
base_config["account"] = {
|
||||
"_TypedMessage_": "xray.proxy.trojan.Account",
|
||||
"password": user.password
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unsupported user type: {type(user)}")
|
||||
|
||||
return base_config
|
||||
|
||||
def _recreate_inbound_without_user(self, protocol, email: str) -> None:
|
||||
"""
|
||||
Recreate inbound without specified user.
|
||||
"""
|
||||
# Get existing users from the inbound
|
||||
existing_users = self._get_existing_users(protocol.tag)
|
||||
|
||||
# Filter out the user to remove
|
||||
all_users = [user for user in existing_users if user.email != email]
|
||||
|
||||
if len(all_users) == len(existing_users):
|
||||
logger.warning(f"User {email} not found in inbound {protocol.tag}")
|
||||
return
|
||||
|
||||
# Remove existing inbound
|
||||
try:
|
||||
self.remove_inbound(protocol.tag)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove inbound {protocol.tag}: {e}")
|
||||
|
||||
# Recreate inbound with remaining users
|
||||
if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__:
|
||||
self.add_vless_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__:
|
||||
self.add_vmess_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__:
|
||||
self.add_trojan_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network,
|
||||
hostname=getattr(protocol, 'hostname', 'localhost')
|
||||
)
|
||||
|
||||
def _recreate_inbound_with_user(self, protocol, new_user: User) -> None:
|
||||
"""
|
||||
Recreate inbound with existing users plus new user.
|
||||
This is a workaround since Xray API doesn't support reliable dynamic user addition.
|
||||
"""
|
||||
# Get existing users from the inbound
|
||||
existing_users = self._get_existing_users(protocol.tag)
|
||||
|
||||
# Check if user already exists
|
||||
for existing_user in existing_users:
|
||||
if existing_user.email == new_user.email:
|
||||
return # User already exists, no need to recreate
|
||||
|
||||
# Add new user to existing users list
|
||||
all_users = existing_users + [new_user]
|
||||
|
||||
# Remove existing inbound
|
||||
try:
|
||||
self.remove_inbound(protocol.tag)
|
||||
except Exception as e:
|
||||
# If removal fails, log but continue - inbound might not exist
|
||||
pass
|
||||
|
||||
# Recreate inbound with all users
|
||||
if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__:
|
||||
self.add_vless_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__:
|
||||
self.add_vmess_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__:
|
||||
self.add_trojan_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network,
|
||||
hostname=getattr(protocol, 'hostname', 'localhost')
|
||||
)
|
||||
|
||||
def _get_existing_users(self, tag: str) -> List[User]:
|
||||
"""
|
||||
Get existing users from an inbound.
|
||||
"""
|
||||
from .models import VlessUser, VmessUser, TrojanUser
|
||||
|
||||
try:
|
||||
# Use inbounduser API command to get existing users
|
||||
result = self._run_api_command("inbounduser", args=[f"-tag={tag}"])
|
||||
|
||||
if result["returncode"] != 0:
|
||||
return [] # No users or inbound doesn't exist
|
||||
|
||||
import json
|
||||
user_data = json.loads(result["stdout"])
|
||||
users = []
|
||||
|
||||
if "users" in user_data:
|
||||
for user_info in user_data["users"]:
|
||||
email = user_info.get("email", "")
|
||||
account = user_info.get("account", {})
|
||||
|
||||
# Determine protocol based on account type
|
||||
account_type = account.get("_TypedMessage_", "")
|
||||
|
||||
if "vless" in account_type.lower():
|
||||
users.append(VlessUser(
|
||||
email=email,
|
||||
uuid=account.get("id", "")
|
||||
))
|
||||
elif "vmess" in account_type.lower():
|
||||
users.append(VmessUser(
|
||||
email=email,
|
||||
uuid=account.get("id", ""),
|
||||
alter_id=account.get("alterId", 0)
|
||||
))
|
||||
elif "trojan" in account_type.lower():
|
||||
users.append(TrojanUser(
|
||||
email=email,
|
||||
password=account.get("password", "")
|
||||
))
|
||||
|
||||
return users
|
||||
|
||||
except Exception as e:
|
||||
# If we can't get existing users, return empty list
|
||||
return []
|
||||
|
||||
def _run_api_command(self, command: str, args: List[str] = None, stdin_data: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Run xray api command.
|
||||
|
||||
Args:
|
||||
command: API command (adi, rmi, lsi, etc.)
|
||||
args: Additional command arguments
|
||||
stdin_data: Data to pass via stdin
|
||||
|
||||
Returns:
|
||||
Dict with stdout, stderr, returncode
|
||||
"""
|
||||
cmd = ["xray", "api", command, f"--server={self.server}"]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
logger.debug(f"Running command: {' '.join(cmd)}")
|
||||
if stdin_data:
|
||||
logger.debug(f"With stdin data: {stdin_data}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=stdin_data,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
logger.debug(f"Command result - returncode: {result.returncode}, stdout: {result.stdout}, stderr: {result.stderr}")
|
||||
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"API command timeout for: {' '.join(cmd)}")
|
||||
raise APIError("API command timeout")
|
||||
except FileNotFoundError:
|
||||
logger.error("xray command not found in PATH")
|
||||
raise APIError("xray command not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error running command: {e}")
|
||||
raise APIError(f"Failed to run command: {e}")
|
||||
33
vpn/xray_api/exceptions.py
Normal file
33
vpn/xray_api/exceptions.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Custom exceptions for Xray Manager.
|
||||
"""
|
||||
|
||||
|
||||
class XrayError(Exception):
|
||||
"""Base exception for all Xray-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class APIError(XrayError):
|
||||
"""Error occurred during API communication."""
|
||||
pass
|
||||
|
||||
|
||||
class InboundNotFoundError(XrayError):
|
||||
"""Inbound with specified tag not found."""
|
||||
pass
|
||||
|
||||
|
||||
class UserNotFoundError(XrayError):
|
||||
"""User with specified email not found."""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(XrayError):
|
||||
"""Error in Xray configuration."""
|
||||
pass
|
||||
|
||||
|
||||
class CertificateError(XrayError):
|
||||
"""Error related to TLS certificates."""
|
||||
pass
|
||||
93
vpn/xray_api/models.py
Normal file
93
vpn/xray_api/models.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Data models for Xray Manager.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any
|
||||
from .utils import generate_uuid
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Base user model."""
|
||||
email: str
|
||||
level: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert user to dictionary representation."""
|
||||
return {
|
||||
"email": self.email,
|
||||
"level": self.level
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class VlessUser(User):
|
||||
"""VLESS protocol user."""
|
||||
uuid: str = field(default_factory=generate_uuid)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"id": self.uuid
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class VmessUser(User):
|
||||
"""VMess protocol user."""
|
||||
uuid: str = field(default_factory=generate_uuid)
|
||||
alter_id: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"id": self.uuid,
|
||||
"alterId": self.alter_id
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanUser(User):
|
||||
"""Trojan protocol user."""
|
||||
password: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"password": self.password
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class Inbound:
|
||||
"""Inbound configuration."""
|
||||
tag: str
|
||||
protocol: str
|
||||
port: int
|
||||
listen: str = "0.0.0.0"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"tag": self.tag,
|
||||
"protocol": self.protocol,
|
||||
"port": self.port,
|
||||
"listen": self.listen
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
"""Statistics data."""
|
||||
uplink: int = 0
|
||||
downlink: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total traffic (uplink + downlink)."""
|
||||
return self.uplink + self.downlink
|
||||
15
vpn/xray_api/protocols/__init__.py
Normal file
15
vpn/xray_api/protocols/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Protocol-specific implementations for Xray Manager.
|
||||
"""
|
||||
|
||||
from .base import BaseProtocol
|
||||
from .vless import VlessProtocol
|
||||
from .vmess import VmessProtocol
|
||||
from .trojan import TrojanProtocol
|
||||
|
||||
__all__ = [
|
||||
"BaseProtocol",
|
||||
"VlessProtocol",
|
||||
"VmessProtocol",
|
||||
"TrojanProtocol"
|
||||
]
|
||||
45
vpn/xray_api/protocols/base.py
Normal file
45
vpn/xray_api/protocols/base.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Base protocol implementation.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Optional
|
||||
from ..models import User
|
||||
|
||||
|
||||
class BaseProtocol(ABC):
|
||||
"""Base class for all protocol implementations."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
|
||||
self.port = port
|
||||
self.tag = tag or self._default_tag()
|
||||
self.listen = listen
|
||||
self.network = network
|
||||
|
||||
@abstractmethod
|
||||
def _default_tag(self) -> str:
|
||||
"""Return default tag for this protocol."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_inbound_config(self, users: List[User]) -> Dict[str, Any]:
|
||||
"""Create inbound configuration for this protocol."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_user_config(self, user: User) -> Dict[str, Any]:
|
||||
"""Create user configuration for adding to existing inbound."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_client_link(self, user: User, hostname: str) -> str:
|
||||
"""Generate client connection link."""
|
||||
pass
|
||||
|
||||
def _base_inbound_config(self) -> Dict[str, Any]:
|
||||
"""Common inbound configuration."""
|
||||
return {
|
||||
"listen": self.listen,
|
||||
"port": self.port,
|
||||
"tag": self.tag
|
||||
}
|
||||
80
vpn/xray_api/protocols/trojan.py
Normal file
80
vpn/xray_api/protocols/trojan.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Trojan protocol implementation.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .base import BaseProtocol
|
||||
from ..models import User, TrojanUser
|
||||
from ..utils import generate_self_signed_cert, pem_to_lines
|
||||
from ..exceptions import CertificateError
|
||||
|
||||
|
||||
class TrojanProtocol(BaseProtocol):
|
||||
"""Trojan protocol handler."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0",
|
||||
network: str = "tcp", cert_pem: Optional[str] = None,
|
||||
key_pem: Optional[str] = None, hostname: str = "localhost"):
|
||||
super().__init__(port, tag, listen, network)
|
||||
self.hostname = hostname
|
||||
|
||||
if cert_pem and key_pem:
|
||||
self.cert_pem = cert_pem
|
||||
self.key_pem = key_pem
|
||||
else:
|
||||
# Generate self-signed certificate
|
||||
self.cert_pem, self.key_pem = generate_self_signed_cert(hostname)
|
||||
|
||||
def _default_tag(self) -> str:
|
||||
return "trojan-inbound"
|
||||
|
||||
def create_inbound_config(self, users: List[TrojanUser]) -> Dict[str, Any]:
|
||||
"""Create Trojan inbound configuration."""
|
||||
config = self._base_inbound_config()
|
||||
config.update({
|
||||
"protocol": "trojan",
|
||||
"settings": {
|
||||
"_TypedMessage_": "xray.proxy.trojan.Config",
|
||||
"clients": [self._user_to_client(user) for user in users],
|
||||
"fallbacks": [{"dest": 80}]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": self.network,
|
||||
"security": "tls",
|
||||
"tlsSettings": {
|
||||
"alpn": ["http/1.1"],
|
||||
"certificates": [{
|
||||
"certificate": pem_to_lines(self.cert_pem),
|
||||
"key": pem_to_lines(self.key_pem),
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
return {"inbounds": [config]}
|
||||
|
||||
def create_user_config(self, user: TrojanUser) -> Dict[str, Any]:
|
||||
"""Create user configuration for Trojan."""
|
||||
return {
|
||||
"inboundTag": self.tag,
|
||||
"proxySettings": {
|
||||
"_TypedMessage_": "xray.proxy.trojan.Config",
|
||||
"clients": [self._user_to_client(user)]
|
||||
}
|
||||
}
|
||||
|
||||
def generate_client_link(self, user: TrojanUser, hostname: str) -> str:
|
||||
"""Generate Trojan client link."""
|
||||
return f"trojan://{user.password}@{hostname}:{self.port}#{user.email}"
|
||||
|
||||
def get_client_note(self) -> str:
|
||||
"""Get note for client configuration when using self-signed certificates."""
|
||||
return "Add 'allowInsecure: true' to TLS settings for self-signed certificates"
|
||||
|
||||
def _user_to_client(self, user: TrojanUser) -> Dict[str, Any]:
|
||||
"""Convert TrojanUser to client configuration."""
|
||||
return {
|
||||
"password": user.password,
|
||||
"level": user.level,
|
||||
"email": user.email
|
||||
}
|
||||
55
vpn/xray_api/protocols/vless.py
Normal file
55
vpn/xray_api/protocols/vless.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
VLESS protocol implementation.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .base import BaseProtocol
|
||||
from ..models import User, VlessUser
|
||||
|
||||
|
||||
class VlessProtocol(BaseProtocol):
|
||||
"""VLESS protocol handler."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
|
||||
super().__init__(port, tag, listen, network)
|
||||
|
||||
def _default_tag(self) -> str:
|
||||
return "vless-inbound"
|
||||
|
||||
def create_inbound_config(self, users: List[VlessUser]) -> Dict[str, Any]:
|
||||
"""Create VLESS inbound configuration."""
|
||||
config = self._base_inbound_config()
|
||||
config.update({
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"_TypedMessage_": "xray.proxy.vless.inbound.Config",
|
||||
"clients": [self._user_to_client(user) for user in users],
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": self.network
|
||||
}
|
||||
})
|
||||
return {"inbounds": [config]}
|
||||
|
||||
def create_user_config(self, user: VlessUser) -> Dict[str, Any]:
|
||||
"""Create user configuration for VLESS."""
|
||||
return {
|
||||
"inboundTag": self.tag,
|
||||
"proxySettings": {
|
||||
"_TypedMessage_": "xray.proxy.vless.inbound.Config",
|
||||
"clients": [self._user_to_client(user)]
|
||||
}
|
||||
}
|
||||
|
||||
def generate_client_link(self, user: VlessUser, hostname: str) -> str:
|
||||
"""Generate VLESS client link."""
|
||||
return f"vless://{user.uuid}@{hostname}:{self.port}?encryption=none&type={self.network}#{user.email}"
|
||||
|
||||
def _user_to_client(self, user: VlessUser) -> Dict[str, Any]:
|
||||
"""Convert VlessUser to client configuration."""
|
||||
return {
|
||||
"id": user.uuid,
|
||||
"level": user.level,
|
||||
"email": user.email
|
||||
}
|
||||
73
vpn/xray_api/protocols/vmess.py
Normal file
73
vpn/xray_api/protocols/vmess.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
VMess protocol implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .base import BaseProtocol
|
||||
from ..models import User, VmessUser
|
||||
|
||||
|
||||
class VmessProtocol(BaseProtocol):
|
||||
"""VMess protocol handler."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
|
||||
super().__init__(port, tag, listen, network)
|
||||
|
||||
def _default_tag(self) -> str:
|
||||
return "vmess-inbound"
|
||||
|
||||
def create_inbound_config(self, users: List[VmessUser]) -> Dict[str, Any]:
|
||||
"""Create VMess inbound configuration."""
|
||||
config = self._base_inbound_config()
|
||||
config.update({
|
||||
"protocol": "vmess",
|
||||
"settings": {
|
||||
"_TypedMessage_": "xray.proxy.vmess.inbound.Config",
|
||||
"clients": [self._user_to_client(user) for user in users]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": self.network
|
||||
}
|
||||
})
|
||||
return {"inbounds": [config]}
|
||||
|
||||
def create_user_config(self, user: VmessUser) -> Dict[str, Any]:
|
||||
"""Create user configuration for VMess."""
|
||||
return {
|
||||
"inboundTag": self.tag,
|
||||
"proxySettings": {
|
||||
"_TypedMessage_": "xray.proxy.vmess.inbound.Config",
|
||||
"clients": [self._user_to_client(user)]
|
||||
}
|
||||
}
|
||||
|
||||
def generate_client_link(self, user: VmessUser, hostname: str) -> str:
|
||||
"""Generate VMess client link."""
|
||||
config = {
|
||||
"v": "2",
|
||||
"ps": user.email,
|
||||
"add": hostname,
|
||||
"port": str(self.port),
|
||||
"id": user.uuid,
|
||||
"aid": str(user.alter_id),
|
||||
"net": self.network,
|
||||
"type": "none",
|
||||
"host": "",
|
||||
"path": "",
|
||||
"tls": ""
|
||||
}
|
||||
|
||||
config_json = json.dumps(config, separators=(',', ':'))
|
||||
encoded = base64.b64encode(config_json.encode()).decode()
|
||||
return f"vmess://{encoded}"
|
||||
|
||||
def _user_to_client(self, user: VmessUser) -> Dict[str, Any]:
|
||||
"""Convert VmessUser to client configuration."""
|
||||
return {
|
||||
"id": user.uuid,
|
||||
"alterId": user.alter_id,
|
||||
"level": user.level,
|
||||
"email": user.email
|
||||
}
|
||||
77
vpn/xray_api/utils.py
Normal file
77
vpn/xray_api/utils.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Utility functions for Xray Manager.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import base64
|
||||
import secrets
|
||||
from typing import List
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import datetime
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""Generate a random UUID for VLESS/VMess users."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
|
||||
|
||||
def generate_self_signed_cert(hostname: str = "localhost") -> tuple[str, str]:
|
||||
"""
|
||||
Generate self-signed certificate for Trojan.
|
||||
|
||||
Args:
|
||||
hostname: Common name for certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
# Generate private key
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048
|
||||
)
|
||||
|
||||
# Create certificate
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
])
|
||||
|
||||
cert = x509.CertificateBuilder().subject_name(
|
||||
subject
|
||||
).issuer_name(
|
||||
issuer
|
||||
).public_key(
|
||||
private_key.public_key()
|
||||
).serial_number(
|
||||
x509.random_serial_number()
|
||||
).not_valid_before(
|
||||
datetime.datetime.utcnow()
|
||||
).not_valid_after(
|
||||
datetime.datetime.utcnow() + datetime.timedelta(days=365)
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(hostname),
|
||||
]),
|
||||
critical=False,
|
||||
).sign(private_key, hashes.SHA256())
|
||||
|
||||
# Convert to PEM format
|
||||
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
||||
key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
return cert_pem.decode(), key_pem.decode()
|
||||
|
||||
|
||||
def pem_to_lines(pem_data: str) -> List[str]:
|
||||
"""Convert PEM data to list of lines for Xray JSON format."""
|
||||
return pem_data.strip().split('\n')
|
||||
Reference in New Issue
Block a user