84 Commits
master ... Xray

Author SHA1 Message Date
AB from home.homenet
ea3d74ccbd Xray init support 2025-08-05 01:23:07 +03:00
Alexandr Bogomiakov
c5a94d17dc Added initial xray plugin support 2025-07-27 20:37:21 +03:00
Ultradesu
17f9f5c045 Improve server page
All checks were successful
Docker hub build / docker (push) Successful in 3m40s
2025-07-21 18:55:59 +03:00
Ultradesu
4f7131ff5a Improve server page 2025-07-21 18:26:29 +03:00
Ultradesu
fa7ec5a87e Improve server page
All checks were successful
Docker hub build / docker (push) Successful in 3m37s
2025-07-21 17:40:03 +03:00
Ultradesu
05d19b88af Improve CI 2025-07-21 17:20:08 +03:00
Ultradesu
a75d55ac9d Added outline server managment page template 2025-07-21 17:15:35 +03:00
Ultradesu
90001a1d1e management command for cleanup old access logs 2025-07-21 15:30:57 +03:00
Ultradesu
9325a94cb2 Merged user statistics and acl manager 2025-07-21 14:40:52 +03:00
Ultradesu
8854aacf88 Fixed migrations 2025-07-21 13:55:49 +03:00
Ultradesu
3f346bc6c6 Added statistics cache 2025-07-21 13:49:43 +03:00
Ultradesu
b4bdffbbe3 Added statistics cache 2025-07-21 13:30:09 +03:00
Ultradesu
f5e5298461 Added statistics cache 2025-07-21 13:23:10 +03:00
Ultradesu
243a6734fd Improve acl model 2025-07-21 12:47:47 +03:00
Ultradesu
df5493bf14 Improve acl model 2025-07-21 12:25:37 +03:00
Ultradesu
ba62e214ce Improve acl model 2025-07-21 12:12:31 +03:00
Ultradesu
664bafe067 Added indexes to logs
All checks were successful
Docker hub build / docker (push) Successful in 3m18s
2025-07-21 04:27:29 +03:00
Ultradesu
6d56eb7eab Added indexes to logs 2025-07-21 04:18:27 +03:00
Ultradesu
47572d64c6 Force sync and purge 2025-07-21 03:48:35 +03:00
Ultradesu
8a521dc12e Force sync and purge 2025-07-21 03:32:37 +03:00
Ultradesu
a938dde77c Fixed UI graph 2025-07-21 02:14:38 +03:00
Ultradesu
7efe87c1d2 Added UI features
All checks were successful
Docker hub build / docker (push) Successful in 5m11s
2025-07-21 00:52:45 +03:00
Ultradesu
67f1c4d147 Fix docker compose 2025-07-20 23:35:14 +03:00
Ultradesu
2d4c862c5e Added User UI 2025-07-20 23:32:56 +03:00
Ultradesu
9bd4896040 Added User UI 2025-07-20 23:04:58 +03:00
Ultradesu
ec869b2974 fixed tasks log 2025-07-20 22:55:07 +03:00
Ultradesu
dc6d170f08 Fixed last release 2025-07-20 22:50:22 +03:00
Ultradesu
42a923799b Fixed last release 2025-07-20 22:30:04 +03:00
Alexandr Bogomyakov
9c8f0463a5 Create SECURITY.md
All checks were successful
Docker hub build / docker (push) Successful in 3m30s
2025-06-27 21:24:42 +03:00
Ultradesu
d57232ac98 Added move clients feature 2025-06-27 17:23:05 +03:00
Ultradesu
664c2b5ec4 Added move clients feature 2025-06-27 17:21:54 +03:00
Ultradesu
281b8270ce Added move clients feature 2025-06-27 17:08:32 +03:00
Ultradesu
10b5e5f86a Added move clients feature 2025-06-27 16:36:02 +03:00
Ultradesu
20e322a2e8 Added move clients feature
All checks were successful
Docker hub build / docker (push) Successful in 9m13s
2025-06-27 16:20:31 +03:00
Ultradesu
e77d13ab4e Added move clients feature 2025-06-27 16:02:13 +03:00
Ultradesu
cb9be75e90 Disable unused menus
All checks were successful
Docker hub build / docker (push) Successful in 12m5s
2025-06-20 11:35:29 +01:00
Ultradesu
8e378cb787 Fixed text search fileds for ACL and Logs. Added version info to footer. 2025-06-20 11:30:56 +01:00
Alexandr Bogomyakov
bf4bc505de Update README.md
All checks were successful
Docker hub build / docker (push) Successful in 4m38s
2025-06-16 14:32:58 +01:00
Alexandr Bogomyakov
c4dc0a1b42 Update README.md 2025-06-16 14:30:02 +01:00
Alexandr Bogomyakov
e22b26b1aa Update Dockerfile
All checks were successful
Docker hub build / docker (push) Successful in 2m51s
2025-03-17 15:09:36 +02:00
Alexandr Bogomyakov
8d8d6bb671 Update Dockerfile
Some checks failed
Docker hub build / docker (push) Failing after 22s
2025-03-17 15:04:59 +02:00
Alexandr Bogomyakov
c6da8ea250 Update Dockerfile
Some checks failed
Docker hub build / docker (push) Failing after 24s
2025-03-17 15:03:39 +02:00
Alexandr Bogomyakov
26a94d0e72 Update Dockerfile
Some checks failed
Docker hub build / docker (push) Failing after 24s
2025-03-17 15:00:52 +02:00
Alexandr Bogomyakov
72f59563f5 Bump python base image
All checks were successful
Docker hub build / docker (push) Successful in 2m54s
2025-03-17 14:42:10 +02:00
Ultradesu
fbf5019c32 Adjust server info
All checks were successful
Docker hub build / docker (push) Successful in 3m4s
2025-03-13 02:36:04 +02:00
Ultradesu
53dcc29dc7 Bump django 2025-03-13 01:54:35 +02:00
Ultradesu
89eee8fe3e Bump django 2025-03-13 01:49:35 +02:00
Ultradesu
7c47a3935a Bump django 2025-03-13 01:44:31 +02:00
Ultradesu
43c86e2075 Fix key ids 2025-03-13 01:43:33 +02:00
Ultradesu
ed8bfe7f06 Fixed client names in outline
Some checks are pending
Docker hub build / docker (push) Waiting to run
2025-03-12 23:18:17 +02:00
Ultradesu
ca463fe5ab Added link generator. Added link to /stat/ json object
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-03-04 17:19:13 +00:00
Alexandr Bogomyakov
8f51b4cf9e Update README.md 2025-03-04 11:40:57 +00:00
Ultradesu
760c1c7647 Bump deps
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-02-25 13:44:07 +02:00
Ultradesu
d1908e879b Added user dashboard 2025-02-25 12:39:08 +02:00
A B
c24c35f443 Fixed content type
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-02-23 21:29:03 +00:00
A B
7527ddfcb9 Fixed content type 2025-02-23 21:22:12 +00:00
A B
d9bf110ba9 Fixed content type 2025-02-23 20:19:26 +00:00
A B
e3682fd121 Added json lib 2025-02-23 19:33:54 +00:00
A B
d02377a270 Added yaml lib 2025-02-23 19:27:53 +00:00
A B
35e3980487 Added yaml lib 2025-02-23 19:25:22 +00:00
A B
f139e0bcc6 Some fix 2025-02-23 19:18:23 +00:00
AB
b22477b3e2 Added comment beside of links
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-01-15 15:34:30 +02:00
ultradesu
2323151242 Fix access log filter
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-01-10 11:32:30 +00:00
ultradesu
2ca317a9a2 Added ACLLink comments.
Some checks are pending
Docker hub build / docker (push) Waiting to run
2025-01-09 17:24:23 +00:00
Alexandr Bogomyakov
826827f85e Update README.md 2025-01-09 16:47:20 +00:00
Alexandr Bogomiakov
9763a6e48d Added to run local copy 2025-01-09 16:37:09 +00:00
Alexandr Bogomiakov
4fad180ec9 Adjusted access links in admin interface
Some checks are pending
Docker hub build / docker (push) Waiting to run
2025-01-09 08:49:52 +00:00
Alexandr Bogomyakov
7b5f47fa64 Update Dockerfile
Some checks failed
Docker hub build / docker (push) Has been cancelled
2024-11-20 13:35:56 +02:00
A B
a790da0793 Adjust ACLLinks length. Added links generator
Some checks failed
Docker hub build / docker (push) Has been cancelled
2024-11-18 20:34:54 +00:00
A B
a8ddadbe6d Fix autolink creation
Some checks failed
Docker hub build / docker (push) Has been cancelled
2024-10-28 17:32:06 +00:00
A B
6710cf211c Fix user hash editable 2024-10-28 17:21:23 +00:00
A B
0880401cc4 Added access logs. 2024-10-28 17:15:49 +00:00
A B
7cf99af20d Autologin
Some checks are pending
Docker hub build / docker (push) Waiting to run
2024-10-28 00:06:35 +00:00
A B
b6ad6e8578 Trying remote-auth 2024-10-27 23:37:02 +00:00
A B
7585fb94a1 Fix users
Some checks are pending
Docker hub build / docker (push) Waiting to run
2024-10-27 01:18:06 +00:00
A B
d324edec69 Merge vpn.Users with Django Users 2024-10-27 01:06:37 +00:00
A B
dda9b4ba5a Added keys count on outline page. 2024-10-26 23:36:18 +00:00
A B
75126b09ff Fix
Some checks are pending
Docker hub build / docker (push) Waiting to run
2024-10-26 12:38:50 +00:00
A B
a1ff998b68 Fix 2024-10-26 12:22:19 +00:00
A B
c4d9254824 Update task
Some checks failed
Docker hub build / docker (push) Has been cancelled
2024-10-21 20:36:30 +00:00
A B
2c74667945 Fix beat import to dev
Some checks are pending
Docker hub build / docker (push) Waiting to run
2024-10-21 13:26:22 +00:00
A B
538bc2f65e Fixed tasks 2024-10-21 13:22:03 +00:00
ab
bc5f774d9f fix ci 2024-10-20 22:43:31 +00:00
ab
7bf998ece5 Django UI 2024-10-20 21:57:12 +00:00
95 changed files with 12142 additions and 2042 deletions

View File

@@ -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
View File

@@ -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
View File

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

39
Dockerfile Executable file → Normal file
View 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" ]

View File

@@ -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 @@
![Forks](https://img.shields.io/github/forks/house-of-vanity/outfleet?style=social) ![Stargazers](https://img.shields.io/github/stars/house-of-vanity/outfleet?style=social) ![License](https://img.shields.io/github/license/house-of-vanity/outfleet)
## About The Project
<img width="1454" alt="image" src="https://github.com/user-attachments/assets/20555dd9-54ea-4b95-aa13-a7dd54e34ef4" />
![Screen Shot](img/servers.png)
## 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
View File

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

15
cleanup_analysis.sql Normal file
View File

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

35
cleanup_options.sql Normal file
View File

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

102
docker-compose.yaml Normal file
View 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:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

127
k8s.py
View File

@@ -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
View File

@@ -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
View File

@@ -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
View 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
View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

16
mysite/asgi.py Normal file
View 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
View 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()

View 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
View 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
View 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
View 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
View 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
View 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

View 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;
}

View 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">&times;</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;
}
}
});
});

View 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;
}

View 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);
}

View File

@@ -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

View 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

View File

@@ -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="&#128269;" 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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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."

View File

@@ -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
View File

1836
vpn/admin.py Normal file

File diff suppressed because it is too large Load Diff

6
vpn/apps.py Normal file
View 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
View 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']

View File

@@ -0,0 +1 @@
# Django management commands package\n

View 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:,}")

View 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")

View 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'))

View 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

View 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"))

View 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'),
),
]

View 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;"
),
]

View 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),
),
]

View File

@@ -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'),
),
]

View 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 = [
]

View 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')},
),
]

View 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'),
),
]

View File

@@ -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',
),
]

View 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 = [
]

View File

@@ -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',
),
]

View File

@@ -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),
),
]

View File

@@ -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')},
},
),
]

View 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',
),
]

View File

@@ -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',
),
]

View 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),
),
]

View File

@@ -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),
),
]

View File

@@ -0,0 +1 @@
# Migration package

169
vpn/models.py Normal file
View 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

View 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

View 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

View 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)

View File

@@ -0,0 +1,6 @@
from django.urls import path
from vpn.views import shadowsocks
urlpatterns = [
path('ss/<str:hash_value>/', shadowsocks, name='shadowsocks'),
]

View 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)

File diff suppressed because it is too large Load Diff

569
vpn/tasks.py Normal file
View 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

View 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 %}

View 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 %}

View 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 %}

View 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>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url 'admin:vpn_server_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {{ 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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 %}

View 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">&times;</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 %}

View 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>

View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

400
vpn/views.py Normal file
View 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
View 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
View 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}")

View 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
View 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

View 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"
]

View 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
}

View 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
}

View 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
}

View 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
View 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')