Merge pull request #250 from ReturnFI/beta
Extra subscription configs & blocked-user check
This commit is contained in:
36
changelog
36
changelog
@ -1,32 +1,22 @@
|
|||||||
# [1.14.0] - 2025-08-13
|
# [1.15.0] - 2025-08-17
|
||||||
|
|
||||||
#### ✨ New Features
|
#### ✨ New Features
|
||||||
|
|
||||||
* 🌐 **Per-User Unlimited IP Option**:
|
* 🚫 **Blocked User Check**
|
||||||
|
|
||||||
* Added `unlimited_user` flag to exempt specific users from concurrent IP limits
|
* Subscription endpoint now validates blocked users and prevents access
|
||||||
* Works in both CLI and web panel
|
* 🌐 **External Configs in Subscriptions**
|
||||||
* Integrated into `limit.sh` for enforcement bypass
|
|
||||||
* 🖥️ **Web Panel Enhancements**:
|
|
||||||
|
|
||||||
* Unlimited IP control added to **Users** page
|
* NormalSub links now include external proxy configs
|
||||||
* IP limit UI now conditionally shown based on service status
|
* ⚙️ **Settings Page**
|
||||||
* 🛠️ **CLI & Scripts**:
|
|
||||||
|
|
||||||
* Add unlimited IP option to user creation and editing
|
* Added management UI for extra subscription configs
|
||||||
* Menu script now supports unlimited IP configuration
|
* 🖥️ **Webpanel API**
|
||||||
|
|
||||||
#### 🔄 Improvements
|
* New API endpoints for managing extra configs
|
||||||
|
* 🛠️ **CLI & Scripts**
|
||||||
|
|
||||||
* 📜 **API**:
|
* CLI support for extra subscription configs
|
||||||
|
* New `extra_config.py` script to manage additional proxy configs:
|
||||||
|
|
||||||
* Updated user schemas to match CLI output and new unlimited IP feature
|
* `vmess`, `vless`, `ss`, `trojan`
|
||||||
* 🐛 **Fixes**:
|
|
||||||
|
|
||||||
* Corrected type hints for optional parameters in `config_ip_limiter`
|
|
||||||
|
|
||||||
#### 📦 Dependency Updates
|
|
||||||
|
|
||||||
* ⬆️ `anyio` → 4.10.0
|
|
||||||
* ⬆️ `certbot` → 4.2.0
|
|
||||||
* ⬆️ `charset-normalizer` → 3.4.3
|
|
||||||
|
|||||||
50
core/cli.py
50
core/cli.py
@ -368,6 +368,56 @@ def masquerade(remove: bool, enable: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f'{e}', err=True)
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
|
@cli.group('extra-config')
|
||||||
|
def extra_config():
|
||||||
|
"""Manage extra proxy configurations for subscription links."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@extra_config.command('add')
|
||||||
|
@click.option('--name', required=True, help='A unique name for the configuration.')
|
||||||
|
@click.option('--uri', required=True, help='The proxy URI (vmess, vless, ss, trojan).')
|
||||||
|
def add_extra_config(name: str, uri: str):
|
||||||
|
"""Add a new extra proxy configuration."""
|
||||||
|
try:
|
||||||
|
output = cli_api.add_extra_config(name, uri)
|
||||||
|
click.echo(output)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
|
|
||||||
|
@extra_config.command('delete')
|
||||||
|
@click.option('--name', required=True, help='The name of the configuration to delete.')
|
||||||
|
def delete_extra_config(name: str):
|
||||||
|
"""Delete an extra proxy configuration."""
|
||||||
|
try:
|
||||||
|
output = cli_api.delete_extra_config(name)
|
||||||
|
click.echo(output)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
|
|
||||||
|
@extra_config.command('list')
|
||||||
|
def list_extra_configs():
|
||||||
|
"""List all extra proxy configurations."""
|
||||||
|
try:
|
||||||
|
output = cli_api.list_extra_configs()
|
||||||
|
click.echo(output)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
|
|
||||||
|
@extra_config.command('get')
|
||||||
|
@click.option('--name', required=True, help='The name of the configuration to retrieve.')
|
||||||
|
def get_extra_config(name: str):
|
||||||
|
"""Get a specific extra proxy configuration."""
|
||||||
|
try:
|
||||||
|
res = cli_api.get_extra_config(name)
|
||||||
|
if res:
|
||||||
|
pretty_print(res)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
# region Advanced Menu
|
# region Advanced Menu
|
||||||
|
|||||||
@ -36,6 +36,7 @@ class Command(Enum):
|
|||||||
NODE_MANAGER = os.path.join(SCRIPT_DIR, 'hysteria2', 'node.py')
|
NODE_MANAGER = os.path.join(SCRIPT_DIR, 'hysteria2', 'node.py')
|
||||||
MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.py')
|
MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.py')
|
||||||
MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.py')
|
MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.py')
|
||||||
|
EXTRA_CONFIG_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'extra_config.py')
|
||||||
TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module)
|
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')
|
UPDATE_GEO = os.path.join(SCRIPT_DIR, 'hysteria2', 'update_geo.py')
|
||||||
LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.sh')
|
LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.sh')
|
||||||
@ -476,6 +477,25 @@ def update_geo(country: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HysteriaError(f'An unexpected error occurred: {e}')
|
raise HysteriaError(f'An unexpected error occurred: {e}')
|
||||||
|
|
||||||
|
def add_extra_config(name: str, uri: str) -> str:
|
||||||
|
"""Adds an extra proxy configuration."""
|
||||||
|
return run_cmd(['python3', Command.EXTRA_CONFIG_SCRIPT.value, 'add', '--name', name, '--uri', uri])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_extra_config(name: str) -> str:
|
||||||
|
"""Deletes an extra proxy configuration."""
|
||||||
|
return run_cmd(['python3', Command.EXTRA_CONFIG_SCRIPT.value, 'delete', '--name', name])
|
||||||
|
|
||||||
|
|
||||||
|
def list_extra_configs() -> str:
|
||||||
|
"""Lists all extra proxy configurations."""
|
||||||
|
return run_cmd(['python3', Command.EXTRA_CONFIG_SCRIPT.value, 'list'])
|
||||||
|
|
||||||
|
|
||||||
|
def get_extra_config(name: str) -> dict[str, Any] | None:
|
||||||
|
"""Gets a specific extra proxy configuration."""
|
||||||
|
if res := run_cmd(['python3', Command.EXTRA_CONFIG_SCRIPT.value, 'get', '--name', name]):
|
||||||
|
return json.loads(res)
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
|||||||
104
core/scripts/hysteria2/extra_config.py
Normal file
104
core/scripts/hysteria2/extra_config.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from init_paths import *
|
||||||
|
from paths import *
|
||||||
|
VALID_PROTOCOLS = ("vmess://", "vless://", "ss://", "trojan://")
|
||||||
|
|
||||||
|
def read_configs():
|
||||||
|
if not os.path.exists(EXTRA_CONFIG_PATH):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(EXTRA_CONFIG_PATH, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
if not content:
|
||||||
|
return []
|
||||||
|
return json.loads(content)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def write_configs(configs):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(EXTRA_CONFIG_PATH), exist_ok=True)
|
||||||
|
with open(EXTRA_CONFIG_PATH, 'w') as f:
|
||||||
|
json.dump(configs, f, indent=4)
|
||||||
|
except IOError as e:
|
||||||
|
print(f"Error writing to {EXTRA_CONFIG_PATH}: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def add_config(name, uri):
|
||||||
|
if not any(uri.startswith(protocol) for protocol in VALID_PROTOCOLS):
|
||||||
|
print(f"Error: Invalid URI. Must start with one of {', '.join(VALID_PROTOCOLS)}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
configs = read_configs()
|
||||||
|
|
||||||
|
if any(c['name'] == name for c in configs):
|
||||||
|
print(f"Error: A configuration with the name '{name}' already exists.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
configs.append({"name": name, "uri": uri})
|
||||||
|
write_configs(configs)
|
||||||
|
print(f"Successfully added configuration '{name}'.")
|
||||||
|
|
||||||
|
def delete_config(name):
|
||||||
|
configs = read_configs()
|
||||||
|
|
||||||
|
initial_length = len(configs)
|
||||||
|
configs = [c for c in configs if c['name'] != name]
|
||||||
|
|
||||||
|
if len(configs) == initial_length:
|
||||||
|
print(f"Error: No configuration found with the name '{name}'.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
write_configs(configs)
|
||||||
|
print(f"Successfully deleted configuration '{name}'.")
|
||||||
|
|
||||||
|
def list_configs():
|
||||||
|
configs = read_configs()
|
||||||
|
print(json.dumps(configs, indent=4))
|
||||||
|
|
||||||
|
def get_config(name):
|
||||||
|
configs = read_configs()
|
||||||
|
config = next((c for c in configs if c['name'] == name), None)
|
||||||
|
|
||||||
|
if config:
|
||||||
|
print(json.dumps(config, indent=4))
|
||||||
|
else:
|
||||||
|
print(f"Error: No configuration found with the name '{name}'.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Manage extra proxy configurations for subscription links.")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
parser_add = subparsers.add_parser("add", help="Add a new proxy configuration.")
|
||||||
|
parser_add.add_argument("--name", type=str, required=True, help="A unique name for the configuration.")
|
||||||
|
parser_add.add_argument("--uri", type=str, required=True, help="The proxy URI (vmess, vless, ss, trojan).")
|
||||||
|
|
||||||
|
parser_delete = subparsers.add_parser("delete", help="Delete a proxy configuration.")
|
||||||
|
parser_delete.add_argument("--name", type=str, required=True, help="The name of the configuration to delete.")
|
||||||
|
|
||||||
|
subparsers.add_parser("list", help="List all extra proxy configurations.")
|
||||||
|
|
||||||
|
parser_get = subparsers.add_parser("get", help="Get a specific proxy configuration by name.")
|
||||||
|
parser_get.add_argument("--name", type=str, required=True, help="The name of the configuration to retrieve.")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
print("This script must be run as root.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.command == "add":
|
||||||
|
add_config(args.name, args.uri)
|
||||||
|
elif args.command == "delete":
|
||||||
|
delete_config(args.name)
|
||||||
|
elif args.command == "list":
|
||||||
|
list_configs()
|
||||||
|
elif args.command == "get":
|
||||||
|
get_config(args.name)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -30,6 +30,7 @@ class AppConfig:
|
|||||||
hysteria_cli_path: str
|
hysteria_cli_path: str
|
||||||
users_json_path: str
|
users_json_path: str
|
||||||
nodes_json_path: str
|
nodes_json_path: str
|
||||||
|
extra_config_path: str
|
||||||
rate_limit: int
|
rate_limit: int
|
||||||
rate_limit_window: int
|
rate_limit_window: int
|
||||||
sni: str
|
sni: str
|
||||||
@ -73,6 +74,7 @@ class UserInfo:
|
|||||||
max_download_bytes: int
|
max_download_bytes: int
|
||||||
account_creation_date: str
|
account_creation_date: str
|
||||||
expiration_days: int
|
expiration_days: int
|
||||||
|
blocked: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_usage(self) -> int:
|
def total_usage(self) -> int:
|
||||||
@ -189,22 +191,16 @@ class HysteriaCLI:
|
|||||||
print(f"Hysteria CLI error: {e}")
|
print(f"Hysteria CLI error: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get_user_password(self, username: str) -> Optional[str]:
|
def get_user_details_from_json(self, username: str) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
with open(self.users_json_path, 'r') as f:
|
with open(self.users_json_path, 'r') as f:
|
||||||
users_data = json.load(f)
|
users_data = json.load(f)
|
||||||
user_details = users_data.get(username)
|
return users_data.get(username)
|
||||||
if user_details and 'password' in user_details:
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
return user_details['password']
|
print(f"Error reading user details from {self.users_json_path}: {e}")
|
||||||
return None
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Error: Users file not found at {self.users_json_path}")
|
|
||||||
return None
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
print(f"Error: Could not decode JSON from {self.users_json_path}")
|
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"An unexpected error occurred while reading users file for password: {e}")
|
print(f"An unexpected error occurred while reading users file: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_username_by_password(self, password_token: str) -> Optional[str]:
|
def get_username_by_password(self, password_token: str) -> Optional[str]:
|
||||||
@ -230,8 +226,8 @@ class HysteriaCLI:
|
|||||||
if raw_info_str is None:
|
if raw_info_str is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user_password = self.get_user_password(username)
|
user_details = self.get_user_details_from_json(username)
|
||||||
if user_password is None:
|
if not user_details or 'password' not in user_details:
|
||||||
print(f"Warning: Password for user '{username}' could not be fetched from {self.users_json_path}. Cannot create UserInfo.")
|
print(f"Warning: Password for user '{username}' could not be fetched from {self.users_json_path}. Cannot create UserInfo.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -239,26 +235,25 @@ class HysteriaCLI:
|
|||||||
raw_info = json.loads(raw_info_str)
|
raw_info = json.loads(raw_info_str)
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
username=username,
|
username=username,
|
||||||
password=user_password,
|
password=user_details['password'],
|
||||||
upload_bytes=raw_info.get('upload_bytes', 0),
|
upload_bytes=raw_info.get('upload_bytes', 0),
|
||||||
download_bytes=raw_info.get('download_bytes', 0),
|
download_bytes=raw_info.get('download_bytes', 0),
|
||||||
max_download_bytes=raw_info.get('max_download_bytes', 0),
|
max_download_bytes=raw_info.get('max_download_bytes', 0),
|
||||||
account_creation_date=raw_info.get('account_creation_date', ''),
|
account_creation_date=raw_info.get('account_creation_date', ''),
|
||||||
expiration_days=raw_info.get('expiration_days', 0)
|
expiration_days=raw_info.get('expiration_days', 0),
|
||||||
|
blocked=user_details.get('blocked', False)
|
||||||
)
|
)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}")
|
print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_all_uris(self, username: str) -> List[str]:
|
def get_all_uris(self, username: str) -> List[str]:
|
||||||
"""Fetches all available URIs (local and nodes) for a user."""
|
|
||||||
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
||||||
if not output:
|
if not output:
|
||||||
return []
|
return []
|
||||||
return re.findall(r'hy2://.*', output)
|
return re.findall(r'hy2://.*', output)
|
||||||
|
|
||||||
def get_all_labeled_uris(self, username: str) -> List[Dict[str, str]]:
|
def get_all_labeled_uris(self, username: str) -> List[Dict[str, str]]:
|
||||||
"""Fetches all URIs and their labels."""
|
|
||||||
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
||||||
if not output:
|
if not output:
|
||||||
return []
|
return []
|
||||||
@ -314,7 +309,6 @@ class SingboxConfigGenerator:
|
|||||||
return self._template_cache.copy()
|
return self._template_cache.copy()
|
||||||
|
|
||||||
def generate_config_from_uri(self, uri: str, username: str, fragment: str) -> Optional[Dict[str, Any]]:
|
def generate_config_from_uri(self, uri: str, username: str, fragment: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Generates a Singbox outbound config from a single Hysteria URI."""
|
|
||||||
if not uri:
|
if not uri:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -356,7 +350,6 @@ class SingboxConfigGenerator:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def combine_configs(self, all_uris: List[str], username: str, fragment: str) -> Optional[Dict[str, Any]]:
|
def combine_configs(self, all_uris: List[str], username: str, fragment: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Generates a combined Singbox config from a list of URIs."""
|
|
||||||
if not all_uris:
|
if not all_uris:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -389,14 +382,28 @@ class SubscriptionManager:
|
|||||||
self.hysteria_cli = hysteria_cli
|
self.hysteria_cli = hysteria_cli
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
def _get_extra_configs(self) -> List[str]:
|
||||||
|
if not os.path.exists(self.config.extra_config_path):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(self.config.extra_config_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
if not content:
|
||||||
|
return []
|
||||||
|
configs = json.loads(content)
|
||||||
|
if isinstance(configs, list):
|
||||||
|
return [str(c['uri']) for c in configs if 'uri' in c]
|
||||||
|
return []
|
||||||
|
except (json.JSONDecodeError, IOError, KeyError) as e:
|
||||||
|
print(f"Warning: Could not read or parse extra configs from {self.config.extra_config_path}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def get_normal_subscription(self, username: str, user_agent: str) -> str:
|
def get_normal_subscription(self, username: str, user_agent: str) -> str:
|
||||||
user_info = self.hysteria_cli.get_user_info(username)
|
user_info = self.hysteria_cli.get_user_info(username)
|
||||||
if user_info is None:
|
if user_info is None:
|
||||||
return "User not found"
|
return "User not found"
|
||||||
|
|
||||||
all_uris = self.hysteria_cli.get_all_uris(username)
|
all_uris = self.hysteria_cli.get_all_uris(username)
|
||||||
if not all_uris:
|
|
||||||
return "No URI available"
|
|
||||||
|
|
||||||
processed_uris = []
|
processed_uris = []
|
||||||
for uri in all_uris:
|
for uri in all_uris:
|
||||||
@ -407,6 +414,12 @@ class SubscriptionManager:
|
|||||||
formatted = ":".join("{:02X}".format(byte) for byte in decoded)
|
formatted = ":".join("{:02X}".format(byte) for byte in decoded)
|
||||||
uri = uri.replace(f'pinSHA256=sha256/{match.group(1)}', f'pinSHA256={formatted}')
|
uri = uri.replace(f'pinSHA256=sha256/{match.group(1)}', f'pinSHA256={formatted}')
|
||||||
processed_uris.append(uri)
|
processed_uris.append(uri)
|
||||||
|
|
||||||
|
extra_uris = self._get_extra_configs()
|
||||||
|
all_processed_uris = processed_uris + extra_uris
|
||||||
|
|
||||||
|
if not all_processed_uris:
|
||||||
|
return "No URI available"
|
||||||
|
|
||||||
subscription_info = (
|
subscription_info = (
|
||||||
f"//subscription-userinfo: upload={user_info.upload_bytes}; "
|
f"//subscription-userinfo: upload={user_info.upload_bytes}; "
|
||||||
@ -415,7 +428,7 @@ class SubscriptionManager:
|
|||||||
f"expire={user_info.expiration_timestamp}\n"
|
f"expire={user_info.expiration_timestamp}\n"
|
||||||
)
|
)
|
||||||
profile_lines = f"//profile-title: {username}-Hysteria2 🚀\n//profile-update-interval: 1\n"
|
profile_lines = f"//profile-title: {username}-Hysteria2 🚀\n//profile-update-interval: 1\n"
|
||||||
return profile_lines + subscription_info + "\n".join(processed_uris)
|
return profile_lines + subscription_info + "\n".join(all_processed_uris)
|
||||||
|
|
||||||
|
|
||||||
class TemplateRenderer:
|
class TemplateRenderer:
|
||||||
@ -466,6 +479,7 @@ class HysteriaServer:
|
|||||||
hysteria_cli_path = '/etc/hysteria/core/cli.py'
|
hysteria_cli_path = '/etc/hysteria/core/cli.py'
|
||||||
users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
|
users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
|
||||||
nodes_json_path = '/etc/hysteria/nodes.json'
|
nodes_json_path = '/etc/hysteria/nodes.json'
|
||||||
|
extra_config_path = '/etc/hysteria/extra.json'
|
||||||
rate_limit = 100
|
rate_limit = 100
|
||||||
rate_limit_window = 60
|
rate_limit_window = 60
|
||||||
template_dir = os.path.dirname(__file__)
|
template_dir = os.path.dirname(__file__)
|
||||||
@ -479,6 +493,7 @@ class HysteriaServer:
|
|||||||
hysteria_cli_path=hysteria_cli_path,
|
hysteria_cli_path=hysteria_cli_path,
|
||||||
users_json_path=users_json_path,
|
users_json_path=users_json_path,
|
||||||
nodes_json_path=nodes_json_path,
|
nodes_json_path=nodes_json_path,
|
||||||
|
extra_config_path=extra_config_path,
|
||||||
rate_limit=rate_limit, rate_limit_window=rate_limit_window,
|
rate_limit=rate_limit, rate_limit_window=rate_limit_window,
|
||||||
sni=sni, template_dir=template_dir,
|
sni=sni, template_dir=template_dir,
|
||||||
subpath=subpath)
|
subpath=subpath)
|
||||||
@ -538,11 +553,14 @@ class HysteriaServer:
|
|||||||
if username is None:
|
if username is None:
|
||||||
return web.Response(status=404, text="User not found for the provided token.")
|
return web.Response(status=404, text="User not found for the provided token.")
|
||||||
|
|
||||||
user_agent = request.headers.get('User-Agent', '').lower()
|
|
||||||
user_info = self.hysteria_cli.get_user_info(username)
|
user_info = self.hysteria_cli.get_user_info(username)
|
||||||
if user_info is None:
|
if user_info is None:
|
||||||
return web.Response(status=404, text=f"User '{username}' details not found.")
|
return web.Response(status=404, text=f"User '{username}' details not found.")
|
||||||
|
|
||||||
|
if user_info.blocked:
|
||||||
|
return await self._handle_blocked_user(request)
|
||||||
|
|
||||||
|
user_agent = request.headers.get('User-Agent', '').lower()
|
||||||
if any(browser in user_agent for browser in ['chrome', 'firefox', 'safari', 'edge', 'opera']):
|
if any(browser in user_agent for browser in ['chrome', 'firefox', 'safari', 'edge', 'opera']):
|
||||||
return await self._handle_html(request, username, user_info)
|
return await self._handle_html(request, username, user_info)
|
||||||
fragment = request.query.get('fragment', '')
|
fragment = request.query.get('fragment', '')
|
||||||
@ -555,6 +573,39 @@ class HysteriaServer:
|
|||||||
print(f"Internal Server Error: {e}")
|
print(f"Internal Server Error: {e}")
|
||||||
return web.Response(status=500, text="Error: Internal server error")
|
return web.Response(status=500, text="Error: Internal server error")
|
||||||
|
|
||||||
|
async def _handle_blocked_user(self, request: web.Request) -> web.Response:
|
||||||
|
fake_uri = "hysteria2://x@end.com:443?sni=support.me#⛔Account-Expired⚠️"
|
||||||
|
user_agent = request.headers.get('User-Agent', '').lower()
|
||||||
|
|
||||||
|
if any(browser in user_agent for browser in ['chrome', 'firefox', 'safari', 'edge', 'opera']):
|
||||||
|
context = self._get_blocked_template_context(fake_uri)
|
||||||
|
return web.Response(text=self.template_renderer.render(context), content_type='text/html')
|
||||||
|
|
||||||
|
fragment = request.query.get('fragment', '')
|
||||||
|
if not user_agent.startswith('hiddifynext') and ('singbox' in user_agent or 'sing' in user_agent):
|
||||||
|
combined_config = self.singbox_generator.combine_configs([fake_uri], "blocked", fragment)
|
||||||
|
return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json')
|
||||||
|
|
||||||
|
return web.Response(text=fake_uri, content_type='text/plain')
|
||||||
|
|
||||||
|
def _get_blocked_template_context(self, fake_uri: str) -> TemplateContext:
|
||||||
|
return TemplateContext(
|
||||||
|
username="blocked",
|
||||||
|
usage="N/A",
|
||||||
|
usage_raw="This account has been suspended.",
|
||||||
|
expiration_date="N/A",
|
||||||
|
sublink_qrcode=Utils.generate_qrcode_base64("blocked"),
|
||||||
|
sub_link="#blocked",
|
||||||
|
local_uris=[
|
||||||
|
NodeURI(
|
||||||
|
label="Blocked",
|
||||||
|
uri=fake_uri,
|
||||||
|
qrcode=Utils.generate_qrcode_base64(fake_uri)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
node_uris=[]
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_html(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
|
async def _handle_html(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
|
||||||
context = await self._get_template_context(username, user_info)
|
context = await self._get_template_context(username, user_info)
|
||||||
return web.Response(text=self.template_renderer.render(context), content_type='text/html')
|
return web.Response(text=self.template_renderer.render(context), content_type='text/html')
|
||||||
|
|||||||
@ -8,6 +8,7 @@ TRAFFIC_FILE = BASE_DIR / "traffic_data.json"
|
|||||||
CONFIG_FILE = BASE_DIR / "config.json"
|
CONFIG_FILE = BASE_DIR / "config.json"
|
||||||
CONFIG_ENV = BASE_DIR / ".configs.env"
|
CONFIG_ENV = BASE_DIR / ".configs.env"
|
||||||
NODES_JSON_PATH = BASE_DIR / "nodes.json"
|
NODES_JSON_PATH = BASE_DIR / "nodes.json"
|
||||||
|
EXTRA_CONFIG_PATH = BASE_DIR / "extra.json"
|
||||||
TELEGRAM_ENV = BASE_DIR / "core/scripts/telegrambot/.env"
|
TELEGRAM_ENV = BASE_DIR / "core/scripts/telegrambot/.env"
|
||||||
SINGBOX_ENV = BASE_DIR / "core/scripts/singbox/.env"
|
SINGBOX_ENV = BASE_DIR / "core/scripts/singbox/.env"
|
||||||
NORMALSUB_ENV = BASE_DIR / "core/scripts/normalsub/.env"
|
NORMALSUB_ENV = BASE_DIR / "core/scripts/normalsub/.env"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from . import normalsub
|
|||||||
from . import singbox
|
from . import singbox
|
||||||
from . import ip
|
from . import ip
|
||||||
from . import misc
|
from . import misc
|
||||||
|
from . import extra_config
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -16,4 +17,5 @@ router.include_router(telegram.router, prefix='/telegram')
|
|||||||
router.include_router(normalsub.router, prefix='/normalsub')
|
router.include_router(normalsub.router, prefix='/normalsub')
|
||||||
router.include_router(singbox.router, prefix='/singbox')
|
router.include_router(singbox.router, prefix='/singbox')
|
||||||
router.include_router(ip.router, prefix='/ip')
|
router.include_router(ip.router, prefix='/ip')
|
||||||
|
router.include_router(extra_config.router, prefix='/extra-config', tags=['Config - Extra Config'])
|
||||||
router.include_router(misc.router)
|
router.include_router(misc.router)
|
||||||
|
|||||||
59
core/scripts/webpanel/routers/api/v1/config/extra_config.py
Normal file
59
core/scripts/webpanel/routers/api/v1/config/extra_config.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from ..schema.response import DetailResponse
|
||||||
|
import json
|
||||||
|
from ..schema.config.extra_config import (
|
||||||
|
AddExtraConfigBody,
|
||||||
|
DeleteExtraConfigBody,
|
||||||
|
ExtraConfigListResponse,
|
||||||
|
)
|
||||||
|
import cli_api
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get('/list', response_model=ExtraConfigListResponse, summary='Get All Extra Configs')
|
||||||
|
async def get_all_extra_configs():
|
||||||
|
"""
|
||||||
|
Retrieves the list of all configured extra proxy configurations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of extra config objects, each containing a name and a URI.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
configs_str = cli_api.list_extra_configs()
|
||||||
|
if not configs_str:
|
||||||
|
return []
|
||||||
|
return json.loads(configs_str)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to parse extra configs list: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to retrieve extra configs: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/add', response_model=DetailResponse, summary='Add Extra Config')
|
||||||
|
async def add_extra_config(body: AddExtraConfigBody):
|
||||||
|
"""
|
||||||
|
Adds a new extra proxy configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: Request body containing the name and URI of the config.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cli_api.add_extra_config(body.name, body.uri)
|
||||||
|
return DetailResponse(detail=f"Extra config '{body.name}' added successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/delete', response_model=DetailResponse, summary='Delete Extra Config')
|
||||||
|
async def delete_extra_config(body: DeleteExtraConfigBody):
|
||||||
|
"""
|
||||||
|
Deletes an extra proxy configuration by its name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: Request body containing the name of the config to delete.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cli_api.delete_extra_config(body.name)
|
||||||
|
return DetailResponse(detail=f"Extra config '{body.name}' deleted successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@ -3,3 +3,4 @@ from . import normalsub
|
|||||||
from . import singbox
|
from . import singbox
|
||||||
from . import telegram
|
from . import telegram
|
||||||
from . import warp
|
from . import warp
|
||||||
|
from . import extra_config
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
from pydantic import BaseModel, field_validator, Field
|
||||||
|
|
||||||
|
VALID_PROTOCOLS = ("vmess://", "vless://", "ss://", "trojan://")
|
||||||
|
|
||||||
|
class ExtraConfigBase(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, description="A unique name for the configuration.")
|
||||||
|
uri: str = Field(..., description="The proxy URI.")
|
||||||
|
|
||||||
|
@field_validator('uri')
|
||||||
|
def validate_uri_protocol(cls, v):
|
||||||
|
if not any(v.startswith(protocol) for protocol in VALID_PROTOCOLS):
|
||||||
|
raise ValueError(f"Invalid URI. Must start with one of {', '.join(VALID_PROTOCOLS)}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
class AddExtraConfigBody(ExtraConfigBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DeleteExtraConfigBody(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class ExtraConfigResponse(ExtraConfigBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
ExtraConfigListResponse = list[ExtraConfigResponse]
|
||||||
@ -51,6 +51,11 @@
|
|||||||
aria-controls='change_ip' aria-selected='false'><i class="fas fa-network-wired"></i>
|
aria-controls='change_ip' aria-selected='false'><i class="fas fa-network-wired"></i>
|
||||||
IP Management</a>
|
IP Management</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class='nav-item'>
|
||||||
|
<a class='nav-link' id='extra-config-tab' data-toggle='pill' href='#extra-config' role='tab'
|
||||||
|
aria-controls='extra-config' aria-selected='false'><i class="fas fa-plus-circle"></i>
|
||||||
|
Extra Configs</a>
|
||||||
|
</li>
|
||||||
<li class='nav-item'>
|
<li class='nav-item'>
|
||||||
<a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab'
|
<a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab'
|
||||||
aria-controls='backup' aria-selected='false'><i class="fas fa-download"></i>
|
aria-controls='backup' aria-selected='false'><i class="fas fa-download"></i>
|
||||||
@ -273,6 +278,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Extra Configs Tab -->
|
||||||
|
<div class='tab-pane fade' id='extra-config' role='tabpanel' aria-labelledby='extra-config-tab'>
|
||||||
|
<div class="card card-outline card-info">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">External Proxy Configurations for Subscriptions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Add external proxy links (Vmess, Vless, SS, Trojan) to be included in all users' subscription links.</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered table-striped" id="extra_configs_table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>URI</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="no_extra_configs_message" class="alert alert-info" style="display: none;">
|
||||||
|
No external configurations have been added yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h5>Add New Configuration</h5>
|
||||||
|
<form id="add_extra_config_form" class="form-inline">
|
||||||
|
<div class="form-group mb-2 mr-sm-2">
|
||||||
|
<label for="extra_config_name" class="sr-only">Name</label>
|
||||||
|
<input type="text" class="form-control" id="extra_config_name" placeholder="e.g., My-Vmess-Link">
|
||||||
|
<div class="invalid-feedback">Please enter a unique name.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-2 mr-sm-2 flex-grow-1">
|
||||||
|
<label for="extra_config_uri" class="sr-only">URI</label>
|
||||||
|
<input type="text" class="form-control w-100" id="extra_config_uri" placeholder="vmess://...">
|
||||||
|
<div class="invalid-feedback">URI must start with vmess://, vless://, ss://, or trojan://.</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="add_extra_config_btn" class="btn btn-success mb-2">
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||||
|
Add Config
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Backup Tab -->
|
<!-- Backup Tab -->
|
||||||
<div class='tab-pane fade' id='backup' role='tabpanel' aria-labelledby='backup-tab'>
|
<div class='tab-pane fade' id='backup' role='tabpanel' aria-labelledby='backup-tab'>
|
||||||
@ -479,6 +531,27 @@
|
|||||||
fetchDecoyStatus();
|
fetchDecoyStatus();
|
||||||
fetchObfsStatus();
|
fetchObfsStatus();
|
||||||
fetchNodes();
|
fetchNodes();
|
||||||
|
fetchExtraConfigs();
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
var map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
if (text === null || typeof text === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidURI(uri) {
|
||||||
|
if (!uri) return false;
|
||||||
|
const lowerUri = uri.toLowerCase();
|
||||||
|
return lowerUri.startsWith("vmess://") || lowerUri.startsWith("vless://") || lowerUri.startsWith("ss://") || lowerUri.startsWith("trojan://");
|
||||||
|
}
|
||||||
|
|
||||||
function isValidPath(path) {
|
function isValidPath(path) {
|
||||||
if (!path) return false;
|
if (!path) return false;
|
||||||
@ -566,7 +639,16 @@
|
|||||||
if (Array.isArray(detail)) {
|
if (Array.isArray(detail)) {
|
||||||
errorMessage = detail.map(err => `Error in '${err.loc[1]}': ${err.msg}`).join('\n');
|
errorMessage = detail.map(err => `Error in '${err.loc[1]}': ${err.msg}`).join('\n');
|
||||||
} else if (typeof detail === 'string') {
|
} else if (typeof detail === 'string') {
|
||||||
errorMessage = detail;
|
let userMessage = detail;
|
||||||
|
const failMarker = 'failed with exit code';
|
||||||
|
const markerIndex = detail.indexOf(failMarker);
|
||||||
|
if (markerIndex > -1) {
|
||||||
|
const colonIndex = detail.indexOf(':', markerIndex);
|
||||||
|
if (colonIndex > -1) {
|
||||||
|
userMessage = detail.substring(colonIndex + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errorMessage = userMessage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Swal.fire("Error!", errorMessage, "error");
|
Swal.fire("Error!", errorMessage, "error");
|
||||||
@ -598,8 +680,10 @@
|
|||||||
fieldValid = (input.val().trim() === '') ? true : isValidIPorDomain(input.val());
|
fieldValid = (input.val().trim() === '') ? true : isValidIPorDomain(input.val());
|
||||||
} else if (id === 'node_ip') {
|
} else if (id === 'node_ip') {
|
||||||
fieldValid = isValidIPorDomain(input.val());
|
fieldValid = isValidIPorDomain(input.val());
|
||||||
} else if (id === 'node_name') {
|
} else if (id === 'node_name' || id === 'extra_config_name') {
|
||||||
fieldValid = input.val().trim() !== "";
|
fieldValid = input.val().trim() !== "";
|
||||||
|
} else if (id === 'extra_config_uri') {
|
||||||
|
fieldValid = isValidURI(input.val());
|
||||||
} else if (id === 'block_duration' || id === 'max_ips') {
|
} else if (id === 'block_duration' || id === 'max_ips') {
|
||||||
fieldValid = isValidPositiveNumber(input.val());
|
fieldValid = isValidPositiveNumber(input.val());
|
||||||
} else if (id === 'decoy_path') {
|
} else if (id === 'decoy_path') {
|
||||||
@ -691,10 +775,10 @@
|
|||||||
$("#no_nodes_message").hide();
|
$("#no_nodes_message").hide();
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const row = `<tr>
|
const row = `<tr>
|
||||||
<td>${node.name}</td>
|
<td>${escapeHtml(node.name)}</td>
|
||||||
<td>${node.ip}</td>
|
<td>${escapeHtml(node.ip)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-xs btn-danger delete-node-btn" data-name="${node.name}">
|
<button class="btn btn-xs btn-danger delete-node-btn" data-name="${escapeHtml(node.name)}">
|
||||||
<i class="fas fa-trash"></i> Delete
|
<i class="fas fa-trash"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@ -745,6 +829,84 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchExtraConfigs() {
|
||||||
|
$.ajax({
|
||||||
|
url: "{{ url_for('get_all_extra_configs') }}",
|
||||||
|
type: "GET",
|
||||||
|
success: function (configs) {
|
||||||
|
renderExtraConfigs(configs);
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
Swal.fire("Error!", "Failed to fetch extra configurations.", "error");
|
||||||
|
console.error("Error fetching extra configs:", xhr.responseText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExtraConfigs(configs) {
|
||||||
|
const tableBody = $("#extra_configs_table tbody");
|
||||||
|
tableBody.empty();
|
||||||
|
|
||||||
|
if (configs && configs.length > 0) {
|
||||||
|
$("#extra_configs_table").show();
|
||||||
|
$("#no_extra_configs_message").hide();
|
||||||
|
configs.forEach(config => {
|
||||||
|
const shortUri = config.uri.length > 50 ? config.uri.substring(0, 50) + '...' : config.uri;
|
||||||
|
const row = `<tr>
|
||||||
|
<td>${escapeHtml(config.name)}</td>
|
||||||
|
<td title="${escapeHtml(config.uri)}">${escapeHtml(shortUri)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-xs btn-danger delete-extra-config-btn" data-name="${escapeHtml(config.name)}">
|
||||||
|
<i class="fas fa-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
tableBody.append(row);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$("#extra_configs_table").hide();
|
||||||
|
$("#no_extra_configs_message").show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExtraConfig() {
|
||||||
|
if (!validateForm('add_extra_config_form')) return;
|
||||||
|
|
||||||
|
const name = $("#extra_config_name").val().trim();
|
||||||
|
const uri = $("#extra_config_uri").val().trim();
|
||||||
|
|
||||||
|
confirmAction(`add the configuration '${name}'`, function () {
|
||||||
|
sendRequest(
|
||||||
|
"{{ url_for('add_extra_config') }}",
|
||||||
|
"POST",
|
||||||
|
{ name: name, uri: uri },
|
||||||
|
`Configuration '${name}' added successfully!`,
|
||||||
|
"#add_extra_config_btn",
|
||||||
|
false,
|
||||||
|
function() {
|
||||||
|
$("#extra_config_name").val('');
|
||||||
|
$("#extra_config_uri").val('');
|
||||||
|
$("#add_extra_config_form .form-control").removeClass('is-invalid');
|
||||||
|
fetchExtraConfigs();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteExtraConfig(configName) {
|
||||||
|
confirmAction(`delete the configuration '${configName}'`, function () {
|
||||||
|
sendRequest(
|
||||||
|
"{{ url_for('delete_extra_config') }}",
|
||||||
|
"POST",
|
||||||
|
{ name: configName },
|
||||||
|
`Configuration '${configName}' deleted successfully!`,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
fetchExtraConfigs
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateServiceUI(data) {
|
function updateServiceUI(data) {
|
||||||
const servicesMap = {
|
const servicesMap = {
|
||||||
"hysteria_telegram_bot": "#telegram_form",
|
"hysteria_telegram_bot": "#telegram_form",
|
||||||
@ -1339,6 +1501,11 @@
|
|||||||
const nodeName = $(this).data("name");
|
const nodeName = $(this).data("name");
|
||||||
deleteNode(nodeName);
|
deleteNode(nodeName);
|
||||||
});
|
});
|
||||||
|
$("#add_extra_config_btn").on("click", addExtraConfig);
|
||||||
|
$("#extra_configs_table").on("click", ".delete-extra-config-btn", function() {
|
||||||
|
const configName = $(this).data("name");
|
||||||
|
deleteExtraConfig(configName);
|
||||||
|
});
|
||||||
|
|
||||||
$('#normal_domain, #sni_domain, #decoy_domain').on('input', function () {
|
$('#normal_domain, #sni_domain, #decoy_domain').on('input', function () {
|
||||||
if (isValidDomain($(this).val())) {
|
if (isValidDomain($(this).val())) {
|
||||||
@ -1381,7 +1548,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#node_name').on('input', function() {
|
$('#node_name, #extra_config_name').on('input', function() {
|
||||||
if ($(this).val().trim() !== "") {
|
if ($(this).val().trim() !== "") {
|
||||||
$(this).removeClass('is-invalid');
|
$(this).removeClass('is-invalid');
|
||||||
} else {
|
} else {
|
||||||
@ -1389,6 +1556,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#extra_config_uri').on('input', function () {
|
||||||
|
if (isValidURI($(this).val())) {
|
||||||
|
$(this).removeClass('is-invalid');
|
||||||
|
} else if ($(this).val().trim() !== "") {
|
||||||
|
$(this).addClass('is-invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('#telegram_api_token, #telegram_admin_id').on('input', function () {
|
$('#telegram_api_token, #telegram_admin_id').on('input', function () {
|
||||||
if ($(this).val().trim() !== "") {
|
if ($(this).val().trim() !== "") {
|
||||||
$(this).removeClass('is-invalid');
|
$(this).removeClass('is-invalid');
|
||||||
@ -1418,4 +1593,4 @@
|
|||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user