Skip to content

Commit 7dee669

Browse files
authored
Merge pull request #258 from ReturnFI/hold
Bulk User Export, On-Hold Logic & Performance Optimizations
2 parents b4579f1 + 3838178 commit 7dee669

File tree

14 files changed

+486
-759
lines changed

14 files changed

+486
-759
lines changed

changelog

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
# [1.17.0] - 2025-08-24
1+
# [1.18.0] - 2025-08-27
22

3+
#### ⚡ Performance
34

4-
####Authentication
5+
***Optimized bulk user URI fetching**:
56

6-
* 🚀 **Implemented Go HTTP Auth Server** for **maximum performance**
7-
* ⚡ Removed old command-based auth system
7+
* New API endpoint `/api/v1/users/uri/bulk` to fetch multiple user links in a single call
8+
* Eliminates N separate script executions → **huge speedup** 🚀
9+
* ⚡ Refactored `wrapper_uri.py` for faster bulk processing & maintainability
810

9-
#### 👥 User Management
11+
#### ✨ Features
1012

11-
* ✨ **Bulk User Creation** added across:
13+
* 📤 **Bulk user link export** directly from the **Users Page**
14+
* 🎨 Distinct **color coding** for user statuses in Web Panel
15+
* ⏸️ **On-Hold User Activation** logic introduced in `traffic.py` (with `creation_date=None` default)
1216

13-
* 🖥️ **Frontend UI**
14-
* 📡 **API Endpoint**
15-
* 💻 **CLI Command**
16-
* 📜 **Automation Script**
17-
* 🔍 New **Online User Filter & Sort** on the Users page
18-
* 🐛 Fixed: underscores now supported in usernames
17+
#### 🐛 Fixes & Refactors
18+
19+
* 🤖 **Bot**: Properly handle escaped underscores in usernames
20+
* 🛠️ **Webpanel**: Improved handling of malformed user data & more accurate status for on-hold users
21+
* 🐛 Show Go installation correctly
22+
* 🔄 Refactored on-hold user logic into `traffic.py` for central management

core/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def bulk_user_add(traffic_gb: float, expiration_days: int, count: int, prefix: s
160160
@click.option('--new-expiration-days', '-ne', required=False, help='Expiration days for the new user', type=int)
161161
@click.option('--renew-password', '-rp', is_flag=True, help='Renew password for the user')
162162
@click.option('--renew-creation-date', '-rc', is_flag=True, help='Renew creation date for the user')
163-
@click.option('--blocked/--unblocked', 'blocked', default=None, help='Block or unblock the user.')
163+
@click.option('--blocked/--unblocked', 'blocked', '-b', default=None, help='Block or unblock the user.')
164164
@click.option('--unlimited-ip/--limited-ip', 'unlimited_ip', default=None, help='Set user to be exempt from or subject to IP limits.')
165165
def edit_user(username: str, new_username: str, new_traffic_limit: int, new_expiration_days: int, renew_password: bool, renew_creation_date: bool, blocked: bool | None, unlimited_ip: bool | None):
166166
try:

core/scripts/hysteria2/add_user.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
1818
traffic_gb (str): The traffic limit in GB.
1919
expiration_days (str): The number of days until the account expires.
2020
password (str, optional): The user's password. If None, a random one is generated.
21-
creation_date (str, optional): The account creation date in YYYY-MM-DD format. If None, the current date is used.
21+
creation_date (str, optional): The account creation date in YYYY-MM-DD format. Defaults to None.
2222
unlimited_user (bool, optional): If True, user is exempt from IP limits. Defaults to False.
2323
2424
Returns:
@@ -48,9 +48,7 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
4848
print("Error: Failed to generate password. Please install 'pwgen' or ensure /proc access.")
4949
return 1
5050

51-
if not creation_date:
52-
creation_date = datetime.now().strftime("%Y-%m-%d")
53-
else:
51+
if creation_date:
5452
if not re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", creation_date):
5553
print("Invalid date format. Expected YYYY-MM-DD.")
5654
return 1
@@ -59,6 +57,8 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
5957
except ValueError:
6058
print("Invalid date. Please provide a valid date in YYYY-MM-DD format.")
6159
return 1
60+
else:
61+
creation_date = None
6262

6363
if not re.match(r"^[a-zA-Z0-9_]+$", username):
6464
print("Error: Username can only contain letters, numbers, and underscores.")

core/scripts/hysteria2/bulk_users.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def add_bulk_users(traffic_gb, expiration_days, count, prefix, start_number, unl
3535

3636
existing_users_lower = {u.lower() for u in users_data}
3737
new_users_to_add = {}
38-
creation_date = datetime.now().strftime("%Y-%m-%d")
38+
creation_date = None
3939

4040
try:
4141
password_process = subprocess.run(['pwgen', '-s', '32', str(count)], capture_output=True, text=True, check=True)
Lines changed: 116 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,128 @@
1-
import subprocess
2-
import concurrent.futures
3-
import re
4-
import json
1+
#!/usr/bin/env python3
2+
3+
import os
54
import sys
5+
import json
6+
import argparse
7+
from functools import lru_cache
8+
from typing import Dict, List, Any
69

710
from init_paths import *
811
from paths import *
912

10-
DEFAULT_ARGS = ["-a", "-n", "-s"]
11-
12-
def run_show_uri(username):
13+
@lru_cache(maxsize=None)
14+
def load_json_file(file_path: str) -> Any:
15+
if not os.path.exists(file_path):
16+
return None
1317
try:
14-
cmd = ["python3", CLI_PATH, "show-user-uri", "-u", username] + DEFAULT_ARGS
15-
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
16-
output = result.stdout
17-
if "Invalid username" in output:
18-
return {"username": username, "error": "User not found"}
19-
return parse_output(username, output)
20-
except subprocess.CalledProcessError as e:
21-
return {"username": username, "error": e.stderr.strip()}
22-
23-
def parse_output(username, output):
24-
ipv4 = None
25-
ipv6 = None
26-
normal_sub = None
27-
nodes = []
28-
29-
ipv4_match = re.search(r"IPv4:\s*(hy2://[^\s]+)", output)
30-
ipv6_match = re.search(r"IPv6:\s*(hy2://[^\s]+)", output)
31-
normal_sub_match = re.search(r"Normal-SUB Sublink:\s*(https?://[^\s]+)", output)
32-
33-
if ipv4_match:
34-
ipv4 = ipv4_match.group(1)
35-
if ipv6_match:
36-
ipv6 = ipv6_match.group(1)
37-
if normal_sub_match:
38-
normal_sub = normal_sub_match.group(1)
39-
40-
node_matches = re.findall(r"Node: (.+?) \(IPv[46]\):\s*(hy2://[^\s]+)", output)
41-
for name, uri in node_matches:
42-
nodes.append({"name": name.strip(), "uri": uri})
43-
44-
45-
return {
46-
"username": username,
47-
"ipv4": ipv4,
48-
"ipv6": ipv6,
49-
"nodes": nodes,
50-
"normal_sub": normal_sub
18+
with open(file_path, 'r', encoding='utf-8') as f:
19+
content = f.read()
20+
return json.loads(content) if content else None
21+
except (json.JSONDecodeError, IOError):
22+
return None
23+
24+
@lru_cache(maxsize=None)
25+
def load_env_file(env_file: str) -> Dict[str, str]:
26+
env_vars = {}
27+
if os.path.exists(env_file):
28+
with open(env_file, 'r', encoding='utf-8') as f:
29+
for line in f:
30+
line = line.strip()
31+
if line and not line.startswith('#') and '=' in line:
32+
key, value = line.split('=', 1)
33+
env_vars[key] = value.strip()
34+
return env_vars
35+
36+
def generate_uri(username: str, auth_password: str, ip: str, port: str,
37+
uri_params: Dict[str, str], ip_version: int, fragment_tag: str) -> str:
38+
ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip
39+
uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}"
40+
query_string = "&".join([f"{k}={v}" for k, v in uri_params.items()])
41+
return f"{uri_base}?{query_string}#{fragment_tag}"
42+
43+
def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
44+
config = load_json_file(CONFIG_FILE)
45+
all_users = load_json_file(USERS_FILE)
46+
47+
if not config or not all_users:
48+
print("Error: Could not load Hysteria2 configuration or user files.", file=sys.stderr)
49+
sys.exit(1)
50+
51+
nodes = load_json_file(NODES_JSON_PATH) or []
52+
port = config.get("listen", "").split(":")[-1]
53+
tls_config = config.get("tls", {})
54+
hy2_env = load_env_file(CONFIG_ENV)
55+
ns_env = load_env_file(NORMALSUB_ENV)
56+
57+
base_uri_params = {
58+
"insecure": "1" if tls_config.get("insecure", True) else "0",
59+
"sni": hy2_env.get('SNI', '')
5160
}
61+
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password")
62+
if obfs_password:
63+
base_uri_params["obfs"] = "salamander"
64+
base_uri_params["obfs-password"] = obfs_password
65+
66+
sha256 = tls_config.get("pinSHA256")
67+
if sha256:
68+
base_uri_params["pinSHA256"] = sha256
69+
70+
ip4 = hy2_env.get('IP4')
71+
ip6 = hy2_env.get('IP6')
72+
ns_domain, ns_port, ns_subpath = ns_env.get('HYSTERIA_DOMAIN'), ns_env.get('HYSTERIA_PORT'), ns_env.get('SUBPATH')
73+
74+
results = []
75+
for username in target_usernames:
76+
user_data = all_users.get(username)
77+
if not user_data or "password" not in user_data:
78+
results.append({"username": username, "error": "User not found or password not set"})
79+
continue
80+
81+
auth_password = user_data["password"]
82+
user_output = {"username": username, "ipv4": None, "ipv6": None, "nodes": [], "normal_sub": None}
83+
84+
if ip4 and ip4 != "None":
85+
user_output["ipv4"] = generate_uri(username, auth_password, ip4, port, base_uri_params, 4, f"{username}-IPv4")
86+
if ip6 and ip6 != "None":
87+
user_output["ipv6"] = generate_uri(username, auth_password, ip6, port, base_uri_params, 6, f"{username}-IPv6")
5288

53-
def batch_show_uri(usernames, max_workers=20):
54-
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
55-
results = list(executor.map(run_show_uri, usernames))
89+
for node in nodes:
90+
if node_name := node.get("name"):
91+
if node_ip := node.get("ip"):
92+
ip_v = 6 if ':' in node_ip else 4
93+
tag = f"{username}-{node_name}"
94+
uri = generate_uri(username, auth_password, node_ip, port, base_uri_params, ip_v, tag)
95+
user_output["nodes"].append({"name": node_name, "uri": uri})
96+
97+
if ns_domain and ns_port and ns_subpath:
98+
user_output["normal_sub"] = f"https://{ns_domain}:{ns_port}/{ns_subpath}/sub/normal/{auth_password}#{username}"
99+
100+
results.append(user_output)
101+
56102
return results
57103

58-
if __name__ == "__main__":
59-
if len(sys.argv) < 2:
60-
print("Usage: python3 show_uri_json.py user1 user2 ...")
104+
def main():
105+
parser = argparse.ArgumentParser(description="Efficiently generate Hysteria2 URIs for multiple users.")
106+
parser.add_argument('usernames', nargs='*', help="A list of usernames to process.")
107+
parser.add_argument('--all', action='store_true', help="Process all users from users.json.")
108+
109+
args = parser.parse_args()
110+
target_usernames = args.usernames
111+
112+
if args.all:
113+
all_users = load_json_file(USERS_FILE)
114+
if all_users:
115+
target_usernames = list(all_users.keys())
116+
else:
117+
print("Error: Could not load users.json to process all users.", file=sys.stderr)
118+
sys.exit(1)
119+
120+
if not target_usernames:
121+
parser.print_help()
61122
sys.exit(1)
62123

63-
usernames = sys.argv[1:]
64-
output_list = batch_show_uri(usernames)
65-
print(json.dumps(output_list, indent=2))
124+
output_list = process_users(target_usernames)
125+
print(json.dumps(output_list, indent=2))
126+
127+
if __name__ == "__main__":
128+
main()

core/scripts/scheduler.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
#!/usr/bin/env python3
2-
import os
3-
import sys
42
import time
53
import schedule
64
import logging
75
import subprocess
86
import fcntl
9-
import datetime
107
from pathlib import Path
118
from paths import *
129

@@ -23,7 +20,6 @@
2320
# Constants
2421
BASE_DIR = Path("/etc/hysteria")
2522
VENV_ACTIVATE = BASE_DIR / "hysteria2_venv/bin/activate"
26-
# CLI_PATH = BASE_DIR / "core/cli.py"
2723
LOCK_FILE = "/tmp/hysteria_scheduler.lock"
2824

2925
def acquire_lock():
@@ -41,7 +37,6 @@ def release_lock(lock_fd):
4137
lock_fd.close()
4238

4339
def run_command(command, log_success=False):
44-
4540
activate_cmd = f"source {VENV_ACTIVATE}"
4641
full_cmd = f"{activate_cmd} && {command}"
4742

@@ -94,6 +89,7 @@ def main():
9489
schedule.every(1).minutes.do(check_traffic_status)
9590
schedule.every(6).hours.do(backup_hysteria)
9691

92+
check_traffic_status()
9793
backup_hysteria()
9894

9995
while True:

core/scripts/telegrambot/utils/adduser.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import qrcode
22
import io
33
import json
4+
import re
45
from telebot import types
56
from utils.command import *
67
from utils.common import create_main_markup
78

9+
def escape_markdown(text):
10+
return str(text).replace('_', '\\_').replace('*', '\\*').replace('`', '\\`')
811

912
def create_cancel_markup(back_step=None):
1013
markup = types.ReplyKeyboardMarkup(resize_keyboard=True, one_time_keyboard=True)
@@ -15,7 +18,7 @@ def create_cancel_markup(back_step=None):
1518

1619
@bot.message_handler(func=lambda message: is_admin(message.from_user.id) and message.text == 'Add User')
1720
def add_user(message):
18-
msg = bot.reply_to(message, "Enter username:", reply_markup=create_cancel_markup())
21+
msg = bot.reply_to(message, "Enter username (only letters, numbers, and underscores are allowed):", reply_markup=create_cancel_markup())
1922
bot.register_next_step_handler(msg, process_add_user_step1)
2023

2124
def process_add_user_step1(message):
@@ -24,6 +27,12 @@ def process_add_user_step1(message):
2427
return
2528

2629
username = message.text.strip()
30+
31+
if not re.match("^[a-zA-Z0-9_]*$", username):
32+
bot.reply_to(message, "Invalid username. Only letters, numbers, and underscores are allowed. Please try again:", reply_markup=create_cancel_markup())
33+
bot.register_next_step_handler(message, process_add_user_step1)
34+
return
35+
2736
if not username:
2837
bot.reply_to(message, "Username cannot be empty. Please enter a valid username:", reply_markup=create_cancel_markup())
2938
bot.register_next_step_handler(message, process_add_user_step1)
@@ -41,7 +50,7 @@ def process_add_user_step1(message):
4150
users_data = json.loads(result)
4251
existing_users = {user_key.lower() for user_key in users_data.keys()}
4352
if username.lower() in existing_users:
44-
bot.reply_to(message, f"Username '{username}' already exists. Please choose a different username:", reply_markup=create_cancel_markup())
53+
bot.reply_to(message, f"Username '{escape_markdown(username)}' already exists. Please choose a different username:", reply_markup=create_cancel_markup())
4554
bot.register_next_step_handler(message, process_add_user_step1)
4655
return
4756
except json.JSONDecodeError:
@@ -59,7 +68,7 @@ def process_add_user_step2(message, username):
5968
bot.reply_to(message, "Process canceled.", reply_markup=create_main_markup())
6069
return
6170
if message.text == "⬅️ Back":
62-
msg = bot.reply_to(message, "Enter username:", reply_markup=create_cancel_markup())
71+
msg = bot.reply_to(message, "Enter username (only letters, numbers, and underscores are allowed):", reply_markup=create_cancel_markup())
6372
bot.register_next_step_handler(msg, process_add_user_step1)
6473
return
6574

@@ -96,8 +105,7 @@ def process_add_user_step3(message, username, traffic_limit):
96105

97106
bot.send_chat_action(message.chat.id, 'typing')
98107

99-
lower_username = username.lower()
100-
uri_info_command = f"python3 {CLI_PATH} show-user-uri -u \"{lower_username}\" -ip 4 -n"
108+
uri_info_command = f"python3 {CLI_PATH} show-user-uri -u \"{username}\" -ip 4 -n"
101109
uri_info_output = run_cli_command(uri_info_command)
102110

103111
direct_uri = None
@@ -121,18 +129,20 @@ def process_add_user_step3(message, username, traffic_limit):
121129
except (IndexError, AttributeError):
122130
pass
123131

124-
caption_text = f"{add_user_feedback}\n"
132+
display_username = escape_markdown(username)
133+
escaped_feedback = escape_markdown(add_user_feedback)
134+
caption_text = f"{escaped_feedback}\n"
125135
link_to_generate_qr_for = None
126136
link_type_for_caption = ""
127137

128138
if normal_sub_link:
129139
link_to_generate_qr_for = normal_sub_link
130140
link_type_for_caption = "Normal Subscription Link"
131-
caption_text += f"\n{link_type_for_caption} for `{username}`:\n`{normal_sub_link}`"
141+
caption_text += f"\n{link_type_for_caption} for `{display_username}`:\n`{normal_sub_link}`"
132142
elif direct_uri:
133143
link_to_generate_qr_for = direct_uri
134144
link_type_for_caption = "Hysteria2 IPv4 URI"
135-
caption_text += f"\n{link_type_for_caption} for `{username}`:\n`{direct_uri}`"
145+
caption_text += f"\n{link_type_for_caption} for `{display_username}`:\n`{direct_uri}`"
136146

137147
if link_to_generate_qr_for:
138148
qr_img = qrcode.make(link_to_generate_qr_for)

0 commit comments

Comments
 (0)