feat(normalsub): Add external node URIs to subscriptions

This commit is contained in:
Whispering Wind
2025-08-06 15:37:52 +03:30
committed by GitHub
parent 133684f4fe
commit de4c3de84e
2 changed files with 171 additions and 135 deletions

View File

@ -6,7 +6,7 @@ import time
import shlex import shlex
import base64 import base64
from typing import Dict, List, Optional, Tuple, Any, Union from typing import Dict, List, Optional, Tuple, Any, Union
from dataclasses import dataclass from dataclasses import dataclass, field
from io import BytesIO from io import BytesIO
from aiohttp import web from aiohttp import web
@ -29,6 +29,7 @@ class AppConfig:
singbox_template_path: str singbox_template_path: str
hysteria_cli_path: str hysteria_cli_path: str
users_json_path: str users_json_path: str
nodes_json_path: str
rate_limit: int rate_limit: int
rate_limit_window: int rate_limit_window: int
sni: str sni: str
@ -106,6 +107,13 @@ class UserInfo:
return f"Upload: {upload}, Download: {download}, Total: {total}" return f"Upload: {upload}, Download: {download}, Total: {total}"
@dataclass
class NodeURI:
label: str
uri: str
qrcode: Optional[str] = None
@dataclass @dataclass
class TemplateContext: class TemplateContext:
username: str username: str
@ -113,11 +121,9 @@ class TemplateContext:
usage_raw: str usage_raw: str
expiration_date: str expiration_date: str
sublink_qrcode: str sublink_qrcode: str
ipv4_qrcode: Optional[str]
ipv6_qrcode: Optional[str]
sub_link: str sub_link: str
ipv4_uri: Optional[str] local_uris: List[NodeURI] = field(default_factory=list)
ipv6_uri: Optional[str] node_uris: List[NodeURI] = field(default_factory=list)
class Utils: class Utils:
@ -244,17 +250,23 @@ class HysteriaCLI:
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_all_uris(self, username: str) -> List[str]:
if ip_version: """Fetches all available URIs (local and nodes) for a user."""
return self._run_command(['show-user-uri', '-u', username, '-ip', ip_version])
else:
return self._run_command(['show-user-uri', '-u', username, '-a'])
def get_uris(self, username: str) -> Tuple[Optional[str], Optional[str]]:
output = self._run_command(['show-user-uri', '-u', username, '-a']) output = self._run_command(['show-user-uri', '-u', username, '-a'])
ipv4_uri = re.search(r'IPv4:\s*(.*)', output) if not output:
ipv6_uri = re.search(r'IPv6:\s*(.*)', output) return []
return (ipv4_uri.group(1).strip() if ipv4_uri else None, ipv6_uri.group(1).strip() if ipv6_uri else None) # Find all hy2:// links in the output
return re.findall(r'(hy2://[^\s]+)', 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 []
# This regex captures the label (e.g., "IPv4", "Node: DE (IPv6)") and the URI
matches = re.findall(r"^(.*?):\s*(hy2://[^\s]+)", output, re.MULTILINE)
return [{'label': label.strip(), 'uri': uri} for label, uri in matches]
class UriParser: class UriParser:
@ -303,74 +315,67 @@ class SingboxConfigGenerator:
raise RuntimeError(f"Error loading Singbox template: {e}") from e raise RuntimeError(f"Error loading Singbox template: {e}") from e
return self._template_cache.copy() return self._template_cache.copy()
def generate_config(self, username: str, ip_version: str, fragment: str) -> Optional[Dict[str, Any]]: def generate_config_from_uri(self, uri: str, username: str, fragment: str) -> Optional[Dict[str, Any]]:
try: """Generates a Singbox outbound config from a single Hysteria URI."""
uri = self.hysteria_cli.get_user_uri(username, ip_version)
except Exception:
print(f"Failed to get URI for {username} with IP version {ip_version}. Skipping.")
return None
if not uri: if not uri:
print(f"No URI found for {username} with IP version {ip_version}. Skipping.")
return None return None
components = UriParser.extract_uri_components(uri, f'IPv{ip_version}:')
if components is None or components.port is None: # A simplified parser since we already have the full URI
print(f"Invalid URI components for {username} with IP version {ip_version}. Skipping.") try:
parsed_url = urlparse(uri)
server = parsed_url.hostname
server_port = parsed_url.port
password = parsed_url.password
full_user = unquote(parsed_url.username)
obfs_password = parse_qs(parsed_url.query).get('obfs-password', [''])[0]
except Exception:
return None return None
return { return {
"outbounds": [{ "type": "hysteria2",
"type": "hysteria2", "tag": unquote(parsed_url.fragment),
"tag": f"{username}-Hysteria2", "server": server,
"server": components.ip, "server_port": server_port,
"server_port": components.port, "obfs": {
"obfs": { "type": "salamander",
"type": "salamander", "password": obfs_password
"password": components.obfs_password },
}, "password": f"{full_user}:{password}",
"password": f"{username}:{components.password}", "tls": {
"tls": { "enabled": True,
"enabled": True, "server_name": fragment if fragment else self.default_sni,
"server_name": fragment if fragment else self.default_sni, "insecure": True
"insecure": True }
}
}]
} }
def combine_configs(self, username: str, config_v4: Optional[Dict[str, Any]], config_v6: Optional[Dict[str, Any]]) -> 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:
return None
combined_config = self.get_template() combined_config = self.get_template()
combined_config['outbounds'] = [outbound for outbound in combined_config['outbounds'] # Clear any placeholder hysteria2 outbounds
if outbound.get('type') != 'hysteria2'] combined_config['outbounds'] = [out for out in combined_config['outbounds'] if out.get('type') != 'hysteria2']
modified_v4_outbounds = [] hysteria_outbounds = []
if config_v4: for uri in all_uris:
v4_outbound = config_v4['outbounds'][0] outbound = self.generate_config_from_uri(uri, username, fragment)
v4_outbound['tag'] = f"{username}-IPv4" if outbound:
modified_v4_outbounds.append(v4_outbound) hysteria_outbounds.append(outbound)
modified_v6_outbounds = [] if not hysteria_outbounds:
if config_v6: return None
v6_outbound = config_v6['outbounds'][0]
v6_outbound['tag'] = f"{username}-IPv6"
modified_v6_outbounds.append(v6_outbound)
select_outbounds = ["auto"] all_tags = [out['tag'] for out in hysteria_outbounds]
if config_v4:
select_outbounds.append(f"{username}-IPv4")
if config_v6:
select_outbounds.append(f"{username}-IPv6")
auto_outbounds = []
if config_v4:
auto_outbounds.append(f"{username}-IPv4")
if config_v6:
auto_outbounds.append(f"{username}-IPv6")
# Update 'select' and 'auto' groups
for outbound in combined_config['outbounds']: for outbound in combined_config['outbounds']:
if outbound.get('tag') == 'select': if outbound.get('tag') == 'select':
outbound['outbounds'] = select_outbounds outbound['outbounds'] = ["auto"] + all_tags
elif outbound.get('tag') == 'auto': elif outbound.get('tag') == 'auto':
outbound['outbounds'] = auto_outbounds outbound['outbounds'] = all_tags
combined_config['outbounds'].extend(modified_v4_outbounds + modified_v6_outbounds)
combined_config['outbounds'].extend(hysteria_outbounds)
return combined_config return combined_config
@ -383,13 +388,13 @@ class SubscriptionManager:
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"
ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username)
output_lines = [uri for uri in [ipv4_uri, ipv6_uri] if uri] all_uris = self.hysteria_cli.get_all_uris(username)
if not output_lines: if not all_uris:
return "No URI available" return "No URI available"
processed_uris = [] processed_uris = []
for uri in output_lines: for uri in all_uris:
if "v2ray" in user_agent and "ng" in user_agent: if "v2ray" in user_agent and "ng" in user_agent:
match = re.search(r'pinSHA256=sha256/([^&]+)', uri) match = re.search(r'pinSHA256=sha256/([^&]+)', uri)
if match: if match:
@ -455,6 +460,7 @@ class HysteriaServer:
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') users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
nodes_json_path = '/etc/hysteria/nodes.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__)
@ -467,6 +473,7 @@ class HysteriaServer:
singbox_template_path=singbox_template_path, singbox_template_path=singbox_template_path,
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,
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)
@ -548,22 +555,21 @@ class HysteriaServer:
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:
config_v4 = self.singbox_generator.generate_config(username, '4', fragment) all_uris = self.hysteria_cli.get_all_uris(username)
config_v6 = self.singbox_generator.generate_config(username, '6', fragment) if not all_uris:
if config_v4 is None and config_v6 is None:
return web.Response(status=404, text=f"Error: No valid URIs found for user {username}.") return web.Response(status=404, text=f"Error: No valid URIs found for user {username}.")
combined_config = self.singbox_generator.combine_configs(username, config_v4, config_v6) combined_config = self.singbox_generator.combine_configs(all_uris, username, fragment)
return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json') return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json')
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": # Should be caught earlier by user_info check if subscription == "User not found":
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')
async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext: async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext:
ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username) labeled_uris = self.hysteria_cli.get_all_labeled_uris(username)
port_str = f":{self.config.external_port}" if self.config.external_port not in [80, 443, 0] else "" 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}{port_str}"
@ -571,10 +577,21 @@ class HysteriaServer:
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/{user_info.password}" sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{user_info.password}"
ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri)
ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri)
sublink_qrcode = Utils.generate_qrcode_base64(sub_link) sublink_qrcode = Utils.generate_qrcode_base64(sub_link)
local_uris = []
node_uris = []
for item in labeled_uris:
node_uri = NodeURI(
label=item['label'],
uri=item['uri'],
qrcode=Utils.generate_qrcode_base64(item['uri'])
)
if item['label'].startswith('Node:'):
node_uris.append(node_uri)
else:
local_uris.append(node_uri)
return TemplateContext( return TemplateContext(
username=username, username=username,
@ -582,11 +599,9 @@ class HysteriaServer:
usage_raw=user_info.usage_detailed, usage_raw=user_info.usage_detailed,
expiration_date=user_info.expiration_date, expiration_date=user_info.expiration_date,
sublink_qrcode=sublink_qrcode, sublink_qrcode=sublink_qrcode,
ipv4_qrcode=ipv4_qrcode,
ipv6_qrcode=ipv6_qrcode,
sub_link=sub_link, sub_link=sub_link,
ipv4_uri=ipv4_uri, local_uris=local_uris,
ipv6_uri=ipv6_uri node_uris=node_uris
) )
async def robots_handler(self, request: web.Request) -> web.Response: async def robots_handler(self, request: web.Request) -> web.Response:
@ -605,7 +620,6 @@ class HysteriaServer:
port=self.config.aiohttp_listen_port port=self.config.aiohttp_listen_port
) )
if __name__ == '__main__': if __name__ == '__main__':
server = HysteriaServer() server = HysteriaServer()
server.run() server.run()

View File

@ -41,7 +41,6 @@
color: var(--text-dark); color: var(--text-dark);
} }
/* Animated background elements */
.background-animation { .background-animation {
position: fixed; position: fixed;
top: 0; top: 0;
@ -78,17 +77,14 @@
0% { 0% {
transform: translate(0, 0) rotate(0deg); transform: translate(0, 0) rotate(0deg);
} }
50% { 50% {
transform: translate(100px, 100px) rotate(180deg); transform: translate(100px, 100px) rotate(180deg);
} }
100% { 100% {
transform: translate(0, 0) rotate(360deg); transform: translate(0, 0) rotate(360deg);
} }
} }
/* Rest of the styles remain the same */
.container { .container {
max-width: 1000px; max-width: 1000px;
margin: 2rem auto; margin: 2rem auto;
@ -175,10 +171,10 @@
background: var(--card-bg-light); background: var(--card-bg-light);
border-radius: 1rem; border-radius: 1rem;
overflow: hidden; overflow: hidden;
/* Add this to contain the header */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 1.5rem;
} }
.qr-header { .qr-header {
@ -196,7 +192,6 @@
padding: 2rem; padding: 2rem;
} }
.dark-mode .qr-section { .dark-mode .qr-section {
background: rgba(31, 41, 55, 0.8); background: rgba(31, 41, 55, 0.8);
border-color: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.05);
@ -229,6 +224,14 @@
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.uri-unavailable {
color: #9ca3af;
}
.dark-mode .uri-unavailable {
color: #4b5563;
}
.btn-group { .btn-group {
display: flex; display: flex;
@ -278,6 +281,7 @@
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: none; display: none;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
z-index: 100;
} }
.dark-mode .loading-indicator { .dark-mode .loading-indicator {
@ -355,63 +359,81 @@
</div> </div>
</div> </div>
</div> </div>
<div class="qr-section"> <div class="qr-section">
<div class="qr-header"> <div class="qr-header">
<i class="fas fa-qrcode"></i> <i class="fas fa-link"></i>
QR Codes Subscription Link
</div> </div>
<div class="qr-content"> <div class="qr-content">
<div class="qr-grid"> <div class="qr-grid">
<div class="qr-item"> <div class="qr-item" style="grid-column: 1 / -1;">
<h3 class="qr-title">Subscription Link</h3> <h3 class="qr-title">Universal Subscription Link</h3>
<img src="{{ sublink_qrcode }}" alt="Subscription QR Code" class="qrcode"> <img src="{{ sublink_qrcode }}" alt="Subscription QR Code" class="qrcode">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ sub_link }}')"> <button class="btn btn-primary" onclick="copyToClipboard('{{ sub_link }}')">
<i class="fas fa-copy"></i> Copy <i class="fas fa-copy"></i> Copy
</button> </button>
<!-- <a href="{{ sub_link }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
<i class="fas fa-external-link-alt"></i> Open
</a> -->
</div> </div>
</div> </div>
<div class="qr-item">
<h3 class="qr-title">IPv4 URI</h3>
{% if ipv4_qrcode %}
<img src="{{ ipv4_qrcode }}" alt="IPv4 QR Code" class="qrcode">
<div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ ipv4_uri }}')">
<i class="fas fa-copy"></i> Copy
</button>
<!-- <a href="{{ ipv4_uri }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
<i class="fas fa-external-link-alt"></i> Open
</a> -->
</div>
{% else %}
<p class="uri-unavailable">IPv4 URI not available</p>
{% endif %}
</div>
<div class="qr-item">
<h3 class="qr-title">IPv6 URI</h3>
{% if ipv6_qrcode %}
<img src="{{ ipv6_qrcode }}" alt="IPv6 QR Code" class="qrcode">
<div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ ipv6_uri }}')">
<i class="fas fa-copy"></i> Copy
</button>
<!-- <a href="{{ ipv6_uri }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
<i class="fas fa-external-link-alt"></i> Open
</a> -->
</div>
{% else %}
<p class="uri-unavailable">IPv6 URI not available</p>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="qr-section">
<div class="qr-header">
<i class="fas fa-server"></i>
Local Server Connections
</div>
<div class="qr-content">
<div class="qr-grid">
{% for item in local_uris %}
<div class="qr-item">
<h3 class="qr-title">{{ item.label }} URI</h3>
{% if item.qrcode %}
<img src="{{ item.qrcode }}" alt="{{ item.label }} QR Code" class="qrcode">
<div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ item.uri }}')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
{% else %}
<p class="uri-unavailable">{{ item.label }} URI not available</p>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% if node_uris %}
<div class="qr-section">
<div class="qr-header">
<i class="fas fa-globe-americas"></i>
External Nodes
</div>
<div class="qr-content">
<div class="qr-grid">
{% for item in node_uris %}
<div class="qr-item">
<h3 class="qr-title">{{ item.label }}</h3>
{% if item.qrcode %}
<img src="{{ item.qrcode }}" alt="{{ item.label }} QR Code" class="qrcode">
<div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ item.uri }}')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
{% else %}
<p class="uri-unavailable">{{ item.label }} URI not available</p>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div> </div>
<script> <script>