Merge pull request #180 from ReturnFI/beta

Privacy Boost, WARP Improvements & Caddy Proxy Integration
This commit is contained in:
Whispering Wind
2025-05-28 23:14:39 +03:30
committed by GitHub
10 changed files with 386 additions and 278 deletions

View File

@ -1,15 +1,18 @@
# [1.10.1] - 2025-05-24 # [1.11.0] - 2025-05-28
## ✨ New Features
* 🔐 **feat:** Add option to reset Web Panel admin credentials via CLI #### ⚠️ Attention Required
* 🧩 **feat:** Add "Reset Web Panel credentials" option to Bash menu
* 🌐 **feat:** Add API endpoint to fetch IP Limiter configuration
## 🖌️ UI Improvements 🚨 **Important:** Due to changes in the `normalsub` system (now using passwords in links and routing through Caddy),
you must **re-activate NormalSUB** from the settings panel after upgrading to **v1.11.0**.
* 🧼 **clean:** Removed shield icons from footer for a cleaner look Failure to do so may result in broken subscription links.
## 🐛 Fixes ---
* 📊 **fix:** Align memory usage reporting with `free -m` in `server_info.py` #### ✨ Features & Improvements
* 🔐 **normalsub:** Use **user password** instead of username in subscription path for improved privacy (harder to enumerate users)
* 🔁 **normalsub:** Now uses **Caddy** as a reverse proxy for better performance and flexibility
* 🧩 **WARP API:** Adapted backend to support **JSON-based status output**
* 🛠️ **Script Update:** WARP status script now outputs clean **JSON**, allowing easier parsing and integration

View File

@ -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."""

View File

@ -1,5 +1,4 @@
import os import os
import ssl
import json import json
import subprocess import subprocess
import re import re
@ -23,12 +22,13 @@ load_dotenv()
@dataclass @dataclass
class AppConfig: class AppConfig:
domain: str domain: str
cert_file: str external_port: int
key_file: str aiohttp_listen_address: str
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
@ -66,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
@ -154,7 +155,6 @@ class Utils:
@staticmethod @staticmethod
def is_valid_url(url: str) -> bool: def is_valid_url(url: str) -> bool:
"""Checks if the given string is a valid URL."""
try: try:
result = urlparse(url) result = urlparse(url)
return all([result.scheme, result.netloc]) return all([result.scheme, result.netloc])
@ -163,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:
@ -173,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 # Indicate user not found 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)
@ -182,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 # User not found 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),
@ -378,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)
@ -392,36 +436,39 @@ 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'/{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)
def _load_config(self) -> AppConfig: def _load_config(self) -> AppConfig:
domain = os.getenv('HYSTERIA_DOMAIN', 'localhost') domain = os.getenv('HYSTERIA_DOMAIN', 'localhost')
cert_file = os.getenv('HYSTERIA_CERTFILE') external_port = int(os.getenv('HYSTERIA_PORT', '443'))
key_file = os.getenv('HYSTERIA_KEYFILE') aiohttp_listen_address = os.getenv('AIOHTTP_LISTEN_ADDRESS', '127.0.0.1')
port = int(os.getenv('HYSTERIA_PORT', '3326')) aiohttp_listen_port = int(os.getenv('AIOHTTP_LISTEN_PORT', '33261'))
subpath = os.getenv('SUBPATH', '').strip().strip("/")
if not self.is_valid_subpath(subpath): subpath = os.getenv('SUBPATH', '').strip().strip("/")
if not subpath or not self.is_valid_subpath(subpath):
raise ValueError( 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' 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, cert_file=cert_file, key_file=key_file, port=port, sni_file=sni_file, return AppConfig(domain=domain, external_port=external_port,
singbox_template_path=singbox_template_path, hysteria_cli_path=hysteria_cli_path, aiohttp_listen_address=aiohttp_listen_address,
rate_limit=rate_limit, rate_limit_window=rate_limit_window, sni=sni, template_dir=template_dir, aiohttp_listen_port=aiohttp_listen_port,
sni_file=sni_file,
singbox_template_path=singbox_template_path,
hysteria_cli_path=hysteria_cli_path,
users_json_path=users_json_path,
rate_limit=rate_limit, rate_limit_window=rate_limit_window,
sni=sni, template_dir=template_dir,
subpath=subpath) subpath=subpath)
def _load_sni_from_env(self, sni_file: str) -> str: def _load_sni_from_env(self, sni_file: str) -> str:
@ -435,26 +482,27 @@ class HysteriaServer:
return "bts.com" return "bts.com"
def is_valid_subpath(self, subpath: str) -> bool: 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: def validate_and_escape_subpath(self, subpath: str) -> str:
"""Validates the subpath and returns the escaped version."""
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 = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP', request.remote)) client_ip_hdr = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP'))
if not self.rate_limiter.check_limit(client_ip): # type: ignore 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 web.Response(status=429, text="Rate limit exceeded.")
return await handler(request) return await handler(request)
@middleware @middleware
async def _invalid_endpoint_middleware(self, request: web.Request, handler): async def _invalid_endpoint_middleware(self, request: web.Request, handler):
path = f'/{self.config.subpath}/' expected_prefix = f'/{self.config.subpath}/'
if not request.path.startswith(path): 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: if request.transport is not None:
request.transport.close() request.transport.close()
raise web.HTTPForbidden() raise web.HTTPForbidden()
@ -468,13 +516,20 @@ class HysteriaServer:
async def handle(self, request: web.Request) -> web.Response: async def handle(self, request: web.Request) -> web.Response:
try: try:
username = Utils.sanitize_input(request.match_info.get('username', ''), r'^[a-zA-Z0-9_-]+$') password_token_raw = request.match_info.get('password_token', '')
if not username: 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.")
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)
@ -503,17 +558,19 @@ 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')
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) 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): 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}"
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)
@ -535,21 +592,18 @@ class HysteriaServer:
async def robots_handler(self, request: web.Request) -> web.Response: async def robots_handler(self, request: web.Request) -> web.Response:
return web.Response(text="User-agent: *\nDisallow: /", content_type="text/plain") return web.Response(text="User-agent: *\nDisallow: /", content_type="text/plain")
async def handle_404(self, request: web.Request) -> web.Response: async def handle_404_subpath(self, request: web.Request) -> web.Response:
"""Handles 404 Not Found errors *within* the subpath.""" print(f"404 Not Found (within subpath, unhandled by specific routes): {request.path}")
print(f"404 Not Found (within subpath): {request.path}")
return web.Response(status=404, text="Not Found within Subpath") 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): def run(self):
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) print(f"Starting Hysteria Normalsub server on {self.config.aiohttp_listen_address}:{self.config.aiohttp_listen_port}")
ssl_context.load_cert_chain(certfile=self.config.cert_file, keyfile=self.config.key_file) print(f"External access via Caddy should be at https://{self.config.domain}:{self.config.external_port}/{self.config.subpath}/sub/normal/<USER_PASSWORD>")
ssl_context.set_ciphers('ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384') web.run_app(
web.run_app(self.app, port=self.config.port, ssl_context=ssl_context) self.app,
host=self.config.aiohttp_listen_address,
port=self.config.aiohttp_listen_port
)
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -2,30 +2,100 @@
source /etc/hysteria/core/scripts/utils.sh source /etc/hysteria/core/scripts/utils.sh
define_colors 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() { update_env_file() {
local domain=$1 local domain=$1
local port=$2 local external_port=$2
local cert_dir="/etc/letsencrypt/live/$domain" local aiohttp_listen_address=$3
local aiohttp_listen_port=$4
local subpath_val=$(pwgen -s 32 1)
cat <<EOL > /etc/hysteria/core/scripts/normalsub/.env cat <<EOL > "$NORMALSUB_ENV_FILE"
HYSTERIA_DOMAIN=$domain HYSTERIA_DOMAIN=$domain
HYSTERIA_PORT=$port HYSTERIA_PORT=$external_port
HYSTERIA_CERTFILE=$cert_dir/fullchain.pem AIOHTTP_LISTEN_ADDRESS=$aiohttp_listen_address
HYSTERIA_KEYFILE=$cert_dir/privkey.pem AIOHTTP_LISTEN_PORT=$aiohttp_listen_port
SUBPATH=$(pwgen -s 32 1) SUBPATH=$subpath_val
EOL 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 <<EOL > "$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 <<EOL > /etc/systemd/system/hysteria-normal-sub.service cat <<EOL > /etc/systemd/system/hysteria-normal-sub.service
[Unit] [Unit]
Description=normalsub Python Service Description=Hysteria Normalsub Python Service
After=network.target After=network.target
[Service] [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' 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 WorkingDirectory=/etc/hysteria/core/scripts/normalsub
EnvironmentFile=/etc/hysteria/core/scripts/normalsub/.env EnvironmentFile=$NORMALSUB_ENV_FILE
Restart=always Restart=always
User=root User=root
Group=root Group=root
@ -35,107 +105,120 @@ WantedBy=multi-user.target
EOL EOL
} }
create_caddy_normalsub_service_file() {
cat <<EOL > /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() { start_service() {
local domain=$1 local domain=$1
local port=$2 local external_port=$2
if systemctl is-active --quiet hysteria-normal-sub.service; then install_caddy_if_needed
echo "The hysteria-normal-sub.service is already running."
return
fi
echo "Checking SSL certificates for $domain..." local aiohttp_listen_address="$DEFAULT_AIOHTTP_LISTEN_ADDRESS"
if certbot certificates | grep -q "$domain"; then local aiohttp_listen_port="$DEFAULT_AIOHTTP_LISTEN_PORT"
echo -e "${yellow}Certificate for $domain already exists. Renewing...${NC}"
certbot renew --cert-name "$domain" update_env_file "$domain" "$external_port" "$aiohttp_listen_address" "$aiohttp_listen_port"
if [ $? -ne 0 ]; then source "$NORMALSUB_ENV_FILE" # To get SUBPATH for Caddyfile
echo -e "${red}Error: Failed to renew SSL certificate. ${NC}"
exit 1 update_caddy_file_normalsub "$HYSTERIA_DOMAIN" "$HYSTERIA_PORT" "$SUBPATH" "$AIOHTTP_LISTEN_ADDRESS" "$AIOHTTP_LISTEN_PORT"
fi
echo -e "${green}Certificate renewed successfully. ${NC}" create_normalsub_python_service_file
else create_caddy_normalsub_service_file
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
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 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
if systemctl is-active --quiet hysteria-normal-sub.service; then systemctl enable hysteria-normal-sub.service > /dev/null 2>&1
echo -e "${green}normalsub service setup completed. The service is now running on port $port. ${NC}" 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 && 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 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 fi
} }
stop_service() { stop_service() {
if [ -f /etc/hysteria/core/scripts/normalsub/.env ]; then echo -e "${yellow}Stopping Hysteria Normalsub Python service...${NC}"
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
systemctl stop hysteria-normal-sub.service > /dev/null 2>&1 systemctl stop hysteria-normal-sub.service > /dev/null 2>&1
systemctl disable 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 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() { edit_subpath() {
local new_path="$1" 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 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}" echo -e "${red}Error: New subpath must contain only alphanumeric characters (a-z, A-Z, 0-9) and cannot be empty.${NC}"
exit 1 exit 1
fi fi
if [ ! -f "$env_file" ]; then source "$NORMALSUB_ENV_FILE"
echo -e "${red}Error: .env file ($env_file) not found. Please run the start command first.${NC}" local old_subpath="$SUBPATH"
exit 1
fi
if grep -q "^SUBPATH=" "$env_file"; then sed -i "s|^SUBPATH=.*|SUBPATH=$new_path|" "$NORMALSUB_ENV_FILE"
sed -i "s|^SUBPATH=.*|SUBPATH=$new_path|" "$env_file" echo -e "${green}SUBPATH updated to $new_path in $NORMALSUB_ENV_FILE.${NC}"
else
echo "SUBPATH=$new_path" >> "$env_file" update_caddy_file_normalsub "$HYSTERIA_DOMAIN" "$HYSTERIA_PORT" "$new_path" "$AIOHTTP_LISTEN_ADDRESS" "$AIOHTTP_LISTEN_PORT"
fi echo -e "${green}Caddyfile for Normalsub updated.${NC}"
echo -e "${green}SUBPATH updated to $new_path in $env_file.${NC}"
echo -e "${yellow}Restarting hysteria-normal-sub service...${NC}" echo -e "${yellow}Restarting hysteria-normal-sub service...${NC}"
systemctl daemon-reload
systemctl restart hysteria-normal-sub.service 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 if systemctl is-active --quiet hysteria-normal-sub.service && systemctl is-active --quiet hysteria-caddy-normalsub.service; then
echo -e "${green}hysteria-normal-sub service restarted successfully.${NC}" 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 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 fi
} }
case "$1" in case "$1" in
start) start)
if [ -z "$2" ] || [ -z "$3" ]; then if [ -z "$2" ] || [ -z "$3" ]; then
echo -e "${red}Usage: $0 start <DOMAIN> <PORT> ${NC}" echo -e "${red}Usage: $0 start <EXTERNAL_DOMAIN> <EXTERNAL_PORT>${NC}"
exit 1 exit 1
fi fi
start_service "$2" "$3" start_service "$2" "$3"
@ -145,13 +228,13 @@ case "$1" in
;; ;;
edit_subpath) edit_subpath)
if [ -z "$2" ]; then if [ -z "$2" ]; then
echo -e "${red}Usage: $0 edit_subpath <NEW_SUBPATH> ${NC}" echo -e "${red}Usage: $0 edit_subpath <NEW_SUBPATH>${NC}"
exit 1 exit 1
fi fi
edit_subpath "$2" edit_subpath "$2"
;; ;;
*) *)
echo -e "${red}Usage: $0 {start <DOMAIN> <PORT> | stop | edit_subpath <NEW_SUBPATH>} ${NC}" echo -e "${red}Usage: $0 {start <EXTERNAL_DOMAIN> <EXTERNAL_PORT> | stop | edit_subpath <NEW_SUBPATH>}${NC}"
exit 1 exit 1
;; ;;
esac esac

View File

@ -7,6 +7,7 @@ declare -a services=(
"hysteria-caddy.service" "hysteria-caddy.service"
"hysteria-telegram-bot.service" "hysteria-telegram-bot.service"
"hysteria-normal-sub.service" "hysteria-normal-sub.service"
"hysteria-caddy-normalsub.service"
# "hysteria-singbox.service" # "hysteria-singbox.service"
"hysteria-ip-limit.service" "hysteria-ip-limit.service"
"wg-quick@wgcf.service" "wg-quick@wgcf.service"

View File

@ -10,65 +10,44 @@ if str(core_scripts_dir) not in sys.path:
from paths import * from paths import *
colors = {
"cyan": "\033[96m",
"green": "\033[92m",
"red": "\033[91m",
"purple": "\033[95m",
"end": "\033[0m"
}
def echo_status(label, is_active):
status = f"{colors['green']}Active{colors['end']}" if is_active else f"{colors['red']}Inactive{colors['end']}"
print(f"{colors['cyan']}{label}:{colors['end']} {status}")
def check_warp_configuration(): def check_warp_configuration():
status_data = {}
if not Path(CONFIG_FILE).exists(): if not Path(CONFIG_FILE).exists():
print(f"{colors['red']}Error: Config file not found at {CONFIG_FILE}{colors['end']}") status_data["error"] = f"Config file not found at {CONFIG_FILE}"
print(json.dumps(status_data, indent=4))
return
try:
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
except json.JSONDecodeError:
status_data["error"] = f"Invalid JSON in config file: {CONFIG_FILE}"
print(json.dumps(status_data, indent=4))
return return
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
acl_inline = config.get("acl", {}).get("inline", []) acl_inline = config.get("acl", {}).get("inline", [])
def contains_warp(rule_prefixes): def contains_warp(rule_prefixes):
return any(rule.startswith(prefix) for rule in acl_inline for prefix in rule_prefixes) return any(rule.startswith(prefix) for rule in acl_inline for prefix in rule_prefixes)
print("--------------------------------") status_data["all_traffic_via_warp"] = contains_warp(["warps(all)"])
print(f"{colors['purple']}Current WARP Configuration:{colors['end']}") status_data["popular_sites_via_warp"] = contains_warp([
"warps(geosite:google)",
"warps(geoip:google)",
"warps(geosite:netflix)",
"warps(geosite:spotify)",
"warps(geosite:openai)",
"warps(geoip:openai)"
])
status_data["domestic_sites_via_warp"] = contains_warp([
"warps(geosite:ir)",
"warps(geoip:ir)"
])
status_data["block_adult_content"] = "reject(geosite:nsfw)" in acl_inline
echo_status( print(json.dumps(status_data, indent=4))
"All traffic",
contains_warp(["warps(all)"])
)
echo_status(
"Popular sites (Google, Netflix, etc.)",
contains_warp([
"warps(geosite:google)",
"warps(geoip:google)",
"warps(geosite:netflix)",
"warps(geosite:spotify)",
"warps(geosite:openai)",
"warps(geoip:openai)"
])
)
echo_status(
"Domestic sites (geosite:ir, geoip:ir)",
contains_warp([
"warps(geosite:ir)",
"warps(geoip:ir)"
])
)
echo_status(
"Block adult content",
"reject(geosite:nsfw)" in acl_inline
)
print("--------------------------------")
if __name__ == "__main__": if __name__ == "__main__":
check_warp_configuration() check_warp_configuration()

View File

@ -1,4 +1,4 @@
import re import json
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse from ..schema.response import DetailResponse
from ..schema.config.warp import ConfigureInputBody, StatusResponse from ..schema.config.warp import ConfigureInputBody, StatusResponse
@ -69,74 +69,21 @@ async def configure(body: ConfigureInputBody):
@router.get('/status', response_model=StatusResponse, summary='Get WARP Status') @router.get('/status', response_model=StatusResponse, summary='Get WARP Status')
async def status(): async def status():
"""
Retrieves the current status of WARP.
Returns:
StatusResponse: A response model containing the current WARP status details.
Raises:
HTTPException: If the WARP status is not available (404) or if there is an error processing the request (400).
"""
try: try:
if res := cli_api.warp_status(): status_json_str = cli_api.warp_status()
return __parse_status(res) if not status_json_str:
raise HTTPException(status_code=404, detail='WARP status not available.') raise HTTPException(status_code=404, detail='WARP status not available.')
status_data = json.loads(status_json_str)
if "error" in status_data:
raise HTTPException(status_code=500, detail=f'Error getting WARP status: {status_data["error"]}')
return StatusResponse(**status_data)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail='Error decoding WARP status JSON.')
except HTTPException as e:
raise e
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(e)}') raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
def __parse_status(status: str) -> StatusResponse:
"""
Parses the output of the WARP status command to extract the current configuration settings.
Args:
status: The output of the WARP status command as a string.
Returns:
StatusResponse: A response model containing the current WARP status details.
Raises:
ValueError: If the WARP status is invalid or incomplete.
"""
# Example output(status) from cli_api.warp_status():
# --------------------------------
# Current WARP Configuration:
# All traffic: Inactive
# Popular sites (Google, Netflix, etc.): Inactive
# Domestic sites (geosite:ir, geoip:ir): Inactive
# Block adult content: Inactive
# --------------------------------
data = {}
# Remove ANSI escape sequences(colors) (e.g., \x1b[1;35m)
clean_status = re.sub(r'\x1b\[[0-9;]*m', '', status)
for line in clean_status.split('\n'):
if ':' not in line:
continue
if 'Current WARP Configuration:' in line:
continue
key, _, value = line.partition(':')
key = key.strip().lower()
value = value.strip()
if not key or not value:
continue
if 'all traffic' in key:
data['all_traffic'] = value == 'active'
elif 'popular sites' in key:
data['popular_sites'] = value == 'active'
elif 'domestic sites' in key:
data['domestic_sites'] = value == 'active'
elif 'block adult content' in key:
data['block_adult_sites'] = value == 'active'
if not data:
raise ValueError('Invalid WARP status')
try:
return StatusResponse(**data)
except Exception as e:
raise ValueError(f'Invalid or incomplete WARP status: {e}')

View File

@ -10,7 +10,7 @@ class ConfigureInputBody(BaseModel):
class StatusResponse(BaseModel): class StatusResponse(BaseModel):
all_traffic: bool all_traffic_via_warp: bool
popular_sites: bool popular_sites_via_warp: bool
domestic_sites: bool domestic_sites_via_warp: bool
block_adult_sites: bool block_adult_content: bool

61
menu.sh
View File

@ -383,7 +383,33 @@ warp_configure_handler() {
local service_name="wg-quick@wgcf.service" local service_name="wg-quick@wgcf.service"
if systemctl is-active --quiet "$service_name"; then if systemctl is-active --quiet "$service_name"; then
python3 $CLI_PATH warp-status echo -e "${cyan}=== WARP Status ===${NC}"
status_json=$(python3 $CLI_PATH warp-status)
all_traffic=$(echo "$status_json" | grep -o '"all_traffic_via_warp": *[^,}]*' | cut -d':' -f2 | tr -d ' "')
popular_sites=$(echo "$status_json" | grep -o '"popular_sites_via_warp": *[^,}]*' | cut -d':' -f2 | tr -d ' "')
domestic_sites=$(echo "$status_json" | grep -o '"domestic_sites_via_warp": *[^,}]*' | cut -d':' -f2 | tr -d ' "')
block_adult=$(echo "$status_json" | grep -o '"block_adult_content": *[^,}]*' | cut -d':' -f2 | tr -d ' "')
display_status() {
local label="$1"
local status="$2"
if [ "$status" = "true" ]; then
echo -e " ${green}${NC} $label: ${green}Enabled${NC}"
else
echo -e " ${red}${NC} $label: ${red}Disabled${NC}"
fi
}
display_status "All Traffic via WARP" "$all_traffic"
display_status "Popular Sites via WARP" "$popular_sites"
display_status "Domestic Sites via WARP" "$domestic_sites"
display_status "Block Adult Content" "$block_adult"
echo -e "${cyan}==================${NC}"
echo
echo "Configure WARP Options:" echo "Configure WARP Options:"
echo "1. Use WARP for all traffic" echo "1. Use WARP for all traffic"
echo "2. Use WARP for popular sites" echo "2. Use WARP for popular sites"
@ -401,25 +427,38 @@ warp_configure_handler() {
3) python3 $CLI_PATH configure-warp --domestic-sites ;; 3) python3 $CLI_PATH configure-warp --domestic-sites ;;
4) python3 $CLI_PATH configure-warp --block-adult-sites ;; 4) python3 $CLI_PATH configure-warp --block-adult-sites ;;
5) 5)
ip=$(curl -s --interface wgcf --connect-timeout 0.5 http://v4.ident.me) ip=$(curl -s --interface wgcf --connect-timeout 0.5 http://v4.ident.me)
cd /etc/warp/ && wgcf status cd /etc/warp/ && wgcf status
echo echo
echo -e "${yellow}Warp IP :${NC} ${cyan}$ip ${NC}" ;; echo -e "${yellow}Warp IP:${NC} ${cyan}$ip${NC}"
;;
6) 6)
old_ip=$(curl -s --interface wgcf --connect-timeout 0.5 http://v4.ident.me) old_ip=$(curl -s --interface wgcf --connect-timeout 0.5 http://v4.ident.me)
echo "Current IP address: $old_ip" echo -e "${yellow}Current IP:${NC} ${cyan}$old_ip${NC}"
echo "Restarting $service_name..." echo "Restarting $service_name..."
systemctl restart "$service_name" systemctl restart "$service_name"
sleep 5
echo -n "Waiting for service to restart"
for i in {1..5}; do
echo -n "."
sleep 1
done
echo
new_ip=$(curl -s --interface wgcf --connect-timeout 0.5 http://v4.ident.me) new_ip=$(curl -s --interface wgcf --connect-timeout 0.5 http://v4.ident.me)
echo "New IP address: $new_ip" echo -e "${yellow}New IP:${NC} ${green}$new_ip${NC}"
if [ "$old_ip" != "$new_ip" ]; then
echo -e "${green}✓ IP address changed successfully${NC}"
else
echo -e "${yellow}⚠ IP address remained the same${NC}"
fi
;; ;;
0) echo "WARP configuration canceled." ;; 0) echo "WARP configuration canceled." ;;
*) echo "Invalid option. Please try again." ;; *) echo -e "${red}Invalid option. Please try again.${NC}" ;;
esac esac
else else
echo "$service_name is not active. Please start the service before configuring WARP." echo -e "${red}$service_name is not active. Please start the service before configuring WARP.${NC}"
fi fi
} }

View File

@ -33,8 +33,9 @@ FILES=(
"$HYSTERIA_INSTALL_DIR/config.json" "$HYSTERIA_INSTALL_DIR/config.json"
"$HYSTERIA_INSTALL_DIR/.configs.env" "$HYSTERIA_INSTALL_DIR/.configs.env"
"$HYSTERIA_INSTALL_DIR/core/scripts/telegrambot/.env" "$HYSTERIA_INSTALL_DIR/core/scripts/telegrambot/.env"
"$HYSTERIA_INSTALL_DIR/core/scripts/singbox/.env" # "$HYSTERIA_INSTALL_DIR/core/scripts/singbox/.env"
"$HYSTERIA_INSTALL_DIR/core/scripts/normalsub/.env" "$HYSTERIA_INSTALL_DIR/core/scripts/normalsub/.env"
"$HYSTERIA_INSTALL_DIR/core/scripts/normalsub/Caddyfile.normalsub"
"$HYSTERIA_INSTALL_DIR/core/scripts/webpanel/.env" "$HYSTERIA_INSTALL_DIR/core/scripts/webpanel/.env"
"$HYSTERIA_INSTALL_DIR/core/scripts/webpanel/Caddyfile" "$HYSTERIA_INSTALL_DIR/core/scripts/webpanel/Caddyfile"
) )
@ -80,7 +81,7 @@ info "Setting ownership and permissions..."
chown hysteria:hysteria "$HYSTERIA_INSTALL_DIR/ca.key" "$HYSTERIA_INSTALL_DIR/ca.crt" chown hysteria:hysteria "$HYSTERIA_INSTALL_DIR/ca.key" "$HYSTERIA_INSTALL_DIR/ca.crt"
chmod 640 "$HYSTERIA_INSTALL_DIR/ca.key" "$HYSTERIA_INSTALL_DIR/ca.crt" chmod 640 "$HYSTERIA_INSTALL_DIR/ca.key" "$HYSTERIA_INSTALL_DIR/ca.crt"
chown -R hysteria:hysteria "$HYSTERIA_INSTALL_DIR/core/scripts/singbox" # chown -R hysteria:hysteria "$HYSTERIA_INSTALL_DIR/core/scripts/singbox"
chown -R hysteria:hysteria "$HYSTERIA_INSTALL_DIR/core/scripts/telegrambot" chown -R hysteria:hysteria "$HYSTERIA_INSTALL_DIR/core/scripts/telegrambot"
chmod +x "$HYSTERIA_INSTALL_DIR/core/scripts/hysteria2/user.sh" chmod +x "$HYSTERIA_INSTALL_DIR/core/scripts/hysteria2/user.sh"
@ -118,6 +119,7 @@ SERVICES=(
hysteria-scheduler.service hysteria-scheduler.service
hysteria-telegram-bot.service hysteria-telegram-bot.service
hysteria-normal-sub.service hysteria-normal-sub.service
hysteria-caddy-normalsub.service
hysteria-webpanel.service hysteria-webpanel.service
hysteria-ip-limit.service hysteria-ip-limit.service
) )