diff --git a/changelog b/changelog index cf90ebd..003329a 100644 --- a/changelog +++ b/changelog @@ -1,32 +1,22 @@ -# [1.14.0] - 2025-08-13 +# [1.15.0] - 2025-08-17 #### โœจ New Features -* ๐ŸŒ **Per-User Unlimited IP Option**: +* ๐Ÿšซ **Blocked User Check** - * Added `unlimited_user` flag to exempt specific users from concurrent IP limits - * Works in both CLI and web panel - * Integrated into `limit.sh` for enforcement bypass -* ๐Ÿ–ฅ๏ธ **Web Panel Enhancements**: + * Subscription endpoint now validates blocked users and prevents access +* ๐ŸŒ **External Configs in Subscriptions** - * Unlimited IP control added to **Users** page - * IP limit UI now conditionally shown based on service status -* ๐Ÿ› ๏ธ **CLI & Scripts**: + * NormalSub links now include external proxy configs +* โš™๏ธ **Settings Page** - * Add unlimited IP option to user creation and editing - * Menu script now supports unlimited IP configuration + * Added management UI for extra subscription configs +* ๐Ÿ–ฅ๏ธ **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 -* ๐Ÿ› **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 + * `vmess`, `vless`, `ss`, `trojan` diff --git a/core/cli.py b/core/cli.py index a6b416e..ef9a075 100644 --- a/core/cli.py +++ b/core/cli.py @@ -368,6 +368,56 @@ def masquerade(remove: bool, enable: str): except Exception as e: 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 # region Advanced Menu diff --git a/core/cli_api.py b/core/cli_api.py index 3098060..c235dce 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -36,6 +36,7 @@ class Command(Enum): NODE_MANAGER = os.path.join(SCRIPT_DIR, 'hysteria2', 'node.py') MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.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) UPDATE_GEO = os.path.join(SCRIPT_DIR, 'hysteria2', 'update_geo.py') LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.sh') @@ -476,6 +477,25 @@ def update_geo(country: str): except Exception as 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 diff --git a/core/scripts/hysteria2/extra_config.py b/core/scripts/hysteria2/extra_config.py new file mode 100644 index 0000000..aac6d58 --- /dev/null +++ b/core/scripts/hysteria2/extra_config.py @@ -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() \ No newline at end of file diff --git a/core/scripts/normalsub/normalsub.py b/core/scripts/normalsub/normalsub.py index a3bddba..fdfcebb 100644 --- a/core/scripts/normalsub/normalsub.py +++ b/core/scripts/normalsub/normalsub.py @@ -30,6 +30,7 @@ class AppConfig: hysteria_cli_path: str users_json_path: str nodes_json_path: str + extra_config_path: str rate_limit: int rate_limit_window: int sni: str @@ -73,6 +74,7 @@ class UserInfo: max_download_bytes: int account_creation_date: str expiration_days: int + blocked: bool = False @property def total_usage(self) -> int: @@ -189,22 +191,16 @@ class HysteriaCLI: print(f"Hysteria CLI error: {e}") raise - def get_user_password(self, username: str) -> Optional[str]: + def get_user_details_from_json(self, username: str) -> Optional[Dict[str, Any]]: try: with open(self.users_json_path, 'r') as f: users_data = json.load(f) - user_details = users_data.get(username) - if user_details and 'password' in user_details: - return user_details['password'] - 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 users_data.get(username) + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f"Error reading user details from {self.users_json_path}: {e}") return None 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 def get_username_by_password(self, password_token: str) -> Optional[str]: @@ -230,8 +226,8 @@ class HysteriaCLI: if raw_info_str is None: return None - user_password = self.get_user_password(username) - if user_password is None: + user_details = self.get_user_details_from_json(username) + 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.") return None @@ -239,26 +235,25 @@ class HysteriaCLI: raw_info = json.loads(raw_info_str) return UserInfo( username=username, - password=user_password, + password=user_details['password'], upload_bytes=raw_info.get('upload_bytes', 0), download_bytes=raw_info.get('download_bytes', 0), max_download_bytes=raw_info.get('max_download_bytes', 0), 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: print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}") return None 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']) if not output: return [] return re.findall(r'hy2://.*', output) 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']) if not output: return [] @@ -314,7 +309,6 @@ class SingboxConfigGenerator: return self._template_cache.copy() 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: return None @@ -356,7 +350,6 @@ class SingboxConfigGenerator: } 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: return None @@ -389,14 +382,28 @@ class SubscriptionManager: self.hysteria_cli = hysteria_cli 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: user_info = self.hysteria_cli.get_user_info(username) if user_info is None: return "User not found" all_uris = self.hysteria_cli.get_all_uris(username) - if not all_uris: - return "No URI available" processed_uris = [] for uri in all_uris: @@ -407,6 +414,12 @@ class SubscriptionManager: formatted = ":".join("{:02X}".format(byte) for byte in decoded) uri = uri.replace(f'pinSHA256=sha256/{match.group(1)}', f'pinSHA256={formatted}') 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 = ( f"//subscription-userinfo: upload={user_info.upload_bytes}; " @@ -415,7 +428,7 @@ class SubscriptionManager: f"expire={user_info.expiration_timestamp}\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: @@ -466,6 +479,7 @@ class HysteriaServer: hysteria_cli_path = '/etc/hysteria/core/cli.py' users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json') nodes_json_path = '/etc/hysteria/nodes.json' + extra_config_path = '/etc/hysteria/extra.json' rate_limit = 100 rate_limit_window = 60 template_dir = os.path.dirname(__file__) @@ -479,6 +493,7 @@ class HysteriaServer: hysteria_cli_path=hysteria_cli_path, users_json_path=users_json_path, nodes_json_path=nodes_json_path, + extra_config_path=extra_config_path, rate_limit=rate_limit, rate_limit_window=rate_limit_window, sni=sni, template_dir=template_dir, subpath=subpath) @@ -538,11 +553,14 @@ class HysteriaServer: if username is None: 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) if user_info is None: 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']): return await self._handle_html(request, username, user_info) fragment = request.query.get('fragment', '') @@ -555,6 +573,39 @@ class HysteriaServer: print(f"Internal Server Error: {e}") 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: context = await self._get_template_context(username, user_info) return web.Response(text=self.template_renderer.render(context), content_type='text/html') diff --git a/core/scripts/paths.py b/core/scripts/paths.py index d59c6ec..5dbab5f 100644 --- a/core/scripts/paths.py +++ b/core/scripts/paths.py @@ -8,6 +8,7 @@ TRAFFIC_FILE = BASE_DIR / "traffic_data.json" CONFIG_FILE = BASE_DIR / "config.json" CONFIG_ENV = BASE_DIR / ".configs.env" NODES_JSON_PATH = BASE_DIR / "nodes.json" +EXTRA_CONFIG_PATH = BASE_DIR / "extra.json" TELEGRAM_ENV = BASE_DIR / "core/scripts/telegrambot/.env" SINGBOX_ENV = BASE_DIR / "core/scripts/singbox/.env" NORMALSUB_ENV = BASE_DIR / "core/scripts/normalsub/.env" diff --git a/core/scripts/webpanel/routers/api/v1/config/__init__.py b/core/scripts/webpanel/routers/api/v1/config/__init__.py index 02812a3..ee15be7 100644 --- a/core/scripts/webpanel/routers/api/v1/config/__init__.py +++ b/core/scripts/webpanel/routers/api/v1/config/__init__.py @@ -6,6 +6,7 @@ from . import normalsub from . import singbox from . import ip from . import misc +from . import extra_config router = APIRouter() @@ -16,4 +17,5 @@ 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(extra_config.router, prefix='/extra-config', tags=['Config - Extra Config']) router.include_router(misc.router) diff --git a/core/scripts/webpanel/routers/api/v1/config/extra_config.py b/core/scripts/webpanel/routers/api/v1/config/extra_config.py new file mode 100644 index 0000000..50605bc --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/config/extra_config.py @@ -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)) \ No newline at end of file diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/__init__.py b/core/scripts/webpanel/routers/api/v1/schema/config/__init__.py index cd54453..0aac258 100644 --- a/core/scripts/webpanel/routers/api/v1/schema/config/__init__.py +++ b/core/scripts/webpanel/routers/api/v1/schema/config/__init__.py @@ -3,3 +3,4 @@ from . import normalsub from . import singbox from . import telegram from . import warp +from . import extra_config diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/extra_config.py b/core/scripts/webpanel/routers/api/v1/schema/config/extra_config.py new file mode 100644 index 0000000..fda8190 --- /dev/null +++ b/core/scripts/webpanel/routers/api/v1/schema/config/extra_config.py @@ -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] \ No newline at end of file diff --git a/core/scripts/webpanel/templates/settings.html b/core/scripts/webpanel/templates/settings.html index da42d18..caf9ca7 100644 --- a/core/scripts/webpanel/templates/settings.html +++ b/core/scripts/webpanel/templates/settings.html @@ -51,6 +51,11 @@ aria-controls='change_ip' aria-selected='false'> IP Management +