Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.

Commit c5300a7

Browse files
authored
Merge pull request #239 from ReturnFI/beta
External Node Management & Fixes
2 parents 80f4f62 + 97e0c45 commit c5300a7

21 files changed

Lines changed: 887 additions & 429 deletions

File tree

changelog

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
# [1.12.1] - 2025-07-09
1+
# [1.13.0] - 2025-08-10
22

33
#### ✨ UI Enhancements
44

5-
* 📌 **Sticky Sidebar:** Sidebar now stays fixed when scrolling through long pages for easier navigation
6-
* 🃏 **Sticky Headers:** Card headers are now sticky with a sleek **bokeh blur** effect – better usability on settings and user lists
7-
* ⏎ **Login UX:** Pressing **Enter** now submits the login form properly for faster access
5+
* ✨ feat(core): Implement external node management system
6+
* 🌐 feat(api): Add external node management endpoints
7+
* 🔗 feat(normalsub): Add external node URIs to subscriptions
8+
* 💾 feat: Backup nodes.json
9+
* 🛠️ fix: Robust parsing, unlimited user handling, and various script/UI issues
10+
* 📦 chore: Dependency updates (pytelegrambotapi, aiohttp)

core/cli.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,41 @@ def ip_address(edit: bool, ipv4: str, ipv6: str):
298298
click.echo(f'{e}', err=True)
299299

300300

301+
@cli.group()
302+
def node():
303+
"""Manage external node IPs for multi-server setups."""
304+
pass
305+
306+
@node.command('add')
307+
@click.option('--name', required=True, type=str, help='A unique name for the node (e.g., "Node-DE").')
308+
@click.option('--ip', required=True, type=str, help='The public IP address of the node.')
309+
def add_node(name, ip):
310+
"""Add a new external node."""
311+
try:
312+
output = cli_api.add_node(name, ip)
313+
click.echo(output.strip())
314+
except Exception as e:
315+
click.echo(f'{e}', err=True)
316+
317+
@node.command('delete')
318+
@click.option('--name', required=True, type=str, help='The name of the node to delete.')
319+
def delete_node(name):
320+
"""Delete an external node by its name."""
321+
try:
322+
output = cli_api.delete_node(name)
323+
click.echo(output.strip())
324+
except Exception as e:
325+
click.echo(f'{e}', err=True)
326+
327+
@node.command('list')
328+
def list_nodes():
329+
"""List all configured external nodes."""
330+
try:
331+
output = cli_api.list_nodes()
332+
click.echo(output.strip())
333+
except Exception as e:
334+
click.echo(f'{e}', err=True)
335+
301336
@cli.command('update-geo')
302337
@click.option('--country', '-c',
303338
type=click.Choice(['iran', 'china', 'russia'], case_sensitive=False),
@@ -323,9 +358,6 @@ def masquerade(remove: bool, enable: str):
323358
raise click.UsageError('Error: You cannot use both --remove and --enable at the same time')
324359

325360
if enable:
326-
# NOT SURE THIS IS NEEDED
327-
# if not enable.startswith('http://') and not enable.startswith('https://'):
328-
# enable = 'https://' + enable
329361
cli_api.enable_hysteria2_masquerade(enable)
330362
click.echo('Masquerade enabled successfully.')
331363
elif remove:
@@ -651,4 +683,4 @@ def config_ip_limit(block_duration: int, max_ips: int):
651683

652684

653685
if __name__ == '__main__':
654-
cli()
686+
cli()

core/cli_api.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
CONFIG_ENV_FILE = '/etc/hysteria/.configs.env'
1515
WEBPANEL_ENV_FILE = '/etc/hysteria/core/scripts/webpanel/.env'
1616
NORMALSUB_ENV_FILE = '/etc/hysteria/core/scripts/normalsub/.env'
17+
NODES_JSON_PATH = "/etc/hysteria/nodes.json"
1718

1819

1920
class Command(Enum):
@@ -32,6 +33,7 @@ class Command(Enum):
3233
SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.py')
3334
WRAPPER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'wrapper_uri.py')
3435
IP_ADD = os.path.join(SCRIPT_DIR, 'hysteria2', 'ip.py')
36+
NODE_MANAGER = os.path.join(SCRIPT_DIR, 'hysteria2', 'node.py')
3537
MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.py')
3638
MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.py')
3739
TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module)
@@ -281,20 +283,22 @@ def edit_user(username: str, new_username: str | None, new_traffic_limit: int |
281283
'''
282284
if not username:
283285
raise InvalidInputError('Error: username is required')
284-
if not any([new_username, new_traffic_limit, new_expiration_days, renew_password, renew_creation_date, blocked is not None]): # type: ignore
285-
raise InvalidInputError('Error: at least one option is required')
286-
if new_traffic_limit is not None and new_traffic_limit <= 0:
287-
raise InvalidInputError('Error: traffic limit must be greater than 0')
288-
if new_expiration_days is not None and new_expiration_days <= 0:
289-
raise InvalidInputError('Error: expiration days must be greater than 0')
286+
287+
if new_traffic_limit is not None and new_traffic_limit < 0:
288+
raise InvalidInputError('Error: traffic limit must be a non-negative number.')
289+
if new_expiration_days is not None and new_expiration_days < 0:
290+
raise InvalidInputError('Error: expiration days must be a non-negative number.')
291+
290292
if renew_password:
291293
password = generate_password()
292294
else:
293295
password = ''
296+
294297
if renew_creation_date:
295298
creation_date = datetime.now().strftime('%Y-%m-%d')
296299
else:
297300
creation_date = ''
301+
298302
command_args = [
299303
'bash',
300304
Command.EDIT_USER.value,
@@ -426,6 +430,23 @@ def edit_ip_address(ipv4: str, ipv6: str):
426430
if ipv6:
427431
run_cmd(['python3', Command.IP_ADD.value, 'edit', '-6', ipv6])
428432

433+
def add_node(name: str, ip: str):
434+
"""
435+
Adds a new external node.
436+
"""
437+
return run_cmd(['python3', Command.NODE_MANAGER.value, 'add', '--name', name, '--ip', ip])
438+
439+
def delete_node(name: str):
440+
"""
441+
Deletes an external node by name.
442+
"""
443+
return run_cmd(['python3', Command.NODE_MANAGER.value, 'delete', '--name', name])
444+
445+
def list_nodes():
446+
"""
447+
Lists all configured external nodes.
448+
"""
449+
return run_cmd(['python3', Command.NODE_MANAGER.value, 'list'])
429450

430451
def update_geo(country: str):
431452
'''

core/scripts/hysteria2/edit_user.sh

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,23 @@ validate_username() {
2020

2121
validate_traffic_limit() {
2222
local traffic_limit=$1
23-
if [ -z "$traffic_limit" ]; then
24-
return 0 # Optional value is valid
23+
if [ -z "$traffic_limit" ]; then
24+
return 0 # Optional value is valid
2525
fi
2626
if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then
27-
echo "Traffic limit must be a valid integer."
27+
echo "Error: Traffic limit must be a valid non-negative number (use 0 for unlimited)."
2828
return 1
2929
fi
3030
return 0
3131
}
3232

3333
validate_expiration_days() {
3434
local expiration_days=$1
35-
if [ -z "$expiration_days" ]; then
35+
if [ -z "$expiration_days" ]; then
3636
return 0 # Optional value is valid
3737
fi
3838
if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then
39-
echo "Expiration days must be a valid integer."
39+
echo "Error: Expiration days must be a valid non-negative number (use 0 for unlimited)."
4040
return 1
4141
fi
4242
return 0
@@ -237,4 +237,4 @@ edit_user() {
237237

238238

239239
# Run the script
240-
edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7"
240+
edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7"

core/scripts/hysteria2/node.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env python3
2+
3+
import sys
4+
import json
5+
import argparse
6+
from pathlib import Path
7+
import re
8+
from ipaddress import ip_address
9+
10+
core_scripts_dir = Path(__file__).resolve().parents[1]
11+
if str(core_scripts_dir) not in sys.path:
12+
sys.path.append(str(core_scripts_dir))
13+
14+
try:
15+
from paths import NODES_JSON_PATH
16+
except ImportError:
17+
NODES_JSON_PATH = Path("/etc/hysteria/nodes.json")
18+
19+
20+
def is_valid_ip_or_domain(value: str) -> bool:
21+
"""Check if the value is a valid IP address or domain name."""
22+
if not value or not value.strip():
23+
return False
24+
value = value.strip()
25+
try:
26+
ip_address(value)
27+
return True
28+
except ValueError:
29+
domain_regex = re.compile(
30+
r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$',
31+
re.IGNORECASE
32+
)
33+
return re.match(domain_regex, value) is not None
34+
35+
def read_nodes():
36+
if not NODES_JSON_PATH.exists():
37+
return []
38+
try:
39+
with NODES_JSON_PATH.open("r") as f:
40+
content = f.read()
41+
if not content:
42+
return []
43+
return json.loads(content)
44+
except (json.JSONDecodeError, IOError, OSError) as e:
45+
sys.exit(f"Error reading or parsing {NODES_JSON_PATH}: {e}")
46+
47+
def write_nodes(nodes):
48+
try:
49+
NODES_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
50+
with NODES_JSON_PATH.open("w") as f:
51+
json.dump(nodes, f, indent=4)
52+
except (IOError, OSError) as e:
53+
sys.exit(f"Error writing to {NODES_JSON_PATH}: {e}")
54+
55+
def add_node(name: str, ip: str):
56+
if not is_valid_ip_or_domain(ip):
57+
print(f"Error: '{ip}' is not a valid IP address or domain name.", file=sys.stderr)
58+
sys.exit(1)
59+
60+
nodes = read_nodes()
61+
if any(node['name'] == name for node in nodes):
62+
print(f"Error: A node with the name '{name}' already exists.", file=sys.stderr)
63+
sys.exit(1)
64+
if any(node['ip'] == ip for node in nodes):
65+
print(f"Error: A node with the IP/domain '{ip}' already exists.", file=sys.stderr)
66+
sys.exit(1)
67+
68+
nodes.append({"name": name, "ip": ip})
69+
write_nodes(nodes)
70+
print(f"Successfully added node '{name}' with IP/domain '{ip}'.")
71+
72+
def delete_node(name: str):
73+
nodes = read_nodes()
74+
original_count = len(nodes)
75+
nodes = [node for node in nodes if node['name'] != name]
76+
77+
if len(nodes) == original_count:
78+
print(f"Error: No node with the name '{name}' found.", file=sys.stderr)
79+
sys.exit(1)
80+
81+
write_nodes(nodes)
82+
print(f"Successfully deleted node '{name}'.")
83+
84+
def list_nodes():
85+
nodes = read_nodes()
86+
if not nodes:
87+
print("No nodes configured.")
88+
return
89+
90+
print(f"{'Name':<30} {'IP Address / Domain'}")
91+
print(f"{'-'*30} {'-'*25}")
92+
for node in sorted(nodes, key=lambda x: x['name']):
93+
print(f"{node['name']:<30} {node['ip']}")
94+
95+
def main():
96+
parser = argparse.ArgumentParser(description="Manage external node configurations.")
97+
subparsers = parser.add_subparsers(dest='command', required=True)
98+
99+
add_parser = subparsers.add_parser('add', help='Add a new node.')
100+
add_parser.add_argument('--name', type=str, required=True, help='The unique name of the node.')
101+
add_parser.add_argument('--ip', type=str, required=True, help='The IP address or domain of the node.')
102+
103+
delete_parser = subparsers.add_parser('delete', help='Delete a node by name.')
104+
delete_parser.add_argument('--name', type=str, required=True, help='The name of the node to delete.')
105+
106+
subparsers.add_parser('list', help='List all configured nodes.')
107+
108+
args = parser.parse_args()
109+
110+
if args.command == 'add':
111+
add_node(args.name, args.ip)
112+
elif args.command == 'delete':
113+
delete_node(args.name)
114+
elif args.command == 'list':
115+
list_nodes()
116+
117+
if __name__ == "__main__":
118+
main()

0 commit comments

Comments
 (0)