From 2168080843463b5eb058b27891af67b7fa8c4462 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Tue, 27 May 2025 16:20:02 +0330 Subject: [PATCH] feat: Refactor normalsub to use Caddy as reverse proxy --- core/scripts/normalsub/normalsub.py | 98 ++++++------ core/scripts/normalsub/normalsub.sh | 229 +++++++++++++++++++--------- 2 files changed, 206 insertions(+), 121 deletions(-) diff --git a/core/scripts/normalsub/normalsub.py b/core/scripts/normalsub/normalsub.py index 1140480..1e375c1 100644 --- a/core/scripts/normalsub/normalsub.py +++ b/core/scripts/normalsub/normalsub.py @@ -1,5 +1,4 @@ import os -import ssl import json import subprocess import re @@ -22,10 +21,10 @@ load_dotenv() @dataclass class AppConfig: - domain: str - cert_file: str - key_file: str - port: int + domain: str + external_port: int + aiohttp_listen_address: str + aiohttp_listen_port: int sni_file: str singbox_template_path: str hysteria_cli_path: str @@ -154,7 +153,6 @@ class Utils: @staticmethod def is_valid_url(url: str) -> bool: - """Checks if the given string is a valid URL.""" try: result = urlparse(url) return all([result.scheme, result.netloc]) @@ -173,7 +171,7 @@ class HysteriaCLI: stdout, stderr = process.communicate() if process.returncode != 0: if "User not found" in stderr: - return None # Indicate user not found + return None else: print(f"Hysteria CLI error: {stderr}") raise subprocess.CalledProcessError(process.returncode, command, output=stdout, stderr=stderr) @@ -185,7 +183,7 @@ class HysteriaCLI: def get_user_info(self, username: str) -> Optional[UserInfo]: raw_info_str = self._run_command(['get-user', '-u', username]) if raw_info_str is None: - return None # User not found + return None try: raw_info = json.loads(raw_info_str) return UserInfo( @@ -394,22 +392,19 @@ class HysteriaServer: base_path = f'/{safe_subpath}' self.app.router.add_get(f'{base_path}/sub/normal/{{username}}', self.handle) self.app.router.add_get(f'{base_path}/robots.txt', self.robots_handler) - - self.app.router.add_route('*', f'/{safe_subpath}/{{tail:.*}}', self.handle_404) - - # This is handled by self._invalid_endpoint_middleware middleware - # self.app.router.add_route('*', '/{tail:.*}', self.handle_generic_404) + self.app.router.add_route('*', f'{base_path}/{{tail:.*}}', self.handle_404_subpath) + def _load_config(self) -> AppConfig: domain = os.getenv('HYSTERIA_DOMAIN', 'localhost') - cert_file = os.getenv('HYSTERIA_CERTFILE') - key_file = os.getenv('HYSTERIA_KEYFILE') - port = int(os.getenv('HYSTERIA_PORT', '3326')) + external_port = int(os.getenv('HYSTERIA_PORT', '443')) + aiohttp_listen_address = os.getenv('AIOHTTP_LISTEN_ADDRESS', '127.0.0.1') + aiohttp_listen_port = int(os.getenv('AIOHTTP_LISTEN_PORT', '28261')) + subpath = os.getenv('SUBPATH', '').strip().strip("/") - - if not self.is_valid_subpath(subpath): + if not subpath or not self.is_valid_subpath(subpath): raise ValueError( - f"Invalid SUBPATH: '{subpath}'. Subpath must contain only alphanumeric characters, hyphens, and underscores.") + f"Invalid or empty SUBPATH: '{subpath}'. Subpath must be non-empty and contain only alphanumeric characters.") sni_file = '/etc/hysteria/.configs.env' singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json' @@ -419,9 +414,14 @@ class HysteriaServer: template_dir = os.path.dirname(__file__) sni = self._load_sni_from_env(sni_file) - return AppConfig(domain=domain, cert_file=cert_file, key_file=key_file, port=port, sni_file=sni_file, - singbox_template_path=singbox_template_path, hysteria_cli_path=hysteria_cli_path, - rate_limit=rate_limit, rate_limit_window=rate_limit_window, sni=sni, template_dir=template_dir, + return AppConfig(domain=domain, external_port=external_port, + aiohttp_listen_address=aiohttp_listen_address, + aiohttp_listen_port=aiohttp_listen_port, + sni_file=sni_file, + singbox_template_path=singbox_template_path, + hysteria_cli_path=hysteria_cli_path, + rate_limit=rate_limit, rate_limit_window=rate_limit_window, + sni=sni, template_dir=template_dir, subpath=subpath) def _load_sni_from_env(self, sni_file: str) -> str: @@ -435,26 +435,27 @@ class HysteriaServer: return "bts.com" def is_valid_subpath(self, subpath: str) -> bool: - """Validates the subpath using a regex.""" - return bool(re.match(r"^[a-zA-Z0-9_-]+$", subpath)) + return bool(re.match(r"^[a-zA-Z0-9]+$", subpath)) def validate_and_escape_subpath(self, subpath: str) -> str: - """Validates the subpath and returns the escaped version.""" if not self.is_valid_subpath(subpath): raise ValueError(f"Invalid subpath: {subpath}") - return re.escape(subpath) + return re.escape(subpath) @middleware async def _rate_limit_middleware(self, request: web.Request, handler): - client_ip = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP', request.remote)) - if not self.rate_limiter.check_limit(client_ip): # type: ignore + client_ip_hdr = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP')) + client_ip = client_ip_hdr.split(',')[0].strip() if client_ip_hdr else request.remote + + if client_ip and not self.rate_limiter.check_limit(client_ip): return web.Response(status=429, text="Rate limit exceeded.") return await handler(request) @middleware async def _invalid_endpoint_middleware(self, request: web.Request, handler): - path = f'/{self.config.subpath}/' - if not request.path.startswith(path): + expected_prefix = f'/{self.config.subpath}/' + if not request.path.startswith(expected_prefix): + print(f"Warning: Request {request.path} reached aiohttp outside expected subpath {expected_prefix}. Closing connection.") if request.transport is not None: request.transport.close() raise web.HTTPForbidden() @@ -468,9 +469,11 @@ class HysteriaServer: async def handle(self, request: web.Request) -> web.Response: try: - username = Utils.sanitize_input(request.match_info.get('username', ''), r'^[a-zA-Z0-9_-]+$') - if not username: - return web.Response(status=400, text="Error: Missing 'username' parameter.") + username_raw = request.match_info.get('username', '') + if not username_raw: # Should not happen due to route def + return web.Response(status=400, text="Error: Missing 'username' parameter.") + username = Utils.sanitize_input(username_raw, r'^[a-zA-Z0-9_-]+$') + user_agent = request.headers.get('User-Agent', '').lower() user_info = self.hysteria_cli.get_user_info(username) if user_info is None: @@ -509,10 +512,12 @@ class HysteriaServer: async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext: ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username) + port_str = f":{self.config.external_port}" if self.config.external_port not in [80, 443, 0] else "" + base_url = f"https://{self.config.domain}{port_str}" - base_url = f"https://{self.config.domain}:{self.config.port}" if not Utils.is_valid_url(base_url): - raise ValueError(f"Invalid base URL constructed: {base_url}") + print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.") + sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{username}" ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri) @@ -535,23 +540,20 @@ class HysteriaServer: async def robots_handler(self, request: web.Request) -> web.Response: return web.Response(text="User-agent: *\nDisallow: /", content_type="text/plain") - async def handle_404(self, request: web.Request) -> web.Response: - """Handles 404 Not Found errors *within* the subpath.""" - print(f"404 Not Found (within subpath): {request.path}") + async def handle_404_subpath(self, request: web.Request) -> web.Response: + print(f"404 Not Found (within subpath, unhandled by specific routes): {request.path}") return web.Response(status=404, text="Not Found within Subpath") - # async def handle_generic_404(self, request: web.Request) -> web.Response: - # """Handles 404 Not Found errors *outside* the subpath.""" - # print(f"404 Not Found (generic): {request.path}") - # return web.Response(status=404, text="Not Found") - def run(self): - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ssl_context.load_cert_chain(certfile=self.config.cert_file, keyfile=self.config.key_file) - ssl_context.set_ciphers('ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384') - web.run_app(self.app, port=self.config.port, ssl_context=ssl_context) + print(f"Starting Hysteria Normalsub server on {self.config.aiohttp_listen_address}:{self.config.aiohttp_listen_port}") + print(f"External access via Caddy should be at https://{self.config.domain}:{self.config.external_port}/{self.config.subpath}/") + web.run_app( + self.app, + host=self.config.aiohttp_listen_address, + port=self.config.aiohttp_listen_port + ) if __name__ == '__main__': server = HysteriaServer() - server.run() + server.run() \ No newline at end of file diff --git a/core/scripts/normalsub/normalsub.sh b/core/scripts/normalsub/normalsub.sh index ceae80f..b21f89b 100644 --- a/core/scripts/normalsub/normalsub.sh +++ b/core/scripts/normalsub/normalsub.sh @@ -2,30 +2,100 @@ source /etc/hysteria/core/scripts/utils.sh define_colors +CADDY_CONFIG_FILE_NORMALSUB="/etc/hysteria/core/scripts/normalsub/Caddyfile.normalsub" +NORMALSUB_ENV_FILE="/etc/hysteria/core/scripts/normalsub/.env" +DEFAULT_AIOHTTP_LISTEN_ADDRESS="127.0.0.1" +DEFAULT_AIOHTTP_LISTEN_PORT="28261" + +install_caddy_if_needed() { + if command -v caddy &> /dev/null; then + echo -e "${green}Caddy is already installed.${NC}" + if systemctl list-units --full -all | grep -q 'caddy.service'; then + if systemctl is-active --quiet caddy.service; then + echo -e "${yellow}Stopping and disabling default caddy.service...${NC}" + systemctl stop caddy > /dev/null 2>&1 + systemctl disable caddy > /dev/null 2>&1 + fi + fi + return 0 + fi + + echo -e "${yellow}Installing Caddy...${NC}" + sudo apt update -y > /dev/null 2>&1 + sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl > /dev/null 2>&1 + curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key | sudo tee /etc/apt/trusted.gpg.d/caddy-stable.asc > /dev/null 2>&1 + echo "deb [signed-by=/etc/apt/trusted.gpg.d/caddy-stable.asc] https://dl.cloudsmith.io/public/caddy/stable/deb/ubuntu $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null 2>&1 + sudo apt update -y > /dev/null 2>&1 + sudo apt install -y caddy + if [ $? -ne 0 ]; then + echo -e "${red}Error: Failed to install Caddy. ${NC}" + exit 1 + fi + systemctl stop caddy > /dev/null 2>&1 + systemctl disable caddy > /dev/null 2>&1 + echo -e "${green}Caddy installed successfully. ${NC}" +} + update_env_file() { local domain=$1 - local port=$2 - local cert_dir="/etc/letsencrypt/live/$domain" + local external_port=$2 + local aiohttp_listen_address=$3 + local aiohttp_listen_port=$4 + local subpath_val=$(pwgen -s 32 1) - cat < /etc/hysteria/core/scripts/normalsub/.env + cat < "$NORMALSUB_ENV_FILE" HYSTERIA_DOMAIN=$domain -HYSTERIA_PORT=$port -HYSTERIA_CERTFILE=$cert_dir/fullchain.pem -HYSTERIA_KEYFILE=$cert_dir/privkey.pem -SUBPATH=$(pwgen -s 32 1) +HYSTERIA_PORT=$external_port +AIOHTTP_LISTEN_ADDRESS=$aiohttp_listen_address +AIOHTTP_LISTEN_PORT=$aiohttp_listen_port +SUBPATH=$subpath_val EOL } -create_service_file() { +update_caddy_file_normalsub() { + local domain=$1 + local external_port=$2 + local subpath_val=$3 + local aiohttp_address=$4 + local aiohttp_port=$5 + + cat < "$CADDY_CONFIG_FILE_NORMALSUB" +# Global configuration +{ + admin off + auto_https disable_redirects +} + +$domain:$external_port { + encode gzip zstd + + route /$subpath_val/* { + reverse_proxy $aiohttp_address:$aiohttp_port { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Port {server_port} + header_up X-Forwarded-Proto {scheme} + } + } + + @blocked { + not path /$subpath_val/* + } + abort @blocked +} +EOL +} + +create_normalsub_python_service_file() { cat < /etc/systemd/system/hysteria-normal-sub.service [Unit] -Description=normalsub Python Service +Description=Hysteria Normalsub Python Service After=network.target [Service] ExecStart=/bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && /etc/hysteria/hysteria2_venv/bin/python /etc/hysteria/core/scripts/normalsub/normalsub.py' WorkingDirectory=/etc/hysteria/core/scripts/normalsub -EnvironmentFile=/etc/hysteria/core/scripts/normalsub/.env +EnvironmentFile=$NORMALSUB_ENV_FILE Restart=always User=root Group=root @@ -35,107 +105,120 @@ WantedBy=multi-user.target EOL } +create_caddy_normalsub_service_file() { + cat < /etc/systemd/system/hysteria-caddy-normalsub.service +[Unit] +Description=Caddy for Hysteria Normalsub +After=network.target + +[Service] +WorkingDirectory=/etc/hysteria/core/scripts/normalsub +ExecStart=/usr/bin/caddy run --environ --config $CADDY_CONFIG_FILE_NORMALSUB +ExecReload=/usr/bin/caddy reload --config $CADDY_CONFIG_FILE_NORMALSUB --force +TimeoutStopSec=5s +LimitNOFILE=1048576 +PrivateTmp=true +User=root +Group=root + +[Install] +WantedBy=multi-user.target +EOL +} + start_service() { local domain=$1 - local port=$2 + local external_port=$2 - if systemctl is-active --quiet hysteria-normal-sub.service; then - echo "The hysteria-normal-sub.service is already running." - return - fi + install_caddy_if_needed - echo "Checking SSL certificates for $domain..." - if certbot certificates | grep -q "$domain"; then - echo -e "${yellow}Certificate for $domain already exists. Renewing...${NC}" - certbot renew --cert-name "$domain" - if [ $? -ne 0 ]; then - echo -e "${red}Error: Failed to renew SSL certificate. ${NC}" - exit 1 - fi - echo -e "${green}Certificate renewed successfully. ${NC}" - else - echo -e "${yellow}Requesting new certificate for $domain...${NC}" - certbot certonly --standalone --agree-tos --register-unsafely-without-email -d "$domain" - if [ $? -ne 0 ]; then - echo -e "${red}Error: Failed to generate SSL certificate. ${NC}" - exit 1 - fi - echo -e "${green}Certificate generated successfully. ${NC}" - fi + local aiohttp_listen_address="$DEFAULT_AIOHTTP_LISTEN_ADDRESS" + local aiohttp_listen_port="$DEFAULT_AIOHTTP_LISTEN_PORT" + + update_env_file "$domain" "$external_port" "$aiohttp_listen_address" "$aiohttp_listen_port" + source "$NORMALSUB_ENV_FILE" # To get SUBPATH for Caddyfile + + update_caddy_file_normalsub "$HYSTERIA_DOMAIN" "$HYSTERIA_PORT" "$SUBPATH" "$AIOHTTP_LISTEN_ADDRESS" "$AIOHTTP_LISTEN_PORT" + + create_normalsub_python_service_file + create_caddy_normalsub_service_file - update_env_file "$domain" "$port" - create_service_file - chown -R hysteria:hysteria "/etc/letsencrypt/live/$domain" - chown -R hysteria:hysteria /etc/hysteria/core/scripts/normalsub systemctl daemon-reload + systemctl enable hysteria-normal-sub.service > /dev/null 2>&1 - systemctl start hysteria-normal-sub.service > /dev/null 2>&1 - systemctl daemon-reload > /dev/null 2>&1 + systemctl start hysteria-normal-sub.service + + systemctl enable hysteria-caddy-normalsub.service > /dev/null 2>&1 + systemctl start hysteria-caddy-normalsub.service - if systemctl is-active --quiet hysteria-normal-sub.service; then - echo -e "${green}normalsub service setup completed. The service is now running on port $port. ${NC}" + if systemctl is-active --quiet hysteria-normal-sub.service && systemctl is-active --quiet hysteria-caddy-normalsub.service; then + echo -e "${green}Normalsub service setup completed.${NC}" + echo -e "${green}Access base URL: https://$HYSTERIA_DOMAIN:$HYSTERIA_PORT/$SUBPATH/sub/normal/{username}${NC}" else - echo -e "${red}normalsub setup completed. The service failed to start. ${NC}" + echo -e "${red}Normalsub setup completed, but one or more services failed to start.${NC}" + systemctl status hysteria-normal-sub.service --no-pager + systemctl status hysteria-caddy-normalsub.service --no-pager fi } stop_service() { - if [ -f /etc/hysteria/core/scripts/normalsub/.env ]; then - source /etc/hysteria/core/scripts/normalsub/.env - fi - - if [ -n "$HYSTERIA_DOMAIN" ]; then - echo -e "${yellow}Deleting SSL certificate for domain: $HYSTERIA_DOMAIN...${NC}" - certbot delete --cert-name "$HYSTERIA_DOMAIN" --non-interactive > /dev/null 2>&1 - else - echo -e "${red}HYSTERIA_DOMAIN not found in .env. Skipping certificate deletion.${NC}" - fi - + echo -e "${yellow}Stopping Hysteria Normalsub Python service...${NC}" systemctl stop hysteria-normal-sub.service > /dev/null 2>&1 systemctl disable hysteria-normal-sub.service > /dev/null 2>&1 + echo -e "${yellow}Stopping Caddy service for Normalsub...${NC}" + systemctl stop hysteria-caddy-normalsub.service > /dev/null 2>&1 + systemctl disable hysteria-caddy-normalsub.service > /dev/null 2>&1 + systemctl daemon-reload > /dev/null 2>&1 - rm -f /etc/hysteria/core/scripts/normalsub/.env + rm -f "$NORMALSUB_ENV_FILE" + rm -f "$CADDY_CONFIG_FILE_NORMALSUB" + rm -f /etc/systemd/system/hysteria-normal-sub.service + rm -f /etc/systemd/system/hysteria-caddy-normalsub.service + systemctl daemon-reload > /dev/null 2>&1 - echo -e "${yellow}normalsub service stopped and disabled. .env file removed.${NC}" + echo -e "${green}Normalsub services stopped and disabled. Configuration files removed.${NC}" } edit_subpath() { local new_path="$1" - local env_file="/etc/hysteria/core/scripts/normalsub/.env" + + if [ ! -f "$NORMALSUB_ENV_FILE" ]; then + echo -e "${red}Error: .env file ($NORMALSUB_ENV_FILE) not found. Please run the start command first.${NC}" + exit 1 + fi if [[ ! "$new_path" =~ ^[a-zA-Z0-9]+$ ]]; then echo -e "${red}Error: New subpath must contain only alphanumeric characters (a-z, A-Z, 0-9) and cannot be empty.${NC}" exit 1 fi - if [ ! -f "$env_file" ]; then - echo -e "${red}Error: .env file ($env_file) not found. Please run the start command first.${NC}" - exit 1 - fi + source "$NORMALSUB_ENV_FILE" + local old_subpath="$SUBPATH" + + sed -i "s|^SUBPATH=.*|SUBPATH=$new_path|" "$NORMALSUB_ENV_FILE" + echo -e "${green}SUBPATH updated to $new_path in $NORMALSUB_ENV_FILE.${NC}" - if grep -q "^SUBPATH=" "$env_file"; then - sed -i "s|^SUBPATH=.*|SUBPATH=$new_path|" "$env_file" - else - echo "SUBPATH=$new_path" >> "$env_file" - fi - echo -e "${green}SUBPATH updated to $new_path in $env_file.${NC}" + update_caddy_file_normalsub "$HYSTERIA_DOMAIN" "$HYSTERIA_PORT" "$new_path" "$AIOHTTP_LISTEN_ADDRESS" "$AIOHTTP_LISTEN_PORT" + echo -e "${green}Caddyfile for Normalsub updated.${NC}" echo -e "${yellow}Restarting hysteria-normal-sub service...${NC}" - systemctl daemon-reload systemctl restart hysteria-normal-sub.service + echo -e "${yellow}Reloading hysteria-caddy-normalsub service...${NC}" + systemctl restart hysteria-caddy-normalsub.service - if systemctl is-active --quiet hysteria-normal-sub.service; then - echo -e "${green}hysteria-normal-sub service restarted successfully.${NC}" + if systemctl is-active --quiet hysteria-normal-sub.service && systemctl is-active --quiet hysteria-caddy-normalsub.service; then + echo -e "${green}Services restarted/reloaded successfully.${NC}" + echo -e "${green}New access base URL: https://$HYSTERIA_DOMAIN:$HYSTERIA_PORT/$new_path/sub/normal/{username}${NC}" else - echo -e "${red}Error: hysteria-normal-sub service failed to restart. Please check logs.${NC}" + echo -e "${red}Error: One or more services failed to restart/reload. Please check logs.${NC}" fi } case "$1" in start) if [ -z "$2" ] || [ -z "$3" ]; then - echo -e "${red}Usage: $0 start ${NC}" + echo -e "${red}Usage: $0 start ${NC}" exit 1 fi start_service "$2" "$3" @@ -145,13 +228,13 @@ case "$1" in ;; edit_subpath) if [ -z "$2" ]; then - echo -e "${red}Usage: $0 edit_subpath ${NC}" + echo -e "${red}Usage: $0 edit_subpath ${NC}" exit 1 fi edit_subpath "$2" ;; *) - echo -e "${red}Usage: $0 {start | stop | edit_subpath } ${NC}" + echo -e "${red}Usage: $0 {start | stop | edit_subpath }${NC}" exit 1 ;; esac \ No newline at end of file