diff --git a/.gitignore b/.gitignore index 68bc17f..358b61c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/changelog b/changelog index 11c1309..a5f0798 100644 --- a/changelog +++ b/changelog @@ -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 diff --git a/core/cli.py b/core/cli.py index d49b183..0828c61 100644 --- a/core/cli.py +++ b/core/cli.py @@ -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="", 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='', 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 diff --git a/core/cli_api.py b/core/cli_api.py new file mode 100644 index 0000000..f5e5cc4 --- /dev/null +++ b/core/cli_api.py @@ -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 diff --git a/core/scripts/hysteria2/edit_user.sh b/core/scripts/hysteria2/edit_user.sh index e3f6931..f27d63c 100644 --- a/core/scripts/hysteria2/edit_user.sh +++ b/core/scripts/hysteria2/edit_user.sh @@ -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" diff --git a/core/scripts/hysteria2/manage_obfs.sh b/core/scripts/hysteria2/manage_obfs.sh index 0d7ef95..ff334be 100644 --- a/core/scripts/hysteria2/manage_obfs.sh +++ b/core/scripts/hysteria2/manage_obfs.sh @@ -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" diff --git a/core/scripts/hysteria2/masquerade.sh b/core/scripts/hysteria2/masquerade.sh index 68b0c35..d6ed54c 100644 --- a/core/scripts/hysteria2/masquerade.sh +++ b/core/scripts/hysteria2/masquerade.sh @@ -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]" diff --git a/core/scripts/hysteria2/server_info.sh b/core/scripts/hysteria2/server_info.sh index 335dbed..adbd55c 100644 --- a/core/scripts/hysteria2/server_info.sh +++ b/core/scripts/hysteria2/server_info.sh @@ -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 diff --git a/core/scripts/hysteria2/show_user_uri.sh b/core/scripts/hysteria2/show_user_uri.sh index 926f3d5..5333468 100644 --- a/core/scripts/hysteria2/show_user_uri.sh +++ b/core/scripts/hysteria2/show_user_uri.sh @@ -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" diff --git a/core/scripts/hysteria2/uninstall.sh b/core/scripts/hysteria2/uninstall.sh index 1528294..04c1c45 100644 --- a/core/scripts/hysteria2/uninstall.sh +++ b/core/scripts/hysteria2/uninstall.sh @@ -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!" diff --git a/core/scripts/normalsub/normalsub.sh b/core/scripts/normalsub/normalsub.sh index 8f39067..e0c447f 100644 --- a/core/scripts/normalsub/normalsub.sh +++ b/core/scripts/normalsub/normalsub.sh @@ -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 < /etc/systemd/system/normalsub.service + cat < /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 diff --git a/core/scripts/path.sh b/core/scripts/path.sh index 76b4da3..bdacf90 100644 --- a/core/scripts/path.sh +++ b/core/scripts/path.sh @@ -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" diff --git a/core/scripts/services_status.sh b/core/scripts/services_status.sh new file mode 100644 index 0000000..2f8968b --- /dev/null +++ b/core/scripts/services_status.sh @@ -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 . diff --git a/core/scripts/singbox/singbox_shell.sh b/core/scripts/singbox/singbox_shell.sh index 337fe22..9f0d16c 100644 --- a/core/scripts/singbox/singbox_shell.sh +++ b/core/scripts/singbox/singbox_shell.sh @@ -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 < /etc/systemd/system/singbox.service + cat < /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 diff --git a/core/scripts/telegrambot/runbot.sh b/core/scripts/telegrambot/runbot.sh index 60a63ee..f68396b 100644 --- a/core/scripts/telegrambot/runbot.sh +++ b/core/scripts/telegrambot/runbot.sh @@ -13,7 +13,7 @@ EOL } create_service_file() { - cat < /etc/systemd/system/hysteria-bot.service + cat < /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" diff --git a/core/scripts/utils.sh b/core/scripts/utils.sh index 8095164..2c6c875 100644 --- a/core/scripts/utils.sh +++ b/core/scripts/utils.sh @@ -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 +# } diff --git a/core/scripts/webpanel/app.py b/core/scripts/webpanel/app.py new file mode 100644 index 0000000..9d04819 --- /dev/null +++ b/core/scripts/webpanel/app.py @@ -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 diff --git a/core/scripts/webpanel/assets/css/custom.css b/core/scripts/webpanel/assets/css/custom.css new file mode 100644 index 0000000..874508a --- /dev/null +++ b/core/scripts/webpanel/assets/css/custom.css @@ -0,0 +1,9 @@ +/* Center the qrcodesContainer */ +#qrcodesContainer { + text-align: center; + } + + /* Style the config type text */ + .config-type-text { + font-weight: 600; + } \ No newline at end of file diff --git a/core/scripts/webpanel/assets/img/favicon-bg.ico b/core/scripts/webpanel/assets/img/favicon-bg.ico new file mode 100644 index 0000000..1bff90d Binary files /dev/null and b/core/scripts/webpanel/assets/img/favicon-bg.ico differ diff --git a/core/scripts/webpanel/assets/img/favicon.ico b/core/scripts/webpanel/assets/img/favicon.ico new file mode 100644 index 0000000..192515a Binary files /dev/null and b/core/scripts/webpanel/assets/img/favicon.ico differ diff --git a/core/scripts/webpanel/config/__init__.py b/core/scripts/webpanel/config/__init__.py new file mode 100644 index 0000000..d24f7aa --- /dev/null +++ b/core/scripts/webpanel/config/__init__.py @@ -0,0 +1 @@ +from .config import CONFIGS diff --git a/core/scripts/webpanel/config/config.py b/core/scripts/webpanel/config/config.py new file mode 100644 index 0000000..3960896 --- /dev/null +++ b/core/scripts/webpanel/config/config.py @@ -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 diff --git a/core/scripts/webpanel/dependency/__init__.py b/core/scripts/webpanel/dependency/__init__.py new file mode 100644 index 0000000..c4881d5 --- /dev/null +++ b/core/scripts/webpanel/dependency/__init__.py @@ -0,0 +1 @@ +from .dependency import get_templates, get_session_manager diff --git a/core/scripts/webpanel/dependency/dependency.py b/core/scripts/webpanel/dependency/dependency.py new file mode 100644 index 0000000..8a9f603 --- /dev/null +++ b/core/scripts/webpanel/dependency/dependency.py @@ -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 diff --git a/core/scripts/webpanel/exception_handler/__init__.py b/core/scripts/webpanel/exception_handler/__init__.py new file mode 100644 index 0000000..8a11b3f --- /dev/null +++ b/core/scripts/webpanel/exception_handler/__init__.py @@ -0,0 +1 @@ +from .handler import setup_exception_handler, exception_handler diff --git a/core/scripts/webpanel/exception_handler/handler.py b/core/scripts/webpanel/exception_handler/handler.py new file mode 100644 index 0000000..8ddfe2d --- /dev/null +++ b/core/scripts/webpanel/exception_handler/handler.py @@ -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(), + ) diff --git a/core/scripts/webpanel/middleware/__init__.py b/core/scripts/webpanel/middleware/__init__.py new file mode 100644 index 0000000..ac99e2e --- /dev/null +++ b/core/scripts/webpanel/middleware/__init__.py @@ -0,0 +1,2 @@ +from .auth import AuthMiddleware +from .request import AfterRequestMiddleware diff --git a/core/scripts/webpanel/middleware/auth.py b/core/scripts/webpanel/middleware/auth.py new file mode 100644 index 0000000..cfc893d --- /dev/null +++ b/core/scripts/webpanel/middleware/auth.py @@ -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) diff --git a/core/scripts/webpanel/middleware/request.py b/core/scripts/webpanel/middleware/request.py new file mode 100644 index 0000000..22688b5 --- /dev/null +++ b/core/scripts/webpanel/middleware/request.py @@ -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 diff --git a/core/scripts/webpanel/openapi/__init__.py b/core/scripts/webpanel/openapi/__init__.py new file mode 100644 index 0000000..c9aeec1 --- /dev/null +++ b/core/scripts/webpanel/openapi/__init__.py @@ -0,0 +1 @@ +from .openapi import setup_openapi_schema diff --git a/core/scripts/webpanel/openapi/openapi.py b/core/scripts/webpanel/openapi/openapi.py new file mode 100644 index 0000000..23c2987 --- /dev/null +++ b/core/scripts/webpanel/openapi/openapi.py @@ -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": []}) diff --git a/core/scripts/webpanel/routers/__init__.py b/core/scripts/webpanel/routers/__init__.py new file mode 100644 index 0000000..9db98b2 --- /dev/null +++ b/core/scripts/webpanel/routers/__init__.py @@ -0,0 +1,5 @@ +from . import basic +from . import api +from . import user +from . import login +from . import settings diff --git a/core/scripts/webpanel/routers/api/__init__.py b/core/scripts/webpanel/routers/api/__init__.py new file mode 100644 index 0000000..bbf8c7e --- /dev/null +++ b/core/scripts/webpanel/routers/api/__init__.py @@ -0,0 +1 @@ +from . import v1 \ No newline at end of file diff --git a/core/scripts/webpanel/routers/api/v1/__init__.py b/core/scripts/webpanel/routers/api/v1/__init__.py new file mode 100644 index 0000000..ed12a2a --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/__init__.py @@ -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') diff --git a/core/scripts/webpanel/routers/api/v1/config/__init__.py b/core/scripts/webpanel/routers/api/v1/config/__init__.py new file mode 100644 index 0000000..02812a3 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/config/__init__.py @@ -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) diff --git a/core/scripts/webpanel/routers/api/v1/config/hysteria.py b/core/scripts/webpanel/routers/api/v1/config/hysteria.py new file mode 100644 index 0000000..3294df3 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/config/hysteria.py @@ -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)}') diff --git a/core/scripts/webpanel/routers/api/v1/config/ip.py b/core/scripts/webpanel/routers/api/v1/config/ip.py new file mode 100644 index 0000000..211e03d --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/config/ip.py @@ -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)}') diff --git a/core/scripts/webpanel/routers/api/v1/config/misc.py b/core/scripts/webpanel/routers/api/v1/config/misc.py new file mode 100644 index 0000000..949d55b --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/config/misc.py @@ -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)}') diff --git a/core/scripts/webpanel/routers/api/v1/config/normalsub.py b/core/scripts/webpanel/routers/api/v1/config/normalsub.py new file mode 100644 index 0000000..93e933f --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/config/normalsub.py @@ -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 diff --git a/core/scripts/webpanel/routers/api/v1/config/singbox.py b/core/scripts/webpanel/routers/api/v1/config/singbox.py new file mode 100644 index 0000000..5d93934 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/config/singbox.py @@ -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 diff --git a/core/scripts/webpanel/routers/api/v1/config/telegram.py b/core/scripts/webpanel/routers/api/v1/config/telegram.py new file mode 100644 index 0000000..a504187 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/config/telegram.py @@ -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 diff --git a/core/scripts/webpanel/routers/api/v1/config/warp.py b/core/scripts/webpanel/routers/api/v1/config/warp.py new file mode 100644 index 0000000..87c5d0b --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/config/warp.py @@ -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}') diff --git a/core/scripts/webpanel/routers/api/v1/schema/__init__.py b/core/scripts/webpanel/routers/api/v1/schema/__init__.py new file mode 100644 index 0000000..90320e6 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/__init__.py @@ -0,0 +1,3 @@ +from . import user +from . import server +from . import config diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/__init__.py b/core/scripts/webpanel/routers/api/v1/schema/config/__init__.py new file mode 100644 index 0000000..cd54453 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/config/__init__.py @@ -0,0 +1,5 @@ +from . import hysteria +from . import normalsub +from . import singbox +from . import telegram +from . import warp diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/hysteria.py b/core/scripts/webpanel/routers/api/v1/schema/config/hysteria.py new file mode 100644 index 0000000..b319433 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/config/hysteria.py @@ -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] diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/ip.py b/core/scripts/webpanel/routers/api/v1/schema/config/ip.py new file mode 100644 index 0000000..81a5667 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/config/ip.py @@ -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 diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/normalsub.py b/core/scripts/webpanel/routers/api/v1/schema/config/normalsub.py new file mode 100644 index 0000000..10933a9 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/config/normalsub.py @@ -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 diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/singbox.py b/core/scripts/webpanel/routers/api/v1/schema/config/singbox.py new file mode 100644 index 0000000..cdd2d2a --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/config/singbox.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class StartInputBody(BaseModel): + domain: str + port: int diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/telegram.py b/core/scripts/webpanel/routers/api/v1/schema/config/telegram.py new file mode 100644 index 0000000..77e2508 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/config/telegram.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class StartInputBody(BaseModel): + token: str + admin_id: str diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/warp.py b/core/scripts/webpanel/routers/api/v1/schema/config/warp.py new file mode 100644 index 0000000..cc69af3 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/config/warp.py @@ -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 diff --git a/core/scripts/webpanel/routers/api/v1/schema/response.py b/core/scripts/webpanel/routers/api/v1/schema/response.py new file mode 100644 index 0000000..234fc2c --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/response.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class DetailResponse(BaseModel): + detail: str diff --git a/core/scripts/webpanel/routers/api/v1/schema/server.py b/core/scripts/webpanel/routers/api/v1/schema/server.py new file mode 100644 index 0000000..9372c52 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/server.py @@ -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 diff --git a/core/scripts/webpanel/routers/api/v1/schema/user.py b/core/scripts/webpanel/routers/api/v1/schema/user.py new file mode 100644 index 0000000..7b47b82 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/user.py @@ -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 diff --git a/core/scripts/webpanel/routers/api/v1/server.py b/core/scripts/webpanel/routers/api/v1/server.py new file mode 100644 index 0000000..2ef99b0 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/server.py @@ -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) diff --git a/core/scripts/webpanel/routers/api/v1/user.py b/core/scripts/webpanel/routers/api/v1/user.py new file mode 100644 index 0000000..4bcd7bb --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/user.py @@ -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)}') diff --git a/core/scripts/webpanel/routers/basic/__init__.py b/core/scripts/webpanel/routers/basic/__init__.py new file mode 100644 index 0000000..2067708 --- /dev/null +++ b/core/scripts/webpanel/routers/basic/__init__.py @@ -0,0 +1 @@ +from .basic import router diff --git a/core/scripts/webpanel/routers/basic/basic.py b/core/scripts/webpanel/routers/basic/basic.py new file mode 100644 index 0000000..b164440 --- /dev/null +++ b/core/scripts/webpanel/routers/basic/basic.py @@ -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: /') diff --git a/core/scripts/webpanel/routers/login/__init__.py b/core/scripts/webpanel/routers/login/__init__.py new file mode 100644 index 0000000..e53750a --- /dev/null +++ b/core/scripts/webpanel/routers/login/__init__.py @@ -0,0 +1 @@ +from .login import router diff --git a/core/scripts/webpanel/routers/login/login.py b/core/scripts/webpanel/routers/login/login.py new file mode 100644 index 0000000..e65e88b --- /dev/null +++ b/core/scripts/webpanel/routers/login/login.py @@ -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 diff --git a/core/scripts/webpanel/routers/settings/__init__.py b/core/scripts/webpanel/routers/settings/__init__.py new file mode 100644 index 0000000..88d2333 --- /dev/null +++ b/core/scripts/webpanel/routers/settings/__init__.py @@ -0,0 +1 @@ +from .settings import router diff --git a/core/scripts/webpanel/routers/settings/settings.py b/core/scripts/webpanel/routers/settings/settings.py new file mode 100644 index 0000000..516a664 --- /dev/null +++ b/core/scripts/webpanel/routers/settings/settings.py @@ -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}) diff --git a/core/scripts/webpanel/routers/user/__init__.py b/core/scripts/webpanel/routers/user/__init__.py new file mode 100644 index 0000000..3dc7669 --- /dev/null +++ b/core/scripts/webpanel/routers/user/__init__.py @@ -0,0 +1,2 @@ + +from .user import router diff --git a/core/scripts/webpanel/routers/user/user.py b/core/scripts/webpanel/routers/user/user.py new file mode 100644 index 0000000..565225f --- /dev/null +++ b/core/scripts/webpanel/routers/user/user.py @@ -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)}') diff --git a/core/scripts/webpanel/routers/user/viewmodel.py b/core/scripts/webpanel/routers/user/viewmodel.py new file mode 100644 index 0000000..41ed225 --- /dev/null +++ b/core/scripts/webpanel/routers/user/viewmodel.py @@ -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' diff --git a/core/scripts/webpanel/session/__init__.py b/core/scripts/webpanel/session/__init__.py new file mode 100644 index 0000000..f0870ec --- /dev/null +++ b/core/scripts/webpanel/session/__init__.py @@ -0,0 +1 @@ +from .session import SessionData, SessionStorage, SessionManager diff --git a/core/scripts/webpanel/session/session.py b/core/scripts/webpanel/session/session.py new file mode 100644 index 0000000..a4d0233 --- /dev/null +++ b/core/scripts/webpanel/session/session.py @@ -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) diff --git a/core/scripts/webpanel/templates/base.html b/core/scripts/webpanel/templates/base.html new file mode 100644 index 0000000..a1ca9d1 --- /dev/null +++ b/core/scripts/webpanel/templates/base.html @@ -0,0 +1,148 @@ + + + + + + + + + Admin Panel | {% block title %}{% endblock %} + + + + + + + + + {% block stylesheets %}{% endblock %} + + + +
+ + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + +
+ + + + + + + + + + + {% block javascripts %}{% endblock %} + + + \ No newline at end of file diff --git a/core/scripts/webpanel/templates/config.html b/core/scripts/webpanel/templates/config.html new file mode 100644 index 0000000..5a309b2 --- /dev/null +++ b/core/scripts/webpanel/templates/config.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} + +{% block title %}Config Editor{% endblock %} + +{% block content %} +
+
+
+ + +
+ +
+
+{% endblock %} + +{% block javascripts %} + + + + + +{% endblock %} \ No newline at end of file diff --git a/core/scripts/webpanel/templates/index.html b/core/scripts/webpanel/templates/index.html new file mode 100644 index 0000000..5c05618 --- /dev/null +++ b/core/scripts/webpanel/templates/index.html @@ -0,0 +1,171 @@ +{% extends "base.html" %} + +{% block title %}Dashboard{% endblock %} + +{% block content %} +
+
+
+
+

Dashboard

+
+
+
+
+ +
+
+
+
+ +
+
+

--%

+

CPU Usage

+
+
+ +
+
+
+
+ +
+
+

--

+

RAM Usage

+
+
+ +
+
+
+
+ +
+
+

--

+

Total Traffic

+
+
+ +
+
+
+
+ +
+
+

--

+

Online Users

+
+
+ +
+
+
+
+ +
+
+
+
+

--

+

Hysteria2

+
+
+ +
+
+
+
+
+
+

--

+

Telegram Bot

+
+
+ +
+
+
+
+
+
+

--

+

Singbox

+
+
+ +
+
+
+
+
+
+

--

+

Normalsub

+
+
+ +
+
+
+
+
+
+{% endblock %} + +{% block javascripts %} + + +{% endblock %} \ No newline at end of file diff --git a/core/scripts/webpanel/templates/login.html b/core/scripts/webpanel/templates/login.html new file mode 100644 index 0000000..057118a --- /dev/null +++ b/core/scripts/webpanel/templates/login.html @@ -0,0 +1,71 @@ + + + + + + + Admin Dashboard - Login + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/scripts/webpanel/templates/settings.html b/core/scripts/webpanel/templates/settings.html new file mode 100644 index 0000000..c384a28 --- /dev/null +++ b/core/scripts/webpanel/templates/settings.html @@ -0,0 +1,366 @@ +{% extends 'base.html' %} + +{% block title %}Settings{% endblock %} + +{% block content %} +
+
+
+
+

Settings

+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ + +
+ +
+
+ +
+
+
+ + +
+
+ + +
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+ + + +
+
+
+
+ + + +
+
+
+ + +
+
+ + +
+ + + +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
+
+
+{% endblock %} + +{% block javascripts %} + + + + + + + + +{% endblock %} diff --git a/core/scripts/webpanel/templates/users.html b/core/scripts/webpanel/templates/users.html new file mode 100644 index 0000000..0d6b755 --- /dev/null +++ b/core/scripts/webpanel/templates/users.html @@ -0,0 +1,582 @@ +{% extends "base.html" %} + +{% block title %}Users{% endblock %} + +{% block content %} +
+
+
+
+

Users

+
+
+
+
+ +
+
+
+
+

User List

+
+ +
+ +
+ +
+
+ + +
+
+
+ {% if users|length == 0 %} + + {% else %} + + + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + + {% endfor %} + +
StatusUsernameQuotaUsedExpiry DateExpiry DaysEnableConfigsActions
+ {% if user['status'] == "Online" %} + Online + {% elif user['status'] == "Offline" %} + Offline + {% else %} + {{ user['status'] }} + {% endif %} + {{ user.username }}{{ user.quota }}{{ user.traffic_used }}{{ user.expiry_date }}{{ user.expiry_days }} + {% if user.enable %} + + {% else %} + + {% endif %} + + + + + + + + + +
+ {% endif %} +
+
+
+ {# {{ pagination.links }} #} +
+
+
+
+
+
+
+ + + + + + + + +{% endblock %} + +{% block javascripts %} + + + + + +{% endblock %} \ No newline at end of file diff --git a/core/scripts/webpanel/webpanel_shell.sh b/core/scripts/webpanel/webpanel_shell.sh new file mode 100644 index 0000000..0a768de --- /dev/null +++ b/core/scripts/webpanel/webpanel_shell.sh @@ -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 < /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 < "$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 < /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 < /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 ${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} ${NC}" + exit 1 + ;; +esac + + diff --git a/core/validator.py b/core/validator.py deleted file mode 100644 index 04c09a2..0000000 --- a/core/validator.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/install.sh b/install.sh index f95bacf..9879150 100644 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/menu.sh b/menu.sh index 5aaa06a..32bd617 100644 --- a/menu.sh +++ b/menu.sh @@ -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 diff --git a/requirements.txt b/requirements.txt index cfec43e..7acdfaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/upgrade.sh b/upgrade.sh index 22170c7..b5081c1 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -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