feat(normalsub): Implement flexible, path-based subscription URLs
Refactors the NormalSub feature to move away from a hardcoded `/sub/normal/` path and a restrictive single-word subpath. This change introduces support for multi-segment, slash-separated subpaths (e.g., `path/to/resource`), providing greater flexibility for creating descriptive and structured subscription URLs.
This commit is contained in:
@ -586,8 +586,9 @@ def singbox(action: str, domain: str, port: int):
|
||||
help='Action to perform: start, stop, or edit_subpath')
|
||||
@click.option('--domain', '-d', required=False, help='Domain name for SSL (for start action)', type=str)
|
||||
@click.option('--port', '-p', required=False, help='Port number for NormalSub service (for start action)', type=int)
|
||||
@click.option('--subpath', '-sp', required=False, help='New subpath (alphanumeric, for edit_subpath action)', type=str)
|
||||
@click.option('--subpath', '-sp', required=False, help="New subpath (e.g., 'path' or 'path/to/resource', for edit_subpath action)", type=str)
|
||||
def normalsub(action: str, domain: str, port: int, subpath: str):
|
||||
"""Manage the NormalSub service."""
|
||||
try:
|
||||
if action == 'start':
|
||||
if not domain or not port:
|
||||
|
||||
@ -5,6 +5,7 @@ from datetime import datetime
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
from dotenv import dotenv_values
|
||||
import re
|
||||
|
||||
import traffic
|
||||
|
||||
@ -661,8 +662,8 @@ def edit_normalsub_subpath(new_subpath: str):
|
||||
'''Edits the subpath for NormalSub service.'''
|
||||
if not new_subpath:
|
||||
raise InvalidInputError('Error: New subpath cannot be empty.')
|
||||
if not new_subpath.isalnum():
|
||||
raise InvalidInputError('Error: New subpath must contain only alphanumeric characters (a-z, A-Z, 0-9).')
|
||||
if not re.match(r"^[a-zA-Z0-9]+(?:/[a-zA-Z0-9]+)*$", new_subpath):
|
||||
raise InvalidInputError("Error: Invalid subpath format. Must be alphanumeric segments separated by single slashes (e.g., 'path' or 'path/to/resource').")
|
||||
|
||||
run_cmd(['bash', Command.INSTALL_NORMALSUB.value, 'edit_subpath', new_subpath])
|
||||
|
||||
|
||||
@ -206,7 +206,7 @@ def show_uri(args: argparse.Namespace) -> None:
|
||||
if args.normalsub and is_service_active("hysteria-normal-sub.service"):
|
||||
domain, port, subpath = get_normalsub_domain_and_port()
|
||||
if domain and port:
|
||||
print(f"\nNormal-SUB Sublink:\nhttps://{domain}:{port}/{subpath}/sub/normal/{auth_password}#Hysteria2\n")
|
||||
print(f"\nNormal-SUB Sublink:\nhttps://{domain}:{port}/{subpath}/{auth_password}#Hysteria2\n")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Hysteria2 URI Generator")
|
||||
|
||||
@ -117,7 +117,7 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
|
||||
user_output["nodes"].append({"name": node_name, "uri": uri})
|
||||
|
||||
if ns_domain and ns_port and ns_subpath:
|
||||
user_output["normal_sub"] = f"https://{ns_domain}:{ns_port}/{ns_subpath}/sub/normal/{auth_password}#{username}"
|
||||
user_output["normal_sub"] = f"https://{ns_domain}:{ns_port}/{ns_subpath}/{auth_password}#{username}"
|
||||
|
||||
results.append(user_output)
|
||||
|
||||
|
||||
@ -432,12 +432,12 @@ class HysteriaServer:
|
||||
self._noindex_middleware
|
||||
])
|
||||
|
||||
safe_subpath = self.validate_and_escape_subpath(self.config.subpath)
|
||||
safe_subpath = self.validate_subpath_for_routing(self.config.subpath)
|
||||
|
||||
base_path = f'/{safe_subpath}'
|
||||
self.app.router.add_get(f'{base_path}/sub/normal/style.css', self.handle_style)
|
||||
self.app.router.add_get(f'{base_path}/sub/normal/script.js', self.handle_script)
|
||||
self.app.router.add_get(f'{base_path}/sub/normal/{{password_token}}', self.handle)
|
||||
self.app.router.add_get(f'{base_path}/style.css', self.handle_style)
|
||||
self.app.router.add_get(f'{base_path}/script.js', self.handle_script)
|
||||
self.app.router.add_get(f'{base_path}/{{password_token}}', self.handle)
|
||||
self.app.router.add_get(f'{base_path}/robots.txt', self.robots_handler)
|
||||
self.app.router.add_route('*', f'{base_path}/{{tail:.*}}', self.handle_404_subpath)
|
||||
|
||||
@ -450,7 +450,7 @@ class HysteriaServer:
|
||||
subpath = os.getenv('SUBPATH', '').strip().strip("/")
|
||||
if not subpath or not self.is_valid_subpath(subpath):
|
||||
raise ValueError(
|
||||
f"Invalid or empty SUBPATH: '{subpath}'. Subpath must be non-empty and contain only alphanumeric characters.")
|
||||
f"Invalid or empty SUBPATH: '{subpath}'. Subpath must be valid segments separated by slashes (e.g., 'path' or 'path/to/resource').")
|
||||
|
||||
sni_file = '/etc/hysteria/.configs.env'
|
||||
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
|
||||
@ -485,12 +485,12 @@ class HysteriaServer:
|
||||
return "bts.com"
|
||||
|
||||
def is_valid_subpath(self, subpath: str) -> bool:
|
||||
return bool(re.match(r"^[a-zA-Z0-9]+$", subpath))
|
||||
return bool(re.match(r"^[a-zA-Z0-9]+(?:/[a-zA-Z0-9]+)*$", subpath))
|
||||
|
||||
def validate_and_escape_subpath(self, subpath: str) -> str:
|
||||
def validate_subpath_for_routing(self, subpath: str) -> str:
|
||||
if not self.is_valid_subpath(subpath):
|
||||
raise ValueError(f"Invalid subpath: {subpath}")
|
||||
return re.escape(subpath)
|
||||
return subpath
|
||||
|
||||
@middleware
|
||||
async def _rate_limit_middleware(self, request: web.Request, handler):
|
||||
@ -610,7 +610,7 @@ class HysteriaServer:
|
||||
if not Utils.is_valid_url(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/{user_info.password}"
|
||||
sub_link = f"{base_url}/{self.config.subpath}/{user_info.password}"
|
||||
sub_link_encoded = quote(sub_link, safe='')
|
||||
sublink_qrcode = Utils.generate_qrcode_base64(sub_link)
|
||||
|
||||
@ -665,7 +665,7 @@ class HysteriaServer:
|
||||
|
||||
def run(self):
|
||||
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}/sub/normal/<USER_PASSWORD>")
|
||||
print(f"External access via Caddy should be at https://{self.config.domain}:{self.config.external_port}/{self.config.subpath}/<USER_PASSWORD>")
|
||||
web.run_app(
|
||||
self.app,
|
||||
host=self.config.aiohttp_listen_address,
|
||||
|
||||
@ -140,7 +140,7 @@ start_service() {
|
||||
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
|
||||
source "$NORMALSUB_ENV_FILE"
|
||||
|
||||
update_caddy_file_normalsub "$HYSTERIA_DOMAIN" "$HYSTERIA_PORT" "$SUBPATH" "$AIOHTTP_LISTEN_ADDRESS" "$AIOHTTP_LISTEN_PORT"
|
||||
|
||||
@ -157,7 +157,7 @@ start_service() {
|
||||
|
||||
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}"
|
||||
echo -e "${green}Access base URL: https://$HYSTERIA_DOMAIN:$HYSTERIA_PORT/$SUBPATH/{username}${NC}"
|
||||
else
|
||||
echo -e "${red}Normalsub setup completed, but one or more services failed to start.${NC}"
|
||||
systemctl status hysteria-normal-sub.service --no-pager
|
||||
@ -192,8 +192,8 @@ edit_subpath() {
|
||||
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}"
|
||||
if [[ ! "$new_path" =~ ^[a-zA-Z0-9]+(/[a-zA-Z0-9]+)*$ ]]; then
|
||||
echo -e "${red}Error: Invalid subpath format. Must be alphanumeric segments separated by single slashes (e.g., 'path' or 'path/to/resource'). Cannot start/end with a slash or have consecutive slashes.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -224,7 +224,7 @@ edit_subpath() {
|
||||
|
||||
if systemctl is-active --quiet hysteria-normal-sub.service && systemctl is-active --quiet hysteria-caddy-normalsub.service; then
|
||||
echo -e "${green}Services updated successfully.${NC}"
|
||||
echo -e "${green}New access base URL: https://$HYSTERIA_DOMAIN:$HYSTERIA_PORT/$new_path/sub/normal/{username}${NC}"
|
||||
echo -e "${green}New access base URL: https://$HYSTERIA_DOMAIN:$HYSTERIA_PORT/$new_path/{username}${NC}"
|
||||
echo -e "${cyan}Old subpath '$old_subpath' is no longer accessible.${NC}"
|
||||
else
|
||||
echo -e "${red}Error: One or more services failed to restart/reload. Please check logs.${NC}"
|
||||
|
||||
@ -88,7 +88,8 @@ $(document).ready(function () {
|
||||
|
||||
function isValidSubPath(subpath) {
|
||||
if (!subpath) return false;
|
||||
return /^[a-zA-Z0-9]+$/.test(subpath);
|
||||
const subpathRegex = /^[a-zA-Z0-9]+(?:\/[a-zA-Z0-9]+)*$/;
|
||||
return subpathRegex.test(subpath);
|
||||
}
|
||||
|
||||
function isValidIPorDomain(input) {
|
||||
|
||||
@ -6,7 +6,7 @@ class StartInputBody(BaseModel):
|
||||
port: int
|
||||
|
||||
class EditSubPathInputBody(BaseModel):
|
||||
subpath: str = Field(..., min_length=1, pattern=r"^[a-zA-Z0-9]+$", description="The new subpath, must be alphanumeric.")
|
||||
subpath: str = Field(..., min_length=1, pattern=r"^[a-zA-Z0-9]+(?:/[a-zA-Z0-9]+)*$", description="The new subpath, must be alphanumeric.")
|
||||
|
||||
class GetSubPathResponse(BaseModel):
|
||||
subpath: Optional[str] = Field(None, description="The current NormalSub subpath, or null if not set/found.")
|
||||
@ -142,9 +142,13 @@
|
||||
<div class='form-group'>
|
||||
<label for='normal_subpath_input'>Subscription Path Segment:</label>
|
||||
<input type='text' class='form-control' id='normal_subpath_input'
|
||||
placeholder='e.g., mysub (becomes /mysub/...)'>
|
||||
placeholder='e.g., mysub or path/to/resource'>
|
||||
<small class="form-text text-muted">
|
||||
This path will be part of the URL. Do not use a leading or trailing slash.
|
||||
Example: <code>path/to/resource</code>
|
||||
</small>
|
||||
<div class="invalid-feedback">
|
||||
Please enter a valid subpath (alphanumeric characters only, e.g., mysub).
|
||||
Invalid format. Use alphanumeric segments separated by single slashes.
|
||||
</div>
|
||||
</div>
|
||||
<button id="normal_subpath_save_btn" type='button' class='btn btn-primary'>
|
||||
|
||||
Reference in New Issue
Block a user