Merge pull request #250 from ReturnFI/beta

Extra subscription configs & blocked-user check
This commit is contained in:
Whispering Wind
2025-08-18 00:00:20 +03:30
committed by GitHub
11 changed files with 530 additions and 53 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View 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()

View File

@ -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')

View File

@ -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"

View File

@ -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)

View 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))

View File

@ -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

View File

@ -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]

View File

@ -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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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 %}