Merge pull request #82 from ReturnFI/webpanel
Merge webpanel to main branch
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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
|
||||
|
||||
617
core/cli.py
617
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="<domain>", type=str, help="Enable 'masquerade' in config.json with the specified domain.")
|
||||
def masquerade(remove, enable):
|
||||
"""Manage 'masquerade' in Hysteria2 configuration."""
|
||||
|
||||
if remove and enable:
|
||||
click.echo("Error: You cannot use both --remove and --enable at the same time.")
|
||||
return
|
||||
elif remove:
|
||||
click.echo("Removing 'masquerade' from config.json...")
|
||||
run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '2'])
|
||||
elif enable:
|
||||
if not enable:
|
||||
click.echo("Error: You must specify a domain with --enable.")
|
||||
return
|
||||
click.echo(f"Enabling 'masquerade' with URL: {enable}...")
|
||||
run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '1', enable])
|
||||
else:
|
||||
click.echo("Error: Please specify either --remove or --enable.")
|
||||
|
||||
@click.option('--enable', '-e', metavar='<domain>', type=str, help="Enable 'masquerade' in config.json with the specified domain.")
|
||||
def masquerade(remove: bool, enable: str):
|
||||
'''Manage 'masquerade' in Hysteria2 configuration.'''
|
||||
try:
|
||||
if not remove and not enable:
|
||||
raise click.UsageError('Error: You must use either --remove or --enable')
|
||||
if remove and enable:
|
||||
raise click.UsageError('Error: You cannot use both --remove and --enable at the same time')
|
||||
|
||||
if enable:
|
||||
# NOT SURE THIS IS NEEDED
|
||||
# if not enable.startswith('http://') and not enable.startswith('https://'):
|
||||
# enable = 'https://' + enable
|
||||
cli_api.enable_hysteria2_masquerade(enable)
|
||||
click.echo('Masquerade enabled successfully.')
|
||||
elif remove:
|
||||
cli_api.disable_hysteria2_masquerade()
|
||||
click.echo('Masquerade disabled successfully.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
# endregion
|
||||
|
||||
# region advanced menu
|
||||
# region Advanced Menu
|
||||
|
||||
|
||||
@ cli.command('install-tcp-brutal')
|
||||
@cli.command('install-tcp-brutal')
|
||||
def install_tcp_brutal():
|
||||
run_cmd(['bash', Command.INSTALL_TCP_BRUTAL.value])
|
||||
try:
|
||||
cli_api.install_tcp_brutal()
|
||||
click.echo('TCP Brutal installed successfully.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@ cli.command('install-warp')
|
||||
@cli.command('install-warp')
|
||||
def install_warp():
|
||||
run_cmd(['bash', Command.INSTALL_WARP.value])
|
||||
try:
|
||||
cli_api.install_warp()
|
||||
click.echo('WARP installed successfully.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@ cli.command('uninstall-warp')
|
||||
@cli.command('uninstall-warp')
|
||||
def uninstall_warp():
|
||||
run_cmd(['bash', Command.UNINSTALL_WARP.value])
|
||||
try:
|
||||
cli_api.uninstall_warp()
|
||||
click.echo('WARP uninstalled successfully.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.command('configure-warp')
|
||||
@ -353,81 +325,156 @@ def uninstall_warp():
|
||||
@click.option('--domestic-sites', '-d', is_flag=True, help='Use WARP for Iran domestic sites')
|
||||
@click.option('--block-adult-sites', '-x', is_flag=True, help='Block adult content (porn)')
|
||||
@click.option('--warp-option', '-w', type=click.Choice(['warp', 'warp plus'], case_sensitive=False), help='Specify whether to use WARP or WARP Plus')
|
||||
@click.option('--warp-key', '-k', help='WARP Plus key (required if warp-option is "warp plus")')
|
||||
@click.option('--warp-key', '-k', help="WARP Plus key (required if warp-option is 'warp plus')")
|
||||
def configure_warp(all: bool, popular_sites: bool, domestic_sites: bool, block_adult_sites: bool, warp_option: str, warp_key: str):
|
||||
if warp_option == 'warp plus' and not warp_key:
|
||||
print("Error: WARP Plus key is required when 'warp plus' is selected.")
|
||||
return
|
||||
try:
|
||||
cli_api.configure_warp(all, popular_sites, domestic_sites, block_adult_sites, warp_option, warp_key)
|
||||
click.echo('WARP configured successfully.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
options = {
|
||||
"all": 'true' if all else 'false',
|
||||
"popular_sites": 'true' if popular_sites else 'false',
|
||||
"domestic_sites": 'true' if domestic_sites else 'false',
|
||||
"block_adult_sites": 'true' if block_adult_sites else 'false',
|
||||
"warp_option": warp_option or '',
|
||||
"warp_key": warp_key or ''
|
||||
}
|
||||
|
||||
cmd_args = [
|
||||
'bash', Command.CONFIGURE_WARP.value,
|
||||
options['all'],
|
||||
options['popular_sites'],
|
||||
options['domestic_sites'],
|
||||
options['block_adult_sites'],
|
||||
options['warp_option']
|
||||
]
|
||||
|
||||
if options['warp_key']:
|
||||
cmd_args.append(options['warp_key'])
|
||||
|
||||
run_cmd(cmd_args)
|
||||
|
||||
@cli.command('warp-status')
|
||||
def warp_status():
|
||||
output = run_cmd(['bash', Command.STATUS_WARP.value])
|
||||
if output:
|
||||
print(output)
|
||||
try:
|
||||
res = cli_api.warp_status()
|
||||
if res:
|
||||
pretty_print(res)
|
||||
else:
|
||||
click.echo('WARP status not available.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.command('telegram')
|
||||
@click.option('--action', '-a', required=True, help='Action to perform: start or stop', type=click.Choice(['start', 'stop'], case_sensitive=False))
|
||||
@click.option('--token', '-t', required=False, help='Token for running the telegram bot', type=str)
|
||||
@click.option('--adminid', '-aid', required=False, help='Telegram admins ID for running the telegram bot', type=str)
|
||||
def telegram(action: str, token: str, adminid: str):
|
||||
if action == 'start':
|
||||
if not token or not adminid:
|
||||
print("Error: Both --token and --adminid are required for the start action.")
|
||||
return
|
||||
admin_ids = f'{adminid}'
|
||||
run_cmd(['bash', Command.INSTALL_TELEGRAMBOT.value, 'start', token, admin_ids])
|
||||
elif action == 'stop':
|
||||
run_cmd(['bash', Command.INSTALL_TELEGRAMBOT.value, 'stop'])
|
||||
try:
|
||||
if action == 'start':
|
||||
if not token or not adminid:
|
||||
raise click.UsageError('Error: Both --token and --adminid are required for the start action.')
|
||||
cli_api.start_telegram_bot(token, adminid)
|
||||
click.echo(f'Telegram bot started successfully.')
|
||||
elif action == 'stop':
|
||||
cli_api.stop_telegram_bot()
|
||||
click.echo(f'Telegram bot stopped successfully.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.command('singbox')
|
||||
@click.option('--action', '-a', required=True, help='Action to perform: start or stop', type=click.Choice(['start', 'stop'], case_sensitive=False))
|
||||
@click.option('--domain', '-d', required=False, help='Domain name for SSL', type=str)
|
||||
@click.option('--port', '-p', required=False, help='Port number for Singbox service', type=int)
|
||||
def singbox(action: str, domain: str, port: int):
|
||||
if action == 'start':
|
||||
if not domain or not port:
|
||||
click.echo("Error: Both --domain and --port are required for the start action.")
|
||||
return
|
||||
run_cmd(['bash', Command.INSTALL_SINGBOX.value, 'start', domain, str(port)])
|
||||
elif action == 'stop':
|
||||
run_cmd(['bash', Command.INSTALL_SINGBOX.value, 'stop'])
|
||||
try:
|
||||
if action == 'start':
|
||||
if not domain or not port:
|
||||
raise click.UsageError('Error: Both --domain and --port are required for the start action.')
|
||||
cli_api.start_singbox(domain, port)
|
||||
click.echo(f'Singbox started successfully.')
|
||||
elif action == 'stop':
|
||||
cli_api.stop_singbox()
|
||||
click.echo(f'Singbox stopped successfully.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.command('normal-sub')
|
||||
@click.option('--action', '-a', required=True, help='Action to perform: start or stop', type=click.Choice(['start', 'stop'], case_sensitive=False))
|
||||
@click.option('--domain', '-d', required=False, help='Domain name for SSL', type=str)
|
||||
@click.option('--port', '-p', required=False, help='Port number for NormalSub service', type=int)
|
||||
def normalsub(action: str, domain: str, port: int):
|
||||
if action == 'start':
|
||||
if not domain or not port:
|
||||
click.echo("Error: Both --domain and --port are required for the start action.")
|
||||
return
|
||||
run_cmd(['bash', Command.INSTALL_NORMALSUB.value, 'start', domain, str(port)])
|
||||
elif action == 'stop':
|
||||
run_cmd(['bash', Command.INSTALL_NORMALSUB.value, 'stop'])
|
||||
try:
|
||||
if action == 'start':
|
||||
if not domain or not port:
|
||||
raise click.UsageError('Error: Both --domain and --port are required for the start action.')
|
||||
cli_api.start_normalsub(domain, port)
|
||||
click.echo(f'NormalSub started successfully.')
|
||||
elif action == 'stop':
|
||||
cli_api.stop_normalsub()
|
||||
click.echo(f'NormalSub stopped successfully.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.command('webpanel')
|
||||
@click.option('--action', '-a', required=True, help='Action to perform: start or stop', type=click.Choice(['start', 'stop'], case_sensitive=False))
|
||||
@click.option('--domain', '-d', required=False, help='Domain name for SSL', type=str)
|
||||
@click.option('--port', '-p', required=False, help='Port number for WebPanel service', type=int)
|
||||
@click.option('--admin-username', '-au', required=False, help='Admin username for WebPanel', type=str)
|
||||
@click.option('--admin-password', '-ap', required=False, help='Admin password for WebPanel', type=str)
|
||||
@click.option('--expiration-minutes', '-e', required=False, help='Expiration minutes for WebPanel', type=int, default=20)
|
||||
@click.option('--debug', '-g', is_flag=True, help='Enable debug mode for WebPanel', default=False)
|
||||
def webpanel(action: str, domain: str, port: int, admin_username: str, admin_password: str, expiration_minutes: int, debug: bool):
|
||||
try:
|
||||
if action == 'start':
|
||||
if not domain or not port or not admin_username or not admin_password:
|
||||
raise click.UsageError('Error: the --domain, --port, --admin-username, and --admin-password are required for the start action.')
|
||||
|
||||
cli_api.start_webpanel(domain, port, admin_username, admin_password, expiration_minutes, debug)
|
||||
|
||||
services_status = cli_api.get_services_status()
|
||||
if not services_status:
|
||||
raise click.Abort('Error: WebPanel services status not available.')
|
||||
if not services_status.get('hysteria-webpanel.service'):
|
||||
raise click.Abort('Error: hysteria-webpanel.service service is not running.')
|
||||
if not services_status.get('hysteria-caddy.service'):
|
||||
raise click.Abort('Error: hysteria-caddy.service service is not running.')
|
||||
|
||||
url = cli_api.get_webpanel_url()
|
||||
click.echo(f'Hysteria web panel is now running. The service is accessible on: {url}')
|
||||
elif action == 'stop':
|
||||
cli_api.stop_webpanel()
|
||||
click.echo(f'WebPanel stopped successfully.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.command('get-webpanel-url')
|
||||
def get_web_panel_url():
|
||||
try:
|
||||
url = cli_api.get_webpanel_url()
|
||||
click.echo(f'Hysteria web panel is now running. The service is accessible on: {url}')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.command('get-webpanel-api-token')
|
||||
def get_web_panel_api_token():
|
||||
try:
|
||||
token = cli_api.get_webpanel_api_token()
|
||||
click.echo(f'WebPanel API token: {token}')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.command('get-webpanel-services-status')
|
||||
def get_web_panel_services_status():
|
||||
try:
|
||||
if services_status := cli_api.get_services_status():
|
||||
webpanel_status = services_status.get('hysteria-webpanel.service', False)
|
||||
caddy_status = services_status.get('hysteria-caddy.service', False)
|
||||
print(f"hysteria-webpanel.service: {'Active' if webpanel_status else 'Inactive'}")
|
||||
print(f"hysteria-caddy.service: {'Active' if caddy_status else 'Inactive'}")
|
||||
else:
|
||||
click.echo('Error: Services status not available.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.command('get-services-status')
|
||||
def get_services_status():
|
||||
try:
|
||||
if services_status := cli_api.get_services_status():
|
||||
for service, status in services_status.items():
|
||||
click.echo(f"{service}: {'Active' if status else 'Inactive'}")
|
||||
else:
|
||||
click.echo('Error: Services status not available.')
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
# endregion
|
||||
|
||||
|
||||
|
||||
469
core/cli_api.py
Normal file
469
core/cli_api.py
Normal file
@ -0,0 +1,469 @@
|
||||
import os
|
||||
import subprocess
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
import json
|
||||
from typing import Any
|
||||
from dotenv import dotenv_values
|
||||
|
||||
import traffic
|
||||
|
||||
DEBUG = False
|
||||
SCRIPT_DIR = '/etc/hysteria/core/scripts'
|
||||
CONFIG_FILE = '/etc/hysteria/config.json'
|
||||
|
||||
|
||||
class Command(Enum):
|
||||
'''Contains path to command's script'''
|
||||
INSTALL_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'install.sh')
|
||||
UNINSTALL_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'uninstall.sh')
|
||||
UPDATE_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'update.sh')
|
||||
RESTART_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'restart.sh')
|
||||
CHANGE_PORT_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_port.sh')
|
||||
CHANGE_SNI_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_sni.sh')
|
||||
GET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'get_user.sh')
|
||||
ADD_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'add_user.sh')
|
||||
EDIT_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'edit_user.sh')
|
||||
RESET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'reset_user.sh')
|
||||
REMOVE_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'remove_user.sh')
|
||||
SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.sh')
|
||||
IP_ADD = os.path.join(SCRIPT_DIR, 'hysteria2', 'ip.sh')
|
||||
MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.sh')
|
||||
MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.sh')
|
||||
TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module)
|
||||
UPDATE_GEO = os.path.join(SCRIPT_DIR, 'hysteria2', 'update_geo.py')
|
||||
LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.sh')
|
||||
SERVER_INFO = os.path.join(SCRIPT_DIR, 'hysteria2', 'server_info.sh')
|
||||
BACKUP_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'backup.sh')
|
||||
INSTALL_TELEGRAMBOT = os.path.join(SCRIPT_DIR, 'telegrambot', 'runbot.sh')
|
||||
SHELL_SINGBOX = os.path.join(SCRIPT_DIR, 'singbox', 'singbox_shell.sh')
|
||||
SHELL_WEBPANEL = os.path.join(SCRIPT_DIR, 'webpanel', 'webpanel_shell.sh')
|
||||
INSTALL_NORMALSUB = os.path.join(SCRIPT_DIR, 'normalsub', 'normalsub.sh')
|
||||
INSTALL_TCP_BRUTAL = os.path.join(SCRIPT_DIR, 'tcp-brutal', 'install.sh')
|
||||
INSTALL_WARP = os.path.join(SCRIPT_DIR, 'warp', 'install.sh')
|
||||
UNINSTALL_WARP = os.path.join(SCRIPT_DIR, 'warp', 'uninstall.sh')
|
||||
CONFIGURE_WARP = os.path.join(SCRIPT_DIR, 'warp', 'configure.sh')
|
||||
STATUS_WARP = os.path.join(SCRIPT_DIR, 'warp', 'status.sh')
|
||||
SERVICES_STATUS = os.path.join(SCRIPT_DIR, 'services_status.sh')
|
||||
|
||||
# region Custom Exceptions
|
||||
|
||||
|
||||
class HysteriaError(Exception):
|
||||
'''Base class for Hysteria-related exceptions.'''
|
||||
pass
|
||||
|
||||
|
||||
class CommandExecutionError(HysteriaError):
|
||||
'''Raised when a command execution fails.'''
|
||||
pass
|
||||
|
||||
|
||||
class InvalidInputError(HysteriaError):
|
||||
'''Raised when the provided input is invalid.'''
|
||||
pass
|
||||
|
||||
|
||||
class PasswordGenerationError(HysteriaError):
|
||||
'''Raised when password generation fails.'''
|
||||
pass
|
||||
|
||||
|
||||
class ScriptNotFoundError(HysteriaError):
|
||||
'''Raised when a required script is not found.'''
|
||||
pass
|
||||
|
||||
# region Utils
|
||||
|
||||
|
||||
def run_cmd(command: list[str]) -> str | None:
|
||||
'''
|
||||
Runs a command and returns the output.
|
||||
Could raise subprocess.CalledProcessError
|
||||
'''
|
||||
if (DEBUG) and not (Command.GET_USER.value in command or Command.LIST_USERS.value in command):
|
||||
print(' '.join(command))
|
||||
try:
|
||||
result = subprocess.check_output(command, stderr=subprocess.STDOUT, shell=False)
|
||||
if result:
|
||||
result = result.decode().strip()
|
||||
return result
|
||||
except subprocess.CalledProcessError as e:
|
||||
if DEBUG:
|
||||
raise CommandExecutionError(f'Command execution failed: {e}\nOutput: {e.output.decode()}')
|
||||
else:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def generate_password() -> str:
|
||||
'''
|
||||
Generates a random password using pwgen for user.
|
||||
Could raise subprocess.CalledProcessError
|
||||
'''
|
||||
try:
|
||||
return subprocess.check_output(['pwgen', '-s', '32', '1'], shell=False).decode().strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise PasswordGenerationError(f'Failed to generate password: {e}')
|
||||
|
||||
# endregion
|
||||
|
||||
# region APIs
|
||||
|
||||
# region Hysteria
|
||||
|
||||
|
||||
def install_hysteria2(port: int, sni: str):
|
||||
'''
|
||||
Installs Hysteria2 on the given port and uses the provided or default SNI value.
|
||||
'''
|
||||
run_cmd(['bash', Command.INSTALL_HYSTERIA2.value, str(port), sni])
|
||||
|
||||
|
||||
def uninstall_hysteria2():
|
||||
'''Uninstalls Hysteria2.'''
|
||||
run_cmd(['bash', Command.UNINSTALL_HYSTERIA2.value])
|
||||
|
||||
|
||||
def update_hysteria2():
|
||||
'''Updates Hysteria2.'''
|
||||
run_cmd(['bash', Command.UPDATE_HYSTERIA2.value])
|
||||
|
||||
|
||||
def restart_hysteria2():
|
||||
'''Restarts Hysteria2.'''
|
||||
run_cmd(['bash', Command.RESTART_HYSTERIA2.value])
|
||||
|
||||
|
||||
def change_hysteria2_port(port: int):
|
||||
'''
|
||||
Changes the port for Hysteria2.
|
||||
'''
|
||||
run_cmd(['bash', Command.CHANGE_PORT_HYSTERIA2.value, str(port)])
|
||||
|
||||
|
||||
def change_hysteria2_sni(sni: str):
|
||||
'''
|
||||
Changes the SNI for Hysteria2.
|
||||
'''
|
||||
run_cmd(['bash', Command.CHANGE_SNI_HYSTERIA2.value, sni])
|
||||
|
||||
|
||||
def backup_hysteria2():
|
||||
'''Backups Hysteria configuration.'''
|
||||
run_cmd(['bash', Command.BACKUP_HYSTERIA2.value])
|
||||
|
||||
|
||||
def enable_hysteria2_obfs():
|
||||
'''Generates 'obfs' in Hysteria2 configuration.'''
|
||||
run_cmd(['bash', Command.MANAGE_OBFS.value, '--generate'])
|
||||
|
||||
|
||||
def disable_hysteria2_obfs():
|
||||
'''Removes 'obfs' from Hysteria2 configuration.'''
|
||||
run_cmd(['bash', Command.MANAGE_OBFS.value, '--remove'])
|
||||
|
||||
|
||||
def enable_hysteria2_masquerade(domain: str):
|
||||
'''Enables masquerade for Hysteria2.'''
|
||||
run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '1', domain])
|
||||
|
||||
|
||||
def disable_hysteria2_masquerade():
|
||||
'''Disables masquerade for Hysteria2.'''
|
||||
run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '2'])
|
||||
|
||||
|
||||
def get_hysteria2_config_file() -> dict[str, Any]:
|
||||
with open(CONFIG_FILE, 'r') as f:
|
||||
return json.loads(f.read())
|
||||
|
||||
|
||||
def set_hysteria2_config_file(data: dict[str, Any]):
|
||||
content = json.dumps(data, indent=4)
|
||||
|
||||
with open(CONFIG_FILE, 'w') as f:
|
||||
f.write(content)
|
||||
# endregion
|
||||
|
||||
# region User
|
||||
|
||||
|
||||
def list_users() -> dict[str, dict[str, Any]] | None:
|
||||
'''
|
||||
Lists all users.
|
||||
'''
|
||||
if res := run_cmd(['bash', Command.LIST_USERS.value]):
|
||||
return json.loads(res)
|
||||
|
||||
|
||||
def get_user(username: str) -> dict[str, Any] | None:
|
||||
'''
|
||||
Retrieves information about a specific user.
|
||||
'''
|
||||
if res := run_cmd(['bash', Command.GET_USER.value, '-u', str(username)]):
|
||||
return json.loads(res)
|
||||
|
||||
|
||||
def add_user(username: str, traffic_limit: int, expiration_days: int, password: str | None, creation_date: str | None):
|
||||
'''
|
||||
Adds a new user with the given parameters.
|
||||
'''
|
||||
if not password:
|
||||
password = generate_password()
|
||||
if not creation_date:
|
||||
creation_date = datetime.now().strftime('%Y-%m-%d')
|
||||
run_cmd(['bash', Command.ADD_USER.value, username, str(traffic_limit), str(expiration_days), password, creation_date])
|
||||
|
||||
|
||||
def edit_user(username: str, new_username: str | None, new_traffic_limit: int | None, new_expiration_days: int | None, renew_password: bool, renew_creation_date: bool, blocked: bool):
|
||||
'''
|
||||
Edits an existing user's details.
|
||||
'''
|
||||
if not username:
|
||||
raise InvalidInputError('Error: username is required')
|
||||
if not any([new_username, new_traffic_limit, new_expiration_days, renew_password, renew_creation_date, blocked is not None]): # type: ignore
|
||||
raise InvalidInputError('Error: at least one option is required')
|
||||
if new_traffic_limit is not None and new_traffic_limit <= 0:
|
||||
raise InvalidInputError('Error: traffic limit must be greater than 0')
|
||||
if new_expiration_days is not None and new_expiration_days <= 0:
|
||||
raise InvalidInputError('Error: expiration days must be greater than 0')
|
||||
if renew_password:
|
||||
password = generate_password()
|
||||
else:
|
||||
password = ''
|
||||
if renew_creation_date:
|
||||
creation_date = datetime.now().strftime('%Y-%m-%d')
|
||||
else:
|
||||
creation_date = ''
|
||||
command_args = [
|
||||
'bash',
|
||||
Command.EDIT_USER.value,
|
||||
username,
|
||||
new_username or '',
|
||||
str(new_traffic_limit) if new_traffic_limit is not None else '',
|
||||
str(new_expiration_days) if new_expiration_days is not None else '',
|
||||
password,
|
||||
creation_date,
|
||||
'true' if blocked else 'false'
|
||||
]
|
||||
run_cmd(command_args)
|
||||
|
||||
|
||||
def reset_user(username: str):
|
||||
'''
|
||||
Resets a user's configuration.
|
||||
'''
|
||||
run_cmd(['bash', Command.RESET_USER.value, username])
|
||||
|
||||
|
||||
def remove_user(username: str):
|
||||
'''
|
||||
Removes a user by username.
|
||||
'''
|
||||
run_cmd(['bash', Command.REMOVE_USER.value, username])
|
||||
|
||||
|
||||
# TODO: it's better to return json
|
||||
def show_user_uri(username: str, qrcode: bool, ipv: int, all: bool, singbox: bool, normalsub: bool) -> str | None:
|
||||
'''
|
||||
Displays the URI for a user, with options for QR code and other formats.
|
||||
'''
|
||||
command_args = ['bash', Command.SHOW_USER_URI.value, '-u', username]
|
||||
if qrcode:
|
||||
command_args.append('-qr')
|
||||
if all:
|
||||
command_args.append('-a')
|
||||
else:
|
||||
command_args.extend(['-ip', str(ipv)])
|
||||
if singbox:
|
||||
command_args.append('-s')
|
||||
if normalsub:
|
||||
command_args.append('-n')
|
||||
return run_cmd(command_args)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Server
|
||||
|
||||
|
||||
def traffic_status():
|
||||
'''Fetches traffic status.'''
|
||||
traffic.traffic_status()
|
||||
|
||||
|
||||
# TODO: it's better to return json
|
||||
def server_info() -> str | None:
|
||||
'''Retrieves server information.'''
|
||||
return run_cmd(['bash', Command.SERVER_INFO.value])
|
||||
|
||||
|
||||
def get_ip_address() -> tuple[str | None, str | None]:
|
||||
'''
|
||||
Retrieves the IP address from the .configs.env file.
|
||||
'''
|
||||
|
||||
env_file_path = os.path.join(SCRIPT_DIR, '..', '..', '.configs.env')
|
||||
env_vars = dotenv_values(env_file_path)
|
||||
|
||||
return env_vars.get('IP4'), env_vars.get('IP6')
|
||||
|
||||
|
||||
def add_ip_address():
|
||||
'''
|
||||
Adds IP addresses from the environment to the .configs.env file.
|
||||
'''
|
||||
run_cmd(['bash', Command.IP_ADD.value, 'add'])
|
||||
|
||||
|
||||
def edit_ip_address(ipv4: str, ipv6: str):
|
||||
'''
|
||||
Edits the IP address configuration based on provided IPv4 and/or IPv6 addresses.
|
||||
|
||||
:param ipv4: The new IPv4 address to be configured. If provided, the IPv4 address will be updated.
|
||||
:param ipv6: The new IPv6 address to be configured. If provided, the IPv6 address will be updated.
|
||||
:raises InvalidInputError: If neither ipv4 nor ipv6 is provided.
|
||||
'''
|
||||
|
||||
if not ipv4 and not ipv6:
|
||||
raise InvalidInputError('Error: --edit requires at least one of --ipv4 or --ipv6.')
|
||||
if ipv4:
|
||||
run_cmd(['bash', Command.IP_ADD.value, 'edit', '-4', ipv4])
|
||||
if ipv6:
|
||||
run_cmd(['bash', Command.IP_ADD.value, 'edit', '-6', ipv6])
|
||||
|
||||
|
||||
def update_geo(country: str):
|
||||
'''
|
||||
Updates geographic data files based on the specified country.
|
||||
'''
|
||||
script_path = Command.UPDATE_GEO.value
|
||||
try:
|
||||
subprocess.run(['python3', script_path, country.lower()], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise CommandExecutionError(f'Failed to update geo files: {e}')
|
||||
except FileNotFoundError:
|
||||
raise ScriptNotFoundError(f'Script not found: {script_path}')
|
||||
except Exception as e:
|
||||
raise HysteriaError(f'An unexpected error occurred: {e}')
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Advanced Menu
|
||||
|
||||
|
||||
def install_tcp_brutal():
|
||||
'''Installs TCP Brutal.'''
|
||||
run_cmd(['bash', Command.INSTALL_TCP_BRUTAL.value])
|
||||
|
||||
|
||||
def install_warp():
|
||||
'''Installs WARP.'''
|
||||
run_cmd(['bash', Command.INSTALL_WARP.value])
|
||||
|
||||
|
||||
def uninstall_warp():
|
||||
'''Uninstalls WARP.'''
|
||||
run_cmd(['bash', Command.UNINSTALL_WARP.value])
|
||||
|
||||
|
||||
def configure_warp(all: bool, popular_sites: bool, domestic_sites: bool, block_adult_sites: bool, warp_option: str, warp_key: str):
|
||||
'''
|
||||
Configures WARP with various options.
|
||||
'''
|
||||
if warp_option == 'warp plus' and not warp_key:
|
||||
raise InvalidInputError('Error: WARP Plus key is required when \'warp plus\' is selected.')
|
||||
options = {
|
||||
'all': 'true' if all else 'false',
|
||||
'popular_sites': 'true' if popular_sites else 'false',
|
||||
'domestic_sites': 'true' if domestic_sites else 'false',
|
||||
'block_adult_sites': 'true' if block_adult_sites else 'false',
|
||||
'warp_option': warp_option or '',
|
||||
'warp_key': warp_key or ''
|
||||
}
|
||||
cmd_args = [
|
||||
'bash', Command.CONFIGURE_WARP.value,
|
||||
options['all'],
|
||||
options['popular_sites'],
|
||||
options['domestic_sites'],
|
||||
options['block_adult_sites'],
|
||||
options['warp_option']
|
||||
]
|
||||
if options['warp_key']:
|
||||
cmd_args.append(options['warp_key'])
|
||||
run_cmd(cmd_args)
|
||||
|
||||
|
||||
def warp_status() -> str | None:
|
||||
'''Checks the status of WARP.'''
|
||||
return run_cmd(['bash', Command.STATUS_WARP.value])
|
||||
|
||||
|
||||
def start_telegram_bot(token: str, adminid: str):
|
||||
'''Starts the Telegram bot.'''
|
||||
if not token or not adminid:
|
||||
raise InvalidInputError('Error: Both --token and --adminid are required for the start action.')
|
||||
run_cmd(['bash', Command.INSTALL_TELEGRAMBOT.value, 'start', token, adminid])
|
||||
|
||||
|
||||
def stop_telegram_bot():
|
||||
'''Stops the Telegram bot.'''
|
||||
run_cmd(['bash', Command.INSTALL_TELEGRAMBOT.value, 'stop'])
|
||||
|
||||
|
||||
def start_singbox(domain: str, port: int):
|
||||
'''Starts Singbox.'''
|
||||
if not domain or not port:
|
||||
raise InvalidInputError('Error: Both --domain and --port are required for the start action.')
|
||||
run_cmd(['bash', Command.SHELL_SINGBOX.value, 'start', domain, str(port)])
|
||||
|
||||
|
||||
def stop_singbox():
|
||||
'''Stops Singbox.'''
|
||||
run_cmd(['bash', Command.SHELL_SINGBOX.value, 'stop'])
|
||||
|
||||
|
||||
def start_normalsub(domain: str, port: int):
|
||||
'''Starts NormalSub.'''
|
||||
if not domain or not port:
|
||||
raise InvalidInputError('Error: Both --domain and --port are required for the start action.')
|
||||
run_cmd(['bash', Command.INSTALL_NORMALSUB.value, 'start', domain, str(port)])
|
||||
|
||||
|
||||
def stop_normalsub():
|
||||
'''Stops NormalSub.'''
|
||||
run_cmd(['bash', Command.INSTALL_NORMALSUB.value, 'stop'])
|
||||
|
||||
|
||||
def start_webpanel(domain: str, port: int, admin_username: str, admin_password: str, expiration_minutes: int, debug: bool):
|
||||
'''Starts WebPanel.'''
|
||||
if not domain or not port or not admin_username or not admin_password or not expiration_minutes:
|
||||
raise InvalidInputError('Error: Both --domain and --port are required for the start action.')
|
||||
run_cmd(
|
||||
['bash', Command.SHELL_WEBPANEL.value, 'start',
|
||||
domain, str(port), admin_username, admin_password, str(expiration_minutes), str(debug).lower()]
|
||||
)
|
||||
|
||||
|
||||
def stop_webpanel():
|
||||
'''Stops WebPanel.'''
|
||||
run_cmd(['bash', Command.SHELL_WEBPANEL.value, 'stop'])
|
||||
|
||||
|
||||
def get_webpanel_url() -> str | None:
|
||||
'''Gets the URL of WebPanel.'''
|
||||
return run_cmd(['bash', Command.SHELL_WEBPANEL.value, 'url'])
|
||||
|
||||
|
||||
def get_webpanel_api_token() -> str | None:
|
||||
'''Gets the API token of WebPanel.'''
|
||||
return run_cmd(['bash', Command.SHELL_WEBPANEL.value, 'api-token'])
|
||||
|
||||
|
||||
def get_services_status() -> dict[str, bool] | None:
|
||||
'''Gets the status of all project services.'''
|
||||
if res := run_cmd(['bash', Command.SERVICES_STATUS.value]):
|
||||
return json.loads(res)
|
||||
# endregion
|
||||
# endregion
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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]"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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!"
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
source /etc/hysteria/core/scripts/utils.sh
|
||||
define_colors
|
||||
|
||||
install_dependencies() {
|
||||
echo "Installing necessary dependencies..."
|
||||
apt-get install certbot -y > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Error: Failed to install certbot. ${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${green}Certbot installed successfully. ${NC}"
|
||||
}
|
||||
# install_dependencies() {
|
||||
# echo "Installing necessary dependencies..."
|
||||
# apt-get install certbot -y > /dev/null 2>&1
|
||||
# if [ $? -ne 0 ]; then
|
||||
# echo -e "${red}Error: Failed to install certbot. ${NC}"
|
||||
# exit 1
|
||||
# fi
|
||||
# echo -e "${green}Certbot installed successfully. ${NC}"
|
||||
# }
|
||||
|
||||
update_env_file() {
|
||||
local domain=$1
|
||||
@ -26,7 +26,7 @@ EOL
|
||||
}
|
||||
|
||||
create_service_file() {
|
||||
cat <<EOL > /etc/systemd/system/normalsub.service
|
||||
cat <<EOL > /etc/systemd/system/hysteria-normal-sub.service
|
||||
[Unit]
|
||||
Description=normalsub Python Service
|
||||
After=network.target
|
||||
@ -48,12 +48,13 @@ start_service() {
|
||||
local domain=$1
|
||||
local port=$2
|
||||
|
||||
if systemctl is-active --quiet normalsub.service; then
|
||||
echo "The normalsub.service is already running."
|
||||
if systemctl is-active --quiet hysteria-normal-sub.service; then
|
||||
echo "The hysteria-normal-sub.service is already running."
|
||||
return
|
||||
fi
|
||||
|
||||
install_dependencies
|
||||
# install_dependencies
|
||||
# systemctl stop caddy.service > /dev/null 2>&1 # We stopped caddy service just after its installation
|
||||
|
||||
echo "Generating SSL certificates for $domain..."
|
||||
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d "$domain"
|
||||
@ -67,11 +68,12 @@ start_service() {
|
||||
chown -R hysteria:hysteria "/etc/letsencrypt/live/$domain"
|
||||
chown -R hysteria:hysteria /etc/hysteria/core/scripts/normalsub
|
||||
systemctl daemon-reload
|
||||
systemctl enable normalsub.service > /dev/null 2>&1
|
||||
systemctl start normalsub.service > /dev/null 2>&1
|
||||
systemctl enable hysteria-normal-sub.service > /dev/null 2>&1
|
||||
systemctl start hysteria-normal-sub.service > /dev/null 2>&1
|
||||
# systemctl restart caddy.service > /dev/null 2>&1 # We stopped caddy service just after its installation
|
||||
systemctl daemon-reload > /dev/null 2>&1
|
||||
|
||||
if systemctl is-active --quiet normalsub.service; then
|
||||
if systemctl is-active --quiet hysteria-normal-sub.service; then
|
||||
echo -e "${green}normalsub service setup completed. The service is now running on port $port. ${NC}"
|
||||
else
|
||||
echo -e "${red}normalsub setup completed. The service failed to start. ${NC}"
|
||||
@ -85,13 +87,13 @@ stop_service() {
|
||||
|
||||
if [ -n "$HYSTERIA_DOMAIN" ]; then
|
||||
echo -e "${yellow}Deleting SSL certificate for domain: $HYSTERIA_DOMAIN...${NC}"
|
||||
sudo certbot delete --cert-name "$HYSTERIA_DOMAIN" --non-interactive > /dev/null 2>&1
|
||||
certbot delete --cert-name "$HYSTERIA_DOMAIN" --non-interactive > /dev/null 2>&1
|
||||
else
|
||||
echo -e "${red}HYSTERIA_DOMAIN not found in .env. Skipping certificate deletion.${NC}"
|
||||
fi
|
||||
|
||||
systemctl stop normalsub.service > /dev/null 2>&1
|
||||
systemctl disable normalsub.service > /dev/null 2>&1
|
||||
systemctl stop hysteria-normal-sub.service > /dev/null 2>&1
|
||||
systemctl disable hysteria-normal-sub.service > /dev/null 2>&1
|
||||
systemctl daemon-reload > /dev/null 2>&1
|
||||
|
||||
rm -f /etc/hysteria/core/scripts/normalsub/.env
|
||||
|
||||
@ -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"
|
||||
|
||||
26
core/scripts/services_status.sh
Normal file
26
core/scripts/services_status.sh
Normal file
@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
declare -a services=(
|
||||
"hysteria-server.service"
|
||||
"hysteria-webpanel.service"
|
||||
"hysteria-caddy.service"
|
||||
"hysteria-telegram-bot.service"
|
||||
"hysteria-normal-sub.service"
|
||||
"hysteria-singbox.service"
|
||||
"wg-quick@wgcf.service"
|
||||
)
|
||||
|
||||
status_json="{"
|
||||
for service in "${services[@]}"; do
|
||||
if systemctl is-active --quiet "$service"; then
|
||||
status_json+="\"$service\":true,"
|
||||
else
|
||||
status_json+="\"$service\":false,"
|
||||
fi
|
||||
done
|
||||
|
||||
# Remove trailing comma and close JSON properly
|
||||
status_json="${status_json%,}}"
|
||||
|
||||
# Format output as valid JSON
|
||||
echo "$status_json" | jq -M .
|
||||
@ -2,15 +2,15 @@
|
||||
source /etc/hysteria/core/scripts/utils.sh
|
||||
define_colors
|
||||
|
||||
install_dependencies() {
|
||||
echo "Installing necessary dependencies..."
|
||||
apt-get install certbot -y > /dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Error: Failed to install certbot. ${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${green}Certbot installed successfully. ${NC}"
|
||||
}
|
||||
# install_dependencies() {
|
||||
# echo "Installing necessary dependencies..."
|
||||
# apt-get install certbot -y > /dev/null 2>&1
|
||||
# if [ $? -ne 0 ]; then
|
||||
# echo -e "${red}Error: Failed to install certbot. ${NC}"
|
||||
# exit 1
|
||||
# fi
|
||||
# echo -e "${green}Certbot installed successfully. ${NC}"
|
||||
# }
|
||||
|
||||
update_env_file() {
|
||||
local domain=$1
|
||||
@ -26,7 +26,7 @@ EOL
|
||||
}
|
||||
|
||||
create_service_file() {
|
||||
cat <<EOL > /etc/systemd/system/singbox.service
|
||||
cat <<EOL > /etc/systemd/system/hysteria-singbox.service
|
||||
[Unit]
|
||||
Description=Singbox Python Service
|
||||
After=network.target
|
||||
@ -48,12 +48,13 @@ start_service() {
|
||||
local domain=$1
|
||||
local port=$2
|
||||
|
||||
if systemctl is-active --quiet singbox.service; then
|
||||
echo "The singbox.service is already running."
|
||||
if systemctl is-active --quiet hysteria-singbox.service; then
|
||||
echo "The hysteria-singbox.service is already running."
|
||||
return
|
||||
fi
|
||||
|
||||
install_dependencies
|
||||
# install_dependencies
|
||||
# systemctl stop caddy.service > /dev/null 2>&1 # We stopped caddy service just after its installation
|
||||
|
||||
echo "Generating SSL certificates for $domain..."
|
||||
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d "$domain"
|
||||
@ -64,14 +65,19 @@ start_service() {
|
||||
|
||||
update_env_file "$domain" "$port"
|
||||
create_service_file
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Error: Failed to create the service file. ${NC}"
|
||||
exit 1
|
||||
fi
|
||||
chown -R hysteria:hysteria "/etc/letsencrypt/live/$domain"
|
||||
chown -R hysteria:hysteria /etc/hysteria/core/scripts/singbox
|
||||
systemctl daemon-reload
|
||||
systemctl enable singbox.service > /dev/null 2>&1
|
||||
systemctl start singbox.service > /dev/null 2>&1
|
||||
systemctl enable hysteria-singbox.service > /dev/null 2>&1
|
||||
systemctl start hysteria-singbox.service > /dev/null 2>&1
|
||||
# systemctl restart caddy.service > /dev/null 2>&1 # We stopped caddy service just after its installation
|
||||
systemctl daemon-reload > /dev/null 2>&1
|
||||
|
||||
if systemctl is-active --quiet singbox.service; then
|
||||
if systemctl is-active --quiet hysteria-singbox.service; then
|
||||
echo -e "${green}Singbox service setup completed. The service is now running on port $port. ${NC}"
|
||||
else
|
||||
echo -e "${red}Singbox setup completed. The service failed to start. ${NC}"
|
||||
@ -85,13 +91,13 @@ stop_service() {
|
||||
|
||||
if [ -n "$HYSTERIA_DOMAIN" ]; then
|
||||
echo -e "${yellow}Deleting SSL certificate for domain: $HYSTERIA_DOMAIN...${NC}"
|
||||
sudo certbot delete --cert-name "$HYSTERIA_DOMAIN" --non-interactive > /dev/null 2>&1
|
||||
certbot delete --cert-name "$HYSTERIA_DOMAIN" --non-interactive > /dev/null 2>&1
|
||||
else
|
||||
echo -e "${red}HYSTERIA_DOMAIN not found in .env. Skipping certificate deletion.${NC}"
|
||||
fi
|
||||
|
||||
systemctl stop singbox.service > /dev/null 2>&1
|
||||
systemctl disable singbox.service > /dev/null 2>&1
|
||||
systemctl stop hysteria-singbox.service > /dev/null 2>&1
|
||||
systemctl disable hysteria-singbox.service > /dev/null 2>&1
|
||||
systemctl daemon-reload > /dev/null 2>&1
|
||||
|
||||
rm -f /etc/hysteria/core/scripts/singbox/.env
|
||||
|
||||
@ -13,7 +13,7 @@ EOL
|
||||
}
|
||||
|
||||
create_service_file() {
|
||||
cat <<EOL > /etc/systemd/system/hysteria-bot.service
|
||||
cat <<EOL > /etc/systemd/system/hysteria-telegram-bot.service
|
||||
[Unit]
|
||||
Description=Hysteria Telegram Bot
|
||||
After=network.target
|
||||
@ -32,8 +32,8 @@ start_service() {
|
||||
local api_token=$1
|
||||
local admin_user_ids=$2
|
||||
|
||||
if systemctl is-active --quiet hysteria-bot.service; then
|
||||
echo "The hysteria-bot.service is already running."
|
||||
if systemctl is-active --quiet hysteria-telegram-bot.service; then
|
||||
echo "The hysteria-telegram-bot.service is already running."
|
||||
return
|
||||
fi
|
||||
|
||||
@ -41,10 +41,10 @@ start_service() {
|
||||
create_service_file
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable hysteria-bot.service > /dev/null 2>&1
|
||||
systemctl start hysteria-bot.service > /dev/null 2>&1
|
||||
systemctl enable hysteria-telegram-bot.service > /dev/null 2>&1
|
||||
systemctl start hysteria-telegram-bot.service > /dev/null 2>&1
|
||||
|
||||
if systemctl is-active --quiet hysteria-bot.service; then
|
||||
if systemctl is-active --quiet hysteria-telegram-bot.service; then
|
||||
echo -e "${green}Hysteria bot setup completed. The service is now running. ${NC}"
|
||||
echo -e "\n\n"
|
||||
else
|
||||
@ -53,8 +53,8 @@ start_service() {
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
systemctl stop hysteria-bot.service > /dev/null 2>&1
|
||||
systemctl disable hysteria-bot.service > /dev/null 2>&1
|
||||
systemctl stop hysteria-telegram-bot.service > /dev/null 2>&1
|
||||
systemctl disable hysteria-telegram-bot.service > /dev/null 2>&1
|
||||
|
||||
rm -f /etc/hysteria/core/scripts/telegrambot/.env
|
||||
echo -e "\n"
|
||||
|
||||
@ -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
|
||||
# }
|
||||
|
||||
80
core/scripts/webpanel/app.py
Normal file
80
core/scripts/webpanel/app.py
Normal file
@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
from fastapi import FastAPI
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from config import CONFIGS # Loads the configuration from .env
|
||||
from middleware import AuthMiddleware # Defines authentication middleware
|
||||
from middleware import AfterRequestMiddleware # Defines after request middleware
|
||||
from dependency import get_session_manager # Defines dependencies across routers
|
||||
from openapi import setup_openapi_schema # Adds authorization header to openapi schema
|
||||
from exception_handler import setup_exception_handler # Defines exception handlers
|
||||
|
||||
# Append directory of cli_api.py to be able to import it
|
||||
HYSTERIA_CORE_DIR = '/etc/hysteria/core/'
|
||||
sys.path.append(HYSTERIA_CORE_DIR)
|
||||
|
||||
import routers # noqa: This import should be after the sys.path modification, because it imports cli_api
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
'''
|
||||
Create FastAPI app.
|
||||
'''
|
||||
|
||||
# Set up FastAPI
|
||||
app = FastAPI(
|
||||
title='Hysteria Webpanel',
|
||||
description='Webpanel for Hysteria',
|
||||
version='0.1.0',
|
||||
contact={
|
||||
'github': 'https://github.com/ReturnFI/Hysteria2'
|
||||
},
|
||||
debug=CONFIGS.DEBUG,
|
||||
root_path=f'/{CONFIGS.ROOT_PATH}',
|
||||
)
|
||||
|
||||
# Set up static files
|
||||
app.mount('/assets', StaticFiles(directory='assets'), name='assets')
|
||||
|
||||
# Set up exception handlers
|
||||
setup_exception_handler(app)
|
||||
|
||||
# Set up authentication middleware
|
||||
|
||||
app.add_middleware(AuthMiddleware, session_manager=get_session_manager(), api_token=CONFIGS.API_TOKEN)
|
||||
# Set up after request middleware
|
||||
app.add_middleware(AfterRequestMiddleware)
|
||||
|
||||
# Set up Routers
|
||||
app.include_router(routers.basic.router, prefix='', tags=['Basic Routes[Web]']) # Add basic router
|
||||
app.include_router(routers.login.router, prefix='', tags=['Authentication[Web]']) # Add authentication router
|
||||
app.include_router(routers.settings.router, prefix='/settings', tags=['Settings[Web]']) # Add settings router
|
||||
app.include_router(routers.user.router, prefix='/users', tags=['User Management[Web]']) # Add user router
|
||||
app.include_router(routers.api.v1.api_v1_router, prefix='/api/v1', tags=['API Version 1']) # Add API version 1 router # type: ignore
|
||||
|
||||
# Document that the API requires an API key
|
||||
setup_openapi_schema(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app: FastAPI = create_app()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from hypercorn.config import Config
|
||||
from hypercorn.asyncio import serve # type: ignore
|
||||
from hypercorn.middleware import ProxyFixMiddleware
|
||||
|
||||
config = Config()
|
||||
config.debug = CONFIGS.DEBUG
|
||||
config.bind = ['127.0.0.1:8080']
|
||||
config.accesslog = '-'
|
||||
config.errorlog = '-'
|
||||
|
||||
# Fix proxy headers
|
||||
app = ProxyFixMiddleware(app, 'legacy') # type: ignore
|
||||
asyncio.run(serve(app, config)) # type: ignore
|
||||
9
core/scripts/webpanel/assets/css/custom.css
Normal file
9
core/scripts/webpanel/assets/css/custom.css
Normal file
@ -0,0 +1,9 @@
|
||||
/* Center the qrcodesContainer */
|
||||
#qrcodesContainer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Style the config type text */
|
||||
.config-type-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
BIN
core/scripts/webpanel/assets/img/favicon-bg.ico
Normal file
BIN
core/scripts/webpanel/assets/img/favicon-bg.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
core/scripts/webpanel/assets/img/favicon.ico
Normal file
BIN
core/scripts/webpanel/assets/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
1
core/scripts/webpanel/config/__init__.py
Normal file
1
core/scripts/webpanel/config/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .config import CONFIGS
|
||||
19
core/scripts/webpanel/config/config.py
Normal file
19
core/scripts/webpanel/config/config.py
Normal file
@ -0,0 +1,19 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Configs(BaseSettings):
|
||||
PORT: int
|
||||
DOMAIN: str
|
||||
DEBUG: bool
|
||||
ADMIN_USERNAME: str
|
||||
ADMIN_PASSWORD: str
|
||||
API_TOKEN: str
|
||||
EXPIRATION_MINUTES: int
|
||||
ROOT_PATH: str
|
||||
|
||||
class Config:
|
||||
env_file = '.env'
|
||||
env_file_encoding = 'utf-8'
|
||||
|
||||
|
||||
CONFIGS = Configs() # type: ignore
|
||||
1
core/scripts/webpanel/dependency/__init__.py
Normal file
1
core/scripts/webpanel/dependency/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .dependency import get_templates, get_session_manager
|
||||
33
core/scripts/webpanel/dependency/dependency.py
Normal file
33
core/scripts/webpanel/dependency/dependency.py
Normal file
@ -0,0 +1,33 @@
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from session import SessionStorage, SessionManager
|
||||
from config import CONFIGS
|
||||
|
||||
__TEMPLATES = Jinja2Templates(directory='templates')
|
||||
|
||||
# This was a custom url_for function for Jinja2 to add a prefix to the generated URL but we fix the url generation by setting the root path
|
||||
# @pass_context
|
||||
# def url_for(context: dict[str, Any], name: str = '', **path_params: dict[str, Any]) -> URL:
|
||||
# '''
|
||||
# Custom url_for function for Jinja2 to add a prefix to the generated URL.
|
||||
# '''
|
||||
# request: Request = context["request"]
|
||||
# url = request.url_for(name, **path_params)
|
||||
# prefixed_path = f"{CONFIGS.ROOT_PATH.rstrip('/')}/{url.path.lstrip('/')}"
|
||||
|
||||
# return url.replace(path=prefixed_path)
|
||||
|
||||
|
||||
# __TEMPLATES.env.globals['url_for'] = url_for # type: ignore
|
||||
|
||||
|
||||
def get_templates() -> Jinja2Templates:
|
||||
return __TEMPLATES
|
||||
|
||||
|
||||
__SESSION_STORAGE = SessionStorage()
|
||||
__SESSION_MANAGER = SessionManager(__SESSION_STORAGE, CONFIGS.EXPIRATION_MINUTES)
|
||||
|
||||
|
||||
def get_session_manager() -> SessionManager:
|
||||
return __SESSION_MANAGER
|
||||
1
core/scripts/webpanel/exception_handler/__init__.py
Normal file
1
core/scripts/webpanel/exception_handler/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .handler import setup_exception_handler, exception_handler
|
||||
24
core/scripts/webpanel/exception_handler/handler.py
Normal file
24
core/scripts/webpanel/exception_handler/handler.py
Normal file
@ -0,0 +1,24 @@
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class JSONErrorResponse(BaseModel):
|
||||
status: int
|
||||
detail: str
|
||||
|
||||
|
||||
def setup_exception_handler(app: FastAPI):
|
||||
'''
|
||||
Setup exception handler for FastAPI.
|
||||
'''
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler_wrapper(request: Request, exc: HTTPException): # type: ignore
|
||||
return exception_handler(exc)
|
||||
|
||||
|
||||
def exception_handler(exc: HTTPException): # type: ignore
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=JSONErrorResponse(status=exc.status_code, detail=exc.detail).model_dump(),
|
||||
)
|
||||
2
core/scripts/webpanel/middleware/__init__.py
Normal file
2
core/scripts/webpanel/middleware/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .auth import AuthMiddleware
|
||||
from .request import AfterRequestMiddleware
|
||||
76
core/scripts/webpanel/middleware/auth.py
Normal file
76
core/scripts/webpanel/middleware/auth.py
Normal file
@ -0,0 +1,76 @@
|
||||
from fastapi import Request, Response, HTTPException
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable
|
||||
from starlette.types import ASGIApp
|
||||
from urllib.parse import quote
|
||||
|
||||
from exception_handler import exception_handler
|
||||
from session import SessionManager
|
||||
from config import CONFIGS
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
'''Middleware that handles session authentication.'''
|
||||
|
||||
def __init__(self, app: ASGIApp, session_manager: SessionManager, api_token: str | None):
|
||||
super().__init__(app)
|
||||
self.__session_manager = session_manager
|
||||
self.__api_token = api_token
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]): # type: ignore
|
||||
'''Handles session authentication.'''
|
||||
public_routes = [
|
||||
f'/{CONFIGS.ROOT_PATH}/login',
|
||||
f'/{CONFIGS.ROOT_PATH}/robots.txt'
|
||||
]
|
||||
if request.url.path in public_routes:
|
||||
return await call_next(request)
|
||||
|
||||
is_api_request = '/api/v1/' in request.url.path
|
||||
|
||||
if is_api_request:
|
||||
if self.__api_token:
|
||||
# Attempt to authenticate with API token
|
||||
if api_key := request.headers.get('Authorization'):
|
||||
if api_key == self.__api_token:
|
||||
return await call_next(request)
|
||||
else:
|
||||
return self.__handle_api_failure(status=401, detail="Invalid API token.")
|
||||
|
||||
# Extract session_id from cookies
|
||||
session_id = request.cookies.get("session_id")
|
||||
|
||||
if not session_id:
|
||||
if is_api_request:
|
||||
return self.__handle_api_failure(status=401, detail="Unauthorized.")
|
||||
|
||||
return self.__redirect_to_login(request)
|
||||
|
||||
session_data = self.__session_manager.get_session(session_id)
|
||||
|
||||
if not session_data:
|
||||
if is_api_request:
|
||||
return self.__handle_api_failure(status=401, detail="The session is invalid.")
|
||||
|
||||
return self.__redirect_to_login(request)
|
||||
|
||||
if session_data.expires_at < datetime.now(timezone.utc):
|
||||
if is_api_request:
|
||||
return self.__handle_api_failure(status=401, detail="The session has expired.")
|
||||
|
||||
return self.__redirect_to_login(request)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
def __handle_api_failure(self, status: int, detail: str):
|
||||
exc = HTTPException(status_code=status, detail=detail)
|
||||
|
||||
return exception_handler(exc)
|
||||
|
||||
def __redirect_to_login(self, request: Request):
|
||||
next_url = quote(str(request.url))
|
||||
redirect_url = str(request.url_for('login')) + f'?next_url={next_url}'
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
12
core/scripts/webpanel/middleware/request.py
Normal file
12
core/scripts/webpanel/middleware/request.py
Normal file
@ -0,0 +1,12 @@
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi import Request, Response
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
|
||||
class AfterRequestMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]):
|
||||
response = await call_next(request)
|
||||
|
||||
# Add X-Robots-Tag header
|
||||
response.headers['X-Robots-Tag'] = 'noindex, nofollow'
|
||||
return response
|
||||
1
core/scripts/webpanel/openapi/__init__.py
Normal file
1
core/scripts/webpanel/openapi/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .openapi import setup_openapi_schema
|
||||
39
core/scripts/webpanel/openapi/openapi.py
Normal file
39
core/scripts/webpanel/openapi/openapi.py
Normal file
@ -0,0 +1,39 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from config import CONFIGS
|
||||
|
||||
|
||||
def setup_openapi_schema(app: FastAPI):
|
||||
"""Set up the OpenAPI schema for the API.
|
||||
|
||||
The OpenAPI schema is modified to include the API key in the
|
||||
"Authorization" header. All routes under /api/v1/ are modified to
|
||||
require the API key.
|
||||
"""
|
||||
|
||||
app.openapi_schema = app.openapi()
|
||||
|
||||
app.openapi_schema["servers"] = [
|
||||
{
|
||||
"url": f"/{CONFIGS.ROOT_PATH}",
|
||||
"description": "Root path of the API"
|
||||
}
|
||||
]
|
||||
|
||||
# Define API key in the OpenAPI schema
|
||||
# It's a header with the name "Authorization"
|
||||
app.openapi_schema["components"]["securitySchemes"] = {
|
||||
"ApiKeyAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "Authorization"
|
||||
}
|
||||
}
|
||||
|
||||
# Apply the API key requirement to all paths under /api/v1/ dynamically
|
||||
for path, operations in app.openapi_schema["paths"].items():
|
||||
if path.startswith("/api/v1/"): # Apply security to routes starting with /api/v1/
|
||||
for operation in operations.values():
|
||||
if "security" not in operation:
|
||||
operation["security"] = []
|
||||
operation["security"].append({"ApiKeyAuth": []})
|
||||
5
core/scripts/webpanel/routers/__init__.py
Normal file
5
core/scripts/webpanel/routers/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from . import basic
|
||||
from . import api
|
||||
from . import user
|
||||
from . import login
|
||||
from . import settings
|
||||
1
core/scripts/webpanel/routers/api/__init__.py
Normal file
1
core/scripts/webpanel/routers/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import v1
|
||||
10
core/scripts/webpanel/routers/api/v1/__init__.py
Normal file
10
core/scripts/webpanel/routers/api/v1/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
from . import user
|
||||
from . import server
|
||||
from . import config
|
||||
|
||||
api_v1_router = APIRouter()
|
||||
|
||||
api_v1_router.include_router(user.router, prefix='/users')
|
||||
api_v1_router.include_router(server.router, prefix='/server')
|
||||
api_v1_router.include_router(config.router, prefix='/config')
|
||||
19
core/scripts/webpanel/routers/api/v1/config/__init__.py
Normal file
19
core/scripts/webpanel/routers/api/v1/config/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter
|
||||
from . import hysteria
|
||||
from . import warp
|
||||
from . import telegram
|
||||
from . import normalsub
|
||||
from . import singbox
|
||||
from . import ip
|
||||
from . import misc
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
router.include_router(hysteria.router, prefix='/hysteria')
|
||||
router.include_router(warp.router, prefix='/warp')
|
||||
router.include_router(telegram.router, prefix='/telegram')
|
||||
router.include_router(normalsub.router, prefix='/normalsub')
|
||||
router.include_router(singbox.router, prefix='/singbox')
|
||||
router.include_router(ip.router, prefix='/ip')
|
||||
router.include_router(misc.router)
|
||||
241
core/scripts/webpanel/routers/api/v1/config/hysteria.py
Normal file
241
core/scripts/webpanel/routers/api/v1/config/hysteria.py
Normal file
@ -0,0 +1,241 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ..schema.config.hysteria import ConfigFile
|
||||
from ..schema.response import DetailResponse
|
||||
# from ..schema.config.hysteria import InstallInputBody
|
||||
import cli_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Change: Installing and uninstalling Hysteria2 is possible only through the CLI
|
||||
# @router.post('/install', response_model=DetailResponse, summary='Install Hysteria2')
|
||||
# async def install(body: InstallInputBody):
|
||||
# """
|
||||
# Installs Hysteria2 on the given port and uses the provided or default SNI value.
|
||||
|
||||
# Args:
|
||||
# body: An instance of InstallInputBody containing the new port and SNI value.
|
||||
|
||||
# Returns:
|
||||
# A DetailResponse with a message indicating the Hysteria2 installation was successful.
|
||||
|
||||
# Raises:
|
||||
# HTTPException: if an error occurs while installing Hysteria2.
|
||||
# """
|
||||
# try:
|
||||
# cli_api.install_hysteria2(body.port, body.sni)
|
||||
# return DetailResponse(detail=f'Hysteria2 installed successfully on port {body.port} with SNI {body.sni}.')
|
||||
# except Exception as e:
|
||||
# raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
# @router.delete('/uninstall', response_model=DetailResponse, summary='Uninstall Hysteria2')
|
||||
# async def uninstall():
|
||||
# """
|
||||
# Uninstalls Hysteria2.
|
||||
|
||||
# Returns:
|
||||
# A DetailResponse with a message indicating the Hysteria2 uninstallation was successful.
|
||||
|
||||
# Raises:
|
||||
# HTTPException: if an error occurs while uninstalling Hysteria2.
|
||||
# """
|
||||
# try:
|
||||
# cli_api.uninstall_hysteria2()
|
||||
# return DetailResponse(detail='Hysteria2 uninstalled successfully.')
|
||||
# except Exception as e:
|
||||
# raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.patch('/update', response_model=DetailResponse, summary='Update Hysteria2')
|
||||
async def update():
|
||||
"""
|
||||
Updates Hysteria2.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the Hysteria2 update was successful.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while updating Hysteria2.
|
||||
"""
|
||||
try:
|
||||
cli_api.update_hysteria2()
|
||||
return DetailResponse(detail='Hysteria2 updated successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/set-port/{port}', response_model=DetailResponse, summary='Set Hysteria2 port')
|
||||
async def set_port(port: int):
|
||||
"""
|
||||
Sets the port for Hysteria2.
|
||||
|
||||
Args:
|
||||
port: The new port value.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the Hysteria2 port change was successful.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while changing the Hysteria2 port.
|
||||
"""
|
||||
try:
|
||||
cli_api.change_hysteria2_port(port)
|
||||
return DetailResponse(detail=f'Hysteria2 port changed to {port} successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/set-sni/{sni}', response_model=DetailResponse, summary='Set Hysteria2 SNI')
|
||||
async def set_sni(sni: str):
|
||||
"""
|
||||
Sets the SNI for Hysteria2.
|
||||
|
||||
Args:
|
||||
sni: The new SNI value.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the Hysteria2 SNI change was successful.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while changing the Hysteria2 SNI.
|
||||
"""
|
||||
try:
|
||||
cli_api.change_hysteria2_sni(sni)
|
||||
return DetailResponse(detail=f'Hysteria2 SNI changed to {sni} successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/backup', response_model=DetailResponse, summary='Backup Hysteria2 configuration')
|
||||
async def backup():
|
||||
"""
|
||||
Backups the Hysteria2 configuration.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the Hysteria2 configuration backup was successful.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while backing up the Hysteria2 configuration.
|
||||
"""
|
||||
try:
|
||||
cli_api.backup_hysteria2()
|
||||
return DetailResponse(detail='Hysteria2 configuration backed up successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/enable-obfs', response_model=DetailResponse, summary='Enable Hysteria2 obfs')
|
||||
async def enable_obfs():
|
||||
"""
|
||||
Enables Hysteria2 obfs.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the Hysteria2 obfs was enabled successfully.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while enabling Hysteria2 obfs.
|
||||
"""
|
||||
try:
|
||||
cli_api.enable_hysteria2_obfs()
|
||||
return DetailResponse(detail='Hysteria2 obfs enabled successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/disable-obfs', response_model=DetailResponse, summary='Disable Hysteria2 obfs')
|
||||
async def disable_obfs():
|
||||
"""
|
||||
Disables Hysteria2 obfs.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the Hysteria2 obfs was disabled successfully.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while disabling Hysteria2 obfs.
|
||||
"""
|
||||
try:
|
||||
cli_api.disable_hysteria2_obfs()
|
||||
return DetailResponse(detail='Hysteria2 obfs disabled successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/enable-masquerade/{domain}', response_model=DetailResponse, summary='Enable Hysteria2 masquerade')
|
||||
async def enable_masquerade(domain: str):
|
||||
"""
|
||||
Enables Hysteria2 masquerade for the given domain.
|
||||
|
||||
Args:
|
||||
domain: The domain to enable Hysteria2 masquerade for.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the Hysteria2 masquerade was enabled successfully.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while enabling Hysteria2 masquerade.
|
||||
"""
|
||||
try:
|
||||
cli_api.enable_hysteria2_masquerade(domain)
|
||||
return DetailResponse(detail='Hysteria2 masquerade enabled successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/disable-masquerade', response_model=DetailResponse, summary='Disable Hysteria2 masquerade')
|
||||
async def disable_masquerade():
|
||||
"""
|
||||
Disables Hysteria2 masquerade.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the Hysteria2 masquerade was disabled successfully.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while disabling Hysteria2 masquerade.
|
||||
"""
|
||||
try:
|
||||
cli_api.disable_hysteria2_masquerade()
|
||||
return DetailResponse(detail='Hysteria2 masquerade disabled successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/file', response_model=ConfigFile, summary='Get Hysteria2 configuration file')
|
||||
async def get_file():
|
||||
"""
|
||||
Gets the Hysteria2 configuration file.
|
||||
|
||||
Returns:
|
||||
A JSONResponse containing the Hysteria2 configuration file.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while getting the Hysteria2 configuration file.
|
||||
"""
|
||||
try:
|
||||
if config_file := cli_api.get_hysteria2_config_file():
|
||||
return ConfigFile(root=config_file)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail='Hysteria2 configuration file not found.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.post('/file', response_model=DetailResponse, summary='Update Hysteria2 configuration file')
|
||||
async def set_file(body: ConfigFile):
|
||||
"""
|
||||
Updates the Hysteria2 configuration file.
|
||||
|
||||
Args:
|
||||
file: The Hysteria2 configuration file to update.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the Hysteria2 configuration file was updated successfully.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while updating the Hysteria2 configuration file.
|
||||
"""
|
||||
try:
|
||||
cli_api.set_hysteria2_config_file(body.root)
|
||||
return DetailResponse(detail='Hysteria2 configuration file updated successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
71
core/scripts/webpanel/routers/api/v1/config/ip.py
Normal file
71
core/scripts/webpanel/routers/api/v1/config/ip.py
Normal file
@ -0,0 +1,71 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ..schema.response import DetailResponse
|
||||
|
||||
|
||||
from ..schema.config.ip import EditInputBody, StatusResponse
|
||||
import cli_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/get', response_model=StatusResponse, summary='Get IP Status')
|
||||
async def get_ip_api():
|
||||
"""
|
||||
Retrieves the current status of IP addresses.
|
||||
|
||||
Returns:
|
||||
StatusResponse: A response model containing the current IP address details.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the IP status is not available (404) or if there is an error processing the request (400).
|
||||
"""
|
||||
try:
|
||||
|
||||
ipv4, ipv6 = cli_api.get_ip_address()
|
||||
if ipv4 or ipv6:
|
||||
return StatusResponse(ipv4=ipv4, ipv6=ipv6) # type: ignore
|
||||
raise HTTPException(status_code=404, detail='IP status not available.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/add', response_model=DetailResponse, summary='Add IP')
|
||||
async def add_ip_api():
|
||||
"""
|
||||
Adds the auto-detected IP addresses to the .configs.env file.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the IP addresses were added successfully.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while adding the IP addresses.
|
||||
"""
|
||||
try:
|
||||
cli_api.add_ip_address()
|
||||
return DetailResponse(detail='IP addresses added successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.post('/edit', response_model=DetailResponse, summary='Edit IP')
|
||||
async def edit_ip_api(body: EditInputBody):
|
||||
"""
|
||||
Edits the IP addresses in the .configs.env file.
|
||||
|
||||
Args:
|
||||
body: An instance of EditInputBody containing the new IPv4 and/or IPv6 addresses.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the IP addresses were edited successfully.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while editing the IP addresses.
|
||||
"""
|
||||
try:
|
||||
if not body.ipv4 and not body.ipv6:
|
||||
raise HTTPException(status_code=400, detail='Error: You must specify either ipv4 or ipv6')
|
||||
|
||||
cli_api.edit_ip_address(str(body.ipv4), str(body.ipv6))
|
||||
return DetailResponse(detail='IP address edited successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
47
core/scripts/webpanel/routers/api/v1/config/misc.py
Normal file
47
core/scripts/webpanel/routers/api/v1/config/misc.py
Normal file
@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ..schema.response import DetailResponse
|
||||
import cli_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/install-tcp-brutal', response_model=DetailResponse, summary='Install TCP Brutal')
|
||||
async def install_tcp_brutal():
|
||||
"""
|
||||
Endpoint to install TCP Brutal service.
|
||||
It's post method because keeping backward compatibility if we need to add parameters in the future.
|
||||
|
||||
Returns:
|
||||
DetailResponse: A response object indicating the success of the installation.
|
||||
|
||||
Raises:
|
||||
HTTPException: If an error occurs during the installation, an HTTP 400 error is raised with the error details.
|
||||
"""
|
||||
try:
|
||||
cli_api.install_tcp_brutal()
|
||||
return DetailResponse(detail='TCP Brutal installed successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/update-geo/{country}', response_model=DetailResponse, summary='Update Geo files')
|
||||
async def update_geo(country: str):
|
||||
"""
|
||||
Endpoint to update geographic data files based on the specified country.
|
||||
|
||||
Args:
|
||||
country (str): The country for which to update the geo files.
|
||||
Accepts 'iran', 'china', or 'russia'.
|
||||
|
||||
Returns:
|
||||
DetailResponse: A response object indicating the success of the update operation.
|
||||
|
||||
Raises:
|
||||
HTTPException: If an error occurs during the update process, an HTTP 400 error is raised with the error details.
|
||||
"""
|
||||
|
||||
try:
|
||||
cli_api.update_geo(country)
|
||||
return DetailResponse(detail='Geo updated successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
54
core/scripts/webpanel/routers/api/v1/config/normalsub.py
Normal file
54
core/scripts/webpanel/routers/api/v1/config/normalsub.py
Normal file
@ -0,0 +1,54 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ..schema.response import DetailResponse
|
||||
from ..schema.config.normalsub import StartInputBody
|
||||
import cli_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/start', response_model=DetailResponse, summary='Start NormalSub')
|
||||
async def normal_sub_start_api(body: StartInputBody):
|
||||
"""
|
||||
Starts the NormalSub service using the provided domain and port.
|
||||
|
||||
Args:
|
||||
body (StartInputBody): The request body containing the domain and port
|
||||
information for starting the NormalSub service.
|
||||
|
||||
Returns:
|
||||
DetailResponse: A response object containing a success message indicating
|
||||
that the NormalSub service has been started successfully.
|
||||
|
||||
Raises:
|
||||
HTTPException: If there is an error starting the NormalSub service, an
|
||||
HTTPException with status code 400 and error details will be raised.
|
||||
"""
|
||||
try:
|
||||
|
||||
cli_api.start_normalsub(body.domain, body.port)
|
||||
return DetailResponse(detail='Normalsub started successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.delete('/stop', response_model=DetailResponse, summary='Stop NormalSub')
|
||||
async def normal_sub_stop_api():
|
||||
"""
|
||||
Stops the NormalSub service.
|
||||
|
||||
Returns:
|
||||
DetailResponse: A response object containing a success message indicating
|
||||
that the NormalSub service has been stopped successfully.
|
||||
|
||||
Raises:
|
||||
HTTPException: If there is an error stopping the NormalSub service, an
|
||||
HTTPException with status code 400 and error details will be raised.
|
||||
"""
|
||||
|
||||
try:
|
||||
cli_api.stop_normalsub()
|
||||
return DetailResponse(detail='Normalsub stopped successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
# TODO: Maybe would be nice to have a status endpoint
|
||||
49
core/scripts/webpanel/routers/api/v1/config/singbox.py
Normal file
49
core/scripts/webpanel/routers/api/v1/config/singbox.py
Normal file
@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ..schema.response import DetailResponse
|
||||
from ..schema.config.singbox import StartInputBody
|
||||
import cli_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/start', response_model=DetailResponse, summary='Start Singbox')
|
||||
async def singbox_start_api(body: StartInputBody):
|
||||
"""
|
||||
Start the Singbox service.
|
||||
|
||||
This endpoint starts the Singbox service if it is currently not running.
|
||||
|
||||
Args:
|
||||
body (StartInputBody): The domain name and port number for the service.
|
||||
|
||||
Returns:
|
||||
DetailResponse: The response with the result of the command.
|
||||
"""
|
||||
try:
|
||||
cli_api.start_singbox(body.domain, body.port)
|
||||
return DetailResponse(detail='Singbox started successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.delete('/stop', response_model=DetailResponse, summary='Stop Singbox')
|
||||
async def singbox_stop_api():
|
||||
"""
|
||||
Stop the Singbox service.
|
||||
|
||||
This endpoint stops the Singbox service if it is currently running.
|
||||
|
||||
Returns:
|
||||
DetailResponse: A response model indicating the stop status of the Singbox service.
|
||||
|
||||
Raises:
|
||||
HTTPException: If there is an error stopping the Singbox service (400).
|
||||
"""
|
||||
|
||||
try:
|
||||
cli_api.stop_singbox()
|
||||
return DetailResponse(detail='Singbox stopped successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
# TODO: Maybe would be nice to have a status endpoint
|
||||
41
core/scripts/webpanel/routers/api/v1/config/telegram.py
Normal file
41
core/scripts/webpanel/routers/api/v1/config/telegram.py
Normal file
@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ..schema.response import DetailResponse
|
||||
from ..schema.config.telegram import StartInputBody
|
||||
import cli_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/start', response_model=DetailResponse, summary='Start Telegram Bot')
|
||||
async def telegram_start_api(body: StartInputBody):
|
||||
"""
|
||||
Starts the Telegram bot.
|
||||
|
||||
Args:
|
||||
body (StartInputBody): The data containing the Telegram bot token and admin ID.
|
||||
|
||||
Returns:
|
||||
DetailResponse: The response containing the result of the action.
|
||||
"""
|
||||
try:
|
||||
cli_api.start_telegram_bot(body.token, body.admin_id)
|
||||
return DetailResponse(detail='Telegram bot started successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.delete('/stop', response_model=DetailResponse, summary='Stop Telegram Bot')
|
||||
async def telegram_stop_api():
|
||||
"""
|
||||
Stops the Telegram bot.
|
||||
|
||||
Returns:
|
||||
DetailResponse: The response containing the result of the action.
|
||||
"""
|
||||
|
||||
try:
|
||||
cli_api.stop_telegram_bot()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
# TODO: Maybe would be nice to have a status endpoint
|
||||
142
core/scripts/webpanel/routers/api/v1/config/warp.py
Normal file
142
core/scripts/webpanel/routers/api/v1/config/warp.py
Normal file
@ -0,0 +1,142 @@
|
||||
import re
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ..schema.response import DetailResponse
|
||||
from ..schema.config.warp import ConfigureInputBody, StatusResponse
|
||||
|
||||
import cli_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/install', response_model=DetailResponse, summary='Install WARP')
|
||||
async def install():
|
||||
"""
|
||||
Installs WARP.
|
||||
It's post method because keeping backward compatibility if we need to add parameters in the future.
|
||||
|
||||
Returns:
|
||||
DetailResponse: A response indicating the success of the installation.
|
||||
|
||||
Raises:
|
||||
HTTPException: If an error occurs during installation, an HTTP 400 error is raised with the error details.
|
||||
"""
|
||||
try:
|
||||
cli_api.install_warp()
|
||||
return DetailResponse(detail='WARP installed successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.delete('/uninstall', response_model=DetailResponse, summary='Uninstall WARP')
|
||||
async def uninstall():
|
||||
"""
|
||||
Uninstalls WARP.
|
||||
|
||||
Returns:
|
||||
DetailResponse: A response indicating the success of the uninstallation.
|
||||
|
||||
Raises:
|
||||
HTTPException: If an error occurs during uninstallation, an HTTP 400 error is raised with the error details.
|
||||
"""
|
||||
try:
|
||||
cli_api.uninstall_warp()
|
||||
return DetailResponse(detail='WARP uninstalled successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.post('/configure', response_model=DetailResponse, summary='Configure WARP')
|
||||
async def configure(body: ConfigureInputBody):
|
||||
"""
|
||||
Configures WARP with the given options.
|
||||
|
||||
Args:
|
||||
body: An instance of ConfigureInputBody containing configuration options.
|
||||
|
||||
Returns:
|
||||
DetailResponse: A response indicating the success of the configuration.
|
||||
|
||||
Raises:
|
||||
HTTPException: If an error occurs during configuration, an HTTP 400 error is raised with the error details.
|
||||
"""
|
||||
try:
|
||||
cli_api.configure_warp(body.all, body.popular_sites, body.domestic_sites,
|
||||
body.block_adult_sites, body.warp_option, body.warp_key)
|
||||
return DetailResponse(detail='WARP configured successfully.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/status', response_model=StatusResponse, summary='Get WARP Status')
|
||||
async def status():
|
||||
"""
|
||||
Retrieves the current status of WARP.
|
||||
|
||||
Returns:
|
||||
StatusResponse: A response model containing the current WARP status details.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the WARP status is not available (404) or if there is an error processing the request (400).
|
||||
"""
|
||||
try:
|
||||
if res := cli_api.warp_status():
|
||||
return __parse_status(res)
|
||||
raise HTTPException(status_code=404, detail='WARP status not available.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
def __parse_status(status: str) -> StatusResponse:
|
||||
"""
|
||||
Parses the output of the WARP status command to extract the current configuration settings.
|
||||
|
||||
Args:
|
||||
status: The output of the WARP status command as a string.
|
||||
|
||||
Returns:
|
||||
StatusResponse: A response model containing the current WARP status details.
|
||||
|
||||
Raises:
|
||||
ValueError: If the WARP status is invalid or incomplete.
|
||||
"""
|
||||
|
||||
# Example output(status) from cli_api.warp_status():
|
||||
# --------------------------------
|
||||
# Current WARP Configuration:
|
||||
# All traffic: Inactive
|
||||
# Popular sites (Google, Netflix, etc.): Inactive
|
||||
# Domestic sites (geosite:ir, geoip:ir): Inactive
|
||||
# Block adult content: Inactive
|
||||
# --------------------------------
|
||||
data = {}
|
||||
|
||||
# Remove ANSI escape sequences(colors) (e.g., \x1b[1;35m)
|
||||
clean_status = re.sub(r'\x1b\[[0-9;]*m', '', status)
|
||||
|
||||
for line in clean_status.split('\n'):
|
||||
if ':' not in line:
|
||||
continue
|
||||
if 'Current WARP Configuration:' in line:
|
||||
continue
|
||||
key, _, value = line.partition(':')
|
||||
key = key.strip().lower()
|
||||
value = value.strip()
|
||||
|
||||
if not key or not value:
|
||||
continue
|
||||
|
||||
if 'all traffic' in key:
|
||||
data['all_traffic'] = value == 'active'
|
||||
elif 'popular sites' in key:
|
||||
data['popular_sites'] = value == 'active'
|
||||
elif 'domestic sites' in key:
|
||||
data['domestic_sites'] = value == 'active'
|
||||
elif 'block adult content' in key:
|
||||
data['block_adult_sites'] = value == 'active'
|
||||
|
||||
if not data:
|
||||
raise ValueError('Invalid WARP status')
|
||||
try:
|
||||
return StatusResponse(**data)
|
||||
except Exception as e:
|
||||
raise ValueError(f'Invalid or incomplete WARP status: {e}')
|
||||
3
core/scripts/webpanel/routers/api/v1/schema/__init__.py
Normal file
3
core/scripts/webpanel/routers/api/v1/schema/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from . import user
|
||||
from . import server
|
||||
from . import config
|
||||
@ -0,0 +1,5 @@
|
||||
from . import hysteria
|
||||
from . import normalsub
|
||||
from . import singbox
|
||||
from . import telegram
|
||||
from . import warp
|
||||
@ -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]
|
||||
11
core/scripts/webpanel/routers/api/v1/schema/config/ip.py
Normal file
11
core/scripts/webpanel/routers/api/v1/schema/config/ip.py
Normal file
@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
ipv4: IPv4Address | None = None
|
||||
ipv6: IPv6Address | None = None
|
||||
|
||||
|
||||
class EditInputBody(StatusResponse):
|
||||
pass
|
||||
@ -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
|
||||
@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StartInputBody(BaseModel):
|
||||
domain: str
|
||||
port: int
|
||||
@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StartInputBody(BaseModel):
|
||||
token: str
|
||||
admin_id: str
|
||||
18
core/scripts/webpanel/routers/api/v1/schema/config/warp.py
Normal file
18
core/scripts/webpanel/routers/api/v1/schema/config/warp.py
Normal file
@ -0,0 +1,18 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
|
||||
class ConfigureInputBody(BaseModel):
|
||||
all: bool = False
|
||||
popular_sites: bool = False
|
||||
domestic_sites: bool = False
|
||||
block_adult_sites: bool = False
|
||||
warp_option: Literal['warp', 'warp plus', ''] = ''
|
||||
warp_key: str = ''
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
all_traffic: bool
|
||||
popular_sites: bool
|
||||
domestic_sites: bool
|
||||
block_adult_sites: bool
|
||||
5
core/scripts/webpanel/routers/api/v1/schema/response.py
Normal file
5
core/scripts/webpanel/routers/api/v1/schema/response.py
Normal file
@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DetailResponse(BaseModel):
|
||||
detail: str
|
||||
25
core/scripts/webpanel/routers/api/v1/schema/server.py
Normal file
25
core/scripts/webpanel/routers/api/v1/schema/server.py
Normal file
@ -0,0 +1,25 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# We can't return bytes because the underlying script is returning human readable values which are hard to parse it
|
||||
# It's better to chnage the underlying script to return bytes instead of changing it here
|
||||
# Because of this problem we use str type instead of int as type
|
||||
class ServerStatusResponse(BaseModel):
|
||||
# disk_usage: int
|
||||
cpu_usage: str
|
||||
total_ram: str
|
||||
ram_usage: str
|
||||
online_users: int
|
||||
|
||||
uploaded_traffic: str
|
||||
downloaded_traffic: str
|
||||
total_traffic: str
|
||||
|
||||
|
||||
class ServerServicesStatusResponse(BaseModel):
|
||||
hysteria_server: bool
|
||||
hysteria_webpanel: bool
|
||||
hysteria_singbox: bool
|
||||
hysteria_normal_sub: bool
|
||||
hysteria_telegram_bot: bool
|
||||
hysteria_warp: bool
|
||||
36
core/scripts/webpanel/routers/api/v1/schema/user.py
Normal file
36
core/scripts/webpanel/routers/api/v1/schema/user.py
Normal file
@ -0,0 +1,36 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, RootModel
|
||||
|
||||
|
||||
# WE CAN'T USE SHARED SCHEMA BECAUSE THE CLI IS RETURNING SAME FIELD IN DIFFERENT NAMES SOMETIMES
|
||||
# WHAT I'M SAYING IS THAT OUR CODE IN HERE WILL BE SPAGHETTI CODE IF WE WANT TO HAVE CONSISTENCY IN ALL RESPONSES OF THE APIs
|
||||
# THE MAIN PROBLEM IS IN THE CLI CODE NOT IN THE WEB PANEL CODE (HERE)
|
||||
|
||||
class UserInfoResponse(BaseModel):
|
||||
password: str
|
||||
max_download_bytes: int
|
||||
expiration_days: int
|
||||
account_creation_date: str
|
||||
blocked: bool
|
||||
|
||||
|
||||
class UserListResponse(RootModel): # type: ignore
|
||||
root: dict[str, UserInfoResponse]
|
||||
|
||||
|
||||
class AddUserInputBody(BaseModel):
|
||||
username: str
|
||||
traffic_limit: int
|
||||
expiration_days: int
|
||||
password: str | None = None
|
||||
creation_date: str | None = None
|
||||
|
||||
|
||||
class EditUserInputBody(BaseModel):
|
||||
# username: str
|
||||
new_username: str | None = None
|
||||
new_traffic_limit: int | None = None
|
||||
new_expiration_days: int | None = None
|
||||
renew_password: bool = False
|
||||
renew_creation_date: bool = False
|
||||
blocked: bool = False
|
||||
146
core/scripts/webpanel/routers/api/v1/server.py
Normal file
146
core/scripts/webpanel/routers/api/v1/server.py
Normal file
@ -0,0 +1,146 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import cli_api
|
||||
from .schema.server import ServerStatusResponse, ServerServicesStatusResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/status', response_model=ServerStatusResponse)
|
||||
async def server_status_api():
|
||||
"""
|
||||
Retrieve the server status.
|
||||
|
||||
This endpoint provides information about the current server status,
|
||||
including CPU usage, RAM usage, online users, and traffic statistics.
|
||||
|
||||
Returns:
|
||||
ServerStatusResponse: A response model containing server status details.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the server information is not available (404) or
|
||||
if there is an error processing the request (400).
|
||||
"""
|
||||
|
||||
try:
|
||||
if res := cli_api.server_info():
|
||||
return __parse_server_status(res)
|
||||
raise HTTPException(status_code=404, detail='Server information not available.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
def __parse_server_status(server_info: str) -> ServerStatusResponse:
|
||||
# Initial data with default values
|
||||
"""
|
||||
Parse the server information provided by cli_api.server_info()
|
||||
and return a ServerStatusResponse instance.
|
||||
|
||||
Args:
|
||||
server_info (str): The output of cli_api.server_info() as a string.
|
||||
|
||||
Returns:
|
||||
ServerStatusResponse: A response model containing server status details.
|
||||
|
||||
Raises:
|
||||
ValueError: If the server information is invalid or incomplete.
|
||||
"""
|
||||
data = {
|
||||
'cpu_usage': '0%',
|
||||
'total_ram': '0MB',
|
||||
'ram_usage': '0MB',
|
||||
'online_users': 0,
|
||||
'uploaded_traffic': '0KB',
|
||||
'downloaded_traffic': '0KB',
|
||||
'total_traffic': '0KB'
|
||||
}
|
||||
|
||||
# Example output(server_info) from cli_api.server_info():
|
||||
# 📈 CPU Usage: 9.4%
|
||||
# 📋 Total RAM: 3815MB
|
||||
# 💻 Used RAM: 2007MB
|
||||
# 👥 Online Users: 0
|
||||
#
|
||||
# 🔼 Uploaded Traffic: 0 KB
|
||||
# 🔽 Downloaded Traffic: 0 KB
|
||||
# 📊 Total Traffic: 0 KB
|
||||
|
||||
for line in server_info.splitlines():
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip().lower()
|
||||
value = value.strip()
|
||||
|
||||
if not key or not value:
|
||||
continue # Skip empty or malformed lines
|
||||
|
||||
try:
|
||||
if 'cpu usage' in key:
|
||||
data['cpu_usage'] = value
|
||||
elif 'total ram' in key:
|
||||
data['total_ram'] = value
|
||||
elif 'used ram' in key:
|
||||
data['ram_usage'] = value
|
||||
elif 'online users' in key:
|
||||
data['online_users'] = int(value)
|
||||
elif 'uploaded traffic' in key:
|
||||
value = value.replace(' ', '')
|
||||
data['uploaded_traffic'] = value
|
||||
elif "downloaded traffic" in key:
|
||||
value = value.replace(' ', '')
|
||||
data['downloaded_traffic'] = value
|
||||
elif 'total traffic' in key:
|
||||
value = value.replace(' ', '')
|
||||
data["total_traffic"] = value
|
||||
except ValueError as e:
|
||||
raise ValueError(f'Error parsing line \'{line}\': {e}')
|
||||
|
||||
# Validate required fields
|
||||
try:
|
||||
return ServerStatusResponse(**data) # type: ignore
|
||||
except Exception as e:
|
||||
raise ValueError(f'Invalid or incomplete server info: {e}')
|
||||
|
||||
|
||||
@router.get('/services/status', response_model=ServerServicesStatusResponse)
|
||||
async def server_services_status_api():
|
||||
"""
|
||||
Retrieve the status of various services.
|
||||
|
||||
This endpoint provides information about the status of different services,
|
||||
including Hysteria2, TelegramBot, Singbox, and Normal-SUB.
|
||||
|
||||
Returns:
|
||||
ServerServicesStatusResponse: A response model containing service status details.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the services status is not available (404) or
|
||||
if there is an error processing the request (400).
|
||||
"""
|
||||
|
||||
try:
|
||||
if res := cli_api.get_services_status():
|
||||
return __parse_services_status(res)
|
||||
raise HTTPException(status_code=404, detail='Services status not available.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
def __parse_services_status(services_status: dict[str, bool]) -> ServerServicesStatusResponse:
|
||||
'''
|
||||
Parse the services status provided by cli_api.get_services_status()
|
||||
and return a ServerServicesStatusResponse instance.
|
||||
'''
|
||||
parsed_services_status: dict[str, bool] = {}
|
||||
for service, status in services_status.items():
|
||||
if 'hysteria-server' in service:
|
||||
parsed_services_status['hysteria_server'] = status
|
||||
elif 'hysteria-webpanel' in service:
|
||||
parsed_services_status['hysteria_webpanel'] = status
|
||||
elif 'telegram-bot' in service:
|
||||
parsed_services_status['hysteria_telegram_bot'] = status
|
||||
elif 'hysteria-normal-sub' in service:
|
||||
parsed_services_status['hysteria_normal_sub'] = status
|
||||
elif 'hysteria-singbox' in service:
|
||||
parsed_services_status['hysteria_singbox'] = status
|
||||
elif 'wg-quick' in service:
|
||||
parsed_services_status['hysteria_warp'] = status
|
||||
return ServerServicesStatusResponse(**parsed_services_status)
|
||||
143
core/scripts/webpanel/routers/api/v1/user.py
Normal file
143
core/scripts/webpanel/routers/api/v1/user.py
Normal file
@ -0,0 +1,143 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from .schema.user import UserListResponse, UserInfoResponse, AddUserInputBody, EditUserInputBody
|
||||
from .schema.response import DetailResponse
|
||||
import cli_api
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/', response_model=UserListResponse)
|
||||
async def list_users_api():
|
||||
"""
|
||||
Get a list of all users.
|
||||
|
||||
Returns:
|
||||
List of user dictionaries.
|
||||
Raises:
|
||||
HTTPException: if no users are found, or if an error occurs.
|
||||
"""
|
||||
try:
|
||||
if res := cli_api.list_users():
|
||||
return res
|
||||
raise HTTPException(status_code=404, detail='No users found.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.post('/', response_model=DetailResponse)
|
||||
async def add_user_api(body: AddUserInputBody):
|
||||
"""
|
||||
Add a new user to the system.
|
||||
|
||||
Args:
|
||||
body: An instance of AddUserInputBody containing the user's details.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the user has been added.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while adding the user.
|
||||
"""
|
||||
|
||||
try:
|
||||
cli_api.add_user(body.username, body.traffic_limit, body.expiration_days, body.password, body.creation_date)
|
||||
return DetailResponse(detail=f'User {body.username} has been added.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/{username}', response_model=UserInfoResponse)
|
||||
async def get_user_api(username: str):
|
||||
"""
|
||||
Get the details of a user.
|
||||
|
||||
Args:
|
||||
username: The username of the user to get.
|
||||
|
||||
Returns:
|
||||
A user dictionary.
|
||||
|
||||
Raises:
|
||||
HTTPException: if the user is not found, or if an error occurs.
|
||||
"""
|
||||
try:
|
||||
if res := cli_api.get_user(username):
|
||||
return res
|
||||
raise HTTPException(status_code=404, detail=f'User {username} not found.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.patch('/{username}', response_model=DetailResponse)
|
||||
async def edit_user_api(username: str, body: EditUserInputBody):
|
||||
"""
|
||||
Edit a user's details.
|
||||
|
||||
Args:
|
||||
username: The username of the user to edit.
|
||||
body: An instance of EditUserInputBody containing the new user details.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the user has been edited.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while editing the user.
|
||||
"""
|
||||
try:
|
||||
cli_api.edit_user(username, body.new_username, body.new_traffic_limit, body.new_expiration_days,
|
||||
body.renew_password, body.renew_creation_date, body.blocked)
|
||||
return DetailResponse(detail=f'User {username} has been edited.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.delete('/{username}', response_model=DetailResponse)
|
||||
async def remove_user_api(username: str):
|
||||
"""
|
||||
Remove a user.
|
||||
|
||||
Args:
|
||||
username: The username of the user to remove.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the user has been removed.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while removing the user.
|
||||
"""
|
||||
try:
|
||||
cli_api.remove_user(username)
|
||||
return DetailResponse(detail=f'User {username} has been removed.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/{username}/reset', response_model=DetailResponse)
|
||||
async def reset_user_api(username: str):
|
||||
"""
|
||||
Resets a user.
|
||||
|
||||
Args:
|
||||
username: The username of the user to reset.
|
||||
|
||||
Returns:
|
||||
A DetailResponse with a message indicating the user has been reset.
|
||||
|
||||
Raises:
|
||||
HTTPException: if an error occurs while resetting the user.
|
||||
"""
|
||||
try:
|
||||
cli_api.reset_user(username)
|
||||
return DetailResponse(detail=f'User {username} has been reset.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
# TODO implement show user uri endpoint
|
||||
# @router.get('/{username}/uri', response_model=TODO)
|
||||
# async def show_user_uri(username: str):
|
||||
# try:
|
||||
# res = cli_api.show_user_uri(username)
|
||||
# return res
|
||||
# except Exception as e:
|
||||
# raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
1
core/scripts/webpanel/routers/basic/__init__.py
Normal file
1
core/scripts/webpanel/routers/basic/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .basic import router
|
||||
16
core/scripts/webpanel/routers/basic/basic.py
Normal file
16
core/scripts/webpanel/routers/basic/basic.py
Normal file
@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from dependency import get_templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def index(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||
return templates.TemplateResponse('index.html', {'request': request})
|
||||
|
||||
|
||||
@router.get('/robots.txt')
|
||||
async def robots_txt(request: Request):
|
||||
return PlainTextResponse('User-agent: *\nDisallow: /')
|
||||
1
core/scripts/webpanel/routers/login/__init__.py
Normal file
1
core/scripts/webpanel/routers/login/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .login import router
|
||||
53
core/scripts/webpanel/routers/login/login.py
Normal file
53
core/scripts/webpanel/routers/login/login.py
Normal file
@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from hashlib import sha256
|
||||
|
||||
from dependency import get_templates, get_session_manager
|
||||
from session import SessionManager
|
||||
from config import CONFIGS # type: ignore
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/login')
|
||||
async def login(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||
return templates.TemplateResponse('login.html', {'request': request})
|
||||
|
||||
|
||||
@router.post('/login')
|
||||
async def login_post(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates), session_manager: SessionManager = Depends(get_session_manager),
|
||||
username: str = Form(), password: str = Form(), next_url: str = Form(default='/')
|
||||
):
|
||||
'''
|
||||
Handles login form submission.
|
||||
'''
|
||||
password_hash = sha256(password.encode()).hexdigest()
|
||||
if not username == CONFIGS.ADMIN_USERNAME or not password_hash == CONFIGS.ADMIN_PASSWORD: # type: ignore
|
||||
return templates.TemplateResponse('login.html', {'request': request, 'error': 'Invalid username or password'})
|
||||
|
||||
session_id = session_manager.set_session(username)
|
||||
|
||||
# Redirect to the index page if there is no next query parameter in the URL
|
||||
if next_url == '/':
|
||||
redirect_url = request.url_for('index')
|
||||
else:
|
||||
redirect_url = next_url
|
||||
|
||||
res = RedirectResponse(url=redirect_url, status_code=302)
|
||||
res.set_cookie(key='session_id', value=session_id)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
@router.get('/logout')
|
||||
async def logout(request: Request, session_manager: SessionManager = Depends(get_session_manager)):
|
||||
session_id = request.cookies.get('session_id')
|
||||
if session_id:
|
||||
session_manager.revoke_session(session_id)
|
||||
|
||||
res = RedirectResponse(url=request.url_for('index'), status_code=302)
|
||||
res.delete_cookie('session_id')
|
||||
return res
|
||||
1
core/scripts/webpanel/routers/settings/__init__.py
Normal file
1
core/scripts/webpanel/routers/settings/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .settings import router
|
||||
15
core/scripts/webpanel/routers/settings/settings.py
Normal file
15
core/scripts/webpanel/routers/settings/settings.py
Normal file
@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from dependency import get_templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def settings(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||
return templates.TemplateResponse('settings.html', {'request': request})
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
async def config(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||
return templates.TemplateResponse('config.html', {'request': request})
|
||||
2
core/scripts/webpanel/routers/user/__init__.py
Normal file
2
core/scripts/webpanel/routers/user/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
from .user import router
|
||||
23
core/scripts/webpanel/routers/user/user.py
Normal file
23
core/scripts/webpanel/routers/user/user.py
Normal file
@ -0,0 +1,23 @@
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependency import get_templates
|
||||
from .viewmodel import User
|
||||
import cli_api
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def users(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||
try:
|
||||
dict_users = cli_api.list_users() # type: ignore
|
||||
users: list[User] = []
|
||||
if dict_users:
|
||||
users: list[User] = [User.from_dict(key, value) for key, value in dict_users.items()] # type: ignore
|
||||
|
||||
return templates.TemplateResponse('users.html', {'users': users, 'request': request})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
108
core/scripts/webpanel/routers/user/viewmodel.py
Normal file
108
core/scripts/webpanel/routers/user/viewmodel.py
Normal file
@ -0,0 +1,108 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import cli_api
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
type: str
|
||||
link: str
|
||||
|
||||
@staticmethod
|
||||
def from_username(username: str) -> list['Config']:
|
||||
raw_uri = Config.__get_user_configs_uri(username)
|
||||
if not raw_uri:
|
||||
return []
|
||||
|
||||
res = []
|
||||
for line in raw_uri.splitlines():
|
||||
config = Config.__parse_user_configs_uri_line(line)
|
||||
if config:
|
||||
res.append(config)
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def __get_user_configs_uri(username: str) -> str:
|
||||
# This command is equivalent to `show-user-uri --username $username --ipv 4 --all --singbox --normalsub`
|
||||
raw_uri = cli_api.show_user_uri(username, False, 4, True, True, True)
|
||||
|
||||
return raw_uri.strip() if raw_uri else ''
|
||||
|
||||
@staticmethod
|
||||
def __parse_user_configs_uri_line(line: str) -> "Config | None":
|
||||
config_type = ''
|
||||
config_link = ''
|
||||
|
||||
line = line.strip()
|
||||
if line.startswith("hy2://"):
|
||||
if "@" in line:
|
||||
ip_version = "IPv6" if line.split("@")[1].count(":") > 1 else "IPv4"
|
||||
config_type = ip_version
|
||||
config_link = line
|
||||
else:
|
||||
return None
|
||||
elif line.startswith("https://"):
|
||||
if "singbox" in line.lower():
|
||||
config_type = "Singbox"
|
||||
elif "normal" in line.lower():
|
||||
config_type = "Normal-SUB"
|
||||
else:
|
||||
return None
|
||||
config_link = line
|
||||
else:
|
||||
return None
|
||||
|
||||
return Config(type=config_type, link=config_link)
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
username: str
|
||||
status: str
|
||||
quota: str
|
||||
traffic_used: str
|
||||
expiry_date: datetime
|
||||
expiry_days: int
|
||||
enable: bool
|
||||
configs: list[Config]
|
||||
|
||||
@staticmethod
|
||||
def from_dict(username: str, user_data: dict):
|
||||
user_data = {'username': username, **user_data}
|
||||
user_data = User.__parse_user_data(user_data)
|
||||
return User(**user_data)
|
||||
|
||||
@staticmethod
|
||||
def __parse_user_data(user_data: dict) -> dict:
|
||||
expiry_date = 'N/A'
|
||||
creation_date_str = user_data.get("account_creation_date")
|
||||
expiration_days = user_data.get('expiration_days', 0)
|
||||
if creation_date_str and expiration_days:
|
||||
try:
|
||||
creation_date = datetime.strptime(creation_date_str, "%Y-%m-%d")
|
||||
expiry_date = creation_date + timedelta(days=expiration_days)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
traffic_used = User.__format_traffic(user_data.get("download_bytes", 0) + user_data.get("upload_bytes", 0))
|
||||
|
||||
return {
|
||||
'username': user_data['username'],
|
||||
'status': user_data.get('status', 'Not Active'),
|
||||
'quota': User.__format_traffic(user_data.get('max_download_bytes', 0)),
|
||||
'traffic_used': traffic_used,
|
||||
'expiry_date': expiry_date,
|
||||
'expiry_days': expiration_days,
|
||||
'enable': False if user_data.get('blocked', False) else True,
|
||||
'configs': Config.from_username(user_data['username'])
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def __format_traffic(traffic_bytes) -> str:
|
||||
if traffic_bytes < 1024:
|
||||
return f'{traffic_bytes} B'
|
||||
elif traffic_bytes < 1024**2:
|
||||
return f'{traffic_bytes / 1024:.2f} KB'
|
||||
elif traffic_bytes < 1024**3:
|
||||
return f'{traffic_bytes / 1024**2:.2f} MB'
|
||||
else:
|
||||
return f'{traffic_bytes / 1024**3:.2f} GB'
|
||||
1
core/scripts/webpanel/session/__init__.py
Normal file
1
core/scripts/webpanel/session/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .session import SessionData, SessionStorage, SessionManager
|
||||
55
core/scripts/webpanel/session/session.py
Normal file
55
core/scripts/webpanel/session/session.py
Normal file
@ -0,0 +1,55 @@
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SessionData(BaseModel):
|
||||
'''Pydantic model for representing session data.'''
|
||||
username: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class SessionStorage:
|
||||
'''Abstracts session storage (default: in-memory, can be replaced with Redis).'''
|
||||
|
||||
def __init__(self):
|
||||
# The sessions are now stored as a dictionary with SessionData as values
|
||||
self.sessions: dict[str, SessionData] = {}
|
||||
|
||||
def set(self, session_id: str, data: SessionData):
|
||||
'''Store the session data with the session_id.'''
|
||||
self.sessions[session_id] = data
|
||||
|
||||
def get(self, session_id: str) -> SessionData | None:
|
||||
'''Retrieve session data by session_id.'''
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def delete(self, session_id: str):
|
||||
'''Delete a session from storage.'''
|
||||
self.sessions.pop(session_id, None)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
'''Manages user authentication with session storage.'''
|
||||
|
||||
def __init__(self, storage: SessionStorage, expiration_minutes: int = 60):
|
||||
self.storage = storage
|
||||
self.expiration = timedelta(minutes=expiration_minutes)
|
||||
|
||||
def set_session(self, username: str) -> str:
|
||||
'''Generates a session ID and stores user data in the session.'''
|
||||
session_id = secrets.token_hex(32)
|
||||
session_data = SessionData(username=username, created_at=datetime.now(timezone.utc), expires_at=datetime.now(timezone.utc) + self.expiration)
|
||||
|
||||
self.storage.set(session_id, session_data)
|
||||
|
||||
return session_id
|
||||
|
||||
def get_session(self, session_id: str) -> SessionData | None:
|
||||
'''Retrieves and validates a session. Raises HTTPException if the session is invalid or expired.'''
|
||||
return self.storage.get(session_id)
|
||||
|
||||
def revoke_session(self, session_id: str):
|
||||
'''Removes session from storage.'''
|
||||
self.storage.delete(session_id)
|
||||
148
core/scripts/webpanel/templates/base.html
Normal file
148
core/scripts/webpanel/templates/base.html
Normal file
@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="icon" href="{{ url_for('assets', path='img/favicon.ico') }}">
|
||||
<title>Admin Panel | {% block title %}{% endblock %}</title>
|
||||
|
||||
<!-- Google Font: Source Sans Pro -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,400i,700&display=fallback">
|
||||
<!-- Font Awesome Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
|
||||
<!-- Theme style -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/admin-lte/3.2.0/css/adminlte.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/icheck-bootstrap@3.0.1/icheck-bootstrap.min.css">
|
||||
{% block stylesheets %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body class="hold-transition sidebar-mini">
|
||||
<div class="wrapper">
|
||||
|
||||
<!-- Navbar -->
|
||||
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Right navbar links -->
|
||||
<ul class="navbar-nav ml-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="darkModeToggle" href="#">
|
||||
<i id="darkModeIcon" class="fas fa-moon"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('logout') }}">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Main Sidebar Container -->
|
||||
<aside class="main-sidebar sidebar-dark-primary elevation-4">
|
||||
<!-- Brand Logo -->
|
||||
<a href="{{ url_for('index') }}" class="brand-link">
|
||||
<span class="brand-text font-weight-light">Hysteria2 Dashboard</span>
|
||||
</a>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar">
|
||||
<!-- Sidebar Menu -->
|
||||
<nav class="mt-2">
|
||||
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('index') }}" class="nav-link {% if request.path == url_for('index') %}active{% endif %}">
|
||||
<i class="nav-icon fas fa-tachometer-alt"></i>
|
||||
<p>Dashboard</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('users') }}" class="nav-link {% if request.path == url_for('users') %}active{% endif %}">
|
||||
<i class="nav-icon fas fa-users"></i>
|
||||
<p>Users</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('settings') }}" class="nav-link {% if request.path == url_for('settings') %}active{% endif %}">
|
||||
<i class="nav-icon fas fa-tools"></i>
|
||||
<p>Settings</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('config') }}" class="nav-link {% if request.path == url_for('config') %}active{% endif %}">
|
||||
<i class="nav-icon fas fa-cog"></i>
|
||||
<p>Config</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content Wrapper -->
|
||||
<div class="content-wrapper">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="main-footer">
|
||||
<strong>Copyright © 2023 <a href="https://github.com/ReturnFI/Hysteria2">Return-Hysteria2</a>.</strong>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- REQUIRED SCRIPTS -->
|
||||
<!-- jQuery -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
|
||||
<!-- Bootstrap 4 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- AdminLTE App -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/admin-lte/3.2.0/js/adminlte.min.js"></script>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
// Dark Mode Toggle
|
||||
const darkModeToggle = $("#darkModeToggle");
|
||||
const darkModeIcon = $("#darkModeIcon");
|
||||
|
||||
// Check for saved preference in localStorage
|
||||
const isDarkMode = localStorage.getItem("darkMode") === "enabled";
|
||||
setDarkMode(isDarkMode);
|
||||
updateIcon(isDarkMode);
|
||||
|
||||
// Toggle dark mode on click
|
||||
darkModeToggle.on("click", function (e) {
|
||||
e.preventDefault();
|
||||
const enabled = $("body").hasClass("dark-mode");
|
||||
localStorage.setItem("darkMode", enabled ? "disabled" : "enabled");
|
||||
setDarkMode(!enabled);
|
||||
updateIcon(!enabled);
|
||||
});
|
||||
|
||||
// Function to set dark mode styles
|
||||
function setDarkMode(enabled) {
|
||||
if (enabled) {
|
||||
$("body").addClass("dark-mode");
|
||||
} else {
|
||||
$("body").removeClass("dark-mode");
|
||||
}
|
||||
}
|
||||
|
||||
// Function to update the icon
|
||||
function updateIcon(enabled) {
|
||||
if (enabled) {
|
||||
darkModeIcon.removeClass("fa-moon").addClass("fa-sun");
|
||||
} else {
|
||||
darkModeIcon.removeClass("fa-sun").addClass("fa-moon");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% block javascripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
112
core/scripts/webpanel/templates/config.html
Normal file
112
core/scripts/webpanel/templates/config.html
Normal file
@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Config Editor{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<br>
|
||||
<div class="" style="margin-left: 20px; margin-right: 20px; margin-bottom: 20px;">
|
||||
<div class="flex justify mb-4 space-x-2">
|
||||
<button id="restore-button" onclick="restoreJson()" class="px-4 py-2 bg-blue text-black rounded-lg">Restore JSON</button>
|
||||
<button id="save-button" onclick="saveJson()" class="px-4 py-2 bg-green text-black rounded-lg duration-200">Save JSON</button>
|
||||
</div>
|
||||
|
||||
<div id="jsoneditor" class="border rounded-lg shadow-lg" style="height: 750px; width: 100%;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/jsoneditor@9.1.0/dist/jsoneditor.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsoneditor@9.1.0/dist/jsoneditor.min.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
<script>
|
||||
const saveButton = document.getElementById("save-button");
|
||||
const container = document.getElementById("jsoneditor");
|
||||
|
||||
const editor = new JSONEditor(container, {
|
||||
mode: "code",
|
||||
modes: ["code", "tree"],
|
||||
onChange: validateJson
|
||||
});
|
||||
|
||||
// Function to validate the JSON and update UI elements
|
||||
function validateJson() {
|
||||
try {
|
||||
editor.get(); // Validate the JSON
|
||||
updateSaveButton(true);
|
||||
hideErrorMessage();
|
||||
} catch (error) {
|
||||
updateSaveButton(false);
|
||||
showErrorMessage("Invalid JSON! Please correct the errors.");
|
||||
}
|
||||
}
|
||||
|
||||
// Function to update save button based on JSON validity
|
||||
function updateSaveButton(isValid) {
|
||||
saveButton.disabled = !isValid;
|
||||
saveButton.style.cursor = isValid ? "pointer" : "not-allowed";
|
||||
|
||||
saveButton.style.setProperty('background-color', isValid ? "#28a745" : "#ccc", 'important');
|
||||
saveButton.style.setProperty('color', isValid ? "#fff" : "#666", 'important');
|
||||
//saveButton.style.setProperty('border-color', isValid ? "#28a745" : "#ccc", 'important');
|
||||
}
|
||||
|
||||
// Function to show error message with SweetAlert2
|
||||
function showErrorMessage(message) {
|
||||
Swal.fire({
|
||||
title: "Error",
|
||||
text: message,
|
||||
icon: "error",
|
||||
showConfirmButton: false,
|
||||
timer: 5000,
|
||||
position: 'top-right',
|
||||
toast: true,
|
||||
showClass: { popup: 'animate__animated animate__fadeInDown' },
|
||||
hideClass: { popup: 'animate__animated animate__fadeOutUp' }
|
||||
});
|
||||
}
|
||||
|
||||
// Function to hide error message if JSON is fixed
|
||||
function hideErrorMessage() {
|
||||
Swal.close();
|
||||
}
|
||||
|
||||
// Function to ask the user before saving and save the JSON if confirmed
|
||||
function saveJson() {
|
||||
Swal.fire({
|
||||
title: 'Are you sure?',
|
||||
text: 'Do you want to save the changes?',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, save it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
reverseButtons: true
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
fetch("{{ url_for('set_file') }}", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(editor.get())
|
||||
})
|
||||
.then(() => {
|
||||
Swal.fire('Saved!', 'Your changes have been saved.', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
Swal.fire('Error!', 'There was an error saving your data.', 'error');
|
||||
console.error("Error saving JSON:", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function to load JSON from the server
|
||||
function restoreJson() {
|
||||
fetch("{{ url_for('get_file') }}")
|
||||
.then(response => response.json())
|
||||
.then(json => editor.set(json))
|
||||
.catch(error => console.error("Error loading JSON:", error));
|
||||
}
|
||||
|
||||
restoreJson(); // Initial JSON load
|
||||
</script>
|
||||
{% endblock %}
|
||||
171
core/scripts/webpanel/templates/index.html
Normal file
171
core/scripts/webpanel/templates/index.html
Normal file
@ -0,0 +1,171 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content-header">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-6">
|
||||
<h1 class="m-0">Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-lg-3 col-6">
|
||||
<!-- small box -->
|
||||
<div class="small-box bg-info">
|
||||
<div class="inner">
|
||||
<h3 id="cpu-usage">--<sup style="font-size: 20px">%</sup></h3>
|
||||
<p>CPU Usage</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<i class="fas fa-microchip"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-6">
|
||||
<!-- small box -->
|
||||
<div class="small-box bg-warning">
|
||||
<div class="inner">
|
||||
<h3 id="ram-usage">--</h3>
|
||||
<p>RAM Usage</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<i class="fas fa-memory"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-6">
|
||||
<!-- small box -->
|
||||
<div class="small-box bg-secondary">
|
||||
<div class="inner">
|
||||
<h3 id="total-traffic">--</h3>
|
||||
<p>Total Traffic</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<i class="fas fa-network-wired"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-6">
|
||||
<!-- small box -->
|
||||
<div class="small-box bg-success">
|
||||
<div class="inner">
|
||||
<h3 id="online-users">--</h3>
|
||||
<p>Online Users</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-3 col-6">
|
||||
<div class="small-box" id="hysteria2-status-box">
|
||||
<div class="inner">
|
||||
<h3 id="hysteria2-status">--</h3>
|
||||
<p>Hysteria2</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<i class="fas fa-bolt"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-6">
|
||||
<div class="small-box" id="telegrambot-status-box">
|
||||
<div class="inner">
|
||||
<h3 id="telegrambot-status">--</h3>
|
||||
<p>Telegram Bot</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<i class="fab fa-telegram"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-6">
|
||||
<div class="small-box" id="singbox-status-box">
|
||||
<div class="inner">
|
||||
<h3 id="singbox-status">--</h3>
|
||||
<p>Singbox</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<i class="fas fa-box"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-6">
|
||||
<div class="small-box" id="normalsub-status-box">
|
||||
<div class="inner">
|
||||
<h3 id="normalsub-status">--</h3>
|
||||
<p>Normalsub</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<i class="fas fa-rss"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function updateServerInfo() {
|
||||
fetch('{{ url_for("server_status_api") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('cpu-usage').textContent = data.cpu_usage;
|
||||
document.getElementById('ram-usage').textContent = data.ram_usage;
|
||||
document.getElementById('online-users').textContent = data.online_users;
|
||||
document.getElementById('total-traffic').textContent = data.total_traffic;
|
||||
})
|
||||
.catch(error => console.error('Error fetching server info:', error));
|
||||
}
|
||||
|
||||
|
||||
function updateServiceStatuses() {
|
||||
// Add services api in fetch
|
||||
fetch('{{ url_for("server_services_status_api") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
|
||||
updateServiceBox('hysteria2', data.hysteria_server);
|
||||
updateServiceBox('telegrambot', data.hysteria_telegram_bot);
|
||||
updateServiceBox('singbox', data.hysteria_singbox);
|
||||
updateServiceBox('normalsub', data.hysteria_normal_sub);
|
||||
})
|
||||
.catch(error => console.error('Error fetching service statuses:', error));
|
||||
}
|
||||
|
||||
function updateServiceBox(serviceName, status) {
|
||||
const statusElement = document.getElementById(serviceName + '-status');
|
||||
const statusBox = document.getElementById(serviceName + '-status-box');
|
||||
|
||||
if (status === true) {
|
||||
statusElement.textContent = 'Active';
|
||||
statusBox.classList.remove('bg-danger');
|
||||
statusBox.classList.add('bg-success'); // Add green
|
||||
} else {
|
||||
statusElement.textContent = 'Inactive';
|
||||
statusBox.classList.remove('bg-success');
|
||||
statusBox.classList.add('bg-danger'); // Add red
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
updateServerInfo();
|
||||
updateServiceStatuses();
|
||||
|
||||
setInterval(updateServerInfo, 5000);
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
71
core/scripts/webpanel/templates/login.html
Normal file
71
core/scripts/webpanel/templates/login.html
Normal file
@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin Dashboard - Login</title>
|
||||
|
||||
<!-- Google Font: Source Sans Pro -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,400i,700&display=fallback">
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
|
||||
<!-- icheck bootstrap -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/icheck-bootstrap/3.0.1/icheck-bootstrap.min.css">
|
||||
<!-- Theme style -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/admin-lte/3.2.0/css/adminlte.min.css">
|
||||
</head>
|
||||
|
||||
<body class="hold-transition login-page">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<a href="#"><b>Admin</b> Dashboard</a>
|
||||
</div>
|
||||
<!-- /.login-logo -->
|
||||
<div class="card">
|
||||
<div class="card-body login-card-body">
|
||||
<p class="login-box-msg">Sign in to start your session</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('login') }}" method="post">
|
||||
<input type="hidden" name="next_url" value="{{ request.query_params.get('next_url', '/') }}">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" name="username" class="form-control" placeholder="Username" required>
|
||||
<div class="input-group-append">
|
||||
<div class="input-group-text">
|
||||
<span class="fas fa-user"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<input type="password" name="password" class="form-control" placeholder="Password" required>
|
||||
<div class="input-group-append">
|
||||
<div class="input-group-text">
|
||||
<span class="fas fa-lock"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- /.login-card-body -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.login-box -->
|
||||
|
||||
<!-- jQuery -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
|
||||
<!-- Bootstrap 4 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- AdminLTE App -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/admin-lte/3.2.0/js/adminlte.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
366
core/scripts/webpanel/templates/settings.html
Normal file
366
core/scripts/webpanel/templates/settings.html
Normal file
@ -0,0 +1,366 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class='content-header'>
|
||||
<div class='container-fluid'>
|
||||
<div class='row mb-2'>
|
||||
<div class='col-sm-6'>
|
||||
<h1 class='m-0'>Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='content'>
|
||||
<div class='container-fluid'>
|
||||
<div class='row'>
|
||||
<div class='col-lg-12'>
|
||||
<div class='card card-primary card-outline card-tabs'>
|
||||
<div class='card-header p-0 pt-1 border-bottom-0'>
|
||||
<ul class='nav nav-pills' id='custom-tabs-three-tab' role='tablist' style="margin-left: 20px; margin-top: 10px;">
|
||||
<li class='nav-item'>
|
||||
<a class='nav-link active' id='subs-tab' data-toggle='pill' href='#subs' role='tab' aria-controls='subs' aria-selected='false'>Subscriptions</a>
|
||||
</li>
|
||||
<li class='nav-item'>
|
||||
<a class='nav-link' id='telegram-tab' data-toggle='pill' href='#telegram' role='tab' aria-controls='telegram' aria-selected='true'>Telegram
|
||||
Bot</a>
|
||||
</li>
|
||||
<li class='nav-item'>
|
||||
<a class='nav-link' id='sni-tab' data-toggle='pill' href='#sni' role='tab' aria-controls='sni' aria-selected='false'>Change SNI</a>
|
||||
</li>
|
||||
<li class='nav-item'>
|
||||
<a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab' aria-controls='change_ip' aria-selected='false'>Change IP</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='card-body' style="margin-left: 25px;">
|
||||
<div class='tab-content' id='custom-tabs-three-tabContent'>
|
||||
|
||||
<!-- Subscriptions Tab -->
|
||||
<div class='tab-pane fade show active' id='subs' role='tabpanel' aria-labelledby='subs-tab'>
|
||||
<ul class='nav nav-tabs' id='subs-tabs' role='tablist'>
|
||||
<li class='nav-item'>
|
||||
<a class='nav-link active' id='singbox-tab' data-toggle='tab' href='#singbox' role='tab' aria-controls='singbox'
|
||||
aria-selected='true'><strong>SingBox</strong></a>
|
||||
</li>
|
||||
<li class='nav-item'>
|
||||
<a class='nav-link' id='normal-tab' data-toggle='tab' href='#normal' role='tab' aria-controls='normal'
|
||||
aria-selected='false'><strong>Normal</strong></a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class='tab-content' id='subs-tabs-content'>
|
||||
<br>
|
||||
<!-- SingBox Sub Tab -->
|
||||
<div class='tab-pane fade show active' id='singbox' role='tabpanel' aria-labelledby='singbox-tab'>
|
||||
<form>
|
||||
<div class='form-group'>
|
||||
<label for='singbox_domain'>Domain:</label>
|
||||
<input type='text' class='form-control' id='singbox_domain' placeholder='Enter Domain'>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='singbox_port'>Port:</label>
|
||||
<input type='text' class='form-control' id='singbox_port' placeholder='Enter Port'>
|
||||
</div>
|
||||
<button id="singbox_start" type='button' class='btn btn-success'>Start</button>
|
||||
<button id="singbox_stop" type='button' class='btn btn-danger' style="display: none;">Stop</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Normal Sub Tab -->
|
||||
<div class='tab-pane fade' id='normal' role='tabpanel' aria-labelledby='normal-tab'>
|
||||
<form>
|
||||
<div class='form-group'>
|
||||
<label for='normal_domain'>Domain:</label>
|
||||
<input type='text' class='form-control' id='normal_domain' placeholder='Enter Domain'>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='normal_port'>Port:</label>
|
||||
<input type='text' class='form-control' id='normal_port' placeholder='Enter Port'>
|
||||
</div>
|
||||
<button id="normal_start" type='button' class='btn btn-success'>Start</button>
|
||||
<button id="normal_stop" type='button' class='btn btn-danger' style="display: none;">Stop</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Telegram Bot Tab -->
|
||||
<div class='tab-pane fade show' id='telegram' role='tabpanel' aria-labelledby='telegram-tab'>
|
||||
<form>
|
||||
<div class='form-group'>
|
||||
<label for='telegram_api_token'>API Token:</label>
|
||||
<input type='text' class='form-control' id='telegram_api_token' placeholder='Enter API Token'>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='telegram_admin_id'>Admin ID:</label>
|
||||
<input type='text' class='form-control' id='telegram_admin_id' placeholder='Enter Admin ID'>
|
||||
</div>
|
||||
<button id="telegram_start" type='button' class='btn btn-success'>Start</button>
|
||||
<button id="telegram_stop" type='button' class='btn btn-danger' style="display: none;">Stop</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- SNI Tab -->
|
||||
<div class='tab-pane fade' id='sni' role='tabpanel' aria-labelledby='sni-tab'>
|
||||
|
||||
<form>
|
||||
<div class='form-group'>
|
||||
<label for='sni_domain'>Domain:</label>
|
||||
<input type='text' class='form-control' id='sni_domain' placeholder='Enter Domain'>
|
||||
</div>
|
||||
<button id="sni_change" type='button' class='btn btn-primary'>Save</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change IP Tab -->
|
||||
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
|
||||
<form>
|
||||
<div class='form-group'>
|
||||
<label for='ipv4'>IPv4:</label>
|
||||
<input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4' value="{{ ipv4 or '' }}">
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='ipv6'>IPv6:</label>
|
||||
<input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6' value="{{ ipv6 or '' }}">
|
||||
</div>
|
||||
<button id="ip_change" type='button' class='btn btn-primary'>Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.card -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script>
|
||||
|
||||
function initUI() {
|
||||
// Fetch service status on page load
|
||||
$.ajax({
|
||||
url: "{{ url_for('server_services_status_api') }}",
|
||||
type: "GET",
|
||||
success: function (data) {
|
||||
updateServiceUI(data);
|
||||
},
|
||||
error: function () {
|
||||
console.error("Failed to fetch service status.");
|
||||
}
|
||||
});
|
||||
|
||||
// Update UI based on service status
|
||||
function updateServiceUI(data) {
|
||||
const servicesMap = {
|
||||
"hysteria_telegram_bot": "#telegram",
|
||||
"hysteria_singbox": "#singbox",
|
||||
"hysteria_normal_sub": "#normal"
|
||||
};
|
||||
|
||||
Object.keys(servicesMap).forEach(service => {
|
||||
let selector = servicesMap[service];
|
||||
let isRunning = data[service];
|
||||
|
||||
if (isRunning) {
|
||||
$(selector + " input, " + selector + " label").remove();
|
||||
$(selector + " .btn-success").remove();
|
||||
$(selector).prepend(`<div class='alert alert-info'>Service is running. You can stop it if needed.</div>`);
|
||||
$(selector + " .btn-danger").show();
|
||||
} else {
|
||||
$(selector + " input, " + selector + " label").show();
|
||||
$(selector + " .btn-success").show();
|
||||
$(selector + " .btn-danger").hide();
|
||||
$(selector + " .alert-info").remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
$(document).ready(function () {
|
||||
// Init UI
|
||||
initUI();
|
||||
|
||||
// Function to show confirmation before executing API calls
|
||||
function confirmAction(actionName, callback) {
|
||||
Swal.fire({
|
||||
title: `Are you sure?`,
|
||||
text: `Do you really want to ${actionName}?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#3085d6",
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonText: "Yes, proceed!",
|
||||
cancelButtonText: "Cancel"
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function to handle AJAX requests
|
||||
function sendRequest(url, type, data, successMessage) {
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: type,
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(data),
|
||||
success: function (response) {
|
||||
Swal.fire("Success!", successMessage, "success").then(() => {
|
||||
location.reload();
|
||||
});
|
||||
console.log(response);
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
Swal.fire("Error!", "Something went wrong. Check the console for details.", "error");
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Telegram Bot Start
|
||||
function startTelegram() {
|
||||
const apiToken = $("#telegram_api_token").val();
|
||||
const adminId = $("#telegram_admin_id").val();
|
||||
confirmAction("start the Telegram bot", function () {
|
||||
sendRequest(
|
||||
"{{ url_for('telegram_start_api') }}",
|
||||
"POST",
|
||||
{ token: apiToken, admin_id: adminId },
|
||||
"Telegram bot started successfully!"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Telegram Bot Stop
|
||||
function stopTelegram() {
|
||||
confirmAction("stop the Telegram bot", function () {
|
||||
sendRequest(
|
||||
"{{ url_for('telegram_stop_api') }}",
|
||||
"DELETE",
|
||||
null,
|
||||
"Telegram bot stopped successfully!"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// SingBox Start
|
||||
function startSingbox() {
|
||||
const domain = $("#singbox_domain").val();
|
||||
const port = $("#singbox_port").val();
|
||||
confirmAction("start SingBox", function () {
|
||||
sendRequest(
|
||||
"{{ url_for('singbox_start_api') }}",
|
||||
"POST",
|
||||
{ domain: domain, port: port },
|
||||
"SingBox started successfully!"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// SingBox Stop
|
||||
function stopSingbox() {
|
||||
confirmAction("stop SingBox", function () {
|
||||
sendRequest(
|
||||
"{{ url_for('singbox_stop_api') }}",
|
||||
"DELETE",
|
||||
null,
|
||||
"SingBox stopped successfully!"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Normal Subscription Start
|
||||
function startNormal() {
|
||||
const domain = $("#normal_domain").val();
|
||||
const port = $("#normal_port").val();
|
||||
confirmAction("start the normal subscription", function () {
|
||||
sendRequest(
|
||||
"{{ url_for('normal_sub_start_api') }}",
|
||||
"POST",
|
||||
{ domain: domain, port: port },
|
||||
"Normal subscription started successfully!"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Normal Subscription Stop
|
||||
function stopNormal() {
|
||||
confirmAction("stop the normal subscription", function () {
|
||||
sendRequest(
|
||||
"{{ url_for('normal_sub_stop_api') }}",
|
||||
"DELETE",
|
||||
null,
|
||||
"Normal subscription stopped successfully!"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Change SNI
|
||||
function changeSNI() {
|
||||
const domain = $("#sni_domain").val();
|
||||
const baseUrl = "{{ url_for('set_sni', sni='SNI_PLACEHOLDER') }}";
|
||||
const url = baseUrl.replace("SNI_PLACEHOLDER", domain);
|
||||
confirmAction("change the SNI", function () {
|
||||
sendRequest(url, "GET", null, "SNI changed successfully!");
|
||||
});
|
||||
}
|
||||
|
||||
// Save IP
|
||||
function saveIP() {
|
||||
confirmAction("save the new IP", function () {
|
||||
sendRequest(
|
||||
"{{ url_for('edit_ip_api') }}",
|
||||
"POST",
|
||||
{ ipv4: $("#ipv4").val(), ipv6: $("#ipv6").val() },
|
||||
"New IP saved successfully!"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach event listeners
|
||||
$("#telegram_start").on("click", startTelegram);
|
||||
$("#telegram_stop").on("click", stopTelegram);
|
||||
$("#singbox_start").on("click", startSingbox);
|
||||
$("#singbox_stop").on("click", stopSingbox);
|
||||
$("#normal_start").on("click", startNormal);
|
||||
$("#normal_stop").on("click", stopNormal);
|
||||
$("#sni_change").on("click", changeSNI);
|
||||
$("#ip_change").on("click", saveIP);
|
||||
|
||||
$.ajax({
|
||||
url: "{{ url_for('get_ip_api') }}",
|
||||
type: "GET",
|
||||
success: function (data) {
|
||||
$("#ipv4").val(data.ipv4 || "");
|
||||
$("#ipv6").val(data.ipv6 || "");
|
||||
|
||||
$("#ipv4").attr("placeholder", "Enter IPv4");
|
||||
$("#ipv6").attr("placeholder", "Enter IPv6");
|
||||
|
||||
|
||||
},
|
||||
error: function () {
|
||||
console.error("Failed to fetch IP addresses.");
|
||||
$("#ipv4").attr("placeholder", "Enter IPv4");
|
||||
$("#ipv6").attr("placeholder", "Enter IPv6");
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
582
core/scripts/webpanel/templates/users.html
Normal file
582
core/scripts/webpanel/templates/users.html
Normal file
@ -0,0 +1,582 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Users{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content-header">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-6">
|
||||
<h1 class="m-0">Users</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">User List</h3>
|
||||
<div class="card-tools d-flex align-items-center">
|
||||
<!-- Search Form -->
|
||||
<div class="input-group input-group-sm" style="width: 100px;">
|
||||
<input type="text" id="searchInput" class="form-control float-right" placeholder="Search">
|
||||
<div class="input-group-append">
|
||||
<button type="submit" class="btn btn-default" id="searchButton">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add User Button -->
|
||||
<button type="button" class="btn btn-primary ml-2" data-toggle="modal" data-target="#addUserModal">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
{% if users|length == 0 %}
|
||||
<div class="alert alert-warning" role="alert" style="margin: 20px;">
|
||||
No users found.
|
||||
</div>
|
||||
{% else %}
|
||||
<table class="table table-bordered table-hover" id="userTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Username</th>
|
||||
<th>Quota</th>
|
||||
<th>Used</th>
|
||||
<th class="text-nowrap">Expiry Date</th>
|
||||
<th class="text-nowrap">Expiry Days</th>
|
||||
<th>Enable</th>
|
||||
<th class="text-nowrap">Configs</th>
|
||||
<th class="text-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if user['status'] == "Online" %}
|
||||
<i class="fas fa-circle text-success"></i> Online
|
||||
{% elif user['status'] == "Offline" %}
|
||||
<i class="fas fa-circle text-secondary"></i> Offline
|
||||
{% else %}
|
||||
<i class="fas fa-circle text-danger"></i> {{ user['status'] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-username="{{ user.username }}">{{ user.username }}</td>
|
||||
<td>{{ user.quota }}</td>
|
||||
<td>{{ user.traffic_used }}</td>
|
||||
<td>{{ user.expiry_date }}</td>
|
||||
<td>{{ user.expiry_days }}</td>
|
||||
<td>
|
||||
{% if user.enable %}
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-times-circle text-danger"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
<a href="#" class="config-link" data-toggle="modal" data-target="#qrcodeModal" data-username="{{ user.username }}">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
</a>
|
||||
<div id="userConfigs-{{ user.username }}" style="display: none;">
|
||||
{% for config in user.configs %}
|
||||
<div class="config-container" data-link="{{ config.link }}">
|
||||
<span class="config-type">{{ config.type }}:</span>
|
||||
{% if config.type == "Singbox" or config.type == "Normal-SUB" %}
|
||||
<span class="config-link-text">{{ config.link }}</span>
|
||||
{% else %}
|
||||
<span class="config-link-text">{{ config.link }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
<button type="button" class="btn btn-sm btn-info edit-user" data-user='{{ user.username }}' data-toggle="modal" data-target="#editUserModal">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-warning reset-user" data-user='{{ user.username }}'>
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger delete-user" data-user='{{ user.username }}'>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<div class="row mt-3">
|
||||
<div class="col-sm-12 col-md-7">
|
||||
<div class="dataTables_paginate paging_simple_numbers" id="userTable_paginate">
|
||||
{# {{ pagination.links }} #}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Add User Modal -->
|
||||
<div class="modal fade" id="addUserModal" tabindex="-1" role="dialog" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addUserModalLabel">Add User</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="addUserForm">
|
||||
<div class="form-group">
|
||||
<label for="addUsername">Username</label>
|
||||
<input type="text" class="form-control" id="addUsername" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="addTrafficLimit">Traffic Limit (GB)</label>
|
||||
<input type="number" class="form-control" id="addTrafficLimit" name="traffic_limit" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="addExpirationDays">Expiration Days</label>
|
||||
<input type="number" class="form-control" id="addExpirationDays" name="expiration_days" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit User Modal -->
|
||||
<div class="modal fade" id="editUserModal" tabindex="-1" role="dialog" aria-labelledby="editUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editUserModalLabel">Edit User</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editUserForm">
|
||||
<div class="form-group">
|
||||
<label for="editUsername">Username</label>
|
||||
<input type="text" class="form-control" id="editUsername" name="new_username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editTrafficLimit">Traffic Limit (GB)</label>
|
||||
<input type="number" class="form-control" id="editTrafficLimit" name="new_traffic_limit">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editExpirationDays">Expiration Days</label>
|
||||
<input type="number" class="form-control" id="editExpirationDays" name="new_expiration_days">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="editBlocked" name="blocked" value="true">
|
||||
<label class="form-check-label" for="editBlocked">Blocked</label>
|
||||
</div>
|
||||
<input type="hidden" id="originalUsername" name="username">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- QR Code Modal -->
|
||||
<div class="modal fade" id="qrcodeModal" tabindex="-1" role="dialog" aria-labelledby="qrcodeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="qrcodeModalLabel">QR Codes</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<div id="qrcodesContainer" class="mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<!-- Include qr-code-styling library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/qr-code-styling/lib/qr-code-styling.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
// Add User Form Submit
|
||||
$("#addUserForm").on("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = $(this).serializeArray();
|
||||
const jsonData = {};
|
||||
formData.forEach(field => {
|
||||
jsonData[field.name] = field.value;
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: " {{ url_for('add_user_api') }} ",
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(jsonData),
|
||||
success: function (response) {
|
||||
if (response.detail) {
|
||||
Swal.fire({
|
||||
title: "Success!",
|
||||
text: response.detail,
|
||||
icon: "success",
|
||||
confirmButtonText: "OK",
|
||||
}).then(() => {
|
||||
location.reload();
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: response.error || "Failed to add user",
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: "An error occurred while adding user",
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Edit User Form Populate and Submit
|
||||
$(document).on("click", ".edit-user", function () {
|
||||
const username = $(this).data("user");
|
||||
const row = $(this).closest("tr");
|
||||
const quota = row.find("td:eq(2)").text().trim();
|
||||
const expiry = row.find("td:eq(4)").text().trim(); // Get expiry from the table
|
||||
const expiry_days = row.find("td:eq(5)").text().trim();
|
||||
const blocked = row.find("td:eq(6)").text().trim().toLowerCase() === 'disabled'; // Check if 'disabled'
|
||||
|
||||
// Extract numeric values from quota and expiry strings
|
||||
const quotaValue = parseFloat(quota);
|
||||
const expiryValue = parseInt(expiry); // Parse expiry as integer
|
||||
|
||||
// Populate the modal fields
|
||||
$("#originalUsername").val(username);
|
||||
$("#editUsername").val(username);
|
||||
$("#editTrafficLimit").val(quotaValue);
|
||||
$("#editExpirationDays").val(expiry_days);
|
||||
$("#editBlocked").prop("checked", blocked);
|
||||
});
|
||||
|
||||
$("#editUserForm").on("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = $(this).serializeArray();
|
||||
const jsonData = {};
|
||||
formData.forEach(field => {
|
||||
jsonData[field.name] = field.value;
|
||||
});
|
||||
|
||||
const editUserUrl = "{{ url_for('edit_user_api', username='USERNAME_PLACEHOLDER') }}";
|
||||
const url = editUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent($("#originalUsername").val()));
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: "PATCH",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(jsonData),
|
||||
success: function (response) {
|
||||
if (typeof response === 'string' && response.includes("User updated successfully")) {
|
||||
const username = $("#originalUsername").val();
|
||||
const row = $(`td[data-username='${username}']`).closest("tr");
|
||||
row.find("td:eq(1)").text($("#editUsername").val());
|
||||
row.find("td:eq(2)").text($("#editTrafficLimit").val() + " GB");
|
||||
row.find("td:eq(5)").text($("#editExpirationDays").val());
|
||||
row.find("td:eq(6) i")
|
||||
.removeClass()
|
||||
.addClass(
|
||||
$("#editBlocked").prop("checked")
|
||||
? "fas fa-times-circle text-danger"
|
||||
: "fas fa-check-circle text-success"
|
||||
);
|
||||
// Hide the modal
|
||||
$("#editUserModal").modal("hide");
|
||||
Swal.fire({
|
||||
title: "Success!",
|
||||
text: "User updated successfully!",
|
||||
icon: "success",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
else if (response && response.detail) {
|
||||
const username = $("#originalUsername").val();
|
||||
const row = $(`td[data-username='${username}']`).closest("tr");
|
||||
row.find("td:eq(1)").text($("#editUsername").val());
|
||||
row.find("td:eq(2)").text($("#editTrafficLimit").val() + " GB");
|
||||
row.find("td:eq(5)").text($("#editExpirationDays").val());
|
||||
row.find("td:eq(6) i")
|
||||
.removeClass()
|
||||
.addClass(
|
||||
$("#editBlocked").prop("checked")
|
||||
? "fas fa-times-circle text-danger"
|
||||
: "fas fa-check-circle text-success"
|
||||
);
|
||||
// Hide the modal
|
||||
$("#editUserModal").modal("hide");
|
||||
|
||||
// Show a success message
|
||||
Swal.fire({
|
||||
title: "Success!",
|
||||
text: response.detail,
|
||||
icon: "success",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
} else {
|
||||
$("#editUserModal").modal("hide");
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: response.error || "An error occurred.",
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function (error) {
|
||||
console.error(error);
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: "An error occurred while updating user",
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent click event on the submit button from triggering form submission
|
||||
$("#editUserForm button[type='submit']").on("click", function (e) {
|
||||
e.preventDefault();
|
||||
$(this).closest("form").submit();
|
||||
});
|
||||
|
||||
// Reset User Button Click
|
||||
$("#userTable").on("click", ".reset-user", function () {
|
||||
const username = $(this).data("user");
|
||||
|
||||
Swal.fire({
|
||||
title: "Are you sure?",
|
||||
html: `This will reset <b>${username}</b>'s data.<br>This action cannot be undone!`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#3085d6",
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonText: "Yes, reset it!",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
const resetUserUrl = "{{ url_for('reset_user_api', username='USERNAME_PLACEHOLDER') }}";
|
||||
const url = resetUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username));
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: "GET",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({ username: username }),
|
||||
success: function (response) {
|
||||
if (response.detail) {
|
||||
Swal.fire({
|
||||
title: "Success!",
|
||||
text: response.detail,
|
||||
icon: "success",
|
||||
confirmButtonText: "OK",
|
||||
}).then(() => {
|
||||
location.reload();
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: response.error || "Failed to reset user",
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: "An error occurred while resetting user",
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete User Button Click
|
||||
$("#userTable").on("click", ".delete-user", function () {
|
||||
const username = $(this).data("user");
|
||||
|
||||
Swal.fire({
|
||||
title: "Are you sure?",
|
||||
html: `This will delete the user <b>${username}</b>.<br>This action cannot be undone!`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#3085d6",
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonText: "Yes, delete it!",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
const removeUserUrl = "{{ url_for('remove_user_api', username='USERNAME_PLACEHOLDER') }}";
|
||||
const url = removeUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username));
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: "DELETE",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({ username: username }),
|
||||
success: function (response) {
|
||||
if (response.detail) {
|
||||
Swal.fire({
|
||||
title: "Success!",
|
||||
text: response.detail,
|
||||
icon: "success",
|
||||
confirmButtonText: "OK",
|
||||
}).then(() => {
|
||||
location.reload();
|
||||
});
|
||||
} else {
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: response.error || "Failed to delete user",
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: "An error occurred while deleting user",
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// QR Code Modal
|
||||
$("#qrcodeModal").on("show.bs.modal", function (event) {
|
||||
const button = $(event.relatedTarget);
|
||||
const username = button.data("username");
|
||||
const configContainer = $(`#userConfigs-${username}`);
|
||||
const qrcodesContainer = $("#qrcodesContainer");
|
||||
qrcodesContainer.empty();
|
||||
|
||||
configContainer.find(".config-container").each(function () {
|
||||
const configLink = $(this).data("link");
|
||||
const configType = $(this).find(".config-type").text().replace(":", "");
|
||||
|
||||
// Create a card for each QR code
|
||||
const card = $(`
|
||||
<div class="card d-inline-block mx-2 my-2" style="width: 180px;">
|
||||
<div class="card-body">
|
||||
<div id="qrcode-${configType}" class="mx-auto cursor-pointer"></div>
|
||||
<div class="config-type-text mt-2 text-center">${configType}</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
qrcodesContainer.append(card);
|
||||
|
||||
const qrCodeStyling = new QRCodeStyling({
|
||||
width: 150,
|
||||
height: 150,
|
||||
data: configLink,
|
||||
dotsOptions: {
|
||||
color: "#212121",
|
||||
type: "square"
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
color: "#212121",
|
||||
type: "square"
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: "#FAFAFA",
|
||||
},
|
||||
imageOptions: {
|
||||
hideBackgroundDots: true,
|
||||
}
|
||||
});
|
||||
|
||||
qrCodeStyling.append(document.getElementById(`qrcode-${configType}`));
|
||||
|
||||
// Add click to copy functionality to the card
|
||||
card.on("click", function () {
|
||||
navigator.clipboard.writeText(configLink)
|
||||
.then(() => {
|
||||
Swal.fire({
|
||||
icon: "success",
|
||||
title: configType + " link copied!",
|
||||
showConfirmButton: false,
|
||||
timer: 1500,
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to copy link: ", err);
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: "Failed to copy link",
|
||||
text: "Please copy manually.",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent modal from closing when clicking inside
|
||||
$("#qrcodeModal .modal-content").on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Clear the QR code when the modal is hidden
|
||||
$("#qrcodeModal").on("hidden.bs.modal", function () {
|
||||
$("#qrcodesContainer").empty();
|
||||
});
|
||||
|
||||
$("#qrcodeModal .close").on("click", function () {
|
||||
$("#qrcodeModal").modal("hide");
|
||||
});
|
||||
|
||||
// Search Functionality
|
||||
function filterUsers() {
|
||||
const searchText = $("#searchInput").val().toLowerCase();
|
||||
|
||||
$("#userTable tbody tr").each(function () {
|
||||
const username = $(this).find("td:eq(1)").text().toLowerCase();
|
||||
if (username.includes(searchText)) {
|
||||
$(this).show();
|
||||
} else {
|
||||
$(this).hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$("#searchButton").on("click", filterUsers);
|
||||
$("#searchInput").on("keyup", filterUsers);
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
266
core/scripts/webpanel/webpanel_shell.sh
Normal file
266
core/scripts/webpanel/webpanel_shell.sh
Normal file
@ -0,0 +1,266 @@
|
||||
#!/bin/bash
|
||||
source /etc/hysteria/core/scripts/utils.sh
|
||||
define_colors
|
||||
|
||||
CADDY_CONFIG_FILE="/etc/hysteria/core/scripts/webpanel/Caddyfile"
|
||||
|
||||
install_dependencies() {
|
||||
# Update system
|
||||
sudo apt update -y > /dev/null 2>&1
|
||||
|
||||
# Install dependencies
|
||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl > /dev/null 2>&1
|
||||
|
||||
# Add Caddy repository
|
||||
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key | sudo tee /etc/apt/trusted.gpg.d/caddy.asc > /dev/null 2>&1
|
||||
echo "deb [signed-by=/etc/apt/trusted.gpg.d/caddy.asc] https://dl.cloudsmith.io/public/caddy/stable/deb/ubuntu/ $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null 2>&1
|
||||
|
||||
# Update package index again with Caddy repo
|
||||
sudo apt update -y > /dev/null 2>&1
|
||||
|
||||
apt install libnss3-tools -y > /dev/null 2>&1
|
||||
|
||||
# Install Caddy
|
||||
sudo apt install -y caddy
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Error: Failed to install Caddy. ${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop and disable Caddy service
|
||||
systemctl stop caddy > /dev/null 2>&1
|
||||
systemctl disable caddy > /dev/null 2>&1
|
||||
|
||||
echo -e "${green}Caddy installed successfully. ${NC}"
|
||||
}
|
||||
update_env_file() {
|
||||
local domain=$1
|
||||
local port=$2
|
||||
local admin_username=$3
|
||||
local admin_password=$4
|
||||
local admin_password_hash=$(echo -n "$admin_password" | sha256sum | cut -d' ' -f1) # hash the password
|
||||
local expiration_minutes=$5
|
||||
local debug=$6
|
||||
|
||||
local api_token=$(openssl rand -hex 32)
|
||||
local root_path=$(openssl rand -hex 16)
|
||||
|
||||
cat <<EOL > /etc/hysteria/core/scripts/webpanel/.env
|
||||
DEBUG=$debug
|
||||
DOMAIN=$domain
|
||||
PORT=$port
|
||||
ROOT_PATH=$root_path
|
||||
API_TOKEN=$api_token
|
||||
ADMIN_USERNAME=$admin_username
|
||||
ADMIN_PASSWORD=$admin_password_hash
|
||||
EXPIRATION_MINUTES=$expiration_minutes
|
||||
EOL
|
||||
}
|
||||
|
||||
update_caddy_file() {
|
||||
source /etc/hysteria/core/scripts/webpanel/.env
|
||||
|
||||
# Ensure all required variables are set
|
||||
if [ -z "$DOMAIN" ] || [ -z "$ROOT_PATH" ] || [ -z "$PORT" ]; then
|
||||
echo -e "${red}Error: One or more environment variables are missing.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Update the Caddyfile without the email directive
|
||||
cat <<EOL > "$CADDY_CONFIG_FILE"
|
||||
# Global configuration
|
||||
{
|
||||
# Disable admin panel of the Caddy
|
||||
admin off
|
||||
# Disable automatic HTTP to HTTPS redirects so the Caddy won't listen on port 80 (We need this port for other parts of the project)
|
||||
auto_https disable_redirects
|
||||
}
|
||||
|
||||
# Listen for incoming requests on the specified domain and port
|
||||
$DOMAIN:$PORT {
|
||||
# Define a route to handle all requests starting with ROOT_PATH('/$ROOT_PATH/')
|
||||
route /$ROOT_PATH/* {
|
||||
# We don't strip the ROOT_PATH('/$ROOT_PATH/') from the request
|
||||
# uri strip_prefix /$ROOT_PATH
|
||||
|
||||
# We are proxying all requests under the ROOT_PATH to FastAPI at 127.0.0.1:8080
|
||||
# FastAPI handles these requests because we set the 'root_path' parameter in the FastAPI instance.
|
||||
reverse_proxy http://127.0.0.1:8080
|
||||
}
|
||||
|
||||
# Any request that doesn't start with the ROOT_PATH('/$ROOT_PATH/') will be blocked and no response will be sent to the client
|
||||
@blocked {
|
||||
not path /fd31b4edc70619d5d39edf3c2da97e2c/*
|
||||
}
|
||||
|
||||
# Abort the request, effectively dropping the connection without a response for invalid paths
|
||||
abort @blocked
|
||||
}
|
||||
EOL
|
||||
}
|
||||
|
||||
|
||||
create_webpanel_service_file() {
|
||||
cat <<EOL > /etc/systemd/system/hysteria-webpanel.service
|
||||
[Unit]
|
||||
Description=Hysteria2 Web Panel
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/etc/hysteria/core/scripts/webpanel
|
||||
EnvironmentFile=/etc/hysteria/core/scripts/webpanel/.env
|
||||
ExecStart=/bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && /etc/hysteria/hysteria2_venv/bin/python /etc/hysteria/core/scripts/webpanel/app.py'
|
||||
#Restart=always
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOL
|
||||
}
|
||||
|
||||
create_caddy_service_file() {
|
||||
cat <<EOL > /etc/systemd/system/hysteria-caddy.service
|
||||
[Unit]
|
||||
Description=Hysteria2 Caddy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/etc/caddy
|
||||
ExecStart=/usr/bin/caddy run --environ --config $CADDY_CONFIG_FILE
|
||||
ExecReload=/usr/bin/caddy reload --config $CADDY_CONFIG_FILE --force
|
||||
TimeoutStopSec=5s
|
||||
LimitNOFILE=1048576
|
||||
PrivateTmp=true
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOL
|
||||
}
|
||||
|
||||
start_service() {
|
||||
local domain=$1
|
||||
local port=$2
|
||||
local admin_username=$3
|
||||
local admin_password=$4
|
||||
local expiration_minutes=$5
|
||||
local debug=$6
|
||||
|
||||
# MAYBE I WANT TO CHANGE CONFIGS WITHOUT RESTARTING THE SERVICE MYSELF
|
||||
# # Check if the services are already active
|
||||
# if systemctl is-active --quiet hysteria-webpanel.service && systemctl is-active --quiet hysteria-caddy.service; then
|
||||
# echo -e "${green}Hysteria web panel is already running with Caddy.${NC}"
|
||||
# source /etc/hysteria/core/scripts/webpanel/.env
|
||||
# echo -e "${yellow}The web panel is accessible at: http://$domain:$port/$ROOT_PATH${NC}"
|
||||
# return
|
||||
# fi
|
||||
|
||||
# Install required dependencies
|
||||
install_dependencies
|
||||
|
||||
# Update environment file
|
||||
update_env_file "$domain" "$port" "$admin_username" "$admin_password" "$expiration_minutes" "$debug"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Error: Failed to update the environment file.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Create the web panel service file
|
||||
create_webpanel_service_file
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Error: Failed to create the webpanel service file.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Reload systemd and enable webpanel service
|
||||
systemctl daemon-reload
|
||||
systemctl enable hysteria-webpanel.service > /dev/null 2>&1
|
||||
systemctl start hysteria-webpanel.service > /dev/null 2>&1
|
||||
|
||||
# Check if the web panel is running
|
||||
if systemctl is-active --quiet hysteria-webpanel.service; then
|
||||
echo -e "${green}Hysteria web panel setup completed. The web panel is running locally on: http://127.0.0.1:8080/${NC}"
|
||||
else
|
||||
echo -e "${red}Error: Hysteria web panel service failed to start.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Update Caddy configuration
|
||||
update_caddy_file
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Error: Failed to update the Caddyfile.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
create_caddy_service_file
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Error: Failed to create the Caddy service file.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Restart Caddy service
|
||||
systemctl restart hysteria-caddy.service
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Error: Failed to restart Caddy.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if the web panel is still running after Caddy restart
|
||||
if systemctl is-active --quiet hysteria-webpanel.service; then
|
||||
source /etc/hysteria/core/scripts/webpanel/.env
|
||||
local webpanel_url="http://$domain:$port/$ROOT_PATH/"
|
||||
echo -e "${green}Hysteria web panel is now running. The service is accessible on: $webpanel_url ${NC}"
|
||||
else
|
||||
echo -e "${red}Error: Hysteria web panel failed to start after Caddy restart.${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
show_webpanel_url() {
|
||||
source /etc/hysteria/core/scripts/webpanel/.env
|
||||
local webpanel_url="https://$DOMAIN:$PORT/$ROOT_PATH/"
|
||||
echo "$webpanel_url"
|
||||
}
|
||||
|
||||
show_webpanel_api_token() {
|
||||
source /etc/hysteria/core/scripts/webpanel/.env
|
||||
echo "$API_TOKEN"
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
echo "Stopping Caddy..."
|
||||
systemctl disable hysteria-caddy.service
|
||||
systemctl stop hysteria-caddy.service
|
||||
echo "Caddy stopped."
|
||||
|
||||
echo "Stopping Hysteria web panel..."
|
||||
systemctl disable hysteria-webpanel.service
|
||||
systemctl stop hysteria-webpanel.service
|
||||
echo "Hysteria web panel stopped."
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
if [ -z "$2" ] || [ -z "$3" ]; then
|
||||
echo -e "${red}Usage: $0 start <DOMAIN> <PORT> ${NC}"
|
||||
exit 1
|
||||
fi
|
||||
start_service "$2" "$3" "$4" "$5" "$6" "$7"
|
||||
;;
|
||||
stop)
|
||||
stop_service
|
||||
;;
|
||||
url)
|
||||
show_webpanel_url
|
||||
;;
|
||||
api-token)
|
||||
show_webpanel_api_token
|
||||
;;
|
||||
*)
|
||||
echo -e "${red}Usage: $0 {start|stop} <DOMAIN> <PORT> ${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@ -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
|
||||
@ -53,7 +53,7 @@ else
|
||||
echo "All required packages are already installed."
|
||||
fi
|
||||
|
||||
git clone https://github.com/ReturnFI/Hysteria2 /etc/hysteria
|
||||
git clone -b webpanel https://github.com/ReturnFI/Hysteria2 /etc/hysteria
|
||||
|
||||
cd /etc/hysteria
|
||||
python3 -m venv hysteria2_venv
|
||||
|
||||
181
menu.sh
181
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
|
||||
|
||||
@ -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
|
||||
|
||||
36
upgrade.sh
36
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
|
||||
|
||||
Reference in New Issue
Block a user