mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-12-16 17:37:51 +00:00
Compare commits
132 Commits
k8s
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea1362183 | ||
|
|
2fe59062c9 | ||
|
|
fe56811b33 | ||
|
|
787432cbcf | ||
|
|
56b0b160e3 | ||
|
|
1f7953a74c | ||
|
|
ea3d74ccbd | ||
|
|
c5a94d17dc | ||
|
|
17f9f5c045 | ||
|
|
4f7131ff5a | ||
|
|
fa7ec5a87e | ||
|
|
05d19b88af | ||
|
|
a75d55ac9d | ||
|
|
90001a1d1e | ||
|
|
9325a94cb2 | ||
|
|
8854aacf88 | ||
|
|
3f346bc6c6 | ||
|
|
b4bdffbbe3 | ||
|
|
f5e5298461 | ||
|
|
243a6734fd | ||
|
|
df5493bf14 | ||
|
|
ba62e214ce | ||
|
|
664bafe067 | ||
|
|
6d56eb7eab | ||
|
|
47572d64c6 | ||
|
|
8a521dc12e | ||
|
|
a938dde77c | ||
|
|
7efe87c1d2 | ||
|
|
67f1c4d147 | ||
|
|
2d4c862c5e | ||
|
|
9bd4896040 | ||
|
|
ec869b2974 | ||
|
|
dc6d170f08 | ||
|
|
42a923799b | ||
|
|
9c8f0463a5 | ||
|
|
d57232ac98 | ||
|
|
664c2b5ec4 | ||
|
|
281b8270ce | ||
|
|
10b5e5f86a | ||
|
|
20e322a2e8 | ||
|
|
e77d13ab4e | ||
|
|
cb9be75e90 | ||
|
|
8e378cb787 | ||
|
|
bf4bc505de | ||
|
|
c4dc0a1b42 | ||
|
|
e22b26b1aa | ||
|
|
8d8d6bb671 | ||
|
|
c6da8ea250 | ||
|
|
26a94d0e72 | ||
|
|
72f59563f5 | ||
|
|
fbf5019c32 | ||
|
|
53dcc29dc7 | ||
|
|
89eee8fe3e | ||
|
|
7c47a3935a | ||
|
|
43c86e2075 | ||
|
|
ed8bfe7f06 | ||
|
|
ca463fe5ab | ||
|
|
8f51b4cf9e | ||
|
|
760c1c7647 | ||
|
|
d1908e879b | ||
| c24c35f443 | |||
| 7527ddfcb9 | |||
| d9bf110ba9 | |||
| e3682fd121 | |||
| d02377a270 | |||
| 35e3980487 | |||
| f139e0bcc6 | |||
|
|
b22477b3e2 | ||
|
|
2323151242 | ||
|
|
2ca317a9a2 | ||
|
|
826827f85e | ||
|
|
9763a6e48d | ||
|
|
4fad180ec9 | ||
|
|
7b5f47fa64 | ||
| a790da0793 | |||
| a8ddadbe6d | |||
| 6710cf211c | |||
| 0880401cc4 | |||
| 7cf99af20d | |||
| b6ad6e8578 | |||
| 7585fb94a1 | |||
| d324edec69 | |||
| dda9b4ba5a | |||
| 75126b09ff | |||
| a1ff998b68 | |||
| c4d9254824 | |||
| 2c74667945 | |||
| 538bc2f65e | |||
|
|
bc5f774d9f | ||
|
|
7bf998ece5 | ||
|
|
9680ce802d | ||
|
|
e4fd6ea5d7 | ||
|
|
22cca991fc | ||
|
|
dd5f0c4e2f | ||
|
|
98d993423d | ||
|
|
db382f2b27 | ||
|
|
7e08bd465b | ||
|
|
f7ce671427 | ||
|
|
dceb07137a | ||
|
|
e41febe061 | ||
|
|
2397a05a08 | ||
|
|
c940e9f38b | ||
|
|
315be97354 | ||
|
|
8a5e1d2d69 | ||
|
|
22eb5ec7af | ||
|
|
3da1d4f5f7 | ||
|
|
c8dcd4439c | ||
|
|
b01d86251c | ||
| 58be345610 | |||
| 48521cb8a3 | |||
| 35f57de110 | |||
| 423c408893 | |||
|
|
788797f3ef | ||
| c9ae1bbbbd | |||
| 5cc32b18af | |||
|
|
f6bcb42ec4 | ||
|
|
bae0b91bab | ||
|
|
ab6d53a837 | ||
|
|
9709d2f029 | ||
| e818d63cad | |||
| 2039654f12 | |||
| f82631b174 | |||
| 77b78ec751 | |||
| f6a728ef1a | |||
| 5c1ffcbdc3 | |||
| f6c3262fb8 | |||
| 607730e781 | |||
| 0e7cabe336 | |||
| 614140840d | |||
|
|
3a6a60032e | ||
| 263acf540d | |||
|
|
443198aad1 |
23
.github/workflows/main.yml
vendored
23
.github/workflows/main.yml
vendored
@@ -3,7 +3,7 @@ name: Docker hub build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'django'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -24,13 +24,28 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Set outputs
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "sha_full=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
|
||||
echo "branch_name=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
|
||||
- name: Check outputs
|
||||
run: echo ${{ steps.vars.outputs.sha_short }}
|
||||
run: |
|
||||
echo "Short SHA: ${{ steps.vars.outputs.sha_short }}"
|
||||
echo "Full SHA: ${{ steps.vars.outputs.sha_full }}"
|
||||
echo "Build Date: ${{ steps.vars.outputs.build_date }}"
|
||||
echo "Branch: ${{ steps.vars.outputs.branch_name }}"
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ultradesu/outfleet:latest,ultradesu/outfleet:${{ steps.vars.outputs.sha_short }}
|
||||
cache-from: type=registry,ref=ultradesu/outfleet:buildcache
|
||||
cache-to: type=registry,ref=ultradesu/outfleet:buildcache,mode=max
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ steps.vars.outputs.sha_full }}
|
||||
GIT_COMMIT_SHORT=${{ steps.vars.outputs.sha_short }}
|
||||
BUILD_DATE=${{ steps.vars.outputs.build_date }}
|
||||
BRANCH_NAME=${{ steps.vars.outputs.branch_name }}
|
||||
tags: ultradesu/outfleet:v2,ultradesu/outfleet:${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,9 +1,21 @@
|
||||
config.yaml
|
||||
__pycache__/
|
||||
sync.log
|
||||
main.py
|
||||
.idea/*
|
||||
.vscode/*
|
||||
db.sqlite3
|
||||
debug.log
|
||||
*.swp
|
||||
*.swo
|
||||
*.swn
|
||||
*.pyc
|
||||
staticfiles/
|
||||
*.__pycache__.*
|
||||
celerybeat-schedule*
|
||||
|
||||
# macOS system files
|
||||
._*
|
||||
.DS_Store
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Temporary files
|
||||
/tmp/
|
||||
*.tmp
|
||||
|
||||
64
.vscode/launch.json
vendored
Normal file
64
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Django VPN app",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"env": {
|
||||
"POSTGRES_PORT": "5433",
|
||||
"DJANGO_SETTINGS_MODULE": "mysite.settings",
|
||||
"EXTERNAL_ADDRESS": "http://localhost:8000"
|
||||
},
|
||||
"args": [
|
||||
"runserver",
|
||||
"0.0.0.0:8000"
|
||||
],
|
||||
"django": true,
|
||||
"autoStartBrowser": false,
|
||||
"program": "${workspaceFolder}/manage.py"
|
||||
},
|
||||
{
|
||||
"name": "Celery Worker",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "celery",
|
||||
"args": [
|
||||
"-A", "mysite",
|
||||
"worker",
|
||||
"--loglevel=info"
|
||||
],
|
||||
"env": {
|
||||
"POSTGRES_PORT": "5433",
|
||||
"DJANGO_SETTINGS_MODULE": "mysite.settings"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Celery Beat",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "celery",
|
||||
"args": [
|
||||
"-A", "mysite",
|
||||
"beat",
|
||||
"--loglevel=info"
|
||||
],
|
||||
"env": {
|
||||
"POSTGRES_PORT": "5433",
|
||||
"DJANGO_SETTINGS_MODULE": "mysite.settings"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Run Django, Celery Worker, and Celery Beat",
|
||||
"configurations": [
|
||||
"Django VPN app",
|
||||
"Celery Worker",
|
||||
"Celery Beat"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
40
Dockerfile
Executable file → Normal file
40
Dockerfile
Executable file → Normal file
@@ -1,14 +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 main.py .
|
||||
COPY lib.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" ]
|
||||
|
||||
67
README.md
67
README.md
@@ -2,7 +2,7 @@
|
||||
<h1 align="center">OutFleet: Master Your OutLine VPN</h1>
|
||||
|
||||
<p align="center">
|
||||
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers and always-updated Dynamic Access Keys instead of ss:// links
|
||||
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers, users and always-updated Dynamic Access Keys instead of ss:// links
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://github.com/house-of-vanity/outfleet/issues">Request Feature</a>
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
  
|
||||
|
||||
## About The Project
|
||||
<img width="1454" alt="image" src="https://github.com/user-attachments/assets/20555dd9-54ea-4b95-aa13-a7dd54e34ef4" />
|
||||
|
||||

|
||||
## About The Project
|
||||
|
||||
### Key Features
|
||||
|
||||
@@ -28,62 +28,31 @@ 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.
|
||||
You may run script in Admin PowerShell to create Task for autorun **sslocal** and update connection details automatically using Outfleet API
|
||||
```PowerShell
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force; Invoke-Expression (Invoke-WebRequest -Uri "https://raw.githubusercontent.com/house-of-vanity/OutFleet/refs/heads/master/tools/windows-helper.ps1" -UseBasicParsing).Content
|
||||
```
|
||||
[Firefox PluginProxy Switcher and Manager](https://addons.mozilla.org/en-US/firefox/addon/proxy-switcher-and-manager/) && [Chrome plugin Proxy Switcher and Manager](https://chromewebstore.google.com/detail/proxy-switcher-and-manage/onnfghpihccifgojkpnnncpagjcdbjod)
|
||||
|
||||
Keep in mind that all user keys are stored in a single **config.yaml** file. If this file is lost, user keys will remain on the servers, but OutFleet will lose the ability to manage them. Handle with extreme caution and use backups.
|
||||
|
||||
## Authors
|
||||
|
||||
* **UltraDesu** - *Humble amateur developer* - [UltraDesu](https://github.com/house-of-vanity) - *All the work*
|
||||
* **UltraDesu** - *Humble amateur developer* - [UltraDesu](https://github.com/house-of-vanity) - *Author*
|
||||
* **Contributors**
|
||||
* * @Sanapach
|
||||
|
||||
21
SECURITY.md
Normal file
21
SECURITY.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.1.x | :white_check_mark: |
|
||||
| 5.0.x | :x: |
|
||||
| 4.0.x | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Use this section to tell people how to report a vulnerability.
|
||||
|
||||
Tell them where to go, how often they can expect to get an update on a
|
||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||
declined, etc.
|
||||
15
cleanup_analysis.sql
Normal file
15
cleanup_analysis.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Проверить количество записей без acl_link_id
|
||||
SELECT COUNT(*) as total_without_link
|
||||
FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = '';
|
||||
|
||||
-- Проверить общее количество записей
|
||||
SELECT COUNT(*) as total_records FROM vpn_accesslog;
|
||||
|
||||
-- Показать распределение по датам (последние записи без ссылок)
|
||||
SELECT DATE(timestamp) as date, COUNT(*) as count
|
||||
FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
GROUP BY DATE(timestamp)
|
||||
ORDER BY date DESC
|
||||
LIMIT 10;
|
||||
35
cleanup_options.sql
Normal file
35
cleanup_options.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- ВАРИАНТ 1: Удалить ВСЕ записи без acl_link_id
|
||||
-- ОСТОРОЖНО! Это удалит все старые логи
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = '';
|
||||
|
||||
-- ВАРИАНТ 2: Удалить записи без acl_link_id старше 30 дней
|
||||
-- Более безопасный вариант
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < NOW() - INTERVAL 30 DAY;
|
||||
|
||||
-- ВАРИАНТ 3: Удалить записи без acl_link_id старше 7 дней
|
||||
-- Еще более консервативный подход
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < NOW() - INTERVAL 7 DAY;
|
||||
|
||||
-- ВАРИАНТ 4: Оставить только последние 1000 записей без ссылок (для истории)
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1000
|
||||
) AS recent_logs
|
||||
);
|
||||
|
||||
-- ВАРИАНТ 5: Поэтапное удаление (для больших БД)
|
||||
-- Удаляем по 10000 записей за раз
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < NOW() - INTERVAL 30 DAY
|
||||
LIMIT 10000;
|
||||
102
docker-compose.yaml
Normal file
102
docker-compose.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
services:
|
||||
web_ui:
|
||||
image: outfleet:local
|
||||
container_name: outfleet-web
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- EXTERNAL_ADDRESS=http://127.0.0.1:8000
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "sleep 1 &&
|
||||
python manage.py makemigrations &&
|
||||
python manage.py migrate &&
|
||||
python manage.py create_admin &&
|
||||
python manage.py runserver 0.0.0.0:8000"
|
||||
|
||||
worker:
|
||||
image: outfleet:local
|
||||
container_name: outfleet-worker
|
||||
build:
|
||||
context: .
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "sleep 3 && celery -A mysite worker"
|
||||
|
||||
beat:
|
||||
image: outfleet:local
|
||||
container_name: outfleet-beat
|
||||
build:
|
||||
context: .
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "sleep 3 && celery -A mysite beat"
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: outfleet
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
BIN
img/servers.png
BIN
img/servers.png
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
77
k8s.py
77
k8s.py
@@ -1,77 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import yaml
|
||||
import logging
|
||||
from kubernetes import client, config
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
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.k8s")
|
||||
file_handler = logging.FileHandler("sync.log")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
log.addHandler(file_handler)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
config.load_incluster_config()
|
||||
|
||||
v1 = client.CoreV1Api()
|
||||
|
||||
NAMESPACE = False
|
||||
SERVERS = list()
|
||||
CONFIG = None
|
||||
|
||||
log.info("Checking for Kubernetes environment")
|
||||
try:
|
||||
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f:
|
||||
NAMESPACE = f.read().strip()
|
||||
log.info(f"Found Kubernetes environment. Namespace {NAMESPACE}")
|
||||
except IOError:
|
||||
log.info("Kubernetes environment not detected")
|
||||
pass
|
||||
|
||||
# config = v1.list_namespaced_config_map(NAMESPACE, label_selector="app=outfleet").items["data"]["config.yaml"]
|
||||
try:
|
||||
CONFIG = yaml.safe_load(v1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.info(f"ConfigMap config.yaml loaded from Kubernetes API. Servers: {len(CONFIG['servers'])}, Clients: {len(CONFIG['clients'])}")
|
||||
except ApiException as e:
|
||||
log.warning(f"ConfigMap not found. Fisrt run?")
|
||||
|
||||
#servers = v1.list_namespaced_secret(NAMESPACE, label_selector="app=shadowbox")
|
||||
|
||||
if not CONFIG:
|
||||
log.info(f"Creating new ConfigMap [config-outfleet]")
|
||||
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'])
|
||||
|
||||
178
lib.py
178
lib.py
@@ -1,178 +0,0 @@
|
||||
import argparse
|
||||
import logging
|
||||
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",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
def get_config():
|
||||
if not k8s.NAMESPACE:
|
||||
try:
|
||||
with open(args.config, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
except:
|
||||
try:
|
||||
with open(args.config, "w"):
|
||||
pass
|
||||
except Exception as exp:
|
||||
log.error(f"Couldn't create config. {exp}")
|
||||
return None
|
||||
return config
|
||||
else:
|
||||
return k8s.CONFIG
|
||||
|
||||
def write_config(config):
|
||||
if not k8s.NAMESPACE:
|
||||
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}")
|
||||
else:
|
||||
k8s.write_config(config)
|
||||
|
||||
|
||||
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"]}]')
|
||||
file_handler = logging.FileHandler("sync.log")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
self.log.addHandler(file_handler)
|
||||
|
||||
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.key_id == 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, CFG_PATH):
|
||||
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)
|
||||
399
main.py
399
main.py
@@ -1,399 +0,0 @@
|
||||
import yaml
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import random
|
||||
import string
|
||||
import argparse
|
||||
import uuid
|
||||
|
||||
|
||||
import k8s
|
||||
from flask import Flask, render_template, request, url_for, redirect
|
||||
from flask_cors import CORS
|
||||
from lib import Server, write_config, get_config, args
|
||||
|
||||
|
||||
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")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
log.addHandler(file_handler)
|
||||
|
||||
|
||||
CFG_PATH = args.config
|
||||
NAMESPACE = k8s.NAMESPACE
|
||||
SERVERS = list()
|
||||
BROKEN_SERVERS = list()
|
||||
CLIENTS = dict()
|
||||
VERSION = '3'
|
||||
HOSTNAME = ""
|
||||
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():
|
||||
global SERVERS
|
||||
global CLIENTS
|
||||
global BROKEN_SERVERS
|
||||
global HOSTNAME
|
||||
|
||||
SERVERS = list()
|
||||
BROKEN_SERVERS = list()
|
||||
CLIENTS = dict()
|
||||
config = get_config()
|
||||
|
||||
|
||||
if config:
|
||||
HOSTNAME = config.get("ui_hostname", "my-own-SSL-ENABLED-domain.com")
|
||||
servers = config.get("servers", dict())
|
||||
for local_server_id, server_config in servers.items():
|
||||
try:
|
||||
server = Server(
|
||||
url=server_config["url"],
|
||||
cert=server_config["cert"],
|
||||
comment=server_config["comment"],
|
||||
local_server_id=local_server_id,
|
||||
)
|
||||
SERVERS.append(server)
|
||||
log.info(
|
||||
"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)
|
||||
|
||||
CLIENTS = config.get("clients", dict())
|
||||
|
||||
|
||||
@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,
|
||||
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, CFG_PATH)
|
||||
update_state()
|
||||
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,
|
||||
CLIENTS=CLIENTS,
|
||||
VERSION=VERSION,
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
return redirect(url_for("clients", nt="User has been deleted"))
|
||||
|
||||
|
||||
@app.route("/dynamic/<server_name>/<client_id>", methods=["GET"], strict_slashes=False)
|
||||
def dynamic(server_name, client_id):
|
||||
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 wants ssconf for %s", client["name"], server.data["name"]
|
||||
)
|
||||
return {
|
||||
"server": server.data["hostname_for_access_keys"],
|
||||
"server_port": key.port,
|
||||
"password": key.password,
|
||||
"method": key.method,
|
||||
"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 "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
|
||||
except:
|
||||
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
|
||||
return "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
|
||||
|
||||
|
||||
@app.route("/dynamic/", methods=["GET"], strict_slashes=False)
|
||||
def _dynamic():
|
||||
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
|
||||
return (
|
||||
"Hey buddy, i think you got the wrong door the leather-club is two blocks down"
|
||||
)
|
||||
|
||||
|
||||
@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":
|
||||
log = logging.getLogger("sync")
|
||||
file_handler = logging.FileHandler("sync.log")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
log.addHandler(file_handler)
|
||||
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 = {}
|
||||
for server in SERVERS:
|
||||
server_hash[server.data["local_server_id"]] = server
|
||||
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()
|
||||
return redirect(url_for("sync"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_state()
|
||||
app.run(host="0.0.0.0")
|
||||
22
manage.py
Executable file
22
manage.py
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
3
mysite/__init__.py
Normal file
3
mysite/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
16
mysite/asgi.py
Normal file
16
mysite/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for mysite project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
40
mysite/celery.py
Normal file
40
mysite/celery.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery import shared_task
|
||||
from celery.schedules import crontab
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
logger = logging.getLogger(__name__)
|
||||
app = Celery('mysite')
|
||||
|
||||
app.conf.beat_schedule = {
|
||||
'periodical_servers_sync': {
|
||||
'task': 'sync_all_servers',
|
||||
'schedule': crontab(minute=0, hour='*/3'), # Every 3 hours
|
||||
},
|
||||
'cleanup_old_task_logs': {
|
||||
'task': 'cleanup_task_logs',
|
||||
'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Additional celery settings for better logging and performance
|
||||
app.conf.update(
|
||||
# Keep detailed results for debugging
|
||||
result_expires=3600, # 1 hour
|
||||
task_always_eager=False,
|
||||
task_eager_propagates=True,
|
||||
# Improve task tracking
|
||||
task_track_started=True,
|
||||
task_send_sent_event=True,
|
||||
# Clean up settings
|
||||
result_backend_cleanup_interval=300, # Clean up every 5 minutes
|
||||
)
|
||||
|
||||
app.autodiscover_tasks()
|
||||
|
||||
42
mysite/context_processors.py
Normal file
42
mysite/context_processors.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from django.conf import settings
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def version_info(request):
|
||||
"""Add version information to template context"""
|
||||
|
||||
git_commit = getattr(settings, 'GIT_COMMIT', None)
|
||||
git_commit_short = getattr(settings, 'GIT_COMMIT_SHORT', None)
|
||||
build_date = getattr(settings, 'BUILD_DATE', None)
|
||||
|
||||
if not git_commit or git_commit == 'development':
|
||||
try:
|
||||
base_dir = getattr(settings, 'BASE_DIR', Path(__file__).resolve().parent.parent)
|
||||
result = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
||||
capture_output=True, text=True, cwd=base_dir, timeout=5)
|
||||
if result.returncode == 0:
|
||||
git_commit = result.stdout.strip()
|
||||
git_commit_short = git_commit[:7]
|
||||
|
||||
date_result = subprocess.run(['git', 'log', '-1', '--format=%ci'],
|
||||
capture_output=True, text=True, cwd=base_dir, timeout=5)
|
||||
if date_result.returncode == 0:
|
||||
build_date = date_result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
if not git_commit:
|
||||
git_commit = 'development'
|
||||
if not git_commit_short:
|
||||
git_commit_short = 'dev'
|
||||
if not build_date:
|
||||
build_date = 'unknown'
|
||||
|
||||
return {
|
||||
'VERSION_INFO': {
|
||||
'git_commit': git_commit,
|
||||
'git_commit_short': git_commit_short,
|
||||
'build_date': build_date,
|
||||
'is_development': git_commit_short == 'dev'
|
||||
}
|
||||
}
|
||||
22
mysite/middleware.py
Normal file
22
mysite/middleware.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
class RequestLogger:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
print(f"Original: {request.build_absolute_uri()}")
|
||||
print(f"Path : {request.path}")
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class AutoLoginMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
user = authenticate(username='admin', password='admin')
|
||||
if user:
|
||||
login(request, user)
|
||||
227
mysite/settings.py
Normal file
227
mysite/settings.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import environ
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
|
||||
ENV = environ.Env(
|
||||
DEBUG=(bool, False)
|
||||
)
|
||||
|
||||
environ.Env.read_env()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY=ENV('SECRET_KEY', default='django-insecure-change-me-in-production')
|
||||
TIME_ZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
||||
EXTERNAL_ADDRESS = ENV('EXTERNAL_ADDRESS', default='https://example.org')
|
||||
|
||||
CELERY_BROKER_URL = ENV('CELERY_BROKER_URL', default='redis://localhost:6379/0')
|
||||
CELERY_RESULT_BACKEND = 'django-db'
|
||||
CELERY_TIMEZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_RESULT_EXTENDED = True
|
||||
|
||||
# Celery Beat Schedule
|
||||
from celery.schedules import crontab
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
'update-user-statistics': {
|
||||
'task': 'update_user_statistics',
|
||||
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
||||
},
|
||||
'cleanup-task-logs': {
|
||||
'task': 'cleanup_task_logs',
|
||||
'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
|
||||
},
|
||||
}
|
||||
|
||||
AUTH_USER_MODEL = "vpn.User"
|
||||
|
||||
DEBUG = ENV('DEBUG')
|
||||
|
||||
ALLOWED_HOSTS = ENV.list('ALLOWED_HOSTS', default=["*"])
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CSRF_TRUSTED_ORIGINS = ENV.list('CSRF_TRUSTED_ORIGINS', default=[])
|
||||
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '[{asctime}] {levelname} {name} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
'simple': {
|
||||
'format': '{levelname} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'file': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': os.path.join(BASE_DIR, 'debug.log'),
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
'vpn': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
'requests': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'urllib3': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'jazzmin',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'polymorphic',
|
||||
'corsheaders',
|
||||
'django_celery_results',
|
||||
'django_celery_beat',
|
||||
'vpn',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'mysite.middleware.AutoLoginMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'mysite.urls'
|
||||
|
||||
GIT_COMMIT = ENV('GIT_COMMIT', default='development')
|
||||
GIT_COMMIT_SHORT = ENV('GIT_COMMIT_SHORT', default='dev')
|
||||
BUILD_DATE = ENV('BUILD_DATE', default='unknown')
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [
|
||||
os.path.join(BASE_DIR, 'templates'),
|
||||
os.path.join(BASE_DIR, 'vpn', 'templates')
|
||||
],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'mysite.context_processors.version_info',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
WSGI_APPLICATION = 'mysite.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
# CREATE USER outfleet WITH PASSWORD 'password';
|
||||
# GRANT ALL PRIVILEGES ON DATABASE outfleet TO outfleet;
|
||||
# ALTER DATABASE outfleet OWNER TO outfleet;
|
||||
|
||||
DATABASES = {
|
||||
'sqlite': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
},
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': ENV('POSTGRES_DB', default="outfleet"),
|
||||
'USER': ENV('POSTGRES_USER', default="outfleet"),
|
||||
'PASSWORD': ENV('POSTGRES_PASSWORD', default="outfleet"),
|
||||
'HOST': ENV('POSTGRES_HOST', default='localhost'),
|
||||
'PORT': ENV('POSTGRES_PORT', default='5432'),
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
30
mysite/urls.py
Normal file
30
mysite/urls.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
URL configuration for mysite project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.1/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.views.generic import RedirectView
|
||||
from vpn.views import shadowsocks, userFrontend, userPortal, xray_subscription
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('ss/<path:link>', shadowsocks, name='shadowsocks'),
|
||||
path('dynamic/<path:link>', shadowsocks, name='shadowsocks'),
|
||||
path('xray/<str:user_hash>', xray_subscription, name='xray_subscription'),
|
||||
path('stat/<path:user_hash>', userFrontend, name='userFrontend'),
|
||||
path('u/<path:user_hash>', userPortal, name='userPortal'),
|
||||
path('', RedirectView.as_view(url='/admin/', permanent=False)),
|
||||
]
|
||||
16
mysite/wsgi.py
Normal file
16
mysite/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for mysite project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
26
requirements.txt
Executable file → Normal file
26
requirements.txt
Executable file → Normal file
@@ -1,5 +1,21 @@
|
||||
outline-vpn-api
|
||||
kubernetes
|
||||
PyYAML>=6.0.1
|
||||
Flask>=2.3.3
|
||||
flask-cors
|
||||
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==78.1.1
|
||||
shortuuid==1.0.13
|
||||
cryptography==45.0.5
|
||||
acme>=2.0.0
|
||||
cloudflare>=4.3.1
|
||||
josepy>=2.0.0
|
||||
|
||||
335
static/admin/css/vpn_admin.css
Normal file
335
static/admin/css/vpn_admin.css
Normal file
@@ -0,0 +1,335 @@
|
||||
/* Custom styles for VPN admin interface */
|
||||
|
||||
/* Quick action buttons in server list */
|
||||
.quick-actions .button {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
margin: 0 2px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to bottom, #f8f8f8, #e8e8e8);
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-actions .button:hover {
|
||||
background: linear-gradient(to bottom, #e8e8e8, #d8d8d8);
|
||||
border-color: #bbb;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.quick-actions .button:active {
|
||||
background: linear-gradient(to bottom, #d8d8d8, #e8e8e8);
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Sync button - blue theme */
|
||||
.quick-actions .button[href*="/sync/"] {
|
||||
background: linear-gradient(to bottom, #4a90e2, #357abd);
|
||||
border-color: #2968a3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-actions .button[href*="/sync/"]:hover {
|
||||
background: linear-gradient(to bottom, #357abd, #2968a3);
|
||||
border-color: #1f5582;
|
||||
}
|
||||
|
||||
/* Move clients button - orange theme */
|
||||
.quick-actions .button[href*="/move-clients/"] {
|
||||
background: linear-gradient(to bottom, #f39c12, #e67e22);
|
||||
border-color: #d35400;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-actions .button[href*="/move-clients/"]:hover {
|
||||
background: linear-gradient(to bottom, #e67e22, #d35400);
|
||||
border-color: #bf4f36;
|
||||
}
|
||||
|
||||
/* Status indicators improvements */
|
||||
.server-status-ok {
|
||||
color: #27ae60;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-status-error {
|
||||
color: #e74c3c;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-status-warning {
|
||||
color: #f39c12;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Better spacing for list display */
|
||||
.admin-object-tools {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Improve readability of pre-formatted status */
|
||||
.changelist-results pre {
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Server admin compact styles */
|
||||
.server-stats {
|
||||
max-width: 120px;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.server-activity {
|
||||
max-width: 140px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
max-width: 160px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.server-comment {
|
||||
max-width: 200px;
|
||||
min-width: 100px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Compact server display elements */
|
||||
.changelist-results .server-stats div,
|
||||
.changelist-results .server-activity div,
|
||||
.changelist-results .server-status div {
|
||||
line-height: 1.3;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
/* Status indicator colors */
|
||||
.status-online {
|
||||
color: #16a34a !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #dc2626 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: #f97316 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-unavailable {
|
||||
color: #f97316 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Activity indicators */
|
||||
.activity-high {
|
||||
color: #16a34a !important;
|
||||
}
|
||||
|
||||
.activity-medium {
|
||||
color: #eab308 !important;
|
||||
}
|
||||
|
||||
.activity-low {
|
||||
color: #f97316 !important;
|
||||
}
|
||||
|
||||
.activity-none {
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
|
||||
/* User stats indicators */
|
||||
.users-active {
|
||||
color: #16a34a !important;
|
||||
}
|
||||
|
||||
.users-medium {
|
||||
color: #eab308 !important;
|
||||
}
|
||||
|
||||
.users-low {
|
||||
color: #f97316 !important;
|
||||
}
|
||||
|
||||
.users-none {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Table cell width constraints for better layout */
|
||||
table.changelist-results th:nth-child(1), /* Name */
|
||||
table.changelist-results td:nth-child(1) {
|
||||
width: 180px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(3), /* Comment */
|
||||
table.changelist-results td:nth-child(3) {
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(4), /* User Stats */
|
||||
table.changelist-results td:nth-child(4) {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(5), /* Activity */
|
||||
table.changelist-results td:nth-child(5) {
|
||||
width: 140px;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(6), /* Status */
|
||||
table.changelist-results td:nth-child(6) {
|
||||
width: 160px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
/* Ensure text doesn't overflow in server admin */
|
||||
.changelist-results td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Allow wrapping for multi-line server info displays */
|
||||
.changelist-results td .server-stats,
|
||||
.changelist-results td .server-activity,
|
||||
.changelist-results td .server-status {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Server type icons */
|
||||
.server-type-outline {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.server-type-wireguard {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Tooltip styles for truncated text */
|
||||
[title] {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted #999;
|
||||
}
|
||||
|
||||
/* Form improvements for move clients page */
|
||||
.form-row.field-box {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.form-row.field-box label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-row.field-box .readonly {
|
||||
padding: 5px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.help {
|
||||
background: #e8f4fd;
|
||||
border: 1px solid #b8daff;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.help h3 {
|
||||
margin-top: 0;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.help ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Make user statistics section wider */
|
||||
.field-user_statistics_summary {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field-user_statistics_summary .readonly {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field-user_statistics_summary .user-management-section {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Wider fieldset for statistics */
|
||||
.wide {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.wide .form-row {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Server status button styles */
|
||||
.check-status-btn {
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-status-btn:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.check-status-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Make admin tables more responsive */
|
||||
.changelist-results table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
/* Improve button spacing */
|
||||
.btn-sm-custom {
|
||||
margin: 0 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
203
static/admin/js/generate_link.js
Normal file
203
static/admin/js/generate_link.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// static/admin/js/generate_uuid.js
|
||||
|
||||
function generateLink(button) {
|
||||
let row = button.closest('tr');
|
||||
let inputField = row.querySelector('input[name$="link"]');
|
||||
|
||||
if (inputField) {
|
||||
inputField.value = generateRandomString(16);
|
||||
}
|
||||
}
|
||||
|
||||
function generateRandomString(length) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// OutlineServer JSON Configuration Functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// JSON Import functionality
|
||||
const importJsonBtn = document.getElementById('import-json-btn');
|
||||
const importJsonTextarea = document.getElementById('import-json-config');
|
||||
|
||||
if (importJsonBtn && importJsonTextarea) {
|
||||
// Auto-fill on paste event
|
||||
importJsonTextarea.addEventListener('paste', function(e) {
|
||||
// Small delay to let paste complete
|
||||
setTimeout(() => {
|
||||
tryAutoFillFromJson();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Manual import button
|
||||
importJsonBtn.addEventListener('click', function() {
|
||||
tryAutoFillFromJson();
|
||||
});
|
||||
|
||||
function tryAutoFillFromJson() {
|
||||
try {
|
||||
const jsonText = importJsonTextarea.value.trim();
|
||||
if (!jsonText) {
|
||||
alert('Please enter JSON configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(jsonText);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.apiUrl || !config.certSha256) {
|
||||
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse apiUrl to extract components
|
||||
const url = new URL(config.apiUrl);
|
||||
|
||||
// Fill form fields
|
||||
const adminUrlField = document.getElementById('id_admin_url');
|
||||
const adminCertField = document.getElementById('id_admin_access_cert');
|
||||
const clientHostnameField = document.getElementById('id_client_hostname');
|
||||
const clientPortField = document.getElementById('id_client_port');
|
||||
const nameField = document.getElementById('id_name');
|
||||
const commentField = document.getElementById('id_comment');
|
||||
|
||||
if (adminUrlField) adminUrlField.value = config.apiUrl;
|
||||
if (adminCertField) adminCertField.value = config.certSha256;
|
||||
|
||||
// Use provided hostname or extract from URL
|
||||
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
|
||||
if (clientHostnameField) clientHostnameField.value = hostname;
|
||||
|
||||
// Use provided port or extract from various sources
|
||||
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
|
||||
if (clientPortField) clientPortField.value = clientPort;
|
||||
|
||||
// Generate server name if not provided and field is empty
|
||||
if (nameField && !nameField.value) {
|
||||
const serverName = config.serverName || config.name || `Outline-${hostname}`;
|
||||
nameField.value = serverName;
|
||||
}
|
||||
|
||||
// Fill comment if provided and field exists
|
||||
if (commentField && config.comment) {
|
||||
commentField.value = config.comment;
|
||||
}
|
||||
|
||||
// Clear the JSON input
|
||||
importJsonTextarea.value = '';
|
||||
|
||||
// Show success message
|
||||
showSuccessMessage('✅ Configuration imported successfully!');
|
||||
|
||||
} catch (error) {
|
||||
alert('Invalid JSON format: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy to clipboard functionality
|
||||
window.copyToClipboard = function(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
const text = element.textContent || element.innerText;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopySuccess();
|
||||
}).catch(err => {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function fallbackCopyTextToClipboard(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopySuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
showSuccessMessage('📋 Copied to clipboard!');
|
||||
}
|
||||
|
||||
function showSuccessMessage(message) {
|
||||
const alertHtml = `
|
||||
<div class="alert alert-success alert-dismissible" style="margin: 1rem 0;">
|
||||
${message}
|
||||
<button type="button" class="close" aria-label="Close" onclick="this.parentElement.remove()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Try to find a container for the message
|
||||
const container = document.querySelector('.card-body') || document.querySelector('#content-main');
|
||||
if (container) {
|
||||
container.insertAdjacentHTML('afterbegin', alertHtml);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const alert = document.querySelector('.alert-success');
|
||||
if (alert) alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Sync server button - handle both static and dynamic buttons
|
||||
document.addEventListener('click', async function(e) {
|
||||
if (e.target && (e.target.id === 'sync-server-btn' || e.target.matches('[id="sync-server-btn"]'))) {
|
||||
const syncBtn = e.target;
|
||||
const serverId = syncBtn.dataset.serverId;
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
const originalText = syncBtn.textContent;
|
||||
syncBtn.textContent = '⏳ Syncing...';
|
||||
syncBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/outlineserver/${serverId}/sync/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccessMessage(`✅ ${data.message}`);
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} else {
|
||||
alert('Sync failed: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error: ' + error.message);
|
||||
} finally {
|
||||
syncBtn.textContent = originalText;
|
||||
syncBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
94
static/admin/js/server_status_check.js
Normal file
94
static/admin/js/server_status_check.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Server status check functionality for admin
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add event listeners to all check status buttons
|
||||
document.querySelectorAll('.check-status-btn').forEach(button => {
|
||||
button.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const serverId = this.dataset.serverId;
|
||||
const serverName = this.dataset.serverName;
|
||||
const serverType = this.dataset.serverType;
|
||||
const originalText = this.textContent;
|
||||
const originalColor = this.style.background;
|
||||
|
||||
// Show loading state
|
||||
this.textContent = '⏳ Checking...';
|
||||
this.style.background = '#6c757d';
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
// Try AJAX request first
|
||||
const response = await fetch(`/admin/vpn/server/${serverId}/check-status/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update button based on status
|
||||
if (data.status === 'online') {
|
||||
this.textContent = '✅ Online';
|
||||
this.style.background = '#28a745';
|
||||
} else if (data.status === 'offline') {
|
||||
this.textContent = '❌ Offline';
|
||||
this.style.background = '#dc3545';
|
||||
} else if (data.status === 'error') {
|
||||
this.textContent = '⚠️ Error';
|
||||
this.style.background = '#fd7e14';
|
||||
} else {
|
||||
this.textContent = '❓ Unknown';
|
||||
this.style.background = '#6c757d';
|
||||
}
|
||||
|
||||
// Show additional info if available
|
||||
if (data.message) {
|
||||
this.title = data.message;
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to check status');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking server status:', error);
|
||||
|
||||
// Fallback: show basic server info
|
||||
this.textContent = `📊 ${serverType}`;
|
||||
this.style.background = '#17a2b8';
|
||||
this.title = `Server: ${serverName} (${serverType}) - Status check failed: ${error.message}`;
|
||||
}
|
||||
|
||||
// Reset after 5 seconds in all cases
|
||||
setTimeout(() => {
|
||||
this.textContent = originalText;
|
||||
this.style.background = originalColor;
|
||||
this.title = '';
|
||||
this.disabled = false;
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to get CSRF token
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
289
static/admin/js/xray_inbound_defaults.js
Normal file
289
static/admin/js/xray_inbound_defaults.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// Xray Inbound Auto-Fill Helper
|
||||
console.log('Xray inbound helper script loaded');
|
||||
|
||||
// Protocol configurations based on Xray documentation
|
||||
const protocolConfigs = {
|
||||
'vless': {
|
||||
port: 443,
|
||||
network: 'tcp',
|
||||
security: 'tls',
|
||||
description: 'VLESS - Lightweight protocol with UUID authentication'
|
||||
},
|
||||
'vmess': {
|
||||
port: 443,
|
||||
network: 'ws',
|
||||
security: 'tls',
|
||||
description: 'VMess - V2Ray protocol with encryption and authentication'
|
||||
},
|
||||
'trojan': {
|
||||
port: 443,
|
||||
network: 'tcp',
|
||||
security: 'tls',
|
||||
description: 'Trojan - TLS-based protocol mimicking HTTPS traffic'
|
||||
},
|
||||
'shadowsocks': {
|
||||
port: 8388,
|
||||
network: 'tcp',
|
||||
security: 'none',
|
||||
ss_method: 'aes-256-gcm',
|
||||
description: 'Shadowsocks - SOCKS5 proxy with encryption'
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM ready, initializing Xray helper');
|
||||
|
||||
// Add help text and generate buttons
|
||||
addHelpText();
|
||||
addGenerateButtons();
|
||||
|
||||
// Watch for protocol field changes
|
||||
const protocolField = document.getElementById('id_protocol');
|
||||
if (protocolField) {
|
||||
protocolField.addEventListener('change', function() {
|
||||
handleProtocolChange(this.value);
|
||||
});
|
||||
|
||||
// Auto-fill on initial load if new inbound
|
||||
if (protocolField.value && isNewInbound()) {
|
||||
handleProtocolChange(protocolField.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function isNewInbound() {
|
||||
// Check if this is a new inbound (no port value set)
|
||||
const portField = document.getElementById('id_port');
|
||||
return !portField || !portField.value;
|
||||
}
|
||||
|
||||
function handleProtocolChange(protocol) {
|
||||
if (!protocol || !protocolConfigs[protocol]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = protocolConfigs[protocol];
|
||||
|
||||
// Only auto-fill for new inbounds to avoid overwriting user data
|
||||
if (isNewInbound()) {
|
||||
console.log('Auto-filling fields for new', protocol, 'inbound');
|
||||
autoFillFields(protocol, config);
|
||||
showMessage(`Auto-filled ${protocol.toUpperCase()} configuration`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function autoFillFields(protocol, config) {
|
||||
// Fill basic fields only if they're empty
|
||||
fillIfEmpty('id_port', config.port);
|
||||
fillIfEmpty('id_network', config.network);
|
||||
fillIfEmpty('id_security', config.security);
|
||||
|
||||
// Protocol-specific fields
|
||||
if (config.ss_method && protocol === 'shadowsocks') {
|
||||
fillIfEmpty('id_ss_method', config.ss_method);
|
||||
}
|
||||
|
||||
// Generate helpful JSON configs
|
||||
generateJsonConfigs(protocol, config);
|
||||
}
|
||||
|
||||
function fillIfEmpty(fieldId, value) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field && !field.value && value !== undefined) {
|
||||
field.value = value;
|
||||
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function generateJsonConfigs(protocol, config) {
|
||||
// Generate stream settings
|
||||
const streamField = document.getElementById('id_stream_settings');
|
||||
if (streamField && !streamField.value) {
|
||||
const streamSettings = getStreamSettings(protocol, config.network);
|
||||
if (streamSettings) {
|
||||
streamField.value = JSON.stringify(streamSettings, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate sniffing settings
|
||||
const sniffingField = document.getElementById('id_sniffing_settings');
|
||||
if (sniffingField && !sniffingField.value) {
|
||||
const sniffingSettings = {
|
||||
enabled: true,
|
||||
destOverride: ['http', 'tls'],
|
||||
metadataOnly: false
|
||||
};
|
||||
sniffingField.value = JSON.stringify(sniffingSettings, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function getStreamSettings(protocol, network) {
|
||||
const settings = {};
|
||||
|
||||
switch (network) {
|
||||
case 'ws':
|
||||
settings.wsSettings = {
|
||||
path: '/ws',
|
||||
headers: {
|
||||
Host: 'example.com'
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'grpc':
|
||||
settings.grpcSettings = {
|
||||
serviceName: 'GunService'
|
||||
};
|
||||
break;
|
||||
case 'h2':
|
||||
settings.httpSettings = {
|
||||
host: ['example.com'],
|
||||
path: '/path'
|
||||
};
|
||||
break;
|
||||
case 'tcp':
|
||||
settings.tcpSettings = {
|
||||
header: {
|
||||
type: 'none'
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'kcp':
|
||||
settings.kcpSettings = {
|
||||
mtu: 1350,
|
||||
tti: 50,
|
||||
uplinkCapacity: 5,
|
||||
downlinkCapacity: 20,
|
||||
congestion: false,
|
||||
readBufferSize: 2,
|
||||
writeBufferSize: 2,
|
||||
header: {
|
||||
type: 'none'
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : null;
|
||||
}
|
||||
|
||||
function addHelpText() {
|
||||
// Add help text to complex fields
|
||||
addFieldHelp('id_stream_settings',
|
||||
'Transport settings: TCP (none), WebSocket (path/host), gRPC (serviceName), etc. Format: JSON');
|
||||
|
||||
addFieldHelp('id_sniffing_settings',
|
||||
'Traffic sniffing for routing: enabled, destOverride ["http","tls"], metadataOnly');
|
||||
|
||||
addFieldHelp('id_tls_cert_file',
|
||||
'TLS certificate file path (required for TLS security). Example: /path/to/cert.pem');
|
||||
|
||||
addFieldHelp('id_tls_key_file',
|
||||
'TLS private key file path (required for TLS security). Example: /path/to/key.pem');
|
||||
|
||||
addFieldHelp('id_protocol',
|
||||
'VLESS: lightweight + UUID | VMess: V2Ray encrypted | Trojan: HTTPS-like | Shadowsocks: SOCKS5');
|
||||
|
||||
addFieldHelp('id_network',
|
||||
'Transport: tcp (direct), ws (WebSocket), grpc (HTTP/2), h2 (HTTP/2), kcp (mKCP)');
|
||||
|
||||
addFieldHelp('id_security',
|
||||
'Encryption: none (no TLS), tls (standard TLS), reality (advanced steganography)');
|
||||
}
|
||||
|
||||
function addFieldHelp(fieldId, helpText) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field) return;
|
||||
|
||||
const helpDiv = document.createElement('div');
|
||||
helpDiv.className = 'help';
|
||||
helpDiv.style.cssText = 'font-size: 11px; color: #666; margin-top: 2px; line-height: 1.3;';
|
||||
helpDiv.textContent = helpText;
|
||||
|
||||
field.parentNode.appendChild(helpDiv);
|
||||
}
|
||||
|
||||
function showMessage(message, type = 'info') {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `alert alert-${type}`;
|
||||
messageDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
background: ${type === 'success' ? '#d4edda' : '#cce7ff'};
|
||||
border: 1px solid ${type === 'success' ? '#c3e6cb' : '#b8daff'};
|
||||
color: ${type === 'success' ? '#155724' : '#004085'};
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
`;
|
||||
messageDiv.textContent = message;
|
||||
|
||||
document.body.appendChild(messageDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Helper functions for generating values
|
||||
function generateRandomString(length = 8) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateShortId() {
|
||||
return Math.random().toString(16).substr(2, 8);
|
||||
}
|
||||
|
||||
function suggestPort(protocol) {
|
||||
const ports = {
|
||||
'vless': [443, 8443, 2053, 2083],
|
||||
'vmess': [443, 80, 8080, 8443],
|
||||
'trojan': [443, 8443, 2087],
|
||||
'shadowsocks': [8388, 1080, 8080]
|
||||
};
|
||||
const protocolPorts = ports[protocol] || [443];
|
||||
return protocolPorts[Math.floor(Math.random() * protocolPorts.length)];
|
||||
}
|
||||
|
||||
// Add generate buttons to fields
|
||||
function addGenerateButtons() {
|
||||
console.log('Adding generate buttons');
|
||||
|
||||
// Add tag generator
|
||||
addGenerateButton('id_tag', '🎲', () => `inbound-${generateShortId()}`);
|
||||
|
||||
// Add port suggestion based on protocol
|
||||
addGenerateButton('id_port', '🎯', () => {
|
||||
const protocol = document.getElementById('id_protocol')?.value;
|
||||
return suggestPort(protocol);
|
||||
});
|
||||
}
|
||||
|
||||
function addGenerateButton(fieldId, icon, generator) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field || field.nextElementSibling?.classList.contains('generate-btn')) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'generate-btn btn btn-sm btn-secondary';
|
||||
button.innerHTML = icon;
|
||||
button.title = 'Generate value';
|
||||
button.style.cssText = 'margin-left: 5px; padding: 2px 6px; font-size: 12px;';
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const value = generator();
|
||||
field.value = value;
|
||||
showMessage(`Generated: ${value}`, 'success');
|
||||
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
field.parentNode.insertBefore(button, field.nextSibling);
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
/*
|
||||
* -- 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`
|
||||
*/
|
||||
#layout, #nav, #list, #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: 80%;
|
||||
}
|
||||
/* 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-item {
|
||||
padding: 0.9em 1em;
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-left: 6px solid transparent;
|
||||
}
|
||||
.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-item:hover {
|
||||
background: #d1d0d0;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
|
||||
/* Move the layout over so we can fit the nav + list in on the left */
|
||||
#layout {
|
||||
padding-left:500px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* These are position:fixed; elements that will be in the left 500px of the screen */
|
||||
#nav, #list {
|
||||
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;
|
||||
}
|
||||
|
||||
#list {
|
||||
margin-left: -350px;
|
||||
width: 100%;
|
||||
height: 33%;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#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 */
|
||||
#list {
|
||||
margin-left: -350px;
|
||||
width:350px;
|
||||
height: 100%;
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
202
templates/admin/create_xray_inbound.html
Normal file
202
templates/admin/create_xray_inbound.html
Normal file
@@ -0,0 +1,202 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="protocol">Protocol *</label>
|
||||
<select name="protocol" id="protocol" class="form-control" required>
|
||||
<option value="">Select Protocol</option>
|
||||
{% for proto in protocols %}
|
||||
<option value="{{ proto }}">{{ proto|upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="port">Port *</label>
|
||||
<input type="number" name="port" id="port" class="form-control"
|
||||
min="1" max="65535" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="tag">Tag</label>
|
||||
<input type="text" name="tag" id="tag" class="form-control"
|
||||
placeholder="Auto-generated if empty">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="network">Network</label>
|
||||
<select name="network" id="network" class="form-control">
|
||||
{% for net in networks %}
|
||||
<option value="{{ net }}" {% if net == 'tcp' %}selected{% endif %}>
|
||||
{{ net|upper }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="security">Security</label>
|
||||
<select name="security" id="security" class="form-control">
|
||||
{% for sec in securities %}
|
||||
<option value="{{ sec }}" {% if sec == 'none' %}selected{% endif %}>
|
||||
{{ sec|upper }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="alert alert-info">
|
||||
<strong>Note:</strong> The inbound will be created on both the Django database and the Xray server via gRPC API.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-success">
|
||||
➕ Create Inbound
|
||||
</button>
|
||||
<a href="{% url 'admin:vpn_xraycoreserver_change' server.pk %}" class="btn btn-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const protocolField = document.getElementById('protocol');
|
||||
const portField = document.getElementById('port');
|
||||
const tagField = document.getElementById('tag');
|
||||
|
||||
// Auto-suggest ports based on protocol
|
||||
protocolField.addEventListener('change', function() {
|
||||
const protocol = this.value;
|
||||
const ports = {
|
||||
'vless': 443,
|
||||
'vmess': 443,
|
||||
'trojan': 443
|
||||
};
|
||||
|
||||
if (ports[protocol] && !portField.value) {
|
||||
portField.value = ports[protocol];
|
||||
}
|
||||
|
||||
if (protocol && !tagField.value) {
|
||||
tagField.placeholder = `${protocol}-${portField.value || 'PORT'}`;
|
||||
}
|
||||
});
|
||||
|
||||
portField.addEventListener('input', function() {
|
||||
const protocol = protocolField.value;
|
||||
if (protocol && !tagField.value) {
|
||||
tagField.placeholder = `${protocol}-${this.value}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
margin: 0 -10px;
|
||||
}
|
||||
|
||||
.col-md-6 {
|
||||
flex: 0 0 50%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,93 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
|
||||
<title>{% block title %}Dashboard{% endblock %}</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='pure.css') }}">
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='layout.css') }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="layout" class="content pure-g">
|
||||
<div id="nav" class="pure-u-1-3">
|
||||
<a href="#" id="menuLink" class="nav-menu-button">Menu</a>
|
||||
|
||||
<div class="nav-inner">
|
||||
<button onclick="location.href='/';" style="cursor:pointer;" class="primary-button pure-button">OutFleet v.{{ VERSION }}</button>
|
||||
|
||||
<div class="pure-menu custom-restricted-width">
|
||||
<ul class="pure-menu-list">
|
||||
<li class="pure-menu-item"><a href="/" class="pure-menu-link">Servers</a></li>
|
||||
<li class="pure-menu-item"><a href="/clients" class="pure-menu-link">Clients</a></li>
|
||||
<li class="pure-menu-item"><a href="/sync" class="pure-menu-link">Sync status</a></li>
|
||||
</ul>
|
||||
{{ VERSION }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<!-- Script to make the Menu link work -->
|
||||
<!-- Just stripped down version of the js/ui.js script for the side-menu layout -->
|
||||
<script>
|
||||
function getElements() {
|
||||
return {
|
||||
menu: document.getElementById('nav'),
|
||||
menuLink: document.getElementById('menuLink')
|
||||
};
|
||||
}
|
||||
|
||||
function toggleClass(element, className) {
|
||||
var classes = element.className.split(/\s+/);
|
||||
var length = classes.length;
|
||||
var i = 0;
|
||||
|
||||
for (; i < length; i++) {
|
||||
if (classes[i] === className) {
|
||||
classes.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The className is not found
|
||||
if (length === classes.length) {
|
||||
classes.push(className);
|
||||
}
|
||||
|
||||
element.className = classes.join(' ');
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
var active = 'active';
|
||||
var elements = getElements();
|
||||
|
||||
toggleClass(elements.menu, active);
|
||||
}
|
||||
|
||||
function handleEvent(e) {
|
||||
var elements = getElements();
|
||||
|
||||
if (e.target.id === elements.menuLink.id) {
|
||||
toggleMenu();
|
||||
e.preventDefault();
|
||||
} else if (elements.menu.className.indexOf('active') !== -1) {
|
||||
toggleMenu();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.addEventListener('click', handleEvent);
|
||||
});
|
||||
</script>
|
||||
{% if nt %}
|
||||
<label>
|
||||
<input type="checkbox" class="alertCheckbox" autocomplete="off" />
|
||||
<div class="alert {% if nl == 'error' %}error{% else %}success{% endif %}">
|
||||
<span class="alertText">{{nt}}
|
||||
<br class="clear"/></span>
|
||||
</div>
|
||||
</label>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,174 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="list" class="pure-u-1-3" xmlns="http://www.w3.org/1999/html" xmlns="http://www.w3.org/1999/html">
|
||||
<div class="server-item pure-g">
|
||||
<h1 class="server-content-title">Clients</h1>
|
||||
</div>
|
||||
{% for client, values in CLIENTS.items() %}
|
||||
<div class="server-item server-item-{% if client == selected_client %}unread{% else %}selected{% endif %} pure-g">
|
||||
<div class="pure-u-3-4" onclick="location.href='/clients?selected_client={{ client }}';">
|
||||
<h5 class="server-name">{{ values["name"] }}</h5>
|
||||
<h4 class="server-info">Allowed {{ values["servers"]|length }} server{% if values["servers"]|length >1 %}s{%endif%}</h4>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div onclick="location.href='/clients?add_client=True';" class="server-item server-add pure-g">
|
||||
<div class="pure-u-1">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if add_client %}
|
||||
<div class="pure-u-1-3">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="pure-u-1-2">
|
||||
<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 pure-u-md-1-3">
|
||||
<input type="text" class="pure-u-23-24" name="name" required placeholder="Name"/>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<input type="text" class="pure-u-23-24" name="comment" placeholder="Comment"/>
|
||||
</div>
|
||||
<div class="pure-checkbox">
|
||||
{% for server in SERVERS %}
|
||||
<label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["name"]}}
|
||||
<input type="checkbox" id="option{{loop.index0}}" name="servers" value="{{server.info()['local_server_id']}}"></label>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if selected_client and not add_client %}
|
||||
{% set client = CLIENTS[selected_client] %}
|
||||
|
||||
<div class="pure-u-1-2">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="pure-u-1-2">
|
||||
<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 pure-u-md-1-3">
|
||||
<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 pure-u-md-1-3">
|
||||
<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 %}
|
||||
<label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["name"]}}{% if server.info()['local_server_id'] in client['servers'] %} ( Used {% for key in server.data["keys"] %}{% if key.name == client['name'] %}{{ (key.used_bytes if key.used_bytes else 0) | filesizeformat }}{% endif %}{% endfor %}){%endif%}
|
||||
<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>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Save and apply</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<h3>Invite text</h3><hr>
|
||||
<textarea style="width: 100%; rows=10">
|
||||
Install OutLine VPN. Copy and paste below keys to OutLine client.
|
||||
Same keys will work simultaneously on many devices.
|
||||
{% for server in SERVERS -%}
|
||||
|
||||
{% if server.info()['local_server_id'] in client['servers'] %}
|
||||
{{server.info()['name']}}
|
||||
```{% for key in server.data["keys"] %}{% if key.key_id == client['name'] %}ssconf://{{ dynamic_hostname }}/dynamic/{{server.info()['name']}}/{{selected_client}}#{{server.info()['comment']}}{% endif %}{% endfor %}```
|
||||
{% endif %}
|
||||
{%- endfor -%}</textarea>
|
||||
</div>
|
||||
<hr>
|
||||
<div style="padding-top: 15px; padding-bottom: 15px">
|
||||
<div class="pure-u-1">
|
||||
<h3>Dynamic Access Keys</h3>
|
||||
<table class="pure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Server</th>
|
||||
<th>Dynamic</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in SERVERS %}
|
||||
{% if server.info()['local_server_id'] in client['servers'] %}
|
||||
<tr>
|
||||
<td>{{ server.info()['name'] }}</td>
|
||||
<td>
|
||||
<p style="font-size: 10pt">{% for key in server.data["keys"] %}{% if key.key_id == client['name'] %}ssconf://{{ dynamic_hostname }}/dynamic/{{server.info()['name']}}/{{selected_client}}#{{server.info()['comment']}}{% endif %}{% endfor %}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1">
|
||||
<h3>SS Links</h3>
|
||||
<table class="pure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Server</th>
|
||||
<th>SSlink</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in SERVERS %}
|
||||
{% if server.info()['local_server_id'] in client['servers'] %}
|
||||
<tr>
|
||||
<td>{{ server.info()['name'] }}</td>
|
||||
<td>
|
||||
<pre style="font-size: 10pt">{% for key in server.data["keys"] %}{% if key.key_id == client['name'] %}{{ key.access_url }}{% endif %}{% endfor %}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
<form action="/del_client" class="pure-form pure-form-stacked" method="POST">
|
||||
<input type="hidden" class="pure-u-1" name="name" required value="{{client['name']}}"/>
|
||||
<input type="hidden" class="pure-u-1" name="user_id" value="{{selected_client}}"/>
|
||||
<button type="submit" class="pure-button button-error pure-input-1 ">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,150 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="list" class="pure-u-1-3" xmlns="http://www.w3.org/1999/html">
|
||||
<div class="server-item pure-g">
|
||||
<h1 class="server-content-title">Servers</h1>
|
||||
</div>
|
||||
{% for server in SERVERS %}
|
||||
{% set list_ns = namespace(total_bytes=0) %}
|
||||
{% for key in server.data["keys"] %}
|
||||
{% if key.used_bytes %}
|
||||
{% set list_ns.total_bytes = list_ns.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: {{ list_ns.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 onclick="location.href='/?add_server=True';" class="server-item server-add pure-g">
|
||||
<div class="pure-u-1">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if add_server %}
|
||||
<div class="pure-u-1-3">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="pure-u-1-2">
|
||||
<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 class="pure-g">
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<input type="text" class="pure-u-23-24" name="url" placeholder="Server management URL"/>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<input type="text"class="pure-u-23-24" name="cert" placeholder="Certificate"/>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<input type="text" class="pure-u-23-24" name="comment" placeholder="Comment"/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</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="main" class="pure-u-1">
|
||||
<div class="server-content">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="pure-u-1-2">
|
||||
<h1 class="server-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 pure-u-md-1-3">
|
||||
<label for="name">Server Name</br> Note that this will not be reflected on the devices of the users that you invited to connect to it.</label>
|
||||
<input type="text" id="name" class="pure-u-23-24" name="name" value="{{server.info()['name']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<label for="comment">Comment</br>This value will be used as "Server name" in client app.</label>
|
||||
<input type="text" id="comment" class="pure-u-23-24" name="comment" value="{{server.info()['comment']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<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-23-24" name="port_for_new_access_keys" value="{{server.info()['port_for_new_access_keys']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<label for="hostname_for_access_keys">Hostname For Access Keys</label>
|
||||
<input type="text" id="hostname_for_access_keys" class="pure-u-23-24" name="hostname_for_access_keys" value="{{server.info()['hostname_for_access_keys']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<label for="url">Server URL</label>
|
||||
<input type="text" readonly id="url" class="pure-u-23-24" name="url" value="{{server.info()['url']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<label for="cert">Server Access Certificate</label>
|
||||
<input type="text" readonly id="cert" class="pure-u-23-24" name="cert" value="{{server.info()['cert']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<label for="created_timestamp_ms">Created</label>
|
||||
<input type="text" readonly id="created_timestamp_ms" class="pure-u-23-24" name="created_timestamp_ms" value="{{format_timestamp(server.info()['created_timestamp_ms']) }}"/>
|
||||
</div>
|
||||
<input type="hidden" readonly id="server_id" class="pure-u-23-24" 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>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,17 +0,0 @@
|
||||
<h1>Last sync log</h1>
|
||||
<form action="/sync" class="pure-form pure-form-stacked" method="POST">
|
||||
<p>Wipe ALL keys on ALL servers?</p>
|
||||
<label for="no_wipe" class="pure-radio">
|
||||
<input type="radio" id="no_wipe" name="wipe" value="no_wipe" checked /> No
|
||||
</label>
|
||||
<label for="do_wipe" class="pure-radio">
|
||||
<input type="radio" id="do_wipe" name="wipe" value="all" /> Yes
|
||||
</label>
|
||||
<button type="submit" class="pure-button button-error pure-input-1 ">Sync now</button>
|
||||
</form>
|
||||
|
||||
<pre>
|
||||
<code>
|
||||
{% for line in lines %}{{ line }}{% endfor %}
|
||||
</code>
|
||||
</pre>
|
||||
0
vpn/__init__.py
Normal file
0
vpn/__init__.py
Normal file
1895
vpn/admin.py
Normal file
1895
vpn/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
867
vpn/admin_xray.py
Normal file
867
vpn/admin_xray.py
Normal file
@@ -0,0 +1,867 @@
|
||||
"""
|
||||
Admin interface for new Xray models.
|
||||
"""
|
||||
|
||||
import json
|
||||
from django.contrib import admin, messages
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.db import models
|
||||
from django.forms import CheckboxSelectMultiple, Textarea
|
||||
from django.shortcuts import render, redirect
|
||||
from django.urls import path, reverse
|
||||
from django.http import JsonResponse, HttpResponseRedirect
|
||||
|
||||
from .models_xray import (
|
||||
Credentials, Certificate,
|
||||
Inbound, SubscriptionGroup, UserSubscription, ServerInbound
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Credentials admin available through direct URL but not in main menu
|
||||
class CredentialsAdmin(admin.ModelAdmin):
|
||||
"""Admin for credentials management (accessible via direct URL only)"""
|
||||
list_display = ('name', 'cred_type', 'description', 'created_at')
|
||||
list_filter = ('cred_type',)
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
'fields': ('name', 'cred_type', 'description')
|
||||
}),
|
||||
('Credentials Data', {
|
||||
'fields': ('credentials_help', 'credentials'),
|
||||
'description': 'Enter credentials as JSON'
|
||||
}),
|
||||
('Preview', {
|
||||
'fields': ('credentials_display',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = ('credentials_display', 'credentials_help', 'created_at', 'updated_at')
|
||||
|
||||
formfield_overrides = {
|
||||
models.JSONField: {'widget': Textarea(attrs={'rows': 6, 'style': 'font-family: monospace;'})},
|
||||
}
|
||||
|
||||
def credentials_help(self, obj):
|
||||
"""Display help for different credential formats"""
|
||||
examples = {
|
||||
'cloudflare': {
|
||||
'api_token': 'your_cloudflare_api_token_here'
|
||||
},
|
||||
'digitalocean': {
|
||||
'token': 'your_digitalocean_token_here'
|
||||
},
|
||||
'aws_route53': {
|
||||
'access_key_id': 'your_access_key_id',
|
||||
'secret_access_key': 'your_secret_access_key',
|
||||
'region': 'us-east-1'
|
||||
}
|
||||
}
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px; margin-bottom: 10px;">'
|
||||
html += '<h4>JSON Examples:</h4>'
|
||||
|
||||
for cred_type, example in examples.items():
|
||||
html += '<div style="margin: 10px 0;">'
|
||||
html += '<strong>' + cred_type.title() + ':</strong>'
|
||||
json_str = json.dumps(example, indent=2)
|
||||
html += '<pre style="background: white; padding: 8px; border-radius: 3px; font-size: 12px;">' + json_str + '</pre>'
|
||||
html += '</div>'
|
||||
|
||||
html += '<p><strong>Note:</strong> Make sure your JSON is valid. Use double quotes for strings.</p>'
|
||||
html += '</div>'
|
||||
|
||||
return mark_safe(html)
|
||||
credentials_help.short_description = 'Credentials Format Help'
|
||||
|
||||
def credentials_display(self, obj):
|
||||
"""Display credentials in a safe format"""
|
||||
if obj.credentials:
|
||||
# Hide sensitive values
|
||||
safe_creds = {}
|
||||
for key, value in obj.credentials.items():
|
||||
if any(sensitive in key.lower() for sensitive in ['token', 'key', 'password', 'secret']):
|
||||
safe_creds[key] = '*' * 8
|
||||
else:
|
||||
safe_creds[key] = value
|
||||
|
||||
return format_html(
|
||||
'<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px;">{}</pre>',
|
||||
json.dumps(safe_creds, indent=2)
|
||||
)
|
||||
return '-'
|
||||
credentials_display.short_description = 'Credentials (Preview)'
|
||||
|
||||
# Credentials admin is available through Certificate admin only
|
||||
# Do not register directly to avoid showing in main menu
|
||||
|
||||
|
||||
@admin.register(Certificate)
|
||||
class CertificateAdmin(admin.ModelAdmin):
|
||||
"""Admin for certificate management"""
|
||||
list_display = (
|
||||
'domain', 'cert_type', 'status_display',
|
||||
'expires_at', 'auto_renew', 'action_buttons'
|
||||
)
|
||||
list_filter = ('cert_type', 'auto_renew')
|
||||
search_fields = ('domain',)
|
||||
actions = ['rotate_selected_certificates']
|
||||
|
||||
fieldsets = (
|
||||
('Certificate Request', {
|
||||
'fields': ('domain', 'cert_type', 'acme_email', 'auto_renew'),
|
||||
'description': 'For Let\'s Encrypt certificates, provide email for ACME registration and select/create credentials below.'
|
||||
}),
|
||||
('API Credentials', {
|
||||
'fields': ('credentials',),
|
||||
'description': 'Select API credentials for automatic Let\'s Encrypt certificate generation'
|
||||
}),
|
||||
('Certificate Generation Status', {
|
||||
'fields': ('generation_help',),
|
||||
'classes': ('wide',)
|
||||
}),
|
||||
('Certificate Data', {
|
||||
'fields': ('certificate_info', 'certificate_pem', 'private_key_pem'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Detailed certificate information'
|
||||
}),
|
||||
('Renewal Settings', {
|
||||
'fields': ('last_renewed',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = (
|
||||
'certificate_info', 'status_display', 'generation_help',
|
||||
'expires_at', 'last_renewed', 'created_at', 'updated_at'
|
||||
)
|
||||
|
||||
def generation_help(self, obj):
|
||||
"""Show help text for certificate generation"""
|
||||
if not obj.pk:
|
||||
return mark_safe('<div style="background: #e3f2fd; padding: 10px; border-radius: 4px;">'
|
||||
'<p><strong>How it works:</strong></p>'
|
||||
'<ol>'
|
||||
'<li>Fill in the domain name</li>'
|
||||
'<li>Select certificate type (Let\'s Encrypt recommended)</li>'
|
||||
'<li>For Let\'s Encrypt: provide email for ACME account registration</li>'
|
||||
'<li>Select credentials with Cloudflare API token</li>'
|
||||
'<li>Save - certificate will be generated automatically</li>'
|
||||
'</ol>'
|
||||
'</div>')
|
||||
|
||||
if obj.cert_type == 'letsencrypt' and not obj.certificate_pem:
|
||||
return mark_safe('<div style="background: #fff3e0; padding: 10px; border-radius: 4px;">'
|
||||
'<p><strong>⏳ Certificate not generated yet</strong></p>'
|
||||
'<p>Certificate will be generated automatically using Let\'s Encrypt DNS-01 challenge.</p>'
|
||||
'</div>')
|
||||
|
||||
if obj.certificate_pem:
|
||||
days = obj.days_until_expiration if obj.days_until_expiration is not None else 'Unknown'
|
||||
return mark_safe('<div style="background: #e8f5e8; padding: 10px; border-radius: 4px;">'
|
||||
'<p><strong>✅ Certificate generated successfully</strong></p>'
|
||||
f'<p>Expires: {obj.expires_at}</p>'
|
||||
f'<p>Days remaining: {days}</p>'
|
||||
'</div>')
|
||||
|
||||
return '-'
|
||||
generation_help.short_description = 'Certificate Generation Status'
|
||||
|
||||
def status_display(self, obj):
|
||||
"""Display certificate status"""
|
||||
if obj.is_expired:
|
||||
return format_html(
|
||||
'<span style="color: red;">❌ Expired</span>'
|
||||
)
|
||||
elif obj.needs_renewal:
|
||||
return format_html(
|
||||
'<span style="color: orange;">⚠️ Needs renewal ({} days)</span>',
|
||||
obj.days_until_expiration
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: green;">✅ Valid ({} days)</span>',
|
||||
obj.days_until_expiration
|
||||
)
|
||||
status_display.short_description = 'Status'
|
||||
|
||||
def certificate_preview(self, obj):
|
||||
"""Preview certificate info"""
|
||||
if obj.certificate_pem:
|
||||
lines = obj.certificate_pem.strip().split('\n')
|
||||
preview = '\n'.join(lines[:5] + ['...'] + lines[-3:])
|
||||
return format_html(
|
||||
'<pre style="background: #f4f4f4; padding: 10px; font-family: monospace; font-size: 12px;">{}</pre>',
|
||||
preview
|
||||
)
|
||||
return '-'
|
||||
certificate_preview.short_description = 'Certificate Preview'
|
||||
|
||||
def action_buttons(self, obj):
|
||||
"""Action buttons for certificate"""
|
||||
buttons = []
|
||||
|
||||
if obj.needs_renewal and obj.auto_renew:
|
||||
renew_url = reverse('admin:certificate_renew', args=[obj.pk])
|
||||
buttons.append(
|
||||
f'<a href="{renew_url}" class="button" style="background: #ff9800;">🔄 Renew Now</a>'
|
||||
)
|
||||
|
||||
if obj.cert_type == 'self_signed':
|
||||
regenerate_url = reverse('admin:certificate_regenerate', args=[obj.pk])
|
||||
buttons.append(
|
||||
f'<a href="{regenerate_url}" class="button">🔄 Regenerate</a>'
|
||||
)
|
||||
|
||||
return format_html(' '.join(buttons)) if buttons else '-'
|
||||
action_buttons.short_description = 'Actions'
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<int:cert_id>/renew/',
|
||||
self.admin_site.admin_view(self.renew_certificate_view),
|
||||
name='certificate_renew'),
|
||||
path('<int:cert_id>/regenerate/',
|
||||
self.admin_site.admin_view(self.regenerate_certificate_view),
|
||||
name='certificate_regenerate'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def renew_certificate_view(self, request, cert_id):
|
||||
"""Renew Let's Encrypt certificate"""
|
||||
try:
|
||||
cert = Certificate.objects.get(pk=cert_id)
|
||||
# TODO: Implement renewal logic
|
||||
messages.success(request, f'Certificate for {cert.domain} renewed successfully!')
|
||||
except Exception as e:
|
||||
messages.error(request, f'Failed to renew certificate: {e}')
|
||||
|
||||
return redirect('admin:vpn_certificate_change', cert_id)
|
||||
|
||||
def regenerate_certificate_view(self, request, cert_id):
|
||||
"""Regenerate self-signed certificate"""
|
||||
try:
|
||||
cert = Certificate.objects.get(pk=cert_id)
|
||||
# TODO: Implement regeneration logic
|
||||
messages.success(request, f'Certificate for {cert.domain} regenerated successfully!')
|
||||
except Exception as e:
|
||||
messages.error(request, f'Failed to regenerate certificate: {e}')
|
||||
|
||||
return redirect('admin:vpn_certificate_change', cert_id)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Auto-generate certificate for Let's Encrypt after saving"""
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
# Auto-generate Let's Encrypt certificate if needed
|
||||
if obj.cert_type == 'letsencrypt' and not obj.certificate_pem:
|
||||
try:
|
||||
self.generate_letsencrypt_certificate(obj, request)
|
||||
except Exception as e:
|
||||
messages.warning(request, f'Certificate saved but auto-generation failed: {e}')
|
||||
|
||||
def generate_letsencrypt_certificate(self, cert_obj, request):
|
||||
"""Generate Let's Encrypt certificate using DNS-01 challenge"""
|
||||
if not cert_obj.credentials:
|
||||
messages.error(request, 'Credentials required for Let\'s Encrypt certificate generation')
|
||||
return
|
||||
|
||||
if not cert_obj.acme_email:
|
||||
messages.error(request, 'ACME email address required for Let\'s Encrypt certificate generation')
|
||||
return
|
||||
|
||||
try:
|
||||
from vpn.letsencrypt.letsencrypt_dns import get_certificate_for_domain
|
||||
from datetime import datetime, timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
# Get Cloudflare credentials
|
||||
api_token = cert_obj.credentials.get_credential('api_token')
|
||||
|
||||
if not api_token:
|
||||
messages.error(request, 'Cloudflare API token not found in credentials')
|
||||
return
|
||||
|
||||
messages.info(request, f'🔄 Generating Let\'s Encrypt certificate for {cert_obj.domain} using {cert_obj.acme_email}...')
|
||||
|
||||
# Schedule certificate generation via Celery
|
||||
from vpn.tasks import generate_certificate_task
|
||||
task = generate_certificate_task.delay(cert_obj.id)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f'🔄 Certificate generation scheduled for {cert_obj.domain}. Task ID: {task.id}'
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
messages.warning(request, 'Let\'s Encrypt DNS challenge library not available')
|
||||
except Exception as e:
|
||||
messages.error(request, f'Failed to generate certificate: {str(e)}')
|
||||
# Log the full error for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Certificate generation failed for {cert_obj.domain}: {e}', exc_info=True)
|
||||
|
||||
def certificate_info(self, obj):
|
||||
"""Display detailed certificate information"""
|
||||
if not obj.pk:
|
||||
return "Save certificate to see details"
|
||||
|
||||
if not obj.certificate_pem:
|
||||
return "Certificate not generated yet"
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px;">'
|
||||
|
||||
# Import here to avoid circular imports
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
# Parse certificate
|
||||
cert = x509.load_pem_x509_certificate(obj.certificate_pem.encode(), default_backend())
|
||||
|
||||
# Basic info
|
||||
html += '<h4>📜 Certificate Information</h4>'
|
||||
html += '<table style="width: 100%; font-size: 12px;">'
|
||||
html += f'<tr><td><strong>Subject:</strong></td><td>{cert.subject.rfc4514_string()}</td></tr>'
|
||||
html += f'<tr><td><strong>Issuer:</strong></td><td>{cert.issuer.rfc4514_string()}</td></tr>'
|
||||
html += f'<tr><td><strong>Serial Number:</strong></td><td>{cert.serial_number}</td></tr>'
|
||||
# Use UTC versions to avoid deprecation warnings
|
||||
try:
|
||||
# Try new UTC properties first (cryptography >= 42.0.0)
|
||||
valid_from = cert.not_valid_before_utc
|
||||
valid_until = cert.not_valid_after_utc
|
||||
cert_not_after = valid_until
|
||||
except AttributeError:
|
||||
# Fall back to old properties for older cryptography versions
|
||||
valid_from = cert.not_valid_before
|
||||
valid_until = cert.not_valid_after
|
||||
cert_not_after = cert.not_valid_after
|
||||
if cert_not_after.tzinfo is None:
|
||||
cert_not_after = cert_not_after.replace(tzinfo=timezone.utc)
|
||||
|
||||
html += f'<tr><td><strong>Valid From:</strong></td><td>{valid_from}</td></tr>'
|
||||
html += f'<tr><td><strong>Valid Until:</strong></td><td>{valid_until}</td></tr>'
|
||||
|
||||
# Status
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
days_until_expiry = (cert_not_after - now).days
|
||||
|
||||
if days_until_expiry < 0:
|
||||
status = f'<span style="color: red;">❌ Expired {abs(days_until_expiry)} days ago</span>'
|
||||
elif days_until_expiry < 30:
|
||||
status = f'<span style="color: orange;">⚠️ Expires in {days_until_expiry} days</span>'
|
||||
else:
|
||||
status = f'<span style="color: green;">✅ Valid for {days_until_expiry} days</span>'
|
||||
|
||||
html += f'<tr><td><strong>Status:</strong></td><td>{status}</td></tr>'
|
||||
|
||||
# Extensions
|
||||
try:
|
||||
san = cert.extensions.get_extension_for_oid(x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
|
||||
domains = [name.value for name in san.value]
|
||||
html += f'<tr><td><strong>Domains:</strong></td><td>{", ".join(domains)}</td></tr>'
|
||||
except:
|
||||
# No SAN extension or other error
|
||||
pass
|
||||
|
||||
html += '</table>'
|
||||
|
||||
except ImportError:
|
||||
html += '<p>⚠️ Install cryptography package to see detailed certificate information</p>'
|
||||
except Exception as e:
|
||||
html += f'<p>❌ Error parsing certificate: {e}</p>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
certificate_info.short_description = 'Certificate Details'
|
||||
|
||||
def rotate_selected_certificates(self, request, queryset):
|
||||
"""Admin action to rotate selected certificates"""
|
||||
from vpn.tasks import generate_certificate_task
|
||||
|
||||
# Filter only Let's Encrypt certificates
|
||||
valid_certs = queryset.filter(cert_type='letsencrypt')
|
||||
if not valid_certs.exists():
|
||||
self.message_user(request, "No Let's Encrypt certificates selected. Only Let's Encrypt certificates can be rotated.", level='ERROR')
|
||||
return
|
||||
|
||||
# Check for certificates without credentials
|
||||
certs_without_creds = valid_certs.filter(credentials__isnull=True)
|
||||
if certs_without_creds.exists():
|
||||
domains = ', '.join(certs_without_creds.values_list('domain', flat=True))
|
||||
self.message_user(request, f"The following certificates have no credentials configured and will be skipped: {domains}", level='WARNING')
|
||||
|
||||
# Filter certificates that have credentials
|
||||
certs_to_rotate = valid_certs.filter(credentials__isnull=False)
|
||||
|
||||
if not certs_to_rotate.exists():
|
||||
self.message_user(request, "No certificates with valid credentials found.", level='ERROR')
|
||||
return
|
||||
|
||||
# Launch rotation tasks
|
||||
rotated_count = 0
|
||||
task_ids = []
|
||||
|
||||
for certificate in certs_to_rotate:
|
||||
try:
|
||||
task = generate_certificate_task.delay(certificate.id)
|
||||
task_ids.append(task.id)
|
||||
rotated_count += 1
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Failed to start rotation for {certificate.domain}: {str(e)}", level='ERROR')
|
||||
|
||||
if rotated_count > 0:
|
||||
domains = ', '.join(certs_to_rotate.values_list('domain', flat=True))
|
||||
task_list = ', '.join(task_ids)
|
||||
self.message_user(
|
||||
request,
|
||||
f'Successfully initiated certificate rotation for {rotated_count} certificate(s): {domains}. '
|
||||
f'Task IDs: {task_list}. Certificates will be automatically redeployed to all servers once generated.',
|
||||
level='SUCCESS'
|
||||
)
|
||||
|
||||
rotate_selected_certificates.short_description = "🔄 Rotate selected Let's Encrypt certificates"
|
||||
|
||||
|
||||
@admin.register(Inbound)
|
||||
class InboundAdmin(admin.ModelAdmin):
|
||||
"""Admin for inbound template management"""
|
||||
list_display = (
|
||||
'name', 'protocol', 'port', 'network',
|
||||
'security', 'certificate_status', 'group_count'
|
||||
)
|
||||
list_filter = ('protocol', 'network', 'security')
|
||||
search_fields = ('name',)
|
||||
|
||||
fieldsets = (
|
||||
('Basic Configuration', {
|
||||
'fields': ('name', 'protocol', 'port'),
|
||||
'description': 'Domain will be taken from server client_hostname when deployed'
|
||||
}),
|
||||
('Transport & Security', {
|
||||
'fields': ('network', 'security', 'certificate', 'listen_address')
|
||||
}),
|
||||
('Advanced Settings', {
|
||||
'fields': ('enable_sniffing', 'full_config_display'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Configuration is auto-generated based on basic settings above'
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = ('full_config_display', 'created_at', 'updated_at')
|
||||
|
||||
def certificate_status(self, obj):
|
||||
"""Display certificate status"""
|
||||
if obj.security == 'tls':
|
||||
if obj.certificate:
|
||||
if obj.certificate.is_expired:
|
||||
return format_html('<span style="color: red;">❌ Expired</span>')
|
||||
else:
|
||||
return format_html('<span style="color: green;">✅ Valid</span>')
|
||||
else:
|
||||
return format_html('<span style="color: orange;">⚠️ No cert</span>')
|
||||
return format_html('<span style="color: gray;">-</span>')
|
||||
certificate_status.short_description = 'Cert Status'
|
||||
|
||||
def group_count(self, obj):
|
||||
"""Number of groups this inbound belongs to"""
|
||||
return obj.subscriptiongroup_set.count()
|
||||
group_count.short_description = 'Groups'
|
||||
|
||||
def full_config_display(self, obj):
|
||||
"""Display full config in formatted JSON"""
|
||||
if obj.full_config:
|
||||
return format_html(
|
||||
'<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 400px; overflow-y: auto;">{}</pre>',
|
||||
json.dumps(obj.full_config, indent=2)
|
||||
)
|
||||
return 'Not generated yet'
|
||||
full_config_display.short_description = 'Configuration Preview'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Generate config on save"""
|
||||
try:
|
||||
# Always regenerate config to reflect any changes
|
||||
obj.build_config()
|
||||
if change:
|
||||
messages.success(request, f'✅ Inbound "{obj.name}" updated. Changes will be automatically deployed to servers.')
|
||||
else:
|
||||
messages.success(request, f'✅ Inbound "{obj.name}" created. It will be deployed when added to subscription groups.')
|
||||
except Exception as e:
|
||||
messages.warning(request, f'Inbound saved but config generation failed: {e}')
|
||||
# Set empty dict if generation fails
|
||||
if not obj.full_config:
|
||||
obj.full_config = {}
|
||||
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class InboundInline(admin.TabularInline):
|
||||
"""Inline for inbounds in subscription groups"""
|
||||
model = SubscriptionGroup.inbounds.through
|
||||
extra = 1
|
||||
verbose_name = "Inbound"
|
||||
verbose_name_plural = "Inbounds in this group"
|
||||
|
||||
|
||||
class SubscriptionGroupAdmin(admin.ModelAdmin):
|
||||
"""Admin for subscription groups"""
|
||||
list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('name', 'description')
|
||||
filter_horizontal = ('inbounds',)
|
||||
|
||||
fieldsets = (
|
||||
('Group Information', {
|
||||
'fields': ('name', 'description', 'is_active')
|
||||
}),
|
||||
('Inbounds', {
|
||||
'fields': ('inbounds',),
|
||||
'description': 'Select inbounds to include in this group. ' +
|
||||
'<br><strong>🚀 Auto-sync enabled:</strong> Changes will be automatically deployed to servers!'
|
||||
}),
|
||||
('Statistics', {
|
||||
'fields': ('group_statistics',),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = ('group_statistics',)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Override save to notify about auto-sync"""
|
||||
super().save_model(request, obj, form, change)
|
||||
if change:
|
||||
messages.success(
|
||||
request,
|
||||
f'Subscription group "{obj.name}" updated. Changes will be automatically synchronized to all Xray servers.'
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
f'Subscription group "{obj.name}" created. Inbounds will be automatically deployed when you add them to this group.'
|
||||
)
|
||||
|
||||
def group_statistics(self, obj):
|
||||
"""Display group statistics"""
|
||||
if obj.pk:
|
||||
stats = {
|
||||
'Total Inbounds': obj.inbound_count,
|
||||
'Active Users': obj.user_count,
|
||||
'Protocols': list(obj.inbounds.values_list('protocol', flat=True).distinct()),
|
||||
'Ports': list(obj.inbounds.values_list('port', flat=True).distinct())
|
||||
}
|
||||
|
||||
html = '<div style="background: #f4f4f4; padding: 10px; border-radius: 4px;">'
|
||||
for key, value in stats.items():
|
||||
if isinstance(value, list):
|
||||
value = ', '.join(map(str, value))
|
||||
html += f'<div><strong>{key}:</strong> {value}</div>'
|
||||
html += '</div>'
|
||||
|
||||
return format_html(html)
|
||||
return 'Save to see statistics'
|
||||
group_statistics.short_description = 'Group Statistics'
|
||||
|
||||
|
||||
|
||||
class UserSubscriptionInline(admin.TabularInline):
|
||||
"""Inline for user subscriptions"""
|
||||
model = UserSubscription
|
||||
extra = 0
|
||||
fields = ('subscription_group', 'active', 'created_at')
|
||||
readonly_fields = ('created_at',)
|
||||
verbose_name = "Subscription Group"
|
||||
verbose_name_plural = "User's Subscription Groups"
|
||||
|
||||
|
||||
# Extension for User admin
|
||||
def add_subscription_management_to_user(UserAdmin):
|
||||
"""Add subscription management to existing User admin"""
|
||||
|
||||
# Add inline
|
||||
if hasattr(UserAdmin, 'inlines'):
|
||||
UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline]
|
||||
else:
|
||||
UserAdmin.inlines = [UserSubscriptionInline]
|
||||
|
||||
# Add custom fields to fieldsets
|
||||
original_fieldsets = list(UserAdmin.fieldsets)
|
||||
|
||||
# Find where to insert our fieldset
|
||||
insert_index = len(original_fieldsets)
|
||||
for i, (title, fields_dict) in enumerate(original_fieldsets):
|
||||
if title and 'Statistics' in title:
|
||||
insert_index = i + 1
|
||||
break
|
||||
|
||||
# Insert our fieldset
|
||||
subscription_fieldset = (
|
||||
'Xray Subscriptions', {
|
||||
'fields': ('subscription_groups_widget',),
|
||||
'classes': ('wide',)
|
||||
}
|
||||
)
|
||||
original_fieldsets.insert(insert_index, subscription_fieldset)
|
||||
UserAdmin.fieldsets = tuple(original_fieldsets)
|
||||
|
||||
# Add readonly field
|
||||
if hasattr(UserAdmin, 'readonly_fields'):
|
||||
UserAdmin.readonly_fields = list(UserAdmin.readonly_fields) + ['subscription_groups_widget']
|
||||
else:
|
||||
UserAdmin.readonly_fields = ['subscription_groups_widget']
|
||||
|
||||
# Add method for displaying subscription groups
|
||||
def subscription_groups_widget(self, obj):
|
||||
"""Display subscription groups management widget"""
|
||||
if not obj or not obj.pk:
|
||||
return mark_safe('<div style="color: #6c757d;">Save user first to manage subscriptions</div>')
|
||||
|
||||
# Get all groups and user's current subscriptions
|
||||
all_groups = SubscriptionGroup.objects.filter(is_active=True)
|
||||
user_groups = obj.xray_subscriptions.filter(active=True).values_list('subscription_group_id', flat=True)
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px;">'
|
||||
html += '<h4 style="margin-top: 0;">Available Subscription Groups:</h4>'
|
||||
|
||||
if all_groups:
|
||||
html += '<div style="display: grid; gap: 10px;">'
|
||||
for group in all_groups:
|
||||
checked = 'checked' if group.id in user_groups else ''
|
||||
status = '✅' if group.id in user_groups else '⬜'
|
||||
|
||||
html += f'''
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 8px; background: white; border-radius: 4px;">
|
||||
<span style="font-size: 18px;">{status}</span>
|
||||
<label style="flex: 1; cursor: pointer;">
|
||||
<strong>{group.name}</strong>
|
||||
{f' - {group.description}' if group.description else ''}
|
||||
<small style="color: #6c757d;"> ({group.inbound_count} inbounds)</small>
|
||||
</label>
|
||||
</div>
|
||||
'''
|
||||
html += '</div>'
|
||||
html += '<div style="margin-top: 10px; color: #6c757d; font-size: 12px;">'
|
||||
html += 'ℹ️ Use the inline form below to manage subscriptions'
|
||||
html += '</div>'
|
||||
else:
|
||||
html += '<div style="color: #6c757d;">No active subscription groups available</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
subscription_groups_widget.short_description = 'Subscription Groups Overview'
|
||||
UserAdmin.subscription_groups_widget = subscription_groups_widget
|
||||
|
||||
|
||||
# UserSubscription admin will be integrated into unified Subscriptions admin
|
||||
class UserSubscriptionAdmin(admin.ModelAdmin):
|
||||
"""Admin for user subscriptions (integrated into unified Subscriptions admin)"""
|
||||
list_display = ('user', 'subscription_group', 'active', 'created_at')
|
||||
list_filter = ('active', 'subscription_group')
|
||||
search_fields = ('user__username', 'subscription_group__name')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return True # Allow adding subscriptions
|
||||
|
||||
|
||||
# ServerInbound admin is integrated into XrayServerV2 admin, not shown in main menu
|
||||
class ServerInboundAdmin(admin.ModelAdmin):
|
||||
"""Admin for server-inbound deployment tracking"""
|
||||
list_display = ('server', 'inbound', 'active', 'deployed_at', 'updated_at')
|
||||
list_filter = ('active', 'inbound__protocol', 'deployed_at')
|
||||
search_fields = ('server__name', 'inbound__name')
|
||||
date_hierarchy = 'deployed_at'
|
||||
|
||||
fieldsets = (
|
||||
('Template Deployment', {
|
||||
'fields': ('server', 'inbound', 'active')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('deployed_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = ('deployed_at', 'updated_at')
|
||||
|
||||
|
||||
# Unified Subscriptions Admin with tabs
|
||||
@admin.register(SubscriptionGroup)
|
||||
class UnifiedSubscriptionsAdmin(admin.ModelAdmin):
|
||||
"""Unified admin for managing both Subscription Groups and User Subscriptions"""
|
||||
|
||||
# Use SubscriptionGroup as the base model but provide access to UserSubscription via tabs
|
||||
list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('name', 'description')
|
||||
filter_horizontal = ('inbounds',)
|
||||
|
||||
def get_urls(self):
|
||||
"""Add custom URLs for user subscriptions tab"""
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('user-subscriptions/',
|
||||
self.admin_site.admin_view(self.user_subscriptions_view),
|
||||
name='vpn_usersubscription_changelist_tab'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def user_subscriptions_view(self, request):
|
||||
"""Redirect to user subscriptions with tab navigation"""
|
||||
from django.shortcuts import redirect
|
||||
return redirect('/admin/vpn/usersubscription/')
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
"""Override changelist to add tab navigation"""
|
||||
extra_context = extra_context or {}
|
||||
extra_context.update({
|
||||
'show_tab_navigation': True,
|
||||
'current_tab': 'subscription_groups'
|
||||
})
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||
"""Override change view to add tab navigation"""
|
||||
extra_context = extra_context or {}
|
||||
extra_context.update({
|
||||
'show_tab_navigation': True,
|
||||
'current_tab': 'subscription_groups'
|
||||
})
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
"""Override add view to add tab navigation"""
|
||||
extra_context = extra_context or {}
|
||||
extra_context.update({
|
||||
'show_tab_navigation': True,
|
||||
'current_tab': 'subscription_groups'
|
||||
})
|
||||
return super().add_view(request, form_url, extra_context)
|
||||
|
||||
# Copy fieldsets and methods from SubscriptionGroupAdmin
|
||||
fieldsets = (
|
||||
('Group Information', {
|
||||
'fields': ('name', 'description', 'is_active')
|
||||
}),
|
||||
('Inbounds', {
|
||||
'fields': ('inbounds',),
|
||||
'description': 'Select inbounds to include in this group. ' +
|
||||
'<br><strong>🚀 Auto-sync enabled:</strong> Changes will be automatically deployed to servers!'
|
||||
}),
|
||||
('Statistics', {
|
||||
'fields': ('group_statistics',),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = ('group_statistics',)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Override save to notify about auto-sync"""
|
||||
super().save_model(request, obj, form, change)
|
||||
if change:
|
||||
messages.success(
|
||||
request,
|
||||
f'Subscription group "{obj.name}" updated. Changes will be automatically synchronized to all Xray servers.'
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
f'Subscription group "{obj.name}" created. Inbounds will be automatically deployed when you add them to this group.'
|
||||
)
|
||||
|
||||
def group_statistics(self, obj):
|
||||
"""Display group statistics"""
|
||||
if obj.pk:
|
||||
stats = {
|
||||
'Total Inbounds': obj.inbound_count,
|
||||
'Active Users': obj.user_count,
|
||||
'Protocols': list(obj.inbounds.values_list('protocol', flat=True).distinct()),
|
||||
'Ports': list(obj.inbounds.values_list('port', flat=True).distinct())
|
||||
}
|
||||
|
||||
html = '<div style="background: #f4f4f4; padding: 10px; border-radius: 4px;">'
|
||||
for key, value in stats.items():
|
||||
if isinstance(value, list):
|
||||
value = ', '.join(map(str, value))
|
||||
html += f'<div><strong>{key}:</strong> {value}</div>'
|
||||
html += '</div>'
|
||||
|
||||
return format_html(html)
|
||||
return 'Save to see statistics'
|
||||
group_statistics.short_description = 'Group Statistics'
|
||||
|
||||
|
||||
# UserSubscription admin with tab navigation (hidden from main menu)
|
||||
@admin.register(UserSubscription)
|
||||
class UserSubscriptionTabAdmin(UserSubscriptionAdmin):
|
||||
"""UserSubscription admin with tab navigation"""
|
||||
|
||||
def has_module_permission(self, request):
|
||||
"""Hide this model from the main admin index"""
|
||||
return False
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
"""Allow viewing through direct URL access"""
|
||||
return request.user.is_staff
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Allow adding through direct URL access"""
|
||||
return request.user.is_staff
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Allow changing through direct URL access"""
|
||||
return request.user.is_staff
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Allow deleting through direct URL access"""
|
||||
return request.user.is_staff
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
"""Override changelist to add tab navigation"""
|
||||
extra_context = extra_context or {}
|
||||
extra_context.update({
|
||||
'show_tab_navigation': True,
|
||||
'current_tab': 'user_subscriptions'
|
||||
})
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||
"""Override change view to add tab navigation"""
|
||||
extra_context = extra_context or {}
|
||||
extra_context.update({
|
||||
'show_tab_navigation': True,
|
||||
'current_tab': 'user_subscriptions'
|
||||
})
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
"""Override add view to add tab navigation"""
|
||||
extra_context = extra_context or {}
|
||||
extra_context.update({
|
||||
'show_tab_navigation': True,
|
||||
'current_tab': 'user_subscriptions'
|
||||
})
|
||||
return super().add_view(request, form_url, extra_context)
|
||||
13
vpn/apps.py
Normal file
13
vpn/apps.py
Normal file
@@ -0,0 +1,13 @@
|
||||
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'
|
||||
|
||||
def ready(self):
|
||||
"""Import signals when Django starts"""
|
||||
try:
|
||||
import vpn.signals # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
14
vpn/forms.py
Normal file
14
vpn/forms.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
from .models import User
|
||||
from .server_plugins import Server
|
||||
|
||||
class UserForm(forms.ModelForm):
|
||||
servers = forms.ModelMultipleChoiceField(
|
||||
queryset=Server.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'comment', 'servers']
|
||||
13
vpn/letsencrypt/__init__.py
Normal file
13
vpn/letsencrypt/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Let's Encrypt DNS Challenge Library for OutFleet"""
|
||||
|
||||
from .letsencrypt_dns import (
|
||||
AcmeDnsChallenge,
|
||||
get_certificate,
|
||||
get_certificate_for_domain
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'AcmeDnsChallenge',
|
||||
'get_certificate',
|
||||
'get_certificate_for_domain'
|
||||
]
|
||||
403
vpn/letsencrypt/letsencrypt_dns.py
Normal file
403
vpn/letsencrypt/letsencrypt_dns.py
Normal file
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Let's Encrypt/ZeroSSL Certificate Library with Cloudflare DNS Challenge
|
||||
Generate publicly trusted SSL certificates using ACME DNS-01 challenge
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from acme import client, messages, challenges, errors
|
||||
from acme.client import ClientV2
|
||||
import josepy as jose
|
||||
from cloudflare import Cloudflare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AcmeDnsChallenge:
|
||||
"""ACME DNS-01 Challenge handler with Cloudflare API"""
|
||||
|
||||
def __init__(self, cloudflare_token: str, acme_directory: str = None):
|
||||
"""
|
||||
Initialize ACME DNS challenge handler
|
||||
|
||||
Args:
|
||||
cloudflare_token: Cloudflare API token with DNS edit permissions
|
||||
acme_directory: ACME directory URL (defaults to Let's Encrypt production)
|
||||
"""
|
||||
self.cf_token = cloudflare_token
|
||||
self.cf = Cloudflare(api_token=cloudflare_token)
|
||||
|
||||
# ACME directory URLs
|
||||
self.acme_directories = {
|
||||
'letsencrypt': 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
'letsencrypt_staging': 'https://acme-staging-v02.api.letsencrypt.org/directory',
|
||||
'zerossl': 'https://acme.zerossl.com/v2/DV90'
|
||||
}
|
||||
|
||||
self.acme_directory = acme_directory or self.acme_directories['letsencrypt']
|
||||
self.acme_client = None
|
||||
self.account_key = None
|
||||
|
||||
def _generate_account_key(self) -> jose.JWKRSA:
|
||||
"""Generate RSA private key for ACME account"""
|
||||
# Generate cryptography key first
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048
|
||||
)
|
||||
# Convert to josepy format for ACME
|
||||
return jose.JWKRSA(key=private_key)
|
||||
|
||||
def _get_zone_id(self, domain: str) -> str:
|
||||
"""Get Cloudflare zone ID for domain"""
|
||||
try:
|
||||
# Get base domain (remove subdomains)
|
||||
parts = domain.split('.')
|
||||
if len(parts) >= 2:
|
||||
base_domain = '.'.join(parts[-2:])
|
||||
else:
|
||||
base_domain = domain
|
||||
|
||||
zones = self.cf.zones.list(name=base_domain)
|
||||
if not zones.result:
|
||||
raise ValueError(f"Domain {base_domain} not found in Cloudflare")
|
||||
|
||||
return zones.result[0].id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get zone ID for {domain}: {e}")
|
||||
raise
|
||||
|
||||
def _create_dns_record(self, domain: str, name: str, content: str) -> str:
|
||||
"""Create DNS TXT record for ACME challenge"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
|
||||
result = self.cf.dns.records.create(
|
||||
zone_id=zone_id,
|
||||
name=name,
|
||||
type='TXT',
|
||||
content=content,
|
||||
ttl=60 # 1 minute TTL for faster propagation
|
||||
)
|
||||
logger.info(f"Created DNS record: {name} = {content}")
|
||||
return result.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create DNS record {name}: {e}")
|
||||
raise
|
||||
|
||||
def _delete_dns_record(self, domain: str, record_id: str):
|
||||
"""Delete DNS TXT record"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
self.cf.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
||||
logger.info(f"Deleted DNS record: {record_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete DNS record {record_id}: {e}")
|
||||
|
||||
def _wait_for_dns_propagation(self, record_name: str, expected_value: str, wait_time: int = 20):
|
||||
"""Wait for DNS record to propagate - no local checks, just wait"""
|
||||
logger.info(f"Waiting {wait_time} seconds for DNS propagation of {record_name}...")
|
||||
logger.info(f"Record value: {expected_value}")
|
||||
logger.info("(No local DNS checks - Let's Encrypt servers will verify)")
|
||||
|
||||
time.sleep(wait_time)
|
||||
|
||||
logger.info("DNS propagation wait completed - proceeding with challenge")
|
||||
return True
|
||||
|
||||
def create_acme_client(self, email: str, accept_tos: bool = True) -> ClientV2:
|
||||
"""Create and register ACME client"""
|
||||
if self.acme_client:
|
||||
return self.acme_client
|
||||
|
||||
try:
|
||||
logger.info("Generating ACME account key...")
|
||||
# Generate account key
|
||||
self.account_key = self._generate_account_key()
|
||||
logger.info("Account key generated successfully")
|
||||
|
||||
logger.info(f"Connecting to ACME directory: {self.acme_directory}")
|
||||
# Create ACME client
|
||||
net = client.ClientNetwork(self.account_key, user_agent='letsencrypt-dns-lib/1.0')
|
||||
logger.info("Getting ACME directory...")
|
||||
directory_response = net.get(self.acme_directory)
|
||||
logger.info(f"Directory response status: {directory_response.status_code}")
|
||||
directory = messages.Directory.from_json(directory_response.json())
|
||||
logger.info("ACME directory loaded successfully")
|
||||
|
||||
self.acme_client = ClientV2(directory, net=net)
|
||||
logger.info("ACME client created successfully")
|
||||
|
||||
# Register account
|
||||
logger.info(f"Registering ACME account for email: {email}")
|
||||
try:
|
||||
registration = messages.NewRegistration.from_data(
|
||||
email=email,
|
||||
terms_of_service_agreed=accept_tos
|
||||
)
|
||||
logger.info("Sending account registration...")
|
||||
account = self.acme_client.new_account(registration)
|
||||
logger.info(f"ACME account registered: {account.uri}")
|
||||
|
||||
except errors.ConflictError as e:
|
||||
logger.info(f"Account already exists (ConflictError): {e}")
|
||||
# Account already exists
|
||||
account = self.acme_client.query_registration(messages.NewRegistration())
|
||||
logger.info("Using existing ACME account")
|
||||
except Exception as reg_e:
|
||||
logger.error(f"Account registration failed: {reg_e}")
|
||||
logger.error(f"Registration error type: {type(reg_e).__name__}")
|
||||
raise
|
||||
|
||||
return self.acme_client
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
logger.error(f"Error type: {type(e).__name__}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
def request_certificate(self, domains: List[str], email: str,
|
||||
key_size: int = 2048) -> Tuple[str, str]:
|
||||
"""
|
||||
Request certificate using DNS-01 challenge
|
||||
|
||||
Args:
|
||||
domains: List of domain names for certificate
|
||||
email: Email for ACME account registration
|
||||
key_size: RSA key size for certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
logger.info(f"Requesting certificate for domains: {domains}")
|
||||
|
||||
try:
|
||||
# Create ACME client
|
||||
logger.info("Creating ACME client...")
|
||||
acme_client = self.create_acme_client(email)
|
||||
logger.info("ACME client created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
try:
|
||||
# Generate private key for certificate
|
||||
logger.info(f"Generating {key_size}-bit RSA private key...")
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size
|
||||
)
|
||||
logger.info("Private key generated successfully")
|
||||
|
||||
# Create CSR
|
||||
logger.info(f"Creating CSR for domains: {domains}")
|
||||
csr_obj = x509.CertificateSigningRequestBuilder().subject_name(
|
||||
x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, domains[0])
|
||||
])
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(domain) for domain in domains
|
||||
]),
|
||||
critical=False
|
||||
).sign(private_key, hashes.SHA256())
|
||||
|
||||
# Convert CSR to PEM format for ACME
|
||||
csr_pem = csr_obj.public_bytes(serialization.Encoding.PEM)
|
||||
logger.info("CSR created successfully")
|
||||
|
||||
# Request certificate
|
||||
logger.info("Requesting certificate order from ACME...")
|
||||
order = acme_client.new_order(csr_pem)
|
||||
logger.info(f"Created ACME order: {order.uri}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed during CSR/order creation: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# Process challenges - collect all challenges first, then create DNS records
|
||||
dns_records = []
|
||||
challenges_to_answer = []
|
||||
|
||||
try:
|
||||
# First pass: collect all challenges and create DNS records
|
||||
for authorization in order.authorizations:
|
||||
domain = authorization.body.identifier.value
|
||||
logger.info(f"Processing authorization for: {domain}")
|
||||
|
||||
# Find DNS-01 challenge
|
||||
dns_challenge = None
|
||||
for challenge in authorization.body.challenges:
|
||||
if isinstance(challenge.chall, challenges.DNS01):
|
||||
dns_challenge = challenge
|
||||
break
|
||||
|
||||
if not dns_challenge:
|
||||
raise ValueError(f"No DNS-01 challenge found for {domain}")
|
||||
|
||||
# Calculate challenge response
|
||||
response, validation = dns_challenge.response_and_validation(acme_client.net.key)
|
||||
|
||||
# For wildcard domains, use base domain for DNS record
|
||||
if domain.startswith('*.'):
|
||||
dns_domain = domain[2:] # Remove *. prefix
|
||||
else:
|
||||
dns_domain = domain
|
||||
|
||||
# Create DNS record
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
|
||||
# Check if we already created this DNS record
|
||||
existing_record = None
|
||||
for existing_domain, existing_id, existing_validation in dns_records:
|
||||
if existing_domain == dns_domain:
|
||||
existing_record = (existing_domain, existing_id, existing_validation)
|
||||
break
|
||||
|
||||
if existing_record:
|
||||
logger.info(f"DNS record already exists for {dns_domain}, reusing...")
|
||||
record_id = existing_record[1]
|
||||
# Verify the validation value matches
|
||||
if existing_record[2] != validation:
|
||||
logger.warning(f"Validation values differ for {dns_domain}! This may cause issues.")
|
||||
else:
|
||||
logger.info(f"Creating DNS record for {dns_domain}...")
|
||||
record_id = self._create_dns_record(dns_domain, record_name, validation)
|
||||
dns_records.append((dns_domain, record_id, validation))
|
||||
|
||||
# Store challenge to answer later
|
||||
challenges_to_answer.append((dns_challenge, response, domain, dns_domain))
|
||||
|
||||
# Wait for DNS propagation once for all records
|
||||
if dns_records:
|
||||
logger.info(f"Waiting for DNS propagation for {len(dns_records)} DNS records...")
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
self._wait_for_dns_propagation(record_name, validation)
|
||||
|
||||
# Second pass: answer all challenges
|
||||
for dns_challenge, response, domain, dns_domain in challenges_to_answer:
|
||||
logger.info(f"Responding to DNS challenge for {domain}...")
|
||||
challenge_response = acme_client.answer_challenge(dns_challenge, response)
|
||||
logger.info(f"Challenge response sent for {domain}")
|
||||
|
||||
# Finalize order
|
||||
logger.info("Finalizing certificate order...")
|
||||
order = acme_client.poll_and_finalize(order)
|
||||
|
||||
# Get certificate
|
||||
certificate_pem = order.fullchain_pem
|
||||
private_key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode('utf-8')
|
||||
|
||||
logger.info("Certificate obtained successfully!")
|
||||
return certificate_pem, private_key_pem
|
||||
|
||||
finally:
|
||||
# Clean up DNS records
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
try:
|
||||
self._delete_dns_record(dns_domain, record_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup DNS record for {dns_domain}: {e}")
|
||||
|
||||
def get_certificate(domains: List[str], email: str, cloudflare_token: str,
|
||||
provider: str = 'letsencrypt', staging: bool = False) -> Tuple[str, str]:
|
||||
"""
|
||||
Simple function to get Let's Encrypt/ZeroSSL certificate
|
||||
|
||||
Args:
|
||||
domains: List of domains for certificate
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
provider: 'letsencrypt' or 'zerossl'
|
||||
staging: Use staging environment (for testing)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
# Select ACME directory
|
||||
acme_dns = AcmeDnsChallenge(cloudflare_token)
|
||||
|
||||
if provider == 'letsencrypt':
|
||||
if staging:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt_staging']
|
||||
else:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt']
|
||||
elif provider == 'zerossl':
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['zerossl']
|
||||
else:
|
||||
raise ValueError("Provider must be 'letsencrypt' or 'zerossl'")
|
||||
|
||||
return acme_dns.request_certificate(domains, email)
|
||||
|
||||
def get_certificate_for_domain(domain: str, email: str, cloudflare_token: str,
|
||||
include_wildcard: bool = False, **kwargs) -> Tuple[str, str]:
|
||||
"""
|
||||
Helper function to get certificate for single domain (compatible with Cloudflare cert lib)
|
||||
|
||||
Args:
|
||||
domain: Primary domain
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
include_wildcard: Include wildcard subdomain
|
||||
**kwargs: Additional arguments (provider, staging)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
domains = [domain]
|
||||
if include_wildcard:
|
||||
domains.append(f"*.{domain}")
|
||||
|
||||
return get_certificate(domains, email, cloudflare_token, **kwargs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
print("Usage: python letsencrypt_dns.py <domain> <email> <cloudflare_token>")
|
||||
sys.exit(1)
|
||||
|
||||
domain, email, token = sys.argv[1:4]
|
||||
|
||||
try:
|
||||
cert_pem, key_pem = get_certificate_for_domain(
|
||||
domain=domain,
|
||||
email=email,
|
||||
cloudflare_token=token,
|
||||
include_wildcard=True,
|
||||
staging=True # Use staging for testing
|
||||
)
|
||||
|
||||
print(f"Certificate obtained for {domain}")
|
||||
print(f"Certificate length: {len(cert_pem)} bytes")
|
||||
print(f"Private key length: {len(key_pem)} bytes")
|
||||
|
||||
# Save to files
|
||||
with open(f"{domain}.crt", 'w') as f:
|
||||
f.write(cert_pem)
|
||||
with open(f"{domain}.key", 'w') as f:
|
||||
f.write(key_pem)
|
||||
|
||||
print(f"Saved: {domain}.crt, {domain}.key")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
1
vpn/management/__init__.py
Normal file
1
vpn/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Django management commands package\n
|
||||
158
vpn/management/commands/cleanup_access_logs.py
Normal file
158
vpn/management/commands/cleanup_access_logs.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection, transaction
|
||||
from datetime import datetime, timedelta
|
||||
from vpn.models import AccessLog
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up old AccessLog entries without acl_link_id'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--days',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Delete logs older than this many days (default: 30)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--batch-size',
|
||||
type=int,
|
||||
default=10000,
|
||||
help='Number of records to delete in each batch (default: 10000)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be deleted without actually deleting'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--keep-recent',
|
||||
type=int,
|
||||
default=1000,
|
||||
help='Keep this many recent logs even if they have no link (default: 1000)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = options['days']
|
||||
batch_size = options['batch_size']
|
||||
dry_run = options['dry_run']
|
||||
keep_recent = options['keep_recent']
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
self.stdout.write(f"🔍 Analyzing AccessLog cleanup...")
|
||||
self.stdout.write(f" - Delete logs without acl_link_id older than {days} days")
|
||||
self.stdout.write(f" - Keep {keep_recent} most recent logs without links")
|
||||
self.stdout.write(f" - Batch size: {batch_size}")
|
||||
self.stdout.write(f" - Dry run: {dry_run}")
|
||||
|
||||
# Count total records to be deleted
|
||||
with connection.cursor() as cursor:
|
||||
# Count logs without acl_link_id
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
""")
|
||||
total_without_link = cursor.fetchone()[0]
|
||||
|
||||
# Count logs to be deleted (older than cutoff, excluding recent ones to keep)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < %s
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT %s
|
||||
) AS recent_logs
|
||||
)
|
||||
""", [cutoff_date, keep_recent])
|
||||
total_to_delete = cursor.fetchone()[0]
|
||||
|
||||
# Count total records
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
total_records = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write(f"📊 Statistics:")
|
||||
self.stdout.write(f" - Total AccessLog records: {total_records:,}")
|
||||
self.stdout.write(f" - Records without acl_link_id: {total_without_link:,}")
|
||||
self.stdout.write(f" - Records to be deleted: {total_to_delete:,}")
|
||||
self.stdout.write(f" - Records to be kept (recent): {keep_recent:,}")
|
||||
|
||||
if total_to_delete == 0:
|
||||
self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {total_to_delete:,} records"))
|
||||
return
|
||||
|
||||
# Confirm deletion
|
||||
if not options.get('verbosity', 1) == 0: # Only ask if not --verbosity=0
|
||||
confirm = input(f"❓ Delete {total_to_delete:,} records? (yes/no): ")
|
||||
if confirm.lower() != 'yes':
|
||||
self.stdout.write("❌ Cancelled.")
|
||||
return
|
||||
|
||||
self.stdout.write(f"🗑️ Starting deletion of {total_to_delete:,} records...")
|
||||
|
||||
deleted_total = 0
|
||||
batch_num = 0
|
||||
|
||||
while True:
|
||||
batch_num += 1
|
||||
|
||||
with transaction.atomic():
|
||||
with connection.cursor() as cursor:
|
||||
# Delete batch
|
||||
cursor.execute("""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < %s
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT %s
|
||||
) AS recent_logs
|
||||
)
|
||||
LIMIT %s
|
||||
""", [cutoff_date, keep_recent, batch_size])
|
||||
|
||||
deleted_in_batch = cursor.rowcount
|
||||
|
||||
if deleted_in_batch == 0:
|
||||
break
|
||||
|
||||
deleted_total += deleted_in_batch
|
||||
progress = (deleted_total / total_to_delete) * 100
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num}: Deleted {deleted_in_batch:,} records "
|
||||
f"(Total: {deleted_total:,}/{total_to_delete:,}, {progress:.1f}%)"
|
||||
)
|
||||
|
||||
if deleted_in_batch < batch_size:
|
||||
break
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"✅ Cleanup completed!"))
|
||||
self.stdout.write(f" - Deleted {deleted_total:,} old AccessLog records")
|
||||
self.stdout.write(f" - Kept {keep_recent:,} recent records without links")
|
||||
|
||||
# Show final statistics
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
final_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
""")
|
||||
final_without_link = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write(f"📊 Final statistics:")
|
||||
self.stdout.write(f" - Total AccessLog records: {final_total:,}")
|
||||
self.stdout.write(f" - Records without acl_link_id: {final_without_link:,}")
|
||||
208
vpn/management/commands/cleanup_logs.py
Normal file
208
vpn/management/commands/cleanup_logs.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''
|
||||
Clean up AccessLog entries efficiently using direct SQL.
|
||||
|
||||
Examples:
|
||||
# Delete logs without acl_link_id older than 30 days (recommended)
|
||||
python manage.py cleanup_logs --keep-days=30
|
||||
|
||||
# Keep only last 5000 logs without acl_link_id
|
||||
python manage.py cleanup_logs --keep-count=5000
|
||||
|
||||
# Delete ALL logs older than 7 days (including with acl_link_id)
|
||||
python manage.py cleanup_logs --keep-days=7 --target=all
|
||||
|
||||
# Preview what would be deleted
|
||||
python manage.py cleanup_logs --keep-days=30 --dry-run
|
||||
|
||||
# Force delete without confirmation
|
||||
python manage.py cleanup_logs --keep-days=30 --force
|
||||
'''
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Primary options (mutually exclusive)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
'--keep-days',
|
||||
type=int,
|
||||
help='Keep logs newer than this many days (delete older)'
|
||||
)
|
||||
group.add_argument(
|
||||
'--keep-count',
|
||||
type=int,
|
||||
help='Keep this many most recent logs (delete the rest)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--target',
|
||||
choices=['no-links', 'all'],
|
||||
default='no-links',
|
||||
help='Target: "no-links" = only logs without acl_link_id (default), "all" = all logs'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be deleted without actually deleting'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Skip confirmation prompt'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
keep_days = options.get('keep_days')
|
||||
keep_count = options.get('keep_count')
|
||||
target = options['target']
|
||||
dry_run = options['dry_run']
|
||||
force = options['force']
|
||||
|
||||
# Build SQL conditions
|
||||
if target == 'no-links':
|
||||
base_condition = "(acl_link_id IS NULL OR acl_link_id = '')"
|
||||
target_desc = "logs without acl_link_id"
|
||||
else:
|
||||
base_condition = "1=1"
|
||||
target_desc = "all logs"
|
||||
|
||||
# Get current statistics
|
||||
with connection.cursor() as cursor:
|
||||
# Total records
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
total_records = cursor.fetchone()[0]
|
||||
|
||||
# Target records count
|
||||
cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
|
||||
target_records = cursor.fetchone()[0]
|
||||
|
||||
# Records to delete
|
||||
if keep_days:
|
||||
cutoff_date = timezone.now() - timedelta(days=keep_days)
|
||||
cursor.execute(f"""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE {base_condition} AND timestamp < %s
|
||||
""", [cutoff_date])
|
||||
to_delete = cursor.fetchone()[0]
|
||||
strategy = f"older than {keep_days} days"
|
||||
else: # keep_count
|
||||
to_delete = max(0, target_records - keep_count)
|
||||
strategy = f"keeping only {keep_count} most recent"
|
||||
|
||||
# Print statistics
|
||||
self.stdout.write("🗑️ AccessLog Cleanup" + (" (DRY RUN)" if dry_run else ""))
|
||||
self.stdout.write(f" Target: {target_desc}")
|
||||
self.stdout.write(f" Strategy: {strategy}")
|
||||
self.stdout.write("")
|
||||
self.stdout.write("📊 Statistics:")
|
||||
self.stdout.write(f" Total AccessLog records: {total_records:,}")
|
||||
self.stdout.write(f" Target records: {target_records:,}")
|
||||
self.stdout.write(f" Records to delete: {to_delete:,}")
|
||||
self.stdout.write(f" Records to keep: {target_records - to_delete:,}")
|
||||
|
||||
if total_records > 0:
|
||||
delete_percent = (to_delete / total_records) * 100
|
||||
self.stdout.write(f" Deletion percentage: {delete_percent:.1f}%")
|
||||
|
||||
if to_delete == 0:
|
||||
self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
|
||||
return
|
||||
|
||||
# Show SQL that will be executed
|
||||
if dry_run or not force:
|
||||
self.stdout.write("")
|
||||
self.stdout.write("📝 SQL to execute:")
|
||||
if keep_days:
|
||||
sql_preview = f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition} AND timestamp < '{cutoff_date.strftime('%Y-%m-%d %H:%M:%S')}'
|
||||
"""
|
||||
else: # keep_count
|
||||
sql_preview = f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT {keep_count}
|
||||
) AS recent_logs
|
||||
)
|
||||
"""
|
||||
self.stdout.write(sql_preview.strip())
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {to_delete:,} records"))
|
||||
return
|
||||
|
||||
# Confirm deletion
|
||||
if not force:
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.ERROR(f"⚠️ About to DELETE {to_delete:,} records!"))
|
||||
confirm = input("Type 'DELETE' to confirm: ")
|
||||
if confirm != 'DELETE':
|
||||
self.stdout.write("❌ Cancelled.")
|
||||
return
|
||||
|
||||
# Execute deletion
|
||||
self.stdout.write("")
|
||||
self.stdout.write(f"🗑️ Deleting {to_delete:,} records...")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
if keep_days:
|
||||
# Simple time-based deletion
|
||||
cursor.execute(f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition} AND timestamp < %s
|
||||
""", [cutoff_date])
|
||||
else:
|
||||
# Keep count deletion (more complex)
|
||||
cursor.execute(f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT %s
|
||||
) AS recent_logs
|
||||
)
|
||||
""", [keep_count])
|
||||
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
# Final statistics
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
final_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
|
||||
final_target = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.SUCCESS("✅ Cleanup completed!"))
|
||||
self.stdout.write(f" Deleted: {deleted_count:,} records")
|
||||
self.stdout.write(f" Remaining total: {final_total:,}")
|
||||
|
||||
if target == 'no-links':
|
||||
self.stdout.write(f" Remaining without links: {final_target:,}")
|
||||
|
||||
# Calculate space saved (rough estimate)
|
||||
if deleted_count > 0:
|
||||
# Rough estimate: ~200 bytes per AccessLog record
|
||||
space_saved_mb = (deleted_count * 200) / (1024 * 1024)
|
||||
if space_saved_mb > 1024:
|
||||
space_saved_gb = space_saved_mb / 1024
|
||||
self.stdout.write(f" Estimated space saved: ~{space_saved_gb:.1f} GB")
|
||||
else:
|
||||
self.stdout.write(f" Estimated space saved: ~{space_saved_mb:.1f} MB")
|
||||
17
vpn/management/commands/create_admin.py
Normal file
17
vpn/management/commands/create_admin.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create default admin user'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
User = get_user_model()
|
||||
if not User.objects.filter(username='admin').exists():
|
||||
User.objects.create_superuser(
|
||||
username='admin',
|
||||
password='admin',
|
||||
email='admin@localhost'
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS('Admin user created'))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING('Admin user already exists'))
|
||||
1
vpn/management/commands/init_statistics.py
Normal file
1
vpn/management/commands/init_statistics.py
Normal file
@@ -0,0 +1 @@
|
||||
from django.core.management.base import BaseCommand\nfrom django.utils import timezone\nfrom vpn.models import User, ACLLink, UserStatistics\nfrom vpn.tasks import update_user_statistics\n\n\nclass Command(BaseCommand):\n help = 'Initialize user statistics cache by running the update task'\n \n def add_arguments(self, parser):\n parser.add_argument(\n '--async',\n action='store_true',\n help='Run statistics update as async Celery task (default: sync)',\n )\n parser.add_argument(\n '--force',\n action='store_true',\n help='Force update even if statistics already exist',\n )\n \n def handle(self, *args, **options):\n # Check if statistics already exist\n existing_stats = UserStatistics.objects.count()\n \n if existing_stats > 0 and not options['force']:\n self.stdout.write(\n self.style.WARNING(\n f'Statistics cache already contains {existing_stats} entries. '\n 'Use --force to update anyway.'\n )\n )\n return\n \n # Check if there are users with ACL links\n users_with_links = User.objects.filter(acl__isnull=False).distinct().count()\n total_links = ACLLink.objects.count()\n \n self.stdout.write(\n f'Found {users_with_links} users with {total_links} ACL links total'\n )\n \n if total_links == 0:\n self.stdout.write(\n self.style.WARNING('No ACL links found. Nothing to process.')\n )\n return\n \n if options['async']:\n # Run as async Celery task\n try:\n task = update_user_statistics.delay()\n self.stdout.write(\n self.style.SUCCESS(\n f'Statistics update task started. Task ID: {task.id}'\n )\n )\n self.stdout.write(\n 'Check admin panel Task Execution Logs for progress.'\n )\n except Exception as e:\n self.stdout.write(\n self.style.ERROR(f'Failed to start async task: {e}')\n )\n else:\n # Run synchronously\n self.stdout.write('Starting synchronous statistics update...')\n \n try:\n # Import and call the task function directly\n from vpn.tasks import update_user_statistics\n \n # Create a mock Celery request object for the task\n class MockRequest:\n id = f'manual-{timezone.now().isoformat()}'\n retries = 0\n \n # Create mock task instance\n task_instance = type('MockTask', (), {\n 'request': MockRequest(),\n })()\n \n # Call the task function directly\n result = update_user_statistics(task_instance)\n \n self.stdout.write(\n self.style.SUCCESS(f'Statistics update completed: {result}')\n )\n \n # Show summary\n final_stats = UserStatistics.objects.count()\n self.stdout.write(\n self.style.SUCCESS(\n f'Statistics cache now contains {final_stats} entries'\n )\n )\n \n except Exception as e:\n self.stdout.write(\n self.style.ERROR(f'Statistics update failed: {e}')\n )\n import traceback\n self.stdout.write(traceback.format_exc())\n
|
||||
51
vpn/management/commands/simple_cleanup_logs.py
Normal file
51
vpn/management/commands/simple_cleanup_logs.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from vpn.models import AccessLog
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Simple cleanup of AccessLog entries without acl_link_id using Django ORM'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--days',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Delete logs older than this many days (default: 30)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = options['days']
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Count records to be deleted
|
||||
old_logs = AccessLog.objects.filter(
|
||||
acl_link_id__isnull=True,
|
||||
timestamp__lt=cutoff_date
|
||||
)
|
||||
|
||||
# Also include empty string acl_link_id
|
||||
empty_logs = AccessLog.objects.filter(
|
||||
acl_link_id='',
|
||||
timestamp__lt=cutoff_date
|
||||
)
|
||||
|
||||
total_old = old_logs.count()
|
||||
total_empty = empty_logs.count()
|
||||
total_to_delete = total_old + total_empty
|
||||
|
||||
self.stdout.write(f"Found {total_to_delete:,} old logs without acl_link_id to delete")
|
||||
|
||||
if total_to_delete == 0:
|
||||
self.stdout.write("Nothing to delete.")
|
||||
return
|
||||
|
||||
# Delete in batches to avoid memory issues
|
||||
self.stdout.write("Deleting old logs...")
|
||||
|
||||
deleted_count = 0
|
||||
deleted_count += old_logs._raw_delete(old_logs.db)
|
||||
deleted_count += empty_logs._raw_delete(empty_logs.db)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {deleted_count:,} old AccessLog records"))
|
||||
139
vpn/migrations/0001_initial.py
Normal file
139
vpn/migrations/0001_initial.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# Initial migration
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import shortuuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('comment', models.TextField(blank=True, default='', help_text='Free form user comment')),
|
||||
('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('last_access', models.DateTimeField(blank=True, null=True)),
|
||||
('hash', models.CharField(help_text='Random user hash. It\'s using for client config generation.', max_length=64, unique=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AccessLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user', models.CharField(blank=True, editable=False, max_length=256, null=True)),
|
||||
('server', models.CharField(blank=True, editable=False, max_length=256, null=True)),
|
||||
('action', models.CharField(editable=False, max_length=100)),
|
||||
('data', models.TextField(blank=True, default='', editable=False)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Server',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Server name', max_length=100)),
|
||||
('comment', models.TextField(blank=True, default='')),
|
||||
('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('server_type', models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard')], editable=False, max_length=50)),
|
||||
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Server',
|
||||
'verbose_name_plural': 'Servers',
|
||||
'permissions': [('access_server', 'Can view public status')],
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OutlineServer',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('admin_url', models.URLField(help_text='Management URL')),
|
||||
('admin_access_cert', models.CharField(help_text='Fingerprint', max_length=255)),
|
||||
('client_hostname', models.CharField(help_text='Server address for clients', max_length=255)),
|
||||
('client_port', models.CharField(help_text='Server port for clients', max_length=5)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Outline',
|
||||
'verbose_name_plural': 'Outline',
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WireguardServer',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('address', models.CharField(max_length=100)),
|
||||
('port', models.IntegerField()),
|
||||
('client_private_key', models.CharField(max_length=255)),
|
||||
('server_publick_key', models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Wireguard',
|
||||
'verbose_name_plural': 'Wireguard',
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ACL',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.server')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ACLLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('comment', models.TextField(blank=True, default='', help_text='ACL link comment, device name, etc...')),
|
||||
('link', models.CharField(blank=True, default='', help_text='Access link to get dynamic configuration', max_length=1024, null=True, unique=True, verbose_name='Access link')),
|
||||
('acl', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='vpn.acl')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='servers',
|
||||
field=models.ManyToManyField(blank=True, help_text='Servers user has access to', through='vpn.ACL', to='vpn.server'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='acl',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'server'), name='unique_user_server'),
|
||||
),
|
||||
]
|
||||
51
vpn/migrations/0002_taskexecutionlog.py
Normal file
51
vpn/migrations/0002_taskexecutionlog.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Generated manually to fix migration issue
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
"DROP TABLE IF EXISTS vpn_taskexecutionlog CASCADE;",
|
||||
reverse_sql="-- No reverse operation"
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskExecutionLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('task_id', models.CharField(help_text='Celery task ID', max_length=255)),
|
||||
('task_name', models.CharField(help_text='Task name', max_length=100)),
|
||||
('action', models.CharField(help_text='Action performed', max_length=100)),
|
||||
('status', models.CharField(choices=[('STARTED', 'Started'), ('SUCCESS', 'Success'), ('FAILURE', 'Failure'), ('RETRY', 'Retry')], default='STARTED', max_length=20)),
|
||||
('message', models.TextField(help_text='Detailed execution message')),
|
||||
('execution_time', models.FloatField(blank=True, help_text='Execution time in seconds', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('server', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.server')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Task Execution Log',
|
||||
'verbose_name_plural': 'Task Execution Logs',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
# Create indexes with safe SQL to avoid conflicts
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS vpn_taskexec_task_id_idx ON vpn_taskexecutionlog (task_id);",
|
||||
reverse_sql="DROP INDEX IF EXISTS vpn_taskexec_task_id_idx;"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS vpn_taskexec_created_idx ON vpn_taskexecutionlog (created_at);",
|
||||
reverse_sql="DROP INDEX IF EXISTS vpn_taskexec_created_idx;"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX IF NOT EXISTS vpn_taskexec_status_idx ON vpn_taskexecutionlog (status);",
|
||||
reverse_sql="DROP INDEX IF EXISTS vpn_taskexec_status_idx;"
|
||||
),
|
||||
]
|
||||
18
vpn/migrations/0003_acllink_last_access_time.py
Normal file
18
vpn/migrations/0003_acllink_last_access_time.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated migration for adding last_access_time field
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0002_taskexecutionlog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='acllink',
|
||||
name='last_access_time',
|
||||
field=models.DateTimeField(blank=True, help_text='Last time this link was accessed', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 01:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0002_taskexecutionlog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='outlineserver',
|
||||
options={'verbose_name': 'Outline', 'verbose_name_plural': 'Outline'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='server',
|
||||
options={'permissions': [('access_server', 'Can view public status')], 'verbose_name': 'Server', 'verbose_name_plural': 'Servers'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='wireguardserver',
|
||||
options={'verbose_name': 'Wireguard', 'verbose_name_plural': 'Wireguard'},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['user'], name='vpn_accessl_user_05a541_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['server'], name='vpn_accessl_server_0865e6_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['timestamp'], name='vpn_accessl_timesta_480a45_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['action', 'timestamp'], name='vpn_accessl_action_898948_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskexecutionlog',
|
||||
index=models.Index(fields=['task_id'], name='vpn_taskexe_task_id_e7e101_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskexecutionlog',
|
||||
index=models.Index(fields=['created_at'], name='vpn_taskexe_created_b458ed_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='taskexecutionlog',
|
||||
index=models.Index(fields=['status'], name='vpn_taskexe_status_2f0769_idx'),
|
||||
),
|
||||
]
|
||||
14
vpn/migrations/0004_merge_20250721_1223.py
Normal file
14
vpn/migrations/0004_merge_20250721_1223.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 09:23
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0003_acllink_last_access_time'),
|
||||
('vpn', '0003_alter_outlineserver_options_alter_server_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
45
vpn/migrations/0005_userstatistics.py
Normal file
45
vpn/migrations/0005_userstatistics.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated migration for UserStatistics model
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0004_merge_20250721_1223'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserStatistics',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('server_name', models.CharField(max_length=256)),
|
||||
('acl_link_id', models.CharField(blank=True, help_text='None for server-level stats', max_length=1024, null=True)),
|
||||
('total_connections', models.IntegerField(default=0)),
|
||||
('recent_connections', models.IntegerField(default=0)),
|
||||
('daily_usage', models.JSONField(default=list, help_text='Daily connection counts for last 30 days')),
|
||||
('max_daily', models.IntegerField(default=0)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User Statistics',
|
||||
'verbose_name_plural': 'User Statistics',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='userstatistics',
|
||||
index=models.Index(fields=['user', 'server_name'], name='vpn_usersta_user_id_1c7cd0_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='userstatistics',
|
||||
index=models.Index(fields=['updated_at'], name='vpn_usersta_updated_8e6e9b_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='userstatistics',
|
||||
unique_together={('user', 'server_name', 'acl_link_id')},
|
||||
),
|
||||
]
|
||||
22
vpn/migrations/0006_accesslog_acl_link_id.py
Normal file
22
vpn/migrations/0006_accesslog_acl_link_id.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated migration for AccessLog acl_link_id field
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0005_userstatistics'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accesslog',
|
||||
name='acl_link_id',
|
||||
field=models.CharField(blank=True, editable=False, help_text='ID of the ACL link used', max_length=1024, null=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accesslog',
|
||||
index=models.Index(fields=['acl_link_id'], name='vpn_accessl_acl_lin_b23c6e_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 10:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0005_userstatistics'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='userstatistics',
|
||||
new_name='vpn_usersta_user_id_512036_idx',
|
||||
old_name='vpn_usersta_user_id_1c7cd0_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='userstatistics',
|
||||
new_name='vpn_usersta_updated_5ac650_idx',
|
||||
old_name='vpn_usersta_updated_8e6e9b_idx',
|
||||
),
|
||||
]
|
||||
14
vpn/migrations/0007_merge_20250721_1345.py
Normal file
14
vpn/migrations/0007_merge_20250721_1345.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 10:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0006_accesslog_acl_link_id'),
|
||||
('vpn', '0006_rename_vpn_usersta_user_id_1c7cd0_idx_vpn_usersta_user_id_512036_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-21 10:54
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0007_merge_20250721_1345'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='accesslog',
|
||||
new_name='vpn_accessl_acl_lin_9f3bc5_idx',
|
||||
old_name='vpn_accessl_acl_lin_b23c6e_idx',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-27 17:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0008_rename_vpn_accessl_acl_lin_b23c6e_idx_vpn_accessl_acl_lin_9f3bc5_idx'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayCoreServer',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('api_address', models.CharField(help_text='Xray Core API address (e.g., http://127.0.0.1:8080)', max_length=255)),
|
||||
('api_port', models.IntegerField(default=8080, help_text='API port for management interface')),
|
||||
('api_token', models.CharField(blank=True, help_text='API authentication token', max_length=255)),
|
||||
('server_address', models.CharField(help_text='Server address for clients to connect', max_length=255)),
|
||||
('server_port', models.IntegerField(default=443, help_text='Server port for client connections')),
|
||||
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('shadowsocks', 'Shadowsocks'), ('trojan', 'Trojan')], default='vless', help_text='Primary protocol for this server', max_length=20)),
|
||||
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY'), ('xtls', 'XTLS')], default='tls', help_text='Security layer configuration', max_length=20)),
|
||||
('transport', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)),
|
||||
('config_json', models.JSONField(blank=True, default=dict, help_text='Complete Xray configuration in JSON format')),
|
||||
('panel_url', models.CharField(blank=True, help_text='Web panel URL if using 3X-UI or similar management panel', max_length=255)),
|
||||
('panel_username', models.CharField(blank=True, help_text='Panel admin username', max_length=100)),
|
||||
('panel_password', models.CharField(blank=True, help_text='Panel admin password', max_length=100)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Core Server',
|
||||
'verbose_name_plural': 'Xray Core Servers',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='server',
|
||||
name='server_type',
|
||||
field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core')], editable=False, max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-28 22:34
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0009_xraycoreserver_alter_server_server_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='api_address',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='api_port',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='api_token',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='config_json',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='panel_password',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='panel_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='panel_username',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='protocol',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='security',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='server_address',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='server_port',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='transport',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='default_protocol',
|
||||
field=models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], default='vless', help_text='Default protocol for new inbounds', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='enable_stats',
|
||||
field=models.BooleanField(default=True, help_text='Enable traffic statistics tracking'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='grpc_address',
|
||||
field=models.CharField(default='127.0.0.1', help_text='Xray Core gRPC API address', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='grpc_port',
|
||||
field=models.IntegerField(default=10085, help_text='gRPC API port (usually 10085)'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='XrayInbound',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('tag', models.CharField(help_text='Unique identifier for this inbound', max_length=100)),
|
||||
('port', models.IntegerField(help_text='Port to listen on')),
|
||||
('listen', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=255)),
|
||||
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], max_length=20)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('is_default', models.BooleanField(default=False, help_text='Use this inbound for new users by default')),
|
||||
('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', max_length=20)),
|
||||
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', max_length=20)),
|
||||
('server_address', models.CharField(blank=True, help_text='Public server address for client connections (if different from listen address)', max_length=255)),
|
||||
('ss_method', models.CharField(blank=True, default='chacha20-ietf-poly1305', help_text='Shadowsocks encryption method', max_length=50)),
|
||||
('ss_password', models.CharField(blank=True, help_text='Shadowsocks password (for single-user mode)', max_length=255)),
|
||||
('tls_cert_file', models.CharField(blank=True, max_length=255)),
|
||||
('tls_key_file', models.CharField(blank=True, max_length=255)),
|
||||
('tls_alpn', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), blank=True, default=list, size=None)),
|
||||
('stream_settings', models.JSONField(blank=True, default=dict)),
|
||||
('sniffing_settings', models.JSONField(blank=True, default=dict)),
|
||||
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inbounds', to='vpn.xraycoreserver')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['port'],
|
||||
'unique_together': {('server', 'port'), ('server', 'tag')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='XrayClient',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
('email', models.CharField(help_text='Email for statistics', max_length=255)),
|
||||
('level', models.IntegerField(default=0)),
|
||||
('enable', models.BooleanField(default=True)),
|
||||
('flow', models.CharField(blank=True, help_text='VLESS flow control', max_length=50)),
|
||||
('alter_id', models.IntegerField(default=0, help_text='VMess alterId')),
|
||||
('password', models.CharField(blank=True, help_text='Password for Trojan/Shadowsocks', max_length=255)),
|
||||
('total_gb', models.IntegerField(blank=True, help_text='Traffic limit in GB', null=True)),
|
||||
('expiry_time', models.DateTimeField(blank=True, help_text='Account expiration time', null=True)),
|
||||
('up', models.BigIntegerField(default=0, help_text='Upload bytes')),
|
||||
('down', models.BigIntegerField(default=0, help_text='Download bytes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clients', to='vpn.xrayinbound')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
'unique_together': {('inbound', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
34
vpn/migrations/0011_xrayinboundproxy_and_more.py
Normal file
34
vpn/migrations/0011_xrayinboundproxy_and_more.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-31 21:52
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0010_remove_xraycoreserver_api_address_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayInboundProxy',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Inbound (Server View)',
|
||||
'verbose_name_plural': 'Xray Inbounds (Server View)',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('vpn.xrayinbound',),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xraycoreserver',
|
||||
name='default_protocol',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='xrayinbound',
|
||||
name='is_default',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-31 21:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0011_xrayinboundproxy_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayInboundServer',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('xray_inbound', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='server_proxy', to='vpn.xrayinbound')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Inbound Server',
|
||||
'verbose_name_plural': 'Xray Inbound Servers',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayInboundProxy',
|
||||
),
|
||||
]
|
||||
18
vpn/migrations/0013_add_client_hostname.py
Normal file
18
vpn/migrations/0013_add_client_hostname.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-07-31 22:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0012_xrayinboundserver_delete_xrayinboundproxy'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='xraycoreserver',
|
||||
name='client_hostname',
|
||||
field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections (what clients use to connect)', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-04 22:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0013_add_client_hostname'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='xraycoreserver',
|
||||
name='client_hostname',
|
||||
field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='xrayinbound',
|
||||
name='server_address',
|
||||
field=models.CharField(blank=True, help_text='Public server address for client connections', max_length=255),
|
||||
),
|
||||
]
|
||||
32
vpn/migrations/0015_remove_old_xray_models.py
Normal file
32
vpn/migrations/0015_remove_old_xray_models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated manually to properly remove old Xray models
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0014_alter_xraycoreserver_client_hostname_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Remove unique_together first to avoid field reference issues
|
||||
migrations.AlterUniqueTogether(
|
||||
name='xrayinbound',
|
||||
unique_together=None,
|
||||
),
|
||||
|
||||
# Remove old models completely
|
||||
migrations.DeleteModel(
|
||||
name='XrayClient',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayInbound',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayInboundServer',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayCoreServer',
|
||||
),
|
||||
]
|
||||
127
vpn/migrations/0016_add_new_xray_models.py
Normal file
127
vpn/migrations/0016_add_new_xray_models.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# Generated manually to add new Xray models
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0015_remove_old_xray_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('grpc_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address (host:port)', max_length=255)),
|
||||
('default_client_hostname', models.CharField(help_text='Default hostname for client connections', max_length=255)),
|
||||
('stats_enabled', models.BooleanField(default=True, help_text='Enable traffic statistics')),
|
||||
('cert_renewal_days', models.IntegerField(default=60, help_text='Renew certificates X days before expiration')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Configuration',
|
||||
'verbose_name_plural': 'Xray Configuration',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Credentials',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Descriptive name for these credentials', max_length=100, unique=True)),
|
||||
('cred_type', models.CharField(choices=[('cloudflare', 'Cloudflare API'), ('dns_provider', 'DNS Provider'), ('email', 'Email SMTP'), ('other', 'Other')], help_text='Type of credentials', max_length=20)),
|
||||
('credentials', models.JSONField(help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})")),
|
||||
('description', models.TextField(blank=True, help_text='Description of what these credentials are used for')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Credentials',
|
||||
'verbose_name_plural': 'Credentials',
|
||||
'ordering': ['cred_type', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Certificate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('domain', models.CharField(help_text='Domain name for this certificate', max_length=255, unique=True)),
|
||||
('certificate_pem', models.TextField(help_text='Certificate in PEM format')),
|
||||
('private_key_pem', models.TextField(help_text='Private key in PEM format')),
|
||||
('cert_type', models.CharField(choices=[('self_signed', 'Self-Signed'), ('letsencrypt', "Let's Encrypt"), ('custom', 'Custom')], help_text='Type of certificate', max_length=20)),
|
||||
('expires_at', models.DateTimeField(help_text='Certificate expiration date')),
|
||||
('auto_renew', models.BooleanField(default=True, help_text='Automatically renew certificate before expiration')),
|
||||
('last_renewed', models.DateTimeField(blank=True, help_text='Last renewal timestamp', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('credentials', models.ForeignKey(blank=True, help_text="Credentials for Let's Encrypt (Cloudflare API)", null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.credentials')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Certificate',
|
||||
'verbose_name_plural': 'Certificates',
|
||||
'ordering': ['domain'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Inbound',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Unique identifier for this inbound', max_length=100, unique=True)),
|
||||
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], help_text='Protocol type', max_length=20)),
|
||||
('port', models.IntegerField(help_text='Port to listen on')),
|
||||
('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('grpc', 'gRPC'), ('http', 'HTTP/2'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)),
|
||||
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', help_text='Security type', max_length=20)),
|
||||
('domain', models.CharField(blank=True, help_text='Client connection domain', max_length=255)),
|
||||
('full_config', models.JSONField(default=dict, help_text='Complete configuration for creating inbound on server')),
|
||||
('listen_address', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=45)),
|
||||
('enable_sniffing', models.BooleanField(default=True, help_text='Enable protocol sniffing')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('certificate', models.ForeignKey(blank=True, help_text='Certificate for TLS', null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.certificate')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Inbound',
|
||||
'verbose_name_plural': 'Inbounds',
|
||||
'ordering': ['protocol', 'port'],
|
||||
'unique_together': {('port', 'listen_address')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubscriptionGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')", max_length=100, unique=True)),
|
||||
('description', models.TextField(blank=True, help_text='Description of this subscription group')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this group is active')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('inbounds', models.ManyToManyField(blank=True, help_text='Inbounds included in this group', to='vpn.inbound')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Subscription Group',
|
||||
'verbose_name_plural': 'Subscription Groups',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserSubscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('active', models.BooleanField(default=True, help_text='Whether this subscription is active')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('subscription_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.subscriptiongroup')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='xray_subscriptions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User Subscription',
|
||||
'verbose_name_plural': 'User Subscriptions',
|
||||
'ordering': ['user__username', 'subscription_group__name'],
|
||||
'unique_together': {('user', 'subscription_group')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 13:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0016_add_new_xray_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayServerV2',
|
||||
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')),
|
||||
('client_hostname', models.CharField(help_text='Client connection hostname (what users see in their configs)', max_length=255)),
|
||||
('api_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address for management', max_length=255)),
|
||||
('api_enabled', models.BooleanField(default=True, help_text='Enable gRPC API for user management')),
|
||||
('stats_enabled', models.BooleanField(default=True, help_text='Enable traffic statistics collection')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Server v2',
|
||||
'verbose_name_plural': 'Xray Servers v2',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='server',
|
||||
name='server_type',
|
||||
field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core'), ('xray_v2', 'Xray Server v2')], editable=False, max_length=50),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServerInbound',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('active', models.BooleanField(default=True, help_text='Whether this inbound is active on the server')),
|
||||
('deployed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('deployment_config', models.JSONField(blank=True, default=dict, help_text='Server-specific deployment configuration')),
|
||||
('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_servers', to='vpn.inbound')),
|
||||
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_inbounds', to='vpn.server')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Server Inbound Deployment',
|
||||
'verbose_name_plural': 'Server Inbound Deployments',
|
||||
'ordering': ['server__name', 'inbound__name'],
|
||||
'unique_together': {('server', 'inbound')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 14:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0017_xrayserverv2_alter_server_server_type_serverinbound'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='certificate',
|
||||
name='certificate_pem',
|
||||
field=models.TextField(blank=True, help_text="Certificate in PEM format (auto-generated for Let's Encrypt)"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='certificate',
|
||||
name='expires_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Certificate expiration date (auto-filled after generation)', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='certificate',
|
||||
name='private_key_pem',
|
||||
field=models.TextField(blank=True, help_text="Private key in PEM format (auto-generated for Let's Encrypt)"),
|
||||
),
|
||||
]
|
||||
18
vpn/migrations/0019_certificate_acme_email.py
Normal file
18
vpn/migrations/0019_certificate_acme_email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 14:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0018_alter_certificate_certificate_pem_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='certificate',
|
||||
name='acme_email',
|
||||
field=models.EmailField(blank=True, help_text="Email address for ACME account registration (required for Let's Encrypt)", max_length=254),
|
||||
),
|
||||
]
|
||||
18
vpn/migrations/0020_alter_inbound_full_config.py
Normal file
18
vpn/migrations/0020_alter_inbound_full_config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 15:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0019_certificate_acme_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='inbound',
|
||||
name='full_config',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Complete configuration for creating inbound on server (auto-generated if empty)'),
|
||||
),
|
||||
]
|
||||
16
vpn/migrations/0021_remove_xray_configuration.py
Normal file
16
vpn/migrations/0021_remove_xray_configuration.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-08 03:33
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0020_alter_inbound_full_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='XrayConfiguration',
|
||||
),
|
||||
]
|
||||
21
vpn/migrations/0022_remove_inbound_domain_field.py
Normal file
21
vpn/migrations/0022_remove_inbound_domain_field.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-08 04:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0021_remove_xray_configuration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='inbound',
|
||||
options={'ordering': ['protocol', 'port'], 'verbose_name': 'Inbound Template', 'verbose_name_plural': 'Inbound Templates'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inbound',
|
||||
name='domain',
|
||||
),
|
||||
]
|
||||
17
vpn/migrations/0023_alter_subscriptiongroup_options.py
Normal file
17
vpn/migrations/0023_alter_subscriptiongroup_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-08 04:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0022_remove_inbound_domain_field'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='subscriptiongroup',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Subscriptions', 'verbose_name_plural': 'Subscriptions'},
|
||||
),
|
||||
]
|
||||
1
vpn/migrations/__init__.py
Normal file
1
vpn/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Migration package
|
||||
176
vpn/models.py
Normal file
176
vpn/models.py
Normal file
@@ -0,0 +1,176 @@
|
||||
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
|
||||
|
||||
|
||||
# Import new Xray models
|
||||
from .models_xray import (
|
||||
Credentials, Certificate,
|
||||
Inbound, SubscriptionGroup, UserSubscription
|
||||
)
|
||||
444
vpn/models_xray.py
Normal file
444
vpn/models_xray.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
New Xray models for flexible inbound and subscription management.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Credentials(models.Model):
|
||||
"""Universal credentials storage for various services"""
|
||||
CRED_TYPES = [
|
||||
('cloudflare', 'Cloudflare API'),
|
||||
('dns_provider', 'DNS Provider'),
|
||||
('email', 'Email SMTP'),
|
||||
('other', 'Other')
|
||||
]
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Descriptive name for these credentials"
|
||||
)
|
||||
cred_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=CRED_TYPES,
|
||||
help_text="Type of credentials"
|
||||
)
|
||||
credentials = models.JSONField(
|
||||
help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of what these credentials are used for"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Credentials"
|
||||
verbose_name_plural = "Credentials"
|
||||
ordering = ['cred_type', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_cred_type_display()})"
|
||||
|
||||
def get_credential(self, key: str, default=None):
|
||||
"""Safely get credential value"""
|
||||
return self.credentials.get(key, default)
|
||||
|
||||
|
||||
class Certificate(models.Model):
|
||||
"""SSL/TLS Certificate management"""
|
||||
CERT_TYPES = [
|
||||
('self_signed', 'Self-Signed'),
|
||||
('letsencrypt', "Let's Encrypt"),
|
||||
('custom', 'Custom')
|
||||
]
|
||||
|
||||
domain = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
help_text="Domain name for this certificate"
|
||||
)
|
||||
certificate_pem = models.TextField(
|
||||
blank=True,
|
||||
help_text="Certificate in PEM format (auto-generated for Let's Encrypt)"
|
||||
)
|
||||
private_key_pem = models.TextField(
|
||||
blank=True,
|
||||
help_text="Private key in PEM format (auto-generated for Let's Encrypt)"
|
||||
)
|
||||
cert_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=CERT_TYPES,
|
||||
help_text="Type of certificate"
|
||||
)
|
||||
expires_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Certificate expiration date (auto-filled after generation)"
|
||||
)
|
||||
credentials = models.ForeignKey(
|
||||
Credentials,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="Credentials for Let's Encrypt (Cloudflare API)"
|
||||
)
|
||||
acme_email = models.EmailField(
|
||||
blank=True,
|
||||
help_text="Email address for ACME account registration (required for Let's Encrypt)"
|
||||
)
|
||||
auto_renew = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Automatically renew certificate before expiration"
|
||||
)
|
||||
last_renewed = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Last renewal timestamp"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Certificate"
|
||||
verbose_name_plural = "Certificates"
|
||||
ordering = ['domain']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.domain} ({self.get_cert_type_display()})"
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Check if certificate is expired"""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
@property
|
||||
def days_until_expiration(self):
|
||||
"""Days until certificate expires"""
|
||||
if not self.expires_at:
|
||||
return None
|
||||
delta = self.expires_at - timezone.now()
|
||||
return delta.days
|
||||
|
||||
@property
|
||||
def needs_renewal(self):
|
||||
"""Check if certificate needs renewal"""
|
||||
if not self.auto_renew or not self.expires_at:
|
||||
return False
|
||||
|
||||
# Default renewal period
|
||||
renewal_days = 60
|
||||
|
||||
days_left = self.days_until_expiration
|
||||
if days_left is None:
|
||||
return False
|
||||
|
||||
return days_left <= renewal_days
|
||||
|
||||
|
||||
class Inbound(models.Model):
|
||||
"""Independent inbound configuration"""
|
||||
PROTOCOLS = [
|
||||
('vless', 'VLESS'),
|
||||
('vmess', 'VMess'),
|
||||
('trojan', 'Trojan'),
|
||||
('shadowsocks', 'Shadowsocks')
|
||||
]
|
||||
|
||||
NETWORKS = [
|
||||
('tcp', 'TCP'),
|
||||
('ws', 'WebSocket'),
|
||||
('grpc', 'gRPC'),
|
||||
('http', 'HTTP/2'),
|
||||
('quic', 'QUIC')
|
||||
]
|
||||
|
||||
SECURITIES = [
|
||||
('none', 'None'),
|
||||
('tls', 'TLS'),
|
||||
('reality', 'REALITY')
|
||||
]
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Unique identifier for this inbound"
|
||||
)
|
||||
protocol = models.CharField(
|
||||
max_length=20,
|
||||
choices=PROTOCOLS,
|
||||
help_text="Protocol type"
|
||||
)
|
||||
port = models.IntegerField(
|
||||
help_text="Port to listen on"
|
||||
)
|
||||
network = models.CharField(
|
||||
max_length=20,
|
||||
choices=NETWORKS,
|
||||
default='tcp',
|
||||
help_text="Transport protocol"
|
||||
)
|
||||
security = models.CharField(
|
||||
max_length=20,
|
||||
choices=SECURITIES,
|
||||
default='none',
|
||||
help_text="Security type"
|
||||
)
|
||||
certificate = models.ForeignKey(
|
||||
Certificate,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="Certificate for TLS"
|
||||
)
|
||||
|
||||
# Full configuration for Xray
|
||||
full_config = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Complete configuration for creating inbound on server (auto-generated if empty)"
|
||||
)
|
||||
|
||||
# Additional settings
|
||||
listen_address = models.CharField(
|
||||
max_length=45,
|
||||
default="0.0.0.0",
|
||||
help_text="IP address to listen on"
|
||||
)
|
||||
enable_sniffing = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable protocol sniffing"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Inbound Template"
|
||||
verbose_name_plural = "Inbound Templates"
|
||||
ordering = ['protocol', 'port']
|
||||
unique_together = [['port', 'listen_address']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.protocol.upper()}:{self.port})"
|
||||
|
||||
def generate_tag(self):
|
||||
"""Generate unique tag for inbound"""
|
||||
return f"{self.protocol}-{self.port}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def build_config(self):
|
||||
"""Build full configuration for Xray"""
|
||||
try:
|
||||
# Build basic Xray inbound configuration
|
||||
config = {
|
||||
"tag": self.name,
|
||||
"port": self.port,
|
||||
"listen": self.listen_address,
|
||||
"protocol": self.protocol,
|
||||
"settings": self._build_protocol_settings(),
|
||||
"streamSettings": self._build_stream_settings(),
|
||||
"sniffing": {
|
||||
"enabled": self.enable_sniffing,
|
||||
"destOverride": ["http", "tls"]
|
||||
} if self.enable_sniffing else {}
|
||||
}
|
||||
|
||||
# Store the built config
|
||||
self.full_config = config
|
||||
return self.full_config
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to basic config if detailed build fails
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Failed to build detailed config for {self.name}: {e}")
|
||||
|
||||
self.full_config = {
|
||||
"tag": self.name,
|
||||
"port": self.port,
|
||||
"listen": self.listen_address,
|
||||
"protocol": self.protocol,
|
||||
"settings": {},
|
||||
"streamSettings": {}
|
||||
}
|
||||
return self.full_config
|
||||
|
||||
def _build_protocol_settings(self):
|
||||
"""Build protocol-specific settings"""
|
||||
settings = {}
|
||||
|
||||
if self.protocol == 'vless':
|
||||
settings = {
|
||||
"clients": [], # Will be populated when users are added
|
||||
"decryption": "none"
|
||||
}
|
||||
elif self.protocol == 'vmess':
|
||||
settings = {
|
||||
"clients": [] # Will be populated when users are added
|
||||
}
|
||||
elif self.protocol == 'trojan':
|
||||
settings = {
|
||||
"clients": [] # Will be populated when users are added
|
||||
}
|
||||
elif self.protocol == 'shadowsocks':
|
||||
settings = {
|
||||
"method": "aes-128-gcm", # Default method
|
||||
"password": "", # Will be set when configured
|
||||
"network": "tcp,udp"
|
||||
}
|
||||
|
||||
return settings
|
||||
|
||||
def _build_stream_settings(self):
|
||||
"""Build stream transport settings"""
|
||||
stream_settings = {
|
||||
"network": self.network
|
||||
}
|
||||
|
||||
# Add network-specific settings
|
||||
if self.network == "ws":
|
||||
stream_settings["wsSettings"] = {
|
||||
"path": f"/{self.name}",
|
||||
"headers": {}
|
||||
}
|
||||
elif self.network == "grpc":
|
||||
stream_settings["grpcSettings"] = {
|
||||
"serviceName": self.name
|
||||
}
|
||||
elif self.network == "http":
|
||||
stream_settings["httpSettings"] = {
|
||||
"path": f"/{self.name}",
|
||||
"host": [] # Will be filled when deployed to server
|
||||
}
|
||||
|
||||
# Add security settings
|
||||
if self.security == "tls":
|
||||
stream_settings["security"] = "tls"
|
||||
tls_settings = {
|
||||
"serverName": "localhost", # Will be replaced with server hostname when deployed
|
||||
"alpn": ["h2", "http/1.1"]
|
||||
}
|
||||
|
||||
if self.certificate:
|
||||
tls_settings.update({
|
||||
"certificates": [{
|
||||
"certificateFile": f"/etc/xray/certs/{self.certificate.domain}.crt",
|
||||
"keyFile": f"/etc/xray/certs/{self.certificate.domain}.key"
|
||||
}]
|
||||
})
|
||||
|
||||
stream_settings["tlsSettings"] = tls_settings
|
||||
|
||||
elif self.security == "reality":
|
||||
stream_settings["security"] = "reality"
|
||||
# Reality settings would be configured here
|
||||
stream_settings["realitySettings"] = {
|
||||
"dest": "example.com:443", # Will be replaced with server hostname when deployed
|
||||
"serverNames": ["example.com"], # Will be replaced with server hostname when deployed
|
||||
"privateKey": "", # Would be generated
|
||||
"shortIds": [""] # Would be generated
|
||||
}
|
||||
|
||||
return stream_settings
|
||||
|
||||
|
||||
class SubscriptionGroup(models.Model):
|
||||
"""Groups of inbounds for subscription management"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of this subscription group"
|
||||
)
|
||||
inbounds = models.ManyToManyField(
|
||||
Inbound,
|
||||
blank=True,
|
||||
help_text="Inbounds included in this group"
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this group is active"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Subscriptions"
|
||||
verbose_name_plural = "Subscriptions"
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def inbound_count(self):
|
||||
"""Number of inbounds in this group"""
|
||||
return self.inbounds.count()
|
||||
|
||||
@property
|
||||
def user_count(self):
|
||||
"""Number of users subscribed to this group"""
|
||||
return self.usersubscription_set.filter(active=True).count()
|
||||
|
||||
|
||||
class UserSubscription(models.Model):
|
||||
"""User subscriptions to groups"""
|
||||
user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='xray_subscriptions'
|
||||
)
|
||||
subscription_group = models.ForeignKey(
|
||||
SubscriptionGroup,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this subscription is active"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User Subscription"
|
||||
verbose_name_plural = "User Subscriptions"
|
||||
unique_together = ['user', 'subscription_group']
|
||||
ordering = ['user__username', 'subscription_group__name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.subscription_group.name}"
|
||||
|
||||
|
||||
class ServerInbound(models.Model):
|
||||
"""Many-to-many relationship between servers and inbounds to track deployment"""
|
||||
server = models.ForeignKey('Server', on_delete=models.CASCADE, related_name='deployed_inbounds')
|
||||
inbound = models.ForeignKey(Inbound, on_delete=models.CASCADE, related_name='deployed_servers')
|
||||
active = models.BooleanField(default=True, help_text="Whether this inbound is active on the server")
|
||||
|
||||
deployed_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Store deployment-specific configuration if needed
|
||||
deployment_config = models.JSONField(default=dict, blank=True, help_text="Server-specific deployment configuration")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Server Inbound Deployment"
|
||||
verbose_name_plural = "Server Inbound Deployments"
|
||||
ordering = ['server__name', 'inbound__name']
|
||||
unique_together = [('server', 'inbound')]
|
||||
|
||||
def __str__(self):
|
||||
status = "Active" if self.active else "Inactive"
|
||||
return f"{self.server.name} -> {self.inbound.name} ({status})"
|
||||
5
vpn/server_plugins/__init__.py
Normal file
5
vpn/server_plugins/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .generic import Server
|
||||
from .outline import OutlineServer, OutlineServerAdmin
|
||||
from .wireguard import WireguardServer, WireguardServerAdmin
|
||||
from .xray_v2 import XrayServerV2, XrayServerV2Admin
|
||||
from .urls import urlpatterns
|
||||
62
vpn/server_plugins/generic.py
Normal file
62
vpn/server_plugins/generic.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from polymorphic.models import PolymorphicModel
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Server(PolymorphicModel):
|
||||
SERVER_TYPE_CHOICES = (
|
||||
('Outline', 'Outline'),
|
||||
('Wireguard', 'Wireguard'),
|
||||
('xray_core', 'Xray Core'),
|
||||
('xray_v2', 'Xray Server v2'),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=100, help_text="Server name")
|
||||
comment = models.TextField(default="", blank=True)
|
||||
registration_date = models.DateTimeField(auto_now_add=True, verbose_name="Created")
|
||||
server_type = models.CharField(max_length=50, choices=SERVER_TYPE_CHOICES, editable=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Only sync if the server actually exists and is valid
|
||||
is_new = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Schedule sync task for existing servers only
|
||||
if not is_new:
|
||||
try:
|
||||
from vpn.tasks import sync_server
|
||||
sync_server.delay(self.id)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to schedule sync for server {self.name}: {e}")
|
||||
|
||||
def get_server_status(self, *args, **kwargs):
|
||||
return {"name": self.name}
|
||||
|
||||
def sync(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def sync_users(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def add_user(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get_user(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def delete_user(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Server"
|
||||
verbose_name_plural = "Servers"
|
||||
permissions = [
|
||||
("access_server", "Can view public status"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
823
vpn/server_plugins/outline.py
Normal file
823
vpn/server_plugins/outline.py
Normal file
@@ -0,0 +1,823 @@
|
||||
import logging
|
||||
import json
|
||||
import requests
|
||||
from django.db import models
|
||||
from django.shortcuts import render, redirect
|
||||
from django.conf import settings
|
||||
from .generic import Server
|
||||
from urllib3 import PoolManager
|
||||
from outline_vpn.outline_vpn import OutlineVPN, OutlineServerErrorException
|
||||
from polymorphic.admin import PolymorphicChildModelAdmin
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
class OutlineConnectionError(Exception):
|
||||
def __init__(self, message, original_exception=None):
|
||||
super().__init__(message)
|
||||
self.original_exception = original_exception
|
||||
|
||||
class _FingerprintAdapter(requests.adapters.HTTPAdapter):
|
||||
"""
|
||||
This adapter injected into the requests session will check that the
|
||||
fingerprint for the certificate matches for every request
|
||||
"""
|
||||
|
||||
def __init__(self, fingerprint=None, **kwargs):
|
||||
self.fingerprint = str(fingerprint)
|
||||
super(_FingerprintAdapter, self).__init__(**kwargs)
|
||||
|
||||
def init_poolmanager(self, connections, maxsize, block=False):
|
||||
self.poolmanager = PoolManager(
|
||||
num_pools=connections,
|
||||
maxsize=maxsize,
|
||||
block=block,
|
||||
assert_fingerprint=self.fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class OutlineServer(Server):
|
||||
admin_url = models.URLField(help_text="Management URL")
|
||||
admin_access_cert = models.CharField(max_length=255, help_text="Fingerprint")
|
||||
client_hostname = models.CharField(max_length=255, help_text="Server address for clients")
|
||||
client_port = models.CharField(max_length=5, help_text="Server port for clients")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Outline'
|
||||
verbose_name_plural = 'Outline'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.server_type = 'Outline'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self.get_server_status(raw=True)
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
return OutlineVPN(api_url=self.admin_url, cert_sha256=self.admin_access_cert)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.client_hostname}:{self.client_port})"
|
||||
|
||||
def get_server_status(self, raw=False):
|
||||
status = {}
|
||||
|
||||
try:
|
||||
info = self.client.get_server_information()
|
||||
if raw:
|
||||
status = info
|
||||
else:
|
||||
keys = self.client.get_keys()
|
||||
status.update(info)
|
||||
status.update({"keys": len(keys)})
|
||||
status["all_keys"] = []
|
||||
for key in keys:
|
||||
status["all_keys"].append(key.key_id)
|
||||
except Exception as e:
|
||||
status.update({f"error": e})
|
||||
return status
|
||||
|
||||
def sync_users(self):
|
||||
from vpn.models import User, ACL
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"[{self.name}] Sync all users")
|
||||
|
||||
try:
|
||||
keys = self.client.get_keys()
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Failed to get keys from server: {e}")
|
||||
return False
|
||||
|
||||
acls = ACL.objects.filter(server=self)
|
||||
acl_users = set(acl.user for acl in acls)
|
||||
|
||||
# Log user synchronization details
|
||||
user_list = ", ".join([user.username for user in acl_users])
|
||||
logger.info(f"[{self.name}] Syncing {len(acl_users)} users: {user_list[:200]}{'...' if len(user_list) > 200 else ''}")
|
||||
|
||||
for user in User.objects.all():
|
||||
if user in acl_users:
|
||||
try:
|
||||
result = self.add_user(user=user)
|
||||
logger.debug(f"[{self.name}] Added user {user.username}: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Failed to add user {user.username}: {e}")
|
||||
else:
|
||||
try:
|
||||
result = self.delete_user(user=user)
|
||||
if result and 'status' in result and 'deleted' in result['status']:
|
||||
logger.debug(f"[{self.name}] Removed user {user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Failed to remove user {user.username}: {e}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def sync(self):
|
||||
status = {}
|
||||
try:
|
||||
state = self.client.get_server_information()
|
||||
if state["name"] != self.name:
|
||||
self.client.set_server_name(self.name)
|
||||
status["name"] = f"{state['name']} -> {self.name}"
|
||||
elif state["hostnameForAccessKeys"] != self.client_hostname:
|
||||
self.client.set_hostname(self.client_hostname)
|
||||
status["hostnameForAccessKeys"] = f"{state['hostnameForAccessKeys']} -> {self.client_hostname}"
|
||||
elif int(state["portForNewAccessKeys"]) != int(self.client_port):
|
||||
self.client.set_port_new_for_access_keys(int(self.client_port))
|
||||
status["portForNewAccessKeys"] = f"{state['portForNewAccessKeys']} -> {self.client_port}"
|
||||
if len(status) == 0:
|
||||
status = {"status": "Nothing to do"}
|
||||
return status
|
||||
except AttributeError as e:
|
||||
raise OutlineConnectionError("Client error. Can't connect.", original_exception=e)
|
||||
|
||||
def _get_key(self, user):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"[{self.name}] Looking for key for user {user.username}")
|
||||
try:
|
||||
# Try to get key by username first
|
||||
result = self.client.get_key(str(user.username))
|
||||
logger.debug(f"[{self.name}] Found key for user {user.username} by username")
|
||||
return result
|
||||
except OutlineServerErrorException:
|
||||
# If not found by username, search by password (hash)
|
||||
logger.debug(f"[{self.name}] Key not found by username, searching by password")
|
||||
try:
|
||||
keys = self.client.get_keys()
|
||||
for key in keys:
|
||||
if key.password == user.hash:
|
||||
logger.debug(f"[{self.name}] Found key for user {user.username} by password match")
|
||||
return key
|
||||
# No key found
|
||||
logger.debug(f"[{self.name}] No key found for user {user.username}")
|
||||
raise OutlineServerErrorException(f"Key not found for user {user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Error searching for key for user {user.username}: {e}")
|
||||
raise OutlineServerErrorException(f"Error searching for key: {e}")
|
||||
|
||||
def get_user(self, user, raw=False):
|
||||
try:
|
||||
user_info = self._get_key(user)
|
||||
if raw:
|
||||
return user_info
|
||||
else:
|
||||
outline_key_dict = user_info.__dict__
|
||||
|
||||
outline_key_dict = {
|
||||
key: value
|
||||
for key, value in user_info.__dict__.items()
|
||||
if not key.startswith('_') and key not in [] # fields to mask
|
||||
}
|
||||
return outline_key_dict
|
||||
except OutlineServerErrorException as e:
|
||||
# If user key not found, try to create it automatically
|
||||
if "Key not found" in str(e):
|
||||
self.logger.warning(f"[{self.name}] Key not found for user {user.username}, attempting to create")
|
||||
try:
|
||||
self.add_user(user)
|
||||
# Try to get the key again after creation
|
||||
user_info = self._get_key(user)
|
||||
if raw:
|
||||
return user_info
|
||||
else:
|
||||
outline_key_dict = {
|
||||
key: value
|
||||
for key, value in user_info.__dict__.items()
|
||||
if not key.startswith('_') and key not in []
|
||||
}
|
||||
return outline_key_dict
|
||||
except Exception as create_error:
|
||||
self.logger.error(f"[{self.name}] Failed to create missing key for user {user.username}: {create_error}")
|
||||
raise OutlineServerErrorException(f"Failed to get credentials: {e}")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def add_user(self, user):
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
server_user = self._get_key(user)
|
||||
except OutlineServerErrorException as e:
|
||||
server_user = None
|
||||
|
||||
logger.debug(f"[{self.name}] User {str(server_user)}")
|
||||
|
||||
result = {}
|
||||
key = None
|
||||
|
||||
if server_user:
|
||||
# Check if user needs update - but don't delete immediately
|
||||
needs_update = (
|
||||
server_user.method != "chacha20-ietf-poly1305" or
|
||||
server_user.name != user.username or
|
||||
server_user.password != user.hash
|
||||
# Don't check port as Outline can assign different ports automatically
|
||||
)
|
||||
|
||||
if needs_update:
|
||||
# Delete old key before creating new one
|
||||
try:
|
||||
self.client.delete_key(server_user.key_id)
|
||||
logger.debug(f"[{self.name}] Deleted outdated key for user {user.username}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.name}] Failed to delete old key for user {user.username}: {e}")
|
||||
|
||||
# Create new key with correct parameters
|
||||
try:
|
||||
key = self.client.create_key(
|
||||
key_id=user.username,
|
||||
name=user.username,
|
||||
method="chacha20-ietf-poly1305",
|
||||
password=user.hash,
|
||||
data_limit=None
|
||||
# Don't specify port - let server assign automatically
|
||||
)
|
||||
logger.info(f"[{self.name}] User {user.username} updated")
|
||||
except OutlineServerErrorException as e:
|
||||
raise OutlineConnectionError(f"Failed to create updated key for user {user.username}", original_exception=e)
|
||||
else:
|
||||
# User exists and is up to date
|
||||
key = server_user
|
||||
logger.debug(f"[{self.name}] User {user.username} already up to date")
|
||||
else:
|
||||
# User doesn't exist, create new key
|
||||
try:
|
||||
key = self.client.create_key(
|
||||
key_id=user.username,
|
||||
name=user.username,
|
||||
method="chacha20-ietf-poly1305",
|
||||
password=user.hash,
|
||||
data_limit=None
|
||||
# Don't specify port - let server assign automatically
|
||||
)
|
||||
logger.info(f"[{self.name}] User {user.username} created")
|
||||
except OutlineServerErrorException as e:
|
||||
error_message = str(e)
|
||||
if "code\":\"Conflict" in error_message:
|
||||
logger.warning(f"[{self.name}] Conflict for User {user.username}, trying to resolve. {error_message}")
|
||||
# Find conflicting key by password and remove it
|
||||
try:
|
||||
for existing_key in self.client.get_keys():
|
||||
if existing_key.password == user.hash:
|
||||
logger.warning(f"[{self.name}] Found conflicting key {existing_key.key_id} with same password")
|
||||
self.client.delete_key(existing_key.key_id)
|
||||
break
|
||||
# Try to create again after cleanup
|
||||
return self.add_user(user)
|
||||
except Exception as cleanup_error:
|
||||
logger.error(f"[{self.name}] Failed to resolve conflict for user {user.username}: {cleanup_error}")
|
||||
raise OutlineConnectionError(f"Conflict resolution failed for user {user.username}", original_exception=e)
|
||||
else:
|
||||
raise OutlineConnectionError("API Error", original_exception=e)
|
||||
|
||||
# Build result from key object
|
||||
try:
|
||||
if key:
|
||||
result = {
|
||||
'key_id': key.key_id,
|
||||
'name': key.name,
|
||||
'method': key.method,
|
||||
'password': key.password,
|
||||
'data_limit': key.data_limit,
|
||||
'port': key.port
|
||||
}
|
||||
else:
|
||||
result = {"error": "No key object returned"}
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.name}] Error building result for user {user.username}: {e}")
|
||||
result = {"error": str(e)}
|
||||
|
||||
return result
|
||||
|
||||
def delete_user(self, user):
|
||||
result = None
|
||||
try:
|
||||
server_user = self._get_key(user)
|
||||
except OutlineServerErrorException as e:
|
||||
return {"status": "User not found on server. Nothing to do."}
|
||||
|
||||
if server_user:
|
||||
self.logger.info(f"Deleting key with key_id: {server_user.key_id}")
|
||||
self.client.delete_key(server_user.key_id)
|
||||
result = {"status": "User was deleted"}
|
||||
self.logger.info(f"[{self.name}] User deleted: {user.username} on server {self.name}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
class OutlineServerAdmin(PolymorphicChildModelAdmin):
|
||||
base_model = OutlineServer
|
||||
show_in_index = False
|
||||
list_display = (
|
||||
'name',
|
||||
'admin_url',
|
||||
'admin_access_cert',
|
||||
'client_hostname',
|
||||
'client_port',
|
||||
'user_count',
|
||||
'server_status_inline',
|
||||
)
|
||||
readonly_fields = ('server_status_full', 'registration_date', 'export_configuration_display', 'server_statistics_display', 'recent_activity_display', 'json_import_field')
|
||||
list_editable = ('admin_url', 'admin_access_cert', 'client_hostname', 'client_port',)
|
||||
exclude = ('server_type',)
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
"""Customize fieldsets based on whether object exists"""
|
||||
if obj is None: # Adding new server
|
||||
return (
|
||||
('JSON Import', {
|
||||
'fields': ('json_import_field',),
|
||||
'description': 'Quick import from Outline server JSON configuration'
|
||||
}),
|
||||
('Server Configuration', {
|
||||
'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port')
|
||||
}),
|
||||
)
|
||||
else: # Editing existing server
|
||||
return (
|
||||
('Server Configuration', {
|
||||
'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port', 'registration_date')
|
||||
}),
|
||||
('Server Status', {
|
||||
'fields': ('server_status_full',)
|
||||
}),
|
||||
('Export Configuration', {
|
||||
'fields': ('export_configuration_display',)
|
||||
}),
|
||||
('Statistics & Users', {
|
||||
'fields': ('server_statistics_display',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Recent Activity', {
|
||||
'fields': ('recent_activity_display',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def get_urls(self):
|
||||
from django.urls import path
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<int:object_id>/sync/', self.admin_site.admin_view(self.sync_server_view), name='outlineserver_sync'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
@admin.display(description='Clients', ordering='user_count')
|
||||
def user_count(self, obj):
|
||||
return obj.user_count
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(user_count=Count('acl__user'))
|
||||
return qs
|
||||
|
||||
def server_status_inline(self, obj):
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
# Преобразуем JSON в красивый формат
|
||||
import json
|
||||
pretty_status = json.dumps(status, indent=4)
|
||||
return mark_safe(f"<pre>{pretty_status}</pre>")
|
||||
server_status_inline.short_description = "Status"
|
||||
|
||||
def server_status_full(self, obj):
|
||||
if obj and obj.pk:
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
import json
|
||||
pretty_status = json.dumps(status, indent=4)
|
||||
return mark_safe(f"<pre>{pretty_status}</pre>")
|
||||
return "N/A"
|
||||
|
||||
server_status_full.short_description = "Server Status"
|
||||
|
||||
def sync_server_view(self, request, object_id):
|
||||
"""AJAX view to sync server settings"""
|
||||
from django.http import JsonResponse
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
server = OutlineServer.objects.get(pk=object_id)
|
||||
result = server.sync()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': f'Server "{server.name}" synchronized successfully',
|
||||
'details': result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
"""Use the default Django admin add view"""
|
||||
return super().add_view(request, form_url, extra_context)
|
||||
|
||||
@admin.display(description='Import JSON Configuration')
|
||||
def json_import_field(self, obj):
|
||||
"""Display JSON import field for new servers only"""
|
||||
if obj and obj.pk:
|
||||
# Hide for existing servers
|
||||
return ''
|
||||
|
||||
html = '''
|
||||
<div style="width: 100%;">
|
||||
<textarea id="import-json-config" class="vLargeTextField" rows="8"
|
||||
placeholder='{
|
||||
"apiUrl": "https://your-server:port/path",
|
||||
"certSha256": "your-certificate-hash",
|
||||
"serverName": "My Outline Server",
|
||||
"clientHostname": "your-server.com",
|
||||
"clientPort": 1257,
|
||||
"comment": "Server description"
|
||||
}' style="font-family: 'Courier New', monospace; font-size: 0.875rem; width: 100%;"></textarea>
|
||||
<div class="help" style="margin-top: 0.5rem;">
|
||||
Paste JSON configuration from your Outline server setup to automatically fill the fields below.
|
||||
</div>
|
||||
<div style="margin-top: 1rem;">
|
||||
<button type="button" id="import-json-btn" class="btn btn-primary" onclick="importJsonConfig()">Import Configuration</button>
|
||||
</div>
|
||||
<script>
|
||||
function importJsonConfig() {
|
||||
const textarea = document.getElementById('import-json-config');
|
||||
try {
|
||||
const jsonText = textarea.value.trim();
|
||||
if (!jsonText) {
|
||||
alert('Please enter JSON configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(jsonText);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.apiUrl || !config.certSha256) {
|
||||
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse apiUrl to extract components
|
||||
const url = new URL(config.apiUrl);
|
||||
|
||||
// Fill form fields
|
||||
const adminUrlField = document.getElementById('id_admin_url');
|
||||
const adminCertField = document.getElementById('id_admin_access_cert');
|
||||
const clientHostnameField = document.getElementById('id_client_hostname');
|
||||
const clientPortField = document.getElementById('id_client_port');
|
||||
const nameField = document.getElementById('id_name');
|
||||
const commentField = document.getElementById('id_comment');
|
||||
|
||||
if (adminUrlField) adminUrlField.value = config.apiUrl;
|
||||
if (adminCertField) adminCertField.value = config.certSha256;
|
||||
|
||||
// Use provided hostname or extract from URL
|
||||
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
|
||||
if (clientHostnameField) clientHostnameField.value = hostname;
|
||||
|
||||
// Use provided port or extract from various sources
|
||||
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
|
||||
if (clientPortField) clientPortField.value = clientPort;
|
||||
|
||||
// Generate server name if not provided and field is empty
|
||||
if (nameField && !nameField.value) {
|
||||
const serverName = config.serverName || config.name || 'Outline-' + hostname;
|
||||
nameField.value = serverName;
|
||||
}
|
||||
|
||||
// Fill comment if provided and field exists
|
||||
if (commentField && config.comment) {
|
||||
commentField.value = config.comment;
|
||||
}
|
||||
|
||||
// Clear the JSON input
|
||||
textarea.value = '';
|
||||
|
||||
alert('Configuration imported successfully! Review the fields below and save.');
|
||||
|
||||
// Click on Server Configuration tab if using Jazzmin
|
||||
const serverTab = document.querySelector('a[href="#server-configuration-tab"]');
|
||||
if (serverTab) {
|
||||
serverTab.click();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
alert('Invalid JSON format: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Add paste event listener
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const textarea = document.getElementById('import-json-config');
|
||||
if (textarea) {
|
||||
textarea.addEventListener('paste', function(e) {
|
||||
setTimeout(importJsonConfig, 100);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
'''
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
@admin.display(description='Server Statistics & Users')
|
||||
def server_statistics_display(self, obj):
|
||||
"""Display server statistics and user management"""
|
||||
if not obj or not obj.pk:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic;">Statistics will be available after saving</div>')
|
||||
|
||||
try:
|
||||
from vpn.models import ACL, AccessLog, UserStatistics
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
# Get user statistics
|
||||
user_count = ACL.objects.filter(server=obj).count()
|
||||
total_links = 0
|
||||
server_keys_count = 0
|
||||
|
||||
try:
|
||||
from vpn.models import ACLLink
|
||||
total_links = ACLLink.objects.filter(acl__server=obj).count()
|
||||
|
||||
# Try to get actual keys count from server
|
||||
server_status = obj.get_server_status()
|
||||
if 'keys' in server_status:
|
||||
server_keys_count = server_status['keys']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get active users count (last 30 days)
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
active_users_count = UserStatistics.objects.filter(
|
||||
server_name=obj.name,
|
||||
recent_connections__gt=0
|
||||
).values('user').distinct().count()
|
||||
|
||||
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: 1rem;">'
|
||||
|
||||
# Overall Statistics
|
||||
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
|
||||
html += '<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
|
||||
html += f'<div><strong>Total Users:</strong> {user_count}</div>'
|
||||
html += f'<div><strong>Active Users (30d):</strong> {active_users_count}</div>'
|
||||
html += f'<div><strong>Total Links:</strong> {total_links}</div>'
|
||||
html += f'<div><strong>Server Keys:</strong> {server_keys_count}</div>'
|
||||
html += '</div>'
|
||||
html += '<div style="margin-top: 8px; font-size: 11px; color: #6c757d;">'
|
||||
html += '📊 User activity data is from cached statistics for fast loading. Status indicators show usage patterns.'
|
||||
html += '</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Get users data with ACL information
|
||||
acls = ACL.objects.filter(server=obj).select_related('user').prefetch_related('links')
|
||||
|
||||
if acls:
|
||||
html += '<h5 style="color: #495057; margin: 16px 0 8px 0;">👥 Users with Access</h5>'
|
||||
|
||||
for acl in acls:
|
||||
user = acl.user
|
||||
links = list(acl.links.all())
|
||||
|
||||
# Get last access time from any link
|
||||
last_access = None
|
||||
for link in links:
|
||||
if link.last_access_time:
|
||||
if last_access is None or link.last_access_time > last_access:
|
||||
last_access = link.last_access_time
|
||||
|
||||
# Use cached statistics instead of live server check for performance
|
||||
user_stats = UserStatistics.objects.filter(user=user, server_name=obj.name)
|
||||
server_key_status = "unknown"
|
||||
total_user_connections = 0
|
||||
recent_user_connections = 0
|
||||
|
||||
if user_stats.exists():
|
||||
# User has cached data, likely has server access
|
||||
total_user_connections = sum(stat.total_connections for stat in user_stats)
|
||||
recent_user_connections = sum(stat.recent_connections for stat in user_stats)
|
||||
|
||||
if total_user_connections > 0:
|
||||
server_key_status = "cached_active"
|
||||
else:
|
||||
server_key_status = "cached_inactive"
|
||||
else:
|
||||
# No cached data - either new user or no access
|
||||
server_key_status = "no_cache"
|
||||
|
||||
html += '<div style="background: #ffffff; border: 1px solid #e9ecef; border-radius: 0.25rem; padding: 0.75rem; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center;">'
|
||||
|
||||
# User info section
|
||||
html += '<div style="flex: 1;">'
|
||||
html += f'<div style="font-weight: 500; font-size: 14px; color: #495057;">{user.username}'
|
||||
if user.comment:
|
||||
html += f' <span style="color: #6c757d; font-size: 12px; font-weight: normal;">- {user.comment}</span>'
|
||||
html += '</div>'
|
||||
html += f'<div style="font-size: 12px; color: #6c757d;">{len(links)} link(s)'
|
||||
if last_access:
|
||||
from django.utils.timezone import localtime
|
||||
local_time = localtime(last_access)
|
||||
html += f' | Last access: {local_time.strftime("%Y-%m-%d %H:%M")}'
|
||||
else:
|
||||
html += ' | Never accessed'
|
||||
|
||||
# Add usage statistics inside the same div
|
||||
if total_user_connections > 0:
|
||||
html += f' | {total_user_connections} total uses'
|
||||
if recent_user_connections > 0:
|
||||
html += f' ({recent_user_connections} recent)'
|
||||
|
||||
html += '</div>' # End user info
|
||||
html += '</div>' # End flex-1 div
|
||||
|
||||
# Status and actions section
|
||||
html += '<div style="display: flex; gap: 8px; align-items: center;">'
|
||||
|
||||
# Status indicator based on cached data
|
||||
if server_key_status == "cached_active":
|
||||
html += '<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">📊 Active User</span>'
|
||||
elif server_key_status == "cached_inactive":
|
||||
html += '<span style="background: #fff3cd; color: #856404; padding: 2px 6px; border-radius: 3px; font-size: 10px;">📊 Inactive</span>'
|
||||
else:
|
||||
html += '<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">❓ No Data</span>'
|
||||
|
||||
html += f'<a href="/admin/vpn/user/{user.id}/change/" class="btn btn-sm btn-outline-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 0.2rem; margin: 0 0.1rem;">👤 Edit</a>'
|
||||
html += '</div>' # End actions div
|
||||
html += '</div>' # End main user div
|
||||
else:
|
||||
html += '<div style="color: #6c757d; font-style: italic; text-align: center; padding: 20px; background: #f9fafb; border-radius: 6px;">'
|
||||
html += 'No users assigned to this server'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<div style="color: #dc3545;">Error loading statistics: {e}</div>')
|
||||
|
||||
@admin.display(description='Export Configuration')
|
||||
def export_configuration_display(self, obj):
|
||||
"""Display JSON export configuration"""
|
||||
if not obj or not obj.pk:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic;">Export will be available after saving</div>')
|
||||
|
||||
try:
|
||||
# Build export data
|
||||
export_data = {
|
||||
'apiUrl': obj.admin_url,
|
||||
'certSha256': obj.admin_access_cert,
|
||||
'serverName': obj.name,
|
||||
'clientHostname': obj.client_hostname,
|
||||
'clientPort': int(obj.client_port),
|
||||
'comment': obj.comment,
|
||||
'serverType': 'outline',
|
||||
'dateCreated': obj.registration_date.isoformat() if obj.registration_date else None,
|
||||
'id': obj.id
|
||||
}
|
||||
|
||||
# Try to get server status
|
||||
try:
|
||||
server_status = obj.get_server_status()
|
||||
if 'error' not in server_status:
|
||||
export_data['serverInfo'] = server_status
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
json_str = json.dumps(export_data, indent=2)
|
||||
# Escape the JSON for HTML
|
||||
from django.utils.html import escape
|
||||
escaped_json = escape(json_str)
|
||||
|
||||
html = '''
|
||||
<div>
|
||||
<textarea id="export-json-config" class="vLargeTextField" rows="10" readonly
|
||||
style="font-family: 'Courier New', monospace; font-size: 0.875rem; background-color: #f8f9fa; width: 100%;">''' + escaped_json + '''</textarea>
|
||||
<div class="help" style="margin-top: 0.5rem;">
|
||||
<strong>Includes:</strong> Server settings, connection details, live server info (if accessible), creation date, and comment.
|
||||
</div>
|
||||
<div style="padding-top: 1rem;">
|
||||
<button type="button" id="copy-export-btn" class="btn btn-sm btn-secondary"
|
||||
onclick="var btn=this; document.getElementById('export-json-config').select(); document.execCommand('copy'); btn.innerHTML='✅ Copied!'; setTimeout(function(){btn.innerHTML='📋 Copy JSON';}, 2000);"
|
||||
style="margin-right: 10px;">📋 Copy JSON</button>
|
||||
<button type="button" id="sync-server-btn" data-server-id="''' + str(obj.id) + '''" class="btn btn-sm btn-primary">🔄 Sync Server Settings</button>
|
||||
<span style="margin-left: 0.5rem; font-size: 0.875rem; color: #6c757d;">
|
||||
Synchronize server name, hostname, and port settings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<div style="color: #dc3545;">Error generating export: {e}</div>')
|
||||
|
||||
@admin.display(description='Recent Activity')
|
||||
def recent_activity_display(self, obj):
|
||||
"""Display recent activity in admin-friendly format"""
|
||||
if not obj or not obj.pk:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic;">Activity will be available after saving</div>')
|
||||
|
||||
try:
|
||||
from vpn.models import AccessLog
|
||||
from django.utils.timezone import localtime
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
# Get recent access logs for this server (last 7 days)
|
||||
seven_days_ago = timezone.now() - timedelta(days=7)
|
||||
recent_logs = AccessLog.objects.filter(
|
||||
server=obj.name,
|
||||
timestamp__gte=seven_days_ago
|
||||
).order_by('-timestamp')[:20]
|
||||
|
||||
if not recent_logs:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic; padding: 12px; background: #f8f9fa; border-radius: 4px;">No recent activity (last 7 days)</div>')
|
||||
|
||||
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 0; max-height: 300px; overflow-y: auto;">'
|
||||
|
||||
# Header
|
||||
html += '<div style="background: #e9ecef; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600; font-size: 12px; color: #495057;">'
|
||||
html += f'📊 Access Log ({recent_logs.count()} entries, last 7 days)'
|
||||
html += '</div>'
|
||||
|
||||
# Activity entries
|
||||
for i, log in enumerate(recent_logs):
|
||||
bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa'
|
||||
local_time = localtime(log.timestamp)
|
||||
|
||||
# Status icon and color
|
||||
if log.action == 'Success':
|
||||
icon = '✅'
|
||||
status_color = '#28a745'
|
||||
elif log.action == 'Failed':
|
||||
icon = '❌'
|
||||
status_color = '#dc3545'
|
||||
else:
|
||||
icon = 'ℹ️'
|
||||
status_color = '#6c757d'
|
||||
|
||||
html += f'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f1f3f4; background: {bg_color};">'
|
||||
|
||||
# Left side - user and link info
|
||||
html += '<div style="display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0;">'
|
||||
html += f'<span style="color: {status_color}; font-size: 14px;">{icon}</span>'
|
||||
html += '<div style="overflow: hidden;">'
|
||||
html += f'<div style="font-weight: 500; font-size: 12px; color: #495057; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{log.user}</div>'
|
||||
|
||||
if log.acl_link_id:
|
||||
link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id
|
||||
html += f'<div style="font-family: monospace; font-size: 10px; color: #6c757d;">{link_short}</div>'
|
||||
|
||||
html += '</div></div>'
|
||||
|
||||
# Right side - timestamp and status
|
||||
html += '<div style="text-align: right; flex-shrink: 0;">'
|
||||
html += f'<div style="font-size: 10px; color: #6c757d;">{local_time.strftime("%m-%d %H:%M")}</div>'
|
||||
html += f'<div style="font-size: 9px; color: {status_color}; font-weight: 500;">{log.action}</div>'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
|
||||
# Footer with summary if there are more entries
|
||||
total_recent = AccessLog.objects.filter(
|
||||
server=obj.name,
|
||||
timestamp__gte=seven_days_ago
|
||||
).count()
|
||||
|
||||
if total_recent > 20:
|
||||
html += f'<div style="background: #e9ecef; padding: 6px 12px; font-size: 11px; color: #6c757d; text-align: center;">'
|
||||
html += f'Showing 20 of {total_recent} entries from last 7 days'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<div style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</div>')
|
||||
|
||||
def get_model_perms(self, request):
|
||||
"""It disables display for sub-model"""
|
||||
return {}
|
||||
|
||||
class Media:
|
||||
js = ('admin/js/generate_link.js',)
|
||||
css = {'all': ('admin/css/vpn_admin.css',)}
|
||||
|
||||
admin.site.register(OutlineServer, OutlineServerAdmin)
|
||||
7
vpn/server_plugins/urls.py
Normal file
7
vpn/server_plugins/urls.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
from vpn.views import shadowsocks, xray_subscription
|
||||
|
||||
urlpatterns = [
|
||||
path('ss/<str:hash_value>/', shadowsocks, name='shadowsocks'),
|
||||
path('xray/<str:user_hash>/', xray_subscription, name='xray_subscription'),
|
||||
]
|
||||
83
vpn/server_plugins/wireguard.py
Normal file
83
vpn/server_plugins/wireguard.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from .generic import Server
|
||||
from django.db import models
|
||||
from polymorphic.admin import (
|
||||
PolymorphicChildModelAdmin,
|
||||
)
|
||||
from django.contrib import admin
|
||||
from django.db.models import Count
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
class WireguardServer(Server):
|
||||
address = models.CharField(max_length=100)
|
||||
port = models.IntegerField()
|
||||
client_private_key = models.CharField(max_length=255)
|
||||
server_publick_key = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Wireguard'
|
||||
verbose_name_plural = 'Wireguard'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.server_type = 'Wireguard'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.address})"
|
||||
|
||||
def get_server_status(self):
|
||||
status = super().get_server_status()
|
||||
status.update({
|
||||
"address": self.address,
|
||||
"port": self.port,
|
||||
"client_private_key": self.client_private_key,
|
||||
"server_publick_key": self.server_publick_key,
|
||||
})
|
||||
return status
|
||||
|
||||
class WireguardServerAdmin(PolymorphicChildModelAdmin):
|
||||
base_model = WireguardServer
|
||||
show_in_index = False # Не отображать в главном списке админки
|
||||
list_display = (
|
||||
'name',
|
||||
'address',
|
||||
'port',
|
||||
'server_publick_key',
|
||||
'client_private_key',
|
||||
'server_status_inline',
|
||||
'user_count',
|
||||
'registration_date'
|
||||
)
|
||||
readonly_fields = ('server_status_full', )
|
||||
exclude = ('server_type',)
|
||||
|
||||
@admin.display(description='Clients', ordering='user_count')
|
||||
def user_count(self, obj):
|
||||
return obj.user_count
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(user_count=Count('acl__user'))
|
||||
return qs
|
||||
|
||||
def server_status_inline(self, obj):
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
return mark_safe(f"<pre>{status}</pre>")
|
||||
server_status_inline.short_description = "Server Status"
|
||||
|
||||
def server_status_full(self, obj):
|
||||
if obj and obj.pk:
|
||||
status = obj.get_server_status()
|
||||
if 'error' in status:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
|
||||
return mark_safe(f"<pre>{status}</pre>")
|
||||
return "N/A"
|
||||
|
||||
server_status_full.short_description = "Server Status"
|
||||
|
||||
def get_model_perms(self, request):
|
||||
"""It disables display for sub-model"""
|
||||
return {}
|
||||
|
||||
admin.site.register(WireguardServer, WireguardServerAdmin)
|
||||
736
vpn/server_plugins/xray_v2.py
Normal file
736
vpn/server_plugins/xray_v2.py
Normal file
@@ -0,0 +1,736 @@
|
||||
import logging
|
||||
from django.db import models
|
||||
from django.contrib import admin
|
||||
from .generic import Server
|
||||
from vpn.models_xray import Inbound, UserSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XrayServerV2(Server):
|
||||
"""
|
||||
New Xray server that works with subscription groups and inbounds.
|
||||
This server can host multiple inbounds and users access them through subscription groups.
|
||||
"""
|
||||
client_hostname = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Client connection hostname (what users see in their configs)"
|
||||
)
|
||||
api_address = models.CharField(
|
||||
max_length=255,
|
||||
default="127.0.0.1:10085",
|
||||
help_text="Xray gRPC API address for management"
|
||||
)
|
||||
api_enabled = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable gRPC API for user management"
|
||||
)
|
||||
stats_enabled = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable traffic statistics collection"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Xray Server v2"
|
||||
verbose_name_plural = "Xray Servers v2"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.server_type:
|
||||
self.server_type = 'xray_v2'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_server_status(self):
|
||||
"""Get server status including active inbounds"""
|
||||
try:
|
||||
# Get basic server information
|
||||
active_inbounds = self.get_active_inbounds()
|
||||
|
||||
# Try to connect to Xray API if enabled
|
||||
api_status = False
|
||||
api_error = None
|
||||
api_stats = {}
|
||||
|
||||
if self.api_enabled:
|
||||
try:
|
||||
# Try different methods to check server status
|
||||
import socket
|
||||
import json
|
||||
|
||||
# Parse API address
|
||||
host, port = self.api_address.split(':')
|
||||
port = int(port)
|
||||
|
||||
# Test basic connection
|
||||
logger.info(f"Testing connection to Xray API at {host}:{port}")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
result = sock.connect_ex((host, port))
|
||||
sock.close()
|
||||
|
||||
if result == 0:
|
||||
api_status = True
|
||||
logger.info(f"Successfully connected to Xray API at {self.api_address}")
|
||||
|
||||
# Try to get stats if library is available
|
||||
try:
|
||||
from vpn.xray_api_v2.server_manager import ServerManager
|
||||
manager = ServerManager(self.api_address)
|
||||
api_stats = manager.get_server_stats()
|
||||
logger.info(f"Got server stats: {api_stats}")
|
||||
except ImportError:
|
||||
logger.info("Xray API v2 library not available, but connection successful")
|
||||
api_stats = {"connection": "ok", "library": "not_available"}
|
||||
except Exception as stats_e:
|
||||
logger.warning(f"Connection OK but stats failed: {stats_e}")
|
||||
api_stats = {"connection": "ok", "stats_error": str(stats_e)}
|
||||
else:
|
||||
api_error = f"Connection failed to {host}:{port}"
|
||||
logger.warning(f"Failed to connect to Xray API at {self.api_address}: {api_error}")
|
||||
|
||||
except Exception as e:
|
||||
api_error = f"Connection test failed: {str(e)}"
|
||||
logger.warning(f"Failed to test connection to Xray API for server {self.name}: {e}")
|
||||
else:
|
||||
api_error = "API disabled in server settings"
|
||||
logger.info(f"API disabled for server {self.name}")
|
||||
|
||||
# Build status response
|
||||
status = {
|
||||
'server_name': self.name,
|
||||
'server_type': 'Xray Server v2',
|
||||
'client_hostname': self.client_hostname,
|
||||
'api_address': self.api_address,
|
||||
'api_enabled': self.api_enabled,
|
||||
'api_connected': api_status,
|
||||
'api_error': api_error,
|
||||
'api_stats': api_stats,
|
||||
'stats_enabled': self.stats_enabled,
|
||||
'total_inbounds': active_inbounds.count(),
|
||||
'inbound_ports': [], # Will be populated when ServerInbound model is fully implemented
|
||||
'accessible': api_status if self.api_enabled else True, # Consider accessible if API disabled
|
||||
'status': 'Connected' if api_status else 'API Issue' if self.api_enabled else 'No API Check'
|
||||
}
|
||||
|
||||
logger.info(f"Server status for {self.name}: {status['status']}")
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get status for Xray server {self.name}: {e}")
|
||||
return {
|
||||
'error': str(e),
|
||||
'server_name': self.name,
|
||||
'server_type': 'Xray Server v2',
|
||||
'accessible': False,
|
||||
'status': 'Error'
|
||||
}
|
||||
|
||||
def get_active_inbounds(self):
|
||||
"""Get all inbounds that are deployed on this server"""
|
||||
try:
|
||||
from vpn.models_xray import ServerInbound
|
||||
return ServerInbound.objects.filter(server=self, active=True).select_related('inbound')
|
||||
except ImportError:
|
||||
# ServerInbound model doesn't exist yet, return empty queryset
|
||||
from django.db.models import QuerySet
|
||||
from vpn.models_xray import Inbound
|
||||
return Inbound.objects.none()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting active inbounds for server {self.name}: {e}")
|
||||
from vpn.models_xray import Inbound
|
||||
return Inbound.objects.none()
|
||||
|
||||
def sync_users(self):
|
||||
"""Sync all users who have subscription groups containing inbounds on this server"""
|
||||
try:
|
||||
from vpn.tasks import sync_server_users
|
||||
task = sync_server_users.delay(self.id)
|
||||
logger.info(f"Scheduled user sync for Xray server {self.name} - task ID: {task.id}")
|
||||
# Return success to indicate task was scheduled
|
||||
return {"status": "scheduled", "task_id": str(task.id)}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule user sync for server {self.name}: {e}")
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
def sync_inbounds(self, auto_sync_users=True):
|
||||
"""Deploy all required inbounds on this server based on subscription groups"""
|
||||
try:
|
||||
from vpn.tasks import sync_server_inbounds
|
||||
task = sync_server_inbounds.delay(self.id, auto_sync_users)
|
||||
logger.info(f"Scheduled inbound sync for Xray server {self.name} - task ID: {task.id}")
|
||||
return {"task_id": str(task.id), "auto_sync_users": auto_sync_users}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule inbound sync for server {self.name}: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def deploy_inbound(self, inbound, users=None):
|
||||
"""Deploy a specific inbound on this server with optional users"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
import uuid
|
||||
|
||||
logger.info(f"Deploying inbound {inbound.name} with protocol {inbound.protocol} on port {inbound.port}")
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Build user configs if users are provided
|
||||
user_configs = []
|
||||
if users:
|
||||
for user in users:
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
|
||||
|
||||
if inbound.protocol == 'vless':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0,
|
||||
"alterId": 0
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"password": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Unsupported protocol {inbound.protocol} for user {user.username}")
|
||||
continue
|
||||
|
||||
user_configs.append(user_config)
|
||||
logger.info(f"Added user {user.username} to inbound config")
|
||||
|
||||
# Build proper inbound configuration based on protocol
|
||||
if inbound.full_config:
|
||||
inbound_config = inbound.full_config.copy() # Make a copy to modify
|
||||
logger.info(f"Using existing full_config for inbound {inbound.name}")
|
||||
|
||||
# Add users to the config if provided
|
||||
if user_configs:
|
||||
if 'settings' not in inbound_config:
|
||||
inbound_config['settings'] = {}
|
||||
inbound_config['settings']['clients'] = user_configs
|
||||
logger.info(f"Added {len(user_configs)} users to full_config")
|
||||
|
||||
# If inbound has a certificate, update the config to use inline certificates
|
||||
if inbound.certificate and inbound.certificate.certificate_pem:
|
||||
logger.info(f"Updating full_config with inline certificate for {inbound.certificate.domain}")
|
||||
|
||||
# Convert PEM to lines for Xray format
|
||||
cert_lines = inbound.certificate.certificate_pem.strip().split('\n')
|
||||
key_lines = inbound.certificate.private_key_pem.strip().split('\n')
|
||||
|
||||
# Update streamSettings if it exists
|
||||
if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]:
|
||||
inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
logger.info("Updated existing tlsSettings with inline certificate")
|
||||
else:
|
||||
# Build full config based on protocol
|
||||
inbound_config = {
|
||||
"tag": inbound.name,
|
||||
"port": inbound.port,
|
||||
"protocol": inbound.protocol,
|
||||
"listen": inbound.listen_address or "0.0.0.0",
|
||||
}
|
||||
|
||||
# Add protocol-specific settings
|
||||
if inbound.protocol == 'vless':
|
||||
inbound_config["settings"] = {
|
||||
"clients": user_configs, # Add users during creation
|
||||
"decryption": "none"
|
||||
}
|
||||
if inbound.network == 'ws':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "ws",
|
||||
"wsSettings": {
|
||||
"path": f"/{inbound.name}"
|
||||
}
|
||||
}
|
||||
elif inbound.network == 'tcp':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "tcp"
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
inbound_config["settings"] = {
|
||||
"clients": user_configs # Add users during creation
|
||||
}
|
||||
if inbound.network == 'ws':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "ws",
|
||||
"wsSettings": {
|
||||
"path": f"/{inbound.name}"
|
||||
}
|
||||
}
|
||||
elif inbound.network == 'tcp':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "tcp"
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
inbound_config["settings"] = {
|
||||
"clients": user_configs # Add users during creation
|
||||
}
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "tcp",
|
||||
"security": "tls"
|
||||
}
|
||||
|
||||
# Trojan always requires TLS certificate
|
||||
if inbound.certificate and inbound.certificate.certificate_pem:
|
||||
logger.info(f"Using certificate for Trojan inbound on domain {inbound.certificate.domain}")
|
||||
# Convert PEM to lines for Xray format
|
||||
cert_lines = inbound.certificate.certificate_pem.strip().split('\n')
|
||||
key_lines = inbound.certificate.private_key_pem.strip().split('\n')
|
||||
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
}
|
||||
else:
|
||||
logger.error(f"Trojan protocol requires certificate, but none found for inbound {inbound.name}!")
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": []
|
||||
}
|
||||
|
||||
# Add TLS if specified
|
||||
if inbound.security == 'tls' and inbound.protocol != 'trojan':
|
||||
if "streamSettings" not in inbound_config:
|
||||
inbound_config["streamSettings"] = {}
|
||||
inbound_config["streamSettings"]["security"] = "tls"
|
||||
|
||||
# Check if inbound has a certificate
|
||||
if inbound.certificate and inbound.certificate.certificate_pem:
|
||||
logger.info(f"Using certificate for domain {inbound.certificate.domain}")
|
||||
# Convert PEM to lines for Xray format
|
||||
cert_lines = inbound.certificate.certificate_pem.strip().split('\n')
|
||||
key_lines = inbound.certificate.private_key_pem.strip().split('\n')
|
||||
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
}
|
||||
else:
|
||||
logger.warning(f"No certificate found for inbound {inbound.name}, TLS will not work!")
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": []
|
||||
}
|
||||
|
||||
logger.info(f"Inbound config: {inbound_config}")
|
||||
|
||||
# Add inbound using the client's add_inbound method which handles wrapping
|
||||
try:
|
||||
result = client.add_inbound(inbound_config)
|
||||
logger.info(f"Deploy inbound result: {result}")
|
||||
|
||||
# Check if command was successful
|
||||
if result is not None and not (isinstance(result, dict) and 'error' in result):
|
||||
# Mark as deployed on this server
|
||||
from vpn.models_xray import ServerInbound
|
||||
ServerInbound.objects.update_or_create(
|
||||
server=self,
|
||||
inbound=inbound,
|
||||
defaults={'active': True}
|
||||
)
|
||||
logger.info(f"Successfully deployed inbound {inbound.name} on server {self.name}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to deploy inbound {inbound.name} on server {self.name}. Result: {result}")
|
||||
return False
|
||||
except Exception as cmd_error:
|
||||
logger.error(f"Command execution error: {cmd_error}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deploying inbound {inbound.name} on server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def add_user_to_inbound(self, user, inbound):
|
||||
"""Add a user to a specific inbound on this server using inbound recreation approach"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
import uuid
|
||||
|
||||
logger.info(f"Adding user {user.username} to inbound {inbound.name} using inbound recreation")
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Generate user UUID based on username and inbound
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
|
||||
logger.info(f"Generated UUID for user {user.username}: {user_uuid}")
|
||||
|
||||
# Build user config based on protocol
|
||||
if inbound.protocol == 'vless':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0,
|
||||
"alterId": 0
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"password": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
else:
|
||||
logger.error(f"Unsupported protocol: {inbound.protocol}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# First, get existing inbound to check for other users
|
||||
existing_result = client.execute_command('lsi')
|
||||
existing_inbound = None
|
||||
|
||||
if existing_result and 'inbounds' in existing_result:
|
||||
for ib in existing_result['inbounds']:
|
||||
if ib.get('tag') == inbound.name:
|
||||
existing_inbound = ib
|
||||
break
|
||||
|
||||
if not existing_inbound:
|
||||
logger.warning(f"Inbound {inbound.name} not found on server, deploying it first")
|
||||
# Deploy the inbound if it doesn't exist
|
||||
if not self.deploy_inbound(inbound):
|
||||
logger.error(f"Failed to deploy inbound {inbound.name}")
|
||||
return False
|
||||
# Get the inbound config we just created
|
||||
existing_inbound = {"settings": {"clients": []}}
|
||||
|
||||
# Get existing users from the inbound
|
||||
existing_users = existing_inbound.get('settings', {}).get('clients', [])
|
||||
logger.info(f"Found {len(existing_users)} existing users in inbound {inbound.name}")
|
||||
|
||||
# Check if user already exists
|
||||
for existing_user in existing_users:
|
||||
if existing_user.get('email') == f"{user.username}@{self.name}":
|
||||
logger.info(f"User {user.username} already exists in inbound {inbound.name}")
|
||||
return True
|
||||
|
||||
# Add new user to existing users list
|
||||
existing_users.append(user_config)
|
||||
logger.info(f"Creating new inbound with {len(existing_users)} users including {user.username}")
|
||||
|
||||
# Remove the old inbound
|
||||
logger.info(f"Removing old inbound {inbound.name}")
|
||||
client.remove_inbound(inbound.name)
|
||||
|
||||
# Recreate inbound with updated user list
|
||||
if inbound.full_config:
|
||||
inbound_config = inbound.full_config.copy()
|
||||
if 'settings' not in inbound_config:
|
||||
inbound_config['settings'] = {}
|
||||
inbound_config['settings']['clients'] = existing_users
|
||||
|
||||
# Handle certificate embedding if needed
|
||||
if inbound.certificate and inbound.certificate.certificate_pem:
|
||||
cert_lines = inbound.certificate.certificate_pem.strip().split('\n')
|
||||
key_lines = inbound.certificate.private_key_pem.strip().split('\n')
|
||||
|
||||
if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]:
|
||||
inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
else:
|
||||
# Build config from scratch with the users
|
||||
inbound_config = {
|
||||
"tag": inbound.name,
|
||||
"port": inbound.port,
|
||||
"protocol": inbound.protocol,
|
||||
"listen": inbound.listen_address or "0.0.0.0",
|
||||
"settings": {}
|
||||
}
|
||||
|
||||
if inbound.protocol in ['vless', 'vmess']:
|
||||
inbound_config["settings"]["clients"] = existing_users
|
||||
if inbound.protocol == 'vless':
|
||||
inbound_config["settings"]["decryption"] = "none"
|
||||
elif inbound.protocol == 'trojan':
|
||||
inbound_config["settings"]["clients"] = existing_users
|
||||
|
||||
logger.info(f"Deploying updated inbound with users: {[u.get('email') for u in existing_users]}")
|
||||
result = client.add_inbound(inbound_config)
|
||||
|
||||
if result is not None and not (isinstance(result, dict) and 'error' in result):
|
||||
logger.info(f"Successfully added user {user.username} to inbound {inbound.name} via inbound recreation")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to recreate inbound {inbound.name} with user. Result: {result}")
|
||||
return False
|
||||
|
||||
except Exception as cmd_error:
|
||||
logger.error(f"Error during inbound recreation: {cmd_error}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding user {user.username} to inbound {inbound.name} on server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def remove_user_from_inbound(self, user, inbound):
|
||||
"""Remove a user from a specific inbound on this server"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Remove user using the client's remove_users method
|
||||
user_email = f"{user.username}@{self.name}"
|
||||
logger.info(f"Removing user {user_email} from inbound {inbound.name}")
|
||||
|
||||
result = client.remove_users(inbound.name, user_email)
|
||||
logger.info(f"Remove user result: {result}")
|
||||
|
||||
if result is not None and not (isinstance(result, dict) and 'error' in result):
|
||||
logger.info(f"Successfully removed user {user.username} from inbound {inbound.name} on server {self.name}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to remove user {user.username} from inbound {inbound.name} on server {self.name}. Result: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing user {user.username} from inbound {inbound.name} on server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def get_user_configs(self, user):
|
||||
"""Generate all connection configs for a user on this server"""
|
||||
configs = []
|
||||
|
||||
try:
|
||||
# Get all subscription groups for this user
|
||||
user_subscriptions = UserSubscription.objects.filter(
|
||||
user=user,
|
||||
active=True,
|
||||
subscription_group__is_active=True
|
||||
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||||
|
||||
for subscription in user_subscriptions:
|
||||
group = subscription.subscription_group
|
||||
|
||||
# Check which inbounds from this group are active on this server
|
||||
active_inbounds = self.get_active_inbounds().filter(
|
||||
inbound__in=group.inbounds.all()
|
||||
)
|
||||
|
||||
for server_inbound in active_inbounds:
|
||||
inbound = server_inbound.inbound
|
||||
|
||||
try:
|
||||
# Generate connection string directly
|
||||
from vpn.views import generate_xray_connection_string
|
||||
connection_string = generate_xray_connection_string(user, inbound, self.name, self.client_hostname)
|
||||
|
||||
if connection_string:
|
||||
configs.append({
|
||||
'protocol': inbound.protocol,
|
||||
'inbound_name': inbound.name,
|
||||
'group_name': group.name,
|
||||
'connection_string': connection_string,
|
||||
'port': inbound.port,
|
||||
'network': inbound.network,
|
||||
'security': inbound.security
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate config for user {user.username} on inbound {inbound.name}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Generated {len(configs)} configs for user {user.username} on server {self.name}")
|
||||
return configs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating user configs for {user.username} on server {self.name}: {e}")
|
||||
return []
|
||||
|
||||
def sync(self):
|
||||
"""Sync server configuration and users"""
|
||||
try:
|
||||
self.sync_inbounds()
|
||||
self.sync_users()
|
||||
logger.info(f"Full sync completed for server {self.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Sync failed for server {self.name}: {e}")
|
||||
|
||||
def add_user(self, user, **kwargs):
|
||||
"""Add user to server - implemented through subscription groups"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Users are added through subscription groups in the new architecture
|
||||
subscriptions = user.xray_subscriptions.filter(active=True)
|
||||
added_count = 0
|
||||
|
||||
logger.info(f"User {user.username} has {subscriptions.count()} active subscriptions")
|
||||
|
||||
if subscriptions.count() == 0:
|
||||
logger.warning(f"User {user.username} has no active Xray subscriptions - cannot add to server")
|
||||
return False
|
||||
|
||||
# Get all inbounds that this user should have access to
|
||||
inbounds_to_process = []
|
||||
for subscription in subscriptions:
|
||||
logger.info(f"Processing subscription group: {subscription.subscription_group.name}")
|
||||
for inbound in subscription.subscription_group.inbounds.all():
|
||||
if inbound not in inbounds_to_process:
|
||||
inbounds_to_process.append(inbound)
|
||||
logger.info(f"Added inbound {inbound.name} to processing list")
|
||||
|
||||
# Get existing inbounds on server
|
||||
try:
|
||||
existing_result = client.execute_command('lsi') # List inbounds
|
||||
existing_inbound_tags = set()
|
||||
if existing_result and 'inbounds' in existing_result:
|
||||
existing_inbound_tags = {ib.get('tag') for ib in existing_result['inbounds'] if ib.get('tag')}
|
||||
logger.info(f"Existing inbound tags on server: {existing_inbound_tags}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to list inbounds: {e}")
|
||||
existing_inbound_tags = set()
|
||||
|
||||
# Process each inbound
|
||||
for inbound in inbounds_to_process:
|
||||
logger.info(f"Processing inbound: {inbound.name} (protocol: {inbound.protocol})")
|
||||
|
||||
# Check if inbound exists on server
|
||||
if inbound.name not in existing_inbound_tags:
|
||||
logger.info(f"Inbound {inbound.name} doesn't exist on server, creating with user")
|
||||
# Create the inbound with the user directly
|
||||
if self.deploy_inbound(inbound, users=[user]):
|
||||
logger.info(f"Successfully created inbound {inbound.name} with user {user.username}")
|
||||
added_count += 1
|
||||
existing_inbound_tags.add(inbound.name)
|
||||
|
||||
# Mark as deployed on this server
|
||||
from vpn.models_xray import ServerInbound
|
||||
ServerInbound.objects.update_or_create(
|
||||
server=self,
|
||||
inbound=inbound,
|
||||
defaults={'active': True}
|
||||
)
|
||||
else:
|
||||
logger.error(f"Failed to create inbound {inbound.name} with user")
|
||||
continue
|
||||
else:
|
||||
# Inbound exists, add user using recreation approach
|
||||
logger.info(f"Inbound {inbound.name} exists, adding user via recreation")
|
||||
if self.add_user_to_inbound(user, inbound):
|
||||
added_count += 1
|
||||
logger.info(f"Successfully added user {user.username} to existing inbound {inbound.name}")
|
||||
else:
|
||||
logger.error(f"Failed to add user {user.username} to existing inbound {inbound.name}")
|
||||
|
||||
logger.info(f"Added user {user.username} to {added_count} inbounds on server {self.name}")
|
||||
return added_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add user {user.username} to server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def get_user(self, user, raw=False):
|
||||
"""Get user configurations from server"""
|
||||
try:
|
||||
configs = self.get_user_configs(user)
|
||||
if raw:
|
||||
return {
|
||||
'configs': configs,
|
||||
'total_configs': len(configs)
|
||||
}
|
||||
return configs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user {user.username} from server {self.name}: {e}")
|
||||
return [] if not raw else {'error': str(e)}
|
||||
|
||||
def delete_user(self, user):
|
||||
"""Remove user from server"""
|
||||
try:
|
||||
removed_count = 0
|
||||
subscriptions = user.xray_subscriptions.filter(active=True)
|
||||
|
||||
for subscription in subscriptions:
|
||||
for inbound in subscription.subscription_group.inbounds.all():
|
||||
if self.remove_user_from_inbound(user, inbound):
|
||||
removed_count += 1
|
||||
|
||||
logger.info(f"Removed user {user.username} from {removed_count} inbounds on server {self.name}")
|
||||
return removed_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove user {user.username} from server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return f"Xray Server v2: {self.name}"
|
||||
|
||||
|
||||
class ServerInboundInline(admin.TabularInline):
|
||||
"""Inline for managing inbound templates on a server"""
|
||||
from vpn.models_xray import ServerInbound
|
||||
model = ServerInbound
|
||||
extra = 0
|
||||
fields = ('inbound', 'active')
|
||||
verbose_name = "Inbound Template"
|
||||
verbose_name_plural = "Inbound Templates"
|
||||
|
||||
|
||||
class XrayServerV2Admin(admin.ModelAdmin):
|
||||
list_display = ['name', 'client_hostname', 'api_address', 'api_enabled', 'stats_enabled', 'registration_date']
|
||||
list_filter = ['api_enabled', 'stats_enabled', 'registration_date']
|
||||
search_fields = ['name', 'client_hostname', 'comment']
|
||||
readonly_fields = ['server_type', 'registration_date']
|
||||
inlines = [ServerInboundInline]
|
||||
|
||||
fieldsets = [
|
||||
('Basic Information', {
|
||||
'fields': ('name', 'comment', 'server_type')
|
||||
}),
|
||||
('Connection Settings', {
|
||||
'fields': ('client_hostname', 'api_address')
|
||||
}),
|
||||
('API Settings', {
|
||||
'fields': ('api_enabled', 'stats_enabled')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('registration_date',),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
]
|
||||
|
||||
actions = ['sync_users', 'sync_inbounds', 'get_status']
|
||||
|
||||
def sync_users(self, request, queryset):
|
||||
for server in queryset:
|
||||
server.sync_users()
|
||||
self.message_user(request, f"Scheduled user sync for {queryset.count()} servers")
|
||||
sync_users.short_description = "Sync users for selected servers"
|
||||
|
||||
def sync_inbounds(self, request, queryset):
|
||||
for server in queryset:
|
||||
server.sync_inbounds()
|
||||
self.message_user(request, f"Scheduled inbound sync for {queryset.count()} servers")
|
||||
sync_inbounds.short_description = "Sync inbounds for selected servers"
|
||||
|
||||
def get_status(self, request, queryset):
|
||||
statuses = []
|
||||
for server in queryset:
|
||||
status = server.get_server_status()
|
||||
statuses.append(f"{server.name}: {status.get('accessible', 'Unknown')}")
|
||||
self.message_user(request, f"Server statuses: {', '.join(statuses)}")
|
||||
get_status.short_description = "Check status of selected servers"
|
||||
346
vpn/signals.py
Normal file
346
vpn/signals.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
Django signals for automatic Xray server synchronization
|
||||
"""
|
||||
import logging
|
||||
from django.db.models.signals import post_save, post_delete, m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from django.db import transaction
|
||||
from celery import group
|
||||
|
||||
from .models_xray import (
|
||||
Inbound,
|
||||
SubscriptionGroup,
|
||||
UserSubscription,
|
||||
Certificate,
|
||||
ServerInbound
|
||||
)
|
||||
from .server_plugins.xray_v2 import XrayServerV2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_active_xray_servers():
|
||||
"""Get all active Xray servers"""
|
||||
from .server_plugins import Server
|
||||
return [
|
||||
server.get_real_instance()
|
||||
for server in Server.objects.all()
|
||||
if hasattr(server.get_real_instance(), 'api_enabled') and
|
||||
server.get_real_instance().api_enabled
|
||||
]
|
||||
|
||||
|
||||
def schedule_inbound_sync_for_servers(inbound, servers=None):
|
||||
"""Schedule inbound deployment on servers"""
|
||||
if servers is None:
|
||||
servers = get_active_xray_servers()
|
||||
|
||||
if not servers:
|
||||
logger.warning("No active Xray servers found for inbound sync")
|
||||
return
|
||||
|
||||
logger.info(f"Scheduling inbound {inbound.name} deployment on {len(servers)} servers")
|
||||
|
||||
# Schedule deployment tasks
|
||||
from .tasks import deploy_inbound_on_server
|
||||
tasks = []
|
||||
for server in servers:
|
||||
task = deploy_inbound_on_server.s(server.id, inbound.id)
|
||||
tasks.append(task)
|
||||
|
||||
# Execute all deployments in parallel
|
||||
job = group(tasks)
|
||||
result = job.apply_async()
|
||||
|
||||
logger.info(f"Scheduled inbound deployment tasks: {result}")
|
||||
return result
|
||||
|
||||
|
||||
def schedule_user_sync_for_servers(servers=None):
|
||||
"""Schedule user sync on servers after inbound changes"""
|
||||
if servers is None:
|
||||
servers = get_active_xray_servers()
|
||||
|
||||
if not servers:
|
||||
logger.warning("No active Xray servers found for user sync")
|
||||
return
|
||||
|
||||
logger.info(f"Scheduling user sync on {len(servers)} servers")
|
||||
|
||||
# Schedule user sync tasks
|
||||
from .tasks import sync_server_users
|
||||
tasks = []
|
||||
for server in servers:
|
||||
task = sync_server_users.s(server.id)
|
||||
tasks.append(task)
|
||||
|
||||
# Execute all user syncs in parallel with delay to allow inbound sync to complete
|
||||
job = group(tasks)
|
||||
result = job.apply_async(countdown=10) # 10 second delay
|
||||
|
||||
logger.info(f"Scheduled user sync tasks: {result}")
|
||||
return result
|
||||
|
||||
|
||||
@receiver(post_save, sender=Inbound)
|
||||
def inbound_created_or_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When an inbound is created or updated, deploy it to all servers
|
||||
where subscription groups contain this inbound
|
||||
"""
|
||||
if created:
|
||||
logger.info(f"New inbound {instance.name} created, will deploy when added to groups")
|
||||
else:
|
||||
logger.info(f"Inbound {instance.name} updated, scheduling redeployment")
|
||||
|
||||
# Get all subscription groups that contain this inbound
|
||||
groups = instance.subscriptiongroup_set.filter(is_active=True)
|
||||
|
||||
if groups.exists():
|
||||
# Get all servers that should have this inbound
|
||||
servers = get_active_xray_servers()
|
||||
|
||||
# Schedule redeployment
|
||||
transaction.on_commit(lambda: schedule_inbound_sync_for_servers(instance, servers))
|
||||
|
||||
# Schedule user sync after inbound update
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Inbound)
|
||||
def inbound_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
When an inbound is deleted, remove it from all servers
|
||||
"""
|
||||
logger.info(f"Inbound {instance.name} deleted, scheduling removal from servers")
|
||||
|
||||
# Schedule removal from all servers
|
||||
from .tasks import remove_inbound_from_server
|
||||
servers = get_active_xray_servers()
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
task = remove_inbound_from_server.s(server.id, instance.name)
|
||||
tasks.append(task)
|
||||
|
||||
if tasks:
|
||||
job = group(tasks)
|
||||
transaction.on_commit(lambda: job.apply_async())
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=SubscriptionGroup.inbounds.through)
|
||||
def subscription_group_inbounds_changed(sender, instance, action, pk_set, **kwargs):
|
||||
"""
|
||||
When inbounds are added/removed from subscription groups,
|
||||
automatically deploy/remove them on servers
|
||||
"""
|
||||
if action in ['post_add', 'post_remove']:
|
||||
logger.info(f"Subscription group {instance.name} inbounds changed: {action}")
|
||||
|
||||
if action == 'post_add' and pk_set:
|
||||
# Inbounds were added to the group - deploy them
|
||||
inbounds = Inbound.objects.filter(pk__in=pk_set)
|
||||
servers = get_active_xray_servers()
|
||||
|
||||
for inbound in inbounds:
|
||||
logger.info(f"Deploying inbound {inbound.name} (added to group {instance.name})")
|
||||
transaction.on_commit(
|
||||
lambda inb=inbound: schedule_inbound_sync_for_servers(inb, servers)
|
||||
)
|
||||
|
||||
# Schedule user sync after all inbounds are deployed
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
|
||||
elif action == 'post_remove' and pk_set:
|
||||
# Inbounds were removed from the group
|
||||
inbounds = Inbound.objects.filter(pk__in=pk_set)
|
||||
|
||||
for inbound in inbounds:
|
||||
# Check if inbound is still used by other active groups
|
||||
other_groups = inbound.subscriptiongroup_set.filter(is_active=True).exclude(id=instance.id)
|
||||
|
||||
if not other_groups.exists():
|
||||
# Inbound is not used by any other group - remove from servers
|
||||
logger.info(f"Removing inbound {inbound.name} from servers (no longer in any group)")
|
||||
from .tasks import remove_inbound_from_server
|
||||
servers = get_active_xray_servers()
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
task = remove_inbound_from_server.s(server.id, inbound.name)
|
||||
tasks.append(task)
|
||||
|
||||
if tasks:
|
||||
job = group(tasks)
|
||||
transaction.on_commit(lambda: job.apply_async())
|
||||
|
||||
|
||||
@receiver(post_save, sender=ServerInbound)
|
||||
def server_inbound_created_or_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When ServerInbound is created or updated, immediately deploy the inbound
|
||||
template to the server (not wait for subscription group changes)
|
||||
"""
|
||||
logger.info(f"ServerInbound {instance.inbound.name} {'created' if created else 'updated'} for server {instance.server.name}")
|
||||
|
||||
if instance.active:
|
||||
# Deploy inbound immediately
|
||||
servers = [instance.server.get_real_instance()]
|
||||
transaction.on_commit(
|
||||
lambda: schedule_inbound_sync_for_servers(instance.inbound, servers)
|
||||
)
|
||||
|
||||
# Schedule user sync after inbound deployment
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
else:
|
||||
# Remove inbound from server if deactivated
|
||||
logger.info(f"Removing inbound {instance.inbound.name} from server {instance.server.name} (deactivated)")
|
||||
from .tasks import remove_inbound_from_server
|
||||
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
|
||||
transaction.on_commit(lambda: task.apply_async())
|
||||
|
||||
|
||||
@receiver(post_delete, sender=ServerInbound)
|
||||
def server_inbound_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
When ServerInbound is deleted, remove the inbound from the server
|
||||
"""
|
||||
logger.info(f"ServerInbound {instance.inbound.name} deleted from server {instance.server.name}")
|
||||
|
||||
from .tasks import remove_inbound_from_server
|
||||
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
|
||||
transaction.on_commit(lambda: task.apply_async())
|
||||
|
||||
|
||||
@receiver(post_save, sender=UserSubscription)
|
||||
def user_subscription_created_or_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When user subscription is created or updated, sync the user to servers
|
||||
"""
|
||||
if created:
|
||||
logger.info(f"New subscription created for user {instance.user.username} in group {instance.subscription_group.name}")
|
||||
else:
|
||||
logger.info(f"Subscription updated for user {instance.user.username} in group {instance.subscription_group.name}")
|
||||
|
||||
if instance.active:
|
||||
# Schedule user sync on all servers
|
||||
servers = get_active_xray_servers()
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
|
||||
|
||||
@receiver(post_delete, sender=UserSubscription)
|
||||
def user_subscription_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
When user subscription is deleted, remove user from servers if no other subscriptions
|
||||
"""
|
||||
logger.info(f"Subscription deleted for user {instance.user.username} in group {instance.subscription_group.name}")
|
||||
|
||||
# Check if user has other active subscriptions
|
||||
other_subscriptions = UserSubscription.objects.filter(
|
||||
user=instance.user,
|
||||
active=True
|
||||
).exclude(id=instance.id).exists()
|
||||
|
||||
if not other_subscriptions:
|
||||
# User has no more subscriptions - remove from all servers
|
||||
logger.info(f"User {instance.user.username} has no more subscriptions, removing from servers")
|
||||
from .tasks import remove_user_from_server
|
||||
servers = get_active_xray_servers()
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
task = remove_user_from_server.s(server.id, instance.user.id)
|
||||
tasks.append(task)
|
||||
|
||||
if tasks:
|
||||
job = group(tasks)
|
||||
transaction.on_commit(lambda: job.apply_async())
|
||||
|
||||
|
||||
@receiver(post_save, sender=Certificate)
|
||||
def certificate_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When certificate is updated, redeploy all inbounds that use it
|
||||
"""
|
||||
if not created and instance.certificate_pem: # Only on updates when cert is available
|
||||
logger.info(f"Certificate {instance.domain} updated, redeploying dependent inbounds")
|
||||
|
||||
# Find all inbounds that use this certificate
|
||||
inbounds = Inbound.objects.filter(certificate=instance)
|
||||
servers = get_active_xray_servers()
|
||||
|
||||
for inbound in inbounds:
|
||||
transaction.on_commit(
|
||||
lambda inb=inbound: schedule_inbound_sync_for_servers(inb, servers)
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=SubscriptionGroup)
|
||||
def subscription_group_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When subscription group is created/updated, sync its state
|
||||
"""
|
||||
if created:
|
||||
logger.info(f"New subscription group {instance.name} created")
|
||||
else:
|
||||
logger.info(f"Subscription group {instance.name} updated")
|
||||
|
||||
if not instance.is_active:
|
||||
# Group was deactivated - remove its inbounds from servers if not used elsewhere
|
||||
logger.info(f"Subscription group {instance.name} deactivated, checking inbounds")
|
||||
|
||||
for inbound in instance.inbounds.all():
|
||||
# Check if inbound is used by other active groups
|
||||
other_groups = inbound.subscriptiongroup_set.filter(is_active=True).exclude(id=instance.id)
|
||||
|
||||
if not other_groups.exists():
|
||||
# Remove inbound from servers
|
||||
logger.info(f"Removing inbound {inbound.name} from servers (group deactivated)")
|
||||
from .tasks import remove_inbound_from_server
|
||||
servers = get_active_xray_servers()
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
task = remove_inbound_from_server.s(server.id, inbound.name)
|
||||
tasks.append(task)
|
||||
|
||||
if tasks:
|
||||
job = group(tasks)
|
||||
transaction.on_commit(lambda: job.apply_async())
|
||||
|
||||
|
||||
@receiver(post_save, sender=ServerInbound)
|
||||
def server_inbound_created_or_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When ServerInbound is created or updated, immediately deploy the inbound
|
||||
template to the server (not wait for subscription group changes)
|
||||
"""
|
||||
logger.info(f"ServerInbound {instance.inbound.name} {'created' if created else 'updated'} for server {instance.server.name}")
|
||||
|
||||
if instance.active:
|
||||
# Deploy inbound immediately
|
||||
servers = [instance.server.get_real_instance()]
|
||||
transaction.on_commit(
|
||||
lambda: schedule_inbound_sync_for_servers(instance.inbound, servers)
|
||||
)
|
||||
|
||||
# Schedule user sync after inbound deployment
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
else:
|
||||
# Remove inbound from server if deactivated
|
||||
logger.info(f"Removing inbound {instance.inbound.name} from server {instance.server.name} (deactivated)")
|
||||
from .tasks import remove_inbound_from_server
|
||||
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
|
||||
transaction.on_commit(lambda: task.apply_async())
|
||||
|
||||
|
||||
@receiver(post_delete, sender=ServerInbound)
|
||||
def server_inbound_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
When ServerInbound is deleted, remove the inbound from the server
|
||||
"""
|
||||
logger.info(f"ServerInbound {instance.inbound.name} deleted from server {instance.server.name}")
|
||||
|
||||
from .tasks import remove_inbound_from_server
|
||||
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
|
||||
transaction.on_commit(lambda: task.apply_async())
|
||||
1110
vpn/tasks.py
Normal file
1110
vpn/tasks.py
Normal file
File diff suppressed because it is too large
Load Diff
37
vpn/templates/admin/base_site.html
Normal file
37
vpn/templates/admin/base_site.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||
|
||||
{% block branding %}
|
||||
<h1 id="site-name"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Django administration') }}</a></h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<div id="footer" style="margin-top: 20px; padding: 10px; border-top: 1px solid #ccc; font-size: 12px; color: #666;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<strong>OutFleet VPN Manager</strong>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
{% if VERSION_INFO %}
|
||||
<div>
|
||||
<strong>Version:</strong>
|
||||
<code>{{ VERSION_INFO.git_commit_short }}</code>
|
||||
{% if VERSION_INFO.is_development %}
|
||||
<span style="color: #e74c3c;">(Development)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not VERSION_INFO.is_development %}
|
||||
<div style="margin-top: 2px;">
|
||||
<strong>Built:</strong> {{ VERSION_INFO.build_date }}
|
||||
</div>
|
||||
<div style="margin-top: 2px;">
|
||||
<strong>Commit:</strong>
|
||||
<code style="font-size: 10px;">{{ VERSION_INFO.git_commit }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
499
vpn/templates/admin/move_clients.html
Normal file
499
vpn/templates/admin/move_clients.html
Normal file
@@ -0,0 +1,499 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content-main">
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<div class="alert alert-info" style="margin: 10px 0; padding: 10px; border-radius: 4px; background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;">
|
||||
<strong>Note:</strong> This operation only affects the database and works even if servers are unreachable.
|
||||
Server connectivity is not required for moving links between servers.
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}" style="margin: 10px 0; padding: 10px; border-radius: 4px;
|
||||
{% if message.tags == 'error' %}background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;
|
||||
{% elif message.tags == 'success' %}background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724;
|
||||
{% elif message.tags == 'warning' %}background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404;
|
||||
{% elif message.tags == 'info' %}background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;
|
||||
{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" id="move-clients-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-row" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; gap: 20px;">
|
||||
<div style="flex: 1;">
|
||||
<label for="source_server"><strong>Source Server:</strong></label>
|
||||
<select id="source_server" name="source_server" style="width: 100%; padding: 5px;" onchange="updateLinksList()">
|
||||
<option value="">-- Select Source Server --</option>
|
||||
{% for server in servers %}
|
||||
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1;">
|
||||
<label for="target_server"><strong>Target Server:</strong></label>
|
||||
<select id="target_server" name="target_server" style="width: 100%; padding: 5px;" onchange="updateSubmitButton()">
|
||||
<option value="">-- Select Target Server --</option>
|
||||
{% for server in all_servers %}
|
||||
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="links-list" style="margin-bottom: 20px;">
|
||||
<h3>Select Client Links to Move:</h3>
|
||||
<div id="links-container">
|
||||
<p style="color: #666;">Please select a source server first.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-bottom: 20px;">
|
||||
<h3>Comment Transformation (Optional)</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="comment_regex"><strong>Regular Expression Pattern:</strong></label>
|
||||
<input type="text" id="comment_regex" name="comment_regex"
|
||||
style="width: 100%; padding: 8px; font-family: monospace;"
|
||||
placeholder="Example: ^(.*)$ -> [OLD_SERVER] $1"
|
||||
title="Use regex pattern -> replacement format">
|
||||
</div>
|
||||
|
||||
<div class="regex-help" style="background-color: #f8f9fa; padding: 10px; border-radius: 5px; border-left: 4px solid #007cba;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #007cba; font-size: 14px;">Regular Expression Help & Examples:</h4>
|
||||
<div style="font-size: 13px;">
|
||||
<p style="margin: 0 0 8px 0;"><strong>Format:</strong> <code>pattern -> replacement</code></p>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<h5 style="margin: 0 0 5px 0; font-size: 13px;">Common Examples:</h5>
|
||||
<ul style="margin: 0; padding-left: 15px; line-height: 1.4;">
|
||||
<li><code>^(.*)$ -> [FROM RU] $1</code> <small>- Add prefix to all comments</small></li>
|
||||
<li><code>^(.*)$ -> $1 (moved)</code> <small>- Add suffix to all comments</small></li>
|
||||
<li><code>^$ -> Default Device</code> <small>- Replace empty comments</small></li>
|
||||
<li><code>phone -> mobile</code> <small>- Replace specific word</small></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<h5 style="margin: 0 0 5px 0; font-size: 13px;">Advanced Examples:</h5>
|
||||
<ul style="margin: 0; padding-left: 15px; line-height: 1.4;">
|
||||
<li><code>^(.+) - (.+)$ -> $2: $1</code> <small>- Swap parts separated by " - "</small></li>
|
||||
<li><code>(\d{4})-(\d{2})-(\d{2}) -> $3/$2/$1</code> <small>- Change date format</small></li>
|
||||
<li><code>^(.{1,20}).*$ -> $1...</code> <small>- Truncate long comments</small></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="padding: 8px; background-color: #fff3cd; border-radius: 3px; margin: 0;">
|
||||
<strong style="font-size: 12px;">Tips:</strong>
|
||||
<ul style="margin: 3px 0 0 0; padding-left: 15px; font-size: 12px; line-height: 1.3;">
|
||||
<li>Use <code>$1, $2, $3...</code> for captured groups (auto-converted to Python)</li>
|
||||
<li>Use <code>^</code> for start, <code>$</code> for end of string</li>
|
||||
<li>Preview shows result; leave empty to keep original comments</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="Move Selected Links" class="default" id="submit-btn" disabled>
|
||||
<a href="{% url 'admin:vpn_server_changelist' %}" class="button cancel">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Client links data for each server
|
||||
var linksByServer = {
|
||||
{% for server, links in links_by_server.items %}
|
||||
"{{ server.id }}": [
|
||||
{% for link in links %}
|
||||
{
|
||||
"id": {{ link.id }},
|
||||
"link": "{{ link.link|escapejs }}",
|
||||
"comment": "{{ link.comment|escapejs }}",
|
||||
"username": "{{ link.acl.user.username|escapejs }}",
|
||||
"user_comment": "{{ link.acl.user.comment|escapejs }}",
|
||||
"created_at": "{{ link.acl.created_at|date:'Y-m-d H:i' }}",
|
||||
"full_url": "{{ EXTERNAL_ADDRESS }}/ss/{{ link.link }}#{{ link.acl.server.name|escapejs }}"
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
function updateLinksList() {
|
||||
var sourceServerId = document.getElementById('source_server').value;
|
||||
var linksContainer = document.getElementById('links-container');
|
||||
var submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
if (!sourceServerId) {
|
||||
linksContainer.innerHTML = '<p style="color: #666;">Please select a source server first.</p>';
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var links = linksByServer[sourceServerId] || [];
|
||||
|
||||
if (links.length === 0) {
|
||||
linksContainer.innerHTML = '<p style="color: #666;">No client links found for this server.</p>';
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group links by user for better organization
|
||||
var linksByUser = {};
|
||||
links.forEach(function(link) {
|
||||
if (!linksByUser[link.username]) {
|
||||
linksByUser[link.username] = {
|
||||
user_comment: link.user_comment,
|
||||
created_at: link.created_at,
|
||||
links: []
|
||||
};
|
||||
}
|
||||
linksByUser[link.username].links.push(link);
|
||||
});
|
||||
|
||||
var html = '<div style="max-height: 500px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">';
|
||||
html += '<div style="margin-bottom: 10px;">';
|
||||
html += '<button type="button" onclick="toggleAllLinks()" style="padding: 5px 10px; margin-right: 10px;">Select All</button>';
|
||||
html += '<button type="button" onclick="toggleAllLinks(false)" style="padding: 5px 10px;">Deselect All</button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<table style="width: 100%; border-collapse: collapse;">';
|
||||
html += '<thead><tr style="background-color: #f5f5f5;">';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd; width: 50px;">Select</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">Username</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">Link Comment</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">Link ID</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">User Comment</th>';
|
||||
html += '<th style="padding: 8px; border: 1px solid #ddd;">Created</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
// Sort users alphabetically
|
||||
var sortedUsers = Object.keys(linksByUser).sort();
|
||||
|
||||
sortedUsers.forEach(function(username) {
|
||||
var userData = linksByUser[username];
|
||||
var userLinks = userData.links;
|
||||
|
||||
// Add user row header if user has multiple links
|
||||
if (userLinks.length > 1) {
|
||||
html += '<tr style="background-color: #f9f9f9;">';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">';
|
||||
html += '<input type="checkbox" class="user-checkbox" data-username="' + username + '" onchange="toggleUserLinks(\'' + username + '\')">';
|
||||
html += '</td>';
|
||||
html += '<td colspan="5" style="padding: 8px; border: 1px solid #ddd;"><strong>' + username + '</strong> (' + userLinks.length + ' links)</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
// Add individual link rows
|
||||
userLinks.forEach(function(link) {
|
||||
html += '<tr class="link-row" data-username="' + username + '">';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">';
|
||||
html += '<input type="checkbox" name="selected_links" value="' + link.id + '" class="link-checkbox" data-username="' + username + '" onchange="updateSubmitButton(); updateUserCheckbox(\'' + username + '\')">';
|
||||
html += '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + (userLinks.length === 1 ? '<strong>' + username + '</strong>' : '↳') + '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + (link.comment || '<em>No comment</em>') + '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd; font-family: monospace; font-size: 12px;">' + link.link + '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + (userData.user_comment || '') + '</td>';
|
||||
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + userData.created_at + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
linksContainer.innerHTML = html;
|
||||
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function toggleAllLinks(selectAll = true) {
|
||||
var checkboxes = document.getElementsByName('selected_links');
|
||||
var userCheckboxes = document.getElementsByClassName('user-checkbox');
|
||||
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
checkboxes[i].checked = selectAll;
|
||||
}
|
||||
|
||||
for (var i = 0; i < userCheckboxes.length; i++) {
|
||||
userCheckboxes[i].checked = selectAll;
|
||||
}
|
||||
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function toggleUserLinks(username) {
|
||||
var userCheckbox = document.querySelector('.user-checkbox[data-username="' + username + '"]');
|
||||
var userLinkCheckboxes = document.querySelectorAll('.link-checkbox[data-username="' + username + '"]');
|
||||
|
||||
for (var i = 0; i < userLinkCheckboxes.length; i++) {
|
||||
userLinkCheckboxes[i].checked = userCheckbox.checked;
|
||||
}
|
||||
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function updateUserCheckbox(username) {
|
||||
var userLinkCheckboxes = document.querySelectorAll('.link-checkbox[data-username="' + username + '"]');
|
||||
var userCheckbox = document.querySelector('.user-checkbox[data-username="' + username + '"]');
|
||||
|
||||
if (!userCheckbox) return; // No user checkbox for single-link users
|
||||
|
||||
var allChecked = true;
|
||||
var noneChecked = true;
|
||||
|
||||
for (var i = 0; i < userLinkCheckboxes.length; i++) {
|
||||
if (userLinkCheckboxes[i].checked) {
|
||||
noneChecked = false;
|
||||
} else {
|
||||
allChecked = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allChecked) {
|
||||
userCheckbox.checked = true;
|
||||
userCheckbox.indeterminate = false;
|
||||
} else if (noneChecked) {
|
||||
userCheckbox.checked = false;
|
||||
userCheckbox.indeterminate = false;
|
||||
} else {
|
||||
userCheckbox.checked = false;
|
||||
userCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSubmitButton() {
|
||||
var checkboxes = document.getElementsByName('selected_links');
|
||||
var sourceServer = document.getElementById('source_server').value;
|
||||
var targetServer = document.getElementById('target_server').value;
|
||||
var submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
var hasSelected = false;
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
if (checkboxes[i].checked) {
|
||||
hasSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.disabled = !(hasSelected && sourceServer && targetServer && sourceServer !== targetServer);
|
||||
}
|
||||
|
||||
// Form submission confirmation
|
||||
document.getElementById('move-clients-form').addEventListener('submit', function(e) {
|
||||
var checkboxes = document.getElementsByName('selected_links');
|
||||
var selectedCount = 0;
|
||||
var selectedUsers = new Set();
|
||||
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
if (checkboxes[i].checked) {
|
||||
selectedCount++;
|
||||
selectedUsers.add(checkboxes[i].getAttribute('data-username'));
|
||||
}
|
||||
}
|
||||
|
||||
var sourceServerName = document.getElementById('source_server').selectedOptions[0].text;
|
||||
var targetServerName = document.getElementById('target_server').selectedOptions[0].text;
|
||||
var commentRegex = document.getElementById('comment_regex').value.trim();
|
||||
|
||||
var confirmMessage = 'Are you sure you want to move ' + selectedCount + ' link(s) for ' + selectedUsers.size + ' user(s) from "' + sourceServerName + '" to "' + targetServerName + '"?\n\n';
|
||||
confirmMessage += 'This action will:\n';
|
||||
confirmMessage += '- Transfer selected links to target server\n';
|
||||
confirmMessage += '- Create ACLs for users who don\'t have access to target server\n';
|
||||
confirmMessage += '- Remove empty ACLs from source server\n';
|
||||
confirmMessage += '- Preserve all link settings and comments\n';
|
||||
|
||||
if (commentRegex) {
|
||||
confirmMessage += '- Apply regex transformation to comments: "' + commentRegex + '"\n';
|
||||
}
|
||||
|
||||
confirmMessage += '\nThis cannot be undone.';
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Add regex preview functionality
|
||||
document.getElementById('comment_regex').addEventListener('input', function() {
|
||||
updateRegexPreview();
|
||||
});
|
||||
|
||||
function updateRegexPreview() {
|
||||
var regexInput = document.getElementById('comment_regex').value.trim();
|
||||
|
||||
// Remove any existing preview
|
||||
var existingPreview = document.getElementById('regex-preview');
|
||||
if (existingPreview) {
|
||||
existingPreview.remove();
|
||||
}
|
||||
|
||||
if (!regexInput) return;
|
||||
|
||||
// Parse pattern -> replacement format
|
||||
var parts = regexInput.split(' -> ');
|
||||
if (parts.length !== 2) {
|
||||
showRegexError('Invalid format. Use: pattern -> replacement');
|
||||
return;
|
||||
}
|
||||
|
||||
var pattern = parts[0];
|
||||
var replacement = parts[1];
|
||||
|
||||
try {
|
||||
var regex = new RegExp(pattern, 'g');
|
||||
|
||||
// Test with sample comments from currently visible links
|
||||
var sampleComments = getSampleComments();
|
||||
if (sampleComments.length > 0) {
|
||||
showRegexPreview(sampleComments, regex, replacement);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
showRegexError('Invalid regex pattern: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getSampleComments() {
|
||||
var comments = [];
|
||||
var checkboxes = document.getElementsByName('selected_links');
|
||||
|
||||
// Collect actual comments from visible links
|
||||
for (var i = 0; i < checkboxes.length && comments.length < 5; i++) {
|
||||
var row = checkboxes[i].closest('tr');
|
||||
if (row) {
|
||||
var commentCell = row.children[2]; // Link Comment column
|
||||
if (commentCell) {
|
||||
var commentText = commentCell.textContent.trim();
|
||||
if (commentText && commentText !== 'No comment') {
|
||||
comments.push(commentText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add some realistic default samples if no comments found or need more samples
|
||||
var defaultSamples = ['iPhone 13'];
|
||||
for (var i = 0; i < defaultSamples.length && comments.length < 5; i++) {
|
||||
if (comments.indexOf(defaultSamples[i]) === -1) {
|
||||
comments.push(defaultSamples[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return comments.slice(0, 5); // Limit to 5 samples
|
||||
}
|
||||
|
||||
function showRegexPreview(samples, regex, replacement) {
|
||||
var previewHtml = '<div id="regex-preview" style="margin-top: 10px; padding: 10px; background-color: #e8f5e8; border-radius: 3px; border-left: 4px solid #28a745;">';
|
||||
previewHtml += '<h5 style="margin-top: 0; color: #28a745;">Preview (first 5 samples):</h5>';
|
||||
previewHtml += '<table style="width: 100%; font-size: 13px;">';
|
||||
previewHtml += '<tr><th style="text-align: left; padding: 5px;">Original</th><th style="text-align: left; padding: 5px;">→</th><th style="text-align: left; padding: 5px;">Transformed</th></tr>';
|
||||
|
||||
samples.forEach(function(comment) {
|
||||
var original = comment || '(empty)';
|
||||
var transformed;
|
||||
|
||||
try {
|
||||
// Use string replace with regex and replacement string
|
||||
transformed = original.replace(regex, replacement);
|
||||
} catch (e) {
|
||||
transformed = '(error: ' + e.message + ')';
|
||||
}
|
||||
|
||||
var changed = original !== transformed;
|
||||
|
||||
previewHtml += '<tr>';
|
||||
previewHtml += '<td style="padding: 3px 5px; font-family: monospace;">' + escapeHtml(original) + '</td>';
|
||||
previewHtml += '<td style="padding: 3px 5px;">→</td>';
|
||||
previewHtml += '<td style="padding: 3px 5px; font-family: monospace;' + (changed ? ' font-weight: bold; color: #28a745;' : '') + '">' + escapeHtml(transformed) + '</td>';
|
||||
previewHtml += '</tr>';
|
||||
});
|
||||
|
||||
previewHtml += '</table></div>';
|
||||
|
||||
document.querySelector('.regex-help').insertAdjacentHTML('afterend', previewHtml);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showRegexError(message) {
|
||||
var errorHtml = '<div id="regex-preview" style="margin-top: 10px; padding: 10px; background-color: #f8d7da; border-radius: 3px; border-left: 4px solid #dc3545;">';
|
||||
errorHtml += '<h5 style="margin-top: 0; color: #dc3545;">Error:</h5>';
|
||||
errorHtml += '<p style="margin: 0; color: #721c24;">' + message + '</p>';
|
||||
errorHtml += '</div>';
|
||||
|
||||
document.querySelector('.regex-help').insertAdjacentHTML('afterend', errorHtml);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.alert {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.submit-row {
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 20px;
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.submit-row input[type="submit"] {
|
||||
background: #417690;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.submit-row input[type="submit"]:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.submit-row .cancel {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.submit-row .cancel:hover {
|
||||
background: #5a6268;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.link-row:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:indeterminate {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
10
vpn/templates/admin/polls/user/change_form.html
Normal file
10
vpn/templates/admin/polls/user/change_form.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block after_field_sets %}
|
||||
<div>
|
||||
<h2>Create ACLs</h2>
|
||||
{{ adminform.form.servers }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
93
vpn/templates/admin/simple_move_clients.html
Normal file
93
vpn/templates/admin/simple_move_clients.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n admin_urls static admin_list %}
|
||||
|
||||
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/vpn_admin.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url 'admin:vpn_server_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› {{ title }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<div class="module aligned">
|
||||
<div class="form-row">
|
||||
<div class="form-row field-box">
|
||||
<label>Source Server:</label>
|
||||
<div class="readonly"><strong>{{ source_server.name }}</strong> ({{ source_server.server_type }})</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row field-box">
|
||||
<label>Statistics:</label>
|
||||
<div class="readonly">
|
||||
<strong>{{ links_count }}</strong> client link(s) for <strong>{{ users_count }}</strong> user(s)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if links_count == 0 %}
|
||||
<div class="messagelist">
|
||||
<div class="warning">No client links found on this server.</div>
|
||||
</div>
|
||||
<div class="submit-row">
|
||||
<a href="{% url 'admin:vpn_server_changelist' %}" class="default">« Back to server list</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" id="move-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<fieldset class="module aligned">
|
||||
<h2>Move Options</h2>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label for="target_server" class="required">Target Server:</label>
|
||||
<select id="target_server" name="target_server" class="vLargeTextField" required>
|
||||
<option value="">-- Select target server --</option>
|
||||
{% for server in all_servers %}
|
||||
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label for="add_prefix">Add prefix to comments (optional):</label>
|
||||
<input type="text" id="add_prefix" name="add_prefix" class="vTextField"
|
||||
placeholder="e.g. [FROM {{ source_server.name }}]">
|
||||
<p class="help">This prefix will be added to all client link comments</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="Move All Client Links" class="default"
|
||||
onclick="return confirm('Are you sure you want to move ALL {{ links_count }} client link(s) from {{ source_server.name }} to the selected target server?\\n\\nThis action cannot be undone.');">
|
||||
<a href="{% url 'admin:vpn_server_changelist' %}" class="button cancel">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="help">
|
||||
<h3>What will happen:</h3>
|
||||
<ul>
|
||||
<li>All {{ links_count }} client links will be moved from <strong>{{ source_server.name }}</strong> to the target server</li>
|
||||
<li>Users who don't have access to the target server will get new ACL entries created automatically</li>
|
||||
<li>Empty ACL entries on the source server will be cleaned up</li>
|
||||
<li>All link settings and comments will be preserved (with optional prefix)</li>
|
||||
<li>This operation is database-only and doesn't require server connectivity</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
47
vpn/templates/admin/vpn/acllink/change_list.html
Normal file
47
vpn/templates/admin/vpn/acllink/change_list.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1 class="h4 m-0 pr-3 mr-3 border-right">ACL Links & User Statistics</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Compact Statistics Panel -->
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 0 0 16px 0;">
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: center;">
|
||||
<!-- Key Metrics -->
|
||||
<div style="background: #3b82f6; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold;">{{ total_links|default:0 }}</div>
|
||||
<div style="font-size: 11px; opacity: 0.9;">Links</div>
|
||||
</div>
|
||||
<div style="background: #10b981; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold;">{{ total_uses|default:0|floatformat:0 }}</div>
|
||||
<div style="font-size: 11px; opacity: 0.9;">Total Uses</div>
|
||||
</div>
|
||||
<div style="background: #8b5cf6; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold;">{{ recent_uses|default:0|floatformat:0 }}</div>
|
||||
<div style="font-size: 11px; opacity: 0.9;">Recent (30d)</div>
|
||||
</div>
|
||||
|
||||
<!-- Status indicators -->
|
||||
<div style="margin-left: 20px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
{% if never_accessed > 0 %}
|
||||
<span style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">❌ {{ never_accessed }} never used</span>
|
||||
{% endif %}
|
||||
{% if old_links > 0 %}
|
||||
<span style="background: #f97316; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">⚠️ {{ old_links }} old</span>
|
||||
{% endif %}
|
||||
<span style="background: #059669; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">✅ {{ active_links|default:0 }} active</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div style="margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<a href="?last_access_status=never" style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">🔍 Never Used</a>
|
||||
<a href="?last_access_status=old" style="background: #f97316; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">⏰ Old</a>
|
||||
<a href="?last_access_status=week" style="background: #10b981; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">📅 Recent</a>
|
||||
<a href="?" style="background: #6b7280; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">🔄 Clear</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
176
vpn/templates/admin/vpn/outlineserver/add_form.html.backup
Normal file
176
vpn/templates/admin/vpn/outlineserver/add_form.html.backup
Normal file
@@ -0,0 +1,176 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load i18n admin_urls static %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add JSON Import tab
|
||||
const tabList = document.getElementById('jazzy-tabs');
|
||||
const tabContent = document.querySelector('.tab-content');
|
||||
|
||||
if (tabList && tabContent) {
|
||||
// Add new tab
|
||||
const newTab = document.createElement('li');
|
||||
newTab.className = 'nav-item';
|
||||
newTab.innerHTML = `
|
||||
<a class="nav-link" data-toggle="pill" role="tab" aria-controls="json-import-tab" aria-selected="false" href="#json-import-tab">
|
||||
📥 JSON Import
|
||||
</a>
|
||||
`;
|
||||
tabList.insertBefore(newTab, tabList.firstChild);
|
||||
|
||||
// Add tab content
|
||||
const newTabContent = document.createElement('div');
|
||||
newTabContent.id = 'json-import-tab';
|
||||
newTabContent.className = 'tab-pane fade';
|
||||
newTabContent.setAttribute('role', 'tabpanel');
|
||||
newTabContent.setAttribute('aria-labelledby', 'json-import-tab');
|
||||
newTabContent.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="p-5 card-body">
|
||||
<h4 style="color: #007cba; margin-bottom: 1rem;">📥 Quick Import from JSON</h4>
|
||||
<p style="font-size: 0.875rem; color: #6c757d; margin-bottom: 1rem;">
|
||||
Paste the JSON configuration from your Outline server setup to automatically fill the fields:
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="import-json-config">JSON Configuration:</label>
|
||||
<textarea id="import-json-config" class="form-control" rows="8"
|
||||
placeholder='{
|
||||
"apiUrl": "https://your-server:port/path",
|
||||
"certSha256": "your-certificate-hash",
|
||||
"serverName": "My Outline Server",
|
||||
"clientHostname": "your-server.com",
|
||||
"clientPort": 1257,
|
||||
"comment": "Server description"
|
||||
}' style="font-family: 'Courier New', monospace; font-size: 0.875rem;"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="button" id="import-json-btn" class="btn btn-primary">
|
||||
Import Configuration
|
||||
</button>
|
||||
|
||||
<div style="margin-top: 1rem; padding: 0.75rem; background: #e7f3ff; border-left: 4px solid #007cba; border-radius: 4px;">
|
||||
<strong>Required fields:</strong>
|
||||
<ul style="margin: 0.5rem 0; padding-left: 20px;">
|
||||
<li><code>apiUrl</code> - Server management URL</li>
|
||||
<li><code>certSha256</code> - Certificate fingerprint</li>
|
||||
</ul>
|
||||
<strong>Optional fields:</strong>
|
||||
<ul style="margin: 0.5rem 0; padding-left: 20px;">
|
||||
<li><code>serverName</code> - Display name</li>
|
||||
<li><code>clientHostname</code> - Client hostname</li>
|
||||
<li><code>clientPort</code> - Client port</li>
|
||||
<li><code>comment</code> - Description</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
tabContent.insertBefore(newTabContent, tabContent.firstChild);
|
||||
|
||||
// Make first tab (JSON Import) active
|
||||
document.querySelector('#jazzy-tabs .nav-link').classList.remove('active');
|
||||
newTab.querySelector('.nav-link').classList.add('active');
|
||||
document.querySelector('.tab-pane.active').classList.remove('active', 'show');
|
||||
newTabContent.classList.add('active', 'show');
|
||||
}
|
||||
|
||||
// Import functionality
|
||||
function tryAutoFillFromJson() {
|
||||
const importJsonTextarea = document.getElementById('import-json-config');
|
||||
|
||||
try {
|
||||
const jsonText = importJsonTextarea.value.trim();
|
||||
if (!jsonText) {
|
||||
alert('Please enter JSON configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(jsonText);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.apiUrl || !config.certSha256) {
|
||||
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse apiUrl to extract components
|
||||
const url = new URL(config.apiUrl);
|
||||
|
||||
// Fill form fields
|
||||
const adminUrlField = document.getElementById('id_admin_url');
|
||||
const adminCertField = document.getElementById('id_admin_access_cert');
|
||||
const clientHostnameField = document.getElementById('id_client_hostname');
|
||||
const clientPortField = document.getElementById('id_client_port');
|
||||
const nameField = document.getElementById('id_name');
|
||||
const commentField = document.getElementById('id_comment');
|
||||
|
||||
if (adminUrlField) adminUrlField.value = config.apiUrl;
|
||||
if (adminCertField) adminCertField.value = config.certSha256;
|
||||
|
||||
// Use provided hostname or extract from URL
|
||||
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
|
||||
if (clientHostnameField) clientHostnameField.value = hostname;
|
||||
|
||||
// Use provided port or extract from various sources
|
||||
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
|
||||
if (clientPortField) clientPortField.value = clientPort;
|
||||
|
||||
// Generate server name if not provided and field is empty
|
||||
if (nameField && !nameField.value) {
|
||||
const serverName = config.serverName || config.name || `Outline-${hostname}`;
|
||||
nameField.value = serverName;
|
||||
}
|
||||
|
||||
// Fill comment if provided and field exists
|
||||
if (commentField && config.comment) {
|
||||
commentField.value = config.comment;
|
||||
}
|
||||
|
||||
// Clear the JSON input
|
||||
importJsonTextarea.value = '';
|
||||
|
||||
// Show success message
|
||||
alert('✅ Configuration imported successfully! Review the fields and save.');
|
||||
|
||||
// Switch to Server Configuration tab
|
||||
const serverConfigTab = document.querySelector('a[href="#server-configuration-tab"]');
|
||||
if (serverConfigTab) {
|
||||
serverConfigTab.click();
|
||||
}
|
||||
|
||||
// Focus on name field
|
||||
if (nameField) {
|
||||
setTimeout(() => {
|
||||
nameField.focus();
|
||||
nameField.select();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
alert(`Invalid JSON format: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit for DOM to be ready, then add event listeners
|
||||
setTimeout(() => {
|
||||
const importBtn = document.getElementById('import-json-btn');
|
||||
const importTextarea = document.getElementById('import-json-config');
|
||||
|
||||
if (importBtn) {
|
||||
importBtn.addEventListener('click', tryAutoFillFromJson);
|
||||
}
|
||||
|
||||
if (importTextarea) {
|
||||
importTextarea.addEventListener('paste', function(e) {
|
||||
setTimeout(() => {
|
||||
tryAutoFillFromJson();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
23
vpn/templates/admin/vpn/outlineserver/change_form.html
Normal file
23
vpn/templates/admin/vpn/outlineserver/change_form.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1 class="h4 m-0 pr-3 mr-3 border-right">
|
||||
{% if original %}
|
||||
🔵 Outline Server: {{ original.name }}
|
||||
{% else %}
|
||||
🔵 Add Outline Server
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_change_form_document_ready %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
// All JavaScript functionality is now handled by generate_link.js
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block field_sets %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
11
vpn/templates/admin/vpn/server/change_list.html.backup
Normal file
11
vpn/templates/admin/vpn/server/change_list.html.backup
Normal file
@@ -0,0 +1,11 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load admin_list admin_urls %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1>{{ cl.opts.verbose_name_plural|capfirst }}</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% comment %}
|
||||
This template overrides the default changelist to provide a cleaner interface
|
||||
without any bulk operations blocks that might be added by external packages
|
||||
{% endcomment %}
|
||||
20
vpn/templates/admin/vpn/subscriptiongroup/change_form.html
Normal file
20
vpn/templates/admin/vpn/subscriptiongroup/change_form.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
|
||||
📋 Subscription Groups
|
||||
</a>
|
||||
<a href="/admin/vpn/usersubscription/"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
|
||||
👥 User Subscriptions
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
20
vpn/templates/admin/vpn/subscriptiongroup/change_list.html
Normal file
20
vpn/templates/admin/vpn/subscriptiongroup/change_list.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if current_tab == 'subscription_groups' %}#417690{% else %}transparent{% endif %}; color: {% if current_tab == 'subscription_groups' %}#417690{% else %}#666{% endif %};">
|
||||
📋 Subscription Groups
|
||||
</a>
|
||||
<a href="/admin/vpn/usersubscription/"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if current_tab == 'user_subscriptions' %}#417690{% else %}transparent{% endif %}; color: {% if current_tab == 'user_subscriptions' %}#417690{% else %}#666{% endif %};">
|
||||
👥 User Subscriptions
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1 class="h4 m-0 pr-3 mr-3 border-right">Task Execution Logs</h1>
|
||||
{% endblock %}
|
||||
219
vpn/templates/admin/vpn/user/change_form.html
Normal file
219
vpn/templates/admin/vpn/user/change_form.html
Normal file
@@ -0,0 +1,219 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1 class="h4 m-0 pr-3 mr-3 border-right">
|
||||
{% if original %}
|
||||
👤 User: {{ original.username }}
|
||||
{% else %}
|
||||
👤 Add User
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.user-management-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.user-management-section h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #495057;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-section {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-sm-custom {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.2rem;
|
||||
margin: 0 0.1rem;
|
||||
}
|
||||
|
||||
.readonly .user-management-section {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_change_form_document_ready %}
|
||||
{{ block.super }}
|
||||
{% if original %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userId = {{ original.id }};
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
// Show success/error messages in Django admin style
|
||||
function showMessage(message, type = 'success') {
|
||||
const messageClass = type === 'error' ? 'error' : 'success';
|
||||
const messageHtml = `
|
||||
<div class="alert alert-${messageClass} alert-dismissible" style="margin: 1rem 0;">
|
||||
${message}
|
||||
<button type="button" class="close" aria-label="Close" onclick="this.parentElement.remove()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const target = document.querySelector('.card-body') || document.querySelector('.content');
|
||||
if (target) {
|
||||
target.insertAdjacentHTML('afterbegin', messageHtml);
|
||||
setTimeout(() => {
|
||||
const alert = target.querySelector('.alert');
|
||||
if (alert) alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new link functionality
|
||||
document.querySelectorAll('.add-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const serverId = this.dataset.serverId;
|
||||
const serverName = this.dataset.serverName;
|
||||
|
||||
const comment = prompt(`Add comment for new link on ${serverName} (optional):`, '');
|
||||
if (comment === null) return;
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '⏳ Adding...';
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/user/${userId}/add-link/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: `server_id=${serverId}&comment=${encodeURIComponent(comment)}`
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ New link created successfully: ${data.link}`);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
showMessage(`❌ Error: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ Network error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete link functionality
|
||||
document.querySelectorAll('.delete-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const linkId = this.dataset.linkId;
|
||||
const linkName = this.dataset.linkName;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete link ${linkName}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '⏳ Deleting...';
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/user/${userId}/delete-link/${linkId}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ Link ${linkName} deleted successfully`);
|
||||
this.closest('.link-item')?.remove();
|
||||
} else {
|
||||
showMessage(`❌ Error: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ Network error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add server access functionality
|
||||
document.querySelectorAll('.add-server-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const serverId = this.dataset.serverId;
|
||||
const serverName = this.dataset.serverName;
|
||||
|
||||
if (!confirm(`Add access to server ${serverName}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '⏳ Adding...';
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/user/${userId}/add-server-access/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: `server_id=${serverId}`
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ Access to ${serverName} added successfully`);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
showMessage(`❌ Error: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ Network error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/usersubscription/change_form.html
Normal file
18
vpn/templates/admin/vpn/usersubscription/change_form.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
|
||||
📋 Subscription Groups
|
||||
</a>
|
||||
<a href="/admin/vpn/usersubscription/"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
|
||||
👥 User Subscriptions
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/usersubscription/change_list.html
Normal file
18
vpn/templates/admin/vpn/usersubscription/change_list.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
|
||||
📋 Subscription Groups
|
||||
</a>
|
||||
<a href="/admin/vpn/usersubscription/"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
|
||||
👥 User Subscriptions
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
765
vpn/templates/vpn/user_portal.html
Normal file
765
vpn/templates/vpn/user_portal.html
Normal file
@@ -0,0 +1,765 @@
|
||||
<!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_groups }}</span>
|
||||
<span class="stat-label">Subscription Groups</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_inbounds }}</span>
|
||||
<span class="stat-label">Available Inbounds</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_access %}
|
||||
<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;">
|
||||
{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if groups_data %}
|
||||
<div class="servers-grid">
|
||||
{% for group_name, group_data in groups_data.items %}
|
||||
<div class="server-card">
|
||||
<div class="server-header">
|
||||
<div class="server-info">
|
||||
<div class="server-name">{{ group_name }}</div>
|
||||
<div class="server-stats">
|
||||
<span class="connection-count">🔗 {{ group_data.deployed_count }} inbound(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-type">Xray Group</div>
|
||||
</div>
|
||||
|
||||
<div class="server-status">
|
||||
<div class="status-indicator status-online">
|
||||
<div class="status-dot"></div>
|
||||
Active Subscription
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual Subscription Link for this Group -->
|
||||
<div class="links-container">
|
||||
<div class="link-item">
|
||||
<div class="link-header">
|
||||
<div class="link-info">
|
||||
<div class="link-comment">🚀 {{ group_name }} Subscription</div>
|
||||
<div class="link-stats">
|
||||
<span class="last-used">🔗 {{ group_data.deployed_count }} inbound(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-chart">
|
||||
<div class="chart-title">Protocols</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px; align-items: center;">
|
||||
{% for inbound_data in group_data.inbounds %}
|
||||
<div style="background: rgba(74, 222, 128, 0.2); padding: 3px 8px; border-radius: 8px; font-size: 0.7rem; color: #4ade80;">
|
||||
{{ inbound_data.server_name }}: {{ inbound_data.protocol|upper }}:{{ inbound_data.port }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-url">
|
||||
{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-servers">
|
||||
<h3>No Xray Subscriptions Available</h3>
|
||||
<p>You don't have access to any subscription groups yet. Please contact your administrator.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Show old ACL links for backwards compatibility -->
|
||||
{% if has_old_links %}
|
||||
<h2 style="color: #9ca3af; margin: 40px 0 20px 0; text-align: center;">Legacy Shadowsocks Access</h2>
|
||||
<div class="servers-grid">
|
||||
{% for acl_link in acl_links %}
|
||||
<div class="server-card" style="opacity: 0.7;">
|
||||
<div class="server-header">
|
||||
<div class="server-info">
|
||||
<div class="server-name">{{ acl_link.acl.server.name }}</div>
|
||||
<div class="server-stats">
|
||||
<span class="connection-count">📊 Legacy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-type">Shadowsocks</div>
|
||||
</div>
|
||||
|
||||
<div class="server-status">
|
||||
<div class="status-indicator status-online">
|
||||
<div class="status-dot"></div>
|
||||
Legacy Access
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links-container">
|
||||
<div class="link-item">
|
||||
<div class="link-header">
|
||||
<div class="link-info">
|
||||
<div class="link-comment">📱 {{ acl_link.comment }}</div>
|
||||
<div class="link-stats">
|
||||
<span class="last-used">🕒 {{ acl_link.last_access_time|default:"Never used" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-url">
|
||||
{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://github.com/house-of-vanity/OutFleet" target="_blank">OutFleet VPN Manager</a></p>
|
||||
<p>Keep this link secure and don't share it with others</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Copy to clipboard functionality
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
// Visual feedback
|
||||
const event = new CustomEvent('copied');
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Show temporary feedback
|
||||
showCopyFeedback();
|
||||
} catch (err) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
showCopyFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
function showCopyFeedback() {
|
||||
// Create and show a toast notification
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = 'Link copied to clipboard! ✓';
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #4ade80;
|
||||
color: #000;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => document.body.removeChild(toast), 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Add animation styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes barGrow {
|
||||
from { transform: scaleY(0); }
|
||||
to { transform: scaleY(1); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Update page title with username
|
||||
document.title = `VPN Portal - {{ user.username }}`;
|
||||
|
||||
// Add some interactivity on load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize chart bars
|
||||
initializeCharts();
|
||||
|
||||
// Animate cards on load
|
||||
const cards = document.querySelectorAll('.server-card');
|
||||
cards.forEach((card, index) => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
card.style.transition = 'all 0.6s ease';
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'translateY(0)';
|
||||
}, index * 150);
|
||||
});
|
||||
|
||||
// Animate stat numbers
|
||||
const statNumbers = document.querySelectorAll('.stat-number');
|
||||
statNumbers.forEach((stat, index) => {
|
||||
const finalValue = parseInt(stat.textContent);
|
||||
if (finalValue > 0) {
|
||||
stat.textContent = '0';
|
||||
let current = 0;
|
||||
const increment = Math.ceil(finalValue / 20);
|
||||
const timer = setInterval(() => {
|
||||
current += increment;
|
||||
if (current >= finalValue) {
|
||||
stat.textContent = finalValue;
|
||||
clearInterval(timer);
|
||||
} else {
|
||||
stat.textContent = current;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Add pulse animation to connection counts
|
||||
setTimeout(() => {
|
||||
const connectionCounts = document.querySelectorAll('.connection-count, .usage-count, .recent-count');
|
||||
connectionCounts.forEach((count, index) => {
|
||||
setTimeout(() => {
|
||||
count.style.animation = 'pulse 0.6s ease-in-out';
|
||||
}, index * 100);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Animate chart bars
|
||||
setTimeout(() => {
|
||||
const chartBars = document.querySelectorAll('.chart-bar');
|
||||
chartBars.forEach((bar, index) => {
|
||||
setTimeout(() => {
|
||||
bar.style.animation = 'barGrow 0.8s ease-out';
|
||||
}, index * 50);
|
||||
});
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
function initializeCharts() {
|
||||
const charts = document.querySelectorAll('.usage-chart');
|
||||
|
||||
charts.forEach(chart => {
|
||||
const maxValue = parseInt(chart.dataset.max) || 1;
|
||||
const bars = chart.querySelectorAll('.chart-bar');
|
||||
|
||||
bars.forEach(bar => {
|
||||
const height = parseInt(bar.dataset.height) || 0;
|
||||
const maxHeight = parseInt(bar.dataset.max) || 1;
|
||||
|
||||
if (height === 0) {
|
||||
bar.classList.add('zero');
|
||||
bar.style.height = '2px';
|
||||
} else {
|
||||
// Calculate height as percentage of container (30px max)
|
||||
const percentage = Math.max(10, (height / Math.max(maxHeight, 1)) * 100);
|
||||
const pixelHeight = Math.max(3, (percentage / 100) * 28); // 28px max for padding
|
||||
bar.style.height = pixelHeight + 'px';
|
||||
|
||||
// Add tooltip
|
||||
bar.title = `${height} connections`;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
148
vpn/templates/vpn/user_portal_error.html
Normal file
148
vpn/templates/vpn/user_portal_error.html
Normal file
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ error_title }} - VPN Portal</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #0c0c0c 0%, #1a1a1a 100%);
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
max-width: 500px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
animation: fadeInUp 0.6s ease;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
color: #f87171;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #9ca3af;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
background: #4ade80;
|
||||
color: #000;
|
||||
padding: 12px 24px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: #22c55e;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #4ade80;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-card">
|
||||
<div class="error-icon">🚫</div>
|
||||
<h1 class="error-title">{{ error_title }}</h1>
|
||||
<p class="error-message">{{ error_message }}</p>
|
||||
|
||||
<a href="javascript:history.back()" class="back-link">← Go Back</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://github.com/house-of-vanity/OutFleet" target="_blank">OutFleet VPN Manager</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Add some interactivity
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-refresh every 30 seconds for server errors
|
||||
{% if 'Server Error' in error_title %}
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 30000);
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3
vpn/tests.py
Normal file
3
vpn/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user