Merge pull request #267 from ReturnFI/beta

Migrate User Management to MongoDB
This commit is contained in:
Whispering Wind
2025-09-10 23:04:21 +03:30
committed by GitHub
29 changed files with 1217 additions and 1327 deletions

View File

@ -1,31 +1,12 @@
### 🛡️ **\[1.18.1] Security Patch & Hysteria Settings Tab** ### 🚀 **\[2.0.0] Major Release: MongoDB Migration**
*Released: 2025-08-30* *Released: 2025-09-10*
#### 🔒 Security #### 💾 Core Update
* 🛡️ **Open Redirect Fix**: * 📦 **User management migrated from `users.json` → MongoDB**
Removed `next_url` parameter from login flow to prevent **open redirect vulnerability**. * ⚡ Improved **scalability, performance, and reliability** for large deployments
Special thanks to [**@HEXER365**](https://github.com/HEXER365) for responsible disclosure 🙏 #### ⚠️ Breaking Change
#### ✨ Features * Previous JSON-based `users.json` file is no longer used
* ⚙️ **New Hysteria Settings tab** in Web Panel (with **geo update support**)
* 🎨 Redesigned **Login Page** for better UI/UX
#### 🛠️ Fixes
* 📦 Backup `extra.json` during upgrades
* 📱 Improved responsive design across web panel
* 🧩 Relaxed conflict check in user viewmodel
* 🧹 Removed `/dev/null` redirects for cleaner logging
#### 📦 Chore & Dependencies
* ⬆️ Updated dependencies:
* `typing-extensions` → 4.15.0
* `starlette` → 0.47.3
* `requests` → 2.32.5
* 🧹 Removed **deprecated `user.sh`** script (legacy auth)

View File

@ -28,7 +28,7 @@ class Command(Enum):
GET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'get_user.py') GET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'get_user.py')
ADD_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'add_user.py') ADD_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'add_user.py')
BULK_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'bulk_users.py') BULK_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'bulk_users.py')
EDIT_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'edit_user.sh') EDIT_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'edit_user.py')
RESET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'reset_user.py') RESET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'reset_user.py')
REMOVE_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'remove_user.py') REMOVE_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'remove_user.py')
SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.py') SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.py')
@ -307,43 +307,40 @@ def bulk_user_add(traffic_gb: float, expiration_days: int, count: int, prefix: s
def edit_user(username: str, new_username: str | None, new_traffic_limit: int | None, new_expiration_days: int | None, renew_password: bool, renew_creation_date: bool, blocked: bool | None, unlimited_ip: bool | None): def edit_user(username: str, new_username: str | None, new_traffic_limit: int | None, new_expiration_days: int | None, renew_password: bool, renew_creation_date: bool, blocked: bool | None, unlimited_ip: bool | None):
''' '''
Edits an existing user's details. Edits an existing user's details by calling the new edit_user.py script with named flags.
''' '''
if not username: if not username:
raise InvalidInputError('Error: username is required') raise InvalidInputError('Error: username is required')
if new_traffic_limit is not None and new_traffic_limit < 0: command_args = ['python3', Command.EDIT_USER.value, username]
raise InvalidInputError('Error: traffic limit must be a non-negative number.')
if new_expiration_days is not None and new_expiration_days < 0:
raise InvalidInputError('Error: expiration days must be a non-negative number.')
password = generate_password() if renew_password else '' if new_username:
creation_date = datetime.now().strftime('%Y-%m-%d') if renew_creation_date else '' command_args.extend(['--new-username', new_username])
blocked_str = '' if new_traffic_limit is not None:
if blocked is True: if new_traffic_limit < 0:
blocked_str = 'true' raise InvalidInputError('Error: traffic limit must be a non-negative number.')
elif blocked is False: command_args.extend(['--traffic-gb', str(new_traffic_limit)])
blocked_str = 'false'
unlimited_str = '' if new_expiration_days is not None:
if unlimited_ip is True: if new_expiration_days < 0:
unlimited_str = 'true' raise InvalidInputError('Error: expiration days must be a non-negative number.')
elif unlimited_ip is False: command_args.extend(['--expiration-days', str(new_expiration_days)])
unlimited_str = 'false'
if renew_password:
password = generate_password()
command_args.extend(['--password', password])
if renew_creation_date:
creation_date = datetime.now().strftime('%Y-%m-%d')
command_args.extend(['--creation-date', creation_date])
if blocked is not None:
command_args.extend(['--blocked', 'true' if blocked else 'false'])
if unlimited_ip is not None:
command_args.extend(['--unlimited', 'true' if unlimited_ip else 'false'])
command_args = [
'bash',
Command.EDIT_USER.value,
username,
new_username or '',
str(new_traffic_limit) if new_traffic_limit is not None else '',
str(new_expiration_days) if new_expiration_days is not None else '',
password,
creation_date,
blocked_str,
unlimited_str
]
run_cmd(command_args) run_cmd(command_args)

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"io" "io"
@ -8,25 +9,30 @@ import (
"net/http" "net/http"
"os" "os"
"strings" "strings"
"sync"
"time" "time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
) )
const ( const (
listenAddr = "127.0.0.1:28262" listenAddr = "127.0.0.1:28262"
usersFile = "/etc/hysteria/users.json" mongoURI = "mongodb://localhost:27017"
cacheTTL = 5 * time.Second dbName = "blitz_panel"
collectionName = "users"
) )
type User struct { type User struct {
Password string `json:"password"` ID string `bson:"_id"`
MaxDownloadBytes int64 `json:"max_download_bytes"` Password string `bson:"password"`
ExpirationDays int `json:"expiration_days"` MaxDownloadBytes int64 `bson:"max_download_bytes"`
AccountCreationDate string `json:"account_creation_date"` ExpirationDays int `bson:"expiration_days"`
Blocked bool `json:"blocked"` AccountCreationDate string `bson:"account_creation_date"`
UploadBytes int64 `json:"upload_bytes"` Blocked bool `bson:"blocked"`
DownloadBytes int64 `json:"download_bytes"` UploadBytes int64 `bson:"upload_bytes"`
UnlimitedUser bool `json:"unlimited_user"` DownloadBytes int64 `bson:"download_bytes"`
UnlimitedUser bool `bson:"unlimited_user"`
} }
type httpAuthRequest struct { type httpAuthRequest struct {
@ -40,24 +46,7 @@ type httpAuthResponse struct {
ID string `json:"id"` ID string `json:"id"`
} }
var ( var userCollection *mongo.Collection
userCache map[string]User
cacheMutex = &sync.RWMutex{}
)
func loadUsersToCache() {
data, err := os.ReadFile(usersFile)
if err != nil {
return
}
var users map[string]User
if err := json.Unmarshal(data, &users); err != nil {
return
}
cacheMutex.Lock()
userCache = users
cacheMutex.Unlock()
}
func authHandler(w http.ResponseWriter, r *http.Request) { func authHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@ -77,11 +66,12 @@ func authHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
cacheMutex.RLock() var user User
user, ok := userCache[username] ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
cacheMutex.RUnlock() defer cancel()
if !ok { err := userCollection.FindOne(ctx, bson.M{"_id": username}).Decode(&user)
if err != nil {
json.NewEncoder(w).Encode(httpAuthResponse{OK: false}) json.NewEncoder(w).Encode(httpAuthResponse{OK: false})
return return
} }
@ -92,7 +82,7 @@ func authHandler(w http.ResponseWriter, r *http.Request) {
} }
if subtle.ConstantTimeCompare([]byte(user.Password), []byte(password)) != 1 { if subtle.ConstantTimeCompare([]byte(user.Password), []byte(password)) != 1 {
time.Sleep(5 * time.Second) // Slow down brute-force attacks time.Sleep(5 * time.Second)
json.NewEncoder(w).Encode(httpAuthResponse{OK: false}) json.NewEncoder(w).Encode(httpAuthResponse{OK: false})
return return
} }
@ -122,18 +112,26 @@ func authHandler(w http.ResponseWriter, r *http.Request) {
func main() { func main() {
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
loadUsersToCache()
ticker := time.NewTicker(cacheTTL) clientOptions := options.Client().ApplyURI(mongoURI)
go func() { client, err := mongo.Connect(context.TODO(), clientOptions)
for range ticker.C { if err != nil {
loadUsersToCache() log.SetOutput(os.Stderr)
} log.Fatalf("Failed to connect to MongoDB: %v", err)
}() }
err = client.Ping(context.TODO(), nil)
if err != nil {
log.SetOutput(os.Stderr)
log.Fatalf("Failed to ping MongoDB: %v", err)
}
userCollection = client.Database(dbName).Collection(collectionName)
http.HandleFunc("/auth", authHandler) http.HandleFunc("/auth", authHandler)
log.SetOutput(os.Stderr)
log.Printf("Auth server starting on %s", listenAddr)
if err := http.ListenAndServe(listenAddr, nil); err != nil { if err := http.ListenAndServe(listenAddr, nil); err != nil {
log.SetOutput(os.Stderr)
log.Fatalf("Failed to start server: %v", err) log.Fatalf("Failed to start server: %v", err)
} }
} }

View File

@ -0,0 +1,41 @@
import pymongo
from bson.objectid import ObjectId
class Database:
def __init__(self, db_name="blitz_panel", collection_name="users"):
try:
self.client = pymongo.MongoClient("mongodb://localhost:27017/")
self.db = self.client[db_name]
self.collection = self.db[collection_name]
self.client.server_info()
except pymongo.errors.ConnectionFailure as e:
print(f"Could not connect to MongoDB: {e}")
raise
def add_user(self, user_data):
username = user_data.pop('username', None)
if not username:
raise ValueError("Username is required")
if self.collection.find_one({"_id": username.lower()}):
return None
user_data['_id'] = username.lower()
return self.collection.insert_one(user_data)
def get_user(self, username):
return self.collection.find_one({"_id": username.lower()})
def get_all_users(self):
return list(self.collection.find({}))
def update_user(self, username, updates):
return self.collection.update_one({"_id": username.lower()}, {"$set": updates})
def delete_user(self, username):
return self.collection.delete_one({"_id": username.lower()})
try:
db = Database()
except pymongo.errors.ConnectionFailure:
db = None

View File

@ -0,0 +1,70 @@
import json
from pathlib import Path
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from db.database import db
def migrate():
users_json_path = Path("/etc/hysteria/users.json")
if not users_json_path.exists():
print("users.json not found, no migration needed.")
return
if db is None:
print("Error: Database connection failed. Cannot perform migration.", file=sys.stderr)
sys.exit(1)
try:
with users_json_path.open('r') as f:
users_data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
print(f"Error reading or parsing users.json: {e}", file=sys.stderr)
sys.exit(1)
print(f"Found {len(users_data)} users in users.json to migrate.")
migrated_count = 0
for username, data in users_data.items():
try:
user_doc = {
"_id": username.lower(),
"password": data.get("password"),
"max_download_bytes": data.get("max_download_bytes", 0),
"expiration_days": data.get("expiration_days", 0),
"account_creation_date": data.get("account_creation_date"),
"blocked": data.get("blocked", False),
"unlimited_user": data.get("unlimited_user", False),
"status": data.get("status", "Offline"),
"upload_bytes": data.get("upload_bytes", 0),
"download_bytes": data.get("download_bytes", 0),
}
if user_doc["password"] is None:
print(f"Warning: User '{username}' has no password, skipping.", file=sys.stderr)
continue
db.collection.update_one(
{'_id': user_doc['_id']},
{'$set': user_doc},
upsert=True
)
migrated_count += 1
print(f" - Migrated user: {username}")
except Exception as e:
print(f"Error migrating user '{username}': {e}", file=sys.stderr)
print(f"Migration complete. {migrated_count} users successfully migrated to MongoDB.")
try:
migrated_file_path = users_json_path.with_name("users.json.migrated")
users_json_path.rename(migrated_file_path)
print(f"Renamed old user file to: {migrated_file_path}")
except OSError as e:
print(f"Warning: Could not rename users.json: {e}", file=sys.stderr)
if __name__ == "__main__":
migrate()

View File

@ -1,33 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import init_paths
import sys import sys
import os import os
import subprocess import subprocess
import re import re
from datetime import datetime from datetime import datetime
from init_paths import * from db.database import db
from paths import *
def add_user(username, traffic_gb, expiration_days, password=None, creation_date=None, unlimited_user=False): def add_user(username, traffic_gb, expiration_days, password=None, creation_date=None, unlimited_user=False):
"""
Adds a new user to the USERS_FILE.
Args:
username (str): The username to add.
traffic_gb (str): The traffic limit in GB.
expiration_days (str): The number of days until the account expires.
password (str, optional): The user's password. If None, a random one is generated.
creation_date (str, optional): The account creation date in YYYY-MM-DD format. Defaults to None.
unlimited_user (bool, optional): If True, user is exempt from IP limits. Defaults to False.
Returns:
int: 0 on success, 1 on failure.
"""
if not username or not traffic_gb or not expiration_days: if not username or not traffic_gb or not expiration_days:
print(f"Usage: {sys.argv[0]} <username> <traffic_limit_GB> <expiration_days> [password] [creation_date] [unlimited_user (true/false)]") print(f"Usage: {sys.argv[0]} <username> <traffic_limit_GB> <expiration_days> [password] [creation_date] [unlimited_user (true/false)]")
return 1 return 1
if db is None:
print("Error: Database connection failed. Please ensure MongoDB is running and configured.")
return 1
try: try:
traffic_bytes = int(float(traffic_gb) * 1073741824) traffic_bytes = int(float(traffic_gb) * 1073741824)
expiration_days = int(expiration_days) expiration_days = int(expiration_days)
@ -48,61 +37,45 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
print("Error: Failed to generate password. Please install 'pwgen' or ensure /proc access.") print("Error: Failed to generate password. Please install 'pwgen' or ensure /proc access.")
return 1 return 1
if creation_date:
if not re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", creation_date):
print("Invalid date format. Expected YYYY-MM-DD.")
return 1
try:
datetime.strptime(creation_date, "%Y-%m-%d")
except ValueError:
print("Invalid date. Please provide a valid date in YYYY-MM-DD format.")
return 1
else:
creation_date = None
if not re.match(r"^[a-zA-Z0-9_]+$", username): if not re.match(r"^[a-zA-Z0-9_]+$", username):
print("Error: Username can only contain letters, numbers, and underscores.") print("Error: Username can only contain letters, numbers, and underscores.")
return 1 return 1
if not os.path.isfile(USERS_FILE): try:
try: if db.get_user(username_lower):
with open(USERS_FILE, 'w') as f: print("User already exists.")
json.dump({}, f)
except IOError:
print(f"Error: Could not create {USERS_FILE}.")
return 1 return 1
try: user_data = {
with open(USERS_FILE, 'r+') as f: "username": username_lower,
"password": password,
"max_download_bytes": traffic_bytes,
"expiration_days": expiration_days,
"blocked": False,
"unlimited_user": unlimited_user
}
if creation_date:
if not re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", creation_date):
print("Invalid date format. Expected YYYY-MM-DD.")
return 1
try: try:
users_data = json.load(f) datetime.strptime(creation_date, "%Y-%m-%d")
except json.JSONDecodeError: user_data["account_creation_date"] = creation_date
print(f"Error: {USERS_FILE} contains invalid JSON.") except ValueError:
print("Invalid date. Please provide a valid date in YYYY-MM-DD format.")
return 1 return 1
for existing_username in users_data: result = db.add_user(user_data)
if existing_username.lower() == username_lower: if result:
print("User already exists.") print(f"User {username} added successfully.")
return 1 return 0
else:
print(f"Error: Failed to add user {username}.")
return 1
users_data[username_lower] = { except Exception as e:
"password": password, print(f"An error occurred: {e}")
"max_download_bytes": traffic_bytes,
"expiration_days": expiration_days,
"account_creation_date": creation_date,
"blocked": False,
"unlimited_user": unlimited_user
}
f.seek(0)
json.dump(users_data, f, indent=4)
f.truncate()
print(f"User {username} added successfully.")
return 0
except IOError:
print(f"Error: Could not write to {USERS_FILE}.")
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,27 +1,69 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import zipfile import zipfile
import subprocess
import shutil
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
backup_dir = Path("/opt/hysbackup") # --- Configuration ---
backup_file = backup_dir / f"hysteria_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip" DB_NAME = "blitz_panel"
BACKUP_ROOT_DIR = Path("/opt/hysbackup")
TIMESTAMP = datetime.now().strftime('%Y%m%d_%H%M%S')
BACKUP_FILENAME = BACKUP_ROOT_DIR / f"hysteria_backup_{TIMESTAMP}.zip"
TEMP_DUMP_DIR = BACKUP_ROOT_DIR / f"mongodump_{TIMESTAMP}"
files_to_backup = [ FILES_TO_BACKUP = [
Path("/etc/hysteria/ca.key"), Path("/etc/hysteria/ca.key"),
Path("/etc/hysteria/ca.crt"), Path("/etc/hysteria/ca.crt"),
Path("/etc/hysteria/users.json"),
Path("/etc/hysteria/config.json"), Path("/etc/hysteria/config.json"),
Path("/etc/hysteria/.configs.env"), Path("/etc/hysteria/.configs.env"),
] ]
backup_dir.mkdir(parents=True, exist_ok=True) def create_backup():
"""Dumps the MongoDB database and zips it with config files."""
try:
BACKUP_ROOT_DIR.mkdir(parents=True, exist_ok=True)
TEMP_DUMP_DIR.mkdir(parents=True)
try: print(f"Dumping database '{DB_NAME}'...")
with zipfile.ZipFile(backup_file, 'w') as zipf: mongodump_cmd = [
for file_path in files_to_backup: "mongodump",
if file_path.exists(): f"--db={DB_NAME}",
zipf.write(file_path, arcname=file_path.name) f"--out={TEMP_DUMP_DIR}"
print("Backup successfully created") ]
except Exception as e: subprocess.run(mongodump_cmd, check=True, capture_output=True)
print("Backup failed!", str(e)) print("Database dump successful.")
print(f"Creating backup archive: {BACKUP_FILENAME}")
with zipfile.ZipFile(BACKUP_FILENAME, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file_path in FILES_TO_BACKUP:
if file_path.exists() and file_path.is_file():
zipf.write(file_path, arcname=file_path.name)
print(f" - Added {file_path.name}")
else:
print(f" - Warning: Skipping missing file {file_path}")
dump_content_path = TEMP_DUMP_DIR / DB_NAME
if dump_content_path.exists():
for file_path in dump_content_path.rglob('*'):
arcname = file_path.relative_to(TEMP_DUMP_DIR)
zipf.write(file_path, arcname=arcname)
print(f" - Added database dump for '{DB_NAME}'")
print("\nBackup successfully created.")
except FileNotFoundError:
print("\nBackup failed! 'mongodump' command not found. Is MongoDB installed and in your PATH?")
except subprocess.CalledProcessError as e:
print("\nBackup failed! Error during mongodump.")
print(f" - Stderr: {e.stderr.decode().strip()}")
except Exception as e:
print(f"\nBackup failed! An unexpected error occurred: {e}")
finally:
if TEMP_DUMP_DIR.exists():
shutil.rmtree(TEMP_DUMP_DIR)
print("Temporary dump directory cleaned up.")
if __name__ == "__main__":
create_backup()

View File

@ -1,97 +1,82 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import init_paths
import sys import sys
import os import os
import subprocess import subprocess
import argparse import argparse
import re import re
from datetime import datetime from db.database import db
from init_paths import *
from paths import *
def add_bulk_users(traffic_gb, expiration_days, count, prefix, start_number, unlimited_user): def add_bulk_users(traffic_gb, expiration_days, count, prefix, start_number, unlimited_user):
if db is None:
print("Error: Database connection failed. Please ensure MongoDB is running.")
return 1
try: try:
traffic_bytes = int(float(traffic_gb) * 1073741824) traffic_bytes = int(float(traffic_gb) * 1073741824)
except ValueError: except ValueError:
print("Error: Traffic limit must be a numeric value.") print("Error: Traffic limit must be a numeric value.")
return 1 return 1
if not os.path.isfile(USERS_FILE): potential_usernames = []
try: for i in range(count):
with open(USERS_FILE, 'w') as f: username = f"{prefix}{start_number + i}"
json.dump({}, f) if not re.match(r"^[a-zA-Z0-9_]+$", username):
except IOError: print(f"Error: Generated username '{username}' contains invalid characters. Aborting.")
print(f"Error: Could not create {USERS_FILE}.")
return 1 return 1
potential_usernames.append(username.lower())
try: try:
with open(USERS_FILE, 'r+') as f: existing_docs = db.collection.find({"_id": {"$in": potential_usernames}}, {"_id": 1})
try: existing_users_set = {doc['_id'] for doc in existing_docs}
users_data = json.load(f) except Exception as e:
except json.JSONDecodeError: print(f"Error querying database for existing users: {e}")
print(f"Error: {USERS_FILE} contains invalid JSON.") return 1
return 1
existing_users_lower = {u.lower() for u in users_data} new_usernames = [u for u in potential_usernames if u not in existing_users_set]
new_users_to_add = {} new_users_count = len(new_usernames)
creation_date = None
try: if new_users_count == 0:
password_process = subprocess.run(['pwgen', '-s', '32', str(count)], capture_output=True, text=True, check=True) print("No new users to add. All generated usernames already exist.")
passwords = password_process.stdout.strip().split('\n')
except (FileNotFoundError, subprocess.CalledProcessError):
print("Warning: 'pwgen' not found or failed. Falling back to UUID for password generation.")
passwords = [subprocess.check_output(['cat', '/proc/sys/kernel/random/uuid'], text=True).strip() for _ in range(count)]
if len(passwords) < count:
print("Error: Could not generate enough passwords.")
return 1
for i in range(count):
username = f"{prefix}{start_number + i}"
username_lower = username.lower()
if not re.match(r"^[a-zA-Z0-9_]+$", username_lower):
print(f"Error: Generated username '{username}' contains invalid characters. Use only letters, numbers, and underscores.")
continue
if username_lower in existing_users_lower or username_lower in new_users_to_add:
print(f"Warning: User '{username}' already exists. Skipping.")
continue
new_users_to_add[username_lower] = {
"password": passwords[i],
"max_download_bytes": traffic_bytes,
"expiration_days": expiration_days,
"account_creation_date": creation_date,
"blocked": False,
"unlimited_user": unlimited_user
}
# print(f"Preparing to add user: {username}")
if not new_users_to_add:
print("No new users to add.")
return 0
users_data.update(new_users_to_add)
f.seek(0)
json.dump(users_data, f, indent=4)
f.truncate()
print(f"\nSuccessfully added {len(new_users_to_add)} users.")
return 0 return 0
except IOError: if count > new_users_count:
print(f"Error: Could not read or write to {USERS_FILE}.") print(f"Warning: {count - new_users_count} user(s) already exist. Skipping them.")
try:
password_process = subprocess.run(['pwgen', '-s', '32', str(new_users_count)], capture_output=True, text=True, check=True)
passwords = password_process.stdout.strip().split('\n')
except (FileNotFoundError, subprocess.CalledProcessError):
print("Warning: 'pwgen' not found or failed. Falling back to UUID for password generation.")
passwords = [subprocess.check_output(['cat', '/proc/sys/kernel/random/uuid'], text=True).strip() for _ in range(new_users_count)]
if len(passwords) < new_users_count:
print("Error: Could not generate enough passwords.")
return 1 return 1
users_to_insert = []
for i, username in enumerate(new_usernames):
user_doc = {
"_id": username,
"password": passwords[i],
"max_download_bytes": traffic_bytes,
"expiration_days": expiration_days,
"blocked": False,
"unlimited_user": unlimited_user
}
users_to_insert.append(user_doc)
try:
db.collection.insert_many(users_to_insert, ordered=False)
print(f"\nSuccessfully added {len(users_to_insert)} new users.")
return 0
except Exception as e: except Exception as e:
print(f"An unexpected error occurred: {e}") print(f"An unexpected error occurred during database insert: {e}")
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Add bulk users to Hysteria2.") parser = argparse.ArgumentParser(description="Add bulk users to Hysteria2 via database.")
parser.add_argument("-t", "--traffic-gb", dest="traffic_gb", type=float, required=True, help="Traffic limit for each user in GB.") parser.add_argument("-t", "--traffic-gb", dest="traffic_gb", type=float, required=True, help="Traffic limit for each user in GB.")
parser.add_argument("-e", "--expiration-days", dest="expiration_days", type=int, required=True, help="Expiration duration for each user in days.") parser.add_argument("-e", "--expiration-days", dest="expiration_days", type=int, required=True, help="Expiration duration for each user in days.")
parser.add_argument("-c", "--count", type=int, required=True, help="Number of users to create.") parser.add_argument("-c", "--count", type=int, required=True, help="Number of users to create.")

View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
import init_paths
import sys
import os
import argparse
import re
from datetime import datetime
from db.database import db
def edit_user(username, new_username=None, new_password=None, traffic_gb=None, expiration_days=None, creation_date=None, blocked=None, unlimited_user=None):
if db is None:
print("Error: Database connection failed.", file=sys.stderr)
return 1
username_lower = username.lower()
try:
user_data = db.get_user(username_lower)
if not user_data:
print(f"Error: User '{username}' not found.", file=sys.stderr)
return 1
except Exception as e:
print(f"Error fetching user data: {e}", file=sys.stderr)
return 1
updates = {}
if new_password:
updates['password'] = new_password
if traffic_gb is not None:
updates['max_download_bytes'] = int(float(traffic_gb) * 1073741824)
if expiration_days is not None:
updates['expiration_days'] = int(expiration_days)
if creation_date is not None:
if creation_date.lower() == 'null':
updates['account_creation_date'] = None
else:
updates['account_creation_date'] = creation_date
if blocked is not None:
updates['blocked'] = blocked
if unlimited_user is not None:
updates['unlimited_user'] = unlimited_user
try:
if updates:
db.update_user(username_lower, updates)
print(f"User '{username}' attributes updated successfully.")
if new_username and new_username.lower() != username_lower:
new_username_lower = new_username.lower()
if db.get_user(new_username_lower):
print(f"Error: Target username '{new_username}' already exists.", file=sys.stderr)
return 1
updated_user_data = db.get_user(username_lower)
updated_user_data.pop('_id')
updated_user_data['_id'] = new_username_lower
db.collection.insert_one(updated_user_data)
db.delete_user(username_lower)
print(f"User '{username}' successfully renamed to '{new_username}'.")
elif not updates and not (new_username and new_username.lower() != username_lower):
print("No changes specified.")
except Exception as e:
print(f"An error occurred during update: {e}", file=sys.stderr)
return 1
return 0
def str_to_bool(val):
if val.lower() in ('true', 'y', '1'):
return True
elif val.lower() in ('false', 'n', '0'):
return False
raise argparse.ArgumentTypeError('Boolean value expected (true/false, y/n, 1/0).')
def validate_date(date_str):
if date_str.lower() == 'null':
return date_str
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError:
raise argparse.ArgumentTypeError("Invalid date format. Expected YYYY-MM-DD or 'null'.")
def validate_username(username):
if not re.match(r"^[a-zA-Z0-9_]+$", username):
raise argparse.ArgumentTypeError("Username can only contain letters, numbers, and underscores.")
return username
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Edit a Hysteria2 user's details in the database.")
parser.add_argument("username", type=validate_username, help="The current username of the user to edit.")
parser.add_argument("--new-username", dest="new_username", type=validate_username, help="New username for the user.")
parser.add_argument("--password", dest="new_password", help="New password for the user.")
parser.add_argument("--traffic-gb", dest="traffic_gb", type=float, help="New traffic limit in GB (e.g., 50). Use 0 for unlimited.")
parser.add_argument("--expiration-days", dest="expiration_days", type=int, help="New expiration in days from creation date (e.g., 30). Use 0 for unlimited.")
parser.add_argument("--creation-date", dest="creation_date", type=validate_date, help="New creation date in YYYY-MM-DD format, or 'null' to reset to On-hold.")
parser.add_argument("--blocked", type=str_to_bool, help="Set blocked status (true/false).")
parser.add_argument("--unlimited", dest="unlimited_user", type=str_to_bool, help="Set unlimited user status for IP limits (true/false).")
args = parser.parse_args()
sys.exit(edit_user(
username=args.username,
new_username=args.new_username,
new_password=args.new_password,
traffic_gb=args.traffic_gb,
expiration_days=args.expiration_days,
creation_date=args.creation_date,
blocked=args.blocked,
unlimited_user=args.unlimited_user
))

View File

@ -1,268 +0,0 @@
#!/bin/bash
source /etc/hysteria/core/scripts/utils.sh
source /etc/hysteria/core/scripts/path.sh
readonly GB_TO_BYTES=$((1024 * 1024 * 1024))
validate_username() {
local username=$1
if [ -z "$username" ]; then
return 0
fi
if ! [[ "$username" =~ ^[a-zA-Z0-9]+$ ]]; then
echo "Username can only contain letters and numbers."
return 1
fi
return 0
}
validate_traffic_limit() {
local traffic_limit=$1
if [ -z "$traffic_limit" ]; then
return 0
fi
if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then
echo "Error: Traffic limit must be a valid non-negative number (use 0 for unlimited)."
return 1
fi
return 0
}
validate_expiration_days() {
local expiration_days=$1
if [ -z "$expiration_days" ]; then
return 0
fi
if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then
echo "Error: Expiration days must be a valid non-negative number (use 0 for unlimited)."
return 1
fi
return 0
}
validate_date() {
local date_str=$1
if [ -z "$date_str" ]; then
return 0
fi
if [ "$date_str" == "null" ]; then
return 0
fi
if ! [[ "$date_str" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "Invalid date format. Expected YYYY-MM-DD."
return 1
elif ! date -d "$date_str" >/dev/null 2>&1; then
echo "Invalid date. Please provide a valid date in YYYY-MM-DD format."
return 1
fi
return 0
}
validate_blocked_status() {
local status=$1
if [ -z "$status" ]; then
return 0
fi
if [ "$status" != "true" ] && [ "$status" != "false" ]; then
echo "Blocked status must be 'true' or 'false'."
return 1
fi
return 0
}
validate_unlimited_status() {
local status=$1
if [ -z "$status" ]; then
return 0
fi
if [ "$status" != "true" ] && [ "$status" != "false" ]; then
echo "Unlimited status must be 'true' or 'false'."
return 1
fi
return 0
}
convert_boolean_status() {
local status=$1
case "$status" in
y|Y) echo "true" ;;
n|N) echo "false" ;;
true|false) echo "$status" ;;
*) echo "false" ;;
esac
}
get_user_info() {
local username=$1
python3 $CLI_PATH get-user -u "$username"
}
update_user_info() {
local old_username=$1
local new_username=$2
local new_password=$3
local new_max_download_bytes=$4
local new_expiration_days=$5
local new_account_creation_date=$6
local new_blocked=$7
local new_unlimited=$8
if [ ! -f "$USERS_FILE" ]; then
echo -e "${red}Error:${NC} File '$USERS_FILE' not found."
return 1
fi
user_exists=$(jq -e --arg username "$old_username" '.[$username]' "$USERS_FILE" >/dev/null 2>&1 )
if [ $? -ne 0 ]; then
echo -e "${red}Error:${NC} User '$old_username' not found."
return 1
fi
existing_user_data=$(jq --arg username "$old_username" '.[$username]' "$USERS_FILE")
upload_bytes=$(echo "$existing_user_data" | jq -r '.upload_bytes // 0')
download_bytes=$(echo "$existing_user_data" | jq -r '.download_bytes // 0')
status=$(echo "$existing_user_data" | jq -r '.status // "Offline"')
echo "Updating user:"
echo "Username: $new_username"
echo "Password: ${new_password:-(not changed)}"
echo "Max Download Bytes: ${new_max_download_bytes:-(not changed)}"
echo "Expiration Days: ${new_expiration_days:-(not changed)}"
echo "Creation Date: ${new_account_creation_date:-(not changed)}"
echo "Blocked: $new_blocked"
echo "Unlimited IP: $new_unlimited"
jq \
--arg old_username "$old_username" \
--arg new_username "$new_username" \
--arg password "${new_password:-null}" \
--argjson max_download_bytes "${new_max_download_bytes:-null}" \
--argjson expiration_days "${new_expiration_days:-null}" \
--arg account_creation_date "${new_account_creation_date:-null}" \
--argjson blocked "$new_blocked" \
--argjson unlimited "$new_unlimited" \
--argjson upload_bytes "$upload_bytes" \
--argjson download_bytes "$download_bytes" \
--arg status "$status" \
'
.[$new_username] = .[$old_username] |
(if $old_username != $new_username then del(.[$old_username]) else . end) |
.[$new_username] |= (
.password = ($password // .password) |
.max_download_bytes = ($max_download_bytes // .max_download_bytes) |
.expiration_days = ($expiration_days // .expiration_days) |
.account_creation_date = (if $account_creation_date == "null" then null else $account_creation_date end) |
.blocked = $blocked |
.unlimited_user = $unlimited |
.status = $status
) |
(if .[$new_username].status != "On-hold" then
.[$new_username] |= (
.upload_bytes = $upload_bytes |
.download_bytes = $download_bytes
)
else . end
)
' "$USERS_FILE" > tmp.$$.json && mv tmp.$$.json "$USERS_FILE"
if [ $? -ne 0 ]; then
echo "Failed to update user '$old_username' in '$USERS_FILE'."
return 1
fi
return 0
}
edit_user() {
local username=$1
local new_username=$2
local new_traffic_limit=$3
local new_expiration_days=$4
local new_password=$5
local new_creation_date=$6
local new_blocked=$7
local new_unlimited=$8
local user_info=$(get_user_info "$username")
if [ $? -ne 0 ] || [ -z "$user_info" ]; then
echo "User '$username' not found."
return 1
fi
local password=$(echo "$user_info" | jq -r '.password')
local traffic_limit=$(echo "$user_info" | jq -r '.max_download_bytes')
local expiration_days=$(echo "$user_info" | jq -r '.expiration_days')
local creation_date=$(echo "$user_info" | jq -r '.account_creation_date')
local blocked=$(echo "$user_info" | jq -r '.blocked')
local unlimited_user=$(echo "$user_info" | jq -r '.unlimited_user // false')
if ! validate_username "$new_username"; then
echo "Invalid username: $new_username"
return 1
fi
if ! validate_traffic_limit "$new_traffic_limit"; then
echo "Invalid traffic limit: $new_traffic_limit"
return 1
fi
if ! validate_expiration_days "$new_expiration_days"; then
echo "Invalid expiration days: $new_expiration_days"
return 1
fi
if ! validate_date "$new_creation_date"; then
echo "Invalid creation date: $new_creation_date"
return 1
fi
if ! validate_blocked_status "$new_blocked"; then
echo "Invalid blocked status: $new_blocked"
return 1
fi
if ! validate_unlimited_status "$new_unlimited"; then
echo "Invalid unlimited status: $new_unlimited"
return 1
fi
new_username=${new_username:-$username}
new_password=${new_password:-$password}
if [ -n "$new_traffic_limit" ]; then
new_traffic_limit=$((new_traffic_limit * GB_TO_BYTES))
else
new_traffic_limit=$traffic_limit
fi
new_expiration_days=${new_expiration_days:-$expiration_days}
new_creation_date=${new_creation_date:-$creation_date}
new_blocked=$(convert_boolean_status "${new_blocked:-$blocked}")
new_unlimited=$(convert_boolean_status "${new_unlimited:-$unlimited_user}")
if ! update_user_info "$username" "$new_username" "$new_password" "$new_traffic_limit" "$new_expiration_days" "$new_creation_date" "$new_blocked" "$new_unlimited"; then
return 1
fi
echo "User updated successfully."
return 0
}
edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8"

View File

@ -1,15 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import init_paths
import json import json
import sys import sys
import os import os
import getopt import getopt
from init_paths import * from db.database import db
from paths import *
def get_user_info(username): def get_user_info(username):
""" """
Retrieves and prints information for a specific user from the USERS_FILE. Retrieves and prints information for a specific user from the database.
Args: Args:
username (str): The username to look up. username (str): The username to look up.
@ -17,30 +17,20 @@ def get_user_info(username):
Returns: Returns:
int: 0 on success, 1 on failure. int: 0 on success, 1 on failure.
""" """
if not os.path.isfile(USERS_FILE): if db is None:
print(f"users.json file not found at {USERS_FILE}!") print("Error: Database connection failed. Please ensure MongoDB is running.")
return 1 return 1
try: try:
with open(USERS_FILE, 'r') as f: user_info = db.get_user(username)
users_data = json.load(f) if user_info:
except json.JSONDecodeError: print(json.dumps(user_info, indent=4))
print(f"Error: {USERS_FILE} contains invalid JSON.") return 0
return 1 else:
print(f"User '{username}' not found in the database.")
if username in users_data: return 1
user_info = users_data[username] except Exception as e:
print(json.dumps(user_info, indent=4)) # Print with indentation for readability print(f"An error occurred while fetching user data: {e}")
# upload_bytes = user_info.get('upload_bytes', "No upload data available")
# download_bytes = user_info.get('download_bytes', "No download data available")
# status = user_info.get('status', "Status unavailable")
# You can choose to print these individually as well, if needed
# print(f"Upload Bytes: {upload_bytes}")
# print(f"Download Bytes: {download_bytes}")
# print(f"Status: {status}")
return 0
else:
print(f"User '{username}' not found in {USERS_FILE}.")
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":
@ -54,7 +44,7 @@ if __name__ == "__main__":
for opt, arg in opts: for opt, arg in opts:
if opt in ("-u", "--username"): if opt in ("-u", "--username"):
username = arg username = arg.lower()
if not username: if not username:
print(f"Usage: {sys.argv[0]} -u <username>") print(f"Usage: {sys.argv[0]} -u <username>")

View File

@ -1,18 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import init_paths
import os import os
import sys import sys
import json import json
import time
import fcntl import fcntl
import shutil
import datetime import datetime
from concurrent.futures import ThreadPoolExecutor
from init_paths import *
from paths import *
from hysteria2_api import Hysteria2Client
import logging import logging
from concurrent.futures import ThreadPoolExecutor
from db.database import db
from hysteria2_api import Hysteria2Client
from paths import CONFIG_FILE
logging.basicConfig( logging.basicConfig(
stream=sys.stdout, stream=sys.stdout,
level=logging.INFO, level=logging.INFO,
@ -22,8 +21,8 @@ logging.basicConfig(
logger = logging.getLogger() logger = logging.getLogger()
LOCKFILE = "/tmp/kick.lock" LOCKFILE = "/tmp/kick.lock"
BACKUP_FILE = f"{USERS_FILE}.bak"
MAX_WORKERS = 8 MAX_WORKERS = 8
API_BASE_URL = 'http://127.0.0.1:25413'
def acquire_lock(): def acquire_lock():
try: try:
@ -34,144 +33,104 @@ def acquire_lock():
logger.warning("Another instance is already running. Exiting.") logger.warning("Another instance is already running. Exiting.")
sys.exit(1) sys.exit(1)
def kick_users(usernames, secret): def get_secret():
try: try:
client = Hysteria2Client( with open(CONFIG_FILE, 'r') as f:
base_url="http://127.0.0.1:25413", config = json.load(f)
secret=secret return config.get('trafficStats', {}).get('secret')
) except (json.JSONDecodeError, FileNotFoundError):
return None
def kick_users_api(usernames, secret):
try:
client = Hysteria2Client(base_url=API_BASE_URL, secret=secret)
client.kick_clients(usernames) client.kick_clients(usernames)
logger.info(f"Successfully kicked {len(usernames)} users: {', '.join(usernames)}") logger.info(f"Successfully sent kick command for users: {', '.join(usernames)}")
return True
except Exception as e: except Exception as e:
logger.error(f"Error kicking users: {str(e)}") logger.error(f"Error kicking users via API: {e}")
return False
def process_user(username, user_data, config_secret, users_data): def process_user(user_doc):
blocked = user_data.get('blocked', False) username = user_doc.get('_id')
if blocked: if not username or user_doc.get('blocked', False):
logger.info(f"Skipping {username} as they are already blocked.")
return None return None
max_download_bytes = user_data.get('max_download_bytes', 0) account_creation_date = user_doc.get('account_creation_date')
expiration_days = user_data.get('expiration_days', 0)
account_creation_date = user_data.get('account_creation_date')
current_download_bytes = user_data.get('download_bytes', 0)
current_upload_bytes = user_data.get('upload_bytes', 0)
total_bytes = current_download_bytes + current_upload_bytes
if not account_creation_date: if not account_creation_date:
logger.info(f"Skipping {username} due to missing account creation date.")
return None return None
should_block = False
try: try:
current_date = datetime.datetime.now().timestamp() expiration_days = user_doc.get('expiration_days', 0)
creation_date = datetime.datetime.fromisoformat(account_creation_date.replace('Z', '+00:00')) if expiration_days > 0:
expiration_date = (creation_date + datetime.timedelta(days=expiration_days)).timestamp() creation_date = datetime.datetime.strptime(account_creation_date, "%Y-%m-%d")
expiration_date = creation_date + datetime.timedelta(days=expiration_days)
should_block = False if datetime.datetime.now() >= expiration_date:
if max_download_bytes > 0 and total_bytes >= 0 and expiration_days > 0:
if total_bytes >= max_download_bytes or current_date >= expiration_date:
should_block = True should_block = True
logger.info(f"User {username} is expired.")
if should_block: if not should_block:
logger.info(f"Setting blocked=True for user {username}") max_download_bytes = user_doc.get('max_download_bytes', 0)
users_data[username]['blocked'] = True if max_download_bytes > 0:
return username total_bytes = user_doc.get('download_bytes', 0) + user_doc.get('upload_bytes', 0)
else: if total_bytes >= max_download_bytes:
logger.info(f"Skipping {username} due to invalid or missing data.") should_block = True
return None logger.info(f"User {username} has exceeded their traffic limit.")
except Exception as e: if should_block:
logger.error(f"Error processing user {username}: {str(e)}") return username
return None
except (ValueError, TypeError) as e:
logger.error(f"Error processing user {username} due to invalid data: {e}")
return None return None
def main(): def main():
lock_file = acquire_lock() lock_file = acquire_lock()
try: try:
shutil.copy2(USERS_FILE, BACKUP_FILE) if db is None:
logger.info(f"Created backup of users file at {BACKUP_FILE}") logger.error("Database connection failed. Exiting.")
try:
with open(CONFIG_FILE, 'r') as f:
config = json.load(f)
secret = config.get('trafficStats', {}).get('secret', '')
if not secret:
logger.error("No secret found in config file")
sys.exit(1)
except Exception as e:
logger.error(f"Failed to load config file: {str(e)}")
shutil.copy2(BACKUP_FILE, USERS_FILE)
sys.exit(1) sys.exit(1)
try: secret = get_secret()
with open(USERS_FILE, 'r') as f: if not secret:
users_data = json.load(f) logger.error(f"Could not find secret in {CONFIG_FILE}. Exiting.")
logger.info(f"Loaded data for {len(users_data)} users")
except json.JSONDecodeError:
logger.error("Invalid users.json. Restoring backup.")
shutil.copy2(BACKUP_FILE, USERS_FILE)
sys.exit(1)
except Exception as e:
logger.error(f"Failed to load users file: {str(e)}")
shutil.copy2(BACKUP_FILE, USERS_FILE)
sys.exit(1) sys.exit(1)
users_to_kick = [] all_users = db.get_all_users()
logger.info(f"Processing {len(users_data)} users in parallel with {MAX_WORKERS} workers") logger.info(f"Loaded {len(all_users)} users from the database for processing.")
users_to_block = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
future_to_user = { future_to_user = {executor.submit(process_user, user_doc): user_doc for user_doc in all_users}
executor.submit(process_user, username, user_data, secret, users_data): username
for username, user_data in users_data.items()
}
for future in future_to_user: for future in future_to_user:
username = future.result() result = future.result()
if username: if result:
users_to_kick.append(username) users_to_block.append(result)
logger.info(f"User {username} added to kick list")
if users_to_kick: if not users_to_block:
logger.info(f"Saving changes to users file for {len(users_to_kick)} blocked users") logger.info("No users to block or kick.")
for retry in range(3): return
try:
with open(USERS_FILE, 'w') as f:
json.dump(users_data, f, indent=2)
break
except Exception as e:
logger.error(f"Failed to save users file (attempt {retry+1}): {str(e)}")
time.sleep(1)
if retry == 2:
raise
if users_to_kick: logger.info(f"Found {len(users_to_block)} users to block: {', '.join(users_to_block)}")
logger.info(f"Kicking {len(users_to_kick)} users")
batch_size = 50 for username in users_to_block:
for i in range(0, len(users_to_kick), batch_size): db.update_user(username, {'blocked': True})
batch = users_to_kick[i:i+batch_size] logger.info("Successfully updated user statuses to 'blocked' in the database.")
logger.info(f"Processing batch of {len(batch)} users")
kick_users(batch, secret) batch_size = 50
for username in batch: for i in range(0, len(users_to_block), batch_size):
logger.info(f"Blocked and kicked user {username}") batch = users_to_block[i:i + batch_size]
else: kick_users_api(batch, secret)
logger.info("No users to kick")
except Exception as e: except Exception as e:
logger.error(f"An error occurred: {str(e)}") logger.error(f"An unexpected error occurred in main execution: {e}", exc_info=True)
logger.info("Restoring users file from backup")
shutil.copy2(BACKUP_FILE, USERS_FILE)
sys.exit(1) sys.exit(1)
finally: finally:
fcntl.flock(lock_file, fcntl.LOCK_UN) fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close() lock_file.close()
logger.info("Script completed") logger.info("Script finished.")
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,14 +1,12 @@
#!/usr/bin/env python3
import init_paths
import sys import sys
import json import json
from pathlib import Path from pathlib import Path
from hysteria2_api import Hysteria2Client
try: from db.database import db
from hysteria2_api import Hysteria2Client from paths import CONFIG_FILE, API_BASE_URL
except ImportError:
sys.exit("Error: hysteria2_api library not found. Please install it.")
sys.path.append(str(Path(__file__).parent.parent))
from paths import USERS_FILE, CONFIG_FILE, API_BASE_URL
def get_secret() -> str | None: def get_secret() -> str | None:
if not CONFIG_FILE.exists(): if not CONFIG_FILE.exists():
@ -20,34 +18,45 @@ def get_secret() -> str | None:
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
return None return None
def get_users() -> dict: def get_users_from_db() -> list:
if not USERS_FILE.exists(): if db is None:
return {} print("Error: Database connection failed.", file=sys.stderr)
return []
try: try:
with USERS_FILE.open('r') as f: users = db.get_all_users()
return json.load(f) for user in users:
except (json.JSONDecodeError, IOError): user['username'] = user.pop('_id')
return {} return users
except Exception as e:
print(f"Error retrieving users from database: {e}", file=sys.stderr)
return []
def main(): def main():
users_dict = get_users() users_list = get_users_from_db()
if not users_list:
print(json.dumps([], indent=2))
return
secret = get_secret() secret = get_secret()
if secret and users_dict: if secret:
try: try:
client = Hysteria2Client(base_url=API_BASE_URL, secret=secret) client = Hysteria2Client(base_url=API_BASE_URL, secret=secret)
online_clients = client.get_online_clients() online_clients = client.get_online_clients()
users_dict = {user['username']: user for user in users_list}
for username, status in online_clients.items(): for username, status in online_clients.items():
if status.is_online and username in users_dict: if status.is_online and username in users_dict:
users_dict[username]['online_count'] = status.connections users_dict[username]['online_count'] = status.connections
except Exception:
users_list = list(users_dict.values())
except Exception as e:
print(f"Warning: Could not connect to Hysteria2 API to get online status. {e}", file=sys.stderr)
pass pass
users_list = [ for user in users_list:
{**user_data, 'username': username, 'online_count': user_data.get('online_count', 0)} user.setdefault('online_count', 0)
for username, user_data in users_dict.items()
]
print(json.dumps(users_list, indent=2)) print(json.dumps(users_list, indent=2))

View File

@ -1,6 +0,0 @@
#!/bin/bash
source /etc/hysteria/core/scripts/path.sh
cat "$USERS_FILE"

View File

@ -1,46 +1,33 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import init_paths
import sys import sys
import os import os
import asyncio from db.database import db
from init_paths import *
from paths import *
def sync_remove_user(username): def remove_user(username):
if not os.path.isfile(USERS_FILE): if db is None:
return 1, f"Error: Config file {USERS_FILE} not found." return 1, "Error: Database connection failed. Please ensure MongoDB is running."
try: try:
with open(USERS_FILE, 'r') as f: result = db.delete_user(username)
try: if result.deleted_count > 0:
users_data = json.load(f)
except json.JSONDecodeError:
return 1, f"Error: {USERS_FILE} contains invalid JSON."
if username in users_data:
del users_data[username]
with open(USERS_FILE, 'w') as f:
json.dump(users_data, f, indent=4)
return 0, f"User {username} removed successfully." return 0, f"User {username} removed successfully."
else: else:
return 1, f"Error: User {username} not found." return 1, f"Error: User {username} not found."
except Exception as e: except Exception as e:
return 1, f"Error: {str(e)}" return 1, f"An error occurred while removing the user: {e}"
async def remove_user(username): def main():
return await asyncio.to_thread(sync_remove_user, username)
async def main():
if len(sys.argv) != 2: if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <username>") print(f"Usage: {sys.argv[0]} <username>")
sys.exit(1) sys.exit(1)
username = sys.argv[1] username = sys.argv[1].lower()
exit_code, message = await remove_user(username) exit_code, message = remove_user(username)
print(message) print(message)
sys.exit(exit_code) sys.exit(exit_code)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) main()

View File

@ -1,15 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import init_paths
import sys import sys
import os import os
from datetime import date from datetime import date
from init_paths import * from db.database import db
from paths import *
def reset_user(username): def reset_user(username):
""" """
Resets the data usage, status, and creation date of a user in the USERS_FILE. Resets the data usage, status, and creation date of a user in the database.
Args: Args:
username (str): The username to reset. username (str): The username to reset.
@ -17,35 +16,34 @@ def reset_user(username):
Returns: Returns:
int: 0 on success, 1 on failure. int: 0 on success, 1 on failure.
""" """
if not os.path.isfile(USERS_FILE): if db is None:
print(f"Error: File '{USERS_FILE}' not found.") print("Error: Database connection failed. Please ensure MongoDB is running.")
return 1 return 1
try: try:
with open(USERS_FILE, 'r') as f: user = db.get_user(username)
users_data = json.load(f) if not user:
except json.JSONDecodeError: print(f"Error: User '{username}' not found in the database.")
print(f"Error: {USERS_FILE} contains invalid JSON.") return 1
return 1
if username not in users_data: updates = {
print(f"Error: User '{username}' not found in '{USERS_FILE}'.") 'upload_bytes': 0,
return 1 'download_bytes': 0,
'status': 'Offline',
'account_creation_date': date.today().strftime("%Y-%m-%d"),
'blocked': False
}
today = date.today().strftime("%Y-%m-%d") result = db.update_user(username, updates)
users_data[username]['upload_bytes'] = 0 if result.modified_count > 0:
users_data[username]['download_bytes'] = 0 print(f"User '{username}' has been reset successfully.")
users_data[username]['status'] = "Offline" return 0
users_data[username]['account_creation_date'] = today else:
users_data[username]['blocked'] = False print(f"User '{username}' data was already in a reset state. No changes made.")
return 0
try: except Exception as e:
with open(USERS_FILE, 'w') as f: print(f"An error occurred while resetting the user: {e}")
json.dump(users_data, f, indent=4)
print(f"User '{username}' has been reset successfully.")
return 0
except IOError:
print(f"Error: Failed to reset user '{username}' in '{USERS_FILE}'.")
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":
@ -53,6 +51,6 @@ if __name__ == "__main__":
print(f"Usage: {sys.argv[0]} <username>") print(f"Usage: {sys.argv[0]} <username>")
sys.exit(1) sys.exit(1)
username_to_reset = sys.argv[1] username_to_reset = sys.argv[1].lower()
exit_code = reset_user(username_to_reset) exit_code = reset_user(username_to_reset)
sys.exit(exit_code) sys.exit(exit_code)

View File

@ -7,158 +7,120 @@ import shutil
import zipfile import zipfile
import tempfile import tempfile
import subprocess import subprocess
import datetime
from pathlib import Path from pathlib import Path
from init_paths import *
from paths import *
def run_command(command, capture_output=True, check=False): DB_NAME = "blitz_panel"
"""Run a shell command and return its output""" HYSTERIA_CONFIG_DIR = Path("/etc/hysteria")
result = subprocess.run( CLI_PATH = Path("/etc/hysteria/core/cli.py")
command,
shell=True, def run_command(command, check=False):
capture_output=capture_output, try:
text=True, return subprocess.run(
check=check command,
) shell=True,
if capture_output: capture_output=True,
return result.returncode, result.stdout.strip() text=True,
return result.returncode, None check=check
)
except subprocess.CalledProcessError as e:
print(f"Error executing command: '{e.cmd}'", file=sys.stderr)
print(f"Stderr: {e.stderr}", file=sys.stderr)
raise
def main(): def main():
if len(sys.argv) < 2: if len(sys.argv) < 2:
print("Error: Backup file path is required.") print("Error: Backup file path is required.", file=sys.stderr)
return 1 return 1
backup_zip_file = sys.argv[1] backup_zip_file = Path(sys.argv[1])
if not os.path.isfile(backup_zip_file): if not backup_zip_file.is_file():
print(f"Error: Backup file not found: {backup_zip_file}") print(f"Error: Backup file not found: {backup_zip_file}", file=sys.stderr)
return 1 return 1
if not backup_zip_file.lower().endswith('.zip'): if backup_zip_file.suffix.lower() != '.zip':
print("Error: Backup file must be a .zip file.") print("Error: Backup file must be a .zip file.", file=sys.stderr)
return 1 return 1
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
restore_dir = f"/tmp/hysteria_restore_{timestamp}"
target_dir = "/etc/hysteria"
try: try:
os.makedirs(restore_dir, exist_ok=True) with tempfile.TemporaryDirectory() as temp_dir_str:
temp_dir = Path(temp_dir_str)
try: print(f"Extracting backup to temporary directory: {temp_dir}")
with zipfile.ZipFile(backup_zip_file) as zf:
zf.testzip()
zf.extractall(restore_dir)
except zipfile.BadZipFile:
print("Error: Invalid ZIP file.")
return 1
except Exception as e:
print(f"Error: Could not extract the ZIP file: {e}")
return 1
expected_files = [
"ca.key",
"ca.crt",
"users.json",
"config.json",
".configs.env"
]
for file in expected_files:
file_path = os.path.join(restore_dir, file)
if not os.path.isfile(file_path):
print(f"Error: Required file '{file}' is missing from the backup.")
return 1
existing_backup_dir = f"/opt/hysbackup/restore_pre_backup_{timestamp}"
os.makedirs(existing_backup_dir, exist_ok=True)
for file in expected_files:
source_file = os.path.join(target_dir, file)
dest_file = os.path.join(existing_backup_dir, file)
if os.path.isfile(source_file):
try:
shutil.copy2(source_file, dest_file)
except Exception as e:
print(f"Error creating backup file before restore from '{source_file}': {e}")
return 1
for file in expected_files:
source_file = os.path.join(restore_dir, file)
dest_file = os.path.join(target_dir, file)
try: try:
shutil.copy2(source_file, dest_file) with zipfile.ZipFile(backup_zip_file) as zf:
except Exception as e: zf.extractall(temp_dir)
print(f"Error: replace Configuration Files '{file}': {e}") except zipfile.BadZipFile:
shutil.rmtree(existing_backup_dir, ignore_errors=True) print("Error: Invalid or corrupt ZIP file.", file=sys.stderr)
return 1 return 1
config_file = os.path.join(target_dir, "config.json") dump_dir = temp_dir / DB_NAME
if not dump_dir.is_dir():
print("Error: Backup is in an old format or is missing the database dump.", file=sys.stderr)
print("Please use a backup created with the new MongoDB-aware script.", file=sys.stderr)
return 1
if os.path.isfile(config_file): print("Restoring MongoDB database... (This will drop the current user data)")
print("Checking and adjusting config.json based on system state...") run_command(f"mongorestore --db={DB_NAME} --drop --dir='{dump_dir}'", check=True)
print("Database restored successfully.")
ret_code, networkdef = run_command("ip route | grep '^default' | awk '{print $5}'") files_to_copy = ["config.json", ".configs.env", "ca.key", "ca.crt"]
networkdef = networkdef.strip() print("Restoring configuration files...")
for filename in files_to_copy:
src = temp_dir / filename
if src.exists():
shutil.copy2(src, HYSTERIA_CONFIG_DIR / filename)
print(f" - Restored {filename}")
if networkdef: adjust_config_file()
with open(config_file, 'r') as f:
config = json.load(f)
for outbound in config.get('outbounds', []): print("Setting permissions...")
if outbound.get('name') == 'v4' and 'direct' in outbound: run_command(f"chown hysteria:hysteria {HYSTERIA_CONFIG_DIR / 'ca.key'} {HYSTERIA_CONFIG_DIR / 'ca.crt'}")
current_v4_device = outbound['direct'].get('bindDevice', '') run_command(f"chmod 640 {HYSTERIA_CONFIG_DIR / 'ca.key'} {HYSTERIA_CONFIG_DIR / 'ca.crt'}")
if current_v4_device != networkdef: print("Restarting Hysteria service...")
print(f"Updating v4 outbound bindDevice from '{current_v4_device}' to '{networkdef}'...") run_command(f"python3 {CLI_PATH} restart-hysteria2", check=True)
outbound['direct']['bindDevice'] = networkdef
with open(config_file, 'w') as f: print("\nRestore completed successfully.")
json.dump(config, f, indent=2) return 0
ret_code, _ = run_command("systemctl is-active --quiet wg-quick@wgcf.service", capture_output=False) except subprocess.CalledProcessError:
print("\nRestore failed due to a command execution error.", file=sys.stderr)
return 1
except Exception as e:
print(f"\nAn unexpected error occurred during restore: {e}", file=sys.stderr)
return 1
if ret_code != 0: def adjust_config_file():
print("wgcf service is NOT active. Removing warps outbound and any ACL rules...") config_file = HYSTERIA_CONFIG_DIR / "config.json"
if not config_file.exists():
return
with open(config_file, 'r') as f: print("Adjusting config.json based on current system state...")
config = json.load(f) try:
with open(config_file, 'r') as f:
config = json.load(f)
config['outbounds'] = [outbound for outbound in config.get('outbounds', []) result = run_command("ip route | grep '^default' | awk '{print $5}'")
if outbound.get('name') != 'warps'] network_device = result.stdout.strip()
if network_device:
for outbound in config.get('outbounds', []):
if outbound.get('name') == 'v4' and 'direct' in outbound:
outbound['direct']['bindDevice'] = network_device
if 'acl' in config and 'inline' in config['acl']: result = run_command("systemctl is-active --quiet wg-quick@wgcf.service")
config['acl']['inline'] = [rule for rule in config['acl']['inline'] if result.returncode != 0:
if not rule.startswith('warps(')] print(" - WARP service is inactive, removing related configuration.")
config['outbounds'] = [o for o in config.get('outbounds', []) if o.get('name') != 'warps']
if 'acl' in config and 'inline' in config['acl']:
config['acl']['inline'] = [r for r in config['acl']['inline'] if not r.startswith('warps(')]
with open(config_file, 'w') as f: with open(config_file, 'w') as f:
json.dump(config, f, indent=2) json.dump(config, f, indent=2)
run_command("chown hysteria:hysteria /etc/hysteria/ca.key /etc/hysteria/ca.crt",
capture_output=False)
run_command("chmod 640 /etc/hysteria/ca.key /etc/hysteria/ca.crt",
capture_output=False)
ret_code, _ = run_command(f"python3 {CLI_PATH} restart-hysteria2", capture_output=False)
if ret_code != 0:
print("Error: Restart service failed.")
return 1
print("Hysteria configuration restored and updated successfully.")
return 0
except Exception as e: except Exception as e:
print(f"An unexpected error occurred: {e}") print(f"Warning: Could not adjust config.json. {e}", file=sys.stderr)
return 1
finally:
shutil.rmtree(restore_dir, ignore_errors=True)
if 'existing_backup_dir' in locals():
shutil.rmtree(existing_backup_dir, ignore_errors=True)
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import init_paths
import os import os
import sys import sys
import json import json
@ -9,11 +10,10 @@ import re
import qrcode import qrcode
from io import StringIO from io import StringIO
from typing import Tuple, Optional, Dict, List, Any from typing import Tuple, Optional, Dict, List, Any
from init_paths import * from db.database import db
from paths import * from paths import *
def load_env_file(env_file: str) -> Dict[str, str]: def load_env_file(env_file: str) -> Dict[str, str]:
"""Load environment variables from a file into a dictionary."""
env_vars = {} env_vars = {}
if os.path.exists(env_file): if os.path.exists(env_file):
with open(env_file, 'r') as f: with open(env_file, 'r') as f:
@ -25,7 +25,6 @@ def load_env_file(env_file: str) -> Dict[str, str]:
return env_vars return env_vars
def load_nodes() -> List[Dict[str, str]]: def load_nodes() -> List[Dict[str, str]]:
"""Load external node information from the nodes JSON file."""
if NODES_JSON_PATH.exists(): if NODES_JSON_PATH.exists():
try: try:
with NODES_JSON_PATH.open("r") as f: with NODES_JSON_PATH.open("r") as f:
@ -37,11 +36,9 @@ def load_nodes() -> List[Dict[str, str]]:
return [] return []
def load_hysteria2_env() -> Dict[str, str]: def load_hysteria2_env() -> Dict[str, str]:
"""Load Hysteria2 environment variables."""
return load_env_file(CONFIG_ENV) return load_env_file(CONFIG_ENV)
def load_hysteria2_ips() -> Tuple[str, str, str]: def load_hysteria2_ips() -> Tuple[str, str, str]:
"""Load Hysteria2 IPv4 and IPv6 addresses from environment."""
env_vars = load_hysteria2_env() env_vars = load_hysteria2_env()
ip4 = env_vars.get('IP4', 'None') ip4 = env_vars.get('IP4', 'None')
ip6 = env_vars.get('IP6', 'None') ip6 = env_vars.get('IP6', 'None')
@ -49,14 +46,12 @@ def load_hysteria2_ips() -> Tuple[str, str, str]:
return ip4, ip6, sni return ip4, ip6, sni
def get_singbox_domain_and_port() -> Tuple[str, str]: def get_singbox_domain_and_port() -> Tuple[str, str]:
"""Get domain and port from SingBox config."""
env_vars = load_env_file(SINGBOX_ENV) env_vars = load_env_file(SINGBOX_ENV)
domain = env_vars.get('HYSTERIA_DOMAIN', '') domain = env_vars.get('HYSTERIA_DOMAIN', '')
port = env_vars.get('HYSTERIA_PORT', '') port = env_vars.get('HYSTERIA_PORT', '')
return domain, port return domain, port
def get_normalsub_domain_and_port() -> Tuple[str, str, str]: def get_normalsub_domain_and_port() -> Tuple[str, str, str]:
"""Get domain, port, and subpath from Normal-SUB config."""
env_vars = load_env_file(NORMALSUB_ENV) env_vars = load_env_file(NORMALSUB_ENV)
domain = env_vars.get('HYSTERIA_DOMAIN', '') domain = env_vars.get('HYSTERIA_DOMAIN', '')
port = env_vars.get('HYSTERIA_PORT', '') port = env_vars.get('HYSTERIA_PORT', '')
@ -64,7 +59,6 @@ def get_normalsub_domain_and_port() -> Tuple[str, str, str]:
return domain, port, subpath return domain, port, subpath
def is_service_active(service_name: str) -> bool: def is_service_active(service_name: str) -> bool:
"""Check if a systemd service is active."""
try: try:
result = subprocess.run( result = subprocess.run(
['systemctl', 'is-active', '--quiet', service_name], ['systemctl', 'is-active', '--quiet', service_name],
@ -77,28 +71,23 @@ def is_service_active(service_name: str) -> bool:
def generate_uri(username: str, auth_password: str, ip: str, port: str, def generate_uri(username: str, auth_password: str, ip: str, port: str,
obfs_password: str, sha256: str, sni: str, ip_version: int, obfs_password: str, sha256: str, sni: str, ip_version: int,
insecure: bool, fragment_tag: str) -> str: insecure: bool, fragment_tag: str) -> str:
"""Generate Hysteria2 URI for the given parameters.""" ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip
uri_base = f"hy2://{username}%3A{auth_password}@{ip}:{port}" uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}"
if ip_version == 6 and re.match(r'^[0-9a-fA-F:]+$', ip):
uri_base = f"hy2://{username}%3A{auth_password}@[{ip}]:{port}"
params = [] params = []
if obfs_password: if obfs_password:
params.append(f"obfs=salamander&obfs-password={obfs_password}") params.append(f"obfs=salamander&obfs-password={obfs_password}")
if sha256: if sha256:
params.append(f"pinSHA256={sha256}") params.append(f"pinSHA256={sha256}")
if sni:
params.append(f"sni={sni}")
insecure_value = "1" if insecure else "0" params.append(f"insecure={'1' if insecure else '0'}")
params.append(f"insecure={insecure_value}&sni={sni}")
params_str = "&".join(params) query_string = "&".join(params)
return f"{uri_base}?{params_str}#{fragment_tag}" return f"{uri_base}?{query_string}#{fragment_tag}"
def generate_qr_code(uri: str) -> List[str]: def generate_qr_code(uri: str) -> List[str]:
"""Generate terminal-friendly ASCII QR code using pure Python."""
try: try:
qr = qrcode.QRCode( qr = qrcode.QRCode(
version=1, version=1,
@ -116,18 +105,15 @@ def generate_qr_code(uri: str) -> List[str]:
return [f"Error generating QR code: {str(e)}"] return [f"Error generating QR code: {str(e)}"]
def center_text(text: str, width: int) -> str: def center_text(text: str, width: int) -> str:
"""Center text in the given width."""
return text.center(width) return text.center(width)
def get_terminal_width() -> int: def get_terminal_width() -> int:
"""Get terminal width."""
try: try:
return os.get_terminal_size().columns return os.get_terminal_size().columns
except (AttributeError, OSError): except (AttributeError, OSError):
return 80 return 80
def display_uri_and_qr(uri: str, label: str, args: argparse.Namespace, terminal_width: int): def display_uri_and_qr(uri: str, label: str, args: argparse.Namespace, terminal_width: int):
"""Helper function to print URI and its QR code."""
if not uri: if not uri:
return return
@ -140,27 +126,28 @@ def display_uri_and_qr(uri: str, label: str, args: argparse.Namespace, terminal_
print(center_text(line, terminal_width)) print(center_text(line, terminal_width))
def show_uri(args: argparse.Namespace) -> None: def show_uri(args: argparse.Namespace) -> None:
"""Show URI and optional QR codes for the given username and nodes.""" if db is None:
if not os.path.exists(USERS_FILE): print("\033[0;31mError:\033[0m Database connection failed.")
print(f"\033[0;31mError:\033[0m Config file {USERS_FILE} not found.")
return return
if not is_service_active("hysteria-server.service"): if not is_service_active("hysteria-server.service"):
print("\033[0;31mError:\033[0m Hysteria2 is not active.") print("\033[0;31mError:\033[0m Hysteria2 is not active.")
return return
with open(CONFIG_FILE, 'r') as f: try:
config = json.load(f) with open(CONFIG_FILE, 'r') as f:
config = json.load(f)
with open(USERS_FILE, 'r') as f: except (FileNotFoundError, json.JSONDecodeError) as e:
users = json.load(f) print(f"\033[0;31mError:\033[0m Could not load config file {CONFIG_FILE}. Details: {e}")
if args.username not in users:
print("Invalid username. Please try again.")
return return
auth_password = users[args.username]["password"] user_doc = db.get_user(args.username)
port = config["listen"].split(":")[1] if ":" in config["listen"] else config["listen"] if not user_doc:
print(f"\033[0;31mError:\033[0m User '{args.username}' not found in the database.")
return
auth_password = user_doc["password"]
port = config["listen"].split(":")[-1]
sha256 = config.get("tls", {}).get("pinSHA256", "") sha256 = config.get("tls", {}).get("pinSHA256", "")
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "") obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
insecure = config.get("tls", {}).get("insecure", True) insecure = config.get("tls", {}).get("insecure", True)
@ -205,7 +192,6 @@ def show_uri(args: argparse.Namespace) -> None:
print(f"\nNormal-SUB Sublink:\nhttps://{domain}:{port}/{subpath}/sub/normal/{auth_password}#Hysteria2\n") print(f"\nNormal-SUB Sublink:\nhttps://{domain}:{port}/{subpath}/sub/normal/{auth_password}#Hysteria2\n")
def main(): def main():
"""Main function to parse arguments and show URIs."""
parser = argparse.ArgumentParser(description="Hysteria2 URI Generator") parser = argparse.ArgumentParser(description="Hysteria2 URI Generator")
parser.add_argument("-u", "--username", help="Username to generate URI for") parser.add_argument("-u", "--username", help="Username to generate URI for")
parser.add_argument("-qr", "--qrcode", action="store_true", help="Generate QR code") parser.add_argument("-qr", "--qrcode", action="store_true", help="Generate QR code")

View File

@ -1,11 +1,16 @@
#!/usr/bin/env python3
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path
try:
import pymongo
except ImportError:
pymongo = None
SERVICES = [ SERVICES = [
"hysteria-server.service", "hysteria-server.service",
"hysteria-auth.service",
"hysteria-webpanel.service", "hysteria-webpanel.service",
"hysteria-caddy.service", "hysteria-caddy.service",
"hysteria-telegram-bot.service", "hysteria-telegram-bot.service",
@ -15,81 +20,99 @@ SERVICES = [
"hysteria-scheduler.service", "hysteria-scheduler.service",
] ]
DB_NAME = "blitz_panel"
def run_command(command, error_message): def run_command(command, error_message):
"""Runs a command and prints an error message if it fails."""
try: try:
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return 0 return True
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
print(error_message) print(f"Warning: {error_message}")
return 1 return False
except FileNotFoundError: except FileNotFoundError:
print(f"Error: Command not found: {command[0]}") print(f"Warning: Command not found: {command[0]}")
return 1 return False
def drop_mongodb_database():
if not pymongo:
print("Warning: pymongo library not found. Skipping database cleanup.")
return
print(f"Attempting to drop MongoDB database: '{DB_NAME}'...")
try:
client = pymongo.MongoClient("mongodb://localhost:27017/", serverSelectionTimeoutMS=5000)
client.server_info()
client.drop_database(DB_NAME)
print(f"Database '{DB_NAME}' dropped successfully.")
except pymongo.errors.ConnectionFailure:
print("Warning: Could not connect to MongoDB. Skipping database cleanup.")
except Exception as e:
print(f"An error occurred during database cleanup: {e}")
def uninstall_hysteria(): def uninstall_hysteria():
"""Uninstalls Hysteria2.""" print("Uninstalling Hysteria2 and all related components...")
print("Uninstalling Hysteria2...")
print("Running uninstallation script...") print("\nStep 1: Stopping and disabling all Hysteria services...")
run_command(["bash", "-c", "curl -fsSL https://get.hy2.sh/ | bash -- --remove"], "Error running the official uninstallation script.") for service in SERVICES:
run_command(["systemctl", "stop", service], f"Failed to stop {service}.")
run_command(["systemctl", "disable", service], f"Failed to disable {service}.")
print("Removing WARP") print("\nStep 2: Removing systemd service files...")
systemd_path = Path("/etc/systemd/system")
for service in SERVICES:
service_file = systemd_path / service
if service_file.exists():
run_command(["rm", "-f", str(service_file)], f"Failed to remove {service_file}")
print("Reloading systemd daemon...")
run_command(["systemctl", "daemon-reload"], "Failed to reload systemd daemon.")
print("\nStep 3: Removing Hysteria binaries...")
run_command(["bash", "-c", "curl -fsSL https://get.hy2.sh/ | bash -- --remove"], "Failed to run the official Hysteria uninstallation script.")
print("\nStep 4: Cleaning MongoDB database...")
drop_mongodb_database()
print("\nStep 5: Removing related components (WARP)...")
cli_path = "/etc/hysteria/core/cli.py" cli_path = "/etc/hysteria/core/cli.py"
if os.path.exists(cli_path): if os.path.exists(cli_path):
run_command([sys.executable, cli_path, "uninstall-warp"], "Error during WARP removal.") run_command([sys.executable, cli_path, "uninstall-warp"], "Failed during WARP removal.")
else: else:
print("Skipping WARP removal (CLI path not found)") print("Skipping WARP removal (CLI path not found)")
print("Removing Hysteria folder...") print("\nStep 6: Removing all Hysteria panel files...")
run_command(["rm", "-rf", "/etc/hysteria"], "Error removing the Hysteria folder.") run_command(["rm", "-rf", "/etc/hysteria"], "Failed to remove the /etc/hysteria folder.")
print("Deleting hysteria user...") print("\nStep 7: Deleting the 'hysteria' user...")
run_command(["userdel", "-r", "hysteria"], "Error deleting the hysteria user.") run_command(["userdel", "-r", "hysteria"], "Failed to delete the 'hysteria' user.")
print("Stop/Disabling Hysteria Services...") print("\nStep 8: Removing cron jobs...")
for service in SERVICES + ["hysteria-server@*.service"]:
print(f"Stopping and disabling {service}...")
run_command(["systemctl", "stop", service], f"Error stopping {service}.")
run_command(["systemctl", "disable", service], f"Error disabling {service}.")
print("Removing systemd service files...")
for service in SERVICES + ["hysteria-server@*.service"]:
print(f"Removing service file: {service}")
run_command(["rm", "-f", f"/etc/systemd/system/{service}", f"/etc/systemd/system/multi-user.target.wants/{service}"], f"Error removing service files for {service}.")
print("Reloading systemd daemon...")
run_command(["systemctl", "daemon-reload"], "Error reloading systemd daemon.")
print("Removing cron jobs...")
try: try:
crontab_list = subprocess.run(["crontab", "-l"], capture_output=True, text=True, check=False) crontab_list_proc = subprocess.run(["crontab", "-l"], capture_output=True, text=True, check=False)
if "hysteria" in crontab_list.stdout: if "hysteria" in crontab_list_proc.stdout:
new_crontab = "\n".join(line for line in crontab_list.stdout.splitlines() if "hysteria" not in line) new_crontab = "\n".join(line for line in crontab_list_proc.stdout.splitlines() if "hysteria" not in line)
process = subprocess.run(["crontab", "-"], input=new_crontab.encode(), check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(["crontab", "-"], input=new_crontab.encode(), check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError: print("Hysteria cron jobs removed.")
print("Warning: crontab command not found.") except (FileNotFoundError, subprocess.CalledProcessError):
except subprocess.CalledProcessError: print("Warning: Could not access or modify crontab.")
print("Warning: Could not access crontab.")
print("Removing alias 'hys2' from .bashrc...") print("\nStep 9: Removing 'hys2' alias from .bashrc...")
bashrc_path = os.path.expanduser("~/.bashrc") bashrc_path = os.path.expanduser("~/.bashrc")
if os.path.exists(bashrc_path): if os.path.exists(bashrc_path):
try: try:
with open(bashrc_path, 'r') as f: with open(bashrc_path, 'r') as f_in:
lines = f.readlines() lines = [line for line in f_in if 'alias hys2=' not in line]
with open(bashrc_path, 'w') as f: with open(bashrc_path, 'w') as f_out:
for line in lines: f_out.writelines(lines)
if 'alias hys2=' not in line: print("Alias 'hys2' removed from .bashrc.")
f.write(line)
except IOError: except IOError:
print(f"Warning: Could not access or modify {bashrc_path}.") print(f"Warning: Could not access or modify {bashrc_path}.")
else:
print(f"Warning: {bashrc_path} not found.")
print("Hysteria2 uninstalled!") print("\nUninstallation of Hysteria2 panel is complete.")
print("Rebooting server...") print("It is recommended to reboot the server to ensure all changes take effect.")
run_command(["reboot"], "Error initiating reboot.")
if __name__ == "__main__": if __name__ == "__main__":
if os.geteuid() != 0:
print("Error: This script must be run as root.")
sys.exit(1)
uninstall_hysteria() uninstall_hysteria()

View File

@ -1,13 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import init_paths
import os import os
import sys import sys
import json import json
import argparse import argparse
from functools import lru_cache from functools import lru_cache
from typing import Dict, List, Any from typing import Dict, List, Any
from db.database import db
from init_paths import *
from paths import * from paths import *
@lru_cache(maxsize=None) @lru_cache(maxsize=None)
@ -42,10 +42,12 @@ def generate_uri(username: str, auth_password: str, ip: str, port: str,
def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
config = load_json_file(CONFIG_FILE) config = load_json_file(CONFIG_FILE)
all_users = load_json_file(USERS_FILE) if not config:
print("Error: Could not load Hysteria2 configuration file.", file=sys.stderr)
sys.exit(1)
if not config or not all_users: if db is None:
print("Error: Could not load Hysteria2 configuration or user files.", file=sys.stderr) print("Error: Database connection failed.", file=sys.stderr)
sys.exit(1) sys.exit(1)
nodes = load_json_file(NODES_JSON_PATH) or [] nodes = load_json_file(NODES_JSON_PATH) or []
@ -73,7 +75,7 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
results = [] results = []
for username in target_usernames: for username in target_usernames:
user_data = all_users.get(username) user_data = db.get_user(username)
if not user_data or "password" not in user_data: if not user_data or "password" not in user_data:
results.append({"username": username, "error": "User not found or password not set"}) results.append({"username": username, "error": "User not found or password not set"})
continue continue
@ -104,17 +106,20 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
def main(): def main():
parser = argparse.ArgumentParser(description="Efficiently generate Hysteria2 URIs for multiple users.") parser = argparse.ArgumentParser(description="Efficiently generate Hysteria2 URIs for multiple users.")
parser.add_argument('usernames', nargs='*', help="A list of usernames to process.") parser.add_argument('usernames', nargs='*', help="A list of usernames to process.")
parser.add_argument('--all', action='store_true', help="Process all users from users.json.") parser.add_argument('--all', action='store_true', help="Process all users from the database.")
args = parser.parse_args() args = parser.parse_args()
target_usernames = args.usernames target_usernames = args.usernames
if args.all: if args.all:
all_users = load_json_file(USERS_FILE) if db is None:
if all_users: print("Error: Database connection failed.", file=sys.stderr)
target_usernames = list(all_users.keys()) sys.exit(1)
else: try:
print("Error: Could not load users.json to process all users.", file=sys.stderr) all_users_docs = db.get_all_users()
target_usernames = [user['_id'] for user in all_users_docs]
except Exception as e:
print(f"Error retrieving all users from database: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
if not target_usernames: if not target_usernames:

View File

@ -5,7 +5,8 @@ import re
import time import time
import shlex import shlex
import base64 import base64
from typing import Dict, List, Optional, Tuple, Any, Union import sys
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, field from dataclasses import dataclass, field
from io import BytesIO from io import BytesIO
@ -16,6 +17,9 @@ from dotenv import load_dotenv
import qrcode import qrcode
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from db.database import db
load_dotenv() load_dotenv()
@ -28,7 +32,6 @@ class AppConfig:
sni_file: str sni_file: str
singbox_template_path: str singbox_template_path: str
hysteria_cli_path: str hysteria_cli_path: str
users_json_path: str
nodes_json_path: str nodes_json_path: str
extra_config_path: str extra_config_path: str
rate_limit: int rate_limit: int
@ -172,9 +175,8 @@ class Utils:
class HysteriaCLI: class HysteriaCLI:
def __init__(self, cli_path: str, users_json_path: str): def __init__(self, cli_path: str):
self.cli_path = cli_path self.cli_path = cli_path
self.users_json_path = users_json_path
def _run_command(self, args: List[str]) -> str: def _run_command(self, args: List[str]) -> str:
try: try:
@ -192,61 +194,29 @@ class HysteriaCLI:
print(f"Hysteria CLI error: {e}") print(f"Hysteria CLI error: {e}")
raise raise
def get_user_details_from_json(self, username: str) -> Optional[Dict[str, Any]]:
try:
with open(self.users_json_path, 'r') as f:
users_data = json.load(f)
return users_data.get(username)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error reading user details from {self.users_json_path}: {e}")
return None
except Exception as e:
print(f"An unexpected error occurred while reading users file: {e}")
return None
def get_username_by_password(self, password_token: str) -> Optional[str]: def get_username_by_password(self, password_token: str) -> Optional[str]:
try: if not db:
with open(self.users_json_path, 'r') as f:
users_data = json.load(f)
for username, details in users_data.items():
if details.get('password') == password_token:
return username
return None
except FileNotFoundError:
print(f"Error: Users file not found at {self.users_json_path}")
return None
except json.JSONDecodeError:
print(f"Error: Could not decode JSON from {self.users_json_path}")
return None
except Exception as e:
print(f"An unexpected error occurred while reading users file: {e}")
return None return None
user_doc = db.collection.find_one({"password": password_token}, {"_id": 1})
return user_doc['_id'] if user_doc else None
def get_user_info(self, username: str) -> Optional[UserInfo]: def get_user_info(self, username: str) -> Optional[UserInfo]:
raw_info_str = self._run_command(['get-user', '-u', username]) if not db:
if raw_info_str is None: return None
user_doc = db.get_user(username)
if not user_doc:
return None return None
user_details = self.get_user_details_from_json(username) return UserInfo(
if not user_details or 'password' not in user_details: username=user_doc.get('_id'),
print(f"Warning: Password for user '{username}' could not be fetched from {self.users_json_path}. Cannot create UserInfo.") password=user_doc.get('password'),
return None upload_bytes=user_doc.get('upload_bytes', 0),
download_bytes=user_doc.get('download_bytes', 0),
try: max_download_bytes=user_doc.get('max_download_bytes', 0),
raw_info = json.loads(raw_info_str) account_creation_date=user_doc.get('account_creation_date', ''),
return UserInfo( expiration_days=user_doc.get('expiration_days', 0),
username=username, blocked=user_doc.get('blocked', False)
password=user_details['password'], )
upload_bytes=raw_info.get('upload_bytes', 0),
download_bytes=raw_info.get('download_bytes', 0),
max_download_bytes=raw_info.get('max_download_bytes', 0),
account_creation_date=raw_info.get('account_creation_date', ''),
expiration_days=raw_info.get('expiration_days', 0),
blocked=user_details.get('blocked', False)
)
except json.JSONDecodeError as e:
print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}")
return None
def get_all_uris(self, username: str) -> List[str]: def get_all_uris(self, username: str) -> List[str]:
output = self._run_command(['show-user-uri', '-u', username, '-a']) output = self._run_command(['show-user-uri', '-u', username, '-a'])
@ -446,7 +416,7 @@ class HysteriaServer:
def __init__(self): def __init__(self):
self.config = self._load_config() self.config = self._load_config()
self.rate_limiter = RateLimiter(self.config.rate_limit, self.config.rate_limit_window) self.rate_limiter = RateLimiter(self.config.rate_limit, self.config.rate_limit_window)
self.hysteria_cli = HysteriaCLI(self.config.hysteria_cli_path, self.config.users_json_path) self.hysteria_cli = HysteriaCLI(self.config.hysteria_cli_path)
self.singbox_generator = SingboxConfigGenerator(self.hysteria_cli, self.config.sni) self.singbox_generator = SingboxConfigGenerator(self.hysteria_cli, self.config.sni)
self.singbox_generator.set_template_path(self.config.singbox_template_path) self.singbox_generator.set_template_path(self.config.singbox_template_path)
self.subscription_manager = SubscriptionManager(self.hysteria_cli, self.config) self.subscription_manager = SubscriptionManager(self.hysteria_cli, self.config)
@ -478,7 +448,6 @@ class HysteriaServer:
sni_file = '/etc/hysteria/.configs.env' sni_file = '/etc/hysteria/.configs.env'
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json' singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
hysteria_cli_path = '/etc/hysteria/core/cli.py' hysteria_cli_path = '/etc/hysteria/core/cli.py'
users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
nodes_json_path = '/etc/hysteria/nodes.json' nodes_json_path = '/etc/hysteria/nodes.json'
extra_config_path = '/etc/hysteria/extra.json' extra_config_path = '/etc/hysteria/extra.json'
rate_limit = 100 rate_limit = 100
@ -492,7 +461,6 @@ class HysteriaServer:
sni_file=sni_file, sni_file=sni_file,
singbox_template_path=singbox_template_path, singbox_template_path=singbox_template_path,
hysteria_cli_path=hysteria_cli_path, hysteria_cli_path=hysteria_cli_path,
users_json_path=users_json_path,
nodes_json_path=nodes_json_path, nodes_json_path=nodes_json_path,
extra_config_path=extra_config_path, extra_config_path=extra_config_path,
rate_limit=rate_limit, rate_limit_window=rate_limit_window, rate_limit=rate_limit, rate_limit_window=rate_limit_window,

View File

@ -48,7 +48,7 @@ def process_add_user_step1(message):
try: try:
users_data = json.loads(result) users_data = json.loads(result)
existing_users = {user_key.lower() for user_key in users_data.keys()} existing_users = {user['username'].lower() for user in users_data}
if username.lower() in existing_users: if username.lower() in existing_users:
bot.reply_to(message, f"Username '{escape_markdown(username)}' already exists. Please choose a different username:", reply_markup=create_cancel_markup()) bot.reply_to(message, f"Username '{escape_markdown(username)}' already exists. Please choose a different username:", reply_markup=create_cancel_markup())
bot.register_next_step_handler(message, process_add_user_step1) bot.register_next_step_handler(message, process_add_user_step1)
@ -105,7 +105,7 @@ def process_add_user_step3(message, username, traffic_limit):
bot.send_chat_action(message.chat.id, 'typing') bot.send_chat_action(message.chat.id, 'typing')
uri_info_command = f"python3 {CLI_PATH} show-user-uri -u \"{username}\" -ip 4 -n" uri_info_command = f"python3 {CLI_PATH} show-user-uri -u \"{username}\" -ip 4 -n -s"
uri_info_output = run_cli_command(uri_info_command) uri_info_output = run_cli_command(uri_info_command)
direct_uri = None direct_uri = None

View File

@ -24,25 +24,25 @@ def show_user(message):
bot.register_next_step_handler(msg, process_show_user) bot.register_next_step_handler(msg, process_show_user)
def process_show_user(message): def process_show_user(message):
username = message.text.strip().lower() username_input = message.text.strip().lower()
bot.send_chat_action(message.chat.id, 'typing') bot.send_chat_action(message.chat.id, 'typing')
command = f"python3 {CLI_PATH} list-users" command = f"python3 {CLI_PATH} list-users"
result = run_cli_command(command) result = run_cli_command(command)
try: try:
users = json.loads(result) users_list = json.loads(result)
existing_users = {user.lower(): user for user in users.keys()} existing_users = {user['username'].lower(): user['username'] for user in users_list}
if username not in existing_users: if username_input not in existing_users:
bot.reply_to(message, f"Username '{escape_markdown(message.text.strip())}' does not exist. Please enter a valid username.") bot.reply_to(message, f"Username '{escape_markdown(message.text.strip())}' does not exist. Please enter a valid username.")
return return
actual_username = existing_users[username] actual_username = existing_users[username_input]
except json.JSONDecodeError: except (json.JSONDecodeError, KeyError):
bot.reply_to(message, "Error retrieving user list. Please try again later.") bot.reply_to(message, "Error retrieving or parsing user list. Please try again later.")
return return
command = f"python3 {CLI_PATH} get-user -u {actual_username}" command = f"python3 {CLI_PATH} get-user -u \"{actual_username}\""
user_result = run_cli_command(command) user_result = run_cli_command(command)
try: try:
@ -53,7 +53,7 @@ def process_show_user(message):
status = user_details.get('status', 'Unknown') status = user_details.get('status', 'Unknown')
if upload_bytes is None or download_bytes is None: if upload_bytes is None or download_bytes is None:
traffic_message = "**Traffic Data:**\nUser not active or no traffic data available." traffic_message = "*Traffic Data:*\nUser not active or no traffic data available."
else: else:
upload_gb = upload_bytes / (1024 ** 3) upload_gb = upload_bytes / (1024 ** 3)
download_gb = download_bytes / (1024 ** 3) download_gb = download_bytes / (1024 ** 3)
@ -66,47 +66,44 @@ def process_show_user(message):
f"🌐 Status: {status}" f"🌐 Status: {status}"
) )
except json.JSONDecodeError: except json.JSONDecodeError:
bot.reply_to(message, "Failed to parse JSON data. The command output may be malformed.") bot.reply_to(message, "Failed to parse user details. The command output may be malformed.")
return return
display_username = escape_markdown(actual_username) display_username = escape_markdown(actual_username)
formatted_details = ( formatted_details = (
f"\n🆔 Name: {display_username}\n" f"\n🆔 Name: {display_username}\n"
f"📊 Traffic Limit: {user_details['max_download_bytes'] / (1024 ** 3):.2f} GB\n" f"📊 Traffic Limit: {user_details.get('max_download_bytes', 0) / (1024 ** 3):.2f} GB\n"
f"📅 Days: {user_details['expiration_days']}\n" f"📅 Days: {user_details.get('expiration_days', 'N/A')}\n"
f"⏳ Creation: {user_details['account_creation_date']}\n" f"⏳ Creation: {user_details.get('account_creation_date', 'N/A')}\n"
f"💡 Blocked: {user_details['blocked']}\n\n" f"💡 Blocked: {user_details.get('blocked', 'N/A')}\n\n"
f"{traffic_message}" f"{traffic_message}"
) )
combined_command = f"python3 {CLI_PATH} show-user-uri -u {actual_username} -ip 4 -s -n" combined_command = f"python3 {CLI_PATH} show-user-uri -u \"{actual_username}\" -ip 4 -s -n"
combined_result = run_cli_command(combined_command) combined_result = run_cli_command(combined_command)
if "Error" in combined_result or "Invalid" in combined_result:
bot.reply_to(message, combined_result)
return
result_lines = combined_result.strip().split('\n')
uri_v4 = "" uri_v4 = ""
normal_sub_sublink = "" normal_sub_link = ""
for line in result_lines: lines = combined_result.strip().split('\n')
line = line.strip() for i, line in enumerate(lines):
if line.startswith("hy2://"): if line.strip() == "IPv4:":
uri_v4 = line if i + 1 < len(lines) and lines[i+1].strip().startswith("hy2://"):
elif line.startswith("Normal-SUB Sublink:"): uri_v4 = lines[i+1].strip()
normal_sub_sublink = result_lines[result_lines.index(line) + 1].strip() elif line.strip() == "Normal-SUB Sublink:":
if i + 1 < len(lines) and (lines[i+1].strip().startswith("http://") or lines[i+1].strip().startswith("https://")):
normal_sub_link = lines[i+1].strip()
if not uri_v4: qr_link = normal_sub_link if normal_sub_link else uri_v4
bot.reply_to(message, "No valid URI found.") if not qr_link:
bot.reply_to(message, "No valid URI or Subscription link found for this user.")
return return
qr_v4 = qrcode.make(uri_v4) qr_img = qrcode.make(qr_link)
bio_v4 = io.BytesIO() bio = io.BytesIO()
qr_v4.save(bio_v4, 'PNG') qr_img.save(bio, 'PNG')
bio_v4.seek(0) bio.seek(0)
markup = types.InlineKeyboardMarkup(row_width=3) markup = types.InlineKeyboardMarkup(row_width=3)
markup.add(types.InlineKeyboardButton("Reset User", callback_data=f"reset_user:{actual_username}"), markup.add(types.InlineKeyboardButton("Reset User", callback_data=f"reset_user:{actual_username}"),
@ -118,13 +115,15 @@ def process_show_user(message):
markup.add(types.InlineKeyboardButton("Renew Creation Date", callback_data=f"renew_creation:{actual_username}"), markup.add(types.InlineKeyboardButton("Renew Creation Date", callback_data=f"renew_creation:{actual_username}"),
types.InlineKeyboardButton("Block User", callback_data=f"block_user:{actual_username}")) types.InlineKeyboardButton("Block User", callback_data=f"block_user:{actual_username}"))
caption = f"{formatted_details}\n\n**IPv4 URI:**\n\n`{uri_v4}`" caption = formatted_details
if normal_sub_sublink: if uri_v4:
caption += f"\n\n**Normal SUB:**\n{normal_sub_sublink}" caption += f"\n\n*IPv4 URI:*\n`{uri_v4}`"
if normal_sub_link:
caption += f"\n\n*Normal SUB:*\n`{normal_sub_link}`"
bot.send_photo( bot.send_photo(
message.chat.id, message.chat.id,
bio_v4, bio,
caption=caption, caption=caption,
reply_markup=markup, reply_markup=markup,
parse_mode="Markdown" parse_mode="Markdown"
@ -145,11 +144,11 @@ def handle_edit_callback(call):
msg = bot.send_message(call.message.chat.id, f"Enter new expiration days for {display_username}:") msg = bot.send_message(call.message.chat.id, f"Enter new expiration days for {display_username}:")
bot.register_next_step_handler(msg, process_edit_expiration, username) bot.register_next_step_handler(msg, process_edit_expiration, username)
elif action == 'renew_password': elif action == 'renew_password':
command = f"python3 {CLI_PATH} edit-user -u {username} -rp" command = f"python3 {CLI_PATH} edit-user -u \"{username}\" -rp"
result = run_cli_command(command) result = run_cli_command(command)
bot.send_message(call.message.chat.id, result) bot.send_message(call.message.chat.id, result)
elif action == 'renew_creation': elif action == 'renew_creation':
command = f"python3 {CLI_PATH} edit-user -u {username} -rc" command = f"python3 {CLI_PATH} edit-user -u \"{username}\" -rc"
result = run_cli_command(command) result = run_cli_command(command)
bot.send_message(call.message.chat.id, result) bot.send_message(call.message.chat.id, result)
elif action == 'block_user': elif action == 'block_user':
@ -158,11 +157,11 @@ def handle_edit_callback(call):
types.InlineKeyboardButton("False", callback_data=f"confirm_block:{username}:false")) types.InlineKeyboardButton("False", callback_data=f"confirm_block:{username}:false"))
bot.send_message(call.message.chat.id, f"Set block status for {display_username}:", reply_markup=markup) bot.send_message(call.message.chat.id, f"Set block status for {display_username}:", reply_markup=markup)
elif action == 'reset_user': elif action == 'reset_user':
command = f"python3 {CLI_PATH} reset-user -u {username}" command = f"python3 {CLI_PATH} reset-user -u \"{username}\""
result = run_cli_command(command) result = run_cli_command(command)
bot.send_message(call.message.chat.id, result) bot.send_message(call.message.chat.id, result)
elif action == 'ipv6_uri': elif action == 'ipv6_uri':
command = f"python3 {CLI_PATH} show-user-uri -u {username} -ip 6" command = f"python3 {CLI_PATH} show-user-uri -u \"{username}\" -ip 6"
result = run_cli_command(command) result = run_cli_command(command)
if "Error" in result or "Invalid" in result: if "Error" in result or "Invalid" in result:
bot.send_message(call.message.chat.id, result) bot.send_message(call.message.chat.id, result)
@ -184,20 +183,21 @@ def handle_edit_callback(call):
@bot.callback_query_handler(func=lambda call: call.data.startswith('confirm_block:')) @bot.callback_query_handler(func=lambda call: call.data.startswith('confirm_block:'))
def handle_block_confirmation(call): def handle_block_confirmation(call):
_, username, block_status = call.data.split(':') _, username, block_status = call.data.split(':')
command = f"python3 {CLI_PATH} edit-user -u {username} {'-b' if block_status == 'true' else ''}" flag = '-b' if block_status == 'true' else '--unblocked'
command = f"python3 {CLI_PATH} edit-user -u \"{username}\" {flag}"
result = run_cli_command(command) result = run_cli_command(command)
bot.send_message(call.message.chat.id, result) bot.send_message(call.message.chat.id, result)
def process_edit_username(message, username): def process_edit_username(message, username):
new_username = message.text.strip() new_username = message.text.strip()
command = f"python3 {CLI_PATH} edit-user -u {username} -nu {new_username}" command = f"python3 {CLI_PATH} edit-user -u \"{username}\" -nu \"{new_username}\""
result = run_cli_command(command) result = run_cli_command(command)
bot.reply_to(message, result) bot.reply_to(message, result)
def process_edit_traffic(message, username): def process_edit_traffic(message, username):
try: try:
new_traffic_limit = int(message.text.strip()) new_traffic_limit = int(message.text.strip())
command = f"python3 {CLI_PATH} edit-user -u {username} -nt {new_traffic_limit}" command = f"python3 {CLI_PATH} edit-user -u \"{username}\" -nt {new_traffic_limit}"
result = run_cli_command(command) result = run_cli_command(command)
bot.reply_to(message, result) bot.reply_to(message, result)
except ValueError: except ValueError:
@ -206,7 +206,7 @@ def process_edit_traffic(message, username):
def process_edit_expiration(message, username): def process_edit_expiration(message, username):
try: try:
new_expiration_days = int(message.text.strip()) new_expiration_days = int(message.text.strip())
command = f"python3 {CLI_PATH} edit-user -u {username} -ne {new_expiration_days}" command = f"python3 {CLI_PATH} edit-user -u \"{username}\" -ne {new_expiration_days}"
result = run_cli_command(command) result = run_cli_command(command)
bot.reply_to(message, result) bot.reply_to(message, result)
except ValueError: except ValueError:

View File

@ -15,38 +15,42 @@ def handle_inline_query(query):
results = [] results = []
if query_text == "block": if query_text == "block":
for username, details in users.items(): for user in users:
if details.get('blocked', False): if user.get('blocked', False):
username = user['username']
title = f"{username} (Blocked)" title = f"{username} (Blocked)"
description = f"Traffic Limit: {details['max_download_bytes'] / (1024 ** 3):.2f} GB, Expiration Days: {details['expiration_days']}" description = f"Traffic Limit: {user.get('max_download_bytes', 0) / (1024 ** 3):.2f} GB, Expiration Days: {user.get('expiration_days', 'N/A')}"
message_content = (
f"Name: {username}\n"
f"Traffic limit: {user.get('max_download_bytes', 0) / (1024 ** 3):.2f} GB\n"
f"Days: {user.get('expiration_days', 'N/A')}\n"
f"Account Creation: {user.get('account_creation_date', 'N/A')}\n"
f"Blocked: {user.get('blocked', False)}"
)
results.append(types.InlineQueryResultArticle( results.append(types.InlineQueryResultArticle(
id=username, id=username,
title=title, title=title,
description=description, description=description,
input_message_content=types.InputTextMessageContent( input_message_content=types.InputTextMessageContent(message_text=message_content)
message_text=f"Name: {username}\n"
f"Traffic limit: {details['max_download_bytes'] / (1024 ** 3):.2f} GB\n"
f"Days: {details['expiration_days']}\n"
f"Account Creation: {details['account_creation_date']}\n"
f"Blocked: {details['blocked']}"
)
)) ))
else: else:
for username, details in users.items(): for user in users:
username = user['username']
if query_text in username.lower(): if query_text in username.lower():
title = f"{username}" title = f"{username}"
description = f"Traffic Limit: {details['max_download_bytes'] / (1024 ** 3):.2f} GB, Expiration Days: {details['expiration_days']}" description = f"Traffic Limit: {user.get('max_download_bytes', 0) / (1024 ** 3):.2f} GB, Expiration Days: {user.get('expiration_days', 'N/A')}"
message_content = (
f"Name: {username}\n"
f"Traffic limit: {user.get('max_download_bytes', 0) / (1024 ** 3):.2f} GB\n"
f"Days: {user.get('expiration_days', 'N/A')}\n"
f"Account Creation: {user.get('account_creation_date', 'N/A')}\n"
f"Blocked: {user.get('blocked', False)}"
)
results.append(types.InlineQueryResultArticle( results.append(types.InlineQueryResultArticle(
id=username, id=username,
title=title, title=title,
description=description, description=description,
input_message_content=types.InputTextMessageContent( input_message_content=types.InputTextMessageContent(message_text=message_content)
message_text=f"Name: {username}\n"
f"Traffic limit: {details['max_download_bytes'] / (1024 ** 3):.2f} GB\n"
f"Days: {details['expiration_days']}\n"
f"Account Creation: {details['account_creation_date']}\n"
f"Blocked: {details['blocked']}"
)
)) ))
bot.answer_inline_query(query.id, results, cache_time=0) bot.answer_inline_query(query.id, results, cache_time=0)

View File

@ -7,6 +7,7 @@ import zipfile
import tempfile import tempfile
# from ..schema.config.hysteria import InstallInputBody # from ..schema.config.hysteria import InstallInputBody
import os import os
from pathlib import Path
import cli_api import cli_api
router = APIRouter() router = APIRouter()
@ -113,12 +114,9 @@ async def set_sni_api(sni: str):
@router.get('/backup', response_class=FileResponse, summary='Backup Hysteria2 configuration') @router.get('/backup', response_class=FileResponse, summary='Backup Hysteria2 configuration')
async def backup_api(): async def backup_api():
"""
Backups the Hysteria2 configuration and sends the backup ZIP file.
"""
try: try:
cli_api.backup_hysteria2() cli_api.backup_hysteria2()
backup_dir = "/opt/hysbackup/" # TODO: get this path from .env backup_dir = "/opt/hysbackup/"
if not os.path.isdir(backup_dir): if not os.path.isdir(backup_dir):
raise HTTPException(status_code=500, detail="Backup directory does not exist.") raise HTTPException(status_code=500, detail="Backup directory does not exist.")
@ -129,13 +127,8 @@ async def backup_api():
if latest_backup_file: if latest_backup_file:
backup_file_path = os.path.join(backup_dir, latest_backup_file) backup_file_path = os.path.join(backup_dir, latest_backup_file)
if not backup_file_path.startswith(backup_dir):
raise HTTPException(status_code=400, detail="Invalid backup file path.")
if not os.path.exists(backup_file_path): if not os.path.exists(backup_file_path):
raise HTTPException(status_code=404, detail="Backup file not found.") raise HTTPException(status_code=404, detail="Backup file not found.")
return FileResponse(path=backup_file_path, filename=os.path.basename(backup_file_path), media_type="application/zip") return FileResponse(path=backup_file_path, filename=os.path.basename(backup_file_path), media_type="application/zip")
else: else:
raise HTTPException(status_code=500, detail="No backup file found after backup process.") raise HTTPException(status_code=500, detail="No backup file found after backup process.")
@ -146,46 +139,43 @@ async def backup_api():
@router.post('/restore', response_model=DetailResponse, summary='Restore Hysteria2 Configuration') @router.post('/restore', response_model=DetailResponse, summary='Restore Hysteria2 Configuration')
async def restore_api(file: UploadFile = File(...)): async def restore_api(file: UploadFile = File(...)):
temp_path = None
try: try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as temp_file:
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
shutil.copyfileobj(file.file, temp_file) shutil.copyfileobj(file.file, temp_file)
temp_path = temp_file.name temp_path = temp_file.name
if not zipfile.is_zipfile(temp_path): if not zipfile.is_zipfile(temp_path):
os.unlink(temp_path)
raise HTTPException(status_code=400, detail="Invalid file type. Must be a ZIP file.") raise HTTPException(status_code=400, detail="Invalid file type. Must be a ZIP file.")
required_files = {"ca.key", "ca.crt", "users.json", "config.json", ".configs.env"}
with zipfile.ZipFile(temp_path, 'r') as zip_ref: with zipfile.ZipFile(temp_path, 'r') as zip_ref:
zip_contents = set(zip_ref.namelist()) namelist = zip_ref.namelist()
missing_files = required_files - zip_contents
required_flat_files = {"ca.key", "ca.crt", "config.json", ".configs.env"}
missing_files = required_flat_files - set(namelist)
if missing_files: if missing_files:
os.unlink(temp_path)
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Backup file is missing required files: {', '.join(missing_files)}" detail=f"Backup is missing required configuration files: {', '.join(missing_files)}"
) )
dst_dir_path = '/opt/hysbackup/' # TODO: get this path from .env db_dump_prefix = "blitz_panel/"
os.makedirs(dst_dir_path, exist_ok=True) if not any(name.startswith(db_dump_prefix) for name in namelist):
raise HTTPException(
status_code=400,
detail=f"Backup file is not a modern backup and is missing the database dump directory: '{db_dump_prefix}'"
)
dst_path = os.path.join(dst_dir_path, file.filename) # type: ignore cli_api.restore_hysteria2(temp_path)
shutil.move(temp_path, dst_path)
cli_api.restore_hysteria2(dst_path)
return DetailResponse(detail='Hysteria2 restored successfully.') return DetailResponse(detail='Hysteria2 restored successfully.')
except HTTPException as e: except HTTPException as e:
raise e raise e
except Exception as e: except Exception as e:
if 'temp_path' in locals():
try:
os.unlink(temp_path)
except:
pass
raise HTTPException(status_code=500, detail=f'Error: {str(e)}') raise HTTPException(status_code=500, detail=f'Error: {str(e)}')
finally:
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
@router.get('/enable-obfs', response_model=DetailResponse, summary='Enable Hysteria2 obfs') @router.get('/enable-obfs', response_model=DetailResponse, summary='Enable Hysteria2 obfs')
async def enable_obfs(): async def enable_obfs():

View File

@ -1,23 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import os import os
import sys import sys
import time
import fcntl import fcntl
import shutil
import datetime import datetime
from concurrent.futures import ThreadPoolExecutor
from hysteria2_api import Hysteria2Client from hysteria2_api import Hysteria2Client
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(SCRIPT_DIR, 'scripts'))
from db.database import db
CONFIG_FILE = '/etc/hysteria/config.json' CONFIG_FILE = '/etc/hysteria/config.json'
USERS_FILE = '/etc/hysteria/users.json'
API_BASE_URL = 'http://127.0.0.1:25413' API_BASE_URL = 'http://127.0.0.1:25413'
LOCKFILE = "/tmp/kick.lock" LOCKFILE = "/tmp/hysteria_traffic.lock"
BACKUP_FILE = f"{USERS_FILE}.bak"
MAX_WORKERS = 8
def acquire_lock(): def acquire_lock():
"""Acquires a lock file to prevent concurrent execution"""
try: try:
lock_file = open(LOCKFILE, 'w') lock_file = open(LOCKFILE, 'w')
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
@ -25,92 +24,22 @@ def acquire_lock():
except IOError: except IOError:
sys.exit(1) sys.exit(1)
def traffic_status(no_gui=False): def get_secret():
"""Updates and retrieves traffic statistics for all users."""
green = '\033[0;32m'
cyan = '\033[0;36m'
NC = '\033[0m'
try: try:
with open(CONFIG_FILE, 'r') as config_file: with open(CONFIG_FILE, 'r') as f:
config = json.load(config_file) config = json.load(f)
secret = config.get('trafficStats', {}).get('secret') return config.get('trafficStats', {}).get('secret')
except (json.JSONDecodeError, FileNotFoundError) as e: except (json.JSONDecodeError, FileNotFoundError):
if not no_gui:
print(f"Error: Failed to read secret from {CONFIG_FILE}. Details: {e}")
return None return None
if not secret: def format_bytes(bytes_val):
if not no_gui: if bytes_val < 1024: return f"{bytes_val}B"
print("Error: Secret not found in config.json") elif bytes_val < 1048576: return f"{bytes_val / 1024:.2f}KB"
return None elif bytes_val < 1073741824: return f"{bytes_val / 1048576:.2f}MB"
elif bytes_val < 1099511627776: return f"{bytes_val / 1073741824:.2f}GB"
client = Hysteria2Client(base_url=API_BASE_URL, secret=secret) else: return f"{bytes_val / 1099511627776:.2f}TB"
try:
traffic_stats = client.get_traffic_stats(clear=True)
online_status = client.get_online_clients()
except Exception as e:
if not no_gui:
print(f"Error communicating with Hysteria2 API: {e}")
return None
users_data = {}
if os.path.exists(USERS_FILE):
try:
with open(USERS_FILE, 'r') as users_file:
users_data = json.load(users_file)
except json.JSONDecodeError:
if not no_gui:
print("Error: Failed to parse existing users data JSON file.")
return None
for user in users_data:
users_data[user]["status"] = "Offline"
for user_id, status in online_status.items():
if user_id in users_data:
users_data[user_id]["status"] = "Online" if status.is_online else "Offline"
else:
users_data[user_id] = {
"upload_bytes": 0, "download_bytes": 0,
"status": "Online" if status.is_online else "Offline"
}
for user_id, stats in traffic_stats.items():
if user_id in users_data:
users_data[user_id]["upload_bytes"] = users_data[user_id].get("upload_bytes", 0) + stats.upload_bytes
users_data[user_id]["download_bytes"] = users_data[user_id].get("download_bytes", 0) + stats.download_bytes
else:
online = user_id in online_status and online_status[user_id].is_online
users_data[user_id] = {
"upload_bytes": stats.upload_bytes, "download_bytes": stats.download_bytes,
"status": "Online" if online else "Offline"
}
today_date = datetime.datetime.now().strftime("%Y-%m-%d")
for username, user_data in users_data.items():
is_on_hold = not user_data.get("account_creation_date")
if is_on_hold:
is_online = user_data.get("status") == "Online"
has_traffic = user_data.get("download_bytes", 0) > 0 or user_data.get("upload_bytes", 0) > 0
if is_online or has_traffic:
user_data["account_creation_date"] = today_date
else:
user_data["status"] = "On-hold"
with open(USERS_FILE, 'w') as users_file:
json.dump(users_data, users_file, indent=4)
if not no_gui:
display_traffic_data(users_data, green, cyan, NC)
return users_data
def display_traffic_data(data, green, cyan, NC): def display_traffic_data(data, green, cyan, NC):
"""Displays traffic data in a formatted table"""
if not data: if not data:
print("No traffic data to display.") print("No traffic data to display.")
return return
@ -123,113 +52,151 @@ def display_traffic_data(data, green, cyan, NC):
for user, entry in data.items(): for user, entry in data.items():
upload_bytes = entry.get("upload_bytes", 0) upload_bytes = entry.get("upload_bytes", 0)
download_bytes = entry.get("download_bytes", 0) download_bytes = entry.get("download_bytes", 0)
status = entry.get("status", "Offline") status = entry.get("status", "On-hold")
formatted_tx = format_bytes(upload_bytes) formatted_tx = format_bytes(upload_bytes)
formatted_rx = format_bytes(download_bytes) formatted_rx = format_bytes(download_bytes)
print(f"{user:<15} {green}{formatted_tx:<15}{NC} {cyan}{formatted_rx:<15}{NC} {status:<10}") print(f"{user:<15} {green}{formatted_tx:<15}{NC} {cyan}{formatted_rx:<15}{NC} {status:<10}")
print("-------------------------------------------------") print("-------------------------------------------------")
def format_bytes(bytes_val): def traffic_status(no_gui=False):
"""Format bytes as human-readable string""" green, cyan, NC = '\033[0;32m', '\033[0;36m', '\033[0m'
if bytes_val < 1024: return f"{bytes_val}B"
elif bytes_val < 1048576: return f"{bytes_val / 1024:.2f}KB"
elif bytes_val < 1073741824: return f"{bytes_val / 1048576:.2f}MB"
elif bytes_val < 1099511627776: return f"{bytes_val / 1073741824:.2f}GB"
else: return f"{bytes_val / 1099511627776:.2f}TB"
def kick_users(usernames, secret): if db is None:
"""Kicks specified users from the server""" if not no_gui: print("Error: Database connection failed.")
return None
secret = get_secret()
if not secret:
if not no_gui: print(f"Error: Secret not found or failed to read {CONFIG_FILE}.")
return None
client = Hysteria2Client(base_url=API_BASE_URL, secret=secret)
try:
traffic_stats = client.get_traffic_stats(clear=True)
online_status = client.get_online_clients()
except Exception as e:
if not no_gui: print(f"Error communicating with Hysteria2 API: {e}")
return None
try:
all_users = db.get_all_users()
initial_users_data = {user['_id']: user for user in all_users}
except Exception as e:
if not no_gui: print(f"Error fetching users from database: {e}")
return None
today_date = datetime.datetime.now().strftime("%Y-%m-%d")
users_to_update = {}
for username, user_data in initial_users_data.items():
updates = {}
is_online = username in online_status and online_status[username].is_online
if username in traffic_stats:
new_upload = user_data.get('upload_bytes', 0) + traffic_stats[username].upload_bytes
new_download = user_data.get('download_bytes', 0) + traffic_stats[username].download_bytes
if new_upload != user_data.get('upload_bytes'): updates['upload_bytes'] = new_upload
if new_download != user_data.get('download_bytes'): updates['download_bytes'] = new_download
is_activated = "account_creation_date" in user_data
if not is_activated:
current_traffic = traffic_stats.get(username)
has_activity = is_online or (current_traffic and (current_traffic.upload_bytes > 0 or current_traffic.download_bytes > 0))
if has_activity:
updates["account_creation_date"] = today_date
updates["status"] = "Online" if is_online else "Offline"
else:
if user_data.get("status") != "On-hold":
updates["status"] = "On-hold"
else:
new_status = "Online" if is_online else "Offline"
if user_data.get("status") != new_status:
updates["status"] = new_status
if updates:
users_to_update[username] = updates
if users_to_update:
try:
for username, update_data in users_to_update.items():
db.update_user(username, update_data)
except Exception as e:
if not no_gui: print(f"Error updating database: {e}")
return None
if not no_gui:
# For display, merge updates into the initial data
for username, updates in users_to_update.items():
initial_users_data[username].update(updates)
display_traffic_data(initial_users_data, green, cyan, NC)
return initial_users_data
def kick_api_call(usernames, secret):
try: try:
client = Hysteria2Client(base_url=API_BASE_URL, secret=secret) client = Hysteria2Client(base_url=API_BASE_URL, secret=secret)
client.kick_clients(usernames) client.kick_clients(usernames)
return True except Exception as e:
except Exception: print(f"Failed to kick users via API: {e}", file=sys.stderr)
return False
def process_user(username, user_data, users_data):
"""Process a single user to check if they should be kicked"""
if user_data.get('blocked', False): return None
account_creation_date = user_data.get('account_creation_date')
if not account_creation_date: return None
max_download_bytes = user_data.get('max_download_bytes', 0)
expiration_days = user_data.get('expiration_days', 0)
total_bytes = user_data.get('download_bytes', 0) + user_data.get('upload_bytes', 0)
should_block = False
try:
if expiration_days > 0:
creation_date = datetime.datetime.strptime(account_creation_date, "%Y-%m-%d")
expiration_date = creation_date + datetime.timedelta(days=expiration_days)
if datetime.datetime.now() >= expiration_date:
should_block = True
if not should_block and max_download_bytes > 0 and total_bytes >= max_download_bytes:
should_block = True
if should_block:
users_data[username]['blocked'] = True
return username
except (ValueError, TypeError):
return None
return None
def kick_expired_users(): def kick_expired_users():
"""Kicks users who have exceeded their data limits or whose accounts have expired""" if db is None:
lock_file = acquire_lock() print("Error: Database connection failed.", file=sys.stderr)
return
try: secret = get_secret()
if not os.path.exists(USERS_FILE): return if not secret:
shutil.copy2(USERS_FILE, BACKUP_FILE) print(f"Error: Secret not found or failed to read {CONFIG_FILE}.", file=sys.stderr)
return
all_users = db.get_all_users()
users_to_kick, users_to_block = [], []
for user in all_users:
username = user.get('_id')
if not username or user.get('blocked', False) or not user.get('account_creation_date'):
continue
total_bytes = user.get('download_bytes', 0) + user.get('upload_bytes', 0)
should_block = False
try: try:
with open(CONFIG_FILE, 'r') as f: if user.get('expiration_days', 0) > 0:
config = json.load(f) creation_date = datetime.datetime.strptime(user['account_creation_date'], "%Y-%m-%d")
secret = config.get('trafficStats', {}).get('secret', '') if datetime.datetime.now() >= creation_date + datetime.timedelta(days=user['expiration_days']):
if not secret: sys.exit(1) should_block = True
except Exception:
sys.exit(1)
with open(USERS_FILE, 'r') as f: if not should_block and user.get('max_download_bytes', 0) > 0 and total_bytes >= user['max_download_bytes']:
users_data = json.load(f) should_block = True
users_to_kick = [] if should_block:
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: users_to_kick.append(username)
futures = [executor.submit(process_user, u, d, users_data) for u, d in users_data.items()] users_to_block.append(username)
for future in futures: except (ValueError, TypeError):
result = future.result() continue
if result:
users_to_kick.append(result)
if users_to_kick: if users_to_block:
with open(USERS_FILE, 'w') as f: for username in users_to_block:
json.dump(users_data, f, indent=4) db.update_user(username, {'blocked': True})
for i in range(0, len(users_to_kick), 50): if users_to_kick:
batch = users_to_kick[i:i+50] for i in range(0, len(users_to_kick), 50):
kick_users(batch, secret) kick_api_call(users_to_kick[i:i+50], secret)
except Exception: if __name__ == "__main__":
if os.path.exists(BACKUP_FILE): lock_file = acquire_lock()
shutil.copy2(BACKUP_FILE, USERS_FILE) try:
sys.exit(1) if len(sys.argv) > 1:
if sys.argv[1] == "kick":
kick_expired_users()
elif sys.argv[1] == "--no-gui":
traffic_status(no_gui=True)
kick_expired_users()
else:
print(f"Usage: python {sys.argv[0]} [kick|--no-gui]")
else:
traffic_status(no_gui=False)
finally: finally:
fcntl.flock(lock_file, fcntl.LOCK_UN) fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close() lock_file.close()
if __name__ == "__main__":
if len(sys.argv) > 1:
if sys.argv[1] == "kick":
kick_expired_users()
elif sys.argv[1] == "--no-gui":
traffic_status(no_gui=True)
kick_expired_users()
else:
print(f"Unknown argument: {sys.argv[1]}")
print("Usage: python traffic.py [kick|--no-gui]")
else:
traffic_status(no_gui=False)

View File

@ -75,14 +75,57 @@ check_os_version() {
fi fi
} }
install_mongodb() {
log_info "Installing MongoDB..."
if command -v mongod &> /dev/null; then
log_success "MongoDB is already installed"
return 0
fi
local os_name os_version
os_name=$(grep '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
os_version=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor
if [[ "$os_name" == "ubuntu" ]]; then
if [[ "$os_version" == "24.04" ]]; then
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu noble/mongodb-org/8.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-8.0.list > /dev/null
elif [[ "$os_version" == "22.04" ]]; then
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-8.0.list > /dev/null
else
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-8.0.list > /dev/null
fi
elif [[ "$os_name" == "debian" && "$os_version" == "12" ]]; then
echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] http://repo.mongodb.org/apt/debian bookworm/mongodb-org/8.0 main" | tee /etc/apt/sources.list.d/mongodb-org-8.0.list > /dev/null
else
log_error "Unsupported OS for MongoDB installation: $os_name $os_version"
exit 1
fi
apt update -qq
apt install -y -qq mongodb-org
systemctl enable mongod
systemctl start mongod
if systemctl is-active --quiet mongod; then
log_success "MongoDB installed and started successfully"
else
log_error "MongoDB installation failed or service not running"
exit 1
fi
}
install_packages() { install_packages() {
local REQUIRED_PACKAGES=("jq" "curl" "pwgen" "python3" "python3-pip" "python3-venv" "git" "bc" "zip" "cron" "lsof" "golang-go") local REQUIRED_PACKAGES=("jq" "curl" "pwgen" "python3" "python3-pip" "python3-venv" "git" "bc" "zip" "cron" "lsof" "golang-go" "gnupg" "lsb-release")
local MISSING_PACKAGES=() local MISSING_PACKAGES=()
log_info "Checking required packages..." log_info "Checking required packages..."
for package in "${REQUIRED_PACKAGES[@]}"; do for package in "${REQUIRED_PACKAGES[@]}"; do
if ! command -v "$package" &> /dev/null; then if ! command -v "$package" &> /dev/null && ! dpkg -l | grep -q "^ii.*$package "; then
MISSING_PACKAGES+=("$package") MISSING_PACKAGES+=("$package")
else else
log_success "Package $package is already installed" log_success "Package $package is already installed"
@ -106,6 +149,8 @@ install_packages() {
else else
log_success "All required packages are already installed." log_success "All required packages are already installed."
fi fi
install_mongodb
} }
clone_repository() { clone_repository() {

View File

@ -23,6 +23,7 @@ yarl==1.20.1
hysteria2-api==0.1.3 hysteria2-api==0.1.3
schedule==1.2.2 schedule==1.2.2
aiofiles==24.1.0 aiofiles==24.1.0
pymongo==4.14.1
# webpanel # webpanel
annotated-types==0.7.0 annotated-types==0.7.0

View File

@ -11,6 +11,7 @@ REPO_URL="https://github.com/ReturnFI/Blitz"
REPO_BRANCH="main" REPO_BRANCH="main"
GEOSITE_URL="https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geosite.dat" GEOSITE_URL="https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geosite.dat"
GEOIP_URL="https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geoip.dat" GEOIP_URL="https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geoip.dat"
MIGRATE_SCRIPT_PATH="$HYSTERIA_INSTALL_DIR/core/scripts/db/migrate_users.py"
# ========== Color Setup ========== # ========== Color Setup ==========
GREEN=$(tput setaf 2) GREEN=$(tput setaf 2)
@ -24,6 +25,62 @@ success() { echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] [OK] - ${RESET} $1";
warn() { echo -e "${YELLOW}[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] - ${RESET} $1"; } warn() { echo -e "${YELLOW}[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] - ${RESET} $1"; }
error() { echo -e "${RED}[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] - ${RESET} $1"; } error() { echo -e "${RED}[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] - ${RESET} $1"; }
# ========== Install MongoDB ==========
install_mongodb() {
info "Checking for MongoDB..."
if ! command -v mongod &>/dev/null; then
warn "MongoDB not found. Installing from official repository..."
local os_name os_version
os_name=$(grep '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
os_version=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
apt-get update
apt-get install -y gnupg curl lsb-release
curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor
if [[ "$os_name" == "ubuntu" ]]; then
if [[ "$os_version" == "24.04" ]]; then
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu noble/mongodb-org/8.0 multiverse" > /etc/apt/sources.list.d/mongodb-org-8.0.list
elif [[ "$os_version" == "22.04" ]]; then
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" > /etc/apt/sources.list.d/mongodb-org-8.0.list
else
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" > /etc/apt/sources.list.d/mongodb-org-8.0.list
fi
elif [[ "$os_name" == "debian" && "$os_version" == "12" ]]; then
echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] http://repo.mongodb.org/apt/debian bookworm/mongodb-org/8.0 main" > /etc/apt/sources.list.d/mongodb-org-8.0.list
else
error "Unsupported OS for MongoDB installation: $os_name $os_version"
exit 1
fi
apt-get update -qq
apt-get install -y mongodb-org
systemctl start mongod
systemctl enable mongod
success "MongoDB installed and started successfully."
else
success "MongoDB is already installed."
fi
}
# ========== New Function to Migrate Data ==========
migrate_json_to_mongo() {
info "Checking for user data migration..."
if [[ -f "$HYSTERIA_INSTALL_DIR/users.json" ]]; then
info "Found users.json. Proceeding with migration to MongoDB."
if python3 "$MIGRATE_SCRIPT_PATH"; then
success "Data migration completed successfully."
else
error "Data migration script failed. Please check the output above."
exit 1
fi
else
info "No users.json found. Skipping migration."
fi
}
# ========== Capture Active Services ========== # ========== Capture Active Services ==========
declare -a ACTIVE_SERVICES_BEFORE_UPGRADE=() declare -a ACTIVE_SERVICES_BEFORE_UPGRADE=()
ALL_SERVICES=( ALL_SERVICES=(
@ -46,14 +103,15 @@ for SERVICE in "${ALL_SERVICES[@]}"; do
fi fi
done done
# ========== Install MongoDB Prerequisite ==========
install_mongodb
# ========== New Function to Install Go and Compile Auth Binary ========== # ========== Install Go and Compile Auth Binary ==========
install_go_and_compile_auth() { install_go_and_compile_auth() {
info "Checking for Go and compiling authentication binary..." info "Checking for Go and compiling authentication binary..."
if ! command -v go &>/dev/null; then if ! command -v go &>/dev/null; then
warn "Go is not installed. Attempting to install..." warn "Go is not installed. Attempting to install..."
#apt-get update -qq >/dev/null apt-get install -y golang-go
apt install golang-go -y
success "Go installed successfully." success "Go installed successfully."
else else
success "Go is already installed." success "Go is already installed."
@ -158,6 +216,9 @@ pip install --upgrade pip >/dev/null
pip install -r requirements.txt >/dev/null pip install -r requirements.txt >/dev/null
success "Python environment ready." success "Python environment ready."
# ========== Data Migration ==========
migrate_json_to_mongo
# ========== Compile Go Binary ========== # ========== Compile Go Binary ==========
install_go_and_compile_auth install_go_and_compile_auth
@ -192,7 +253,6 @@ else
done done
fi fi
# ========== Final Check ========== # ========== Final Check ==========
if systemctl is-active --quiet hysteria-server.service; then if systemctl is-active --quiet hysteria-server.service; then
success "🎉 Upgrade completed successfully!" success "🎉 Upgrade completed successfully!"