mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-12-16 17:37:51 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26fd9c3e85 | ||
|
|
eb9f199d57 | ||
|
|
c9c21f1baa |
23
.github/workflows/main.yml
vendored
23
.github/workflows/main.yml
vendored
@@ -3,7 +3,7 @@ name: Docker hub build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'django'
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -24,28 +24,13 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Set outputs
|
||||
id: vars
|
||||
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
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Check outputs
|
||||
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 }}"
|
||||
run: echo ${{ steps.vars.outputs.sha_short }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
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 }}
|
||||
tags: ultradesu/outfleet:latest,ultradesu/outfleet:${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,21 +1,9 @@
|
||||
db.sqlite3
|
||||
debug.log
|
||||
config.yaml
|
||||
__pycache__/
|
||||
sync.log
|
||||
main.py
|
||||
.idea/*
|
||||
.vscode/*
|
||||
*.swp
|
||||
*.swo
|
||||
*.pyc
|
||||
staticfiles/
|
||||
*.__pycache__.*
|
||||
celerybeat-schedule*
|
||||
|
||||
# macOS system files
|
||||
._*
|
||||
.DS_Store
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Temporary files
|
||||
/tmp/
|
||||
*.tmp
|
||||
*.swn
|
||||
|
||||
64
.vscode/launch.json
vendored
64
.vscode/launch.json
vendored
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Django VPN app",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"env": {
|
||||
"POSTGRES_PORT": "5433",
|
||||
"DJANGO_SETTINGS_MODULE": "mysite.settings",
|
||||
"EXTERNAL_ADDRESS": "http://localhost:8000"
|
||||
},
|
||||
"args": [
|
||||
"runserver",
|
||||
"0.0.0.0:8000"
|
||||
],
|
||||
"django": true,
|
||||
"autoStartBrowser": false,
|
||||
"program": "${workspaceFolder}/manage.py"
|
||||
},
|
||||
{
|
||||
"name": "Celery Worker",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "celery",
|
||||
"args": [
|
||||
"-A", "mysite",
|
||||
"worker",
|
||||
"--loglevel=info"
|
||||
],
|
||||
"env": {
|
||||
"POSTGRES_PORT": "5433",
|
||||
"DJANGO_SETTINGS_MODULE": "mysite.settings"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Celery Beat",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "celery",
|
||||
"args": [
|
||||
"-A", "mysite",
|
||||
"beat",
|
||||
"--loglevel=info"
|
||||
],
|
||||
"env": {
|
||||
"POSTGRES_PORT": "5433",
|
||||
"DJANGO_SETTINGS_MODULE": "mysite.settings"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Run Django, Celery Worker, and Celery Beat",
|
||||
"configurations": [
|
||||
"Django VPN app",
|
||||
"Celery Worker",
|
||||
"Celery Beat"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
39
Dockerfile
Normal file → Executable file
39
Dockerfile
Normal file → Executable file
@@ -1,40 +1,13 @@
|
||||
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
|
||||
|
||||
# Install system dependencies first (this layer will be cached)
|
||||
RUN apk update && apk add git curl unzip
|
||||
COPY requirements.txt .
|
||||
COPY static static
|
||||
COPY templates templates
|
||||
COPY *.py .
|
||||
|
||||
# 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
|
||||
|
||||
# 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" ]
|
||||
EXPOSE 5000
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
58
README.md
58
README.md
@@ -2,7 +2,7 @@
|
||||
<h1 align="center">OutFleet: Master Your OutLine VPN</h1>
|
||||
|
||||
<p align="center">
|
||||
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers, users and always-updated Dynamic Access Keys instead of ss:// links
|
||||
Streamline OutLine VPN experience. OutFleet offers centralized key control for many servers 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,10 +11,10 @@
|
||||
|
||||
  
|
||||
|
||||
<img width="1282" height="840" alt="image" src="https://github.com/user-attachments/assets/3b66f928-853b-4af0-8968-1eacb2c16a1c" />
|
||||
|
||||
## About The Project
|
||||
|
||||

|
||||
|
||||
### Key Features
|
||||
|
||||
* Centralized Key Management
|
||||
@@ -28,18 +28,60 @@ Tired of juggling multiple home servers and the headache of individually managin
|
||||
|
||||
## Built With
|
||||
|
||||
Django, Postgres SQL and hassle-free deployment using Kubernetes or docker-compose
|
||||
Python, Flask and offer hassle-free deployment.
|
||||
|
||||
### Installation
|
||||
|
||||
#### Docker compose
|
||||
Docker deploy is easy:
|
||||
```
|
||||
docker-compose up -d
|
||||
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
|
||||
```
|
||||
#### 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.
|
||||
#### 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
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
auth_basic "Private Place";
|
||||
auth_basic_user_file /etc/nginx/htpasswd;
|
||||
}
|
||||
|
||||
location /dynamic {
|
||||
auth_basic off;
|
||||
proxy_pass http://localhost:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
access_log /var/log/nginx/server.name.access.log;
|
||||
error_log /var/log/nginx/server.name.error.log;
|
||||
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
server_name server.name;
|
||||
listen [::]:80;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
#### Setup sslocal service on Windows
|
||||
Shadowsocks servers can be used directly with **sslocal**. For automatic and regular password updates, you can create a Task Scheduler job to rotate the passwords when they change, as OutFleet manages the passwords automatically.
|
||||
|
||||
21
SECURITY.md
21
SECURITY.md
@@ -1,21 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Проверить количество записей без 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;
|
||||
@@ -1,35 +0,0 @@
|
||||
-- ВАРИАНТ 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;
|
||||
@@ -1,102 +0,0 @@
|
||||
services:
|
||||
web_ui:
|
||||
image: outfleet:local
|
||||
container_name: outfleet-web
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- EXTERNAL_ADDRESS=http://127.0.0.1:8000
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "sleep 1 &&
|
||||
python manage.py makemigrations &&
|
||||
python manage.py migrate &&
|
||||
python manage.py create_admin &&
|
||||
python manage.py runserver 0.0.0.0:8000"
|
||||
|
||||
worker:
|
||||
image: outfleet:local
|
||||
container_name: outfleet-worker
|
||||
build:
|
||||
context: .
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "sleep 3 && celery -A mysite worker"
|
||||
|
||||
beat:
|
||||
image: outfleet:local
|
||||
container_name: outfleet-beat
|
||||
build:
|
||||
context: .
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "sleep 3 && celery -A mysite beat"
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: outfleet
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
BIN
img/servers.png
Executable file
BIN
img/servers.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
127
k8s.py
Executable file
127
k8s.py
Executable file
@@ -0,0 +1,127 @@
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
import yaml
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
import lib
|
||||
from kubernetes import client, config as kube_config
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
log = logging.getLogger("OutFleet.k8s")
|
||||
|
||||
NAMESPACE = False
|
||||
SERVERS = list()
|
||||
CONFIG = None
|
||||
V1 = None
|
||||
K8S_DETECTED = False
|
||||
|
||||
|
||||
def discovery_servers():
|
||||
global CONFIG
|
||||
interval = 60
|
||||
log = logging.getLogger("OutFleet.discovery")
|
||||
|
||||
if K8S_DETECTED:
|
||||
while True:
|
||||
pods = V1.list_namespaced_pod(NAMESPACE, label_selector="app=shadowbox")
|
||||
log.debug(f"Started discovery thread every {interval}")
|
||||
for pod in pods.items:
|
||||
log.debug(f"Found Outline server pod {pod.metadata.name}")
|
||||
container_log = V1.read_namespaced_pod_log(name=pod.metadata.name, namespace=NAMESPACE, container='manager-config-json')
|
||||
secret = json.loads(container_log.replace('\'', '\"'))
|
||||
config = lib.get_config()
|
||||
config_servers = find_server(secret, config["servers"])
|
||||
#log.info(f"config_servers {config_servers}")
|
||||
if len(config_servers) > 0:
|
||||
log.debug(f"Already exist")
|
||||
pass
|
||||
else:
|
||||
with lib.lock:
|
||||
config["servers"][str(uuid.uuid4())] = {
|
||||
"cert": secret["certSha256"],
|
||||
"name": f"{pod.metadata.name}",
|
||||
"comment": f"{pod.spec.node_name}",
|
||||
"url": secret["apiUrl"],
|
||||
}
|
||||
write_config(config)
|
||||
log.info(f"Added discovered server")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def find_server(search_data, servers):
|
||||
found_servers = {}
|
||||
for server_id, server_info in servers.items():
|
||||
if server_info["url"] == search_data["apiUrl"] and server_info["cert"] == search_data["certSha256"]:
|
||||
found_servers[server_id] = server_info
|
||||
return found_servers
|
||||
|
||||
|
||||
|
||||
def write_config(config):
|
||||
config_map = client.V1ConfigMap(
|
||||
api_version="v1",
|
||||
kind="ConfigMap",
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=f"config-outfleet",
|
||||
labels={
|
||||
"app": "outfleet",
|
||||
}
|
||||
),
|
||||
data={"config.yaml": yaml.dump(config)}
|
||||
)
|
||||
try:
|
||||
api_response = V1.create_namespaced_config_map(
|
||||
namespace=NAMESPACE,
|
||||
body=config_map,
|
||||
)
|
||||
except ApiException as e:
|
||||
api_response = V1.patch_namespaced_config_map(
|
||||
name="config-outfleet",
|
||||
namespace=NAMESPACE,
|
||||
body=config_map,
|
||||
)
|
||||
log.info("Updated config in Kubernetes ConfigMap [config-outfleet]")
|
||||
|
||||
|
||||
def reload_config():
|
||||
global CONFIG
|
||||
while True:
|
||||
with lib.lock:
|
||||
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.debug(f"Synced system config with ConfigMap [config-outfleet].")
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
try:
|
||||
kube_config.load_incluster_config()
|
||||
V1 = client.CoreV1Api()
|
||||
if V1 != None:
|
||||
K8S_DETECTED = True
|
||||
try:
|
||||
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f:
|
||||
NAMESPACE = f.read().strip()
|
||||
log.info(f"Found Kubernetes environment. Deployed to namespace '{NAMESPACE}'")
|
||||
try:
|
||||
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.info(f"ConfigMap loaded from Kubernetes API. Servers: {len(CONFIG['servers'])}, Clients: {len(CONFIG['clients'])}. Started monitoring for changes every minute.")
|
||||
except Exception as e:
|
||||
try:
|
||||
write_config({"clients": [], "servers": {}, "ui_hostname": "accessible-address.com"})
|
||||
CONFIG = yaml.safe_load(V1.read_namespaced_config_map(name="config-outfleet", namespace=NAMESPACE).data['config.yaml'])
|
||||
log.info("Created new ConfigMap [config-outfleet]. Started monitoring for changes every minute.")
|
||||
except Exception as e:
|
||||
log.info(f"Failed to create new ConfigMap [config-outfleet] {e}")
|
||||
thread = threading.Thread(target=reload_config)
|
||||
thread.start()
|
||||
|
||||
except:
|
||||
log.info("Kubernetes environment not detected")
|
||||
except:
|
||||
log.info("Kubernetes environment not detected")
|
||||
|
||||
184
lib.py
Executable file
184
lib.py
Executable file
@@ -0,0 +1,184 @@
|
||||
import argparse
|
||||
import logging
|
||||
import threading
|
||||
from typing import TypedDict, List
|
||||
from outline_vpn.outline_vpn import OutlineKey, OutlineVPN
|
||||
import yaml
|
||||
import k8s
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%d-%m-%Y %H:%M:%S",
|
||||
)
|
||||
|
||||
log = logging.getLogger(f'OutFleet.lib')
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
default="/usr/local/etc/outfleet/config.yaml",
|
||||
help="Config file location",
|
||||
)
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
def get_config():
|
||||
if k8s.CONFIG:
|
||||
return k8s.CONFIG
|
||||
else:
|
||||
try:
|
||||
with open(args.config, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
if config == None:
|
||||
config = {
|
||||
"servers": {},
|
||||
"clients": {}
|
||||
}
|
||||
except:
|
||||
try:
|
||||
with open(args.config, "w"):
|
||||
config = {
|
||||
"servers": {},
|
||||
"clients": {}
|
||||
}
|
||||
yaml.safe_dump(config, file)
|
||||
except Exception as exp:
|
||||
log.error(f"Couldn't create config. {exp}")
|
||||
return None
|
||||
return config
|
||||
|
||||
def write_config(config):
|
||||
if k8s.CONFIG:
|
||||
k8s.write_config(config)
|
||||
else:
|
||||
try:
|
||||
with open(args.config, "w") as file:
|
||||
yaml.safe_dump(config, file)
|
||||
except Exception as e:
|
||||
log.error(f"Couldn't write Outfleet config: {e}")
|
||||
|
||||
|
||||
class ServerDict(TypedDict):
|
||||
server_id: str
|
||||
local_server_id: str
|
||||
name: str
|
||||
url: str
|
||||
cert: str
|
||||
comment: str
|
||||
metrics_enabled: str
|
||||
created_timestamp_ms: int
|
||||
version: str
|
||||
port_for_new_access_keys: int
|
||||
hostname_for_access_keys: str
|
||||
keys: List[OutlineKey]
|
||||
|
||||
|
||||
class Server:
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
cert: str,
|
||||
comment: str,
|
||||
# read from config. not the same as real server id you can get from api
|
||||
local_server_id: str,
|
||||
):
|
||||
self.client = OutlineVPN(api_url=url, cert_sha256=cert)
|
||||
self.data: ServerDict = {
|
||||
"local_server_id": local_server_id,
|
||||
"name": self.client.get_server_information()["name"],
|
||||
"url": url,
|
||||
"cert": cert,
|
||||
"comment": comment,
|
||||
"server_id": self.client.get_server_information()["serverId"],
|
||||
"metrics_enabled": self.client.get_server_information()["metricsEnabled"],
|
||||
"created_timestamp_ms": self.client.get_server_information()[
|
||||
"createdTimestampMs"
|
||||
],
|
||||
"version": self.client.get_server_information()["version"],
|
||||
"port_for_new_access_keys": self.client.get_server_information()[
|
||||
"portForNewAccessKeys"
|
||||
],
|
||||
"hostname_for_access_keys": self.client.get_server_information()[
|
||||
"hostnameForAccessKeys"
|
||||
],
|
||||
"keys": self.client.get_keys(),
|
||||
}
|
||||
self.log = logging.getLogger(f'OutFleet.server[{self.data["name"]}]')
|
||||
|
||||
def info(self) -> ServerDict:
|
||||
return self.data
|
||||
|
||||
def check_client(self, name):
|
||||
# Looking for any users with provided name. len(result) != 1 is a problem.
|
||||
result = []
|
||||
for key in self.client.get_keys():
|
||||
if key.name == name:
|
||||
result.append(name)
|
||||
self.log.info(f"check_client found client `{name}` config is correct.")
|
||||
if len(result) != 1:
|
||||
self.log.warning(
|
||||
f"check_client found client `{name}` inconsistent. Found {len(result)} keys."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def apply_config(self, config):
|
||||
if config.get("name"):
|
||||
self.client.set_server_name(config.get("name"))
|
||||
self.log.info(
|
||||
"Changed %s name to '%s'", self.data["local_server_id"], config.get("name")
|
||||
)
|
||||
if config.get("metrics"):
|
||||
self.client.set_metrics_status(
|
||||
True if config.get("metrics") == "True" else False
|
||||
)
|
||||
self.log.info(
|
||||
"Changed %s metrics status to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("metrics"),
|
||||
)
|
||||
if config.get("port_for_new_access_keys"):
|
||||
self.client.set_port_new_for_access_keys(
|
||||
int(config.get("port_for_new_access_keys"))
|
||||
)
|
||||
self.log.info(
|
||||
"Changed %s port_for_new_access_keys to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("port_for_new_access_keys"),
|
||||
)
|
||||
if config.get("hostname_for_access_keys"):
|
||||
self.client.set_hostname(config.get("hostname_for_access_keys"))
|
||||
self.log.info(
|
||||
"Changed %s hostname_for_access_keys to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("hostname_for_access_keys"),
|
||||
)
|
||||
if config.get("comment"):
|
||||
config_file = get_config()
|
||||
config_file["servers"][self.data["local_server_id"]]["comment"] = config.get(
|
||||
"comment"
|
||||
)
|
||||
write_config(config_file)
|
||||
self.log.info(
|
||||
"Changed %s comment to '%s'",
|
||||
self.data["local_server_id"],
|
||||
config.get("comment"),
|
||||
)
|
||||
|
||||
def create_key(self, key_name):
|
||||
self.client.create_key(key_id=key_name, name=key_name)
|
||||
self.log.info("New key created: %s", key_name)
|
||||
return True
|
||||
|
||||
def rename_key(self, key_id, new_name):
|
||||
self.log.info("Key %s renamed: %s", key_id, new_name)
|
||||
return self.client.rename_key(key_id, new_name)
|
||||
|
||||
def delete_key(self, key_id):
|
||||
self.log.info("Key %s deleted", key_id)
|
||||
return self.client.delete_key(key_id)
|
||||
485
main.py
Executable file
485
main.py
Executable file
@@ -0,0 +1,485 @@
|
||||
import threading
|
||||
import time
|
||||
import yaml
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import random
|
||||
import string
|
||||
import argparse
|
||||
import uuid
|
||||
import bcrypt
|
||||
|
||||
|
||||
import k8s
|
||||
from flask import Flask, render_template, request, url_for, redirect
|
||||
from flask_cors import CORS
|
||||
from werkzeug.routing import BaseConverter
|
||||
from lib import Server, write_config, get_config, args, lock
|
||||
|
||||
class DuplicateFilter(logging.Filter):
|
||||
|
||||
def filter(self, record):
|
||||
# add other fields if you need more granular comparison, depends on your app
|
||||
current_log = (record.module, record.levelno, record.msg)
|
||||
if current_log != getattr(self, "last_log", None):
|
||||
self.last_log = current_log
|
||||
return True
|
||||
return False
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%d-%m-%Y %H:%M:%S",
|
||||
)
|
||||
|
||||
log = logging.getLogger("OutFleet")
|
||||
file_handler = logging.FileHandler("sync.log")
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
log.addHandler(file_handler)
|
||||
duplicate_filter = DuplicateFilter()
|
||||
log.addFilter(duplicate_filter)
|
||||
|
||||
CFG_PATH = args.config
|
||||
NAMESPACE = k8s.NAMESPACE
|
||||
SERVERS = list()
|
||||
BROKEN_SERVERS = list()
|
||||
CLIENTS = dict()
|
||||
VERSION = '8.1'
|
||||
SECRET_LINK_LENGTH = 8
|
||||
SECRET_LINK_PREFIX = '$2b$12$'
|
||||
SS_PREFIX = "\u0005\u00DC\u005F\u00E0\u0001\u0020"
|
||||
HOSTNAME = ""
|
||||
WRONG_DOOR = "Hey buddy, i think you got the wrong door the leather-club is two blocks down"
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
|
||||
def format_timestamp(ts):
|
||||
return datetime.fromtimestamp(ts // 1000).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def random_string(length=64):
|
||||
letters = string.ascii_letters + string.digits
|
||||
|
||||
return "".join(random.choice(letters) for i in range(length))
|
||||
|
||||
|
||||
def update_state(timer=40):
|
||||
while True:
|
||||
with lock:
|
||||
global SERVERS
|
||||
global CLIENTS
|
||||
global BROKEN_SERVERS
|
||||
global HOSTNAME
|
||||
config = get_config()
|
||||
|
||||
if config:
|
||||
HOSTNAME = config.get("ui_hostname", "my-own-SSL-ENABLED-domain.com")
|
||||
servers = config.get("servers", dict())
|
||||
_SERVERS = list()
|
||||
for local_server_id, server_config in servers.items():
|
||||
try:
|
||||
server = Server(
|
||||
url=server_config["url"],
|
||||
cert=server_config["cert"],
|
||||
comment=server_config.get("comment", ''),
|
||||
local_server_id=local_server_id,
|
||||
)
|
||||
_SERVERS.append(server)
|
||||
log.debug(
|
||||
"Server state updated: %s, [%s]",
|
||||
server.info()["name"],
|
||||
local_server_id,
|
||||
)
|
||||
except Exception as e:
|
||||
BROKEN_SERVERS.append({
|
||||
"config": server_config,
|
||||
"error": e,
|
||||
"id": local_server_id
|
||||
})
|
||||
log.warning("Can't access server: %s - %s", server_config["url"], e)
|
||||
SERVERS = _SERVERS
|
||||
CLIENTS = config.get("clients", dict())
|
||||
if timer == 0:
|
||||
break
|
||||
time.sleep(40)
|
||||
|
||||
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def index():
|
||||
if request.method == "GET":
|
||||
#if request.args.get("broken") == True:
|
||||
return render_template(
|
||||
"index.html",
|
||||
SERVERS=SERVERS,
|
||||
VERSION=VERSION,
|
||||
K8S_NAMESPACE=k8s.NAMESPACE,
|
||||
BROKEN_SERVERS=BROKEN_SERVERS,
|
||||
nt=request.args.get("nt"),
|
||||
nl=request.args.get("nl"),
|
||||
selected_server=request.args.get("selected_server"),
|
||||
broken=request.args.get("broken", False),
|
||||
add_server=request.args.get("add_server", None),
|
||||
format_timestamp=format_timestamp,
|
||||
)
|
||||
elif request.method == "POST":
|
||||
server = request.form["server_id"]
|
||||
server = next(
|
||||
(item for item in SERVERS if item.info()["local_server_id"] == server), None
|
||||
)
|
||||
server.apply_config(request.form)
|
||||
update_state(timer=0)
|
||||
return redirect(
|
||||
url_for(
|
||||
"index",
|
||||
nt="Updated Outline VPN Server",
|
||||
selected_server=request.args.get("selected_server"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/clients", methods=["GET", "POST"])
|
||||
def clients():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"clients.html",
|
||||
SERVERS=SERVERS,
|
||||
bcrypt=bcrypt,
|
||||
CLIENTS=CLIENTS,
|
||||
VERSION=VERSION,
|
||||
SECRET_LINK_LENGTH=SECRET_LINK_LENGTH,
|
||||
SECRET_LINK_PREFIX=SECRET_LINK_PREFIX,
|
||||
K8S_NAMESPACE=k8s.NAMESPACE,
|
||||
nt=request.args.get("nt"),
|
||||
nl=request.args.get("nl"),
|
||||
selected_client=request.args.get("selected_client"),
|
||||
add_client=request.args.get("add_client", None),
|
||||
format_timestamp=format_timestamp,
|
||||
dynamic_hostname=HOSTNAME,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/add_server", methods=["POST"])
|
||||
def add_server():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
config = get_config()
|
||||
servers = config.get("servers", dict())
|
||||
local_server_id = str(uuid.uuid4())
|
||||
|
||||
new_server = Server(
|
||||
url=request.form["url"],
|
||||
cert=request.form["cert"],
|
||||
comment=request.form["comment"],
|
||||
local_server_id=local_server_id,
|
||||
)
|
||||
|
||||
servers[new_server.data["local_server_id"]] = {
|
||||
"name": new_server.data["name"],
|
||||
"url": new_server.data["url"],
|
||||
"comment": new_server.data["comment"],
|
||||
"cert": request.form["cert"],
|
||||
}
|
||||
config["servers"] = servers
|
||||
write_config(config)
|
||||
log.info("Added server: %s", new_server.data["name"])
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("index", nt="Added Outline VPN Server"))
|
||||
except Exception as e:
|
||||
return redirect(
|
||||
url_for(
|
||||
"index", nt=f"Couldn't access Outline VPN Server: {e}", nl="error"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.route("/del_server", methods=["POST"])
|
||||
def del_server():
|
||||
if request.method == "POST":
|
||||
config = get_config()
|
||||
|
||||
local_server_id = request.form.get("local_server_id")
|
||||
server_name = None
|
||||
try:
|
||||
server_name = config["servers"].pop(local_server_id)["name"]
|
||||
except KeyError as e:
|
||||
pass
|
||||
for client_id, client_config in config["clients"].items():
|
||||
try:
|
||||
client_config["servers"].remove(local_server_id)
|
||||
except ValueError as e:
|
||||
pass
|
||||
write_config(config)
|
||||
log.info("Deleting server %s [%s]", server_name, request.form.get("local_server_id"))
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("index", nt=f"Server {server_name} has been deleted"))
|
||||
|
||||
|
||||
@app.route("/add_client", methods=["POST"])
|
||||
def add_client():
|
||||
if request.method == "POST":
|
||||
config = get_config()
|
||||
|
||||
clients = config.get("clients", dict())
|
||||
user_id = request.form.get("user_id", random_string())
|
||||
|
||||
clients[user_id] = {
|
||||
"name": request.form.get("name"),
|
||||
"comment": request.form.get("comment"),
|
||||
"servers": request.form.getlist("servers"),
|
||||
}
|
||||
config["clients"] = clients
|
||||
write_config(config)
|
||||
log.info("Client %s updated", request.form.get("name"))
|
||||
|
||||
for server in SERVERS:
|
||||
if server.data["local_server_id"] in request.form.getlist("servers"):
|
||||
client = next(
|
||||
(
|
||||
item
|
||||
for item in server.data["keys"]
|
||||
if item.name == request.form.get("old_name")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
if client.name == request.form.get("name"):
|
||||
pass
|
||||
else:
|
||||
server.rename_key(client.key_id, request.form.get("name"))
|
||||
log.info(
|
||||
"Renaming key %s to %s on server %s",
|
||||
request.form.get("old_name"),
|
||||
request.form.get("name"),
|
||||
server.data["name"],
|
||||
)
|
||||
else:
|
||||
server.create_key(request.form.get("name"))
|
||||
log.info(
|
||||
"Creating key %s on server %s",
|
||||
request.form.get("name"),
|
||||
server.data["name"],
|
||||
)
|
||||
else:
|
||||
client = next(
|
||||
(
|
||||
item
|
||||
for item in server.data["keys"]
|
||||
if item.name == request.form.get("old_name")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
server.delete_key(client.key_id)
|
||||
log.info(
|
||||
"Deleting key %s on server %s",
|
||||
request.form.get("name"),
|
||||
server.data["name"],
|
||||
)
|
||||
update_state(timer=0)
|
||||
return redirect(
|
||||
url_for(
|
||||
"clients",
|
||||
nt="Clients updated",
|
||||
selected_client=request.form.get("user_id"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
return redirect(url_for("clients"))
|
||||
|
||||
|
||||
@app.route("/del_client", methods=["POST"])
|
||||
def del_client():
|
||||
if request.method == "POST":
|
||||
config = get_config()
|
||||
clients = config.get("clients", dict())
|
||||
user_id = request.form.get("user_id")
|
||||
if user_id in clients:
|
||||
for server in SERVERS:
|
||||
client = next(
|
||||
(
|
||||
item
|
||||
for item in server.data["keys"]
|
||||
if item.name == request.form.get("name")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client:
|
||||
server.delete_key(client.key_id)
|
||||
|
||||
config["clients"].pop(user_id)
|
||||
write_config(config)
|
||||
log.info("Deleting client %s", request.form.get("name"))
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("clients", nt="User has been deleted"))
|
||||
|
||||
|
||||
def append_to_log(log_entry):
|
||||
with open("access_log.log", "a") as log_file:
|
||||
log_file.write(log_entry + "\n")
|
||||
|
||||
@app.route("/dynamic/<path:hash_secret>", methods=["GET"], strict_slashes=False)
|
||||
def dynamic(hash_secret):
|
||||
# Depricated scheme.
|
||||
for server in SERVERS:
|
||||
if hash_secret.startswith(server.data["name"]):
|
||||
log.warning("Deprecated key request")
|
||||
server_name = hash_secret.split('/')[0]
|
||||
client_id = hash_secret.split('/')[1]
|
||||
return dynamic_depticated(server_name, client_id, hash_secret)
|
||||
try:
|
||||
short_hash_server = hash_secret[0:SECRET_LINK_LENGTH]
|
||||
short_hash_client = hash_secret[SECRET_LINK_LENGTH:SECRET_LINK_LENGTH * 2 ]
|
||||
client_provided_secret = hash_secret[SECRET_LINK_LENGTH * 2:]
|
||||
hash_server = None
|
||||
hash_client = None
|
||||
server = None
|
||||
client = None
|
||||
for _server in SERVERS:
|
||||
if _server.data["local_server_id"][:SECRET_LINK_LENGTH] == short_hash_server:
|
||||
hash_server = _server.data["local_server_id"]
|
||||
server = _server
|
||||
|
||||
for client_id, values in CLIENTS.items():
|
||||
if client_id[:SECRET_LINK_LENGTH] == short_hash_client:
|
||||
hash_client = client_id
|
||||
client = CLIENTS[client_id]
|
||||
|
||||
if server and client:
|
||||
append_to_log(f"User: {client["name"]}. Server: {server.data['name']} client secret string: {hash_secret}")
|
||||
client_shadowsocks_key = next(
|
||||
(item for item in server.data["keys"] if item.key_id == client["name"]), None
|
||||
)
|
||||
|
||||
secret_string = hash_server + hash_client
|
||||
check_secret_hash = bcrypt.checkpw(
|
||||
password=secret_string.encode('utf-8'),
|
||||
hashed_password=f"{SECRET_LINK_PREFIX}{client_provided_secret}".encode('utf-8')
|
||||
)
|
||||
if check_secret_hash:
|
||||
log.info(f"Client {client['name']} has been requested ssconf for {server.data['name']}. Bcrypt client hash {client_provided_secret[0:16]}...[FULL HASH SECURED]")
|
||||
return {
|
||||
"server": server.data["hostname_for_access_keys"],
|
||||
"server_port": client_shadowsocks_key.port,
|
||||
"password": client_shadowsocks_key.password,
|
||||
"method": client_shadowsocks_key.method,
|
||||
"prefix": SS_PREFIX,
|
||||
"info": "Managed by OutFleet [github.com/house-of-vanity/OutFleet/]",
|
||||
}
|
||||
else:
|
||||
log.warning(f"Hack attempt! Client secret does not match: {client_provided_secret}")
|
||||
return WRONG_DOOR
|
||||
else:
|
||||
log.warning(f"Hack attempt! Client or server doesn't exist. payload: {hash_secret[0:200]}")
|
||||
return WRONG_DOOR
|
||||
except Exception as e:
|
||||
log.error(f"Dynamic V2 parse error: {e}")
|
||||
return WRONG_DOOR
|
||||
|
||||
|
||||
def dynamic_depticated(server_name, client_id, hash_secret=""):
|
||||
try:
|
||||
client = next(
|
||||
(keys for client, keys in CLIENTS.items() if client == client_id), None
|
||||
)
|
||||
server = next(
|
||||
(item for item in SERVERS if item.info()["name"] == server_name), None
|
||||
)
|
||||
key = next(
|
||||
(item for item in server.data["keys"] if item.key_id == client["name"]), None
|
||||
)
|
||||
if server and client and key:
|
||||
if server.data["local_server_id"] in client["servers"]:
|
||||
log.info(
|
||||
"Client %s has been requested ssconf for %s", client["name"], server.data["name"]
|
||||
)
|
||||
append_to_log(f"User: {client["name"]}. Server: {server.data['name']} client secret string: {hash_secret}")
|
||||
return {
|
||||
"server": server.data["hostname_for_access_keys"],
|
||||
"server_port": key.port,
|
||||
"password": key.password,
|
||||
"method": key.method,
|
||||
"prefix":SS_PREFIX,
|
||||
"info": "Managed by OutFleet [github.com/house-of-vanity/OutFleet/]",
|
||||
}
|
||||
else:
|
||||
log.warning(
|
||||
"Hack attempt! Client %s denied by ACL on %s",
|
||||
client["name"],
|
||||
server.data["name"],
|
||||
)
|
||||
return WRONG_DOOR
|
||||
except:
|
||||
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
|
||||
return WRONG_DOOR
|
||||
|
||||
|
||||
|
||||
@app.route("/dynamic", methods=["GET"], strict_slashes=False)
|
||||
def _dynamic():
|
||||
log.warning("Hack attempt! Client or server doesn't exist. SCAM")
|
||||
return WRONG_DOOR
|
||||
|
||||
|
||||
@app.route("/sync", methods=["GET", "POST"])
|
||||
def sync():
|
||||
if request.method == "GET":
|
||||
try:
|
||||
with open("sync.log", "r") as file:
|
||||
lines = file.readlines()
|
||||
except:
|
||||
lines = []
|
||||
return render_template(
|
||||
"sync.html",
|
||||
SERVERS=SERVERS,
|
||||
CLIENTS=CLIENTS,
|
||||
lines=lines,
|
||||
)
|
||||
if request.method == "POST":
|
||||
with lock:
|
||||
if request.form.get("wipe") == 'all':
|
||||
for server in SERVERS:
|
||||
log.info("Wiping all keys on [%s]", server.data["name"])
|
||||
for client in server.data['keys']:
|
||||
server.delete_key(client.key_id)
|
||||
|
||||
server_hash = {}
|
||||
with lock:
|
||||
for server in SERVERS:
|
||||
server_hash[server.data["local_server_id"]] = server
|
||||
with lock:
|
||||
for key, client in CLIENTS.items():
|
||||
for u_server_id in client["servers"]:
|
||||
if u_server_id in server_hash:
|
||||
if not server_hash[u_server_id].check_client(client["name"]):
|
||||
log.warning(
|
||||
f"Client {client['name']} absent on {server_hash[u_server_id].data['name']}"
|
||||
)
|
||||
server_hash[u_server_id].create_key(client["name"])
|
||||
else:
|
||||
log.info(
|
||||
f"Client {client['name']} already present on {server_hash[u_server_id].data['name']}"
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
f"Client {client['name']} incorrect server_id {u_server_id}"
|
||||
)
|
||||
update_state(timer=0)
|
||||
return redirect(url_for("sync"))
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_state_thread = threading.Thread(target=update_state)
|
||||
update_state_thread.start()
|
||||
|
||||
discovery_servers_thread = threading.Thread(target=k8s.discovery_servers)
|
||||
discovery_servers_thread.start()
|
||||
app.run(host="0.0.0.0")
|
||||
22
manage.py
22
manage.py
@@ -1,22 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,3 +0,0 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,40 +0,0 @@
|
||||
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()
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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)
|
||||
@@ -1,233 +0,0 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import environ
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
|
||||
ENV = environ.Env(
|
||||
DEBUG=(bool, False)
|
||||
)
|
||||
|
||||
environ.Env.read_env()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY=ENV('SECRET_KEY', default='django-insecure-change-me-in-production')
|
||||
TIME_ZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
||||
EXTERNAL_ADDRESS = ENV('EXTERNAL_ADDRESS', default='https://example.org')
|
||||
|
||||
CELERY_BROKER_URL = ENV('CELERY_BROKER_URL', default='redis://localhost:6379/0')
|
||||
CELERY_RESULT_BACKEND = 'django-db'
|
||||
CELERY_TIMEZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_RESULT_EXTENDED = True
|
||||
|
||||
# Celery Beat Schedule
|
||||
from celery.schedules import crontab
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
'update-user-statistics': {
|
||||
'task': 'update_user_statistics',
|
||||
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
||||
},
|
||||
'cleanup-task-logs': {
|
||||
'task': 'cleanup_task_logs',
|
||||
'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
|
||||
},
|
||||
}
|
||||
|
||||
AUTH_USER_MODEL = "vpn.User"
|
||||
|
||||
DEBUG = ENV('DEBUG')
|
||||
|
||||
ALLOWED_HOSTS = ENV.list('ALLOWED_HOSTS', default=["*"])
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CSRF_TRUSTED_ORIGINS = ENV.list('CSRF_TRUSTED_ORIGINS', default=[])
|
||||
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '[{asctime}] {levelname} {name} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
'simple': {
|
||||
'format': '{levelname} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'file': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': os.path.join(BASE_DIR, 'debug.log'),
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
'vpn': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
'telegram_bot': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
'requests': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'urllib3': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'jazzmin',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'polymorphic',
|
||||
'corsheaders',
|
||||
'django_celery_results',
|
||||
'django_celery_beat',
|
||||
'vpn',
|
||||
'telegram_bot',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'mysite.middleware.AutoLoginMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'mysite.urls'
|
||||
|
||||
GIT_COMMIT = ENV('GIT_COMMIT', default='development')
|
||||
GIT_COMMIT_SHORT = ENV('GIT_COMMIT_SHORT', default='dev')
|
||||
BUILD_DATE = ENV('BUILD_DATE', default='unknown')
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [
|
||||
os.path.join(BASE_DIR, 'templates'),
|
||||
os.path.join(BASE_DIR, 'vpn', 'templates')
|
||||
],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'mysite.context_processors.version_info',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
WSGI_APPLICATION = 'mysite.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
# CREATE USER outfleet WITH PASSWORD 'password';
|
||||
# GRANT ALL PRIVILEGES ON DATABASE outfleet TO outfleet;
|
||||
# ALTER DATABASE outfleet OWNER TO outfleet;
|
||||
|
||||
DATABASES = {
|
||||
'sqlite': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
},
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': ENV('POSTGRES_DB', default="outfleet"),
|
||||
'USER': ENV('POSTGRES_USER', default="outfleet"),
|
||||
'PASSWORD': ENV('POSTGRES_PASSWORD', default="outfleet"),
|
||||
'HOST': ENV('POSTGRES_HOST', default='localhost'),
|
||||
'PORT': ENV('POSTGRES_PORT', default='5432'),
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
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)),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
28
requirements.txt
Normal file → Executable file
28
requirements.txt
Normal file → Executable file
@@ -1,22 +1,6 @@
|
||||
django-environ==0.12.0
|
||||
Django==5.1.7
|
||||
celery==5.4.0
|
||||
django-jazzmin==3.0.1
|
||||
django-polymorphic==3.1.0
|
||||
django-cors-headers==4.5.0
|
||||
django-celery-results==2.5.1
|
||||
git+https://github.com/celery/django-celery-beat#egg=django-celery-beat
|
||||
requests==2.32.3
|
||||
PyYaml==6.0.2
|
||||
Markdown==3.7
|
||||
outline-vpn-api==6.3.0
|
||||
Redis==5.2.1
|
||||
whitenoise==6.9.0
|
||||
psycopg2-binary==2.9.10
|
||||
setuptools==75.2.0
|
||||
shortuuid==1.0.13
|
||||
cryptography==45.0.5
|
||||
acme>=2.0.0
|
||||
cloudflare>=4.3.1
|
||||
josepy>=2.0.0
|
||||
python-telegram-bot==21.10
|
||||
outline-vpn-api
|
||||
kubernetes
|
||||
PyYAML>=6.0.1
|
||||
Flask>=2.3.3
|
||||
flask-cors
|
||||
bcrypt
|
||||
@@ -1,239 +0,0 @@
|
||||
/* static/admin/css/main.css */
|
||||
|
||||
/* Bulk Action Section Styling */
|
||||
.bulk-actions-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 1px solid #dee2e6;
|
||||
border-left: 4px solid #007cba;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.bulk-actions-section h3 {
|
||||
color: #007cba;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.bulk-actions-section p {
|
||||
color: #6c757d;
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Action Button Styles */
|
||||
.server-action-btn, .bulk-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.server-action-btn:before, .bulk-action-btn:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.server-action-btn:hover:before, .bulk-action-btn:hover:before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.server-action-btn:hover, .bulk-action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Specific button colors */
|
||||
.btn-move-clients {
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-move-clients:hover {
|
||||
background-color: #005a8b !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-purge-users {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-purge-users:hover {
|
||||
background-color: #c82333 !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Server list action buttons */
|
||||
.field-server_actions {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.field-server_actions .server-action-btn {
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
/* Server statistics section */
|
||||
.server-stats-section {
|
||||
background-color: #e8f4fd;
|
||||
border: 1px solid #bee5eb;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.server-stats-grid {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #007cba;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Tip section styling */
|
||||
.tip-section {
|
||||
background-color: rgba(255, 243, 205, 0.8);
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.tip-section small {
|
||||
color: #856404;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.server-action-btn.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.server-action-btn.loading:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: auto;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.bulk-actions-section {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.server-action-btn, .bulk-action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.server-stats-grid {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-server_actions > div {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.field-server_actions .server-action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bulk-actions-section h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.server-action-btn, .bulk-action-btn {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bulk-actions-section {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%);
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.bulk-actions-section h3 {
|
||||
color: #63b3ed;
|
||||
}
|
||||
|
||||
.bulk-actions-section p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.server-stats-section {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #63b3ed;
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
/* Custom styles for VPN admin interface */
|
||||
|
||||
/* Quick action buttons in server list */
|
||||
.quick-actions .button {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
margin: 0 2px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to bottom, #f8f8f8, #e8e8e8);
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-actions .button:hover {
|
||||
background: linear-gradient(to bottom, #e8e8e8, #d8d8d8);
|
||||
border-color: #bbb;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.quick-actions .button:active {
|
||||
background: linear-gradient(to bottom, #d8d8d8, #e8e8e8);
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Sync button - blue theme */
|
||||
.quick-actions .button[href*="/sync/"] {
|
||||
background: linear-gradient(to bottom, #4a90e2, #357abd);
|
||||
border-color: #2968a3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-actions .button[href*="/sync/"]:hover {
|
||||
background: linear-gradient(to bottom, #357abd, #2968a3);
|
||||
border-color: #1f5582;
|
||||
}
|
||||
|
||||
/* Move clients button - orange theme */
|
||||
.quick-actions .button[href*="/move-clients/"] {
|
||||
background: linear-gradient(to bottom, #f39c12, #e67e22);
|
||||
border-color: #d35400;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-actions .button[href*="/move-clients/"]:hover {
|
||||
background: linear-gradient(to bottom, #e67e22, #d35400);
|
||||
border-color: #bf4f36;
|
||||
}
|
||||
|
||||
/* Status indicators improvements */
|
||||
.server-status-ok {
|
||||
color: #27ae60;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-status-error {
|
||||
color: #e74c3c;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-status-warning {
|
||||
color: #f39c12;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Better spacing for list display */
|
||||
.admin-object-tools {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Improve readability of pre-formatted status */
|
||||
.changelist-results pre {
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Server admin compact styles */
|
||||
.server-stats {
|
||||
max-width: 120px;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.server-activity {
|
||||
max-width: 140px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
max-width: 160px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.server-comment {
|
||||
max-width: 200px;
|
||||
min-width: 100px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Compact server display elements */
|
||||
.changelist-results .server-stats div,
|
||||
.changelist-results .server-activity div,
|
||||
.changelist-results .server-status div {
|
||||
line-height: 1.3;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
/* Status indicator colors */
|
||||
.status-online {
|
||||
color: #16a34a !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #dc2626 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: #f97316 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-unavailable {
|
||||
color: #f97316 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Activity indicators */
|
||||
.activity-high {
|
||||
color: #16a34a !important;
|
||||
}
|
||||
|
||||
.activity-medium {
|
||||
color: #eab308 !important;
|
||||
}
|
||||
|
||||
.activity-low {
|
||||
color: #f97316 !important;
|
||||
}
|
||||
|
||||
.activity-none {
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
|
||||
/* User stats indicators */
|
||||
.users-active {
|
||||
color: #16a34a !important;
|
||||
}
|
||||
|
||||
.users-medium {
|
||||
color: #eab308 !important;
|
||||
}
|
||||
|
||||
.users-low {
|
||||
color: #f97316 !important;
|
||||
}
|
||||
|
||||
.users-none {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Table cell width constraints for better layout */
|
||||
table.changelist-results th:nth-child(1), /* Name */
|
||||
table.changelist-results td:nth-child(1) {
|
||||
width: 180px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(3), /* Comment */
|
||||
table.changelist-results td:nth-child(3) {
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(4), /* User Stats */
|
||||
table.changelist-results td:nth-child(4) {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(5), /* Activity */
|
||||
table.changelist-results td:nth-child(5) {
|
||||
width: 140px;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
table.changelist-results th:nth-child(6), /* Status */
|
||||
table.changelist-results td:nth-child(6) {
|
||||
width: 160px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
/* Ensure text doesn't overflow in server admin */
|
||||
.changelist-results td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Allow wrapping for multi-line server info displays */
|
||||
.changelist-results td .server-stats,
|
||||
.changelist-results td .server-activity,
|
||||
.changelist-results td .server-status {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Server type icons */
|
||||
.server-type-outline {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.server-type-wireguard {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Tooltip styles for truncated text */
|
||||
[title] {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted #999;
|
||||
}
|
||||
|
||||
/* Form improvements for move clients page */
|
||||
.form-row.field-box {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.form-row.field-box label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-row.field-box .readonly {
|
||||
padding: 5px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.help {
|
||||
background: #e8f4fd;
|
||||
border: 1px solid #b8daff;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.help h3 {
|
||||
margin-top: 0;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.help ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Make user statistics section wider */
|
||||
.field-user_statistics_summary {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field-user_statistics_summary .readonly {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field-user_statistics_summary .user-management-section {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Wider fieldset for statistics */
|
||||
.wide {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.wide .form-row {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Server status button styles */
|
||||
.check-status-btn {
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-status-btn:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.check-status-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Make admin tables more responsive */
|
||||
.changelist-results table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
/* Improve button spacing */
|
||||
.btn-sm-custom {
|
||||
margin: 0 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Hide xray-subscriptions tab if it appears */
|
||||
#xray-subscriptions-tab,
|
||||
a[href="#xray-subscriptions-tab"],
|
||||
li:has(a[href="#xray-subscriptions-tab"]) {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// static/admin/js/generate_uuid.js
|
||||
|
||||
function generateLink(button) {
|
||||
let row = button.closest('tr');
|
||||
let inputField = row.querySelector('input[name$="link"]');
|
||||
|
||||
if (inputField) {
|
||||
inputField.value = generateRandomString(16);
|
||||
}
|
||||
}
|
||||
|
||||
function generateRandomString(length) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// OutlineServer JSON Configuration Functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// JSON Import functionality
|
||||
const importJsonBtn = document.getElementById('import-json-btn');
|
||||
const importJsonTextarea = document.getElementById('import-json-config');
|
||||
|
||||
if (importJsonBtn && importJsonTextarea) {
|
||||
// Auto-fill on paste event
|
||||
importJsonTextarea.addEventListener('paste', function(e) {
|
||||
// Small delay to let paste complete
|
||||
setTimeout(() => {
|
||||
tryAutoFillFromJson();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Manual import button
|
||||
importJsonBtn.addEventListener('click', function() {
|
||||
tryAutoFillFromJson();
|
||||
});
|
||||
|
||||
function tryAutoFillFromJson() {
|
||||
try {
|
||||
const jsonText = importJsonTextarea.value.trim();
|
||||
if (!jsonText) {
|
||||
alert('Please enter JSON configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(jsonText);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.apiUrl || !config.certSha256) {
|
||||
alert('Invalid JSON format. Required fields: apiUrl, certSha256');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse apiUrl to extract components
|
||||
const url = new URL(config.apiUrl);
|
||||
|
||||
// Fill form fields
|
||||
const adminUrlField = document.getElementById('id_admin_url');
|
||||
const adminCertField = document.getElementById('id_admin_access_cert');
|
||||
const clientHostnameField = document.getElementById('id_client_hostname');
|
||||
const clientPortField = document.getElementById('id_client_port');
|
||||
const nameField = document.getElementById('id_name');
|
||||
const commentField = document.getElementById('id_comment');
|
||||
|
||||
if (adminUrlField) adminUrlField.value = config.apiUrl;
|
||||
if (adminCertField) adminCertField.value = config.certSha256;
|
||||
|
||||
// Use provided hostname or extract from URL
|
||||
const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname;
|
||||
if (clientHostnameField) clientHostnameField.value = hostname;
|
||||
|
||||
// Use provided port or extract from various sources
|
||||
const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257';
|
||||
if (clientPortField) clientPortField.value = clientPort;
|
||||
|
||||
// Generate server name if not provided and field is empty
|
||||
if (nameField && !nameField.value) {
|
||||
const serverName = config.serverName || config.name || `Outline-${hostname}`;
|
||||
nameField.value = serverName;
|
||||
}
|
||||
|
||||
// Fill comment if provided and field exists
|
||||
if (commentField && config.comment) {
|
||||
commentField.value = config.comment;
|
||||
}
|
||||
|
||||
// Clear the JSON input
|
||||
importJsonTextarea.value = '';
|
||||
|
||||
// Show success message
|
||||
showSuccessMessage('✅ Configuration imported successfully!');
|
||||
|
||||
} catch (error) {
|
||||
alert('Invalid JSON format: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy to clipboard functionality
|
||||
window.copyToClipboard = function(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
const text = element.textContent || element.innerText;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopySuccess();
|
||||
}).catch(err => {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function fallbackCopyTextToClipboard(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopySuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
showSuccessMessage('📋 Copied to clipboard!');
|
||||
}
|
||||
|
||||
function showSuccessMessage(message) {
|
||||
const alertHtml = `
|
||||
<div class="alert alert-success alert-dismissible" style="margin: 1rem 0;">
|
||||
${message}
|
||||
<button type="button" class="close" aria-label="Close" onclick="this.parentElement.remove()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Try to find a container for the message
|
||||
const container = document.querySelector('.card-body') || document.querySelector('#content-main');
|
||||
if (container) {
|
||||
container.insertAdjacentHTML('afterbegin', alertHtml);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const alert = document.querySelector('.alert-success');
|
||||
if (alert) alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Sync server button - handle both static and dynamic buttons
|
||||
document.addEventListener('click', async function(e) {
|
||||
if (e.target && (e.target.id === 'sync-server-btn' || e.target.matches('[id="sync-server-btn"]'))) {
|
||||
const syncBtn = e.target;
|
||||
const serverId = syncBtn.dataset.serverId;
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
const originalText = syncBtn.textContent;
|
||||
syncBtn.textContent = '⏳ Syncing...';
|
||||
syncBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/outlineserver/${serverId}/sync/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccessMessage(`✅ ${data.message}`);
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} else {
|
||||
alert('Sync failed: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error: ' + error.message);
|
||||
} finally {
|
||||
syncBtn.textContent = originalText;
|
||||
syncBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
475
static/layout.css
Executable file
475
static/layout.css
Executable file
@@ -0,0 +1,475 @@
|
||||
:root {
|
||||
--app-h: 100vh;
|
||||
--app-space-1: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* -- BASE STYLES --
|
||||
* Most of these are inherited from Base, but I want to change a few.
|
||||
*/
|
||||
body {
|
||||
color: #333;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #1b98f8;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* -- HELPER STYLES --
|
||||
* Over-riding some of the .pure-button styles to make my buttons look unique
|
||||
*/
|
||||
.button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
background: #9d2c2c;
|
||||
border: 1px solid #480b0b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/*
|
||||
* -- LAYOUT STYLES --
|
||||
* This layout consists of three main elements, `#nav` (navigation bar), `#list` (server list), and `#main` (server content). All 3 elements are within `#layout`
|
||||
*/
|
||||
#nav,
|
||||
#main {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Make the navigation 100% width on phones */
|
||||
#nav {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
background: rgb(37, 42, 58);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Show the "Menu" button on phones */
|
||||
#nav .nav-menu-button {
|
||||
display: block;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* When "Menu" is clicked, the navbar should be 80% height */
|
||||
#nav.active {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Don't show the navigation items... */
|
||||
.nav-inner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ...until the "Menu" button is clicked */
|
||||
#nav.active .nav-inner {
|
||||
display: block;
|
||||
padding: 2em 0;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* -- NAV BAR STYLES --
|
||||
* Styling the default .pure-menu to look a little more unique.
|
||||
*/
|
||||
#nav .pure-menu {
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#nav .pure-menu-link:hover,
|
||||
#nav .pure-menu-link:focus {
|
||||
background: rgb(55, 60, 90);
|
||||
}
|
||||
|
||||
#nav .pure-menu-link {
|
||||
color: #fff;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
#nav .pure-menu-heading {
|
||||
border-bottom: none;
|
||||
font-size: 110%;
|
||||
color: rgb(75, 113, 151);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* -- server STYLES --
|
||||
* Styles relevant to the server messages, labels, counts, and more.
|
||||
*/
|
||||
.server-count {
|
||||
color: rgb(75, 113, 151);
|
||||
}
|
||||
|
||||
.server-label-personal,
|
||||
.server-label-work,
|
||||
.server-label-travel {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: inline-block;
|
||||
margin-right: 0.5em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.server-label-personal {
|
||||
background: #ffc94c;
|
||||
}
|
||||
|
||||
.server-label-work {
|
||||
background: #41ccb4;
|
||||
}
|
||||
|
||||
.server-label-travel {
|
||||
background: #40c365;
|
||||
}
|
||||
|
||||
|
||||
/* server Item Styles */
|
||||
.server-content-title {
|
||||
padding: var(--app-space-1);
|
||||
/* color: #ffffff; */
|
||||
}
|
||||
|
||||
.server-item {
|
||||
padding: var(--app-space-1);
|
||||
/* color: #ffffff; */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-item:hover {
|
||||
background: #d1d0d0;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.server-name,
|
||||
.server-info {
|
||||
margin: 0;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
color: #999;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.server-comment {
|
||||
font-size: 90%;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.server-add {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 150%;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.server-add:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.server-item-selected {
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.server-item-unread {
|
||||
border-left: 6px solid #1b98f8;
|
||||
}
|
||||
|
||||
.server-item-broken {
|
||||
border-left: 6px solid #880d06;
|
||||
}
|
||||
|
||||
|
||||
/* server Content Styles */
|
||||
.server-content-header,
|
||||
.server-content-body,
|
||||
.server-content-footer {
|
||||
padding: 1em 2em;
|
||||
}
|
||||
|
||||
.server-content-header {
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.server-content-title {
|
||||
margin: 0.5em 0 0;
|
||||
}
|
||||
|
||||
.server-content-subtitle {
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.server-content-subtitle span {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.server-content-controls {
|
||||
margin-top: 2em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.server-content-controls .secondary-button {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.server-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* -- TABLET (AND UP) MEDIA QUERIES --
|
||||
* On tablets and other medium-sized devices, we want to customize some
|
||||
* of the mobile styles.
|
||||
*/
|
||||
@media (min-width: 40em) {
|
||||
|
||||
/* These are position:fixed; elements that will be in the left 500px of the screen */
|
||||
#nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#nav {
|
||||
/* margin-left: -500px; */
|
||||
width: 150px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Show the menu items on the larger screen */
|
||||
.nav-inner {
|
||||
display: block;
|
||||
padding: 2em 0;
|
||||
}
|
||||
|
||||
/* Hide the "Menu" button on larger screens */
|
||||
#nav .nav-menu-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
#main {
|
||||
position: fixed;
|
||||
top: 33%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 150px;
|
||||
overflow: auto;
|
||||
width: auto;
|
||||
/* so that it's not 100% */
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
* -- DESKTOP (AND UP) MEDIA QUERIES --
|
||||
* On desktops and other large-sized devices, we want to customize some
|
||||
* of the mobile styles.
|
||||
*/
|
||||
@media (min-width: 60em) {
|
||||
|
||||
/* This will take up the entire height, and be a little thinner */
|
||||
|
||||
|
||||
/* This will now take up it's own column, so don't need position: fixed; */
|
||||
#main {
|
||||
position: static;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.alert {
|
||||
position: absolute;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
line-height: 1.8;
|
||||
border-radius: 5px;
|
||||
cursor: hand;
|
||||
cursor: pointer;
|
||||
font-family: sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.alertCheckbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:checked+.alert {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alertText {
|
||||
display: table;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
font-size: 150%;
|
||||
}
|
||||
|
||||
.alertClose {
|
||||
float: right;
|
||||
padding-top: 0px;
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: #EEE;
|
||||
border: 1px solid #DDD;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #EFE;
|
||||
border: 1px solid #DED;
|
||||
color: #9A9;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background-color: #EFF;
|
||||
border: 1px solid #DEE;
|
||||
color: #9AA;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: #FDF7DF;
|
||||
border: 1px solid #FEEC6F;
|
||||
color: #C9971C;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #FEE;
|
||||
border: 1px solid #EDD;
|
||||
color: #A66;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"menu entety-menu content";
|
||||
height: var(--app-h);
|
||||
justify-content: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#menu {
|
||||
grid-area: menu;
|
||||
width: 150px;
|
||||
height: 100%;
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
#entety-menu {
|
||||
grid-area: entety-menu;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#content {
|
||||
grid-area: content;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.srcollable-list-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 60em) {
|
||||
#entety-menu {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40em) {
|
||||
#layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"menu"
|
||||
"entety-menu"
|
||||
"content";
|
||||
grid-template-rows: min-content min-content;
|
||||
}
|
||||
|
||||
#menu {
|
||||
width: 100vw;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
#entety-menu {
|
||||
width: 100vw;
|
||||
height: initial;
|
||||
}
|
||||
|
||||
.srcollable-list-content{
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
column-gap: var(--app-space-1);
|
||||
}
|
||||
|
||||
.srcollable-list-content > * {
|
||||
scroll-snap-align: start;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 250px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.server-checkbox {
|
||||
display: flex;
|
||||
|
||||
}
|
||||
|
||||
.server-checkbox input {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.pure-form-message {
|
||||
padding: 0.2em;
|
||||
}
|
||||
#search-form {
|
||||
padding: var(--app-space-1);
|
||||
}
|
||||
#entety-menu-search {
|
||||
width: 100%;
|
||||
}
|
||||
11
static/pure.css
Executable file
11
static/pure.css
Executable file
File diff suppressed because one or more lines are too long
@@ -1,854 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.utils import timezone
|
||||
from django import forms
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from .models import BotSettings, TelegramMessage, AccessRequest
|
||||
from .localization import MessageLocalizer
|
||||
from vpn.models import User
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotSettingsAdminForm(forms.ModelForm):
|
||||
"""Custom form for BotSettings with Telegram admin selection"""
|
||||
|
||||
class Meta:
|
||||
model = BotSettings
|
||||
fields = '__all__'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Show all users for telegram_admins selection
|
||||
if 'telegram_admins' in self.fields:
|
||||
self.fields['telegram_admins'].queryset = User.objects.all().order_by('username')
|
||||
self.fields['telegram_admins'].help_text = (
|
||||
"Select users who will have admin access in the bot. "
|
||||
"Users will get admin rights when they connect to the bot with their Telegram account."
|
||||
)
|
||||
|
||||
def clean_telegram_admins(self):
|
||||
"""Validate that selected admins have telegram_user_id or telegram_username"""
|
||||
admins = self.cleaned_data.get('telegram_admins')
|
||||
# No validation needed - admins can be selected even without telegram connection
|
||||
# They will get admin rights when they connect via bot
|
||||
return admins
|
||||
|
||||
|
||||
class AccessRequestAdminForm(forms.ModelForm):
|
||||
"""Custom form for AccessRequest with existing user selection"""
|
||||
|
||||
class Meta:
|
||||
model = AccessRequest
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'selected_subscription_groups': FilteredSelectMultiple(
|
||||
verbose_name='Subscription Groups',
|
||||
is_stacked=False
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Rename the field for better UI
|
||||
if 'selected_existing_user' in self.fields:
|
||||
self.fields['selected_existing_user'].label = 'Link to existing user'
|
||||
self.fields['selected_existing_user'].empty_label = "— Create new user —"
|
||||
self.fields['selected_existing_user'].help_text = "Select an existing user without Telegram to link, or leave empty to create new user"
|
||||
# Get users without telegram_user_id
|
||||
from vpn.models import User
|
||||
self.fields['selected_existing_user'].queryset = User.objects.filter(
|
||||
telegram_user_id__isnull=True
|
||||
).order_by('username')
|
||||
|
||||
# Configure subscription group fields
|
||||
if 'selected_subscription_groups' in self.fields:
|
||||
from vpn.models_xray import SubscriptionGroup
|
||||
self.fields['selected_subscription_groups'].queryset = SubscriptionGroup.objects.filter(
|
||||
is_active=True
|
||||
).order_by('name')
|
||||
self.fields['selected_subscription_groups'].label = 'Subscription Groups'
|
||||
self.fields['selected_subscription_groups'].help_text = 'Select subscription groups to assign to this user'
|
||||
|
||||
|
||||
@admin.register(BotSettings)
|
||||
class BotSettingsAdmin(admin.ModelAdmin):
|
||||
form = BotSettingsAdminForm
|
||||
list_display = ('__str__', 'enabled', 'bot_token_display', 'admin_count_display', 'updated_at')
|
||||
fieldsets = (
|
||||
('Bot Configuration', {
|
||||
'fields': ('bot_token', 'enabled', 'bot_status_display'),
|
||||
'description': 'Configure bot settings and view current status'
|
||||
}),
|
||||
('Admin Management', {
|
||||
'fields': ('telegram_admins', 'admin_info_display'),
|
||||
'description': 'Select users with linked Telegram accounts who will have admin access in the bot'
|
||||
}),
|
||||
('Connection Settings', {
|
||||
'fields': ('api_base_url', 'connection_timeout', 'use_proxy', 'proxy_url'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
readonly_fields = ('created_at', 'updated_at', 'bot_status_display', 'admin_info_display')
|
||||
filter_horizontal = ('telegram_admins',)
|
||||
|
||||
def bot_token_display(self, obj):
|
||||
"""Mask bot token for security"""
|
||||
if obj.bot_token:
|
||||
token = obj.bot_token
|
||||
if len(token) > 10:
|
||||
return f"{token[:6]}...{token[-4:]}"
|
||||
return "Token set"
|
||||
return "No token set"
|
||||
bot_token_display.short_description = "Bot Token"
|
||||
|
||||
def admin_count_display(self, obj):
|
||||
"""Display count of Telegram admins"""
|
||||
count = obj.telegram_admins.count()
|
||||
if count == 0:
|
||||
return "No admins"
|
||||
elif count == 1:
|
||||
return "1 admin"
|
||||
else:
|
||||
return f"{count} admins"
|
||||
admin_count_display.short_description = "Telegram Admins"
|
||||
|
||||
def admin_info_display(self, obj):
|
||||
"""Display detailed admin information"""
|
||||
if not obj.pk:
|
||||
return "Save settings first to manage admins"
|
||||
|
||||
admins = obj.telegram_admins.all()
|
||||
|
||||
if not admins.exists():
|
||||
html = '<div style="background: #fff3cd; padding: 10px; border-radius: 4px; border-left: 4px solid #ffc107;">'
|
||||
html += '<p style="margin: 0; color: #856404;"><strong>⚠️ No Telegram admins configured</strong></p>'
|
||||
html += '<p style="margin: 5px 0 0 0; color: #856404;">Select users above to give them admin access in the Telegram bot.</p>'
|
||||
html += '</div>'
|
||||
else:
|
||||
html = '<div style="background: #d4edda; padding: 10px; border-radius: 4px; border-left: 4px solid #28a745;">'
|
||||
html += f'<p style="margin: 0; color: #155724;"><strong>✅ {admins.count()} Telegram admin(s) configured</strong></p>'
|
||||
html += '<div style="margin-top: 8px;">'
|
||||
|
||||
for admin in admins:
|
||||
html += '<div style="background: white; margin: 4px 0; padding: 6px 10px; border-radius: 3px; border: 1px solid #c3e6cb;">'
|
||||
html += f'<strong>{admin.username}</strong>'
|
||||
|
||||
if admin.telegram_username:
|
||||
html += f' (@{admin.telegram_username})'
|
||||
|
||||
html += f' <small style="color: #6c757d;">ID: {admin.telegram_user_id}</small>'
|
||||
|
||||
if admin.first_name or admin.last_name:
|
||||
name_parts = []
|
||||
if admin.first_name:
|
||||
name_parts.append(admin.first_name)
|
||||
if admin.last_name:
|
||||
name_parts.append(admin.last_name)
|
||||
html += f'<br><small style="color: #6c757d;">Name: {" ".join(name_parts)}</small>'
|
||||
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
html += '<p style="margin: 8px 0 0 0; color: #155724; font-size: 12px;">These users will receive notifications about new access requests and can approve/reject them directly in Telegram.</p>'
|
||||
html += '</div>'
|
||||
|
||||
return format_html(html)
|
||||
admin_info_display.short_description = "Admin Configuration"
|
||||
|
||||
def bot_status_display(self, obj):
|
||||
"""Display bot status with control buttons"""
|
||||
from .bot import TelegramBotManager
|
||||
import os
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
manager = TelegramBotManager()
|
||||
|
||||
# Check if lock file exists - only reliable indicator
|
||||
lock_dir = os.path.join(getattr(django_settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks')
|
||||
lock_path = os.path.join(lock_dir, 'telegram_bot.lock')
|
||||
is_running = os.path.exists(lock_path)
|
||||
|
||||
if is_running:
|
||||
status_html = '<span style="color: green; font-weight: bold;">🟢 Bot is RUNNING</span>'
|
||||
else:
|
||||
status_html = '<span style="color: red; font-weight: bold;">🔴 Bot is STOPPED</span>'
|
||||
|
||||
# Add control buttons
|
||||
status_html += '<br><br>'
|
||||
if is_running:
|
||||
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_stop_bot")}">Stop Bot</a> '
|
||||
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_restart_bot")}">Restart Bot</a>'
|
||||
else:
|
||||
if obj.enabled and obj.bot_token:
|
||||
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_start_bot")}">Start Bot</a>'
|
||||
else:
|
||||
status_html += '<span style="color: gray;">Configure bot token and enable bot to start</span>'
|
||||
|
||||
return format_html(status_html)
|
||||
bot_status_display.short_description = "Bot Status"
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('start-bot/', self.start_bot, name='telegram_bot_start_bot'),
|
||||
path('stop-bot/', self.stop_bot, name='telegram_bot_stop_bot'),
|
||||
path('restart-bot/', self.restart_bot, name='telegram_bot_restart_bot'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def start_bot(self, request):
|
||||
"""Start the telegram bot"""
|
||||
try:
|
||||
from .bot import TelegramBotManager
|
||||
manager = TelegramBotManager()
|
||||
manager.start()
|
||||
messages.success(request, "Bot started successfully!")
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to start bot: {e}")
|
||||
logger.error(f"Failed to start bot: {e}")
|
||||
|
||||
return redirect('admin:telegram_bot_botsettings_change', object_id=1)
|
||||
|
||||
def stop_bot(self, request):
|
||||
"""Stop the telegram bot"""
|
||||
try:
|
||||
from .bot import TelegramBotManager
|
||||
manager = TelegramBotManager()
|
||||
manager.stop()
|
||||
messages.success(request, "Bot stopped successfully!")
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to stop bot: {e}")
|
||||
logger.error(f"Failed to stop bot: {e}")
|
||||
|
||||
return redirect('admin:telegram_bot_botsettings_change', object_id=1)
|
||||
|
||||
def restart_bot(self, request):
|
||||
"""Restart the telegram bot"""
|
||||
try:
|
||||
from .bot import TelegramBotManager
|
||||
manager = TelegramBotManager()
|
||||
manager.restart()
|
||||
messages.success(request, "Bot restarted successfully!")
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to restart bot: {e}")
|
||||
logger.error(f"Failed to restart bot: {e}")
|
||||
|
||||
return redirect('admin:telegram_bot_botsettings_change', object_id=1)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Prevent creating multiple instances
|
||||
return not BotSettings.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
# Prevent deletion
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(TelegramMessage)
|
||||
class TelegramMessageAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'created_at',
|
||||
'direction_display',
|
||||
'user_display',
|
||||
'language_display',
|
||||
'message_preview',
|
||||
'linked_user'
|
||||
)
|
||||
list_filter = (
|
||||
'direction',
|
||||
'created_at',
|
||||
('linked_user', admin.EmptyFieldListFilter),
|
||||
)
|
||||
search_fields = (
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
'message_text',
|
||||
'telegram_user_id'
|
||||
)
|
||||
readonly_fields = (
|
||||
'direction',
|
||||
'telegram_user_id',
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
'chat_id',
|
||||
'message_id',
|
||||
'message_text',
|
||||
'raw_data_display',
|
||||
'created_at',
|
||||
'linked_user',
|
||||
'user_language'
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Message Info', {
|
||||
'fields': (
|
||||
'direction',
|
||||
'message_text',
|
||||
'created_at'
|
||||
)
|
||||
}),
|
||||
('Telegram User', {
|
||||
'fields': (
|
||||
'telegram_user_id',
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
)
|
||||
}),
|
||||
('Technical Details', {
|
||||
'fields': (
|
||||
'chat_id',
|
||||
'message_id',
|
||||
'linked_user',
|
||||
'raw_data_display'
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
ordering = ['-created_at']
|
||||
list_per_page = 50
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
def direction_display(self, obj):
|
||||
"""Display direction with icon"""
|
||||
if obj.direction == 'incoming':
|
||||
return format_html('<span style="color: blue;">⬇️ Incoming</span>')
|
||||
else:
|
||||
return format_html('<span style="color: green;">⬆️ Outgoing</span>')
|
||||
direction_display.short_description = "Direction"
|
||||
|
||||
def user_display(self, obj):
|
||||
"""Display user info"""
|
||||
display = obj.display_name
|
||||
if obj.telegram_user_id:
|
||||
display += f" (ID: {obj.telegram_user_id})"
|
||||
return display
|
||||
user_display.short_description = "Telegram User"
|
||||
|
||||
def language_display(self, obj):
|
||||
"""Display user language"""
|
||||
lang_map = {'ru': '🇷🇺 RU', 'en': '🇺🇸 EN'}
|
||||
return lang_map.get(obj.user_language, obj.user_language or 'Unknown')
|
||||
language_display.short_description = "Language"
|
||||
|
||||
def message_preview(self, obj):
|
||||
"""Show message preview"""
|
||||
if len(obj.message_text) > 100:
|
||||
return obj.message_text[:100] + "..."
|
||||
return obj.message_text
|
||||
message_preview.short_description = "Message"
|
||||
|
||||
def raw_data_display(self, obj):
|
||||
"""Display raw data as formatted JSON"""
|
||||
import json
|
||||
if obj.raw_data:
|
||||
formatted = json.dumps(obj.raw_data, indent=2, ensure_ascii=False)
|
||||
return format_html('<pre style="max-width: 800px; overflow: auto;">{}</pre>', formatted)
|
||||
return "No raw data"
|
||||
raw_data_display.short_description = "Raw Data"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Messages are created automatically by bot
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
# Messages are read-only
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
# Allow deletion for cleanup
|
||||
return request.user.is_superuser
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Add custom actions"""
|
||||
actions = super().get_actions(request)
|
||||
if not request.user.is_superuser:
|
||||
# Remove delete action for non-superusers
|
||||
if 'delete_selected' in actions:
|
||||
del actions['delete_selected']
|
||||
return actions
|
||||
|
||||
|
||||
@admin.register(AccessRequest)
|
||||
class AccessRequestAdmin(admin.ModelAdmin):
|
||||
form = AccessRequestAdminForm
|
||||
list_display = (
|
||||
'created_at',
|
||||
'user_display',
|
||||
'approved_display',
|
||||
'language_display',
|
||||
'desired_username_display',
|
||||
'message_preview',
|
||||
'created_user',
|
||||
'processed_by'
|
||||
)
|
||||
list_filter = (
|
||||
'approved',
|
||||
'created_at',
|
||||
'processed_at',
|
||||
('processed_by', admin.EmptyFieldListFilter),
|
||||
)
|
||||
search_fields = (
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
'telegram_user_id',
|
||||
'message_text'
|
||||
)
|
||||
readonly_fields = (
|
||||
'telegram_user_id',
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
'message_text',
|
||||
'chat_id',
|
||||
'created_at',
|
||||
'first_message',
|
||||
'processed_at',
|
||||
'processed_by',
|
||||
'created_user',
|
||||
'user_language'
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Request Info', {
|
||||
'fields': (
|
||||
'approved',
|
||||
'admin_comment',
|
||||
'created_at',
|
||||
'processed_at',
|
||||
'processed_by'
|
||||
)
|
||||
}),
|
||||
('User Creation', {
|
||||
'fields': (
|
||||
'selected_existing_user',
|
||||
'desired_username',
|
||||
),
|
||||
'description': 'Choose existing user to link OR specify username for new user'
|
||||
}),
|
||||
('VPN Access Configuration', {
|
||||
'fields': (
|
||||
'selected_subscription_groups',
|
||||
),
|
||||
'description': 'Select subscription groups to assign to the user'
|
||||
}),
|
||||
('Telegram User', {
|
||||
'fields': (
|
||||
'telegram_user_id',
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
)
|
||||
}),
|
||||
('Message Details', {
|
||||
'fields': (
|
||||
'message_text',
|
||||
'chat_id',
|
||||
'first_message'
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Processing Results', {
|
||||
'fields': (
|
||||
'created_user',
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
ordering = ['-created_at']
|
||||
list_per_page = 50
|
||||
date_hierarchy = 'created_at'
|
||||
actions = ['approve_requests']
|
||||
|
||||
def user_display(self, obj):
|
||||
"""Display user info"""
|
||||
return obj.display_name
|
||||
user_display.short_description = "Telegram User"
|
||||
|
||||
def approved_display(self, obj):
|
||||
"""Display approved status with colors"""
|
||||
if obj.approved:
|
||||
return format_html('<span style="color: green; font-weight: bold;">✅ Approved</span>')
|
||||
else:
|
||||
return format_html('<span style="color: orange; font-weight: bold;">🔄 Pending</span>')
|
||||
approved_display.short_description = "Status"
|
||||
|
||||
def message_preview(self, obj):
|
||||
"""Show message preview"""
|
||||
if len(obj.message_text) > 100:
|
||||
return obj.message_text[:100] + "..."
|
||||
return obj.message_text
|
||||
message_preview.short_description = "Message"
|
||||
|
||||
def desired_username_display(self, obj):
|
||||
"""Display desired username"""
|
||||
if obj.desired_username:
|
||||
return obj.desired_username
|
||||
else:
|
||||
fallback = obj.telegram_username or obj.telegram_first_name or f"tg_{obj.telegram_user_id}"
|
||||
return format_html('<span style="color: gray; font-style: italic;">{}</span>', fallback)
|
||||
desired_username_display.short_description = "Desired Username"
|
||||
|
||||
def language_display(self, obj):
|
||||
"""Display user language with flag"""
|
||||
lang_map = {'ru': '🇷🇺 RU', 'en': '🇺🇸 EN'}
|
||||
return lang_map.get(obj.user_language, obj.user_language or 'Unknown')
|
||||
language_display.short_description = "Language"
|
||||
|
||||
def approve_requests(self, request, queryset):
|
||||
"""Approve selected access requests"""
|
||||
pending_requests = queryset.filter(approved=False)
|
||||
count = 0
|
||||
errors = []
|
||||
|
||||
for access_request in pending_requests:
|
||||
try:
|
||||
logger.info(f"Approving request {access_request.id} from user {access_request.telegram_user_id}")
|
||||
user = self._create_user_from_request(access_request, request.user)
|
||||
|
||||
if user:
|
||||
access_request.approved = True
|
||||
access_request.processed_by = request.user
|
||||
access_request.processed_at = timezone.now()
|
||||
access_request.created_user = user
|
||||
access_request.save()
|
||||
|
||||
logger.info(f"Successfully approved request {access_request.id}, created user {user.username}")
|
||||
|
||||
# Send notification to user
|
||||
self._send_approval_notification(access_request)
|
||||
count += 1
|
||||
else:
|
||||
errors.append(f"Failed to create user for {access_request.display_name}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to approve request from {access_request.display_name}: {e}"
|
||||
logger.error(error_msg)
|
||||
errors.append(error_msg)
|
||||
|
||||
if count:
|
||||
messages.success(request, f"Successfully approved {count} request(s)")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
messages.error(request, error)
|
||||
|
||||
approve_requests.short_description = "✅ Approve selected requests"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Override save to handle existing user linking"""
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
# If approved and existing user was selected, link them
|
||||
if obj.approved and obj.selected_existing_user and not obj.created_user:
|
||||
try:
|
||||
# Link telegram data to selected user
|
||||
obj.selected_existing_user.telegram_user_id = obj.telegram_user_id
|
||||
obj.selected_existing_user.telegram_username = obj.telegram_username
|
||||
obj.selected_existing_user.telegram_first_name = obj.telegram_first_name or ""
|
||||
obj.selected_existing_user.telegram_last_name = obj.telegram_last_name or ""
|
||||
obj.selected_existing_user.save()
|
||||
|
||||
# Update the request to reference the linked user
|
||||
obj.created_user = obj.selected_existing_user
|
||||
obj.processed_by = request.user
|
||||
obj.processed_at = timezone.now()
|
||||
obj.save()
|
||||
|
||||
# Assign VPN access to the linked user
|
||||
try:
|
||||
self._assign_vpn_access(obj.selected_existing_user, obj)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assign VPN access: {e}")
|
||||
messages.warning(request, f"User linked but VPN access assignment failed: {e}")
|
||||
|
||||
# Send notification
|
||||
self._send_approval_notification(obj)
|
||||
|
||||
messages.success(request, f"Successfully linked Telegram user to existing user {obj.selected_existing_user.username}")
|
||||
logger.info(f"Linked Telegram user {obj.telegram_user_id} to existing user {obj.selected_existing_user.username}")
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to link existing user: {e}")
|
||||
logger.error(f"Failed to link existing user: {e}")
|
||||
|
||||
def _create_user_from_request(self, access_request, admin_user):
|
||||
"""Create User from AccessRequest or link to existing user"""
|
||||
from vpn.models import User
|
||||
import secrets
|
||||
import string
|
||||
|
||||
try:
|
||||
# Check if user already exists by telegram_user_id
|
||||
existing_user = User.objects.filter(telegram_user_id=access_request.telegram_user_id).first()
|
||||
if existing_user:
|
||||
logger.info(f"User already exists: {existing_user.username}")
|
||||
return existing_user
|
||||
|
||||
# Check if admin selected an existing user to link
|
||||
if access_request.selected_existing_user:
|
||||
selected_user = access_request.selected_existing_user
|
||||
logger.info(f"Linking Telegram user {access_request.telegram_user_id} to selected existing user {selected_user.username}")
|
||||
|
||||
# Link telegram data to selected user
|
||||
selected_user.telegram_user_id = access_request.telegram_user_id
|
||||
selected_user.telegram_username = access_request.telegram_username
|
||||
selected_user.telegram_first_name = access_request.telegram_first_name or ""
|
||||
selected_user.telegram_last_name = access_request.telegram_last_name or ""
|
||||
selected_user.save()
|
||||
|
||||
# Assign VPN access to the linked user
|
||||
try:
|
||||
self._assign_vpn_access(selected_user, access_request)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assign VPN access to user {selected_user.username}: {e}")
|
||||
|
||||
return selected_user
|
||||
|
||||
# Check if we can link to existing user by telegram_username
|
||||
if access_request.telegram_username:
|
||||
existing_user_by_username = User.objects.filter(
|
||||
telegram_username__iexact=access_request.telegram_username,
|
||||
telegram_user_id__isnull=True # Not yet linked to Telegram
|
||||
).first()
|
||||
|
||||
if existing_user_by_username:
|
||||
# Link telegram data to existing user
|
||||
logger.info(f"Linking Telegram @{access_request.telegram_username} to existing user {existing_user_by_username.username}")
|
||||
existing_user_by_username.telegram_user_id = access_request.telegram_user_id
|
||||
existing_user_by_username.telegram_username = access_request.telegram_username
|
||||
existing_user_by_username.telegram_first_name = access_request.telegram_first_name or ""
|
||||
existing_user_by_username.telegram_last_name = access_request.telegram_last_name or ""
|
||||
existing_user_by_username.save()
|
||||
|
||||
# Assign VPN access to the linked user
|
||||
try:
|
||||
self._assign_vpn_access(existing_user_by_username, access_request)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assign VPN access to user {existing_user_by_username.username}: {e}")
|
||||
|
||||
return existing_user_by_username
|
||||
|
||||
# Use desired_username if provided, otherwise fallback to Telegram data
|
||||
username = access_request.desired_username
|
||||
if not username:
|
||||
# Fallback to telegram_username, first_name or user_id
|
||||
username = access_request.telegram_username or access_request.telegram_first_name or f"tg_{access_request.telegram_user_id}"
|
||||
|
||||
# Clean username (remove special characters)
|
||||
username = ''.join(c for c in username if c.isalnum() or c in '_-').lower()
|
||||
if not username:
|
||||
username = f"tg_{access_request.telegram_user_id}"
|
||||
|
||||
# Make sure username is unique
|
||||
original_username = username
|
||||
counter = 1
|
||||
while User.objects.filter(username=username).exists():
|
||||
username = f"{original_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create new user since no existing user found to link
|
||||
# Generate random password
|
||||
password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
|
||||
|
||||
logger.info(f"Creating new user with username: {username}")
|
||||
|
||||
# Create user
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
password=password,
|
||||
first_name=access_request.telegram_first_name or "",
|
||||
last_name=access_request.telegram_last_name or "",
|
||||
telegram_user_id=access_request.telegram_user_id,
|
||||
telegram_username=access_request.telegram_username or "",
|
||||
telegram_first_name=access_request.telegram_first_name or "",
|
||||
telegram_last_name=access_request.telegram_last_name or "",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created user {user.username} (ID: {user.id}) from Telegram request {access_request.id}")
|
||||
|
||||
# Assign VPN access (inbounds and subscription groups)
|
||||
try:
|
||||
self._assign_vpn_access(user, access_request)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assign VPN access to user {user.username}: {e}")
|
||||
# Continue even if VPN assignment fails - user is already created
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user from request {access_request.id}: {e}")
|
||||
raise
|
||||
|
||||
def _assign_vpn_access(self, user, access_request):
|
||||
"""Assign selected subscription groups to the user"""
|
||||
try:
|
||||
from vpn.models_xray import UserSubscription
|
||||
|
||||
# Assign subscription groups
|
||||
group_count = 0
|
||||
for subscription_group in access_request.selected_subscription_groups.all():
|
||||
user_subscription, created = UserSubscription.objects.get_or_create(
|
||||
user=user,
|
||||
subscription_group=subscription_group,
|
||||
defaults={'active': True}
|
||||
)
|
||||
if created:
|
||||
logger.info(f"Assigned subscription group '{subscription_group.name}' to user {user.username}")
|
||||
group_count += 1
|
||||
else:
|
||||
# Ensure it's active if it already existed
|
||||
if not user_subscription.active:
|
||||
user_subscription.active = True
|
||||
user_subscription.save()
|
||||
logger.info(f"Re-activated subscription group '{subscription_group.name}' for user {user.username}")
|
||||
group_count += 1
|
||||
|
||||
logger.info(f"Successfully assigned {group_count} subscription groups to user {user.username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error assigning VPN access to user {user.username}: {e}")
|
||||
raise
|
||||
|
||||
def _send_approval_notification(self, access_request):
|
||||
"""Send approval notification via Telegram"""
|
||||
try:
|
||||
from .models import BotSettings
|
||||
from telegram import Bot
|
||||
import asyncio
|
||||
|
||||
settings = BotSettings.get_settings()
|
||||
if not settings.enabled or not settings.bot_token:
|
||||
logger.warning("Bot not configured, skipping notification")
|
||||
return
|
||||
|
||||
# Create a simple Bot instance for sending notification
|
||||
# This bypasses the need for the running bot manager
|
||||
async def send_notification():
|
||||
try:
|
||||
# Create bot with custom request settings
|
||||
from telegram.request import HTTPXRequest
|
||||
|
||||
request_kwargs = {
|
||||
'connection_pool_size': 1,
|
||||
'read_timeout': settings.connection_timeout,
|
||||
'write_timeout': settings.connection_timeout,
|
||||
'connect_timeout': settings.connection_timeout,
|
||||
}
|
||||
|
||||
if settings.use_proxy and settings.proxy_url:
|
||||
request_kwargs['proxy'] = settings.proxy_url
|
||||
|
||||
request = HTTPXRequest(**request_kwargs)
|
||||
bot = Bot(token=settings.bot_token, request=request)
|
||||
|
||||
# Send localized approval message with new keyboard
|
||||
from telegram import ReplyKeyboardMarkup, KeyboardButton
|
||||
language = access_request.user_language or 'en'
|
||||
|
||||
# Get localized texts
|
||||
message = MessageLocalizer.get_message('approval_notification', language)
|
||||
access_btn_text = MessageLocalizer.get_button_text('access', language)
|
||||
|
||||
# Create keyboard with Access button
|
||||
keyboard = [[KeyboardButton(access_btn_text)]]
|
||||
reply_markup = ReplyKeyboardMarkup(
|
||||
keyboard,
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=False
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=access_request.telegram_user_id,
|
||||
text=message,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
|
||||
logger.info(f"Sent approval notification to {access_request.telegram_user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram message: {e}")
|
||||
finally:
|
||||
try:
|
||||
# Clean up bot connection
|
||||
await request.shutdown()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Run in thread to avoid blocking admin interface
|
||||
import threading
|
||||
|
||||
def run_async_notification():
|
||||
try:
|
||||
asyncio.run(send_notification())
|
||||
except Exception as e:
|
||||
logger.error(f"Error in notification thread: {e}")
|
||||
|
||||
thread = threading.Thread(target=run_async_notification, daemon=True)
|
||||
thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send approval notification: {e}")
|
||||
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Requests are created by bot
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
# Allow changing only status and comment
|
||||
return True
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Automatically handle approval and user creation"""
|
||||
# Check if this is a change to approved
|
||||
was_approved = False
|
||||
|
||||
# If desired_username was changed and is empty, set default from Telegram data
|
||||
if change and 'desired_username' in form.changed_data and not obj.desired_username:
|
||||
obj.desired_username = obj.telegram_username or obj.telegram_first_name or f"tg_{obj.telegram_user_id}"
|
||||
|
||||
if change and 'approved' in form.changed_data and obj.approved:
|
||||
# Set processed_by and processed_at
|
||||
if not obj.processed_by:
|
||||
obj.processed_by = request.user
|
||||
if not obj.processed_at:
|
||||
obj.processed_at = timezone.now()
|
||||
was_approved = True
|
||||
|
||||
# If approved and no user created yet, create user
|
||||
if was_approved and not obj.created_user:
|
||||
try:
|
||||
logger.info(f"Auto-creating user for approved request {obj.id}")
|
||||
user = self._create_user_from_request(obj, request.user)
|
||||
if user:
|
||||
obj.created_user = user
|
||||
messages.success(request, f"User '{user.username}' created successfully!")
|
||||
logger.info(f"Auto-created user {user.username} for request {obj.id}")
|
||||
|
||||
# Send approval notification
|
||||
self._send_approval_notification(obj)
|
||||
else:
|
||||
messages.error(request, f"Failed to create user for approved request {obj.id}")
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error creating user: {e}")
|
||||
logger.error(f"Error auto-creating user for request {obj.id}: {e}")
|
||||
|
||||
# Save the object
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramBotConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'telegram_bot'
|
||||
|
||||
def ready(self):
|
||||
"""Called when Django starts - attempt to auto-start bot if enabled"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Skip auto-start in various scenarios
|
||||
skip_conditions = [
|
||||
# Management commands
|
||||
'migrate' in sys.argv,
|
||||
'makemigrations' in sys.argv,
|
||||
'collectstatic' in sys.argv,
|
||||
'shell' in sys.argv,
|
||||
'test' in sys.argv,
|
||||
# Celery processes
|
||||
'celery' in sys.argv,
|
||||
'worker' in sys.argv,
|
||||
'beat' in sys.argv,
|
||||
# Environment variables that indicate worker/beat processes
|
||||
os.environ.get('CELERY_WORKER_NAME'),
|
||||
os.environ.get('CELERY_BEAT'),
|
||||
# Process name detection
|
||||
any('celery' in arg.lower() for arg in sys.argv),
|
||||
any('worker' in arg.lower() for arg in sys.argv),
|
||||
any('beat' in arg.lower() for arg in sys.argv),
|
||||
]
|
||||
|
||||
if any(skip_conditions):
|
||||
logger.info(f"Skipping Telegram bot auto-start in process: {' '.join(sys.argv)}")
|
||||
return
|
||||
|
||||
# Additional process detection by checking if we're in main process
|
||||
try:
|
||||
# Check if this is the main Django process (not a worker)
|
||||
current_process = os.environ.get('DJANGO_SETTINGS_MODULE')
|
||||
if not current_process:
|
||||
logger.info("Skipping bot auto-start: not in main Django process")
|
||||
return
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delay import to avoid circular imports
|
||||
try:
|
||||
from .bot import TelegramBotManager
|
||||
import threading
|
||||
import time
|
||||
|
||||
def delayed_autostart():
|
||||
# Wait a bit for Django to fully initialize
|
||||
time.sleep(2)
|
||||
try:
|
||||
manager = TelegramBotManager()
|
||||
if manager.auto_start_if_enabled():
|
||||
logger.info("Telegram bot auto-started successfully")
|
||||
else:
|
||||
logger.info("Telegram bot auto-start skipped (disabled or already running)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-start Telegram bot: {e}")
|
||||
|
||||
logger.info("Starting Telegram bot auto-start thread")
|
||||
# Start in background thread to not block Django startup
|
||||
thread = threading.Thread(target=delayed_autostart, daemon=True)
|
||||
thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up Telegram bot auto-start: {e}")
|
||||
1897
telegram_bot/bot.py
1897
telegram_bot/bot.py
File diff suppressed because it is too large
Load Diff
@@ -1,267 +0,0 @@
|
||||
"""
|
||||
Message localization for Telegram bot
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Translation dictionaries
|
||||
MESSAGES = {
|
||||
'en': {
|
||||
'help_text': "📋 Welcome! Use buttons below to navigate.\n\n📊 Access - View your VPN subscriptions\n\nFor support contact administrator.",
|
||||
'access_request_created': "Access request created, please wait.",
|
||||
'new_user_welcome': "Welcome! To get access to VPN services, please request access using the button below.",
|
||||
'pending_request_msg': "Your access request is pending approval. Please wait for administrator to review it.",
|
||||
'choose_subscription': "**Choose subscription option:**",
|
||||
'all_in_one_desc': "🌍 **All-in-one** - Get all subscriptions in one link",
|
||||
'group_desc': "**Group** - Get specific group subscription",
|
||||
'select_option': "Select an option below:",
|
||||
'no_subscriptions': "❌ You don't have any active Xray subscriptions.\n\nPlease contact administrator for access.",
|
||||
'group_subscription': "**Group: {group_name}**",
|
||||
'subscription_link': "**🔗 Subscription Link:**",
|
||||
'web_portal': "**🌐 Web Portal:**",
|
||||
'tap_to_copy': "_Tap the subscription link to copy it. Use it in your Xray client._",
|
||||
'all_in_one_subscription': "🌍 **All-in-one Subscription**",
|
||||
'your_access_includes': "**Your Access Includes:**",
|
||||
'universal_subscription_link': "**🔗 Universal Subscription Link:**",
|
||||
'all_subscriptions_note': "_This link includes all your active subscriptions. Tap to copy._",
|
||||
'error_loading_subscriptions': "❌ Error loading subscriptions. Please try again later.",
|
||||
'error_loading_group': "❌ Error loading group subscription. Please try again later.",
|
||||
'received_content': "Received your {message_type}. An administrator will review it.",
|
||||
'approval_notification': "✅ Access approved!",
|
||||
'content_types': {
|
||||
'photo': 'photo',
|
||||
'document': 'document',
|
||||
'voice': 'voice',
|
||||
'video': 'video',
|
||||
'content': 'content'
|
||||
},
|
||||
'guide_title': "📖 **VPN Setup Guide**",
|
||||
'guide_choose_platform': "Select your device platform:",
|
||||
'web_portal_description': "_Web portal shows your access list on one convenient page with some statistics._",
|
||||
'servers_in_group': "🔒 **Servers in group:**",
|
||||
|
||||
# Admin messages
|
||||
'admin_new_request_notification': "🔔 **New Access Request**\n\n👤 **User:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Date:** {date}\n\n💬 **Message:** {message}",
|
||||
'admin_access_requests_title': "📋 **Pending Access Requests**",
|
||||
'admin_no_pending_requests': "✅ No pending access requests",
|
||||
'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_",
|
||||
'admin_choose_subscription_groups': "📦 **Choose Subscription Groups for {user_info}:**\n\nSelect groups to assign to this user:",
|
||||
'admin_approval_success': "✅ **Request Approved!**\n\n👤 User: {user_info}\n📦 Groups: {groups}\n\nUser has been notified and given access.",
|
||||
'admin_rejection_success': "❌ **Request Rejected**\n\n👤 User: {user_info}\n\nUser has been notified.",
|
||||
'admin_request_already_processed': "⚠️ This request has already been processed by another admin.",
|
||||
'admin_error_processing': "❌ Error processing request: {error}",
|
||||
|
||||
'android_guide': "🤖 **Android Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites\n• You can choose specific apps to use VPN while others use direct connection\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.",
|
||||
'ios_guide': " **iOS Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**⚠️ Note for iOS users:**\nCurrently, only VLESS protocol works reliably on iOS. Other protocols may have connectivity issues.\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites to improve performance\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.",
|
||||
'buttons': {
|
||||
'access': "🌍 Get access",
|
||||
'guide': "📖 Guide",
|
||||
'android': "🤖 Android",
|
||||
'ios': " iOS",
|
||||
'web_portal': "🌐 Web Portal",
|
||||
'all_in_one': "🌍 All-in-one",
|
||||
'back': "⬅️ Back",
|
||||
'group_prefix': "Group: ",
|
||||
'request_access': "🔑 Request Access",
|
||||
# Admin buttons
|
||||
'access_requests': "📋 Access Requests",
|
||||
'approve': "✅ Approve",
|
||||
'reject': "❌ Reject",
|
||||
'details': "👁 Details",
|
||||
'confirm_approval': "✅ Confirm Approval",
|
||||
'confirm_rejection': "❌ Confirm Rejection",
|
||||
'cancel': "🚫 Cancel"
|
||||
}
|
||||
},
|
||||
'ru': {
|
||||
'help_text': "📋 Добро пожаловать! Используйте кнопки ниже для навигации.\n\n📊 Доступ - Просмотр VPN подписок\n\nДля поддержки обратитесь к администратору.",
|
||||
'access_request_created': "Запрос на доступ создан, ожидайте.",
|
||||
'new_user_welcome': "Добро пожаловать! Для получения доступа к VPN сервисам, пожалуйста запросите доступ с помощью кнопки ниже.",
|
||||
'pending_request_msg': "Ваш запрос на доступ ожидает одобрения. Пожалуйста, дождитесь рассмотрения администратором.",
|
||||
'choose_subscription': "**Выберите вариант подписки:**",
|
||||
'all_in_one_desc': "🌍 **Все в одном** - Получить все подписки в одной ссылке",
|
||||
'group_desc': "**Группа** - Получить подписку на группу",
|
||||
'select_option': "Выберите вариант ниже:",
|
||||
'no_subscriptions': "❌ У вас нет активных Xray подписок.\n\nОбратитесь к администратору для получения доступа.",
|
||||
'group_subscription': "**Группа: {group_name}**",
|
||||
'subscription_link': "**🔗 **",
|
||||
'web_portal': "**🌐 Веб-портал пользователя:**",
|
||||
'tap_to_copy': "_Нажмите на ссылку чтобы скопировать. Используйте в вашем Xray клиенте как подписку._",
|
||||
'all_in_one_subscription': "🌍 **Подписка «Все в одном»**",
|
||||
'your_access_includes': "**Ваш доступ включает:**",
|
||||
'universal_subscription_link': "**🔗 Универсальная ссылка на подписку:**",
|
||||
'all_subscriptions_note': "_Эта ссылка включает все ваши активные подписки. Нажмите чтобы скопировать._",
|
||||
'error_loading_subscriptions': "❌ Ошибка загрузки подписок. Попробуйте позже.",
|
||||
'error_loading_group': "❌ Ошибка загрузки подписки группы. Попробуйте позже.",
|
||||
'received_content': "Получен ваш {message_type}. Администратор его рассмотрит.",
|
||||
'approval_notification': "✅ Доступ одобрен!",
|
||||
'content_types': {
|
||||
'photo': 'фото',
|
||||
'document': 'документ',
|
||||
'voice': 'голосовое сообщение',
|
||||
'video': 'видео',
|
||||
'content': 'контент'
|
||||
},
|
||||
'guide_title': "📖 **Руководство по настройке VPN**",
|
||||
'guide_choose_platform': "Выберите платформу вашего устройства:",
|
||||
'web_portal_description': "_Веб-портал показывает список ваших доступов на одной удобной странице с некоторой статистикой._",
|
||||
'servers_in_group': "🔒 **Серверы в группе:**",
|
||||
|
||||
# Admin messages
|
||||
'admin_new_request_notification': "🔔 **Новый запрос на доступ**\n\n👤 **Пользователь:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Дата:** {date}\n\n💬 **Сообщение:** {message}",
|
||||
'admin_access_requests_title': "📋 **Ожидающие запросы на доступ**",
|
||||
'admin_no_pending_requests': "✅ Нет ожидающих запросов на доступ",
|
||||
'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_",
|
||||
'admin_choose_subscription_groups': "📦 **Выберите группы подписки для {user_info}:**\n\nВыберите группы для назначения этому пользователю:",
|
||||
'admin_approval_success': "✅ **Запрос одобрен!**\n\n👤 Пользователь: {user_info}\n📦 Группы: {groups}\n\nПользователь уведомлен и получил доступ.",
|
||||
'admin_rejection_success': "❌ **Запрос отклонен**\n\n👤 Пользователь: {user_info}\n\nПользователь уведомлен.",
|
||||
'admin_request_already_processed': "⚠️ Этот запрос уже обработан другим администратором.",
|
||||
'admin_error_processing': "❌ Ошибка обработки запроса: {error}",
|
||||
|
||||
'android_guide': "🤖 **Руководство для Android**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**💡 Полезные настройки:**\n• В настройках включите прямой доступ для банковских приложений и местных сайтов\n• Вы можете выбрать конкретные приложения для использования VPN, в то время как остальные будут работать напрямую\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.",
|
||||
'ios_guide': " **Руководство для iOS**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**⚠️ Важно для пользователей iOS:**\nВ настоящее время на iOS стабильно работает только протокол VLESS. Другие протоколы могут иметь проблемы с подключением.\n\n**💡 Полезные настройки:**\n• В настройках включите прямой доступ для банковских приложений и местных сайтов для улучшения производительности\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.",
|
||||
'buttons': {
|
||||
'access': "🌍 Получить VPN",
|
||||
'guide': "📖 Гайд",
|
||||
'android': "🤖 Android",
|
||||
'ios': " iOS",
|
||||
'web_portal': "🌐 Веб-портал",
|
||||
'all_in_one': "🌍 Все в одном",
|
||||
'back': "⬅️ Назад",
|
||||
'group_prefix': "Группа: ",
|
||||
'request_access': "🔑 Запросить доступ",
|
||||
# Admin buttons
|
||||
'access_requests': "📋 Запросы на доступ",
|
||||
'approve': "✅ Одобрить",
|
||||
'reject': "❌ Отклонить",
|
||||
'details': "👁 Подробности",
|
||||
'confirm_approval': "✅ Подтвердить одобрение",
|
||||
'confirm_rejection': "❌ Подтвердить отклонение",
|
||||
'cancel': "🚫 Отмена"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MessageLocalizer:
|
||||
"""Class for bot message localization"""
|
||||
|
||||
@staticmethod
|
||||
def get_user_language(telegram_user) -> str:
|
||||
"""
|
||||
Determines user language from Telegram language_code
|
||||
|
||||
Args:
|
||||
telegram_user: Telegram user object
|
||||
|
||||
Returns:
|
||||
str: Language code ('ru' or 'en')
|
||||
"""
|
||||
if not telegram_user:
|
||||
return 'en'
|
||||
|
||||
language_code = getattr(telegram_user, 'language_code', None)
|
||||
|
||||
if not language_code:
|
||||
return 'en'
|
||||
|
||||
# Support Russian and English
|
||||
if language_code.startswith('ru'):
|
||||
return 'ru'
|
||||
else:
|
||||
return 'en'
|
||||
|
||||
@staticmethod
|
||||
def get_message(key: str, language: str = 'en', **kwargs) -> str:
|
||||
"""
|
||||
Gets localized message
|
||||
|
||||
Args:
|
||||
key: Message key
|
||||
language: Language code
|
||||
**kwargs: Formatting parameters
|
||||
|
||||
Returns:
|
||||
str: Localized message
|
||||
"""
|
||||
try:
|
||||
# Fallback to English if language not supported
|
||||
if language not in MESSAGES:
|
||||
language = 'en'
|
||||
|
||||
message = MESSAGES[language].get(key, MESSAGES['en'].get(key, f"Missing translation: {key}"))
|
||||
|
||||
# Format with parameters
|
||||
if kwargs:
|
||||
try:
|
||||
message = message.format(**kwargs)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.warning(f"Error formatting message {key}: {e}")
|
||||
|
||||
return message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting message {key} for language {language}: {e}")
|
||||
return f"Error: {key}"
|
||||
|
||||
@staticmethod
|
||||
def get_button_text(button_key: str, language: str = 'en') -> str:
|
||||
"""
|
||||
Gets button text
|
||||
|
||||
Args:
|
||||
button_key: Button key
|
||||
language: Language code
|
||||
|
||||
Returns:
|
||||
str: Button text
|
||||
"""
|
||||
try:
|
||||
if language not in MESSAGES:
|
||||
language = 'en'
|
||||
|
||||
buttons = MESSAGES[language].get('buttons', {})
|
||||
return buttons.get(button_key, MESSAGES['en']['buttons'].get(button_key, button_key))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting button text {button_key} for language {language}: {e}")
|
||||
return button_key
|
||||
|
||||
@staticmethod
|
||||
def get_content_type_name(content_type: str, language: str = 'en') -> str:
|
||||
"""
|
||||
Gets localized content type name
|
||||
|
||||
Args:
|
||||
content_type: Content type
|
||||
language: Language code
|
||||
|
||||
Returns:
|
||||
str: Localized name
|
||||
"""
|
||||
try:
|
||||
if language not in MESSAGES:
|
||||
language = 'en'
|
||||
|
||||
content_types = MESSAGES[language].get('content_types', {})
|
||||
return content_types.get(content_type, content_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting content type {content_type} for language {language}: {e}")
|
||||
return content_type
|
||||
|
||||
# Convenience functions for use in code
|
||||
def get_localized_message(telegram_user, message_key: str, **kwargs) -> str:
|
||||
"""Get localized message for user"""
|
||||
language = MessageLocalizer.get_user_language(telegram_user)
|
||||
return MessageLocalizer.get_message(message_key, language, **kwargs)
|
||||
|
||||
def get_localized_button(telegram_user, button_key: str) -> str:
|
||||
"""Get localized button text for user"""
|
||||
language = MessageLocalizer.get_user_language(telegram_user)
|
||||
return MessageLocalizer.get_button_text(button_key, language)
|
||||
|
||||
def get_user_language(telegram_user) -> str:
|
||||
"""Get user language"""
|
||||
return MessageLocalizer.get_user_language(telegram_user)
|
||||
@@ -1,99 +0,0 @@
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from telegram_bot.models import BotSettings, BotStatus
|
||||
from telegram_bot.bot import TelegramBotManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Run the Telegram bot'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.bot_manager = None
|
||||
self.running = False
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Force start even if bot is disabled in settings',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Main command handler"""
|
||||
# Set up signal handlers
|
||||
signal.signal(signal.SIGINT, self.signal_handler)
|
||||
signal.signal(signal.SIGTERM, self.signal_handler)
|
||||
|
||||
# Check settings
|
||||
settings = BotSettings.get_settings()
|
||||
|
||||
if not settings.bot_token:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('Bot token is not configured. Please configure it in the admin panel.')
|
||||
)
|
||||
return
|
||||
|
||||
if not settings.enabled and not options['force']:
|
||||
self.stdout.write(
|
||||
self.style.WARNING('Bot is disabled in settings. Use --force to override.')
|
||||
)
|
||||
return
|
||||
|
||||
# Initialize bot manager
|
||||
self.bot_manager = TelegramBotManager()
|
||||
|
||||
try:
|
||||
# Start the bot
|
||||
self.stdout.write(self.style.SUCCESS('Starting Telegram bot...'))
|
||||
self.bot_manager.start()
|
||||
self.running = True
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Bot is running. Press Ctrl+C to stop.')
|
||||
)
|
||||
|
||||
# Keep the main thread alive
|
||||
while self.running:
|
||||
time.sleep(1)
|
||||
|
||||
# Check if bot is still running
|
||||
if not self.bot_manager.is_running:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('Bot stopped unexpectedly. Check logs for errors.')
|
||||
)
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.stdout.write('\nReceived interrupt signal...')
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Error running bot: {e}')
|
||||
)
|
||||
logger.error(f'Error running bot: {e}', exc_info=True)
|
||||
|
||||
# Update status
|
||||
status = BotStatus.get_status()
|
||||
status.is_running = False
|
||||
status.last_error = str(e)
|
||||
status.last_stopped = timezone.now()
|
||||
status.save()
|
||||
|
||||
finally:
|
||||
# Stop the bot
|
||||
if self.bot_manager:
|
||||
self.stdout.write('Stopping bot...')
|
||||
self.bot_manager.stop()
|
||||
self.stdout.write(self.style.SUCCESS('Bot stopped.'))
|
||||
|
||||
def signal_handler(self, signum, frame):
|
||||
"""Handle shutdown signals"""
|
||||
self.stdout.write('\nShutting down gracefully...')
|
||||
self.running = False
|
||||
@@ -1,112 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from telegram_bot.models import BotSettings, BotStatus
|
||||
from telegram_bot.bot import TelegramBotManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check Telegram bot status and optionally start it'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--auto-start',
|
||||
action='store_true',
|
||||
help='Automatically start bot if enabled in settings',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--sync-status',
|
||||
action='store_true',
|
||||
help='Sync database status with real bot state',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Check bot status"""
|
||||
try:
|
||||
manager = TelegramBotManager()
|
||||
settings = BotSettings.get_settings()
|
||||
status = BotStatus.get_status()
|
||||
|
||||
# Show current configuration
|
||||
self.stdout.write(f"Bot Configuration:")
|
||||
self.stdout.write(f" Enabled: {settings.enabled}")
|
||||
self.stdout.write(f" Token configured: {'Yes' if settings.bot_token else 'No'}")
|
||||
|
||||
# Show status
|
||||
real_running = manager.is_running
|
||||
db_running = status.is_running
|
||||
|
||||
self.stdout.write(f"\nBot Status:")
|
||||
self.stdout.write(f" Database status: {'Running' if db_running else 'Stopped'}")
|
||||
self.stdout.write(f" Real status: {'Running' if real_running else 'Stopped'}")
|
||||
|
||||
# Check lock file status
|
||||
from django.conf import settings as django_settings
|
||||
lock_dir = os.path.join(getattr(django_settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks')
|
||||
lock_path = os.path.join(lock_dir, 'telegram_bot.lock')
|
||||
|
||||
if os.path.exists(lock_path):
|
||||
try:
|
||||
with open(lock_path, 'r') as f:
|
||||
lock_pid = f.read().strip()
|
||||
self.stdout.write(f" Lock file: exists (PID: {lock_pid})")
|
||||
except:
|
||||
self.stdout.write(f" Lock file: exists (unreadable)")
|
||||
else:
|
||||
self.stdout.write(f" Lock file: not found")
|
||||
|
||||
if db_running != real_running:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("⚠️ Status mismatch detected!")
|
||||
)
|
||||
|
||||
if options['sync_status']:
|
||||
status.is_running = real_running
|
||||
if not real_running:
|
||||
status.last_stopped = timezone.now()
|
||||
status.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("✅ Status synchronized")
|
||||
)
|
||||
|
||||
# Show timestamps
|
||||
if status.last_started:
|
||||
self.stdout.write(f" Last started: {status.last_started}")
|
||||
if status.last_stopped:
|
||||
self.stdout.write(f" Last stopped: {status.last_stopped}")
|
||||
if status.last_error:
|
||||
self.stdout.write(f" Last error: {status.last_error}")
|
||||
|
||||
# Auto-start if requested
|
||||
if options['auto_start']:
|
||||
if not real_running and settings.enabled and settings.bot_token:
|
||||
self.stdout.write("\nAttempting to start bot...")
|
||||
try:
|
||||
manager.start()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("✅ Bot started successfully")
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"❌ Failed to start bot: {e}")
|
||||
)
|
||||
elif real_running:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("✅ Bot is already running")
|
||||
)
|
||||
elif not settings.enabled:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("⚠️ Bot is disabled in settings")
|
||||
)
|
||||
elif not settings.bot_token:
|
||||
self.stdout.write(
|
||||
self.style.ERROR("❌ Bot token not configured")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"❌ Error checking bot status: {e}")
|
||||
)
|
||||
@@ -1,70 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 11:18
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BotSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('bot_token', models.CharField(help_text='Telegram Bot Token from @BotFather', max_length=255)),
|
||||
('enabled', models.BooleanField(default=False, help_text='Enable/Disable the bot')),
|
||||
('welcome_message', models.TextField(default='Hello! Your message has been received. An administrator will review it.', help_text='Message sent when user starts conversation')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Bot Settings',
|
||||
'verbose_name_plural': 'Bot Settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BotStatus',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_running', models.BooleanField(default=False)),
|
||||
('last_started', models.DateTimeField(blank=True, null=True)),
|
||||
('last_stopped', models.DateTimeField(blank=True, null=True)),
|
||||
('last_error', models.TextField(blank=True)),
|
||||
('last_update_id', models.BigIntegerField(blank=True, help_text='Last processed update ID from Telegram', null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Bot Status',
|
||||
'verbose_name_plural': 'Bot Status',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TelegramMessage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('direction', models.CharField(choices=[('incoming', 'Incoming'), ('outgoing', 'Outgoing')], db_index=True, max_length=10)),
|
||||
('telegram_user_id', models.BigIntegerField(db_index=True)),
|
||||
('telegram_username', models.CharField(blank=True, db_index=True, max_length=255, null=True)),
|
||||
('telegram_first_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('telegram_last_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('chat_id', models.BigIntegerField(db_index=True)),
|
||||
('message_id', models.BigIntegerField(blank=True, null=True)),
|
||||
('message_text', models.TextField(blank=True)),
|
||||
('raw_data', models.JSONField(blank=True, default=dict, help_text='Full message data from Telegram')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('linked_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='telegram_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Telegram Message',
|
||||
'verbose_name_plural': 'Telegram Messages',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['-created_at', 'direction'], name='telegram_bo_created_19b81b_idx'), models.Index(fields=['telegram_user_id', '-created_at'], name='telegram_bo_telegra_f71f27_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 12:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='api_base_url',
|
||||
field=models.URLField(blank=True, default='https://api.telegram.org', help_text='Telegram API base URL (change for local bot API server)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='connection_timeout',
|
||||
field=models.IntegerField(default=30, help_text='Connection timeout in seconds'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='proxy_url',
|
||||
field=models.URLField(blank=True, help_text='Proxy URL (e.g., http://proxy:8080 or socks5://proxy:1080)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='use_proxy',
|
||||
field=models.BooleanField(default=False, help_text='Enable proxy for Telegram API connections'),
|
||||
),
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 12:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0002_add_connection_settings'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccessRequest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('telegram_user_id', models.BigIntegerField(db_index=True, help_text='Telegram user ID who made the request')),
|
||||
('telegram_username', models.CharField(blank=True, help_text='Telegram username (without @)', max_length=255, null=True)),
|
||||
('telegram_first_name', models.CharField(blank=True, help_text='First name from Telegram', max_length=255, null=True)),
|
||||
('telegram_last_name', models.CharField(blank=True, help_text='Last name from Telegram', max_length=255, null=True)),
|
||||
('message_text', models.TextField(help_text='The message sent by user when requesting access')),
|
||||
('chat_id', models.BigIntegerField(help_text='Telegram chat ID for sending notifications')),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=20)),
|
||||
('admin_comment', models.TextField(blank=True, help_text='Admin comment for approval/rejection')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('processed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_user', models.ForeignKey(blank=True, help_text='User created from this request (when approved)', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('first_message', models.ForeignKey(blank=True, help_text='First message from this user', null=True, on_delete=django.db.models.deletion.SET_NULL, to='telegram_bot.telegrammessage')),
|
||||
('processed_by', models.ForeignKey(blank=True, help_text='Admin who processed this request', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='processed_requests', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Access Request',
|
||||
'verbose_name_plural': 'Access Requests',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['telegram_user_id'], name='telegram_bo_telegra_e3429d_idx'), models.Index(fields=['status', '-created_at'], name='telegram_bo_status_cf9310_idx'), models.Index(fields=['-created_at'], name='telegram_bo_created_c82a74_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('telegram_user_id',), name='unique_telegram_user_request')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,37 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 13:49
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0003_accessrequest'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='accessrequest',
|
||||
name='telegram_bo_status_cf9310_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accessrequest',
|
||||
name='status',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='approved',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='Request approved by administrator'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accessrequest',
|
||||
name='admin_comment',
|
||||
field=models.TextField(blank=True, help_text='Admin comment for approval'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accessrequest',
|
||||
index=models.Index(fields=['approved', '-created_at'], name='telegram_bo_approve_7ae92d_idx'),
|
||||
),
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 22:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0004_remove_accessrequest_telegram_bo_status_cf9310_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='BotStatus',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='botsettings',
|
||||
name='welcome_message',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='help_message',
|
||||
field=models.TextField(default='📋 Available commands:\n/start - Start conversation\n📊 Access - View your VPN subscriptions\n\nFor support contact administrator.', help_text='Help message sent for unrecognized commands'),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 22:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0005_delete_botstatus_remove_botsettings_welcome_message_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='desired_username',
|
||||
field=models.CharField(blank=True, help_text='Desired username for VPN user (defaults to Telegram username)', max_length=150),
|
||||
),
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 22:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0006_accessrequest_desired_username'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='botsettings',
|
||||
name='help_message',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='user_language',
|
||||
field=models.CharField(default='en', help_text="User's preferred language (en/ru)", max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='telegrammessage',
|
||||
name='user_language',
|
||||
field=models.CharField(default='en', help_text="User's preferred language (en/ru)", max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated migration for adding selected_existing_user field
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('telegram_bot', '0007_remove_botsettings_help_message_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='selected_existing_user',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Existing user selected to link with this Telegram account',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='selected_for_requests',
|
||||
to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-15 12:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0008_accessrequest_selected_existing_user'),
|
||||
('vpn', '0026_alter_subscriptiongroup_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='selected_inbounds',
|
||||
field=models.ManyToManyField(blank=True, help_text='Inbound templates to assign to the user', to='vpn.inbound'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='selected_subscription_groups',
|
||||
field=models.ManyToManyField(blank=True, help_text='Subscription groups to assign to the user', to='vpn.subscriptiongroup'),
|
||||
),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-15 13:00
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0009_accessrequest_selected_inbounds_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='telegram_admins',
|
||||
field=models.ManyToManyField(blank=True, help_text='Users with linked Telegram accounts who will have admin access in the bot', related_name='bot_admin_settings', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -1,318 +0,0 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
import json
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class BotSettings(models.Model):
|
||||
"""Singleton model for bot settings"""
|
||||
bot_token = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Telegram Bot Token from @BotFather"
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Enable/Disable the bot"
|
||||
)
|
||||
use_proxy = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Enable proxy for Telegram API connections"
|
||||
)
|
||||
proxy_url = models.URLField(
|
||||
blank=True,
|
||||
help_text="Proxy URL (e.g., http://proxy:8080 or socks5://proxy:1080)"
|
||||
)
|
||||
api_base_url = models.URLField(
|
||||
blank=True,
|
||||
default="https://api.telegram.org",
|
||||
help_text="Telegram API base URL (change for local bot API server)"
|
||||
)
|
||||
connection_timeout = models.IntegerField(
|
||||
default=30,
|
||||
help_text="Connection timeout in seconds"
|
||||
)
|
||||
telegram_admins = models.ManyToManyField(
|
||||
User,
|
||||
blank=True,
|
||||
related_name='bot_admin_settings',
|
||||
help_text="Users with linked Telegram accounts who will have admin access in the bot"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Bot Settings"
|
||||
verbose_name_plural = "Bot Settings"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Ensure only one instance exists
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Prevent deletion
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_settings(cls):
|
||||
"""Get or create singleton settings"""
|
||||
obj, created = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
def __str__(self):
|
||||
return f"Bot Settings ({'Enabled' if self.enabled else 'Disabled'})"
|
||||
|
||||
|
||||
class TelegramMessage(models.Model):
|
||||
"""Store all telegram messages"""
|
||||
DIRECTION_CHOICES = [
|
||||
('incoming', 'Incoming'),
|
||||
('outgoing', 'Outgoing'),
|
||||
]
|
||||
|
||||
direction = models.CharField(
|
||||
max_length=10,
|
||||
choices=DIRECTION_CHOICES,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Telegram user info
|
||||
telegram_user_id = models.BigIntegerField(db_index=True)
|
||||
telegram_username = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True
|
||||
)
|
||||
telegram_first_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
telegram_last_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
user_language = models.CharField(
|
||||
max_length=10,
|
||||
default='en',
|
||||
help_text="User's preferred language (en/ru)"
|
||||
)
|
||||
|
||||
# Message info
|
||||
chat_id = models.BigIntegerField(db_index=True)
|
||||
message_id = models.BigIntegerField(null=True, blank=True)
|
||||
message_text = models.TextField(blank=True)
|
||||
|
||||
# Additional data
|
||||
raw_data = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Full message data from Telegram"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
# Optional link to VPN user if identified
|
||||
linked_user = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='telegram_messages'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Telegram Message"
|
||||
verbose_name_plural = "Telegram Messages"
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['-created_at', 'direction']),
|
||||
models.Index(fields=['telegram_user_id', '-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
username = self.telegram_username or f"ID:{self.telegram_user_id}"
|
||||
direction_icon = "⬇️" if self.direction == 'incoming' else "⬆️"
|
||||
text_preview = self.message_text[:50] + "..." if len(self.message_text) > 50 else self.message_text
|
||||
return f"{direction_icon} {username}: {text_preview}"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Get full name of telegram user"""
|
||||
parts = []
|
||||
if self.telegram_first_name:
|
||||
parts.append(self.telegram_first_name)
|
||||
if self.telegram_last_name:
|
||||
parts.append(self.telegram_last_name)
|
||||
return " ".join(parts) if parts else f"User {self.telegram_user_id}"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get best available display name"""
|
||||
if self.telegram_username:
|
||||
return f"`@{self.telegram_username}`"
|
||||
return self.full_name
|
||||
|
||||
|
||||
|
||||
|
||||
class AccessRequest(models.Model):
|
||||
"""Access requests from Telegram users"""
|
||||
|
||||
# Telegram user information
|
||||
telegram_user_id = models.BigIntegerField(
|
||||
db_index=True,
|
||||
help_text="Telegram user ID who made the request"
|
||||
)
|
||||
telegram_username = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Telegram username (without @)"
|
||||
)
|
||||
telegram_first_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="First name from Telegram"
|
||||
)
|
||||
telegram_last_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Last name from Telegram"
|
||||
)
|
||||
|
||||
# Request details
|
||||
message_text = models.TextField(
|
||||
help_text="The message sent by user when requesting access"
|
||||
)
|
||||
chat_id = models.BigIntegerField(
|
||||
help_text="Telegram chat ID for sending notifications"
|
||||
)
|
||||
|
||||
# Username for VPN user creation
|
||||
desired_username = models.CharField(
|
||||
max_length=150,
|
||||
blank=True,
|
||||
help_text="Desired username for VPN user (defaults to Telegram username)"
|
||||
)
|
||||
|
||||
# User language
|
||||
user_language = models.CharField(
|
||||
max_length=10,
|
||||
default='en',
|
||||
help_text="User's preferred language (en/ru)"
|
||||
)
|
||||
|
||||
# Status and processing
|
||||
approved = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Request approved by administrator"
|
||||
)
|
||||
admin_comment = models.TextField(
|
||||
blank=True,
|
||||
help_text="Admin comment for approval"
|
||||
)
|
||||
|
||||
# Related objects
|
||||
selected_existing_user = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='selected_for_requests',
|
||||
help_text="Existing user selected to link with this Telegram account"
|
||||
)
|
||||
created_user = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="User created from this request (when approved)"
|
||||
)
|
||||
processed_by = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='processed_requests',
|
||||
help_text="Admin who processed this request"
|
||||
)
|
||||
first_message = models.ForeignKey(
|
||||
TelegramMessage,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="First message from this user"
|
||||
)
|
||||
|
||||
# Inbound templates and subscription groups
|
||||
selected_inbounds = models.ManyToManyField(
|
||||
'vpn.Inbound',
|
||||
blank=True,
|
||||
help_text="Inbound templates to assign to the user"
|
||||
)
|
||||
selected_subscription_groups = models.ManyToManyField(
|
||||
'vpn.SubscriptionGroup',
|
||||
blank=True,
|
||||
help_text="Subscription groups to assign to the user"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
processed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Access Request"
|
||||
verbose_name_plural = "Access Requests"
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['telegram_user_id']),
|
||||
models.Index(fields=['approved', '-created_at']),
|
||||
models.Index(fields=['-created_at']),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['telegram_user_id'],
|
||||
name='unique_telegram_user_request'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
username = self.telegram_username or f"ID:{self.telegram_user_id}"
|
||||
status = "Approved" if self.approved else "Pending"
|
||||
return f"Request from @{username} ({status})"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get best available display name"""
|
||||
if self.telegram_username:
|
||||
return f"`@{self.telegram_username}`"
|
||||
|
||||
name_parts = []
|
||||
if self.telegram_first_name:
|
||||
name_parts.append(self.telegram_first_name)
|
||||
if self.telegram_last_name:
|
||||
name_parts.append(self.telegram_last_name)
|
||||
|
||||
if name_parts:
|
||||
return " ".join(name_parts)
|
||||
|
||||
return f"User {self.telegram_user_id}"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Get full name of telegram user"""
|
||||
parts = []
|
||||
if self.telegram_first_name:
|
||||
parts.append(self.telegram_first_name)
|
||||
if self.telegram_last_name:
|
||||
parts.append(self.telegram_last_name)
|
||||
return " ".join(parts) if parts else None
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -1,202 +0,0 @@
|
||||
{% 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 %}
|
||||
161
templates/base.html
Normal file
161
templates/base.html
Normal file
File diff suppressed because one or more lines are too long
167
templates/clients.html
Normal file
167
templates/clients.html
Normal file
@@ -0,0 +1,167 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="entety-menu">
|
||||
<div id="list">
|
||||
<h1 class="server-content-title">Clients</h1>
|
||||
<form id="search-form" class="pure-form">
|
||||
<input placeholder="🔍" type="text" id="entety-menu-search" />
|
||||
</form>
|
||||
<div data-search-box class="srcollable-list-content">
|
||||
{% for client, values in CLIENTS.items() %}
|
||||
<div
|
||||
class="server-item server-item-{% if client == selected_client %}unread{% else %}selected{% endif %} pure-g">
|
||||
<div class="" onclick="location.href='/clients?selected_client={{ client }}';">
|
||||
<h5 data-search class="server-name">{{ values["name"] }}</h5>
|
||||
<h4 class="server-info">{{ values["servers"]|length }} server{% if values["servers"]|length >1
|
||||
%}s{%endif%}</h4>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div onclick="location.href='/clients?add_client=True';" class="server-item server-add pure-g">
|
||||
<div class="pure-u-1">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if add_client %}
|
||||
<div id="content">
|
||||
<div class="">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="">
|
||||
<h1 class="server-content-title">Add new client</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-content-body">
|
||||
<form action="/add_client" class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="" name="name" required placeholder="Name" />
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="" name="comment" placeholder="Comment" />
|
||||
</div>
|
||||
<div class="pure-checkbox">
|
||||
{% for server in SERVERS %}
|
||||
<div class="server-checkbox">
|
||||
<input type="checkbox" id="option{{loop.index0}}" name="servers"
|
||||
value="{{server.info()['local_server_id']}}">
|
||||
<label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["comment"]}}
|
||||
<span class="pure-form-message">ID: {{server.info()['local_server_id'][0:8]}}</span>
|
||||
<span class="pure-form-message">Comment: {{server.info()["name"]}}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if selected_client and not add_client %}
|
||||
{% set client = CLIENTS[selected_client] %}
|
||||
|
||||
<div id="content">
|
||||
<div class="">
|
||||
<div class="server-content-header pure-g">
|
||||
<div class="">
|
||||
<h1 class="server-content-title">{{client['name']}}</h1>
|
||||
<h4 class="server-info">{{ client['comment'] }}</h4>
|
||||
<h4 class="server-info">id {{ selected_client }}</h4>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-content-body">
|
||||
<form action="/add_client" class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="pure-u-1" name="name" required value="{{client['name']}}" />
|
||||
<input type="hidden" class="pure-u-1" name="old_name" required value="{{client['name']}}" />
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<input type="text" class="pure-u-1" name="comment" value="{{client['comment']}}" />
|
||||
</div>
|
||||
<input type="hidden" class="pure-u-1" name="user_id" value="{{selected_client}}" />
|
||||
|
||||
<div class="pure-checkbox">
|
||||
|
||||
<p>Allow access to:</p>
|
||||
|
||||
{% for server in SERVERS %}
|
||||
<div class="server-checkbox">
|
||||
<input {% if server.info()['local_server_id'] in client['servers'] %}checked{%endif%}
|
||||
type="checkbox" id="option{{loop.index0}}" name="servers"
|
||||
value="{{server.info()['local_server_id']}}">
|
||||
<label class="pure-checkbox" for="option{{loop.index0}}">{{server.info()["comment"]}}
|
||||
<span class="pure-form-message">ID: {{server.info()['local_server_id'][0:8]}}</span>
|
||||
<span class="pure-form-message">Comment: {{server.info()["name"]}}</span>
|
||||
<span class="pure-form-message">{% if
|
||||
server.info()['local_server_id'] in client['servers'] %}Usage: {% for key in
|
||||
server.data["keys"] %}{% if key.name == client['name'] %}{{ (key.used_bytes if
|
||||
key.used_bytes else 0) | filesizeformat }}{% endif %}{% endfor
|
||||
%}{%endif%}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="pure-g pure-form pure-form-stacked">
|
||||
<div class="pure-u-1-2">
|
||||
<button type="submit" class="pure-button pure-button-primary button">Save and apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
<form action="/del_client" class="pure-form pure-form-stacked" method="POST">
|
||||
<input type="hidden" name="name" required value="{{client['name']}}" />
|
||||
<input type="hidden" name="user_id" value="{{selected_client}}" />
|
||||
<button type="submit" class="pure-button pure-button-primary delete-button button">Delete
|
||||
Client</button>
|
||||
<input type="checkbox" id="agree" name="agree" required>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<h3>Invite text</h3>
|
||||
<hr>
|
||||
<p>Install Outline VPN. Copy and paste the keys below into the Outline client.
|
||||
The same keys can be used simultaneously on multiple devices.</p>
|
||||
{% for server in SERVERS -%}
|
||||
{% if server.info()['local_server_id'] in client['servers'] %}
|
||||
{% set salt = bcrypt.gensalt() %}
|
||||
{% set secret_string = server.info()['local_server_id'] + selected_client %}
|
||||
{% set hash_secret = bcrypt.hashpw(
|
||||
password=secret_string.encode('utf-8'),
|
||||
salt=salt).decode('utf-8') %}
|
||||
|
||||
<p><b>Server location:</b> {{server.info()['name']}}</p>
|
||||
<p><b>Client link:</b> {% for key in server.data["keys"] %}{% if key.key_id == client['name']
|
||||
%}ssconf://{{ dynamic_hostname
|
||||
}}/dynamic/{{server.info()['local_server_id'][0:SECRET_LINK_LENGTH]}}{{selected_client[0:SECRET_LINK_LENGTH]}}{{hash_secret[SECRET_LINK_PREFIX|length:]}}#{{server.info()['comment']}}{%
|
||||
endif %}{% endfor %}</p>
|
||||
{% endif %}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
159
templates/index.html
Normal file
159
templates/index.html
Normal file
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div id="entety-menu">
|
||||
<div>
|
||||
<h1 class="server-content-title">Servers</h1>
|
||||
<div class="srcollable-list-content">
|
||||
{% for server in SERVERS %}
|
||||
{% set total_traffic = namespace(total_bytes=0) %}
|
||||
{% for key in server.data["keys"] %}
|
||||
{% if key.used_bytes %}
|
||||
{% set total_traffic.total_bytes = total_traffic.total_bytes + key.used_bytes %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="server-item server-item-{% if loop.index0 == selected_server|int %}unread{% else %}selected{% endif %} pure-g">
|
||||
<div class="pure-u-3-4" onclick="location.href='/?selected_server={{loop.index0}}';">
|
||||
<h5 class="server-name">{{ server.info()["name"] }}</h5>
|
||||
<h4 class="server-info">{{ '/'.join(server.info()["url"].split('/')[0:-1]) }}</h4>
|
||||
<h4 class="server-info">Port {{ server.info()["port_for_new_access_keys"] }}</h4>
|
||||
<h4 class="server-info">Hostname {{ server.info()["hostname_for_access_keys"] }}</h4>
|
||||
<h4 class="server-info">Traffic: {{ total_traffic.total_bytes | filesizeformat }}</h4>
|
||||
<h4 class="server-info">v.{{ server.info()["version"] }}</h4>
|
||||
<p class="server-comment">
|
||||
{{ server.info()["comment"] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div onclick="location.href='/?add_server=True';" class="server-item server-add pure-g">
|
||||
<div class="pure-u-1">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if add_server %}
|
||||
<div id="content">
|
||||
<div >
|
||||
<div class="server-content-header pure-g">
|
||||
<div >
|
||||
<h1 class="server-content-title">Add new server</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-content-body">
|
||||
<form action="/add_server" class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div >
|
||||
<label for="url">Server management URL</label>
|
||||
<input type="text" class="pure-u-1" name="url" placeholder="https://example.com:5743/KSsdywe6Sdb..."/>
|
||||
<label for="cert">Server management Certificate</label>
|
||||
<input type="text" class="pure-u-1" name="cert" placeholder="B5DD2443DAF..."/>
|
||||
<label for="cert">Server Comment
|
||||
<span class="pure-form-message">This will be exposed to client and will be used as "Server name" in client Outline app</span>
|
||||
</label>
|
||||
<input type="text" class="pure-u-1" name="comment" placeholder="e.g. server location"/>
|
||||
</div>
|
||||
<button type="submit" class="pure-button pure-input-1 pure-button-primary">Add</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if SERVERS|length != 0 and not add_server %}
|
||||
|
||||
{% if selected_server is none %}
|
||||
{% set server = SERVERS[0] %}
|
||||
{% else %}
|
||||
{% set server = SERVERS[selected_server|int] %}
|
||||
{% endif %}
|
||||
<div id="content">
|
||||
<div >
|
||||
<div class="server-content">
|
||||
<div class="server-content-header pure-g">
|
||||
<div>
|
||||
<h1 class="content-title">{{server.info()["name"]}}</h1>
|
||||
<p class="server-content-subtitle">
|
||||
<span>v.{{server.info()["version"]}} {{server.info()["local_server_id"]}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% set ns = namespace(total_bytes=0) %}
|
||||
{% for key in SERVERS[selected_server|int].data["keys"] %}
|
||||
{% if key.used_bytes %}
|
||||
{% set ns.total_bytes = ns.total_bytes + key.used_bytes %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="server-content-body">
|
||||
<h3>Clients: {{ server.info()['keys']|length }}</h3>
|
||||
<h3>Total traffic: {{ ns.total_bytes | filesizeformat }}</h3>
|
||||
<form class="pure-form pure-form-stacked" method="POST">
|
||||
<fieldset>
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 ">
|
||||
<label for="name">Server Name
|
||||
<span class="pure-form-message">This will not be exposed to client</span>
|
||||
</label>
|
||||
<input type="text" id="name" class="pure-u-1" name="name" value="{{server.info()['name']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="comment">Comment</br>
|
||||
<span class="pure-form-message">This will be exposed to client and will be used as "Server name" in client Outline app</span></label>
|
||||
<input type="text" id="comment" class="pure-u-1" name="comment" value="{{server.info()['comment']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1">
|
||||
<label for="port_for_new_access_keys">Port For New Access Keys</label>
|
||||
<input type="text" id="port_for_new_access_keys" class="pure-u-1" name="port_for_new_access_keys" value="{{server.info()['port_for_new_access_keys']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="hostname_for_access_keys">Hostname For Access Keys</label>
|
||||
<input type="text" id="hostname_for_access_keys" class="pure-u-1" name="hostname_for_access_keys" value="{{server.info()['hostname_for_access_keys']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="url">Server URL</label>
|
||||
<input type="text" readonly id="url" class="pure-u-1" name="url" value="{{server.info()['url']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="cert">Server Access Certificate</label>
|
||||
<input type="text" readonly id="cert" class="pure-u-1" name="cert" value="{{server.info()['cert']}}"/>
|
||||
</div>
|
||||
<div class="pure-u-1 ">
|
||||
<label for="created_timestamp_ms">Created</label>
|
||||
<input type="text" readonly id="created_timestamp_ms" class="pure-u-1" name="created_timestamp_ms" value="{{format_timestamp(server.info()['created_timestamp_ms']) }}"/>
|
||||
</div>
|
||||
<input type="hidden" readonly id="server_id" name="server_id" value="{{server.info()['local_server_id']}}"/>
|
||||
</div>
|
||||
<p>Share anonymous metrics</p>
|
||||
<label for="metrics_enabled" class="pure-radio">
|
||||
<input type="radio" id="metrics_enabled" name="metrics" value="True" {% if server.info()['metrics_enabled'] == True %}checked{% endif %} /> Enable
|
||||
</label>
|
||||
<label for="metrics_disabled" class="pure-radio">
|
||||
<input type="radio" id="metrics_disabled" name="metrics" value="False" {% if server.info()['metrics_enabled'] == False %}checked{% endif %} /> Disable
|
||||
</label>
|
||||
<button type="submit" class="pure-button pure-button-primary button">Save and apply</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<form action="/del_server" method="post">
|
||||
<input type="hidden" name="local_server_id" value="{{ server.info()["local_server_id"] }}">
|
||||
<button type="submit" class="pure-button pure-button-primary delete-button button">Delete Server</button>
|
||||
<input type="checkbox" id="agree" name="agree" required>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
17
templates/sync.html
Normal file
17
templates/sync.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
83
tools/windows-helper.ps1
Normal file
83
tools/windows-helper.ps1
Normal file
@@ -0,0 +1,83 @@
|
||||
$url = Read-Host "Please enter the URL for the JSON configuration"
|
||||
$comment = Read-Host "Comment [server, country, etc]"
|
||||
$port = Read-Host "Please enter the port to use for sslocal"
|
||||
|
||||
$version = "1.21.0"
|
||||
$archiveUrl = "https://github.com/shadowsocks/shadowsocks-rust/releases/download/v${version}/shadowsocks-v${version}.x86_64-pc-windows-gnu.zip"
|
||||
$downloadPath = "$HOME\shadowsocks-rust\shadowsocks.zip"
|
||||
$extractPath = "$HOME\shadowsocks-rust"
|
||||
$scriptUrl = "https://raw.githubusercontent.com/house-of-vanity/OutFleet/refs/heads/master/tools/windows_task.ps1"
|
||||
$cmdFilePath = "$extractPath\run_${comment}.cmd"
|
||||
$taskName = "Shadowsocks_Task_${comment}"
|
||||
$logFile = "$extractPath\Log_${comment}.log"
|
||||
|
||||
|
||||
if ($url -notmatch "^[a-z]+://") {
|
||||
$url = "https://$url"
|
||||
} elseif ($url -like "ssconf://*") {
|
||||
$url = $url -replace "^ssconf://", "https://"
|
||||
}
|
||||
|
||||
function Test-Admin {
|
||||
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
if (-Not (Test-Admin)) {
|
||||
Write-Host "Error: This script requires administrator privileges. Please run PowerShell as administrator." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure the extraction directory exists
|
||||
if (-Not (Test-Path -Path $extractPath)) {
|
||||
New-Item -ItemType Directory -Path $extractPath
|
||||
}
|
||||
|
||||
# Download the archive
|
||||
Invoke-WebRequest -Uri $archiveUrl -OutFile $downloadPath
|
||||
|
||||
# Extract the archive
|
||||
Expand-Archive -Path $downloadPath -DestinationPath $extractPath -Force
|
||||
|
||||
# Check if sslocal.exe exists
|
||||
if (-Not (Test-Path -Path "$extractPath\sslocal.exe")) {
|
||||
Write-Host "Error: sslocal.exe not found in $extractPath" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Download the windows_task.ps1 script
|
||||
Invoke-WebRequest -Uri $scriptUrl -OutFile "$extractPath\windows_task.ps1"
|
||||
|
||||
# Build Batch file content
|
||||
$batchContent = @"
|
||||
@echo off
|
||||
set scriptPath=""$extractPath\windows_task.ps1""
|
||||
powershell.exe -ExecutionPolicy Bypass -File %scriptPath% ""$url"" ""$extractPath\sslocal.exe"" ""$port""
|
||||
"@
|
||||
|
||||
|
||||
$batchContent | Set-Content -Path $cmdFilePath
|
||||
|
||||
# Create or update Task Scheduler
|
||||
$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument "/c $cmdFilePath > $logFile"
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
|
||||
# Check if the task already exists
|
||||
$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
|
||||
if ($existingTask) {
|
||||
Write-Host "Task $taskName already exists. Updating the task..."
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
}
|
||||
|
||||
# Register the new or updated task
|
||||
Register-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings -TaskName $taskName
|
||||
|
||||
Write-Host "Task $taskName has been created/updated successfully."
|
||||
|
||||
# Optionally, start the task immediately
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
Write-Host "Task $taskName has been started."
|
||||
100
tools/windows_task.ps1
Normal file
100
tools/windows_task.ps1
Normal file
@@ -0,0 +1,100 @@
|
||||
if ($args.Count -lt 3) {
|
||||
Write-Host "Usage: windows_task.ps1 <url> <sslocal_path> <local_port>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$url = $args[0]
|
||||
$sslocalPath = $args[1]
|
||||
$localPort = $args[2]
|
||||
|
||||
$localAddr = "localhost"
|
||||
$checkInterval = 60
|
||||
$previousPassword = ""
|
||||
|
||||
# Function to get the process ID of the process listening on a specific port
|
||||
function Get-ProcessByPort {
|
||||
param (
|
||||
[int]$port
|
||||
)
|
||||
|
||||
# Use Get-NetTCPConnection to find the process listening on the given port
|
||||
$connection = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Listen' }
|
||||
|
||||
if ($connection) {
|
||||
# Get the owning process ID (OwningProcess) from the connection
|
||||
$pid = $connection.OwningProcess
|
||||
return Get-Process -Id $pid -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Function to start sslocal
|
||||
function Start-SSLocal {
|
||||
param (
|
||||
[string]$method,
|
||||
[string]$password,
|
||||
[string]$server,
|
||||
[int]$serverPort
|
||||
)
|
||||
|
||||
# Form the Shadowsocks connection string
|
||||
$credentials = "${method}:${password}@${server}:${serverPort}"
|
||||
$encodedCredentials = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($credentials))
|
||||
$ssUrl = "ss://$encodedCredentials"
|
||||
|
||||
# Get the process listening on the specified port and kill it if found
|
||||
$process = Get-ProcessByPort -port $localPort
|
||||
if ($process) {
|
||||
Write-Host "Killing process $($process.Id) using port $localPort"
|
||||
Stop-Process -Id $process.Id -Force
|
||||
}
|
||||
|
||||
# Log the sslocal restart
|
||||
Write-Host "Starting sslocal with method: $method, server: $server, port: $serverPort"
|
||||
|
||||
# Start sslocal with the provided arguments
|
||||
Start-Process -NoNewWindow -FilePath $sslocalPath -ArgumentList "--local-addr ${localAddr}:${localPort} --server-url $ssUrl"
|
||||
}
|
||||
|
||||
# Main loop
|
||||
while ($true) {
|
||||
try {
|
||||
if ($url -notmatch "mode=json") {
|
||||
$delimiter = "?"
|
||||
if ($url -match "\?") {
|
||||
$delimiter = "&"
|
||||
}
|
||||
$url = "$url${delimiter}mode=json"
|
||||
}
|
||||
# Download and parse the JSON
|
||||
$jsonContent = Invoke-WebRequest -Uri $url -UseBasicParsing | Select-Object -ExpandProperty Content
|
||||
$json = $jsonContent | ConvertFrom-Json
|
||||
|
||||
# Extract the necessary fields
|
||||
$method = $json.method
|
||||
$password = $json.password
|
||||
$server = $json.server
|
||||
$serverPort = $json.server_port
|
||||
|
||||
# Log current password and server information
|
||||
Write-Host "Checking server: $server, port: $serverPort"
|
||||
|
||||
# Check if the password has changed
|
||||
if ($password -ne $previousPassword) {
|
||||
# Start/restart sslocal
|
||||
Start-SSLocal -method $method -password $password -server $server -serverPort $serverPort
|
||||
$previousPassword = $password
|
||||
Write-Host "Password changed, restarting sslocal."
|
||||
} else {
|
||||
Write-Host "Password has not changed."
|
||||
}
|
||||
|
||||
} catch {
|
||||
Write-Host "Error occurred: $_"
|
||||
}
|
||||
|
||||
# Wait for the next check
|
||||
Start-Sleep -Seconds $checkInterval
|
||||
}
|
||||
|
||||
169
vpn/admin.py
169
vpn/admin.py
@@ -1,169 +0,0 @@
|
||||
"""
|
||||
Django admin configuration for VPN application
|
||||
|
||||
This module has been refactored for better organization. The admin classes
|
||||
are now split across multiple modules in the vpn.admin package:
|
||||
|
||||
- vpn.admin.user: User management admin interface
|
||||
- vpn.admin.server: Server management admin interface
|
||||
- vpn.admin.access: Access control (ACL/ACLLink) admin interfaces
|
||||
- vpn.admin.logs: Logging (TaskExecutionLog/AccessLog) admin interfaces
|
||||
- vpn.admin.base: Common utilities and base classes
|
||||
"""
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
import json
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
|
||||
# Import server plugins and their admin classes
|
||||
try:
|
||||
from .server_plugins import (
|
||||
XrayServerV2,
|
||||
XrayServerV2Admin
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to import server plugins: {e}")
|
||||
|
||||
# Import admin interfaces from refactored modules
|
||||
# This ensures all admin classes are registered
|
||||
try:
|
||||
from .admin import *
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to import refactored admin modules: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Import Xray admin configuration and all Xray admin classes
|
||||
try:
|
||||
from .admin_xray import *
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to import Xray admin classes: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Set custom admin site configuration
|
||||
admin.site.site_title = "VPN Manager"
|
||||
admin.site.site_header = "VPN Manager"
|
||||
admin.site.index_title = "OutFleet"
|
||||
|
||||
# Custom Celery admin interfaces
|
||||
try:
|
||||
from django_celery_results.models import TaskResult
|
||||
|
||||
# Unregister default TaskResult admin if it exists
|
||||
try:
|
||||
admin.site.unregister(TaskResult)
|
||||
except admin.sites.NotRegistered:
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(TaskResult)
|
||||
class CustomTaskResultAdmin(admin.ModelAdmin):
|
||||
list_display = ('task_name_display', 'status', 'date_created', 'date_done', 'worker', 'result_display', 'traceback_display')
|
||||
list_filter = ('status', 'date_created', 'worker', 'task_name')
|
||||
search_fields = ('task_name', 'task_id', 'worker')
|
||||
readonly_fields = ('task_id', 'task_name', 'status', 'result_formatted', 'date_created', 'date_done', 'traceback', 'worker', 'task_args', 'task_kwargs', 'meta')
|
||||
ordering = ('-date_created',)
|
||||
list_per_page = 50
|
||||
|
||||
fieldsets = (
|
||||
('Task Information', {
|
||||
'fields': ('task_id', 'task_name', 'status', 'worker')
|
||||
}),
|
||||
('Timing', {
|
||||
'fields': ('date_created', 'date_done')
|
||||
}),
|
||||
('Result', {
|
||||
'fields': ('result_formatted',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Arguments', {
|
||||
'fields': ('task_args', 'task_kwargs'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Error Details', {
|
||||
'fields': ('traceback',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('meta',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.display(description='Task Name', ordering='task_name')
|
||||
def task_name_display(self, obj):
|
||||
task_names = {
|
||||
'sync_all_servers': '🔄 Sync All Servers',
|
||||
'sync_server_users': '👥 Sync Users on Server',
|
||||
'sync_server_info': '⚙️ Sync Server Info',
|
||||
'sync_user_on_server': '👤 Sync User on Server',
|
||||
'cleanup_task_logs': '🧹 Cleanup Old Logs',
|
||||
'update_user_statistics': '📊 Update Statistics',
|
||||
}
|
||||
return task_names.get(obj.task_name, obj.task_name)
|
||||
|
||||
@admin.display(description='Result')
|
||||
def result_display(self, obj):
|
||||
if obj.status == 'SUCCESS' and obj.result:
|
||||
try:
|
||||
result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result
|
||||
if isinstance(result, str):
|
||||
return result[:100] + '...' if len(result) > 100 else result
|
||||
elif isinstance(result, dict):
|
||||
return ', '.join(f'{k}: {v}' for k, v in result.items())[:100]
|
||||
except:
|
||||
return str(obj.result)[:100] if obj.result else '-'
|
||||
elif obj.status == 'FAILURE':
|
||||
return '❌ Failed'
|
||||
elif obj.status == 'PENDING':
|
||||
return '⏳ Pending'
|
||||
elif obj.status == 'RETRY':
|
||||
return '🔄 Retrying'
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Result Details')
|
||||
def result_formatted(self, obj):
|
||||
if obj.result:
|
||||
try:
|
||||
result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result
|
||||
formatted = json.dumps(result, indent=2)
|
||||
return mark_safe(f"<pre>{formatted}</pre>")
|
||||
except:
|
||||
return mark_safe(f"<pre>{obj.result}</pre>")
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Error Info')
|
||||
def traceback_display(self, obj):
|
||||
if obj.traceback:
|
||||
# Show first 200 chars of traceback
|
||||
short_tb = obj.traceback[:200] + '...' if len(obj.traceback) > 200 else obj.traceback
|
||||
return mark_safe(f"<pre style='color: red; font-size: 12px;'>{short_tb}</pre>")
|
||||
return '-'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
except ImportError:
|
||||
pass # Celery not available
|
||||
|
||||
# Add subscription management to User admin
|
||||
try:
|
||||
from vpn.admin_xray import add_subscription_management_to_user
|
||||
from vpn.admin.user import UserAdmin
|
||||
add_subscription_management_to_user(UserAdmin)
|
||||
logger.info("✅ Successfully added subscription management to User admin")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add subscription management: {e}")
|
||||
|
||||
# Note: Unwanted admin interfaces are cleaned up in vpn/apps.py ready() method
|
||||
|
||||
# Force reload trigger
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
VPN Admin Module
|
||||
|
||||
This module provides Django admin interfaces for the VPN application.
|
||||
The admin interface has been refactored into separate modules for better organization:
|
||||
|
||||
- base.py: Common utilities and base classes
|
||||
- user.py: User management admin interface
|
||||
- server.py: Server management admin interface
|
||||
- access.py: Access control (ACL/ACLLink) admin interfaces
|
||||
- logs.py: Logging (TaskExecutionLog/AccessLog) admin interfaces
|
||||
|
||||
All admin classes are automatically registered with Django admin.
|
||||
"""
|
||||
|
||||
# Import all admin classes to ensure they are registered
|
||||
from .user import UserAdmin
|
||||
from .server import ServerAdmin, UserACLInline
|
||||
from .access import (
|
||||
ACLAdmin,
|
||||
ACLLinkAdmin,
|
||||
UserNameFilter,
|
||||
ServerNameFilter,
|
||||
LastAccessFilter,
|
||||
ACLLinkInline
|
||||
)
|
||||
from .logs import TaskExecutionLogAdmin, AccessLogAdmin
|
||||
from .base import BaseVPNAdmin, format_bytes
|
||||
|
||||
# Re-export for backward compatibility
|
||||
__all__ = [
|
||||
'UserAdmin',
|
||||
'ServerAdmin',
|
||||
'UserACLInline',
|
||||
'ACLAdmin',
|
||||
'ACLLinkAdmin',
|
||||
'TaskExecutionLogAdmin',
|
||||
'AccessLogAdmin',
|
||||
'BaseVPNAdmin',
|
||||
'format_bytes',
|
||||
'UserNameFilter',
|
||||
'ServerNameFilter',
|
||||
'LastAccessFilter',
|
||||
'ACLLinkInline'
|
||||
]
|
||||
@@ -1,485 +0,0 @@
|
||||
"""
|
||||
Access control admin interfaces (ACL, ACLLink)
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.utils.timezone import localtime
|
||||
from django.db.models import Q
|
||||
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
from vpn.models import ACL, ACLLink, User
|
||||
from .base import BaseVPNAdmin
|
||||
from vpn.utils import format_object
|
||||
|
||||
|
||||
class UserNameFilter(admin.SimpleListFilter):
|
||||
title = 'User'
|
||||
parameter_name = 'user'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
users = set(User.objects.values_list('username', flat=True))
|
||||
return [(user, user) for user in users]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
return queryset.filter(user__username=self.value())
|
||||
return queryset
|
||||
|
||||
|
||||
class ServerNameFilter(admin.SimpleListFilter):
|
||||
title = 'Server Name'
|
||||
parameter_name = 'acl__server__name'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
servers = set(ACL.objects.values_list('server__name', flat=True))
|
||||
return [(server, server) for server in servers]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
return queryset.filter(acl__server__name=self.value())
|
||||
return queryset
|
||||
|
||||
|
||||
class LastAccessFilter(admin.SimpleListFilter):
|
||||
title = 'Last Access'
|
||||
parameter_name = 'last_access_status'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return [
|
||||
('never', 'Never accessed'),
|
||||
('week', 'Last week'),
|
||||
('month', 'Last month'),
|
||||
('old', 'Older than 3 months'),
|
||||
]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
if self.value() == 'never':
|
||||
# Links that have never been accessed
|
||||
return queryset.filter(last_access_time__isnull=True)
|
||||
elif self.value() == 'week':
|
||||
# Links accessed in the last week
|
||||
week_ago = timezone.now() - timedelta(days=7)
|
||||
return queryset.filter(last_access_time__gte=week_ago)
|
||||
elif self.value() == 'month':
|
||||
# Links accessed in the last month
|
||||
month_ago = timezone.now() - timedelta(days=30)
|
||||
return queryset.filter(last_access_time__gte=month_ago)
|
||||
elif self.value() == 'old':
|
||||
# Links not accessed for more than 3 months
|
||||
three_months_ago = timezone.now() - timedelta(days=90)
|
||||
return queryset.filter(last_access_time__lt=three_months_ago)
|
||||
return queryset
|
||||
|
||||
|
||||
class ACLLinkInline(admin.TabularInline):
|
||||
model = ACLLink
|
||||
extra = 1
|
||||
help_text = 'Add or change ACL links'
|
||||
verbose_name = 'Dynamic link'
|
||||
verbose_name_plural = 'Dynamic links'
|
||||
fields = ('link', 'generate_link_button', 'comment')
|
||||
readonly_fields = ('generate_link_button',)
|
||||
|
||||
@admin.display(description="Generate")
|
||||
def generate_link_button(self, obj=None):
|
||||
return format_html(
|
||||
'<button type="button" class="generate-link" onclick="generateLink(this)">🔄</button>'
|
||||
)
|
||||
|
||||
class Media:
|
||||
js = ('admin/js/generate_link.js',)
|
||||
|
||||
|
||||
@admin.register(ACL)
|
||||
class ACLAdmin(BaseVPNAdmin):
|
||||
list_display = ('user', 'server', 'server_type', 'display_links', 'created_at')
|
||||
list_filter = (UserNameFilter, 'server__server_type', ServerNameFilter)
|
||||
# Fixed search_fields - removed problematic polymorphic server fields
|
||||
search_fields = ('user__username', 'user__comment', 'links__link')
|
||||
readonly_fields = ('user_info',)
|
||||
inlines = [ACLLinkInline]
|
||||
|
||||
@admin.display(description='Server Type', ordering='server__server_type')
|
||||
def server_type(self, obj):
|
||||
return obj.server.get_server_type_display()
|
||||
|
||||
@admin.display(description='Client info')
|
||||
def user_info(self, obj):
|
||||
server = obj.server
|
||||
user = obj.user
|
||||
try:
|
||||
# Use cached statistics instead of direct server requests
|
||||
from vpn.models import UserStatistics
|
||||
user_stats = UserStatistics.objects.filter(
|
||||
user=user,
|
||||
server_name=server.name
|
||||
).first()
|
||||
|
||||
if user_stats:
|
||||
# Format cached data nicely
|
||||
data = {
|
||||
'user': user.username,
|
||||
'server': server.name,
|
||||
'total_connections': user_stats.total_connections,
|
||||
'recent_connections': user_stats.recent_connections,
|
||||
'max_daily': user_stats.max_daily,
|
||||
'last_updated': user_stats.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'status': 'from_cache'
|
||||
}
|
||||
return format_object(data)
|
||||
else:
|
||||
# Fallback to minimal server check (avoid slow API calls on admin pages)
|
||||
return mark_safe(
|
||||
'<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 8px; border-radius: 4px;">' +
|
||||
'<strong>ℹ️ User Statistics:</strong><br>' +
|
||||
'No cached statistics available.<br>' +
|
||||
'<small>Run "Update user statistics cache" action to populate data.</small>' +
|
||||
'</div>'
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to get cached user info for {user.username} on {server.name}: {e}")
|
||||
return mark_safe(f"<span style='color: red;'>Cache error: {e}</span>")
|
||||
|
||||
@admin.display(description='User Links')
|
||||
def display_links(self, obj):
|
||||
links_count = obj.links.count()
|
||||
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.user.hash}"
|
||||
|
||||
return format_html(
|
||||
'<div style="font-size: 12px; margin-bottom: 8px;">'
|
||||
'<strong>🔗 {} link(s)</strong>'
|
||||
'</div>'
|
||||
'<a href="{}" target="_blank" style="background: #4ade80; color: #000; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 11px; font-weight: bold;">🌐 User Portal</a>',
|
||||
links_count, portal_url
|
||||
)
|
||||
|
||||
|
||||
# Note: UserStatistics is not registered separately as admin model.
|
||||
# All user statistics functionality is integrated into ACLLinkAdmin below.
|
||||
@admin.register(ACLLink)
|
||||
class ACLLinkAdmin(BaseVPNAdmin):
|
||||
list_display = ('link_display', 'user_display', 'server_display', 'comment_display', 'stats_display', 'usage_chart_display', 'last_access_display', 'created_display')
|
||||
list_filter = ('acl__server__name', 'acl__server__server_type', LastAccessFilter, 'acl__user__username')
|
||||
search_fields = ('link', 'comment', 'acl__user__username', 'acl__server__name')
|
||||
list_per_page = 100
|
||||
actions = ['delete_selected_links', 'update_statistics_action']
|
||||
list_select_related = ('acl__user', 'acl__server')
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.select_related('acl__user', 'acl__server')
|
||||
return qs
|
||||
|
||||
@admin.display(description='Link', ordering='link')
|
||||
def link_display(self, obj):
|
||||
link_url = f"{EXTERNAL_ADDRESS}/ss/{obj.link}#{obj.acl.server.name}"
|
||||
return format_html(
|
||||
'<a href="{}" target="_blank" style="color: #2563eb; text-decoration: none; font-family: monospace;">{}</a>',
|
||||
link_url, obj.link[:16] + '...' if len(obj.link) > 16 else obj.link
|
||||
)
|
||||
|
||||
@admin.display(description='User', ordering='acl__user__username')
|
||||
def user_display(self, obj):
|
||||
return obj.acl.user.username
|
||||
|
||||
@admin.display(description='Server', ordering='acl__server__name')
|
||||
def server_display(self, obj):
|
||||
server_type_icons = {
|
||||
'outline': '🔵',
|
||||
'wireguard': '🟢',
|
||||
'xray_core': '🟣',
|
||||
}
|
||||
icon = server_type_icons.get(obj.acl.server.server_type, '⚪')
|
||||
return f"{icon} {obj.acl.server.name}"
|
||||
|
||||
@admin.display(description='Comment', ordering='comment')
|
||||
def comment_display(self, obj):
|
||||
if obj.comment:
|
||||
return obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Statistics')
|
||||
def stats_display(self, obj):
|
||||
try:
|
||||
from vpn.models import UserStatistics
|
||||
stats = UserStatistics.objects.get(
|
||||
user=obj.acl.user,
|
||||
server_name=obj.acl.server.name,
|
||||
acl_link_id=obj.link
|
||||
)
|
||||
|
||||
# Color coding based on usage
|
||||
if stats.total_connections > 100:
|
||||
color = '#16a34a' # green - high usage
|
||||
elif stats.total_connections > 10:
|
||||
color = '#eab308' # yellow - medium usage
|
||||
elif stats.total_connections > 0:
|
||||
color = '#f97316' # orange - low usage
|
||||
else:
|
||||
color = '#9ca3af' # gray - no usage
|
||||
|
||||
return mark_safe(
|
||||
f'<div style="font-size: 12px;">'
|
||||
f'<span style="color: {color}; font-weight: bold;">✨ {stats.total_connections} total</span><br>'
|
||||
f'<span style="color: #6b7280;">📅 {stats.recent_connections} last 30d</span>'
|
||||
f'</div>'
|
||||
)
|
||||
except:
|
||||
return mark_safe('<span style="color: #dc2626; font-size: 12px;">No cache</span>')
|
||||
|
||||
@admin.display(description='30-day Chart')
|
||||
def usage_chart_display(self, obj):
|
||||
try:
|
||||
from vpn.models import UserStatistics
|
||||
stats = UserStatistics.objects.get(
|
||||
user=obj.acl.user,
|
||||
server_name=obj.acl.server.name,
|
||||
acl_link_id=obj.link
|
||||
)
|
||||
|
||||
if not stats.daily_usage:
|
||||
return mark_safe('<span style="color: #9ca3af; font-size: 11px;">No data</span>')
|
||||
|
||||
# Create wider mini chart for better visibility
|
||||
max_val = max(stats.daily_usage) if stats.daily_usage else 1
|
||||
chart_html = '<div style="display: flex; align-items: end; gap: 1px; height: 35px; width: 180px;">'
|
||||
|
||||
# Show last 30 days with wider bars for better visibility
|
||||
for day_count in stats.daily_usage[-30:]: # Last 30 days
|
||||
if max_val > 0:
|
||||
height_percent = (day_count / max_val) * 100
|
||||
else:
|
||||
height_percent = 0
|
||||
|
||||
color = '#4ade80' if day_count > 0 else '#e5e7eb'
|
||||
chart_html += f'<div style="background: {color}; width: 5px; height: {height_percent}%; min-height: 1px; border-radius: 1px;" title="{day_count} connections"></div>'
|
||||
|
||||
chart_html += '</div>'
|
||||
|
||||
# Add summary info below chart
|
||||
total_last_30 = sum(stats.daily_usage[-30:]) if stats.daily_usage else 0
|
||||
avg_daily = total_last_30 / 30 if total_last_30 > 0 else 0
|
||||
chart_html += f'<div style="font-size: 10px; color: #6b7280; margin-top: 2px;">'
|
||||
chart_html += f'Max: {max_val} | Avg: {avg_daily:.1f}'
|
||||
chart_html += f'</div>'
|
||||
|
||||
return mark_safe(chart_html)
|
||||
except:
|
||||
return mark_safe('<span style="color: #dc2626; font-size: 11px;">-</span>')
|
||||
|
||||
@admin.display(description='Last Access', ordering='last_access_time')
|
||||
def last_access_display(self, obj):
|
||||
if obj.last_access_time:
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
local_time = localtime(obj.last_access_time)
|
||||
now = timezone.now()
|
||||
diff = now - obj.last_access_time
|
||||
|
||||
# Color coding based on age
|
||||
if diff <= timedelta(days=7):
|
||||
color = '#16a34a' # green - recent
|
||||
elif diff <= timedelta(days=30):
|
||||
color = '#eab308' # yellow - medium
|
||||
elif diff <= timedelta(days=90):
|
||||
color = '#f97316' # orange - old
|
||||
else:
|
||||
color = '#dc2626' # red - very old
|
||||
|
||||
formatted_date = local_time.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
# Add relative time info
|
||||
if diff.days > 365:
|
||||
relative = f'{diff.days // 365}y ago'
|
||||
elif diff.days > 30:
|
||||
relative = f'{diff.days // 30}mo ago'
|
||||
elif diff.days > 0:
|
||||
relative = f'{diff.days}d ago'
|
||||
elif diff.seconds > 3600:
|
||||
relative = f'{diff.seconds // 3600}h ago'
|
||||
else:
|
||||
relative = 'Recently'
|
||||
|
||||
return mark_safe(
|
||||
f'<span style="color: {color}; font-weight: bold; font-size: 12px;">{formatted_date}</span>'
|
||||
f'<br><small style="color: {color}; font-size: 10px;">{relative}</small>'
|
||||
)
|
||||
return mark_safe('<span style="color: #dc2626; font-weight: bold; font-size: 12px;">Never</span>')
|
||||
|
||||
@admin.display(description='Created', ordering='acl__created_at')
|
||||
def created_display(self, obj):
|
||||
local_time = localtime(obj.acl.created_at)
|
||||
return local_time.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
def delete_selected_links(self, request, queryset):
|
||||
count = queryset.count()
|
||||
queryset.delete()
|
||||
self.message_user(request, f'Successfully deleted {count} ACL link(s).')
|
||||
delete_selected_links.short_description = "Delete selected ACL links"
|
||||
|
||||
def update_statistics_action(self, request, queryset):
|
||||
"""Trigger comprehensive statistics update for all users and links"""
|
||||
# This action doesn't require selected items
|
||||
try:
|
||||
from vpn.tasks import update_user_statistics
|
||||
|
||||
# Start the statistics update task
|
||||
task = update_user_statistics.delay()
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'📊 Statistics update started successfully! Task ID: {task.id}. '
|
||||
f'This will recalculate usage statistics for all users and links. '
|
||||
f'Refresh this page in a few moments to see updated data.',
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f'❌ Failed to start statistics update: {e}',
|
||||
level=messages.ERROR
|
||||
)
|
||||
update_statistics_action.short_description = "📊 Update all user statistics and usage data"
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Remove default delete action and keep only custom one"""
|
||||
actions = super().get_actions(request)
|
||||
if 'delete_selected' in actions:
|
||||
del actions['delete_selected']
|
||||
return actions
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return True
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return True
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
# Handle actions that don't require item selection
|
||||
if 'action' in request.POST:
|
||||
action = request.POST['action']
|
||||
if action == 'update_statistics_action':
|
||||
# Call the action directly without queryset requirement
|
||||
self.update_statistics_action(request, None)
|
||||
# Return redirect to prevent AttributeError
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
# Add comprehensive statistics to the changelist
|
||||
extra_context = extra_context or {}
|
||||
|
||||
# Get queryset for statistics
|
||||
queryset = self.get_queryset(request)
|
||||
|
||||
total_links = queryset.count()
|
||||
never_accessed = queryset.filter(last_access_time__isnull=True).count()
|
||||
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.db.models import Count, Max, Min
|
||||
|
||||
now = timezone.now()
|
||||
one_week_ago = now - timedelta(days=7)
|
||||
one_month_ago = now - timedelta(days=30)
|
||||
three_months_ago = now - timedelta(days=90)
|
||||
|
||||
# Access time statistics
|
||||
old_links = queryset.filter(
|
||||
Q(last_access_time__lt=three_months_ago) | Q(last_access_time__isnull=True)
|
||||
).count()
|
||||
|
||||
recent_week = queryset.filter(last_access_time__gte=one_week_ago).count()
|
||||
recent_month = queryset.filter(last_access_time__gte=one_month_ago).count()
|
||||
|
||||
# Calculate comprehensive statistics from cache
|
||||
try:
|
||||
from vpn.models import UserStatistics
|
||||
from django.db import models
|
||||
|
||||
# Total usage statistics
|
||||
cached_stats = UserStatistics.objects.aggregate(
|
||||
total_uses=models.Sum('total_connections'),
|
||||
recent_uses=models.Sum('recent_connections'),
|
||||
max_daily_peak=models.Max('max_daily')
|
||||
)
|
||||
total_uses = cached_stats['total_uses'] or 0
|
||||
recent_uses = cached_stats['recent_uses'] or 0
|
||||
max_daily_peak = cached_stats['max_daily_peak'] or 0
|
||||
|
||||
# Server and user breakdown
|
||||
server_stats = UserStatistics.objects.values('server_name').annotate(
|
||||
total_connections=models.Sum('total_connections'),
|
||||
link_count=models.Count('id')
|
||||
).order_by('-total_connections')[:5] # Top 5 servers
|
||||
|
||||
user_stats = UserStatistics.objects.values('user__username').annotate(
|
||||
total_connections=models.Sum('total_connections'),
|
||||
link_count=models.Count('id')
|
||||
).order_by('-total_connections')[:5] # Top 5 users
|
||||
|
||||
# Links with cache data count
|
||||
cached_links_count = UserStatistics.objects.filter(
|
||||
acl_link_id__isnull=False
|
||||
).count()
|
||||
|
||||
except Exception as e:
|
||||
total_uses = 0
|
||||
recent_uses = 0
|
||||
max_daily_peak = 0
|
||||
server_stats = []
|
||||
user_stats = []
|
||||
cached_links_count = 0
|
||||
|
||||
# Active vs inactive breakdown
|
||||
active_links = total_links - never_accessed - old_links
|
||||
if active_links < 0:
|
||||
active_links = 0
|
||||
|
||||
extra_context.update({
|
||||
'total_links': total_links,
|
||||
'never_accessed': never_accessed,
|
||||
'old_links': old_links,
|
||||
'active_links': active_links,
|
||||
'recent_week': recent_week,
|
||||
'recent_month': recent_month,
|
||||
'total_uses': total_uses,
|
||||
'recent_uses': recent_uses,
|
||||
'max_daily_peak': max_daily_peak,
|
||||
'server_stats': server_stats,
|
||||
'user_stats': user_stats,
|
||||
'cached_links_count': cached_links_count,
|
||||
'cache_coverage_percent': (cached_links_count * 100 // total_links) if total_links > 0 else 0,
|
||||
})
|
||||
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
def get_ordering(self, request):
|
||||
"""Allow sorting by annotated fields"""
|
||||
# Handle sorting by last_access_time if requested
|
||||
order_var = request.GET.get('o')
|
||||
if order_var:
|
||||
try:
|
||||
field_index = int(order_var.lstrip('-'))
|
||||
# Check if this corresponds to the last_access column (index 6 in list_display)
|
||||
if field_index == 6: # last_access_display is at index 6
|
||||
if order_var.startswith('-'):
|
||||
return ['-last_access_time']
|
||||
else:
|
||||
return ['last_access_time']
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Default ordering
|
||||
return ['-acl__created_at', 'acl__user__username']
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
Base utilities and common imports for VPN admin interfaces
|
||||
"""
|
||||
import json
|
||||
import shortuuid
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import render, redirect
|
||||
from django.contrib import messages
|
||||
from django.urls import path, reverse
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.utils.timezone import localtime
|
||||
from django.db.models import Max, Subquery, OuterRef, Q
|
||||
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
|
||||
|
||||
def format_bytes(bytes_val):
|
||||
"""Format bytes to human readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if bytes_val < 1024.0:
|
||||
return f"{bytes_val:.1f}{unit}"
|
||||
bytes_val /= 1024.0
|
||||
return f"{bytes_val:.1f}PB"
|
||||
|
||||
|
||||
class BaseVPNAdmin(admin.ModelAdmin):
|
||||
"""Base admin class with common functionality"""
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('admin/css/vpn_admin.css',)
|
||||
}
|
||||
|
||||
def get_external_address(self):
|
||||
"""Get external address for links"""
|
||||
return EXTERNAL_ADDRESS
|
||||
|
||||
def format_hash_link(self, obj, hash_value):
|
||||
"""Format hash as clickable link"""
|
||||
if not hash_value:
|
||||
return mark_safe('<span style="color: #dc3545;">No hash</span>')
|
||||
|
||||
portal_url = f"https://{EXTERNAL_ADDRESS}/u/{hash_value}"
|
||||
return mark_safe(
|
||||
f'<div style="display: flex; align-items: center; gap: 10px;">'
|
||||
f'<code style="background: #f8f9fa; padding: 4px 8px; border-radius: 3px; font-size: 12px;">{hash_value[:12]}...</code>'
|
||||
f'<a href="{portal_url}" target="_blank" style="color: #007cba; text-decoration: none;">🔗 Portal</a>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
|
||||
class BaseListFilter(admin.SimpleListFilter):
|
||||
"""Base filter class with common functionality"""
|
||||
pass
|
||||
@@ -1,179 +0,0 @@
|
||||
"""
|
||||
Logging admin interfaces (TaskExecutionLog, AccessLog)
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.utils.timezone import localtime
|
||||
|
||||
from vpn.models import TaskExecutionLog, AccessLog
|
||||
from .base import BaseVPNAdmin
|
||||
from vpn.utils import format_object
|
||||
|
||||
|
||||
@admin.register(TaskExecutionLog)
|
||||
class TaskExecutionLogAdmin(BaseVPNAdmin):
|
||||
list_display = ('task_name_display', 'action', 'status_display', 'server', 'user', 'execution_time_display', 'created_at')
|
||||
list_filter = ('task_name', 'status', 'server', 'created_at')
|
||||
search_fields = ('task_id', 'task_name', 'action', 'user__username', 'server__name', 'message')
|
||||
readonly_fields = ('task_id', 'task_name', 'server', 'user', 'action', 'status', 'message_formatted', 'execution_time', 'created_at')
|
||||
ordering = ('-created_at',)
|
||||
list_per_page = 100
|
||||
date_hierarchy = 'created_at'
|
||||
actions = ['trigger_full_sync', 'trigger_statistics_update']
|
||||
|
||||
fieldsets = (
|
||||
('Task Information', {
|
||||
'fields': ('task_id', 'task_name', 'action', 'status')
|
||||
}),
|
||||
('Related Objects', {
|
||||
'fields': ('server', 'user')
|
||||
}),
|
||||
('Execution Details', {
|
||||
'fields': ('message_formatted', 'execution_time', 'created_at')
|
||||
}),
|
||||
)
|
||||
|
||||
def trigger_full_sync(self, request, queryset):
|
||||
"""Trigger manual full synchronization of all servers"""
|
||||
# This action doesn't require selected items
|
||||
try:
|
||||
from vpn.tasks import sync_all_users
|
||||
|
||||
# Start the sync task
|
||||
task = sync_all_users.delay()
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'Full synchronization started successfully. Task ID: {task.id}. Check logs below for progress.',
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f'Failed to start full synchronization: {e}',
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
trigger_full_sync.short_description = "🔄 Trigger full sync of all servers"
|
||||
|
||||
def trigger_statistics_update(self, request, queryset):
|
||||
"""Trigger manual update of user statistics cache"""
|
||||
# This action doesn't require selected items
|
||||
try:
|
||||
from vpn.tasks import update_user_statistics
|
||||
|
||||
# Start the statistics update task
|
||||
task = update_user_statistics.delay()
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'User statistics update started successfully. Task ID: {task.id}. Check logs below for progress.',
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f'Failed to start statistics update: {e}',
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
trigger_statistics_update.short_description = "📊 Update user statistics cache"
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Remove default delete action for logs"""
|
||||
actions = super().get_actions(request)
|
||||
if 'delete_selected' in actions:
|
||||
del actions['delete_selected']
|
||||
return actions
|
||||
|
||||
@admin.display(description='Task', ordering='task_name')
|
||||
def task_name_display(self, obj):
|
||||
task_names = {
|
||||
'sync_all_servers': '🔄 Sync All',
|
||||
'sync_server_users': '👥 Server Sync',
|
||||
'sync_server_info': '⚙️ Server Info',
|
||||
'sync_user_on_server': '👤 User Sync',
|
||||
'cleanup_task_logs': '🧹 Cleanup',
|
||||
'update_user_statistics': '📊 Statistics',
|
||||
}
|
||||
return task_names.get(obj.task_name, obj.task_name)
|
||||
|
||||
@admin.display(description='Status', ordering='status')
|
||||
def status_display(self, obj):
|
||||
status_icons = {
|
||||
'STARTED': '🟡 Started',
|
||||
'SUCCESS': '✅ Success',
|
||||
'FAILURE': '❌ Failed',
|
||||
'RETRY': '🔄 Retry',
|
||||
}
|
||||
return status_icons.get(obj.status, obj.status)
|
||||
|
||||
@admin.display(description='Time', ordering='execution_time')
|
||||
def execution_time_display(self, obj):
|
||||
if obj.execution_time:
|
||||
if obj.execution_time < 1:
|
||||
return f"{obj.execution_time*1000:.0f}ms"
|
||||
else:
|
||||
return f"{obj.execution_time:.2f}s"
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Message')
|
||||
def message_formatted(self, obj):
|
||||
if obj.message:
|
||||
return mark_safe(f"<pre style='white-space: pre-wrap; max-width: 800px;'>{obj.message}</pre>")
|
||||
return '-'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
"""Override to handle actions that don't require item selection"""
|
||||
# Handle actions that don't require selection
|
||||
if 'action' in request.POST:
|
||||
action = request.POST['action']
|
||||
if action == 'trigger_full_sync':
|
||||
# Call the action directly without queryset requirement
|
||||
self.trigger_full_sync(request, None)
|
||||
# Return redirect to prevent AttributeError
|
||||
return redirect(request.get_full_path())
|
||||
elif action == 'trigger_statistics_update':
|
||||
# Call the statistics update action
|
||||
self.trigger_statistics_update(request, None)
|
||||
# Return redirect to prevent AttributeError
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
|
||||
@admin.register(AccessLog)
|
||||
class AccessLogAdmin(BaseVPNAdmin):
|
||||
list_display = ('user', 'server', 'acl_link_display', 'action', 'formatted_timestamp')
|
||||
list_filter = ('user', 'server', 'action', 'timestamp')
|
||||
search_fields = ('user', 'server', 'acl_link_id', 'action', 'timestamp', 'data')
|
||||
readonly_fields = ('server', 'user', 'acl_link_id', 'formatted_timestamp', 'action', 'formated_data')
|
||||
|
||||
@admin.display(description='Link', ordering='acl_link_id')
|
||||
def acl_link_display(self, obj):
|
||||
if obj.acl_link_id:
|
||||
return format_html(
|
||||
'<span style="font-family: monospace; color: #2563eb;">{}</span>',
|
||||
obj.acl_link_id[:12] + '...' if len(obj.acl_link_id) > 12 else obj.acl_link_id
|
||||
)
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Timestamp')
|
||||
def formatted_timestamp(self, obj):
|
||||
local_time = localtime(obj.timestamp)
|
||||
return local_time.strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
|
||||
@admin.display(description='Details')
|
||||
def formated_data(self, obj):
|
||||
return format_object(obj.data)
|
||||
@@ -1,864 +0,0 @@
|
||||
"""
|
||||
Server admin interface
|
||||
"""
|
||||
import re
|
||||
from polymorphic.admin import PolymorphicParentModelAdmin
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.db.models import Count, Case, When, Value, IntegerField, F, Subquery, OuterRef
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib import messages
|
||||
from django.urls import path, reverse
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
from vpn.models import Server, ACL, ACLLink
|
||||
from .base import BaseVPNAdmin, format_bytes
|
||||
from vpn.server_plugins import (
|
||||
OutlineServer,
|
||||
WireguardServer,
|
||||
XrayServerV2
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Server)
|
||||
class ServerAdmin(PolymorphicParentModelAdmin, BaseVPNAdmin):
|
||||
base_model = Server
|
||||
child_models = (OutlineServer, WireguardServer, XrayServerV2)
|
||||
list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'server_status_compact', 'registration_date')
|
||||
search_fields = ('name', 'comment')
|
||||
list_filter = ('server_type', )
|
||||
actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers', 'sync_xray_inbounds', 'check_status']
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('admin/css/vpn_admin.css',)
|
||||
}
|
||||
js = ('admin/js/server_status_check.js',)
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('move-clients/', self.admin_site.admin_view(self.move_clients_view), name='server_move_clients'),
|
||||
path('<int:server_id>/check-status/', self.admin_site.admin_view(self.check_server_status_view), name='server_check_status'),
|
||||
path('<int:object_id>/sync/', self.admin_site.admin_view(self.sync_server_view), name='server_sync'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def move_clients_action(self, request, queryset):
|
||||
"""Custom action to move client links between servers"""
|
||||
if queryset.count() == 0:
|
||||
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
|
||||
return
|
||||
|
||||
# Redirect to move clients page
|
||||
selected_ids = ','.join(str(server.id) for server in queryset)
|
||||
return HttpResponseRedirect(f"{reverse('admin:server_move_clients')}?servers={selected_ids}")
|
||||
|
||||
move_clients_action.short_description = "Move client links between servers"
|
||||
|
||||
def move_clients_view(self, request):
|
||||
"""View for moving clients between servers"""
|
||||
if request.method == 'GET':
|
||||
# Get selected servers from URL parameters
|
||||
server_ids = request.GET.get('servers', '').split(',')
|
||||
if not server_ids or server_ids == ['']:
|
||||
messages.error(request, "No servers selected.")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
try:
|
||||
# Only work with database objects, don't check server connectivity
|
||||
servers = Server.objects.filter(id__in=server_ids)
|
||||
all_servers = Server.objects.all()
|
||||
|
||||
# Get ACL links for selected servers with related data
|
||||
# This is purely database operation, no server connectivity required
|
||||
links_by_server = {}
|
||||
for server in servers:
|
||||
try:
|
||||
# Get all ACL links for this server with user and ACL data
|
||||
links = ACLLink.objects.filter(
|
||||
acl__server=server
|
||||
).select_related('acl__user', 'acl__server').order_by('acl__user__username', 'comment')
|
||||
links_by_server[server] = links
|
||||
except Exception as e:
|
||||
# Log the error but continue with other servers
|
||||
messages.warning(request, f"Warning: Could not load links for server {server.name}: {e}")
|
||||
links_by_server[server] = []
|
||||
|
||||
context = {
|
||||
'title': 'Move Client Links Between Servers',
|
||||
'servers': servers,
|
||||
'all_servers': all_servers,
|
||||
'links_by_server': links_by_server,
|
||||
}
|
||||
|
||||
return render(request, 'admin/move_clients.html', context)
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Database error while loading data: {e}")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
elif request.method == 'POST':
|
||||
# Process the transfer of ACL links - purely database operations
|
||||
try:
|
||||
source_server_id = request.POST.get('source_server')
|
||||
target_server_id = request.POST.get('target_server')
|
||||
selected_link_ids = request.POST.getlist('selected_links')
|
||||
comment_regex = request.POST.get('comment_regex', '').strip()
|
||||
|
||||
if not source_server_id or not target_server_id:
|
||||
messages.error(request, "Please select both source and target servers.")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
if source_server_id == target_server_id:
|
||||
messages.error(request, "Source and target servers cannot be the same.")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
if not selected_link_ids:
|
||||
messages.error(request, "Please select at least one link to move.")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
# Parse and validate regex pattern if provided
|
||||
regex_pattern = None
|
||||
regex_replacement = None
|
||||
regex_parts = None
|
||||
if comment_regex:
|
||||
try:
|
||||
regex_parts = comment_regex.split(' -> ')
|
||||
if len(regex_parts) != 2:
|
||||
messages.error(request, "Invalid regex format. Use: pattern -> replacement")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
pattern_str = regex_parts[0]
|
||||
replacement_str = regex_parts[1]
|
||||
|
||||
# Convert JavaScript-style $1, $2, $3 to Python-style \1, \2, \3
|
||||
python_replacement = replacement_str
|
||||
# Replace $1, $2, etc. with \1, \2, etc. for Python regex
|
||||
python_replacement = re.sub(r'\$(\d+)', r'\\\1', replacement_str)
|
||||
|
||||
# Test compile the regex pattern
|
||||
regex_pattern = re.compile(pattern_str)
|
||||
regex_replacement = python_replacement
|
||||
|
||||
# Test the replacement on a sample string to validate syntax
|
||||
test_result = regex_pattern.sub(regex_replacement, "test sample")
|
||||
|
||||
except re.error as e:
|
||||
messages.error(request, f"Invalid regular expression pattern '{regex_parts[0] if regex_parts else comment_regex}': {e}")
|
||||
return redirect(request.get_full_path())
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error in regex replacement '{replacement_str if 'replacement_str' in locals() else 'unknown'}': {e}")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
# Get server objects from database only
|
||||
try:
|
||||
source_server = Server.objects.get(id=source_server_id)
|
||||
target_server = Server.objects.get(id=target_server_id)
|
||||
except Server.DoesNotExist:
|
||||
messages.error(request, "One of the selected servers was not found in database.")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
moved_count = 0
|
||||
errors = []
|
||||
users_processed = set()
|
||||
comments_transformed = 0
|
||||
|
||||
# Process each selected link - database operations only
|
||||
for link_id in selected_link_ids:
|
||||
try:
|
||||
# Get the ACL link with related ACL and user data
|
||||
acl_link = ACLLink.objects.select_related('acl__user', 'acl__server').get(
|
||||
id=link_id,
|
||||
acl__server=source_server
|
||||
)
|
||||
user = acl_link.acl.user
|
||||
|
||||
# Apply regex transformation to comment if provided
|
||||
original_comment = acl_link.comment
|
||||
if regex_pattern and regex_replacement is not None:
|
||||
try:
|
||||
# Use Python's re.sub for replacement, which properly handles $1, $2 groups
|
||||
new_comment = regex_pattern.sub(regex_replacement, original_comment)
|
||||
if new_comment != original_comment:
|
||||
acl_link.comment = new_comment
|
||||
comments_transformed += 1
|
||||
# Debug logging - shows both original and converted patterns
|
||||
print(f"DEBUG: Transformed '{original_comment}' -> '{new_comment}'")
|
||||
print(f" Original pattern: '{regex_parts[0]}' -> '{regex_parts[1]}'")
|
||||
print(f" Python pattern: '{regex_parts[0]}' -> '{regex_replacement}'")
|
||||
except Exception as e:
|
||||
errors.append(f"Error applying regex to link {link_id} ('{original_comment}'): {e}")
|
||||
# Continue with original comment
|
||||
|
||||
# Check if user already has ACL on target server
|
||||
target_acl = ACL.objects.filter(user=user, server=target_server).first()
|
||||
|
||||
if target_acl:
|
||||
created = False
|
||||
else:
|
||||
# Create new ACL without auto-creating default link
|
||||
target_acl = ACL(user=user, server=target_server)
|
||||
target_acl.save(auto_create_link=False)
|
||||
created = True
|
||||
|
||||
# Move the link to target ACL - pure database operation
|
||||
acl_link.acl = target_acl
|
||||
acl_link.save()
|
||||
|
||||
moved_count += 1
|
||||
users_processed.add(user.username)
|
||||
|
||||
if created:
|
||||
messages.info(request, f"Created new ACL for user {user.username} on server {target_server.name}")
|
||||
|
||||
except ACLLink.DoesNotExist:
|
||||
errors.append(f"Link with ID {link_id} not found on source server")
|
||||
except Exception as e:
|
||||
errors.append(f"Database error moving link {link_id}: {e}")
|
||||
|
||||
# Clean up empty ACLs on source server - database operation only
|
||||
try:
|
||||
empty_acls = ACL.objects.filter(
|
||||
server=source_server,
|
||||
links__isnull=True
|
||||
)
|
||||
deleted_acls_count = empty_acls.count()
|
||||
empty_acls.delete()
|
||||
except Exception as e:
|
||||
messages.warning(request, f"Warning: Could not clean up empty ACLs: {e}")
|
||||
deleted_acls_count = 0
|
||||
|
||||
if moved_count > 0:
|
||||
success_msg = (
|
||||
f"Successfully moved {moved_count} link(s) for {len(users_processed)} user(s) "
|
||||
f"from '{source_server.name}' to '{target_server.name}'. "
|
||||
f"Cleaned up {deleted_acls_count} empty ACL(s)."
|
||||
)
|
||||
if comments_transformed > 0:
|
||||
success_msg += f" Transformed {comments_transformed} comment(s) using regex."
|
||||
|
||||
messages.success(request, success_msg)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
messages.error(request, error)
|
||||
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Database error during link transfer: {e}")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
def check_server_status_view(self, request, server_id):
|
||||
"""AJAX view to check server status"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
logger.info(f"Checking status for server ID: {server_id}")
|
||||
server = Server.objects.get(pk=server_id)
|
||||
real_server = server.get_real_instance()
|
||||
logger.info(f"Server found: {server.name}, type: {type(real_server).__name__}")
|
||||
|
||||
# Check server status based on type
|
||||
from vpn.server_plugins.outline import OutlineServer
|
||||
# Old xray_core module removed - skip this server type
|
||||
|
||||
if isinstance(real_server, OutlineServer):
|
||||
try:
|
||||
logger.info(f"Checking Outline server: {server.name}")
|
||||
# Try to get server info to check if it's online
|
||||
info = real_server.client.get_server_information()
|
||||
if info:
|
||||
logger.info(f"Server {server.name} is online with {info.get('accessKeyCount', info.get('access_key_count', 'unknown'))} keys")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'online',
|
||||
'message': f'Server is online. Keys: {info.get("accessKeyCount", info.get("access_key_count", "unknown"))}'
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Server {server.name} returned no info")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'offline',
|
||||
'message': 'Server not responding'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Outline server {server.name}: {e}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'error',
|
||||
'message': f'Connection error: {str(e)[:100]}'
|
||||
})
|
||||
|
||||
elif isinstance(real_server, XrayServerV2):
|
||||
try:
|
||||
logger.info(f"Checking Xray v2 server: {server.name}")
|
||||
# Get server status from new Xray implementation
|
||||
status = real_server.get_server_status()
|
||||
if status and isinstance(status, dict):
|
||||
if status.get('accessible', False):
|
||||
message = f'✅ Server is {status.get("status", "accessible")}. '
|
||||
message += f'Host: {status.get("client_hostname", "N/A")}, '
|
||||
message += f'API: {status.get("api_address", "N/A")}'
|
||||
|
||||
if status.get('api_connected'):
|
||||
message += ' (Connected)'
|
||||
# Add stats if available
|
||||
api_stats = status.get('api_stats', {})
|
||||
if api_stats and isinstance(api_stats, dict):
|
||||
if 'connection' in api_stats:
|
||||
message += f', Stats: {api_stats.get("connection", "ok")}'
|
||||
if api_stats.get('library') == 'not_available':
|
||||
message += ' [Basic check only]'
|
||||
elif status.get('api_error'):
|
||||
message += f' ({status.get("api_error")})'
|
||||
|
||||
message += f', Inbounds: {status.get("total_inbounds", 0)}'
|
||||
|
||||
logger.info(f"Xray v2 server {server.name} status: {message}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'online',
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
error_msg = status.get('error') or status.get('api_error', 'Unknown error')
|
||||
logger.warning(f"Xray v2 server {server.name} not accessible: {error_msg}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'offline',
|
||||
'message': f'❌ Server not accessible: {error_msg}'
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Xray v2 server {server.name} returned invalid status")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'offline',
|
||||
'message': 'Invalid server response'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Xray v2 server {server.name}: {e}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'error',
|
||||
'message': f'Connection error: {str(e)[:100]}'
|
||||
})
|
||||
|
||||
else:
|
||||
# For other server types, just return basic info
|
||||
logger.info(f"Server {server.name}, type: {server.server_type}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'unknown',
|
||||
'message': f'Status check not implemented for {server.server_type} servers'
|
||||
})
|
||||
|
||||
except Server.DoesNotExist:
|
||||
logger.error(f"Server with ID {server_id} not found")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Server not found'
|
||||
}, status=404)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error checking server {server_id}: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'Unexpected error: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
logger.warning(f"Invalid request method {request.method} for server status check")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Invalid request method'
|
||||
}, status=405)
|
||||
|
||||
def purge_all_keys_action(self, request, queryset):
|
||||
"""Purge all keys from selected servers without changing database"""
|
||||
if queryset.count() == 0:
|
||||
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
total_keys_removed = 0
|
||||
|
||||
for server in queryset:
|
||||
try:
|
||||
# Get the real polymorphic instance
|
||||
real_server = server.get_real_instance()
|
||||
server_type = type(real_server).__name__
|
||||
|
||||
# Check if this is an Outline server
|
||||
from vpn.server_plugins.outline import OutlineServer
|
||||
|
||||
if isinstance(real_server, OutlineServer) and hasattr(real_server, 'client'):
|
||||
# For Outline servers, get all keys and delete them
|
||||
try:
|
||||
keys = real_server.client.get_keys()
|
||||
keys_count = len(keys)
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
real_server.client.delete_key(key.key_id)
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Failed to delete key {key.key_id} from {server.name}: {e}",
|
||||
level=messages.WARNING
|
||||
)
|
||||
|
||||
total_keys_removed += keys_count
|
||||
success_count += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f"Successfully purged {keys_count} keys from server '{server.name}'.",
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f"Failed to connect to server '{server.name}': {e}",
|
||||
level=messages.ERROR
|
||||
)
|
||||
else:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Key purging only supported for Outline servers. Skipping '{server.name}' (type: {server_type}).",
|
||||
level=messages.INFO
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f"Unexpected error with server '{server.name}': {e}",
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
# Summary message
|
||||
if success_count > 0:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Purge completed! {success_count} server(s) processed, {total_keys_removed} total keys removed. "
|
||||
f"Database unchanged - run sync to restore proper keys.",
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
if error_count > 0:
|
||||
self.message_user(
|
||||
request,
|
||||
f"{error_count} server(s) had errors during purge.",
|
||||
level=messages.WARNING
|
||||
)
|
||||
|
||||
purge_all_keys_action.short_description = "🗑️ Purge all keys from server (database unchanged)"
|
||||
|
||||
def sync_all_selected_servers(self, request, queryset):
|
||||
"""Trigger sync for all users on selected servers"""
|
||||
if queryset.count() == 0:
|
||||
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
|
||||
return
|
||||
|
||||
try:
|
||||
from vpn.tasks import sync_server_users
|
||||
from celery import current_app
|
||||
|
||||
tasks_started = 0
|
||||
errors = []
|
||||
scheduled_tasks = set() # Track already scheduled tasks to avoid duplicates
|
||||
|
||||
for server in queryset:
|
||||
try:
|
||||
# Check if a task is already running for this server
|
||||
task_key = f"sync_server_{server.id}"
|
||||
|
||||
# Use Celery's inspect to check active tasks (optional, for better UX)
|
||||
try:
|
||||
inspect = current_app.control.inspect()
|
||||
active_tasks = inspect.active()
|
||||
|
||||
# Check if task is already scheduled for this server
|
||||
task_already_running = False
|
||||
if active_tasks:
|
||||
for worker, tasks in active_tasks.items():
|
||||
for task_info in tasks:
|
||||
if task_info.get('name') == 'sync_server_users':
|
||||
# Check if server.id is in the task args
|
||||
task_args = task_info.get('args', [])
|
||||
if task_args and len(task_args) > 0 and task_args[0] == server.id:
|
||||
task_already_running = True
|
||||
break
|
||||
|
||||
if task_already_running:
|
||||
self.message_user(
|
||||
request,
|
||||
f"⏳ Sync already in progress for '{server.name}'",
|
||||
level=messages.WARNING
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
# If we can't check active tasks, just proceed
|
||||
logger.debug(f"Could not check active tasks: {e}")
|
||||
|
||||
# Avoid scheduling duplicate tasks in this batch
|
||||
if server.id in scheduled_tasks:
|
||||
continue
|
||||
|
||||
task = sync_server_users.delay(server.id)
|
||||
scheduled_tasks.add(server.id)
|
||||
tasks_started += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f"🔄 Sync task started for '{server.name}' (Task ID: {task.id})",
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"'{server.name}': {e}")
|
||||
|
||||
if tasks_started > 0:
|
||||
self.message_user(
|
||||
request,
|
||||
f"✅ Started sync tasks for {tasks_started} server(s). Check Task Execution Logs for progress.",
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
self.message_user(
|
||||
request,
|
||||
f"❌ Failed to sync {error}",
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f"❌ Failed to start sync tasks: {e}",
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
sync_all_selected_servers.short_description = "🔄 Sync all users on selected servers"
|
||||
|
||||
def check_status(self, request, queryset):
|
||||
"""Check status for selected servers"""
|
||||
for server in queryset:
|
||||
try:
|
||||
status = server.get_server_status()
|
||||
msg = f"{server.name}: {status.get('accessible', 'Unknown')} - {status.get('status', 'N/A')}"
|
||||
self.message_user(request, msg, level=messages.INFO)
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error checking {server.name}: {e}", level=messages.ERROR)
|
||||
check_status.short_description = "📊 Check server status"
|
||||
|
||||
def sync_xray_inbounds(self, request, queryset):
|
||||
"""Sync inbounds for selected servers (Xray v2 only)"""
|
||||
synced_count = 0
|
||||
|
||||
for server in queryset:
|
||||
try:
|
||||
real_server = server.get_real_instance()
|
||||
if isinstance(real_server, XrayServerV2):
|
||||
real_server.sync_inbounds()
|
||||
synced_count += 1
|
||||
self.message_user(request, f"Scheduled inbound sync for {server.name}", level=messages.SUCCESS)
|
||||
else:
|
||||
self.message_user(request, f"{server.name} is not an Xray v2 server", level=messages.WARNING)
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error syncing inbounds for {server.name}: {e}", level=messages.ERROR)
|
||||
|
||||
if synced_count > 0:
|
||||
self.message_user(request, f"Scheduled inbound sync for {synced_count} server(s)", level=messages.SUCCESS)
|
||||
sync_xray_inbounds.short_description = "🔧 Sync Xray inbounds"
|
||||
|
||||
@admin.display(description='Server', ordering='name')
|
||||
def name_with_icon(self, obj):
|
||||
"""Display server name with type icon"""
|
||||
icons = {
|
||||
'outline': '🔵',
|
||||
'wireguard': '🟢',
|
||||
'xray_core': '🟣',
|
||||
'xray_v2': '🟡',
|
||||
}
|
||||
icon = icons.get(obj.server_type, '')
|
||||
name_part = f"{icon} {obj.name}" if icon else obj.name
|
||||
return name_part
|
||||
|
||||
@admin.display(description='Comment')
|
||||
def comment_short(self, obj):
|
||||
"""Display shortened comment"""
|
||||
if obj.comment:
|
||||
short_comment = obj.comment[:40] + '...' if len(obj.comment) > 40 else obj.comment
|
||||
return mark_safe(f'<span title="{obj.comment}" style="font-size: 12px;">{short_comment}</span>')
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Users & Links')
|
||||
def user_stats(self, obj):
|
||||
"""Display user count and active links statistics (optimized)"""
|
||||
try:
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
user_count = obj.user_count if hasattr(obj, 'user_count') else 0
|
||||
|
||||
# Different logic for Xray vs legacy servers
|
||||
if obj.server_type == 'xray_v2':
|
||||
# For Xray servers, count inbounds and active subscriptions
|
||||
from vpn.models_xray import ServerInbound
|
||||
total_inbounds = ServerInbound.objects.filter(server=obj, active=True).count()
|
||||
|
||||
# Count recent subscription accesses via AccessLog
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
from vpn.models import AccessLog
|
||||
active_accesses = AccessLog.objects.filter(
|
||||
server='Xray-Subscription',
|
||||
action='Success',
|
||||
timestamp__gte=thirty_days_ago
|
||||
).values('user').distinct().count()
|
||||
|
||||
total_links = total_inbounds
|
||||
active_links = min(active_accesses, user_count) # Can't be more than total users
|
||||
else:
|
||||
# Legacy servers: use ACL links as before
|
||||
if hasattr(obj, 'acl_set'):
|
||||
all_links = []
|
||||
for acl in obj.acl_set.all():
|
||||
if hasattr(acl, 'links') and hasattr(acl.links, 'all'):
|
||||
all_links.extend(acl.links.all())
|
||||
|
||||
total_links = len(all_links)
|
||||
|
||||
# Count active links from prefetched data
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
active_links = sum(1 for link in all_links
|
||||
if link.last_access_time and link.last_access_time >= thirty_days_ago)
|
||||
else:
|
||||
# Fallback to direct queries (less efficient)
|
||||
total_links = ACLLink.objects.filter(acl__server=obj).count()
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
active_links = ACLLink.objects.filter(
|
||||
acl__server=obj,
|
||||
last_access_time__isnull=False,
|
||||
last_access_time__gte=thirty_days_ago
|
||||
).count()
|
||||
|
||||
# Color coding based on activity
|
||||
if user_count == 0:
|
||||
color = '#9ca3af' # gray - no users
|
||||
elif total_links == 0:
|
||||
color = '#dc2626' # red - no links/inbounds
|
||||
elif obj.server_type == 'xray_v2':
|
||||
# For Xray: base on user activity rather than link activity
|
||||
if active_links > user_count * 0.5: # More than half users active
|
||||
color = '#16a34a' # green
|
||||
elif active_links > user_count * 0.2: # More than 20% users active
|
||||
color = '#eab308' # yellow
|
||||
else:
|
||||
color = '#f97316' # orange - low activity
|
||||
else:
|
||||
# Legacy servers: base on link activity
|
||||
if total_links > 0 and active_links > total_links * 0.7: # High activity
|
||||
color = '#16a34a' # green
|
||||
elif total_links > 0 and active_links > total_links * 0.3: # Medium activity
|
||||
color = '#eab308' # yellow
|
||||
else:
|
||||
color = '#f97316' # orange - low activity
|
||||
|
||||
# Different display for Xray vs legacy
|
||||
if obj.server_type == 'xray_v2':
|
||||
# Try to get traffic stats if stats enabled
|
||||
traffic_info = ""
|
||||
# Get the real XrayServerV2 instance to access its fields
|
||||
xray_server = obj.get_real_instance()
|
||||
if hasattr(xray_server, 'stats_enabled') and xray_server.stats_enabled and xray_server.api_enabled:
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
from vpn.xray_api_v2.stats import StatsManager
|
||||
|
||||
client = XrayClient(server=xray_server.api_address)
|
||||
stats_manager = StatsManager(client)
|
||||
traffic_summary = stats_manager.get_traffic_summary()
|
||||
|
||||
# Calculate total traffic
|
||||
total_uplink = 0
|
||||
total_downlink = 0
|
||||
|
||||
# Sum up user traffic
|
||||
for user_email, user_traffic in traffic_summary.get('users', {}).items():
|
||||
total_uplink += user_traffic.get('uplink', 0)
|
||||
total_downlink += user_traffic.get('downlink', 0)
|
||||
|
||||
# Format traffic
|
||||
|
||||
if total_uplink > 0 or total_downlink > 0:
|
||||
traffic_info = f'<div style="color: #6b7280; font-size: 11px;">↑{format_bytes(total_uplink)} ↓{format_bytes(total_downlink)}</div>'
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Could not get stats for Xray server {xray_server.name}: {e}")
|
||||
|
||||
return mark_safe(
|
||||
f'<div style="font-size: 12px;">' +
|
||||
f'<div style="color: {color}; font-weight: bold;">👥 {user_count} users</div>' +
|
||||
f'<div style="color: #6b7280;">📡 {total_links} inbounds</div>' +
|
||||
traffic_info +
|
||||
f'</div>'
|
||||
)
|
||||
else:
|
||||
return mark_safe(
|
||||
f'<div style="font-size: 12px;">' +
|
||||
f'<div style="color: {color}; font-weight: bold;">👥 {user_count} users</div>' +
|
||||
f'<div style="color: #6b7280;">🔗 {active_links}/{total_links} active</div>' +
|
||||
f'</div>'
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in user_stats for server {obj.name}: {e}", exc_info=True)
|
||||
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Stats error: {e}</span>')
|
||||
|
||||
@admin.display(description='Activity')
|
||||
def activity_summary(self, obj):
|
||||
"""Display recent activity summary (optimized)"""
|
||||
try:
|
||||
# Simplified version - avoid heavy DB queries on list page
|
||||
# This could be computed once per page load if needed
|
||||
return mark_safe(
|
||||
f'<div style="font-size: 11px; color: #6b7280;">' +
|
||||
f'<div>📊 Activity data</div>' +
|
||||
f'<div><small>Click to view details</small></div>' +
|
||||
f'</div>'
|
||||
)
|
||||
except Exception as e:
|
||||
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Activity unavailable</span>')
|
||||
|
||||
@admin.display(description='Status')
|
||||
def server_status_compact(self, obj):
|
||||
"""Display server status in compact format (optimized)"""
|
||||
try:
|
||||
# Avoid expensive server connectivity checks on list page
|
||||
# Show basic info and let users click to check status
|
||||
server_type_icons = {
|
||||
'outline': '🔵',
|
||||
'wireguard': '🟢',
|
||||
'xray_core': '🟣',
|
||||
}
|
||||
icon = server_type_icons.get(obj.server_type, '⚪')
|
||||
|
||||
return mark_safe(
|
||||
f'<div style="color: #6b7280; font-size: 11px;">' +
|
||||
f'{icon} {obj.server_type.title()}<br>' +
|
||||
f'<button type="button" class="check-status-btn btn btn-xs" '
|
||||
f'data-server-id="{obj.id}" data-server-name="{obj.name}" '
|
||||
f'data-server-type="{obj.server_type}" '
|
||||
f'style="background: #007cba; color: white; border: none; padding: 2px 6px; '
|
||||
f'border-radius: 3px; font-size: 10px; cursor: pointer;">'
|
||||
f'⚪ Check Status'
|
||||
f'</button>' +
|
||||
f'</div>'
|
||||
)
|
||||
except Exception as e:
|
||||
return mark_safe(
|
||||
f'<div style="color: #f97316; font-size: 11px; font-weight: bold;">' +
|
||||
f'⚠️ Error<br>' +
|
||||
f'<span style="font-weight: normal;" title="{str(e)}">' +
|
||||
f'{str(e)[:25]}...</span>' +
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
from vpn.models_xray import UserSubscription, ServerInbound
|
||||
|
||||
qs = super().get_queryset(request)
|
||||
|
||||
# Count ACL users for all servers
|
||||
qs = qs.annotate(
|
||||
acl_user_count=Count('acl__user', distinct=True)
|
||||
)
|
||||
|
||||
# For Xray servers, calculate user count separately
|
||||
# Create subquery to count Xray users
|
||||
xray_user_count_subquery = ServerInbound.objects.filter(
|
||||
server_id=OuterRef('pk'),
|
||||
active=True,
|
||||
inbound__subscriptiongroup__usersubscription__active=True,
|
||||
inbound__subscriptiongroup__is_active=True
|
||||
).values('server_id').annotate(
|
||||
count=Count('inbound__subscriptiongroup__usersubscription__user', distinct=True)
|
||||
).values('count')
|
||||
|
||||
qs = qs.annotate(
|
||||
xray_user_count=Subquery(xray_user_count_subquery, output_field=IntegerField()),
|
||||
user_count=Case(
|
||||
When(server_type='xray_v2', then=F('xray_user_count')),
|
||||
default=F('acl_user_count'),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
)
|
||||
|
||||
# Handle None values from subquery
|
||||
qs = qs.annotate(
|
||||
user_count=Case(
|
||||
When(server_type='xray_v2', user_count__isnull=True, then=Value(0)),
|
||||
When(server_type='xray_v2', then=F('xray_user_count')),
|
||||
default=F('acl_user_count'),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
)
|
||||
|
||||
qs = qs.prefetch_related(
|
||||
'acl_set__links',
|
||||
'acl_set__user'
|
||||
)
|
||||
return qs
|
||||
|
||||
def sync_server_view(self, request, object_id):
|
||||
"""Dispatch sync to appropriate server type."""
|
||||
try:
|
||||
server = get_object_or_404(Server, pk=object_id)
|
||||
real_server = server.get_real_instance()
|
||||
|
||||
# Handle XrayServerV2
|
||||
if isinstance(real_server, XrayServerV2):
|
||||
return redirect(f'/admin/vpn/xrayserverv2/{real_server.pk}/sync/')
|
||||
|
||||
# Fallback for other server types
|
||||
else:
|
||||
messages.info(request, f"Sync not implemented for server type: {real_server.server_type}")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error during sync: {e}")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
|
||||
# Inline for legacy VPN access (Outline/Wireguard)
|
||||
class UserACLInline(admin.TabularInline):
|
||||
model = ACL
|
||||
extra = 0
|
||||
fields = ('server', 'created_at', 'link_count')
|
||||
readonly_fields = ('created_at', 'link_count')
|
||||
verbose_name = "Legacy VPN Server Access"
|
||||
verbose_name_plural = "Legacy VPN Server Access (Outline/Wireguard)"
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
if db_field.name == "server":
|
||||
# Only show old-style servers (Outline/Wireguard)
|
||||
kwargs["queryset"] = Server.objects.exclude(server_type='xray_v2')
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
@admin.display(description='Links')
|
||||
def link_count(self, obj):
|
||||
count = obj.links.count()
|
||||
return format_html(
|
||||
'<span style="font-weight: bold;">{}</span> link(s)',
|
||||
count
|
||||
)
|
||||
@@ -1,702 +0,0 @@
|
||||
"""
|
||||
User admin interface
|
||||
"""
|
||||
import shortuuid
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.contrib import messages
|
||||
from django.urls import path, reverse
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.utils.timezone import localtime
|
||||
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
from vpn.models import User, ACL, ACLLink, Server, AccessLog, UserStatistics
|
||||
from vpn.forms import UserForm
|
||||
from .base import BaseVPNAdmin, format_bytes
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseVPNAdmin):
|
||||
form = UserForm
|
||||
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
|
||||
search_fields = ('username', 'hash', 'telegram_user_id', 'telegram_username')
|
||||
readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary', 'telegram_info_display', 'subscription_management_info')
|
||||
inlines = [] # Inlines will be added by subscription management function
|
||||
|
||||
fieldsets = (
|
||||
('User Information', {
|
||||
'fields': ('username', 'first_name', 'last_name', 'email', 'comment')
|
||||
}),
|
||||
('Telegram Integration', {
|
||||
'fields': ('telegram_username', 'telegram_info_display'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Link existing users to Telegram by setting telegram_username (without @)'
|
||||
}),
|
||||
('Access Information', {
|
||||
'fields': ('hash_link', 'is_active', 'vpn_access_summary')
|
||||
}),
|
||||
('Statistics & Server Management', {
|
||||
'fields': ('user_statistics_summary',),
|
||||
'classes': ('wide',)
|
||||
}),
|
||||
('Subscription Management', {
|
||||
'fields': ('subscription_management_info',),
|
||||
'classes': ('wide',),
|
||||
'description': 'Manage user\'s Xray subscription groups. Use the "User\'s Subscription Groups" section below to add/remove subscriptions.'
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.display(description='VPN Access Summary')
|
||||
def vpn_access_summary(self, obj):
|
||||
"""Display summary of user's VPN access"""
|
||||
if not obj.pk:
|
||||
return "Save user first to see VPN access"
|
||||
|
||||
# Get legacy VPN access
|
||||
acl_count = ACL.objects.filter(user=obj).count()
|
||||
legacy_links = ACLLink.objects.filter(acl__user=obj).count()
|
||||
|
||||
# Get Xray access
|
||||
from vpn.models_xray import UserSubscription
|
||||
xray_subs = UserSubscription.objects.filter(user=obj, active=True).select_related('subscription_group')
|
||||
xray_groups = [sub.subscription_group.name for sub in xray_subs]
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
|
||||
|
||||
# Legacy VPN section
|
||||
html += '<div style="margin-bottom: 15px;">'
|
||||
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">📡 Legacy VPN (Outline/Wireguard)</h4>'
|
||||
if acl_count > 0:
|
||||
html += f'<p style="margin: 5px 0;">✅ Access to <strong>{acl_count}</strong> server(s)</p>'
|
||||
html += f'<p style="margin: 5px 0;">🔗 Total links: <strong>{legacy_links}</strong></p>'
|
||||
else:
|
||||
html += '<p style="margin: 5px 0; color: #6c757d;">No legacy VPN access</p>'
|
||||
html += '</div>'
|
||||
|
||||
# Xray section
|
||||
html += '<div>'
|
||||
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">🚀 Xray VPN</h4>'
|
||||
if xray_groups:
|
||||
html += f'<p style="margin: 5px 0;">✅ Active subscriptions: <strong>{len(xray_groups)}</strong></p>'
|
||||
html += '<ul style="margin: 5px 0; padding-left: 20px;">'
|
||||
for group in xray_groups:
|
||||
html += f'<li>{group}</li>'
|
||||
html += '</ul>'
|
||||
|
||||
# Try to get traffic statistics for this user
|
||||
try:
|
||||
from vpn.server_plugins.xray_v2 import XrayServerV2
|
||||
traffic_total_up = 0
|
||||
traffic_total_down = 0
|
||||
servers_checked = set()
|
||||
|
||||
# Get all Xray servers
|
||||
xray_servers = XrayServerV2.objects.filter(api_enabled=True, stats_enabled=True)
|
||||
|
||||
for server in xray_servers:
|
||||
if server.name not in servers_checked:
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
from vpn.xray_api_v2.stats import StatsManager
|
||||
|
||||
client = XrayClient(server=server.api_address)
|
||||
stats_manager = StatsManager(client)
|
||||
|
||||
# Get user stats (use email format: username@servername)
|
||||
user_email = f"{obj.username}@{server.name}"
|
||||
user_stats = stats_manager.get_user_stats(user_email)
|
||||
|
||||
if user_stats:
|
||||
traffic_total_up += user_stats.get('uplink', 0)
|
||||
traffic_total_down += user_stats.get('downlink', 0)
|
||||
|
||||
servers_checked.add(server.name)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Could not get user stats from server {server.name}: {e}")
|
||||
|
||||
# Format traffic if we got any
|
||||
if traffic_total_up > 0 or traffic_total_down > 0:
|
||||
|
||||
html += f'<p style="margin: 10px 0 5px 0; color: #007cba;"><strong>📊 Traffic Statistics:</strong></p>'
|
||||
html += f'<p style="margin: 5px 0 5px 20px;">↑ Upload: <strong>{format_bytes(traffic_total_up)}</strong></p>'
|
||||
html += f'<p style="margin: 5px 0 5px 20px;">↓ Download: <strong>{format_bytes(traffic_total_down)}</strong></p>'
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Could not get traffic stats for user {obj.username}: {e}")
|
||||
else:
|
||||
html += '<p style="margin: 5px 0; color: #6c757d;">No Xray subscriptions</p>'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
|
||||
return format_html(html)
|
||||
|
||||
@admin.display(description='User Portal', ordering='hash')
|
||||
def hash_link(self, obj):
|
||||
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.hash}"
|
||||
json_url = f"{EXTERNAL_ADDRESS}/stat/{obj.hash}"
|
||||
return format_html(
|
||||
'<div style="display: flex; gap: 10px; flex-wrap: wrap;">' +
|
||||
'<a href="{}" target="_blank" style="background: #4ade80; color: #000; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 12px; font-weight: bold;">🌐 Portal</a>' +
|
||||
'<a href="{}" target="_blank" style="background: #3b82f6; color: #fff; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 12px; font-weight: bold;">📄 JSON</a>' +
|
||||
'</div>',
|
||||
portal_url, json_url
|
||||
)
|
||||
|
||||
@admin.display(description='User Statistics Summary')
|
||||
def user_statistics_summary(self, obj):
|
||||
"""Display user statistics with integrated server management"""
|
||||
try:
|
||||
from vpn.models import UserStatistics
|
||||
from django.db import models
|
||||
|
||||
# Get statistics for this user
|
||||
user_stats = UserStatistics.objects.filter(user=obj).aggregate(
|
||||
total_connections=models.Sum('total_connections'),
|
||||
recent_connections=models.Sum('recent_connections'),
|
||||
total_links=models.Count('id'),
|
||||
max_daily_peak=models.Max('max_daily')
|
||||
)
|
||||
|
||||
# Get server breakdown
|
||||
server_breakdown = UserStatistics.objects.filter(user=obj).values('server_name').annotate(
|
||||
connections=models.Sum('total_connections'),
|
||||
links=models.Count('id')
|
||||
).order_by('-connections')
|
||||
|
||||
# Get all ACLs and links for this user
|
||||
user_acls = ACL.objects.filter(user=obj).select_related('server').prefetch_related('links')
|
||||
|
||||
# Get available servers not yet assigned
|
||||
all_servers = Server.objects.all()
|
||||
assigned_server_ids = [acl.server.id for acl in user_acls]
|
||||
unassigned_servers = all_servers.exclude(id__in=assigned_server_ids)
|
||||
|
||||
html = '<div class="user-management-section">'
|
||||
|
||||
# Overall Statistics
|
||||
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
|
||||
html += f'<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
|
||||
html += f'<div><strong>Total Uses:</strong> {user_stats["total_connections"] or 0}</div>'
|
||||
html += f'<div><strong>Recent (30d):</strong> {user_stats["recent_connections"] or 0}</div>'
|
||||
html += f'<div><strong>Total Links:</strong> {user_stats["total_links"] or 0}</div>'
|
||||
if user_stats["max_daily_peak"]:
|
||||
html += f'<div><strong>Daily Peak:</strong> {user_stats["max_daily_peak"]}</div>'
|
||||
html += f'</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Server Management
|
||||
if user_acls:
|
||||
html += '<h4 style="color: #007cba; margin: 16px 0 8px 0;">🔗 Server Access & Links</h4>'
|
||||
|
||||
for acl in user_acls:
|
||||
server = acl.server
|
||||
links = list(acl.links.all())
|
||||
|
||||
# Server header (no slow server status checks)
|
||||
# Determine server type icon and label
|
||||
if server.server_type == 'Outline':
|
||||
type_icon = '🔵'
|
||||
type_label = 'Outline'
|
||||
elif server.server_type == 'Wireguard':
|
||||
type_icon = '🟢'
|
||||
type_label = 'Wireguard'
|
||||
elif server.server_type in ['xray_core', 'xray_v2']:
|
||||
type_icon = '🟣'
|
||||
type_label = 'Xray'
|
||||
else:
|
||||
type_icon = '❓'
|
||||
type_label = server.server_type
|
||||
|
||||
html += f'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">'
|
||||
html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name} ({type_label})</h5>'
|
||||
|
||||
# Server stats
|
||||
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None)
|
||||
if server_stat:
|
||||
html += f'<span style="background: #f8f9fa; padding: 4px 8px; border-radius: 4px; font-size: 12px; color: #6c757d;">'
|
||||
html += f'📊 {server_stat["connections"]} uses ({server_stat["links"]} links)'
|
||||
html += f'</span>'
|
||||
html += f'</div>'
|
||||
|
||||
html += '<div class="server-section">'
|
||||
|
||||
# Links display
|
||||
if links:
|
||||
for link in links:
|
||||
# Get link stats
|
||||
link_stats = UserStatistics.objects.filter(
|
||||
user=obj, server_name=server.name, acl_link_id=link.link
|
||||
).first()
|
||||
|
||||
html += '<div class="link-item">'
|
||||
html += f'<div style="flex: 1;">'
|
||||
html += f'<div style="font-family: monospace; font-size: 13px; color: #007cba; font-weight: 500;">'
|
||||
html += f'{link.link[:16]}...' if len(link.link) > 16 else link.link
|
||||
html += f'</div>'
|
||||
if link.comment:
|
||||
html += f'<div style="font-size: 11px; color: #6c757d;">{link.comment}</div>'
|
||||
html += f'</div>'
|
||||
|
||||
# Link stats and actions
|
||||
html += f'<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">'
|
||||
if link_stats:
|
||||
html += f'<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
|
||||
html += f'✨ {link_stats.total_connections}'
|
||||
html += f'</span>'
|
||||
|
||||
# Test link button
|
||||
html += f'<a href="{EXTERNAL_ADDRESS}/ss/{link.link}#{server.name}" target="_blank" '
|
||||
html += f'class="btn btn-sm btn-primary btn-sm-custom">🔗</a>'
|
||||
|
||||
# Delete button
|
||||
html += f'<button type="button" class="btn btn-sm btn-danger btn-sm-custom delete-link-btn" '
|
||||
html += f'data-link-id="{link.id}" data-link-name="{link.link[:12]}">🗑️</button>'
|
||||
|
||||
# Last access
|
||||
if link.last_access_time:
|
||||
local_time = localtime(link.last_access_time)
|
||||
html += f'<span style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px; font-size: 10px; color: #6c757d;">'
|
||||
html += f'{local_time.strftime("%m-%d %H:%M")}'
|
||||
html += f'</span>'
|
||||
else:
|
||||
html += f'<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
|
||||
html += f'Never'
|
||||
html += f'</span>'
|
||||
|
||||
html += f'</div></div>'
|
||||
|
||||
# Add link button
|
||||
html += f'<div style="text-align: center; margin-top: 12px;">'
|
||||
html += f'<button type="button" class="btn btn-sm btn-success add-link-btn" '
|
||||
html += f'data-server-id="{server.id}" data-server-name="{server.name}">'
|
||||
html += f'➕ Add Link'
|
||||
html += f'</button>'
|
||||
html += f'</div>'
|
||||
|
||||
html += '</div>' # End server-section
|
||||
|
||||
# Add server access section
|
||||
if unassigned_servers:
|
||||
html += '<div style="background: #d1ecf1; border-left: 4px solid #bee5eb; padding: 12px; margin-top: 16px; border-radius: 4px;">'
|
||||
html += '<h5 style="margin: 0 0 8px 0; color: #0c5460; font-size: 13px;">➕ Available Servers</h5>'
|
||||
html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'
|
||||
for server in unassigned_servers:
|
||||
# Determine server type icon and label
|
||||
if server.server_type == 'Outline':
|
||||
type_icon = '🔵'
|
||||
type_label = 'Outline'
|
||||
elif server.server_type == 'Wireguard':
|
||||
type_icon = '🟢'
|
||||
type_label = 'Wireguard'
|
||||
elif server.server_type in ['xray_core', 'xray_v2']:
|
||||
type_icon = '🟣'
|
||||
type_label = 'Xray'
|
||||
else:
|
||||
type_icon = '❓'
|
||||
type_label = server.server_type
|
||||
|
||||
html += f'<button type="button" class="btn btn-sm btn-outline-info btn-sm-custom add-server-btn" '
|
||||
html += f'data-server-id="{server.id}" data-server-name="{server.name}" '
|
||||
html += f'title="{type_label} server">'
|
||||
html += f'{type_icon} {server.name} ({type_label})'
|
||||
html += f'</button>'
|
||||
html += '</div></div>'
|
||||
|
||||
html += '</div>' # End user-management-section
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<span style="color: #dc3545;">Error loading management interface: {e}</span>')
|
||||
|
||||
@admin.display(description='Recent Activity')
|
||||
def recent_activity_display(self, obj):
|
||||
"""Display recent activity in compact admin-friendly format"""
|
||||
try:
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
# Get recent access logs for this user (last 7 days, limited)
|
||||
seven_days_ago = timezone.now() - timedelta(days=7)
|
||||
recent_logs = AccessLog.objects.filter(
|
||||
user=obj.username,
|
||||
timestamp__gte=seven_days_ago
|
||||
).order_by('-timestamp')[:15] # Limit to 15 most recent
|
||||
|
||||
if not recent_logs:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic; padding: 12px; background: #f8f9fa; border-radius: 4px;">No recent activity (last 7 days)</div>')
|
||||
|
||||
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 0; max-height: 300px; overflow-y: auto;">'
|
||||
|
||||
# Header
|
||||
html += '<div style="background: #e9ecef; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600; font-size: 12px; color: #495057;">'
|
||||
html += f'📊 Recent Activity ({recent_logs.count()} entries, last 7 days)'
|
||||
html += '</div>'
|
||||
|
||||
# Activity entries
|
||||
for i, log in enumerate(recent_logs):
|
||||
bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa'
|
||||
local_time = localtime(log.timestamp)
|
||||
|
||||
# Status icon and color
|
||||
if log.action == 'Success':
|
||||
icon = '✅'
|
||||
status_color = '#28a745'
|
||||
elif log.action == 'Failed':
|
||||
icon = '❌'
|
||||
status_color = '#dc3545'
|
||||
else:
|
||||
icon = 'ℹ️'
|
||||
status_color = '#6c757d'
|
||||
|
||||
html += f'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f1f3f4; background: {bg_color};">'
|
||||
|
||||
# Left side - server and link info
|
||||
html += f'<div style="display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0;">'
|
||||
html += f'<span style="color: {status_color}; font-size: 14px;">{icon}</span>'
|
||||
html += f'<div style="overflow: hidden;">'
|
||||
html += f'<div style="font-weight: 500; font-size: 12px; color: #495057; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{log.server}</div>'
|
||||
|
||||
if log.acl_link_id:
|
||||
link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id
|
||||
html += f'<div style="font-family: monospace; font-size: 10px; color: #6c757d;">{link_short}</div>'
|
||||
|
||||
html += f'</div></div>'
|
||||
|
||||
# Right side - timestamp and status
|
||||
html += f'<div style="text-align: right; flex-shrink: 0;">'
|
||||
html += f'<div style="font-size: 10px; color: #6c757d;">{local_time.strftime("%m-%d %H:%M")}</div>'
|
||||
html += f'<div style="font-size: 9px; color: {status_color}; font-weight: 500;">{log.action}</div>'
|
||||
html += f'</div>'
|
||||
|
||||
html += f'</div>'
|
||||
|
||||
# Footer with summary if there are more entries
|
||||
total_recent = AccessLog.objects.filter(
|
||||
user=obj.username,
|
||||
timestamp__gte=seven_days_ago
|
||||
).count()
|
||||
|
||||
if total_recent > 15:
|
||||
html += f'<div style="background: #e9ecef; padding: 6px 12px; font-size: 11px; color: #6c757d; text-align: center;">'
|
||||
html += f'Showing 15 of {total_recent} entries from last 7 days'
|
||||
html += f'</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<span style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</span>')
|
||||
|
||||
@admin.display(description='Telegram Account')
|
||||
def telegram_info_display(self, obj):
|
||||
"""Display Telegram account information"""
|
||||
if not obj.telegram_user_id:
|
||||
if obj.telegram_username:
|
||||
return mark_safe(f'<div style="background: #fff3cd; padding: 10px; border-radius: 5px; border-left: 4px solid #ffc107;">'
|
||||
f'<span style="color: #856404;">🔗 Ready to link: @{obj.telegram_username}</span><br/>'
|
||||
f'<small>User will be automatically linked when they message the bot</small></div>')
|
||||
else:
|
||||
return mark_safe('<span style="color: #6c757d;">No Telegram account linked</span>')
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
|
||||
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">📱 Telegram Account Information</h4>'
|
||||
|
||||
# Telegram User ID
|
||||
html += f'<p style="margin: 5px 0;"><strong>User ID:</strong> <code>{obj.telegram_user_id}</code></p>'
|
||||
|
||||
# Telegram Username
|
||||
if obj.telegram_username:
|
||||
html += f'<p style="margin: 5px 0;"><strong>Username:</strong> @{obj.telegram_username}</p>'
|
||||
|
||||
# Telegram Names
|
||||
name_parts = []
|
||||
if obj.telegram_first_name:
|
||||
name_parts.append(obj.telegram_first_name)
|
||||
if obj.telegram_last_name:
|
||||
name_parts.append(obj.telegram_last_name)
|
||||
|
||||
if name_parts:
|
||||
full_name = ' '.join(name_parts)
|
||||
html += f'<p style="margin: 5px 0;"><strong>Name:</strong> {full_name}</p>'
|
||||
|
||||
# Telegram Phone (if available)
|
||||
if obj.telegram_phone:
|
||||
html += f'<p style="margin: 5px 0;"><strong>Phone:</strong> {obj.telegram_phone}</p>'
|
||||
|
||||
# Access requests count (if any)
|
||||
try:
|
||||
from telegram_bot.models import AccessRequest
|
||||
requests_count = AccessRequest.objects.filter(telegram_user_id=obj.telegram_user_id).count()
|
||||
if requests_count > 0:
|
||||
html += f'<p style="margin: 10px 0 5px 0; color: #007cba;"><strong>📝 Access Requests:</strong> {requests_count}</p>'
|
||||
|
||||
# Show latest request status
|
||||
latest_request = AccessRequest.objects.filter(telegram_user_id=obj.telegram_user_id).order_by('-created_at').first()
|
||||
if latest_request:
|
||||
status_color = '#28a745' if latest_request.approved else '#ffc107'
|
||||
status_text = 'Approved' if latest_request.approved else 'Pending'
|
||||
html += f'<p style="margin: 5px 0 5px 20px;">Latest: <span style="color: {status_color}; font-weight: bold;">{status_text}</span></p>'
|
||||
except:
|
||||
pass # Telegram bot app might not be available
|
||||
|
||||
# Add unlink button
|
||||
unlink_url = reverse('admin:vpn_user_unlink_telegram', args=[obj.pk])
|
||||
html += f'<div style="margin-top: 15px; padding-top: 10px; border-top: 1px solid #dee2e6;">'
|
||||
html += f'<a href="{unlink_url}" class="button" style="background: #dc3545; color: white; text-decoration: none; padding: 8px 15px; border-radius: 3px; font-size: 13px;" onclick="return confirm(\'Are you sure you want to unlink this Telegram account?\')">🔗💥 Unlink Telegram Account</a>'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
@admin.display(description='Subscription Management')
|
||||
def subscription_management_info(self, obj):
|
||||
"""Display subscription management information and quick access"""
|
||||
if not obj.pk:
|
||||
return "Save user first to manage subscriptions"
|
||||
|
||||
try:
|
||||
from vpn.models_xray import UserSubscription, SubscriptionGroup
|
||||
|
||||
# Get user's current subscriptions
|
||||
user_subscriptions = UserSubscription.objects.filter(user=obj).select_related('subscription_group')
|
||||
active_subs = user_subscriptions.filter(active=True)
|
||||
inactive_subs = user_subscriptions.filter(active=False)
|
||||
|
||||
# Get available subscription groups
|
||||
all_groups = SubscriptionGroup.objects.filter(is_active=True)
|
||||
subscribed_group_ids = user_subscriptions.values_list('subscription_group_id', flat=True)
|
||||
available_groups = all_groups.exclude(id__in=subscribed_group_ids)
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
|
||||
html += '<h4 style="margin: 0 0 15px 0; color: #495057;">🚀 Xray Subscription Management</h4>'
|
||||
|
||||
# Active subscriptions
|
||||
if active_subs.exists():
|
||||
html += '<div style="margin-bottom: 15px;">'
|
||||
html += '<h5 style="color: #28a745; margin: 0 0 8px 0;">✅ Active Subscriptions</h5>'
|
||||
for sub in active_subs:
|
||||
html += f'<div style="background: #d4edda; padding: 8px 12px; border-radius: 4px; margin: 4px 0; display: flex; justify-content: space-between; align-items: center;">'
|
||||
html += f'<span><strong>{sub.subscription_group.name}</strong>'
|
||||
if sub.subscription_group.description:
|
||||
html += f' - {sub.subscription_group.description[:50]}{"..." if len(sub.subscription_group.description) > 50 else ""}'
|
||||
html += f'</span>'
|
||||
html += f'<small style="color: #155724;">Since: {sub.created_at.strftime("%Y-%m-%d")}</small>'
|
||||
html += f'</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Inactive subscriptions
|
||||
if inactive_subs.exists():
|
||||
html += '<div style="margin-bottom: 15px;">'
|
||||
html += '<h5 style="color: #dc3545; margin: 0 0 8px 0;">❌ Inactive Subscriptions</h5>'
|
||||
for sub in inactive_subs:
|
||||
html += f'<div style="background: #f8d7da; padding: 8px 12px; border-radius: 4px; margin: 4px 0;">'
|
||||
html += f'<span style="color: #721c24;"><strong>{sub.subscription_group.name}</strong></span>'
|
||||
html += f'</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Available subscription groups
|
||||
if available_groups.exists():
|
||||
html += '<div style="margin-bottom: 15px;">'
|
||||
html += '<h5 style="color: #007cba; margin: 0 0 8px 0;">➕ Available Subscription Groups</h5>'
|
||||
html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'
|
||||
for group in available_groups[:10]: # Limit to avoid clutter
|
||||
html += f'<span style="background: #cce7ff; color: #004085; padding: 4px 8px; border-radius: 3px; font-size: 12px;">'
|
||||
html += f'{group.name}'
|
||||
html += f'</span>'
|
||||
if available_groups.count() > 10:
|
||||
html += f'<span style="color: #6c757d; font-style: italic;">+{available_groups.count() - 10} more...</span>'
|
||||
html += '</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Quick access links
|
||||
html += '<div style="border-top: 1px solid #dee2e6; padding-top: 12px; margin-top: 15px;">'
|
||||
html += '<h5 style="margin: 0 0 8px 0; color: #495057;">🔗 Quick Access</h5>'
|
||||
html += '<div style="display: flex; gap: 10px; flex-wrap: wrap;">'
|
||||
|
||||
# Link to standalone UserSubscription admin
|
||||
subscription_admin_url = f"/admin/vpn/usersubscription/?user__id__exact={obj.id}"
|
||||
html += f'<a href="{subscription_admin_url}" class="button" style="background: #007cba; color: white; text-decoration: none; padding: 6px 12px; border-radius: 3px; font-size: 12px;">📋 Manage All Subscriptions</a>'
|
||||
|
||||
# Link to add new subscription
|
||||
add_subscription_url = f"/admin/vpn/usersubscription/add/?user={obj.id}"
|
||||
html += f'<a href="{add_subscription_url}" class="button" style="background: #28a745; color: white; text-decoration: none; padding: 6px 12px; border-radius: 3px; font-size: 12px;">➕ Add New Subscription</a>'
|
||||
|
||||
# Link to subscription groups admin
|
||||
groups_admin_url = "/admin/vpn/subscriptiongroup/"
|
||||
html += f'<a href="{groups_admin_url}" class="button" style="background: #17a2b8; color: white; text-decoration: none; padding: 6px 12px; border-radius: 3px; font-size: 12px;">⚙️ Manage Groups</a>'
|
||||
|
||||
html += '</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Statistics
|
||||
total_subs = user_subscriptions.count()
|
||||
if total_subs > 0:
|
||||
html += '<div style="border-top: 1px solid #dee2e6; padding-top: 8px; margin-top: 10px;">'
|
||||
html += f'<small style="color: #6c757d;">📊 Total: {total_subs} subscription(s) | Active: {active_subs.count()} | Inactive: {inactive_subs.count()}</small>'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<div style="background: #f8d7da; padding: 10px; border-radius: 4px; color: #721c24;">❌ Error loading subscription management: {e}</div>')
|
||||
|
||||
@admin.display(description='Allowed servers', ordering='server_count')
|
||||
def server_count(self, obj):
|
||||
return obj.server_count
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(server_count=Count('acl'))
|
||||
return qs
|
||||
|
||||
def get_urls(self):
|
||||
"""Add custom URLs for link management"""
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<int:user_id>/add-link/', self.admin_site.admin_view(self.add_link_view), name='user_add_link'),
|
||||
path('<int:user_id>/delete-link/<int:link_id>/', self.admin_site.admin_view(self.delete_link_view), name='user_delete_link'),
|
||||
path('<int:user_id>/add-server-access/', self.admin_site.admin_view(self.add_server_access_view), name='user_add_server_access'),
|
||||
path('<int:user_id>/unlink-telegram/', self.admin_site.admin_view(self.unlink_telegram_view), name='vpn_user_unlink_telegram'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def add_link_view(self, request, user_id):
|
||||
"""AJAX view to add a new link for user on specific server"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
server_id = request.POST.get('server_id')
|
||||
comment = request.POST.get('comment', '')
|
||||
|
||||
if not server_id:
|
||||
return JsonResponse({'error': 'Server ID is required'}, status=400)
|
||||
|
||||
server = Server.objects.get(pk=server_id)
|
||||
acl = ACL.objects.get(user=user, server=server)
|
||||
|
||||
# Create new link
|
||||
new_link = ACLLink.objects.create(
|
||||
acl=acl,
|
||||
comment=comment,
|
||||
link=shortuuid.ShortUUID().random(length=16)
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'link_id': new_link.id,
|
||||
'link': new_link.link,
|
||||
'comment': new_link.comment,
|
||||
'url': f"{EXTERNAL_ADDRESS}/ss/{new_link.link}#{server.name}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def delete_link_view(self, request, user_id, link_id):
|
||||
"""AJAX view to delete a specific link"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
link = ACLLink.objects.get(pk=link_id, acl__user=user)
|
||||
link.delete()
|
||||
|
||||
return JsonResponse({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def add_server_access_view(self, request, user_id):
|
||||
"""AJAX view to add server access for user"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
server_id = request.POST.get('server_id')
|
||||
|
||||
if not server_id:
|
||||
return JsonResponse({'error': 'Server ID is required'}, status=400)
|
||||
|
||||
server = Server.objects.get(pk=server_id)
|
||||
|
||||
# Check if ACL already exists
|
||||
if ACL.objects.filter(user=user, server=server).exists():
|
||||
return JsonResponse({'error': 'User already has access to this server'}, status=400)
|
||||
|
||||
# Create new ACL (with default link)
|
||||
acl = ACL.objects.create(user=user, server=server)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'server_name': server.name,
|
||||
'server_type': server.server_type,
|
||||
'acl_id': acl.id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def unlink_telegram_view(self, request, user_id):
|
||||
"""Unlink Telegram account from user"""
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
# Store original Telegram info for logging
|
||||
telegram_info = {
|
||||
'user_id': user.telegram_user_id,
|
||||
'username': user.telegram_username,
|
||||
'first_name': user.telegram_first_name,
|
||||
'last_name': user.telegram_last_name
|
||||
}
|
||||
|
||||
# Clear all Telegram fields
|
||||
user.telegram_user_id = None
|
||||
user.telegram_username = ""
|
||||
user.telegram_first_name = ""
|
||||
user.telegram_last_name = ""
|
||||
user.telegram_phone = ""
|
||||
user.save()
|
||||
|
||||
# Also clean up any related access requests
|
||||
try:
|
||||
from telegram_bot.models import AccessRequest
|
||||
AccessRequest.objects.filter(telegram_user_id=telegram_info['user_id']).delete()
|
||||
except:
|
||||
pass # Telegram bot app might not be available
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Telegram account {'@' + telegram_info['username'] if telegram_info['username'] else telegram_info['user_id']} "
|
||||
f"has been unlinked from user '{user.username}'"
|
||||
)
|
||||
|
||||
return HttpResponseRedirect(reverse('admin:vpn_user_change', args=[user_id]))
|
||||
|
||||
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||
"""Override change view to add user management data and fix layout"""
|
||||
extra_context = extra_context or {}
|
||||
|
||||
if object_id:
|
||||
try:
|
||||
user = User.objects.get(pk=object_id)
|
||||
extra_context.update({
|
||||
'user_object': user,
|
||||
'external_address': EXTERNAL_ADDRESS,
|
||||
})
|
||||
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
Minimal admin test to check execution
|
||||
"""
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
import json
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
# Try importing server plugins
|
||||
try:
|
||||
from .server_plugins import (
|
||||
XrayServerV2,
|
||||
XrayServerV2Admin
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"🔴 Failed to import server plugins: {e}")
|
||||
|
||||
# Try importing refactored admin modules
|
||||
try:
|
||||
from .admin import *
|
||||
except Exception as e:
|
||||
logger.error(f"🔴 Failed to import refactored admin modules: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Try importing Xray admin classes
|
||||
try:
|
||||
from .admin_xray import *
|
||||
except Exception as e:
|
||||
logger.error(f"🔴 Failed to import Xray admin classes: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Set custom admin site configuration
|
||||
admin.site.site_title = "VPN Manager"
|
||||
admin.site.site_header = "VPN Manager"
|
||||
admin.site.index_title = "OutFleet"
|
||||
|
||||
# Try adding custom Celery admin interfaces
|
||||
try:
|
||||
from django_celery_results.models import TaskResult
|
||||
|
||||
# Unregister default TaskResult admin if it exists
|
||||
try:
|
||||
admin.site.unregister(TaskResult)
|
||||
except admin.sites.NotRegistered:
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(TaskResult)
|
||||
class CustomTaskResultAdmin(admin.ModelAdmin):
|
||||
list_display = ('task_name_display', 'status', 'date_created')
|
||||
|
||||
@admin.display(description='Task Name', ordering='task_name')
|
||||
def task_name_display(self, obj):
|
||||
return obj.task_name
|
||||
|
||||
|
||||
except ImportError:
|
||||
pass # Celery not available
|
||||
|
||||
# Add subscription management to User admin
|
||||
try:
|
||||
from vpn.admin.user import add_subscription_management_to_user
|
||||
from django.contrib.admin import site
|
||||
for model, admin_instance in site._registry.items():
|
||||
if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'):
|
||||
add_subscription_management_to_user(admin_instance.__class__)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add subscription management: {e}")
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
Test admin file to check if code execution works
|
||||
"""
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🧪 TEST ADMIN FILE EXECUTING!")
|
||||
|
||||
from django.contrib import admin
|
||||
from .models import User
|
||||
|
||||
@admin.register(User)
|
||||
class TestUserAdmin(admin.ModelAdmin):
|
||||
list_display = ('username',)
|
||||
|
||||
logger.info("🧪 TEST ADMIN FILE COMPLETED!")
|
||||
1056
vpn/admin_xray.py
1056
vpn/admin_xray.py
File diff suppressed because it is too large
Load Diff
109
vpn/apps.py
109
vpn/apps.py
@@ -1,109 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
class VPN(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'vpn'
|
||||
|
||||
def ready(self):
|
||||
"""Import signals when Django starts"""
|
||||
import sys
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"VPN App ready() called in process: {' '.join(sys.argv)}")
|
||||
|
||||
try:
|
||||
import vpn.signals # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Only load admin interfaces in web processes, not in worker/beat
|
||||
skip_admin_load = any([
|
||||
'worker' in sys.argv,
|
||||
'beat' in sys.argv,
|
||||
'makemigrations' in sys.argv,
|
||||
'migrate' in sys.argv,
|
||||
'shell' in sys.argv,
|
||||
'test' in sys.argv,
|
||||
])
|
||||
|
||||
if not skip_admin_load:
|
||||
logger.info("VPN App: Loading admin interfaces in web process")
|
||||
# Force load admin interfaces first
|
||||
self._load_admin_interfaces()
|
||||
|
||||
# Clean up unwanted admin interfaces
|
||||
self._cleanup_admin_interfaces()
|
||||
else:
|
||||
logger.info("VPN App: Skipping admin loading in non-web process")
|
||||
|
||||
def _cleanup_admin_interfaces(self):
|
||||
"""Remove unwanted admin interfaces after all apps are loaded"""
|
||||
from django.contrib import admin
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("VPN App: Starting admin cleanup...")
|
||||
|
||||
try:
|
||||
from django_celery_results.models import GroupResult
|
||||
from django_celery_beat.models import (
|
||||
PeriodicTask,
|
||||
ClockedSchedule,
|
||||
CrontabSchedule,
|
||||
IntervalSchedule,
|
||||
SolarSchedule
|
||||
)
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
# Unregister celery models that we don't want in admin
|
||||
models_to_unregister = [
|
||||
GroupResult, PeriodicTask, ClockedSchedule,
|
||||
CrontabSchedule, IntervalSchedule, SolarSchedule
|
||||
]
|
||||
|
||||
for model in models_to_unregister:
|
||||
try:
|
||||
admin.site.unregister(model)
|
||||
logger.info(f"VPN App: Unregistered {model.__name__}")
|
||||
except admin.sites.NotRegistered:
|
||||
logger.debug(f"VPN App: {model.__name__} was not registered, skipping")
|
||||
|
||||
# Unregister Django's default Group model
|
||||
try:
|
||||
admin.site.unregister(Group)
|
||||
logger.info("VPN App: Unregistered Django Group model")
|
||||
except admin.sites.NotRegistered:
|
||||
logger.debug("VPN App: Django Group was not registered, skipping")
|
||||
|
||||
except ImportError as e:
|
||||
# Celery packages not installed
|
||||
logger.warning(f"VPN App: Celery packages not available: {e}")
|
||||
|
||||
logger.info("VPN App: Admin cleanup completed")
|
||||
|
||||
def _load_admin_interfaces(self):
|
||||
"""Force load admin interfaces to ensure they are registered"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("VPN App: Force loading admin interfaces...")
|
||||
|
||||
try:
|
||||
# Import admin module to trigger registration
|
||||
import sys
|
||||
if 'vpn.admin_minimal' in sys.modules:
|
||||
# Module already imported, remove it to force fresh import
|
||||
del sys.modules['vpn.admin_minimal']
|
||||
logger.info("VPN App: Removed vpn.admin_minimal from cache")
|
||||
|
||||
import vpn.admin_minimal
|
||||
logger.info("VPN App: Successfully loaded vpn.admin_minimal")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"VPN App: Failed to load vpn.admin: {e}")
|
||||
import traceback
|
||||
logger.error(f"VPN App: Traceback: {traceback.format_exc()}")
|
||||
|
||||
logger.info("VPN App: Admin loading completed")
|
||||
@@ -1,7 +0,0 @@
|
||||
from django import forms
|
||||
from .models import User
|
||||
|
||||
class UserForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'first_name', 'last_name', 'email', 'comment', 'is_active']
|
||||
@@ -1,13 +0,0 @@
|
||||
"""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'
|
||||
]
|
||||
@@ -1,403 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Let's Encrypt/ZeroSSL Certificate Library with Cloudflare DNS Challenge
|
||||
Generate publicly trusted SSL certificates using ACME DNS-01 challenge
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from acme import client, messages, challenges, errors
|
||||
from acme.client import ClientV2
|
||||
import josepy as jose
|
||||
from cloudflare import Cloudflare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AcmeDnsChallenge:
|
||||
"""ACME DNS-01 Challenge handler with Cloudflare API"""
|
||||
|
||||
def __init__(self, cloudflare_token: str, acme_directory: str = None):
|
||||
"""
|
||||
Initialize ACME DNS challenge handler
|
||||
|
||||
Args:
|
||||
cloudflare_token: Cloudflare API token with DNS edit permissions
|
||||
acme_directory: ACME directory URL (defaults to Let's Encrypt production)
|
||||
"""
|
||||
self.cf_token = cloudflare_token
|
||||
self.cf = Cloudflare(api_token=cloudflare_token)
|
||||
|
||||
# ACME directory URLs
|
||||
self.acme_directories = {
|
||||
'letsencrypt': 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
'letsencrypt_staging': 'https://acme-staging-v02.api.letsencrypt.org/directory',
|
||||
'zerossl': 'https://acme.zerossl.com/v2/DV90'
|
||||
}
|
||||
|
||||
self.acme_directory = acme_directory or self.acme_directories['letsencrypt']
|
||||
self.acme_client = None
|
||||
self.account_key = None
|
||||
|
||||
def _generate_account_key(self) -> jose.JWKRSA:
|
||||
"""Generate RSA private key for ACME account"""
|
||||
# Generate cryptography key first
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048
|
||||
)
|
||||
# Convert to josepy format for ACME
|
||||
return jose.JWKRSA(key=private_key)
|
||||
|
||||
def _get_zone_id(self, domain: str) -> str:
|
||||
"""Get Cloudflare zone ID for domain"""
|
||||
try:
|
||||
# Get base domain (remove subdomains)
|
||||
parts = domain.split('.')
|
||||
if len(parts) >= 2:
|
||||
base_domain = '.'.join(parts[-2:])
|
||||
else:
|
||||
base_domain = domain
|
||||
|
||||
zones = self.cf.zones.list(name=base_domain)
|
||||
if not zones.result:
|
||||
raise ValueError(f"Domain {base_domain} not found in Cloudflare")
|
||||
|
||||
return zones.result[0].id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get zone ID for {domain}: {e}")
|
||||
raise
|
||||
|
||||
def _create_dns_record(self, domain: str, name: str, content: str) -> str:
|
||||
"""Create DNS TXT record for ACME challenge"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
|
||||
result = self.cf.dns.records.create(
|
||||
zone_id=zone_id,
|
||||
name=name,
|
||||
type='TXT',
|
||||
content=content,
|
||||
ttl=60 # 1 minute TTL for faster propagation
|
||||
)
|
||||
logger.info(f"Created DNS record: {name} = {content}")
|
||||
return result.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create DNS record {name}: {e}")
|
||||
raise
|
||||
|
||||
def _delete_dns_record(self, domain: str, record_id: str):
|
||||
"""Delete DNS TXT record"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
self.cf.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
||||
logger.info(f"Deleted DNS record: {record_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete DNS record {record_id}: {e}")
|
||||
|
||||
def _wait_for_dns_propagation(self, record_name: str, expected_value: str, wait_time: int = 20):
|
||||
"""Wait for DNS record to propagate - no local checks, just wait"""
|
||||
logger.info(f"Waiting {wait_time} seconds for DNS propagation of {record_name}...")
|
||||
logger.info(f"Record value: {expected_value}")
|
||||
logger.info("(No local DNS checks - Let's Encrypt servers will verify)")
|
||||
|
||||
time.sleep(wait_time)
|
||||
|
||||
logger.info("DNS propagation wait completed - proceeding with challenge")
|
||||
return True
|
||||
|
||||
def create_acme_client(self, email: str, accept_tos: bool = True) -> ClientV2:
|
||||
"""Create and register ACME client"""
|
||||
if self.acme_client:
|
||||
return self.acme_client
|
||||
|
||||
try:
|
||||
logger.info("Generating ACME account key...")
|
||||
# Generate account key
|
||||
self.account_key = self._generate_account_key()
|
||||
logger.info("Account key generated successfully")
|
||||
|
||||
logger.info(f"Connecting to ACME directory: {self.acme_directory}")
|
||||
# Create ACME client
|
||||
net = client.ClientNetwork(self.account_key, user_agent='letsencrypt-dns-lib/1.0')
|
||||
logger.info("Getting ACME directory...")
|
||||
directory_response = net.get(self.acme_directory)
|
||||
logger.info(f"Directory response status: {directory_response.status_code}")
|
||||
directory = messages.Directory.from_json(directory_response.json())
|
||||
logger.info("ACME directory loaded successfully")
|
||||
|
||||
self.acme_client = ClientV2(directory, net=net)
|
||||
logger.info("ACME client created successfully")
|
||||
|
||||
# Register account
|
||||
logger.info(f"Registering ACME account for email: {email}")
|
||||
try:
|
||||
registration = messages.NewRegistration.from_data(
|
||||
email=email,
|
||||
terms_of_service_agreed=accept_tos
|
||||
)
|
||||
logger.info("Sending account registration...")
|
||||
account = self.acme_client.new_account(registration)
|
||||
logger.info(f"ACME account registered: {account.uri}")
|
||||
|
||||
except errors.ConflictError as e:
|
||||
logger.info(f"Account already exists (ConflictError): {e}")
|
||||
# Account already exists
|
||||
account = self.acme_client.query_registration(messages.NewRegistration())
|
||||
logger.info("Using existing ACME account")
|
||||
except Exception as reg_e:
|
||||
logger.error(f"Account registration failed: {reg_e}")
|
||||
logger.error(f"Registration error type: {type(reg_e).__name__}")
|
||||
raise
|
||||
|
||||
return self.acme_client
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
logger.error(f"Error type: {type(e).__name__}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
def request_certificate(self, domains: List[str], email: str,
|
||||
key_size: int = 2048) -> Tuple[str, str]:
|
||||
"""
|
||||
Request certificate using DNS-01 challenge
|
||||
|
||||
Args:
|
||||
domains: List of domain names for certificate
|
||||
email: Email for ACME account registration
|
||||
key_size: RSA key size for certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
logger.info(f"Requesting certificate for domains: {domains}")
|
||||
|
||||
try:
|
||||
# Create ACME client
|
||||
logger.info("Creating ACME client...")
|
||||
acme_client = self.create_acme_client(email)
|
||||
logger.info("ACME client created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
try:
|
||||
# Generate private key for certificate
|
||||
logger.info(f"Generating {key_size}-bit RSA private key...")
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size
|
||||
)
|
||||
logger.info("Private key generated successfully")
|
||||
|
||||
# Create CSR
|
||||
logger.info(f"Creating CSR for domains: {domains}")
|
||||
csr_obj = x509.CertificateSigningRequestBuilder().subject_name(
|
||||
x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, domains[0])
|
||||
])
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(domain) for domain in domains
|
||||
]),
|
||||
critical=False
|
||||
).sign(private_key, hashes.SHA256())
|
||||
|
||||
# Convert CSR to PEM format for ACME
|
||||
csr_pem = csr_obj.public_bytes(serialization.Encoding.PEM)
|
||||
logger.info("CSR created successfully")
|
||||
|
||||
# Request certificate
|
||||
logger.info("Requesting certificate order from ACME...")
|
||||
order = acme_client.new_order(csr_pem)
|
||||
logger.info(f"Created ACME order: {order.uri}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed during CSR/order creation: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# Process challenges - collect all challenges first, then create DNS records
|
||||
dns_records = []
|
||||
challenges_to_answer = []
|
||||
|
||||
try:
|
||||
# First pass: collect all challenges and create DNS records
|
||||
for authorization in order.authorizations:
|
||||
domain = authorization.body.identifier.value
|
||||
logger.info(f"Processing authorization for: {domain}")
|
||||
|
||||
# Find DNS-01 challenge
|
||||
dns_challenge = None
|
||||
for challenge in authorization.body.challenges:
|
||||
if isinstance(challenge.chall, challenges.DNS01):
|
||||
dns_challenge = challenge
|
||||
break
|
||||
|
||||
if not dns_challenge:
|
||||
raise ValueError(f"No DNS-01 challenge found for {domain}")
|
||||
|
||||
# Calculate challenge response
|
||||
response, validation = dns_challenge.response_and_validation(acme_client.net.key)
|
||||
|
||||
# For wildcard domains, use base domain for DNS record
|
||||
if domain.startswith('*.'):
|
||||
dns_domain = domain[2:] # Remove *. prefix
|
||||
else:
|
||||
dns_domain = domain
|
||||
|
||||
# Create DNS record
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
|
||||
# Check if we already created this DNS record
|
||||
existing_record = None
|
||||
for existing_domain, existing_id, existing_validation in dns_records:
|
||||
if existing_domain == dns_domain:
|
||||
existing_record = (existing_domain, existing_id, existing_validation)
|
||||
break
|
||||
|
||||
if existing_record:
|
||||
logger.info(f"DNS record already exists for {dns_domain}, reusing...")
|
||||
record_id = existing_record[1]
|
||||
# Verify the validation value matches
|
||||
if existing_record[2] != validation:
|
||||
logger.warning(f"Validation values differ for {dns_domain}! This may cause issues.")
|
||||
else:
|
||||
logger.info(f"Creating DNS record for {dns_domain}...")
|
||||
record_id = self._create_dns_record(dns_domain, record_name, validation)
|
||||
dns_records.append((dns_domain, record_id, validation))
|
||||
|
||||
# Store challenge to answer later
|
||||
challenges_to_answer.append((dns_challenge, response, domain, dns_domain))
|
||||
|
||||
# Wait for DNS propagation once for all records
|
||||
if dns_records:
|
||||
logger.info(f"Waiting for DNS propagation for {len(dns_records)} DNS records...")
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
self._wait_for_dns_propagation(record_name, validation)
|
||||
|
||||
# Second pass: answer all challenges
|
||||
for dns_challenge, response, domain, dns_domain in challenges_to_answer:
|
||||
logger.info(f"Responding to DNS challenge for {domain}...")
|
||||
challenge_response = acme_client.answer_challenge(dns_challenge, response)
|
||||
logger.info(f"Challenge response sent for {domain}")
|
||||
|
||||
# Finalize order
|
||||
logger.info("Finalizing certificate order...")
|
||||
order = acme_client.poll_and_finalize(order)
|
||||
|
||||
# Get certificate
|
||||
certificate_pem = order.fullchain_pem
|
||||
private_key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode('utf-8')
|
||||
|
||||
logger.info("Certificate obtained successfully!")
|
||||
return certificate_pem, private_key_pem
|
||||
|
||||
finally:
|
||||
# Clean up DNS records
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
try:
|
||||
self._delete_dns_record(dns_domain, record_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup DNS record for {dns_domain}: {e}")
|
||||
|
||||
def get_certificate(domains: List[str], email: str, cloudflare_token: str,
|
||||
provider: str = 'letsencrypt', staging: bool = False) -> Tuple[str, str]:
|
||||
"""
|
||||
Simple function to get Let's Encrypt/ZeroSSL certificate
|
||||
|
||||
Args:
|
||||
domains: List of domains for certificate
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
provider: 'letsencrypt' or 'zerossl'
|
||||
staging: Use staging environment (for testing)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
# Select ACME directory
|
||||
acme_dns = AcmeDnsChallenge(cloudflare_token)
|
||||
|
||||
if provider == 'letsencrypt':
|
||||
if staging:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt_staging']
|
||||
else:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt']
|
||||
elif provider == 'zerossl':
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['zerossl']
|
||||
else:
|
||||
raise ValueError("Provider must be 'letsencrypt' or 'zerossl'")
|
||||
|
||||
return acme_dns.request_certificate(domains, email)
|
||||
|
||||
def get_certificate_for_domain(domain: str, email: str, cloudflare_token: str,
|
||||
include_wildcard: bool = False, **kwargs) -> Tuple[str, str]:
|
||||
"""
|
||||
Helper function to get certificate for single domain (compatible with Cloudflare cert lib)
|
||||
|
||||
Args:
|
||||
domain: Primary domain
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
include_wildcard: Include wildcard subdomain
|
||||
**kwargs: Additional arguments (provider, staging)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
domains = [domain]
|
||||
if include_wildcard:
|
||||
domains.append(f"*.{domain}")
|
||||
|
||||
return get_certificate(domains, email, cloudflare_token, **kwargs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
print("Usage: python letsencrypt_dns.py <domain> <email> <cloudflare_token>")
|
||||
sys.exit(1)
|
||||
|
||||
domain, email, token = sys.argv[1:4]
|
||||
|
||||
try:
|
||||
cert_pem, key_pem = get_certificate_for_domain(
|
||||
domain=domain,
|
||||
email=email,
|
||||
cloudflare_token=token,
|
||||
include_wildcard=True,
|
||||
staging=True # Use staging for testing
|
||||
)
|
||||
|
||||
print(f"Certificate obtained for {domain}")
|
||||
print(f"Certificate length: {len(cert_pem)} bytes")
|
||||
print(f"Private key length: {len(key_pem)} bytes")
|
||||
|
||||
# Save to files
|
||||
with open(f"{domain}.crt", 'w') as f:
|
||||
f.write(cert_pem)
|
||||
with open(f"{domain}.key", 'w') as f:
|
||||
f.write(key_pem)
|
||||
|
||||
print(f"Saved: {domain}.crt, {domain}.key")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
@@ -1 +0,0 @@
|
||||
# Django management commands package\n
|
||||
@@ -1,158 +0,0 @@
|
||||
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:,}")
|
||||
@@ -1,208 +0,0 @@
|
||||
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")
|
||||
@@ -1,17 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create default admin user'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
User = get_user_model()
|
||||
if not User.objects.filter(username='admin').exists():
|
||||
User.objects.create_superuser(
|
||||
username='admin',
|
||||
password='admin',
|
||||
email='admin@localhost'
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS('Admin user created'))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING('Admin user already exists'))
|
||||
@@ -1 +0,0 @@
|
||||
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
|
||||
@@ -1,51 +0,0 @@
|
||||
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"))
|
||||
@@ -1,139 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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;"
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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 = [
|
||||
]
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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')},
|
||||
),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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 = [
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -1,137 +0,0 @@
|
||||
# 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-04 22:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0013_add_client_hostname'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='xraycoreserver',
|
||||
name='client_hostname',
|
||||
field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='xrayinbound',
|
||||
name='server_address',
|
||||
field=models.CharField(blank=True, help_text='Public server address for client connections', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -1,127 +0,0 @@
|
||||
# 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user