132 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-08 04:40:12 +00:00
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
Alexandr Bogomyakov
f6bcb42ec4 Bump version 2024-03-24 18:07:23 +02:00
Alexandr Bogomyakov
bae0b91bab Merge pull request #7 from Sanapach/master
Outfleet::log fixing
2024-03-24 18:04:18 +02:00
mmilavkin
ab6d53a837 Outfleet::log fixing::2 2024-03-24 18:02:51 +02:00
mmilavkin
9709d2f029 Outfleet::log fixing 2024-03-24 17:58:54 +02:00
e818d63cad fix k8s things 2024-03-19 02:47:29 +02:00
2039654f12 fix k8s things 2024-03-19 01:44:38 +02:00
f82631b174 fix k8s things 2024-03-19 01:11:08 +02:00
77b78ec751 fix k8s things 2024-03-18 22:53:43 +02:00
f6a728ef1a fix k8s things 2024-03-18 22:08:43 +02:00
5c1ffcbdc3 fix k8s things 2024-03-18 21:30:34 +02:00
f6c3262fb8 fix V1 api define 2024-03-18 20:42:14 +02:00
607730e781 fix V1 api define 2024-03-18 20:36:07 +02:00
0e7cabe336 fix V1 api define 2024-03-18 20:02:33 +02:00
614140840d Fix k8s exception 2024-03-18 19:50:24 +02:00
Alexandr Bogomyakov
3a6a60032e Update README.md 2024-03-18 19:39:57 +02:00
263acf540d Fix docker build 2024-03-18 19:21:25 +02:00
Alexandr Bogomyakov
443198aad1 Merge pull request #6 from house-of-vanity/k8s
K8s
2024-03-18 19:12:05 +02:00
115 changed files with 15215 additions and 1523 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"
]
}
]
}

40
Dockerfile Executable file → Normal file
View File

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

View File

@@ -2,7 +2,7 @@
<h1 align="center">OutFleet: Master Your OutLine VPN</h1>
<p align="center">
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers and always-updated Dynamic Access Keys instead of ss:// links
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers, users and always-updated Dynamic Access Keys instead of ss:// links
<br/>
<br/>
<a href="https://github.com/house-of-vanity/outfleet/issues">Request Feature</a>
@@ -11,9 +11,9 @@
![Forks](https://img.shields.io/github/forks/house-of-vanity/outfleet?style=social) ![Stargazers](https://img.shields.io/github/stars/house-of-vanity/outfleet?style=social) ![License](https://img.shields.io/github/license/house-of-vanity/outfleet)
## About The Project
<img width="1454" alt="image" src="https://github.com/user-attachments/assets/20555dd9-54ea-4b95-aa13-a7dd54e34ef4" />
![Screen Shot](img/servers.png)
## About The Project
### Key Features
@@ -28,62 +28,31 @@ Tired of juggling multiple home servers and the headache of individually managin
## Built With
Python, Flask and offer hassle-free deployment.
Django, Postgres SQL and hassle-free deployment using Kubernetes or docker-compose
### Installation
#### Docker compose
Docker deploy is easy:
```
docker run --restart always -p 5000:5000 -d --name outfleet --mount type=bind,source=/etc/outfleet/config.yaml,target=/usr/local/etc/outfleet/config.yaml ultradesu/outfleet:latest
docker-compose up -d
```
#### Use reverse proxy to secure ALL path of OutFleet except of `/dynamic/*`
```nginx
server {
listen 443 ssl http2;
server_name server.name;
# Specify SSL config if using a shared one.
#include conf.d/ssl/ssl.conf;
# Allow large attachments
client_max_body_size 128M;
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/server.name/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/server.name/privkey.pem; # managed by Certbot
#### Kubernetes
I use ArgoCD for deployment. [Take a look](https://gt.hexor.cy/ab/homelab/src/branch/main/k8s/apps/vpn) to `outfleet.yaml` file for manifests.
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
auth_basic "Private Place";
auth_basic_user_file /etc/nginx/htpasswd;
}
location /dynamic {
auth_basic off;
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
access_log /var/log/nginx/server.name.access.log;
error_log /var/log/nginx/server.name.error.log;
}
server {
listen 80;
server_name server.name;
listen [::]:80;
return 301 https://$host$request_uri;
}
#### Setup sslocal service on Windows
Shadowsocks servers can be used directly with **sslocal**. For automatic and regular password updates, you can create a Task Scheduler job to rotate the passwords when they change, as OutFleet manages the passwords automatically.
You may run script in Admin PowerShell to create Task for autorun **sslocal** and update connection details automatically using Outfleet API
```PowerShell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force; Invoke-Expression (Invoke-WebRequest -Uri "https://raw.githubusercontent.com/house-of-vanity/OutFleet/refs/heads/master/tools/windows-helper.ps1" -UseBasicParsing).Content
```
[Firefox PluginProxy Switcher and Manager](https://addons.mozilla.org/en-US/firefox/addon/proxy-switcher-and-manager/) && [Chrome plugin Proxy Switcher and Manager](https://chromewebstore.google.com/detail/proxy-switcher-and-manage/onnfghpihccifgojkpnnncpagjcdbjod)
Keep in mind that all user keys are stored in a single **config.yaml** file. If this file is lost, user keys will remain on the servers, but OutFleet will lose the ability to manage them. Handle with extreme caution and use backups.
## Authors
* **UltraDesu** - *Humble amateur developer* - [UltraDesu](https://github.com/house-of-vanity) - *All the work*
* **UltraDesu** - *Humble amateur developer* - [UltraDesu](https://github.com/house-of-vanity) - *Author*
* **Contributors**
* * @Sanapach

21
SECURITY.md Normal file
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

77
k8s.py
View File

@@ -1,77 +0,0 @@
import base64
import json
import yaml
import logging
from kubernetes import client, config
from kubernetes.client.rest import ApiException
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%d-%m-%Y %H:%M:%S",
)
log = logging.getLogger("OutFleet.k8s")
file_handler = logging.FileHandler("sync.log")
file_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
file_handler.setFormatter(formatter)
log.addHandler(file_handler)
def write_config(config):
config_map = client.V1ConfigMap(
api_version="v1",
kind="ConfigMap",
metadata=client.V1ObjectMeta(
name=f"config-outfleet",
labels={
"app": "outfleet",
}
),
data={"config.yaml": yaml.dump(config)}
)
try:
api_response = v1.create_namespaced_config_map(
namespace=NAMESPACE,
body=config_map,
)
except ApiException as e:
api_response = v1.patch_namespaced_config_map(
name="config-outfleet",
namespace=NAMESPACE,
body=config_map,
)
config.load_incluster_config()
v1 = client.CoreV1Api()
NAMESPACE = False
SERVERS = list()
CONFIG = None
log.info("Checking for Kubernetes environment")
try:
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f:
NAMESPACE = f.read().strip()
log.info(f"Found Kubernetes environment. Namespace {NAMESPACE}")
except IOError:
log.info("Kubernetes environment not detected")
pass
# config = v1.list_namespaced_config_map(NAMESPACE, label_selector="app=outfleet").items["data"]["config.yaml"]
try:
CONFIG = yaml.safe_load(v1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
log.info(f"ConfigMap config.yaml loaded from Kubernetes API. Servers: {len(CONFIG['servers'])}, Clients: {len(CONFIG['clients'])}")
except ApiException as e:
log.warning(f"ConfigMap not found. Fisrt run?")
#servers = v1.list_namespaced_secret(NAMESPACE, label_selector="app=shadowbox")
if not CONFIG:
log.info(f"Creating new ConfigMap [config-outfleet]")
write_config({"clients": [], "servers": [], "ui_hostname": "accessible-address.com"})
CONFIG = yaml.safe_load(v1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])

178
lib.py
View File

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

399
main.py
View File

@@ -1,399 +0,0 @@
import yaml
import logging
from datetime import datetime
import random
import string
import argparse
import uuid
import k8s
from flask import Flask, render_template, request, url_for, redirect
from flask_cors import CORS
from lib import Server, write_config, get_config, args
logging.getLogger("werkzeug").setLevel(logging.ERROR)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%d-%m-%Y %H:%M:%S",
)
log = logging.getLogger("OutFleet")
file_handler = logging.FileHandler("sync.log")
file_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
file_handler.setFormatter(formatter)
log.addHandler(file_handler)
CFG_PATH = args.config
NAMESPACE = k8s.NAMESPACE
SERVERS = list()
BROKEN_SERVERS = list()
CLIENTS = dict()
VERSION = '3'
HOSTNAME = ""
app = Flask(__name__)
CORS(app)
def format_timestamp(ts):
return datetime.fromtimestamp(ts // 1000).strftime("%Y-%m-%d %H:%M:%S")
def random_string(length=64):
letters = string.ascii_letters + string.digits
return "".join(random.choice(letters) for i in range(length))
def update_state():
global SERVERS
global CLIENTS
global BROKEN_SERVERS
global HOSTNAME
SERVERS = list()
BROKEN_SERVERS = list()
CLIENTS = dict()
config = get_config()
if config:
HOSTNAME = config.get("ui_hostname", "my-own-SSL-ENABLED-domain.com")
servers = config.get("servers", dict())
for local_server_id, server_config in servers.items():
try:
server = Server(
url=server_config["url"],
cert=server_config["cert"],
comment=server_config["comment"],
local_server_id=local_server_id,
)
SERVERS.append(server)
log.info(
"Server state updated: %s, [%s]",
server.info()["name"],
local_server_id,
)
except Exception as e:
BROKEN_SERVERS.append({
"config": server_config,
"error": e,
"id": local_server_id
})
log.warning("Can't access server: %s - %s", server_config["url"], e)
CLIENTS = config.get("clients", dict())
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "GET":
#if request.args.get("broken") == True:
return render_template(
"index.html",
SERVERS=SERVERS,
VERSION=VERSION,
BROKEN_SERVERS=BROKEN_SERVERS,
nt=request.args.get("nt"),
nl=request.args.get("nl"),
selected_server=request.args.get("selected_server"),
broken=request.args.get("broken", False),
add_server=request.args.get("add_server", None),
format_timestamp=format_timestamp,
)
elif request.method == "POST":
server = request.form["server_id"]
server = next(
(item for item in SERVERS if item.info()["local_server_id"] == server), None
)
server.apply_config(request.form, CFG_PATH)
update_state()
return redirect(
url_for(
"index",
nt="Updated Outline VPN Server",
selected_server=request.args.get("selected_server"),
)
)
else:
return redirect(url_for("index"))
@app.route("/clients", methods=["GET", "POST"])
def clients():
if request.method == "GET":
return render_template(
"clients.html",
SERVERS=SERVERS,
CLIENTS=CLIENTS,
VERSION=VERSION,
nt=request.args.get("nt"),
nl=request.args.get("nl"),
selected_client=request.args.get("selected_client"),
add_client=request.args.get("add_client", None),
format_timestamp=format_timestamp,
dynamic_hostname=HOSTNAME,
)
@app.route("/add_server", methods=["POST"])
def add_server():
if request.method == "POST":
try:
config = get_config()
servers = config.get("servers", dict())
local_server_id = str(uuid.uuid4())
new_server = Server(
url=request.form["url"],
cert=request.form["cert"],
comment=request.form["comment"],
local_server_id=local_server_id,
)
servers[new_server.data["local_server_id"]] = {
"name": new_server.data["name"],
"url": new_server.data["url"],
"comment": new_server.data["comment"],
"cert": request.form["cert"],
}
config["servers"] = servers
write_config(config)
log.info("Added server: %s", new_server.data["name"])
update_state()
return redirect(url_for("index", nt="Added Outline VPN Server"))
except Exception as e:
return redirect(
url_for(
"index", nt=f"Couldn't access Outline VPN Server: {e}", nl="error"
)
)
@app.route("/del_server", methods=["POST"])
def del_server():
if request.method == "POST":
config = get_config()
local_server_id = request.form.get("local_server_id")
server_name = None
try:
server_name = config["servers"].pop(local_server_id)["name"]
except KeyError as e:
pass
for client_id, client_config in config["clients"].items():
try:
client_config["servers"].remove(local_server_id)
except ValueError as e:
pass
write_config(config)
log.info("Deleting server %s [%s]", server_name, request.form.get("local_server_id"))
update_state()
return redirect(url_for("index", nt=f"Server {server_name} has been deleted"))
@app.route("/add_client", methods=["POST"])
def add_client():
if request.method == "POST":
config = get_config()
clients = config.get("clients", dict())
user_id = request.form.get("user_id", random_string())
clients[user_id] = {
"name": request.form.get("name"),
"comment": request.form.get("comment"),
"servers": request.form.getlist("servers"),
}
config["clients"] = clients
write_config(config)
log.info("Client %s updated", request.form.get("name"))
for server in SERVERS:
if server.data["local_server_id"] in request.form.getlist("servers"):
client = next(
(
item
for item in server.data["keys"]
if item.name == request.form.get("old_name")
),
None,
)
if client:
if client.name == request.form.get("name"):
pass
else:
server.rename_key(client.key_id, request.form.get("name"))
log.info(
"Renaming key %s to %s on server %s",
request.form.get("old_name"),
request.form.get("name"),
server.data["name"],
)
else:
server.create_key(request.form.get("name"))
log.info(
"Creating key %s on server %s",
request.form.get("name"),
server.data["name"],
)
else:
client = next(
(
item
for item in server.data["keys"]
if item.name == request.form.get("old_name")
),
None,
)
if client:
server.delete_key(client.key_id)
log.info(
"Deleting key %s on server %s",
request.form.get("name"),
server.data["name"],
)
update_state()
return redirect(
url_for(
"clients",
nt="Clients updated",
selected_client=request.form.get("user_id"),
)
)
else:
return redirect(url_for("clients"))
@app.route("/del_client", methods=["POST"])
def del_client():
if request.method == "POST":
config = get_config()
clients = config.get("clients", dict())
user_id = request.form.get("user_id")
if user_id in clients:
for server in SERVERS:
client = next(
(
item
for item in server.data["keys"]
if item.name == request.form.get("name")
),
None,
)
if client:
server.delete_key(client.key_id)
config["clients"].pop(user_id)
write_config(config)
log.info("Deleting client %s", request.form.get("name"))
update_state()
return redirect(url_for("clients", nt="User has been deleted"))
@app.route("/dynamic/<server_name>/<client_id>", methods=["GET"], strict_slashes=False)
def dynamic(server_name, client_id):
try:
client = next(
(keys for client, keys in CLIENTS.items() if client == client_id), None
)
server = next(
(item for item in SERVERS if item.info()["name"] == server_name), None
)
key = next(
(item for item in server.data["keys"] if item.key_id == client["name"]), None
)
if server and client and key:
if server.data["local_server_id"] in client["servers"]:
log.info(
"Client %s wants ssconf for %s", client["name"], server.data["name"]
)
return {
"server": server.data["hostname_for_access_keys"],
"server_port": key.port,
"password": key.password,
"method": key.method,
"info": "Managed by OutFleet [github.com/house-of-vanity/OutFleet/]",
}
else:
log.warning(
"Hack attempt! Client %s denied by ACL on %s",
client["name"],
server.data["name"],
)
return "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
except:
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
return "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
@app.route("/dynamic/", methods=["GET"], strict_slashes=False)
def _dynamic():
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
return (
"Hey buddy, i think you got the wrong door the leather-club is two blocks down"
)
@app.route("/sync", methods=["GET", "POST"])
def sync():
if request.method == "GET":
try:
with open("sync.log", "r") as file:
lines = file.readlines()
except:
lines = []
return render_template(
"sync.html",
SERVERS=SERVERS,
CLIENTS=CLIENTS,
lines=lines,
)
if request.method == "POST":
log = logging.getLogger("sync")
file_handler = logging.FileHandler("sync.log")
file_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
file_handler.setFormatter(formatter)
log.addHandler(file_handler)
if request.form.get("wipe") == 'all':
for server in SERVERS:
log.info("Wiping all keys on [%s]", server.data["name"])
for client in server.data['keys']:
server.delete_key(client.key_id)
server_hash = {}
for server in SERVERS:
server_hash[server.data["local_server_id"]] = server
for key, client in CLIENTS.items():
for u_server_id in client["servers"]:
if u_server_id in server_hash:
if not server_hash[u_server_id].check_client(client["name"]):
log.warning(
f"Client {client['name']} absent on {server_hash[u_server_id].data['name']}"
)
server_hash[u_server_id].create_key(client["name"])
else:
log.info(
f"Client {client['name']} already present on {server_hash[u_server_id].data['name']}"
)
else:
log.info(
f"Client {client['name']} incorrect server_id {u_server_id}"
)
update_state()
return redirect(url_for("sync"))
if __name__ == "__main__":
update_state()
app.run(host="0.0.0.0")

22
manage.py Executable file
View File

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

3
mysite/__init__.py Normal file
View File

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

16
mysite/asgi.py Normal file
View File

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

40
mysite/celery.py Normal file
View File

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

View File

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

22
mysite/middleware.py Normal file
View File

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

227
mysite/settings.py Normal file
View File

@@ -0,0 +1,227 @@
from pathlib import Path
import os
import environ
from django.core.management.utils import get_random_secret_key
ENV = environ.Env(
DEBUG=(bool, False)
)
environ.Env.read_env()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY=ENV('SECRET_KEY', default='django-insecure-change-me-in-production')
TIME_ZONE = ENV('TIMEZONE', default='Asia/Nicosia')
EXTERNAL_ADDRESS = ENV('EXTERNAL_ADDRESS', default='https://example.org')
CELERY_BROKER_URL = ENV('CELERY_BROKER_URL', default='redis://localhost:6379/0')
CELERY_RESULT_BACKEND = 'django-db'
CELERY_TIMEZONE = ENV('TIMEZONE', default='Asia/Nicosia')
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_RESULT_EXTENDED = True
# Celery Beat Schedule
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
'update-user-statistics': {
'task': 'update_user_statistics',
'schedule': crontab(minute='*/5'), # Every 5 minutes
},
'cleanup-task-logs': {
'task': 'cleanup_task_logs',
'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
},
}
AUTH_USER_MODEL = "vpn.User"
DEBUG = ENV('DEBUG')
ALLOWED_HOSTS = ENV.list('ALLOWED_HOSTS', default=["*"])
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = ENV.list('CSRF_TRUSTED_ORIGINS', default=[])
STATIC_ROOT = BASE_DIR / "staticfiles"
LOGIN_REDIRECT_URL = '/'
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '[{asctime}] {levelname} {name} {message}',
'style': '{',
},
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR, 'debug.log'),
'formatter': 'verbose',
},
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'INFO',
'propagate': True,
},
'vpn': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': False,
},
'requests': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
'urllib3': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
},
}
INSTALLED_APPS = [
'jazzmin',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'polymorphic',
'corsheaders',
'django_celery_results',
'django_celery_beat',
'vpn',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'mysite.middleware.AutoLoginMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
]
ROOT_URLCONF = 'mysite.urls'
GIT_COMMIT = ENV('GIT_COMMIT', default='development')
GIT_COMMIT_SHORT = ENV('GIT_COMMIT_SHORT', default='dev')
BUILD_DATE = ENV('BUILD_DATE', default='unknown')
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'templates'),
os.path.join(BASE_DIR, 'vpn', 'templates')
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'mysite.context_processors.version_info',
],
},
},
]
WSGI_APPLICATION = 'mysite.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
# CREATE USER outfleet WITH PASSWORD 'password';
# GRANT ALL PRIVILEGES ON DATABASE outfleet TO outfleet;
# ALTER DATABASE outfleet OWNER TO outfleet;
DATABASES = {
'sqlite': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
},
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': ENV('POSTGRES_DB', default="outfleet"),
'USER': ENV('POSTGRES_USER', default="outfleet"),
'PASSWORD': ENV('POSTGRES_PASSWORD', default="outfleet"),
'HOST': ENV('POSTGRES_HOST', default='localhost'),
'PORT': ENV('POSTGRES_PORT', default='5432'),
}
}
# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = [
BASE_DIR / 'static',
]
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

30
mysite/urls.py Normal file
View File

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

26
requirements.txt Executable file → Normal file
View File

@@ -1,5 +1,21 @@
outline-vpn-api
kubernetes
PyYAML>=6.0.1
Flask>=2.3.3
flask-cors
django-environ==0.12.0
Django==5.1.7
celery==5.4.0
django-jazzmin==3.0.1
django-polymorphic==3.1.0
django-cors-headers==4.5.0
django-celery-results==2.5.1
git+https://github.com/celery/django-celery-beat#egg=django-celery-beat
requests==2.32.3
PyYaml==6.0.2
Markdown==3.7
outline-vpn-api==6.3.0
Redis==5.2.1
whitenoise==6.9.0
psycopg2-binary==2.9.10
setuptools==78.1.1
shortuuid==1.0.13
cryptography==45.0.5
acme>=2.0.0
cloudflare>=4.3.1
josepy>=2.0.0

View File

@@ -0,0 +1,335 @@
/* Custom styles for VPN admin interface */
/* Quick action buttons in server list */
.quick-actions .button {
display: inline-block;
padding: 4px 8px;
margin: 0 2px;
font-size: 11px;
line-height: 1.2;
text-decoration: none;
border: 1px solid #ccc;
border-radius: 3px;
background: linear-gradient(to bottom, #f8f8f8, #e8e8e8);
color: #333;
cursor: pointer;
white-space: nowrap;
min-width: 60px;
text-align: center;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
transition: all 0.2s ease;
}
.quick-actions .button:hover {
background: linear-gradient(to bottom, #e8e8e8, #d8d8d8);
border-color: #bbb;
color: #000;
text-decoration: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
}
.quick-actions .button:active {
background: linear-gradient(to bottom, #d8d8d8, #e8e8e8);
box-shadow: inset 0 1px 2px rgba(0,0,0,0.2);
}
/* Sync button - blue theme */
.quick-actions .button[href*="/sync/"] {
background: linear-gradient(to bottom, #4a90e2, #357abd);
border-color: #2968a3;
color: white;
}
.quick-actions .button[href*="/sync/"]:hover {
background: linear-gradient(to bottom, #357abd, #2968a3);
border-color: #1f5582;
}
/* Move clients button - orange theme */
.quick-actions .button[href*="/move-clients/"] {
background: linear-gradient(to bottom, #f39c12, #e67e22);
border-color: #d35400;
color: white;
}
.quick-actions .button[href*="/move-clients/"]:hover {
background: linear-gradient(to bottom, #e67e22, #d35400);
border-color: #bf4f36;
}
/* Status indicators improvements */
.server-status-ok {
color: #27ae60;
font-weight: bold;
}
.server-status-error {
color: #e74c3c;
font-weight: bold;
}
.server-status-warning {
color: #f39c12;
font-weight: bold;
}
/* Better spacing for list display */
.admin-object-tools {
margin-bottom: 10px;
}
/* Improve readability of pre-formatted status */
.changelist-results pre {
font-size: 11px;
margin: 0;
padding: 2px 4px;
background: #f8f8f8;
border: 1px solid #ddd;
border-radius: 3px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Server admin compact styles */
.server-stats {
max-width: 120px;
min-width: 90px;
}
.server-activity {
max-width: 140px;
min-width: 100px;
}
.server-status {
max-width: 160px;
min-width: 120px;
}
.server-comment {
max-width: 200px;
min-width: 100px;
word-wrap: break-word;
}
/* Compact server display elements */
.changelist-results .server-stats div,
.changelist-results .server-activity div,
.changelist-results .server-status div {
line-height: 1.3;
margin: 1px 0;
}
/* Status indicator colors */
.status-online {
color: #16a34a !important;
font-weight: bold;
}
.status-error {
color: #dc2626 !important;
font-weight: bold;
}
.status-warning {
color: #f97316 !important;
font-weight: bold;
}
.status-unavailable {
color: #f97316 !important;
font-weight: bold;
}
/* Activity indicators */
.activity-high {
color: #16a34a !important;
}
.activity-medium {
color: #eab308 !important;
}
.activity-low {
color: #f97316 !important;
}
.activity-none {
color: #dc2626 !important;
}
/* User stats indicators */
.users-active {
color: #16a34a !important;
}
.users-medium {
color: #eab308 !important;
}
.users-low {
color: #f97316 !important;
}
.users-none {
color: #9ca3af !important;
}
/* Table cell width constraints for better layout */
table.changelist-results th:nth-child(1), /* Name */
table.changelist-results td:nth-child(1) {
width: 180px;
max-width: 180px;
}
table.changelist-results th:nth-child(3), /* Comment */
table.changelist-results td:nth-child(3) {
width: 200px;
max-width: 200px;
}
table.changelist-results th:nth-child(4), /* User Stats */
table.changelist-results td:nth-child(4) {
width: 120px;
max-width: 120px;
}
table.changelist-results th:nth-child(5), /* Activity */
table.changelist-results td:nth-child(5) {
width: 140px;
max-width: 140px;
}
table.changelist-results th:nth-child(6), /* Status */
table.changelist-results td:nth-child(6) {
width: 160px;
max-width: 160px;
}
/* Ensure text doesn't overflow in server admin */
.changelist-results td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: top;
}
/* Allow wrapping for multi-line server info displays */
.changelist-results td .server-stats,
.changelist-results td .server-activity,
.changelist-results td .server-status {
white-space: normal;
}
/* Server type icons */
.server-type-outline {
color: #3b82f6;
}
.server-type-wireguard {
color: #10b981;
}
/* Tooltip styles for truncated text */
[title] {
cursor: help;
border-bottom: 1px dotted #999;
}
/* Form improvements for move clients page */
.form-row.field-box {
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
margin: 10px 0;
background: #f9f9f9;
}
.form-row.field-box label {
font-weight: bold;
color: #333;
display: block;
margin-bottom: 5px;
}
.form-row.field-box .readonly {
padding: 5px;
background: white;
border: 1px solid #ddd;
border-radius: 3px;
}
.help {
background: #e8f4fd;
border: 1px solid #b8daff;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
}
.help h3 {
margin-top: 0;
color: #0066cc;
}
.help ul {
margin-bottom: 0;
}
.help li {
margin-bottom: 5px;
}
/* Make user statistics section wider */
.field-user_statistics_summary {
width: 100% !important;
}
.field-user_statistics_summary .readonly {
max-width: none !important;
width: 100% !important;
}
.field-user_statistics_summary .user-management-section {
width: 100% !important;
max-width: none !important;
}
/* Wider fieldset for statistics */
.wide {
width: 100% !important;
}
.wide .form-row {
width: 100% !important;
}
/* Server status button styles */
.check-status-btn {
transition: all 0.2s ease;
white-space: nowrap;
}
.check-status-btn:hover {
opacity: 0.8;
transform: scale(1.05);
}
.check-status-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Make admin tables more responsive */
.changelist-results table {
width: 100%;
table-layout: auto;
}
/* Improve button spacing */
.btn-sm-custom {
margin: 0 2px;
display: inline-block;
}

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Dashboard{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='pure.css') }}">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='layout.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="layout" class="content pure-g">
<div id="nav" class="pure-u-1-3">
<a href="#" id="menuLink" class="nav-menu-button">Menu</a>
<div class="nav-inner">
<button onclick="location.href='/';" style="cursor:pointer;" class="primary-button pure-button">OutFleet v.{{ VERSION }}</button>
<div class="pure-menu custom-restricted-width">
<ul class="pure-menu-list">
<li class="pure-menu-item"><a href="/" class="pure-menu-link">Servers</a></li>
<li class="pure-menu-item"><a href="/clients" class="pure-menu-link">Clients</a></li>
<li class="pure-menu-item"><a href="/sync" class="pure-menu-link">Sync status</a></li>
</ul>
{{ VERSION }}
</div>
</div>
</div>
{% block content %}{% endblock %}
</div>
<!-- Script to make the Menu link work -->
<!-- Just stripped down version of the js/ui.js script for the side-menu layout -->
<script>
function getElements() {
return {
menu: document.getElementById('nav'),
menuLink: document.getElementById('menuLink')
};
}
function toggleClass(element, className) {
var classes = element.className.split(/\s+/);
var length = classes.length;
var i = 0;
for (; i < length; i++) {
if (classes[i] === className) {
classes.splice(i, 1);
break;
}
}
// The className is not found
if (length === classes.length) {
classes.push(className);
}
element.className = classes.join(' ');
}
function toggleMenu() {
var active = 'active';
var elements = getElements();
toggleClass(elements.menu, active);
}
function handleEvent(e) {
var elements = getElements();
if (e.target.id === elements.menuLink.id) {
toggleMenu();
e.preventDefault();
} else if (elements.menu.className.indexOf('active') !== -1) {
toggleMenu();
}
}
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('click', handleEvent);
});
</script>
{% if nt %}
<label>
<input type="checkbox" class="alertCheckbox" autocomplete="off" />
<div class="alert {% if nl == 'error' %}error{% else %}success{% endif %}">
<span class="alertText">{{nt}}
<br class="clear"/></span>
</div>
</label>
{% endif %}
</body>
</html>

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

1895
vpn/admin.py Normal file

File diff suppressed because it is too large Load Diff

867
vpn/admin_xray.py Normal file
View File

@@ -0,0 +1,867 @@
"""
Admin interface for new Xray models.
"""
import json
from django.contrib import admin, messages
from django.utils.safestring import mark_safe
from django.utils.html import format_html
from django.db import models
from django.forms import CheckboxSelectMultiple, Textarea
from django.shortcuts import render, redirect
from django.urls import path, reverse
from django.http import JsonResponse, HttpResponseRedirect
from .models_xray import (
Credentials, Certificate,
Inbound, SubscriptionGroup, UserSubscription, ServerInbound
)
# Credentials admin available through direct URL but not in main menu
class CredentialsAdmin(admin.ModelAdmin):
"""Admin for credentials management (accessible via direct URL only)"""
list_display = ('name', 'cred_type', 'description', 'created_at')
list_filter = ('cred_type',)
search_fields = ('name', 'description')
fieldsets = (
('Basic Information', {
'fields': ('name', 'cred_type', 'description')
}),
('Credentials Data', {
'fields': ('credentials_help', 'credentials'),
'description': 'Enter credentials as JSON'
}),
('Preview', {
'fields': ('credentials_display',),
'classes': ('collapse',),
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
})
)
readonly_fields = ('credentials_display', 'credentials_help', 'created_at', 'updated_at')
formfield_overrides = {
models.JSONField: {'widget': Textarea(attrs={'rows': 6, 'style': 'font-family: monospace;'})},
}
def credentials_help(self, obj):
"""Display help for different credential formats"""
examples = {
'cloudflare': {
'api_token': 'your_cloudflare_api_token_here'
},
'digitalocean': {
'token': 'your_digitalocean_token_here'
},
'aws_route53': {
'access_key_id': 'your_access_key_id',
'secret_access_key': 'your_secret_access_key',
'region': 'us-east-1'
}
}
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px; margin-bottom: 10px;">'
html += '<h4>JSON Examples:</h4>'
for cred_type, example in examples.items():
html += '<div style="margin: 10px 0;">'
html += '<strong>' + cred_type.title() + ':</strong>'
json_str = json.dumps(example, indent=2)
html += '<pre style="background: white; padding: 8px; border-radius: 3px; font-size: 12px;">' + json_str + '</pre>'
html += '</div>'
html += '<p><strong>Note:</strong> Make sure your JSON is valid. Use double quotes for strings.</p>'
html += '</div>'
return mark_safe(html)
credentials_help.short_description = 'Credentials Format Help'
def credentials_display(self, obj):
"""Display credentials in a safe format"""
if obj.credentials:
# Hide sensitive values
safe_creds = {}
for key, value in obj.credentials.items():
if any(sensitive in key.lower() for sensitive in ['token', 'key', 'password', 'secret']):
safe_creds[key] = '*' * 8
else:
safe_creds[key] = value
return format_html(
'<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px;">{}</pre>',
json.dumps(safe_creds, indent=2)
)
return '-'
credentials_display.short_description = 'Credentials (Preview)'
# Credentials admin is available through Certificate admin only
# Do not register directly to avoid showing in main menu
@admin.register(Certificate)
class CertificateAdmin(admin.ModelAdmin):
"""Admin for certificate management"""
list_display = (
'domain', 'cert_type', 'status_display',
'expires_at', 'auto_renew', 'action_buttons'
)
list_filter = ('cert_type', 'auto_renew')
search_fields = ('domain',)
actions = ['rotate_selected_certificates']
fieldsets = (
('Certificate Request', {
'fields': ('domain', 'cert_type', 'acme_email', 'auto_renew'),
'description': 'For Let\'s Encrypt certificates, provide email for ACME registration and select/create credentials below.'
}),
('API Credentials', {
'fields': ('credentials',),
'description': 'Select API credentials for automatic Let\'s Encrypt certificate generation'
}),
('Certificate Generation Status', {
'fields': ('generation_help',),
'classes': ('wide',)
}),
('Certificate Data', {
'fields': ('certificate_info', 'certificate_pem', 'private_key_pem'),
'classes': ('collapse',),
'description': 'Detailed certificate information'
}),
('Renewal Settings', {
'fields': ('last_renewed',),
'classes': ('collapse',)
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
})
)
readonly_fields = (
'certificate_info', 'status_display', 'generation_help',
'expires_at', 'last_renewed', 'created_at', 'updated_at'
)
def generation_help(self, obj):
"""Show help text for certificate generation"""
if not obj.pk:
return mark_safe('<div style="background: #e3f2fd; padding: 10px; border-radius: 4px;">'
'<p><strong>How it works:</strong></p>'
'<ol>'
'<li>Fill in the domain name</li>'
'<li>Select certificate type (Let\'s Encrypt recommended)</li>'
'<li>For Let\'s Encrypt: provide email for ACME account registration</li>'
'<li>Select credentials with Cloudflare API token</li>'
'<li>Save - certificate will be generated automatically</li>'
'</ol>'
'</div>')
if obj.cert_type == 'letsencrypt' and not obj.certificate_pem:
return mark_safe('<div style="background: #fff3e0; padding: 10px; border-radius: 4px;">'
'<p><strong>⏳ Certificate not generated yet</strong></p>'
'<p>Certificate will be generated automatically using Let\'s Encrypt DNS-01 challenge.</p>'
'</div>')
if obj.certificate_pem:
days = obj.days_until_expiration if obj.days_until_expiration is not None else 'Unknown'
return mark_safe('<div style="background: #e8f5e8; padding: 10px; border-radius: 4px;">'
'<p><strong>✅ Certificate generated successfully</strong></p>'
f'<p>Expires: {obj.expires_at}</p>'
f'<p>Days remaining: {days}</p>'
'</div>')
return '-'
generation_help.short_description = 'Certificate Generation Status'
def status_display(self, obj):
"""Display certificate status"""
if obj.is_expired:
return format_html(
'<span style="color: red;">❌ Expired</span>'
)
elif obj.needs_renewal:
return format_html(
'<span style="color: orange;">⚠️ Needs renewal ({} days)</span>',
obj.days_until_expiration
)
else:
return format_html(
'<span style="color: green;">✅ Valid ({} days)</span>',
obj.days_until_expiration
)
status_display.short_description = 'Status'
def certificate_preview(self, obj):
"""Preview certificate info"""
if obj.certificate_pem:
lines = obj.certificate_pem.strip().split('\n')
preview = '\n'.join(lines[:5] + ['...'] + lines[-3:])
return format_html(
'<pre style="background: #f4f4f4; padding: 10px; font-family: monospace; font-size: 12px;">{}</pre>',
preview
)
return '-'
certificate_preview.short_description = 'Certificate Preview'
def action_buttons(self, obj):
"""Action buttons for certificate"""
buttons = []
if obj.needs_renewal and obj.auto_renew:
renew_url = reverse('admin:certificate_renew', args=[obj.pk])
buttons.append(
f'<a href="{renew_url}" class="button" style="background: #ff9800;">🔄 Renew Now</a>'
)
if obj.cert_type == 'self_signed':
regenerate_url = reverse('admin:certificate_regenerate', args=[obj.pk])
buttons.append(
f'<a href="{regenerate_url}" class="button">🔄 Regenerate</a>'
)
return format_html(' '.join(buttons)) if buttons else '-'
action_buttons.short_description = 'Actions'
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('<int:cert_id>/renew/',
self.admin_site.admin_view(self.renew_certificate_view),
name='certificate_renew'),
path('<int:cert_id>/regenerate/',
self.admin_site.admin_view(self.regenerate_certificate_view),
name='certificate_regenerate'),
]
return custom_urls + urls
def renew_certificate_view(self, request, cert_id):
"""Renew Let's Encrypt certificate"""
try:
cert = Certificate.objects.get(pk=cert_id)
# TODO: Implement renewal logic
messages.success(request, f'Certificate for {cert.domain} renewed successfully!')
except Exception as e:
messages.error(request, f'Failed to renew certificate: {e}')
return redirect('admin:vpn_certificate_change', cert_id)
def regenerate_certificate_view(self, request, cert_id):
"""Regenerate self-signed certificate"""
try:
cert = Certificate.objects.get(pk=cert_id)
# TODO: Implement regeneration logic
messages.success(request, f'Certificate for {cert.domain} regenerated successfully!')
except Exception as e:
messages.error(request, f'Failed to regenerate certificate: {e}')
return redirect('admin:vpn_certificate_change', cert_id)
def save_model(self, request, obj, form, change):
"""Auto-generate certificate for Let's Encrypt after saving"""
super().save_model(request, obj, form, change)
# Auto-generate Let's Encrypt certificate if needed
if obj.cert_type == 'letsencrypt' and not obj.certificate_pem:
try:
self.generate_letsencrypt_certificate(obj, request)
except Exception as e:
messages.warning(request, f'Certificate saved but auto-generation failed: {e}')
def generate_letsencrypt_certificate(self, cert_obj, request):
"""Generate Let's Encrypt certificate using DNS-01 challenge"""
if not cert_obj.credentials:
messages.error(request, 'Credentials required for Let\'s Encrypt certificate generation')
return
if not cert_obj.acme_email:
messages.error(request, 'ACME email address required for Let\'s Encrypt certificate generation')
return
try:
from vpn.letsencrypt.letsencrypt_dns import get_certificate_for_domain
from datetime import datetime, timedelta
from django.utils import timezone
# Get Cloudflare credentials
api_token = cert_obj.credentials.get_credential('api_token')
if not api_token:
messages.error(request, 'Cloudflare API token not found in credentials')
return
messages.info(request, f'🔄 Generating Let\'s Encrypt certificate for {cert_obj.domain} using {cert_obj.acme_email}...')
# Schedule certificate generation via Celery
from vpn.tasks import generate_certificate_task
task = generate_certificate_task.delay(cert_obj.id)
messages.success(
request,
f'🔄 Certificate generation scheduled for {cert_obj.domain}. Task ID: {task.id}'
)
except ImportError:
messages.warning(request, 'Let\'s Encrypt DNS challenge library not available')
except Exception as e:
messages.error(request, f'Failed to generate certificate: {str(e)}')
# Log the full error for debugging
import logging
logger = logging.getLogger(__name__)
logger.error(f'Certificate generation failed for {cert_obj.domain}: {e}', exc_info=True)
def certificate_info(self, obj):
"""Display detailed certificate information"""
if not obj.pk:
return "Save certificate to see details"
if not obj.certificate_pem:
return "Certificate not generated yet"
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px;">'
# Import here to avoid circular imports
try:
from cryptography import x509
from cryptography.hazmat.backends import default_backend
# Parse certificate
cert = x509.load_pem_x509_certificate(obj.certificate_pem.encode(), default_backend())
# Basic info
html += '<h4>📜 Certificate Information</h4>'
html += '<table style="width: 100%; font-size: 12px;">'
html += f'<tr><td><strong>Subject:</strong></td><td>{cert.subject.rfc4514_string()}</td></tr>'
html += f'<tr><td><strong>Issuer:</strong></td><td>{cert.issuer.rfc4514_string()}</td></tr>'
html += f'<tr><td><strong>Serial Number:</strong></td><td>{cert.serial_number}</td></tr>'
# Use UTC versions to avoid deprecation warnings
try:
# Try new UTC properties first (cryptography >= 42.0.0)
valid_from = cert.not_valid_before_utc
valid_until = cert.not_valid_after_utc
cert_not_after = valid_until
except AttributeError:
# Fall back to old properties for older cryptography versions
valid_from = cert.not_valid_before
valid_until = cert.not_valid_after
cert_not_after = cert.not_valid_after
if cert_not_after.tzinfo is None:
cert_not_after = cert_not_after.replace(tzinfo=timezone.utc)
html += f'<tr><td><strong>Valid From:</strong></td><td>{valid_from}</td></tr>'
html += f'<tr><td><strong>Valid Until:</strong></td><td>{valid_until}</td></tr>'
# Status
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
days_until_expiry = (cert_not_after - now).days
if days_until_expiry < 0:
status = f'<span style="color: red;">❌ Expired {abs(days_until_expiry)} days ago</span>'
elif days_until_expiry < 30:
status = f'<span style="color: orange;">⚠️ Expires in {days_until_expiry} days</span>'
else:
status = f'<span style="color: green;">✅ Valid for {days_until_expiry} days</span>'
html += f'<tr><td><strong>Status:</strong></td><td>{status}</td></tr>'
# Extensions
try:
san = cert.extensions.get_extension_for_oid(x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
domains = [name.value for name in san.value]
html += f'<tr><td><strong>Domains:</strong></td><td>{", ".join(domains)}</td></tr>'
except:
# No SAN extension or other error
pass
html += '</table>'
except ImportError:
html += '<p>⚠️ Install cryptography package to see detailed certificate information</p>'
except Exception as e:
html += f'<p>❌ Error parsing certificate: {e}</p>'
html += '</div>'
return mark_safe(html)
certificate_info.short_description = 'Certificate Details'
def rotate_selected_certificates(self, request, queryset):
"""Admin action to rotate selected certificates"""
from vpn.tasks import generate_certificate_task
# Filter only Let's Encrypt certificates
valid_certs = queryset.filter(cert_type='letsencrypt')
if not valid_certs.exists():
self.message_user(request, "No Let's Encrypt certificates selected. Only Let's Encrypt certificates can be rotated.", level='ERROR')
return
# Check for certificates without credentials
certs_without_creds = valid_certs.filter(credentials__isnull=True)
if certs_without_creds.exists():
domains = ', '.join(certs_without_creds.values_list('domain', flat=True))
self.message_user(request, f"The following certificates have no credentials configured and will be skipped: {domains}", level='WARNING')
# Filter certificates that have credentials
certs_to_rotate = valid_certs.filter(credentials__isnull=False)
if not certs_to_rotate.exists():
self.message_user(request, "No certificates with valid credentials found.", level='ERROR')
return
# Launch rotation tasks
rotated_count = 0
task_ids = []
for certificate in certs_to_rotate:
try:
task = generate_certificate_task.delay(certificate.id)
task_ids.append(task.id)
rotated_count += 1
except Exception as e:
self.message_user(request, f"Failed to start rotation for {certificate.domain}: {str(e)}", level='ERROR')
if rotated_count > 0:
domains = ', '.join(certs_to_rotate.values_list('domain', flat=True))
task_list = ', '.join(task_ids)
self.message_user(
request,
f'Successfully initiated certificate rotation for {rotated_count} certificate(s): {domains}. '
f'Task IDs: {task_list}. Certificates will be automatically redeployed to all servers once generated.',
level='SUCCESS'
)
rotate_selected_certificates.short_description = "🔄 Rotate selected Let's Encrypt certificates"
@admin.register(Inbound)
class InboundAdmin(admin.ModelAdmin):
"""Admin for inbound template management"""
list_display = (
'name', 'protocol', 'port', 'network',
'security', 'certificate_status', 'group_count'
)
list_filter = ('protocol', 'network', 'security')
search_fields = ('name',)
fieldsets = (
('Basic Configuration', {
'fields': ('name', 'protocol', 'port'),
'description': 'Domain will be taken from server client_hostname when deployed'
}),
('Transport & Security', {
'fields': ('network', 'security', 'certificate', 'listen_address')
}),
('Advanced Settings', {
'fields': ('enable_sniffing', 'full_config_display'),
'classes': ('collapse',),
'description': 'Configuration is auto-generated based on basic settings above'
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
})
)
readonly_fields = ('full_config_display', 'created_at', 'updated_at')
def certificate_status(self, obj):
"""Display certificate status"""
if obj.security == 'tls':
if obj.certificate:
if obj.certificate.is_expired:
return format_html('<span style="color: red;">❌ Expired</span>')
else:
return format_html('<span style="color: green;">✅ Valid</span>')
else:
return format_html('<span style="color: orange;">⚠️ No cert</span>')
return format_html('<span style="color: gray;">-</span>')
certificate_status.short_description = 'Cert Status'
def group_count(self, obj):
"""Number of groups this inbound belongs to"""
return obj.subscriptiongroup_set.count()
group_count.short_description = 'Groups'
def full_config_display(self, obj):
"""Display full config in formatted JSON"""
if obj.full_config:
return format_html(
'<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 400px; overflow-y: auto;">{}</pre>',
json.dumps(obj.full_config, indent=2)
)
return 'Not generated yet'
full_config_display.short_description = 'Configuration Preview'
def save_model(self, request, obj, form, change):
"""Generate config on save"""
try:
# Always regenerate config to reflect any changes
obj.build_config()
if change:
messages.success(request, f'✅ Inbound "{obj.name}" updated. Changes will be automatically deployed to servers.')
else:
messages.success(request, f'✅ Inbound "{obj.name}" created. It will be deployed when added to subscription groups.')
except Exception as e:
messages.warning(request, f'Inbound saved but config generation failed: {e}')
# Set empty dict if generation fails
if not obj.full_config:
obj.full_config = {}
super().save_model(request, obj, form, change)
class InboundInline(admin.TabularInline):
"""Inline for inbounds in subscription groups"""
model = SubscriptionGroup.inbounds.through
extra = 1
verbose_name = "Inbound"
verbose_name_plural = "Inbounds in this group"
class SubscriptionGroupAdmin(admin.ModelAdmin):
"""Admin for subscription groups"""
list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at')
list_filter = ('is_active',)
search_fields = ('name', 'description')
filter_horizontal = ('inbounds',)
fieldsets = (
('Group Information', {
'fields': ('name', 'description', 'is_active')
}),
('Inbounds', {
'fields': ('inbounds',),
'description': 'Select inbounds to include in this group. ' +
'<br><strong>🚀 Auto-sync enabled:</strong> Changes will be automatically deployed to servers!'
}),
('Statistics', {
'fields': ('group_statistics',),
'classes': ('collapse',)
})
)
readonly_fields = ('group_statistics',)
def save_model(self, request, obj, form, change):
"""Override save to notify about auto-sync"""
super().save_model(request, obj, form, change)
if change:
messages.success(
request,
f'Subscription group "{obj.name}" updated. Changes will be automatically synchronized to all Xray servers.'
)
else:
messages.success(
request,
f'Subscription group "{obj.name}" created. Inbounds will be automatically deployed when you add them to this group.'
)
def group_statistics(self, obj):
"""Display group statistics"""
if obj.pk:
stats = {
'Total Inbounds': obj.inbound_count,
'Active Users': obj.user_count,
'Protocols': list(obj.inbounds.values_list('protocol', flat=True).distinct()),
'Ports': list(obj.inbounds.values_list('port', flat=True).distinct())
}
html = '<div style="background: #f4f4f4; padding: 10px; border-radius: 4px;">'
for key, value in stats.items():
if isinstance(value, list):
value = ', '.join(map(str, value))
html += f'<div><strong>{key}:</strong> {value}</div>'
html += '</div>'
return format_html(html)
return 'Save to see statistics'
group_statistics.short_description = 'Group Statistics'
class UserSubscriptionInline(admin.TabularInline):
"""Inline for user subscriptions"""
model = UserSubscription
extra = 0
fields = ('subscription_group', 'active', 'created_at')
readonly_fields = ('created_at',)
verbose_name = "Subscription Group"
verbose_name_plural = "User's Subscription Groups"
# Extension for User admin
def add_subscription_management_to_user(UserAdmin):
"""Add subscription management to existing User admin"""
# Add inline
if hasattr(UserAdmin, 'inlines'):
UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline]
else:
UserAdmin.inlines = [UserSubscriptionInline]
# Add custom fields to fieldsets
original_fieldsets = list(UserAdmin.fieldsets)
# Find where to insert our fieldset
insert_index = len(original_fieldsets)
for i, (title, fields_dict) in enumerate(original_fieldsets):
if title and 'Statistics' in title:
insert_index = i + 1
break
# Insert our fieldset
subscription_fieldset = (
'Xray Subscriptions', {
'fields': ('subscription_groups_widget',),
'classes': ('wide',)
}
)
original_fieldsets.insert(insert_index, subscription_fieldset)
UserAdmin.fieldsets = tuple(original_fieldsets)
# Add readonly field
if hasattr(UserAdmin, 'readonly_fields'):
UserAdmin.readonly_fields = list(UserAdmin.readonly_fields) + ['subscription_groups_widget']
else:
UserAdmin.readonly_fields = ['subscription_groups_widget']
# Add method for displaying subscription groups
def subscription_groups_widget(self, obj):
"""Display subscription groups management widget"""
if not obj or not obj.pk:
return mark_safe('<div style="color: #6c757d;">Save user first to manage subscriptions</div>')
# Get all groups and user's current subscriptions
all_groups = SubscriptionGroup.objects.filter(is_active=True)
user_groups = obj.xray_subscriptions.filter(active=True).values_list('subscription_group_id', flat=True)
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px;">'
html += '<h4 style="margin-top: 0;">Available Subscription Groups:</h4>'
if all_groups:
html += '<div style="display: grid; gap: 10px;">'
for group in all_groups:
checked = 'checked' if group.id in user_groups else ''
status = '' if group.id in user_groups else ''
html += f'''
<div style="display: flex; align-items: center; gap: 10px; padding: 8px; background: white; border-radius: 4px;">
<span style="font-size: 18px;">{status}</span>
<label style="flex: 1; cursor: pointer;">
<strong>{group.name}</strong>
{f' - {group.description}' if group.description else ''}
<small style="color: #6c757d;"> ({group.inbound_count} inbounds)</small>
</label>
</div>
'''
html += '</div>'
html += '<div style="margin-top: 10px; color: #6c757d; font-size: 12px;">'
html += ' Use the inline form below to manage subscriptions'
html += '</div>'
else:
html += '<div style="color: #6c757d;">No active subscription groups available</div>'
html += '</div>'
return mark_safe(html)
subscription_groups_widget.short_description = 'Subscription Groups Overview'
UserAdmin.subscription_groups_widget = subscription_groups_widget
# UserSubscription admin will be integrated into unified Subscriptions admin
class UserSubscriptionAdmin(admin.ModelAdmin):
"""Admin for user subscriptions (integrated into unified Subscriptions admin)"""
list_display = ('user', 'subscription_group', 'active', 'created_at')
list_filter = ('active', 'subscription_group')
search_fields = ('user__username', 'subscription_group__name')
date_hierarchy = 'created_at'
def has_add_permission(self, request):
return True # Allow adding subscriptions
# ServerInbound admin is integrated into XrayServerV2 admin, not shown in main menu
class ServerInboundAdmin(admin.ModelAdmin):
"""Admin for server-inbound deployment tracking"""
list_display = ('server', 'inbound', 'active', 'deployed_at', 'updated_at')
list_filter = ('active', 'inbound__protocol', 'deployed_at')
search_fields = ('server__name', 'inbound__name')
date_hierarchy = 'deployed_at'
fieldsets = (
('Template Deployment', {
'fields': ('server', 'inbound', 'active')
}),
('Timestamps', {
'fields': ('deployed_at', 'updated_at'),
'classes': ('collapse',)
})
)
readonly_fields = ('deployed_at', 'updated_at')
# Unified Subscriptions Admin with tabs
@admin.register(SubscriptionGroup)
class UnifiedSubscriptionsAdmin(admin.ModelAdmin):
"""Unified admin for managing both Subscription Groups and User Subscriptions"""
# Use SubscriptionGroup as the base model but provide access to UserSubscription via tabs
list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at')
list_filter = ('is_active',)
search_fields = ('name', 'description')
filter_horizontal = ('inbounds',)
def get_urls(self):
"""Add custom URLs for user subscriptions tab"""
urls = super().get_urls()
custom_urls = [
path('user-subscriptions/',
self.admin_site.admin_view(self.user_subscriptions_view),
name='vpn_usersubscription_changelist_tab'),
]
return custom_urls + urls
def user_subscriptions_view(self, request):
"""Redirect to user subscriptions with tab navigation"""
from django.shortcuts import redirect
return redirect('/admin/vpn/usersubscription/')
def changelist_view(self, request, extra_context=None):
"""Override changelist to add tab navigation"""
extra_context = extra_context or {}
extra_context.update({
'show_tab_navigation': True,
'current_tab': 'subscription_groups'
})
return super().changelist_view(request, extra_context)
def change_view(self, request, object_id, form_url='', extra_context=None):
"""Override change view to add tab navigation"""
extra_context = extra_context or {}
extra_context.update({
'show_tab_navigation': True,
'current_tab': 'subscription_groups'
})
return super().change_view(request, object_id, form_url, extra_context)
def add_view(self, request, form_url='', extra_context=None):
"""Override add view to add tab navigation"""
extra_context = extra_context or {}
extra_context.update({
'show_tab_navigation': True,
'current_tab': 'subscription_groups'
})
return super().add_view(request, form_url, extra_context)
# Copy fieldsets and methods from SubscriptionGroupAdmin
fieldsets = (
('Group Information', {
'fields': ('name', 'description', 'is_active')
}),
('Inbounds', {
'fields': ('inbounds',),
'description': 'Select inbounds to include in this group. ' +
'<br><strong>🚀 Auto-sync enabled:</strong> Changes will be automatically deployed to servers!'
}),
('Statistics', {
'fields': ('group_statistics',),
'classes': ('collapse',)
})
)
readonly_fields = ('group_statistics',)
def save_model(self, request, obj, form, change):
"""Override save to notify about auto-sync"""
super().save_model(request, obj, form, change)
if change:
messages.success(
request,
f'Subscription group "{obj.name}" updated. Changes will be automatically synchronized to all Xray servers.'
)
else:
messages.success(
request,
f'Subscription group "{obj.name}" created. Inbounds will be automatically deployed when you add them to this group.'
)
def group_statistics(self, obj):
"""Display group statistics"""
if obj.pk:
stats = {
'Total Inbounds': obj.inbound_count,
'Active Users': obj.user_count,
'Protocols': list(obj.inbounds.values_list('protocol', flat=True).distinct()),
'Ports': list(obj.inbounds.values_list('port', flat=True).distinct())
}
html = '<div style="background: #f4f4f4; padding: 10px; border-radius: 4px;">'
for key, value in stats.items():
if isinstance(value, list):
value = ', '.join(map(str, value))
html += f'<div><strong>{key}:</strong> {value}</div>'
html += '</div>'
return format_html(html)
return 'Save to see statistics'
group_statistics.short_description = 'Group Statistics'
# UserSubscription admin with tab navigation (hidden from main menu)
@admin.register(UserSubscription)
class UserSubscriptionTabAdmin(UserSubscriptionAdmin):
"""UserSubscription admin with tab navigation"""
def has_module_permission(self, request):
"""Hide this model from the main admin index"""
return False
def has_view_permission(self, request, obj=None):
"""Allow viewing through direct URL access"""
return request.user.is_staff
def has_add_permission(self, request):
"""Allow adding through direct URL access"""
return request.user.is_staff
def has_change_permission(self, request, obj=None):
"""Allow changing through direct URL access"""
return request.user.is_staff
def has_delete_permission(self, request, obj=None):
"""Allow deleting through direct URL access"""
return request.user.is_staff
def changelist_view(self, request, extra_context=None):
"""Override changelist to add tab navigation"""
extra_context = extra_context or {}
extra_context.update({
'show_tab_navigation': True,
'current_tab': 'user_subscriptions'
})
return super().changelist_view(request, extra_context)
def change_view(self, request, object_id, form_url='', extra_context=None):
"""Override change view to add tab navigation"""
extra_context = extra_context or {}
extra_context.update({
'show_tab_navigation': True,
'current_tab': 'user_subscriptions'
})
return super().change_view(request, object_id, form_url, extra_context)
def add_view(self, request, form_url='', extra_context=None):
"""Override add view to add tab navigation"""
extra_context = extra_context or {}
extra_context.update({
'show_tab_navigation': True,
'current_tab': 'user_subscriptions'
})
return super().add_view(request, form_url, extra_context)

13
vpn/apps.py Normal file
View File

@@ -0,0 +1,13 @@
from django.apps import AppConfig
from django.contrib.auth import get_user_model
class VPN(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'vpn'
def ready(self):
"""Import signals when Django starts"""
try:
import vpn.signals # noqa
except ImportError:
pass

14
vpn/forms.py Normal file
View File

@@ -0,0 +1,14 @@
from django import forms
from .models import User
from .server_plugins import Server
class UserForm(forms.ModelForm):
servers = forms.ModelMultipleChoiceField(
queryset=Server.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False
)
class Meta:
model = User
fields = ['username', 'comment', 'servers']

View File

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-08-07 14:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0018_alter_certificate_certificate_pem_and_more'),
]
operations = [
migrations.AddField(
model_name='certificate',
name='acme_email',
field=models.EmailField(blank=True, help_text="Email address for ACME account registration (required for Let's Encrypt)", max_length=254),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-08-07 15:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0019_certificate_acme_email'),
]
operations = [
migrations.AlterField(
model_name='inbound',
name='full_config',
field=models.JSONField(blank=True, default=dict, help_text='Complete configuration for creating inbound on server (auto-generated if empty)'),
),
]

View File

@@ -0,0 +1,16 @@
# Generated by Django 5.1.7 on 2025-08-08 03:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0020_alter_inbound_full_config'),
]
operations = [
migrations.DeleteModel(
name='XrayConfiguration',
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.1.7 on 2025-08-08 04:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0021_remove_xray_configuration'),
]
operations = [
migrations.AlterModelOptions(
name='inbound',
options={'ordering': ['protocol', 'port'], 'verbose_name': 'Inbound Template', 'verbose_name_plural': 'Inbound Templates'},
),
migrations.RemoveField(
model_name='inbound',
name='domain',
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.7 on 2025-08-08 04:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0022_remove_inbound_domain_field'),
]
operations = [
migrations.AlterModelOptions(
name='subscriptiongroup',
options={'ordering': ['name'], 'verbose_name': 'Subscriptions', 'verbose_name_plural': 'Subscriptions'},
),
]

View File

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

176
vpn/models.py Normal file
View File

@@ -0,0 +1,176 @@
import uuid
import logging
from django.db import models
from vpn.tasks import sync_user
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from .server_plugins import Server
import shortuuid
from django.contrib.auth.models import AbstractUser
logger = logging.getLogger(__name__)
class UserStatistics(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE)
server_name = models.CharField(max_length=256)
acl_link_id = models.CharField(max_length=1024, null=True, blank=True, help_text="None for server-level stats")
total_connections = models.IntegerField(default=0)
recent_connections = models.IntegerField(default=0)
daily_usage = models.JSONField(default=list, help_text="Daily connection counts for last 30 days")
max_daily = models.IntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user', 'server_name', 'acl_link_id']
verbose_name = 'User Statistics'
verbose_name_plural = 'User Statistics'
indexes = [
models.Index(fields=['user', 'server_name']),
models.Index(fields=['updated_at']),
]
def __str__(self):
link_part = f" (link: {self.acl_link_id})" if self.acl_link_id else " (server total)"
return f"{self.user.username} - {self.server_name}{link_part}"
class TaskExecutionLog(models.Model):
task_id = models.CharField(max_length=255, help_text="Celery task ID")
task_name = models.CharField(max_length=100, help_text="Task name")
server = models.ForeignKey('Server', on_delete=models.SET_NULL, null=True, blank=True)
user = models.ForeignKey('User', on_delete=models.SET_NULL, null=True, blank=True)
action = models.CharField(max_length=100, help_text="Action performed")
status = models.CharField(max_length=20, choices=[
('STARTED', 'Started'),
('SUCCESS', 'Success'),
('FAILURE', 'Failure'),
('RETRY', 'Retry'),
], default='STARTED')
message = models.TextField(help_text="Detailed execution message")
execution_time = models.FloatField(null=True, blank=True, help_text="Execution time in seconds")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'Task Execution Log'
verbose_name_plural = 'Task Execution Logs'
indexes = [
models.Index(fields=['task_id']),
models.Index(fields=['created_at']),
models.Index(fields=['status']),
]
def __str__(self):
return f"{self.task_name} - {self.action} ({self.status})"
class AccessLog(models.Model):
user = models.CharField(max_length=256, blank=True, null=True, editable=False)
server = models.CharField(max_length=256, blank=True, null=True, editable=False)
acl_link_id = models.CharField(max_length=1024, blank=True, null=True, editable=False, help_text="ID of the ACL link used")
action = models.CharField(max_length=100, editable=False)
data = models.TextField(default="", blank=True, editable=False)
timestamp = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['user']),
models.Index(fields=['server']),
models.Index(fields=['acl_link_id']),
models.Index(fields=['timestamp']),
models.Index(fields=['action', 'timestamp']),
]
def __str__(self):
link_part = f" (link: {self.acl_link_id})" if self.acl_link_id else ""
return f"{self.action} {self.user} request for {self.server}{link_part} at {self.timestamp}"
class User(AbstractUser):
#is_active = False
comment = models.TextField(default="", blank=True, help_text="Free form user comment")
registration_date = models.DateTimeField(auto_now_add=True, verbose_name="Created")
servers = models.ManyToManyField('Server', through='ACL', blank=True, help_text="Servers user has access to")
last_access = models.DateTimeField(null=True, blank=True)
hash = models.CharField(max_length=64, unique=True, help_text="Random user hash. It's using for client config generation.")
def get_servers(self):
return Server.objects.filter(acl__user=self)
def save(self, *args, **kwargs):
if not self.hash:
self.hash = shortuuid.ShortUUID().random(length=16)
super().save(*args, **kwargs)
def __str__(self):
return self.username
class ACL(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE)
server = models.ForeignKey('Server', on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created")
class Meta:
constraints = [
models.UniqueConstraint(fields=['user', 'server'], name='unique_user_server')
]
def __str__(self):
return f"{self.user.username} - {self.server.name}"
def save(self, *args, **kwargs):
# Check if this is a new ACL and if auto_create_link should be enabled
is_new = self.pk is None
auto_create_link = kwargs.pop('auto_create_link', True)
super().save(*args, **kwargs)
# Only create default link for new ACLs when auto_create_link is True
# This happens when ACL is created through admin interface or initial user setup
if is_new and auto_create_link and not self.links.exists():
ACLLink.objects.create(acl=self, link=shortuuid.ShortUUID().random(length=16))
@receiver(post_save, sender=ACL)
def acl_created_or_updated(sender, instance, created, **kwargs):
try:
sync_user.delay_on_commit(instance.user.id, instance.server.id)
if created:
logger.info(f"Scheduled sync for new ACL: user {instance.user.username} on server {instance.server.name}")
else:
logger.info(f"Scheduled sync for updated ACL: user {instance.user.username} on server {instance.server.name}")
except Exception as e:
logger.error(f"Failed to schedule sync task for ACL {instance.id}: {e}")
# Don't raise exception to avoid blocking ACL creation/update
@receiver(pre_delete, sender=ACL)
def acl_deleted(sender, instance, **kwargs):
try:
sync_user.delay_on_commit(instance.user.id, instance.server.id)
logger.info(f"Scheduled sync for deleted ACL: user {instance.user.username} on server {instance.server.name}")
except Exception as e:
logger.error(f"Failed to schedule sync task for ACL deletion {instance.id}: {e}")
# Don't raise exception to avoid blocking ACL deletion
class ACLLink(models.Model):
acl = models.ForeignKey(ACL, related_name='links', on_delete=models.CASCADE)
comment = models.TextField(default="", blank=True, help_text="ACL link comment, device name, etc...")
link = models.CharField(max_length=1024, default="", unique=True, blank=True, null=True, verbose_name="Access link", help_text="Access link to get dynamic configuration")
last_access_time = models.DateTimeField(null=True, blank=True, help_text="Last time this link was accessed")
def save(self, *args, **kwargs):
if self.link == "":
self.link = shortuuid.ShortUUID().random(length=16)
super().save(*args, **kwargs)
def __str__(self):
return self.link
# Import new Xray models
from .models_xray import (
Credentials, Certificate,
Inbound, SubscriptionGroup, UserSubscription
)

444
vpn/models_xray.py Normal file
View File

@@ -0,0 +1,444 @@
"""
New Xray models for flexible inbound and subscription management.
"""
import json
import uuid
from datetime import datetime, timedelta
from django.db import models
from django.core.exceptions import ValidationError
from django.utils import timezone
class Credentials(models.Model):
"""Universal credentials storage for various services"""
CRED_TYPES = [
('cloudflare', 'Cloudflare API'),
('dns_provider', 'DNS Provider'),
('email', 'Email SMTP'),
('other', 'Other')
]
name = models.CharField(
max_length=100,
unique=True,
help_text="Descriptive name for these credentials"
)
cred_type = models.CharField(
max_length=20,
choices=CRED_TYPES,
help_text="Type of credentials"
)
credentials = models.JSONField(
help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})"
)
description = models.TextField(
blank=True,
help_text="Description of what these credentials are used for"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Credentials"
verbose_name_plural = "Credentials"
ordering = ['cred_type', 'name']
def __str__(self):
return f"{self.name} ({self.get_cred_type_display()})"
def get_credential(self, key: str, default=None):
"""Safely get credential value"""
return self.credentials.get(key, default)
class Certificate(models.Model):
"""SSL/TLS Certificate management"""
CERT_TYPES = [
('self_signed', 'Self-Signed'),
('letsencrypt', "Let's Encrypt"),
('custom', 'Custom')
]
domain = models.CharField(
max_length=255,
unique=True,
help_text="Domain name for this certificate"
)
certificate_pem = models.TextField(
blank=True,
help_text="Certificate in PEM format (auto-generated for Let's Encrypt)"
)
private_key_pem = models.TextField(
blank=True,
help_text="Private key in PEM format (auto-generated for Let's Encrypt)"
)
cert_type = models.CharField(
max_length=20,
choices=CERT_TYPES,
help_text="Type of certificate"
)
expires_at = models.DateTimeField(
null=True,
blank=True,
help_text="Certificate expiration date (auto-filled after generation)"
)
credentials = models.ForeignKey(
Credentials,
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text="Credentials for Let's Encrypt (Cloudflare API)"
)
acme_email = models.EmailField(
blank=True,
help_text="Email address for ACME account registration (required for Let's Encrypt)"
)
auto_renew = models.BooleanField(
default=True,
help_text="Automatically renew certificate before expiration"
)
last_renewed = models.DateTimeField(
null=True,
blank=True,
help_text="Last renewal timestamp"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Certificate"
verbose_name_plural = "Certificates"
ordering = ['domain']
def __str__(self):
return f"{self.domain} ({self.get_cert_type_display()})"
@property
def is_expired(self):
"""Check if certificate is expired"""
if not self.expires_at:
return False
return timezone.now() > self.expires_at
@property
def days_until_expiration(self):
"""Days until certificate expires"""
if not self.expires_at:
return None
delta = self.expires_at - timezone.now()
return delta.days
@property
def needs_renewal(self):
"""Check if certificate needs renewal"""
if not self.auto_renew or not self.expires_at:
return False
# Default renewal period
renewal_days = 60
days_left = self.days_until_expiration
if days_left is None:
return False
return days_left <= renewal_days
class Inbound(models.Model):
"""Independent inbound configuration"""
PROTOCOLS = [
('vless', 'VLESS'),
('vmess', 'VMess'),
('trojan', 'Trojan'),
('shadowsocks', 'Shadowsocks')
]
NETWORKS = [
('tcp', 'TCP'),
('ws', 'WebSocket'),
('grpc', 'gRPC'),
('http', 'HTTP/2'),
('quic', 'QUIC')
]
SECURITIES = [
('none', 'None'),
('tls', 'TLS'),
('reality', 'REALITY')
]
name = models.CharField(
max_length=100,
unique=True,
help_text="Unique identifier for this inbound"
)
protocol = models.CharField(
max_length=20,
choices=PROTOCOLS,
help_text="Protocol type"
)
port = models.IntegerField(
help_text="Port to listen on"
)
network = models.CharField(
max_length=20,
choices=NETWORKS,
default='tcp',
help_text="Transport protocol"
)
security = models.CharField(
max_length=20,
choices=SECURITIES,
default='none',
help_text="Security type"
)
certificate = models.ForeignKey(
Certificate,
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text="Certificate for TLS"
)
# Full configuration for Xray
full_config = models.JSONField(
default=dict,
blank=True,
help_text="Complete configuration for creating inbound on server (auto-generated if empty)"
)
# Additional settings
listen_address = models.CharField(
max_length=45,
default="0.0.0.0",
help_text="IP address to listen on"
)
enable_sniffing = models.BooleanField(
default=True,
help_text="Enable protocol sniffing"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Inbound Template"
verbose_name_plural = "Inbound Templates"
ordering = ['protocol', 'port']
unique_together = [['port', 'listen_address']]
def __str__(self):
return f"{self.name} ({self.protocol.upper()}:{self.port})"
def generate_tag(self):
"""Generate unique tag for inbound"""
return f"{self.protocol}-{self.port}-{uuid.uuid4().hex[:8]}"
def build_config(self):
"""Build full configuration for Xray"""
try:
# Build basic Xray inbound configuration
config = {
"tag": self.name,
"port": self.port,
"listen": self.listen_address,
"protocol": self.protocol,
"settings": self._build_protocol_settings(),
"streamSettings": self._build_stream_settings(),
"sniffing": {
"enabled": self.enable_sniffing,
"destOverride": ["http", "tls"]
} if self.enable_sniffing else {}
}
# Store the built config
self.full_config = config
return self.full_config
except Exception as e:
# Fallback to basic config if detailed build fails
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to build detailed config for {self.name}: {e}")
self.full_config = {
"tag": self.name,
"port": self.port,
"listen": self.listen_address,
"protocol": self.protocol,
"settings": {},
"streamSettings": {}
}
return self.full_config
def _build_protocol_settings(self):
"""Build protocol-specific settings"""
settings = {}
if self.protocol == 'vless':
settings = {
"clients": [], # Will be populated when users are added
"decryption": "none"
}
elif self.protocol == 'vmess':
settings = {
"clients": [] # Will be populated when users are added
}
elif self.protocol == 'trojan':
settings = {
"clients": [] # Will be populated when users are added
}
elif self.protocol == 'shadowsocks':
settings = {
"method": "aes-128-gcm", # Default method
"password": "", # Will be set when configured
"network": "tcp,udp"
}
return settings
def _build_stream_settings(self):
"""Build stream transport settings"""
stream_settings = {
"network": self.network
}
# Add network-specific settings
if self.network == "ws":
stream_settings["wsSettings"] = {
"path": f"/{self.name}",
"headers": {}
}
elif self.network == "grpc":
stream_settings["grpcSettings"] = {
"serviceName": self.name
}
elif self.network == "http":
stream_settings["httpSettings"] = {
"path": f"/{self.name}",
"host": [] # Will be filled when deployed to server
}
# Add security settings
if self.security == "tls":
stream_settings["security"] = "tls"
tls_settings = {
"serverName": "localhost", # Will be replaced with server hostname when deployed
"alpn": ["h2", "http/1.1"]
}
if self.certificate:
tls_settings.update({
"certificates": [{
"certificateFile": f"/etc/xray/certs/{self.certificate.domain}.crt",
"keyFile": f"/etc/xray/certs/{self.certificate.domain}.key"
}]
})
stream_settings["tlsSettings"] = tls_settings
elif self.security == "reality":
stream_settings["security"] = "reality"
# Reality settings would be configured here
stream_settings["realitySettings"] = {
"dest": "example.com:443", # Will be replaced with server hostname when deployed
"serverNames": ["example.com"], # Will be replaced with server hostname when deployed
"privateKey": "", # Would be generated
"shortIds": [""] # Would be generated
}
return stream_settings
class SubscriptionGroup(models.Model):
"""Groups of inbounds for subscription management"""
name = models.CharField(
max_length=100,
unique=True,
help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')"
)
description = models.TextField(
blank=True,
help_text="Description of this subscription group"
)
inbounds = models.ManyToManyField(
Inbound,
blank=True,
help_text="Inbounds included in this group"
)
is_active = models.BooleanField(
default=True,
help_text="Whether this group is active"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Subscriptions"
verbose_name_plural = "Subscriptions"
ordering = ['name']
def __str__(self):
return self.name
@property
def inbound_count(self):
"""Number of inbounds in this group"""
return self.inbounds.count()
@property
def user_count(self):
"""Number of users subscribed to this group"""
return self.usersubscription_set.filter(active=True).count()
class UserSubscription(models.Model):
"""User subscriptions to groups"""
user = models.ForeignKey(
'User',
on_delete=models.CASCADE,
related_name='xray_subscriptions'
)
subscription_group = models.ForeignKey(
SubscriptionGroup,
on_delete=models.CASCADE
)
active = models.BooleanField(
default=True,
help_text="Whether this subscription is active"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "User Subscription"
verbose_name_plural = "User Subscriptions"
unique_together = ['user', 'subscription_group']
ordering = ['user__username', 'subscription_group__name']
def __str__(self):
return f"{self.user.username} - {self.subscription_group.name}"
class ServerInbound(models.Model):
"""Many-to-many relationship between servers and inbounds to track deployment"""
server = models.ForeignKey('Server', on_delete=models.CASCADE, related_name='deployed_inbounds')
inbound = models.ForeignKey(Inbound, on_delete=models.CASCADE, related_name='deployed_servers')
active = models.BooleanField(default=True, help_text="Whether this inbound is active on the server")
deployed_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Store deployment-specific configuration if needed
deployment_config = models.JSONField(default=dict, blank=True, help_text="Server-specific deployment configuration")
class Meta:
verbose_name = "Server Inbound Deployment"
verbose_name_plural = "Server Inbound Deployments"
ordering = ['server__name', 'inbound__name']
unique_together = [('server', 'inbound')]
def __str__(self):
status = "Active" if self.active else "Inactive"
return f"{self.server.name} -> {self.inbound.name} ({status})"

View File

@@ -0,0 +1,5 @@
from .generic import Server
from .outline import OutlineServer, OutlineServerAdmin
from .wireguard import WireguardServer, WireguardServerAdmin
from .xray_v2 import XrayServerV2, XrayServerV2Admin
from .urls import urlpatterns

View File

@@ -0,0 +1,62 @@
from polymorphic.models import PolymorphicModel
from django.db import models
class Server(PolymorphicModel):
SERVER_TYPE_CHOICES = (
('Outline', 'Outline'),
('Wireguard', 'Wireguard'),
('xray_core', 'Xray Core'),
('xray_v2', 'Xray Server v2'),
)
name = models.CharField(max_length=100, help_text="Server name")
comment = models.TextField(default="", blank=True)
registration_date = models.DateTimeField(auto_now_add=True, verbose_name="Created")
server_type = models.CharField(max_length=50, choices=SERVER_TYPE_CHOICES, editable=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def save(self, *args, **kwargs):
# Only sync if the server actually exists and is valid
is_new = self.pk is None
super().save(*args, **kwargs)
# Schedule sync task for existing servers only
if not is_new:
try:
from vpn.tasks import sync_server
sync_server.delay(self.id)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to schedule sync for server {self.name}: {e}")
def get_server_status(self, *args, **kwargs):
return {"name": self.name}
def sync(self, *args, **kwargs):
pass
def sync_users(self, *args, **kwargs):
pass
def add_user(self, *args, **kwargs):
pass
def get_user(self, *args, **kwargs):
pass
def delete_user(self, *args, **kwargs):
pass
class Meta:
verbose_name = "Server"
verbose_name_plural = "Servers"
permissions = [
("access_server", "Can view public status"),
]
def __str__(self):
return self.name

View File

@@ -0,0 +1,823 @@
import logging
import json
import requests
from django.db import models
from django.shortcuts import render, redirect
from django.conf import settings
from .generic import Server
from urllib3 import PoolManager
from outline_vpn.outline_vpn import OutlineVPN, OutlineServerErrorException
from polymorphic.admin import PolymorphicChildModelAdmin
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.db.models import Count
class OutlineConnectionError(Exception):
def __init__(self, message, original_exception=None):
super().__init__(message)
self.original_exception = original_exception
class _FingerprintAdapter(requests.adapters.HTTPAdapter):
"""
This adapter injected into the requests session will check that the
fingerprint for the certificate matches for every request
"""
def __init__(self, fingerprint=None, **kwargs):
self.fingerprint = str(fingerprint)
super(_FingerprintAdapter, self).__init__(**kwargs)
def init_poolmanager(self, connections, maxsize, block=False):
self.poolmanager = PoolManager(
num_pools=connections,
maxsize=maxsize,
block=block,
assert_fingerprint=self.fingerprint,
)
class OutlineServer(Server):
admin_url = models.URLField(help_text="Management URL")
admin_access_cert = models.CharField(max_length=255, help_text="Fingerprint")
client_hostname = models.CharField(max_length=255, help_text="Server address for clients")
client_port = models.CharField(max_length=5, help_text="Server port for clients")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.logger = logging.getLogger(__name__)
class Meta:
verbose_name = 'Outline'
verbose_name_plural = 'Outline'
def save(self, *args, **kwargs):
self.server_type = 'Outline'
super().save(*args, **kwargs)
@property
def status(self):
return self.get_server_status(raw=True)
@property
def client(self):
return OutlineVPN(api_url=self.admin_url, cert_sha256=self.admin_access_cert)
def __str__(self):
return f"{self.name} ({self.client_hostname}:{self.client_port})"
def get_server_status(self, raw=False):
status = {}
try:
info = self.client.get_server_information()
if raw:
status = info
else:
keys = self.client.get_keys()
status.update(info)
status.update({"keys": len(keys)})
status["all_keys"] = []
for key in keys:
status["all_keys"].append(key.key_id)
except Exception as e:
status.update({f"error": e})
return status
def sync_users(self):
from vpn.models import User, ACL
logger = logging.getLogger(__name__)
logger.debug(f"[{self.name}] Sync all users")
try:
keys = self.client.get_keys()
except Exception as e:
logger.error(f"[{self.name}] Failed to get keys from server: {e}")
return False
acls = ACL.objects.filter(server=self)
acl_users = set(acl.user for acl in acls)
# Log user synchronization details
user_list = ", ".join([user.username for user in acl_users])
logger.info(f"[{self.name}] Syncing {len(acl_users)} users: {user_list[:200]}{'...' if len(user_list) > 200 else ''}")
for user in User.objects.all():
if user in acl_users:
try:
result = self.add_user(user=user)
logger.debug(f"[{self.name}] Added user {user.username}: {result}")
except Exception as e:
logger.error(f"[{self.name}] Failed to add user {user.username}: {e}")
else:
try:
result = self.delete_user(user=user)
if result and 'status' in result and 'deleted' in result['status']:
logger.debug(f"[{self.name}] Removed user {user.username}")
except Exception as e:
logger.error(f"[{self.name}] Failed to remove user {user.username}: {e}")
return True
def sync(self):
status = {}
try:
state = self.client.get_server_information()
if state["name"] != self.name:
self.client.set_server_name(self.name)
status["name"] = f"{state['name']} -> {self.name}"
elif state["hostnameForAccessKeys"] != self.client_hostname:
self.client.set_hostname(self.client_hostname)
status["hostnameForAccessKeys"] = f"{state['hostnameForAccessKeys']} -> {self.client_hostname}"
elif int(state["portForNewAccessKeys"]) != int(self.client_port):
self.client.set_port_new_for_access_keys(int(self.client_port))
status["portForNewAccessKeys"] = f"{state['portForNewAccessKeys']} -> {self.client_port}"
if len(status) == 0:
status = {"status": "Nothing to do"}
return status
except AttributeError as e:
raise OutlineConnectionError("Client error. Can't connect.", original_exception=e)
def _get_key(self, user):
logger = logging.getLogger(__name__)
logger.debug(f"[{self.name}] Looking for key for user {user.username}")
try:
# Try to get key by username first
result = self.client.get_key(str(user.username))
logger.debug(f"[{self.name}] Found key for user {user.username} by username")
return result
except OutlineServerErrorException:
# If not found by username, search by password (hash)
logger.debug(f"[{self.name}] Key not found by username, searching by password")
try:
keys = self.client.get_keys()
for key in keys:
if key.password == user.hash:
logger.debug(f"[{self.name}] Found key for user {user.username} by password match")
return key
# No key found
logger.debug(f"[{self.name}] No key found for user {user.username}")
raise OutlineServerErrorException(f"Key not found for user {user.username}")
except Exception as e:
logger.error(f"[{self.name}] Error searching for key for user {user.username}: {e}")
raise OutlineServerErrorException(f"Error searching for key: {e}")
def get_user(self, user, raw=False):
try:
user_info = self._get_key(user)
if raw:
return user_info
else:
outline_key_dict = user_info.__dict__
outline_key_dict = {
key: value
for key, value in user_info.__dict__.items()
if not key.startswith('_') and key not in [] # fields to mask
}
return outline_key_dict
except OutlineServerErrorException as e:
# If user key not found, try to create it automatically
if "Key not found" in str(e):
self.logger.warning(f"[{self.name}] Key not found for user {user.username}, attempting to create")
try:
self.add_user(user)
# Try to get the key again after creation
user_info = self._get_key(user)
if raw:
return user_info
else:
outline_key_dict = {
key: value
for key, value in user_info.__dict__.items()
if not key.startswith('_') and key not in []
}
return outline_key_dict
except Exception as create_error:
self.logger.error(f"[{self.name}] Failed to create missing key for user {user.username}: {create_error}")
raise OutlineServerErrorException(f"Failed to get credentials: {e}")
else:
raise
def add_user(self, user):
logger = logging.getLogger(__name__)
try:
server_user = self._get_key(user)
except OutlineServerErrorException as e:
server_user = None
logger.debug(f"[{self.name}] User {str(server_user)}")
result = {}
key = None
if server_user:
# Check if user needs update - but don't delete immediately
needs_update = (
server_user.method != "chacha20-ietf-poly1305" or
server_user.name != user.username or
server_user.password != user.hash
# Don't check port as Outline can assign different ports automatically
)
if needs_update:
# Delete old key before creating new one
try:
self.client.delete_key(server_user.key_id)
logger.debug(f"[{self.name}] Deleted outdated key for user {user.username}")
except Exception as e:
logger.warning(f"[{self.name}] Failed to delete old key for user {user.username}: {e}")
# Create new key with correct parameters
try:
key = self.client.create_key(
key_id=user.username,
name=user.username,
method="chacha20-ietf-poly1305",
password=user.hash,
data_limit=None
# Don't specify port - let server assign automatically
)
logger.info(f"[{self.name}] User {user.username} updated")
except OutlineServerErrorException as e:
raise OutlineConnectionError(f"Failed to create updated key for user {user.username}", original_exception=e)
else:
# User exists and is up to date
key = server_user
logger.debug(f"[{self.name}] User {user.username} already up to date")
else:
# User doesn't exist, create new key
try:
key = self.client.create_key(
key_id=user.username,
name=user.username,
method="chacha20-ietf-poly1305",
password=user.hash,
data_limit=None
# Don't specify port - let server assign automatically
)
logger.info(f"[{self.name}] User {user.username} created")
except OutlineServerErrorException as e:
error_message = str(e)
if "code\":\"Conflict" in error_message:
logger.warning(f"[{self.name}] Conflict for User {user.username}, trying to resolve. {error_message}")
# Find conflicting key by password and remove it
try:
for existing_key in self.client.get_keys():
if existing_key.password == user.hash:
logger.warning(f"[{self.name}] Found conflicting key {existing_key.key_id} with same password")
self.client.delete_key(existing_key.key_id)
break
# Try to create again after cleanup
return self.add_user(user)
except Exception as cleanup_error:
logger.error(f"[{self.name}] Failed to resolve conflict for user {user.username}: {cleanup_error}")
raise OutlineConnectionError(f"Conflict resolution failed for user {user.username}", original_exception=e)
else:
raise OutlineConnectionError("API Error", original_exception=e)
# Build result from key object
try:
if key:
result = {
'key_id': key.key_id,
'name': key.name,
'method': key.method,
'password': key.password,
'data_limit': key.data_limit,
'port': key.port
}
else:
result = {"error": "No key object returned"}
except Exception as e:
logger.error(f"[{self.name}] Error building result for user {user.username}: {e}")
result = {"error": str(e)}
return result
def delete_user(self, user):
result = None
try:
server_user = self._get_key(user)
except OutlineServerErrorException as e:
return {"status": "User not found on server. Nothing to do."}
if server_user:
self.logger.info(f"Deleting key with key_id: {server_user.key_id}")
self.client.delete_key(server_user.key_id)
result = {"status": "User was deleted"}
self.logger.info(f"[{self.name}] User deleted: {user.username} on server {self.name}")
return result
class OutlineServerAdmin(PolymorphicChildModelAdmin):
base_model = OutlineServer
show_in_index = False
list_display = (
'name',
'admin_url',
'admin_access_cert',
'client_hostname',
'client_port',
'user_count',
'server_status_inline',
)
readonly_fields = ('server_status_full', 'registration_date', 'export_configuration_display', 'server_statistics_display', 'recent_activity_display', 'json_import_field')
list_editable = ('admin_url', 'admin_access_cert', 'client_hostname', 'client_port',)
exclude = ('server_type',)
def get_fieldsets(self, request, obj=None):
"""Customize fieldsets based on whether object exists"""
if obj is None: # Adding new server
return (
('JSON Import', {
'fields': ('json_import_field',),
'description': 'Quick import from Outline server JSON configuration'
}),
('Server Configuration', {
'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port')
}),
)
else: # Editing existing server
return (
('Server Configuration', {
'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port', 'registration_date')
}),
('Server Status', {
'fields': ('server_status_full',)
}),
('Export Configuration', {
'fields': ('export_configuration_display',)
}),
('Statistics & Users', {
'fields': ('server_statistics_display',),
'classes': ('collapse',)
}),
('Recent Activity', {
'fields': ('recent_activity_display',),
'classes': ('collapse',)
}),
)
def get_urls(self):
from django.urls import path
urls = super().get_urls()
custom_urls = [
path('<int:object_id>/sync/', self.admin_site.admin_view(self.sync_server_view), name='outlineserver_sync'),
]
return custom_urls + urls
@admin.display(description='Clients', ordering='user_count')
def user_count(self, obj):
return obj.user_count
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(user_count=Count('acl__user'))
return qs
def server_status_inline(self, obj):
status = obj.get_server_status()
if 'error' in status:
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
# Преобразуем JSON в красивый формат
import json
pretty_status = json.dumps(status, indent=4)
return mark_safe(f"<pre>{pretty_status}</pre>")
server_status_inline.short_description = "Status"
def server_status_full(self, obj):
if obj and obj.pk:
status = obj.get_server_status()
if 'error' in status:
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
import json
pretty_status = json.dumps(status, indent=4)
return mark_safe(f"<pre>{pretty_status}</pre>")
return "N/A"
server_status_full.short_description = "Server Status"
def sync_server_view(self, request, object_id):
"""AJAX view to sync server settings"""
from django.http import JsonResponse
if request.method == 'POST':
try:
server = OutlineServer.objects.get(pk=object_id)
result = server.sync()
return JsonResponse({
'success': True,
'message': f'Server "{server.name}" synchronized successfully',
'details': result
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
return JsonResponse({'error': 'Invalid request method'}, status=405)
def add_view(self, request, form_url='', extra_context=None):
"""Use the default Django admin add view"""
return super().add_view(request, form_url, extra_context)
@admin.display(description='Import JSON Configuration')
def json_import_field(self, obj):
"""Display JSON import field for new servers only"""
if obj and obj.pk:
# Hide for existing servers
return ''
html = '''
<div style="width: 100%;">
<textarea id="import-json-config" class="vLargeTextField" rows="8"
placeholder='{
"apiUrl": "https://your-server:port/path",
"certSha256": "your-certificate-hash",
"serverName": "My Outline Server",
"clientHostname": "your-server.com",
"clientPort": 1257,
"comment": "Server description"
}' style="font-family: 'Courier New', monospace; font-size: 0.875rem; width: 100%;"></textarea>
<div class="help" style="margin-top: 0.5rem;">
Paste JSON configuration from your Outline server setup to automatically fill the fields below.
</div>
<div style="margin-top: 1rem;">
<button type="button" id="import-json-btn" class="btn btn-primary" onclick="importJsonConfig()">Import Configuration</button>
</div>
<script>
function importJsonConfig() {
const textarea = document.getElementById('import-json-config');
try {
const jsonText = textarea.value.trim();
if (!jsonText) {
alert('Please enter JSON configuration');
return;
}
const config = JSON.parse(jsonText);
// Validate required fields
if (!config.apiUrl || !config.certSha256) {
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
return;
}
// Parse apiUrl to extract components
const url = new URL(config.apiUrl);
// Fill form fields
const adminUrlField = document.getElementById('id_admin_url');
const adminCertField = document.getElementById('id_admin_access_cert');
const clientHostnameField = document.getElementById('id_client_hostname');
const clientPortField = document.getElementById('id_client_port');
const nameField = document.getElementById('id_name');
const commentField = document.getElementById('id_comment');
if (adminUrlField) adminUrlField.value = config.apiUrl;
if (adminCertField) adminCertField.value = config.certSha256;
// Use provided hostname or extract from URL
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
if (clientHostnameField) clientHostnameField.value = hostname;
// Use provided port or extract from various sources
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
if (clientPortField) clientPortField.value = clientPort;
// Generate server name if not provided and field is empty
if (nameField && !nameField.value) {
const serverName = config.serverName || config.name || 'Outline-' + hostname;
nameField.value = serverName;
}
// Fill comment if provided and field exists
if (commentField && config.comment) {
commentField.value = config.comment;
}
// Clear the JSON input
textarea.value = '';
alert('Configuration imported successfully! Review the fields below and save.');
// Click on Server Configuration tab if using Jazzmin
const serverTab = document.querySelector('a[href="#server-configuration-tab"]');
if (serverTab) {
serverTab.click();
}
} catch (error) {
alert('Invalid JSON format: ' + error.message);
}
}
// Add paste event listener
document.addEventListener('DOMContentLoaded', function() {
const textarea = document.getElementById('import-json-config');
if (textarea) {
textarea.addEventListener('paste', function(e) {
setTimeout(importJsonConfig, 100);
});
}
});
</script>
</div>
'''
return mark_safe(html)
@admin.display(description='Server Statistics & Users')
def server_statistics_display(self, obj):
"""Display server statistics and user management"""
if not obj or not obj.pk:
return mark_safe('<div style="color: #6c757d; font-style: italic;">Statistics will be available after saving</div>')
try:
from vpn.models import ACL, AccessLog, UserStatistics
from django.utils import timezone
from datetime import timedelta
# Get user statistics
user_count = ACL.objects.filter(server=obj).count()
total_links = 0
server_keys_count = 0
try:
from vpn.models import ACLLink
total_links = ACLLink.objects.filter(acl__server=obj).count()
# Try to get actual keys count from server
server_status = obj.get_server_status()
if 'keys' in server_status:
server_keys_count = server_status['keys']
except Exception:
pass
# Get active users count (last 30 days)
thirty_days_ago = timezone.now() - timedelta(days=30)
active_users_count = UserStatistics.objects.filter(
server_name=obj.name,
recent_connections__gt=0
).values('user').distinct().count()
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: 1rem;">'
# Overall Statistics
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
html += '<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
html += f'<div><strong>Total Users:</strong> {user_count}</div>'
html += f'<div><strong>Active Users (30d):</strong> {active_users_count}</div>'
html += f'<div><strong>Total Links:</strong> {total_links}</div>'
html += f'<div><strong>Server Keys:</strong> {server_keys_count}</div>'
html += '</div>'
html += '<div style="margin-top: 8px; font-size: 11px; color: #6c757d;">'
html += '📊 User activity data is from cached statistics for fast loading. Status indicators show usage patterns.'
html += '</div>'
html += '</div>'
# Get users data with ACL information
acls = ACL.objects.filter(server=obj).select_related('user').prefetch_related('links')
if acls:
html += '<h5 style="color: #495057; margin: 16px 0 8px 0;">👥 Users with Access</h5>'
for acl in acls:
user = acl.user
links = list(acl.links.all())
# Get last access time from any link
last_access = None
for link in links:
if link.last_access_time:
if last_access is None or link.last_access_time > last_access:
last_access = link.last_access_time
# Use cached statistics instead of live server check for performance
user_stats = UserStatistics.objects.filter(user=user, server_name=obj.name)
server_key_status = "unknown"
total_user_connections = 0
recent_user_connections = 0
if user_stats.exists():
# User has cached data, likely has server access
total_user_connections = sum(stat.total_connections for stat in user_stats)
recent_user_connections = sum(stat.recent_connections for stat in user_stats)
if total_user_connections > 0:
server_key_status = "cached_active"
else:
server_key_status = "cached_inactive"
else:
# No cached data - either new user or no access
server_key_status = "no_cache"
html += '<div style="background: #ffffff; border: 1px solid #e9ecef; border-radius: 0.25rem; padding: 0.75rem; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center;">'
# User info section
html += '<div style="flex: 1;">'
html += f'<div style="font-weight: 500; font-size: 14px; color: #495057;">{user.username}'
if user.comment:
html += f' <span style="color: #6c757d; font-size: 12px; font-weight: normal;">- {user.comment}</span>'
html += '</div>'
html += f'<div style="font-size: 12px; color: #6c757d;">{len(links)} link(s)'
if last_access:
from django.utils.timezone import localtime
local_time = localtime(last_access)
html += f' | Last access: {local_time.strftime("%Y-%m-%d %H:%M")}'
else:
html += ' | Never accessed'
# Add usage statistics inside the same div
if total_user_connections > 0:
html += f' | {total_user_connections} total uses'
if recent_user_connections > 0:
html += f' ({recent_user_connections} recent)'
html += '</div>' # End user info
html += '</div>' # End flex-1 div
# Status and actions section
html += '<div style="display: flex; gap: 8px; align-items: center;">'
# Status indicator based on cached data
if server_key_status == "cached_active":
html += '<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">📊 Active User</span>'
elif server_key_status == "cached_inactive":
html += '<span style="background: #fff3cd; color: #856404; padding: 2px 6px; border-radius: 3px; font-size: 10px;">📊 Inactive</span>'
else:
html += '<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">❓ No Data</span>'
html += f'<a href="/admin/vpn/user/{user.id}/change/" class="btn btn-sm btn-outline-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 0.2rem; margin: 0 0.1rem;">👤 Edit</a>'
html += '</div>' # End actions div
html += '</div>' # End main user div
else:
html += '<div style="color: #6c757d; font-style: italic; text-align: center; padding: 20px; background: #f9fafb; border-radius: 6px;">'
html += 'No users assigned to this server'
html += '</div>'
html += '</div>'
return mark_safe(html)
except Exception as e:
return mark_safe(f'<div style="color: #dc3545;">Error loading statistics: {e}</div>')
@admin.display(description='Export Configuration')
def export_configuration_display(self, obj):
"""Display JSON export configuration"""
if not obj or not obj.pk:
return mark_safe('<div style="color: #6c757d; font-style: italic;">Export will be available after saving</div>')
try:
# Build export data
export_data = {
'apiUrl': obj.admin_url,
'certSha256': obj.admin_access_cert,
'serverName': obj.name,
'clientHostname': obj.client_hostname,
'clientPort': int(obj.client_port),
'comment': obj.comment,
'serverType': 'outline',
'dateCreated': obj.registration_date.isoformat() if obj.registration_date else None,
'id': obj.id
}
# Try to get server status
try:
server_status = obj.get_server_status()
if 'error' not in server_status:
export_data['serverInfo'] = server_status
except Exception:
pass
json_str = json.dumps(export_data, indent=2)
# Escape the JSON for HTML
from django.utils.html import escape
escaped_json = escape(json_str)
html = '''
<div>
<textarea id="export-json-config" class="vLargeTextField" rows="10" readonly
style="font-family: 'Courier New', monospace; font-size: 0.875rem; background-color: #f8f9fa; width: 100%;">''' + escaped_json + '''</textarea>
<div class="help" style="margin-top: 0.5rem;">
<strong>Includes:</strong> Server settings, connection details, live server info (if accessible), creation date, and comment.
</div>
<div style="padding-top: 1rem;">
<button type="button" id="copy-export-btn" class="btn btn-sm btn-secondary"
onclick="var btn=this; document.getElementById('export-json-config').select(); document.execCommand('copy'); btn.innerHTML='✅ Copied!'; setTimeout(function(){btn.innerHTML='📋 Copy JSON';}, 2000);"
style="margin-right: 10px;">📋 Copy JSON</button>
<button type="button" id="sync-server-btn" data-server-id="''' + str(obj.id) + '''" class="btn btn-sm btn-primary">🔄 Sync Server Settings</button>
<span style="margin-left: 0.5rem; font-size: 0.875rem; color: #6c757d;">
Synchronize server name, hostname, and port settings
</span>
</div>
</div>
'''
return mark_safe(html)
except Exception as e:
return mark_safe(f'<div style="color: #dc3545;">Error generating export: {e}</div>')
@admin.display(description='Recent Activity')
def recent_activity_display(self, obj):
"""Display recent activity in admin-friendly format"""
if not obj or not obj.pk:
return mark_safe('<div style="color: #6c757d; font-style: italic;">Activity will be available after saving</div>')
try:
from vpn.models import AccessLog
from django.utils.timezone import localtime
from datetime import timedelta
from django.utils import timezone
# Get recent access logs for this server (last 7 days)
seven_days_ago = timezone.now() - timedelta(days=7)
recent_logs = AccessLog.objects.filter(
server=obj.name,
timestamp__gte=seven_days_ago
).order_by('-timestamp')[:20]
if not recent_logs:
return mark_safe('<div style="color: #6c757d; font-style: italic; padding: 12px; background: #f8f9fa; border-radius: 4px;">No recent activity (last 7 days)</div>')
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 0; max-height: 300px; overflow-y: auto;">'
# Header
html += '<div style="background: #e9ecef; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600; font-size: 12px; color: #495057;">'
html += f'📊 Access Log ({recent_logs.count()} entries, last 7 days)'
html += '</div>'
# Activity entries
for i, log in enumerate(recent_logs):
bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa'
local_time = localtime(log.timestamp)
# Status icon and color
if log.action == 'Success':
icon = ''
status_color = '#28a745'
elif log.action == 'Failed':
icon = ''
status_color = '#dc3545'
else:
icon = ''
status_color = '#6c757d'
html += f'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f1f3f4; background: {bg_color};">'
# Left side - user and link info
html += '<div style="display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0;">'
html += f'<span style="color: {status_color}; font-size: 14px;">{icon}</span>'
html += '<div style="overflow: hidden;">'
html += f'<div style="font-weight: 500; font-size: 12px; color: #495057; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{log.user}</div>'
if log.acl_link_id:
link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id
html += f'<div style="font-family: monospace; font-size: 10px; color: #6c757d;">{link_short}</div>'
html += '</div></div>'
# Right side - timestamp and status
html += '<div style="text-align: right; flex-shrink: 0;">'
html += f'<div style="font-size: 10px; color: #6c757d;">{local_time.strftime("%m-%d %H:%M")}</div>'
html += f'<div style="font-size: 9px; color: {status_color}; font-weight: 500;">{log.action}</div>'
html += '</div>'
html += '</div>'
# Footer with summary if there are more entries
total_recent = AccessLog.objects.filter(
server=obj.name,
timestamp__gte=seven_days_ago
).count()
if total_recent > 20:
html += f'<div style="background: #e9ecef; padding: 6px 12px; font-size: 11px; color: #6c757d; text-align: center;">'
html += f'Showing 20 of {total_recent} entries from last 7 days'
html += '</div>'
html += '</div>'
return mark_safe(html)
except Exception as e:
return mark_safe(f'<div style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</div>')
def get_model_perms(self, request):
"""It disables display for sub-model"""
return {}
class Media:
js = ('admin/js/generate_link.js',)
css = {'all': ('admin/css/vpn_admin.css',)}
admin.site.register(OutlineServer, OutlineServerAdmin)

View File

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

View File

@@ -0,0 +1,83 @@
from .generic import Server
from django.db import models
from polymorphic.admin import (
PolymorphicChildModelAdmin,
)
from django.contrib import admin
from django.db.models import Count
from django.utils.safestring import mark_safe
class WireguardServer(Server):
address = models.CharField(max_length=100)
port = models.IntegerField()
client_private_key = models.CharField(max_length=255)
server_publick_key = models.CharField(max_length=255)
class Meta:
verbose_name = 'Wireguard'
verbose_name_plural = 'Wireguard'
def save(self, *args, **kwargs):
self.server_type = 'Wireguard'
super().save(*args, **kwargs)
def __str__(self):
return f"{self.name} ({self.address})"
def get_server_status(self):
status = super().get_server_status()
status.update({
"address": self.address,
"port": self.port,
"client_private_key": self.client_private_key,
"server_publick_key": self.server_publick_key,
})
return status
class WireguardServerAdmin(PolymorphicChildModelAdmin):
base_model = WireguardServer
show_in_index = False # Не отображать в главном списке админки
list_display = (
'name',
'address',
'port',
'server_publick_key',
'client_private_key',
'server_status_inline',
'user_count',
'registration_date'
)
readonly_fields = ('server_status_full', )
exclude = ('server_type',)
@admin.display(description='Clients', ordering='user_count')
def user_count(self, obj):
return obj.user_count
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(user_count=Count('acl__user'))
return qs
def server_status_inline(self, obj):
status = obj.get_server_status()
if 'error' in status:
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
return mark_safe(f"<pre>{status}</pre>")
server_status_inline.short_description = "Server Status"
def server_status_full(self, obj):
if obj and obj.pk:
status = obj.get_server_status()
if 'error' in status:
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>")
return mark_safe(f"<pre>{status}</pre>")
return "N/A"
server_status_full.short_description = "Server Status"
def get_model_perms(self, request):
"""It disables display for sub-model"""
return {}
admin.site.register(WireguardServer, WireguardServerAdmin)

View File

@@ -0,0 +1,736 @@
import logging
from django.db import models
from django.contrib import admin
from .generic import Server
from vpn.models_xray import Inbound, UserSubscription
logger = logging.getLogger(__name__)
class XrayServerV2(Server):
"""
New Xray server that works with subscription groups and inbounds.
This server can host multiple inbounds and users access them through subscription groups.
"""
client_hostname = models.CharField(
max_length=255,
help_text="Client connection hostname (what users see in their configs)"
)
api_address = models.CharField(
max_length=255,
default="127.0.0.1:10085",
help_text="Xray gRPC API address for management"
)
api_enabled = models.BooleanField(
default=True,
help_text="Enable gRPC API for user management"
)
stats_enabled = models.BooleanField(
default=True,
help_text="Enable traffic statistics collection"
)
class Meta:
verbose_name = "Xray Server v2"
verbose_name_plural = "Xray Servers v2"
def save(self, *args, **kwargs):
if not self.server_type:
self.server_type = 'xray_v2'
super().save(*args, **kwargs)
def get_server_status(self):
"""Get server status including active inbounds"""
try:
# Get basic server information
active_inbounds = self.get_active_inbounds()
# Try to connect to Xray API if enabled
api_status = False
api_error = None
api_stats = {}
if self.api_enabled:
try:
# Try different methods to check server status
import socket
import json
# Parse API address
host, port = self.api_address.split(':')
port = int(port)
# Test basic connection
logger.info(f"Testing connection to Xray API at {host}:{port}")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
result = sock.connect_ex((host, port))
sock.close()
if result == 0:
api_status = True
logger.info(f"Successfully connected to Xray API at {self.api_address}")
# Try to get stats if library is available
try:
from vpn.xray_api_v2.server_manager import ServerManager
manager = ServerManager(self.api_address)
api_stats = manager.get_server_stats()
logger.info(f"Got server stats: {api_stats}")
except ImportError:
logger.info("Xray API v2 library not available, but connection successful")
api_stats = {"connection": "ok", "library": "not_available"}
except Exception as stats_e:
logger.warning(f"Connection OK but stats failed: {stats_e}")
api_stats = {"connection": "ok", "stats_error": str(stats_e)}
else:
api_error = f"Connection failed to {host}:{port}"
logger.warning(f"Failed to connect to Xray API at {self.api_address}: {api_error}")
except Exception as e:
api_error = f"Connection test failed: {str(e)}"
logger.warning(f"Failed to test connection to Xray API for server {self.name}: {e}")
else:
api_error = "API disabled in server settings"
logger.info(f"API disabled for server {self.name}")
# Build status response
status = {
'server_name': self.name,
'server_type': 'Xray Server v2',
'client_hostname': self.client_hostname,
'api_address': self.api_address,
'api_enabled': self.api_enabled,
'api_connected': api_status,
'api_error': api_error,
'api_stats': api_stats,
'stats_enabled': self.stats_enabled,
'total_inbounds': active_inbounds.count(),
'inbound_ports': [], # Will be populated when ServerInbound model is fully implemented
'accessible': api_status if self.api_enabled else True, # Consider accessible if API disabled
'status': 'Connected' if api_status else 'API Issue' if self.api_enabled else 'No API Check'
}
logger.info(f"Server status for {self.name}: {status['status']}")
return status
except Exception as e:
logger.error(f"Failed to get status for Xray server {self.name}: {e}")
return {
'error': str(e),
'server_name': self.name,
'server_type': 'Xray Server v2',
'accessible': False,
'status': 'Error'
}
def get_active_inbounds(self):
"""Get all inbounds that are deployed on this server"""
try:
from vpn.models_xray import ServerInbound
return ServerInbound.objects.filter(server=self, active=True).select_related('inbound')
except ImportError:
# ServerInbound model doesn't exist yet, return empty queryset
from django.db.models import QuerySet
from vpn.models_xray import Inbound
return Inbound.objects.none()
except Exception as e:
logger.warning(f"Error getting active inbounds for server {self.name}: {e}")
from vpn.models_xray import Inbound
return Inbound.objects.none()
def sync_users(self):
"""Sync all users who have subscription groups containing inbounds on this server"""
try:
from vpn.tasks import sync_server_users
task = sync_server_users.delay(self.id)
logger.info(f"Scheduled user sync for Xray server {self.name} - task ID: {task.id}")
# Return success to indicate task was scheduled
return {"status": "scheduled", "task_id": str(task.id)}
except Exception as e:
logger.error(f"Failed to schedule user sync for server {self.name}: {e}")
return {"status": "failed", "error": str(e)}
def sync_inbounds(self, auto_sync_users=True):
"""Deploy all required inbounds on this server based on subscription groups"""
try:
from vpn.tasks import sync_server_inbounds
task = sync_server_inbounds.delay(self.id, auto_sync_users)
logger.info(f"Scheduled inbound sync for Xray server {self.name} - task ID: {task.id}")
return {"task_id": str(task.id), "auto_sync_users": auto_sync_users}
except Exception as e:
logger.error(f"Failed to schedule inbound sync for server {self.name}: {e}")
return {"error": str(e)}
def deploy_inbound(self, inbound, users=None):
"""Deploy a specific inbound on this server with optional users"""
try:
from vpn.xray_api_v2.client import XrayClient
import uuid
logger.info(f"Deploying inbound {inbound.name} with protocol {inbound.protocol} on port {inbound.port}")
client = XrayClient(server=self.api_address)
# Build user configs if users are provided
user_configs = []
if users:
for user in users:
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
if inbound.protocol == 'vless':
user_config = {
"email": f"{user.username}@{self.name}",
"id": user_uuid,
"level": 0
}
elif inbound.protocol == 'vmess':
user_config = {
"email": f"{user.username}@{self.name}",
"id": user_uuid,
"level": 0,
"alterId": 0
}
elif inbound.protocol == 'trojan':
user_config = {
"email": f"{user.username}@{self.name}",
"password": user_uuid,
"level": 0
}
else:
logger.warning(f"Unsupported protocol {inbound.protocol} for user {user.username}")
continue
user_configs.append(user_config)
logger.info(f"Added user {user.username} to inbound config")
# Build proper inbound configuration based on protocol
if inbound.full_config:
inbound_config = inbound.full_config.copy() # Make a copy to modify
logger.info(f"Using existing full_config for inbound {inbound.name}")
# Add users to the config if provided
if user_configs:
if 'settings' not in inbound_config:
inbound_config['settings'] = {}
inbound_config['settings']['clients'] = user_configs
logger.info(f"Added {len(user_configs)} users to full_config")
# If inbound has a certificate, update the config to use inline certificates
if inbound.certificate and inbound.certificate.certificate_pem:
logger.info(f"Updating full_config with inline certificate for {inbound.certificate.domain}")
# Convert PEM to lines for Xray format
cert_lines = inbound.certificate.certificate_pem.strip().split('\n')
key_lines = inbound.certificate.private_key_pem.strip().split('\n')
# Update streamSettings if it exists
if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]:
inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
"certificate": cert_lines,
"key": key_lines,
"usage": "encipherment"
}]
logger.info("Updated existing tlsSettings with inline certificate")
else:
# Build full config based on protocol
inbound_config = {
"tag": inbound.name,
"port": inbound.port,
"protocol": inbound.protocol,
"listen": inbound.listen_address or "0.0.0.0",
}
# Add protocol-specific settings
if inbound.protocol == 'vless':
inbound_config["settings"] = {
"clients": user_configs, # Add users during creation
"decryption": "none"
}
if inbound.network == 'ws':
inbound_config["streamSettings"] = {
"network": "ws",
"wsSettings": {
"path": f"/{inbound.name}"
}
}
elif inbound.network == 'tcp':
inbound_config["streamSettings"] = {
"network": "tcp"
}
elif inbound.protocol == 'vmess':
inbound_config["settings"] = {
"clients": user_configs # Add users during creation
}
if inbound.network == 'ws':
inbound_config["streamSettings"] = {
"network": "ws",
"wsSettings": {
"path": f"/{inbound.name}"
}
}
elif inbound.network == 'tcp':
inbound_config["streamSettings"] = {
"network": "tcp"
}
elif inbound.protocol == 'trojan':
inbound_config["settings"] = {
"clients": user_configs # Add users during creation
}
inbound_config["streamSettings"] = {
"network": "tcp",
"security": "tls"
}
# Trojan always requires TLS certificate
if inbound.certificate and inbound.certificate.certificate_pem:
logger.info(f"Using certificate for Trojan inbound on domain {inbound.certificate.domain}")
# Convert PEM to lines for Xray format
cert_lines = inbound.certificate.certificate_pem.strip().split('\n')
key_lines = inbound.certificate.private_key_pem.strip().split('\n')
inbound_config["streamSettings"]["tlsSettings"] = {
"certificates": [{
"certificate": cert_lines,
"key": key_lines,
"usage": "encipherment"
}]
}
else:
logger.error(f"Trojan protocol requires certificate, but none found for inbound {inbound.name}!")
inbound_config["streamSettings"]["tlsSettings"] = {
"certificates": []
}
# Add TLS if specified
if inbound.security == 'tls' and inbound.protocol != 'trojan':
if "streamSettings" not in inbound_config:
inbound_config["streamSettings"] = {}
inbound_config["streamSettings"]["security"] = "tls"
# Check if inbound has a certificate
if inbound.certificate and inbound.certificate.certificate_pem:
logger.info(f"Using certificate for domain {inbound.certificate.domain}")
# Convert PEM to lines for Xray format
cert_lines = inbound.certificate.certificate_pem.strip().split('\n')
key_lines = inbound.certificate.private_key_pem.strip().split('\n')
inbound_config["streamSettings"]["tlsSettings"] = {
"certificates": [{
"certificate": cert_lines,
"key": key_lines,
"usage": "encipherment"
}]
}
else:
logger.warning(f"No certificate found for inbound {inbound.name}, TLS will not work!")
inbound_config["streamSettings"]["tlsSettings"] = {
"certificates": []
}
logger.info(f"Inbound config: {inbound_config}")
# Add inbound using the client's add_inbound method which handles wrapping
try:
result = client.add_inbound(inbound_config)
logger.info(f"Deploy inbound result: {result}")
# Check if command was successful
if result is not None and not (isinstance(result, dict) and 'error' in result):
# Mark as deployed on this server
from vpn.models_xray import ServerInbound
ServerInbound.objects.update_or_create(
server=self,
inbound=inbound,
defaults={'active': True}
)
logger.info(f"Successfully deployed inbound {inbound.name} on server {self.name}")
return True
else:
logger.error(f"Failed to deploy inbound {inbound.name} on server {self.name}. Result: {result}")
return False
except Exception as cmd_error:
logger.error(f"Command execution error: {cmd_error}")
return False
except Exception as e:
logger.error(f"Error deploying inbound {inbound.name} on server {self.name}: {e}")
return False
def add_user_to_inbound(self, user, inbound):
"""Add a user to a specific inbound on this server using inbound recreation approach"""
try:
from vpn.xray_api_v2.client import XrayClient
import uuid
logger.info(f"Adding user {user.username} to inbound {inbound.name} using inbound recreation")
client = XrayClient(server=self.api_address)
# Generate user UUID based on username and inbound
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
logger.info(f"Generated UUID for user {user.username}: {user_uuid}")
# Build user config based on protocol
if inbound.protocol == 'vless':
user_config = {
"email": f"{user.username}@{self.name}",
"id": user_uuid,
"level": 0
}
elif inbound.protocol == 'vmess':
user_config = {
"email": f"{user.username}@{self.name}",
"id": user_uuid,
"level": 0,
"alterId": 0
}
elif inbound.protocol == 'trojan':
user_config = {
"email": f"{user.username}@{self.name}",
"password": user_uuid,
"level": 0
}
else:
logger.error(f"Unsupported protocol: {inbound.protocol}")
return False
try:
# First, get existing inbound to check for other users
existing_result = client.execute_command('lsi')
existing_inbound = None
if existing_result and 'inbounds' in existing_result:
for ib in existing_result['inbounds']:
if ib.get('tag') == inbound.name:
existing_inbound = ib
break
if not existing_inbound:
logger.warning(f"Inbound {inbound.name} not found on server, deploying it first")
# Deploy the inbound if it doesn't exist
if not self.deploy_inbound(inbound):
logger.error(f"Failed to deploy inbound {inbound.name}")
return False
# Get the inbound config we just created
existing_inbound = {"settings": {"clients": []}}
# Get existing users from the inbound
existing_users = existing_inbound.get('settings', {}).get('clients', [])
logger.info(f"Found {len(existing_users)} existing users in inbound {inbound.name}")
# Check if user already exists
for existing_user in existing_users:
if existing_user.get('email') == f"{user.username}@{self.name}":
logger.info(f"User {user.username} already exists in inbound {inbound.name}")
return True
# Add new user to existing users list
existing_users.append(user_config)
logger.info(f"Creating new inbound with {len(existing_users)} users including {user.username}")
# Remove the old inbound
logger.info(f"Removing old inbound {inbound.name}")
client.remove_inbound(inbound.name)
# Recreate inbound with updated user list
if inbound.full_config:
inbound_config = inbound.full_config.copy()
if 'settings' not in inbound_config:
inbound_config['settings'] = {}
inbound_config['settings']['clients'] = existing_users
# Handle certificate embedding if needed
if inbound.certificate and inbound.certificate.certificate_pem:
cert_lines = inbound.certificate.certificate_pem.strip().split('\n')
key_lines = inbound.certificate.private_key_pem.strip().split('\n')
if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]:
inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
"certificate": cert_lines,
"key": key_lines,
"usage": "encipherment"
}]
else:
# Build config from scratch with the users
inbound_config = {
"tag": inbound.name,
"port": inbound.port,
"protocol": inbound.protocol,
"listen": inbound.listen_address or "0.0.0.0",
"settings": {}
}
if inbound.protocol in ['vless', 'vmess']:
inbound_config["settings"]["clients"] = existing_users
if inbound.protocol == 'vless':
inbound_config["settings"]["decryption"] = "none"
elif inbound.protocol == 'trojan':
inbound_config["settings"]["clients"] = existing_users
logger.info(f"Deploying updated inbound with users: {[u.get('email') for u in existing_users]}")
result = client.add_inbound(inbound_config)
if result is not None and not (isinstance(result, dict) and 'error' in result):
logger.info(f"Successfully added user {user.username} to inbound {inbound.name} via inbound recreation")
return True
else:
logger.error(f"Failed to recreate inbound {inbound.name} with user. Result: {result}")
return False
except Exception as cmd_error:
logger.error(f"Error during inbound recreation: {cmd_error}")
return False
except Exception as e:
logger.error(f"Error adding user {user.username} to inbound {inbound.name} on server {self.name}: {e}")
return False
def remove_user_from_inbound(self, user, inbound):
"""Remove a user from a specific inbound on this server"""
try:
from vpn.xray_api_v2.client import XrayClient
client = XrayClient(server=self.api_address)
# Remove user using the client's remove_users method
user_email = f"{user.username}@{self.name}"
logger.info(f"Removing user {user_email} from inbound {inbound.name}")
result = client.remove_users(inbound.name, user_email)
logger.info(f"Remove user result: {result}")
if result is not None and not (isinstance(result, dict) and 'error' in result):
logger.info(f"Successfully removed user {user.username} from inbound {inbound.name} on server {self.name}")
return True
else:
logger.error(f"Failed to remove user {user.username} from inbound {inbound.name} on server {self.name}. Result: {result}")
return False
except Exception as e:
logger.error(f"Error removing user {user.username} from inbound {inbound.name} on server {self.name}: {e}")
return False
def get_user_configs(self, user):
"""Generate all connection configs for a user on this server"""
configs = []
try:
# Get all subscription groups for this user
user_subscriptions = UserSubscription.objects.filter(
user=user,
active=True,
subscription_group__is_active=True
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
for subscription in user_subscriptions:
group = subscription.subscription_group
# Check which inbounds from this group are active on this server
active_inbounds = self.get_active_inbounds().filter(
inbound__in=group.inbounds.all()
)
for server_inbound in active_inbounds:
inbound = server_inbound.inbound
try:
# Generate connection string directly
from vpn.views import generate_xray_connection_string
connection_string = generate_xray_connection_string(user, inbound, self.name, self.client_hostname)
if connection_string:
configs.append({
'protocol': inbound.protocol,
'inbound_name': inbound.name,
'group_name': group.name,
'connection_string': connection_string,
'port': inbound.port,
'network': inbound.network,
'security': inbound.security
})
except Exception as e:
logger.warning(f"Failed to generate config for user {user.username} on inbound {inbound.name}: {e}")
continue
logger.info(f"Generated {len(configs)} configs for user {user.username} on server {self.name}")
return configs
except Exception as e:
logger.error(f"Error generating user configs for {user.username} on server {self.name}: {e}")
return []
def sync(self):
"""Sync server configuration and users"""
try:
self.sync_inbounds()
self.sync_users()
logger.info(f"Full sync completed for server {self.name}")
except Exception as e:
logger.error(f"Sync failed for server {self.name}: {e}")
def add_user(self, user, **kwargs):
"""Add user to server - implemented through subscription groups"""
try:
from vpn.xray_api_v2.client import XrayClient
client = XrayClient(server=self.api_address)
# Users are added through subscription groups in the new architecture
subscriptions = user.xray_subscriptions.filter(active=True)
added_count = 0
logger.info(f"User {user.username} has {subscriptions.count()} active subscriptions")
if subscriptions.count() == 0:
logger.warning(f"User {user.username} has no active Xray subscriptions - cannot add to server")
return False
# Get all inbounds that this user should have access to
inbounds_to_process = []
for subscription in subscriptions:
logger.info(f"Processing subscription group: {subscription.subscription_group.name}")
for inbound in subscription.subscription_group.inbounds.all():
if inbound not in inbounds_to_process:
inbounds_to_process.append(inbound)
logger.info(f"Added inbound {inbound.name} to processing list")
# Get existing inbounds on server
try:
existing_result = client.execute_command('lsi') # List inbounds
existing_inbound_tags = set()
if existing_result and 'inbounds' in existing_result:
existing_inbound_tags = {ib.get('tag') for ib in existing_result['inbounds'] if ib.get('tag')}
logger.info(f"Existing inbound tags on server: {existing_inbound_tags}")
except Exception as e:
logger.warning(f"Failed to list inbounds: {e}")
existing_inbound_tags = set()
# Process each inbound
for inbound in inbounds_to_process:
logger.info(f"Processing inbound: {inbound.name} (protocol: {inbound.protocol})")
# Check if inbound exists on server
if inbound.name not in existing_inbound_tags:
logger.info(f"Inbound {inbound.name} doesn't exist on server, creating with user")
# Create the inbound with the user directly
if self.deploy_inbound(inbound, users=[user]):
logger.info(f"Successfully created inbound {inbound.name} with user {user.username}")
added_count += 1
existing_inbound_tags.add(inbound.name)
# Mark as deployed on this server
from vpn.models_xray import ServerInbound
ServerInbound.objects.update_or_create(
server=self,
inbound=inbound,
defaults={'active': True}
)
else:
logger.error(f"Failed to create inbound {inbound.name} with user")
continue
else:
# Inbound exists, add user using recreation approach
logger.info(f"Inbound {inbound.name} exists, adding user via recreation")
if self.add_user_to_inbound(user, inbound):
added_count += 1
logger.info(f"Successfully added user {user.username} to existing inbound {inbound.name}")
else:
logger.error(f"Failed to add user {user.username} to existing inbound {inbound.name}")
logger.info(f"Added user {user.username} to {added_count} inbounds on server {self.name}")
return added_count > 0
except Exception as e:
logger.error(f"Failed to add user {user.username} to server {self.name}: {e}")
return False
def get_user(self, user, raw=False):
"""Get user configurations from server"""
try:
configs = self.get_user_configs(user)
if raw:
return {
'configs': configs,
'total_configs': len(configs)
}
return configs
except Exception as e:
logger.error(f"Failed to get user {user.username} from server {self.name}: {e}")
return [] if not raw else {'error': str(e)}
def delete_user(self, user):
"""Remove user from server"""
try:
removed_count = 0
subscriptions = user.xray_subscriptions.filter(active=True)
for subscription in subscriptions:
for inbound in subscription.subscription_group.inbounds.all():
if self.remove_user_from_inbound(user, inbound):
removed_count += 1
logger.info(f"Removed user {user.username} from {removed_count} inbounds on server {self.name}")
return removed_count > 0
except Exception as e:
logger.error(f"Failed to remove user {user.username} from server {self.name}: {e}")
return False
def __str__(self):
return f"Xray Server v2: {self.name}"
class ServerInboundInline(admin.TabularInline):
"""Inline for managing inbound templates on a server"""
from vpn.models_xray import ServerInbound
model = ServerInbound
extra = 0
fields = ('inbound', 'active')
verbose_name = "Inbound Template"
verbose_name_plural = "Inbound Templates"
class XrayServerV2Admin(admin.ModelAdmin):
list_display = ['name', 'client_hostname', 'api_address', 'api_enabled', 'stats_enabled', 'registration_date']
list_filter = ['api_enabled', 'stats_enabled', 'registration_date']
search_fields = ['name', 'client_hostname', 'comment']
readonly_fields = ['server_type', 'registration_date']
inlines = [ServerInboundInline]
fieldsets = [
('Basic Information', {
'fields': ('name', 'comment', 'server_type')
}),
('Connection Settings', {
'fields': ('client_hostname', 'api_address')
}),
('API Settings', {
'fields': ('api_enabled', 'stats_enabled')
}),
('Timestamps', {
'fields': ('registration_date',),
'classes': ('collapse',)
})
]
actions = ['sync_users', 'sync_inbounds', 'get_status']
def sync_users(self, request, queryset):
for server in queryset:
server.sync_users()
self.message_user(request, f"Scheduled user sync for {queryset.count()} servers")
sync_users.short_description = "Sync users for selected servers"
def sync_inbounds(self, request, queryset):
for server in queryset:
server.sync_inbounds()
self.message_user(request, f"Scheduled inbound sync for {queryset.count()} servers")
sync_inbounds.short_description = "Sync inbounds for selected servers"
def get_status(self, request, queryset):
statuses = []
for server in queryset:
status = server.get_server_status()
statuses.append(f"{server.name}: {status.get('accessible', 'Unknown')}")
self.message_user(request, f"Server statuses: {', '.join(statuses)}")
get_status.short_description = "Check status of selected servers"

346
vpn/signals.py Normal file
View File

@@ -0,0 +1,346 @@
"""
Django signals for automatic Xray server synchronization
"""
import logging
from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver
from django.db import transaction
from celery import group
from .models_xray import (
Inbound,
SubscriptionGroup,
UserSubscription,
Certificate,
ServerInbound
)
from .server_plugins.xray_v2 import XrayServerV2
logger = logging.getLogger(__name__)
def get_active_xray_servers():
"""Get all active Xray servers"""
from .server_plugins import Server
return [
server.get_real_instance()
for server in Server.objects.all()
if hasattr(server.get_real_instance(), 'api_enabled') and
server.get_real_instance().api_enabled
]
def schedule_inbound_sync_for_servers(inbound, servers=None):
"""Schedule inbound deployment on servers"""
if servers is None:
servers = get_active_xray_servers()
if not servers:
logger.warning("No active Xray servers found for inbound sync")
return
logger.info(f"Scheduling inbound {inbound.name} deployment on {len(servers)} servers")
# Schedule deployment tasks
from .tasks import deploy_inbound_on_server
tasks = []
for server in servers:
task = deploy_inbound_on_server.s(server.id, inbound.id)
tasks.append(task)
# Execute all deployments in parallel
job = group(tasks)
result = job.apply_async()
logger.info(f"Scheduled inbound deployment tasks: {result}")
return result
def schedule_user_sync_for_servers(servers=None):
"""Schedule user sync on servers after inbound changes"""
if servers is None:
servers = get_active_xray_servers()
if not servers:
logger.warning("No active Xray servers found for user sync")
return
logger.info(f"Scheduling user sync on {len(servers)} servers")
# Schedule user sync tasks
from .tasks import sync_server_users
tasks = []
for server in servers:
task = sync_server_users.s(server.id)
tasks.append(task)
# Execute all user syncs in parallel with delay to allow inbound sync to complete
job = group(tasks)
result = job.apply_async(countdown=10) # 10 second delay
logger.info(f"Scheduled user sync tasks: {result}")
return result
@receiver(post_save, sender=Inbound)
def inbound_created_or_updated(sender, instance, created, **kwargs):
"""
When an inbound is created or updated, deploy it to all servers
where subscription groups contain this inbound
"""
if created:
logger.info(f"New inbound {instance.name} created, will deploy when added to groups")
else:
logger.info(f"Inbound {instance.name} updated, scheduling redeployment")
# Get all subscription groups that contain this inbound
groups = instance.subscriptiongroup_set.filter(is_active=True)
if groups.exists():
# Get all servers that should have this inbound
servers = get_active_xray_servers()
# Schedule redeployment
transaction.on_commit(lambda: schedule_inbound_sync_for_servers(instance, servers))
# Schedule user sync after inbound update
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
@receiver(post_delete, sender=Inbound)
def inbound_deleted(sender, instance, **kwargs):
"""
When an inbound is deleted, remove it from all servers
"""
logger.info(f"Inbound {instance.name} deleted, scheduling removal from servers")
# Schedule removal from all servers
from .tasks import remove_inbound_from_server
servers = get_active_xray_servers()
tasks = []
for server in servers:
task = remove_inbound_from_server.s(server.id, instance.name)
tasks.append(task)
if tasks:
job = group(tasks)
transaction.on_commit(lambda: job.apply_async())
@receiver(m2m_changed, sender=SubscriptionGroup.inbounds.through)
def subscription_group_inbounds_changed(sender, instance, action, pk_set, **kwargs):
"""
When inbounds are added/removed from subscription groups,
automatically deploy/remove them on servers
"""
if action in ['post_add', 'post_remove']:
logger.info(f"Subscription group {instance.name} inbounds changed: {action}")
if action == 'post_add' and pk_set:
# Inbounds were added to the group - deploy them
inbounds = Inbound.objects.filter(pk__in=pk_set)
servers = get_active_xray_servers()
for inbound in inbounds:
logger.info(f"Deploying inbound {inbound.name} (added to group {instance.name})")
transaction.on_commit(
lambda inb=inbound: schedule_inbound_sync_for_servers(inb, servers)
)
# Schedule user sync after all inbounds are deployed
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
elif action == 'post_remove' and pk_set:
# Inbounds were removed from the group
inbounds = Inbound.objects.filter(pk__in=pk_set)
for inbound in inbounds:
# Check if inbound is still used by other active groups
other_groups = inbound.subscriptiongroup_set.filter(is_active=True).exclude(id=instance.id)
if not other_groups.exists():
# Inbound is not used by any other group - remove from servers
logger.info(f"Removing inbound {inbound.name} from servers (no longer in any group)")
from .tasks import remove_inbound_from_server
servers = get_active_xray_servers()
tasks = []
for server in servers:
task = remove_inbound_from_server.s(server.id, inbound.name)
tasks.append(task)
if tasks:
job = group(tasks)
transaction.on_commit(lambda: job.apply_async())
@receiver(post_save, sender=ServerInbound)
def server_inbound_created_or_updated(sender, instance, created, **kwargs):
"""
When ServerInbound is created or updated, immediately deploy the inbound
template to the server (not wait for subscription group changes)
"""
logger.info(f"ServerInbound {instance.inbound.name} {'created' if created else 'updated'} for server {instance.server.name}")
if instance.active:
# Deploy inbound immediately
servers = [instance.server.get_real_instance()]
transaction.on_commit(
lambda: schedule_inbound_sync_for_servers(instance.inbound, servers)
)
# Schedule user sync after inbound deployment
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
else:
# Remove inbound from server if deactivated
logger.info(f"Removing inbound {instance.inbound.name} from server {instance.server.name} (deactivated)")
from .tasks import remove_inbound_from_server
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
transaction.on_commit(lambda: task.apply_async())
@receiver(post_delete, sender=ServerInbound)
def server_inbound_deleted(sender, instance, **kwargs):
"""
When ServerInbound is deleted, remove the inbound from the server
"""
logger.info(f"ServerInbound {instance.inbound.name} deleted from server {instance.server.name}")
from .tasks import remove_inbound_from_server
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
transaction.on_commit(lambda: task.apply_async())
@receiver(post_save, sender=UserSubscription)
def user_subscription_created_or_updated(sender, instance, created, **kwargs):
"""
When user subscription is created or updated, sync the user to servers
"""
if created:
logger.info(f"New subscription created for user {instance.user.username} in group {instance.subscription_group.name}")
else:
logger.info(f"Subscription updated for user {instance.user.username} in group {instance.subscription_group.name}")
if instance.active:
# Schedule user sync on all servers
servers = get_active_xray_servers()
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
@receiver(post_delete, sender=UserSubscription)
def user_subscription_deleted(sender, instance, **kwargs):
"""
When user subscription is deleted, remove user from servers if no other subscriptions
"""
logger.info(f"Subscription deleted for user {instance.user.username} in group {instance.subscription_group.name}")
# Check if user has other active subscriptions
other_subscriptions = UserSubscription.objects.filter(
user=instance.user,
active=True
).exclude(id=instance.id).exists()
if not other_subscriptions:
# User has no more subscriptions - remove from all servers
logger.info(f"User {instance.user.username} has no more subscriptions, removing from servers")
from .tasks import remove_user_from_server
servers = get_active_xray_servers()
tasks = []
for server in servers:
task = remove_user_from_server.s(server.id, instance.user.id)
tasks.append(task)
if tasks:
job = group(tasks)
transaction.on_commit(lambda: job.apply_async())
@receiver(post_save, sender=Certificate)
def certificate_updated(sender, instance, created, **kwargs):
"""
When certificate is updated, redeploy all inbounds that use it
"""
if not created and instance.certificate_pem: # Only on updates when cert is available
logger.info(f"Certificate {instance.domain} updated, redeploying dependent inbounds")
# Find all inbounds that use this certificate
inbounds = Inbound.objects.filter(certificate=instance)
servers = get_active_xray_servers()
for inbound in inbounds:
transaction.on_commit(
lambda inb=inbound: schedule_inbound_sync_for_servers(inb, servers)
)
@receiver(post_save, sender=SubscriptionGroup)
def subscription_group_updated(sender, instance, created, **kwargs):
"""
When subscription group is created/updated, sync its state
"""
if created:
logger.info(f"New subscription group {instance.name} created")
else:
logger.info(f"Subscription group {instance.name} updated")
if not instance.is_active:
# Group was deactivated - remove its inbounds from servers if not used elsewhere
logger.info(f"Subscription group {instance.name} deactivated, checking inbounds")
for inbound in instance.inbounds.all():
# Check if inbound is used by other active groups
other_groups = inbound.subscriptiongroup_set.filter(is_active=True).exclude(id=instance.id)
if not other_groups.exists():
# Remove inbound from servers
logger.info(f"Removing inbound {inbound.name} from servers (group deactivated)")
from .tasks import remove_inbound_from_server
servers = get_active_xray_servers()
tasks = []
for server in servers:
task = remove_inbound_from_server.s(server.id, inbound.name)
tasks.append(task)
if tasks:
job = group(tasks)
transaction.on_commit(lambda: job.apply_async())
@receiver(post_save, sender=ServerInbound)
def server_inbound_created_or_updated(sender, instance, created, **kwargs):
"""
When ServerInbound is created or updated, immediately deploy the inbound
template to the server (not wait for subscription group changes)
"""
logger.info(f"ServerInbound {instance.inbound.name} {'created' if created else 'updated'} for server {instance.server.name}")
if instance.active:
# Deploy inbound immediately
servers = [instance.server.get_real_instance()]
transaction.on_commit(
lambda: schedule_inbound_sync_for_servers(instance.inbound, servers)
)
# Schedule user sync after inbound deployment
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
else:
# Remove inbound from server if deactivated
logger.info(f"Removing inbound {instance.inbound.name} from server {instance.server.name} (deactivated)")
from .tasks import remove_inbound_from_server
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
transaction.on_commit(lambda: task.apply_async())
@receiver(post_delete, sender=ServerInbound)
def server_inbound_deleted(sender, instance, **kwargs):
"""
When ServerInbound is deleted, remove the inbound from the server
"""
logger.info(f"ServerInbound {instance.inbound.name} deleted from server {instance.server.name}")
from .tasks import remove_inbound_from_server
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
transaction.on_commit(lambda: task.apply_async())

1110
vpn/tasks.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
{% extends "admin/base.html" %}
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block branding %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Django administration') }}</a></h1>
{% endblock %}
{% block footer %}
<div id="footer" style="margin-top: 20px; padding: 10px; border-top: 1px solid #ccc; font-size: 12px; color: #666;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>OutFleet VPN Manager</strong>
</div>
<div style="text-align: right;">
{% if VERSION_INFO %}
<div>
<strong>Version:</strong>
<code>{{ VERSION_INFO.git_commit_short }}</code>
{% if VERSION_INFO.is_development %}
<span style="color: #e74c3c;">(Development)</span>
{% endif %}
</div>
{% if not VERSION_INFO.is_development %}
<div style="margin-top: 2px;">
<strong>Built:</strong> {{ VERSION_INFO.build_date }}
</div>
<div style="margin-top: 2px;">
<strong>Commit:</strong>
<code style="font-size: 10px;">{{ VERSION_INFO.git_commit }}</code>
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,499 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block content %}
<div class="content-main">
<h1>{{ title }}</h1>
<div class="alert alert-info" style="margin: 10px 0; padding: 10px; border-radius: 4px; background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;">
<strong>Note:</strong> This operation only affects the database and works even if servers are unreachable.
Server connectivity is not required for moving links between servers.
</div>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}" style="margin: 10px 0; padding: 10px; border-radius: 4px;
{% if message.tags == 'error' %}background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;
{% elif message.tags == 'success' %}background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724;
{% elif message.tags == 'warning' %}background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404;
{% elif message.tags == 'info' %}background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;
{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
<form method="post" id="move-clients-form">
{% csrf_token %}
<div class="form-row" style="margin-bottom: 20px;">
<div style="display: flex; gap: 20px;">
<div style="flex: 1;">
<label for="source_server"><strong>Source Server:</strong></label>
<select id="source_server" name="source_server" style="width: 100%; padding: 5px;" onchange="updateLinksList()">
<option value="">-- Select Source Server --</option>
{% for server in servers %}
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
{% endfor %}
</select>
</div>
<div style="flex: 1;">
<label for="target_server"><strong>Target Server:</strong></label>
<select id="target_server" name="target_server" style="width: 100%; padding: 5px;" onchange="updateSubmitButton()">
<option value="">-- Select Target Server --</option>
{% for server in all_servers %}
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div id="links-list" style="margin-bottom: 20px;">
<h3>Select Client Links to Move:</h3>
<div id="links-container">
<p style="color: #666;">Please select a source server first.</p>
</div>
</div>
<div class="form-row" style="margin-bottom: 20px;">
<h3>Comment Transformation (Optional)</h3>
<div style="margin-bottom: 10px;">
<label for="comment_regex"><strong>Regular Expression Pattern:</strong></label>
<input type="text" id="comment_regex" name="comment_regex"
style="width: 100%; padding: 8px; font-family: monospace;"
placeholder="Example: ^(.*)$ -> [OLD_SERVER] $1"
title="Use regex pattern -> replacement format">
</div>
<div class="regex-help" style="background-color: #f8f9fa; padding: 10px; border-radius: 5px; border-left: 4px solid #007cba;">
<h4 style="margin: 0 0 10px 0; color: #007cba; font-size: 14px;">Regular Expression Help & Examples:</h4>
<div style="font-size: 13px;">
<p style="margin: 0 0 8px 0;"><strong>Format:</strong> <code>pattern -> replacement</code></p>
<div style="margin-bottom: 10px;">
<h5 style="margin: 0 0 5px 0; font-size: 13px;">Common Examples:</h5>
<ul style="margin: 0; padding-left: 15px; line-height: 1.4;">
<li><code>^(.*)$ -> [FROM RU] $1</code> <small>- Add prefix to all comments</small></li>
<li><code>^(.*)$ -> $1 (moved)</code> <small>- Add suffix to all comments</small></li>
<li><code>^$ -> Default Device</code> <small>- Replace empty comments</small></li>
<li><code>phone -> mobile</code> <small>- Replace specific word</small></li>
</ul>
</div>
<div style="margin-bottom: 10px;">
<h5 style="margin: 0 0 5px 0; font-size: 13px;">Advanced Examples:</h5>
<ul style="margin: 0; padding-left: 15px; line-height: 1.4;">
<li><code>^(.+) - (.+)$ -> $2: $1</code> <small>- Swap parts separated by " - "</small></li>
<li><code>(\d{4})-(\d{2})-(\d{2}) -> $3/$2/$1</code> <small>- Change date format</small></li>
<li><code>^(.{1,20}).*$ -> $1...</code> <small>- Truncate long comments</small></li>
</ul>
</div>
<div style="padding: 8px; background-color: #fff3cd; border-radius: 3px; margin: 0;">
<strong style="font-size: 12px;">Tips:</strong>
<ul style="margin: 3px 0 0 0; padding-left: 15px; font-size: 12px; line-height: 1.3;">
<li>Use <code>$1, $2, $3...</code> for captured groups (auto-converted to Python)</li>
<li>Use <code>^</code> for start, <code>$</code> for end of string</li>
<li>Preview shows result; leave empty to keep original comments</li>
</ul>
</div>
</div>
</div>
</div>
<div class="submit-row">
<input type="submit" value="Move Selected Links" class="default" id="submit-btn" disabled>
<a href="{% url 'admin:vpn_server_changelist' %}" class="button cancel">Cancel</a>
</div>
</form>
</div>
<script>
// Client links data for each server
var linksByServer = {
{% for server, links in links_by_server.items %}
"{{ server.id }}": [
{% for link in links %}
{
"id": {{ link.id }},
"link": "{{ link.link|escapejs }}",
"comment": "{{ link.comment|escapejs }}",
"username": "{{ link.acl.user.username|escapejs }}",
"user_comment": "{{ link.acl.user.comment|escapejs }}",
"created_at": "{{ link.acl.created_at|date:'Y-m-d H:i' }}",
"full_url": "{{ EXTERNAL_ADDRESS }}/ss/{{ link.link }}#{{ link.acl.server.name|escapejs }}"
},
{% endfor %}
],
{% endfor %}
};
function updateLinksList() {
var sourceServerId = document.getElementById('source_server').value;
var linksContainer = document.getElementById('links-container');
var submitBtn = document.getElementById('submit-btn');
if (!sourceServerId) {
linksContainer.innerHTML = '<p style="color: #666;">Please select a source server first.</p>';
submitBtn.disabled = true;
return;
}
var links = linksByServer[sourceServerId] || [];
if (links.length === 0) {
linksContainer.innerHTML = '<p style="color: #666;">No client links found for this server.</p>';
submitBtn.disabled = true;
return;
}
// Group links by user for better organization
var linksByUser = {};
links.forEach(function(link) {
if (!linksByUser[link.username]) {
linksByUser[link.username] = {
user_comment: link.user_comment,
created_at: link.created_at,
links: []
};
}
linksByUser[link.username].links.push(link);
});
var html = '<div style="max-height: 500px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">';
html += '<div style="margin-bottom: 10px;">';
html += '<button type="button" onclick="toggleAllLinks()" style="padding: 5px 10px; margin-right: 10px;">Select All</button>';
html += '<button type="button" onclick="toggleAllLinks(false)" style="padding: 5px 10px;">Deselect All</button>';
html += '</div>';
html += '<table style="width: 100%; border-collapse: collapse;">';
html += '<thead><tr style="background-color: #f5f5f5;">';
html += '<th style="padding: 8px; border: 1px solid #ddd; width: 50px;">Select</th>';
html += '<th style="padding: 8px; border: 1px solid #ddd;">Username</th>';
html += '<th style="padding: 8px; border: 1px solid #ddd;">Link Comment</th>';
html += '<th style="padding: 8px; border: 1px solid #ddd;">Link ID</th>';
html += '<th style="padding: 8px; border: 1px solid #ddd;">User Comment</th>';
html += '<th style="padding: 8px; border: 1px solid #ddd;">Created</th>';
html += '</tr></thead><tbody>';
// Sort users alphabetically
var sortedUsers = Object.keys(linksByUser).sort();
sortedUsers.forEach(function(username) {
var userData = linksByUser[username];
var userLinks = userData.links;
// Add user row header if user has multiple links
if (userLinks.length > 1) {
html += '<tr style="background-color: #f9f9f9;">';
html += '<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">';
html += '<input type="checkbox" class="user-checkbox" data-username="' + username + '" onchange="toggleUserLinks(\'' + username + '\')">';
html += '</td>';
html += '<td colspan="5" style="padding: 8px; border: 1px solid #ddd;"><strong>' + username + '</strong> (' + userLinks.length + ' links)</td>';
html += '</tr>';
}
// Add individual link rows
userLinks.forEach(function(link) {
html += '<tr class="link-row" data-username="' + username + '">';
html += '<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">';
html += '<input type="checkbox" name="selected_links" value="' + link.id + '" class="link-checkbox" data-username="' + username + '" onchange="updateSubmitButton(); updateUserCheckbox(\'' + username + '\')">';
html += '</td>';
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + (userLinks.length === 1 ? '<strong>' + username + '</strong>' : '↳') + '</td>';
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + (link.comment || '<em>No comment</em>') + '</td>';
html += '<td style="padding: 8px; border: 1px solid #ddd; font-family: monospace; font-size: 12px;">' + link.link + '</td>';
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + (userData.user_comment || '') + '</td>';
html += '<td style="padding: 8px; border: 1px solid #ddd;">' + userData.created_at + '</td>';
html += '</tr>';
});
});
html += '</tbody></table></div>';
linksContainer.innerHTML = html;
updateSubmitButton();
}
function toggleAllLinks(selectAll = true) {
var checkboxes = document.getElementsByName('selected_links');
var userCheckboxes = document.getElementsByClassName('user-checkbox');
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = selectAll;
}
for (var i = 0; i < userCheckboxes.length; i++) {
userCheckboxes[i].checked = selectAll;
}
updateSubmitButton();
}
function toggleUserLinks(username) {
var userCheckbox = document.querySelector('.user-checkbox[data-username="' + username + '"]');
var userLinkCheckboxes = document.querySelectorAll('.link-checkbox[data-username="' + username + '"]');
for (var i = 0; i < userLinkCheckboxes.length; i++) {
userLinkCheckboxes[i].checked = userCheckbox.checked;
}
updateSubmitButton();
}
function updateUserCheckbox(username) {
var userLinkCheckboxes = document.querySelectorAll('.link-checkbox[data-username="' + username + '"]');
var userCheckbox = document.querySelector('.user-checkbox[data-username="' + username + '"]');
if (!userCheckbox) return; // No user checkbox for single-link users
var allChecked = true;
var noneChecked = true;
for (var i = 0; i < userLinkCheckboxes.length; i++) {
if (userLinkCheckboxes[i].checked) {
noneChecked = false;
} else {
allChecked = false;
}
}
if (allChecked) {
userCheckbox.checked = true;
userCheckbox.indeterminate = false;
} else if (noneChecked) {
userCheckbox.checked = false;
userCheckbox.indeterminate = false;
} else {
userCheckbox.checked = false;
userCheckbox.indeterminate = true;
}
}
function updateSubmitButton() {
var checkboxes = document.getElementsByName('selected_links');
var sourceServer = document.getElementById('source_server').value;
var targetServer = document.getElementById('target_server').value;
var submitBtn = document.getElementById('submit-btn');
var hasSelected = false;
for (var i = 0; i < checkboxes.length; i++) {
if (checkboxes[i].checked) {
hasSelected = true;
break;
}
}
submitBtn.disabled = !(hasSelected && sourceServer && targetServer && sourceServer !== targetServer);
}
// Form submission confirmation
document.getElementById('move-clients-form').addEventListener('submit', function(e) {
var checkboxes = document.getElementsByName('selected_links');
var selectedCount = 0;
var selectedUsers = new Set();
for (var i = 0; i < checkboxes.length; i++) {
if (checkboxes[i].checked) {
selectedCount++;
selectedUsers.add(checkboxes[i].getAttribute('data-username'));
}
}
var sourceServerName = document.getElementById('source_server').selectedOptions[0].text;
var targetServerName = document.getElementById('target_server').selectedOptions[0].text;
var commentRegex = document.getElementById('comment_regex').value.trim();
var confirmMessage = 'Are you sure you want to move ' + selectedCount + ' link(s) for ' + selectedUsers.size + ' user(s) from "' + sourceServerName + '" to "' + targetServerName + '"?\n\n';
confirmMessage += 'This action will:\n';
confirmMessage += '- Transfer selected links to target server\n';
confirmMessage += '- Create ACLs for users who don\'t have access to target server\n';
confirmMessage += '- Remove empty ACLs from source server\n';
confirmMessage += '- Preserve all link settings and comments\n';
if (commentRegex) {
confirmMessage += '- Apply regex transformation to comments: "' + commentRegex + '"\n';
}
confirmMessage += '\nThis cannot be undone.';
if (!confirm(confirmMessage)) {
e.preventDefault();
}
});
// Add regex preview functionality
document.getElementById('comment_regex').addEventListener('input', function() {
updateRegexPreview();
});
function updateRegexPreview() {
var regexInput = document.getElementById('comment_regex').value.trim();
// Remove any existing preview
var existingPreview = document.getElementById('regex-preview');
if (existingPreview) {
existingPreview.remove();
}
if (!regexInput) return;
// Parse pattern -> replacement format
var parts = regexInput.split(' -> ');
if (parts.length !== 2) {
showRegexError('Invalid format. Use: pattern -> replacement');
return;
}
var pattern = parts[0];
var replacement = parts[1];
try {
var regex = new RegExp(pattern, 'g');
// Test with sample comments from currently visible links
var sampleComments = getSampleComments();
if (sampleComments.length > 0) {
showRegexPreview(sampleComments, regex, replacement);
}
} catch (e) {
showRegexError('Invalid regex pattern: ' + e.message);
}
}
function getSampleComments() {
var comments = [];
var checkboxes = document.getElementsByName('selected_links');
// Collect actual comments from visible links
for (var i = 0; i < checkboxes.length && comments.length < 5; i++) {
var row = checkboxes[i].closest('tr');
if (row) {
var commentCell = row.children[2]; // Link Comment column
if (commentCell) {
var commentText = commentCell.textContent.trim();
if (commentText && commentText !== 'No comment') {
comments.push(commentText);
}
}
}
}
// Add some realistic default samples if no comments found or need more samples
var defaultSamples = ['iPhone 13'];
for (var i = 0; i < defaultSamples.length && comments.length < 5; i++) {
if (comments.indexOf(defaultSamples[i]) === -1) {
comments.push(defaultSamples[i]);
}
}
return comments.slice(0, 5); // Limit to 5 samples
}
function showRegexPreview(samples, regex, replacement) {
var previewHtml = '<div id="regex-preview" style="margin-top: 10px; padding: 10px; background-color: #e8f5e8; border-radius: 3px; border-left: 4px solid #28a745;">';
previewHtml += '<h5 style="margin-top: 0; color: #28a745;">Preview (first 5 samples):</h5>';
previewHtml += '<table style="width: 100%; font-size: 13px;">';
previewHtml += '<tr><th style="text-align: left; padding: 5px;">Original</th><th style="text-align: left; padding: 5px;">→</th><th style="text-align: left; padding: 5px;">Transformed</th></tr>';
samples.forEach(function(comment) {
var original = comment || '(empty)';
var transformed;
try {
// Use string replace with regex and replacement string
transformed = original.replace(regex, replacement);
} catch (e) {
transformed = '(error: ' + e.message + ')';
}
var changed = original !== transformed;
previewHtml += '<tr>';
previewHtml += '<td style="padding: 3px 5px; font-family: monospace;">' + escapeHtml(original) + '</td>';
previewHtml += '<td style="padding: 3px 5px;">→</td>';
previewHtml += '<td style="padding: 3px 5px; font-family: monospace;' + (changed ? ' font-weight: bold; color: #28a745;' : '') + '">' + escapeHtml(transformed) + '</td>';
previewHtml += '</tr>';
});
previewHtml += '</table></div>';
document.querySelector('.regex-help').insertAdjacentHTML('afterend', previewHtml);
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showRegexError(message) {
var errorHtml = '<div id="regex-preview" style="margin-top: 10px; padding: 10px; background-color: #f8d7da; border-radius: 3px; border-left: 4px solid #dc3545;">';
errorHtml += '<h5 style="margin-top: 0; color: #dc3545;">Error:</h5>';
errorHtml += '<p style="margin: 0; color: #721c24;">' + message + '</p>';
errorHtml += '</div>';
document.querySelector('.regex-help').insertAdjacentHTML('afterend', errorHtml);
}
</script>
<style>
.alert {
margin: 10px 0;
padding: 10px;
border-radius: 4px;
}
.form-row {
margin-bottom: 20px;
}
.submit-row {
padding: 12px 14px;
margin: 0 0 20px;
background: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
}
.submit-row input[type="submit"] {
background: #417690;
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
.submit-row input[type="submit"]:disabled {
background: #ccc;
cursor: not-allowed;
}
.submit-row .cancel {
background: #6c757d;
color: white;
padding: 10px 15px;
border-radius: 4px;
text-decoration: none;
}
.submit-row .cancel:hover {
background: #5a6268;
color: white;
}
.link-row:hover {
background-color: #f8f9fa;
}
input[type="checkbox"]:indeterminate {
opacity: 0.5;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,10 @@
{% extends "admin/change_form.html" %}
{% load static %}
{% block after_field_sets %}
<div>
<h2>Create ACLs</h2>
{{ adminform.form.servers }}
</div>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static admin_list %}
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block extrahead %}
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/vpn_admin.css' %}">
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url 'admin:vpn_server_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {{ title }}
</div>
{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
<div class="module aligned">
<div class="form-row">
<div class="form-row field-box">
<label>Source Server:</label>
<div class="readonly"><strong>{{ source_server.name }}</strong> ({{ source_server.server_type }})</div>
</div>
<div class="form-row field-box">
<label>Statistics:</label>
<div class="readonly">
<strong>{{ links_count }}</strong> client link(s) for <strong>{{ users_count }}</strong> user(s)
</div>
</div>
</div>
</div>
{% if links_count == 0 %}
<div class="messagelist">
<div class="warning">No client links found on this server.</div>
</div>
<div class="submit-row">
<a href="{% url 'admin:vpn_server_changelist' %}" class="default">« Back to server list</a>
</div>
{% else %}
<form method="post" id="move-form">
{% csrf_token %}
<fieldset class="module aligned">
<h2>Move Options</h2>
<div class="form-row">
<div>
<label for="target_server" class="required">Target Server:</label>
<select id="target_server" name="target_server" class="vLargeTextField" required>
<option value="">-- Select target server --</option>
{% for server in all_servers %}
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row">
<div>
<label for="add_prefix">Add prefix to comments (optional):</label>
<input type="text" id="add_prefix" name="add_prefix" class="vTextField"
placeholder="e.g. [FROM {{ source_server.name }}]">
<p class="help">This prefix will be added to all client link comments</p>
</div>
</div>
</fieldset>
<div class="submit-row">
<input type="submit" value="Move All Client Links" class="default"
onclick="return confirm('Are you sure you want to move ALL {{ links_count }} client link(s) from {{ source_server.name }} to the selected target server?\\n\\nThis action cannot be undone.');">
<a href="{% url 'admin:vpn_server_changelist' %}" class="button cancel">Cancel</a>
</div>
</form>
<div class="help">
<h3>What will happen:</h3>
<ul>
<li>All {{ links_count }} client links will be moved from <strong>{{ source_server.name }}</strong> to the target server</li>
<li>Users who don't have access to the target server will get new ACL entries created automatically</li>
<li>Empty ACL entries on the source server will be cleaned up</li>
<li>All link settings and comments will be preserved (with optional prefix)</li>
<li>This operation is database-only and doesn't require server connectivity</li>
</ul>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "admin/change_list.html" %}
{% block content_title %}
<h1 class="h4 m-0 pr-3 mr-3 border-right">ACL Links & User Statistics</h1>
{% endblock %}
{% block content %}
<!-- Compact Statistics Panel -->
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 0 0 16px 0;">
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: center;">
<!-- Key Metrics -->
<div style="background: #3b82f6; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
<div style="font-size: 18px; font-weight: bold;">{{ total_links|default:0 }}</div>
<div style="font-size: 11px; opacity: 0.9;">Links</div>
</div>
<div style="background: #10b981; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
<div style="font-size: 18px; font-weight: bold;">{{ total_uses|default:0|floatformat:0 }}</div>
<div style="font-size: 11px; opacity: 0.9;">Total Uses</div>
</div>
<div style="background: #8b5cf6; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
<div style="font-size: 18px; font-weight: bold;">{{ recent_uses|default:0|floatformat:0 }}</div>
<div style="font-size: 11px; opacity: 0.9;">Recent (30d)</div>
</div>
<!-- Status indicators -->
<div style="margin-left: 20px; display: flex; gap: 12px; flex-wrap: wrap;">
{% if never_accessed > 0 %}
<span style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">❌ {{ never_accessed }} never used</span>
{% endif %}
{% if old_links > 0 %}
<span style="background: #f97316; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">⚠️ {{ old_links }} old</span>
{% endif %}
<span style="background: #059669; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">✅ {{ active_links|default:0 }} active</span>
</div>
<!-- Quick Actions -->
<div style="margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap;">
<a href="?last_access_status=never" style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">🔍 Never Used</a>
<a href="?last_access_status=old" style="background: #f97316; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">⏰ Old</a>
<a href="?last_access_status=week" style="background: #10b981; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">📅 Recent</a>
<a href="?" style="background: #6b7280; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">🔄 Clear</a>
</div>
</div>
</div>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,176 @@
{% extends "admin/change_form.html" %}
{% load i18n admin_urls static %}
{% block extrahead %}
{{ block.super }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add JSON Import tab
const tabList = document.getElementById('jazzy-tabs');
const tabContent = document.querySelector('.tab-content');
if (tabList && tabContent) {
// Add new tab
const newTab = document.createElement('li');
newTab.className = 'nav-item';
newTab.innerHTML = `
<a class="nav-link" data-toggle="pill" role="tab" aria-controls="json-import-tab" aria-selected="false" href="#json-import-tab">
📥 JSON Import
</a>
`;
tabList.insertBefore(newTab, tabList.firstChild);
// Add tab content
const newTabContent = document.createElement('div');
newTabContent.id = 'json-import-tab';
newTabContent.className = 'tab-pane fade';
newTabContent.setAttribute('role', 'tabpanel');
newTabContent.setAttribute('aria-labelledby', 'json-import-tab');
newTabContent.innerHTML = `
<div class="card">
<div class="p-5 card-body">
<h4 style="color: #007cba; margin-bottom: 1rem;">📥 Quick Import from JSON</h4>
<p style="font-size: 0.875rem; color: #6c757d; margin-bottom: 1rem;">
Paste the JSON configuration from your Outline server setup to automatically fill the fields:
</p>
<div class="form-group">
<label for="import-json-config">JSON Configuration:</label>
<textarea id="import-json-config" class="form-control" rows="8"
placeholder='{
"apiUrl": "https://your-server:port/path",
"certSha256": "your-certificate-hash",
"serverName": "My Outline Server",
"clientHostname": "your-server.com",
"clientPort": 1257,
"comment": "Server description"
}' style="font-family: 'Courier New', monospace; font-size: 0.875rem;"></textarea>
</div>
<button type="button" id="import-json-btn" class="btn btn-primary">
Import Configuration
</button>
<div style="margin-top: 1rem; padding: 0.75rem; background: #e7f3ff; border-left: 4px solid #007cba; border-radius: 4px;">
<strong>Required fields:</strong>
<ul style="margin: 0.5rem 0; padding-left: 20px;">
<li><code>apiUrl</code> - Server management URL</li>
<li><code>certSha256</code> - Certificate fingerprint</li>
</ul>
<strong>Optional fields:</strong>
<ul style="margin: 0.5rem 0; padding-left: 20px;">
<li><code>serverName</code> - Display name</li>
<li><code>clientHostname</code> - Client hostname</li>
<li><code>clientPort</code> - Client port</li>
<li><code>comment</code> - Description</li>
</ul>
</div>
</div>
</div>
`;
tabContent.insertBefore(newTabContent, tabContent.firstChild);
// Make first tab (JSON Import) active
document.querySelector('#jazzy-tabs .nav-link').classList.remove('active');
newTab.querySelector('.nav-link').classList.add('active');
document.querySelector('.tab-pane.active').classList.remove('active', 'show');
newTabContent.classList.add('active', 'show');
}
// Import functionality
function tryAutoFillFromJson() {
const importJsonTextarea = document.getElementById('import-json-config');
try {
const jsonText = importJsonTextarea.value.trim();
if (!jsonText) {
alert('Please enter JSON configuration');
return;
}
const config = JSON.parse(jsonText);
// Validate required fields
if (!config.apiUrl || !config.certSha256) {
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
return;
}
// Parse apiUrl to extract components
const url = new URL(config.apiUrl);
// Fill form fields
const adminUrlField = document.getElementById('id_admin_url');
const adminCertField = document.getElementById('id_admin_access_cert');
const clientHostnameField = document.getElementById('id_client_hostname');
const clientPortField = document.getElementById('id_client_port');
const nameField = document.getElementById('id_name');
const commentField = document.getElementById('id_comment');
if (adminUrlField) adminUrlField.value = config.apiUrl;
if (adminCertField) adminCertField.value = config.certSha256;
// Use provided hostname or extract from URL
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
if (clientHostnameField) clientHostnameField.value = hostname;
// Use provided port or extract from various sources
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
if (clientPortField) clientPortField.value = clientPort;
// Generate server name if not provided and field is empty
if (nameField && !nameField.value) {
const serverName = config.serverName || config.name || `Outline-${hostname}`;
nameField.value = serverName;
}
// Fill comment if provided and field exists
if (commentField && config.comment) {
commentField.value = config.comment;
}
// Clear the JSON input
importJsonTextarea.value = '';
// Show success message
alert('✅ Configuration imported successfully! Review the fields and save.');
// Switch to Server Configuration tab
const serverConfigTab = document.querySelector('a[href="#server-configuration-tab"]');
if (serverConfigTab) {
serverConfigTab.click();
}
// Focus on name field
if (nameField) {
setTimeout(() => {
nameField.focus();
nameField.select();
}, 300);
}
} catch (error) {
alert(`Invalid JSON format: ${error.message}`);
}
}
// Wait a bit for DOM to be ready, then add event listeners
setTimeout(() => {
const importBtn = document.getElementById('import-json-btn');
const importTextarea = document.getElementById('import-json-config');
if (importBtn) {
importBtn.addEventListener('click', tryAutoFillFromJson);
}
if (importTextarea) {
importTextarea.addEventListener('paste', function(e) {
setTimeout(() => {
tryAutoFillFromJson();
}, 100);
});
}
}, 500);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "admin/change_form.html" %}
{% load static %}
{% block content_title %}
<h1 class="h4 m-0 pr-3 mr-3 border-right">
{% if original %}
🔵 Outline Server: {{ original.name }}
{% else %}
🔵 Add Outline Server
{% endif %}
</h1>
{% endblock %}
{% block admin_change_form_document_ready %}
{{ block.super }}
<script>
// All JavaScript functionality is now handled by generate_link.js
</script>
{% endblock %}
{% block field_sets %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "admin/change_list.html" %}
{% load admin_list admin_urls %}
{% block content_title %}
<h1>{{ cl.opts.verbose_name_plural|capfirst }}</h1>
{% endblock %}
{% comment %}
This template overrides the default changelist to provide a cleaner interface
without any bulk operations blocks that might be added by external packages
{% endcomment %}

View File

@@ -0,0 +1,20 @@
{% extends "admin/change_form.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
👥 User Subscriptions
</a>
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "admin/change_list.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if current_tab == 'subscription_groups' %}#417690{% else %}transparent{% endif %}; color: {% if current_tab == 'subscription_groups' %}#417690{% else %}#666{% endif %};">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if current_tab == 'user_subscriptions' %}#417690{% else %}transparent{% endif %}; color: {% if current_tab == 'user_subscriptions' %}#417690{% else %}#666{% endif %};">
👥 User Subscriptions
</a>
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% extends "admin/change_list.html" %}
{% block content_title %}
<h1 class="h4 m-0 pr-3 mr-3 border-right">Task Execution Logs</h1>
{% endblock %}

View File

@@ -0,0 +1,219 @@
{% extends "admin/change_form.html" %}
{% load static %}
{% block content_title %}
<h1 class="h4 m-0 pr-3 mr-3 border-right">
{% if original %}
👤 User: {{ original.username }}
{% else %}
👤 Add User
{% endif %}
</h1>
{% endblock %}
{% block extrahead %}
{{ block.super }}
<style>
.user-management-section {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1rem;
margin: 0.5rem 0;
}
.user-management-section h4 {
margin: 0 0 0.75rem 0;
color: #495057;
font-size: 1rem;
font-weight: 600;
}
.server-section {
background: #ffffff;
border: 1px solid #e9ecef;
border-radius: 0.25rem;
padding: 1rem;
margin-bottom: 1rem;
}
.link-item {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.25rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-sm-custom {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.2rem;
margin: 0 0.1rem;
}
.readonly .user-management-section {
border: none;
background: transparent;
padding: 0;
}
</style>
{% endblock %}
{% block admin_change_form_document_ready %}
{{ block.super }}
{% if original %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const userId = {{ original.id }};
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
// Show success/error messages in Django admin style
function showMessage(message, type = 'success') {
const messageClass = type === 'error' ? 'error' : 'success';
const messageHtml = `
<div class="alert alert-${messageClass} alert-dismissible" style="margin: 1rem 0;">
${message}
<button type="button" class="close" aria-label="Close" onclick="this.parentElement.remove()">
<span aria-hidden="true">&times;</span>
</button>
</div>
`;
const target = document.querySelector('.card-body') || document.querySelector('.content');
if (target) {
target.insertAdjacentHTML('afterbegin', messageHtml);
setTimeout(() => {
const alert = target.querySelector('.alert');
if (alert) alert.remove();
}, 5000);
}
}
// Add new link functionality
document.querySelectorAll('.add-link-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const serverId = this.dataset.serverId;
const serverName = this.dataset.serverName;
const comment = prompt(`Add comment for new link on ${serverName} (optional):`, '');
if (comment === null) return;
const originalText = this.textContent;
this.textContent = '⏳ Adding...';
this.disabled = true;
try {
const response = await fetch(`/admin/vpn/user/${userId}/add-link/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken
},
body: `server_id=${serverId}&comment=${encodeURIComponent(comment)}`
});
const data = await response.json();
if (data.success) {
showMessage(`✅ New link created successfully: ${data.link}`);
setTimeout(() => window.location.reload(), 1000);
} else {
showMessage(`❌ Error: ${data.error}`, 'error');
}
} catch (error) {
showMessage(`❌ Network error: ${error.message}`, 'error');
} finally {
this.textContent = originalText;
this.disabled = false;
}
});
});
// Delete link functionality
document.querySelectorAll('.delete-link-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const linkId = this.dataset.linkId;
const linkName = this.dataset.linkName;
if (!confirm(`Are you sure you want to delete link ${linkName}?`)) {
return;
}
const originalText = this.textContent;
this.textContent = '⏳ Deleting...';
this.disabled = true;
try {
const response = await fetch(`/admin/vpn/user/${userId}/delete-link/${linkId}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showMessage(`✅ Link ${linkName} deleted successfully`);
this.closest('.link-item')?.remove();
} else {
showMessage(`❌ Error: ${data.error}`, 'error');
}
} catch (error) {
showMessage(`❌ Network error: ${error.message}`, 'error');
} finally {
this.textContent = originalText;
this.disabled = false;
}
});
});
// Add server access functionality
document.querySelectorAll('.add-server-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const serverId = this.dataset.serverId;
const serverName = this.dataset.serverName;
if (!confirm(`Add access to server ${serverName}?`)) {
return;
}
const originalText = this.textContent;
this.textContent = '⏳ Adding...';
this.disabled = true;
try {
const response = await fetch(`/admin/vpn/user/${userId}/add-server-access/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken
},
body: `server_id=${serverId}`
});
const data = await response.json();
if (data.success) {
showMessage(`✅ Access to ${serverName} added successfully`);
setTimeout(() => window.location.reload(), 1000);
} else {
showMessage(`❌ Error: ${data.error}`, 'error');
}
} catch (error) {
showMessage(`❌ Network error: ${error.message}`, 'error');
} finally {
this.textContent = originalText;
this.disabled = false;
}
});
});
});
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "admin/change_form.html" %}
{% block content %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
👥 User Subscriptions
</a>
</div>
</div>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "admin/change_list.html" %}
{% block content %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
👥 User Subscriptions
</a>
</div>
</div>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,765 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VPN Access Portal - {{ user.username }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #0c0c0c 0%, #1a1a1a 100%);
color: #e0e0e0;
min-height: 100vh;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 30px;
margin-bottom: 30px;
text-align: center;
}
.header h1 {
color: #4ade80;
font-size: 2.5rem;
margin-bottom: 10px;
font-weight: 700;
}
.header .subtitle {
color: #9ca3af;
font-size: 1.1rem;
margin-bottom: 20px;
}
.stats {
display: flex;
justify-content: center;
gap: 40px;
margin-top: 20px;
flex-wrap: wrap;
}
.stat {
text-align: center;
}
.stat-number {
display: block;
font-size: 2rem;
font-weight: bold;
color: #4ade80;
}
.stat-label {
color: #9ca3af;
font-size: 0.9rem;
}
.stats-info {
margin-top: 15px;
text-align: center;
}
.stats-info p {
color: #6b7280;
font-size: 0.85rem;
margin: 0;
opacity: 0.8;
}
.servers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 30px;
margin-bottom: 40px;
}
.server-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 30px;
transition: all 0.3s ease;
}
.server-card:hover {
transform: translateY(-5px);
border-color: #4ade80;
box-shadow: 0 20px 40px rgba(74, 222, 128, 0.1);
}
.server-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
.server-info {
flex: 1;
}
.server-name {
font-size: 1.5rem;
font-weight: 600;
color: #fff;
margin-bottom: 5px;
}
.server-stats {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.connection-count {
color: #9ca3af;
font-size: 0.85rem;
background: rgba(74, 222, 128, 0.1);
padding: 4px 8px;
border-radius: 12px;
border: 1px solid rgba(74, 222, 128, 0.2);
transition: all 0.3s ease;
}
.connection-count:hover {
background: rgba(74, 222, 128, 0.2);
border-color: rgba(74, 222, 128, 0.4);
transform: scale(1.05);
}
.server-type {
background: #4ade80;
color: #000;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
align-self: flex-start;
}
.server-status {
margin-bottom: 20px;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 25px;
font-size: 0.9rem;
font-weight: 500;
}
.status-online {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
border: 1px solid rgba(74, 222, 128, 0.3);
}
.status-offline {
background: rgba(248, 113, 113, 0.2);
color: #f87171;
border: 1px solid rgba(248, 113, 113, 0.3);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.links-container {
display: grid;
gap: 15px;
}
.link-item {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 15px;
padding: 20px;
transition: all 0.3s ease;
}
.link-item:hover {
background: rgba(0, 0, 0, 0.5);
border-color: rgba(74, 222, 128, 0.5);
}
.link-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 15px;
}
.link-info {
flex: 1;
min-width: 200px;
}
.link-comment {
font-weight: 600;
color: #fff;
margin-bottom: 5px;
}
.link-stats {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.last-used {
color: #9ca3af;
font-size: 0.8rem;
background: rgba(156, 163, 175, 0.1);
padding: 3px 8px;
border-radius: 10px;
border: 1px solid rgba(156, 163, 175, 0.2);
transition: all 0.3s ease;
}
.last-used:hover {
background: rgba(156, 163, 175, 0.2);
border-color: rgba(156, 163, 175, 0.4);
transform: scale(1.05);
}
.usage-count {
color: #9ca3af;
font-size: 0.8rem;
background: rgba(59, 130, 246, 0.1);
padding: 3px 8px;
border-radius: 10px;
border: 1px solid rgba(59, 130, 246, 0.2);
transition: all 0.3s ease;
}
.usage-count:hover {
background: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.4);
transform: scale(1.05);
}
.recent-count {
color: #9ca3af;
font-size: 0.8rem;
background: rgba(168, 85, 247, 0.1);
padding: 3px 8px;
border-radius: 10px;
border: 1px solid rgba(168, 85, 247, 0.2);
transition: all 0.3s ease;
}
.recent-count:hover {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.4);
transform: scale(1.05);
}
.usage-chart {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 0, 0, 0.2);
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 170px;
}
.chart-title {
color: #9ca3af;
font-size: 0.7rem;
margin-bottom: 8px;
text-align: center;
font-weight: 500;
}
.chart-bars {
display: flex;
align-items: end;
gap: 1px;
height: 30px;
width: 150px;
}
.chart-bar {
background: linear-gradient(to top, #4ade80, #22c55e);
width: 4px;
border-radius: 1px 1px 0 0;
transition: all 0.3s ease;
min-height: 2px;
opacity: 0.7;
transform-origin: bottom;
}
.chart-bar:hover {
opacity: 1;
transform: scaleY(1.1);
background: linear-gradient(to top, #22c55e, #16a34a);
}
.chart-bar.zero {
background: rgba(107, 114, 128, 0.3);
height: 2px !important;
}
.link-url {
background: rgba(0, 0, 0, 0.5);
padding: 12px;
border-radius: 8px;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.9rem;
color: #4ade80;
word-break: break-all;
margin-bottom: 15px;
position: relative;
}
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
background: #4ade80;
color: #000;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.7rem;
font-weight: 600;
transition: all 0.3s ease;
}
.copy-btn:hover {
background: #22c55e;
}
.footer {
text-align: center;
padding: 40px 20px;
color: #6b7280;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 40px;
}
.footer a {
color: #4ade80;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.servers-grid {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 2rem;
}
.stats {
gap: 15px;
}
.stat {
min-width: 120px;
}
.server-card {
padding: 20px;
}
.server-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.server-stats {
gap: 10px;
}
.link-stats {
gap: 8px;
}
.usage-chart {
min-width: 160px;
padding: 8px;
}
.chart-bars {
width: 120px;
height: 25px;
}
.chart-bar {
width: 3px;
}
}
.no-servers {
text-align: center;
padding: 60px 20px;
color: #9ca3af;
}
.no-servers h3 {
font-size: 1.5rem;
margin-bottom: 10px;
color: #6b7280;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 VPN Access Portal</h1>
<div class="subtitle">Welcome back, <strong>{{ user.username }}</strong></div>
<div class="stats">
<div class="stat">
<span class="stat-number">{{ total_groups }}</span>
<span class="stat-label">Subscription Groups</span>
</div>
<div class="stat">
<span class="stat-number">{{ total_inbounds }}</span>
<span class="stat-label">Available Inbounds</span>
</div>
<div class="stat">
<span class="stat-number">{{ total_connections }}</span>
<span class="stat-label">Total Uses</span>
</div>
<div class="stat">
<span class="stat-number">{{ recent_connections }}</span>
<span class="stat-label">Last 30 Days</span>
</div>
</div>
<div class="stats-info">
{% if total_connections == 0 and total_links > 0 %}
<p>📊 Statistics cache is empty. Run update in Admin → Task Execution Logs</p>
{% else %}
<p>📊 Statistics are updated every 5 minutes and show your connection history</p>
{% endif %}
</div>
<!-- Xray Subscription Link -->
{% if has_xray_access %}
<div class="xray-subscription" style="margin-top: 20px; padding: 15px; background: rgba(148, 163, 184, 0.1); border: 1px solid rgba(148, 163, 184, 0.3); border-radius: 12px;">
<h3 style="color: #94a3b8; margin-bottom: 10px; font-size: 1.1rem;">🚀 Xray Universal Subscription</h3>
<p style="color: #64748b; font-size: 0.9rem; margin-bottom: 10px;">
One link for all your Xray protocols (VLESS, VMess, Trojan)
</p>
<div class="link-url" style="margin-bottom: 0;">
{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}
<button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}')">Copy</button>
</div>
</div>
{% endif %}
</div>
{% if groups_data %}
<div class="servers-grid">
{% for group_name, group_data in groups_data.items %}
<div class="server-card">
<div class="server-header">
<div class="server-info">
<div class="server-name">{{ group_name }}</div>
<div class="server-stats">
<span class="connection-count">🔗 {{ group_data.deployed_count }} inbound(s)</span>
</div>
</div>
<div class="server-type">Xray Group</div>
</div>
<div class="server-status">
<div class="status-indicator status-online">
<div class="status-dot"></div>
Active Subscription
</div>
</div>
<!-- Individual Subscription Link for this Group -->
<div class="links-container">
<div class="link-item">
<div class="link-header">
<div class="link-info">
<div class="link-comment">🚀 {{ group_name }} Subscription</div>
<div class="link-stats">
<span class="last-used">🔗 {{ group_data.deployed_count }} inbound(s)</span>
</div>
</div>
<div class="usage-chart">
<div class="chart-title">Protocols</div>
<div style="display: flex; flex-direction: column; gap: 5px; align-items: center;">
{% for inbound_data in group_data.inbounds %}
<div style="background: rgba(74, 222, 128, 0.2); padding: 3px 8px; border-radius: 8px; font-size: 0.7rem; color: #4ade80;">
{{ inbound_data.server_name }}: {{ inbound_data.protocol|upper }}:{{ inbound_data.port }}
</div>
{% endfor %}
</div>
</div>
</div>
<div class="link-url">
{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}
<button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}')">Copy</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-servers">
<h3>No Xray Subscriptions Available</h3>
<p>You don't have access to any subscription groups yet. Please contact your administrator.</p>
</div>
{% endif %}
<!-- Show old ACL links for backwards compatibility -->
{% if has_old_links %}
<h2 style="color: #9ca3af; margin: 40px 0 20px 0; text-align: center;">Legacy Shadowsocks Access</h2>
<div class="servers-grid">
{% for acl_link in acl_links %}
<div class="server-card" style="opacity: 0.7;">
<div class="server-header">
<div class="server-info">
<div class="server-name">{{ acl_link.acl.server.name }}</div>
<div class="server-stats">
<span class="connection-count">📊 Legacy</span>
</div>
</div>
<div class="server-type">Shadowsocks</div>
</div>
<div class="server-status">
<div class="status-indicator status-online">
<div class="status-dot"></div>
Legacy Access
</div>
</div>
<div class="links-container">
<div class="link-item">
<div class="link-header">
<div class="link-info">
<div class="link-comment">📱 {{ acl_link.comment }}</div>
<div class="link-stats">
<span class="last-used">🕒 {{ acl_link.last_access_time|default:"Never used" }}</span>
</div>
</div>
</div>
<div class="link-url">
{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}
<button class="copy-btn" onclick="copyToClipboard('{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}')">Copy</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="footer">
<p>Powered by <a href="https://github.com/house-of-vanity/OutFleet" target="_blank">OutFleet VPN Manager</a></p>
<p>Keep this link secure and don't share it with others</p>
</div>
</div>
<script>
// Copy to clipboard functionality
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
// Visual feedback
const event = new CustomEvent('copied');
document.dispatchEvent(event);
// Show temporary feedback
showCopyFeedback();
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showCopyFeedback();
}
}
function showCopyFeedback() {
// Create and show a toast notification
const toast = document.createElement('div');
toast.textContent = 'Link copied to clipboard! ✓';
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #4ade80;
color: #000;
padding: 12px 20px;
border-radius: 8px;
font-weight: 600;
z-index: 1000;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => document.body.removeChild(toast), 300);
}, 2000);
}
// Add animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
@keyframes barGrow {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
`;
document.head.appendChild(style);
// Update page title with username
document.title = `VPN Portal - {{ user.username }}`;
// Add some interactivity on load
document.addEventListener('DOMContentLoaded', function() {
// Initialize chart bars
initializeCharts();
// Animate cards on load
const cards = document.querySelectorAll('.server-card');
cards.forEach((card, index) => {
card.style.opacity = '0';
card.style.transform = 'translateY(20px)';
setTimeout(() => {
card.style.transition = 'all 0.6s ease';
card.style.opacity = '1';
card.style.transform = 'translateY(0)';
}, index * 150);
});
// Animate stat numbers
const statNumbers = document.querySelectorAll('.stat-number');
statNumbers.forEach((stat, index) => {
const finalValue = parseInt(stat.textContent);
if (finalValue > 0) {
stat.textContent = '0';
let current = 0;
const increment = Math.ceil(finalValue / 20);
const timer = setInterval(() => {
current += increment;
if (current >= finalValue) {
stat.textContent = finalValue;
clearInterval(timer);
} else {
stat.textContent = current;
}
}, 50);
}
});
// Add pulse animation to connection counts
setTimeout(() => {
const connectionCounts = document.querySelectorAll('.connection-count, .usage-count, .recent-count');
connectionCounts.forEach((count, index) => {
setTimeout(() => {
count.style.animation = 'pulse 0.6s ease-in-out';
}, index * 100);
});
}, 1000);
// Animate chart bars
setTimeout(() => {
const chartBars = document.querySelectorAll('.chart-bar');
chartBars.forEach((bar, index) => {
setTimeout(() => {
bar.style.animation = 'barGrow 0.8s ease-out';
}, index * 50);
});
}, 1500);
});
function initializeCharts() {
const charts = document.querySelectorAll('.usage-chart');
charts.forEach(chart => {
const maxValue = parseInt(chart.dataset.max) || 1;
const bars = chart.querySelectorAll('.chart-bar');
bars.forEach(bar => {
const height = parseInt(bar.dataset.height) || 0;
const maxHeight = parseInt(bar.dataset.max) || 1;
if (height === 0) {
bar.classList.add('zero');
bar.style.height = '2px';
} else {
// Calculate height as percentage of container (30px max)
const percentage = Math.max(10, (height / Math.max(maxHeight, 1)) * 100);
const pixelHeight = Math.max(3, (percentage / 100) * 28); // 28px max for padding
bar.style.height = pixelHeight + 'px';
// Add tooltip
bar.title = `${height} connections`;
}
});
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ error_title }} - VPN Portal</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #0c0c0c 0%, #1a1a1a 100%);
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.6;
}
.error-container {
max-width: 500px;
padding: 40px;
text-align: center;
}
.error-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 40px;
animation: fadeInUp 0.6s ease;
}
.error-icon {
font-size: 4rem;
margin-bottom: 20px;
opacity: 0.7;
}
.error-title {
color: #f87171;
font-size: 2rem;
font-weight: 700;
margin-bottom: 15px;
}
.error-message {
color: #9ca3af;
font-size: 1.1rem;
margin-bottom: 30px;
line-height: 1.6;
}
.back-link {
display: inline-block;
background: #4ade80;
color: #000;
padding: 12px 24px;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
}
.back-link:hover {
background: #22c55e;
transform: translateY(-2px);
}
.footer {
margin-top: 40px;
color: #6b7280;
font-size: 0.9rem;
}
.footer a {
color: #4ade80;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.error-container {
padding: 20px;
}
.error-card {
padding: 30px 20px;
}
.error-title {
font-size: 1.5rem;
}
.error-message {
font-size: 1rem;
}
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-card">
<div class="error-icon">🚫</div>
<h1 class="error-title">{{ error_title }}</h1>
<p class="error-message">{{ error_message }}</p>
<a href="javascript:history.back()" class="back-link">← Go Back</a>
</div>
<div class="footer">
<p>Powered by <a href="https://github.com/house-of-vanity/OutFleet" target="_blank">OutFleet VPN Manager</a></p>
</div>
</div>
<script>
// Add some interactivity
document.addEventListener('DOMContentLoaded', function() {
// Auto-refresh every 30 seconds for server errors
{% if 'Server Error' in error_title %}
setTimeout(() => {
window.location.reload();
}, 30000);
{% endif %}
});
</script>
</body>
</html>

3
vpn/tests.py Normal file
View File

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

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