Merge pull request #267 from ReturnFI/beta
Migrate User Management to MongoDB
This commit is contained in:
33
changelog
33
changelog
@ -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 🙏
|
|
||||||
|
|
||||||
#### ✨ Features
|
#### ⚠️ Breaking Change
|
||||||
|
|
||||||
* ⚙️ **New Hysteria Settings tab** in Web Panel (with **geo update support**)
|
* Previous JSON-based `users.json` file is no longer used
|
||||||
* 🎨 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)
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
41
core/scripts/db/database.py
Normal file
41
core/scripts/db/database.py
Normal 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
|
||||||
70
core/scripts/db/migrate_users.py
Normal file
70
core/scripts/db/migrate_users.py
Normal 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()
|
||||||
@ -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__":
|
||||||
|
|||||||
@ -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()
|
||||||
@ -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
|
|
||||||
|
new_usernames = [u for u in potential_usernames if u not in existing_users_set]
|
||||||
|
new_users_count = len(new_usernames)
|
||||||
|
|
||||||
existing_users_lower = {u.lower() for u in users_data}
|
if new_users_count == 0:
|
||||||
new_users_to_add = {}
|
print("No new users to add. All generated usernames already exist.")
|
||||||
creation_date = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
password_process = subprocess.run(['pwgen', '-s', '32', str(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(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.")
|
||||||
|
|||||||
123
core/scripts/hysteria2/edit_user.py
Normal file
123
core/scripts/hysteria2/edit_user.py
Normal 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
|
||||||
|
))
|
||||||
@ -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"
|
|
||||||
@ -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>")
|
||||||
|
|||||||
@ -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
|
||||||
client.kick_clients(usernames)
|
|
||||||
logger.info(f"Successfully kicked {len(usernames)} users: {', '.join(usernames)}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error kicking users: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def process_user(username, user_data, config_secret, users_data):
|
def kick_users_api(usernames, secret):
|
||||||
blocked = user_data.get('blocked', False)
|
|
||||||
|
|
||||||
if blocked:
|
|
||||||
logger.info(f"Skipping {username} as they are already blocked.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
max_download_bytes = user_data.get('max_download_bytes', 0)
|
|
||||||
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:
|
|
||||||
logger.info(f"Skipping {username} due to missing account creation date.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_date = datetime.datetime.now().timestamp()
|
client = Hysteria2Client(base_url=API_BASE_URL, secret=secret)
|
||||||
creation_date = datetime.datetime.fromisoformat(account_creation_date.replace('Z', '+00:00'))
|
client.kick_clients(usernames)
|
||||||
expiration_date = (creation_date + datetime.timedelta(days=expiration_days)).timestamp()
|
logger.info(f"Successfully sent kick command for users: {', '.join(usernames)}")
|
||||||
|
|
||||||
should_block = False
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if should_block:
|
|
||||||
logger.info(f"Setting blocked=True for user {username}")
|
|
||||||
users_data[username]['blocked'] = True
|
|
||||||
return username
|
|
||||||
else:
|
|
||||||
logger.info(f"Skipping {username} due to invalid or missing data.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing user {username}: {str(e)}")
|
logger.error(f"Error kicking users via API: {e}")
|
||||||
|
|
||||||
|
def process_user(user_doc):
|
||||||
|
username = user_doc.get('_id')
|
||||||
|
|
||||||
|
if not username or user_doc.get('blocked', False):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
account_creation_date = user_doc.get('account_creation_date')
|
||||||
|
if not account_creation_date:
|
||||||
|
return None
|
||||||
|
|
||||||
|
should_block = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
expiration_days = user_doc.get('expiration_days', 0)
|
||||||
|
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
|
||||||
|
logger.info(f"User {username} is expired.")
|
||||||
|
|
||||||
|
if not should_block:
|
||||||
|
max_download_bytes = user_doc.get('max_download_bytes', 0)
|
||||||
|
if max_download_bytes > 0:
|
||||||
|
total_bytes = user_doc.get('download_bytes', 0) + user_doc.get('upload_bytes', 0)
|
||||||
|
if total_bytes >= max_download_bytes:
|
||||||
|
should_block = True
|
||||||
|
logger.info(f"User {username} has exceeded their traffic limit.")
|
||||||
|
|
||||||
|
if should_block:
|
||||||
|
return username
|
||||||
|
|
||||||
|
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.")
|
||||||
|
sys.exit(1)
|
||||||
try:
|
|
||||||
with open(CONFIG_FILE, 'r') as f:
|
secret = get_secret()
|
||||||
config = json.load(f)
|
if not secret:
|
||||||
secret = config.get('trafficStats', {}).get('secret', '')
|
logger.error(f"Could not find secret in {CONFIG_FILE}. Exiting.")
|
||||||
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:
|
all_users = db.get_all_users()
|
||||||
with open(USERS_FILE, 'r') as f:
|
logger.info(f"Loaded {len(all_users)} users from the database for processing.")
|
||||||
users_data = json.load(f)
|
|
||||||
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)
|
|
||||||
|
|
||||||
users_to_kick = []
|
users_to_block = []
|
||||||
logger.info(f"Processing {len(users_data)} users in parallel with {MAX_WORKERS} workers")
|
|
||||||
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:
|
logger.info(f"Found {len(users_to_block)} users to block: {', '.join(users_to_block)}")
|
||||||
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:
|
for username in users_to_block:
|
||||||
logger.info(f"Kicking {len(users_to_kick)} users")
|
db.update_user(username, {'blocked': True})
|
||||||
batch_size = 50
|
logger.info("Successfully updated user statuses to 'blocked' in the database.")
|
||||||
for i in range(0, len(users_to_kick), batch_size):
|
|
||||||
batch = users_to_kick[i:i+batch_size]
|
batch_size = 50
|
||||||
logger.info(f"Processing batch of {len(batch)} users")
|
for i in range(0, len(users_to_block), batch_size):
|
||||||
kick_users(batch, secret)
|
batch = users_to_block[i:i + batch_size]
|
||||||
for username in batch:
|
kick_users_api(batch, secret)
|
||||||
logger.info(f"Blocked and kicked user {username}")
|
|
||||||
else:
|
|
||||||
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__":
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
source /etc/hysteria/core/scripts/path.sh
|
|
||||||
|
|
||||||
|
|
||||||
cat "$USERS_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()
|
||||||
@ -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)
|
||||||
@ -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
|
||||||
|
|
||||||
|
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
|
return 1
|
||||||
|
|
||||||
config_file = os.path.join(target_dir, "config.json")
|
|
||||||
|
|
||||||
if os.path.isfile(config_file):
|
|
||||||
print("Checking and adjusting config.json based on system state...")
|
|
||||||
|
|
||||||
ret_code, networkdef = run_command("ip route | grep '^default' | awk '{print $5}'")
|
print("Restoring MongoDB database... (This will drop the current user data)")
|
||||||
networkdef = networkdef.strip()
|
run_command(f"mongorestore --db={DB_NAME} --drop --dir='{dump_dir}'", check=True)
|
||||||
|
print("Database restored successfully.")
|
||||||
if networkdef:
|
|
||||||
with open(config_file, 'r') as f:
|
files_to_copy = ["config.json", ".configs.env", "ca.key", "ca.crt"]
|
||||||
config = json.load(f)
|
print("Restoring configuration files...")
|
||||||
|
for filename in files_to_copy:
|
||||||
for outbound in config.get('outbounds', []):
|
src = temp_dir / filename
|
||||||
if outbound.get('name') == 'v4' and 'direct' in outbound:
|
if src.exists():
|
||||||
current_v4_device = outbound['direct'].get('bindDevice', '')
|
shutil.copy2(src, HYSTERIA_CONFIG_DIR / filename)
|
||||||
|
print(f" - Restored {filename}")
|
||||||
if current_v4_device != networkdef:
|
|
||||||
print(f"Updating v4 outbound bindDevice from '{current_v4_device}' to '{networkdef}'...")
|
adjust_config_file()
|
||||||
outbound['direct']['bindDevice'] = networkdef
|
|
||||||
|
print("Setting permissions...")
|
||||||
with open(config_file, 'w') as f:
|
run_command(f"chown hysteria:hysteria {HYSTERIA_CONFIG_DIR / 'ca.key'} {HYSTERIA_CONFIG_DIR / 'ca.crt'}")
|
||||||
json.dump(config, f, indent=2)
|
run_command(f"chmod 640 {HYSTERIA_CONFIG_DIR / 'ca.key'} {HYSTERIA_CONFIG_DIR / 'ca.crt'}")
|
||||||
|
|
||||||
ret_code, _ = run_command("systemctl is-active --quiet wg-quick@wgcf.service", capture_output=False)
|
print("Restarting Hysteria service...")
|
||||||
|
run_command(f"python3 {CLI_PATH} restart-hysteria2", check=True)
|
||||||
if ret_code != 0:
|
|
||||||
print("wgcf service is NOT active. Removing warps outbound and any ACL rules...")
|
print("\nRestore completed successfully.")
|
||||||
|
return 0
|
||||||
with open(config_file, 'r') as f:
|
|
||||||
config = json.load(f)
|
except subprocess.CalledProcessError:
|
||||||
|
print("\nRestore failed due to a command execution error.", file=sys.stderr)
|
||||||
config['outbounds'] = [outbound for outbound in config.get('outbounds', [])
|
|
||||||
if outbound.get('name') != 'warps']
|
|
||||||
|
|
||||||
if 'acl' in config and 'inline' in config['acl']:
|
|
||||||
config['acl']['inline'] = [rule for rule in config['acl']['inline']
|
|
||||||
if not rule.startswith('warps(')]
|
|
||||||
|
|
||||||
with open(config_file, 'w') as f:
|
|
||||||
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:
|
|
||||||
print(f"An unexpected error occurred: {e}")
|
|
||||||
return 1
|
return 1
|
||||||
finally:
|
except Exception as e:
|
||||||
shutil.rmtree(restore_dir, ignore_errors=True)
|
print(f"\nAn unexpected error occurred during restore: {e}", file=sys.stderr)
|
||||||
if 'existing_backup_dir' in locals():
|
return 1
|
||||||
shutil.rmtree(existing_backup_dir, ignore_errors=True)
|
|
||||||
|
def adjust_config_file():
|
||||||
|
config_file = HYSTERIA_CONFIG_DIR / "config.json"
|
||||||
|
if not config_file.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Adjusting config.json based on current system state...")
|
||||||
|
try:
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
result = run_command("ip route | grep '^default' | awk '{print $5}'")
|
||||||
|
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
|
||||||
|
|
||||||
|
result = run_command("systemctl is-active --quiet wg-quick@wgcf.service")
|
||||||
|
if result.returncode != 0:
|
||||||
|
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:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not adjust config.json. {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
@ -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}")
|
||||||
|
return
|
||||||
if args.username not in users:
|
|
||||||
print("Invalid username. Please try again.")
|
user_doc = db.get_user(args.username)
|
||||||
|
if not user_doc:
|
||||||
|
print(f"\033[0;31mError:\033[0m User '{args.username}' not found in the database.")
|
||||||
return
|
return
|
||||||
|
|
||||||
auth_password = users[args.username]["password"]
|
auth_password = user_doc["password"]
|
||||||
port = config["listen"].split(":")[1] if ":" in config["listen"] else config["listen"]
|
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")
|
||||||
|
|||||||
@ -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("\nUninstallation of Hysteria2 panel is complete.")
|
||||||
|
print("It is recommended to reboot the server to ensure all changes take effect.")
|
||||||
print("Hysteria2 uninstalled!")
|
|
||||||
print("Rebooting server...")
|
|
||||||
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()
|
||||||
@ -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)
|
||||||
if not config or not all_users:
|
sys.exit(1)
|
||||||
print("Error: Could not load Hysteria2 configuration or user files.", file=sys.stderr)
|
|
||||||
|
if db is None:
|
||||||
|
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:
|
||||||
|
|||||||
@ -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
|
return None
|
||||||
|
user_doc = db.get_user(username)
|
||||||
user_details = self.get_user_details_from_json(username)
|
if not user_doc:
|
||||||
if not user_details or 'password' not in user_details:
|
|
||||||
print(f"Warning: Password for user '{username}' could not be fetched from {self.users_json_path}. Cannot create UserInfo.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_info = json.loads(raw_info_str)
|
|
||||||
return UserInfo(
|
|
||||||
username=username,
|
|
||||||
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
|
return None
|
||||||
|
|
||||||
|
return UserInfo(
|
||||||
|
username=user_doc.get('_id'),
|
||||||
|
password=user_doc.get('password'),
|
||||||
|
upload_bytes=user_doc.get('upload_bytes', 0),
|
||||||
|
download_bytes=user_doc.get('download_bytes', 0),
|
||||||
|
max_download_bytes=user_doc.get('max_download_bytes', 0),
|
||||||
|
account_creation_date=user_doc.get('account_creation_date', ''),
|
||||||
|
expiration_days=user_doc.get('expiration_days', 0),
|
||||||
|
blocked=user_doc.get('blocked', False)
|
||||||
|
)
|
||||||
|
|
||||||
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,
|
||||||
@ -681,4 +649,4 @@ class HysteriaServer:
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
server = HysteriaServer()
|
server = HysteriaServer()
|
||||||
server.run()
|
server.run()
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,48 +66,45 @@ 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}"),
|
||||||
types.InlineKeyboardButton("IPv6-URI", callback_data=f"ipv6_uri:{actual_username}"))
|
types.InlineKeyboardButton("IPv6-URI", callback_data=f"ipv6_uri:{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,8 +206,8 @@ 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:
|
||||||
bot.reply_to(message, "Invalid expiration days. Please enter a number.")
|
bot.reply_to(message, "Invalid expiration days. Please enter a number.")
|
||||||
@ -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)
|
||||||
@ -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(
|
||||||
dst_path = os.path.join(dst_dir_path, file.filename) # type: ignore
|
status_code=400,
|
||||||
shutil.move(temp_path, dst_path)
|
detail=f"Backup file is not a modern backup and is missing the database dump directory: '{db_dump_prefix}'"
|
||||||
cli_api.restore_hysteria2(dst_path)
|
)
|
||||||
|
|
||||||
|
cli_api.restore_hysteria2(temp_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():
|
||||||
|
|||||||
331
core/traffic.py
331
core/traffic.py
@ -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"
|
if db is None:
|
||||||
elif bytes_val < 1073741824: return f"{bytes_val / 1048576:.2f}MB"
|
if not no_gui: print("Error: Database connection failed.")
|
||||||
elif bytes_val < 1099511627776: return f"{bytes_val / 1073741824:.2f}GB"
|
return None
|
||||||
else: return f"{bytes_val / 1099511627776:.2f}TB"
|
|
||||||
|
|
||||||
def kick_users(usernames, secret):
|
secret = get_secret()
|
||||||
"""Kicks specified users from the server"""
|
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
|
||||||
|
|
||||||
|
secret = get_secret()
|
||||||
|
if not secret:
|
||||||
|
print(f"Error: Secret not found or failed to read {CONFIG_FILE}.", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
all_users = db.get_all_users()
|
||||||
if not os.path.exists(USERS_FILE): return
|
users_to_kick, users_to_block = [], []
|
||||||
shutil.copy2(USERS_FILE, BACKUP_FILE)
|
|
||||||
|
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_block:
|
||||||
|
for username in users_to_block:
|
||||||
if users_to_kick:
|
db.update_user(username, {'blocked': True})
|
||||||
with open(USERS_FILE, 'w') as f:
|
|
||||||
json.dump(users_data, f, indent=4)
|
if users_to_kick:
|
||||||
|
for i in range(0, len(users_to_kick), 50):
|
||||||
for i in range(0, len(users_to_kick), 50):
|
kick_api_call(users_to_kick[i:i+50], secret)
|
||||||
batch = users_to_kick[i:i+50]
|
|
||||||
kick_users(batch, secret)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
if os.path.exists(BACKUP_FILE):
|
|
||||||
shutil.copy2(BACKUP_FILE, USERS_FILE)
|
|
||||||
sys.exit(1)
|
|
||||||
finally:
|
|
||||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
|
||||||
lock_file.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if len(sys.argv) > 1:
|
lock_file = acquire_lock()
|
||||||
if sys.argv[1] == "kick":
|
try:
|
||||||
kick_expired_users()
|
if len(sys.argv) > 1:
|
||||||
elif sys.argv[1] == "--no-gui":
|
if sys.argv[1] == "kick":
|
||||||
traffic_status(no_gui=True)
|
kick_expired_users()
|
||||||
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:
|
else:
|
||||||
print(f"Unknown argument: {sys.argv[1]}")
|
traffic_status(no_gui=False)
|
||||||
print("Usage: python traffic.py [kick|--no-gui]")
|
finally:
|
||||||
else:
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
traffic_status(no_gui=False)
|
lock_file.close()
|
||||||
49
install.sh
49
install.sh
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
68
upgrade.sh
68
upgrade.sh
@ -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!"
|
||||||
|
|||||||
Reference in New Issue
Block a user