Merge pull request #82 from ReturnFI/webpanel

Merge webpanel to main branch
This commit is contained in:
IamSarina
2025-02-09 12:21:37 +03:30
committed by GitHub
78 changed files with 4685 additions and 546 deletions

3
.gitignore vendored
View File

@ -158,3 +158,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
hysteria2_venv/
.vscode/

View File

@ -1 +1,7 @@
1. Add: Manage masquerade
Major Update!!
1. Added web panel service
2. Added API
3. Refactored CLI
4. Renamed systemd services
5. Improved modularization

View File

@ -1,146 +1,132 @@
#!/usr/bin/env python3
from datetime import datetime
import os
import io
import typing
import click
import subprocess
from enum import Enum
import traffic
import validator
import cli_api
import json
SCRIPT_DIR = '/etc/hysteria/core/scripts'
DEBUG = False
def pretty_print(data: typing.Any):
if isinstance(data, dict):
print(json.dumps(data, indent=4))
return
class Command(Enum):
'''Constais path to command's script'''
INSTALL_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'install.sh')
UNINSTALL_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'uninstall.sh')
UPDATE_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'update.sh')
RESTART_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'restart.sh')
CHANGE_PORT_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_port.sh')
CHANGE_SNI_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_sni.sh')
GET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'get_user.sh')
ADD_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'add_user.sh')
EDIT_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'edit_user.sh')
RESET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'reset_user.sh')
REMOVE_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'remove_user.sh')
SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.sh')
IP_ADD = os.path.join(SCRIPT_DIR, 'hysteria2', 'ip.sh')
MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.sh')
MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.sh')
TRAFFIC_STATUS = 'traffic.py' # won't be call directly (it's a python module)
UPDATE_GEO = os.path.join(SCRIPT_DIR, 'hysteria2', 'update_geo.py')
LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.sh')
SERVER_INFO = os.path.join(SCRIPT_DIR, 'hysteria2', 'server_info.sh')
BACKUP_HYSTERIA = os.path.join(SCRIPT_DIR, 'hysteria2', 'backup.sh')
INSTALL_TELEGRAMBOT = os.path.join(SCRIPT_DIR, 'telegrambot', 'runbot.sh')
INSTALL_SINGBOX = os.path.join(SCRIPT_DIR, 'singbox', 'singbox_shell.sh')
INSTALL_NORMALSUB = os.path.join(SCRIPT_DIR, 'normalsub', 'normalsub.sh')
INSTALL_TCP_BRUTAL = os.path.join(SCRIPT_DIR, 'tcp-brutal', 'install.sh')
INSTALL_WARP = os.path.join(SCRIPT_DIR, 'warp', 'install.sh')
UNINSTALL_WARP = os.path.join(SCRIPT_DIR, 'warp', 'uninstall.sh')
CONFIGURE_WARP = os.path.join(SCRIPT_DIR, 'warp', 'configure.sh')
STATUS_WARP = os.path.join(SCRIPT_DIR, 'warp', 'status.sh')
# region utils
def run_cmd(command: list[str]):
'''
Runs a command and returns the output.
Could raise subprocess.CalledProcessError
'''
# if the command is GET_USER or LIST_USERS we don't print the debug-command and just print the output
if DEBUG and not (Command.GET_USER.value in command or Command.LIST_USERS.value in command):
print(' '.join(command))
result = subprocess.check_output(command, shell=False)
print(result.decode().strip())
def generate_password() -> str:
'''
Generates a random password using pwgen for user.
Could raise subprocess.CalledProcessError
'''
return subprocess.check_output(['pwgen', '-s', '32', '1'], shell=False).decode().strip()
# endregion
print(data)
@click.group()
def cli():
pass
# region hysteria2 menu options
# region Hysteria2
@cli.command('install-hysteria2')
@click.option('--port', '-p', required=True, help='Port for Hysteria2', type=int, callback=validator.validate_port)
@click.option('--port', '-p', required=True, help='Port for Hysteria2', type=int)
@click.option('--sni', '-s', required=False, default='bts.com', help='SNI for Hysteria2 (default: bts.com)', type=str)
def install_hysteria2(port: int, sni: str):
"""
Installs Hysteria2 on the given port and uses the provided or default SNI value.
"""
run_cmd(['bash', Command.INSTALL_HYSTERIA2.value, str(port), sni])
try:
cli_api.install_hysteria2(port, sni)
click.echo(f'Hysteria2 installed successfully on port {port} with SNI {sni}.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('uninstall-hysteria2')
def uninstall_hysteria2():
run_cmd(['bash', Command.UNINSTALL_HYSTERIA2.value])
try:
cli_api.uninstall_hysteria2()
click.echo('Hysteria2 uninstalled successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('update-hysteria2')
def update_hysteria2():
run_cmd(['bash', Command.UPDATE_HYSTERIA2.value])
try:
cli_api.update_hysteria2()
click.echo('Hysteria2 updated successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('restart-hysteria2')
def restart_hysteria2():
run_cmd(['bash', Command.RESTART_HYSTERIA2.value])
try:
cli_api.restart_hysteria2()
click.echo('Hysteria2 restarted successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('change-hysteria2-port')
@click.option('--port', '-p', required=True, help='New port for Hysteria2', type=int, callback=validator.validate_port)
@click.option('--port', '-p', required=True, help='New port for Hysteria2', type=int)
def change_hysteria2_port(port: int):
run_cmd(['bash', Command.CHANGE_PORT_HYSTERIA2.value, str(port)])
try:
cli_api.change_hysteria2_port(port)
click.echo(f'Hysteria2 port changed to {port} successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('change-hysteria2-sni')
@click.option('--sni', '-s', required=True, help='New SNI for Hysteria2', type=str)
def change_hysteria2_sni(sni: str):
run_cmd(['bash', Command.CHANGE_SNI_HYSTERIA2.value, sni])
try:
cli_api.change_hysteria2_sni(sni)
click.echo(f'Hysteria2 SNI changed to {sni} successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('backup-hysteria')
def backup_hysteria2():
try:
cli_api.backup_hysteria2()
click.echo('Hysteria configuration backed up successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
# endregion
# region User
@cli.command('list-users')
def list_users():
try:
res = cli_api.list_users()
if res:
pretty_print(res)
else:
click.echo('No users found.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('get-user')
@click.option('--username', '-u', required=True, help='Username for the user to get', type=str)
def get_user(username: str):
cmd = ['bash', Command.GET_USER.value, '-u', str(username)]
run_cmd(cmd)
try:
if res := cli_api.get_user(username):
pretty_print(res)
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('add-user')
@click.option('--username', '-u', required=True, help='Username for the new user', type=str)
@click.option('--traffic-limit', '-t', required=True, help='Traffic limit for the new user in GB', type=int)
@click.option('--expiration-days', '-e', required=True, help='Expiration days for the new user', type=int)
@click.option('--password', '-p', required=False, help='Password for the user', type=str)
@click.option('--creation-date', '-c', required=False, help='Creation date for the user', type=str)
@click.option('--creation-date', '-c', required=False, help='Creation date for the user (YYYY-MM-DD)', type=str)
def add_user(username: str, traffic_limit: int, expiration_days: int, password: str, creation_date: str):
if not password:
try:
password = generate_password()
except subprocess.CalledProcessError as e:
print(f'Error: failed to generate password\n{e}')
exit(1)
if not creation_date:
creation_date = datetime.now().strftime('%Y-%m-%d')
try:
run_cmd(['bash', Command.ADD_USER.value, username, str(traffic_limit), str(expiration_days), password, creation_date])
except subprocess.CalledProcessError as e:
click.echo(f"{e.output.decode()}", err=True)
exit(1)
cli_api.add_user(username, traffic_limit, expiration_days, password, creation_date)
click.echo(f"User '{username}' added successfully.")
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('edit-user')
@click.option('--username', '-u', required=True, help='Username for the user to edit', type=str)
@ -151,61 +137,33 @@ def add_user(username: str, traffic_limit: int, expiration_days: int, password:
@click.option('--renew-creation-date', '-rc', is_flag=True, help='Renew creation date for the user')
@click.option('--blocked', '-b', is_flag=True, help='Block the user')
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):
if not username:
print('Error: username is required')
exit(1)
try:
cli_api.edit_user(username, new_username, new_traffic_limit, new_expiration_days,
renew_password, renew_creation_date, blocked)
click.echo(f"User '{username}' updated successfully.")
except Exception as e:
click.echo(f'{e}', err=True)
if not any([new_username, new_traffic_limit, new_expiration_days, renew_password, renew_creation_date, blocked is not None]):
print('Error: at least one option is required')
exit(1)
if new_traffic_limit is not None and new_traffic_limit <= 0:
print('Error: traffic limit must be greater than 0')
exit(1)
if new_expiration_days is not None and new_expiration_days <= 0:
print('Error: expiration days must be greater than 0')
exit(1)
# Handle renewing password and creation date
if renew_password:
try:
password = generate_password()
except subprocess.CalledProcessError as e:
print(f'Error: failed to generate password\n{e}')
exit(1)
else:
password = ""
if renew_creation_date:
creation_date = datetime.now().strftime('%Y-%m-%d')
else:
creation_date = ""
# Prepare arguments for the command
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,
'true' if blocked else 'false'
]
run_cmd(command_args)
@ cli.command('reset-user')
@ click.option('--username', '-u', required=True, help='Username for the user to Reset', type=str)
@cli.command('reset-user')
@click.option('--username', '-u', required=True, help='Username for the user to Reset', type=str)
def reset_user(username: str):
run_cmd(['bash', Command.RESET_USER.value, username])
try:
cli_api.reset_user(username)
click.echo(f"User '{username}' reset successfully.")
except Exception as e:
click.echo(f'{e}', err=True)
@ cli.command('remove-user')
@ click.option('--username', '-u', required=True, help='Username for the user to remove', type=str)
@cli.command('remove-user')
@click.option('--username', '-u', required=True, help='Username for the user to remove', type=str)
def remove_user(username: str):
run_cmd(['bash', Command.REMOVE_USER.value, username])
try:
cli_api.remove_user(username)
click.echo(f"User '{username}' removed successfully.")
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('show-user-uri')
@click.option('--username', '-u', required=True, help='Username for the user to show the URI', type=str)
@ -215,136 +173,150 @@ def remove_user(username: str):
@click.option('--singbox', '-s', is_flag=True, help='Generate Singbox sublink if Singbox service is active')
@click.option('--normalsub', '-n', is_flag=True, help='Generate Normal sublink if normalsub service is active')
def show_user_uri(username: str, qrcode: bool, ipv: int, all: bool, singbox: bool, normalsub: bool):
command_args = ['bash', Command.SHOW_USER_URI.value, '-u', username]
if qrcode:
command_args.append('-qr')
if all:
command_args.append('-a')
else:
command_args.extend(['-ip', str(ipv)])
if singbox:
command_args.append('-s')
if normalsub:
command_args.append('-n')
try:
res = cli_api.show_user_uri(username, qrcode, ipv, all, singbox, normalsub)
if res:
click.echo(res)
else:
click.echo(f"URI for user '{username}' could not be generated.")
except Exception as e:
click.echo(f'{e}', err=True)
# endregion
run_cmd(command_args)
# region Server
@ cli.command('traffic-status')
@cli.command('traffic-status')
def traffic_status():
traffic.traffic_status()
@ cli.command('list-users')
def list_users():
run_cmd(['bash', Command.LIST_USERS.value])
try:
cli_api.traffic_status()
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('server-info')
def server_info():
output = run_cmd(['bash', Command.SERVER_INFO.value])
if output:
print(output)
@cli.command('backup-hysteria')
def backup_hysteria():
try:
run_cmd(['bash', Command.BACKUP_HYSTERIA.value])
except subprocess.CalledProcessError as e:
click.echo(f"Backup failed: {e.output.decode()}", err=True)
res = cli_api.server_info()
if res:
pretty_print(res)
else:
click.echo('Server information not available.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('manage_obfs')
@click.option('--remove', '-r', is_flag=True, help="Remove 'obfs' from config.json.")
@click.option('--generate', '-g', is_flag=True, help="Generate new 'obfs' in config.json.")
def manage_obfs(remove, generate):
"""Manage 'obfs' in Hysteria2 configuration."""
if remove and generate:
click.echo("Error: You cannot use both --remove and --generate at the same time.")
return
elif remove:
click.echo("Removing 'obfs' from config.json...")
run_cmd(['bash', Command.MANAGE_OBFS.value, '--remove'])
elif generate:
click.echo("Generating 'obfs' in config.json...")
run_cmd(['bash', Command.MANAGE_OBFS.value, '--generate'])
else:
click.echo("Error: Please specify either --remove or --generate.")
def manage_obfs(remove: bool, generate: bool):
try:
if not remove and not generate:
raise click.UsageError('Error: You must use either --remove or --generate')
if remove and generate:
raise click.UsageError('Error: You cannot use both --remove and --generate at the same time')
if generate:
cli_api.enable_hysteria2_obfs()
click.echo('Obfs enabled successfully.')
elif remove:
cli_api.disable_hysteria2_obfs()
click.echo('Obfs disabled successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('ip-address')
@click.option('--edit', is_flag=True, help="Edit IP addresses manually.")
@click.option('-4', '--ipv4', type=str, help="Specify the new IPv4 address.")
@click.option('-6', '--ipv6', type=str, help="Specify the new IPv6 address.")
def ip_address(edit, ipv4, ipv6):
"""
@click.option('--edit', is_flag=True, help='Edit IP addresses manually.')
@click.option('-4', '--ipv4', type=str, help='Specify the new IPv4 address.')
@click.option('-6', '--ipv6', type=str, help='Specify the new IPv6 address.')
def ip_address(edit: bool, ipv4: str, ipv6: str):
'''
Manage IP addresses in .configs.env.
- Use without options to add auto-detected IPs.
- Use --edit with -4 or -6 to manually update IPs.
"""
if edit:
if ipv4:
run_cmd(['bash', Command.IP_ADD.value, 'edit', '-4', ipv4])
if ipv6:
run_cmd(['bash', Command.IP_ADD.value, 'edit', '-6', ipv6])
'''
try:
if not edit:
cli_api.add_ip_address()
click.echo('IP addresses added successfully.')
return
if not ipv4 and not ipv6:
click.echo("Error: --edit requires at least one of --ipv4 or --ipv6.")
else:
run_cmd(['bash', Command.IP_ADD.value, 'add'])
raise click.UsageError('Error: You must specify either -4 or -6')
cli_api.edit_ip_address(ipv4, ipv6)
click.echo('IP address configuration updated successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('update-geo')
@click.option('--country', '-c',
@click.option('--country', '-c',
type=click.Choice(['iran', 'china', 'russia'], case_sensitive=False),
default='iran',
help='Select country for geo files (default: iran)')
def cli_update_geo(country):
script_path = Command.UPDATE_GEO.value
def update_geo(country: str):
try:
subprocess.run(['python3', script_path, country.lower()], check=True)
except subprocess.CalledProcessError as e:
print(f"Failed to update geo files: {e}")
except FileNotFoundError:
print(f"Script not found: {script_path}")
cli_api.update_geo(country)
click.echo(f'Geo files for {country} updated successfully.')
except Exception as e:
print(f"An unexpected error occurred: {e}")
click.echo(f'{e}', err=True)
@cli.command('masquerade')
@click.option('--remove', '-r', is_flag=True, help="Remove 'masquerade' from config.json.")
@click.option('--enable', '-e', metavar="<domain>", type=str, help="Enable 'masquerade' in config.json with the specified domain.")
def masquerade(remove, enable):
"""Manage 'masquerade' in Hysteria2 configuration."""
if remove and enable:
click.echo("Error: You cannot use both --remove and --enable at the same time.")
return
elif remove:
click.echo("Removing 'masquerade' from config.json...")
run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '2'])
elif enable:
if not enable:
click.echo("Error: You must specify a domain with --enable.")
return
click.echo(f"Enabling 'masquerade' with URL: {enable}...")
run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '1', enable])
else:
click.echo("Error: Please specify either --remove or --enable.")
@click.option('--enable', '-e', metavar='<domain>', type=str, help="Enable 'masquerade' in config.json with the specified domain.")
def masquerade(remove: bool, enable: str):
'''Manage 'masquerade' in Hysteria2 configuration.'''
try:
if not remove and not enable:
raise click.UsageError('Error: You must use either --remove or --enable')
if remove and enable:
raise click.UsageError('Error: You cannot use both --remove and --enable at the same time')
if enable:
# NOT SURE THIS IS NEEDED
# if not enable.startswith('http://') and not enable.startswith('https://'):
# enable = 'https://' + enable
cli_api.enable_hysteria2_masquerade(enable)
click.echo('Masquerade enabled successfully.')
elif remove:
cli_api.disable_hysteria2_masquerade()
click.echo('Masquerade disabled successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
# endregion
# region advanced menu
# region Advanced Menu
@ cli.command('install-tcp-brutal')
@cli.command('install-tcp-brutal')
def install_tcp_brutal():
run_cmd(['bash', Command.INSTALL_TCP_BRUTAL.value])
try:
cli_api.install_tcp_brutal()
click.echo('TCP Brutal installed successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@ cli.command('install-warp')
@cli.command('install-warp')
def install_warp():
run_cmd(['bash', Command.INSTALL_WARP.value])
try:
cli_api.install_warp()
click.echo('WARP installed successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@ cli.command('uninstall-warp')
@cli.command('uninstall-warp')
def uninstall_warp():
run_cmd(['bash', Command.UNINSTALL_WARP.value])
try:
cli_api.uninstall_warp()
click.echo('WARP uninstalled successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('configure-warp')
@ -353,81 +325,156 @@ def uninstall_warp():
@click.option('--domestic-sites', '-d', is_flag=True, help='Use WARP for Iran domestic sites')
@click.option('--block-adult-sites', '-x', is_flag=True, help='Block adult content (porn)')
@click.option('--warp-option', '-w', type=click.Choice(['warp', 'warp plus'], case_sensitive=False), help='Specify whether to use WARP or WARP Plus')
@click.option('--warp-key', '-k', help='WARP Plus key (required if warp-option is "warp plus")')
@click.option('--warp-key', '-k', help="WARP Plus key (required if warp-option is 'warp plus')")
def configure_warp(all: bool, popular_sites: bool, domestic_sites: bool, block_adult_sites: bool, warp_option: str, warp_key: str):
if warp_option == 'warp plus' and not warp_key:
print("Error: WARP Plus key is required when 'warp plus' is selected.")
return
try:
cli_api.configure_warp(all, popular_sites, domestic_sites, block_adult_sites, warp_option, warp_key)
click.echo('WARP configured successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
options = {
"all": 'true' if all else 'false',
"popular_sites": 'true' if popular_sites else 'false',
"domestic_sites": 'true' if domestic_sites else 'false',
"block_adult_sites": 'true' if block_adult_sites else 'false',
"warp_option": warp_option or '',
"warp_key": warp_key or ''
}
cmd_args = [
'bash', Command.CONFIGURE_WARP.value,
options['all'],
options['popular_sites'],
options['domestic_sites'],
options['block_adult_sites'],
options['warp_option']
]
if options['warp_key']:
cmd_args.append(options['warp_key'])
run_cmd(cmd_args)
@cli.command('warp-status')
def warp_status():
output = run_cmd(['bash', Command.STATUS_WARP.value])
if output:
print(output)
try:
res = cli_api.warp_status()
if res:
pretty_print(res)
else:
click.echo('WARP status not available.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('telegram')
@click.option('--action', '-a', required=True, help='Action to perform: start or stop', type=click.Choice(['start', 'stop'], case_sensitive=False))
@click.option('--token', '-t', required=False, help='Token for running the telegram bot', type=str)
@click.option('--adminid', '-aid', required=False, help='Telegram admins ID for running the telegram bot', type=str)
def telegram(action: str, token: str, adminid: str):
if action == 'start':
if not token or not adminid:
print("Error: Both --token and --adminid are required for the start action.")
return
admin_ids = f'{adminid}'
run_cmd(['bash', Command.INSTALL_TELEGRAMBOT.value, 'start', token, admin_ids])
elif action == 'stop':
run_cmd(['bash', Command.INSTALL_TELEGRAMBOT.value, 'stop'])
try:
if action == 'start':
if not token or not adminid:
raise click.UsageError('Error: Both --token and --adminid are required for the start action.')
cli_api.start_telegram_bot(token, adminid)
click.echo(f'Telegram bot started successfully.')
elif action == 'stop':
cli_api.stop_telegram_bot()
click.echo(f'Telegram bot stopped successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('singbox')
@click.option('--action', '-a', required=True, help='Action to perform: start or stop', type=click.Choice(['start', 'stop'], case_sensitive=False))
@click.option('--domain', '-d', required=False, help='Domain name for SSL', type=str)
@click.option('--port', '-p', required=False, help='Port number for Singbox service', type=int)
def singbox(action: str, domain: str, port: int):
if action == 'start':
if not domain or not port:
click.echo("Error: Both --domain and --port are required for the start action.")
return
run_cmd(['bash', Command.INSTALL_SINGBOX.value, 'start', domain, str(port)])
elif action == 'stop':
run_cmd(['bash', Command.INSTALL_SINGBOX.value, 'stop'])
try:
if action == 'start':
if not domain or not port:
raise click.UsageError('Error: Both --domain and --port are required for the start action.')
cli_api.start_singbox(domain, port)
click.echo(f'Singbox started successfully.')
elif action == 'stop':
cli_api.stop_singbox()
click.echo(f'Singbox stopped successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('normal-sub')
@click.option('--action', '-a', required=True, help='Action to perform: start or stop', type=click.Choice(['start', 'stop'], case_sensitive=False))
@click.option('--domain', '-d', required=False, help='Domain name for SSL', type=str)
@click.option('--port', '-p', required=False, help='Port number for NormalSub service', type=int)
def normalsub(action: str, domain: str, port: int):
if action == 'start':
if not domain or not port:
click.echo("Error: Both --domain and --port are required for the start action.")
return
run_cmd(['bash', Command.INSTALL_NORMALSUB.value, 'start', domain, str(port)])
elif action == 'stop':
run_cmd(['bash', Command.INSTALL_NORMALSUB.value, 'stop'])
try:
if action == 'start':
if not domain or not port:
raise click.UsageError('Error: Both --domain and --port are required for the start action.')
cli_api.start_normalsub(domain, port)
click.echo(f'NormalSub started successfully.')
elif action == 'stop':
cli_api.stop_normalsub()
click.echo(f'NormalSub stopped successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('webpanel')
@click.option('--action', '-a', required=True, help='Action to perform: start or stop', type=click.Choice(['start', 'stop'], case_sensitive=False))
@click.option('--domain', '-d', required=False, help='Domain name for SSL', type=str)
@click.option('--port', '-p', required=False, help='Port number for WebPanel service', type=int)
@click.option('--admin-username', '-au', required=False, help='Admin username for WebPanel', type=str)
@click.option('--admin-password', '-ap', required=False, help='Admin password for WebPanel', type=str)
@click.option('--expiration-minutes', '-e', required=False, help='Expiration minutes for WebPanel', type=int, default=20)
@click.option('--debug', '-g', is_flag=True, help='Enable debug mode for WebPanel', default=False)
def webpanel(action: str, domain: str, port: int, admin_username: str, admin_password: str, expiration_minutes: int, debug: bool):
try:
if action == 'start':
if not domain or not port or not admin_username or not admin_password:
raise click.UsageError('Error: the --domain, --port, --admin-username, and --admin-password are required for the start action.')
cli_api.start_webpanel(domain, port, admin_username, admin_password, expiration_minutes, debug)
services_status = cli_api.get_services_status()
if not services_status:
raise click.Abort('Error: WebPanel services status not available.')
if not services_status.get('hysteria-webpanel.service'):
raise click.Abort('Error: hysteria-webpanel.service service is not running.')
if not services_status.get('hysteria-caddy.service'):
raise click.Abort('Error: hysteria-caddy.service service is not running.')
url = cli_api.get_webpanel_url()
click.echo(f'Hysteria web panel is now running. The service is accessible on: {url}')
elif action == 'stop':
cli_api.stop_webpanel()
click.echo(f'WebPanel stopped successfully.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('get-webpanel-url')
def get_web_panel_url():
try:
url = cli_api.get_webpanel_url()
click.echo(f'Hysteria web panel is now running. The service is accessible on: {url}')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('get-webpanel-api-token')
def get_web_panel_api_token():
try:
token = cli_api.get_webpanel_api_token()
click.echo(f'WebPanel API token: {token}')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('get-webpanel-services-status')
def get_web_panel_services_status():
try:
if services_status := cli_api.get_services_status():
webpanel_status = services_status.get('hysteria-webpanel.service', False)
caddy_status = services_status.get('hysteria-caddy.service', False)
print(f"hysteria-webpanel.service: {'Active' if webpanel_status else 'Inactive'}")
print(f"hysteria-caddy.service: {'Active' if caddy_status else 'Inactive'}")
else:
click.echo('Error: Services status not available.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('get-services-status')
def get_services_status():
try:
if services_status := cli_api.get_services_status():
for service, status in services_status.items():
click.echo(f"{service}: {'Active' if status else 'Inactive'}")
else:
click.echo('Error: Services status not available.')
except Exception as e:
click.echo(f'{e}', err=True)
# endregion

469
core/cli_api.py Normal file
View File

@ -0,0 +1,469 @@
import os
import subprocess
from enum import Enum
from datetime import datetime
import json
from typing import Any
from dotenv import dotenv_values
import traffic
DEBUG = False
SCRIPT_DIR = '/etc/hysteria/core/scripts'
CONFIG_FILE = '/etc/hysteria/config.json'
class Command(Enum):
'''Contains path to command's script'''
INSTALL_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'install.sh')
UNINSTALL_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'uninstall.sh')
UPDATE_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'update.sh')
RESTART_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'restart.sh')
CHANGE_PORT_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_port.sh')
CHANGE_SNI_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_sni.sh')
GET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'get_user.sh')
ADD_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'add_user.sh')
EDIT_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'edit_user.sh')
RESET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'reset_user.sh')
REMOVE_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'remove_user.sh')
SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.sh')
IP_ADD = os.path.join(SCRIPT_DIR, 'hysteria2', 'ip.sh')
MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.sh')
MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.sh')
TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module)
UPDATE_GEO = os.path.join(SCRIPT_DIR, 'hysteria2', 'update_geo.py')
LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.sh')
SERVER_INFO = os.path.join(SCRIPT_DIR, 'hysteria2', 'server_info.sh')
BACKUP_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'backup.sh')
INSTALL_TELEGRAMBOT = os.path.join(SCRIPT_DIR, 'telegrambot', 'runbot.sh')
SHELL_SINGBOX = os.path.join(SCRIPT_DIR, 'singbox', 'singbox_shell.sh')
SHELL_WEBPANEL = os.path.join(SCRIPT_DIR, 'webpanel', 'webpanel_shell.sh')
INSTALL_NORMALSUB = os.path.join(SCRIPT_DIR, 'normalsub', 'normalsub.sh')
INSTALL_TCP_BRUTAL = os.path.join(SCRIPT_DIR, 'tcp-brutal', 'install.sh')
INSTALL_WARP = os.path.join(SCRIPT_DIR, 'warp', 'install.sh')
UNINSTALL_WARP = os.path.join(SCRIPT_DIR, 'warp', 'uninstall.sh')
CONFIGURE_WARP = os.path.join(SCRIPT_DIR, 'warp', 'configure.sh')
STATUS_WARP = os.path.join(SCRIPT_DIR, 'warp', 'status.sh')
SERVICES_STATUS = os.path.join(SCRIPT_DIR, 'services_status.sh')
# region Custom Exceptions
class HysteriaError(Exception):
'''Base class for Hysteria-related exceptions.'''
pass
class CommandExecutionError(HysteriaError):
'''Raised when a command execution fails.'''
pass
class InvalidInputError(HysteriaError):
'''Raised when the provided input is invalid.'''
pass
class PasswordGenerationError(HysteriaError):
'''Raised when password generation fails.'''
pass
class ScriptNotFoundError(HysteriaError):
'''Raised when a required script is not found.'''
pass
# region Utils
def run_cmd(command: list[str]) -> str | None:
'''
Runs a command and returns the output.
Could raise subprocess.CalledProcessError
'''
if (DEBUG) and not (Command.GET_USER.value in command or Command.LIST_USERS.value in command):
print(' '.join(command))
try:
result = subprocess.check_output(command, stderr=subprocess.STDOUT, shell=False)
if result:
result = result.decode().strip()
return result
except subprocess.CalledProcessError as e:
if DEBUG:
raise CommandExecutionError(f'Command execution failed: {e}\nOutput: {e.output.decode()}')
else:
return None
return None
def generate_password() -> str:
'''
Generates a random password using pwgen for user.
Could raise subprocess.CalledProcessError
'''
try:
return subprocess.check_output(['pwgen', '-s', '32', '1'], shell=False).decode().strip()
except subprocess.CalledProcessError as e:
raise PasswordGenerationError(f'Failed to generate password: {e}')
# endregion
# region APIs
# region Hysteria
def install_hysteria2(port: int, sni: str):
'''
Installs Hysteria2 on the given port and uses the provided or default SNI value.
'''
run_cmd(['bash', Command.INSTALL_HYSTERIA2.value, str(port), sni])
def uninstall_hysteria2():
'''Uninstalls Hysteria2.'''
run_cmd(['bash', Command.UNINSTALL_HYSTERIA2.value])
def update_hysteria2():
'''Updates Hysteria2.'''
run_cmd(['bash', Command.UPDATE_HYSTERIA2.value])
def restart_hysteria2():
'''Restarts Hysteria2.'''
run_cmd(['bash', Command.RESTART_HYSTERIA2.value])
def change_hysteria2_port(port: int):
'''
Changes the port for Hysteria2.
'''
run_cmd(['bash', Command.CHANGE_PORT_HYSTERIA2.value, str(port)])
def change_hysteria2_sni(sni: str):
'''
Changes the SNI for Hysteria2.
'''
run_cmd(['bash', Command.CHANGE_SNI_HYSTERIA2.value, sni])
def backup_hysteria2():
'''Backups Hysteria configuration.'''
run_cmd(['bash', Command.BACKUP_HYSTERIA2.value])
def enable_hysteria2_obfs():
'''Generates 'obfs' in Hysteria2 configuration.'''
run_cmd(['bash', Command.MANAGE_OBFS.value, '--generate'])
def disable_hysteria2_obfs():
'''Removes 'obfs' from Hysteria2 configuration.'''
run_cmd(['bash', Command.MANAGE_OBFS.value, '--remove'])
def enable_hysteria2_masquerade(domain: str):
'''Enables masquerade for Hysteria2.'''
run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '1', domain])
def disable_hysteria2_masquerade():
'''Disables masquerade for Hysteria2.'''
run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '2'])
def get_hysteria2_config_file() -> dict[str, Any]:
with open(CONFIG_FILE, 'r') as f:
return json.loads(f.read())
def set_hysteria2_config_file(data: dict[str, Any]):
content = json.dumps(data, indent=4)
with open(CONFIG_FILE, 'w') as f:
f.write(content)
# endregion
# region User
def list_users() -> dict[str, dict[str, Any]] | None:
'''
Lists all users.
'''
if res := run_cmd(['bash', Command.LIST_USERS.value]):
return json.loads(res)
def get_user(username: str) -> dict[str, Any] | None:
'''
Retrieves information about a specific user.
'''
if res := run_cmd(['bash', Command.GET_USER.value, '-u', str(username)]):
return json.loads(res)
def add_user(username: str, traffic_limit: int, expiration_days: int, password: str | None, creation_date: str | None):
'''
Adds a new user with the given parameters.
'''
if not password:
password = generate_password()
if not creation_date:
creation_date = datetime.now().strftime('%Y-%m-%d')
run_cmd(['bash', Command.ADD_USER.value, username, str(traffic_limit), str(expiration_days), password, creation_date])
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):
'''
Edits an existing user's details.
'''
if not username:
raise InvalidInputError('Error: username is required')
if not any([new_username, new_traffic_limit, new_expiration_days, renew_password, renew_creation_date, blocked is not None]): # type: ignore
raise InvalidInputError('Error: at least one option is required')
if new_traffic_limit is not None and new_traffic_limit <= 0:
raise InvalidInputError('Error: traffic limit must be greater than 0')
if new_expiration_days is not None and new_expiration_days <= 0:
raise InvalidInputError('Error: expiration days must be greater than 0')
if renew_password:
password = generate_password()
else:
password = ''
if renew_creation_date:
creation_date = datetime.now().strftime('%Y-%m-%d')
else:
creation_date = ''
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,
'true' if blocked else 'false'
]
run_cmd(command_args)
def reset_user(username: str):
'''
Resets a user's configuration.
'''
run_cmd(['bash', Command.RESET_USER.value, username])
def remove_user(username: str):
'''
Removes a user by username.
'''
run_cmd(['bash', Command.REMOVE_USER.value, username])
# TODO: it's better to return json
def show_user_uri(username: str, qrcode: bool, ipv: int, all: bool, singbox: bool, normalsub: bool) -> str | None:
'''
Displays the URI for a user, with options for QR code and other formats.
'''
command_args = ['bash', Command.SHOW_USER_URI.value, '-u', username]
if qrcode:
command_args.append('-qr')
if all:
command_args.append('-a')
else:
command_args.extend(['-ip', str(ipv)])
if singbox:
command_args.append('-s')
if normalsub:
command_args.append('-n')
return run_cmd(command_args)
# endregion
# region Server
def traffic_status():
'''Fetches traffic status.'''
traffic.traffic_status()
# TODO: it's better to return json
def server_info() -> str | None:
'''Retrieves server information.'''
return run_cmd(['bash', Command.SERVER_INFO.value])
def get_ip_address() -> tuple[str | None, str | None]:
'''
Retrieves the IP address from the .configs.env file.
'''
env_file_path = os.path.join(SCRIPT_DIR, '..', '..', '.configs.env')
env_vars = dotenv_values(env_file_path)
return env_vars.get('IP4'), env_vars.get('IP6')
def add_ip_address():
'''
Adds IP addresses from the environment to the .configs.env file.
'''
run_cmd(['bash', Command.IP_ADD.value, 'add'])
def edit_ip_address(ipv4: str, ipv6: str):
'''
Edits the IP address configuration based on provided IPv4 and/or IPv6 addresses.
:param ipv4: The new IPv4 address to be configured. If provided, the IPv4 address will be updated.
:param ipv6: The new IPv6 address to be configured. If provided, the IPv6 address will be updated.
:raises InvalidInputError: If neither ipv4 nor ipv6 is provided.
'''
if not ipv4 and not ipv6:
raise InvalidInputError('Error: --edit requires at least one of --ipv4 or --ipv6.')
if ipv4:
run_cmd(['bash', Command.IP_ADD.value, 'edit', '-4', ipv4])
if ipv6:
run_cmd(['bash', Command.IP_ADD.value, 'edit', '-6', ipv6])
def update_geo(country: str):
'''
Updates geographic data files based on the specified country.
'''
script_path = Command.UPDATE_GEO.value
try:
subprocess.run(['python3', script_path, country.lower()], check=True)
except subprocess.CalledProcessError as e:
raise CommandExecutionError(f'Failed to update geo files: {e}')
except FileNotFoundError:
raise ScriptNotFoundError(f'Script not found: {script_path}')
except Exception as e:
raise HysteriaError(f'An unexpected error occurred: {e}')
# endregion
# region Advanced Menu
def install_tcp_brutal():
'''Installs TCP Brutal.'''
run_cmd(['bash', Command.INSTALL_TCP_BRUTAL.value])
def install_warp():
'''Installs WARP.'''
run_cmd(['bash', Command.INSTALL_WARP.value])
def uninstall_warp():
'''Uninstalls WARP.'''
run_cmd(['bash', Command.UNINSTALL_WARP.value])
def configure_warp(all: bool, popular_sites: bool, domestic_sites: bool, block_adult_sites: bool, warp_option: str, warp_key: str):
'''
Configures WARP with various options.
'''
if warp_option == 'warp plus' and not warp_key:
raise InvalidInputError('Error: WARP Plus key is required when \'warp plus\' is selected.')
options = {
'all': 'true' if all else 'false',
'popular_sites': 'true' if popular_sites else 'false',
'domestic_sites': 'true' if domestic_sites else 'false',
'block_adult_sites': 'true' if block_adult_sites else 'false',
'warp_option': warp_option or '',
'warp_key': warp_key or ''
}
cmd_args = [
'bash', Command.CONFIGURE_WARP.value,
options['all'],
options['popular_sites'],
options['domestic_sites'],
options['block_adult_sites'],
options['warp_option']
]
if options['warp_key']:
cmd_args.append(options['warp_key'])
run_cmd(cmd_args)
def warp_status() -> str | None:
'''Checks the status of WARP.'''
return run_cmd(['bash', Command.STATUS_WARP.value])
def start_telegram_bot(token: str, adminid: str):
'''Starts the Telegram bot.'''
if not token or not adminid:
raise InvalidInputError('Error: Both --token and --adminid are required for the start action.')
run_cmd(['bash', Command.INSTALL_TELEGRAMBOT.value, 'start', token, adminid])
def stop_telegram_bot():
'''Stops the Telegram bot.'''
run_cmd(['bash', Command.INSTALL_TELEGRAMBOT.value, 'stop'])
def start_singbox(domain: str, port: int):
'''Starts Singbox.'''
if not domain or not port:
raise InvalidInputError('Error: Both --domain and --port are required for the start action.')
run_cmd(['bash', Command.SHELL_SINGBOX.value, 'start', domain, str(port)])
def stop_singbox():
'''Stops Singbox.'''
run_cmd(['bash', Command.SHELL_SINGBOX.value, 'stop'])
def start_normalsub(domain: str, port: int):
'''Starts NormalSub.'''
if not domain or not port:
raise InvalidInputError('Error: Both --domain and --port are required for the start action.')
run_cmd(['bash', Command.INSTALL_NORMALSUB.value, 'start', domain, str(port)])
def stop_normalsub():
'''Stops NormalSub.'''
run_cmd(['bash', Command.INSTALL_NORMALSUB.value, 'stop'])
def start_webpanel(domain: str, port: int, admin_username: str, admin_password: str, expiration_minutes: int, debug: bool):
'''Starts WebPanel.'''
if not domain or not port or not admin_username or not admin_password or not expiration_minutes:
raise InvalidInputError('Error: Both --domain and --port are required for the start action.')
run_cmd(
['bash', Command.SHELL_WEBPANEL.value, 'start',
domain, str(port), admin_username, admin_password, str(expiration_minutes), str(debug).lower()]
)
def stop_webpanel():
'''Stops WebPanel.'''
run_cmd(['bash', Command.SHELL_WEBPANEL.value, 'stop'])
def get_webpanel_url() -> str | None:
'''Gets the URL of WebPanel.'''
return run_cmd(['bash', Command.SHELL_WEBPANEL.value, 'url'])
def get_webpanel_api_token() -> str | None:
'''Gets the API token of WebPanel.'''
return run_cmd(['bash', Command.SHELL_WEBPANEL.value, 'api-token'])
def get_services_status() -> dict[str, bool] | None:
'''Gets the status of all project services.'''
if res := run_cmd(['bash', Command.SERVICES_STATUS.value]):
return json.loads(res)
# endregion
# endregion

View File

@ -3,77 +3,88 @@
source /etc/hysteria/core/scripts/utils.sh
source /etc/hysteria/core/scripts/path.sh
# Function to validate all user input fields
validate_inputs() {
local new_username=$1
local new_password=$2
local new_traffic_limit=$3
local new_expiration_days=$4
local new_creation_date=$5
local new_blocked=$6
readonly GB_TO_BYTES=$((1024 * 1024 * 1024))
# Validate username
if [ -n "$new_username" ]; then
if ! [[ "$new_username" =~ ^[a-zA-Z0-9]+$ ]]; then
echo -e "${red}Error:${NC} Username can only contain letters and numbers."
exit 1
fi
validate_username() {
local username=$1
if [ -z "$username" ]; then
return 0 # Optional value is valid
fi
# Validate traffic limit
if [ -n "$new_traffic_limit" ]; then
if ! [[ "$new_traffic_limit" =~ ^[0-9]+$ ]]; then
echo -e "${red}Error:${NC} Traffic limit must be a valid integer."
exit 1
fi
fi
# Validate expiration days
if [ -n "$new_expiration_days" ]; then
if ! [[ "$new_expiration_days" =~ ^[0-9]+$ ]]; then
echo -e "${red}Error:${NC} Expiration days must be a valid integer."
exit 1
fi
fi
# Validate date format
if [ -n "$new_creation_date" ]; then
if ! [[ "$new_creation_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "Invalid date format. Expected YYYY-MM-DD."
exit 1
elif ! date -d "$new_creation_date" >/dev/null 2>&1; then
echo "Invalid date. Please provide a valid date in YYYY-MM-DD format."
exit 1
fi
fi
# Validate blocked status
if [ -n "$new_blocked" ]; then
if [ "$new_blocked" != "true" ] && [ "$new_blocked" != "false" ] && [ "$new_blocked" != "y" ] && [ "$new_blocked" != "n" ]; then
echo -e "${red}Error:${NC} Blocked status must be 'true', 'false', 'y', or 'n'."
exit 1
fi
if ! [[ "$username" =~ ^[a-zA-Z0-9]+$ ]]; then
echo "Username can only contain letters and numbers."
return 1
fi
return 0
}
# Convert 'y'/'n' to 'true'/'false'
validate_traffic_limit() {
local traffic_limit=$1
if [ -z "$traffic_limit" ]; then
return 0 # Optional value is valid
fi
if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then
echo "Traffic limit must be a valid integer."
return 1
fi
return 0
}
validate_expiration_days() {
local expiration_days=$1
if [ -z "$expiration_days" ]; then
return 0 # Optional value is valid
fi
if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then
echo "Expiration days must be a valid integer."
return 1
fi
return 0
}
validate_date() {
local date_str=$1
if [ -z "$date_str" ]; then
return 0 # Optional value is valid
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 # Optional value is valid
fi
if [ "$status" != "true" ] && [ "$status" != "false" ]; then
echo "Blocked status must be 'true' or 'false'."
return 1
fi
return 0
}
convert_blocked_status() {
local status=$1
case "$status" in
y|Y) echo "true" ;;
n|N) echo "false" ;;
true|false) echo "$status" ;;
*) echo "false" ;; # Default to false if something unexpected
*) echo "false" ;; # Default to false for safety
esac
}
# Function to get user info from users.json
get_user_info() {
local username=$1
python3 $CLI_PATH get-user -u "$username"
}
# Function to update user info in users.json
update_user_info() {
local old_username=$1
local new_username=$2
@ -84,72 +95,69 @@ update_user_info() {
local new_blocked=$7
if [ ! -f "$USERS_FILE" ]; then
echo "Error: File '$USERS_FILE' not found."
echo -e "${red}Error:${NC} File '$USERS_FILE' not found."
return 1
fi
echo "Checking if user exists"
user_exists=$(jq -e --arg username "$old_username" '.[$username]' "$USERS_FILE")
if [ $? -ne 0 ]; then
echo "Error: User '$old_username' not found."
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
echo "User exists."
# Get existing user data
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"')
# Debugging output
echo "Updating user:"
echo "Username: $new_username"
echo "Password: $new_password"
echo "Max Download Bytes: $new_max_download_bytes"
echo "Expiration Days: $new_expiration_days"
echo "Creation Date: $new_account_creation_date"
echo "Blocked: $new_blocked"
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"
# Update user fields, only if new values are provided
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_creation_date:-null}" \
--argjson blocked "$(convert_blocked_status "${new_blocked:-false}")" \
--argjson upload_bytes "$upload_bytes" \
--argjson download_bytes "$download_bytes" \
--arg status "$status" \
'
.[$new_username] = .[$old_username] |
del(.[$old_username]) |
.[$new_username] |= (
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 "$(convert_blocked_status "${new_blocked:-false}")" \
--argjson upload_bytes "$upload_bytes" \
--argjson download_bytes "$download_bytes" \
--arg status "$status" \
'
.[$new_username] = .[$old_username] |
del(.[$old_username]) |
.[$new_username] |= (
.password = ($password // .password) |
.max_download_bytes = ($max_download_bytes // .max_download_bytes) |
.expiration_days = ($expiration_days // .expiration_days) |
.account_creation_date = ($account_creation_date // .account_creation_date) |
.blocked = $blocked |
.upload_bytes = $upload_bytes |
.blocked = $blocked |
.upload_bytes = $upload_bytes |
.download_bytes = $download_bytes |
.status = $status
)' "$USERS_FILE" > tmp.$$.json && mv tmp.$$.json "$USERS_FILE"
)
' "$USERS_FILE" > tmp.$$.json && mv tmp.$$.json "$USERS_FILE"
if [ $? -ne 0 ]; then
echo "Error: Failed to update user '$old_username' in '$USERS_FILE'."
return 1
fi
if [ $? -ne 0 ]; then
echo "Failed to update user '$old_username' in '$USERS_FILE'."
return 1
fi
python3 $CLI_PATH restart-hysteria2
if [ $? -ne 0 ]; then
echo "Error: Failed to restart Hysteria service."
exit 1
fi
return 0
}
# Main function to edit user
edit_user() {
local username=$1
local new_username=$2
@ -159,50 +167,78 @@ edit_user() {
local new_creation_date=$6
local new_blocked=$7
# Get user info
user_info=$(get_user_info "$username")
local user_info=$(get_user_info "$username")
if [ $? -ne 0 ] || [ -z "$user_info" ]; then
echo -e "${red}Error:${NC} User '$username' not found."
exit 1
echo "User '$username' not found."
return 1
fi
# Extract user info
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')
# Validate all inputs
validate_inputs "$new_username" "$new_password" "$new_traffic_limit" "$new_expiration_days" "$new_creation_date" "$new_blocked"
if ! validate_username "$new_username"; then
echo "Invalid username: $new_username"
return 1
fi
# Set new values with validation
new_username=${new_username:-$username}
new_password=${new_password:-$password}
# Convert traffic limit to bytes if provided, otherwise keep existing
if [ -n "$new_traffic_limit" ]; then
new_traffic_limit=$(echo "$new_traffic_limit * 1073741824" | bc)
else
new_traffic_limit=$traffic_limit
if ! validate_traffic_limit "$new_traffic_limit"; then
echo "Invalid traffic limit: $new_traffic_limit"
return 1
fi
new_expiration_days=${new_expiration_days:-$expiration_days}
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
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_blocked_status "${new_blocked:-$blocked}")
# Update user info in JSON file
update_user_info "$username" "$new_username" "$new_password" "$new_traffic_limit" "$new_expiration_days" "$new_creation_date" "$new_blocked"
python3 $CLI_PATH restart-hysteria2
if ! update_user_info "$username" "$new_username" "$new_password" "$new_traffic_limit" "$new_expiration_days" "$new_creation_date" "$new_blocked"; then
return 1 # Update user failed
fi
if [ $? -ne 0 ]; then
echo "Error: Failed to restart Hysteria service."
exit 1
fi
if [ $? -ne 0 ]; then
echo "Failed to restart Hysteria service."
return 1
fi
echo "User updated successfully."
return 0 # Operation complete without error.
echo "Hysteria service restarted successfully."
}
# Run the script
edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7"

View File

@ -33,8 +33,10 @@ generate_obfs() {
}
if [[ $1 == "--remove" || $1 == "-r" ]]; then
echo "Removing 'obfs' from config.json..."
remove_obfs
elif [[ $1 == "--generate" || $1 == "-g" ]]; then
echo "Generating 'obfs' in config.json..."
generate_obfs
else
echo "Usage: $0 --remove|-r | --generate|-g"

View File

@ -27,8 +27,10 @@ function remove_masquerade() {
}
if [[ "$1" == "1" ]]; then
echo "Enabling 'masquerade' with URL: $2..."
enable_masquerade "$2"
elif [[ "$1" == "2" ]]; then
echo "Removing 'masquerade' from config.json..."
remove_masquerade
else
echo "Usage: $0 {1|2} [domain]"

View File

@ -31,6 +31,8 @@ convert_bytes() {
fi
}
# iT'S BETTER TO PRINT BYTES ITSELF AND NOT HUMAN READABLE FORMAT BECAUSE THE CALLER SHOULD DECIDE WHAT TO PRINT
cpu_usage=$(top -bn1 | grep "Cpu(s)" | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print 100 - $1"%"}')
total_ram=$(free -m | awk '/Mem:/ {print $2}')
used_ram=$(free -m | awk '/Mem:/ {print $3}')
@ -48,7 +50,7 @@ echo "📋 Total RAM: ${total_ram}MB"
echo "💻 Used RAM: ${used_ram}MB"
echo "👥 Online Users: $online_user_count"
echo
echo "🚦Total Traffic: "
#echo "🚦Total Traffic: "
if [ -f "$USERS_FILE" ]; then
total_upload=0
@ -64,10 +66,10 @@ if [ -f "$USERS_FILE" ]; then
total_upload_human=$(convert_bytes $total_upload)
total_download_human=$(convert_bytes $total_download)
echo "🔼${total_upload_human} uploaded"
echo "🔽${total_download_human} downloaded"
echo "🔼 Uploaded Traffic: ${total_upload_human}"
echo "🔽 Downloaded Traffic: ${total_download_human}"
total_traffic=$((total_upload + total_download))
total_traffic_human=$(convert_bytes $total_traffic)
echo "📊 ${total_traffic_human} total traffic"
echo "📊 Total Traffic: ${total_traffic_human}"
fi

View File

@ -59,25 +59,31 @@ show_uri() {
if jq -e "has(\"$username\")" "$USERS_FILE" > /dev/null; then
authpassword=$(jq -r ".\"$username\".password" "$USERS_FILE")
port=$(jq -r '.listen' "$CONFIG_FILE" | cut -d':' -f2)
sha256=$(jq -r '.tls.pinSHA256' "$CONFIG_FILE")
sha256=$(jq -r '.tls.pinSHA256 // empty' "$CONFIG_FILE")
obfspassword=$(jq -r '.obfs.salamander.password // empty' "$CONFIG_FILE")
generate_uri() {
local ip_version=$1
local ip=$2
if [ -n "$obfspassword" ]; then
if [ "$ip_version" -eq 4 ]; then
echo "hy2://$username%3A$authpassword@$ip:$port?obfs=salamander&obfs-password=$obfspassword&pinSHA256=$sha256&insecure=1&sni=$SNI#$username-IPv4"
elif [ "$ip_version" -eq 6 ]; then
echo "hy2://$username%3A$authpassword@[$ip]:$port?obfs=salamander&obfs-password=$obfspassword&pinSHA256=$sha256&insecure=1&sni=$SNI#$username-IPv6"
fi
else
if [ "$ip_version" -eq 4 ]; then
echo "hy2://$username%3A$authpassword@$ip:$port?pinSHA256=$sha256&insecure=1&sni=$SNI#$username-IPv4"
elif [ "$ip_version" -eq 6 ]; then
echo "hy2://$username%3A$authpassword@[$ip]:$port?pinSHA256=$sha256&insecure=1&sni=$SNI#$username-IPv6"
fi
local uri_base="hy2://$username%3A$authpassword@$ip:$port"
if [ "$ip_version" -eq 6 ]; then
uri_base="hy2://$username%3A$authpassword@[$ip]:$port"
fi
local params=""
if [ -n "$obfspassword" ]; then
params+="obfs=salamander&obfs-password=$obfspassword&"
fi
if [ -n "$sha256" ]; then
params+="pinSHA256=$sha256&"
fi
params+="insecure=1&sni=$SNI"
echo "$uri_base?$params#$username-IPv$ip_version"
}
if [ "$show_all" = true ]; then
@ -116,13 +122,13 @@ show_uri() {
fi
fi
if [ "$generate_singbox" = true ] && systemctl is-active --quiet singbox.service; then
if [ "$generate_singbox" = true ] && systemctl is-active --quiet hysteria-singbox.service; then
read -r domain port < <(get_singbox_domain_and_port)
if [ -n "$domain" ] && [ -n "$port" ]; then
echo -e "\nSingbox Sublink:\nhttps://$domain:$port/sub/singbox/$username/$ip_version#$username\n"
fi
fi
if [ "$generate_normalsub" = true ] && systemctl is-active --quiet normalsub.service; then
if [ "$generate_normalsub" = true ] && systemctl is-active --quiet hysteria-normal-sub.service; then
read -r domain port < <(get_normalsub_domain_and_port)
if [ -n "$domain" ] && [ -n "$port" ]; then
echo -e "\nNormal-SUB Sublink:\nhttps://$domain:$port/sub/normal/$username#Hysteria2\n"

View File

@ -32,12 +32,12 @@ echo "Removing alias 'hys2' from .bashrc..."
sed -i '/alias hys2=.*\/etc\/hysteria\/menu.sh/d' ~/.bashrc
echo "Stop/Disabling Hysteria TelegramBOT Service..."
systemctl stop hysteria-bot.service > /dev/null 2>&1
systemctl disable hysteria-bot.service > /dev/null 2>&1
systemctl stop hysteria-telegram-bot.service > /dev/null 2>&1
systemctl disable hysteria-telegram-bot.service > /dev/null 2>&1
echo "Stop/Disabling Singbox SubLink Service..."
systemctl stop singbox.service > /dev/null 2>&1
systemctl disable singbox.service > /dev/null 2>&1
systemctl stop hysteria-singbox.service > /dev/null 2>&1
systemctl disable hysteria-singbox.service > /dev/null 2>&1
echo "Hysteria2 uninstalled!"

View File

@ -2,15 +2,15 @@
source /etc/hysteria/core/scripts/utils.sh
define_colors
install_dependencies() {
echo "Installing necessary dependencies..."
apt-get install certbot -y > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${red}Error: Failed to install certbot. ${NC}"
exit 1
fi
echo -e "${green}Certbot installed successfully. ${NC}"
}
# install_dependencies() {
# echo "Installing necessary dependencies..."
# apt-get install certbot -y > /dev/null 2>&1
# if [ $? -ne 0 ]; then
# echo -e "${red}Error: Failed to install certbot. ${NC}"
# exit 1
# fi
# echo -e "${green}Certbot installed successfully. ${NC}"
# }
update_env_file() {
local domain=$1
@ -26,7 +26,7 @@ EOL
}
create_service_file() {
cat <<EOL > /etc/systemd/system/normalsub.service
cat <<EOL > /etc/systemd/system/hysteria-normal-sub.service
[Unit]
Description=normalsub Python Service
After=network.target
@ -48,12 +48,13 @@ start_service() {
local domain=$1
local port=$2
if systemctl is-active --quiet normalsub.service; then
echo "The normalsub.service is already running."
if systemctl is-active --quiet hysteria-normal-sub.service; then
echo "The hysteria-normal-sub.service is already running."
return
fi
install_dependencies
# install_dependencies
# systemctl stop caddy.service > /dev/null 2>&1 # We stopped caddy service just after its installation
echo "Generating SSL certificates for $domain..."
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d "$domain"
@ -67,11 +68,12 @@ start_service() {
chown -R hysteria:hysteria "/etc/letsencrypt/live/$domain"
chown -R hysteria:hysteria /etc/hysteria/core/scripts/normalsub
systemctl daemon-reload
systemctl enable normalsub.service > /dev/null 2>&1
systemctl start normalsub.service > /dev/null 2>&1
systemctl enable hysteria-normal-sub.service > /dev/null 2>&1
systemctl start hysteria-normal-sub.service > /dev/null 2>&1
# systemctl restart caddy.service > /dev/null 2>&1 # We stopped caddy service just after its installation
systemctl daemon-reload > /dev/null 2>&1
if systemctl is-active --quiet normalsub.service; then
if systemctl is-active --quiet hysteria-normal-sub.service; then
echo -e "${green}normalsub service setup completed. The service is now running on port $port. ${NC}"
else
echo -e "${red}normalsub setup completed. The service failed to start. ${NC}"
@ -85,13 +87,13 @@ stop_service() {
if [ -n "$HYSTERIA_DOMAIN" ]; then
echo -e "${yellow}Deleting SSL certificate for domain: $HYSTERIA_DOMAIN...${NC}"
sudo certbot delete --cert-name "$HYSTERIA_DOMAIN" --non-interactive > /dev/null 2>&1
certbot delete --cert-name "$HYSTERIA_DOMAIN" --non-interactive > /dev/null 2>&1
else
echo -e "${red}HYSTERIA_DOMAIN not found in .env. Skipping certificate deletion.${NC}"
fi
systemctl stop normalsub.service > /dev/null 2>&1
systemctl disable normalsub.service > /dev/null 2>&1
systemctl stop hysteria-normal-sub.service > /dev/null 2>&1
systemctl disable hysteria-normal-sub.service > /dev/null 2>&1
systemctl daemon-reload > /dev/null 2>&1
rm -f /etc/hysteria/core/scripts/normalsub/.env

View File

@ -6,6 +6,7 @@ CONFIG_ENV="/etc/hysteria/.configs.env"
TELEGRAM_ENV="/etc/hysteria/core/scripts/telegrambot/.env"
SINGBOX_ENV="/etc/hysteria/core/scripts/singbox/.env"
NORMALSUB_ENV="/etc/hysteria/core/scripts/normalsub/.env"
WEBPANEL_ENV="/etc/hysteria/core/scripts/webpanel/.env"
ONLINE_API_URL="http://127.0.0.1:25413/online"
LOCALVERSION="/etc/hysteria/VERSION"
LATESTVERSION="https://raw.githubusercontent.com/ReturnFI/Hysteria2/main/VERSION"

View File

@ -0,0 +1,26 @@
#!/bin/bash
declare -a services=(
"hysteria-server.service"
"hysteria-webpanel.service"
"hysteria-caddy.service"
"hysteria-telegram-bot.service"
"hysteria-normal-sub.service"
"hysteria-singbox.service"
"wg-quick@wgcf.service"
)
status_json="{"
for service in "${services[@]}"; do
if systemctl is-active --quiet "$service"; then
status_json+="\"$service\":true,"
else
status_json+="\"$service\":false,"
fi
done
# Remove trailing comma and close JSON properly
status_json="${status_json%,}}"
# Format output as valid JSON
echo "$status_json" | jq -M .

View File

@ -2,15 +2,15 @@
source /etc/hysteria/core/scripts/utils.sh
define_colors
install_dependencies() {
echo "Installing necessary dependencies..."
apt-get install certbot -y > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${red}Error: Failed to install certbot. ${NC}"
exit 1
fi
echo -e "${green}Certbot installed successfully. ${NC}"
}
# install_dependencies() {
# echo "Installing necessary dependencies..."
# apt-get install certbot -y > /dev/null 2>&1
# if [ $? -ne 0 ]; then
# echo -e "${red}Error: Failed to install certbot. ${NC}"
# exit 1
# fi
# echo -e "${green}Certbot installed successfully. ${NC}"
# }
update_env_file() {
local domain=$1
@ -26,7 +26,7 @@ EOL
}
create_service_file() {
cat <<EOL > /etc/systemd/system/singbox.service
cat <<EOL > /etc/systemd/system/hysteria-singbox.service
[Unit]
Description=Singbox Python Service
After=network.target
@ -48,12 +48,13 @@ start_service() {
local domain=$1
local port=$2
if systemctl is-active --quiet singbox.service; then
echo "The singbox.service is already running."
if systemctl is-active --quiet hysteria-singbox.service; then
echo "The hysteria-singbox.service is already running."
return
fi
install_dependencies
# install_dependencies
# systemctl stop caddy.service > /dev/null 2>&1 # We stopped caddy service just after its installation
echo "Generating SSL certificates for $domain..."
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d "$domain"
@ -64,14 +65,19 @@ start_service() {
update_env_file "$domain" "$port"
create_service_file
if [ $? -ne 0 ]; then
echo -e "${red}Error: Failed to create the service file. ${NC}"
exit 1
fi
chown -R hysteria:hysteria "/etc/letsencrypt/live/$domain"
chown -R hysteria:hysteria /etc/hysteria/core/scripts/singbox
systemctl daemon-reload
systemctl enable singbox.service > /dev/null 2>&1
systemctl start singbox.service > /dev/null 2>&1
systemctl enable hysteria-singbox.service > /dev/null 2>&1
systemctl start hysteria-singbox.service > /dev/null 2>&1
# systemctl restart caddy.service > /dev/null 2>&1 # We stopped caddy service just after its installation
systemctl daemon-reload > /dev/null 2>&1
if systemctl is-active --quiet singbox.service; then
if systemctl is-active --quiet hysteria-singbox.service; then
echo -e "${green}Singbox service setup completed. The service is now running on port $port. ${NC}"
else
echo -e "${red}Singbox setup completed. The service failed to start. ${NC}"
@ -85,13 +91,13 @@ stop_service() {
if [ -n "$HYSTERIA_DOMAIN" ]; then
echo -e "${yellow}Deleting SSL certificate for domain: $HYSTERIA_DOMAIN...${NC}"
sudo certbot delete --cert-name "$HYSTERIA_DOMAIN" --non-interactive > /dev/null 2>&1
certbot delete --cert-name "$HYSTERIA_DOMAIN" --non-interactive > /dev/null 2>&1
else
echo -e "${red}HYSTERIA_DOMAIN not found in .env. Skipping certificate deletion.${NC}"
fi
systemctl stop singbox.service > /dev/null 2>&1
systemctl disable singbox.service > /dev/null 2>&1
systemctl stop hysteria-singbox.service > /dev/null 2>&1
systemctl disable hysteria-singbox.service > /dev/null 2>&1
systemctl daemon-reload > /dev/null 2>&1
rm -f /etc/hysteria/core/scripts/singbox/.env

View File

@ -13,7 +13,7 @@ EOL
}
create_service_file() {
cat <<EOL > /etc/systemd/system/hysteria-bot.service
cat <<EOL > /etc/systemd/system/hysteria-telegram-bot.service
[Unit]
Description=Hysteria Telegram Bot
After=network.target
@ -32,8 +32,8 @@ start_service() {
local api_token=$1
local admin_user_ids=$2
if systemctl is-active --quiet hysteria-bot.service; then
echo "The hysteria-bot.service is already running."
if systemctl is-active --quiet hysteria-telegram-bot.service; then
echo "The hysteria-telegram-bot.service is already running."
return
fi
@ -41,10 +41,10 @@ start_service() {
create_service_file
systemctl daemon-reload
systemctl enable hysteria-bot.service > /dev/null 2>&1
systemctl start hysteria-bot.service > /dev/null 2>&1
systemctl enable hysteria-telegram-bot.service > /dev/null 2>&1
systemctl start hysteria-telegram-bot.service > /dev/null 2>&1
if systemctl is-active --quiet hysteria-bot.service; then
if systemctl is-active --quiet hysteria-telegram-bot.service; then
echo -e "${green}Hysteria bot setup completed. The service is now running. ${NC}"
echo -e "\n\n"
else
@ -53,8 +53,8 @@ start_service() {
}
stop_service() {
systemctl stop hysteria-bot.service > /dev/null 2>&1
systemctl disable hysteria-bot.service > /dev/null 2>&1
systemctl stop hysteria-telegram-bot.service > /dev/null 2>&1
systemctl disable hysteria-telegram-bot.service > /dev/null 2>&1
rm -f /etc/hysteria/core/scripts/telegrambot/.env
echo -e "\n"

View File

@ -1,4 +1,5 @@
source /etc/hysteria/core/scripts/path.sh
# source /etc/hysteria/core/scripts/services_status.sh
# Function to define colors
define_colors() {
@ -88,20 +89,17 @@ load_hysteria2_ips() {
fi
}
check_services() {
declare -A service_names=(
["hysteria-server.service"]="Hysteria2"
["normalsub.service"]="Normal Subscription"
["singbox.service"]="Singbox Subscription"
["hysteria-bot.service"]="Hysteria Telegram Bot"
["wg-quick@wgcf.service"]="WireGuard (WARP)"
)
# check_services() {
# # source /etc/hysteria/core/scripts/services_status.sh
# for service in "${services[@]}"; do
# service_base_name=$(basename "$service" .service)
for service in "${!service_names[@]}"; do
if systemctl is-active --quiet "$service"; then
echo -e "${NC}${service_names[$service]}:${green} Active${NC}"
else
echo -e "${NC}${service_names[$service]}:${red} Inactive${NC}"
fi
done
}
# display_name=$(echo "$service_base_name" | sed -E 's/([^-]+)-?/\u\1/g')
# if systemctl is-active --quiet "$service"; then
# echo -e "${NC}${display_name}:${green} Active${NC}"
# else
# echo -e "${NC}${display_name}:${red} Inactive${NC}"
# fi
# done
# }

View File

@ -0,0 +1,80 @@
#!/usr/bin/env python3
import sys
import asyncio
from fastapi import FastAPI
from starlette.staticfiles import StaticFiles
from config import CONFIGS # Loads the configuration from .env
from middleware import AuthMiddleware # Defines authentication middleware
from middleware import AfterRequestMiddleware # Defines after request middleware
from dependency import get_session_manager # Defines dependencies across routers
from openapi import setup_openapi_schema # Adds authorization header to openapi schema
from exception_handler import setup_exception_handler # Defines exception handlers
# Append directory of cli_api.py to be able to import it
HYSTERIA_CORE_DIR = '/etc/hysteria/core/'
sys.path.append(HYSTERIA_CORE_DIR)
import routers # noqa: This import should be after the sys.path modification, because it imports cli_api
def create_app() -> FastAPI:
'''
Create FastAPI app.
'''
# Set up FastAPI
app = FastAPI(
title='Hysteria Webpanel',
description='Webpanel for Hysteria',
version='0.1.0',
contact={
'github': 'https://github.com/ReturnFI/Hysteria2'
},
debug=CONFIGS.DEBUG,
root_path=f'/{CONFIGS.ROOT_PATH}',
)
# Set up static files
app.mount('/assets', StaticFiles(directory='assets'), name='assets')
# Set up exception handlers
setup_exception_handler(app)
# Set up authentication middleware
app.add_middleware(AuthMiddleware, session_manager=get_session_manager(), api_token=CONFIGS.API_TOKEN)
# Set up after request middleware
app.add_middleware(AfterRequestMiddleware)
# Set up Routers
app.include_router(routers.basic.router, prefix='', tags=['Basic Routes[Web]']) # Add basic router
app.include_router(routers.login.router, prefix='', tags=['Authentication[Web]']) # Add authentication router
app.include_router(routers.settings.router, prefix='/settings', tags=['Settings[Web]']) # Add settings router
app.include_router(routers.user.router, prefix='/users', tags=['User Management[Web]']) # Add user router
app.include_router(routers.api.v1.api_v1_router, prefix='/api/v1', tags=['API Version 1']) # Add API version 1 router # type: ignore
# Document that the API requires an API key
setup_openapi_schema(app)
return app
app: FastAPI = create_app()
if __name__ == '__main__':
from hypercorn.config import Config
from hypercorn.asyncio import serve # type: ignore
from hypercorn.middleware import ProxyFixMiddleware
config = Config()
config.debug = CONFIGS.DEBUG
config.bind = ['127.0.0.1:8080']
config.accesslog = '-'
config.errorlog = '-'
# Fix proxy headers
app = ProxyFixMiddleware(app, 'legacy') # type: ignore
asyncio.run(serve(app, config)) # type: ignore

View File

@ -0,0 +1,9 @@
/* Center the qrcodesContainer */
#qrcodesContainer {
text-align: center;
}
/* Style the config type text */
.config-type-text {
font-weight: 600;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1 @@
from .config import CONFIGS

View File

@ -0,0 +1,19 @@
from pydantic_settings import BaseSettings
class Configs(BaseSettings):
PORT: int
DOMAIN: str
DEBUG: bool
ADMIN_USERNAME: str
ADMIN_PASSWORD: str
API_TOKEN: str
EXPIRATION_MINUTES: int
ROOT_PATH: str
class Config:
env_file = '.env'
env_file_encoding = 'utf-8'
CONFIGS = Configs() # type: ignore

View File

@ -0,0 +1 @@
from .dependency import get_templates, get_session_manager

View File

@ -0,0 +1,33 @@
from fastapi.templating import Jinja2Templates
from session import SessionStorage, SessionManager
from config import CONFIGS
__TEMPLATES = Jinja2Templates(directory='templates')
# This was a custom url_for function for Jinja2 to add a prefix to the generated URL but we fix the url generation by setting the root path
# @pass_context
# def url_for(context: dict[str, Any], name: str = '', **path_params: dict[str, Any]) -> URL:
# '''
# Custom url_for function for Jinja2 to add a prefix to the generated URL.
# '''
# request: Request = context["request"]
# url = request.url_for(name, **path_params)
# prefixed_path = f"{CONFIGS.ROOT_PATH.rstrip('/')}/{url.path.lstrip('/')}"
# return url.replace(path=prefixed_path)
# __TEMPLATES.env.globals['url_for'] = url_for # type: ignore
def get_templates() -> Jinja2Templates:
return __TEMPLATES
__SESSION_STORAGE = SessionStorage()
__SESSION_MANAGER = SessionManager(__SESSION_STORAGE, CONFIGS.EXPIRATION_MINUTES)
def get_session_manager() -> SessionManager:
return __SESSION_MANAGER

View File

@ -0,0 +1 @@
from .handler import setup_exception_handler, exception_handler

View File

@ -0,0 +1,24 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
class JSONErrorResponse(BaseModel):
status: int
detail: str
def setup_exception_handler(app: FastAPI):
'''
Setup exception handler for FastAPI.
'''
@app.exception_handler(HTTPException)
async def http_exception_handler_wrapper(request: Request, exc: HTTPException): # type: ignore
return exception_handler(exc)
def exception_handler(exc: HTTPException): # type: ignore
return JSONResponse(
status_code=exc.status_code,
content=JSONErrorResponse(status=exc.status_code, detail=exc.detail).model_dump(),
)

View File

@ -0,0 +1,2 @@
from .auth import AuthMiddleware
from .request import AfterRequestMiddleware

View File

@ -0,0 +1,76 @@
from fastapi import Request, Response, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.responses import RedirectResponse
from datetime import datetime, timezone
from typing import Awaitable, Callable
from starlette.types import ASGIApp
from urllib.parse import quote
from exception_handler import exception_handler
from session import SessionManager
from config import CONFIGS
class AuthMiddleware(BaseHTTPMiddleware):
'''Middleware that handles session authentication.'''
def __init__(self, app: ASGIApp, session_manager: SessionManager, api_token: str | None):
super().__init__(app)
self.__session_manager = session_manager
self.__api_token = api_token
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]): # type: ignore
'''Handles session authentication.'''
public_routes = [
f'/{CONFIGS.ROOT_PATH}/login',
f'/{CONFIGS.ROOT_PATH}/robots.txt'
]
if request.url.path in public_routes:
return await call_next(request)
is_api_request = '/api/v1/' in request.url.path
if is_api_request:
if self.__api_token:
# Attempt to authenticate with API token
if api_key := request.headers.get('Authorization'):
if api_key == self.__api_token:
return await call_next(request)
else:
return self.__handle_api_failure(status=401, detail="Invalid API token.")
# Extract session_id from cookies
session_id = request.cookies.get("session_id")
if not session_id:
if is_api_request:
return self.__handle_api_failure(status=401, detail="Unauthorized.")
return self.__redirect_to_login(request)
session_data = self.__session_manager.get_session(session_id)
if not session_data:
if is_api_request:
return self.__handle_api_failure(status=401, detail="The session is invalid.")
return self.__redirect_to_login(request)
if session_data.expires_at < datetime.now(timezone.utc):
if is_api_request:
return self.__handle_api_failure(status=401, detail="The session has expired.")
return self.__redirect_to_login(request)
return await call_next(request)
def __handle_api_failure(self, status: int, detail: str):
exc = HTTPException(status_code=status, detail=detail)
return exception_handler(exc)
def __redirect_to_login(self, request: Request):
next_url = quote(str(request.url))
redirect_url = str(request.url_for('login')) + f'?next_url={next_url}'
return RedirectResponse(url=redirect_url, status_code=302)

View File

@ -0,0 +1,12 @@
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request, Response
from typing import Awaitable, Callable
class AfterRequestMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]):
response = await call_next(request)
# Add X-Robots-Tag header
response.headers['X-Robots-Tag'] = 'noindex, nofollow'
return response

View File

@ -0,0 +1 @@
from .openapi import setup_openapi_schema

View File

@ -0,0 +1,39 @@
from fastapi import FastAPI
from config import CONFIGS
def setup_openapi_schema(app: FastAPI):
"""Set up the OpenAPI schema for the API.
The OpenAPI schema is modified to include the API key in the
"Authorization" header. All routes under /api/v1/ are modified to
require the API key.
"""
app.openapi_schema = app.openapi()
app.openapi_schema["servers"] = [
{
"url": f"/{CONFIGS.ROOT_PATH}",
"description": "Root path of the API"
}
]
# Define API key in the OpenAPI schema
# It's a header with the name "Authorization"
app.openapi_schema["components"]["securitySchemes"] = {
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "Authorization"
}
}
# Apply the API key requirement to all paths under /api/v1/ dynamically
for path, operations in app.openapi_schema["paths"].items():
if path.startswith("/api/v1/"): # Apply security to routes starting with /api/v1/
for operation in operations.values():
if "security" not in operation:
operation["security"] = []
operation["security"].append({"ApiKeyAuth": []})

View File

@ -0,0 +1,5 @@
from . import basic
from . import api
from . import user
from . import login
from . import settings

View File

@ -0,0 +1 @@
from . import v1

View File

@ -0,0 +1,10 @@
from fastapi import APIRouter
from . import user
from . import server
from . import config
api_v1_router = APIRouter()
api_v1_router.include_router(user.router, prefix='/users')
api_v1_router.include_router(server.router, prefix='/server')
api_v1_router.include_router(config.router, prefix='/config')

View File

@ -0,0 +1,19 @@
from fastapi import APIRouter
from . import hysteria
from . import warp
from . import telegram
from . import normalsub
from . import singbox
from . import ip
from . import misc
router = APIRouter()
router.include_router(hysteria.router, prefix='/hysteria')
router.include_router(warp.router, prefix='/warp')
router.include_router(telegram.router, prefix='/telegram')
router.include_router(normalsub.router, prefix='/normalsub')
router.include_router(singbox.router, prefix='/singbox')
router.include_router(ip.router, prefix='/ip')
router.include_router(misc.router)

View File

@ -0,0 +1,241 @@
from fastapi import APIRouter, HTTPException
from ..schema.config.hysteria import ConfigFile
from ..schema.response import DetailResponse
# from ..schema.config.hysteria import InstallInputBody
import cli_api
router = APIRouter()
# Change: Installing and uninstalling Hysteria2 is possible only through the CLI
# @router.post('/install', response_model=DetailResponse, summary='Install Hysteria2')
# async def install(body: InstallInputBody):
# """
# Installs Hysteria2 on the given port and uses the provided or default SNI value.
# Args:
# body: An instance of InstallInputBody containing the new port and SNI value.
# Returns:
# A DetailResponse with a message indicating the Hysteria2 installation was successful.
# Raises:
# HTTPException: if an error occurs while installing Hysteria2.
# """
# try:
# cli_api.install_hysteria2(body.port, body.sni)
# return DetailResponse(detail=f'Hysteria2 installed successfully on port {body.port} with SNI {body.sni}.')
# except Exception as e:
# raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
# @router.delete('/uninstall', response_model=DetailResponse, summary='Uninstall Hysteria2')
# async def uninstall():
# """
# Uninstalls Hysteria2.
# Returns:
# A DetailResponse with a message indicating the Hysteria2 uninstallation was successful.
# Raises:
# HTTPException: if an error occurs while uninstalling Hysteria2.
# """
# try:
# cli_api.uninstall_hysteria2()
# return DetailResponse(detail='Hysteria2 uninstalled successfully.')
# except Exception as e:
# raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.patch('/update', response_model=DetailResponse, summary='Update Hysteria2')
async def update():
"""
Updates Hysteria2.
Returns:
A DetailResponse with a message indicating the Hysteria2 update was successful.
Raises:
HTTPException: if an error occurs while updating Hysteria2.
"""
try:
cli_api.update_hysteria2()
return DetailResponse(detail='Hysteria2 updated successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/set-port/{port}', response_model=DetailResponse, summary='Set Hysteria2 port')
async def set_port(port: int):
"""
Sets the port for Hysteria2.
Args:
port: The new port value.
Returns:
A DetailResponse with a message indicating the Hysteria2 port change was successful.
Raises:
HTTPException: if an error occurs while changing the Hysteria2 port.
"""
try:
cli_api.change_hysteria2_port(port)
return DetailResponse(detail=f'Hysteria2 port changed to {port} successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/set-sni/{sni}', response_model=DetailResponse, summary='Set Hysteria2 SNI')
async def set_sni(sni: str):
"""
Sets the SNI for Hysteria2.
Args:
sni: The new SNI value.
Returns:
A DetailResponse with a message indicating the Hysteria2 SNI change was successful.
Raises:
HTTPException: if an error occurs while changing the Hysteria2 SNI.
"""
try:
cli_api.change_hysteria2_sni(sni)
return DetailResponse(detail=f'Hysteria2 SNI changed to {sni} successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/backup', response_model=DetailResponse, summary='Backup Hysteria2 configuration')
async def backup():
"""
Backups the Hysteria2 configuration.
Returns:
A DetailResponse with a message indicating the Hysteria2 configuration backup was successful.
Raises:
HTTPException: if an error occurs while backing up the Hysteria2 configuration.
"""
try:
cli_api.backup_hysteria2()
return DetailResponse(detail='Hysteria2 configuration backed up successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/enable-obfs', response_model=DetailResponse, summary='Enable Hysteria2 obfs')
async def enable_obfs():
"""
Enables Hysteria2 obfs.
Returns:
A DetailResponse with a message indicating the Hysteria2 obfs was enabled successfully.
Raises:
HTTPException: if an error occurs while enabling Hysteria2 obfs.
"""
try:
cli_api.enable_hysteria2_obfs()
return DetailResponse(detail='Hysteria2 obfs enabled successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/disable-obfs', response_model=DetailResponse, summary='Disable Hysteria2 obfs')
async def disable_obfs():
"""
Disables Hysteria2 obfs.
Returns:
A DetailResponse with a message indicating the Hysteria2 obfs was disabled successfully.
Raises:
HTTPException: if an error occurs while disabling Hysteria2 obfs.
"""
try:
cli_api.disable_hysteria2_obfs()
return DetailResponse(detail='Hysteria2 obfs disabled successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/enable-masquerade/{domain}', response_model=DetailResponse, summary='Enable Hysteria2 masquerade')
async def enable_masquerade(domain: str):
"""
Enables Hysteria2 masquerade for the given domain.
Args:
domain: The domain to enable Hysteria2 masquerade for.
Returns:
A DetailResponse with a message indicating the Hysteria2 masquerade was enabled successfully.
Raises:
HTTPException: if an error occurs while enabling Hysteria2 masquerade.
"""
try:
cli_api.enable_hysteria2_masquerade(domain)
return DetailResponse(detail='Hysteria2 masquerade enabled successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/disable-masquerade', response_model=DetailResponse, summary='Disable Hysteria2 masquerade')
async def disable_masquerade():
"""
Disables Hysteria2 masquerade.
Returns:
A DetailResponse with a message indicating the Hysteria2 masquerade was disabled successfully.
Raises:
HTTPException: if an error occurs while disabling Hysteria2 masquerade.
"""
try:
cli_api.disable_hysteria2_masquerade()
return DetailResponse(detail='Hysteria2 masquerade disabled successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/file', response_model=ConfigFile, summary='Get Hysteria2 configuration file')
async def get_file():
"""
Gets the Hysteria2 configuration file.
Returns:
A JSONResponse containing the Hysteria2 configuration file.
Raises:
HTTPException: if an error occurs while getting the Hysteria2 configuration file.
"""
try:
if config_file := cli_api.get_hysteria2_config_file():
return ConfigFile(root=config_file)
else:
raise HTTPException(status_code=404, detail='Hysteria2 configuration file not found.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.post('/file', response_model=DetailResponse, summary='Update Hysteria2 configuration file')
async def set_file(body: ConfigFile):
"""
Updates the Hysteria2 configuration file.
Args:
file: The Hysteria2 configuration file to update.
Returns:
A DetailResponse with a message indicating the Hysteria2 configuration file was updated successfully.
Raises:
HTTPException: if an error occurs while updating the Hysteria2 configuration file.
"""
try:
cli_api.set_hysteria2_config_file(body.root)
return DetailResponse(detail='Hysteria2 configuration file updated successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')

View File

@ -0,0 +1,71 @@
from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse
from ..schema.config.ip import EditInputBody, StatusResponse
import cli_api
router = APIRouter()
@router.get('/get', response_model=StatusResponse, summary='Get IP Status')
async def get_ip_api():
"""
Retrieves the current status of IP addresses.
Returns:
StatusResponse: A response model containing the current IP address details.
Raises:
HTTPException: If the IP status is not available (404) or if there is an error processing the request (400).
"""
try:
ipv4, ipv6 = cli_api.get_ip_address()
if ipv4 or ipv6:
return StatusResponse(ipv4=ipv4, ipv6=ipv6) # type: ignore
raise HTTPException(status_code=404, detail='IP status not available.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/add', response_model=DetailResponse, summary='Add IP')
async def add_ip_api():
"""
Adds the auto-detected IP addresses to the .configs.env file.
Returns:
A DetailResponse with a message indicating the IP addresses were added successfully.
Raises:
HTTPException: if an error occurs while adding the IP addresses.
"""
try:
cli_api.add_ip_address()
return DetailResponse(detail='IP addresses added successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.post('/edit', response_model=DetailResponse, summary='Edit IP')
async def edit_ip_api(body: EditInputBody):
"""
Edits the IP addresses in the .configs.env file.
Args:
body: An instance of EditInputBody containing the new IPv4 and/or IPv6 addresses.
Returns:
A DetailResponse with a message indicating the IP addresses were edited successfully.
Raises:
HTTPException: if an error occurs while editing the IP addresses.
"""
try:
if not body.ipv4 and not body.ipv6:
raise HTTPException(status_code=400, detail='Error: You must specify either ipv4 or ipv6')
cli_api.edit_ip_address(str(body.ipv4), str(body.ipv6))
return DetailResponse(detail='IP address edited successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')

View File

@ -0,0 +1,47 @@
from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse
import cli_api
router = APIRouter()
@router.post('/install-tcp-brutal', response_model=DetailResponse, summary='Install TCP Brutal')
async def install_tcp_brutal():
"""
Endpoint to install TCP Brutal service.
It's post method because keeping backward compatibility if we need to add parameters in the future.
Returns:
DetailResponse: A response object indicating the success of the installation.
Raises:
HTTPException: If an error occurs during the installation, an HTTP 400 error is raised with the error details.
"""
try:
cli_api.install_tcp_brutal()
return DetailResponse(detail='TCP Brutal installed successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/update-geo/{country}', response_model=DetailResponse, summary='Update Geo files')
async def update_geo(country: str):
"""
Endpoint to update geographic data files based on the specified country.
Args:
country (str): The country for which to update the geo files.
Accepts 'iran', 'china', or 'russia'.
Returns:
DetailResponse: A response object indicating the success of the update operation.
Raises:
HTTPException: If an error occurs during the update process, an HTTP 400 error is raised with the error details.
"""
try:
cli_api.update_geo(country)
return DetailResponse(detail='Geo updated successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')

View File

@ -0,0 +1,54 @@
from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse
from ..schema.config.normalsub import StartInputBody
import cli_api
router = APIRouter()
@router.post('/start', response_model=DetailResponse, summary='Start NormalSub')
async def normal_sub_start_api(body: StartInputBody):
"""
Starts the NormalSub service using the provided domain and port.
Args:
body (StartInputBody): The request body containing the domain and port
information for starting the NormalSub service.
Returns:
DetailResponse: A response object containing a success message indicating
that the NormalSub service has been started successfully.
Raises:
HTTPException: If there is an error starting the NormalSub service, an
HTTPException with status code 400 and error details will be raised.
"""
try:
cli_api.start_normalsub(body.domain, body.port)
return DetailResponse(detail='Normalsub started successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.delete('/stop', response_model=DetailResponse, summary='Stop NormalSub')
async def normal_sub_stop_api():
"""
Stops the NormalSub service.
Returns:
DetailResponse: A response object containing a success message indicating
that the NormalSub service has been stopped successfully.
Raises:
HTTPException: If there is an error stopping the NormalSub service, an
HTTPException with status code 400 and error details will be raised.
"""
try:
cli_api.stop_normalsub()
return DetailResponse(detail='Normalsub stopped successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
# TODO: Maybe would be nice to have a status endpoint

View File

@ -0,0 +1,49 @@
from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse
from ..schema.config.singbox import StartInputBody
import cli_api
router = APIRouter()
@router.post('/start', response_model=DetailResponse, summary='Start Singbox')
async def singbox_start_api(body: StartInputBody):
"""
Start the Singbox service.
This endpoint starts the Singbox service if it is currently not running.
Args:
body (StartInputBody): The domain name and port number for the service.
Returns:
DetailResponse: The response with the result of the command.
"""
try:
cli_api.start_singbox(body.domain, body.port)
return DetailResponse(detail='Singbox started successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.delete('/stop', response_model=DetailResponse, summary='Stop Singbox')
async def singbox_stop_api():
"""
Stop the Singbox service.
This endpoint stops the Singbox service if it is currently running.
Returns:
DetailResponse: A response model indicating the stop status of the Singbox service.
Raises:
HTTPException: If there is an error stopping the Singbox service (400).
"""
try:
cli_api.stop_singbox()
return DetailResponse(detail='Singbox stopped successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
# TODO: Maybe would be nice to have a status endpoint

View File

@ -0,0 +1,41 @@
from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse
from ..schema.config.telegram import StartInputBody
import cli_api
router = APIRouter()
@router.post('/start', response_model=DetailResponse, summary='Start Telegram Bot')
async def telegram_start_api(body: StartInputBody):
"""
Starts the Telegram bot.
Args:
body (StartInputBody): The data containing the Telegram bot token and admin ID.
Returns:
DetailResponse: The response containing the result of the action.
"""
try:
cli_api.start_telegram_bot(body.token, body.admin_id)
return DetailResponse(detail='Telegram bot started successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.delete('/stop', response_model=DetailResponse, summary='Stop Telegram Bot')
async def telegram_stop_api():
"""
Stops the Telegram bot.
Returns:
DetailResponse: The response containing the result of the action.
"""
try:
cli_api.stop_telegram_bot()
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
# TODO: Maybe would be nice to have a status endpoint

View File

@ -0,0 +1,142 @@
import re
from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse
from ..schema.config.warp import ConfigureInputBody, StatusResponse
import cli_api
router = APIRouter()
@router.post('/install', response_model=DetailResponse, summary='Install WARP')
async def install():
"""
Installs WARP.
It's post method because keeping backward compatibility if we need to add parameters in the future.
Returns:
DetailResponse: A response indicating the success of the installation.
Raises:
HTTPException: If an error occurs during installation, an HTTP 400 error is raised with the error details.
"""
try:
cli_api.install_warp()
return DetailResponse(detail='WARP installed successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.delete('/uninstall', response_model=DetailResponse, summary='Uninstall WARP')
async def uninstall():
"""
Uninstalls WARP.
Returns:
DetailResponse: A response indicating the success of the uninstallation.
Raises:
HTTPException: If an error occurs during uninstallation, an HTTP 400 error is raised with the error details.
"""
try:
cli_api.uninstall_warp()
return DetailResponse(detail='WARP uninstalled successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.post('/configure', response_model=DetailResponse, summary='Configure WARP')
async def configure(body: ConfigureInputBody):
"""
Configures WARP with the given options.
Args:
body: An instance of ConfigureInputBody containing configuration options.
Returns:
DetailResponse: A response indicating the success of the configuration.
Raises:
HTTPException: If an error occurs during configuration, an HTTP 400 error is raised with the error details.
"""
try:
cli_api.configure_warp(body.all, body.popular_sites, body.domestic_sites,
body.block_adult_sites, body.warp_option, body.warp_key)
return DetailResponse(detail='WARP configured successfully.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/status', response_model=StatusResponse, summary='Get WARP Status')
async def status():
"""
Retrieves the current status of WARP.
Returns:
StatusResponse: A response model containing the current WARP status details.
Raises:
HTTPException: If the WARP status is not available (404) or if there is an error processing the request (400).
"""
try:
if res := cli_api.warp_status():
return __parse_status(res)
raise HTTPException(status_code=404, detail='WARP status not available.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
def __parse_status(status: str) -> StatusResponse:
"""
Parses the output of the WARP status command to extract the current configuration settings.
Args:
status: The output of the WARP status command as a string.
Returns:
StatusResponse: A response model containing the current WARP status details.
Raises:
ValueError: If the WARP status is invalid or incomplete.
"""
# Example output(status) from cli_api.warp_status():
# --------------------------------
# Current WARP Configuration:
# All traffic: Inactive
# Popular sites (Google, Netflix, etc.): Inactive
# Domestic sites (geosite:ir, geoip:ir): Inactive
# Block adult content: Inactive
# --------------------------------
data = {}
# Remove ANSI escape sequences(colors) (e.g., \x1b[1;35m)
clean_status = re.sub(r'\x1b\[[0-9;]*m', '', status)
for line in clean_status.split('\n'):
if ':' not in line:
continue
if 'Current WARP Configuration:' in line:
continue
key, _, value = line.partition(':')
key = key.strip().lower()
value = value.strip()
if not key or not value:
continue
if 'all traffic' in key:
data['all_traffic'] = value == 'active'
elif 'popular sites' in key:
data['popular_sites'] = value == 'active'
elif 'domestic sites' in key:
data['domestic_sites'] = value == 'active'
elif 'block adult content' in key:
data['block_adult_sites'] = value == 'active'
if not data:
raise ValueError('Invalid WARP status')
try:
return StatusResponse(**data)
except Exception as e:
raise ValueError(f'Invalid or incomplete WARP status: {e}')

View File

@ -0,0 +1,3 @@
from . import user
from . import server
from . import config

View File

@ -0,0 +1,5 @@
from . import hysteria
from . import normalsub
from . import singbox
from . import telegram
from . import warp

View File

@ -0,0 +1,12 @@
from pydantic import RootModel
from typing import Any
# Change: Installing and uninstalling Hysteria2 is possible only through the CLI
# class InstallInputBody(BaseModel):
# port: int
# sni: str
# TODO: Define supported fields of the config file
class ConfigFile(RootModel): # type: ignore
root: dict[str, Any]

View File

@ -0,0 +1,11 @@
from pydantic import BaseModel
from ipaddress import IPv4Address, IPv6Address
class StatusResponse(BaseModel):
ipv4: IPv4Address | None = None
ipv6: IPv6Address | None = None
class EditInputBody(StatusResponse):
pass

View File

@ -0,0 +1,9 @@
from pydantic import BaseModel
# The StartInputBody is the same as in /hysteria/core/scripts/webpanel/routers/api/v1/schema/config/singbox.py but for /normalsub endpoint
# I'm defining it separately because highly likely it'll be different
class StartInputBody(BaseModel):
domain: str
port: int

View File

@ -0,0 +1,6 @@
from pydantic import BaseModel
class StartInputBody(BaseModel):
domain: str
port: int

View File

@ -0,0 +1,6 @@
from pydantic import BaseModel
class StartInputBody(BaseModel):
token: str
admin_id: str

View File

@ -0,0 +1,18 @@
from pydantic import BaseModel
from typing import Literal
class ConfigureInputBody(BaseModel):
all: bool = False
popular_sites: bool = False
domestic_sites: bool = False
block_adult_sites: bool = False
warp_option: Literal['warp', 'warp plus', ''] = ''
warp_key: str = ''
class StatusResponse(BaseModel):
all_traffic: bool
popular_sites: bool
domestic_sites: bool
block_adult_sites: bool

View File

@ -0,0 +1,5 @@
from pydantic import BaseModel
class DetailResponse(BaseModel):
detail: str

View File

@ -0,0 +1,25 @@
from pydantic import BaseModel
# We can't return bytes because the underlying script is returning human readable values which are hard to parse it
# It's better to chnage the underlying script to return bytes instead of changing it here
# Because of this problem we use str type instead of int as type
class ServerStatusResponse(BaseModel):
# disk_usage: int
cpu_usage: str
total_ram: str
ram_usage: str
online_users: int
uploaded_traffic: str
downloaded_traffic: str
total_traffic: str
class ServerServicesStatusResponse(BaseModel):
hysteria_server: bool
hysteria_webpanel: bool
hysteria_singbox: bool
hysteria_normal_sub: bool
hysteria_telegram_bot: bool
hysteria_warp: bool

View File

@ -0,0 +1,36 @@
from pydantic import BaseModel
from pydantic import BaseModel, RootModel
# WE CAN'T USE SHARED SCHEMA BECAUSE THE CLI IS RETURNING SAME FIELD IN DIFFERENT NAMES SOMETIMES
# WHAT I'M SAYING IS THAT OUR CODE IN HERE WILL BE SPAGHETTI CODE IF WE WANT TO HAVE CONSISTENCY IN ALL RESPONSES OF THE APIs
# THE MAIN PROBLEM IS IN THE CLI CODE NOT IN THE WEB PANEL CODE (HERE)
class UserInfoResponse(BaseModel):
password: str
max_download_bytes: int
expiration_days: int
account_creation_date: str
blocked: bool
class UserListResponse(RootModel): # type: ignore
root: dict[str, UserInfoResponse]
class AddUserInputBody(BaseModel):
username: str
traffic_limit: int
expiration_days: int
password: str | None = None
creation_date: str | None = None
class EditUserInputBody(BaseModel):
# username: str
new_username: str | None = None
new_traffic_limit: int | None = None
new_expiration_days: int | None = None
renew_password: bool = False
renew_creation_date: bool = False
blocked: bool = False

View File

@ -0,0 +1,146 @@
from fastapi import APIRouter, HTTPException
import cli_api
from .schema.server import ServerStatusResponse, ServerServicesStatusResponse
router = APIRouter()
@router.get('/status', response_model=ServerStatusResponse)
async def server_status_api():
"""
Retrieve the server status.
This endpoint provides information about the current server status,
including CPU usage, RAM usage, online users, and traffic statistics.
Returns:
ServerStatusResponse: A response model containing server status details.
Raises:
HTTPException: If the server information is not available (404) or
if there is an error processing the request (400).
"""
try:
if res := cli_api.server_info():
return __parse_server_status(res)
raise HTTPException(status_code=404, detail='Server information not available.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
def __parse_server_status(server_info: str) -> ServerStatusResponse:
# Initial data with default values
"""
Parse the server information provided by cli_api.server_info()
and return a ServerStatusResponse instance.
Args:
server_info (str): The output of cli_api.server_info() as a string.
Returns:
ServerStatusResponse: A response model containing server status details.
Raises:
ValueError: If the server information is invalid or incomplete.
"""
data = {
'cpu_usage': '0%',
'total_ram': '0MB',
'ram_usage': '0MB',
'online_users': 0,
'uploaded_traffic': '0KB',
'downloaded_traffic': '0KB',
'total_traffic': '0KB'
}
# Example output(server_info) from cli_api.server_info():
# 📈 CPU Usage: 9.4%
# 📋 Total RAM: 3815MB
# 💻 Used RAM: 2007MB
# 👥 Online Users: 0
#
# 🔼 Uploaded Traffic: 0 KB
# 🔽 Downloaded Traffic: 0 KB
# 📊 Total Traffic: 0 KB
for line in server_info.splitlines():
key, _, value = line.partition(":")
key = key.strip().lower()
value = value.strip()
if not key or not value:
continue # Skip empty or malformed lines
try:
if 'cpu usage' in key:
data['cpu_usage'] = value
elif 'total ram' in key:
data['total_ram'] = value
elif 'used ram' in key:
data['ram_usage'] = value
elif 'online users' in key:
data['online_users'] = int(value)
elif 'uploaded traffic' in key:
value = value.replace(' ', '')
data['uploaded_traffic'] = value
elif "downloaded traffic" in key:
value = value.replace(' ', '')
data['downloaded_traffic'] = value
elif 'total traffic' in key:
value = value.replace(' ', '')
data["total_traffic"] = value
except ValueError as e:
raise ValueError(f'Error parsing line \'{line}\': {e}')
# Validate required fields
try:
return ServerStatusResponse(**data) # type: ignore
except Exception as e:
raise ValueError(f'Invalid or incomplete server info: {e}')
@router.get('/services/status', response_model=ServerServicesStatusResponse)
async def server_services_status_api():
"""
Retrieve the status of various services.
This endpoint provides information about the status of different services,
including Hysteria2, TelegramBot, Singbox, and Normal-SUB.
Returns:
ServerServicesStatusResponse: A response model containing service status details.
Raises:
HTTPException: If the services status is not available (404) or
if there is an error processing the request (400).
"""
try:
if res := cli_api.get_services_status():
return __parse_services_status(res)
raise HTTPException(status_code=404, detail='Services status not available.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
def __parse_services_status(services_status: dict[str, bool]) -> ServerServicesStatusResponse:
'''
Parse the services status provided by cli_api.get_services_status()
and return a ServerServicesStatusResponse instance.
'''
parsed_services_status: dict[str, bool] = {}
for service, status in services_status.items():
if 'hysteria-server' in service:
parsed_services_status['hysteria_server'] = status
elif 'hysteria-webpanel' in service:
parsed_services_status['hysteria_webpanel'] = status
elif 'telegram-bot' in service:
parsed_services_status['hysteria_telegram_bot'] = status
elif 'hysteria-normal-sub' in service:
parsed_services_status['hysteria_normal_sub'] = status
elif 'hysteria-singbox' in service:
parsed_services_status['hysteria_singbox'] = status
elif 'wg-quick' in service:
parsed_services_status['hysteria_warp'] = status
return ServerServicesStatusResponse(**parsed_services_status)

View File

@ -0,0 +1,143 @@
from fastapi import APIRouter, HTTPException
from .schema.user import UserListResponse, UserInfoResponse, AddUserInputBody, EditUserInputBody
from .schema.response import DetailResponse
import cli_api
router = APIRouter()
@router.get('/', response_model=UserListResponse)
async def list_users_api():
"""
Get a list of all users.
Returns:
List of user dictionaries.
Raises:
HTTPException: if no users are found, or if an error occurs.
"""
try:
if res := cli_api.list_users():
return res
raise HTTPException(status_code=404, detail='No users found.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.post('/', response_model=DetailResponse)
async def add_user_api(body: AddUserInputBody):
"""
Add a new user to the system.
Args:
body: An instance of AddUserInputBody containing the user's details.
Returns:
A DetailResponse with a message indicating the user has been added.
Raises:
HTTPException: if an error occurs while adding the user.
"""
try:
cli_api.add_user(body.username, body.traffic_limit, body.expiration_days, body.password, body.creation_date)
return DetailResponse(detail=f'User {body.username} has been added.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/{username}', response_model=UserInfoResponse)
async def get_user_api(username: str):
"""
Get the details of a user.
Args:
username: The username of the user to get.
Returns:
A user dictionary.
Raises:
HTTPException: if the user is not found, or if an error occurs.
"""
try:
if res := cli_api.get_user(username):
return res
raise HTTPException(status_code=404, detail=f'User {username} not found.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.patch('/{username}', response_model=DetailResponse)
async def edit_user_api(username: str, body: EditUserInputBody):
"""
Edit a user's details.
Args:
username: The username of the user to edit.
body: An instance of EditUserInputBody containing the new user details.
Returns:
A DetailResponse with a message indicating the user has been edited.
Raises:
HTTPException: if an error occurs while editing the user.
"""
try:
cli_api.edit_user(username, body.new_username, body.new_traffic_limit, body.new_expiration_days,
body.renew_password, body.renew_creation_date, body.blocked)
return DetailResponse(detail=f'User {username} has been edited.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.delete('/{username}', response_model=DetailResponse)
async def remove_user_api(username: str):
"""
Remove a user.
Args:
username: The username of the user to remove.
Returns:
A DetailResponse with a message indicating the user has been removed.
Raises:
HTTPException: if an error occurs while removing the user.
"""
try:
cli_api.remove_user(username)
return DetailResponse(detail=f'User {username} has been removed.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
@router.get('/{username}/reset', response_model=DetailResponse)
async def reset_user_api(username: str):
"""
Resets a user.
Args:
username: The username of the user to reset.
Returns:
A DetailResponse with a message indicating the user has been reset.
Raises:
HTTPException: if an error occurs while resetting the user.
"""
try:
cli_api.reset_user(username)
return DetailResponse(detail=f'User {username} has been reset.')
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
# TODO implement show user uri endpoint
# @router.get('/{username}/uri', response_model=TODO)
# async def show_user_uri(username: str):
# try:
# res = cli_api.show_user_uri(username)
# return res
# except Exception as e:
# raise HTTPException(status_code=400, detail=f'Error: {str(e)}')

View File

@ -0,0 +1 @@
from .basic import router

View File

@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import PlainTextResponse
from dependency import get_templates
router = APIRouter()
@router.get('/')
async def index(request: Request, templates: Jinja2Templates = Depends(get_templates)):
return templates.TemplateResponse('index.html', {'request': request})
@router.get('/robots.txt')
async def robots_txt(request: Request):
return PlainTextResponse('User-agent: *\nDisallow: /')

View File

@ -0,0 +1 @@
from .login import router

View File

@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from hashlib import sha256
from dependency import get_templates, get_session_manager
from session import SessionManager
from config import CONFIGS # type: ignore
router = APIRouter()
@router.get('/login')
async def login(request: Request, templates: Jinja2Templates = Depends(get_templates)):
return templates.TemplateResponse('login.html', {'request': request})
@router.post('/login')
async def login_post(
request: Request,
templates: Jinja2Templates = Depends(get_templates), session_manager: SessionManager = Depends(get_session_manager),
username: str = Form(), password: str = Form(), next_url: str = Form(default='/')
):
'''
Handles login form submission.
'''
password_hash = sha256(password.encode()).hexdigest()
if not username == CONFIGS.ADMIN_USERNAME or not password_hash == CONFIGS.ADMIN_PASSWORD: # type: ignore
return templates.TemplateResponse('login.html', {'request': request, 'error': 'Invalid username or password'})
session_id = session_manager.set_session(username)
# Redirect to the index page if there is no next query parameter in the URL
if next_url == '/':
redirect_url = request.url_for('index')
else:
redirect_url = next_url
res = RedirectResponse(url=redirect_url, status_code=302)
res.set_cookie(key='session_id', value=session_id)
return res
@router.get('/logout')
async def logout(request: Request, session_manager: SessionManager = Depends(get_session_manager)):
session_id = request.cookies.get('session_id')
if session_id:
session_manager.revoke_session(session_id)
res = RedirectResponse(url=request.url_for('index'), status_code=302)
res.delete_cookie('session_id')
return res

View File

@ -0,0 +1 @@
from .settings import router

View File

@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates
from dependency import get_templates
router = APIRouter()
@router.get('/')
async def settings(request: Request, templates: Jinja2Templates = Depends(get_templates)):
return templates.TemplateResponse('settings.html', {'request': request})
@router.get('/config')
async def config(request: Request, templates: Jinja2Templates = Depends(get_templates)):
return templates.TemplateResponse('config.html', {'request': request})

View File

@ -0,0 +1,2 @@
from .user import router

View File

@ -0,0 +1,23 @@
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.templating import Jinja2Templates
from dependency import get_templates
from .viewmodel import User
import cli_api
router = APIRouter()
@router.get('/')
async def users(request: Request, templates: Jinja2Templates = Depends(get_templates)):
try:
dict_users = cli_api.list_users() # type: ignore
users: list[User] = []
if dict_users:
users: list[User] = [User.from_dict(key, value) for key, value in dict_users.items()] # type: ignore
return templates.TemplateResponse('users.html', {'users': users, 'request': request})
except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')

View File

@ -0,0 +1,108 @@
from pydantic import BaseModel
from datetime import datetime, timedelta
import cli_api
class Config(BaseModel):
type: str
link: str
@staticmethod
def from_username(username: str) -> list['Config']:
raw_uri = Config.__get_user_configs_uri(username)
if not raw_uri:
return []
res = []
for line in raw_uri.splitlines():
config = Config.__parse_user_configs_uri_line(line)
if config:
res.append(config)
return res
@staticmethod
def __get_user_configs_uri(username: str) -> str:
# This command is equivalent to `show-user-uri --username $username --ipv 4 --all --singbox --normalsub`
raw_uri = cli_api.show_user_uri(username, False, 4, True, True, True)
return raw_uri.strip() if raw_uri else ''
@staticmethod
def __parse_user_configs_uri_line(line: str) -> "Config | None":
config_type = ''
config_link = ''
line = line.strip()
if line.startswith("hy2://"):
if "@" in line:
ip_version = "IPv6" if line.split("@")[1].count(":") > 1 else "IPv4"
config_type = ip_version
config_link = line
else:
return None
elif line.startswith("https://"):
if "singbox" in line.lower():
config_type = "Singbox"
elif "normal" in line.lower():
config_type = "Normal-SUB"
else:
return None
config_link = line
else:
return None
return Config(type=config_type, link=config_link)
class User(BaseModel):
username: str
status: str
quota: str
traffic_used: str
expiry_date: datetime
expiry_days: int
enable: bool
configs: list[Config]
@staticmethod
def from_dict(username: str, user_data: dict):
user_data = {'username': username, **user_data}
user_data = User.__parse_user_data(user_data)
return User(**user_data)
@staticmethod
def __parse_user_data(user_data: dict) -> dict:
expiry_date = 'N/A'
creation_date_str = user_data.get("account_creation_date")
expiration_days = user_data.get('expiration_days', 0)
if creation_date_str and expiration_days:
try:
creation_date = datetime.strptime(creation_date_str, "%Y-%m-%d")
expiry_date = creation_date + timedelta(days=expiration_days)
except ValueError:
pass
traffic_used = User.__format_traffic(user_data.get("download_bytes", 0) + user_data.get("upload_bytes", 0))
return {
'username': user_data['username'],
'status': user_data.get('status', 'Not Active'),
'quota': User.__format_traffic(user_data.get('max_download_bytes', 0)),
'traffic_used': traffic_used,
'expiry_date': expiry_date,
'expiry_days': expiration_days,
'enable': False if user_data.get('blocked', False) else True,
'configs': Config.from_username(user_data['username'])
}
@staticmethod
def __format_traffic(traffic_bytes) -> str:
if traffic_bytes < 1024:
return f'{traffic_bytes} B'
elif traffic_bytes < 1024**2:
return f'{traffic_bytes / 1024:.2f} KB'
elif traffic_bytes < 1024**3:
return f'{traffic_bytes / 1024**2:.2f} MB'
else:
return f'{traffic_bytes / 1024**3:.2f} GB'

View File

@ -0,0 +1 @@
from .session import SessionData, SessionStorage, SessionManager

View File

@ -0,0 +1,55 @@
import secrets
from datetime import datetime, timedelta, timezone
from pydantic import BaseModel
class SessionData(BaseModel):
'''Pydantic model for representing session data.'''
username: str
created_at: datetime
expires_at: datetime
class SessionStorage:
'''Abstracts session storage (default: in-memory, can be replaced with Redis).'''
def __init__(self):
# The sessions are now stored as a dictionary with SessionData as values
self.sessions: dict[str, SessionData] = {}
def set(self, session_id: str, data: SessionData):
'''Store the session data with the session_id.'''
self.sessions[session_id] = data
def get(self, session_id: str) -> SessionData | None:
'''Retrieve session data by session_id.'''
return self.sessions.get(session_id)
def delete(self, session_id: str):
'''Delete a session from storage.'''
self.sessions.pop(session_id, None)
class SessionManager:
'''Manages user authentication with session storage.'''
def __init__(self, storage: SessionStorage, expiration_minutes: int = 60):
self.storage = storage
self.expiration = timedelta(minutes=expiration_minutes)
def set_session(self, username: str) -> str:
'''Generates a session ID and stores user data in the session.'''
session_id = secrets.token_hex(32)
session_data = SessionData(username=username, created_at=datetime.now(timezone.utc), expires_at=datetime.now(timezone.utc) + self.expiration)
self.storage.set(session_id, session_data)
return session_id
def get_session(self, session_id: str) -> SessionData | None:
'''Retrieves and validates a session. Raises HTTPException if the session is invalid or expired.'''
return self.storage.get(session_id)
def revoke_session(self, session_id: str):
'''Removes session from storage.'''
self.storage.delete(session_id)

View File

@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="{{ url_for('assets', path='img/favicon.ico') }}">
<title>Admin Panel | {% block title %}{% endblock %}</title>
<!-- Google Font: Source Sans Pro -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,400i,700&display=fallback">
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<!-- Theme style -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/admin-lte/3.2.0/css/adminlte.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/icheck-bootstrap@3.0.1/icheck-bootstrap.min.css">
{% block stylesheets %}{% endblock %}
</head>
<body class="hold-transition sidebar-mini">
<div class="wrapper">
<!-- Navbar -->
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a>
</li>
</ul>
<!-- Right navbar links -->
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" id="darkModeToggle" href="#">
<i id="darkModeIcon" class="fas fa-moon"></i>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('logout') }}">
<i class="fas fa-sign-out-alt"></i>
</a>
</li>
</ul>
</nav>
<!-- Main Sidebar Container -->
<aside class="main-sidebar sidebar-dark-primary elevation-4">
<!-- Brand Logo -->
<a href="{{ url_for('index') }}" class="brand-link">
<span class="brand-text font-weight-light">Hysteria2 Dashboard</span>
</a>
<!-- Sidebar -->
<div class="sidebar">
<!-- Sidebar Menu -->
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
<li class="nav-item">
<a href="{{ url_for('index') }}" class="nav-link {% if request.path == url_for('index') %}active{% endif %}">
<i class="nav-icon fas fa-tachometer-alt"></i>
<p>Dashboard</p>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('users') }}" class="nav-link {% if request.path == url_for('users') %}active{% endif %}">
<i class="nav-icon fas fa-users"></i>
<p>Users</p>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('settings') }}" class="nav-link {% if request.path == url_for('settings') %}active{% endif %}">
<i class="nav-icon fas fa-tools"></i>
<p>Settings</p>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('config') }}" class="nav-link {% if request.path == url_for('config') %}active{% endif %}">
<i class="nav-icon fas fa-cog"></i>
<p>Config</p>
</a>
</li>
</ul>
</nav>
</div>
</aside>
<!-- Content Wrapper -->
<div class="content-wrapper">
{% block content %}{% endblock %}
</div>
<!-- Footer -->
<footer class="main-footer">
<strong>Copyright © 2023 <a href="https://github.com/ReturnFI/Hysteria2">Return-Hysteria2</a>.</strong>
</footer>
</div>
<!-- REQUIRED SCRIPTS -->
<!-- jQuery -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<!-- Bootstrap 4 -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/js/bootstrap.bundle.min.js"></script>
<!-- AdminLTE App -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/admin-lte/3.2.0/js/adminlte.min.js"></script>
<script>
$(function () {
// Dark Mode Toggle
const darkModeToggle = $("#darkModeToggle");
const darkModeIcon = $("#darkModeIcon");
// Check for saved preference in localStorage
const isDarkMode = localStorage.getItem("darkMode") === "enabled";
setDarkMode(isDarkMode);
updateIcon(isDarkMode);
// Toggle dark mode on click
darkModeToggle.on("click", function (e) {
e.preventDefault();
const enabled = $("body").hasClass("dark-mode");
localStorage.setItem("darkMode", enabled ? "disabled" : "enabled");
setDarkMode(!enabled);
updateIcon(!enabled);
});
// Function to set dark mode styles
function setDarkMode(enabled) {
if (enabled) {
$("body").addClass("dark-mode");
} else {
$("body").removeClass("dark-mode");
}
}
// Function to update the icon
function updateIcon(enabled) {
if (enabled) {
darkModeIcon.removeClass("fa-moon").addClass("fa-sun");
} else {
darkModeIcon.removeClass("fa-sun").addClass("fa-moon");
}
}
});
</script>
{% block javascripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block title %}Config Editor{% endblock %}
{% block content %}
<br>
<div class="" style="margin-left: 20px; margin-right: 20px; margin-bottom: 20px;">
<div class="flex justify mb-4 space-x-2">
<button id="restore-button" onclick="restoreJson()" class="px-4 py-2 bg-blue text-black rounded-lg">Restore JSON</button>
<button id="save-button" onclick="saveJson()" class="px-4 py-2 bg-green text-black rounded-lg duration-200">Save JSON</button>
</div>
<div id="jsoneditor" class="border rounded-lg shadow-lg" style="height: 750px; width: 100%;"></div>
</div>
{% endblock %}
{% block javascripts %}
<script src="https://cdn.jsdelivr.net/npm/jsoneditor@9.1.0/dist/jsoneditor.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsoneditor@9.1.0/dist/jsoneditor.min.css" />
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
const saveButton = document.getElementById("save-button");
const container = document.getElementById("jsoneditor");
const editor = new JSONEditor(container, {
mode: "code",
modes: ["code", "tree"],
onChange: validateJson
});
// Function to validate the JSON and update UI elements
function validateJson() {
try {
editor.get(); // Validate the JSON
updateSaveButton(true);
hideErrorMessage();
} catch (error) {
updateSaveButton(false);
showErrorMessage("Invalid JSON! Please correct the errors.");
}
}
// Function to update save button based on JSON validity
function updateSaveButton(isValid) {
saveButton.disabled = !isValid;
saveButton.style.cursor = isValid ? "pointer" : "not-allowed";
saveButton.style.setProperty('background-color', isValid ? "#28a745" : "#ccc", 'important');
saveButton.style.setProperty('color', isValid ? "#fff" : "#666", 'important');
//saveButton.style.setProperty('border-color', isValid ? "#28a745" : "#ccc", 'important');
}
// Function to show error message with SweetAlert2
function showErrorMessage(message) {
Swal.fire({
title: "Error",
text: message,
icon: "error",
showConfirmButton: false,
timer: 5000,
position: 'top-right',
toast: true,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' }
});
}
// Function to hide error message if JSON is fixed
function hideErrorMessage() {
Swal.close();
}
// Function to ask the user before saving and save the JSON if confirmed
function saveJson() {
Swal.fire({
title: 'Are you sure?',
text: 'Do you want to save the changes?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes, save it!',
cancelButtonText: 'Cancel',
reverseButtons: true
}).then((result) => {
if (result.isConfirmed) {
fetch("{{ url_for('set_file') }}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editor.get())
})
.then(() => {
Swal.fire('Saved!', 'Your changes have been saved.', 'success');
})
.catch(error => {
Swal.fire('Error!', 'There was an error saving your data.', 'error');
console.error("Error saving JSON:", error);
});
}
});
}
// Function to load JSON from the server
function restoreJson() {
fetch("{{ url_for('get_file') }}")
.then(response => response.json())
.then(json => editor.set(json))
.catch(error => console.error("Error loading JSON:", error));
}
restoreJson(); // Initial JSON load
</script>
{% endblock %}

View File

@ -0,0 +1,171 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0">Dashboard</h1>
</div>
</div>
</div>
</div>
<section class="content">
<div class="container-fluid">
<div class="row">
<div class="col-lg-3 col-6">
<!-- small box -->
<div class="small-box bg-info">
<div class="inner">
<h3 id="cpu-usage">--<sup style="font-size: 20px">%</sup></h3>
<p>CPU Usage</p>
</div>
<div class="icon">
<i class="fas fa-microchip"></i>
</div>
</div>
</div>
<div class="col-lg-3 col-6">
<!-- small box -->
<div class="small-box bg-warning">
<div class="inner">
<h3 id="ram-usage">--</h3>
<p>RAM Usage</p>
</div>
<div class="icon">
<i class="fas fa-memory"></i>
</div>
</div>
</div>
<div class="col-lg-3 col-6">
<!-- small box -->
<div class="small-box bg-secondary">
<div class="inner">
<h3 id="total-traffic">--</h3>
<p>Total Traffic</p>
</div>
<div class="icon">
<i class="fas fa-network-wired"></i>
</div>
</div>
</div>
<div class="col-lg-3 col-6">
<!-- small box -->
<div class="small-box bg-success">
<div class="inner">
<h3 id="online-users">--</h3>
<p>Online Users</p>
</div>
<div class="icon">
<i class="fas fa-users"></i>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-3 col-6">
<div class="small-box" id="hysteria2-status-box">
<div class="inner">
<h3 id="hysteria2-status">--</h3>
<p>Hysteria2</p>
</div>
<div class="icon">
<i class="fas fa-bolt"></i>
</div>
</div>
</div>
<div class="col-lg-3 col-6">
<div class="small-box" id="telegrambot-status-box">
<div class="inner">
<h3 id="telegrambot-status">--</h3>
<p>Telegram Bot</p>
</div>
<div class="icon">
<i class="fab fa-telegram"></i>
</div>
</div>
</div>
<div class="col-lg-3 col-6">
<div class="small-box" id="singbox-status-box">
<div class="inner">
<h3 id="singbox-status">--</h3>
<p>Singbox</p>
</div>
<div class="icon">
<i class="fas fa-box"></i>
</div>
</div>
</div>
<div class="col-lg-3 col-6">
<div class="small-box" id="normalsub-status-box">
<div class="inner">
<h3 id="normalsub-status">--</h3>
<p>Normalsub</p>
</div>
<div class="icon">
<i class="fas fa-rss"></i>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block javascripts %}
<script>
function updateServerInfo() {
fetch('{{ url_for("server_status_api") }}')
.then(response => response.json())
.then(data => {
document.getElementById('cpu-usage').textContent = data.cpu_usage;
document.getElementById('ram-usage').textContent = data.ram_usage;
document.getElementById('online-users').textContent = data.online_users;
document.getElementById('total-traffic').textContent = data.total_traffic;
})
.catch(error => console.error('Error fetching server info:', error));
}
function updateServiceStatuses() {
// Add services api in fetch
fetch('{{ url_for("server_services_status_api") }}')
.then(response => response.json())
.then(data => {
updateServiceBox('hysteria2', data.hysteria_server);
updateServiceBox('telegrambot', data.hysteria_telegram_bot);
updateServiceBox('singbox', data.hysteria_singbox);
updateServiceBox('normalsub', data.hysteria_normal_sub);
})
.catch(error => console.error('Error fetching service statuses:', error));
}
function updateServiceBox(serviceName, status) {
const statusElement = document.getElementById(serviceName + '-status');
const statusBox = document.getElementById(serviceName + '-status-box');
if (status === true) {
statusElement.textContent = 'Active';
statusBox.classList.remove('bg-danger');
statusBox.classList.add('bg-success'); // Add green
} else {
statusElement.textContent = 'Inactive';
statusBox.classList.remove('bg-success');
statusBox.classList.add('bg-danger'); // Add red
}
}
updateServerInfo();
updateServiceStatuses();
setInterval(updateServerInfo, 5000);
</script>
{% endblock %}

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Dashboard - Login</title>
<!-- Google Font: Source Sans Pro -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,400i,700&display=fallback">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<!-- icheck bootstrap -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/icheck-bootstrap/3.0.1/icheck-bootstrap.min.css">
<!-- Theme style -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/admin-lte/3.2.0/css/adminlte.min.css">
</head>
<body class="hold-transition login-page">
<div class="login-box">
<div class="login-logo">
<a href="#"><b>Admin</b> Dashboard</a>
</div>
<!-- /.login-logo -->
<div class="card">
<div class="card-body login-card-body">
<p class="login-box-msg">Sign in to start your session</p>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form action="{{ url_for('login') }}" method="post">
<input type="hidden" name="next_url" value="{{ request.query_params.get('next_url', '/') }}">
<div class="input-group mb-3">
<input type="text" name="username" class="form-control" placeholder="Username" required>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-user"></span>
</div>
</div>
</div>
<div class="input-group mb-3">
<input type="password" name="password" class="form-control" placeholder="Password" required>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-lock"></span>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
</div>
</div>
</form>
</div>
<!-- /.login-card-body -->
</div>
</div>
<!-- /.login-box -->
<!-- jQuery -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<!-- Bootstrap 4 -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/js/bootstrap.bundle.min.js"></script>
<!-- AdminLTE App -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/admin-lte/3.2.0/js/adminlte.min.js"></script>
</body>
</html>

View File

@ -0,0 +1,366 @@
{% extends 'base.html' %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class='content-header'>
<div class='container-fluid'>
<div class='row mb-2'>
<div class='col-sm-6'>
<h1 class='m-0'>Settings</h1>
</div>
</div>
</div>
</div>
<div class='content'>
<div class='container-fluid'>
<div class='row'>
<div class='col-lg-12'>
<div class='card card-primary card-outline card-tabs'>
<div class='card-header p-0 pt-1 border-bottom-0'>
<ul class='nav nav-pills' id='custom-tabs-three-tab' role='tablist' style="margin-left: 20px; margin-top: 10px;">
<li class='nav-item'>
<a class='nav-link active' id='subs-tab' data-toggle='pill' href='#subs' role='tab' aria-controls='subs' aria-selected='false'>Subscriptions</a>
</li>
<li class='nav-item'>
<a class='nav-link' id='telegram-tab' data-toggle='pill' href='#telegram' role='tab' aria-controls='telegram' aria-selected='true'>Telegram
Bot</a>
</li>
<li class='nav-item'>
<a class='nav-link' id='sni-tab' data-toggle='pill' href='#sni' role='tab' aria-controls='sni' aria-selected='false'>Change SNI</a>
</li>
<li class='nav-item'>
<a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab' aria-controls='change_ip' aria-selected='false'>Change IP</a>
</li>
</ul>
</div>
<div class='card-body' style="margin-left: 25px;">
<div class='tab-content' id='custom-tabs-three-tabContent'>
<!-- Subscriptions Tab -->
<div class='tab-pane fade show active' id='subs' role='tabpanel' aria-labelledby='subs-tab'>
<ul class='nav nav-tabs' id='subs-tabs' role='tablist'>
<li class='nav-item'>
<a class='nav-link active' id='singbox-tab' data-toggle='tab' href='#singbox' role='tab' aria-controls='singbox'
aria-selected='true'><strong>SingBox</strong></a>
</li>
<li class='nav-item'>
<a class='nav-link' id='normal-tab' data-toggle='tab' href='#normal' role='tab' aria-controls='normal'
aria-selected='false'><strong>Normal</strong></a>
</li>
</ul>
<div class='tab-content' id='subs-tabs-content'>
<br>
<!-- SingBox Sub Tab -->
<div class='tab-pane fade show active' id='singbox' role='tabpanel' aria-labelledby='singbox-tab'>
<form>
<div class='form-group'>
<label for='singbox_domain'>Domain:</label>
<input type='text' class='form-control' id='singbox_domain' placeholder='Enter Domain'>
</div>
<div class='form-group'>
<label for='singbox_port'>Port:</label>
<input type='text' class='form-control' id='singbox_port' placeholder='Enter Port'>
</div>
<button id="singbox_start" type='button' class='btn btn-success'>Start</button>
<button id="singbox_stop" type='button' class='btn btn-danger' style="display: none;">Stop</button>
</form>
</div>
<!-- Normal Sub Tab -->
<div class='tab-pane fade' id='normal' role='tabpanel' aria-labelledby='normal-tab'>
<form>
<div class='form-group'>
<label for='normal_domain'>Domain:</label>
<input type='text' class='form-control' id='normal_domain' placeholder='Enter Domain'>
</div>
<div class='form-group'>
<label for='normal_port'>Port:</label>
<input type='text' class='form-control' id='normal_port' placeholder='Enter Port'>
</div>
<button id="normal_start" type='button' class='btn btn-success'>Start</button>
<button id="normal_stop" type='button' class='btn btn-danger' style="display: none;">Stop</button>
</form>
</div>
</div>
</div>
<!-- Telegram Bot Tab -->
<div class='tab-pane fade show' id='telegram' role='tabpanel' aria-labelledby='telegram-tab'>
<form>
<div class='form-group'>
<label for='telegram_api_token'>API Token:</label>
<input type='text' class='form-control' id='telegram_api_token' placeholder='Enter API Token'>
</div>
<div class='form-group'>
<label for='telegram_admin_id'>Admin ID:</label>
<input type='text' class='form-control' id='telegram_admin_id' placeholder='Enter Admin ID'>
</div>
<button id="telegram_start" type='button' class='btn btn-success'>Start</button>
<button id="telegram_stop" type='button' class='btn btn-danger' style="display: none;">Stop</button>
</form>
</div>
<!-- SNI Tab -->
<div class='tab-pane fade' id='sni' role='tabpanel' aria-labelledby='sni-tab'>
<form>
<div class='form-group'>
<label for='sni_domain'>Domain:</label>
<input type='text' class='form-control' id='sni_domain' placeholder='Enter Domain'>
</div>
<button id="sni_change" type='button' class='btn btn-primary'>Save</button>
</form>
</div>
<!-- Change IP Tab -->
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
<form>
<div class='form-group'>
<label for='ipv4'>IPv4:</label>
<input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4' value="{{ ipv4 or '' }}">
</div>
<div class='form-group'>
<label for='ipv6'>IPv6:</label>
<input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6' value="{{ ipv6 or '' }}">
</div>
<button id="ip_change" type='button' class='btn btn-primary'>Save</button>
</form>
</div>
</div>
</div>
<!-- /.card -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
function initUI() {
// Fetch service status on page load
$.ajax({
url: "{{ url_for('server_services_status_api') }}",
type: "GET",
success: function (data) {
updateServiceUI(data);
},
error: function () {
console.error("Failed to fetch service status.");
}
});
// Update UI based on service status
function updateServiceUI(data) {
const servicesMap = {
"hysteria_telegram_bot": "#telegram",
"hysteria_singbox": "#singbox",
"hysteria_normal_sub": "#normal"
};
Object.keys(servicesMap).forEach(service => {
let selector = servicesMap[service];
let isRunning = data[service];
if (isRunning) {
$(selector + " input, " + selector + " label").remove();
$(selector + " .btn-success").remove();
$(selector).prepend(`<div class='alert alert-info'>Service is running. You can stop it if needed.</div>`);
$(selector + " .btn-danger").show();
} else {
$(selector + " input, " + selector + " label").show();
$(selector + " .btn-success").show();
$(selector + " .btn-danger").hide();
$(selector + " .alert-info").remove();
}
});
}
}
$(document).ready(function () {
// Init UI
initUI();
// Function to show confirmation before executing API calls
function confirmAction(actionName, callback) {
Swal.fire({
title: `Are you sure?`,
text: `Do you really want to ${actionName}?`,
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes, proceed!",
cancelButtonText: "Cancel"
}).then((result) => {
if (result.isConfirmed) {
callback();
}
});
}
// Function to handle AJAX requests
function sendRequest(url, type, data, successMessage) {
$.ajax({
url: url,
type: type,
contentType: "application/json",
data: JSON.stringify(data),
success: function (response) {
Swal.fire("Success!", successMessage, "success").then(() => {
location.reload();
});
console.log(response);
},
error: function (xhr, status, error) {
Swal.fire("Error!", "Something went wrong. Check the console for details.", "error");
console.error(error);
}
});
}
// Telegram Bot Start
function startTelegram() {
const apiToken = $("#telegram_api_token").val();
const adminId = $("#telegram_admin_id").val();
confirmAction("start the Telegram bot", function () {
sendRequest(
"{{ url_for('telegram_start_api') }}",
"POST",
{ token: apiToken, admin_id: adminId },
"Telegram bot started successfully!"
);
});
}
// Telegram Bot Stop
function stopTelegram() {
confirmAction("stop the Telegram bot", function () {
sendRequest(
"{{ url_for('telegram_stop_api') }}",
"DELETE",
null,
"Telegram bot stopped successfully!"
);
});
}
// SingBox Start
function startSingbox() {
const domain = $("#singbox_domain").val();
const port = $("#singbox_port").val();
confirmAction("start SingBox", function () {
sendRequest(
"{{ url_for('singbox_start_api') }}",
"POST",
{ domain: domain, port: port },
"SingBox started successfully!"
);
});
}
// SingBox Stop
function stopSingbox() {
confirmAction("stop SingBox", function () {
sendRequest(
"{{ url_for('singbox_stop_api') }}",
"DELETE",
null,
"SingBox stopped successfully!"
);
});
}
// Normal Subscription Start
function startNormal() {
const domain = $("#normal_domain").val();
const port = $("#normal_port").val();
confirmAction("start the normal subscription", function () {
sendRequest(
"{{ url_for('normal_sub_start_api') }}",
"POST",
{ domain: domain, port: port },
"Normal subscription started successfully!"
);
});
}
// Normal Subscription Stop
function stopNormal() {
confirmAction("stop the normal subscription", function () {
sendRequest(
"{{ url_for('normal_sub_stop_api') }}",
"DELETE",
null,
"Normal subscription stopped successfully!"
);
});
}
// Change SNI
function changeSNI() {
const domain = $("#sni_domain").val();
const baseUrl = "{{ url_for('set_sni', sni='SNI_PLACEHOLDER') }}";
const url = baseUrl.replace("SNI_PLACEHOLDER", domain);
confirmAction("change the SNI", function () {
sendRequest(url, "GET", null, "SNI changed successfully!");
});
}
// Save IP
function saveIP() {
confirmAction("save the new IP", function () {
sendRequest(
"{{ url_for('edit_ip_api') }}",
"POST",
{ ipv4: $("#ipv4").val(), ipv6: $("#ipv6").val() },
"New IP saved successfully!"
);
});
}
// Attach event listeners
$("#telegram_start").on("click", startTelegram);
$("#telegram_stop").on("click", stopTelegram);
$("#singbox_start").on("click", startSingbox);
$("#singbox_stop").on("click", stopSingbox);
$("#normal_start").on("click", startNormal);
$("#normal_stop").on("click", stopNormal);
$("#sni_change").on("click", changeSNI);
$("#ip_change").on("click", saveIP);
$.ajax({
url: "{{ url_for('get_ip_api') }}",
type: "GET",
success: function (data) {
$("#ipv4").val(data.ipv4 || "");
$("#ipv6").val(data.ipv6 || "");
$("#ipv4").attr("placeholder", "Enter IPv4");
$("#ipv6").attr("placeholder", "Enter IPv6");
},
error: function () {
console.error("Failed to fetch IP addresses.");
$("#ipv4").attr("placeholder", "Enter IPv4");
$("#ipv6").attr("placeholder", "Enter IPv6");
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,582 @@
{% extends "base.html" %}
{% block title %}Users{% endblock %}
{% block content %}
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0">Users</h1>
</div>
</div>
</div>
</div>
<section class="content">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h3 class="card-title">User List</h3>
<div class="card-tools d-flex align-items-center">
<!-- Search Form -->
<div class="input-group input-group-sm" style="width: 100px;">
<input type="text" id="searchInput" class="form-control float-right" placeholder="Search">
<div class="input-group-append">
<button type="submit" class="btn btn-default" id="searchButton">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<!-- Add User Button -->
<button type="button" class="btn btn-primary ml-2" data-toggle="modal" data-target="#addUserModal">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body table-responsive p-0">
{% if users|length == 0 %}
<div class="alert alert-warning" role="alert" style="margin: 20px;">
No users found.
</div>
{% else %}
<table class="table table-bordered table-hover" id="userTable">
<thead>
<tr>
<th>Status</th>
<th>Username</th>
<th>Quota</th>
<th>Used</th>
<th class="text-nowrap">Expiry Date</th>
<th class="text-nowrap">Expiry Days</th>
<th>Enable</th>
<th class="text-nowrap">Configs</th>
<th class="text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
{% if user['status'] == "Online" %}
<i class="fas fa-circle text-success"></i> Online
{% elif user['status'] == "Offline" %}
<i class="fas fa-circle text-secondary"></i> Offline
{% else %}
<i class="fas fa-circle text-danger"></i> {{ user['status'] }}
{% endif %}
</td>
<td data-username="{{ user.username }}">{{ user.username }}</td>
<td>{{ user.quota }}</td>
<td>{{ user.traffic_used }}</td>
<td>{{ user.expiry_date }}</td>
<td>{{ user.expiry_days }}</td>
<td>
{% if user.enable %}
<i class="fas fa-check-circle text-success"></i>
{% else %}
<i class="fas fa-times-circle text-danger"></i>
{% endif %}
</td>
<td class="text-nowrap">
<a href="#" class="config-link" data-toggle="modal" data-target="#qrcodeModal" data-username="{{ user.username }}">
<i class="fas fa-qrcode"></i>
</a>
<div id="userConfigs-{{ user.username }}" style="display: none;">
{% for config in user.configs %}
<div class="config-container" data-link="{{ config.link }}">
<span class="config-type">{{ config.type }}:</span>
{% if config.type == "Singbox" or config.type == "Normal-SUB" %}
<span class="config-link-text">{{ config.link }}</span>
{% else %}
<span class="config-link-text">{{ config.link }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</td>
<td class="text-nowrap">
<button type="button" class="btn btn-sm btn-info edit-user" data-user='{{ user.username }}' data-toggle="modal" data-target="#editUserModal">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-sm btn-warning reset-user" data-user='{{ user.username }}'>
<i class="fas fa-undo"></i>
</button>
<button type="button" class="btn btn-sm btn-danger delete-user" data-user='{{ user.username }}'>
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<div class="row mt-3">
<div class="col-sm-12 col-md-7">
<div class="dataTables_paginate paging_simple_numbers" id="userTable_paginate">
{# {{ pagination.links }} #}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Add User Modal -->
<div class="modal fade" id="addUserModal" tabindex="-1" role="dialog" aria-labelledby="addUserModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addUserModalLabel">Add User</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<form id="addUserForm">
<div class="form-group">
<label for="addUsername">Username</label>
<input type="text" class="form-control" id="addUsername" name="username" required>
</div>
<div class="form-group">
<label for="addTrafficLimit">Traffic Limit (GB)</label>
<input type="number" class="form-control" id="addTrafficLimit" name="traffic_limit" required>
</div>
<div class="form-group">
<label for="addExpirationDays">Expiration Days</label>
<input type="number" class="form-control" id="addExpirationDays" name="expiration_days" required>
</div>
<button type="submit" class="btn btn-primary">Add User</button>
</form>
</div>
</div>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" role="dialog" aria-labelledby="editUserModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editUserModalLabel">Edit User</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<form id="editUserForm">
<div class="form-group">
<label for="editUsername">Username</label>
<input type="text" class="form-control" id="editUsername" name="new_username">
</div>
<div class="form-group">
<label for="editTrafficLimit">Traffic Limit (GB)</label>
<input type="number" class="form-control" id="editTrafficLimit" name="new_traffic_limit">
</div>
<div class="form-group">
<label for="editExpirationDays">Expiration Days</label>
<input type="number" class="form-control" id="editExpirationDays" name="new_expiration_days">
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="editBlocked" name="blocked" value="true">
<label class="form-check-label" for="editBlocked">Blocked</label>
</div>
<input type="hidden" id="originalUsername" name="username">
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</div>
</div>
</div>
</div>
<!-- QR Code Modal -->
<div class="modal fade" id="qrcodeModal" tabindex="-1" role="dialog" aria-labelledby="qrcodeModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="qrcodeModalLabel">QR Codes</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body text-center">
<div id="qrcodesContainer" class="mx-auto"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
<!-- Include qr-code-styling library -->
<script src="https://cdn.jsdelivr.net/npm/qr-code-styling/lib/qr-code-styling.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
$(function () {
// Add User Form Submit
$("#addUserForm").on("submit", function (e) {
e.preventDefault();
const formData = $(this).serializeArray();
const jsonData = {};
formData.forEach(field => {
jsonData[field.name] = field.value;
});
$.ajax({
url: " {{ url_for('add_user_api') }} ",
method: "POST",
contentType: "application/json",
data: JSON.stringify(jsonData),
success: function (response) {
if (response.detail) {
Swal.fire({
title: "Success!",
text: response.detail,
icon: "success",
confirmButtonText: "OK",
}).then(() => {
location.reload();
});
} else {
Swal.fire({
title: "Error!",
text: response.error || "Failed to add user",
icon: "error",
confirmButtonText: "OK",
});
}
},
error: function () {
Swal.fire({
title: "Error!",
text: "An error occurred while adding user",
icon: "error",
confirmButtonText: "OK",
});
}
});
});
// Edit User Form Populate and Submit
$(document).on("click", ".edit-user", function () {
const username = $(this).data("user");
const row = $(this).closest("tr");
const quota = row.find("td:eq(2)").text().trim();
const expiry = row.find("td:eq(4)").text().trim(); // Get expiry from the table
const expiry_days = row.find("td:eq(5)").text().trim();
const blocked = row.find("td:eq(6)").text().trim().toLowerCase() === 'disabled'; // Check if 'disabled'
// Extract numeric values from quota and expiry strings
const quotaValue = parseFloat(quota);
const expiryValue = parseInt(expiry); // Parse expiry as integer
// Populate the modal fields
$("#originalUsername").val(username);
$("#editUsername").val(username);
$("#editTrafficLimit").val(quotaValue);
$("#editExpirationDays").val(expiry_days);
$("#editBlocked").prop("checked", blocked);
});
$("#editUserForm").on("submit", function (e) {
e.preventDefault();
const formData = $(this).serializeArray();
const jsonData = {};
formData.forEach(field => {
jsonData[field.name] = field.value;
});
const editUserUrl = "{{ url_for('edit_user_api', username='USERNAME_PLACEHOLDER') }}";
const url = editUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent($("#originalUsername").val()));
$.ajax({
url: url,
method: "PATCH",
contentType: "application/json",
data: JSON.stringify(jsonData),
success: function (response) {
if (typeof response === 'string' && response.includes("User updated successfully")) {
const username = $("#originalUsername").val();
const row = $(`td[data-username='${username}']`).closest("tr");
row.find("td:eq(1)").text($("#editUsername").val());
row.find("td:eq(2)").text($("#editTrafficLimit").val() + " GB");
row.find("td:eq(5)").text($("#editExpirationDays").val());
row.find("td:eq(6) i")
.removeClass()
.addClass(
$("#editBlocked").prop("checked")
? "fas fa-times-circle text-danger"
: "fas fa-check-circle text-success"
);
// Hide the modal
$("#editUserModal").modal("hide");
Swal.fire({
title: "Success!",
text: "User updated successfully!",
icon: "success",
confirmButtonText: "OK",
});
}
else if (response && response.detail) {
const username = $("#originalUsername").val();
const row = $(`td[data-username='${username}']`).closest("tr");
row.find("td:eq(1)").text($("#editUsername").val());
row.find("td:eq(2)").text($("#editTrafficLimit").val() + " GB");
row.find("td:eq(5)").text($("#editExpirationDays").val());
row.find("td:eq(6) i")
.removeClass()
.addClass(
$("#editBlocked").prop("checked")
? "fas fa-times-circle text-danger"
: "fas fa-check-circle text-success"
);
// Hide the modal
$("#editUserModal").modal("hide");
// Show a success message
Swal.fire({
title: "Success!",
text: response.detail,
icon: "success",
confirmButtonText: "OK",
});
} else {
$("#editUserModal").modal("hide");
Swal.fire({
title: "Error!",
text: response.error || "An error occurred.",
icon: "error",
confirmButtonText: "OK",
});
}
},
error: function (error) {
console.error(error);
Swal.fire({
title: "Error!",
text: "An error occurred while updating user",
icon: "error",
confirmButtonText: "OK",
});
}
});
});
// Prevent click event on the submit button from triggering form submission
$("#editUserForm button[type='submit']").on("click", function (e) {
e.preventDefault();
$(this).closest("form").submit();
});
// Reset User Button Click
$("#userTable").on("click", ".reset-user", function () {
const username = $(this).data("user");
Swal.fire({
title: "Are you sure?",
html: `This will reset <b>${username}</b>'s data.<br>This action cannot be undone!`,
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes, reset it!",
}).then((result) => {
if (result.isConfirmed) {
const resetUserUrl = "{{ url_for('reset_user_api', username='USERNAME_PLACEHOLDER') }}";
const url = resetUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username));
$.ajax({
url: url,
method: "GET",
contentType: "application/json",
data: JSON.stringify({ username: username }),
success: function (response) {
if (response.detail) {
Swal.fire({
title: "Success!",
text: response.detail,
icon: "success",
confirmButtonText: "OK",
}).then(() => {
location.reload();
});
} else {
Swal.fire({
title: "Error!",
text: response.error || "Failed to reset user",
icon: "error",
confirmButtonText: "OK",
});
}
},
error: function () {
Swal.fire({
title: "Error!",
text: "An error occurred while resetting user",
icon: "error",
confirmButtonText: "OK",
});
}
});
}
});
});
// Delete User Button Click
$("#userTable").on("click", ".delete-user", function () {
const username = $(this).data("user");
Swal.fire({
title: "Are you sure?",
html: `This will delete the user <b>${username}</b>.<br>This action cannot be undone!`,
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes, delete it!",
}).then((result) => {
if (result.isConfirmed) {
const removeUserUrl = "{{ url_for('remove_user_api', username='USERNAME_PLACEHOLDER') }}";
const url = removeUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username));
$.ajax({
url: url,
method: "DELETE",
contentType: "application/json",
data: JSON.stringify({ username: username }),
success: function (response) {
if (response.detail) {
Swal.fire({
title: "Success!",
text: response.detail,
icon: "success",
confirmButtonText: "OK",
}).then(() => {
location.reload();
});
} else {
Swal.fire({
title: "Error!",
text: response.error || "Failed to delete user",
icon: "error",
confirmButtonText: "OK",
});
}
},
error: function () {
Swal.fire({
title: "Error!",
text: "An error occurred while deleting user",
icon: "error",
confirmButtonText: "OK",
});
}
});
}
});
});
// QR Code Modal
$("#qrcodeModal").on("show.bs.modal", function (event) {
const button = $(event.relatedTarget);
const username = button.data("username");
const configContainer = $(`#userConfigs-${username}`);
const qrcodesContainer = $("#qrcodesContainer");
qrcodesContainer.empty();
configContainer.find(".config-container").each(function () {
const configLink = $(this).data("link");
const configType = $(this).find(".config-type").text().replace(":", "");
// Create a card for each QR code
const card = $(`
<div class="card d-inline-block mx-2 my-2" style="width: 180px;">
<div class="card-body">
<div id="qrcode-${configType}" class="mx-auto cursor-pointer"></div>
<div class="config-type-text mt-2 text-center">${configType}</div>
</div>
</div>
`);
qrcodesContainer.append(card);
const qrCodeStyling = new QRCodeStyling({
width: 150,
height: 150,
data: configLink,
dotsOptions: {
color: "#212121",
type: "square"
},
cornersSquareOptions: {
color: "#212121",
type: "square"
},
backgroundOptions: {
color: "#FAFAFA",
},
imageOptions: {
hideBackgroundDots: true,
}
});
qrCodeStyling.append(document.getElementById(`qrcode-${configType}`));
// Add click to copy functionality to the card
card.on("click", function () {
navigator.clipboard.writeText(configLink)
.then(() => {
Swal.fire({
icon: "success",
title: configType + " link copied!",
showConfirmButton: false,
timer: 1500,
});
})
.catch(err => {
console.error("Failed to copy link: ", err);
Swal.fire({
icon: "error",
title: "Failed to copy link",
text: "Please copy manually.",
});
});
});
});
});
// Prevent modal from closing when clicking inside
$("#qrcodeModal .modal-content").on("click", function (e) {
e.stopPropagation();
});
// Clear the QR code when the modal is hidden
$("#qrcodeModal").on("hidden.bs.modal", function () {
$("#qrcodesContainer").empty();
});
$("#qrcodeModal .close").on("click", function () {
$("#qrcodeModal").modal("hide");
});
// Search Functionality
function filterUsers() {
const searchText = $("#searchInput").val().toLowerCase();
$("#userTable tbody tr").each(function () {
const username = $(this).find("td:eq(1)").text().toLowerCase();
if (username.includes(searchText)) {
$(this).show();
} else {
$(this).hide();
}
});
}
$("#searchButton").on("click", filterUsers);
$("#searchInput").on("keyup", filterUsers);
});
</script>
{% endblock %}

View File

@ -0,0 +1,266 @@
#!/bin/bash
source /etc/hysteria/core/scripts/utils.sh
define_colors
CADDY_CONFIG_FILE="/etc/hysteria/core/scripts/webpanel/Caddyfile"
install_dependencies() {
# Update system
sudo apt update -y > /dev/null 2>&1
# Install dependencies
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl > /dev/null 2>&1
# Add Caddy repository
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key | sudo tee /etc/apt/trusted.gpg.d/caddy.asc > /dev/null 2>&1
echo "deb [signed-by=/etc/apt/trusted.gpg.d/caddy.asc] https://dl.cloudsmith.io/public/caddy/stable/deb/ubuntu/ $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null 2>&1
# Update package index again with Caddy repo
sudo apt update -y > /dev/null 2>&1
apt install libnss3-tools -y > /dev/null 2>&1
# Install Caddy
sudo apt install -y caddy
if [ $? -ne 0 ]; then
echo -e "${red}Error: Failed to install Caddy. ${NC}"
exit 1
fi
# Stop and disable Caddy service
systemctl stop caddy > /dev/null 2>&1
systemctl disable caddy > /dev/null 2>&1
echo -e "${green}Caddy installed successfully. ${NC}"
}
update_env_file() {
local domain=$1
local port=$2
local admin_username=$3
local admin_password=$4
local admin_password_hash=$(echo -n "$admin_password" | sha256sum | cut -d' ' -f1) # hash the password
local expiration_minutes=$5
local debug=$6
local api_token=$(openssl rand -hex 32)
local root_path=$(openssl rand -hex 16)
cat <<EOL > /etc/hysteria/core/scripts/webpanel/.env
DEBUG=$debug
DOMAIN=$domain
PORT=$port
ROOT_PATH=$root_path
API_TOKEN=$api_token
ADMIN_USERNAME=$admin_username
ADMIN_PASSWORD=$admin_password_hash
EXPIRATION_MINUTES=$expiration_minutes
EOL
}
update_caddy_file() {
source /etc/hysteria/core/scripts/webpanel/.env
# Ensure all required variables are set
if [ -z "$DOMAIN" ] || [ -z "$ROOT_PATH" ] || [ -z "$PORT" ]; then
echo -e "${red}Error: One or more environment variables are missing.${NC}"
return 1
fi
# Update the Caddyfile without the email directive
cat <<EOL > "$CADDY_CONFIG_FILE"
# Global configuration
{
# Disable admin panel of the Caddy
admin off
# Disable automatic HTTP to HTTPS redirects so the Caddy won't listen on port 80 (We need this port for other parts of the project)
auto_https disable_redirects
}
# Listen for incoming requests on the specified domain and port
$DOMAIN:$PORT {
# Define a route to handle all requests starting with ROOT_PATH('/$ROOT_PATH/')
route /$ROOT_PATH/* {
# We don't strip the ROOT_PATH('/$ROOT_PATH/') from the request
# uri strip_prefix /$ROOT_PATH
# We are proxying all requests under the ROOT_PATH to FastAPI at 127.0.0.1:8080
# FastAPI handles these requests because we set the 'root_path' parameter in the FastAPI instance.
reverse_proxy http://127.0.0.1:8080
}
# Any request that doesn't start with the ROOT_PATH('/$ROOT_PATH/') will be blocked and no response will be sent to the client
@blocked {
not path /fd31b4edc70619d5d39edf3c2da97e2c/*
}
# Abort the request, effectively dropping the connection without a response for invalid paths
abort @blocked
}
EOL
}
create_webpanel_service_file() {
cat <<EOL > /etc/systemd/system/hysteria-webpanel.service
[Unit]
Description=Hysteria2 Web Panel
After=network.target
[Service]
WorkingDirectory=/etc/hysteria/core/scripts/webpanel
EnvironmentFile=/etc/hysteria/core/scripts/webpanel/.env
ExecStart=/bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && /etc/hysteria/hysteria2_venv/bin/python /etc/hysteria/core/scripts/webpanel/app.py'
#Restart=always
User=root
Group=root
[Install]
WantedBy=multi-user.target
EOL
}
create_caddy_service_file() {
cat <<EOL > /etc/systemd/system/hysteria-caddy.service
[Unit]
Description=Hysteria2 Caddy
After=network.target
[Service]
WorkingDirectory=/etc/caddy
ExecStart=/usr/bin/caddy run --environ --config $CADDY_CONFIG_FILE
ExecReload=/usr/bin/caddy reload --config $CADDY_CONFIG_FILE --force
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
User=root
Group=root
[Install]
WantedBy=multi-user.target
EOL
}
start_service() {
local domain=$1
local port=$2
local admin_username=$3
local admin_password=$4
local expiration_minutes=$5
local debug=$6
# MAYBE I WANT TO CHANGE CONFIGS WITHOUT RESTARTING THE SERVICE MYSELF
# # Check if the services are already active
# if systemctl is-active --quiet hysteria-webpanel.service && systemctl is-active --quiet hysteria-caddy.service; then
# echo -e "${green}Hysteria web panel is already running with Caddy.${NC}"
# source /etc/hysteria/core/scripts/webpanel/.env
# echo -e "${yellow}The web panel is accessible at: http://$domain:$port/$ROOT_PATH${NC}"
# return
# fi
# Install required dependencies
install_dependencies
# Update environment file
update_env_file "$domain" "$port" "$admin_username" "$admin_password" "$expiration_minutes" "$debug"
if [ $? -ne 0 ]; then
echo -e "${red}Error: Failed to update the environment file.${NC}"
return 1
fi
# Create the web panel service file
create_webpanel_service_file
if [ $? -ne 0 ]; then
echo -e "${red}Error: Failed to create the webpanel service file.${NC}"
return 1
fi
# Reload systemd and enable webpanel service
systemctl daemon-reload
systemctl enable hysteria-webpanel.service > /dev/null 2>&1
systemctl start hysteria-webpanel.service > /dev/null 2>&1
# Check if the web panel is running
if systemctl is-active --quiet hysteria-webpanel.service; then
echo -e "${green}Hysteria web panel setup completed. The web panel is running locally on: http://127.0.0.1:8080/${NC}"
else
echo -e "${red}Error: Hysteria web panel service failed to start.${NC}"
return 1
fi
# Update Caddy configuration
update_caddy_file
if [ $? -ne 0 ]; then
echo -e "${red}Error: Failed to update the Caddyfile.${NC}"
return 1
fi
create_caddy_service_file
if [ $? -ne 0 ]; then
echo -e "${red}Error: Failed to create the Caddy service file.${NC}"
return 1
fi
# Restart Caddy service
systemctl restart hysteria-caddy.service
if [ $? -ne 0 ]; then
echo -e "${red}Error: Failed to restart Caddy.${NC}"
return 1
fi
# Check if the web panel is still running after Caddy restart
if systemctl is-active --quiet hysteria-webpanel.service; then
source /etc/hysteria/core/scripts/webpanel/.env
local webpanel_url="http://$domain:$port/$ROOT_PATH/"
echo -e "${green}Hysteria web panel is now running. The service is accessible on: $webpanel_url ${NC}"
else
echo -e "${red}Error: Hysteria web panel failed to start after Caddy restart.${NC}"
fi
}
show_webpanel_url() {
source /etc/hysteria/core/scripts/webpanel/.env
local webpanel_url="https://$DOMAIN:$PORT/$ROOT_PATH/"
echo "$webpanel_url"
}
show_webpanel_api_token() {
source /etc/hysteria/core/scripts/webpanel/.env
echo "$API_TOKEN"
}
stop_service() {
echo "Stopping Caddy..."
systemctl disable hysteria-caddy.service
systemctl stop hysteria-caddy.service
echo "Caddy stopped."
echo "Stopping Hysteria web panel..."
systemctl disable hysteria-webpanel.service
systemctl stop hysteria-webpanel.service
echo "Hysteria web panel stopped."
}
case "$1" in
start)
if [ -z "$2" ] || [ -z "$3" ]; then
echo -e "${red}Usage: $0 start <DOMAIN> <PORT> ${NC}"
exit 1
fi
start_service "$2" "$3" "$4" "$5" "$6" "$7"
;;
stop)
stop_service
;;
url)
show_webpanel_url
;;
api-token)
show_webpanel_api_token
;;
*)
echo -e "${red}Usage: $0 {start|stop} <DOMAIN> <PORT> ${NC}"
exit 1
;;
esac

View File

@ -1,10 +0,0 @@
import os
import click
def validate_port(ctx,param,value:int) -> int:
if value < 1 or value > 65535:
raise click.BadParameter('Port must be between 1 and 65535')
# check if port is in use
if os.system(f'lsof -i:{value}') == 0:
raise click.BadParameter(f'Port {value} is in use')
return value

View File

@ -53,7 +53,7 @@ else
echo "All required packages are already installed."
fi
git clone https://github.com/ReturnFI/Hysteria2 /etc/hysteria
git clone -b webpanel https://github.com/ReturnFI/Hysteria2 /etc/hysteria
cd /etc/hysteria
python3 -m venv hysteria2_venv

181
menu.sh
View File

@ -2,6 +2,21 @@
source /etc/hysteria/core/scripts/utils.sh
source /etc/hysteria/core/scripts/path.sh
source /etc/hysteria/core/scripts/services_status.sh >/dev/null 2>&1
check_services() {
for service in "${services[@]}"; do
service_base_name=$(basename "$service" .service)
display_name=$(echo "$service_base_name" | sed -E 's/([^-]+)-?/\u\1/g')
if systemctl is-active --quiet "$service"; then
echo -e "${NC}${display_name}:${green} Active${NC}"
else
echo -e "${NC}${display_name}:${red} Inactive${NC}"
fi
done
}
# OPTION HANDLERS (ONLY NEEDED ONE)
hysteria2_install_handler() {
@ -37,7 +52,7 @@ hysteria2_add_user_handler() {
read -p "Enter the username: " username
if [[ "$username" =~ ^[a-zA-Z0-9]+$ ]]; then
if python3 $CLI_PATH get-user --username "$username" > /dev/null 2>&1; then
if [[ -n $(python3 $CLI_PATH get-user -u "$username") ]]; then
echo -e "${red}Error:${NC} Username already exists. Please choose another username."
else
break
@ -56,7 +71,7 @@ hysteria2_add_user_handler() {
python3 $CLI_PATH add-user --username "$username" --traffic-limit "$traffic_limit_GB" --expiration-days "$expiration_days" --password "$password" --creation-date "$creation_date"
}
hysteria2_edit_user() {
hysteria2_edit_user_handler() {
# Function to prompt for user input with validation
prompt_for_input() {
local prompt_message="$1"
@ -82,8 +97,9 @@ hysteria2_edit_user() {
prompt_for_input "Enter the username you want to edit: " '^[a-zA-Z0-9]+$' '' username
# Check if user exists
if ! python3 $CLI_PATH get-user --username "$username" > /dev/null 2>&1; then
echo -e "${red}Error:${NC} User '$username' not found."
user_exists_output=$(python3 $CLI_PATH get-user -u "$username" 2>&1)
if [[ -z "$user_exists_output" ]]; then
echo -e "${red}Error:${NC} User '$username' not found or an error occurred."
return 1
fi
@ -258,11 +274,11 @@ hysteria2_show_user_uri_handler() {
flags=""
if check_service_active "singbox.service"; then
if check_service_active "hysteria-singbox.service"; then
flags+=" -s"
fi
if check_service_active "normalsub.service"; then
if check_service_active "hysteria-normal-sub.service"; then
flags+=" -n"
fi
@ -299,8 +315,8 @@ hysteria2_change_sni_handler() {
python3 $CLI_PATH change-hysteria2-sni --sni "$sni"
if systemctl is-active --quiet singbox.service; then
systemctl restart singbox.service
if systemctl is-active --quiet hysteria-singbox.service; then
systemctl restart hysteria-singbox.service
fi
}
@ -421,8 +437,8 @@ telegram_bot_handler() {
case $option in
1)
if systemctl is-active --quiet hysteria-bot.service; then
echo "The hysteria-bot.service is already active."
if systemctl is-active --quiet hysteria-telegram-bot.service; then
echo "The hysteria-telegram-bot.service is already active."
else
while true; do
read -e -p "Enter the Telegram bot token: " token
@ -469,8 +485,8 @@ singbox_handler() {
case $option in
1)
if systemctl is-active --quiet singbox.service; then
echo "The singbox.service is already active."
if systemctl is-active --quiet hysteria-singbox.service; then
echo "The hysteria-singbox.service is already active."
else
while true; do
read -e -p "Enter the domain name for the SSL certificate: " domain
@ -496,8 +512,8 @@ singbox_handler() {
fi
;;
2)
if ! systemctl is-active --quiet singbox.service; then
echo "The singbox.service is already inactive."
if ! systemctl is-active --quiet hysteria-singbox.service; then
echo "The hysteria-singbox.service is already inactive."
else
python3 $CLI_PATH singbox -a stop
fi
@ -521,8 +537,8 @@ normalsub_handler() {
case $option in
1)
if systemctl is-active --quiet normalsub.service; then
echo "The normalsub.service is already active."
if systemctl is-active --quiet hysteria-normal-sub.service; then
echo "The hysteria-normal-sub.service is already active."
else
while true; do
read -e -p "Enter the domain name for the SSL certificate: " domain
@ -548,8 +564,8 @@ normalsub_handler() {
fi
;;
2)
if ! systemctl is-active --quiet normalsub.service; then
echo "The normalsub.service is already inactive."
if ! systemctl is-active --quiet hysteria-normal-sub.service; then
echo "The hysteria-normal-sub.service is already inactive."
else
python3 $CLI_PATH normal-sub -a stop
fi
@ -564,6 +580,95 @@ normalsub_handler() {
done
}
webpanel_handler() {
service_status=$(python3 $CLI_PATH get-webpanel-services-status)
echo -e "${cyan}Services Status:${NC}"
echo "$service_status"
echo ""
while true; do
echo -e "${cyan}1.${NC} Start WebPanel service"
echo -e "${red}2.${NC} Stop WebPanel service"
echo -e "${cyan}3.${NC} Get WebPanel URL"
echo -e "${cyan}4.${NC} Show API Token"
echo "0. Back"
read -p "Choose an option: " option
case $option in
1)
if systemctl is-active --quiet hysteria-webpanel.service; then
echo "The hysteria-webpanel.service is already active."
else
while true; do
read -e -p "Enter the domain name for the SSL certificate: " domain
if [ -z "$domain" ]; then
echo "Domain name cannot be empty. Please try again."
else
break
fi
done
while true; do
read -e -p "Enter the port number for the service: " port
if [ -z "$port" ]; then
echo "Port number cannot be empty. Please try again."
elif ! [[ "$port" =~ ^[0-9]+$ ]]; then
echo "Port must be a number. Please try again."
else
break
fi
done
while true; do
read -e -p "Enter the admin username: " admin_username
if [ -z "$admin_username" ]; then
echo "Admin username cannot be empty. Please try again."
else
break
fi
done
while true; do
read -e -p "Enter the admin password: " admin_password
if [ -z "$admin_password" ]; then
echo "Admin password cannot be empty. Please try again."
else
break
fi
done
python3 $CLI_PATH webpanel -a start -d "$domain" -p "$port" -au "$admin_username" -ap "$admin_password"
fi
;;
2)
if ! systemctl is-active --quiet hysteria-webpanel.service; then
echo "The hysteria-webpanel.service is already inactive."
else
python3 $CLI_PATH webpanel -a stop
fi
;;
3)
url=$(python3 $CLI_PATH get-webpanel-url)
echo "-------------------------------"
echo "$url"
echo "-------------------------------"
;;
4)
api_token=$(python3 $CLI_PATH get-webpanel-api-token)
echo "-------------------------------"
echo "$api_token"
echo "-------------------------------"
;;
0)
break
;;
*)
echo "Invalid option. Please try again."
;;
esac
done
}
obfs_handler() {
while true; do
@ -758,7 +863,7 @@ hysteria2_menu() {
case $choice in
1) hysteria2_install_handler ;;
2) hysteria2_add_user_handler ;;
3) hysteria2_edit_user ;;
3) hysteria2_edit_user_handler ;;
4) hysteria2_reset_user_handler ;;
5) hysteria2_remove_user_handler ;;
6) hysteria2_get_user_handler ;;
@ -786,15 +891,16 @@ display_advance_menu() {
echo -e "${green}[5] ${NC}↝ Telegram Bot"
echo -e "${green}[6] ${NC}↝ SingBox SubLink"
echo -e "${green}[7] ${NC}↝ Normal-SUB SubLink"
echo -e "${cyan}[8] ${NC}Change Port Hysteria2"
echo -e "${cyan}[9] ${NC}↝ Change SNI Hysteria2"
echo -e "${cyan}[10] ${NC}Manage OBFS"
echo -e "${cyan}[11] ${NC}Change IPs(4-6)"
echo -e "${cyan}[12] ${NC}Update geo Files"
echo -e "${cyan}[13] ${NC}Manage Masquerade"
echo -e "${cyan}[14] ${NC}Restart Hysteria2"
echo -e "${cyan}[15] ${NC}Update Core Hysteria2"
echo -e "${red}[16] ${NC}↝ Uninstall Hysteria2"
echo -e "${green}[8] ${NC}Web Panel"
echo -e "${cyan}[9] ${NC}↝ Change Port Hysteria2"
echo -e "${cyan}[10] ${NC}Change SNI Hysteria2"
echo -e "${cyan}[11] ${NC}Manage OBFS"
echo -e "${cyan}[12] ${NC}Change IPs(4-6)"
echo -e "${cyan}[13] ${NC}Update geo Files"
echo -e "${cyan}[14] ${NC}Manage Masquerade"
echo -e "${cyan}[15] ${NC}Restart Hysteria2"
echo -e "${cyan}[16] ${NC}↝ Update Core Hysteria2"
echo -e "${red}[17] ${NC}↝ Uninstall Hysteria2"
echo -e "${red}[0] ${NC}↝ Back to Main Menu"
echo -e "${LPurple}◇──────────────────────────────────────────────────────────────────────◇${NC}"
echo -ne "${yellow}➜ Enter your option: ${NC}"
@ -815,15 +921,16 @@ advance_menu() {
5) telegram_bot_handler ;;
6) singbox_handler ;;
7) normalsub_handler ;;
8) hysteria2_change_port_handler ;;
9) hysteria2_change_sni_handler ;;
10) obfs_handler ;;
11) edit_ips ;;
12) geo_update_handler ;;
13) masquerade_handler ;;
14) python3 $CLI_PATH restart-hysteria2 ;;
15) python3 $CLI_PATH update-hysteria2 ;;
16) python3 $CLI_PATH uninstall-hysteria2 ;;
8) webpanel_handler ;;
9) hysteria2_change_port_handler ;;
10) hysteria2_change_sni_handler ;;
11) obfs_handler ;;
12) edit_ips ;;
13) geo_update_handler ;;
14) masquerade_handler ;;
15) python3 $CLI_PATH restart-hysteria2 ;;
16) python3 $CLI_PATH update-hysteria2 ;;
17) python3 $CLI_PATH uninstall-hysteria2 ;;
0) return ;;
*) echo "Invalid option. Please try again." ;;
esac

View File

@ -20,3 +20,22 @@ requests==2.32.3
typing_extensions==4.12.2
urllib3==2.3.0
yarl==1.18.3
# webpanel
annotated-types==0.7.0
anyio==4.8.0
fastapi==0.115.6
h11==0.14.0
itsdangerous==2.2.0
Jinja2==3.1.5
MarkupSafe==3.0.2
pydantic==2.10.5
pydantic_core==2.27.2
python-multipart==0.0.20
sniffio==1.3.1
starlette==0.41.3
hypercorn==0.17.3
pydantic-settings==2.7.1
# subs
certbot==3.1.0

View File

@ -12,9 +12,10 @@ FILES=(
"/etc/hysteria/core/scripts/telegrambot/.env"
"/etc/hysteria/core/scripts/singbox/.env"
"/etc/hysteria/core/scripts/normalsub/.env"
"/etc/hysteria/core/scripts/webpanel/.env"
)
echo "Backing up and Stopping all cron jobs"
echo "Backing up and stopping all cron jobs"
crontab -l > /tmp/crontab_backup
crontab -r
@ -24,11 +25,33 @@ for FILE in "${FILES[@]}"; do
cp "$FILE" "$TEMP_DIR/$FILE"
done
echo "Checking and renaming old systemd service files"
declare -A SERVICE_MAP=(
["/etc/systemd/system/hysteria-bot.service"]="hysteria-telegram-bot.service"
["/etc/systemd/system/singbox.service"]="hysteria-singbox.service"
["/etc/systemd/system/normalsub.service"]="hysteria-normal-sub.service"
)
for OLD_SERVICE in "${!SERVICE_MAP[@]}"; do
NEW_SERVICE="/etc/systemd/system/${SERVICE_MAP[$OLD_SERVICE]}"
if [[ -f "$OLD_SERVICE" ]]; then
echo "Stopping old service: $(basename "$OLD_SERVICE")"
systemctl stop "$(basename "$OLD_SERVICE")" 2>/dev/null
echo "Renaming $OLD_SERVICE to $NEW_SERVICE"
mv "$OLD_SERVICE" "$NEW_SERVICE"
echo "Reloading systemd daemon"
systemctl daemon-reload
fi
done
echo "Removing /etc/hysteria directory"
rm -rf /etc/hysteria/
echo "Cloning Hysteria2 repository"
git clone https://github.com/ReturnFI/Hysteria2 /etc/hysteria
git clone -b webpanel https://github.com/ReturnFI/Hysteria2 /etc/hysteria
echo "Downloading geosite.dat and geoip.dat"
wget -O /etc/hysteria/geosite.dat https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geosite.dat >/dev/null 2>&1
@ -41,10 +64,8 @@ done
CONFIG_ENV="/etc/hysteria/.configs.env"
if [ ! -f "$CONFIG_ENV" ]; then
echo ".configs.env not found, creating it with default SNI=bts.com and IPs."
echo ".configs.env not found, creating it with default values."
echo "SNI=bts.com" > "$CONFIG_ENV"
else
echo ".configs.env already exists."
fi
export $(grep -v '^#' "$CONFIG_ENV" | xargs 2>/dev/null)
@ -78,8 +99,9 @@ pip install -r requirements.txt
echo "Restarting hysteria services"
systemctl restart hysteria-server.service
systemctl restart hysteria-bot.service
systemctl restart singbox.service
systemctl restart hysteria-telegram-bot.service
systemctl restart hysteria-singbox.service
systemctl restart hysteria-normal-sub.service
echo "Checking hysteria-server.service status"
if systemctl is-active --quiet hysteria-server.service; then