134 Commits

Author SHA1 Message Date
Ultradesu
777af49ebf Fixed TG messages quotes. Fixed sync tasks loop.
All checks were successful
Docker hub build / docker (push) Successful in 9m33s
2025-09-17 16:34:55 +03:00
Ultradesu
d4042435fe Fixed TG messages quotes. Fixed sync tasks loop. 2025-09-17 13:54:48 +03:00
Ultradesu
f304825836 Fixed TG messages quotes. Fixed sync tasks loop. 2025-09-17 13:34:46 +03:00
Ultradesu
c4057180b9 Fixed TG messages quotes. Fixed sync tasks loop. 2025-09-17 13:20:20 +03:00
Ultradesu
7584e80477 Added tg bot autoconfirm
All checks were successful
Docker hub build / docker (push) Successful in 5m39s
2025-08-15 17:09:31 +03:00
Ultradesu
95e0d08b51 Added tg bot autoconfirm 2025-08-15 16:33:23 +03:00
AB
57cef79748 Approve reworked 2025-08-15 15:37:58 +03:00
Ultradesu
9158e330e5 Added TG bot
All checks were successful
Docker hub build / docker (push) Successful in 7m48s
2025-08-15 05:28:21 +03:00
Ultradesu
14590aaddc Added TG bot 2025-08-15 05:15:13 +03:00
Ultradesu
afd7ad2b28 Added TG bot 2025-08-15 04:53:30 +03:00
Ultradesu
36f9e495b5 Added TG bot 2025-08-15 04:02:22 +03:00
AB from home.homenet
402e4d84fc Fixed sync user tasks .
All checks were successful
Docker hub build / docker (push) Successful in 5m36s
2025-08-08 14:35:19 +03:00
AB from home.homenet
c148bb99dc Fixed multiuser outline and xray . 2025-08-08 14:24:05 +03:00
Alexandr Bogomyakov
dcad41711e Update README.md 2025-08-08 12:46:31 +03:00
AB from home.homenet
05465f9595 Fixed multiuser outline and xray . 2025-08-08 12:41:33 +03:00
AB from home.homenet
4c32679d86 Fixed cert generation .
All checks were successful
Docker hub build / docker (push) Successful in 5m43s
2025-08-08 10:32:14 +03:00
AB from home.homenet
397e05b3cc Fixed cert generation . 2025-08-08 09:08:18 +03:00
AB from home.homenet
99b79c38a0 Fixed xray grps user update . 2025-08-08 08:48:56 +03:00
AB from home.homenet
042ce6bd3f Xray works. 2025-08-08 08:35:47 +03:00
AB from home.homenet
9363bd4db8 Xray works. 2025-08-08 07:47:23 +03:00
AB from home.homenet
2fe59062c9 Xray works. 2025-08-08 07:39:01 +03:00
AB from home.homenet
fe56811b33 Xray works. fixed certs. 2025-08-08 06:50:04 +03:00
AB from home.homenet
787432cbcf Xray works 2025-08-08 05:46:36 +03:00
AB from home.homenet
56b0b160e3 Fixed sub links generation
All checks were successful
Docker hub build / docker (push) Successful in 6m12s
2025-08-05 01:50:11 +03:00
AB from home.homenet
1f7953a74c Fixed sub links generation 2025-08-05 01:40:10 +03:00
AB from home.homenet
ea3d74ccbd Xray init support 2025-08-05 01:23:07 +03:00
Alexandr Bogomiakov
c5a94d17dc Added initial xray plugin support 2025-07-27 20:37:21 +03:00
Ultradesu
17f9f5c045 Improve server page
All checks were successful
Docker hub build / docker (push) Successful in 3m40s
2025-07-21 18:55:59 +03:00
Ultradesu
4f7131ff5a Improve server page 2025-07-21 18:26:29 +03:00
Ultradesu
fa7ec5a87e Improve server page
All checks were successful
Docker hub build / docker (push) Successful in 3m37s
2025-07-21 17:40:03 +03:00
Ultradesu
05d19b88af Improve CI 2025-07-21 17:20:08 +03:00
Ultradesu
a75d55ac9d Added outline server managment page template 2025-07-21 17:15:35 +03:00
Ultradesu
90001a1d1e management command for cleanup old access logs 2025-07-21 15:30:57 +03:00
Ultradesu
9325a94cb2 Merged user statistics and acl manager 2025-07-21 14:40:52 +03:00
Ultradesu
8854aacf88 Fixed migrations 2025-07-21 13:55:49 +03:00
Ultradesu
3f346bc6c6 Added statistics cache 2025-07-21 13:49:43 +03:00
Ultradesu
b4bdffbbe3 Added statistics cache 2025-07-21 13:30:09 +03:00
Ultradesu
f5e5298461 Added statistics cache 2025-07-21 13:23:10 +03:00
Ultradesu
243a6734fd Improve acl model 2025-07-21 12:47:47 +03:00
Ultradesu
df5493bf14 Improve acl model 2025-07-21 12:25:37 +03:00
Ultradesu
ba62e214ce Improve acl model 2025-07-21 12:12:31 +03:00
Ultradesu
664bafe067 Added indexes to logs
All checks were successful
Docker hub build / docker (push) Successful in 3m18s
2025-07-21 04:27:29 +03:00
Ultradesu
6d56eb7eab Added indexes to logs 2025-07-21 04:18:27 +03:00
Ultradesu
47572d64c6 Force sync and purge 2025-07-21 03:48:35 +03:00
Ultradesu
8a521dc12e Force sync and purge 2025-07-21 03:32:37 +03:00
Ultradesu
a938dde77c Fixed UI graph 2025-07-21 02:14:38 +03:00
Ultradesu
7efe87c1d2 Added UI features
All checks were successful
Docker hub build / docker (push) Successful in 5m11s
2025-07-21 00:52:45 +03:00
Ultradesu
67f1c4d147 Fix docker compose 2025-07-20 23:35:14 +03:00
Ultradesu
2d4c862c5e Added User UI 2025-07-20 23:32:56 +03:00
Ultradesu
9bd4896040 Added User UI 2025-07-20 23:04:58 +03:00
Ultradesu
ec869b2974 fixed tasks log 2025-07-20 22:55:07 +03:00
Ultradesu
dc6d170f08 Fixed last release 2025-07-20 22:50:22 +03:00
Ultradesu
42a923799b Fixed last release 2025-07-20 22:30:04 +03:00
Alexandr Bogomyakov
9c8f0463a5 Create SECURITY.md
All checks were successful
Docker hub build / docker (push) Successful in 3m30s
2025-06-27 21:24:42 +03:00
Ultradesu
d57232ac98 Added move clients feature 2025-06-27 17:23:05 +03:00
Ultradesu
664c2b5ec4 Added move clients feature 2025-06-27 17:21:54 +03:00
Ultradesu
281b8270ce Added move clients feature 2025-06-27 17:08:32 +03:00
Ultradesu
10b5e5f86a Added move clients feature 2025-06-27 16:36:02 +03:00
Ultradesu
20e322a2e8 Added move clients feature
All checks were successful
Docker hub build / docker (push) Successful in 9m13s
2025-06-27 16:20:31 +03:00
Ultradesu
e77d13ab4e Added move clients feature 2025-06-27 16:02:13 +03:00
Ultradesu
cb9be75e90 Disable unused menus
All checks were successful
Docker hub build / docker (push) Successful in 12m5s
2025-06-20 11:35:29 +01:00
Ultradesu
8e378cb787 Fixed text search fileds for ACL and Logs. Added version info to footer. 2025-06-20 11:30:56 +01:00
Alexandr Bogomyakov
bf4bc505de Update README.md
All checks were successful
Docker hub build / docker (push) Successful in 4m38s
2025-06-16 14:32:58 +01:00
Alexandr Bogomyakov
c4dc0a1b42 Update README.md 2025-06-16 14:30:02 +01:00
Alexandr Bogomyakov
e22b26b1aa Update Dockerfile
All checks were successful
Docker hub build / docker (push) Successful in 2m51s
2025-03-17 15:09:36 +02:00
Alexandr Bogomyakov
8d8d6bb671 Update Dockerfile
Some checks failed
Docker hub build / docker (push) Failing after 22s
2025-03-17 15:04:59 +02:00
Alexandr Bogomyakov
c6da8ea250 Update Dockerfile
Some checks failed
Docker hub build / docker (push) Failing after 24s
2025-03-17 15:03:39 +02:00
Alexandr Bogomyakov
26a94d0e72 Update Dockerfile
Some checks failed
Docker hub build / docker (push) Failing after 24s
2025-03-17 15:00:52 +02:00
Alexandr Bogomyakov
72f59563f5 Bump python base image
All checks were successful
Docker hub build / docker (push) Successful in 2m54s
2025-03-17 14:42:10 +02:00
Ultradesu
fbf5019c32 Adjust server info
All checks were successful
Docker hub build / docker (push) Successful in 3m4s
2025-03-13 02:36:04 +02:00
Ultradesu
53dcc29dc7 Bump django 2025-03-13 01:54:35 +02:00
Ultradesu
89eee8fe3e Bump django 2025-03-13 01:49:35 +02:00
Ultradesu
7c47a3935a Bump django 2025-03-13 01:44:31 +02:00
Ultradesu
43c86e2075 Fix key ids 2025-03-13 01:43:33 +02:00
Ultradesu
ed8bfe7f06 Fixed client names in outline
Some checks are pending
Docker hub build / docker (push) Waiting to run
2025-03-12 23:18:17 +02:00
Ultradesu
ca463fe5ab Added link generator. Added link to /stat/ json object
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-03-04 17:19:13 +00:00
Alexandr Bogomyakov
8f51b4cf9e Update README.md 2025-03-04 11:40:57 +00:00
Ultradesu
760c1c7647 Bump deps
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-02-25 13:44:07 +02:00
Ultradesu
d1908e879b Added user dashboard 2025-02-25 12:39:08 +02:00
A B
c24c35f443 Fixed content type
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-02-23 21:29:03 +00:00
A B
7527ddfcb9 Fixed content type 2025-02-23 21:22:12 +00:00
A B
d9bf110ba9 Fixed content type 2025-02-23 20:19:26 +00:00
A B
e3682fd121 Added json lib 2025-02-23 19:33:54 +00:00
A B
d02377a270 Added yaml lib 2025-02-23 19:27:53 +00:00
A B
35e3980487 Added yaml lib 2025-02-23 19:25:22 +00:00
A B
f139e0bcc6 Some fix 2025-02-23 19:18:23 +00:00
AB
b22477b3e2 Added comment beside of links
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-01-15 15:34:30 +02:00
ultradesu
2323151242 Fix access log filter
Some checks failed
Docker hub build / docker (push) Has been cancelled
2025-01-10 11:32:30 +00:00
ultradesu
2ca317a9a2 Added ACLLink comments.
Some checks are pending
Docker hub build / docker (push) Waiting to run
2025-01-09 17:24:23 +00:00
Alexandr Bogomyakov
826827f85e Update README.md 2025-01-09 16:47:20 +00:00
Alexandr Bogomiakov
9763a6e48d Added to run local copy 2025-01-09 16:37:09 +00:00
Alexandr Bogomiakov
4fad180ec9 Adjusted access links in admin interface
Some checks are pending
Docker hub build / docker (push) Waiting to run
2025-01-09 08:49:52 +00:00
Alexandr Bogomyakov
7b5f47fa64 Update Dockerfile
Some checks failed
Docker hub build / docker (push) Has been cancelled
2024-11-20 13:35:56 +02:00
A B
a790da0793 Adjust ACLLinks length. Added links generator
Some checks failed
Docker hub build / docker (push) Has been cancelled
2024-11-18 20:34:54 +00:00
A B
a8ddadbe6d Fix autolink creation
Some checks failed
Docker hub build / docker (push) Has been cancelled
2024-10-28 17:32:06 +00:00
A B
6710cf211c Fix user hash editable 2024-10-28 17:21:23 +00:00
A B
0880401cc4 Added access logs. 2024-10-28 17:15:49 +00:00
A B
7cf99af20d Autologin
Some checks are pending
Docker hub build / docker (push) Waiting to run
2024-10-28 00:06:35 +00:00
A B
b6ad6e8578 Trying remote-auth 2024-10-27 23:37:02 +00:00
A B
7585fb94a1 Fix users
Some checks are pending
Docker hub build / docker (push) Waiting to run
2024-10-27 01:18:06 +00:00
A B
d324edec69 Merge vpn.Users with Django Users 2024-10-27 01:06:37 +00:00
A B
dda9b4ba5a Added keys count on outline page. 2024-10-26 23:36:18 +00:00
A B
75126b09ff Fix
Some checks are pending
Docker hub build / docker (push) Waiting to run
2024-10-26 12:38:50 +00:00
A B
a1ff998b68 Fix 2024-10-26 12:22:19 +00:00
A B
c4d9254824 Update task
Some checks failed
Docker hub build / docker (push) Has been cancelled
2024-10-21 20:36:30 +00:00
A B
2c74667945 Fix beat import to dev
Some checks are pending
Docker hub build / docker (push) Waiting to run
2024-10-21 13:26:22 +00:00
A B
538bc2f65e Fixed tasks 2024-10-21 13:22:03 +00:00
ab
bc5f774d9f fix ci 2024-10-20 22:43:31 +00:00
ab
7bf998ece5 Django UI 2024-10-20 21:57:12 +00:00
Alexandr Bogomyakov
9680ce802d Update README.md
Some checks failed
Docker hub build / docker (push) Has been cancelled
2024-09-28 22:04:27 +03:00
Alexandr Bogomyakov
e4fd6ea5d7 Update README.md 2024-09-28 22:03:07 +03:00
Alexandr Bogomyakov
22cca991fc Update README.md 2024-09-28 21:50:23 +03:00
Alexandr Bogomyakov
dd5f0c4e2f Update windows-helper.ps1 2024-09-28 21:37:42 +03:00
Alexandr Bogomyakov
98d993423d Update windows-helper.ps1 2024-09-28 21:19:42 +03:00
Alexandr Bogomyakov
db382f2b27 Rename windows_task.ps1 to tools/windows_task.ps1 2024-09-28 21:19:28 +03:00
Alexandr Bogomyakov
7e08bd465b Create windows-helper.ps1 2024-09-28 21:19:09 +03:00
Alexandr Bogomyakov
f7ce671427 Update windows_task.ps1 2024-09-28 21:17:34 +03:00
Alexandr Bogomyakov
dceb07137a Update windows_task.ps1 2024-09-28 20:44:08 +03:00
Alexandr Bogomyakov
e41febe061 Update windows_task.ps1 2024-09-28 20:42:36 +03:00
Alexandr Bogomyakov
2397a05a08 Update windows_task.ps1 2024-09-28 20:36:27 +03:00
Alexandr Bogomyakov
c940e9f38b Update windows_task.ps1 2024-09-28 20:31:25 +03:00
Alexandr Bogomyakov
315be97354 Update windows_task.ps1 2024-09-28 19:53:01 +03:00
Alexandr Bogomyakov
8a5e1d2d69 Update and rename windows_service.ps1 to windows_task.ps1 2024-09-28 19:32:30 +03:00
Alexandr Bogomyakov
22eb5ec7af Create windows_service.ps1 2024-09-28 18:15:43 +03:00
Alexandr Bogomyakov
3da1d4f5f7 Bump version 2024-06-12 12:21:03 +03:00
Alexandr Bogomyakov
c8dcd4439c Fix logging 2024-06-12 12:20:09 +03:00
Alexandr Bogomyakov
b01d86251c fix logging 2024-06-12 12:08:41 +03:00
AB
58be345610 New UI fix
Co-authored-by: XakPlant <xakplant@users.noreply.github.com>
2024-04-28 15:06:58 +03:00
AB
48521cb8a3 New UI
Co-authored-by: XakPlant <xakplant@users.noreply.github.com>
2024-04-28 13:15:07 +03:00
35f57de110 Reworked dynamic keys link generation. 2024-04-23 19:30:14 +03:00
423c408893 new links 2024-04-19 20:10:31 +03:00
Alexandr Bogomyakov
788797f3ef Merge pull request #10 from Sanapach/master
Outfleet::fix empty config
2024-04-17 20:16:39 +03:00
c9ae1bbbbd Outfleet::fix empty config 2024-04-17 20:15:09 +03:00
5cc32b18af Outfleet::fix empty config 2024-04-17 20:13:10 +03:00
159 changed files with 21465 additions and 1610 deletions

View File

@@ -3,7 +3,7 @@ name: Docker hub build
on:
push:
branches:
- 'master'
- 'django'
jobs:
docker:
@@ -24,13 +24,28 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set outputs
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
run: |
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "sha_full=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
echo "branch_name=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
- name: Check outputs
run: echo ${{ steps.vars.outputs.sha_short }}
run: |
echo "Short SHA: ${{ steps.vars.outputs.sha_short }}"
echo "Full SHA: ${{ steps.vars.outputs.sha_full }}"
echo "Build Date: ${{ steps.vars.outputs.build_date }}"
echo "Branch: ${{ steps.vars.outputs.branch_name }}"
-
name: Build and push
uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ultradesu/outfleet:latest,ultradesu/outfleet:${{ steps.vars.outputs.sha_short }}
cache-from: type=registry,ref=ultradesu/outfleet:buildcache
cache-to: type=registry,ref=ultradesu/outfleet:buildcache,mode=max
build-args: |
GIT_COMMIT=${{ steps.vars.outputs.sha_full }}
GIT_COMMIT_SHORT=${{ steps.vars.outputs.sha_short }}
BUILD_DATE=${{ steps.vars.outputs.build_date }}
BRANCH_NAME=${{ steps.vars.outputs.branch_name }}
tags: ultradesu/outfleet:v2,ultradesu/outfleet:${{ steps.vars.outputs.sha_short }}

26
.gitignore vendored
View File

@@ -1,9 +1,21 @@
config.yaml
__pycache__/
sync.log
main.py
.idea/*
.vscode/*
db.sqlite3
debug.log
*.swp
*.swo
*.swn
*.pyc
staticfiles/
*.__pycache__.*
celerybeat-schedule*
# macOS system files
._*
.DS_Store
# Virtual environments
venv/
.venv/
env/
# Temporary files
/tmp/
*.tmp

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

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

39
Dockerfile Executable file → Normal file
View File

@@ -1,13 +1,40 @@
FROM python:3-alpine
# Build arguments
ARG GIT_COMMIT="development"
ARG GIT_COMMIT_SHORT="dev"
ARG BUILD_DATE="unknown"
ARG BRANCH_NAME="unknown"
# Environment variables from build args
ENV GIT_COMMIT=${GIT_COMMIT}
ENV GIT_COMMIT_SHORT=${GIT_COMMIT_SHORT}
ENV BUILD_DATE=${BUILD_DATE}
ENV BRANCH_NAME=${BRANCH_NAME}
WORKDIR /app
COPY requirements.txt .
COPY static static
COPY templates templates
COPY *.py .
# Install system dependencies first (this layer will be cached)
RUN apk update && apk add git curl unzip
# Copy and install Python dependencies (this layer will be cached when requirements.txt doesn't change)
COPY ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5000
CMD ["python", "main.py"]
# Install Xray-core
RUN XRAY_VERSION=$(curl -s https://api.github.com/repos/XTLS/Xray-core/releases/latest | sed -n 's/.*"tag_name": "\([^"]*\)".*/\1/p') && \
curl -L -o /tmp/xray.zip "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-64.zip" && \
cd /tmp && unzip xray.zip && \
ls -la /tmp/ && \
find /tmp -name "xray" -type f && \
cp xray /usr/local/bin/xray && \
chmod +x /usr/local/bin/xray && \
rm -rf /tmp/xray.zip /tmp/xray
# Copy the rest of the application code (this layer will change frequently)
COPY . .
# Run collectstatic
RUN python manage.py collectstatic --noinput
CMD [ "python", "./manage.py", "runserver", "0.0.0.0:8000" ]

View File

@@ -2,7 +2,7 @@
<h1 align="center">OutFleet: Master Your OutLine VPN</h1>
<p align="center">
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers and always-updated Dynamic Access Keys instead of ss:// links
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers, users and always-updated Dynamic Access Keys instead of ss:// links
<br/>
<br/>
<a href="https://github.com/house-of-vanity/outfleet/issues">Request Feature</a>
@@ -11,9 +11,9 @@
![Forks](https://img.shields.io/github/forks/house-of-vanity/outfleet?style=social) ![Stargazers](https://img.shields.io/github/stars/house-of-vanity/outfleet?style=social) ![License](https://img.shields.io/github/license/house-of-vanity/outfleet)
## About The Project
<img width="1282" height="840" alt="image" src="https://github.com/user-attachments/assets/3b66f928-853b-4af0-8968-1eacb2c16a1c" />
![Screen Shot](img/servers.png)
## About The Project
### Key Features
@@ -28,60 +28,27 @@ 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

21
SECURITY.md Normal file
View File

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

15
cleanup_analysis.sql Normal file
View File

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

35
cleanup_options.sql Normal file
View File

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

102
docker-compose.yaml Normal file
View File

@@ -0,0 +1,102 @@
services:
web_ui:
image: outfleet:local
container_name: outfleet-web
build:
context: .
ports:
- "8000:8000"
environment:
- POSTGRES_HOST=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- EXTERNAL_ADDRESS=http://127.0.0.1:8000
- CELERY_BROKER_URL=redis://redis:6379/0
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- .:/app
working_dir: /app
command: >
sh -c "sleep 1 &&
python manage.py makemigrations &&
python manage.py migrate &&
python manage.py create_admin &&
python manage.py runserver 0.0.0.0:8000"
worker:
image: outfleet:local
container_name: outfleet-worker
build:
context: .
environment:
- POSTGRES_HOST=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- CELERY_BROKER_URL=redis://redis:6379/0
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- .:/app
working_dir: /app
command: >
sh -c "sleep 3 && celery -A mysite worker"
beat:
image: outfleet:local
container_name: outfleet-beat
build:
context: .
environment:
- POSTGRES_HOST=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- CELERY_BROKER_URL=redis://redis:6379/0
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- .:/app
working_dir: /app
command: >
sh -c "sleep 3 && celery -A mysite beat"
postgres:
image: postgres:15
container_name: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: outfleet
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7
container_name: redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
postgres_data:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

128
k8s.py
View File

@@ -1,128 +0,0 @@
import base64
import json
import uuid
import yaml
import logging
import threading
import time
import lib
from kubernetes import client, config as kube_config
from kubernetes.client.rest import ApiException
log = logging.getLogger("OutFleet.k8s")
NAMESPACE = False
SERVERS = list()
CONFIG = None
V1 = None
K8S_DETECTED = False
def discovery_servers():
global CONFIG
interval = 60
log = logging.getLogger("OutFleet.discovery")
if K8S_DETECTED:
while True:
pods = V1.list_namespaced_pod(NAMESPACE, label_selector="app=shadowbox")
log.debug(f"Started discovery thread every {interval}")
for pod in pods.items:
log.debug(f"Found Outline server pod {pod.metadata.name}")
container_log = V1.read_namespaced_pod_log(name=pod.metadata.name, namespace=NAMESPACE, container='manager-config-json')
secret = json.loads(container_log.replace('\'', '\"'))
config = lib.get_config()
config_servers = find_server(secret, config["servers"])
#log.info(f"config_servers {config_servers}")
if len(config_servers) > 0:
log.debug(f"Already exist")
pass
else:
with lib.lock:
config["servers"][str(uuid.uuid4())] = {
"cert": secret["certSha256"],
"name": f"{pod.metadata.name}",
"comment": f"{pod.spec.node_name}",
"url": secret["apiUrl"],
}
write_config(config)
log.info(f"Added discovered server")
time.sleep(interval)
def find_server(search_data, servers):
found_servers = {}
for server_id, server_info in servers.items():
if server_info["url"] == search_data["apiUrl"] and server_info["cert"] == search_data["certSha256"]:
found_servers[server_id] = server_info
return found_servers
def write_config(config):
config_map = client.V1ConfigMap(
api_version="v1",
kind="ConfigMap",
metadata=client.V1ObjectMeta(
name=f"config-outfleet",
labels={
"app": "outfleet",
}
),
data={"config.yaml": yaml.dump(config)}
)
try:
api_response = V1.create_namespaced_config_map(
namespace=NAMESPACE,
body=config_map,
)
except ApiException as e:
api_response = V1.patch_namespaced_config_map(
name="config-outfleet",
namespace=NAMESPACE,
body=config_map,
)
log.info("Updated config in Kubernetes ConfigMap [config-outfleet]")
def reload_config():
global CONFIG
while True:
new_config = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
with lib.lock:
CONFIG = new_config
log.debug(f"Synced system config with ConfigMap [config-outfleet].")
time.sleep(30)
try:
kube_config.load_incluster_config()
V1 = client.CoreV1Api()
if V1 != None:
K8S_DETECTED = True
try:
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f:
NAMESPACE = f.read().strip()
log.info(f"Found Kubernetes environment. Deployed to namespace '{NAMESPACE}'")
try:
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
log.info(f"ConfigMap loaded from Kubernetes API. Servers: {len(CONFIG['servers'])}, Clients: {len(CONFIG['clients'])}. Started monitoring for changes every minute.")
except Exception as e:
try:
write_config({"clients": [], "servers": {}, "ui_hostname": "accessible-address.com"})
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
log.info("Created new ConfigMap [config-outfleet]. Started monitoring for changes every minute.")
except Exception as e:
log.info(f"Failed to create new ConfigMap [config-outfleet] {e}")
thread = threading.Thread(target=reload_config)
thread.start()
except:
log.info("Kubernetes environment not detected")
except:
log.info("Kubernetes environment not detected")

175
lib.py
View File

@@ -1,175 +0,0 @@
import argparse
import logging
import threading
from typing import TypedDict, List
from outline_vpn.outline_vpn import OutlineKey, OutlineVPN
import yaml
import k8s
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%d-%m-%Y %H:%M:%S",
)
log = logging.getLogger(f'OutFleet.lib')
parser = argparse.ArgumentParser()
parser.add_argument(
"-c",
"--config",
default="/usr/local/etc/outfleet/config.yaml",
help="Config file location",
)
lock = threading.Lock()
args = parser.parse_args()
def get_config():
if k8s.CONFIG:
return k8s.CONFIG
else:
try:
with open(args.config, "r") as file:
config = yaml.safe_load(file)
except:
try:
with open(args.config, "w"):
pass
except Exception as exp:
log.error(f"Couldn't create config. {exp}")
return None
return config
def write_config(config):
if k8s.CONFIG:
k8s.write_config(config)
else:
try:
with open(args.config, "w") as file:
yaml.safe_dump(config, file)
except Exception as e:
log.error(f"Couldn't write Outfleet config: {e}")
class ServerDict(TypedDict):
server_id: str
local_server_id: str
name: str
url: str
cert: str
comment: str
metrics_enabled: str
created_timestamp_ms: int
version: str
port_for_new_access_keys: int
hostname_for_access_keys: str
keys: List[OutlineKey]
class Server:
def __init__(
self,
url: str,
cert: str,
comment: str,
# read from config. not the same as real server id you can get from api
local_server_id: str,
):
self.client = OutlineVPN(api_url=url, cert_sha256=cert)
self.data: ServerDict = {
"local_server_id": local_server_id,
"name": self.client.get_server_information()["name"],
"url": url,
"cert": cert,
"comment": comment,
"server_id": self.client.get_server_information()["serverId"],
"metrics_enabled": self.client.get_server_information()["metricsEnabled"],
"created_timestamp_ms": self.client.get_server_information()[
"createdTimestampMs"
],
"version": self.client.get_server_information()["version"],
"port_for_new_access_keys": self.client.get_server_information()[
"portForNewAccessKeys"
],
"hostname_for_access_keys": self.client.get_server_information()[
"hostnameForAccessKeys"
],
"keys": self.client.get_keys(),
}
self.log = logging.getLogger(f'OutFleet.server[{self.data["name"]}]')
def info(self) -> ServerDict:
return self.data
def check_client(self, name):
# Looking for any users with provided name. len(result) != 1 is a problem.
result = []
for key in self.client.get_keys():
if key.name == name:
result.append(name)
self.log.info(f"check_client found client `{name}` config is correct.")
if len(result) != 1:
self.log.warning(
f"check_client found client `{name}` inconsistent. Found {len(result)} keys."
)
return False
else:
return True
def apply_config(self, config):
if config.get("name"):
self.client.set_server_name(config.get("name"))
self.log.info(
"Changed %s name to '%s'", self.data["local_server_id"], config.get("name")
)
if config.get("metrics"):
self.client.set_metrics_status(
True if config.get("metrics") == "True" else False
)
self.log.info(
"Changed %s metrics status to '%s'",
self.data["local_server_id"],
config.get("metrics"),
)
if config.get("port_for_new_access_keys"):
self.client.set_port_new_for_access_keys(
int(config.get("port_for_new_access_keys"))
)
self.log.info(
"Changed %s port_for_new_access_keys to '%s'",
self.data["local_server_id"],
config.get("port_for_new_access_keys"),
)
if config.get("hostname_for_access_keys"):
self.client.set_hostname(config.get("hostname_for_access_keys"))
self.log.info(
"Changed %s hostname_for_access_keys to '%s'",
self.data["local_server_id"],
config.get("hostname_for_access_keys"),
)
if config.get("comment"):
config_file = get_config()
config_file["servers"][self.data["local_server_id"]]["comment"] = config.get(
"comment"
)
write_config(config_file)
self.log.info(
"Changed %s comment to '%s'",
self.data["local_server_id"],
config.get("comment"),
)
def create_key(self, key_name):
self.client.create_key(key_id=key_name, name=key_name)
self.log.info("New key created: %s", key_name)
return True
def rename_key(self, key_id, new_name):
self.log.info("Key %s renamed: %s", key_id, new_name)
return self.client.rename_key(key_id, new_name)
def delete_key(self, key_id):
self.log.info("Key %s deleted", key_id)
return self.client.delete_key(key_id)

427
main.py
View File

@@ -1,427 +0,0 @@
import threading
import time
import yaml
import logging
from datetime import datetime
import random
import string
import uuid
import k8s
from flask import Flask, render_template, request, url_for, redirect, jsonify
from flask_cors import CORS
from lib import Server, write_config, get_config, args, lock
logging.getLogger("werkzeug").setLevel(logging.ERROR)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%d-%m-%Y %H:%M:%S",
)
log = logging.getLogger("OutFleet")
file_handler = logging.FileHandler("sync.log")
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
file_handler.setFormatter(formatter)
log.addHandler(file_handler)
CFG_PATH = args.config
NAMESPACE = k8s.NAMESPACE
SERVERS = list()
BROKEN_SERVERS = list()
CLIENTS = dict()
VERSION = '5'
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(timer=40):
while True:
with lock:
global SERVERS
global CLIENTS
global BROKEN_SERVERS
global HOSTNAME
config = get_config()
if config:
HOSTNAME = config.get("ui_hostname", "my-own-SSL-ENABLED-domain.com")
servers = config.get("servers", dict())
_SERVERS = list()
for local_server_id, server_config in servers.items():
try:
server = Server(
url=server_config["url"],
cert=server_config["cert"],
comment=server_config.get("comment", ''),
local_server_id=local_server_id,
)
_SERVERS.append(server)
log.debug(
"Server state updated: %s, [%s]",
server.info()["name"],
local_server_id,
)
except Exception as e:
BROKEN_SERVERS.append({
"config": server_config,
"error": e,
"id": local_server_id
})
log.warning("Can't access server: %s - %s", server_config["url"], e)
SERVERS = _SERVERS
CLIENTS = config.get("clients", dict())
if timer == 0:
break
time.sleep(40)
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "GET":
#if request.args.get("broken") == True:
return render_template(
"index.html",
SERVERS=SERVERS,
VERSION=VERSION,
K8S_NAMESPACE=k8s.NAMESPACE,
BROKEN_SERVERS=BROKEN_SERVERS,
nt=request.args.get("nt"),
nl=request.args.get("nl"),
selected_server=request.args.get("selected_server"),
broken=request.args.get("broken", False),
add_server=request.args.get("add_server", None),
format_timestamp=format_timestamp,
)
elif request.method == "POST":
server = request.form["server_id"]
server = next(
(item for item in SERVERS if item.info()["local_server_id"] == server), None
)
server.apply_config(request.form)
update_state(timer=0)
return redirect(
url_for(
"index",
nt="Updated Outline VPN Server",
selected_server=request.args.get("selected_server"),
)
)
else:
return redirect(url_for("index"))
@app.route("/servers", methods=["GET", "POST"])
@app.route("/servers/<string:local_server_id>", methods=["GET", "POST", "DELETE"])
def servers(local_server_id=None):
if local_server_id:
log.info(f"Got {local_server_id} Config {get_config()}")
server = get_config()['servers'].get(local_server_id, None)
return jsonify(server)
if request.method == "GET":
return jsonify({ "servers": get_config()['servers']})
elif request.method == "POST":
server = request.form["server_id"]
server = next(
(item for item in SERVERS if item.info()["local_server_id"] == server), None
)
server.apply_config(request.form)
update_state(timer=0)
return redirect(
url_for(
"index",
nt="Updated Outline VPN Server",
selected_server=request.args.get("selected_server"),
)
)
else:
return redirect(url_for("index"))
@app.route("/clients", methods=["GET", "POST"])
def clients():
if request.method == "GET":
return render_template(
"clients.html",
SERVERS=SERVERS,
CLIENTS=CLIENTS,
VERSION=VERSION,
K8S_NAMESPACE=k8s.NAMESPACE,
nt=request.args.get("nt"),
nl=request.args.get("nl"),
selected_client=request.args.get("selected_client"),
add_client=request.args.get("add_client", None),
format_timestamp=format_timestamp,
dynamic_hostname=HOSTNAME,
)
@app.route("/add_server", methods=["POST"])
def add_server():
if request.method == "POST":
try:
config = get_config()
servers = config.get("servers", dict())
local_server_id = str(uuid.uuid4())
new_server = Server(
url=request.form["url"],
cert=request.form["cert"],
comment=request.form["comment"],
local_server_id=local_server_id,
)
servers[new_server.data["local_server_id"]] = {
"name": new_server.data["name"],
"url": new_server.data["url"],
"comment": new_server.data["comment"],
"cert": request.form["cert"],
}
config["servers"] = servers
write_config(config)
log.info("Added server: %s", new_server.data["name"])
update_state(timer=0)
return redirect(url_for("index", nt="Added Outline VPN Server"))
except Exception as e:
return redirect(
url_for(
"index", nt=f"Couldn't access Outline VPN Server: {e}", nl="error"
)
)
@app.route("/del_server", methods=["POST"])
def del_server():
if request.method == "POST":
config = get_config()
local_server_id = request.form.get("local_server_id")
server_name = None
try:
server_name = config["servers"].pop(local_server_id)["name"]
except KeyError as e:
pass
for client_id, client_config in config["clients"].items():
try:
client_config["servers"].remove(local_server_id)
except ValueError as e:
pass
write_config(config)
log.info("Deleting server %s [%s]", server_name, request.form.get("local_server_id"))
update_state(timer=0)
return redirect(url_for("index", nt=f"Server {server_name} has been deleted"))
@app.route("/add_client", methods=["POST"])
def add_client():
if request.method == "POST":
config = get_config()
clients = config.get("clients", dict())
user_id = request.form.get("user_id", random_string())
clients[user_id] = {
"name": request.form.get("name"),
"comment": request.form.get("comment"),
"servers": request.form.getlist("servers"),
}
config["clients"] = clients
write_config(config)
log.info("Client %s updated", request.form.get("name"))
for server in SERVERS:
if server.data["local_server_id"] in request.form.getlist("servers"):
client = next(
(
item
for item in server.data["keys"]
if item.name == request.form.get("old_name")
),
None,
)
if client:
if client.name == request.form.get("name"):
pass
else:
server.rename_key(client.key_id, request.form.get("name"))
log.info(
"Renaming key %s to %s on server %s",
request.form.get("old_name"),
request.form.get("name"),
server.data["name"],
)
else:
server.create_key(request.form.get("name"))
log.info(
"Creating key %s on server %s",
request.form.get("name"),
server.data["name"],
)
else:
client = next(
(
item
for item in server.data["keys"]
if item.name == request.form.get("old_name")
),
None,
)
if client:
server.delete_key(client.key_id)
log.info(
"Deleting key %s on server %s",
request.form.get("name"),
server.data["name"],
)
update_state(timer=0)
return redirect(
url_for(
"clients",
nt="Clients updated",
selected_client=request.form.get("user_id"),
)
)
else:
return redirect(url_for("clients"))
@app.route("/del_client", methods=["POST"])
def del_client():
if request.method == "POST":
config = get_config()
clients = config.get("clients", dict())
user_id = request.form.get("user_id")
if user_id in clients:
for server in SERVERS:
client = next(
(
item
for item in server.data["keys"]
if item.name == request.form.get("name")
),
None,
)
if client:
server.delete_key(client.key_id)
config["clients"].pop(user_id)
write_config(config)
log.info("Deleting client %s", request.form.get("name"))
update_state(timer=0)
return redirect(url_for("clients", nt="User has been deleted"))
@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":
with lock:
if request.form.get("wipe") == 'all':
for server in SERVERS:
log.info("Wiping all keys on [%s]", server.data["name"])
for client in server.data['keys']:
server.delete_key(client.key_id)
server_hash = {}
with lock:
for server in SERVERS:
server_hash[server.data["local_server_id"]] = server
with lock:
for key, client in CLIENTS.items():
for u_server_id in client["servers"]:
if u_server_id in server_hash:
if not server_hash[u_server_id].check_client(client["name"]):
log.warning(
f"Client {client['name']} absent on {server_hash[u_server_id].data['name']}"
)
server_hash[u_server_id].create_key(client["name"])
else:
log.info(
f"Client {client['name']} already present on {server_hash[u_server_id].data['name']}"
)
else:
log.info(
f"Client {client['name']} incorrect server_id {u_server_id}"
)
update_state(timer=0)
return redirect(url_for("sync"))
if __name__ == "__main__":
update_state_thread = threading.Thread(target=update_state)
update_state_thread.start()
discovery_servers_thread = threading.Thread(target=k8s.discovery_servers)
discovery_servers_thread.start()
app.run(host="0.0.0.0")

22
manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

3
mysite/__init__.py Normal file
View File

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

16
mysite/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for mysite project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
application = get_asgi_application()

40
mysite/celery.py Normal file
View File

@@ -0,0 +1,40 @@
import logging
import os
from celery import Celery
from celery import shared_task
from celery.schedules import crontab
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
logger = logging.getLogger(__name__)
app = Celery('mysite')
app.conf.beat_schedule = {
'periodical_servers_sync': {
'task': 'sync_all_servers',
'schedule': crontab(minute=0, hour='*/3'), # Every 3 hours
},
'cleanup_old_task_logs': {
'task': 'cleanup_task_logs',
'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
},
}
app.config_from_object('django.conf:settings', namespace='CELERY')
# Additional celery settings for better logging and performance
app.conf.update(
# Keep detailed results for debugging
result_expires=3600, # 1 hour
task_always_eager=False,
task_eager_propagates=True,
# Improve task tracking
task_track_started=True,
task_send_sent_event=True,
# Clean up settings
result_backend_cleanup_interval=300, # Clean up every 5 minutes
)
app.autodiscover_tasks()

View File

@@ -0,0 +1,42 @@
from django.conf import settings
import subprocess
from pathlib import Path
def version_info(request):
"""Add version information to template context"""
git_commit = getattr(settings, 'GIT_COMMIT', None)
git_commit_short = getattr(settings, 'GIT_COMMIT_SHORT', None)
build_date = getattr(settings, 'BUILD_DATE', None)
if not git_commit or git_commit == 'development':
try:
base_dir = getattr(settings, 'BASE_DIR', Path(__file__).resolve().parent.parent)
result = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, cwd=base_dir, timeout=5)
if result.returncode == 0:
git_commit = result.stdout.strip()
git_commit_short = git_commit[:7]
date_result = subprocess.run(['git', 'log', '-1', '--format=%ci'],
capture_output=True, text=True, cwd=base_dir, timeout=5)
if date_result.returncode == 0:
build_date = date_result.stdout.strip()
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
pass
if not git_commit:
git_commit = 'development'
if not git_commit_short:
git_commit_short = 'dev'
if not build_date:
build_date = 'unknown'
return {
'VERSION_INFO': {
'git_commit': git_commit,
'git_commit_short': git_commit_short,
'build_date': build_date,
'is_development': git_commit_short == 'dev'
}
}

22
mysite/middleware.py Normal file
View File

@@ -0,0 +1,22 @@
from django.contrib.auth import authenticate, login
from django.utils.deprecation import MiddlewareMixin
class RequestLogger:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
print(f"Original: {request.build_absolute_uri()}")
print(f"Path : {request.path}")
response = self.get_response(request)
return response
class AutoLoginMiddleware(MiddlewareMixin):
def process_request(self, request):
if not request.user.is_authenticated:
user = authenticate(username='admin', password='admin')
if user:
login(request, user)

233
mysite/settings.py Normal file
View File

@@ -0,0 +1,233 @@
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,
},
'telegram_bot': {
'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',
'telegram_bot',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'mysite.middleware.AutoLoginMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
]
ROOT_URLCONF = 'mysite.urls'
GIT_COMMIT = ENV('GIT_COMMIT', default='development')
GIT_COMMIT_SHORT = ENV('GIT_COMMIT_SHORT', default='dev')
BUILD_DATE = ENV('BUILD_DATE', default='unknown')
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'templates'),
os.path.join(BASE_DIR, 'vpn', 'templates')
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'mysite.context_processors.version_info',
],
},
},
]
WSGI_APPLICATION = 'mysite.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
# CREATE USER outfleet WITH PASSWORD 'password';
# GRANT ALL PRIVILEGES ON DATABASE outfleet TO outfleet;
# ALTER DATABASE outfleet OWNER TO outfleet;
DATABASES = {
'sqlite': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
},
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': ENV('POSTGRES_DB', default="outfleet"),
'USER': ENV('POSTGRES_USER', default="outfleet"),
'PASSWORD': ENV('POSTGRES_PASSWORD', default="outfleet"),
'HOST': ENV('POSTGRES_HOST', default='localhost'),
'PORT': ENV('POSTGRES_PORT', default='5432'),
}
}
# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = [
BASE_DIR / 'static',
]
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

30
mysite/urls.py Normal file
View File

@@ -0,0 +1,30 @@
"""
URL configuration for mysite project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.views.generic import RedirectView
from vpn.views import shadowsocks, userFrontend, userPortal, xray_subscription
urlpatterns = [
path('admin/', admin.site.urls),
path('ss/<path:link>', shadowsocks, name='shadowsocks'),
path('dynamic/<path:link>', shadowsocks, name='shadowsocks'),
path('xray/<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
View File

@@ -0,0 +1,16 @@
"""
WSGI config for mysite project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
application = get_wsgi_application()

27
requirements.txt Executable file → Normal file
View File

@@ -1,5 +1,22 @@
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==75.2.0
shortuuid==1.0.13
cryptography==45.0.5
acme>=2.0.0
cloudflare>=4.3.1
josepy>=2.0.0
python-telegram-bot==21.10

239
static/admin/css/main.css Normal file
View File

@@ -0,0 +1,239 @@
/* static/admin/css/main.css */
/* Bulk Action Section Styling */
.bulk-actions-section {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 1px solid #dee2e6;
border-left: 4px solid #007cba;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.bulk-actions-section h3 {
color: #007cba;
margin-top: 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
}
.bulk-actions-section p {
color: #6c757d;
margin-bottom: 15px;
line-height: 1.5;
}
/* Action Button Styles */
.server-action-btn, .bulk-action-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 15px;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
font-size: 14px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: relative;
overflow: hidden;
}
.server-action-btn:before, .bulk-action-btn:before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s;
}
.server-action-btn:hover:before, .bulk-action-btn:hover:before {
left: 100%;
}
.server-action-btn:hover, .bulk-action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
text-decoration: none;
}
/* Specific button colors */
.btn-move-clients {
background-color: #007cba;
color: white;
}
.btn-move-clients:hover {
background-color: #005a8b !important;
color: white;
}
.btn-purge-users {
background-color: #dc3545;
color: white;
}
.btn-purge-users:hover {
background-color: #c82333 !important;
color: white;
}
/* Server list action buttons */
.field-server_actions {
min-width: 160px;
}
.field-server_actions .server-action-btn {
padding: 5px 8px;
font-size: 11px;
gap: 4px;
margin: 2px;
}
/* Server statistics section */
.server-stats-section {
background-color: #e8f4fd;
border: 1px solid #bee5eb;
border-radius: 6px;
padding: 12px;
margin: 15px 0;
}
.server-stats-grid {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
}
.stat-item {
display: flex;
align-items: center;
gap: 5px;
}
.stat-label {
color: #495057;
font-weight: 500;
}
.stat-value {
color: #007cba;
font-weight: bold;
}
/* Tip section styling */
.tip-section {
background-color: rgba(255, 243, 205, 0.8);
border-left: 4px solid #ffc107;
border-radius: 4px;
padding: 12px;
margin-top: 15px;
}
.tip-section small {
color: #856404;
line-height: 1.4;
}
/* Loading states */
.server-action-btn.loading {
pointer-events: none;
opacity: 0.7;
}
.server-action-btn.loading:after {
content: '';
position: absolute;
width: 16px;
height: 16px;
margin: auto;
border: 2px solid transparent;
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive design */
@media (max-width: 768px) {
.bulk-actions-section {
padding: 15px;
}
.server-action-btn, .bulk-action-btn {
width: 100%;
justify-content: center;
margin-bottom: 8px;
}
.server-stats-grid {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.field-server_actions > div {
flex-direction: column;
}
.field-server_actions .server-action-btn {
width: 100%;
justify-content: center;
margin: 2px 0;
}
}
@media (max-width: 480px) {
.bulk-actions-section h3 {
font-size: 16px;
}
.server-action-btn, .bulk-action-btn {
font-size: 13px;
padding: 8px 12px;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.bulk-actions-section {
background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%);
border-color: #4a5568;
color: #e2e8f0;
}
.bulk-actions-section h3 {
color: #63b3ed;
}
.bulk-actions-section p {
color: #a0aec0;
}
.server-stats-section {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.stat-label {
color: #a0aec0;
}
.stat-value {
color: #63b3ed;
}
}

View File

@@ -0,0 +1,342 @@
/* 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;
}
/* Hide xray-subscriptions tab if it appears */
#xray-subscriptions-tab,
a[href="#xray-subscriptions-tab"],
li:has(a[href="#xray-subscriptions-tab"]) {
display: none !important;
}

View File

@@ -0,0 +1,203 @@
// static/admin/js/generate_uuid.js
function generateLink(button) {
let row = button.closest('tr');
let inputField = row.querySelector('input[name$="link"]');
if (inputField) {
inputField.value = generateRandomString(16);
}
}
function generateRandomString(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
// OutlineServer JSON Configuration Functionality
document.addEventListener('DOMContentLoaded', function() {
// JSON Import functionality
const importJsonBtn = document.getElementById('import-json-btn');
const importJsonTextarea = document.getElementById('import-json-config');
if (importJsonBtn && importJsonTextarea) {
// Auto-fill on paste event
importJsonTextarea.addEventListener('paste', function(e) {
// Small delay to let paste complete
setTimeout(() => {
tryAutoFillFromJson();
}, 100);
});
// Manual import button
importJsonBtn.addEventListener('click', function() {
tryAutoFillFromJson();
});
function tryAutoFillFromJson() {
try {
const jsonText = importJsonTextarea.value.trim();
if (!jsonText) {
alert('Please enter JSON configuration');
return;
}
const config = JSON.parse(jsonText);
// Validate required fields
if (!config.apiUrl || !config.certSha256) {
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
return;
}
// Parse apiUrl to extract components
const url = new URL(config.apiUrl);
// Fill form fields
const adminUrlField = document.getElementById('id_admin_url');
const adminCertField = document.getElementById('id_admin_access_cert');
const clientHostnameField = document.getElementById('id_client_hostname');
const clientPortField = document.getElementById('id_client_port');
const nameField = document.getElementById('id_name');
const commentField = document.getElementById('id_comment');
if (adminUrlField) adminUrlField.value = config.apiUrl;
if (adminCertField) adminCertField.value = config.certSha256;
// Use provided hostname or extract from URL
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
if (clientHostnameField) clientHostnameField.value = hostname;
// Use provided port or extract from various sources
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
if (clientPortField) clientPortField.value = clientPort;
// Generate server name if not provided and field is empty
if (nameField && !nameField.value) {
const serverName = config.serverName || config.name || `Outline-${hostname}`;
nameField.value = serverName;
}
// Fill comment if provided and field exists
if (commentField && config.comment) {
commentField.value = config.comment;
}
// Clear the JSON input
importJsonTextarea.value = '';
// Show success message
showSuccessMessage('✅ Configuration imported successfully!');
} catch (error) {
alert('Invalid JSON format: ' + error.message);
}
}
}
// Copy to clipboard functionality
window.copyToClipboard = function(elementId) {
const element = document.getElementById(elementId);
if (element) {
const text = element.textContent || element.innerText;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
showCopySuccess();
}).catch(err => {
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
}
};
function fallbackCopyTextToClipboard(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showCopySuccess();
} catch (err) {
console.error('Failed to copy text: ', err);
}
document.body.removeChild(textArea);
}
function showCopySuccess() {
showSuccessMessage('📋 Copied to clipboard!');
}
function showSuccessMessage(message) {
const alertHtml = `
<div class="alert alert-success alert-dismissible" style="margin: 1rem 0;">
${message}
<button type="button" class="close" aria-label="Close" onclick="this.parentElement.remove()">
<span aria-hidden="true">&times;</span>
</button>
</div>
`;
// Try to find a container for the message
const container = document.querySelector('.card-body') || document.querySelector('#content-main');
if (container) {
container.insertAdjacentHTML('afterbegin', alertHtml);
}
setTimeout(() => {
const alert = document.querySelector('.alert-success');
if (alert) alert.remove();
}, 5000);
}
// Sync server button - handle both static and dynamic buttons
document.addEventListener('click', async function(e) {
if (e.target && (e.target.id === 'sync-server-btn' || e.target.matches('[id="sync-server-btn"]'))) {
const syncBtn = e.target;
const serverId = syncBtn.dataset.serverId;
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const originalText = syncBtn.textContent;
syncBtn.textContent = '⏳ Syncing...';
syncBtn.disabled = true;
try {
const response = await fetch(`/admin/vpn/outlineserver/${serverId}/sync/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showSuccessMessage(`${data.message}`);
setTimeout(() => window.location.reload(), 2000);
} else {
alert('Sync failed: ' + data.error);
}
} catch (error) {
alert('Network error: ' + error.message);
} finally {
syncBtn.textContent = originalText;
syncBtn.disabled = false;
}
}
});
});

View File

@@ -0,0 +1,94 @@
// Server status check functionality for admin
document.addEventListener('DOMContentLoaded', function() {
// Add event listeners to all check status buttons
document.querySelectorAll('.check-status-btn').forEach(button => {
button.addEventListener('click', async function(e) {
e.preventDefault();
const serverId = this.dataset.serverId;
const serverName = this.dataset.serverName;
const serverType = this.dataset.serverType;
const originalText = this.textContent;
const originalColor = this.style.background;
// Show loading state
this.textContent = '⏳ Checking...';
this.style.background = '#6c757d';
this.disabled = true;
try {
// Try AJAX request first
const response = await fetch(`/admin/vpn/server/${serverId}/check-status/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCookie('csrftoken')
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
// Update button based on status
if (data.status === 'online') {
this.textContent = '✅ Online';
this.style.background = '#28a745';
} else if (data.status === 'offline') {
this.textContent = '❌ Offline';
this.style.background = '#dc3545';
} else if (data.status === 'error') {
this.textContent = '⚠️ Error';
this.style.background = '#fd7e14';
} else {
this.textContent = '❓ Unknown';
this.style.background = '#6c757d';
}
// Show additional info if available
if (data.message) {
this.title = data.message;
}
} else {
throw new Error(data.error || 'Failed to check status');
}
} catch (error) {
console.error('Error checking server status:', error);
// Fallback: show basic server info
this.textContent = `📊 ${serverType}`;
this.style.background = '#17a2b8';
this.title = `Server: ${serverName} (${serverType}) - Status check failed: ${error.message}`;
}
// Reset after 5 seconds in all cases
setTimeout(() => {
this.textContent = originalText;
this.style.background = originalColor;
this.title = '';
this.disabled = false;
}, 5000);
});
});
});
// Helper function to get CSRF token
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}

View File

@@ -0,0 +1,289 @@
// Xray Inbound Auto-Fill Helper
console.log('Xray inbound helper script loaded');
// Protocol configurations based on Xray documentation
const protocolConfigs = {
'vless': {
port: 443,
network: 'tcp',
security: 'tls',
description: 'VLESS - Lightweight protocol with UUID authentication'
},
'vmess': {
port: 443,
network: 'ws',
security: 'tls',
description: 'VMess - V2Ray protocol with encryption and authentication'
},
'trojan': {
port: 443,
network: 'tcp',
security: 'tls',
description: 'Trojan - TLS-based protocol mimicking HTTPS traffic'
},
'shadowsocks': {
port: 8388,
network: 'tcp',
security: 'none',
ss_method: 'aes-256-gcm',
description: 'Shadowsocks - SOCKS5 proxy with encryption'
}
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM ready, initializing Xray helper');
// Add help text and generate buttons
addHelpText();
addGenerateButtons();
// Watch for protocol field changes
const protocolField = document.getElementById('id_protocol');
if (protocolField) {
protocolField.addEventListener('change', function() {
handleProtocolChange(this.value);
});
// Auto-fill on initial load if new inbound
if (protocolField.value && isNewInbound()) {
handleProtocolChange(protocolField.value);
}
}
});
function isNewInbound() {
// Check if this is a new inbound (no port value set)
const portField = document.getElementById('id_port');
return !portField || !portField.value;
}
function handleProtocolChange(protocol) {
if (!protocol || !protocolConfigs[protocol]) {
return;
}
const config = protocolConfigs[protocol];
// Only auto-fill for new inbounds to avoid overwriting user data
if (isNewInbound()) {
console.log('Auto-filling fields for new', protocol, 'inbound');
autoFillFields(protocol, config);
showMessage(`Auto-filled ${protocol.toUpperCase()} configuration`, 'info');
}
}
function autoFillFields(protocol, config) {
// Fill basic fields only if they're empty
fillIfEmpty('id_port', config.port);
fillIfEmpty('id_network', config.network);
fillIfEmpty('id_security', config.security);
// Protocol-specific fields
if (config.ss_method && protocol === 'shadowsocks') {
fillIfEmpty('id_ss_method', config.ss_method);
}
// Generate helpful JSON configs
generateJsonConfigs(protocol, config);
}
function fillIfEmpty(fieldId, value) {
const field = document.getElementById(fieldId);
if (field && !field.value && value !== undefined) {
field.value = value;
field.dispatchEvent(new Event('change', { bubbles: true }));
}
}
function generateJsonConfigs(protocol, config) {
// Generate stream settings
const streamField = document.getElementById('id_stream_settings');
if (streamField && !streamField.value) {
const streamSettings = getStreamSettings(protocol, config.network);
if (streamSettings) {
streamField.value = JSON.stringify(streamSettings, null, 2);
}
}
// Generate sniffing settings
const sniffingField = document.getElementById('id_sniffing_settings');
if (sniffingField && !sniffingField.value) {
const sniffingSettings = {
enabled: true,
destOverride: ['http', 'tls'],
metadataOnly: false
};
sniffingField.value = JSON.stringify(sniffingSettings, null, 2);
}
}
function getStreamSettings(protocol, network) {
const settings = {};
switch (network) {
case 'ws':
settings.wsSettings = {
path: '/ws',
headers: {
Host: 'example.com'
}
};
break;
case 'grpc':
settings.grpcSettings = {
serviceName: 'GunService'
};
break;
case 'h2':
settings.httpSettings = {
host: ['example.com'],
path: '/path'
};
break;
case 'tcp':
settings.tcpSettings = {
header: {
type: 'none'
}
};
break;
case 'kcp':
settings.kcpSettings = {
mtu: 1350,
tti: 50,
uplinkCapacity: 5,
downlinkCapacity: 20,
congestion: false,
readBufferSize: 2,
writeBufferSize: 2,
header: {
type: 'none'
}
};
break;
}
return Object.keys(settings).length > 0 ? settings : null;
}
function addHelpText() {
// Add help text to complex fields
addFieldHelp('id_stream_settings',
'Transport settings: TCP (none), WebSocket (path/host), gRPC (serviceName), etc. Format: JSON');
addFieldHelp('id_sniffing_settings',
'Traffic sniffing for routing: enabled, destOverride ["http","tls"], metadataOnly');
addFieldHelp('id_tls_cert_file',
'TLS certificate file path (required for TLS security). Example: /path/to/cert.pem');
addFieldHelp('id_tls_key_file',
'TLS private key file path (required for TLS security). Example: /path/to/key.pem');
addFieldHelp('id_protocol',
'VLESS: lightweight + UUID | VMess: V2Ray encrypted | Trojan: HTTPS-like | Shadowsocks: SOCKS5');
addFieldHelp('id_network',
'Transport: tcp (direct), ws (WebSocket), grpc (HTTP/2), h2 (HTTP/2), kcp (mKCP)');
addFieldHelp('id_security',
'Encryption: none (no TLS), tls (standard TLS), reality (advanced steganography)');
}
function addFieldHelp(fieldId, helpText) {
const field = document.getElementById(fieldId);
if (!field) return;
const helpDiv = document.createElement('div');
helpDiv.className = 'help';
helpDiv.style.cssText = 'font-size: 11px; color: #666; margin-top: 2px; line-height: 1.3;';
helpDiv.textContent = helpText;
field.parentNode.appendChild(helpDiv);
}
function showMessage(message, type = 'info') {
const messageDiv = document.createElement('div');
messageDiv.className = `alert alert-${type}`;
messageDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
padding: 12px 20px;
border-radius: 4px;
background: ${type === 'success' ? '#d4edda' : '#cce7ff'};
border: 1px solid ${type === 'success' ? '#c3e6cb' : '#b8daff'};
color: ${type === 'success' ? '#155724' : '#004085'};
font-weight: 500;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
`;
messageDiv.textContent = message;
document.body.appendChild(messageDiv);
setTimeout(() => {
messageDiv.remove();
}, 3000);
}
// Helper functions for generating values
function generateRandomString(length = 8) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function generateShortId() {
return Math.random().toString(16).substr(2, 8);
}
function suggestPort(protocol) {
const ports = {
'vless': [443, 8443, 2053, 2083],
'vmess': [443, 80, 8080, 8443],
'trojan': [443, 8443, 2087],
'shadowsocks': [8388, 1080, 8080]
};
const protocolPorts = ports[protocol] || [443];
return protocolPorts[Math.floor(Math.random() * protocolPorts.length)];
}
// Add generate buttons to fields
function addGenerateButtons() {
console.log('Adding generate buttons');
// Add tag generator
addGenerateButton('id_tag', '🎲', () => `inbound-${generateShortId()}`);
// Add port suggestion based on protocol
addGenerateButton('id_port', '🎯', () => {
const protocol = document.getElementById('id_protocol')?.value;
return suggestPort(protocol);
});
}
function addGenerateButton(fieldId, icon, generator) {
const field = document.getElementById(fieldId);
if (!field || field.nextElementSibling?.classList.contains('generate-btn')) return;
const button = document.createElement('button');
button.type = 'button';
button.className = 'generate-btn btn btn-sm btn-secondary';
button.innerHTML = icon;
button.title = 'Generate value';
button.style.cssText = 'margin-left: 5px; padding: 2px 6px; font-size: 12px;';
button.addEventListener('click', () => {
const value = generator();
field.value = value;
showMessage(`Generated: ${value}`, 'success');
field.dispatchEvent(new Event('change', { bubbles: true }));
});
field.parentNode.insertBefore(button, field.nextSibling);
}

View File

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

0
telegram_bot/__init__.py Normal file
View File

854
telegram_bot/admin.py Normal file
View File

@@ -0,0 +1,854 @@
from django.contrib import admin
from django.utils.html import format_html
from django.urls import path, reverse
from django.shortcuts import redirect
from django.contrib import messages
from django.utils import timezone
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from .models import BotSettings, TelegramMessage, AccessRequest
from .localization import MessageLocalizer
from vpn.models import User
import logging
logger = logging.getLogger(__name__)
class BotSettingsAdminForm(forms.ModelForm):
"""Custom form for BotSettings with Telegram admin selection"""
class Meta:
model = BotSettings
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Show all users for telegram_admins selection
if 'telegram_admins' in self.fields:
self.fields['telegram_admins'].queryset = User.objects.all().order_by('username')
self.fields['telegram_admins'].help_text = (
"Select users who will have admin access in the bot. "
"Users will get admin rights when they connect to the bot with their Telegram account."
)
def clean_telegram_admins(self):
"""Validate that selected admins have telegram_user_id or telegram_username"""
admins = self.cleaned_data.get('telegram_admins')
# No validation needed - admins can be selected even without telegram connection
# They will get admin rights when they connect via bot
return admins
class AccessRequestAdminForm(forms.ModelForm):
"""Custom form for AccessRequest with existing user selection"""
class Meta:
model = AccessRequest
fields = '__all__'
widgets = {
'selected_subscription_groups': FilteredSelectMultiple(
verbose_name='Subscription Groups',
is_stacked=False
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Rename the field for better UI
if 'selected_existing_user' in self.fields:
self.fields['selected_existing_user'].label = 'Link to existing user'
self.fields['selected_existing_user'].empty_label = "— Create new user —"
self.fields['selected_existing_user'].help_text = "Select an existing user without Telegram to link, or leave empty to create new user"
# Get users without telegram_user_id
from vpn.models import User
self.fields['selected_existing_user'].queryset = User.objects.filter(
telegram_user_id__isnull=True
).order_by('username')
# Configure subscription group fields
if 'selected_subscription_groups' in self.fields:
from vpn.models_xray import SubscriptionGroup
self.fields['selected_subscription_groups'].queryset = SubscriptionGroup.objects.filter(
is_active=True
).order_by('name')
self.fields['selected_subscription_groups'].label = 'Subscription Groups'
self.fields['selected_subscription_groups'].help_text = 'Select subscription groups to assign to this user'
@admin.register(BotSettings)
class BotSettingsAdmin(admin.ModelAdmin):
form = BotSettingsAdminForm
list_display = ('__str__', 'enabled', 'bot_token_display', 'admin_count_display', 'updated_at')
fieldsets = (
('Bot Configuration', {
'fields': ('bot_token', 'enabled', 'bot_status_display'),
'description': 'Configure bot settings and view current status'
}),
('Admin Management', {
'fields': ('telegram_admins', 'admin_info_display'),
'description': 'Select users with linked Telegram accounts who will have admin access in the bot'
}),
('Connection Settings', {
'fields': ('api_base_url', 'connection_timeout', 'use_proxy', 'proxy_url'),
'classes': ('collapse',)
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at', 'bot_status_display', 'admin_info_display')
filter_horizontal = ('telegram_admins',)
def bot_token_display(self, obj):
"""Mask bot token for security"""
if obj.bot_token:
token = obj.bot_token
if len(token) > 10:
return f"{token[:6]}...{token[-4:]}"
return "Token set"
return "No token set"
bot_token_display.short_description = "Bot Token"
def admin_count_display(self, obj):
"""Display count of Telegram admins"""
count = obj.telegram_admins.count()
if count == 0:
return "No admins"
elif count == 1:
return "1 admin"
else:
return f"{count} admins"
admin_count_display.short_description = "Telegram Admins"
def admin_info_display(self, obj):
"""Display detailed admin information"""
if not obj.pk:
return "Save settings first to manage admins"
admins = obj.telegram_admins.all()
if not admins.exists():
html = '<div style="background: #fff3cd; padding: 10px; border-radius: 4px; border-left: 4px solid #ffc107;">'
html += '<p style="margin: 0; color: #856404;"><strong>⚠️ No Telegram admins configured</strong></p>'
html += '<p style="margin: 5px 0 0 0; color: #856404;">Select users above to give them admin access in the Telegram bot.</p>'
html += '</div>'
else:
html = '<div style="background: #d4edda; padding: 10px; border-radius: 4px; border-left: 4px solid #28a745;">'
html += f'<p style="margin: 0; color: #155724;"><strong>✅ {admins.count()} Telegram admin(s) configured</strong></p>'
html += '<div style="margin-top: 8px;">'
for admin in admins:
html += '<div style="background: white; margin: 4px 0; padding: 6px 10px; border-radius: 3px; border: 1px solid #c3e6cb;">'
html += f'<strong>{admin.username}</strong>'
if admin.telegram_username:
html += f' (@{admin.telegram_username})'
html += f' <small style="color: #6c757d;">ID: {admin.telegram_user_id}</small>'
if admin.first_name or admin.last_name:
name_parts = []
if admin.first_name:
name_parts.append(admin.first_name)
if admin.last_name:
name_parts.append(admin.last_name)
html += f'<br><small style="color: #6c757d;">Name: {" ".join(name_parts)}</small>'
html += '</div>'
html += '</div>'
html += '<p style="margin: 8px 0 0 0; color: #155724; font-size: 12px;">These users will receive notifications about new access requests and can approve/reject them directly in Telegram.</p>'
html += '</div>'
return format_html(html)
admin_info_display.short_description = "Admin Configuration"
def bot_status_display(self, obj):
"""Display bot status with control buttons"""
from .bot import TelegramBotManager
import os
from django.conf import settings as django_settings
manager = TelegramBotManager()
# Check if lock file exists - only reliable indicator
lock_dir = os.path.join(getattr(django_settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks')
lock_path = os.path.join(lock_dir, 'telegram_bot.lock')
is_running = os.path.exists(lock_path)
if is_running:
status_html = '<span style="color: green; font-weight: bold;">🟢 Bot is RUNNING</span>'
else:
status_html = '<span style="color: red; font-weight: bold;">🔴 Bot is STOPPED</span>'
# Add control buttons
status_html += '<br><br>'
if is_running:
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_stop_bot")}">Stop Bot</a> '
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_restart_bot")}">Restart Bot</a>'
else:
if obj.enabled and obj.bot_token:
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_start_bot")}">Start Bot</a>'
else:
status_html += '<span style="color: gray;">Configure bot token and enable bot to start</span>'
return format_html(status_html)
bot_status_display.short_description = "Bot Status"
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('start-bot/', self.start_bot, name='telegram_bot_start_bot'),
path('stop-bot/', self.stop_bot, name='telegram_bot_stop_bot'),
path('restart-bot/', self.restart_bot, name='telegram_bot_restart_bot'),
]
return custom_urls + urls
def start_bot(self, request):
"""Start the telegram bot"""
try:
from .bot import TelegramBotManager
manager = TelegramBotManager()
manager.start()
messages.success(request, "Bot started successfully!")
except Exception as e:
messages.error(request, f"Failed to start bot: {e}")
logger.error(f"Failed to start bot: {e}")
return redirect('admin:telegram_bot_botsettings_change', object_id=1)
def stop_bot(self, request):
"""Stop the telegram bot"""
try:
from .bot import TelegramBotManager
manager = TelegramBotManager()
manager.stop()
messages.success(request, "Bot stopped successfully!")
except Exception as e:
messages.error(request, f"Failed to stop bot: {e}")
logger.error(f"Failed to stop bot: {e}")
return redirect('admin:telegram_bot_botsettings_change', object_id=1)
def restart_bot(self, request):
"""Restart the telegram bot"""
try:
from .bot import TelegramBotManager
manager = TelegramBotManager()
manager.restart()
messages.success(request, "Bot restarted successfully!")
except Exception as e:
messages.error(request, f"Failed to restart bot: {e}")
logger.error(f"Failed to restart bot: {e}")
return redirect('admin:telegram_bot_botsettings_change', object_id=1)
def has_add_permission(self, request):
# Prevent creating multiple instances
return not BotSettings.objects.exists()
def has_delete_permission(self, request, obj=None):
# Prevent deletion
return False
@admin.register(TelegramMessage)
class TelegramMessageAdmin(admin.ModelAdmin):
list_display = (
'created_at',
'direction_display',
'user_display',
'language_display',
'message_preview',
'linked_user'
)
list_filter = (
'direction',
'created_at',
('linked_user', admin.EmptyFieldListFilter),
)
search_fields = (
'telegram_username',
'telegram_first_name',
'telegram_last_name',
'message_text',
'telegram_user_id'
)
readonly_fields = (
'direction',
'telegram_user_id',
'telegram_username',
'telegram_first_name',
'telegram_last_name',
'chat_id',
'message_id',
'message_text',
'raw_data_display',
'created_at',
'linked_user',
'user_language'
)
fieldsets = (
('Message Info', {
'fields': (
'direction',
'message_text',
'created_at'
)
}),
('Telegram User', {
'fields': (
'telegram_user_id',
'telegram_username',
'telegram_first_name',
'telegram_last_name',
)
}),
('Technical Details', {
'fields': (
'chat_id',
'message_id',
'linked_user',
'raw_data_display'
),
'classes': ('collapse',)
})
)
ordering = ['-created_at']
list_per_page = 50
date_hierarchy = 'created_at'
def direction_display(self, obj):
"""Display direction with icon"""
if obj.direction == 'incoming':
return format_html('<span style="color: blue;">⬇️ Incoming</span>')
else:
return format_html('<span style="color: green;">⬆️ Outgoing</span>')
direction_display.short_description = "Direction"
def user_display(self, obj):
"""Display user info"""
display = obj.display_name
if obj.telegram_user_id:
display += f" (ID: {obj.telegram_user_id})"
return display
user_display.short_description = "Telegram User"
def language_display(self, obj):
"""Display user language"""
lang_map = {'ru': '🇷🇺 RU', 'en': '🇺🇸 EN'}
return lang_map.get(obj.user_language, obj.user_language or 'Unknown')
language_display.short_description = "Language"
def message_preview(self, obj):
"""Show message preview"""
if len(obj.message_text) > 100:
return obj.message_text[:100] + "..."
return obj.message_text
message_preview.short_description = "Message"
def raw_data_display(self, obj):
"""Display raw data as formatted JSON"""
import json
if obj.raw_data:
formatted = json.dumps(obj.raw_data, indent=2, ensure_ascii=False)
return format_html('<pre style="max-width: 800px; overflow: auto;">{}</pre>', formatted)
return "No raw data"
raw_data_display.short_description = "Raw Data"
def has_add_permission(self, request):
# Messages are created automatically by bot
return False
def has_change_permission(self, request, obj=None):
# Messages are read-only
return False
def has_delete_permission(self, request, obj=None):
# Allow deletion for cleanup
return request.user.is_superuser
def get_actions(self, request):
"""Add custom actions"""
actions = super().get_actions(request)
if not request.user.is_superuser:
# Remove delete action for non-superusers
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
@admin.register(AccessRequest)
class AccessRequestAdmin(admin.ModelAdmin):
form = AccessRequestAdminForm
list_display = (
'created_at',
'user_display',
'approved_display',
'language_display',
'desired_username_display',
'message_preview',
'created_user',
'processed_by'
)
list_filter = (
'approved',
'created_at',
'processed_at',
('processed_by', admin.EmptyFieldListFilter),
)
search_fields = (
'telegram_username',
'telegram_first_name',
'telegram_last_name',
'telegram_user_id',
'message_text'
)
readonly_fields = (
'telegram_user_id',
'telegram_username',
'telegram_first_name',
'telegram_last_name',
'message_text',
'chat_id',
'created_at',
'first_message',
'processed_at',
'processed_by',
'created_user',
'user_language'
)
fieldsets = (
('Request Info', {
'fields': (
'approved',
'admin_comment',
'created_at',
'processed_at',
'processed_by'
)
}),
('User Creation', {
'fields': (
'selected_existing_user',
'desired_username',
),
'description': 'Choose existing user to link OR specify username for new user'
}),
('VPN Access Configuration', {
'fields': (
'selected_subscription_groups',
),
'description': 'Select subscription groups to assign to the user'
}),
('Telegram User', {
'fields': (
'telegram_user_id',
'telegram_username',
'telegram_first_name',
'telegram_last_name',
)
}),
('Message Details', {
'fields': (
'message_text',
'chat_id',
'first_message'
),
'classes': ('collapse',)
}),
('Processing Results', {
'fields': (
'created_user',
)
})
)
ordering = ['-created_at']
list_per_page = 50
date_hierarchy = 'created_at'
actions = ['approve_requests']
def user_display(self, obj):
"""Display user info"""
return obj.display_name
user_display.short_description = "Telegram User"
def approved_display(self, obj):
"""Display approved status with colors"""
if obj.approved:
return format_html('<span style="color: green; font-weight: bold;">✅ Approved</span>')
else:
return format_html('<span style="color: orange; font-weight: bold;">🔄 Pending</span>')
approved_display.short_description = "Status"
def message_preview(self, obj):
"""Show message preview"""
if len(obj.message_text) > 100:
return obj.message_text[:100] + "..."
return obj.message_text
message_preview.short_description = "Message"
def desired_username_display(self, obj):
"""Display desired username"""
if obj.desired_username:
return obj.desired_username
else:
fallback = obj.telegram_username or obj.telegram_first_name or f"tg_{obj.telegram_user_id}"
return format_html('<span style="color: gray; font-style: italic;">{}</span>', fallback)
desired_username_display.short_description = "Desired Username"
def language_display(self, obj):
"""Display user language with flag"""
lang_map = {'ru': '🇷🇺 RU', 'en': '🇺🇸 EN'}
return lang_map.get(obj.user_language, obj.user_language or 'Unknown')
language_display.short_description = "Language"
def approve_requests(self, request, queryset):
"""Approve selected access requests"""
pending_requests = queryset.filter(approved=False)
count = 0
errors = []
for access_request in pending_requests:
try:
logger.info(f"Approving request {access_request.id} from user {access_request.telegram_user_id}")
user = self._create_user_from_request(access_request, request.user)
if user:
access_request.approved = True
access_request.processed_by = request.user
access_request.processed_at = timezone.now()
access_request.created_user = user
access_request.save()
logger.info(f"Successfully approved request {access_request.id}, created user {user.username}")
# Send notification to user
self._send_approval_notification(access_request)
count += 1
else:
errors.append(f"Failed to create user for {access_request.display_name}")
except Exception as e:
error_msg = f"Failed to approve request from {access_request.display_name}: {e}"
logger.error(error_msg)
errors.append(error_msg)
if count:
messages.success(request, f"Successfully approved {count} request(s)")
if errors:
for error in errors:
messages.error(request, error)
approve_requests.short_description = "✅ Approve selected requests"
def save_model(self, request, obj, form, change):
"""Override save to handle existing user linking"""
super().save_model(request, obj, form, change)
# If approved and existing user was selected, link them
if obj.approved and obj.selected_existing_user and not obj.created_user:
try:
# Link telegram data to selected user
obj.selected_existing_user.telegram_user_id = obj.telegram_user_id
obj.selected_existing_user.telegram_username = obj.telegram_username
obj.selected_existing_user.telegram_first_name = obj.telegram_first_name or ""
obj.selected_existing_user.telegram_last_name = obj.telegram_last_name or ""
obj.selected_existing_user.save()
# Update the request to reference the linked user
obj.created_user = obj.selected_existing_user
obj.processed_by = request.user
obj.processed_at = timezone.now()
obj.save()
# Assign VPN access to the linked user
try:
self._assign_vpn_access(obj.selected_existing_user, obj)
except Exception as e:
logger.error(f"Failed to assign VPN access: {e}")
messages.warning(request, f"User linked but VPN access assignment failed: {e}")
# Send notification
self._send_approval_notification(obj)
messages.success(request, f"Successfully linked Telegram user to existing user {obj.selected_existing_user.username}")
logger.info(f"Linked Telegram user {obj.telegram_user_id} to existing user {obj.selected_existing_user.username}")
except Exception as e:
messages.error(request, f"Failed to link existing user: {e}")
logger.error(f"Failed to link existing user: {e}")
def _create_user_from_request(self, access_request, admin_user):
"""Create User from AccessRequest or link to existing user"""
from vpn.models import User
import secrets
import string
try:
# Check if user already exists by telegram_user_id
existing_user = User.objects.filter(telegram_user_id=access_request.telegram_user_id).first()
if existing_user:
logger.info(f"User already exists: {existing_user.username}")
return existing_user
# Check if admin selected an existing user to link
if access_request.selected_existing_user:
selected_user = access_request.selected_existing_user
logger.info(f"Linking Telegram user {access_request.telegram_user_id} to selected existing user {selected_user.username}")
# Link telegram data to selected user
selected_user.telegram_user_id = access_request.telegram_user_id
selected_user.telegram_username = access_request.telegram_username
selected_user.telegram_first_name = access_request.telegram_first_name or ""
selected_user.telegram_last_name = access_request.telegram_last_name or ""
selected_user.save()
# Assign VPN access to the linked user
try:
self._assign_vpn_access(selected_user, access_request)
except Exception as e:
logger.error(f"Failed to assign VPN access to user {selected_user.username}: {e}")
return selected_user
# Check if we can link to existing user by telegram_username
if access_request.telegram_username:
existing_user_by_username = User.objects.filter(
telegram_username__iexact=access_request.telegram_username,
telegram_user_id__isnull=True # Not yet linked to Telegram
).first()
if existing_user_by_username:
# Link telegram data to existing user
logger.info(f"Linking Telegram @{access_request.telegram_username} to existing user {existing_user_by_username.username}")
existing_user_by_username.telegram_user_id = access_request.telegram_user_id
existing_user_by_username.telegram_username = access_request.telegram_username
existing_user_by_username.telegram_first_name = access_request.telegram_first_name or ""
existing_user_by_username.telegram_last_name = access_request.telegram_last_name or ""
existing_user_by_username.save()
# Assign VPN access to the linked user
try:
self._assign_vpn_access(existing_user_by_username, access_request)
except Exception as e:
logger.error(f"Failed to assign VPN access to user {existing_user_by_username.username}: {e}")
return existing_user_by_username
# Use desired_username if provided, otherwise fallback to Telegram data
username = access_request.desired_username
if not username:
# Fallback to telegram_username, first_name or user_id
username = access_request.telegram_username or access_request.telegram_first_name or f"tg_{access_request.telegram_user_id}"
# Clean username (remove special characters)
username = ''.join(c for c in username if c.isalnum() or c in '_-').lower()
if not username:
username = f"tg_{access_request.telegram_user_id}"
# Make sure username is unique
original_username = username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{original_username}_{counter}"
counter += 1
# Create new user since no existing user found to link
# Generate random password
password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
logger.info(f"Creating new user with username: {username}")
# Create user
user = User.objects.create_user(
username=username,
password=password,
first_name=access_request.telegram_first_name or "",
last_name=access_request.telegram_last_name or "",
telegram_user_id=access_request.telegram_user_id,
telegram_username=access_request.telegram_username or "",
telegram_first_name=access_request.telegram_first_name or "",
telegram_last_name=access_request.telegram_last_name or "",
is_active=True
)
logger.info(f"Successfully created user {user.username} (ID: {user.id}) from Telegram request {access_request.id}")
# Assign VPN access (inbounds and subscription groups)
try:
self._assign_vpn_access(user, access_request)
except Exception as e:
logger.error(f"Failed to assign VPN access to user {user.username}: {e}")
# Continue even if VPN assignment fails - user is already created
return user
except Exception as e:
logger.error(f"Error creating user from request {access_request.id}: {e}")
raise
def _assign_vpn_access(self, user, access_request):
"""Assign selected subscription groups to the user"""
try:
from vpn.models_xray import UserSubscription
# Assign subscription groups
group_count = 0
for subscription_group in access_request.selected_subscription_groups.all():
user_subscription, created = UserSubscription.objects.get_or_create(
user=user,
subscription_group=subscription_group,
defaults={'active': True}
)
if created:
logger.info(f"Assigned subscription group '{subscription_group.name}' to user {user.username}")
group_count += 1
else:
# Ensure it's active if it already existed
if not user_subscription.active:
user_subscription.active = True
user_subscription.save()
logger.info(f"Re-activated subscription group '{subscription_group.name}' for user {user.username}")
group_count += 1
logger.info(f"Successfully assigned {group_count} subscription groups to user {user.username}")
except Exception as e:
logger.error(f"Error assigning VPN access to user {user.username}: {e}")
raise
def _send_approval_notification(self, access_request):
"""Send approval notification via Telegram"""
try:
from .models import BotSettings
from telegram import Bot
import asyncio
settings = BotSettings.get_settings()
if not settings.enabled or not settings.bot_token:
logger.warning("Bot not configured, skipping notification")
return
# Create a simple Bot instance for sending notification
# This bypasses the need for the running bot manager
async def send_notification():
try:
# Create bot with custom request settings
from telegram.request import HTTPXRequest
request_kwargs = {
'connection_pool_size': 1,
'read_timeout': settings.connection_timeout,
'write_timeout': settings.connection_timeout,
'connect_timeout': settings.connection_timeout,
}
if settings.use_proxy and settings.proxy_url:
request_kwargs['proxy'] = settings.proxy_url
request = HTTPXRequest(**request_kwargs)
bot = Bot(token=settings.bot_token, request=request)
# Send localized approval message with new keyboard
from telegram import ReplyKeyboardMarkup, KeyboardButton
language = access_request.user_language or 'en'
# Get localized texts
message = MessageLocalizer.get_message('approval_notification', language)
access_btn_text = MessageLocalizer.get_button_text('access', language)
# Create keyboard with Access button
keyboard = [[KeyboardButton(access_btn_text)]]
reply_markup = ReplyKeyboardMarkup(
keyboard,
resize_keyboard=True,
one_time_keyboard=False
)
await bot.send_message(
chat_id=access_request.telegram_user_id,
text=message,
reply_markup=reply_markup
)
logger.info(f"Sent approval notification to {access_request.telegram_user_id}")
except Exception as e:
logger.error(f"Failed to send Telegram message: {e}")
finally:
try:
# Clean up bot connection
await request.shutdown()
except:
pass
# Run in thread to avoid blocking admin interface
import threading
def run_async_notification():
try:
asyncio.run(send_notification())
except Exception as e:
logger.error(f"Error in notification thread: {e}")
thread = threading.Thread(target=run_async_notification, daemon=True)
thread.start()
except Exception as e:
logger.error(f"Failed to send approval notification: {e}")
def has_add_permission(self, request):
# Requests are created by bot
return False
def has_change_permission(self, request, obj=None):
# Allow changing only status and comment
return True
def save_model(self, request, obj, form, change):
"""Automatically handle approval and user creation"""
# Check if this is a change to approved
was_approved = False
# If desired_username was changed and is empty, set default from Telegram data
if change and 'desired_username' in form.changed_data and not obj.desired_username:
obj.desired_username = obj.telegram_username or obj.telegram_first_name or f"tg_{obj.telegram_user_id}"
if change and 'approved' in form.changed_data and obj.approved:
# Set processed_by and processed_at
if not obj.processed_by:
obj.processed_by = request.user
if not obj.processed_at:
obj.processed_at = timezone.now()
was_approved = True
# If approved and no user created yet, create user
if was_approved and not obj.created_user:
try:
logger.info(f"Auto-creating user for approved request {obj.id}")
user = self._create_user_from_request(obj, request.user)
if user:
obj.created_user = user
messages.success(request, f"User '{user.username}' created successfully!")
logger.info(f"Auto-created user {user.username} for request {obj.id}")
# Send approval notification
self._send_approval_notification(obj)
else:
messages.error(request, f"Failed to create user for approved request {obj.id}")
except Exception as e:
messages.error(request, f"Error creating user: {e}")
logger.error(f"Error auto-creating user for request {obj.id}: {e}")
# Save the object
super().save_model(request, obj, form, change)

75
telegram_bot/apps.py Normal file
View File

@@ -0,0 +1,75 @@
from django.apps import AppConfig
import logging
logger = logging.getLogger(__name__)
class TelegramBotConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'telegram_bot'
def ready(self):
"""Called when Django starts - attempt to auto-start bot if enabled"""
import sys
import os
# Skip auto-start in various scenarios
skip_conditions = [
# Management commands
'migrate' in sys.argv,
'makemigrations' in sys.argv,
'collectstatic' in sys.argv,
'shell' in sys.argv,
'test' in sys.argv,
# Celery processes
'celery' in sys.argv,
'worker' in sys.argv,
'beat' in sys.argv,
# Environment variables that indicate worker/beat processes
os.environ.get('CELERY_WORKER_NAME'),
os.environ.get('CELERY_BEAT'),
# Process name detection
any('celery' in arg.lower() for arg in sys.argv),
any('worker' in arg.lower() for arg in sys.argv),
any('beat' in arg.lower() for arg in sys.argv),
]
if any(skip_conditions):
logger.info(f"Skipping Telegram bot auto-start in process: {' '.join(sys.argv)}")
return
# Additional process detection by checking if we're in main process
try:
# Check if this is the main Django process (not a worker)
current_process = os.environ.get('DJANGO_SETTINGS_MODULE')
if not current_process:
logger.info("Skipping bot auto-start: not in main Django process")
return
except:
pass
# Delay import to avoid circular imports
try:
from .bot import TelegramBotManager
import threading
import time
def delayed_autostart():
# Wait a bit for Django to fully initialize
time.sleep(2)
try:
manager = TelegramBotManager()
if manager.auto_start_if_enabled():
logger.info("Telegram bot auto-started successfully")
else:
logger.info("Telegram bot auto-start skipped (disabled or already running)")
except Exception as e:
logger.error(f"Failed to auto-start Telegram bot: {e}")
logger.info("Starting Telegram bot auto-start thread")
# Start in background thread to not block Django startup
thread = threading.Thread(target=delayed_autostart, daemon=True)
thread.start()
except Exception as e:
logger.error(f"Error setting up Telegram bot auto-start: {e}")

1897
telegram_bot/bot.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,267 @@
"""
Message localization for Telegram bot
"""
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger(__name__)
# Translation dictionaries
MESSAGES = {
'en': {
'help_text': "📋 Welcome! Use buttons below to navigate.\n\n📊 Access - View your VPN subscriptions\n\nFor support contact administrator.",
'access_request_created': "Access request created, please wait.",
'new_user_welcome': "Welcome! To get access to VPN services, please request access using the button below.",
'pending_request_msg': "Your access request is pending approval. Please wait for administrator to review it.",
'choose_subscription': "**Choose subscription option:**",
'all_in_one_desc': "🌍 **All-in-one** - Get all subscriptions in one link",
'group_desc': "**Group** - Get specific group subscription",
'select_option': "Select an option below:",
'no_subscriptions': "❌ You don't have any active Xray subscriptions.\n\nPlease contact administrator for access.",
'group_subscription': "**Group: {group_name}**",
'subscription_link': "**🔗 Subscription Link:**",
'web_portal': "**🌐 Web Portal:**",
'tap_to_copy': "_Tap the subscription link to copy it. Use it in your Xray client._",
'all_in_one_subscription': "🌍 **All-in-one Subscription**",
'your_access_includes': "**Your Access Includes:**",
'universal_subscription_link': "**🔗 Universal Subscription Link:**",
'all_subscriptions_note': "_This link includes all your active subscriptions. Tap to copy._",
'error_loading_subscriptions': "❌ Error loading subscriptions. Please try again later.",
'error_loading_group': "❌ Error loading group subscription. Please try again later.",
'received_content': "Received your {message_type}. An administrator will review it.",
'approval_notification': "✅ Access approved!",
'content_types': {
'photo': 'photo',
'document': 'document',
'voice': 'voice',
'video': 'video',
'content': 'content'
},
'guide_title': "📖 **VPN Setup Guide**",
'guide_choose_platform': "Select your device platform:",
'web_portal_description': "_Web portal shows your access list on one convenient page with some statistics._",
'servers_in_group': "🔒 **Servers in group:**",
# Admin messages
'admin_new_request_notification': "🔔 **New Access Request**\n\n👤 **User:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Date:** {date}\n\n💬 **Message:** {message}",
'admin_access_requests_title': "📋 **Pending Access Requests**",
'admin_no_pending_requests': "✅ No pending access requests",
'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_",
'admin_choose_subscription_groups': "📦 **Choose Subscription Groups for {user_info}:**\n\nSelect groups to assign to this user:",
'admin_approval_success': "✅ **Request Approved!**\n\n👤 User: {user_info}\n📦 Groups: {groups}\n\nUser has been notified and given access.",
'admin_rejection_success': "❌ **Request Rejected**\n\n👤 User: {user_info}\n\nUser has been notified.",
'admin_request_already_processed': "⚠️ This request has already been processed by another admin.",
'admin_error_processing': "❌ Error processing request: {error}",
'android_guide': "🤖 **Android Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites\n• You can choose specific apps to use VPN while others use direct connection\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.",
'ios_guide': " **iOS Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**⚠️ Note for iOS users:**\nCurrently, only VLESS protocol works reliably on iOS. Other protocols may have connectivity issues.\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites to improve performance\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.",
'buttons': {
'access': "🌍 Get access",
'guide': "📖 Guide",
'android': "🤖 Android",
'ios': " iOS",
'web_portal': "🌐 Web Portal",
'all_in_one': "🌍 All-in-one",
'back': "⬅️ Back",
'group_prefix': "Group: ",
'request_access': "🔑 Request Access",
# Admin buttons
'access_requests': "📋 Access Requests",
'approve': "✅ Approve",
'reject': "❌ Reject",
'details': "👁 Details",
'confirm_approval': "✅ Confirm Approval",
'confirm_rejection': "❌ Confirm Rejection",
'cancel': "🚫 Cancel"
}
},
'ru': {
'help_text': "📋 Добро пожаловать! Используйте кнопки ниже для навигации.\n\n📊 Доступ - Просмотр VPN подписок\n\nДля поддержки обратитесь к администратору.",
'access_request_created': "Запрос на доступ создан, ожидайте.",
'new_user_welcome': "Добро пожаловать! Для получения доступа к VPN сервисам, пожалуйста запросите доступ с помощью кнопки ниже.",
'pending_request_msg': "Ваш запрос на доступ ожидает одобрения. Пожалуйста, дождитесь рассмотрения администратором.",
'choose_subscription': "**Выберите вариант подписки:**",
'all_in_one_desc': "🌍 **Все в одном** - Получить все подписки в одной ссылке",
'group_desc': "**Группа** - Получить подписку на группу",
'select_option': "Выберите вариант ниже:",
'no_subscriptions': "У вас нет активных Xray подписок.\n\nОбратитесь к администратору для получения доступа.",
'group_subscription': "**Группа: {group_name}**",
'subscription_link': "**🔗 **",
'web_portal': "**🌐 Веб-портал пользователя:**",
'tap_to_copy': "_Нажмите на ссылку чтобы скопировать. Используйте в вашем Xray клиенте как подписку._",
'all_in_one_subscription': "🌍 **Подписка «Все в одном»**",
'your_access_includes': "**Ваш доступ включает:**",
'universal_subscription_link': "**🔗 Универсальная ссылка на подписку:**",
'all_subscriptions_note': "_Эта ссылка включает все ваши активные подписки. Нажмите чтобы скопировать._",
'error_loading_subscriptions': "❌ Ошибка загрузки подписок. Попробуйте позже.",
'error_loading_group': "❌ Ошибка загрузки подписки группы. Попробуйте позже.",
'received_content': "Получен ваш {message_type}. Администратор его рассмотрит.",
'approval_notification': "✅ Доступ одобрен!",
'content_types': {
'photo': 'фото',
'document': 'документ',
'voice': 'голосовое сообщение',
'video': 'видео',
'content': 'контент'
},
'guide_title': "📖 **Руководство по настройке VPN**",
'guide_choose_platform': "Выберите платформу вашего устройства:",
'web_portal_description': "_Веб-портал показывает список ваших доступов на одной удобной странице с некоторой статистикой._",
'servers_in_group': "🔒 **Серверы в группе:**",
# Admin messages
'admin_new_request_notification': "🔔 **Новый запрос на доступ**\n\n👤 **Пользователь:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Дата:** {date}\n\n💬 **Сообщение:** {message}",
'admin_access_requests_title': "📋 **Ожидающие запросы на доступ**",
'admin_no_pending_requests': "✅ Нет ожидающих запросов на доступ",
'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_",
'admin_choose_subscription_groups': "📦 **Выберите группы подписки для {user_info}:**\n\nВыберите группы для назначения этому пользователю:",
'admin_approval_success': "✅ **Запрос одобрен!**\n\n👤 Пользователь: {user_info}\n📦 Группы: {groups}\n\nПользователь уведомлен и получил доступ.",
'admin_rejection_success': "❌ **Запрос отклонен**\n\n👤 Пользователь: {user_info}\n\nПользователь уведомлен.",
'admin_request_already_processed': "⚠️ Этот запрос уже обработан другим администратором.",
'admin_error_processing': "❌ Ошибка обработки запроса: {error}",
'android_guide': "🤖 **Руководство для Android**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**💡 Полезные настройки:**\nВ настройках включите прямой доступ для банковских приложений и местных сайтов\n• Вы можете выбрать конкретные приложения для использования VPN, в то время как остальные будут работать напрямую\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.",
'ios_guide': " **Руководство для iOS**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**⚠️ Важно для пользователей iOS:**\nВ настоящее время на iOS стабильно работает только протокол VLESS. Другие протоколы могут иметь проблемы с подключением.\n\n**💡 Полезные настройки:**\nВ настройках включите прямой доступ для банковских приложений и местных сайтов для улучшения производительности\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.",
'buttons': {
'access': "🌍 Получить VPN",
'guide': "📖 Гайд",
'android': "🤖 Android",
'ios': " iOS",
'web_portal': "🌐 Веб-портал",
'all_in_one': "🌍 Все в одном",
'back': "⬅️ Назад",
'group_prefix': "Группа: ",
'request_access': "🔑 Запросить доступ",
# Admin buttons
'access_requests': "📋 Запросы на доступ",
'approve': "✅ Одобрить",
'reject': "❌ Отклонить",
'details': "👁 Подробности",
'confirm_approval': "✅ Подтвердить одобрение",
'confirm_rejection': "❌ Подтвердить отклонение",
'cancel': "🚫 Отмена"
}
}
}
class MessageLocalizer:
"""Class for bot message localization"""
@staticmethod
def get_user_language(telegram_user) -> str:
"""
Determines user language from Telegram language_code
Args:
telegram_user: Telegram user object
Returns:
str: Language code ('ru' or 'en')
"""
if not telegram_user:
return 'en'
language_code = getattr(telegram_user, 'language_code', None)
if not language_code:
return 'en'
# Support Russian and English
if language_code.startswith('ru'):
return 'ru'
else:
return 'en'
@staticmethod
def get_message(key: str, language: str = 'en', **kwargs) -> str:
"""
Gets localized message
Args:
key: Message key
language: Language code
**kwargs: Formatting parameters
Returns:
str: Localized message
"""
try:
# Fallback to English if language not supported
if language not in MESSAGES:
language = 'en'
message = MESSAGES[language].get(key, MESSAGES['en'].get(key, f"Missing translation: {key}"))
# Format with parameters
if kwargs:
try:
message = message.format(**kwargs)
except (KeyError, ValueError) as e:
logger.warning(f"Error formatting message {key}: {e}")
return message
except Exception as e:
logger.error(f"Error getting message {key} for language {language}: {e}")
return f"Error: {key}"
@staticmethod
def get_button_text(button_key: str, language: str = 'en') -> str:
"""
Gets button text
Args:
button_key: Button key
language: Language code
Returns:
str: Button text
"""
try:
if language not in MESSAGES:
language = 'en'
buttons = MESSAGES[language].get('buttons', {})
return buttons.get(button_key, MESSAGES['en']['buttons'].get(button_key, button_key))
except Exception as e:
logger.error(f"Error getting button text {button_key} for language {language}: {e}")
return button_key
@staticmethod
def get_content_type_name(content_type: str, language: str = 'en') -> str:
"""
Gets localized content type name
Args:
content_type: Content type
language: Language code
Returns:
str: Localized name
"""
try:
if language not in MESSAGES:
language = 'en'
content_types = MESSAGES[language].get('content_types', {})
return content_types.get(content_type, content_type)
except Exception as e:
logger.error(f"Error getting content type {content_type} for language {language}: {e}")
return content_type
# Convenience functions for use in code
def get_localized_message(telegram_user, message_key: str, **kwargs) -> str:
"""Get localized message for user"""
language = MessageLocalizer.get_user_language(telegram_user)
return MessageLocalizer.get_message(message_key, language, **kwargs)
def get_localized_button(telegram_user, button_key: str) -> str:
"""Get localized button text for user"""
language = MessageLocalizer.get_user_language(telegram_user)
return MessageLocalizer.get_button_text(button_key, language)
def get_user_language(telegram_user) -> str:
"""Get user language"""
return MessageLocalizer.get_user_language(telegram_user)

View File

View File

@@ -0,0 +1,99 @@
import logging
import signal
import sys
import time
from django.core.management.base import BaseCommand
from django.utils import timezone
from telegram_bot.models import BotSettings, BotStatus
from telegram_bot.bot import TelegramBotManager
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Run the Telegram bot'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.bot_manager = None
self.running = False
def add_arguments(self, parser):
parser.add_argument(
'--force',
action='store_true',
help='Force start even if bot is disabled in settings',
)
def handle(self, *args, **options):
"""Main command handler"""
# Set up signal handlers
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
# Check settings
settings = BotSettings.get_settings()
if not settings.bot_token:
self.stdout.write(
self.style.ERROR('Bot token is not configured. Please configure it in the admin panel.')
)
return
if not settings.enabled and not options['force']:
self.stdout.write(
self.style.WARNING('Bot is disabled in settings. Use --force to override.')
)
return
# Initialize bot manager
self.bot_manager = TelegramBotManager()
try:
# Start the bot
self.stdout.write(self.style.SUCCESS('Starting Telegram bot...'))
self.bot_manager.start()
self.running = True
self.stdout.write(
self.style.SUCCESS(f'Bot is running. Press Ctrl+C to stop.')
)
# Keep the main thread alive
while self.running:
time.sleep(1)
# Check if bot is still running
if not self.bot_manager.is_running:
self.stdout.write(
self.style.ERROR('Bot stopped unexpectedly. Check logs for errors.')
)
break
except KeyboardInterrupt:
self.stdout.write('\nReceived interrupt signal...')
except Exception as e:
self.stdout.write(
self.style.ERROR(f'Error running bot: {e}')
)
logger.error(f'Error running bot: {e}', exc_info=True)
# Update status
status = BotStatus.get_status()
status.is_running = False
status.last_error = str(e)
status.last_stopped = timezone.now()
status.save()
finally:
# Stop the bot
if self.bot_manager:
self.stdout.write('Stopping bot...')
self.bot_manager.stop()
self.stdout.write(self.style.SUCCESS('Bot stopped.'))
def signal_handler(self, signum, frame):
"""Handle shutdown signals"""
self.stdout.write('\nShutting down gracefully...')
self.running = False

View File

@@ -0,0 +1,112 @@
import logging
import os
from django.core.management.base import BaseCommand
from django.utils import timezone
from telegram_bot.models import BotSettings, BotStatus
from telegram_bot.bot import TelegramBotManager
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Check Telegram bot status and optionally start it'
def add_arguments(self, parser):
parser.add_argument(
'--auto-start',
action='store_true',
help='Automatically start bot if enabled in settings',
)
parser.add_argument(
'--sync-status',
action='store_true',
help='Sync database status with real bot state',
)
def handle(self, *args, **options):
"""Check bot status"""
try:
manager = TelegramBotManager()
settings = BotSettings.get_settings()
status = BotStatus.get_status()
# Show current configuration
self.stdout.write(f"Bot Configuration:")
self.stdout.write(f" Enabled: {settings.enabled}")
self.stdout.write(f" Token configured: {'Yes' if settings.bot_token else 'No'}")
# Show status
real_running = manager.is_running
db_running = status.is_running
self.stdout.write(f"\nBot Status:")
self.stdout.write(f" Database status: {'Running' if db_running else 'Stopped'}")
self.stdout.write(f" Real status: {'Running' if real_running else 'Stopped'}")
# Check lock file status
from django.conf import settings as django_settings
lock_dir = os.path.join(getattr(django_settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks')
lock_path = os.path.join(lock_dir, 'telegram_bot.lock')
if os.path.exists(lock_path):
try:
with open(lock_path, 'r') as f:
lock_pid = f.read().strip()
self.stdout.write(f" Lock file: exists (PID: {lock_pid})")
except:
self.stdout.write(f" Lock file: exists (unreadable)")
else:
self.stdout.write(f" Lock file: not found")
if db_running != real_running:
self.stdout.write(
self.style.WARNING("⚠️ Status mismatch detected!")
)
if options['sync_status']:
status.is_running = real_running
if not real_running:
status.last_stopped = timezone.now()
status.save()
self.stdout.write(
self.style.SUCCESS("✅ Status synchronized")
)
# Show timestamps
if status.last_started:
self.stdout.write(f" Last started: {status.last_started}")
if status.last_stopped:
self.stdout.write(f" Last stopped: {status.last_stopped}")
if status.last_error:
self.stdout.write(f" Last error: {status.last_error}")
# Auto-start if requested
if options['auto_start']:
if not real_running and settings.enabled and settings.bot_token:
self.stdout.write("\nAttempting to start bot...")
try:
manager.start()
self.stdout.write(
self.style.SUCCESS("✅ Bot started successfully")
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f"❌ Failed to start bot: {e}")
)
elif real_running:
self.stdout.write(
self.style.SUCCESS("✅ Bot is already running")
)
elif not settings.enabled:
self.stdout.write(
self.style.WARNING("⚠️ Bot is disabled in settings")
)
elif not settings.bot_token:
self.stdout.write(
self.style.ERROR("❌ Bot token not configured")
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f"❌ Error checking bot status: {e}")
)

View File

@@ -0,0 +1,70 @@
# Generated by Django 5.1.7 on 2025-08-14 11:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BotSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bot_token', models.CharField(help_text='Telegram Bot Token from @BotFather', max_length=255)),
('enabled', models.BooleanField(default=False, help_text='Enable/Disable the bot')),
('welcome_message', models.TextField(default='Hello! Your message has been received. An administrator will review it.', help_text='Message sent when user starts conversation')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Bot Settings',
'verbose_name_plural': 'Bot Settings',
},
),
migrations.CreateModel(
name='BotStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_running', models.BooleanField(default=False)),
('last_started', models.DateTimeField(blank=True, null=True)),
('last_stopped', models.DateTimeField(blank=True, null=True)),
('last_error', models.TextField(blank=True)),
('last_update_id', models.BigIntegerField(blank=True, help_text='Last processed update ID from Telegram', null=True)),
],
options={
'verbose_name': 'Bot Status',
'verbose_name_plural': 'Bot Status',
},
),
migrations.CreateModel(
name='TelegramMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('direction', models.CharField(choices=[('incoming', 'Incoming'), ('outgoing', 'Outgoing')], db_index=True, max_length=10)),
('telegram_user_id', models.BigIntegerField(db_index=True)),
('telegram_username', models.CharField(blank=True, db_index=True, max_length=255, null=True)),
('telegram_first_name', models.CharField(blank=True, max_length=255, null=True)),
('telegram_last_name', models.CharField(blank=True, max_length=255, null=True)),
('chat_id', models.BigIntegerField(db_index=True)),
('message_id', models.BigIntegerField(blank=True, null=True)),
('message_text', models.TextField(blank=True)),
('raw_data', models.JSONField(blank=True, default=dict, help_text='Full message data from Telegram')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('linked_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='telegram_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Telegram Message',
'verbose_name_plural': 'Telegram Messages',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['-created_at', 'direction'], name='telegram_bo_created_19b81b_idx'), models.Index(fields=['telegram_user_id', '-created_at'], name='telegram_bo_telegra_f71f27_idx')],
},
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.7 on 2025-08-14 12:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('telegram_bot', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='botsettings',
name='api_base_url',
field=models.URLField(blank=True, default='https://api.telegram.org', help_text='Telegram API base URL (change for local bot API server)'),
),
migrations.AddField(
model_name='botsettings',
name='connection_timeout',
field=models.IntegerField(default=30, help_text='Connection timeout in seconds'),
),
migrations.AddField(
model_name='botsettings',
name='proxy_url',
field=models.URLField(blank=True, help_text='Proxy URL (e.g., http://proxy:8080 or socks5://proxy:1080)'),
),
migrations.AddField(
model_name='botsettings',
name='use_proxy',
field=models.BooleanField(default=False, help_text='Enable proxy for Telegram API connections'),
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 5.1.7 on 2025-08-14 12:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('telegram_bot', '0002_add_connection_settings'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AccessRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('telegram_user_id', models.BigIntegerField(db_index=True, help_text='Telegram user ID who made the request')),
('telegram_username', models.CharField(blank=True, help_text='Telegram username (without @)', max_length=255, null=True)),
('telegram_first_name', models.CharField(blank=True, help_text='First name from Telegram', max_length=255, null=True)),
('telegram_last_name', models.CharField(blank=True, help_text='Last name from Telegram', max_length=255, null=True)),
('message_text', models.TextField(help_text='The message sent by user when requesting access')),
('chat_id', models.BigIntegerField(help_text='Telegram chat ID for sending notifications')),
('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=20)),
('admin_comment', models.TextField(blank=True, help_text='Admin comment for approval/rejection')),
('created_at', models.DateTimeField(auto_now_add=True)),
('processed_at', models.DateTimeField(blank=True, null=True)),
('created_user', models.ForeignKey(blank=True, help_text='User created from this request (when approved)', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('first_message', models.ForeignKey(blank=True, help_text='First message from this user', null=True, on_delete=django.db.models.deletion.SET_NULL, to='telegram_bot.telegrammessage')),
('processed_by', models.ForeignKey(blank=True, help_text='Admin who processed this request', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='processed_requests', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Access Request',
'verbose_name_plural': 'Access Requests',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['telegram_user_id'], name='telegram_bo_telegra_e3429d_idx'), models.Index(fields=['status', '-created_at'], name='telegram_bo_status_cf9310_idx'), models.Index(fields=['-created_at'], name='telegram_bo_created_c82a74_idx')],
'constraints': [models.UniqueConstraint(fields=('telegram_user_id',), name='unique_telegram_user_request')],
},
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.1.7 on 2025-08-14 13:49
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('telegram_bot', '0003_accessrequest'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveIndex(
model_name='accessrequest',
name='telegram_bo_status_cf9310_idx',
),
migrations.RemoveField(
model_name='accessrequest',
name='status',
),
migrations.AddField(
model_name='accessrequest',
name='approved',
field=models.BooleanField(db_index=True, default=False, help_text='Request approved by administrator'),
),
migrations.AlterField(
model_name='accessrequest',
name='admin_comment',
field=models.TextField(blank=True, help_text='Admin comment for approval'),
),
migrations.AddIndex(
model_name='accessrequest',
index=models.Index(fields=['approved', '-created_at'], name='telegram_bo_approve_7ae92d_idx'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.7 on 2025-08-14 22:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('telegram_bot', '0004_remove_accessrequest_telegram_bo_status_cf9310_idx_and_more'),
]
operations = [
migrations.DeleteModel(
name='BotStatus',
),
migrations.RemoveField(
model_name='botsettings',
name='welcome_message',
),
migrations.AddField(
model_name='botsettings',
name='help_message',
field=models.TextField(default='📋 Available commands:\n/start - Start conversation\n📊 Access - View your VPN subscriptions\n\nFor support contact administrator.', help_text='Help message sent for unrecognized commands'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-08-14 22:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('telegram_bot', '0005_delete_botstatus_remove_botsettings_welcome_message_and_more'),
]
operations = [
migrations.AddField(
model_name='accessrequest',
name='desired_username',
field=models.CharField(blank=True, help_text='Desired username for VPN user (defaults to Telegram username)', max_length=150),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.1.7 on 2025-08-14 22:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('telegram_bot', '0006_accessrequest_desired_username'),
]
operations = [
migrations.RemoveField(
model_name='botsettings',
name='help_message',
),
migrations.AddField(
model_name='accessrequest',
name='user_language',
field=models.CharField(default='en', help_text="User's preferred language (en/ru)", max_length=10),
),
migrations.AddField(
model_name='telegrammessage',
name='user_language',
field=models.CharField(default='en', help_text="User's preferred language (en/ru)", max_length=10),
),
]

View File

@@ -0,0 +1,28 @@
# Generated migration for adding selected_existing_user field
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('telegram_bot', '0007_remove_botsettings_help_message_and_more'),
]
operations = [
migrations.AddField(
model_name='accessrequest',
name='selected_existing_user',
field=models.ForeignKey(
blank=True,
help_text='Existing user selected to link with this Telegram account',
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='selected_for_requests',
to=settings.AUTH_USER_MODEL
),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.1.7 on 2025-08-15 12:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('telegram_bot', '0008_accessrequest_selected_existing_user'),
('vpn', '0026_alter_subscriptiongroup_options'),
]
operations = [
migrations.AddField(
model_name='accessrequest',
name='selected_inbounds',
field=models.ManyToManyField(blank=True, help_text='Inbound templates to assign to the user', to='vpn.inbound'),
),
migrations.AddField(
model_name='accessrequest',
name='selected_subscription_groups',
field=models.ManyToManyField(blank=True, help_text='Subscription groups to assign to the user', to='vpn.subscriptiongroup'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.7 on 2025-08-15 13:00
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('telegram_bot', '0009_accessrequest_selected_inbounds_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='botsettings',
name='telegram_admins',
field=models.ManyToManyField(blank=True, help_text='Users with linked Telegram accounts who will have admin access in the bot', related_name='bot_admin_settings', to=settings.AUTH_USER_MODEL),
),
]

View File

318
telegram_bot/models.py Normal file
View File

@@ -0,0 +1,318 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
import json
User = get_user_model()
class BotSettings(models.Model):
"""Singleton model for bot settings"""
bot_token = models.CharField(
max_length=255,
help_text="Telegram Bot Token from @BotFather"
)
enabled = models.BooleanField(
default=False,
help_text="Enable/Disable the bot"
)
use_proxy = models.BooleanField(
default=False,
help_text="Enable proxy for Telegram API connections"
)
proxy_url = models.URLField(
blank=True,
help_text="Proxy URL (e.g., http://proxy:8080 or socks5://proxy:1080)"
)
api_base_url = models.URLField(
blank=True,
default="https://api.telegram.org",
help_text="Telegram API base URL (change for local bot API server)"
)
connection_timeout = models.IntegerField(
default=30,
help_text="Connection timeout in seconds"
)
telegram_admins = models.ManyToManyField(
User,
blank=True,
related_name='bot_admin_settings',
help_text="Users with linked Telegram accounts who will have admin access in the bot"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Bot Settings"
verbose_name_plural = "Bot Settings"
def save(self, *args, **kwargs):
# Ensure only one instance exists
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
# Prevent deletion
pass
@classmethod
def get_settings(cls):
"""Get or create singleton settings"""
obj, created = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return f"Bot Settings ({'Enabled' if self.enabled else 'Disabled'})"
class TelegramMessage(models.Model):
"""Store all telegram messages"""
DIRECTION_CHOICES = [
('incoming', 'Incoming'),
('outgoing', 'Outgoing'),
]
direction = models.CharField(
max_length=10,
choices=DIRECTION_CHOICES,
db_index=True
)
# Telegram user info
telegram_user_id = models.BigIntegerField(db_index=True)
telegram_username = models.CharField(
max_length=255,
blank=True,
null=True,
db_index=True
)
telegram_first_name = models.CharField(
max_length=255,
blank=True,
null=True
)
telegram_last_name = models.CharField(
max_length=255,
blank=True,
null=True
)
user_language = models.CharField(
max_length=10,
default='en',
help_text="User's preferred language (en/ru)"
)
# Message info
chat_id = models.BigIntegerField(db_index=True)
message_id = models.BigIntegerField(null=True, blank=True)
message_text = models.TextField(blank=True)
# Additional data
raw_data = models.JSONField(
default=dict,
blank=True,
help_text="Full message data from Telegram"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
# Optional link to VPN user if identified
linked_user = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='telegram_messages'
)
class Meta:
verbose_name = "Telegram Message"
verbose_name_plural = "Telegram Messages"
ordering = ['-created_at']
indexes = [
models.Index(fields=['-created_at', 'direction']),
models.Index(fields=['telegram_user_id', '-created_at']),
]
def __str__(self):
username = self.telegram_username or f"ID:{self.telegram_user_id}"
direction_icon = "⬇️" if self.direction == 'incoming' else "⬆️"
text_preview = self.message_text[:50] + "..." if len(self.message_text) > 50 else self.message_text
return f"{direction_icon} {username}: {text_preview}"
@property
def full_name(self):
"""Get full name of telegram user"""
parts = []
if self.telegram_first_name:
parts.append(self.telegram_first_name)
if self.telegram_last_name:
parts.append(self.telegram_last_name)
return " ".join(parts) if parts else f"User {self.telegram_user_id}"
@property
def display_name(self):
"""Get best available display name"""
if self.telegram_username:
return f"`@{self.telegram_username}`"
return self.full_name
class AccessRequest(models.Model):
"""Access requests from Telegram users"""
# Telegram user information
telegram_user_id = models.BigIntegerField(
db_index=True,
help_text="Telegram user ID who made the request"
)
telegram_username = models.CharField(
max_length=255,
blank=True,
null=True,
help_text="Telegram username (without @)"
)
telegram_first_name = models.CharField(
max_length=255,
blank=True,
null=True,
help_text="First name from Telegram"
)
telegram_last_name = models.CharField(
max_length=255,
blank=True,
null=True,
help_text="Last name from Telegram"
)
# Request details
message_text = models.TextField(
help_text="The message sent by user when requesting access"
)
chat_id = models.BigIntegerField(
help_text="Telegram chat ID for sending notifications"
)
# Username for VPN user creation
desired_username = models.CharField(
max_length=150,
blank=True,
help_text="Desired username for VPN user (defaults to Telegram username)"
)
# User language
user_language = models.CharField(
max_length=10,
default='en',
help_text="User's preferred language (en/ru)"
)
# Status and processing
approved = models.BooleanField(
default=False,
db_index=True,
help_text="Request approved by administrator"
)
admin_comment = models.TextField(
blank=True,
help_text="Admin comment for approval"
)
# Related objects
selected_existing_user = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='selected_for_requests',
help_text="Existing user selected to link with this Telegram account"
)
created_user = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text="User created from this request (when approved)"
)
processed_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='processed_requests',
help_text="Admin who processed this request"
)
first_message = models.ForeignKey(
TelegramMessage,
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text="First message from this user"
)
# Inbound templates and subscription groups
selected_inbounds = models.ManyToManyField(
'vpn.Inbound',
blank=True,
help_text="Inbound templates to assign to the user"
)
selected_subscription_groups = models.ManyToManyField(
'vpn.SubscriptionGroup',
blank=True,
help_text="Subscription groups to assign to the user"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "Access Request"
verbose_name_plural = "Access Requests"
ordering = ['-created_at']
indexes = [
models.Index(fields=['telegram_user_id']),
models.Index(fields=['approved', '-created_at']),
models.Index(fields=['-created_at']),
]
constraints = [
models.UniqueConstraint(
fields=['telegram_user_id'],
name='unique_telegram_user_request'
)
]
def __str__(self):
username = self.telegram_username or f"ID:{self.telegram_user_id}"
status = "Approved" if self.approved else "Pending"
return f"Request from @{username} ({status})"
@property
def display_name(self):
"""Get best available display name"""
if self.telegram_username:
return f"`@{self.telegram_username}`"
name_parts = []
if self.telegram_first_name:
name_parts.append(self.telegram_first_name)
if self.telegram_last_name:
name_parts.append(self.telegram_last_name)
if name_parts:
return " ".join(name_parts)
return f"User {self.telegram_user_id}"
@property
def full_name(self):
"""Get full name of telegram user"""
parts = []
if self.telegram_first_name:
parts.append(self.telegram_first_name)
if self.telegram_last_name:
parts.append(self.telegram_last_name)
return " ".join(parts) if parts else None

3
telegram_bot/tests.py Normal file
View File

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

3
telegram_bot/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

View File

@@ -0,0 +1,202 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h3>{{ title }}</h3>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="protocol">Protocol *</label>
<select name="protocol" id="protocol" class="form-control" required>
<option value="">Select Protocol</option>
{% for proto in protocols %}
<option value="{{ proto }}">{{ proto|upper }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="port">Port *</label>
<input type="number" name="port" id="port" class="form-control"
min="1" max="65535" required>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="tag">Tag</label>
<input type="text" name="tag" id="tag" class="form-control"
placeholder="Auto-generated if empty">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="network">Network</label>
<select name="network" id="network" class="form-control">
{% for net in networks %}
<option value="{{ net }}" {% if net == 'tcp' %}selected{% endif %}>
{{ net|upper }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="security">Security</label>
<select name="security" id="security" class="form-control">
{% for sec in securities %}
<option value="{{ sec }}" {% if sec == 'none' %}selected{% endif %}>
{{ sec|upper }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="form-group">
<div class="alert alert-info">
<strong>Note:</strong> The inbound will be created on both the Django database and the Xray server via gRPC API.
</div>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success">
Create Inbound
</button>
<a href="{% url 'admin:vpn_xraycoreserver_change' server.pk %}" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const protocolField = document.getElementById('protocol');
const portField = document.getElementById('port');
const tagField = document.getElementById('tag');
// Auto-suggest ports based on protocol
protocolField.addEventListener('change', function() {
const protocol = this.value;
const ports = {
'vless': 443,
'vmess': 443,
'trojan': 443
};
if (ports[protocol] && !portField.value) {
portField.value = ports[protocol];
}
if (protocol && !tagField.value) {
tagField.placeholder = `${protocol}-${portField.value || 'PORT'}`;
}
});
portField.addEventListener('input', function() {
const protocol = protocolField.value;
if (protocol && !tagField.value) {
tagField.placeholder = `${protocol}-${this.value}`;
}
});
});
</script>
<style>
.card {
max-width: 800px;
margin: 20px auto;
border: 1px solid #ddd;
border-radius: 8px;
}
.card-header {
background: #f8f9fa;
padding: 15px;
border-bottom: 1px solid #ddd;
}
.card-body {
padding: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
font-weight: bold;
margin-bottom: 5px;
display: block;
}
.form-control {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.alert {
padding: 12px;
border-radius: 4px;
margin: 15px 0;
}
.alert-info {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}
.row {
display: flex;
margin: 0 -10px;
}
.col-md-6 {
flex: 0 0 50%;
padding: 0 10px;
}
</style>
{% endblock %}

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
<h1>Last sync log</h1>
<form action="/sync" class="pure-form pure-form-stacked" method="POST">
<p>Wipe ALL keys on ALL servers?</p>
<label for="no_wipe" class="pure-radio">
<input type="radio" id="no_wipe" name="wipe" value="no_wipe" checked /> No
</label>
<label for="do_wipe" class="pure-radio">
<input type="radio" id="do_wipe" name="wipe" value="all" /> Yes
</label>
<button type="submit" class="pure-button button-error pure-input-1 ">Sync now</button>
</form>
<pre>
<code>
{% for line in lines %}{{ line }}{% endfor %}
</code>
</pre>

0
vpn/__init__.py Normal file
View File

169
vpn/admin.py Normal file
View File

@@ -0,0 +1,169 @@
"""
Django admin configuration for VPN application
This module has been refactored for better organization. The admin classes
are now split across multiple modules in the vpn.admin package:
- vpn.admin.user: User management admin interface
- vpn.admin.server: Server management admin interface
- vpn.admin.access: Access control (ACL/ACLLink) admin interfaces
- vpn.admin.logs: Logging (TaskExecutionLog/AccessLog) admin interfaces
- vpn.admin.base: Common utilities and base classes
"""
import logging
logger = logging.getLogger(__name__)
import json
from django.contrib import admin
from django.utils.safestring import mark_safe
# Import server plugins and their admin classes
try:
from .server_plugins import (
XrayServerV2,
XrayServerV2Admin
)
except Exception as e:
logger.error(f"❌ Failed to import server plugins: {e}")
# Import admin interfaces from refactored modules
# This ensures all admin classes are registered
try:
from .admin import *
except Exception as e:
logger.error(f"❌ Failed to import refactored admin modules: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
# Import Xray admin configuration and all Xray admin classes
try:
from .admin_xray import *
except Exception as e:
logger.error(f"❌ Failed to import Xray admin classes: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
# Set custom admin site configuration
admin.site.site_title = "VPN Manager"
admin.site.site_header = "VPN Manager"
admin.site.index_title = "OutFleet"
# Custom Celery admin interfaces
try:
from django_celery_results.models import TaskResult
# Unregister default TaskResult admin if it exists
try:
admin.site.unregister(TaskResult)
except admin.sites.NotRegistered:
pass
@admin.register(TaskResult)
class CustomTaskResultAdmin(admin.ModelAdmin):
list_display = ('task_name_display', 'status', 'date_created', 'date_done', 'worker', 'result_display', 'traceback_display')
list_filter = ('status', 'date_created', 'worker', 'task_name')
search_fields = ('task_name', 'task_id', 'worker')
readonly_fields = ('task_id', 'task_name', 'status', 'result_formatted', 'date_created', 'date_done', 'traceback', 'worker', 'task_args', 'task_kwargs', 'meta')
ordering = ('-date_created',)
list_per_page = 50
fieldsets = (
('Task Information', {
'fields': ('task_id', 'task_name', 'status', 'worker')
}),
('Timing', {
'fields': ('date_created', 'date_done')
}),
('Result', {
'fields': ('result_formatted',),
'classes': ('collapse',)
}),
('Arguments', {
'fields': ('task_args', 'task_kwargs'),
'classes': ('collapse',)
}),
('Error Details', {
'fields': ('traceback',),
'classes': ('collapse',)
}),
('Metadata', {
'fields': ('meta',),
'classes': ('collapse',)
}),
)
@admin.display(description='Task Name', ordering='task_name')
def task_name_display(self, obj):
task_names = {
'sync_all_servers': '🔄 Sync All Servers',
'sync_server_users': '👥 Sync Users on Server',
'sync_server_info': '⚙️ Sync Server Info',
'sync_user_on_server': '👤 Sync User on Server',
'cleanup_task_logs': '🧹 Cleanup Old Logs',
'update_user_statistics': '📊 Update Statistics',
}
return task_names.get(obj.task_name, obj.task_name)
@admin.display(description='Result')
def result_display(self, obj):
if obj.status == 'SUCCESS' and obj.result:
try:
result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result
if isinstance(result, str):
return result[:100] + '...' if len(result) > 100 else result
elif isinstance(result, dict):
return ', '.join(f'{k}: {v}' for k, v in result.items())[:100]
except:
return str(obj.result)[:100] if obj.result else '-'
elif obj.status == 'FAILURE':
return '❌ Failed'
elif obj.status == 'PENDING':
return '⏳ Pending'
elif obj.status == 'RETRY':
return '🔄 Retrying'
return '-'
@admin.display(description='Result Details')
def result_formatted(self, obj):
if obj.result:
try:
result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result
formatted = json.dumps(result, indent=2)
return mark_safe(f"<pre>{formatted}</pre>")
except:
return mark_safe(f"<pre>{obj.result}</pre>")
return '-'
@admin.display(description='Error Info')
def traceback_display(self, obj):
if obj.traceback:
# Show first 200 chars of traceback
short_tb = obj.traceback[:200] + '...' if len(obj.traceback) > 200 else obj.traceback
return mark_safe(f"<pre style='color: red; font-size: 12px;'>{short_tb}</pre>")
return '-'
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
except ImportError:
pass # Celery not available
# Add subscription management to User admin
try:
from vpn.admin_xray import add_subscription_management_to_user
from vpn.admin.user import UserAdmin
add_subscription_management_to_user(UserAdmin)
logger.info("✅ Successfully added subscription management to User admin")
except Exception as e:
logger.error(f"Failed to add subscription management: {e}")
# Note: Unwanted admin interfaces are cleaned up in vpn/apps.py ready() method
# Force reload trigger

45
vpn/admin/__init__.py Normal file
View File

@@ -0,0 +1,45 @@
"""
VPN Admin Module
This module provides Django admin interfaces for the VPN application.
The admin interface has been refactored into separate modules for better organization:
- base.py: Common utilities and base classes
- user.py: User management admin interface
- server.py: Server management admin interface
- access.py: Access control (ACL/ACLLink) admin interfaces
- logs.py: Logging (TaskExecutionLog/AccessLog) admin interfaces
All admin classes are automatically registered with Django admin.
"""
# Import all admin classes to ensure they are registered
from .user import UserAdmin
from .server import ServerAdmin, UserACLInline
from .access import (
ACLAdmin,
ACLLinkAdmin,
UserNameFilter,
ServerNameFilter,
LastAccessFilter,
ACLLinkInline
)
from .logs import TaskExecutionLogAdmin, AccessLogAdmin
from .base import BaseVPNAdmin, format_bytes
# Re-export for backward compatibility
__all__ = [
'UserAdmin',
'ServerAdmin',
'UserACLInline',
'ACLAdmin',
'ACLLinkAdmin',
'TaskExecutionLogAdmin',
'AccessLogAdmin',
'BaseVPNAdmin',
'format_bytes',
'UserNameFilter',
'ServerNameFilter',
'LastAccessFilter',
'ACLLinkInline'
]

485
vpn/admin/access.py Normal file
View File

@@ -0,0 +1,485 @@
"""
Access control admin interfaces (ACL, ACLLink)
"""
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.html import format_html
from django.shortcuts import redirect
from django.contrib import messages
from django.utils.timezone import localtime
from django.db.models import Q
from mysite.settings import EXTERNAL_ADDRESS
from vpn.models import ACL, ACLLink, User
from .base import BaseVPNAdmin
from vpn.utils import format_object
class UserNameFilter(admin.SimpleListFilter):
title = 'User'
parameter_name = 'user'
def lookups(self, request, model_admin):
users = set(User.objects.values_list('username', flat=True))
return [(user, user) for user in users]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(user__username=self.value())
return queryset
class ServerNameFilter(admin.SimpleListFilter):
title = 'Server Name'
parameter_name = 'acl__server__name'
def lookups(self, request, model_admin):
servers = set(ACL.objects.values_list('server__name', flat=True))
return [(server, server) for server in servers]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(acl__server__name=self.value())
return queryset
class LastAccessFilter(admin.SimpleListFilter):
title = 'Last Access'
parameter_name = 'last_access_status'
def lookups(self, request, model_admin):
return [
('never', 'Never accessed'),
('week', 'Last week'),
('month', 'Last month'),
('old', 'Older than 3 months'),
]
def queryset(self, request, queryset):
from django.utils import timezone
from datetime import timedelta
if self.value() == 'never':
# Links that have never been accessed
return queryset.filter(last_access_time__isnull=True)
elif self.value() == 'week':
# Links accessed in the last week
week_ago = timezone.now() - timedelta(days=7)
return queryset.filter(last_access_time__gte=week_ago)
elif self.value() == 'month':
# Links accessed in the last month
month_ago = timezone.now() - timedelta(days=30)
return queryset.filter(last_access_time__gte=month_ago)
elif self.value() == 'old':
# Links not accessed for more than 3 months
three_months_ago = timezone.now() - timedelta(days=90)
return queryset.filter(last_access_time__lt=three_months_ago)
return queryset
class ACLLinkInline(admin.TabularInline):
model = ACLLink
extra = 1
help_text = 'Add or change ACL links'
verbose_name = 'Dynamic link'
verbose_name_plural = 'Dynamic links'
fields = ('link', 'generate_link_button', 'comment')
readonly_fields = ('generate_link_button',)
@admin.display(description="Generate")
def generate_link_button(self, obj=None):
return format_html(
'<button type="button" class="generate-link" onclick="generateLink(this)">🔄</button>'
)
class Media:
js = ('admin/js/generate_link.js',)
@admin.register(ACL)
class ACLAdmin(BaseVPNAdmin):
list_display = ('user', 'server', 'server_type', 'display_links', 'created_at')
list_filter = (UserNameFilter, 'server__server_type', ServerNameFilter)
# Fixed search_fields - removed problematic polymorphic server fields
search_fields = ('user__username', 'user__comment', 'links__link')
readonly_fields = ('user_info',)
inlines = [ACLLinkInline]
@admin.display(description='Server Type', ordering='server__server_type')
def server_type(self, obj):
return obj.server.get_server_type_display()
@admin.display(description='Client info')
def user_info(self, obj):
server = obj.server
user = obj.user
try:
# Use cached statistics instead of direct server requests
from vpn.models import UserStatistics
user_stats = UserStatistics.objects.filter(
user=user,
server_name=server.name
).first()
if user_stats:
# Format cached data nicely
data = {
'user': user.username,
'server': server.name,
'total_connections': user_stats.total_connections,
'recent_connections': user_stats.recent_connections,
'max_daily': user_stats.max_daily,
'last_updated': user_stats.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
'status': 'from_cache'
}
return format_object(data)
else:
# Fallback to minimal server check (avoid slow API calls on admin pages)
return mark_safe(
'<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 8px; border-radius: 4px;">' +
'<strong> User Statistics:</strong><br>' +
'No cached statistics available.<br>' +
'<small>Run "Update user statistics cache" action to populate data.</small>' +
'</div>'
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to get cached user info for {user.username} on {server.name}: {e}")
return mark_safe(f"<span style='color: red;'>Cache error: {e}</span>")
@admin.display(description='User Links')
def display_links(self, obj):
links_count = obj.links.count()
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.user.hash}"
return format_html(
'<div style="font-size: 12px; margin-bottom: 8px;">'
'<strong>🔗 {} link(s)</strong>'
'</div>'
'<a href="{}" target="_blank" style="background: #4ade80; color: #000; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 11px; font-weight: bold;">🌐 User Portal</a>',
links_count, portal_url
)
# Note: UserStatistics is not registered separately as admin model.
# All user statistics functionality is integrated into ACLLinkAdmin below.
@admin.register(ACLLink)
class ACLLinkAdmin(BaseVPNAdmin):
list_display = ('link_display', 'user_display', 'server_display', 'comment_display', 'stats_display', 'usage_chart_display', 'last_access_display', 'created_display')
list_filter = ('acl__server__name', 'acl__server__server_type', LastAccessFilter, 'acl__user__username')
search_fields = ('link', 'comment', 'acl__user__username', 'acl__server__name')
list_per_page = 100
actions = ['delete_selected_links', 'update_statistics_action']
list_select_related = ('acl__user', 'acl__server')
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.select_related('acl__user', 'acl__server')
return qs
@admin.display(description='Link', ordering='link')
def link_display(self, obj):
link_url = f"{EXTERNAL_ADDRESS}/ss/{obj.link}#{obj.acl.server.name}"
return format_html(
'<a href="{}" target="_blank" style="color: #2563eb; text-decoration: none; font-family: monospace;">{}</a>',
link_url, obj.link[:16] + '...' if len(obj.link) > 16 else obj.link
)
@admin.display(description='User', ordering='acl__user__username')
def user_display(self, obj):
return obj.acl.user.username
@admin.display(description='Server', ordering='acl__server__name')
def server_display(self, obj):
server_type_icons = {
'outline': '🔵',
'wireguard': '🟢',
'xray_core': '🟣',
}
icon = server_type_icons.get(obj.acl.server.server_type, '')
return f"{icon} {obj.acl.server.name}"
@admin.display(description='Comment', ordering='comment')
def comment_display(self, obj):
if obj.comment:
return obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment
return '-'
@admin.display(description='Statistics')
def stats_display(self, obj):
try:
from vpn.models import UserStatistics
stats = UserStatistics.objects.get(
user=obj.acl.user,
server_name=obj.acl.server.name,
acl_link_id=obj.link
)
# Color coding based on usage
if stats.total_connections > 100:
color = '#16a34a' # green - high usage
elif stats.total_connections > 10:
color = '#eab308' # yellow - medium usage
elif stats.total_connections > 0:
color = '#f97316' # orange - low usage
else:
color = '#9ca3af' # gray - no usage
return mark_safe(
f'<div style="font-size: 12px;">'
f'<span style="color: {color}; font-weight: bold;">✨ {stats.total_connections} total</span><br>'
f'<span style="color: #6b7280;">📅 {stats.recent_connections} last 30d</span>'
f'</div>'
)
except:
return mark_safe('<span style="color: #dc2626; font-size: 12px;">No cache</span>')
@admin.display(description='30-day Chart')
def usage_chart_display(self, obj):
try:
from vpn.models import UserStatistics
stats = UserStatistics.objects.get(
user=obj.acl.user,
server_name=obj.acl.server.name,
acl_link_id=obj.link
)
if not stats.daily_usage:
return mark_safe('<span style="color: #9ca3af; font-size: 11px;">No data</span>')
# Create wider mini chart for better visibility
max_val = max(stats.daily_usage) if stats.daily_usage else 1
chart_html = '<div style="display: flex; align-items: end; gap: 1px; height: 35px; width: 180px;">'
# Show last 30 days with wider bars for better visibility
for day_count in stats.daily_usage[-30:]: # Last 30 days
if max_val > 0:
height_percent = (day_count / max_val) * 100
else:
height_percent = 0
color = '#4ade80' if day_count > 0 else '#e5e7eb'
chart_html += f'<div style="background: {color}; width: 5px; height: {height_percent}%; min-height: 1px; border-radius: 1px;" title="{day_count} connections"></div>'
chart_html += '</div>'
# Add summary info below chart
total_last_30 = sum(stats.daily_usage[-30:]) if stats.daily_usage else 0
avg_daily = total_last_30 / 30 if total_last_30 > 0 else 0
chart_html += f'<div style="font-size: 10px; color: #6b7280; margin-top: 2px;">'
chart_html += f'Max: {max_val} | Avg: {avg_daily:.1f}'
chart_html += f'</div>'
return mark_safe(chart_html)
except:
return mark_safe('<span style="color: #dc2626; font-size: 11px;">-</span>')
@admin.display(description='Last Access', ordering='last_access_time')
def last_access_display(self, obj):
if obj.last_access_time:
from django.utils import timezone
from datetime import timedelta
local_time = localtime(obj.last_access_time)
now = timezone.now()
diff = now - obj.last_access_time
# Color coding based on age
if diff <= timedelta(days=7):
color = '#16a34a' # green - recent
elif diff <= timedelta(days=30):
color = '#eab308' # yellow - medium
elif diff <= timedelta(days=90):
color = '#f97316' # orange - old
else:
color = '#dc2626' # red - very old
formatted_date = local_time.strftime('%Y-%m-%d %H:%M')
# Add relative time info
if diff.days > 365:
relative = f'{diff.days // 365}y ago'
elif diff.days > 30:
relative = f'{diff.days // 30}mo ago'
elif diff.days > 0:
relative = f'{diff.days}d ago'
elif diff.seconds > 3600:
relative = f'{diff.seconds // 3600}h ago'
else:
relative = 'Recently'
return mark_safe(
f'<span style="color: {color}; font-weight: bold; font-size: 12px;">{formatted_date}</span>'
f'<br><small style="color: {color}; font-size: 10px;">{relative}</small>'
)
return mark_safe('<span style="color: #dc2626; font-weight: bold; font-size: 12px;">Never</span>')
@admin.display(description='Created', ordering='acl__created_at')
def created_display(self, obj):
local_time = localtime(obj.acl.created_at)
return local_time.strftime('%Y-%m-%d %H:%M')
def delete_selected_links(self, request, queryset):
count = queryset.count()
queryset.delete()
self.message_user(request, f'Successfully deleted {count} ACL link(s).')
delete_selected_links.short_description = "Delete selected ACL links"
def update_statistics_action(self, request, queryset):
"""Trigger comprehensive statistics update for all users and links"""
# This action doesn't require selected items
try:
from vpn.tasks import update_user_statistics
# Start the statistics update task
task = update_user_statistics.delay()
self.message_user(
request,
f'📊 Statistics update started successfully! Task ID: {task.id}. '
f'This will recalculate usage statistics for all users and links. '
f'Refresh this page in a few moments to see updated data.',
level=messages.SUCCESS
)
except Exception as e:
self.message_user(
request,
f'❌ Failed to start statistics update: {e}',
level=messages.ERROR
)
update_statistics_action.short_description = "📊 Update all user statistics and usage data"
def get_actions(self, request):
"""Remove default delete action and keep only custom one"""
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return True
def has_delete_permission(self, request, obj=None):
return True
def changelist_view(self, request, extra_context=None):
# Handle actions that don't require item selection
if 'action' in request.POST:
action = request.POST['action']
if action == 'update_statistics_action':
# Call the action directly without queryset requirement
self.update_statistics_action(request, None)
# Return redirect to prevent AttributeError
return redirect(request.get_full_path())
# Add comprehensive statistics to the changelist
extra_context = extra_context or {}
# Get queryset for statistics
queryset = self.get_queryset(request)
total_links = queryset.count()
never_accessed = queryset.filter(last_access_time__isnull=True).count()
from django.utils import timezone
from datetime import timedelta
from django.db.models import Count, Max, Min
now = timezone.now()
one_week_ago = now - timedelta(days=7)
one_month_ago = now - timedelta(days=30)
three_months_ago = now - timedelta(days=90)
# Access time statistics
old_links = queryset.filter(
Q(last_access_time__lt=three_months_ago) | Q(last_access_time__isnull=True)
).count()
recent_week = queryset.filter(last_access_time__gte=one_week_ago).count()
recent_month = queryset.filter(last_access_time__gte=one_month_ago).count()
# Calculate comprehensive statistics from cache
try:
from vpn.models import UserStatistics
from django.db import models
# Total usage statistics
cached_stats = UserStatistics.objects.aggregate(
total_uses=models.Sum('total_connections'),
recent_uses=models.Sum('recent_connections'),
max_daily_peak=models.Max('max_daily')
)
total_uses = cached_stats['total_uses'] or 0
recent_uses = cached_stats['recent_uses'] or 0
max_daily_peak = cached_stats['max_daily_peak'] or 0
# Server and user breakdown
server_stats = UserStatistics.objects.values('server_name').annotate(
total_connections=models.Sum('total_connections'),
link_count=models.Count('id')
).order_by('-total_connections')[:5] # Top 5 servers
user_stats = UserStatistics.objects.values('user__username').annotate(
total_connections=models.Sum('total_connections'),
link_count=models.Count('id')
).order_by('-total_connections')[:5] # Top 5 users
# Links with cache data count
cached_links_count = UserStatistics.objects.filter(
acl_link_id__isnull=False
).count()
except Exception as e:
total_uses = 0
recent_uses = 0
max_daily_peak = 0
server_stats = []
user_stats = []
cached_links_count = 0
# Active vs inactive breakdown
active_links = total_links - never_accessed - old_links
if active_links < 0:
active_links = 0
extra_context.update({
'total_links': total_links,
'never_accessed': never_accessed,
'old_links': old_links,
'active_links': active_links,
'recent_week': recent_week,
'recent_month': recent_month,
'total_uses': total_uses,
'recent_uses': recent_uses,
'max_daily_peak': max_daily_peak,
'server_stats': server_stats,
'user_stats': user_stats,
'cached_links_count': cached_links_count,
'cache_coverage_percent': (cached_links_count * 100 // total_links) if total_links > 0 else 0,
})
return super().changelist_view(request, extra_context)
def get_ordering(self, request):
"""Allow sorting by annotated fields"""
# Handle sorting by last_access_time if requested
order_var = request.GET.get('o')
if order_var:
try:
field_index = int(order_var.lstrip('-'))
# Check if this corresponds to the last_access column (index 6 in list_display)
if field_index == 6: # last_access_display is at index 6
if order_var.startswith('-'):
return ['-last_access_time']
else:
return ['last_access_time']
except (ValueError, IndexError):
pass
# Default ordering
return ['-acl__created_at', 'acl__user__username']

57
vpn/admin/base.py Normal file
View File

@@ -0,0 +1,57 @@
"""
Base utilities and common imports for VPN admin interfaces
"""
import json
import shortuuid
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.html import format_html
from django.db.models import Count
from django.shortcuts import render, redirect
from django.contrib import messages
from django.urls import path, reverse
from django.http import HttpResponseRedirect, JsonResponse
from django.utils.timezone import localtime
from django.db.models import Max, Subquery, OuterRef, Q
from mysite.settings import EXTERNAL_ADDRESS
def format_bytes(bytes_val):
"""Format bytes to human readable format"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if bytes_val < 1024.0:
return f"{bytes_val:.1f}{unit}"
bytes_val /= 1024.0
return f"{bytes_val:.1f}PB"
class BaseVPNAdmin(admin.ModelAdmin):
"""Base admin class with common functionality"""
class Media:
css = {
'all': ('admin/css/vpn_admin.css',)
}
def get_external_address(self):
"""Get external address for links"""
return EXTERNAL_ADDRESS
def format_hash_link(self, obj, hash_value):
"""Format hash as clickable link"""
if not hash_value:
return mark_safe('<span style="color: #dc3545;">No hash</span>')
portal_url = f"https://{EXTERNAL_ADDRESS}/u/{hash_value}"
return mark_safe(
f'<div style="display: flex; align-items: center; gap: 10px;">'
f'<code style="background: #f8f9fa; padding: 4px 8px; border-radius: 3px; font-size: 12px;">{hash_value[:12]}...</code>'
f'<a href="{portal_url}" target="_blank" style="color: #007cba; text-decoration: none;">🔗 Portal</a>'
f'</div>'
)
class BaseListFilter(admin.SimpleListFilter):
"""Base filter class with common functionality"""
pass

179
vpn/admin/logs.py Normal file
View File

@@ -0,0 +1,179 @@
"""
Logging admin interfaces (TaskExecutionLog, AccessLog)
"""
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.html import format_html
from django.shortcuts import redirect
from django.contrib import messages
from django.utils.timezone import localtime
from vpn.models import TaskExecutionLog, AccessLog
from .base import BaseVPNAdmin
from vpn.utils import format_object
@admin.register(TaskExecutionLog)
class TaskExecutionLogAdmin(BaseVPNAdmin):
list_display = ('task_name_display', 'action', 'status_display', 'server', 'user', 'execution_time_display', 'created_at')
list_filter = ('task_name', 'status', 'server', 'created_at')
search_fields = ('task_id', 'task_name', 'action', 'user__username', 'server__name', 'message')
readonly_fields = ('task_id', 'task_name', 'server', 'user', 'action', 'status', 'message_formatted', 'execution_time', 'created_at')
ordering = ('-created_at',)
list_per_page = 100
date_hierarchy = 'created_at'
actions = ['trigger_full_sync', 'trigger_statistics_update']
fieldsets = (
('Task Information', {
'fields': ('task_id', 'task_name', 'action', 'status')
}),
('Related Objects', {
'fields': ('server', 'user')
}),
('Execution Details', {
'fields': ('message_formatted', 'execution_time', 'created_at')
}),
)
def trigger_full_sync(self, request, queryset):
"""Trigger manual full synchronization of all servers"""
# This action doesn't require selected items
try:
from vpn.tasks import sync_all_users
# Start the sync task
task = sync_all_users.delay()
self.message_user(
request,
f'Full synchronization started successfully. Task ID: {task.id}. Check logs below for progress.',
level=messages.SUCCESS
)
except Exception as e:
self.message_user(
request,
f'Failed to start full synchronization: {e}',
level=messages.ERROR
)
trigger_full_sync.short_description = "🔄 Trigger full sync of all servers"
def trigger_statistics_update(self, request, queryset):
"""Trigger manual update of user statistics cache"""
# This action doesn't require selected items
try:
from vpn.tasks import update_user_statistics
# Start the statistics update task
task = update_user_statistics.delay()
self.message_user(
request,
f'User statistics update started successfully. Task ID: {task.id}. Check logs below for progress.',
level=messages.SUCCESS
)
except Exception as e:
self.message_user(
request,
f'Failed to start statistics update: {e}',
level=messages.ERROR
)
trigger_statistics_update.short_description = "📊 Update user statistics cache"
def get_actions(self, request):
"""Remove default delete action for logs"""
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
@admin.display(description='Task', ordering='task_name')
def task_name_display(self, obj):
task_names = {
'sync_all_servers': '🔄 Sync All',
'sync_server_users': '👥 Server Sync',
'sync_server_info': '⚙️ Server Info',
'sync_user_on_server': '👤 User Sync',
'cleanup_task_logs': '🧹 Cleanup',
'update_user_statistics': '📊 Statistics',
}
return task_names.get(obj.task_name, obj.task_name)
@admin.display(description='Status', ordering='status')
def status_display(self, obj):
status_icons = {
'STARTED': '🟡 Started',
'SUCCESS': '✅ Success',
'FAILURE': '❌ Failed',
'RETRY': '🔄 Retry',
}
return status_icons.get(obj.status, obj.status)
@admin.display(description='Time', ordering='execution_time')
def execution_time_display(self, obj):
if obj.execution_time:
if obj.execution_time < 1:
return f"{obj.execution_time*1000:.0f}ms"
else:
return f"{obj.execution_time:.2f}s"
return '-'
@admin.display(description='Message')
def message_formatted(self, obj):
if obj.message:
return mark_safe(f"<pre style='white-space: pre-wrap; max-width: 800px;'>{obj.message}</pre>")
return '-'
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
"""Override to handle actions that don't require item selection"""
# Handle actions that don't require selection
if 'action' in request.POST:
action = request.POST['action']
if action == 'trigger_full_sync':
# Call the action directly without queryset requirement
self.trigger_full_sync(request, None)
# Return redirect to prevent AttributeError
return redirect(request.get_full_path())
elif action == 'trigger_statistics_update':
# Call the statistics update action
self.trigger_statistics_update(request, None)
# Return redirect to prevent AttributeError
return redirect(request.get_full_path())
return super().changelist_view(request, extra_context)
@admin.register(AccessLog)
class AccessLogAdmin(BaseVPNAdmin):
list_display = ('user', 'server', 'acl_link_display', 'action', 'formatted_timestamp')
list_filter = ('user', 'server', 'action', 'timestamp')
search_fields = ('user', 'server', 'acl_link_id', 'action', 'timestamp', 'data')
readonly_fields = ('server', 'user', 'acl_link_id', 'formatted_timestamp', 'action', 'formated_data')
@admin.display(description='Link', ordering='acl_link_id')
def acl_link_display(self, obj):
if obj.acl_link_id:
return format_html(
'<span style="font-family: monospace; color: #2563eb;">{}</span>',
obj.acl_link_id[:12] + '...' if len(obj.acl_link_id) > 12 else obj.acl_link_id
)
return '-'
@admin.display(description='Timestamp')
def formatted_timestamp(self, obj):
local_time = localtime(obj.timestamp)
return local_time.strftime('%Y-%m-%d %H:%M:%S %Z')
@admin.display(description='Details')
def formated_data(self, obj):
return format_object(obj.data)

864
vpn/admin/server.py Normal file
View File

@@ -0,0 +1,864 @@
"""
Server admin interface
"""
import re
from polymorphic.admin import PolymorphicParentModelAdmin
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.html import format_html
from django.db.models import Count, Case, When, Value, IntegerField, F, Subquery, OuterRef
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from django.urls import path, reverse
from django.http import HttpResponseRedirect, JsonResponse
from mysite.settings import EXTERNAL_ADDRESS
from vpn.models import Server, ACL, ACLLink
from .base import BaseVPNAdmin, format_bytes
from vpn.server_plugins import (
OutlineServer,
WireguardServer,
XrayServerV2
)
@admin.register(Server)
class ServerAdmin(PolymorphicParentModelAdmin, BaseVPNAdmin):
base_model = Server
child_models = (OutlineServer, WireguardServer, XrayServerV2)
list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'server_status_compact', 'registration_date')
search_fields = ('name', 'comment')
list_filter = ('server_type', )
actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers', 'sync_xray_inbounds', 'check_status']
class Media:
css = {
'all': ('admin/css/vpn_admin.css',)
}
js = ('admin/js/server_status_check.js',)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('move-clients/', self.admin_site.admin_view(self.move_clients_view), name='server_move_clients'),
path('<int:server_id>/check-status/', self.admin_site.admin_view(self.check_server_status_view), name='server_check_status'),
path('<int:object_id>/sync/', self.admin_site.admin_view(self.sync_server_view), name='server_sync'),
]
return custom_urls + urls
def move_clients_action(self, request, queryset):
"""Custom action to move client links between servers"""
if queryset.count() == 0:
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
return
# Redirect to move clients page
selected_ids = ','.join(str(server.id) for server in queryset)
return HttpResponseRedirect(f"{reverse('admin:server_move_clients')}?servers={selected_ids}")
move_clients_action.short_description = "Move client links between servers"
def move_clients_view(self, request):
"""View for moving clients between servers"""
if request.method == 'GET':
# Get selected servers from URL parameters
server_ids = request.GET.get('servers', '').split(',')
if not server_ids or server_ids == ['']:
messages.error(request, "No servers selected.")
return redirect('admin:vpn_server_changelist')
try:
# Only work with database objects, don't check server connectivity
servers = Server.objects.filter(id__in=server_ids)
all_servers = Server.objects.all()
# Get ACL links for selected servers with related data
# This is purely database operation, no server connectivity required
links_by_server = {}
for server in servers:
try:
# Get all ACL links for this server with user and ACL data
links = ACLLink.objects.filter(
acl__server=server
).select_related('acl__user', 'acl__server').order_by('acl__user__username', 'comment')
links_by_server[server] = links
except Exception as e:
# Log the error but continue with other servers
messages.warning(request, f"Warning: Could not load links for server {server.name}: {e}")
links_by_server[server] = []
context = {
'title': 'Move Client Links Between Servers',
'servers': servers,
'all_servers': all_servers,
'links_by_server': links_by_server,
}
return render(request, 'admin/move_clients.html', context)
except Exception as e:
messages.error(request, f"Database error while loading data: {e}")
return redirect('admin:vpn_server_changelist')
elif request.method == 'POST':
# Process the transfer of ACL links - purely database operations
try:
source_server_id = request.POST.get('source_server')
target_server_id = request.POST.get('target_server')
selected_link_ids = request.POST.getlist('selected_links')
comment_regex = request.POST.get('comment_regex', '').strip()
if not source_server_id or not target_server_id:
messages.error(request, "Please select both source and target servers.")
return redirect(request.get_full_path())
if source_server_id == target_server_id:
messages.error(request, "Source and target servers cannot be the same.")
return redirect(request.get_full_path())
if not selected_link_ids:
messages.error(request, "Please select at least one link to move.")
return redirect(request.get_full_path())
# Parse and validate regex pattern if provided
regex_pattern = None
regex_replacement = None
regex_parts = None
if comment_regex:
try:
regex_parts = comment_regex.split(' -> ')
if len(regex_parts) != 2:
messages.error(request, "Invalid regex format. Use: pattern -> replacement")
return redirect(request.get_full_path())
pattern_str = regex_parts[0]
replacement_str = regex_parts[1]
# Convert JavaScript-style $1, $2, $3 to Python-style \1, \2, \3
python_replacement = replacement_str
# Replace $1, $2, etc. with \1, \2, etc. for Python regex
python_replacement = re.sub(r'\$(\d+)', r'\\\1', replacement_str)
# Test compile the regex pattern
regex_pattern = re.compile(pattern_str)
regex_replacement = python_replacement
# Test the replacement on a sample string to validate syntax
test_result = regex_pattern.sub(regex_replacement, "test sample")
except re.error as e:
messages.error(request, f"Invalid regular expression pattern '{regex_parts[0] if regex_parts else comment_regex}': {e}")
return redirect(request.get_full_path())
except Exception as e:
messages.error(request, f"Error in regex replacement '{replacement_str if 'replacement_str' in locals() else 'unknown'}': {e}")
return redirect(request.get_full_path())
# Get server objects from database only
try:
source_server = Server.objects.get(id=source_server_id)
target_server = Server.objects.get(id=target_server_id)
except Server.DoesNotExist:
messages.error(request, "One of the selected servers was not found in database.")
return redirect('admin:vpn_server_changelist')
moved_count = 0
errors = []
users_processed = set()
comments_transformed = 0
# Process each selected link - database operations only
for link_id in selected_link_ids:
try:
# Get the ACL link with related ACL and user data
acl_link = ACLLink.objects.select_related('acl__user', 'acl__server').get(
id=link_id,
acl__server=source_server
)
user = acl_link.acl.user
# Apply regex transformation to comment if provided
original_comment = acl_link.comment
if regex_pattern and regex_replacement is not None:
try:
# Use Python's re.sub for replacement, which properly handles $1, $2 groups
new_comment = regex_pattern.sub(regex_replacement, original_comment)
if new_comment != original_comment:
acl_link.comment = new_comment
comments_transformed += 1
# Debug logging - shows both original and converted patterns
print(f"DEBUG: Transformed '{original_comment}' -> '{new_comment}'")
print(f" Original pattern: '{regex_parts[0]}' -> '{regex_parts[1]}'")
print(f" Python pattern: '{regex_parts[0]}' -> '{regex_replacement}'")
except Exception as e:
errors.append(f"Error applying regex to link {link_id} ('{original_comment}'): {e}")
# Continue with original comment
# Check if user already has ACL on target server
target_acl = ACL.objects.filter(user=user, server=target_server).first()
if target_acl:
created = False
else:
# Create new ACL without auto-creating default link
target_acl = ACL(user=user, server=target_server)
target_acl.save(auto_create_link=False)
created = True
# Move the link to target ACL - pure database operation
acl_link.acl = target_acl
acl_link.save()
moved_count += 1
users_processed.add(user.username)
if created:
messages.info(request, f"Created new ACL for user {user.username} on server {target_server.name}")
except ACLLink.DoesNotExist:
errors.append(f"Link with ID {link_id} not found on source server")
except Exception as e:
errors.append(f"Database error moving link {link_id}: {e}")
# Clean up empty ACLs on source server - database operation only
try:
empty_acls = ACL.objects.filter(
server=source_server,
links__isnull=True
)
deleted_acls_count = empty_acls.count()
empty_acls.delete()
except Exception as e:
messages.warning(request, f"Warning: Could not clean up empty ACLs: {e}")
deleted_acls_count = 0
if moved_count > 0:
success_msg = (
f"Successfully moved {moved_count} link(s) for {len(users_processed)} user(s) "
f"from '{source_server.name}' to '{target_server.name}'. "
f"Cleaned up {deleted_acls_count} empty ACL(s)."
)
if comments_transformed > 0:
success_msg += f" Transformed {comments_transformed} comment(s) using regex."
messages.success(request, success_msg)
if errors:
for error in errors:
messages.error(request, error)
return redirect('admin:vpn_server_changelist')
except Exception as e:
messages.error(request, f"Database error during link transfer: {e}")
return redirect('admin:vpn_server_changelist')
def check_server_status_view(self, request, server_id):
"""AJAX view to check server status"""
import logging
logger = logging.getLogger(__name__)
if request.method == 'POST':
try:
logger.info(f"Checking status for server ID: {server_id}")
server = Server.objects.get(pk=server_id)
real_server = server.get_real_instance()
logger.info(f"Server found: {server.name}, type: {type(real_server).__name__}")
# Check server status based on type
from vpn.server_plugins.outline import OutlineServer
# Old xray_core module removed - skip this server type
if isinstance(real_server, OutlineServer):
try:
logger.info(f"Checking Outline server: {server.name}")
# Try to get server info to check if it's online
info = real_server.client.get_server_information()
if info:
logger.info(f"Server {server.name} is online with {info.get('accessKeyCount', info.get('access_key_count', 'unknown'))} keys")
return JsonResponse({
'success': True,
'status': 'online',
'message': f'Server is online. Keys: {info.get("accessKeyCount", info.get("access_key_count", "unknown"))}'
})
else:
logger.warning(f"Server {server.name} returned no info")
return JsonResponse({
'success': True,
'status': 'offline',
'message': 'Server not responding'
})
except Exception as e:
logger.error(f"Error checking Outline server {server.name}: {e}")
return JsonResponse({
'success': True,
'status': 'error',
'message': f'Connection error: {str(e)[:100]}'
})
elif isinstance(real_server, XrayServerV2):
try:
logger.info(f"Checking Xray v2 server: {server.name}")
# Get server status from new Xray implementation
status = real_server.get_server_status()
if status and isinstance(status, dict):
if status.get('accessible', False):
message = f'✅ Server is {status.get("status", "accessible")}. '
message += f'Host: {status.get("client_hostname", "N/A")}, '
message += f'API: {status.get("api_address", "N/A")}'
if status.get('api_connected'):
message += ' (Connected)'
# Add stats if available
api_stats = status.get('api_stats', {})
if api_stats and isinstance(api_stats, dict):
if 'connection' in api_stats:
message += f', Stats: {api_stats.get("connection", "ok")}'
if api_stats.get('library') == 'not_available':
message += ' [Basic check only]'
elif status.get('api_error'):
message += f' ({status.get("api_error")})'
message += f', Inbounds: {status.get("total_inbounds", 0)}'
logger.info(f"Xray v2 server {server.name} status: {message}")
return JsonResponse({
'success': True,
'status': 'online',
'message': message
})
else:
error_msg = status.get('error') or status.get('api_error', 'Unknown error')
logger.warning(f"Xray v2 server {server.name} not accessible: {error_msg}")
return JsonResponse({
'success': True,
'status': 'offline',
'message': f'❌ Server not accessible: {error_msg}'
})
else:
logger.warning(f"Xray v2 server {server.name} returned invalid status")
return JsonResponse({
'success': True,
'status': 'offline',
'message': 'Invalid server response'
})
except Exception as e:
logger.error(f"Error checking Xray v2 server {server.name}: {e}")
return JsonResponse({
'success': True,
'status': 'error',
'message': f'Connection error: {str(e)[:100]}'
})
else:
# For other server types, just return basic info
logger.info(f"Server {server.name}, type: {server.server_type}")
return JsonResponse({
'success': True,
'status': 'unknown',
'message': f'Status check not implemented for {server.server_type} servers'
})
except Server.DoesNotExist:
logger.error(f"Server with ID {server_id} not found")
return JsonResponse({
'success': False,
'error': 'Server not found'
}, status=404)
except Exception as e:
logger.error(f"Unexpected error checking server {server_id}: {e}")
return JsonResponse({
'success': False,
'error': f'Unexpected error: {str(e)}'
}, status=500)
logger.warning(f"Invalid request method {request.method} for server status check")
return JsonResponse({
'success': False,
'error': 'Invalid request method'
}, status=405)
def purge_all_keys_action(self, request, queryset):
"""Purge all keys from selected servers without changing database"""
if queryset.count() == 0:
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
return
success_count = 0
error_count = 0
total_keys_removed = 0
for server in queryset:
try:
# Get the real polymorphic instance
real_server = server.get_real_instance()
server_type = type(real_server).__name__
# Check if this is an Outline server
from vpn.server_plugins.outline import OutlineServer
if isinstance(real_server, OutlineServer) and hasattr(real_server, 'client'):
# For Outline servers, get all keys and delete them
try:
keys = real_server.client.get_keys()
keys_count = len(keys)
for key in keys:
try:
real_server.client.delete_key(key.key_id)
except Exception as e:
self.message_user(
request,
f"Failed to delete key {key.key_id} from {server.name}: {e}",
level=messages.WARNING
)
total_keys_removed += keys_count
success_count += 1
self.message_user(
request,
f"Successfully purged {keys_count} keys from server '{server.name}'.",
level=messages.SUCCESS
)
except Exception as e:
error_count += 1
self.message_user(
request,
f"Failed to connect to server '{server.name}': {e}",
level=messages.ERROR
)
else:
self.message_user(
request,
f"Key purging only supported for Outline servers. Skipping '{server.name}' (type: {server_type}).",
level=messages.INFO
)
except Exception as e:
error_count += 1
self.message_user(
request,
f"Unexpected error with server '{server.name}': {e}",
level=messages.ERROR
)
# Summary message
if success_count > 0:
self.message_user(
request,
f"Purge completed! {success_count} server(s) processed, {total_keys_removed} total keys removed. "
f"Database unchanged - run sync to restore proper keys.",
level=messages.SUCCESS
)
if error_count > 0:
self.message_user(
request,
f"{error_count} server(s) had errors during purge.",
level=messages.WARNING
)
purge_all_keys_action.short_description = "🗑️ Purge all keys from server (database unchanged)"
def sync_all_selected_servers(self, request, queryset):
"""Trigger sync for all users on selected servers"""
if queryset.count() == 0:
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
return
try:
from vpn.tasks import sync_server_users
from celery import current_app
tasks_started = 0
errors = []
scheduled_tasks = set() # Track already scheduled tasks to avoid duplicates
for server in queryset:
try:
# Check if a task is already running for this server
task_key = f"sync_server_{server.id}"
# Use Celery's inspect to check active tasks (optional, for better UX)
try:
inspect = current_app.control.inspect()
active_tasks = inspect.active()
# Check if task is already scheduled for this server
task_already_running = False
if active_tasks:
for worker, tasks in active_tasks.items():
for task_info in tasks:
if task_info.get('name') == 'sync_server_users':
# Check if server.id is in the task args
task_args = task_info.get('args', [])
if task_args and len(task_args) > 0 and task_args[0] == server.id:
task_already_running = True
break
if task_already_running:
self.message_user(
request,
f"⏳ Sync already in progress for '{server.name}'",
level=messages.WARNING
)
continue
except Exception as e:
# If we can't check active tasks, just proceed
logger.debug(f"Could not check active tasks: {e}")
# Avoid scheduling duplicate tasks in this batch
if server.id in scheduled_tasks:
continue
task = sync_server_users.delay(server.id)
scheduled_tasks.add(server.id)
tasks_started += 1
self.message_user(
request,
f"🔄 Sync task started for '{server.name}' (Task ID: {task.id})",
level=messages.SUCCESS
)
except Exception as e:
errors.append(f"'{server.name}': {e}")
if tasks_started > 0:
self.message_user(
request,
f"✅ Started sync tasks for {tasks_started} server(s). Check Task Execution Logs for progress.",
level=messages.SUCCESS
)
if errors:
for error in errors:
self.message_user(
request,
f"❌ Failed to sync {error}",
level=messages.ERROR
)
except Exception as e:
self.message_user(
request,
f"❌ Failed to start sync tasks: {e}",
level=messages.ERROR
)
sync_all_selected_servers.short_description = "🔄 Sync all users on selected servers"
def check_status(self, request, queryset):
"""Check status for selected servers"""
for server in queryset:
try:
status = server.get_server_status()
msg = f"{server.name}: {status.get('accessible', 'Unknown')} - {status.get('status', 'N/A')}"
self.message_user(request, msg, level=messages.INFO)
except Exception as e:
self.message_user(request, f"Error checking {server.name}: {e}", level=messages.ERROR)
check_status.short_description = "📊 Check server status"
def sync_xray_inbounds(self, request, queryset):
"""Sync inbounds for selected servers (Xray v2 only)"""
synced_count = 0
for server in queryset:
try:
real_server = server.get_real_instance()
if isinstance(real_server, XrayServerV2):
real_server.sync_inbounds()
synced_count += 1
self.message_user(request, f"Scheduled inbound sync for {server.name}", level=messages.SUCCESS)
else:
self.message_user(request, f"{server.name} is not an Xray v2 server", level=messages.WARNING)
except Exception as e:
self.message_user(request, f"Error syncing inbounds for {server.name}: {e}", level=messages.ERROR)
if synced_count > 0:
self.message_user(request, f"Scheduled inbound sync for {synced_count} server(s)", level=messages.SUCCESS)
sync_xray_inbounds.short_description = "🔧 Sync Xray inbounds"
@admin.display(description='Server', ordering='name')
def name_with_icon(self, obj):
"""Display server name with type icon"""
icons = {
'outline': '🔵',
'wireguard': '🟢',
'xray_core': '🟣',
'xray_v2': '🟡',
}
icon = icons.get(obj.server_type, '')
name_part = f"{icon} {obj.name}" if icon else obj.name
return name_part
@admin.display(description='Comment')
def comment_short(self, obj):
"""Display shortened comment"""
if obj.comment:
short_comment = obj.comment[:40] + '...' if len(obj.comment) > 40 else obj.comment
return mark_safe(f'<span title="{obj.comment}" style="font-size: 12px;">{short_comment}</span>')
return '-'
@admin.display(description='Users & Links')
def user_stats(self, obj):
"""Display user count and active links statistics (optimized)"""
try:
from django.utils import timezone
from datetime import timedelta
user_count = obj.user_count if hasattr(obj, 'user_count') else 0
# Different logic for Xray vs legacy servers
if obj.server_type == 'xray_v2':
# For Xray servers, count inbounds and active subscriptions
from vpn.models_xray import ServerInbound
total_inbounds = ServerInbound.objects.filter(server=obj, active=True).count()
# Count recent subscription accesses via AccessLog
thirty_days_ago = timezone.now() - timedelta(days=30)
from vpn.models import AccessLog
active_accesses = AccessLog.objects.filter(
server='Xray-Subscription',
action='Success',
timestamp__gte=thirty_days_ago
).values('user').distinct().count()
total_links = total_inbounds
active_links = min(active_accesses, user_count) # Can't be more than total users
else:
# Legacy servers: use ACL links as before
if hasattr(obj, 'acl_set'):
all_links = []
for acl in obj.acl_set.all():
if hasattr(acl, 'links') and hasattr(acl.links, 'all'):
all_links.extend(acl.links.all())
total_links = len(all_links)
# Count active links from prefetched data
thirty_days_ago = timezone.now() - timedelta(days=30)
active_links = sum(1 for link in all_links
if link.last_access_time and link.last_access_time >= thirty_days_ago)
else:
# Fallback to direct queries (less efficient)
total_links = ACLLink.objects.filter(acl__server=obj).count()
thirty_days_ago = timezone.now() - timedelta(days=30)
active_links = ACLLink.objects.filter(
acl__server=obj,
last_access_time__isnull=False,
last_access_time__gte=thirty_days_ago
).count()
# Color coding based on activity
if user_count == 0:
color = '#9ca3af' # gray - no users
elif total_links == 0:
color = '#dc2626' # red - no links/inbounds
elif obj.server_type == 'xray_v2':
# For Xray: base on user activity rather than link activity
if active_links > user_count * 0.5: # More than half users active
color = '#16a34a' # green
elif active_links > user_count * 0.2: # More than 20% users active
color = '#eab308' # yellow
else:
color = '#f97316' # orange - low activity
else:
# Legacy servers: base on link activity
if total_links > 0 and active_links > total_links * 0.7: # High activity
color = '#16a34a' # green
elif total_links > 0 and active_links > total_links * 0.3: # Medium activity
color = '#eab308' # yellow
else:
color = '#f97316' # orange - low activity
# Different display for Xray vs legacy
if obj.server_type == 'xray_v2':
# Try to get traffic stats if stats enabled
traffic_info = ""
# Get the real XrayServerV2 instance to access its fields
xray_server = obj.get_real_instance()
if hasattr(xray_server, 'stats_enabled') and xray_server.stats_enabled and xray_server.api_enabled:
try:
from vpn.xray_api_v2.client import XrayClient
from vpn.xray_api_v2.stats import StatsManager
client = XrayClient(server=xray_server.api_address)
stats_manager = StatsManager(client)
traffic_summary = stats_manager.get_traffic_summary()
# Calculate total traffic
total_uplink = 0
total_downlink = 0
# Sum up user traffic
for user_email, user_traffic in traffic_summary.get('users', {}).items():
total_uplink += user_traffic.get('uplink', 0)
total_downlink += user_traffic.get('downlink', 0)
# Format traffic
if total_uplink > 0 or total_downlink > 0:
traffic_info = f'<div style="color: #6b7280; font-size: 11px;">↑{format_bytes(total_uplink)}{format_bytes(total_downlink)}</div>'
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Could not get stats for Xray server {xray_server.name}: {e}")
return mark_safe(
f'<div style="font-size: 12px;">' +
f'<div style="color: {color}; font-weight: bold;">👥 {user_count} users</div>' +
f'<div style="color: #6b7280;">📡 {total_links} inbounds</div>' +
traffic_info +
f'</div>'
)
else:
return mark_safe(
f'<div style="font-size: 12px;">' +
f'<div style="color: {color}; font-weight: bold;">👥 {user_count} users</div>' +
f'<div style="color: #6b7280;">🔗 {active_links}/{total_links} active</div>' +
f'</div>'
)
except Exception as e:
import traceback
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error in user_stats for server {obj.name}: {e}", exc_info=True)
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Stats error: {e}</span>')
@admin.display(description='Activity')
def activity_summary(self, obj):
"""Display recent activity summary (optimized)"""
try:
# Simplified version - avoid heavy DB queries on list page
# This could be computed once per page load if needed
return mark_safe(
f'<div style="font-size: 11px; color: #6b7280;">' +
f'<div>📊 Activity data</div>' +
f'<div><small>Click to view details</small></div>' +
f'</div>'
)
except Exception as e:
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Activity unavailable</span>')
@admin.display(description='Status')
def server_status_compact(self, obj):
"""Display server status in compact format (optimized)"""
try:
# Avoid expensive server connectivity checks on list page
# Show basic info and let users click to check status
server_type_icons = {
'outline': '🔵',
'wireguard': '🟢',
'xray_core': '🟣',
}
icon = server_type_icons.get(obj.server_type, '')
return mark_safe(
f'<div style="color: #6b7280; font-size: 11px;">' +
f'{icon} {obj.server_type.title()}<br>' +
f'<button type="button" class="check-status-btn btn btn-xs" '
f'data-server-id="{obj.id}" data-server-name="{obj.name}" '
f'data-server-type="{obj.server_type}" '
f'style="background: #007cba; color: white; border: none; padding: 2px 6px; '
f'border-radius: 3px; font-size: 10px; cursor: pointer;">'
f'⚪ Check Status'
f'</button>' +
f'</div>'
)
except Exception as e:
return mark_safe(
f'<div style="color: #f97316; font-size: 11px; font-weight: bold;">' +
f'⚠️ Error<br>' +
f'<span style="font-weight: normal;" title="{str(e)}">' +
f'{str(e)[:25]}...</span>' +
f'</div>'
)
def get_queryset(self, request):
from vpn.models_xray import UserSubscription, ServerInbound
qs = super().get_queryset(request)
# Count ACL users for all servers
qs = qs.annotate(
acl_user_count=Count('acl__user', distinct=True)
)
# For Xray servers, calculate user count separately
# Create subquery to count Xray users
xray_user_count_subquery = ServerInbound.objects.filter(
server_id=OuterRef('pk'),
active=True,
inbound__subscriptiongroup__usersubscription__active=True,
inbound__subscriptiongroup__is_active=True
).values('server_id').annotate(
count=Count('inbound__subscriptiongroup__usersubscription__user', distinct=True)
).values('count')
qs = qs.annotate(
xray_user_count=Subquery(xray_user_count_subquery, output_field=IntegerField()),
user_count=Case(
When(server_type='xray_v2', then=F('xray_user_count')),
default=F('acl_user_count'),
output_field=IntegerField()
)
)
# Handle None values from subquery
qs = qs.annotate(
user_count=Case(
When(server_type='xray_v2', user_count__isnull=True, then=Value(0)),
When(server_type='xray_v2', then=F('xray_user_count')),
default=F('acl_user_count'),
output_field=IntegerField()
)
)
qs = qs.prefetch_related(
'acl_set__links',
'acl_set__user'
)
return qs
def sync_server_view(self, request, object_id):
"""Dispatch sync to appropriate server type."""
try:
server = get_object_or_404(Server, pk=object_id)
real_server = server.get_real_instance()
# Handle XrayServerV2
if isinstance(real_server, XrayServerV2):
return redirect(f'/admin/vpn/xrayserverv2/{real_server.pk}/sync/')
# Fallback for other server types
else:
messages.info(request, f"Sync not implemented for server type: {real_server.server_type}")
return redirect('admin:vpn_server_changelist')
except Exception as e:
messages.error(request, f"Error during sync: {e}")
return redirect('admin:vpn_server_changelist')
# Inline for legacy VPN access (Outline/Wireguard)
class UserACLInline(admin.TabularInline):
model = ACL
extra = 0
fields = ('server', 'created_at', 'link_count')
readonly_fields = ('created_at', 'link_count')
verbose_name = "Legacy VPN Server Access"
verbose_name_plural = "Legacy VPN Server Access (Outline/Wireguard)"
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "server":
# Only show old-style servers (Outline/Wireguard)
kwargs["queryset"] = Server.objects.exclude(server_type='xray_v2')
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.display(description='Links')
def link_count(self, obj):
count = obj.links.count()
return format_html(
'<span style="font-weight: bold;">{}</span> link(s)',
count
)

702
vpn/admin/user.py Normal file
View File

@@ -0,0 +1,702 @@
"""
User admin interface
"""
import shortuuid
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.html import format_html
from django.db.models import Count
from django.shortcuts import get_object_or_404
from django.contrib import messages
from django.urls import path, reverse
from django.http import HttpResponseRedirect, JsonResponse
from django.utils.timezone import localtime
from mysite.settings import EXTERNAL_ADDRESS
from vpn.models import User, ACL, ACLLink, Server, AccessLog, UserStatistics
from vpn.forms import UserForm
from .base import BaseVPNAdmin, format_bytes
@admin.register(User)
class UserAdmin(BaseVPNAdmin):
form = UserForm
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
search_fields = ('username', 'hash', 'telegram_user_id', 'telegram_username')
readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary', 'telegram_info_display', 'subscription_management_info')
inlines = [] # Inlines will be added by subscription management function
fieldsets = (
('User Information', {
'fields': ('username', 'first_name', 'last_name', 'email', 'comment')
}),
('Telegram Integration', {
'fields': ('telegram_username', 'telegram_info_display'),
'classes': ('collapse',),
'description': 'Link existing users to Telegram by setting telegram_username (without @)'
}),
('Access Information', {
'fields': ('hash_link', 'is_active', 'vpn_access_summary')
}),
('Statistics & Server Management', {
'fields': ('user_statistics_summary',),
'classes': ('wide',)
}),
('Subscription Management', {
'fields': ('subscription_management_info',),
'classes': ('wide',),
'description': 'Manage user\'s Xray subscription groups. Use the "User\'s Subscription Groups" section below to add/remove subscriptions.'
}),
)
@admin.display(description='VPN Access Summary')
def vpn_access_summary(self, obj):
"""Display summary of user's VPN access"""
if not obj.pk:
return "Save user first to see VPN access"
# Get legacy VPN access
acl_count = ACL.objects.filter(user=obj).count()
legacy_links = ACLLink.objects.filter(acl__user=obj).count()
# Get Xray access
from vpn.models_xray import UserSubscription
xray_subs = UserSubscription.objects.filter(user=obj, active=True).select_related('subscription_group')
xray_groups = [sub.subscription_group.name for sub in xray_subs]
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
# Legacy VPN section
html += '<div style="margin-bottom: 15px;">'
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">📡 Legacy VPN (Outline/Wireguard)</h4>'
if acl_count > 0:
html += f'<p style="margin: 5px 0;">✅ Access to <strong>{acl_count}</strong> server(s)</p>'
html += f'<p style="margin: 5px 0;">🔗 Total links: <strong>{legacy_links}</strong></p>'
else:
html += '<p style="margin: 5px 0; color: #6c757d;">No legacy VPN access</p>'
html += '</div>'
# Xray section
html += '<div>'
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">🚀 Xray VPN</h4>'
if xray_groups:
html += f'<p style="margin: 5px 0;">✅ Active subscriptions: <strong>{len(xray_groups)}</strong></p>'
html += '<ul style="margin: 5px 0; padding-left: 20px;">'
for group in xray_groups:
html += f'<li>{group}</li>'
html += '</ul>'
# Try to get traffic statistics for this user
try:
from vpn.server_plugins.xray_v2 import XrayServerV2
traffic_total_up = 0
traffic_total_down = 0
servers_checked = set()
# Get all Xray servers
xray_servers = XrayServerV2.objects.filter(api_enabled=True, stats_enabled=True)
for server in xray_servers:
if server.name not in servers_checked:
try:
from vpn.xray_api_v2.client import XrayClient
from vpn.xray_api_v2.stats import StatsManager
client = XrayClient(server=server.api_address)
stats_manager = StatsManager(client)
# Get user stats (use email format: username@servername)
user_email = f"{obj.username}@{server.name}"
user_stats = stats_manager.get_user_stats(user_email)
if user_stats:
traffic_total_up += user_stats.get('uplink', 0)
traffic_total_down += user_stats.get('downlink', 0)
servers_checked.add(server.name)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Could not get user stats from server {server.name}: {e}")
# Format traffic if we got any
if traffic_total_up > 0 or traffic_total_down > 0:
html += f'<p style="margin: 10px 0 5px 0; color: #007cba;"><strong>📊 Traffic Statistics:</strong></p>'
html += f'<p style="margin: 5px 0 5px 20px;">↑ Upload: <strong>{format_bytes(traffic_total_up)}</strong></p>'
html += f'<p style="margin: 5px 0 5px 20px;">↓ Download: <strong>{format_bytes(traffic_total_down)}</strong></p>'
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Could not get traffic stats for user {obj.username}: {e}")
else:
html += '<p style="margin: 5px 0; color: #6c757d;">No Xray subscriptions</p>'
html += '</div>'
html += '</div>'
return format_html(html)
@admin.display(description='User Portal', ordering='hash')
def hash_link(self, obj):
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.hash}"
json_url = f"{EXTERNAL_ADDRESS}/stat/{obj.hash}"
return format_html(
'<div style="display: flex; gap: 10px; flex-wrap: wrap;">' +
'<a href="{}" target="_blank" style="background: #4ade80; color: #000; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 12px; font-weight: bold;">🌐 Portal</a>' +
'<a href="{}" target="_blank" style="background: #3b82f6; color: #fff; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 12px; font-weight: bold;">📄 JSON</a>' +
'</div>',
portal_url, json_url
)
@admin.display(description='User Statistics Summary')
def user_statistics_summary(self, obj):
"""Display user statistics with integrated server management"""
try:
from vpn.models import UserStatistics
from django.db import models
# Get statistics for this user
user_stats = UserStatistics.objects.filter(user=obj).aggregate(
total_connections=models.Sum('total_connections'),
recent_connections=models.Sum('recent_connections'),
total_links=models.Count('id'),
max_daily_peak=models.Max('max_daily')
)
# Get server breakdown
server_breakdown = UserStatistics.objects.filter(user=obj).values('server_name').annotate(
connections=models.Sum('total_connections'),
links=models.Count('id')
).order_by('-connections')
# Get all ACLs and links for this user
user_acls = ACL.objects.filter(user=obj).select_related('server').prefetch_related('links')
# Get available servers not yet assigned
all_servers = Server.objects.all()
assigned_server_ids = [acl.server.id for acl in user_acls]
unassigned_servers = all_servers.exclude(id__in=assigned_server_ids)
html = '<div class="user-management-section">'
# Overall Statistics
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
html += f'<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
html += f'<div><strong>Total Uses:</strong> {user_stats["total_connections"] or 0}</div>'
html += f'<div><strong>Recent (30d):</strong> {user_stats["recent_connections"] or 0}</div>'
html += f'<div><strong>Total Links:</strong> {user_stats["total_links"] or 0}</div>'
if user_stats["max_daily_peak"]:
html += f'<div><strong>Daily Peak:</strong> {user_stats["max_daily_peak"]}</div>'
html += f'</div>'
html += '</div>'
# Server Management
if user_acls:
html += '<h4 style="color: #007cba; margin: 16px 0 8px 0;">🔗 Server Access & Links</h4>'
for acl in user_acls:
server = acl.server
links = list(acl.links.all())
# Server header (no slow server status checks)
# Determine server type icon and label
if server.server_type == 'Outline':
type_icon = '🔵'
type_label = 'Outline'
elif server.server_type == 'Wireguard':
type_icon = '🟢'
type_label = 'Wireguard'
elif server.server_type in ['xray_core', 'xray_v2']:
type_icon = '🟣'
type_label = 'Xray'
else:
type_icon = ''
type_label = server.server_type
html += f'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">'
html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name} ({type_label})</h5>'
# Server stats
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None)
if server_stat:
html += f'<span style="background: #f8f9fa; padding: 4px 8px; border-radius: 4px; font-size: 12px; color: #6c757d;">'
html += f'📊 {server_stat["connections"]} uses ({server_stat["links"]} links)'
html += f'</span>'
html += f'</div>'
html += '<div class="server-section">'
# Links display
if links:
for link in links:
# Get link stats
link_stats = UserStatistics.objects.filter(
user=obj, server_name=server.name, acl_link_id=link.link
).first()
html += '<div class="link-item">'
html += f'<div style="flex: 1;">'
html += f'<div style="font-family: monospace; font-size: 13px; color: #007cba; font-weight: 500;">'
html += f'{link.link[:16]}...' if len(link.link) > 16 else link.link
html += f'</div>'
if link.comment:
html += f'<div style="font-size: 11px; color: #6c757d;">{link.comment}</div>'
html += f'</div>'
# Link stats and actions
html += f'<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">'
if link_stats:
html += f'<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
html += f'{link_stats.total_connections}'
html += f'</span>'
# Test link button
html += f'<a href="{EXTERNAL_ADDRESS}/ss/{link.link}#{server.name}" target="_blank" '
html += f'class="btn btn-sm btn-primary btn-sm-custom">🔗</a>'
# Delete button
html += f'<button type="button" class="btn btn-sm btn-danger btn-sm-custom delete-link-btn" '
html += f'data-link-id="{link.id}" data-link-name="{link.link[:12]}">🗑️</button>'
# Last access
if link.last_access_time:
local_time = localtime(link.last_access_time)
html += f'<span style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px; font-size: 10px; color: #6c757d;">'
html += f'{local_time.strftime("%m-%d %H:%M")}'
html += f'</span>'
else:
html += f'<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
html += f'Never'
html += f'</span>'
html += f'</div></div>'
# Add link button
html += f'<div style="text-align: center; margin-top: 12px;">'
html += f'<button type="button" class="btn btn-sm btn-success add-link-btn" '
html += f'data-server-id="{server.id}" data-server-name="{server.name}">'
html += f' Add Link'
html += f'</button>'
html += f'</div>'
html += '</div>' # End server-section
# Add server access section
if unassigned_servers:
html += '<div style="background: #d1ecf1; border-left: 4px solid #bee5eb; padding: 12px; margin-top: 16px; border-radius: 4px;">'
html += '<h5 style="margin: 0 0 8px 0; color: #0c5460; font-size: 13px;"> Available Servers</h5>'
html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'
for server in unassigned_servers:
# Determine server type icon and label
if server.server_type == 'Outline':
type_icon = '🔵'
type_label = 'Outline'
elif server.server_type == 'Wireguard':
type_icon = '🟢'
type_label = 'Wireguard'
elif server.server_type in ['xray_core', 'xray_v2']:
type_icon = '🟣'
type_label = 'Xray'
else:
type_icon = ''
type_label = server.server_type
html += f'<button type="button" class="btn btn-sm btn-outline-info btn-sm-custom add-server-btn" '
html += f'data-server-id="{server.id}" data-server-name="{server.name}" '
html += f'title="{type_label} server">'
html += f'{type_icon} {server.name} ({type_label})'
html += f'</button>'
html += '</div></div>'
html += '</div>' # End user-management-section
return mark_safe(html)
except Exception as e:
return mark_safe(f'<span style="color: #dc3545;">Error loading management interface: {e}</span>')
@admin.display(description='Recent Activity')
def recent_activity_display(self, obj):
"""Display recent activity in compact admin-friendly format"""
try:
from datetime import timedelta
from django.utils import timezone
# Get recent access logs for this user (last 7 days, limited)
seven_days_ago = timezone.now() - timedelta(days=7)
recent_logs = AccessLog.objects.filter(
user=obj.username,
timestamp__gte=seven_days_ago
).order_by('-timestamp')[:15] # Limit to 15 most recent
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'📊 Recent Activity ({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 - server and link info
html += f'<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 += f'<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.server}</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 += f'</div></div>'
# Right side - timestamp and status
html += f'<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 += f'</div>'
html += f'</div>'
# Footer with summary if there are more entries
total_recent = AccessLog.objects.filter(
user=obj.username,
timestamp__gte=seven_days_ago
).count()
if total_recent > 15:
html += f'<div style="background: #e9ecef; padding: 6px 12px; font-size: 11px; color: #6c757d; text-align: center;">'
html += f'Showing 15 of {total_recent} entries from last 7 days'
html += f'</div>'
html += '</div>'
return mark_safe(html)
except Exception as e:
return mark_safe(f'<span style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</span>')
@admin.display(description='Telegram Account')
def telegram_info_display(self, obj):
"""Display Telegram account information"""
if not obj.telegram_user_id:
if obj.telegram_username:
return mark_safe(f'<div style="background: #fff3cd; padding: 10px; border-radius: 5px; border-left: 4px solid #ffc107;">'
f'<span style="color: #856404;">🔗 Ready to link: @{obj.telegram_username}</span><br/>'
f'<small>User will be automatically linked when they message the bot</small></div>')
else:
return mark_safe('<span style="color: #6c757d;">No Telegram account linked</span>')
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">📱 Telegram Account Information</h4>'
# Telegram User ID
html += f'<p style="margin: 5px 0;"><strong>User ID:</strong> <code>{obj.telegram_user_id}</code></p>'
# Telegram Username
if obj.telegram_username:
html += f'<p style="margin: 5px 0;"><strong>Username:</strong> @{obj.telegram_username}</p>'
# Telegram Names
name_parts = []
if obj.telegram_first_name:
name_parts.append(obj.telegram_first_name)
if obj.telegram_last_name:
name_parts.append(obj.telegram_last_name)
if name_parts:
full_name = ' '.join(name_parts)
html += f'<p style="margin: 5px 0;"><strong>Name:</strong> {full_name}</p>'
# Telegram Phone (if available)
if obj.telegram_phone:
html += f'<p style="margin: 5px 0;"><strong>Phone:</strong> {obj.telegram_phone}</p>'
# Access requests count (if any)
try:
from telegram_bot.models import AccessRequest
requests_count = AccessRequest.objects.filter(telegram_user_id=obj.telegram_user_id).count()
if requests_count > 0:
html += f'<p style="margin: 10px 0 5px 0; color: #007cba;"><strong>📝 Access Requests:</strong> {requests_count}</p>'
# Show latest request status
latest_request = AccessRequest.objects.filter(telegram_user_id=obj.telegram_user_id).order_by('-created_at').first()
if latest_request:
status_color = '#28a745' if latest_request.approved else '#ffc107'
status_text = 'Approved' if latest_request.approved else 'Pending'
html += f'<p style="margin: 5px 0 5px 20px;">Latest: <span style="color: {status_color}; font-weight: bold;">{status_text}</span></p>'
except:
pass # Telegram bot app might not be available
# Add unlink button
unlink_url = reverse('admin:vpn_user_unlink_telegram', args=[obj.pk])
html += f'<div style="margin-top: 15px; padding-top: 10px; border-top: 1px solid #dee2e6;">'
html += f'<a href="{unlink_url}" class="button" style="background: #dc3545; color: white; text-decoration: none; padding: 8px 15px; border-radius: 3px; font-size: 13px;" onclick="return confirm(\'Are you sure you want to unlink this Telegram account?\')">🔗💥 Unlink Telegram Account</a>'
html += '</div>'
html += '</div>'
return mark_safe(html)
@admin.display(description='Subscription Management')
def subscription_management_info(self, obj):
"""Display subscription management information and quick access"""
if not obj.pk:
return "Save user first to manage subscriptions"
try:
from vpn.models_xray import UserSubscription, SubscriptionGroup
# Get user's current subscriptions
user_subscriptions = UserSubscription.objects.filter(user=obj).select_related('subscription_group')
active_subs = user_subscriptions.filter(active=True)
inactive_subs = user_subscriptions.filter(active=False)
# Get available subscription groups
all_groups = SubscriptionGroup.objects.filter(is_active=True)
subscribed_group_ids = user_subscriptions.values_list('subscription_group_id', flat=True)
available_groups = all_groups.exclude(id__in=subscribed_group_ids)
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
html += '<h4 style="margin: 0 0 15px 0; color: #495057;">🚀 Xray Subscription Management</h4>'
# Active subscriptions
if active_subs.exists():
html += '<div style="margin-bottom: 15px;">'
html += '<h5 style="color: #28a745; margin: 0 0 8px 0;">✅ Active Subscriptions</h5>'
for sub in active_subs:
html += f'<div style="background: #d4edda; padding: 8px 12px; border-radius: 4px; margin: 4px 0; display: flex; justify-content: space-between; align-items: center;">'
html += f'<span><strong>{sub.subscription_group.name}</strong>'
if sub.subscription_group.description:
html += f' - {sub.subscription_group.description[:50]}{"..." if len(sub.subscription_group.description) > 50 else ""}'
html += f'</span>'
html += f'<small style="color: #155724;">Since: {sub.created_at.strftime("%Y-%m-%d")}</small>'
html += f'</div>'
html += '</div>'
# Inactive subscriptions
if inactive_subs.exists():
html += '<div style="margin-bottom: 15px;">'
html += '<h5 style="color: #dc3545; margin: 0 0 8px 0;">❌ Inactive Subscriptions</h5>'
for sub in inactive_subs:
html += f'<div style="background: #f8d7da; padding: 8px 12px; border-radius: 4px; margin: 4px 0;">'
html += f'<span style="color: #721c24;"><strong>{sub.subscription_group.name}</strong></span>'
html += f'</div>'
html += '</div>'
# Available subscription groups
if available_groups.exists():
html += '<div style="margin-bottom: 15px;">'
html += '<h5 style="color: #007cba; margin: 0 0 8px 0;"> Available Subscription Groups</h5>'
html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'
for group in available_groups[:10]: # Limit to avoid clutter
html += f'<span style="background: #cce7ff; color: #004085; padding: 4px 8px; border-radius: 3px; font-size: 12px;">'
html += f'{group.name}'
html += f'</span>'
if available_groups.count() > 10:
html += f'<span style="color: #6c757d; font-style: italic;">+{available_groups.count() - 10} more...</span>'
html += '</div>'
html += '</div>'
# Quick access links
html += '<div style="border-top: 1px solid #dee2e6; padding-top: 12px; margin-top: 15px;">'
html += '<h5 style="margin: 0 0 8px 0; color: #495057;">🔗 Quick Access</h5>'
html += '<div style="display: flex; gap: 10px; flex-wrap: wrap;">'
# Link to standalone UserSubscription admin
subscription_admin_url = f"/admin/vpn/usersubscription/?user__id__exact={obj.id}"
html += f'<a href="{subscription_admin_url}" class="button" style="background: #007cba; color: white; text-decoration: none; padding: 6px 12px; border-radius: 3px; font-size: 12px;">📋 Manage All Subscriptions</a>'
# Link to add new subscription
add_subscription_url = f"/admin/vpn/usersubscription/add/?user={obj.id}"
html += f'<a href="{add_subscription_url}" class="button" style="background: #28a745; color: white; text-decoration: none; padding: 6px 12px; border-radius: 3px; font-size: 12px;"> Add New Subscription</a>'
# Link to subscription groups admin
groups_admin_url = "/admin/vpn/subscriptiongroup/"
html += f'<a href="{groups_admin_url}" class="button" style="background: #17a2b8; color: white; text-decoration: none; padding: 6px 12px; border-radius: 3px; font-size: 12px;">⚙️ Manage Groups</a>'
html += '</div>'
html += '</div>'
# Statistics
total_subs = user_subscriptions.count()
if total_subs > 0:
html += '<div style="border-top: 1px solid #dee2e6; padding-top: 8px; margin-top: 10px;">'
html += f'<small style="color: #6c757d;">📊 Total: {total_subs} subscription(s) | Active: {active_subs.count()} | Inactive: {inactive_subs.count()}</small>'
html += '</div>'
html += '</div>'
return mark_safe(html)
except Exception as e:
return mark_safe(f'<div style="background: #f8d7da; padding: 10px; border-radius: 4px; color: #721c24;">❌ Error loading subscription management: {e}</div>')
@admin.display(description='Allowed servers', ordering='server_count')
def server_count(self, obj):
return obj.server_count
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(server_count=Count('acl'))
return qs
def get_urls(self):
"""Add custom URLs for link management"""
urls = super().get_urls()
custom_urls = [
path('<int:user_id>/add-link/', self.admin_site.admin_view(self.add_link_view), name='user_add_link'),
path('<int:user_id>/delete-link/<int:link_id>/', self.admin_site.admin_view(self.delete_link_view), name='user_delete_link'),
path('<int:user_id>/add-server-access/', self.admin_site.admin_view(self.add_server_access_view), name='user_add_server_access'),
path('<int:user_id>/unlink-telegram/', self.admin_site.admin_view(self.unlink_telegram_view), name='vpn_user_unlink_telegram'),
]
return custom_urls + urls
def add_link_view(self, request, user_id):
"""AJAX view to add a new link for user on specific server"""
if request.method == 'POST':
try:
user = User.objects.get(pk=user_id)
server_id = request.POST.get('server_id')
comment = request.POST.get('comment', '')
if not server_id:
return JsonResponse({'error': 'Server ID is required'}, status=400)
server = Server.objects.get(pk=server_id)
acl = ACL.objects.get(user=user, server=server)
# Create new link
new_link = ACLLink.objects.create(
acl=acl,
comment=comment,
link=shortuuid.ShortUUID().random(length=16)
)
return JsonResponse({
'success': True,
'link_id': new_link.id,
'link': new_link.link,
'comment': new_link.comment,
'url': f"{EXTERNAL_ADDRESS}/ss/{new_link.link}#{server.name}"
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({'error': 'Invalid request method'}, status=405)
def delete_link_view(self, request, user_id, link_id):
"""AJAX view to delete a specific link"""
if request.method == 'POST':
try:
user = User.objects.get(pk=user_id)
link = ACLLink.objects.get(pk=link_id, acl__user=user)
link.delete()
return JsonResponse({'success': True})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({'error': 'Invalid request method'}, status=405)
def add_server_access_view(self, request, user_id):
"""AJAX view to add server access for user"""
if request.method == 'POST':
try:
user = User.objects.get(pk=user_id)
server_id = request.POST.get('server_id')
if not server_id:
return JsonResponse({'error': 'Server ID is required'}, status=400)
server = Server.objects.get(pk=server_id)
# Check if ACL already exists
if ACL.objects.filter(user=user, server=server).exists():
return JsonResponse({'error': 'User already has access to this server'}, status=400)
# Create new ACL (with default link)
acl = ACL.objects.create(user=user, server=server)
return JsonResponse({
'success': True,
'server_name': server.name,
'server_type': server.server_type,
'acl_id': acl.id
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({'error': 'Invalid request method'}, status=405)
def unlink_telegram_view(self, request, user_id):
"""Unlink Telegram account from user"""
user = get_object_or_404(User, pk=user_id)
if request.method == 'GET':
# Store original Telegram info for logging
telegram_info = {
'user_id': user.telegram_user_id,
'username': user.telegram_username,
'first_name': user.telegram_first_name,
'last_name': user.telegram_last_name
}
# Clear all Telegram fields
user.telegram_user_id = None
user.telegram_username = ""
user.telegram_first_name = ""
user.telegram_last_name = ""
user.telegram_phone = ""
user.save()
# Also clean up any related access requests
try:
from telegram_bot.models import AccessRequest
AccessRequest.objects.filter(telegram_user_id=telegram_info['user_id']).delete()
except:
pass # Telegram bot app might not be available
messages.success(
request,
f"Telegram account {'@' + telegram_info['username'] if telegram_info['username'] else telegram_info['user_id']} "
f"has been unlinked from user '{user.username}'"
)
return HttpResponseRedirect(reverse('admin:vpn_user_change', args=[user_id]))
def change_view(self, request, object_id, form_url='', extra_context=None):
"""Override change view to add user management data and fix layout"""
extra_context = extra_context or {}
if object_id:
try:
user = User.objects.get(pk=object_id)
extra_context.update({
'user_object': user,
'external_address': EXTERNAL_ADDRESS,
})
except User.DoesNotExist:
pass
return super().change_view(request, object_id, form_url, extra_context)

73
vpn/admin_minimal.py Normal file
View File

@@ -0,0 +1,73 @@
"""
Minimal admin test to check execution
"""
import logging
logger = logging.getLogger(__name__)
import json
from django.contrib import admin
from django.utils.safestring import mark_safe
# Try importing server plugins
try:
from .server_plugins import (
XrayServerV2,
XrayServerV2Admin
)
except Exception as e:
logger.error(f"🔴 Failed to import server plugins: {e}")
# Try importing refactored admin modules
try:
from .admin import *
except Exception as e:
logger.error(f"🔴 Failed to import refactored admin modules: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
# Try importing Xray admin classes
try:
from .admin_xray import *
except Exception as e:
logger.error(f"🔴 Failed to import Xray admin classes: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
# Set custom admin site configuration
admin.site.site_title = "VPN Manager"
admin.site.site_header = "VPN Manager"
admin.site.index_title = "OutFleet"
# Try adding custom Celery admin interfaces
try:
from django_celery_results.models import TaskResult
# Unregister default TaskResult admin if it exists
try:
admin.site.unregister(TaskResult)
except admin.sites.NotRegistered:
pass
@admin.register(TaskResult)
class CustomTaskResultAdmin(admin.ModelAdmin):
list_display = ('task_name_display', 'status', 'date_created')
@admin.display(description='Task Name', ordering='task_name')
def task_name_display(self, obj):
return obj.task_name
except ImportError:
pass # Celery not available
# Add subscription management to User admin
try:
from vpn.admin.user import add_subscription_management_to_user
from django.contrib.admin import site
for model, admin_instance in site._registry.items():
if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'):
add_subscription_management_to_user(admin_instance.__class__)
break
except Exception as e:
logger.error(f"Failed to add subscription management: {e}")

16
vpn/admin_test.py Normal file
View File

@@ -0,0 +1,16 @@
"""
Test admin file to check if code execution works
"""
import logging
logger = logging.getLogger(__name__)
logger.info("🧪 TEST ADMIN FILE EXECUTING!")
from django.contrib import admin
from .models import User
@admin.register(User)
class TestUserAdmin(admin.ModelAdmin):
list_display = ('username',)
logger.info("🧪 TEST ADMIN FILE COMPLETED!")

1056
vpn/admin_xray.py Normal file

File diff suppressed because it is too large Load Diff

109
vpn/apps.py Normal file
View File

@@ -0,0 +1,109 @@
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"""
import sys
import logging
logger = logging.getLogger(__name__)
logger.info(f"VPN App ready() called in process: {' '.join(sys.argv)}")
try:
import vpn.signals # noqa
except ImportError:
pass
# Only load admin interfaces in web processes, not in worker/beat
skip_admin_load = any([
'worker' in sys.argv,
'beat' in sys.argv,
'makemigrations' in sys.argv,
'migrate' in sys.argv,
'shell' in sys.argv,
'test' in sys.argv,
])
if not skip_admin_load:
logger.info("VPN App: Loading admin interfaces in web process")
# Force load admin interfaces first
self._load_admin_interfaces()
# Clean up unwanted admin interfaces
self._cleanup_admin_interfaces()
else:
logger.info("VPN App: Skipping admin loading in non-web process")
def _cleanup_admin_interfaces(self):
"""Remove unwanted admin interfaces after all apps are loaded"""
from django.contrib import admin
import logging
logger = logging.getLogger(__name__)
logger.info("VPN App: Starting admin cleanup...")
try:
from django_celery_results.models import GroupResult
from django_celery_beat.models import (
PeriodicTask,
ClockedSchedule,
CrontabSchedule,
IntervalSchedule,
SolarSchedule
)
from django.contrib.auth.models import Group
# Unregister celery models that we don't want in admin
models_to_unregister = [
GroupResult, PeriodicTask, ClockedSchedule,
CrontabSchedule, IntervalSchedule, SolarSchedule
]
for model in models_to_unregister:
try:
admin.site.unregister(model)
logger.info(f"VPN App: Unregistered {model.__name__}")
except admin.sites.NotRegistered:
logger.debug(f"VPN App: {model.__name__} was not registered, skipping")
# Unregister Django's default Group model
try:
admin.site.unregister(Group)
logger.info("VPN App: Unregistered Django Group model")
except admin.sites.NotRegistered:
logger.debug("VPN App: Django Group was not registered, skipping")
except ImportError as e:
# Celery packages not installed
logger.warning(f"VPN App: Celery packages not available: {e}")
logger.info("VPN App: Admin cleanup completed")
def _load_admin_interfaces(self):
"""Force load admin interfaces to ensure they are registered"""
import logging
logger = logging.getLogger(__name__)
logger.info("VPN App: Force loading admin interfaces...")
try:
# Import admin module to trigger registration
import sys
if 'vpn.admin_minimal' in sys.modules:
# Module already imported, remove it to force fresh import
del sys.modules['vpn.admin_minimal']
logger.info("VPN App: Removed vpn.admin_minimal from cache")
import vpn.admin_minimal
logger.info("VPN App: Successfully loaded vpn.admin_minimal")
except Exception as e:
logger.error(f"VPN App: Failed to load vpn.admin: {e}")
import traceback
logger.error(f"VPN App: Traceback: {traceback.format_exc()}")
logger.info("VPN App: Admin loading completed")

7
vpn/forms.py Normal file
View File

@@ -0,0 +1,7 @@
from django import forms
from .models import User
class UserForm(forms.ModelForm):
class Meta:
model = User
fields = ['username', 'first_name', 'last_name', 'email', 'comment', 'is_active']

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

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

View File

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

View File

@@ -0,0 +1,158 @@
from django.core.management.base import BaseCommand
from django.db import connection, transaction
from datetime import datetime, timedelta
from vpn.models import AccessLog
class Command(BaseCommand):
help = 'Clean up old AccessLog entries without acl_link_id'
def add_arguments(self, parser):
parser.add_argument(
'--days',
type=int,
default=30,
help='Delete logs older than this many days (default: 30)'
)
parser.add_argument(
'--batch-size',
type=int,
default=10000,
help='Number of records to delete in each batch (default: 10000)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be deleted without actually deleting'
)
parser.add_argument(
'--keep-recent',
type=int,
default=1000,
help='Keep this many recent logs even if they have no link (default: 1000)'
)
def handle(self, *args, **options):
days = options['days']
batch_size = options['batch_size']
dry_run = options['dry_run']
keep_recent = options['keep_recent']
cutoff_date = datetime.now() - timedelta(days=days)
self.stdout.write(f"🔍 Analyzing AccessLog cleanup...")
self.stdout.write(f" - Delete logs without acl_link_id older than {days} days")
self.stdout.write(f" - Keep {keep_recent} most recent logs without links")
self.stdout.write(f" - Batch size: {batch_size}")
self.stdout.write(f" - Dry run: {dry_run}")
# Count total records to be deleted
with connection.cursor() as cursor:
# Count logs without acl_link_id
cursor.execute("""
SELECT COUNT(*) FROM vpn_accesslog
WHERE (acl_link_id IS NULL OR acl_link_id = '')
""")
total_without_link = cursor.fetchone()[0]
# Count logs to be deleted (older than cutoff, excluding recent ones to keep)
cursor.execute("""
SELECT COUNT(*) FROM vpn_accesslog
WHERE (acl_link_id IS NULL OR acl_link_id = '')
AND timestamp < %s
AND id NOT IN (
SELECT id FROM (
SELECT id FROM vpn_accesslog
WHERE acl_link_id IS NULL OR acl_link_id = ''
ORDER BY timestamp DESC
LIMIT %s
) AS recent_logs
)
""", [cutoff_date, keep_recent])
total_to_delete = cursor.fetchone()[0]
# Count total records
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
total_records = cursor.fetchone()[0]
self.stdout.write(f"📊 Statistics:")
self.stdout.write(f" - Total AccessLog records: {total_records:,}")
self.stdout.write(f" - Records without acl_link_id: {total_without_link:,}")
self.stdout.write(f" - Records to be deleted: {total_to_delete:,}")
self.stdout.write(f" - Records to be kept (recent): {keep_recent:,}")
if total_to_delete == 0:
self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
return
if dry_run:
self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {total_to_delete:,} records"))
return
# Confirm deletion
if not options.get('verbosity', 1) == 0: # Only ask if not --verbosity=0
confirm = input(f"❓ Delete {total_to_delete:,} records? (yes/no): ")
if confirm.lower() != 'yes':
self.stdout.write("❌ Cancelled.")
return
self.stdout.write(f"🗑️ Starting deletion of {total_to_delete:,} records...")
deleted_total = 0
batch_num = 0
while True:
batch_num += 1
with transaction.atomic():
with connection.cursor() as cursor:
# Delete batch
cursor.execute("""
DELETE FROM vpn_accesslog
WHERE (acl_link_id IS NULL OR acl_link_id = '')
AND timestamp < %s
AND id NOT IN (
SELECT id FROM (
SELECT id FROM vpn_accesslog
WHERE acl_link_id IS NULL OR acl_link_id = ''
ORDER BY timestamp DESC
LIMIT %s
) AS recent_logs
)
LIMIT %s
""", [cutoff_date, keep_recent, batch_size])
deleted_in_batch = cursor.rowcount
if deleted_in_batch == 0:
break
deleted_total += deleted_in_batch
progress = (deleted_total / total_to_delete) * 100
self.stdout.write(
f" Batch {batch_num}: Deleted {deleted_in_batch:,} records "
f"(Total: {deleted_total:,}/{total_to_delete:,}, {progress:.1f}%)"
)
if deleted_in_batch < batch_size:
break
self.stdout.write(self.style.SUCCESS(f"✅ Cleanup completed!"))
self.stdout.write(f" - Deleted {deleted_total:,} old AccessLog records")
self.stdout.write(f" - Kept {keep_recent:,} recent records without links")
# Show final statistics
with connection.cursor() as cursor:
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
final_total = cursor.fetchone()[0]
cursor.execute("""
SELECT COUNT(*) FROM vpn_accesslog
WHERE acl_link_id IS NULL OR acl_link_id = ''
""")
final_without_link = cursor.fetchone()[0]
self.stdout.write(f"📊 Final statistics:")
self.stdout.write(f" - Total AccessLog records: {final_total:,}")
self.stdout.write(f" - Records without acl_link_id: {final_without_link:,}")

View File

@@ -0,0 +1,208 @@
from django.core.management.base import BaseCommand
from django.db import connection
from django.utils import timezone
from datetime import timedelta
class Command(BaseCommand):
help = '''
Clean up AccessLog entries efficiently using direct SQL.
Examples:
# Delete logs without acl_link_id older than 30 days (recommended)
python manage.py cleanup_logs --keep-days=30
# Keep only last 5000 logs without acl_link_id
python manage.py cleanup_logs --keep-count=5000
# Delete ALL logs older than 7 days (including with acl_link_id)
python manage.py cleanup_logs --keep-days=7 --target=all
# Preview what would be deleted
python manage.py cleanup_logs --keep-days=30 --dry-run
# Force delete without confirmation
python manage.py cleanup_logs --keep-days=30 --force
'''
def add_arguments(self, parser):
# Primary options (mutually exclusive)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
'--keep-days',
type=int,
help='Keep logs newer than this many days (delete older)'
)
group.add_argument(
'--keep-count',
type=int,
help='Keep this many most recent logs (delete the rest)'
)
parser.add_argument(
'--target',
choices=['no-links', 'all'],
default='no-links',
help='Target: "no-links" = only logs without acl_link_id (default), "all" = all logs'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be deleted without actually deleting'
)
parser.add_argument(
'--force',
action='store_true',
help='Skip confirmation prompt'
)
def handle(self, *args, **options):
keep_days = options.get('keep_days')
keep_count = options.get('keep_count')
target = options['target']
dry_run = options['dry_run']
force = options['force']
# Build SQL conditions
if target == 'no-links':
base_condition = "(acl_link_id IS NULL OR acl_link_id = '')"
target_desc = "logs without acl_link_id"
else:
base_condition = "1=1"
target_desc = "all logs"
# Get current statistics
with connection.cursor() as cursor:
# Total records
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
total_records = cursor.fetchone()[0]
# Target records count
cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
target_records = cursor.fetchone()[0]
# Records to delete
if keep_days:
cutoff_date = timezone.now() - timedelta(days=keep_days)
cursor.execute(f"""
SELECT COUNT(*) FROM vpn_accesslog
WHERE {base_condition} AND timestamp < %s
""", [cutoff_date])
to_delete = cursor.fetchone()[0]
strategy = f"older than {keep_days} days"
else: # keep_count
to_delete = max(0, target_records - keep_count)
strategy = f"keeping only {keep_count} most recent"
# Print statistics
self.stdout.write("🗑️ AccessLog Cleanup" + (" (DRY RUN)" if dry_run else ""))
self.stdout.write(f" Target: {target_desc}")
self.stdout.write(f" Strategy: {strategy}")
self.stdout.write("")
self.stdout.write("📊 Statistics:")
self.stdout.write(f" Total AccessLog records: {total_records:,}")
self.stdout.write(f" Target records: {target_records:,}")
self.stdout.write(f" Records to delete: {to_delete:,}")
self.stdout.write(f" Records to keep: {target_records - to_delete:,}")
if total_records > 0:
delete_percent = (to_delete / total_records) * 100
self.stdout.write(f" Deletion percentage: {delete_percent:.1f}%")
if to_delete == 0:
self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
return
# Show SQL that will be executed
if dry_run or not force:
self.stdout.write("")
self.stdout.write("📝 SQL to execute:")
if keep_days:
sql_preview = f"""
DELETE FROM vpn_accesslog
WHERE {base_condition} AND timestamp < '{cutoff_date.strftime('%Y-%m-%d %H:%M:%S')}'
"""
else: # keep_count
sql_preview = f"""
DELETE FROM vpn_accesslog
WHERE {base_condition}
AND id NOT IN (
SELECT id FROM (
SELECT id FROM vpn_accesslog
WHERE {base_condition}
ORDER BY timestamp DESC
LIMIT {keep_count}
) AS recent_logs
)
"""
self.stdout.write(sql_preview.strip())
if dry_run:
self.stdout.write("")
self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {to_delete:,} records"))
return
# Confirm deletion
if not force:
self.stdout.write("")
self.stdout.write(self.style.ERROR(f"⚠️ About to DELETE {to_delete:,} records!"))
confirm = input("Type 'DELETE' to confirm: ")
if confirm != 'DELETE':
self.stdout.write("❌ Cancelled.")
return
# Execute deletion
self.stdout.write("")
self.stdout.write(f"🗑️ Deleting {to_delete:,} records...")
with connection.cursor() as cursor:
if keep_days:
# Simple time-based deletion
cursor.execute(f"""
DELETE FROM vpn_accesslog
WHERE {base_condition} AND timestamp < %s
""", [cutoff_date])
else:
# Keep count deletion (more complex)
cursor.execute(f"""
DELETE FROM vpn_accesslog
WHERE {base_condition}
AND id NOT IN (
SELECT id FROM (
SELECT id FROM vpn_accesslog
WHERE {base_condition}
ORDER BY timestamp DESC
LIMIT %s
) AS recent_logs
)
""", [keep_count])
deleted_count = cursor.rowcount
# Final statistics
with connection.cursor() as cursor:
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
final_total = cursor.fetchone()[0]
cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
final_target = cursor.fetchone()[0]
self.stdout.write("")
self.stdout.write(self.style.SUCCESS("✅ Cleanup completed!"))
self.stdout.write(f" Deleted: {deleted_count:,} records")
self.stdout.write(f" Remaining total: {final_total:,}")
if target == 'no-links':
self.stdout.write(f" Remaining without links: {final_target:,}")
# Calculate space saved (rough estimate)
if deleted_count > 0:
# Rough estimate: ~200 bytes per AccessLog record
space_saved_mb = (deleted_count * 200) / (1024 * 1024)
if space_saved_mb > 1024:
space_saved_gb = space_saved_mb / 1024
self.stdout.write(f" Estimated space saved: ~{space_saved_gb:.1f} GB")
else:
self.stdout.write(f" Estimated space saved: ~{space_saved_mb:.1f} MB")

View File

@@ -0,0 +1,17 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
class Command(BaseCommand):
help = 'Create default admin user'
def handle(self, *args, **kwargs):
User = get_user_model()
if not User.objects.filter(username='admin').exists():
User.objects.create_superuser(
username='admin',
password='admin',
email='admin@localhost'
)
self.stdout.write(self.style.SUCCESS('Admin user created'))
else:
self.stdout.write(self.style.WARNING('Admin user already exists'))

View File

@@ -0,0 +1 @@
from django.core.management.base import BaseCommand\nfrom django.utils import timezone\nfrom vpn.models import User, ACLLink, UserStatistics\nfrom vpn.tasks import update_user_statistics\n\n\nclass Command(BaseCommand):\n help = 'Initialize user statistics cache by running the update task'\n \n def add_arguments(self, parser):\n parser.add_argument(\n '--async',\n action='store_true',\n help='Run statistics update as async Celery task (default: sync)',\n )\n parser.add_argument(\n '--force',\n action='store_true',\n help='Force update even if statistics already exist',\n )\n \n def handle(self, *args, **options):\n # Check if statistics already exist\n existing_stats = UserStatistics.objects.count()\n \n if existing_stats > 0 and not options['force']:\n self.stdout.write(\n self.style.WARNING(\n f'Statistics cache already contains {existing_stats} entries. '\n 'Use --force to update anyway.'\n )\n )\n return\n \n # Check if there are users with ACL links\n users_with_links = User.objects.filter(acl__isnull=False).distinct().count()\n total_links = ACLLink.objects.count()\n \n self.stdout.write(\n f'Found {users_with_links} users with {total_links} ACL links total'\n )\n \n if total_links == 0:\n self.stdout.write(\n self.style.WARNING('No ACL links found. Nothing to process.')\n )\n return\n \n if options['async']:\n # Run as async Celery task\n try:\n task = update_user_statistics.delay()\n self.stdout.write(\n self.style.SUCCESS(\n f'Statistics update task started. Task ID: {task.id}'\n )\n )\n self.stdout.write(\n 'Check admin panel Task Execution Logs for progress.'\n )\n except Exception as e:\n self.stdout.write(\n self.style.ERROR(f'Failed to start async task: {e}')\n )\n else:\n # Run synchronously\n self.stdout.write('Starting synchronous statistics update...')\n \n try:\n # Import and call the task function directly\n from vpn.tasks import update_user_statistics\n \n # Create a mock Celery request object for the task\n class MockRequest:\n id = f'manual-{timezone.now().isoformat()}'\n retries = 0\n \n # Create mock task instance\n task_instance = type('MockTask', (), {\n 'request': MockRequest(),\n })()\n \n # Call the task function directly\n result = update_user_statistics(task_instance)\n \n self.stdout.write(\n self.style.SUCCESS(f'Statistics update completed: {result}')\n )\n \n # Show summary\n final_stats = UserStatistics.objects.count()\n self.stdout.write(\n self.style.SUCCESS(\n f'Statistics cache now contains {final_stats} entries'\n )\n )\n \n except Exception as e:\n self.stdout.write(\n self.style.ERROR(f'Statistics update failed: {e}')\n )\n import traceback\n self.stdout.write(traceback.format_exc())\n

View File

@@ -0,0 +1,51 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from datetime import timedelta
from vpn.models import AccessLog
class Command(BaseCommand):
help = 'Simple cleanup of AccessLog entries without acl_link_id using Django ORM'
def add_arguments(self, parser):
parser.add_argument(
'--days',
type=int,
default=30,
help='Delete logs older than this many days (default: 30)'
)
def handle(self, *args, **options):
days = options['days']
cutoff_date = timezone.now() - timedelta(days=days)
# Count records to be deleted
old_logs = AccessLog.objects.filter(
acl_link_id__isnull=True,
timestamp__lt=cutoff_date
)
# Also include empty string acl_link_id
empty_logs = AccessLog.objects.filter(
acl_link_id='',
timestamp__lt=cutoff_date
)
total_old = old_logs.count()
total_empty = empty_logs.count()
total_to_delete = total_old + total_empty
self.stdout.write(f"Found {total_to_delete:,} old logs without acl_link_id to delete")
if total_to_delete == 0:
self.stdout.write("Nothing to delete.")
return
# Delete in batches to avoid memory issues
self.stdout.write("Deleting old logs...")
deleted_count = 0
deleted_count += old_logs._raw_delete(old_logs.db)
deleted_count += empty_logs._raw_delete(empty_logs.db)
self.stdout.write(self.style.SUCCESS(f"Deleted {deleted_count:,} old AccessLog records"))

View File

@@ -0,0 +1,139 @@
# Initial migration
from django.conf import settings
from django.db import migrations, models
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import shortuuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('comment', models.TextField(blank=True, default='', help_text='Free form user comment')),
('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('last_access', models.DateTimeField(blank=True, null=True)),
('hash', models.CharField(help_text='Random user hash. It\'s using for client config generation.', max_length=64, unique=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='AccessLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user', models.CharField(blank=True, editable=False, max_length=256, null=True)),
('server', models.CharField(blank=True, editable=False, max_length=256, null=True)),
('action', models.CharField(editable=False, max_length=100)),
('data', models.TextField(blank=True, default='', editable=False)),
('timestamp', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Server',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Server name', max_length=100)),
('comment', models.TextField(blank=True, default='')),
('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('server_type', models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard')], editable=False, max_length=50)),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'verbose_name': 'Server',
'verbose_name_plural': 'Servers',
'permissions': [('access_server', 'Can view public status')],
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='OutlineServer',
fields=[
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
('admin_url', models.URLField(help_text='Management URL')),
('admin_access_cert', models.CharField(help_text='Fingerprint', max_length=255)),
('client_hostname', models.CharField(help_text='Server address for clients', max_length=255)),
('client_port', models.CharField(help_text='Server port for clients', max_length=5)),
],
options={
'verbose_name': 'Outline',
'verbose_name_plural': 'Outline',
'base_manager_name': 'objects',
},
bases=('vpn.server',),
),
migrations.CreateModel(
name='WireguardServer',
fields=[
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
('address', models.CharField(max_length=100)),
('port', models.IntegerField()),
('client_private_key', models.CharField(max_length=255)),
('server_publick_key', models.CharField(max_length=255)),
],
options={
'verbose_name': 'Wireguard',
'verbose_name_plural': 'Wireguard',
'base_manager_name': 'objects',
},
bases=('vpn.server',),
),
migrations.CreateModel(
name='ACL',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.server')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ACLLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('comment', models.TextField(blank=True, default='', help_text='ACL link comment, device name, etc...')),
('link', models.CharField(blank=True, default='', help_text='Access link to get dynamic configuration', max_length=1024, null=True, unique=True, verbose_name='Access link')),
('acl', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='vpn.acl')),
],
),
migrations.AddField(
model_name='user',
name='servers',
field=models.ManyToManyField(blank=True, help_text='Servers user has access to', through='vpn.ACL', to='vpn.server'),
),
migrations.AddConstraint(
model_name='acl',
constraint=models.UniqueConstraint(fields=('user', 'server'), name='unique_user_server'),
),
]

View File

@@ -0,0 +1,51 @@
# Generated manually to fix migration issue
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('vpn', '0001_initial'),
]
operations = [
migrations.RunSQL(
"DROP TABLE IF EXISTS vpn_taskexecutionlog CASCADE;",
reverse_sql="-- No reverse operation"
),
migrations.CreateModel(
name='TaskExecutionLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('task_id', models.CharField(help_text='Celery task ID', max_length=255)),
('task_name', models.CharField(help_text='Task name', max_length=100)),
('action', models.CharField(help_text='Action performed', max_length=100)),
('status', models.CharField(choices=[('STARTED', 'Started'), ('SUCCESS', 'Success'), ('FAILURE', 'Failure'), ('RETRY', 'Retry')], default='STARTED', max_length=20)),
('message', models.TextField(help_text='Detailed execution message')),
('execution_time', models.FloatField(blank=True, help_text='Execution time in seconds', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('server', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.server')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.user')),
],
options={
'verbose_name': 'Task Execution Log',
'verbose_name_plural': 'Task Execution Logs',
'ordering': ['-created_at'],
},
),
# Create indexes with safe SQL to avoid conflicts
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS vpn_taskexec_task_id_idx ON vpn_taskexecutionlog (task_id);",
reverse_sql="DROP INDEX IF EXISTS vpn_taskexec_task_id_idx;"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS vpn_taskexec_created_idx ON vpn_taskexecutionlog (created_at);",
reverse_sql="DROP INDEX IF EXISTS vpn_taskexec_created_idx;"
),
migrations.RunSQL(
"CREATE INDEX IF NOT EXISTS vpn_taskexec_status_idx ON vpn_taskexecutionlog (status);",
reverse_sql="DROP INDEX IF EXISTS vpn_taskexec_status_idx;"
),
]

View File

@@ -0,0 +1,18 @@
# Generated migration for adding last_access_time field
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0002_taskexecutionlog'),
]
operations = [
migrations.AddField(
model_name='acllink',
name='last_access_time',
field=models.DateTimeField(blank=True, help_text='Last time this link was accessed', null=True),
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.1.7 on 2025-07-21 01:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0002_taskexecutionlog'),
]
operations = [
migrations.AlterModelOptions(
name='outlineserver',
options={'verbose_name': 'Outline', 'verbose_name_plural': 'Outline'},
),
migrations.AlterModelOptions(
name='server',
options={'permissions': [('access_server', 'Can view public status')], 'verbose_name': 'Server', 'verbose_name_plural': 'Servers'},
),
migrations.AlterModelOptions(
name='wireguardserver',
options={'verbose_name': 'Wireguard', 'verbose_name_plural': 'Wireguard'},
),
migrations.AddIndex(
model_name='accesslog',
index=models.Index(fields=['user'], name='vpn_accessl_user_05a541_idx'),
),
migrations.AddIndex(
model_name='accesslog',
index=models.Index(fields=['server'], name='vpn_accessl_server_0865e6_idx'),
),
migrations.AddIndex(
model_name='accesslog',
index=models.Index(fields=['timestamp'], name='vpn_accessl_timesta_480a45_idx'),
),
migrations.AddIndex(
model_name='accesslog',
index=models.Index(fields=['action', 'timestamp'], name='vpn_accessl_action_898948_idx'),
),
migrations.AddIndex(
model_name='taskexecutionlog',
index=models.Index(fields=['task_id'], name='vpn_taskexe_task_id_e7e101_idx'),
),
migrations.AddIndex(
model_name='taskexecutionlog',
index=models.Index(fields=['created_at'], name='vpn_taskexe_created_b458ed_idx'),
),
migrations.AddIndex(
model_name='taskexecutionlog',
index=models.Index(fields=['status'], name='vpn_taskexe_status_2f0769_idx'),
),
]

View File

@@ -0,0 +1,14 @@
# Generated by Django 5.1.7 on 2025-07-21 09:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0003_acllink_last_access_time'),
('vpn', '0003_alter_outlineserver_options_alter_server_options_and_more'),
]
operations = [
]

View File

@@ -0,0 +1,45 @@
# Generated migration for UserStatistics model
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('vpn', '0004_merge_20250721_1223'),
]
operations = [
migrations.CreateModel(
name='UserStatistics',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('server_name', models.CharField(max_length=256)),
('acl_link_id', models.CharField(blank=True, help_text='None for server-level stats', max_length=1024, null=True)),
('total_connections', models.IntegerField(default=0)),
('recent_connections', models.IntegerField(default=0)),
('daily_usage', models.JSONField(default=list, help_text='Daily connection counts for last 30 days')),
('max_daily', models.IntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User Statistics',
'verbose_name_plural': 'User Statistics',
},
),
migrations.AddIndex(
model_name='userstatistics',
index=models.Index(fields=['user', 'server_name'], name='vpn_usersta_user_id_1c7cd0_idx'),
),
migrations.AddIndex(
model_name='userstatistics',
index=models.Index(fields=['updated_at'], name='vpn_usersta_updated_8e6e9b_idx'),
),
migrations.AlterUniqueTogether(
name='userstatistics',
unique_together={('user', 'server_name', 'acl_link_id')},
),
]

View File

@@ -0,0 +1,22 @@
# Generated migration for AccessLog acl_link_id field
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0005_userstatistics'),
]
operations = [
migrations.AddField(
model_name='accesslog',
name='acl_link_id',
field=models.CharField(blank=True, editable=False, help_text='ID of the ACL link used', max_length=1024, null=True),
),
migrations.AddIndex(
model_name='accesslog',
index=models.Index(fields=['acl_link_id'], name='vpn_accessl_acl_lin_b23c6e_idx'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.7 on 2025-07-21 10:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0005_userstatistics'),
]
operations = [
migrations.RenameIndex(
model_name='userstatistics',
new_name='vpn_usersta_user_id_512036_idx',
old_name='vpn_usersta_user_id_1c7cd0_idx',
),
migrations.RenameIndex(
model_name='userstatistics',
new_name='vpn_usersta_updated_5ac650_idx',
old_name='vpn_usersta_updated_8e6e9b_idx',
),
]

View File

@@ -0,0 +1,14 @@
# Generated by Django 5.1.7 on 2025-07-21 10:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0006_accesslog_acl_link_id'),
('vpn', '0006_rename_vpn_usersta_user_id_1c7cd0_idx_vpn_usersta_user_id_512036_idx_and_more'),
]
operations = [
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-07-21 10:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0007_merge_20250721_1345'),
]
operations = [
migrations.RenameIndex(
model_name='accesslog',
new_name='vpn_accessl_acl_lin_9f3bc5_idx',
old_name='vpn_accessl_acl_lin_b23c6e_idx',
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 5.1.7 on 2025-07-27 17:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0008_rename_vpn_accessl_acl_lin_b23c6e_idx_vpn_accessl_acl_lin_9f3bc5_idx'),
]
operations = [
migrations.CreateModel(
name='XrayCoreServer',
fields=[
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
('api_address', models.CharField(help_text='Xray Core API address (e.g., http://127.0.0.1:8080)', max_length=255)),
('api_port', models.IntegerField(default=8080, help_text='API port for management interface')),
('api_token', models.CharField(blank=True, help_text='API authentication token', max_length=255)),
('server_address', models.CharField(help_text='Server address for clients to connect', max_length=255)),
('server_port', models.IntegerField(default=443, help_text='Server port for client connections')),
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('shadowsocks', 'Shadowsocks'), ('trojan', 'Trojan')], default='vless', help_text='Primary protocol for this server', max_length=20)),
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY'), ('xtls', 'XTLS')], default='tls', help_text='Security layer configuration', max_length=20)),
('transport', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)),
('config_json', models.JSONField(blank=True, default=dict, help_text='Complete Xray configuration in JSON format')),
('panel_url', models.CharField(blank=True, help_text='Web panel URL if using 3X-UI or similar management panel', max_length=255)),
('panel_username', models.CharField(blank=True, help_text='Panel admin username', max_length=100)),
('panel_password', models.CharField(blank=True, help_text='Panel admin password', max_length=100)),
],
options={
'verbose_name': 'Xray Core Server',
'verbose_name_plural': 'Xray Core Servers',
},
bases=('vpn.server',),
),
migrations.AlterField(
model_name='server',
name='server_type',
field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core')], editable=False, max_length=50),
),
]

View File

@@ -0,0 +1,137 @@
# Generated by Django 5.1.7 on 2025-07-28 22:34
import django.contrib.postgres.fields
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0009_xraycoreserver_alter_server_server_type'),
]
operations = [
migrations.RemoveField(
model_name='xraycoreserver',
name='api_address',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='api_port',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='api_token',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='config_json',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='panel_password',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='panel_url',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='panel_username',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='protocol',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='security',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='server_address',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='server_port',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='transport',
),
migrations.AddField(
model_name='xraycoreserver',
name='default_protocol',
field=models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], default='vless', help_text='Default protocol for new inbounds', max_length=20),
),
migrations.AddField(
model_name='xraycoreserver',
name='enable_stats',
field=models.BooleanField(default=True, help_text='Enable traffic statistics tracking'),
),
migrations.AddField(
model_name='xraycoreserver',
name='grpc_address',
field=models.CharField(default='127.0.0.1', help_text='Xray Core gRPC API address', max_length=255),
),
migrations.AddField(
model_name='xraycoreserver',
name='grpc_port',
field=models.IntegerField(default=10085, help_text='gRPC API port (usually 10085)'),
),
migrations.CreateModel(
name='XrayInbound',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tag', models.CharField(help_text='Unique identifier for this inbound', max_length=100)),
('port', models.IntegerField(help_text='Port to listen on')),
('listen', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=255)),
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], max_length=20)),
('enabled', models.BooleanField(default=True)),
('is_default', models.BooleanField(default=False, help_text='Use this inbound for new users by default')),
('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', max_length=20)),
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', max_length=20)),
('server_address', models.CharField(blank=True, help_text='Public server address for client connections (if different from listen address)', max_length=255)),
('ss_method', models.CharField(blank=True, default='chacha20-ietf-poly1305', help_text='Shadowsocks encryption method', max_length=50)),
('ss_password', models.CharField(blank=True, help_text='Shadowsocks password (for single-user mode)', max_length=255)),
('tls_cert_file', models.CharField(blank=True, max_length=255)),
('tls_key_file', models.CharField(blank=True, max_length=255)),
('tls_alpn', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), blank=True, default=list, size=None)),
('stream_settings', models.JSONField(blank=True, default=dict)),
('sniffing_settings', models.JSONField(blank=True, default=dict)),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inbounds', to='vpn.xraycoreserver')),
],
options={
'ordering': ['port'],
'unique_together': {('server', 'port'), ('server', 'tag')},
},
),
migrations.CreateModel(
name='XrayClient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
('email', models.CharField(help_text='Email for statistics', max_length=255)),
('level', models.IntegerField(default=0)),
('enable', models.BooleanField(default=True)),
('flow', models.CharField(blank=True, help_text='VLESS flow control', max_length=50)),
('alter_id', models.IntegerField(default=0, help_text='VMess alterId')),
('password', models.CharField(blank=True, help_text='Password for Trojan/Shadowsocks', max_length=255)),
('total_gb', models.IntegerField(blank=True, help_text='Traffic limit in GB', null=True)),
('expiry_time', models.DateTimeField(blank=True, help_text='Account expiration time', null=True)),
('up', models.BigIntegerField(default=0, help_text='Upload bytes')),
('down', models.BigIntegerField(default=0, help_text='Download bytes')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clients', to='vpn.xrayinbound')),
],
options={
'ordering': ['created_at'],
'unique_together': {('inbound', 'user')},
},
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.1.7 on 2025-07-31 21:52
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0010_remove_xraycoreserver_api_address_and_more'),
]
operations = [
migrations.CreateModel(
name='XrayInboundProxy',
fields=[
],
options={
'verbose_name': 'Xray Inbound (Server View)',
'verbose_name_plural': 'Xray Inbounds (Server View)',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('vpn.xrayinbound',),
),
migrations.RemoveField(
model_name='xraycoreserver',
name='default_protocol',
),
migrations.RemoveField(
model_name='xrayinbound',
name='is_default',
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.1.7 on 2025-07-31 21:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0011_xrayinboundproxy_and_more'),
]
operations = [
migrations.CreateModel(
name='XrayInboundServer',
fields=[
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
('xray_inbound', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='server_proxy', to='vpn.xrayinbound')),
],
options={
'verbose_name': 'Xray Inbound Server',
'verbose_name_plural': 'Xray Inbound Servers',
},
bases=('vpn.server',),
),
migrations.DeleteModel(
name='XrayInboundProxy',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-07-31 22:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0012_xrayinboundserver_delete_xrayinboundproxy'),
]
operations = [
migrations.AddField(
model_name='xraycoreserver',
name='client_hostname',
field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections (what clients use to connect)', max_length=255),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.7 on 2025-08-04 22:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0013_add_client_hostname'),
]
operations = [
migrations.AlterField(
model_name='xraycoreserver',
name='client_hostname',
field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections', max_length=255),
),
migrations.AlterField(
model_name='xrayinbound',
name='server_address',
field=models.CharField(blank=True, help_text='Public server address for client connections', max_length=255),
),
]

View File

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

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

View File

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

View File

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

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