feat(normalsub): use user password in subscription link path
Refactored normalsub.py to use the user's password as the identifier in the subscription URL path instead of the username, enhancing privacy by making user enumeration harder.
This commit is contained in:
@ -193,7 +193,7 @@ def show_uri(args: argparse.Namespace) -> None:
|
|||||||
if args.normalsub and is_service_active("hysteria-normal-sub.service"):
|
if args.normalsub and is_service_active("hysteria-normal-sub.service"):
|
||||||
domain, port, subpath = get_normalsub_domain_and_port()
|
domain, port, subpath = get_normalsub_domain_and_port()
|
||||||
if domain and port:
|
if domain and port:
|
||||||
print(f"\nNormal-SUB Sublink:\nhttps://{domain}:{port}/{subpath}/sub/normal/{args.username}#Hysteria2\n")
|
print(f"\nNormal-SUB Sublink:\nhttps://{domain}:{port}/{subpath}/sub/normal/{auth_password}#Hysteria2\n")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main function to parse arguments and show URIs."""
|
"""Main function to parse arguments and show URIs."""
|
||||||
|
|||||||
@ -21,13 +21,14 @@ load_dotenv()
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
domain: str
|
domain: str
|
||||||
external_port: int
|
external_port: int
|
||||||
aiohttp_listen_address: str
|
aiohttp_listen_address: str
|
||||||
aiohttp_listen_port: int
|
aiohttp_listen_port: int
|
||||||
sni_file: str
|
sni_file: str
|
||||||
singbox_template_path: str
|
singbox_template_path: str
|
||||||
hysteria_cli_path: str
|
hysteria_cli_path: str
|
||||||
|
users_json_path: str
|
||||||
rate_limit: int
|
rate_limit: int
|
||||||
rate_limit_window: int
|
rate_limit_window: int
|
||||||
sni: str
|
sni: str
|
||||||
@ -65,6 +66,7 @@ class UriComponents:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class UserInfo:
|
class UserInfo:
|
||||||
username: str
|
username: str
|
||||||
|
password: str
|
||||||
upload_bytes: int
|
upload_bytes: int
|
||||||
download_bytes: int
|
download_bytes: int
|
||||||
max_download_bytes: int
|
max_download_bytes: int
|
||||||
@ -161,8 +163,9 @@ class Utils:
|
|||||||
|
|
||||||
|
|
||||||
class HysteriaCLI:
|
class HysteriaCLI:
|
||||||
def __init__(self, cli_path: str):
|
def __init__(self, cli_path: str, users_json_path: str):
|
||||||
self.cli_path = cli_path
|
self.cli_path = cli_path
|
||||||
|
self.users_json_path = users_json_path
|
||||||
|
|
||||||
def _run_command(self, args: List[str]) -> str:
|
def _run_command(self, args: List[str]) -> str:
|
||||||
try:
|
try:
|
||||||
@ -171,7 +174,7 @@ class HysteriaCLI:
|
|||||||
stdout, stderr = process.communicate()
|
stdout, stderr = process.communicate()
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
if "User not found" in stderr:
|
if "User not found" in stderr:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
print(f"Hysteria CLI error: {stderr}")
|
print(f"Hysteria CLI error: {stderr}")
|
||||||
raise subprocess.CalledProcessError(process.returncode, command, output=stdout, stderr=stderr)
|
raise subprocess.CalledProcessError(process.returncode, command, output=stdout, stderr=stderr)
|
||||||
@ -180,14 +183,57 @@ 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]:
|
||||||
|
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 None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An unexpected error occurred while reading users file for password: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_username_by_password(self, password_token: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
with open(self.users_json_path, 'r') as f:
|
||||||
|
users_data = json.load(f)
|
||||||
|
for username, details in users_data.items():
|
||||||
|
if details.get('password') == password_token:
|
||||||
|
return username
|
||||||
|
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
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An unexpected error occurred while reading users file: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_user_info(self, username: str) -> Optional[UserInfo]:
|
def get_user_info(self, username: str) -> Optional[UserInfo]:
|
||||||
raw_info_str = self._run_command(['get-user', '-u', username])
|
raw_info_str = self._run_command(['get-user', '-u', username])
|
||||||
if raw_info_str is None:
|
if raw_info_str is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
user_password = self.get_user_password(username)
|
||||||
|
if user_password is None:
|
||||||
|
print(f"Warning: Password for user '{username}' could not be fetched from {self.users_json_path}. Cannot create UserInfo.")
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
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,
|
||||||
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),
|
||||||
@ -196,7 +242,7 @@ class HysteriaCLI:
|
|||||||
)
|
)
|
||||||
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_user_uri(self, username: str, ip_version: Optional[str] = None) -> str:
|
def get_user_uri(self, username: str, ip_version: Optional[str] = None) -> str:
|
||||||
if ip_version:
|
if ip_version:
|
||||||
@ -376,7 +422,7 @@ class HysteriaServer:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.config = self._load_config()
|
self.config = self._load_config()
|
||||||
self.rate_limiter = RateLimiter(self.config.rate_limit, self.config.rate_limit_window)
|
self.rate_limiter = RateLimiter(self.config.rate_limit, self.config.rate_limit_window)
|
||||||
self.hysteria_cli = HysteriaCLI(self.config.hysteria_cli_path)
|
self.hysteria_cli = HysteriaCLI(self.config.hysteria_cli_path, self.config.users_json_path)
|
||||||
self.singbox_generator = SingboxConfigGenerator(self.hysteria_cli, self.config.sni)
|
self.singbox_generator = SingboxConfigGenerator(self.hysteria_cli, self.config.sni)
|
||||||
self.singbox_generator.set_template_path(self.config.singbox_template_path)
|
self.singbox_generator.set_template_path(self.config.singbox_template_path)
|
||||||
self.subscription_manager = SubscriptionManager(self.hysteria_cli, self.config)
|
self.subscription_manager = SubscriptionManager(self.hysteria_cli, self.config)
|
||||||
@ -390,16 +436,15 @@ class HysteriaServer:
|
|||||||
safe_subpath = self.validate_and_escape_subpath(self.config.subpath)
|
safe_subpath = self.validate_and_escape_subpath(self.config.subpath)
|
||||||
|
|
||||||
base_path = f'/{safe_subpath}'
|
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}/sub/normal/{{password_token}}', self.handle)
|
||||||
self.app.router.add_get(f'{base_path}/robots.txt', self.robots_handler)
|
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)
|
self.app.router.add_route('*', f'{base_path}/{{tail:.*}}', self.handle_404_subpath)
|
||||||
|
|
||||||
|
|
||||||
def _load_config(self) -> AppConfig:
|
def _load_config(self) -> AppConfig:
|
||||||
domain = os.getenv('HYSTERIA_DOMAIN', 'localhost')
|
domain = os.getenv('HYSTERIA_DOMAIN', 'localhost')
|
||||||
external_port = int(os.getenv('HYSTERIA_PORT', '443'))
|
external_port = int(os.getenv('HYSTERIA_PORT', '443'))
|
||||||
aiohttp_listen_address = os.getenv('AIOHTTP_LISTEN_ADDRESS', '127.0.0.1')
|
aiohttp_listen_address = os.getenv('AIOHTTP_LISTEN_ADDRESS', '127.0.0.1')
|
||||||
aiohttp_listen_port = int(os.getenv('AIOHTTP_LISTEN_PORT', '28261'))
|
aiohttp_listen_port = int(os.getenv('AIOHTTP_LISTEN_PORT', '33261'))
|
||||||
|
|
||||||
subpath = os.getenv('SUBPATH', '').strip().strip("/")
|
subpath = os.getenv('SUBPATH', '').strip().strip("/")
|
||||||
if not subpath or not self.is_valid_subpath(subpath):
|
if not subpath or not self.is_valid_subpath(subpath):
|
||||||
@ -409,18 +454,20 @@ class HysteriaServer:
|
|||||||
sni_file = '/etc/hysteria/.configs.env'
|
sni_file = '/etc/hysteria/.configs.env'
|
||||||
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
|
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
|
||||||
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')
|
||||||
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__)
|
||||||
|
|
||||||
sni = self._load_sni_from_env(sni_file)
|
sni = self._load_sni_from_env(sni_file)
|
||||||
return AppConfig(domain=domain, external_port=external_port,
|
return AppConfig(domain=domain, external_port=external_port,
|
||||||
aiohttp_listen_address=aiohttp_listen_address,
|
aiohttp_listen_address=aiohttp_listen_address,
|
||||||
aiohttp_listen_port=aiohttp_listen_port,
|
aiohttp_listen_port=aiohttp_listen_port,
|
||||||
sni_file=sni_file,
|
sni_file=sni_file,
|
||||||
singbox_template_path=singbox_template_path,
|
singbox_template_path=singbox_template_path,
|
||||||
hysteria_cli_path=hysteria_cli_path,
|
hysteria_cli_path=hysteria_cli_path,
|
||||||
rate_limit=rate_limit, rate_limit_window=rate_limit_window,
|
users_json_path=users_json_path,
|
||||||
|
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)
|
||||||
|
|
||||||
@ -435,19 +482,19 @@ class HysteriaServer:
|
|||||||
return "bts.com"
|
return "bts.com"
|
||||||
|
|
||||||
def is_valid_subpath(self, subpath: str) -> bool:
|
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]+$", subpath))
|
||||||
|
|
||||||
def validate_and_escape_subpath(self, subpath: str) -> str:
|
def validate_and_escape_subpath(self, subpath: str) -> str:
|
||||||
if not self.is_valid_subpath(subpath):
|
if not self.is_valid_subpath(subpath):
|
||||||
raise ValueError(f"Invalid subpath: {subpath}")
|
raise ValueError(f"Invalid subpath: {subpath}")
|
||||||
return re.escape(subpath)
|
return re.escape(subpath)
|
||||||
|
|
||||||
@middleware
|
@middleware
|
||||||
async def _rate_limit_middleware(self, request: web.Request, handler):
|
async def _rate_limit_middleware(self, request: web.Request, handler):
|
||||||
client_ip_hdr = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP'))
|
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
|
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):
|
if client_ip and not self.rate_limiter.check_limit(client_ip):
|
||||||
return web.Response(status=429, text="Rate limit exceeded.")
|
return web.Response(status=429, text="Rate limit exceeded.")
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
|
|
||||||
@ -457,7 +504,7 @@ class HysteriaServer:
|
|||||||
if not request.path.startswith(expected_prefix):
|
if not request.path.startswith(expected_prefix):
|
||||||
print(f"Warning: Request {request.path} reached aiohttp outside expected subpath {expected_prefix}. Closing connection.")
|
print(f"Warning: Request {request.path} reached aiohttp outside expected subpath {expected_prefix}. Closing connection.")
|
||||||
if request.transport is not None:
|
if request.transport is not None:
|
||||||
request.transport.close()
|
request.transport.close()
|
||||||
raise web.HTTPForbidden()
|
raise web.HTTPForbidden()
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
|
|
||||||
@ -469,22 +516,27 @@ class HysteriaServer:
|
|||||||
|
|
||||||
async def handle(self, request: web.Request) -> web.Response:
|
async def handle(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
username_raw = request.match_info.get('username', '')
|
password_token_raw = request.match_info.get('password_token', '')
|
||||||
if not username_raw: # Should not happen due to route def
|
if not password_token_raw:
|
||||||
return web.Response(status=400, text="Error: Missing 'username' parameter.")
|
return web.Response(status=400, text="Error: Missing 'password_token' parameter.")
|
||||||
username = Utils.sanitize_input(username_raw, r'^[a-zA-Z0-9_-]+$')
|
|
||||||
|
password_token = Utils.sanitize_input(password_token_raw, r'^[a-zA-Z0-9]+$')
|
||||||
|
|
||||||
|
username = self.hysteria_cli.get_username_by_password(password_token)
|
||||||
|
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_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}' not found.")
|
return web.Response(status=404, text=f"User '{username}' details not found.")
|
||||||
|
|
||||||
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', '')
|
||||||
if not user_agent.startswith('hiddifynext') and ('singbox' in user_agent or 'sing' in user_agent):
|
if not user_agent.startswith('hiddifynext') and ('singbox' in user_agent or 'sing' in user_agent):
|
||||||
return await self._handle_singbox(username, fragment, user_info)
|
return await self._handle_singbox(username, fragment, user_info)
|
||||||
return await self._handle_normalsub(request, username, user_info)
|
return await self._handle_normalsub(request, username, user_info)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return web.Response(status=400, text=f"Error: {e}")
|
return web.Response(status=400, text=f"Error: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -492,7 +544,7 @@ class HysteriaServer:
|
|||||||
return web.Response(status=500, text="Error: Internal server error")
|
return web.Response(status=500, text="Error: Internal server error")
|
||||||
|
|
||||||
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')
|
||||||
|
|
||||||
async def _handle_singbox(self, username: str, fragment: str, user_info: UserInfo) -> web.Response:
|
async def _handle_singbox(self, username: str, fragment: str, user_info: UserInfo) -> web.Response:
|
||||||
@ -506,7 +558,7 @@ class HysteriaServer:
|
|||||||
async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
|
async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
|
||||||
user_agent = request.headers.get('User-Agent', '').lower()
|
user_agent = request.headers.get('User-Agent', '').lower()
|
||||||
subscription = self.subscription_manager.get_normal_subscription(username, user_agent)
|
subscription = self.subscription_manager.get_normal_subscription(username, user_agent)
|
||||||
if subscription == "User not found":
|
if subscription == "User not found": # Should be caught earlier by user_info check
|
||||||
return web.Response(status=404, text=f"User '{username}' not found.")
|
return web.Response(status=404, text=f"User '{username}' not found.")
|
||||||
return web.Response(text=subscription, content_type='text/plain')
|
return web.Response(text=subscription, content_type='text/plain')
|
||||||
|
|
||||||
@ -518,7 +570,7 @@ class HysteriaServer:
|
|||||||
if not Utils.is_valid_url(base_url):
|
if not Utils.is_valid_url(base_url):
|
||||||
print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.")
|
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}"
|
sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{user_info.password}"
|
||||||
|
|
||||||
ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri)
|
ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri)
|
||||||
ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri)
|
ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri)
|
||||||
@ -546,10 +598,10 @@ class HysteriaServer:
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
print(f"Starting Hysteria Normalsub server on {self.config.aiohttp_listen_address}:{self.config.aiohttp_listen_port}")
|
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}/")
|
print(f"External access via Caddy should be at https://{self.config.domain}:{self.config.external_port}/{self.config.subpath}/sub/normal/<USER_PASSWORD>")
|
||||||
web.run_app(
|
web.run_app(
|
||||||
self.app,
|
self.app,
|
||||||
host=self.config.aiohttp_listen_address,
|
host=self.config.aiohttp_listen_address,
|
||||||
port=self.config.aiohttp_listen_port
|
port=self.config.aiohttp_listen_port
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user