diff --git a/.gitignore b/.gitignore
index 7023d0a8b..700f227ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,8 @@
log
dump.rdb
hiddify_usages.json
-docker-data
\ No newline at end of file
+docker-data
+__pycache__/
+*.pyc
+.venv/
+.venv313/
diff --git a/README.md b/README.md
index f59034db9..f723f1183 100644
--- a/README.md
+++ b/README.md
@@ -184,6 +184,48 @@ When you want to share Telegram proxy or Shadowsocks proxy through other program
## Installation and tutorials
+
+### Quick Start (New Python-based Manager)
+
+The new Python-based manager is the recommended way to install and manage Hiddify. It replaces the legacy Bash scripts (`install.sh`, `menu.sh`, `update.sh`).
+
+```bash
+# Clone the repository
+git clone https://github.com/hiddify/Hiddify-Manager.git /opt/hiddify-manager
+cd /opt/hiddify-manager
+
+# Run init.sh — it will install Python 3.13, set up a virtualenv, and launch the manager
+sudo bash init.sh
+```
+
+#### Available Commands
+
+| Command | Description |
+|---|---|
+| `./init.sh` | Launch the interactive menu |
+| `./init.sh install` | Run a full installation of all modules |
+| `./init.sh update` | Update Hiddify-Manager |
+| `./init.sh status` | Show system status |
+| `./init.sh migrate` | Migrate data from a legacy installation |
+| `./init.sh menu` | Launch the interactive menu |
+
+#### Migrating from a Legacy Installation
+
+If you have an existing installation at `/opt/hiddify-manager` (or `/opt/hiddify-server`), the migration tool will automatically detect it and copy your database, SSL certificates, acme.sh data, and configuration:
+
+```bash
+./init.sh migrate
+```
+
+For a custom legacy path:
+```bash
+python3 -m hiddify_manager.migrate --legacy-dir /path/to/old/install
+```
+
+Use `--dry-run` to preview changes without modifying anything.
+
+### Tutorials
+
**Please find tutorial information on our website by clicking on image below.**
diff --git a/acme.sh/cert_utils.sh b/acme.sh/cert_utils.sh
deleted file mode 100755
index a6a856106..000000000
--- a/acme.sh/cert_utils.sh
+++ /dev/null
@@ -1,149 +0,0 @@
-restricted_tlds=("af" "by" "cu" "er" "gn" "ir" "kp" "lr" "ru" "ss" "su" "sy" "zw" "amazonaws.com","azurewebsites.net","cloudapp.net")
-shopt -s expand_aliases
-
-source ./lib/acme.sh.env
-source ../common/utils.sh
-# Function to check if a domain is restricted
-is_ok_domain_zerossl() {
- domain="$1"
- for tld in "${restricted_tlds[@]}"; do
- if [[ $domain == *.$tld ]]; then
- return 1 # Domain is restricted
- fi
-
- done
- return 0 # Domain is not restricted
-}
-isipv4() {
- [[ $1 =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
- IFS='.' read -r a b c d <<< "$1"
- for o in $a $b $c $d; do
- (( o >= 0 && o <= 255 )) || return 1
- done
- return 0
-}
-
-isipv6() {
- [[ $1 =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$ ]]
-}
-acmecmd() {
- acme.sh --issue \
- -w /opt/hiddify-manager/acme.sh/www/ \
- --log /opt/hiddify-manager/log/system/acme.log \
- --pre-hook "bash /opt/hiddify-manager/acme.sh/prepare_acme.sh" \
- "$@"
-}
-
-
-stop_nginx_acme(){
- echo "" >/opt/hiddify-manager/nginx/parts/acme.conf
- systemctl reload --now hiddify-nginx
- systemctl reload hiddify-haproxy
-}
-
-
-function get_cert() {
- cd /opt/hiddify-manager/acme.sh/
- source ./lib/acme.sh.env
- # ./lib/acme.sh --register-account -m my@example.com
-
- DOMAIN=$1
- ssl_cert_path=/opt/hiddify-manager/ssl
- rm -f $ssl_cert_path/$DOMAIN.key
-
- if [ ${#DOMAIN} -le 64 ]; then
-
-
-
- DOMAIN_IP=$(dig +short -t a $DOMAIN.)
- DOMAIN_IPv6=$(dig +short -t aaaa $DOMAIN.)
- echo "resolving domain $DOMAIN : IP=$DOMAIN_IP IPv6=$DOMAIN_IPv6 ServerIP=$SERVER_IP ServerIPv6=$SERVER_IPv6"
- if [[ "$SERVER_IP" == "" || $SERVER_IP != $DOMAIN_IP ]] && [[ "$SERVER_IPv6" == "" || $SERVER_IPv6 != $DOMAIN_IPv6 ]]; then
- error "maybe it is an error! make sure that it is correct"
- #sleep 10
- fi
-
- flags=
- # if [ "$SERVER_IPv6" != "" ]; then
- # flags="--listen-v6"
- # fi
-
- if isipv4 "$DOMAIN"; then
- acmecmd -d $DOMAIN --server letsencrypt --certificate-profile shortlived --days 6
- elif isipv6 "$DOMAIN"; then
- acmecmd -d [$DOMAIN] --server letsencrypt --certificate-profile shortlived --days 6 --listen-v6
- else
- acmecmd -d "$DOMAIN" --server letsencrypt
- if [ "$?" -ne 0 ] && is_ok_domain_zerossl "$DOMAIN"; then
- acmecmd -d "$DOMAIN" --server zerossl
- fi
-
- fi
-
- acme.sh --installcert -d $DOMAIN \
- --fullchainpath $ssl_cert_path/$DOMAIN.crt \
- --keypath $ssl_cert_path/$DOMAIN.crt.key \
- --reloadcmd "echo success"
-
- err=$?
-
- else
- err=1
- fi
-
- if [[ $err != 0 ]]; then
- get_self_signed_cert $DOMAIN #it will check the certificate if is valid it will not create
- fi
-
- chmod 600 $ssl_cert_path/$DOMAIN.crt.key
- chmod 600 -R $ssl_cert_path
-}
-
-
-function get_self_signed_cert() {
- cd /opt/hiddify-manager/acme.sh/
- local d=$1
- if [ ${#d} -gt 64 ]; then
- echo "Domain length exceeds 64 characters. Truncating to the first 64 characters."
- d="${d:0:64}"
- fi
- mkdir -p /opt/hiddify-manager/ssl
- local certificate="/opt/hiddify-manager/ssl/$d.crt"
- local private_key="/opt/hiddify-manager/ssl/$d.crt.key"
- local current_date=$(date +%s)
- local generate_new_cert=0
- # Check if the certificate file exists
- if [ ! -f "$certificate" ]; then
- echo "Certificate $d ($certificate) file not found. Generating a new certificate."
- generate_new_cert=1
- else
- local expire_date=$(openssl x509 -enddate -noout -in "$certificate" | cut -d= -f2-)
- # Convert the expire date to seconds since epoch
- local expire_date_seconds=$(date -d "$expire_date" +%s)
-
- if [ "$current_date" -ge "$expire_date_seconds" ]; then
- echo "Certificate $d ($certificate) is expired. Generating a new certificate."
- generate_new_cert=1
- fi
- fi
-
- # Check if the private key file exists
- if [ ! -f "$private_key" ]; then
- echo "Private key file $d ($private_key) not found. Generating a new certificate."
- generate_new_cert=1
- else
- # Check if the private key is valid
- if ! openssl rsa -check -in "$private_key" >/dev/null && ! openssl ec -check -in "$private_key" >/dev/null; then
- echo "Private key $d ($private_key) is invalid. Generating a new certificate."
- generate_new_cert=1
- fi
- fi
-
- # Generate a new certificate if necessary
- if [ "$generate_new_cert" -eq 1 ]; then
- openssl req -x509 -newkey rsa:2048 -keyout "$private_key" -out "$certificate" -days 3650 -nodes -subj "/C=GB/ST=London/L=London/O=Google Trust Services LLC/CN=$d"
- echo "New certificate and private key generated."
- fi
- chmod 600 -R $private_key
-
-}
diff --git a/acme.sh/generate_self_signed_cert.sh b/acme.sh/generate_self_signed_cert.sh
deleted file mode 100755
index aa2f612c9..000000000
--- a/acme.sh/generate_self_signed_cert.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-cd $(dirname -- "$0")
-source cert_utils.sh
-get_self_signed_cert $1
diff --git a/acme.sh/get_cert.sh b/acme.sh/get_cert.sh
index a40b55de8..79f54270a 100755
--- a/acme.sh/get_cert.sh
+++ b/acme.sh/get_cert.sh
@@ -1,10 +1,7 @@
#!/bin/bash
-cd $(dirname -- "$0")
-source cert_utils.sh
-#./lib/acme.sh --register-account -m my@example.com
-
-get_cert $1
-
-echo "cert installation is done."
-sleep 2
-stop_nginx_acme
+# Thin shim: the real ACME orchestration lives in
+# hiddify_manager.modules.cert_issuer. Kept as a .sh so commander.py
+# (panel-invoked via sudoers) can keep its absolute-path Command enum
+# without churning the panel side.
+cd "$(dirname -- "$0")/.."
+exec /opt/hiddify-manager/.venv313/bin/python -m hiddify_manager.modules.cert_issuer "$@"
diff --git a/acme.sh/install.sh b/acme.sh/install.sh
deleted file mode 100755
index 31f9765b4..000000000
--- a/acme.sh/install.sh
+++ /dev/null
@@ -1,23 +0,0 @@
-source ../common/utils.sh
-install_package socat
-remove_package certbot
-
-mkdir -p /opt/hiddify-manager/acme.sh/lib/
-
-if ! is_installed ./lib/acme.sh; then
- curl -s -L https://get.acme.sh | sh -s -- home /opt/hiddify-manager/acme.sh/lib \
- --config-home /opt/hiddify-manager/acme.sh/lib/data \
- --cert-home /opt/hiddify-manager/acme.sh/lib/certs --nocron
-fi
-./lib/acme.sh --upgrade
-
-if ! grep -q 'return 10; fi' "./lib/acme.sh"; then
- sed -i 's|_sleep_overload_retry_sec=$_retryafter|_sleep_overload_retry_sec=$_retryafter; if [[ "$_retryafter" > 20 ]];then return 10; fi|g' lib/acme.sh
-fi
-mkdir -p ../ssl/
-
-./lib/acme.sh --uninstall-cronjob
-shopt -s expand_aliases
-source ./lib/acme.sh.env
-acme.sh --register-account -m my@example.com
-systemctl reload hiddify-haproxy
diff --git a/acme.sh/prepare_acme.sh b/acme.sh/prepare_acme.sh
deleted file mode 100644
index 91d6d94a2..000000000
--- a/acme.sh/prepare_acme.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-mkdir -p /opt/hiddify-manager/acme.sh/www/.well-known/acme-challenge
-echo "location /.well-known/acme-challenge {root /opt/hiddify-manager/acme.sh/www/;}" >/opt/hiddify-manager/nginx/parts/acme.conf
-chown -R nginx /opt/hiddify-manager/acme.sh/www/
-
-systemctl restart hiddify-nginx
\ No newline at end of file
diff --git a/acme.sh/run.sh b/acme.sh/run.sh
deleted file mode 100755
index 7114486a6..000000000
--- a/acme.sh/run.sh
+++ /dev/null
@@ -1,27 +0,0 @@
-cd $(dirname -- "$0")
-source ../common/utils.sh
-source ./cert_utils.sh
-
-# domains=$(cat ../current.json | jq -r '.domains[] | select(.mode | IN("direct", "cdn", "worker", "relay", "auto_cdn_ip", "old_xtls_direct", "sub_link_only")) | .domain')
-domains=$(cat ../current.json | jq -r '.domains[] | select(.mode | IN("direct", "relay", "old_xtls_direct", "sub_link_only")) | .domain')
-
-for d in $domains; do
- get_cert $d &
-done
-wait
-stop_nginx_acme
-
-domains=$(cat ../current.json | jq -r '.domains[] | select(.mode | IN("fake")) | .domain')
-for d in $domains; do
- get_self_signed_cert $d &
-done
-wait
-
-for f in ../ssl/*.crt; do
- d=$(basename "$f" .crt)
- get_self_signed_cert $d &
-done
-wait
-systemctl reload hiddify-haproxy
-systemctl reload hiddify-singbox
-# systemctl reload hiddify-xray
\ No newline at end of file
diff --git a/apply_configs.sh b/apply_configs.sh
index 100908558..bfce2ed3b 100755
--- a/apply_configs.sh
+++ b/apply_configs.sh
@@ -1,4 +1,11 @@
#!/bin/bash
-cd $(dirname -- "$0")
-DO_NOT_INSTALL=true ./install.sh apply_configs $@
-#DO_NOT_INSTALL=true ./install.sh
+# Thin shim: panel hits /admin/actions/apply_configs which goes through
+# commander.py apply -> this script. Real impl lives in
+# hiddify_manager.manager.run_apply_configs.
+#
+# Log file (log/system/0-install.log, polled by the panel's
+# admin_log_api) is written by init.sh itself, not here — that way the
+# menu's "Reinstall" and a direct `./init.sh apply-configs` from the
+# shell get the same log file the panel expects.
+cd "$(dirname -- "$0")"
+exec ./init.sh apply-configs
diff --git a/common/add_remote_assistant.sh b/common/add_remote_assistant.sh
deleted file mode 100644
index 0816ba539..000000000
--- a/common/add_remote_assistant.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-mkdir -p ~/.ssh
-echo 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWEXarp7YrTNX+4uNfdYtQ1lVsrD9/6oHaNiR6kgzoeShD/+3Ljou3veXofVstCb6CpFZdmOaKXNJyT5N+gm0eXwYJNsnrkCRq9h/6ydkoVdPAINzHZoetVqwqAPgmqzR8xTKZPP/Ky3Ks8OQEIg1Swnm9XXuP+ApmvOxGut9pPhOozKSATklojRaAmhdz4y9YpkLi94C1Ixd10Ewjld4pnVp4+uDTkXV2i3N3lH5x6zFrk2tefigoZ60brNWC3TGL3SjQ4obkD2qKpKqIRy63cUzfI0lP/0vZ7Ms5ESPlLI/ebMGvns9hINi1KRJ8m0//Jy0CDngJNJxG8KGbvqvLu/avmdVUHr48y7bk6VTGicMp16LfbszRQRF2d61n5uwBGXUB5DbVNI00yOdqAflDEloBEchqiWIEotBXyGTB1e2V1Oe95W27h9QSMbhNwmEk/QGPn4yhRgTbFq1TwNhE6DXZrCUbW8x4KVMQTSD+seUB0fMgTTXtzpPEo3mFAME= hiddify@assistant'>>~/.ssh/authorized_keys
-
-echo "nameserver 1.1.1.1" >> /etc/resolv.conf
-echo ""
-echo "Now Please send the following to the https://t.me/hiddifybot"
-SERVER_IP=`curl --connect-timeout 1 -s https://v4.ident.me/`
-#SERVER_IPv6=`curl --connect-timeout 1 -s https://v6.ident.me/`
-
-
-port=$(ss -tulpn | grep "sshd" | awk '{print $5}' | cut -d':' -f2)
-echo ""
- if [[ -z $port ]]
- then
- echo "ssh $(whoami)@$SERVER_IP"
- else
- for p in $port;do
- echo "ssh $(whoami)@$SERVER_IP -p $p"
- break
- done
- fi
diff --git a/common/daily_actions.sh b/common/daily_actions.sh
deleted file mode 100755
index bfb1af81c..000000000
--- a/common/daily_actions.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/bin/bash
-PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
-
-cd $( dirname -- "$0"; )
-
-
-# systemctl restart systemd-journald
-# sysctl -w vm.drop_caches=3
-
-
-
-bash /opt/hiddify-manager/acme.sh/run.sh
\ No newline at end of file
diff --git a/common/downgrade.sh b/common/downgrade.sh
deleted file mode 100644
index d3b04e36c..000000000
--- a/common/downgrade.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-cd /opt/hiddify-manager/hiddify-panel
-
-source /opt/hiddify-manager/common/utils.sh
-activate_python_venv
-
-hiddify-panel-cli downgrade
-if [ ! -f hiddifypanel.db ] && [ -f hiddifypanel.db.old ]; then
- mv hiddifypanel.db.old hiddifypanel.db
-fi
-cd ..
-
-
-pip install hiddifypanel==$(get_release_version hiddify-panel)
-curl -L -s -o hiddify-manager.zip https://github.com/hiddify/hiddify-manager/releases/latest/download/hiddify-manager.zip
-unzip -o hiddify-manager.zip
-rm hiddify-manager.zip
-ln -s /opt/hiddify-manager /opt/hiddify-config
-bash install.sh
diff --git a/common/download.sh b/common/download.sh
index 4f662a160..25eeb33f7 100755
--- a/common/download.sh
+++ b/common/download.sh
@@ -1,51 +1,71 @@
#!/bin/bash
+#
+# First-install bootstrap: download a Hiddify-Manager release archive
+# straight from GitHub, extract it to /opt/hiddify-manager, and hand off
+# to the python orchestrator (./init.sh upgrade
) for everything
+# else.
+#
+# Replaces the previous flow which downloaded hiddify_installer.sh +
+# utils.sh from raw.githubusercontent and ran them — the installer's
+# logic now lives in modules/manager_updater + modules/panel_installer.
-if [[ "$VER" != "" ]];then
- set -- $VER $@
+set -eu
-fi
-
-echo "$0 input params are $@"
+mode="${1:-release}"
-
-if [[ " $@ " != *"--no-gui"* ]] && [[ "$0" == "bash" ]]; then
- echo "This script is deprecated! Please use the following command"
- echo ""
- echo "bash <(curl https://i.hiddify.com/$1)"
- echo ""
+if [ "$(id -u)" -ne 0 ]; then
+ echo "This script must be run as root" >&2
exit 1
fi
-echo "Downloading '$@'"
+case "$mode" in
+ release)
+ archive_url="https://github.com/hiddify/Hiddify-Manager/releases/latest/download/hiddify-manager.zip"
+ ;;
+ beta)
+ echo "beta mode needs an explicit v; resolve via the GitHub API and pass it." >&2
+ exit 2
+ ;;
+ dev|develop)
+ archive_url="https://github.com/hiddify/hiddify-manager/archive/refs/heads/dev.tar.gz"
+ ;;
+ v*)
+ archive_url="https://github.com/hiddify/Hiddify-Manager/releases/download/${mode}/hiddify-manager.zip"
+ ;;
+ docker)
+ echo "docker bootstrap goes through common/docker-installer.sh, not download.sh" >&2
+ exit 2
+ ;;
+ *)
+ echo "Unknown mode: $mode (expected release|beta|dev|develop|v)" >&2
+ exit 2
+ ;;
+esac
-if [[ " $@ " == *" v8 "* ]]; then
- sudo bash -c "$(curl -sLfo- https://raw.githubusercontent.com/hiddify/hiddify-config/main/common/download_install.sh)"
- exit $?
-fi
+target=/opt/hiddify-manager
+mkdir -p "$target"
+cd "$target"
+# Bootstrap needs unzip / tar to be present before init.sh can run.
+apt-get install -y --no-install-recommends curl ca-certificates unzip tar >/dev/null
-mkdir -p /tmp/hiddify/
-chmod 600 /tmp/hiddify/
-rm -rf /tmp/hiddify/*
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+case "$archive_url" in
+ *.zip)
+ echo "Downloading $archive_url..."
+ curl -fsSL -o "$tmp/manager.zip" "$archive_url"
+ unzip -q -o "$tmp/manager.zip" -d "$target"
+ ;;
+ *.tar.gz)
+ echo "Downloading $archive_url..."
+ curl -fsSL -o "$tmp/manager.tar.gz" "$archive_url"
+ tar -xzf "$tmp/manager.tar.gz" -C "$target" --strip-components=1
+ ;;
+esac
-branch="${1:-release}"
-
-if [[ "$branch" == v* ]]; then
- # If input starts with 'v', treat it as a tag
- base_url="https://raw.githubusercontent.com/hiddify/Hiddify-Manager/refs/tags/$branch/"
-elif [[ "$branch" == "beta" ]]; then
- # If input is 'release' or empty, use main
- base_url="https://raw.githubusercontent.com/hiddify/Hiddify-Manager/refs/heads/beta/"
-elif [[ "$branch" == "dev" ]]; then
- # If input is 'release' or empty, use main
- base_url="https://raw.githubusercontent.com/hiddify/Hiddify-Manager/refs/heads/dev/"
-else
- # Otherwise, use the input as a branch name
- base_url="https://raw.githubusercontent.com/hiddify/Hiddify-Manager/refs/heads/main/"
-fi
-curl -sL -o /tmp/hiddify/hiddify_installer.sh $base_url/common/hiddify_installer.sh
-curl -sL -o /tmp/hiddify/utils.sh $base_url/common/utils.sh
-chmod 700 /tmp/hiddify/*
-
-/tmp/hiddify/hiddify_installer.sh $@
+cd "$target"
+# We just downloaded the source; hand off to `update` (panel install +
+# install loop), not `upgrade` (which would re-download the source).
+exec ./init.sh update "$mode"
diff --git a/common/download_install.sh b/common/download_install.sh
deleted file mode 100755
index 56bff1200..000000000
--- a/common/download_install.sh
+++ /dev/null
@@ -1,88 +0,0 @@
-#!/bin/sh
-if [ "$(id -u)" -ne 0 ]; then
- echo 'This script must be run by root' >&2
- exit 1
-fi
-
-checkOS() {
- # List of supported distributions
- #supported_distros=("Ubuntu" "Debian" "Fedora" "CentOS" "Arch")
- supported_distros=("Ubuntu")
- # Get the distribution name and version
- if [[ -f "/etc/os-release" ]]; then
- source "/etc/os-release"
- distro_name=$NAME
- distro_version=$VERSION_ID
- else
- echo "Unable to determine distribution."
- exit 1
- fi
- # Check if the distribution is supported
- if [[ " ${supported_distros[@]} " =~ " ${distro_name} " ]]; then
- echo "Your Linux distribution is ${distro_name} ${distro_version}"
- : #no-op command
- else
- # Print error message in red
- echo -e "\e[31mYour Linux distribution (${distro_name} ${distro_version}) is not currently supported.\e[0m"
- exit 1
- fi
-
- # This script only works on Ubuntu 22 and above
- if [ "$(uname)" == "Linux" ]; then
- version_info=$(lsb_release -rs | cut -d '.' -f 1)
- # Check if it's Ubuntu and version is below 22
- if [ "$(lsb_release -is)" == "Ubuntu" ] && [ "$version_info" -lt 22 ]; then
- echo "This script only works on Ubuntu 22 and above"
- exit
- fi
- fi
-}
-checkOS
-
-# TODO: this commands are declared in hiddify-panel/install.sh, we don't need them here?!
-#localectl set-locale LANG=C.UTF-8 >/dev/null 2>&1
-#su hiddify-panel -c update-locale LANG=C.UTF-8 >/dev/null 2>&1
-
-export DEBIAN_FRONTEND=noninteractive
-export USE_VENV=true
-
-echo "we are going to download needed files:)"
-GITHUB_REPOSITORY=hiddify-config
-GITHUB_USER=hiddify
-GITHUB_BRANCH_OR_TAG=main
-
-# if [ ! -d "/opt/$GITHUB_REPOSITORY" ];then
-apt update
-#apt upgrade -y
-#apt -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade
-
-apt install -y curl unzip
-mkdir -p /opt/$GITHUB_REPOSITORY
-cd /opt/$GITHUB_REPOSITORY
-curl -L -s -o $GITHUB_REPOSITORY.zip https://github.com/hiddify/$GITHUB_REPOSITORY/releases/download/v10.5.73/$GITHUB_REPOSITORY.zip
-unzip -o $GITHUB_REPOSITORY.zip > /dev/null
-rm $GITHUB_REPOSITORY.zip
-rm -f xray/configs/*.json
-rm -f singbox/configs/*.json
-source /opt/hiddify-config/common/utils.sh
-install_python
-install_pypi_package pip==24.0
-pip install -U hiddifypanel==8.8.99
-bash install.sh --no-gui
-# exit 0
-# fi
-
-sed -i "s|/opt/$GITHUB_REPOSITORY/menu.sh||g" ~/.bashrc
-sed -i "s|cd /opt/$GITHUB_REPOSITORY/||g" ~/.bashrc
-echo "/opt/$GITHUB_REPOSITORY/menu.sh" >>~/.bashrc
-echo "cd /opt/$GITHUB_REPOSITORY/" >>~/.bashrc
-if [ "$CREATE_EASYSETUP_LINK" == "true" ];then
- cd /opt/$GITHUB_REPOSITORY/hiddify-panel
- hiddify-panel-cli set-setting --key create_easysetup_link --val True
-fi
-
-hiddify-panel-cli set-setting --key auto_update --val False
-
-read -p "Press any key to go to menu" -n 1 key
-cd /opt/$GITHUB_REPOSITORY
-bash menu.sh
diff --git a/common/download_install_easylink.sh b/common/download_install_easylink.sh
deleted file mode 100755
index d9cbd9b40..000000000
--- a/common/download_install_easylink.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/sh
-
-export CREATE_EASYSETUP_LINK="true"
-bash -c "$(curl -Lfo- https://raw.githubusercontent.com/hiddify/hiddify-manager/main/common/download_install.sh)"
\ No newline at end of file
diff --git a/common/hiddify_installer.sh b/common/hiddify_installer.sh
deleted file mode 100755
index 95db78228..000000000
--- a/common/hiddify_installer.sh
+++ /dev/null
@@ -1,381 +0,0 @@
-#!/bin/bash
-cd $(dirname -- "$0")
-source ./utils.sh
-if [ "$(id -u)" -ne 0 ]; then
- echo 'This script must be run by root' >&2
- exit 1
-fi
-
-
-checkOS
-
-
-
-
-
-
-export DEBIAN_FRONTEND=noninteractive
-NAME="installer"
-LOG_FILE="$(log_file $NAME)"
-export USE_VENV=true
-
-if [ ! -f /opt/hiddify-manager/install.sh ]; then
- rm -rf /opt/hiddify-manager
-fi
-
-
-
-if [ ! -d "/opt/hiddify-manager/" ] && [ -d "/opt/hiddify-config/" ]; then
- mv /opt/hiddify-config /opt/hiddify-manager
- ln -s /opt/hiddify-manager /opt/hiddify-config
-fi
-if [ ! -d "/opt/hiddify-manager/" ] && [ -d "/opt/hiddify-server/" ]; then
- mv /opt/hiddify-config /opt/hiddify-manager
- ln -s /opt/hiddify-manager /opt/hiddify-server
-fi
-
-function install_panel() {
- local force=${2:-true}
- local package_mode=${1:-release}
- if [ "$package_mode" == "false" ]; then
- package_mode="release"
- fi
- local update=0
- local panel_update=0
- update_progress "Upgrading..." "Upgrading Linux Packages for extra security..." 5
- apt update
- #apt upgrade -y
- # apt -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" --only-upgrade upgrade
- # apt dist-upgrade -y
-
- if ! is_installed hiddifypanel; then
- sed -i "s|/opt/hiddify-manager/menu.sh||g" ~/.bashrc
- sed -i "s|cd /opt/hiddify-manager/||g" ~/.bashrc
- echo "/opt/hiddify-manager/menu.sh" >>~/.bashrc
- echo "cd /opt/hiddify-manager/" >>~/.bashrc
- fi
-
-
-
-
- install_package curl clang libev-dev libevdev2 default-libmysqlclient-dev build-essential git ca-certificates pkg-config jq wireguard pkg-config #needed for installing uv and hiddifypanel
-
- update_panel "$package_mode" "$force"
- panel_update=$?
- # We downgrade the marshmallow because of api_flask is not supporting v4
- #/opt/hiddify-manager/.venv/bin/pip install "marshmallow<=3.26.1"
-
- update_config "$package_mode" "$force"
- config_update=$?
- post_update_tasks "$panel_update" "$config_update" "$package_mode"
-
- if is_installed hiddifypanel && [[ -z "$package_mode" || ($package_mode == "develop" || $package_mode == "beta" || $package_mode == "release") ]]; then
- hiddify-panel-cli set-setting -k package_mode -v $1
- fi
-
-}
-
-function update_panel() {
- update_progress "Checking for Update..." "Hiddify Panel" 5
- local package_mode=$1
- local force=$2
- local current_panel_version=$(get_installed_panel_version)
- # Your existing logic for checking and updating the panel version based on the package mode
- # Set panel_update to 1 if an update is performed
-
- case "$package_mode" in
- docker)
- activate_python_venv
- # install_python310
- # uv pip install -U --no-deps --force-reinstall hiddify-panel/src
- uv pip install /opt/hiddify-manager/hiddify-panel/src
- # pip install -U hiddifypanel
- ;;
- v*)
- update_progress "Updating..." "Hiddify Panel from $current_panel_version to $latest" 10
- panel_path=$(hiddifypanel_path)
- disable_panel_services
- if [ ! -z "$USE_VENV" ]; then
- activate_python_venv
- if [ "$USE_VENV" == "310" ];then
- install_python310
- pip install -U --no-deps --force-reinstall git+https://github.com/hiddify/HiddifyPanel@${package_mode}
- pip install git+https://github.com/hiddify/HiddifyPanel@${package_mode}
- else
- uv pip install -U --no-deps --force-reinstall git+https://github.com/hiddify/HiddifyPanel@${package_mode}
- uv pip install git+https://github.com/hiddify/HiddifyPanel@${package_mode}
- fi
- else
- install_python310
- pip3 install -U --no-deps --force-reinstall git+https://github.com/hiddify/HiddifyPanel@${package_mode}
- pip3 install git+https://github.com/hiddify/HiddifyPanel@${package_mode}
- fi
- update_progress "Updated..." "Hiddify Panel to ${package_mode}" 50
- return 0
- ;;
- develop|dev)
- # Use the latest commit from GitHub
- latest=$(get_commit_version Hiddify-Panel)
- activate_python_venv
- warning "DEVLEOP: hiddify panel version current=$current_panel_version latest=$latest"
- if [[ "$current_panel_version" != "$latest" ]]; then
- error "The current develop version is outdated! Updating..."
- fi
- if [[ $force == "true" || "$latest" != "$current_panel_version" ]]; then
- update_progress "Updating..." "Hiddify Panel from $current_panel_version to $latest" 10
-
- disable_panel_services
-
- uv pip install -U --no-deps --force-reinstall git+https://github.com/hiddify/HiddifyPanel
- uv pip install git+https://github.com/hiddify/HiddifyPanel
- panel_path=$(hiddifypanel_path)
- echo "setting $latest in $panel_path/VERSION"
- echo $latest > $panel_path/VERSION
- sed -i "s/__version__='[^']*'/__version__='$latest'/" $panel_path/VERSION.py
- update_progress "Updated..." "Hiddify Panel to $latest" 50
- return 0
- fi
- ;;
- beta)
- activate_python_venv
- latest=$(get_pre_release_version hiddify-panel)
- warning "BETA: hiddify panel version current=$current_panel_version latest=$latest"
- if [[ "$current_panel_version" != "$latest" ]]; then
- error "The current beta version is outdated! Updating..."
- fi
- if [[ $force == "true" || "$current_panel_version" != "$latest" ]]; then
- update_progress "Updating..." "Hiddify Panel from $current_panel_version to $latest" 10
- # pip install -U --pre hiddifypanel==$latest
- disable_panel_services
- uv pip install -U --pre hiddifypanel
- update_progress "Updated..." "Hiddify Panel to $latest" 50
- return 0
- fi
- ;;
- release)
- #TODO release should change to 3.13
- #install_python310
- activate_python_venv
- # error "you can not install release version 8 using this script"
- # exit 1
- latest=$(get_release_version hiddify-panel)
- if [[ "$current_panel_version" != "$latest" ]]; then
- error "The current beta version is outdated! Updating..."
- fi
- warning "hiddify panel version current=$current_panel_version latest=$latest"
- if [[ $force == "true" || "$current_panel_version" != "$latest" ]]; then
- update_progress "Updating..." "Hiddify Panel from $current_panel_version to $latest" 10
- # pip3 install -U hiddifypanel==$latest
- disable_panel_services
- uv pip install -U wheel hiddifypanel
- update_progress "Updated..." "Hiddify Panel to $latest" 50
- return 0
- fi
- ;;
- *)
- echo "Unknown package mode: $package_mode"
- exit 1
- ;;
- esac
-
- return 1
-}
-
-function update_config() {
- update_progress "Checking for Update..." "Hiddify Config" 55
- local package_mode=$1
- local force=$2
- local current_config_version=$(get_installed_config_version)
-
- case "$package_mode" in
- docker)
- echo "installing in docker mode"
- DO_NOT_RUN=true bash /opt/hiddify-manager/install.sh docker --no-gui --no-log
- echo "installing in docker mode finishs"
- ;;
- v*)
- update_progress "Updating..." "Hiddify Config from $current_config_version to $latest" 60
- export HIDDIFY_DISABLE_UPDATE=true
- #update_from_github "hiddify-manager.tar.gz" "https://github.com/hiddify/Hiddify-Manager/archive/refs/tags/${package_mode}.tar.gz" $latest
- update_from_github "hiddify-manager.zip" "https://github.com/hiddify/Hiddify-Manager/releases/download/${package_mode}/hiddify-manager.zip" $latest
- update_progress "Updated..." "Hiddify Config to $latest" 100
- return 0
- ;;
- develop|dev)
- local latest=$(get_commit_version hiddify-manager)
- echo "DEVELOP: Current Config Version=$current_config_version -- Latest=$latest"
- if [[ "$force" == "true" || "$latest" != "$current_config_version" ]]; then
- update_progress "Updating..." "Hiddify Config from $current_config_version to $latest" 60
- update_from_github "hiddify-manager.tar.gz" "https://github.com/hiddify/hiddify-manager/archive/refs/heads/dev.tar.gz" $latest
-
- update_progress "Updated..." "Hiddify Config to $latest" 100
- return 0
- fi
- ;;
- beta)
- local latest=$(get_pre_release_version hiddify-manager)
- echo "BETA: Current Config Version=$current_config_version -- Latest=$latest"
- if [[ "$force" == "true" || "$latest" != "$current_config_version" ]]; then
- update_progress "Updating..." "Hiddify Config from $current_config_version to $latest" 60
- update_from_github "hiddify-manager.zip" "https://github.com/hiddify/hiddify-manager/releases/download/v$latest/hiddify-manager.zip"
- update_progress "Updated..." "Hiddify Config to $latest" 100
- return 0
- fi
- ;;
- release)
- # error "you can not install release version 8 using this script"
- # exit 1
- local latest=$(get_release_version hiddify-manager)
- echo "RELEASE: Current Config Version=$current_config_version -- Latest=$latest"
- if [[ "$force" == "true" || "$latest" != "$current_config_version" ]]; then
- update_progress "Updating..." "Hiddify Config from $current_config_version to $latest" 60
- update_from_github "hiddify-manager.zip" "https://github.com/hiddify/hiddify-manager/releases/latest/download/hiddify-manager.zip"
- update_progress "Updated..." "Hiddify Config to $latest" 100
- return 0
- fi
-
- ;;
- *)
- echo "Unknown package mode: $package_mode"
- exit 1
- ;;
- esac
-
- return 1
-}
-
-function post_update_tasks() {
- local panel_update=$1
- local config_update=$2
- local package_mode=$3
-
- if [[ $config_update != 0 ]]; then
- echo "---------------------Finished!------------------------"
- fi
- remove_lock $NAME
-
- if [ "$package_mode" != "docker" ];then
- if [[ $panel_update == 0 ]]; then
- systemctl kill -s SIGTERM hiddify-panel
- fi
-
- if [[ $panel_update == 0 && $config_update != 0 ]]; then
- bash /opt/hiddify-manager/apply_configs.sh --no-gui --no-log
- fi
- systemctl start hiddify-panel
- cd /opt/hiddify-manager/hiddify-panel
- if [ "$CREATE_EASYSETUP_LINK" == "true" ];then
- hiddify-panel-cli set-setting --key create_easysetup_link --val True
- fi
-
- case "$package_mode" in
- release|beta)
- hiddify-panel-cli set-setting --key package_mode --val $package_mode
- ;;
- dev|develop)
- hiddify-panel-cli set-setting --key package_mode --val develop
- ;;
- esac
- fi
-}
-
-function update_from_github() {
- local file_name=$1
- local url=$2
- local override_version=$3
-
- local file_type=${file_name##*.}
- mkdir -p /opt/hiddify-manager
- cd /opt/hiddify-manager
- curl -sL -o "$file_name" "$url"
-
- if [[ "$file_type" == "zip" ]]; then
- install_package unzip
- unzip -q -o "$file_name"
- elif [[ "$file_type" == "gz" ]]; then
- tar xzf "$file_name" --strip-components=1
- else
- echo "Unsupported file type: $file_type"
- return 1
- fi
- if [[ ! -z "$override_version" ]]; then
- echo "$override_version" >VERSION
- fi
- rm "$file_name"
- rm -f xray/configs/*.json
- rm -f singbox/configs/*.json
- rm -f /opt/hiddify-manager/xray/configs/05_inbounds_10*.json*
- rm -f /opt/hiddify-manager/xray/configs/05_inbounds_h2*.json*
- rm -f /opt/hiddify-manager/xray/configs/05_inbounds_02_realitygrpc*.json*
- rm -f /opt/hiddify-manager/xray/configs/05_inbounds_02_realityh2*.json*
- rm -f /opt/hiddify-manager/singbox/configs/05_inbounds_2071_realitygrpc_main.json*
- rm -f /opt/hiddify-manager/singbox/configs/05_inbounds_20[123][1234]*.json*
-
- bash install.sh --no-gui --no-log
- bash install.sh --no-gui --no-log #temporary fix
-}
-
-function custom_version_installer(){
- #TAGS=$(curl -s "https://api.github.com/repos/hiddify/hiddify-manager/tags?per_page=1000" | jq -r '.[].name')
- TAGS=$(curl -s "https://pypi.org/pypi/hiddifypanel/json" | jq -r '.releases | keys[]'|sort -V -r)
- version_gt() {
- [ "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1" ]
- }
- FILTERED_TAGS=("release" "" "beta" "" "dev" "")
- for tag in $TAGS; do
- if [[ ! $tag =~ dev ]] && version_gt "$tag" "10.0.0"; then
- FILTERED_TAGS+=("v$tag" "")
- fi
- done
- TAG_LIST=$(printf "%s " "${FILTERED_TAGS[@]}")
- SELECTED_TAG=$(whiptail --title "Custom version Installer" --menu "Choose a version! Note: Downgrade is not supported!" 20 70 12 "${FILTERED_TAGS[@]}" 3>&1 1>&2 2>&3)
- if [ $? -eq 0 ]; then
- echo "You selected: $SELECTED_TAG"
- $0 $SELECTED_TAG
- else
- echo "No tag selected."
- exit 1
- fi
-}
-
-if [[ " $@ " == *" custom "* ]];then
- custom_version_installer
- exit $?
-fi
-
-
-export USE_VENV=313
-if [[ " $@ " == *" dev "* || " $@ " == *" docker "* || " $@ " == *" develop "* || " $@ " == *" beta "* ]];then
- export USE_VENV=313
-fi
-
-# Run the main function and log the output
-if [[ " $@ " == *" --no-gui "* || "$(get_installed_panel_version) " == "8."* || "$NO_UI" == "true" ]]; then
- set -- "${@/--no-gui/}"
- set_lock $NAME
- if [[ " $@ " == *" --no-log "* ]]; then
- set -- "${@/--no-log/}"
- install_panel "$@"
- error_code=$?
- else
- install_panel "$@" |& tee $LOG_FILE
- error_code="${PIPESTATUS[0]}"
- fi
-
- remove_lock $NAME
-else
-
- show_progress_window --subtitle "Installer" --log $LOG_FILE $0 $@ --no-gui --no-log
-
- error_code=$?
- if [[ $error_code != "0" ]]; then
- # echo less -r -P"Installation Failed! Press q to exit" +G "$log_file"
- msg_with_hiddify "Installation Failed! code=$error_code"
- else
- msg_with_hiddify "The installation has successfully completed."
- check_hiddify_panel $@ |& tee -a $LOG_FILE
- read -p "Press any key to go to menu" -n 1 key
- fi
- bash /opt/hiddify-manager/menu.sh
-fi
-exit $error_code
diff --git a/common/install.sh b/common/install.sh
deleted file mode 100755
index 074fa550b..000000000
--- a/common/install.sh
+++ /dev/null
@@ -1,93 +0,0 @@
-#!/bin/bash
-source utils.sh
-remove_package apache2 needrestart needrestart-session
-install_package apt-transport-https apt-utils at build-essential ca-certificates cron curl default-libmysqlclient-dev dnsutils gawk git gnupg-agent gnupg2 iproute2 iptables jq less libev-dev libevdev2 libssl-dev locales lsb-release lsof pkg-config qrencode software-properties-common sudo ubuntu-keyring wget whiptail
-activate_python_venv
-#python -m pip config set global.index-url https://pypi.org/simple > /dev/null
-# remove_package resolvconf
-# rm /etc/resolv.conf
-# ln -s /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
-
-
-
-groupadd -f hiddify-common
-usermod -aG hiddify-common root
-
-# rm /run/resolvconf/interface/*
-#echo "nameserver 8.8.8.8" >/etc/resolv.conf
-#echo "nameserver 1.1.1.1" >>/etc/resolv.conf
-#echo "nameserver 8.8.8.8" >/etc/resolvconf/resolv.conf.d/base
-#echo "nameserver 1.1.1.1" >>/etc/resolvconf/resolv.conf.d/base
-#resolvconf -u
-sudo systemctl unmask --now systemd-resolved.service
-systemctl enable --now systemd-resolved >/dev/null 2>&1
-
-# install requirements for change_dns.py
-install_pypi_package pyyaml > /dev/null
-if ! nslookup google.com &> /dev/null; then
- warning "DNS is not working."
- ./change_dns.py 8.8.8.8 1.1.1.1
-fi
-
-
-ln -sf $(pwd)/sysctl.conf /etc/sysctl.d/hiddify.conf
-
-if [ "${MODE}" != "docker" ];then
- sysctl --system > /dev/null
-fi
-
-if [[ "$ONLY_IPV4" != true ]]; then
- sysctl -w net.ipv6.conf.all.disable_ipv6=0
- sysctl -w net.ipv6.conf.default.disable_ipv6=0
- sysctl -w net.ipv6.conf.lo.disable_ipv6=0
-
- curl --connect-timeout 1 -s http://ipv6.google.com 2>&1 >/dev/null
- if [ $? != 0 ]; then
- ONLY_IPV4=true1
- fi
-fi
-
-INT_STAT=0
-INT_STAT_STR='Enable'
-if [[ "$ONLY_IPV4" == true ]]; then
- INT_STAT=1
- INT_STAT_STR="Disable"
-fi
-
-declare -a excluded_interfaces=("warp" "lo")
-
-for interface_name in $(ip link | awk -F': ' '$2 ~ /^[[:alnum:]]+$/ {print $2}'); do
- if [[ " ${excluded_interfaces[@]} " =~ " ${interface_name} " ]]; then
- continue
- fi
-
- # Disable IPv6 for the current interface
- sysctl -q -w "net.ipv6.conf.$interface_name.disable_ipv6=$INT_STAT"
-
- if [ $? -eq 0 ]; then
- echo "IPv6 ${INT_STAT_STR}d for $interface_name"
- else
- echo "Failed to $INT_STAT_STR IPv6 for $interface_name"
- fi
-done
-
-bash google-bbr.sh > /dev/null
-
-
-echo "@reboot root /opt/hiddify-manager/install.sh --no-gui --no-log >> /opt/hiddify-manager/log/system/reboot.log 2>&1" >/etc/cron.d/hiddify_reinstall_on_reboot
-mv /etc/cron.d/hiddify_daily_memory_release /etc/cron.d/hiddify_daily
-echo "@daily root /opt/hiddify-manager/common/daily_actions.sh >> /opt/hiddify-manager/log/system/daily_actions.log 2>&1" >/etc/cron.d/hiddify_daily
-service cron reload
-
-if [ "${MODE}" != "docker" ];then
- localectl set-locale LANG=C.UTF-8
-fi
-
-update-locale LANG=C.UTF-8
-
-echo "hiddify-panel ALL=(root) NOPASSWD: /opt/hiddify-manager/common/commander.py" >/etc/sudoers.d/hiddify
-
-ln -sf /opt/hiddify-manager/menu.sh /usr/bin/hiddify
-
-systemctl disable --now rpcbind.socket >/dev/null 2>&1
-systemctl disable --now rpcbind >/dev/null 2>&1
diff --git a/common/jinja.py b/common/jinja.py
deleted file mode 100755
index 0677924e8..000000000
--- a/common/jinja.py
+++ /dev/null
@@ -1,119 +0,0 @@
-#!/opt/hiddify-manager/.venv313/bin/python
-import base64
-import os
-import sys
-import threading
-from jinja2 import Environment, FileSystemLoader
-import json5
-import json
-import subprocess
-from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
-import traceback
-from urllib.parse import quote
-
-with open("/opt/hiddify-manager/current.json") as f:
- configs = json.load(f)
- configs["chconfigs"] = {int(k): v for k, v in configs["chconfigs"].items()}
- configs["hconfigs"] = configs["chconfigs"][0]
-
-
-def exec(command):
- try:
- output = subprocess.check_output(
- command, shell=True, stderr=subprocess.STDOUT, text=True
- )
- return output
- except subprocess.CalledProcessError as e:
- print(command)
- print(f"Command failed with exit code {e.returncode}:")
- print(e.output, e)
- return ""
-
-
-def b64encode(s):
- if type(s) == str:
- s = s.encode("utf-8")
- return base64.b64encode(s).decode("utf-8")
-
-env_paths = ["/", "/opt/hiddify-manager/singbox/configs/"]
-env = Environment(loader=FileSystemLoader(env_paths))
-def render(template_path):
- try:
- env.globals['enumerate'] = enumerate
- env.filters["b64encode"] = b64encode
- env.filters['quote'] = lambda s: quote(s,safe='')
- env.filters["hexencode"] = lambda s: "".join(
- hex(ord(c))[2:].zfill(2) for c in s
- )
- print("Rendering: " + template_path)
-
- # Create a template object by reading the file
- template = env.get_template(template_path)
- threading.current_thread().name
-
- # Render the template
- rendered_content = template.render(**configs, exec=exec, os=os)
-
- # print(f"Warning jinja2: {template_path} - Empty")
-
- # Write the rendered content to a new file without the .j2 extension
- output_file_path = os.path.splitext(template_path)[0]
- if rendered_content and output_file_path.endswith(".json"):
- # Remove trailing comma and comments from json
- try:
- json5object = json5.loads(rendered_content)
- rendered_content = json5.dumps(
- json5object,
- trailing_commas=False,
- indent=2,
- quote_keys=True,
- )
- except Exception as e:
- print(f"Error parsing json {template_path}: {e}", file=sys.stderr)
-
- with open(output_file_path, "w", encoding="utf-8") as output_file:
- output_file.write(str(rendered_content))
-
- input_stat = os.stat(template_path)
- os.chmod(output_file_path, input_stat.st_mode)
- # os.chmod(output_file_path, 0o600)
- os.chown(output_file_path, input_stat.st_uid, input_stat.st_gid)
- except Exception as e:
- print(f"Error rendering {template_path}: {e}", file=sys.stderr)
- traceback.print_exc(file = sys.stderr)
-
-
-def render_j2_templates(*start_paths):
- # Set up the Jinja2 environment
-
- # Dirs to ignore from Jinja2 rendering
- exclude_dirs = [
- "/opt/hiddify-manager/.venv",
- "/opt/hiddify-manager/hiddify-panel/src/",
- ]
-
- # Collect all the template paths to render
- templates_to_render = []
- for start_path in start_paths:
- for root, dirs, files in os.walk(start_path):
- for file in files:
- if not file.endswith(".j2"):
- continue
- if any(exclude_dir in root for exclude_dir in exclude_dirs):
- continue
- templates_to_render.append(os.path.join(root, file))
-
- # Render templates in parallel using ThreadPoolExecutor
- with ProcessPoolExecutor(4) as executor:
- executor.map(render, templates_to_render)
- # for t in templates_to_render:
- # render(t)
-
-start_path = "/opt/hiddify-manager/"
-if __name__ == "__main__":
- if len(sys.argv) > 1 and sys.argv[1] == "apply_users":
- render_j2_templates(
- start_path + "singbox/", start_path + "xray/", start_path + "other/wireguard/"
- )
- else:
- render_j2_templates(start_path)
diff --git a/common/package_manager.sh b/common/package_manager.sh
deleted file mode 100755
index bc9d2d0ef..000000000
--- a/common/package_manager.sh
+++ /dev/null
@@ -1,187 +0,0 @@
-#!/bin/bash
-SCRIPT_DIR="$(realpath $(dirname "$BASH_SOURCE"))"
-if [[ "$SCRIPT_DIR" != *develop* ]]; then
- SCRIPT_DIR="/opt/hiddify-manager/common"
-fi
-
-source $SCRIPT_DIR/utils.sh
-# File to store package information
-PACKAGES_LOCK="$SCRIPT_DIR/packages.lock"
-CURRENT_PACKAGES="$SCRIPT_DIR/packages.db"
-touch $CURRENT_PACKAGES
-
-# Function to calculate file hash
-generate_hash() {
- local file=$1
- sha256sum "$file" | awk '{print $1}'
-}
-
-# Add a package entry
-add_package() {
- local package_name=$1
- local version=$2
- local arch=$3
- case "$arch" in
- x86_64) arch="amd64" ;;
- aarch64) arch="arm64" ;;
- both)
- add_package "$package_name" "$version" "amd64" "$4"
- add_package "$package_name" "$version" "arm64" "$4";
- return ;;
- esac
-
- local url=$4
-
- if [[ -z "$package_name" || -z "$version" || -z "$arch" || -z "$url" ]]; then
- error "Usage: $0 add "
- exit 1
- fi
-
- # Download the file to calculate the hash
- temp_file="/tmp/${package_name}_${version}_${arch}.tmp"
- wget -q "$url" -O "$temp_file"
- if [[ $? -ne 0 ]]; then
- error "Error downloading file: $url"
- return 1
- fi
-
- local hash=$(generate_hash "$temp_file")
- rm "$temp_file"
-
- # Check if the package entry already exists
- existing_entry=$(grep "^$package_name|$version|$arch|" "$PACKAGES_LOCK")
- if [[ -n "$existing_entry" ]]; then
- # Update the existing entry
- sed -i "s|^$package_name\|$version\|$arch\|.*|$package_name\|$version\|$arch\|$url\|$hash|" "$PACKAGES_LOCK"
- echo "Package $package_name version $version for $arch updated successfully."
- else
- # Append package info to the database
- echo "$package_name|$version|$arch|$url|$hash" >> "$PACKAGES_LOCK"
- echo "Package $package_name version $version for $arch added successfully."
- fi
-}
-
-# Download a package
-download_package() {
- local package_name=$1
- local output_file=$2
- local requested_version=$3
-
- if [[ -z "$package_name" || -z "$output_file" ]]; then
- error "Usage: $0 download []"
- exit 10
- fi
-
- # Detect architecture
- local arch=$(uname -m)
- case "$arch" in
- x86_64) arch="amd64" ;;
- aarch64) arch="arm64" ;;
- *)
- error "Unsupported architecture: $arch"
- return 1
- ;;
- esac
-
- # Find the package entry in the database
- local entry
- local force=0
- [[ "$4" == "force" || "$3" == "force" ]] && force=1
- local existing_version
- existing_version=$(grep -m1 "^$package_name" "$CURRENT_PACKAGES" | cut -d'|' -f2)
-
- if [[ "$requested_version" == "" ]]; then
- requested_version=$(get_latest_version $package_name $arch)
- fi
-
- entry=$(grep "^$package_name|$requested_version" "$PACKAGES_LOCK" | grep "$arch")
-
- if [[ $force == 0 && "$requested_version" == "$existing_version" ]]; then
- return 1
- fi
-
- if [[ -z "$entry" ]]; then
- error "Package $package_name version $requested_version for $arch not found."
- return 2
- fi
-
- # Parse the entry
- IFS='|' read -r name version arch url stored_hash <<< "$entry"
-
- # Download the file
- echo "Downloading package $package_name version $requested_version for $arch... current version is $existing_version"
- local tmp_file=$(mktemp)
- curl -sL -o "$tmp_file" "$url"
- if [[ $? -ne 0 ]]; then
- error "Error downloading file: $url"
- rm "$tmp_file"
- return 3
- fi
- mv "$tmp_file" "$output_file"
-
- # Verify the hash
- local downloaded_hash=$(generate_hash "$output_file")
- if [[ "$downloaded_hash" != "$stored_hash" ]]; then
- error "Hash mismatch for $output_file. Expected $stored_hash, got $downloaded_hash."
- rm "$output_file"
- return 4
- fi
-
- echo "Package $package_name version $version downloaded successfully to $output_file."
-}
-get_latest_version() {
- local package_name=$1
- local arch=$2
- local entry
- entry=$(grep "^$package_name" "$PACKAGES_LOCK" | grep "$arch" | sort -t'|' -k2.1V | tail -n 1)
- local version
- version=${entry#*$package_name|} # remove package name
- version=${version%%|*} # remove the rest
- echo $version
-}
-# Set the current installed version of a package
-set_installed_version() {
- local package_name=$1
- local version=$2
- if [[ -z "$version" ]]; then
- version=$(get_latest_version $package_name)
- fi
- if [[ -z "$package_name" || -z "$version" ]]; then
- error "Usage: $0 set-installed "
- exit 1
- fi
-
- # Check if the entry already exists in package.lock
- existing_entry=$(grep "^$package_name|" "$CURRENT_PACKAGES")
- if [[ -n "$existing_entry" ]]; then
- # Update the existing entry
- sed -i "s|^$package_name\|.*|$package_name\|$version|" "$CURRENT_PACKAGES"
- echo "Updated installed version of $package_name for $arch to $version."
- else
- # Add a new entry
- echo "$package_name|$version" >> "$CURRENT_PACKAGES"
- echo "Set installed version of $package_name to $version."
- fi
-}
-
-if [[ $BASH_SOURCE == "$0" ]]; then
-# Main script entry point
-case "$1" in
- add)
- add_package "$2" "$3" "$4" "$5"
- ;;
- download)
- download_package "$2" "$3" "$4"
- ;;
- set-installed)
- set_installed_version "$2" "$3"
- ;;
- get-latest-version)
- get_latest_version "$2" "$3"
- ;;
- *)
- error "Usage: $0 {add|download|set-installed} "
- exit 1
- ;;
-esac
-fi
\ No newline at end of file
diff --git a/common/remove_remote_assistant.sh b/common/remove_remote_assistant.sh
deleted file mode 100644
index 867c76a91..000000000
--- a/common/remove_remote_assistant.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-hiddify_ssh_key="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWEXarp7YrTNX+4uNfdYtQ1lVsrD9/6oHaNiR6kgzoeShD/+3Ljou3veXofVstCb6CpFZdmOaKXNJyT5N+gm0eXwYJNsnrkCRq9h/6ydkoVdPAINzHZoetVqwqAPgmqzR8xTKZPP/Ky3Ks8OQEIg1Swnm9XXuP+ApmvOxGut9pPhOozKSATklojRaAmhdz4y9YpkLi94C1Ixd10Ewjld4pnVp4+uDTkXV2i3N3lH5x6zFrk2tefigoZ60brNWC3TGL3SjQ4obkD2qKpKqIRy63cUzfI0lP/0vZ7Ms5ESPlLI/ebMGvns9hINi1KRJ8m0//Jy0CDngJNJxG8KGbvqvLu/avmdVUHr48y7bk6VTGicMp16LfbszRQRF2d61n5uwBGXUB5DbVNI00yOdqAflDEloBEchqiWIEotBXyGTB1e2V1Oe95W27h9QSMbhNwmEk/QGPn4yhRgTbFq1TwNhE6DXZrCUbW8x4KVMQTSD+seUB0fMgTTXtzpPEo3mFAME= hiddify@assistant"
-
-grep -v "$hiddify_ssh_key" ~/.ssh/authorized_keys >~/.ssh/authorized_keys_temp
-mv ~/.ssh/authorized_keys_temp ~/.ssh/authorized_keys
-
-echo "remote assistant access is removed!"
diff --git a/common/replace_variables.sh b/common/replace_variables.sh
deleted file mode 100755
index 1f0aaa8bb..000000000
--- a/common/replace_variables.sh
+++ /dev/null
@@ -1,35 +0,0 @@
-cd $(dirname -- "$0")
-source ./utils.sh
-activate_python_venv
-
-domains=$(cat ../current.json | jq -r '.domains[] | .domain' | tr '\n' ' ')
-
-
-# Loop over the .crt files
-for f in /opt/hiddify-manager/ssl/*.crt; do
- # Get the basename without the .crt extension
- d=$(basename "$f" .crt)
- # Check if $d is not in the list of domains
- if [[ ! " ${domains[@]} " =~ " ${d} " ]]; then
- # If $d is not in domains, remove the file
- rm "/opt/hiddify-manager/ssl/$d.crt"
- rm "/opt/hiddify-manager/ssl/$d.crt.key"
- fi
-done
-
-# we need at least one ssl certificate to be able to run haproxy
-for d in $domains; do
- (bash /opt/hiddify-manager/acme.sh/generate_self_signed_cert.sh $d >/dev/null 2>&1)
-done
-
-# /opt/hiddify-manager/.venv313/bin/python -c "import json5;import jinja2" || uv pip install json5 jinja2
-# rm -f /opt/hiddify-manager/singbox/configs/*.json
-rm -f /opt/hiddify-manager/xray/configs/05_inbounds_10*.json*
-rm -f /opt/hiddify-manager/xray/configs/05_inbounds_h2*.json*
-rm -f /opt/hiddify-manager/xray/configs/05_inbounds_02_realitygrpc*.json*
-rm -f /opt/hiddify-manager/xray/configs/05_inbounds_02_realityh2*.json*
-rm -f /opt/hiddify-manager/singbox/configs/05_inbounds_2071_realitygrpc_main.json*
-rm -f /opt/hiddify-manager/singbox/configs/05_inbounds_20[123][1234]*.json*
-
-
-/opt/hiddify-manager/common/jinja.py $MODE
diff --git a/common/run.sh.j2 b/common/run.sh.j2
deleted file mode 100755
index 3ab03335c..000000000
--- a/common/run.sh.j2
+++ /dev/null
@@ -1,144 +0,0 @@
-source /opt/hiddify-manager/common/utils.sh
-
-if [ "$MODE" != "docker" ];then
- if [[ '{{hconfigs['country']}}' == 'cn' ]]; then
- TIMEZONE=Asia/Shanghai
- elif [[ '{{hconfigs['country']}}' == 'ru' ]]; then
- TIMEZONE=Europe/Moscow
- else
- TIMEZONE=Asia/Tehran
- fi
- CURRENT_TZ=$(timedatectl show --property=Timezone --value)
- if [[ "$CURRENT_TZ" != "$TIMEZONE" ]]; then
- echo "Current timezone is $CURRENT_TZ. Changing to $TIMEZONE..."
- sudo timedatectl set-timezone "$TIMEZONE"
- sudo systemctl restart mariadb
- fi
-fi
-
-#if [ "${MODE}" != "docker" ];then
-
-# open essential ports
-allow_port "tcp" 22
-allow_port "tcp" 80
-allow_port "tcp" 443
-allow_port "udp" 443
-allow_port "udp" 53
-allow_port "tcp" 53
-# allow_port "udp" 3478
-
-allow_port "udp" {{hconfigs['wireguard_port']}} #wireguard
-
-
-add2iptables46 "INPUT -p udp -m conntrack --ctstatus SEEN_REPLY,ASSURED,CONFIRMED -j ACCEPT"
-
-
-add2iptables46 "OUTPUT -p udp -j ACCEPT"
-add2iptables46 "OUTPUT -p tcp -j ACCEPT"
-add2iptables46 "INPUT -i lo -j ACCEPT"
-
-{% if hconfigs['shadowsocks2022_enable'] %}
- allow_port "udp" {{hconfigs['shadowsocks2022_port']}} #shadowsocks
- allow_port "tcp" {{hconfigs['shadowsocks2022_port']}} #shadowsocks
-{% endif %}
-
-{% for d in domains if d['internal_port_hysteria2'] or d['internal_port_tuic'] or d['internal_port_naive'] %}
- {% if d['internal_port_hysteria2']>0 %}
- allow_port "udp" {{d['internal_port_hysteria2']}} #hysteria2
- {%endif%}
- {% if d['internal_port_tuic']>0 %}
- allow_port "udp" {{d['internal_port_tuic']}} #tuic
- {%endif%}
-
- {% if d['internal_port_naive']>0 %}
- allow_port "udp" {{d['internal_port_naive']}} #naive_quic
- {%endif%}
-{%endfor%}
-
-{% if hconfigs['mieru_enable'] %}
- {% for port in hconfigs["mieru_tcp_ports"].split(",") %}
- {%if port %}
- allow_port "tcp" {{port}} #tcp_mieru
- {%endif%}
- {%endfor%}
- {% for port in hconfigs["mieru_udp_ports"].split(",") %}
- {%if port %}
- allow_port "udp" {{port}} #udp_mieru
- {%endif%}
- {%endfor%}
-{%endif%}
-# ICMP for ipv4
-add2iptables "INPUT -p icmp -j ACCEPT"
-# add2iptables "INPUT -p icmp -m icmp --icmp-type 0 -m conntrack --ctstate NEW -j ACCEPT"
-# add2iptables "INPUT -p icmp -m icmp --icmp-type 3 -m conntrack --ctstate NEW -j ACCEPT"
-# add2iptables "INPUT -p icmp -m icmp --icmp-type 11 -m conntrack --ctstate NEW -j ACCEPT"
-# add2iptables "INPUT -p icmp -m icmp --icmp-type 12 -m conntrack --ctstate NEW -j ACCEPT"
-
-# ICMP for ipv6
-add2ip6tables "INPUT -p ipv6-icmp -j ACCEPT"
-# add2ip6tables "INPUT -p ipv6-icmp --icmpv6-type 128 -m conntrack --ctstate NEW -j ACCEPT"
-# add2ip6tables "INPUT -p ipv6-icmp --icmpv6-type 129 -m conntrack --ctstate NEW -j ACCEPT"
-# add2ip6tables "INPUT -p ipv6-icmp --icmpv6-type 1 -m conntrack --ctstate NEW -j ACCEPT"
-# add2ip6tables "INPUT -p ipv6-icmp --icmpv6-type 4 -m conntrack --ctstate NEW -j ACCEPT"
-# add2ip6tables "INPUT -p ipv6-icmp --icmpv6-type 2 -m conntrack --ctstate NEW -j ACCEPT"
-
-allow_apps_ports "sshd"
-allow_apps_ports "x-ui"
-
-add2iptables46 "INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT"
-add2iptables46 "INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT"
-
-# Check if SSH server should be enabled
-{% if hconfigs['ssh_server_enable'] %}
-allow_port "tcp" {{hconfigs['ssh_server_port']}} #ssh_server
-{%else%}
-remove_port "tcp" {{hconfigs['ssh_server_port']}} #ssh_server
-{%endif%}
-
-{% for port in (hconfigs['tls_ports']+","+ hconfigs['http_ports']).split(',') if port %}
-allow_port "tcp" {{port}} #panel ports
-{%endfor%}
-{% for port in (hconfigs['tls_ports']).split(',') if port %}
- allow_port "udp" {{port}}
-{%endfor%}
-
-# Check if PasswordAuthentication is enabled
-if ! grep -rxq "PasswordAuthentication.*no" /etc/ssh/sshd*; then
- chmod a+r /etc/ssh/sshd*
- WARNING_MSG="Hiddify! Your server is vulnerable to abuses because PasswordAuthentication is enabled. To secure your server, please switch to key authentication mechanism and turn off PasswordAuthentication in your ssh config file."
-
- if [[ $(grep "Your server is vulnerable" /etc/motd | wc -l) == 0 ]]; then
- error "$WARNING_MSG" >>/etc/motd 2>&1
- fi
-else
- sed -i "/Your server is vulnerable/d" /etc/motd
-fi
-
-# Restart sshd/ssh
-sudo systemctl restart sshd.service
-sudo systemctl restart ssh.service
-
-{% if hconfigs['firewall'] %}
-iptables -P INPUT DROP
-iptables -P FORWARD DROP
-ip6tables -P INPUT DROP
-ip6tables -P FORWARD DROP
-{%else%}
-iptables -P INPUT ACCEPT
-iptables -P FORWARD ACCEPT
-ip6tables -P FORWARD ACCEPT
-ip6tables -P INPUT ACCEPT
-{%endif%}
-
-save_firewall
-
-#add2iptables "INPUT -p tcp --dport 9000 -j DROP"
-#fi
-{% if hconfigs['auto_update'] %}
-echo "0 3 * * * root $(pwd)/../update.sh --no-gui --no-log" >/etc/cron.d/hiddify_auto_update
-service cron reload
-{%else%}
-rm -rf /etc/cron.d/hiddify_auto_update
-service cron reload
-{%endif%}
-
diff --git a/common/utils.sh b/common/utils.sh
deleted file mode 100644
index 596a8973d..000000000
--- a/common/utils.sh
+++ /dev/null
@@ -1,685 +0,0 @@
-export venv_path="/opt/hiddify-manager/.venv313"
-
-function get_commit_version() {
- json_data=$(curl -sL -H "Accept: application/json" "https://github.com/hiddify/$1/commits/main.atom")
- latest_commit_date=$(echo "$json_data" | jq -r '.payload.commitGroups[0].commits[0].committedDate')
- # xml_data=$(curl -sl "https://github.com/hiddify/$1/commits/main.atom")
- # latest_commit_date=$(echo "$xml_data" | grep -m 1 '' | awk -F'>|<' '{print $3}')
- # COMMIT_URL=$(curl -s https://api.github.com/repos/hiddify/$1/git/refs/heads/main | jq -r .object.url)
- # latest_commit_date=$(curl -s $COMMIT_URL | jq -r .committer.date)
- echo "${latest_commit_date:5:11}"
-}
-
-function get_pre_release_version() {
- # lastversion "$1" --pre --at github
- VERSION=$(curl -sL "https://api.github.com/repos/hiddify/$1/releases" | jq -r 'map(select(.prerelease == true or .draft == true)) | sort_by(.created_at) | last | .tag_name')
- VERSION=${VERSION/#v/}
- echo $VERSION
-}
-
-function get_release_version() {
- VERSION=$(curl -sL "https://api.github.com/repos/hiddify/$1/releases" | jq -r 'map(select(.prerelease == false)) | sort_by(.created_at) | last | .tag_name')
- if [ -z $VERSION ]; then
- # COMMIT_URL=https://api.github.com/repos/hiddify/$1/releases/latest
- # VERSION=$(curl -s --connect-timeout 1 $COMMIT_URL | jq -r .tag_name)
- location=$(curl -sI "https://github.com/hiddify/$1/releases/latest" | grep -i location | awk -F' ' '{print $2}' | tr -d '\r')
- if [[ $location == *"latest"* ]]; then
- location=$(curl -sI "$location" | grep -i location | awk -F' ' '{print $2}' | tr -d '\r')
- fi
-
- VERSION=$(echo $location | rev | awk -F/ '{print $1}' | rev)
- VERSION="${VERSION//$'\r'/}"
- fi
- VERSION=${VERSION/#v/}
- echo $VERSION
-}
-
-function hiddifypanel_path() {
- activate_python_venv
- /opt/hiddify-manager/.venv313/bin/python -c "import os,hiddifypanel;print(os.path.dirname(hiddifypanel.__file__),end='')" 2>&1 || echo "panel is not installed yet."
-}
-function get_installed_panel_version() {
- activate_python_venv
- version=$(cat "$(hiddifypanel_path)/VERSION" 2>/dev/null)
- if [ -z "$version" ]; then
- version="-"
- fi
- echo $version
-}
-function get_installed_config_version() {
- version=$(cat /opt/hiddify-manager/VERSION 2>/dev/null)
-
- if [ -z "$version" ]; then
- version="-"
- fi
- echo $version
-}
-
-function get_package_mode() {
- reload_all_configs | jq -r '.chconfigs["0"].package_mode'
-}
-
-function error() {
- echo -e "\033[91m$1\033[0m" >&2
-}
-
-function warning() {
- echo -e "\033[93m$1\033[0m" >&2
-}
-
-function success() {
- echo -e "\033[92m$1\033[0m" >&2
-}
-
-function get_pretty_service_status() {
- status=$(systemctl is-active $1)
- if [ $? == 0 ]; then
- success $status
- else
- error $status
- fi
-}
-function add_DNS_if_failed() {
- # Domain to check
- DOMAIN="yahoo.com"
-
- # Use dig to resolve the domain
- dig +short $DOMAIN >/dev/null 2>&1
-
- # Check the exit status of the dig command
- if [ $? -ne 0 ]; then
- echo "Dig failed to resolve $DOMAIN! Adding nameserver 8.8.8.8 to /etc/resolv.conf..."
- # Check if 8.8.8.8 is already in the file to avoid appending it multiple times
- grep -q "8.8.8.8" /etc/resolv.conf || echo "nameserver 8.8.8.8" | sudo tee -a /etc/resolv.conf
- # else
- # echo "Dig resolved $DOMAIN successfully!"
- fi
-
-}
-
-function disable_ansii_modes() {
- echo -e "\033[?25l"
- echo -e "\e[?1003l"
- #echo -e '\033c'
- echo -e '\e[?25h'
- tput sgr0
- pkill -9 dialog
-}
-
-function update_progress() {
- title="${1^}"
- text="$2"
- percentage="$3"
- echo -e "####$percentage####$title####$text####"
-}
-
-function is_installed_pypi_package() {
- activate_python_venv
- package_name="$1"
- if [ "$USE_VENV" == "310" ];then
- if pip list --format=freeze | grep -E "^$package_name" >/dev/null; then
- return 0
- else
- echo "Package $package_name is not installed."
- return 1
- fi
- else
- if uv pip list --format=freeze | grep -E "^$package_name" >/dev/null; then
- return 0
- else
- echo "Package $package_name is not installed."
- return 1
- fi
- fi
-}
-
-function install_pypi_package() {
- activate_python_venv
- for package in $@; do
- if ! is_installed_pypi_package $package; then
- if [ "$USE_VENV" == "310" ];then
- pip install -U $package
- else
- uv pip install -U $package
- fi
- fi
- done
-}
-function is_installed_package() {
- package_spec="$1"
-
- # Extract package name and version from the package specification
- package_name=$(echo "$1" | cut -d'=' -f1)
- version=$(echo "$1" | cut -s -d'=' -f2)
- if dpkg -l | grep -qE "^ii $package_name *$version"; then
- return 0
- else
- echo "$package_name version $version is not installed."
- return 1
- fi
-}
-install_package() {
- local not_installed_packages=""
- local package
-
- for package in "$@"; do
- if ! is_installed_package "$package"; then
- # The package is not installed, add it to the list
- not_installed_packages+=" $package"
- fi
- done
-
- if [ -n "$not_installed_packages" ]; then
- apt install -y --no-install-recommends $not_installed_packages
-
- # Check if installation failed
- if [ $? -ne 0 ]; then
- apt --fix-broken install -y
- apt update
- #retries for 3 times
- apt install -y $not_installed_packages ||apt install -y $not_installed_packages||apt install -y $not_installed_packages
-
- fi
- fi
-}
-
-function remove_package() {
- for package in $@; do
- if dpkg -l | grep -q "^ii $package"; then
- apt remove -y --auto-remove "$package"
- fi
- done
-}
-
-function is_installed() {
- if ! command -v "$1" >/dev/null 2>&1; then
- return 1
- fi
- return 0
-}
-
-function msg_with_hiddify() {
- text=$(
- cat </dev/null; then
- echo "Python 3.10 is not installed. "
- install_package software-properties-common
- add-apt-repository -y ppa:deadsnakes/ppa
- # sudo apt-get -y remove python*
- fi
- install_package python3.10-dev
- # ln -sf $(which python3.10) /usr/bin/python3
- ln -sf /usr/bin/python3 /usr/bin/python
- if ! pip --version>/dev/null; then
- curl https://bootstrap.pypa.io/get-pip.py | python3.10 -
- python3.10 -m pip install -U pip
- fi
- # endregion
-
- # region make virtual env
- # Some third-party packages are not compatible with python3.13 eg. grpcio-tools
- # Therefore we still use python3.10
- # Check if USE_VENV doesn't exist or is true
-# if [ "${USE_VENV}" = "310" ]; then
- activate_python_venv
- # fi
- # endregion
-
-}
-function check_hiddify_panel() {
- if [ "$MODE" != "apply_users" ]; then
- reload_all_configs >/dev/null
-
- if [[ $? != 0 ]]; then
- error "Exception in Hiddify Panel. Please send the log to hiddify@gmail.com"
- echo "4" >log/error.lock
- exit 4
- fi
- echo -e "\n\n"
-
- bash /opt/hiddify-manager/status.sh
- bash /opt/hiddify-manager/common/logo.ico
-
- install_package qrencode
- center_text "$(qrencode -t utf8 -m 2 $(cat /opt/hiddify-manager/current.json | jq -r '.panel_links[]' | tail -n 1))"
- echo ""
- center_text $'\t\033[92mFinished! Thank you for helping to skip filternet.\033[0m'
-
- echo -e "\n"
- echo "Please open the following link in the browser for client setup:"
- cat /opt/hiddify-manager/current.json | jq -r '.panel_links[]' | while read -r link; do
- if [[ $link == http://* ]]; then
- link="[insecure] $link"
- error " $link"
- elif [[ $link =~ ^https://(.+@)?[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ ]]; then
- link="[HTTPS] $link"
- warning " $link"
- else
- success " \e]8;;$link\e\\$link\e]8;;\e\\ "
- # success " $link"
- fi
-
- done
-
- # (cd hiddify-panel && python3 -m hiddifypanel admin-links)
-
- for s in hiddify-xray hiddify-singbox hiddify-nginx hiddify-haproxy mysql; do
- [ $s == "hiddify-xray" ] && [ "$(hconfig 'core_type')" != "xray" ] && continue
- s=${s##*/}
- s=${s%%.*}
- for i in $(seq 1 10); do
- if [[ "$(systemctl is-active "$s")" == "active" ]]; then
- break
- else
- if [ $i -eq 10 ]; then
- error "important service $s is not activated after 10 seconds"
- error "Installation Failed!"
- echo "32" >/opt/hiddify-manager/log/error.lock
- exit 32
- fi
- warning "an important service $s is not activating yet"
- sleep 1
- fi
- done
-
- done
- fi
-}
-
-function add2iptables46(){
- add2iptables "$1"
- add2ip6tables "$1"
-}
-
-function add2iptables() {
- iptables -C $1 >/dev/null 2>&1 || echo "adding rule $1" && iptables -I $1
-
-}
-function add2ip6tables() {
- ip6tables -C $1 >/dev/null 2>&1 || echo "adding rule $1" && ip6tables -I $1
-}
-function allow_port() { #allow_port "tcp" "80"
- add2iptables46 "INPUT -p $1 --dport $2 -j ACCEPT"
-
- # if [[ $1 == 'udp' ]]; then
- add2iptables46 "INPUT -p $1 -m $1 --dport $2 -m conntrack --ctstate NEW -j ACCEPT"
- # fi
-}
-
-function block_port() { #allow_port "tcp" "80"
- add2iptables46 "INPUT -p $1 --dport $2 -j DROP"
-}
-
-function remove_port() { #allow_port "tcp" "80"
- iptables -D INPUT -p "$1" --dport "$2" -j ACCEPT
- ip6tables -D INPUT -p "$1" --dport "$2" -j ACCEPT
-}
-
-function allow_apps_ports() {
- local service_name=$1
-
- # Get ports and paths for the service
- local ports=$(ss -tulpn | grep "$service_name" | awk '{print $5}' | cut -d':' -f2)
- local paths=$(pgrep -f "$service_name" | while read -r pid; do readlink -f /proc/"$pid"/exe; done | awk '!seen[$0]++')
-
- if [[ -z $ports ]]; then
- echo "Service not found or not running"
- else
- IFS=' ' read -ra portArray <<<"$ports"
- for p in "${portArray[@]}"; do
- for path in $paths; do
- echo "Service is running on port $p and path $path"
- allow_port "tcp" "$p"
- done
- done
- fi
-}
-function save_firewall() {
- mkdir -p /etc/iptables/
- iptables-save >/etc/iptables/rules.v4
- awk -i inplace '!seen[$0]++' /etc/iptables/rules.v4
- echo "COMMIT" >> /etc/iptables/rules.v4
- ip6tables-save >/etc/iptables/rules.v6
- awk -i inplace '!seen[$0]++' /etc/iptables/rules.v6
- echo "COMMIT" >> /etc/iptables/rules.v6
- ip6tables-restore /dev/null 2>&1
- echo $LOG_DIR
-}
-
-function log_file() {
- echo "$(log_dir)/${1}.log"
-}
-
-function set_lock() {
- LOCK_DIR="/opt/hiddify-manager/log"
- mkdir -p "$LOCK_DIR" >/dev/null 2>&1
- LOCK_FILE=$LOCK_DIR/$1.lock
- if [[ -f $LOCK_FILE && $(($(date +%s) - $(cat $LOCK_FILE))) -lt 120 ]]; then
- error "Another installation is running.... Please wait until it finishes or wait 5 minutes or execute 'rm $LOCK_FILE'"
- exit 12
- fi
- echo "$(date +%s)" >$LOCK_FILE
-}
-
-function remove_lock() {
- LOCK_DIR="/opt/hiddify-manager/log"
- LOCK_FILE=$LOCK_DIR/$1.lock
- rm -f $LOCK_FILE >/dev/null 2>&1
-}
-
-
-function hconfig() {
- local json_file="/opt/hiddify-manager/current.json"
- [ ! -f "$json_file" ] && { error "panel config file not found"; return 1; }
-
- local key=$1
- local essential_vars=$(jq -r '.chconfigs["0"] | to_entries[] | .key' "$json_file")
- for var in $essential_vars; do
- if [ "$key" == "$var" ]; then
- local value=$(jq -r --arg var "$var" '.chconfigs["0"][$var]' "$json_file")
- echo "$value"
- return 0 # Exit the function with success status
- fi
- done
-
- # If the key is not found, return an error status
- error "Error: Key not found: $key"
- return 1
-}
-#TODO: check functionality when not using the venv
-function hiddify-panel-run() {
- local user=$(whoami)
- local base_command="cd /opt/hiddify-manager/hiddify-panel/; source ${venv_path}/bin/activate && $@"
- local command=""
-
- if [ "$user" == "hiddify-panel" ]; then
- command="$base_command"
- else
- command="su hiddify-panel -c \"$base_command\""
- fi
-
- eval "$command"
-}
-
-function hiddify-panel-cli() {
- hiddify-panel-run "python3 -m hiddifypanel $*"
-}
-# region installer utils
-function checkOS() {
- # List of supported distributions
- #supported_distros=("Ubuntu" "Debian" "Fedora" "CentOS" "Arch")
- supported_distros=("Ubuntu")
- # Get the distribution name and version
- if [[ -f "/etc/os-release" ]]; then
- source "/etc/os-release"
- distro_name=$NAME
- distro_version=$VERSION_ID
- else
- echo "Unable to determine distribution."
- exit 1
- fi
- # Check if the distribution is supported
- if [[ " ${supported_distros[@]} " =~ " ${distro_name} " ]]; then
- echo "Your Linux distribution is ${distro_name} ${distro_version}"
- : #no-op command
- else
- # Print error message in red
- echo -e "\e[31mYour Linux distribution (${distro_name} ${distro_version}) is not currently supported.\e[0m"
- exit 1
- fi
-
- # This script only works on Ubuntu 22 and above
- if [ "$(uname)" == "Linux" ]; then
- version_info=$(lsb_release -rs | cut -d '.' -f 1)
- # Check if it's Ubuntu and version is below 22
- if [ "$(lsb_release -is)" == "Ubuntu" ] && [ "$version_info" -lt 22 ]; then
- echo "This script only works on Ubuntu 22 and above"
- exit
- fi
- fi
-}
-function disable_panel_services() {
- # rm /etc/cron.d/hiddify_usage_update
- # rm /etc/cron.d/hiddify_auto_backup
- # service cron reload >/dev/null 2>&1
- # kill -9 $(pgrep -f 'hiddifypanel update-usage')
- # systemctl restart mariadb
- echo ""
-}
-
-function vercomp () {
- if [[ $1 == $2 ]]
- then
- echo 0
- return 0
- fi
- local IFS=.
- local i ver1=($1) ver2=($2)
- # fill empty fields in ver1 with zeros
- for ((i=${#ver1[@]}; i<${#ver2[@]}; i++))
- do
- ver1[i]=0
- done
- for ((i=0; i<${#ver1[@]}; i++))
- do
- if [[ -z ${ver2[i]} ]]
- then
- # fill empty fields in ver2 with zeros
- ver2[i]=0
- fi
- if ((10#${ver1[i]//[!0-9]/} > 10#${ver2[i]//[!0-9]/}))
- then
- echo 1
- return 1
- fi
- if ((10#${ver1[i]//[!0-9]/} < 10#${ver2[i]//[!0-9]/}))
- then
- echo 2
- return 2
- fi
- done
- echo 0
- return 0
-}
-
-
-function check_venv_compatibility() {
- package_mode=${1:-release}
-
- if [ "$package_mode" == "false" ]; then
- package_mode="release"
- fi
-
- first_release_compatible_venv_version=v10.30
-
- case "$package_mode" in
- v*)
- # Check if version is greater than or equal to the compatible release version
-
- if [ $(vercomp "$package_mode" "$first_release_compatible_venv_version") == 0 ] || [ $(vercomp "$package_mode" "$first_release_compatible_venv_version") == 1 ]; then
- USE_VENV=310
- fi
- ;;
- develop|dev)
- # Develop is always venv compatible
- USE_VENV=313
- ;;
- beta)
- # Beta is always venv compatible
- USE_VENV=313
- ;;
- release)
- # Get the latest release version
- USE_VENV=313
- ;;
- *)
- echo "Unknown package mode: $package_mode"
- exit 1
- ;;
- esac
-}
-
-function hiddify-http-api(){
- api_path=$(jq -r '.api_path' /opt/hiddify-manager/current.json)
- api_key=$(jq -r '.api_key' /opt/hiddify-manager/current.json)
-
-
- if [ -z "$api_path" ] || [ -z "$api_key" ]; then
- echo "invalid config file"
- return 1
- fi
- temp_file=$(mktemp)
- http_status=$(curl -s -o $temp_file -w "%{http_code}" http://localhost:9000/${api_path}/api/v2/$1 --header "Hiddify-API-Key: ${api_key}")
- cat $temp_file
- rm $temp_file
- if [ "$http_status" -ne 200 ];then
- echo $http_status
- return 1$http_status
- fi
- return 0
-}
-
-function reload_all_configs(){
- hiddify-http-api admin/all-configs/ > /opt/hiddify-manager/current.json
- if [ "$?" != 0 ];then
- hiddify-panel-cli all-configs > /opt/hiddify-manager/current.json
- if [ $? != 0 ]; then
- return $?
- fi
- fi
- chmod 600 /opt/hiddify-manager/current.json
- cat /opt/hiddify-manager/current.json
-}
-
-
-
-
-set_files_in_folder_readable_to_hiddify_common_group() {
- # Ensure paths with spaces or special characters are handled correctly
- file=$1
- find "$file" -type d -exec chmod u+rx,g+rx,o-rwx {} \; # Directories get rwx for owner, rw- for group
- find "$file" -type f -exec chmod 640 {} \; # Files get rw- for owner and group
- find "$file" -exec chown :hiddify-common {} \;
- # Handle parent directories if the parent is not "hiddify-manager"
- # Resolve the absolute path of the input
-
- parent=$(realpath "$file")
-
- while [[ $(basename "$parent") != "hiddify-manager" && "$parent" != "/" ]]; do
- echo "Setting permissions on $parent"
- chmod u+rx,g+rx "$parent" # Set permissions on the parent
- chown :hiddify-common "$parent" # Change ownership to the group
- parent=$(dirname "$parent") # Move to the next parent directory
- done
-}
\ No newline at end of file
diff --git a/docker-init.sh b/docker-init.sh
index 8ee1e7862..8956a9253 100755
--- a/docker-init.sh
+++ b/docker-init.sh
@@ -41,8 +41,14 @@ if [ $? -ne 0 ]; then
systemctl restart hiddify-panel
fi
-DO_NOT_INSTALL=true ./install.sh docker --no-gui $@
-./status.sh --no-gui
+# The old `./install.sh docker --no-gui` and `./status.sh --no-gui` shims
+# were removed when those legacy entrypoints were deleted. Hand off to the
+# python orchestrator instead. MODE=docker is honoured by common.py to skip
+# host-level steps that don't make sense inside a container (sysctl --system,
+# timezone changes via timedatectl).
+export MODE=docker
+./init.sh install
+./init.sh status
echo Hiddify is started!!!! in 5 seconds you will see the system logs
sleep 5
diff --git a/haproxy/install.sh b/haproxy/install.sh
deleted file mode 100755
index d6e988982..000000000
--- a/haproxy/install.sh
+++ /dev/null
@@ -1,32 +0,0 @@
-source ../common/utils.sh
-rm -rf *.template
-if is_installed sniproxy; then
- # systemctl kill hiddify-sniproxy > /dev/null 2>&1
- systemctl stop hiddify-sniproxy >/dev/null 2>&1
- systemctl disable hiddify-sniproxy >/dev/null 2>&1
- pkill -9 sniproxy >/dev/null 2>&1
-fi
-
-HAPROXY_VERSION=3.3
-if grep -q '^VERSION_CODENAME=jammy' /etc/os-release; then \
- warning "Deprecated Warning: OS is Jammy (Ubuntu 22.04). haproxy max version is 3.0"; \
- HAPROXY_VERSION=3.0
- echo "OS version is 22, checking for haproxy=${HAPROXY_VERSION}"
-fi
-if ! is_installed_package "haproxy=${HAPROXY_VERSION}"; then
- echo "Adding PPA for haproxy-${HAPROXY_VERSION}"
- add-apt-repository -y ppa:vbernat/haproxy-${HAPROXY_VERSION}
- if [ $? -ne 0 ]; then
- add-apt-repository -y ppa:vbernat/haproxy-${HAPROXY_VERSION}
- fi
- echo "Installing haproxy ${HAPROXY_VERSION}"
- install_package "haproxy=${HAPROXY_VERSION}.*"
-else
- echo "haproxy ${HAPROXY_VERSION} is already installed"
-fi
-systemctl kill haproxy >/dev/null 2>&1
-systemctl stop haproxy >/dev/null 2>&1
-systemctl disable haproxy >/dev/null 2>&1
-
-ln -sf $(pwd)/hiddify-haproxy.service /etc/systemd/system/hiddify-haproxy.service
-systemctl enable hiddify-haproxy.service
diff --git a/haproxy/run.sh b/haproxy/run.sh
deleted file mode 100755
index ce41907cc..000000000
--- a/haproxy/run.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-# ln -sf $(pwd)/haproxy.cfg /etc/haproxy/haproxy.cfg
-
-# REALITY_SERVER_NAMES_HAPROXY=$(echo "$REALITY_SERVER_NAMES" | sed 's/,/ || /g')
-# sed -i "s|REALITY_SERVER_NAMES|server $REALITY_SERVER_NAMES_HAPROXY|g" haproxy.cfg
-
-#
-source ../common/utils.sh
-
-chmod 600 *.cfg*
-# systemctl reload hiddify-haproxy
-systemctl stop hiddify-haproxy
-systemctl start hiddify-haproxy
diff --git a/hiddify-panel/backup.sh b/hiddify-panel/backup.sh
deleted file mode 100755
index bfdfa2adc..000000000
--- a/hiddify-panel/backup.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/bin/bash
-cd $( dirname -- "$0"; )
-source ../common/utils.sh
-
-function main(){
- activate_python_venv
- hiddify-panel-cli backup
-}
-main |& tee -a ../log/system/backup.log
\ No newline at end of file
diff --git a/hiddify-panel/download_yt.sh b/hiddify-panel/download_yt.sh
deleted file mode 100644
index 93f9d64a8..000000000
--- a/hiddify-panel/download_yt.sh
+++ /dev/null
@@ -1,30 +0,0 @@
-mkdir -p videos
-which yt-dlp
-if [[ "$?" != 0 ]];then
- source /opt/hiddify-manager/common/utils.sh
- activate_python_venv
- pip3 install yt-dlp
-fi
-declare -A arr
-arr["features"]="https://www.youtube.com/watch?v=-a4tfRUsrNY"
-arr["webapp-ios"]="https://youtube.com/shorts/GBywNl2KZMM"
-arr["webapp-android"]="https://youtube.com/shorts/_-Iyr_RtIH0"
-arr["ios-fair"]="https://youtu.be/01m7w-I4JXE"
-arr["ios-wingx"]="https://youtu.be/qFKv4I-MNQc"
-arr["ios-stash"]="https://youtu.be/D0Xv54nRSY8"
-arr["ios-shadowrocket"]="https://youtu.be/F2bC_mtbYmQ"
-arr["android-v2rayng"]="https://youtu.be/6HncctDHXVs"
-arr['android-hiddifyng']="https://youtu.be/7B0PO3HM6Vg"
-arr['android-hiddifyclash']="https://youtu.be/8P887E-KMls"
-arr['windows-hiddifyn']="https://youtu.be/o9L2sI2T53Q"
-
-for key in ${!arr[@]}; do
- dst=videos/$key.mp4
- link=${arr[${key}]}
- if [[ ! -f $dst ]];then
- yt-dlp --socket-timeout 10 $link -o $dst
- fi
- if [[ ! -f $dst ]];then
- yt-dlp --socket-timeout 10 --proxy socks5://127.0.0.1:3000/ $link -o $dst
- fi
-done
diff --git a/hiddify-panel/install.sh b/hiddify-panel/install.sh
deleted file mode 100755
index 11854ec10..000000000
--- a/hiddify-panel/install.sh
+++ /dev/null
@@ -1,50 +0,0 @@
-source ../common/utils.sh
-activate_python_venv
-install_package wireguard libev-dev libevdev2 default-libmysqlclient-dev build-essential pkg-config ssh
-
-useradd -m hiddify-panel -s /bin/bash >/dev/null 2>&1
-usermod -aG hiddify-common hiddify-panel
-
-echo -n "" >> ../log/system/panel.log
-chown hiddify-panel ../log/system/panel.log
-chsh hiddify-panel -s /bin/bash
-
-chown -R hiddify-panel:hiddify-panel /home/hiddify-panel/ >/dev/null 2>&1
-localectl set-locale LANG=C.UTF-8 >/dev/null 2>&1
-su hiddify-panel -c update-locale LANG=C.UTF-8 >/dev/null 2>&1
-chown -R hiddify-panel:hiddify-panel . >/dev/null 2>&1
-# activate venv for hiddify-panel user
-if ! grep -Fxq "source /opt/hiddify-manager/.venv313/bin/activate" "/home/hiddify-panel/.bashrc" && ! grep -Fxq "export PATH=/opt/hiddify-manager/.venv313/bin:\$PATH" "/home/hiddify-panel/.bashrc"; then
- echo "source /opt/hiddify-manager/.venv313/bin/activate" >> "/home/hiddify-panel/.bashrc"
- echo "export PATH=/opt/hiddify-manager/.venv313/bin:\$PATH" >> "/home/hiddify-panel/.bashrc"
-fi
-
-
-ln -sf $(pwd)/hiddify-panel.service /etc/systemd/system/hiddify-panel.service
-systemctl enable hiddify-panel.service
-
-ln -sf $(pwd)/hiddify-panel-background-tasks.service /etc/systemd/system/hiddify-panel-background-tasks.service
-systemctl enable hiddify-panel-background-tasks.service
-
-if [ -n "$HIDDIFY_PANLE_SOURCE_DIR" ]; then
- echo "NOTICE: building hiddifypanel package from source..."
- echo "NOTICE: the source dir $HIDDIFY_PANLE_SOURCE_DIR"
- uv pip install -e "$HIDDIFY_PANLE_SOURCE_DIR"
-fi
-
-rm -rf /etc/cron.d/{hiddify_usage_update,hiddify_auto_backup}
-# echo "*/1 * * * * root $(pwd)/update_usage.sh" >/etc/cron.d/hiddify_usage_update
-# echo "0 */6 * * * hiddify-panel $(pwd)/backup.sh" >/etc/cron.d/hiddify_auto_backup
-service cron reload >/dev/null 2>&1
-
-
-##### download videos
-
-if [[ ! -e "GeoLite2-ASN.mmdb" || $(find "GeoLite2-ASN.mmdb" -mtime +1) ]]; then
- curl --connect-timeout 10 -sL -o GeoLite2-ASN.mmdb1 https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-ASN.mmdb && mv GeoLite2-ASN.mmdb1 GeoLite2-ASN.mmdb
-fi
-if [[ ! -e "GeoLite2-Country.mmdb" || $(find "GeoLite2-Country.mmdb" -mtime +1) ]]; then
- curl --connect-timeout 10 -sL -o GeoLite2-Country.mmdb1 https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb && mv GeoLite2-Country.mmdb1 GeoLite2-Country.mmdb
-fi
-
-# bash download_yt.sh &
diff --git a/hiddify-panel/run.sh b/hiddify-panel/run.sh
deleted file mode 100644
index eaf47cfde..000000000
--- a/hiddify-panel/run.sh
+++ /dev/null
@@ -1,53 +0,0 @@
-source ../common/utils.sh
-activate_python_venv
-
-echo -n "" >> ../log/system/panel.log
-chown hiddify-panel ../log/system/panel.log
-
-chown -R hiddify-panel:hiddify-panel . >/dev/null 2>&1
-chmod 600 app.cfg
-
-
-# set mysql password to flask app config
-sed -i '/^SQLALCHEMY_DATABASE_URI/d' app.cfg
-if [ -z "${SQLALCHEMY_DATABASE_URI}" ]; then
- if [ -z "${MYSQL_PASS}" ];then
- MYSQL_PASS=$(cat ../other/mysql/mysql_pass)
- fi
- SQLALCHEMY_DATABASE_URI="mysql+mysqldb://hiddifypanel:$MYSQL_PASS@localhost/hiddifypanel?charset=utf8mb4"
-fi
-echo "SQLALCHEMY_DATABASE_URI ='$SQLALCHEMY_DATABASE_URI'" >>app.cfg
-
-sed -i '/^REDIS_URI/d' app.cfg
-if [ -z "${REDIS_URI_MAIN}" ]; then
- if [ -z "${REDIS_PASS}" ];then
- REDIS_PASS=$(grep '^requirepass' "../other/redis/redis.conf" | awk '{print $2}')
- fi
- REDIS_URI_MAIN="redis://:${REDIS_PASS}@127.0.0.1:6379/0"
- REDIS_URI_SSH="redis://:${REDIS_PASS}@127.0.0.1:6379/1"
-fi
-
-echo "REDIS_URI_MAIN = '$REDIS_URI_MAIN'">>app.cfg
-echo "REDIS_URI_SSH = '$REDIS_URI_SSH'">>app.cfg
-
-
-
-if [ -f "../config.env" ]; then
- # systemctl restart --now mariadb
- # sleep 4
-
- hiddify-panel-cli import-config -c $(pwd)/../config.env
-
- # doesn't load virtual env
- #su hiddify-panel -c "hiddifypanel import-config -c $(pwd)/../config.env"
-
- if [ "$?" == 0 ]; then
- mv ../config.env ../config.env.old
- # echo "temporary disable removing config.env"
- fi
-fi
-hiddify-panel-cli init-db
-
-systemctl start hiddify-panel.service
-systemctl restart hiddify-panel-background-tasks.service
-
diff --git a/hiddify-panel/temporary_access.sh b/hiddify-panel/temporary_access.sh
deleted file mode 100755
index 0b22c5528..000000000
--- a/hiddify-panel/temporary_access.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/bash
-cd $(dirname -- "$0")
-
-function main() {
- PORT="${1:-9001}"
- echo "we are openning a port on $PORT"
- iptables -I INPUT -p tcp --dport $PORT -j ACCEPT
- kill $(lsof -t -i:$PORT)
- echo 'kill $(lsof -t -i:'$PORT')' | at now + 4 hour
- echo "iptables -D INPUT -p tcp --dport $PORT -j ACCEPT" | at now + 4 hour
-}
-
-main $@ |& tee ../log/system/temporary_access.log
diff --git a/hiddify-panel/update_usage.sh b/hiddify-panel/update_usage.sh
index ccca694ae..51c955c8e 100755
--- a/hiddify-panel/update_usage.sh
+++ b/hiddify-panel/update_usage.sh
@@ -1,20 +1,5 @@
#!/bin/bash
-
-cd $(dirname -- "$0")
-source ../common/utils.sh
-NAME="update_usage"
-function main() {
- echo "trying to update usage"
-
-
- hiddify-http-api admin/update_user_usage/
- if [ "$?" != 0 ] && [ -z $(pgrep -f 'hiddifypanel update-usage') ]; then
- hiddify-panel-cli "update-usage"
- fi
-
-
-}
-
-set_lock $NAME
-main |& tee -a ../log/system/update_usage.log
-remove_lock $NAME
\ No newline at end of file
+# Thin shim: real implementation in hiddify_manager.modules.update_usage.
+# Kept as a .sh so commander.py's Command.update_usage path doesn't churn.
+cd "$(dirname -- "$0")/.."
+exec /opt/hiddify-manager/.venv313/bin/python -m hiddify_manager.modules.update_usage "$@"
diff --git a/hiddify_manager/__init__.py b/hiddify_manager/__init__.py
new file mode 100644
index 000000000..f39e5e8d6
--- /dev/null
+++ b/hiddify_manager/__init__.py
@@ -0,0 +1 @@
+# Init
diff --git a/hiddify_manager/installer.py b/hiddify_manager/installer.py
new file mode 100644
index 000000000..8ef559d34
--- /dev/null
+++ b/hiddify_manager/installer.py
@@ -0,0 +1,59 @@
+import os
+import importlib
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.paths import module_dir as _module_dir
+
+def install_module(module_name, enable=True):
+ """
+ Simulates the bash `install_run` function.
+ """
+ if not enable:
+ log.info(f"Skipping module {module_name} (disabled)")
+ return
+
+ log.info(f"======================{module_name}=====================================")
+
+ # Check for python module. Use basename so paths like "other/redis"
+ # resolve to hiddify_manager.modules.redis instead of an invalid dotted
+ # path with a slash in it.
+ try:
+ short = os.path.basename(module_name)
+ py_module_name = f"hiddify_manager.modules.{short.replace('-', '_').replace('.', '_')}"
+ py_module = importlib.import_module(py_module_name)
+ if hasattr(py_module, 'install'):
+ log.info(f"Running Python installer for {module_name}")
+ try:
+ py_module.install()
+ except Exception as e:
+ log.exception(f"Python installer for {module_name} raised: {e} — continuing")
+ log.info(f"}}========================{module_name}===================================")
+ return
+ except ImportError:
+ pass # Fallback to bash
+
+ # Path to the module directory relative to the repository root
+ module_dir = _module_dir(module_name)
+
+ if not os.path.exists(module_dir):
+ log.error(f"Module directory does not exist: {module_dir}")
+ return
+
+ # Match legacy bash runsh(): per-script failures are logged but don't
+ # abort the install loop. Idempotency bugs in individual scripts (e.g.
+ # an `mv` on an already-renamed file) shouldn't kill the whole install.
+ install_script = os.path.join(module_dir, "install.sh")
+ if os.path.exists(install_script):
+ log.info(f"===install.sh {module_name}")
+ res = run_cmd(["bash", "install.sh"], cwd=module_dir, check=False)
+ if getattr(res, "returncode", 0) != 0:
+ log.error(f"install.sh for {module_name} exited {res.returncode} — continuing")
+
+ run_script = os.path.join(module_dir, "run.sh")
+ if os.path.exists(run_script):
+ log.info(f"===run.sh {module_name}")
+ res = run_cmd(["bash", "run.sh"], cwd=module_dir, check=False)
+ if getattr(res, "returncode", 0) != 0:
+ log.error(f"run.sh for {module_name} exited {res.returncode} — continuing")
+
+ log.info(f"}}========================{module_name}===================================")
diff --git a/hiddify_manager/manager.py b/hiddify_manager/manager.py
new file mode 100644
index 000000000..e6a2be69c
--- /dev/null
+++ b/hiddify_manager/manager.py
@@ -0,0 +1,267 @@
+import argparse
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.progress import progress
+from hiddify_manager.utils.system import check_root
+from hiddify_manager.installer import install_module
+
+
+# (percent, display name) per module. The percent is the value the panel's
+# progress bar should show *while* that module is being installed. Tuned to
+# roughly match the legacy install.sh's progression so the UI feels familiar.
+_INSTALL_PROGRESS = {
+ "common": (2, "Common Tools and Requirements"),
+ "other/redis": (8, "Redis"),
+ "other/mysql": (12, "MySQL"),
+ "hiddify-panel": (20, "Hiddify Panel"),
+ "nginx": (40, "Nginx"),
+ "haproxy": (50, "HAProxy"),
+ "acme.sh": (60, "Getting Certificates"),
+ "other/speedtest": (62, "SpeedTest"),
+ "other/dnstt": (65, "DNStt Proxy"),
+ "other/telegram": (68, "Telegram Proxy"),
+ "other/ssfaketls": (72, "FakeTLS Proxy"),
+ "other/ssh": (75, "SSH Proxy"),
+ "other/warp": (78, "Warp"),
+ "xray": (82, "Xray"),
+ "other/hiddify-cli": (86, "HiddifyCli"),
+ "other/wireguard": (90, "Wireguard"),
+ "singbox": (94, "Singbox"),
+}
+
+
+def _render_all_templates():
+ """
+ After the panel is up, walk the project tree and render every *.j2
+ file against current.json, then ensure each domain has a self-signed
+ cert under ssl/ (haproxy and nginx both refuse to start otherwise).
+ Mirrors what common/jinja.py + replace_variables.sh did in the
+ legacy install chain.
+ """
+ import os
+ from hiddify_manager.utils.config import hiddify_config
+ from hiddify_manager.utils.template import render_tree
+ from hiddify_manager.utils.paths import PROJECT_ROOT
+
+ configs = hiddify_config()
+ if not configs:
+ log.warning("render_all: no panel configs available — skipping global render")
+ return
+
+ # Generate self-signed certs BEFORE the template render — some
+ # templates (singbox/xray inbounds with TLS) shell to `ls ssl/*.crt`
+ # via the exec() helper and bake the listing into their JSON. If we
+ # render first, those captures contain the "ls: cannot access" error
+ # string and the config consumer fails to parse it.
+ from hiddify_manager.utils.certs import ensure_self_signed_cert
+ ssl_dir = os.path.join(PROJECT_ROOT, "ssl")
+ for d in (configs.get("domains") or []):
+ domain = d.get("domain") if isinstance(d, dict) else None
+ if domain:
+ ensure_self_signed_cert(domain, ssl_dir)
+
+ log.info("Rendering all *.j2 templates against current.json...")
+ render_tree([PROJECT_ROOT], configs)
+
+ # Post-panel system config: timezone, firewall, SSH MOTD audit,
+ # auto-update cron. Replaces common/run.sh.j2.
+ from hiddify_manager.modules.common import apply_runtime_config
+ log.info("Applying post-panel system config (timezone, firewall, sshd)...")
+ apply_runtime_config(configs)
+
+
+def _fetch_real_certs():
+ """Fetch real certs for direct-mode domains. Must run AFTER the full
+ module loop — needs the acme.sh binary installed (acme.sh module) and
+ nginx/haproxy running (to serve the HTTP-01 challenge). Replaces the
+ legacy acme.sh/run.sh per-domain get_cert loop."""
+ from hiddify_manager.utils.config import hiddify_config
+ from hiddify_manager.modules.cert_issuer import fetch_real_certs
+ configs = hiddify_config()
+ if not configs:
+ log.warning("fetch_certs: no panel configs available — skipping")
+ return
+ log.info("Fetching real certs for direct-mode domains...")
+ fetch_real_certs(configs)
+
+
+def run_install():
+ log.info("Starting installation...")
+ progress(0, "Please wait...", "We are going to install Hiddify")
+ modules = [
+ "common", "other/redis", "other/mysql", "hiddify-panel",
+ "nginx", "haproxy", "acme.sh", "other/speedtest", "other/dnstt",
+ "other/telegram", "other/ssfaketls", "other/ssh", "other/warp",
+ "xray", "other/hiddify-cli", "other/wireguard", "singbox"
+ ]
+ for mod in modules:
+ pct, label = _INSTALL_PROGRESS.get(mod, (None, None))
+ if pct is not None:
+ progress(pct, "Installing...", label)
+ install_module(mod)
+ if mod == "hiddify-panel":
+ progress(30, "Configuring...", "Rendering configs + system setup")
+ _render_all_templates()
+ # Real-cert fetch happens last: the acme.sh binary is installed by the
+ # acme.sh module (index 6) and the HTTP-01 challenge needs nginx +
+ # haproxy already up — both only true after the loop completes.
+ progress(96, "Certificates", "Fetching real certs")
+ _fetch_real_certs()
+ progress(98, "Almost finished", "Wrapping up")
+ log.info("Installation completed successfully.")
+ progress(100, "Done", "")
+
+
+def run_update(mode):
+ """
+ Update the hiddifypanel package, then re-run the install loop so the
+ new code lands in /opt/hiddify-manager and its dependents (templates,
+ firewall, services) get reapplied.
+ """
+ from hiddify_manager.modules.panel_installer import update_panel
+ log.info(f"Starting panel update (mode={mode!r})...")
+ progress(5, "Updating", f"Hiddify Panel ({mode})")
+ if not update_panel(mode):
+ log.error("Panel update failed; skipping install loop.")
+ progress(100, "Failed", "Panel update failed")
+ return
+ log.info("Panel update finished; reapplying install loop.")
+ run_install()
+
+
+def run_apply_configs(apply_users_only=False):
+ """
+ Lightweight "the panel config changed, re-derive everything from it" pass.
+ This is what `apply_configs.sh` did in the bash era — called by the
+ panel via commander.py on every Apply-Configs / user-add / user-remove.
+
+ Unlike run_install(), no apt installs, no binary downloads. Just:
+ 1. Force-regenerate current.json from the panel.
+ 2. Render every *.j2 against the fresh configs.
+ 3. Re-generate self-signed certs for any new domain.
+ 4. Re-apply firewall + timezone + sshd audit.
+ 5. Restart services so they pick up the new configs.
+
+ `apply_users_only=True` (the commander.py `apply-users` route) skips
+ the firewall + timezone pass — only users/peers changed, no need to
+ touch system-level config.
+ """
+ import os
+ from hiddify_manager.utils.config import generate_current_json, hiddify_config
+ from hiddify_manager.utils.template import render_tree
+ from hiddify_manager.utils.paths import PROJECT_ROOT
+ from hiddify_manager.utils.certs import ensure_self_signed_cert
+
+ log.info(
+ f"Applying configs (apply_users_only={apply_users_only})..."
+ )
+ progress(5, "Applying configs", "Reading from panel")
+
+ # Force a fresh current.json — without this the panel's new state
+ # wouldn't be visible until something else triggered regeneration.
+ if not generate_current_json():
+ log.error("apply_configs: could not regenerate current.json — aborting")
+ progress(100, "Failed", "Couldn't regenerate current.json")
+ return
+
+ configs = hiddify_config()
+ if not configs:
+ log.error("apply_configs: current.json present but unreadable — aborting")
+ progress(100, "Failed", "current.json unreadable")
+ return
+
+ progress(20, "Generating certs", "Per-domain self-signed")
+ ssl_dir = os.path.join(PROJECT_ROOT, "ssl")
+ for d in (configs.get("domains") or []):
+ domain = d.get("domain") if isinstance(d, dict) else None
+ if domain:
+ ensure_self_signed_cert(domain, ssl_dir)
+
+ progress(40, "Rendering", "All *.j2 templates")
+ log.info("Rendering all *.j2 templates against current.json...")
+ render_tree([PROJECT_ROOT], configs)
+
+ if not apply_users_only:
+ from hiddify_manager.modules.common import apply_runtime_config
+ progress(70, "Applying", "Firewall, timezone, sshd")
+ log.info("Re-applying system config (firewall, timezone, sshd)...")
+ apply_runtime_config(configs)
+
+ from hiddify_manager.modules.services import restart
+ progress(85, "Restarting services", "")
+ log.info("Restarting services...")
+ restart()
+
+ # Fetch real certs AFTER services are up — nginx/haproxy must be
+ # running to serve the ACME HTTP-01 challenge. Skipped for the
+ # users-only path (no domains added/changed there). Mirrors the
+ # legacy acme.sh/run.sh per-domain get_cert loop.
+ if not apply_users_only:
+ from hiddify_manager.modules.cert_issuer import fetch_real_certs
+ progress(92, "Fetching certs", "Let's Encrypt / ZeroSSL")
+ log.info("Fetching real certs for direct-mode domains...")
+ fetch_real_certs(configs)
+
+ progress(100, "Done", "Configs applied")
+
+
+def run_upgrade(mode):
+ """
+ Full upgrade: pull the latest hiddify-manager source from GitHub,
+ then re-exec ./init.sh update so the new code drives the
+ rest of the flow (panel package + install loop).
+
+ This is what `bash hiddify_installer.sh ` used to do.
+ """
+ import os
+ from hiddify_manager.modules.manager_updater import update_manager_source
+ from hiddify_manager.utils.paths import PROJECT_ROOT
+
+ log.info(f"Starting full upgrade (mode={mode!r})...")
+ if not update_manager_source(mode):
+ log.error("Manager source update failed; skipping panel update.")
+ return
+
+ init_sh = os.path.join(PROJECT_ROOT, "init.sh")
+ log.info(f"Re-executing {init_sh} update {mode} with the updated source...")
+ os.execv(init_sh, [init_sh, "update", mode])
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Hiddify-Manager Configuration Tool")
+ parser.add_argument("command", nargs="?",
+ choices=["install", "update", "upgrade", "status", "menu",
+ "migrate", "apply-configs", "apply-users", "restart"],
+ help="Command to run")
+ parser.add_argument("mode", nargs="?", default="release",
+ help="Mode (release/beta/dev/develop/docker/v); used with `update` and `upgrade`")
+
+ args = parser.parse_args()
+
+ check_root()
+
+ if not args.command or args.command == "menu":
+ from hiddify_manager.menu import show_menu
+ show_menu()
+ elif args.command == "install":
+ run_install()
+ elif args.command == "update":
+ run_update(args.mode)
+ elif args.command == "upgrade":
+ run_upgrade(args.mode)
+ elif args.command == "status":
+ log.info("Checking status...")
+ from hiddify_manager.modules.services import status
+ status()
+ elif args.command == "restart":
+ from hiddify_manager.modules.services import restart
+ restart()
+ elif args.command == "apply-configs":
+ run_apply_configs(apply_users_only=False)
+ elif args.command == "apply-users":
+ run_apply_configs(apply_users_only=True)
+ elif args.command == "migrate":
+ from hiddify_manager.migrate import run_migration
+ run_migration()
+
+if __name__ == "__main__":
+ main()
diff --git a/hiddify_manager/menu.py b/hiddify_manager/menu.py
new file mode 100644
index 000000000..d8b636340
--- /dev/null
+++ b/hiddify_manager/menu.py
@@ -0,0 +1,106 @@
+"""
+Interactive operator menu — `./init.sh menu` (or just `hiddify`).
+
+questionary's `use_shortcuts=True` assigns 1-9 keys automatically;
+we set `shortcut_key=` on each Choice so memorable letters work too
+("q" to quit, "s" for status, etc.). Output uses rich.Console so the
+menu prompts aren't decorated with logger timestamps.
+"""
+import sys
+
+import questionary
+from rich.console import Console
+
+console = Console()
+
+
+def _wait_to_return():
+ questionary.text("Press Enter to return...").ask()
+
+
+def _run_choice(choice):
+ """Handle a top-level menu choice. Returns True to keep looping, False to exit."""
+ if choice in (None, "quit"):
+ return False
+ if choice == "status":
+ from hiddify_manager.modules.services import status
+ status()
+ elif choice == "admin":
+ from hiddify_manager.modules.admin_links import show
+ show()
+ elif choice == "log":
+ from hiddify_manager.modules.logs import browse
+ browse()
+ elif choice == "restart":
+ from hiddify_manager.modules.services import restart
+ restart()
+ elif choice == "install":
+ from hiddify_manager.manager import run_install
+ run_install()
+ elif choice == "update":
+ from hiddify_manager.manager import run_update
+ run_update("release")
+ elif choice == "advanced":
+ show_advanced_menu()
+ return True # advanced has its own "press enter" prompts
+ _wait_to_return()
+ return True
+
+
+def show_menu():
+ while True:
+ choice = questionary.select(
+ "Hiddify Manager",
+ choices=[
+ questionary.Choice("View status of system", value="status", shortcut_key="s"),
+ questionary.Choice("Show admin link", value="admin", shortcut_key="a"),
+ questionary.Choice("View system logs", value="log", shortcut_key="l"),
+ questionary.Choice("Restart services", value="restart", shortcut_key="r"),
+ questionary.Choice("Reinstall the server", value="install", shortcut_key="i"),
+ questionary.Choice("Update", value="update", shortcut_key="u"),
+ questionary.Choice("Advanced (Uninstall, Remote Assistant, ...)",
+ value="advanced", shortcut_key="x"),
+ questionary.Choice("Quit", value="quit", shortcut_key="q"),
+ ],
+ use_shortcuts=True,
+ ).ask()
+ if not _run_choice(choice):
+ sys.exit(0)
+
+
+def show_advanced_menu():
+ choice = questionary.select(
+ "Advanced Options",
+ choices=[
+ questionary.Choice("Check WARP status", value="warp", shortcut_key="w"),
+ questionary.Choice("Add remote assistant", value="add_remote", shortcut_key="a"),
+ questionary.Choice("Remove remote assistant", value="remove_remote", shortcut_key="r"),
+ questionary.Choice("Uninstall", value="uninstall", shortcut_key="u"),
+ questionary.Choice("Back", value="back", shortcut_key="b"),
+ ],
+ use_shortcuts=True,
+ ).ask()
+
+ if choice == "warp":
+ from hiddify_manager.modules.warp import _real_test
+ if _real_test():
+ console.print("[green]WARP is WORKING[/green]")
+ else:
+ console.print("[yellow]WARP is NOT working[/yellow]")
+ elif choice == "add_remote":
+ from hiddify_manager.modules.remote_assistant import add as add_assistant
+ add_assistant()
+ elif choice == "remove_remote":
+ from hiddify_manager.modules.remote_assistant import remove as remove_assistant
+ remove_assistant()
+ elif choice == "uninstall":
+ confirm = questionary.confirm(
+ "This will stop + disable every hiddify-managed unit and clear hiddify-* crons. Continue?",
+ default=False,
+ ).ask()
+ if confirm:
+ from hiddify_manager.uninstall import run as run_uninstall
+ run_uninstall(purge=False)
+
+ if choice != "back":
+ _wait_to_return()
diff --git a/hiddify_manager/migrate.py b/hiddify_manager/migrate.py
new file mode 100644
index 000000000..d63b57161
--- /dev/null
+++ b/hiddify_manager/migrate.py
@@ -0,0 +1,182 @@
+"""
+Migration tool for Hiddify-Manager.
+
+Migrates data from a legacy Hiddify installation to the new Python-based setup.
+This is designed to be run once after a fresh installation to import
+configuration and user data from a previous installation.
+"""
+import os
+import sys
+import shutil
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import PROJECT_ROOT
+
+# Default legacy installation path
+LEGACY_DIR = "/opt/hiddify-manager"
+
+
+def find_legacy_installation():
+ """Locate the legacy installation directory."""
+ candidates = [
+ LEGACY_DIR,
+ "/opt/hiddify-server",
+ "/opt/hiddify-config",
+ ]
+ for path in candidates:
+ if os.path.isdir(path) and os.path.realpath(path) != os.path.realpath(PROJECT_ROOT):
+ return path
+ return None
+
+
+def migrate_database(legacy_dir: str, dry_run: bool = False):
+ """Copy hiddify-panel.db from the legacy installation."""
+ db_candidates = [
+ os.path.join(legacy_dir, "hiddify-panel", "hiddifypanel.db"),
+ os.path.join(legacy_dir, "hiddify-panel", "hiddify-panel.db"),
+ ]
+ target_dir = os.path.join(PROJECT_ROOT, "hiddify-panel")
+ os.makedirs(target_dir, exist_ok=True)
+
+ for db_path in db_candidates:
+ if os.path.isfile(db_path):
+ target = os.path.join(target_dir, os.path.basename(db_path))
+ if dry_run:
+ log.info(f"[DRY RUN] Would copy {db_path} -> {target}")
+ else:
+ shutil.copy2(db_path, target)
+ log.info(f"Migrated database: {db_path} -> {target}")
+ return True
+
+ log.warning("No legacy database found to migrate.")
+ return False
+
+
+def migrate_ssl_certs(legacy_dir: str, dry_run: bool = False):
+ """Copy SSL certificates from the legacy installation."""
+ legacy_ssl = os.path.join(legacy_dir, "ssl")
+ target_ssl = os.path.join(PROJECT_ROOT, "ssl")
+
+ if not os.path.isdir(legacy_ssl):
+ log.info("No legacy SSL directory found, skipping.")
+ return False
+
+ if dry_run:
+ log.info(f"[DRY RUN] Would copy {legacy_ssl} -> {target_ssl}")
+ else:
+ if os.path.exists(target_ssl):
+ shutil.rmtree(target_ssl)
+ shutil.copytree(legacy_ssl, target_ssl)
+ log.info(f"Migrated SSL certs: {legacy_ssl} -> {target_ssl}")
+ return True
+
+
+def migrate_acme_data(legacy_dir: str, dry_run: bool = False):
+ """Copy acme.sh data (certificates, account keys) from the legacy installation."""
+ legacy_acme = os.path.join(legacy_dir, "acme.sh", "lib")
+ target_acme = os.path.join(PROJECT_ROOT, "acme.sh", "lib")
+
+ if not os.path.isdir(legacy_acme):
+ log.info("No legacy acme.sh data found, skipping.")
+ return False
+
+ if dry_run:
+ log.info(f"[DRY RUN] Would copy {legacy_acme} -> {target_acme}")
+ else:
+ if os.path.exists(target_acme):
+ shutil.rmtree(target_acme)
+ shutil.copytree(legacy_acme, target_acme)
+ log.info(f"Migrated acme.sh data: {legacy_acme} -> {target_acme}")
+ return True
+
+
+def migrate_config_env(legacy_dir: str, dry_run: bool = False):
+ """Copy config.env from the legacy installation."""
+ legacy_env = os.path.join(legacy_dir, "config.env")
+ target_env = os.path.join(PROJECT_ROOT, "config.env")
+
+ if not os.path.isfile(legacy_env):
+ log.info("No legacy config.env found, skipping.")
+ return False
+
+ if dry_run:
+ log.info(f"[DRY RUN] Would copy {legacy_env} -> {target_env}")
+ else:
+ shutil.copy2(legacy_env, target_env)
+ log.info(f"Migrated config.env: {legacy_env} -> {target_env}")
+ return True
+
+
+def migrate_hiddify_data(legacy_dir: str, dry_run: bool = False):
+ """Copy /hiddify-data/ if it exists (Docker setups)."""
+ legacy_data = "/hiddify-data"
+ if not os.path.isdir(legacy_data):
+ log.info("No /hiddify-data directory found, skipping.")
+ return False
+
+ target_data = os.path.join(PROJECT_ROOT, "hiddify-data")
+ if dry_run:
+ log.info(f"[DRY RUN] Would copy {legacy_data} -> {target_data}")
+ else:
+ if not os.path.exists(target_data):
+ shutil.copytree(legacy_data, target_data)
+ log.info(f"Migrated hiddify-data: {legacy_data} -> {target_data}")
+ else:
+ log.info("hiddify-data already exists in the new installation, skipping.")
+ return True
+
+
+def run_migration(legacy_dir: str = None, dry_run: bool = False):
+ """
+ Run the full migration workflow.
+
+ Args:
+ legacy_dir: Path to legacy installation. Auto-detected if None.
+ dry_run: If True, only log what would be done without making changes.
+ """
+ if legacy_dir is None:
+ legacy_dir = find_legacy_installation()
+
+ if legacy_dir is None:
+ log.error("No legacy installation found. Nothing to migrate.")
+ log.info("If your old installation is in a custom path, pass it explicitly:")
+ log.info(" python3 -m hiddify_manager.migrate --legacy-dir /path/to/old/install")
+ return False
+
+ log.info(f"{'[DRY RUN] ' if dry_run else ''}Starting migration from: {legacy_dir}")
+ log.info(f"Target installation: {PROJECT_ROOT}")
+ log.info("=" * 60)
+
+ results = {
+ "database": migrate_database(legacy_dir, dry_run),
+ "ssl_certs": migrate_ssl_certs(legacy_dir, dry_run),
+ "acme_data": migrate_acme_data(legacy_dir, dry_run),
+ "config_env": migrate_config_env(legacy_dir, dry_run),
+ "hiddify_data": migrate_hiddify_data(legacy_dir, dry_run),
+ }
+
+ log.info("=" * 60)
+ migrated = [k for k, v in results.items() if v]
+ skipped = [k for k, v in results.items() if not v]
+
+ if migrated:
+ log.info(f"Successfully migrated: {', '.join(migrated)}")
+ if skipped:
+ log.info(f"Skipped (not found): {', '.join(skipped)}")
+
+ return bool(migrated)
+
+
+def main():
+ import argparse
+ parser = argparse.ArgumentParser(description="Migrate data from a legacy Hiddify installation")
+ parser.add_argument("--legacy-dir", help="Path to the legacy installation directory")
+ parser.add_argument("--dry-run", action="store_true", help="Show what would be done without making changes")
+
+ args = parser.parse_args()
+ success = run_migration(legacy_dir=args.legacy_dir, dry_run=args.dry_run)
+ sys.exit(0 if success else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/hiddify_manager/modules/__init__.py b/hiddify_manager/modules/__init__.py
new file mode 100644
index 000000000..f39e5e8d6
--- /dev/null
+++ b/hiddify_manager/modules/__init__.py
@@ -0,0 +1 @@
+# Init
diff --git a/hiddify_manager/modules/__pycache__/__init__.cpython-314.pyc b/hiddify_manager/modules/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 000000000..ca7ad0b6f
Binary files /dev/null and b/hiddify_manager/modules/__pycache__/__init__.cpython-314.pyc differ
diff --git a/hiddify_manager/modules/__pycache__/xray.cpython-314.pyc b/hiddify_manager/modules/__pycache__/xray.cpython-314.pyc
new file mode 100644
index 000000000..d65b5a739
Binary files /dev/null and b/hiddify_manager/modules/__pycache__/xray.cpython-314.pyc differ
diff --git a/hiddify_manager/modules/acme_sh.py b/hiddify_manager/modules/acme_sh.py
new file mode 100644
index 000000000..7713520b8
--- /dev/null
+++ b/hiddify_manager/modules/acme_sh.py
@@ -0,0 +1,41 @@
+import os
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.paths import module_dir as _module_dir
+
+def install():
+ module_dir = _module_dir("acme.sh")
+
+ run_cmd(["apt-get", "install", "-y", "socat"])
+ run_cmd(["apt-get", "remove", "-y", "certbot"], check=False)
+
+ lib_dir = os.path.join(module_dir, "lib")
+ os.makedirs(lib_dir, exist_ok=True)
+
+ acme_sh_path = os.path.join(lib_dir, "acme.sh")
+ if not os.path.exists(acme_sh_path):
+ log.info("Downloading acme.sh...")
+ install_cmd = f"curl -s -L https://get.acme.sh | sh -s -- home {lib_dir} --config-home {lib_dir}/data --cert-home {lib_dir}/certs --nocron"
+ run_cmd(install_cmd, shell=True)
+
+ run_cmd([acme_sh_path, "--upgrade"], check=False)
+
+ if os.path.exists(acme_sh_path):
+ with open(acme_sh_path, 'r') as f:
+ content = f.read()
+ if 'return 10; fi' not in content:
+ content = content.replace(
+ '_sleep_overload_retry_sec=$_retryafter',
+ '_sleep_overload_retry_sec=$_retryafter; if [[ "$_retryafter" > 20 ]];then return 10; fi'
+ )
+ with open(acme_sh_path, 'w') as f:
+ f.write(content)
+
+ os.makedirs(os.path.join(os.path.dirname(module_dir), "ssl"), exist_ok=True)
+
+ run_cmd([acme_sh_path, "--uninstall-cronjob"], check=False)
+
+ run_cmd([acme_sh_path, "--register-account", "-m", "my@example.com"], check=False)
+ run_cmd(["systemctl", "reload", "hiddify-haproxy"], check=False)
+
+ log.info("Acme.sh setup complete.")
diff --git a/hiddify_manager/modules/admin_links.py b/hiddify_manager/modules/admin_links.py
new file mode 100644
index 000000000..cc4370e82
--- /dev/null
+++ b/hiddify_manager/modules/admin_links.py
@@ -0,0 +1,54 @@
+"""
+Print the panel's admin links to the operator.
+
+Replaces the previous menu entry that called `hiddify-panel-cli
+reset-owner-password` (which 1) doesn't exist on PATH and 2) is
+destructive — it resets the admin password, which is not what
+"Show admin link" should do).
+
+Read panel_links straight from current.json and colour them per the
+legacy check_hiddify_panel rules:
+
+ http://* -> red [insecure]
+ https:/// -> yellow [self-signed]
+ otherwise -> green (real cert)
+"""
+import json
+import re
+
+from rich.console import Console
+
+from hiddify_manager.utils.paths import CURRENT_JSON
+
+
+_IPV4_HOST_RE = re.compile(r"^https://(?:.+@)?\d+\.\d+\.\d+\.\d+(?::\d+)?/")
+
+
+def _classify(link):
+ if link.startswith("http://"):
+ return "[insecure]", "red"
+ if _IPV4_HOST_RE.match(link):
+ return "[self-signed]", "yellow"
+ return "", "green"
+
+
+def show():
+ console = Console()
+ try:
+ with open(CURRENT_JSON) as f:
+ data = json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ console.print(f"[red]admin_links: can't read {CURRENT_JSON}: {e}[/red]")
+ return 1
+
+ links = data.get("panel_links") or []
+ if not links:
+ console.print("[yellow]admin_links: no panel_links in current.json[/yellow]")
+ return 1
+
+ console.print("[bold]Admin links:[/bold]")
+ for link in links:
+ tag, colour = _classify(link)
+ prefix = f"{tag} " if tag else ""
+ console.print(f" [{colour}]{prefix}{link}[/{colour}]")
+ return 0
diff --git a/hiddify_manager/modules/cert_issuer.py b/hiddify_manager/modules/cert_issuer.py
new file mode 100644
index 000000000..d0700f370
--- /dev/null
+++ b/hiddify_manager/modules/cert_issuer.py
@@ -0,0 +1,320 @@
+"""
+Real-ACME certificate acquisition for a single domain.
+
+Ports the orchestration in acme.sh/cert_utils.sh::get_cert: resolve the
+domain, decide which ACME server / flags to use, drive the bundled
+acme.sh binary to actually do the challenge, install the resulting
+cert + key into ssl/, and fall back to a self-signed cert if anything
+fails.
+
+The acme.sh binary itself stays (real ACME client; no good drop-in
+Python alternative without taking on certbot's massive dep tree).
+Everything around it — dig, the per-IP-type branching, the LE→ZeroSSL
+fallback, the restricted-TLD check, install + reload, the
+fall-back-to-self-signed — is python now.
+"""
+import ipaddress
+import os
+import socket
+import sys
+
+from hiddify_manager.utils.certs import ensure_self_signed_cert
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import PROJECT_ROOT
+from hiddify_manager.utils.shell import run_cmd
+
+
+SSL_DIR = os.path.join(PROJECT_ROOT, "ssl")
+ACME_DIR = os.path.join(PROJECT_ROOT, "acme.sh")
+ACME_BIN = os.path.join(ACME_DIR, "lib", "acme.sh")
+WEBROOT = os.path.join(ACME_DIR, "www")
+ACME_LOG = os.path.join(PROJECT_ROOT, "log", "system", "acme.log")
+NGINX_ACME_CONF = os.path.join(PROJECT_ROOT, "nginx", "parts", "acme.conf")
+ACME_NGINX_BLOCK = (
+ "location /.well-known/acme-challenge "
+ "{root /opt/hiddify-manager/acme.sh/www/;}\n"
+)
+
+MAX_DOMAIN_LEN = 64
+
+# Domain modes that should get a REAL (Let's Encrypt / ZeroSSL) cert.
+# Mirrors the `select(.mode | IN(...))` in the legacy acme.sh/run.sh.
+# Every other mode (fake, cdn, worker, auto_cdn_ip, ...) keeps the
+# self-signed cert that the render step already generated.
+REAL_CERT_MODES = frozenset({
+ "direct", "relay", "old_xtls_direct", "sub_link_only",
+})
+
+# Same list as cert_utils.sh: TLDs that ZeroSSL's policy doesn't accept.
+RESTRICTED_TLDS = frozenset({
+ "af", "by", "cu", "er", "gn", "ir", "kp", "lr", "ru", "ss", "su",
+ "sy", "zw", "amazonaws.com", "azurewebsites.net", "cloudapp.net",
+})
+
+
+def _is_ip(domain):
+ """Return 4 for IPv4 literal, 6 for IPv6 literal, None for a hostname."""
+ try:
+ addr = ipaddress.ip_address(domain)
+ return addr.version
+ except ValueError:
+ return None
+
+
+def _is_zerossl_ok(domain):
+ domain = domain.lower()
+ for tld in RESTRICTED_TLDS:
+ if domain.endswith("." + tld):
+ return False
+ return True
+
+
+def _resolve(domain, family):
+ """Best-effort DNS lookup. Returns the first address or '' on failure."""
+ try:
+ infos = socket.getaddrinfo(domain, None, family=family)
+ except (socket.gaierror, OSError):
+ return ""
+ for info in infos:
+ return info[4][0]
+ return ""
+
+
+def _public_ip(version):
+ """Mirror set_config_from_hpanel's `curl https://v4.ident.me`-style probe."""
+ url = "https://v6.ident.me/" if version == 6 else "https://v4.ident.me/"
+ res = run_cmd(
+ ["curl", "--connect-timeout", "2", "-s", url],
+ check=False, capture_output=True,
+ )
+ return (res.stdout or "").strip()
+
+
+def _prepare_acme():
+ """
+ One-time setup that the legacy acme.sh --pre-hook
+ bash prepare_acme.sh did per challenge: create the webroot
+ challenge dir, install the nginx location block that exposes
+ /.well-known/acme-challenge, chown the webroot to nginx, restart
+ hiddify-nginx — but ONLY restart when the config actually changed.
+
+ The original ran on every domain (and restarted nginx every time);
+ we run it once per cert_issuer invocation and skip the restart
+ when acme.conf already matches.
+ """
+ os.makedirs(os.path.join(WEBROOT, ".well-known", "acme-challenge"), exist_ok=True)
+
+ current = ""
+ if os.path.exists(NGINX_ACME_CONF):
+ try:
+ with open(NGINX_ACME_CONF) as f:
+ current = f.read()
+ except OSError:
+ current = ""
+ if current != ACME_NGINX_BLOCK:
+ os.makedirs(os.path.dirname(NGINX_ACME_CONF), exist_ok=True)
+ with open(NGINX_ACME_CONF, "w") as f:
+ f.write(ACME_NGINX_BLOCK)
+ run_cmd(["systemctl", "restart", "hiddify-nginx"], check=False)
+
+ run_cmd(["chown", "-R", "nginx", WEBROOT], check=False)
+
+
+class _MissingBinary:
+ """Stand-in result when the acme.sh binary isn't present, so callers
+ that read .returncode get a non-zero (failure) without an exception."""
+ returncode = 127
+ stdout = ""
+
+
+def _acmecmd(extra_args):
+ """Equivalent of the legacy acmecmd() in cert_utils.sh.
+
+ Returns a non-zero result (rather than raising) if the acme.sh binary
+ is missing — e.g. the acme.sh module hasn't run yet, or a deploy wiped
+ acme.sh/lib/. get_cert() then falls back to a self-signed cert instead
+ of taking down the whole install with a FileNotFoundError.
+ """
+ if not os.path.exists(ACME_BIN):
+ log.error(f"cert_issuer: acme.sh binary missing at {ACME_BIN}; "
+ "skipping real-cert issuance (self-signed fallback)")
+ return _MissingBinary()
+ base = [
+ ACME_BIN, "--issue",
+ "-w", WEBROOT,
+ "--log", ACME_LOG,
+ ]
+ os.makedirs(os.path.dirname(ACME_LOG), exist_ok=True)
+ return run_cmd(base + list(extra_args), cwd=ACME_DIR, check=False)
+
+
+def _try_issue(domain, ip_version):
+ """
+ Drive the acme.sh CLI to issue a cert. Returns 0 on success, non-zero on
+ failure. Branches per IP-version like the bash did.
+ """
+ if ip_version == 4:
+ # Short-lived LE profile for direct IP issuance.
+ return _acmecmd([
+ "-d", domain, "--server", "letsencrypt",
+ "--certificate-profile", "shortlived", "--days", "6",
+ ]).returncode
+ if ip_version == 6:
+ return _acmecmd([
+ "-d", f"[{domain}]", "--server", "letsencrypt",
+ "--certificate-profile", "shortlived", "--days", "6",
+ "--listen-v6",
+ ]).returncode
+
+ # Plain hostname: try LE; fall back to ZeroSSL when the TLD isn't on
+ # ZeroSSL's blocklist.
+ rc = _acmecmd(["-d", domain, "--server", "letsencrypt"]).returncode
+ if rc != 0 and _is_zerossl_ok(domain):
+ log.info(f"cert_issuer: LE failed for {domain}, retrying via ZeroSSL")
+ rc = _acmecmd(["-d", domain, "--server", "zerossl"]).returncode
+ return rc
+
+
+def _install_cert(domain):
+ """`acme.sh --installcert` into ssl/.crt + .crt.key."""
+ cert_path = os.path.join(SSL_DIR, f"{domain}.crt")
+ key_path = os.path.join(SSL_DIR, f"{domain}.crt.key")
+ os.makedirs(SSL_DIR, exist_ok=True)
+ return run_cmd(
+ [
+ ACME_BIN, "--installcert", "-d", domain,
+ "--fullchainpath", cert_path,
+ "--keypath", key_path,
+ "--reloadcmd", "echo success",
+ ],
+ cwd=ACME_DIR, check=False,
+ ).returncode
+
+
+def _stop_nginx_acme():
+ """Mirror cert_utils.sh::stop_nginx_acme: empty acme.conf + reload."""
+ try:
+ with open(NGINX_ACME_CONF, "w") as f:
+ f.write("")
+ except OSError as e:
+ log.warning(f"cert_issuer: could not clear {NGINX_ACME_CONF}: {e}")
+ run_cmd(["systemctl", "reload", "--now", "hiddify-nginx"], check=False)
+ run_cmd(["systemctl", "reload", "hiddify-haproxy"], check=False)
+
+
+def _lockdown(domain):
+ """Match legacy: chmod 600 on the per-domain key and the ssl/ tree."""
+ key = os.path.join(SSL_DIR, f"{domain}.crt.key")
+ if os.path.exists(key):
+ os.chmod(key, 0o600)
+ if os.path.isdir(SSL_DIR):
+ for name in os.listdir(SSL_DIR):
+ try:
+ os.chmod(os.path.join(SSL_DIR, name), 0o600)
+ except OSError:
+ pass
+
+
+def get_cert(domain):
+ """
+ Top-level: issue + install a real cert for `domain`; on any failure,
+ drop a self-signed one so haproxy/nginx still have something to serve.
+
+ Returns True if a real cert was issued, False if we fell back to
+ self-signed (or if the domain was rejected outright).
+ """
+ if not domain or len(domain) > MAX_DOMAIN_LEN:
+ log.warning(f"cert_issuer: skipping invalid/long domain {domain!r}")
+ ensure_self_signed_cert(domain or "invalid", SSL_DIR)
+ _lockdown(domain or "")
+ return False
+
+ ip_version = _is_ip(domain)
+ if ip_version is None:
+ v4 = _resolve(domain, socket.AF_INET)
+ v6 = _resolve(domain, socket.AF_INET6)
+ server_v4 = _public_ip(4)
+ server_v6 = _public_ip(6)
+ log.info(
+ f"cert_issuer: {domain} resolves to v4={v4!r} v6={v6!r}; "
+ f"server v4={server_v4!r} v6={server_v6!r}"
+ )
+ if (not server_v4 or v4 != server_v4) and (not server_v6 or v6 != server_v6):
+ log.warning(
+ f"cert_issuer: {domain} doesn't resolve to this server; "
+ "ACME will probably fail but trying anyway"
+ )
+
+ # The legacy --pre-hook ran prepare_acme.sh per challenge (and
+ # restarted nginx every time). Do it once here; _prepare_acme
+ # short-circuits the nginx restart when acme.conf is already correct.
+ _prepare_acme()
+
+ rc = _try_issue(domain, ip_version)
+ if rc == 0:
+ rc = _install_cert(domain)
+
+ if rc != 0:
+ log.warning(
+ f"cert_issuer: ACME flow exited {rc}; falling back to self-signed"
+ )
+ ensure_self_signed_cert(domain, SSL_DIR)
+ _lockdown(domain)
+ _stop_nginx_acme()
+ return False
+
+ _lockdown(domain)
+ _stop_nginx_acme()
+ return True
+
+
+def fetch_real_certs(configs):
+ """
+ Walk every domain in current.json and fetch a real cert for the ones
+ whose mode is in REAL_CERT_MODES. Ports the per-domain `get_cert $d`
+ loop from the legacy acme.sh/run.sh that ran on every install/apply.
+
+ Self-signed certs for all domains are assumed to already exist (the
+ render step calls ensure_self_signed_cert first), so get_cert only
+ *upgrades* the real-cert-mode domains; on ACME failure it leaves the
+ self-signed in place.
+
+ Runs sequentially (one ACME challenge at a time) — easier to read in
+ the log and to pin down a single domain's failure. Reloads singbox at
+ the end (get_cert already reloads haproxy+nginx per call).
+
+ Returns the list of domains for which a real cert was obtained.
+ """
+ domains = configs.get("domains") or []
+ real_domains = [
+ d["domain"] for d in domains
+ if isinstance(d, dict) and d.get("domain")
+ and d.get("mode") in REAL_CERT_MODES
+ ]
+ if not real_domains:
+ log.info("cert_issuer: no domains in a real-cert mode; skipping ACME fetch")
+ return []
+
+ obtained = []
+ for domain in real_domains:
+ log.info(f"cert_issuer: fetching real cert for {domain} (mode in {sorted(REAL_CERT_MODES)})")
+ if get_cert(domain):
+ obtained.append(domain)
+
+ # haproxy/nginx are reloaded inside get_cert; singbox isn't.
+ run_cmd(["systemctl", "reload", "hiddify-singbox"], check=False)
+ log.info(f"cert_issuer: real certs obtained for {obtained or 'none'}")
+ return obtained
+
+
+def main():
+ """CLI entry. `python -m hiddify_manager.modules.cert_issuer `."""
+ if len(sys.argv) < 2:
+ log.error("usage: cert_issuer ")
+ return 2
+ ok = get_cert(sys.argv[1])
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/hiddify_manager/modules/common.py b/hiddify_manager/modules/common.py
new file mode 100644
index 000000000..3e5ac400c
--- /dev/null
+++ b/hiddify_manager/modules/common.py
@@ -0,0 +1,438 @@
+"""
+Replaces common/install.sh + common/run.sh.j2.
+
+install() is the pre-panel system bootstrap (called from the install
+loop with no current.json yet): apt packages, hiddify-common group,
+sysctl, IPv6 toggle, cron entries, locale, rpcbind disable.
+
+apply_runtime_config(configs) is the post-panel system configuration
+(called by manager.run_install after the panel produces current.json):
+country-based timezone, the full INPUT/FORWARD firewall ruleset built
+from hconfigs + per-domain ports, SSH PasswordAuthentication audit,
+auto-update cron.
+"""
+import os
+import re
+import shutil
+
+from hiddify_manager.utils import firewall
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import COMMON_DIR, PROJECT_ROOT, LOG_DIR, VENV_DIR
+from hiddify_manager.utils.shell import run_cmd
+
+
+APT_BASE_PACKAGES = [
+ "apt-transport-https", "apt-utils", "at", "build-essential",
+ "ca-certificates", "clang", "cron", "curl",
+ "default-libmysqlclient-dev", "dnsutils", "gawk", "git",
+ "gnupg-agent", "gnupg2", "iproute2", "iptables", "jq", "less",
+ "libev-dev", "libevdev2", "libssl-dev", "locales", "lsb-release",
+ "lsof", "pkg-config", "qrencode", "software-properties-common",
+ "sudo", "ubuntu-keyring", "wget", "whiptail", "wireguard",
+]
+APT_REMOVE_PACKAGES = ["apache2", "needrestart", "needrestart-session"]
+EXCLUDED_IFACES = {"warp", "lo"}
+
+
+def _ensure_bashrc_lines(rc_path, lines, stale_patterns=()):
+ """
+ Strip any line containing one of stale_patterns, then append the
+ given lines if they're not already present. Equivalent to the legacy
+ `sed -i s|X||g; echo Y >> .bashrc` pattern.
+ """
+ if not os.path.exists(rc_path):
+ existing = []
+ else:
+ with open(rc_path) as f:
+ existing = f.readlines()
+
+ def is_stale(ln):
+ return any(p in ln for p in stale_patterns)
+
+ out = [ln for ln in existing if not is_stale(ln)]
+ for line in lines:
+ wanted = line.rstrip("\n") + "\n"
+ if wanted not in out:
+ if out and not out[-1].endswith("\n"):
+ out[-1] = out[-1] + "\n"
+ out.append(wanted)
+ with open(rc_path, "w") as f:
+ f.writelines(out)
+
+
+def _apt_install(packages):
+ run_cmd(
+ ["apt-get", "install", "-y", "--no-install-recommends", *packages],
+ check=False,
+ )
+
+
+def _apt_remove(packages):
+ for pkg in packages:
+ # Cheap installed-check: dpkg -s exits 0 only if installed.
+ res = run_cmd(["dpkg", "-s", pkg], check=False, capture_output=True)
+ if res.returncode == 0:
+ run_cmd(["apt-get", "remove", "-y", "--auto-remove", pkg], check=False)
+
+
+def _iface_names():
+ res = run_cmd(["ip", "-o", "link", "show"], check=False, capture_output=True)
+ if res.returncode != 0 or not res.stdout:
+ return []
+ names = []
+ for line in res.stdout.splitlines():
+ # Format: "1: lo: ..."
+ parts = line.split(":")
+ if len(parts) >= 2:
+ name = parts[1].strip().split("@")[0]
+ if name and name.replace("_", "").isalnum():
+ names.append(name)
+ return names
+
+
+def _toggle_ipv6(only_ipv4):
+ stat = "1" if only_ipv4 else "0"
+ label = "Disable" if only_ipv4 else "Enable"
+
+ if not only_ipv4:
+ for k in ("all", "default", "lo"):
+ run_cmd(["sysctl", "-w", f"net.ipv6.conf.{k}.disable_ipv6=0"], check=False)
+
+ for iface in _iface_names():
+ if iface in EXCLUDED_IFACES:
+ continue
+ res = run_cmd(
+ ["sysctl", "-q", "-w", f"net.ipv6.conf.{iface}.disable_ipv6={stat}"],
+ check=False,
+ )
+ log.info(f"IPv6 {label}d for {iface}" if res.returncode == 0
+ else f"Failed to {label} IPv6 for {iface}")
+
+
+def _write_cron_entries():
+ reboot_cron = "/etc/cron.d/hiddify_reinstall_on_reboot"
+ daily_cron = "/etc/cron.d/hiddify_daily"
+ legacy = "/etc/cron.d/hiddify_daily_memory_release"
+
+ with open(reboot_cron, "w") as f:
+ f.write(
+ "@reboot root /opt/hiddify-manager/init.sh install "
+ ">> /opt/hiddify-manager/log/system/reboot.log 2>&1\n"
+ )
+
+ # One-shot legacy filename migration; ignore if already gone.
+ if os.path.exists(legacy) and not os.path.exists(daily_cron):
+ try:
+ shutil.move(legacy, daily_cron)
+ except OSError as e:
+ log.warning(f"cron migration mv failed: {e}")
+ elif os.path.exists(legacy):
+ try:
+ os.remove(legacy)
+ except OSError:
+ pass
+
+ with open(daily_cron, "w") as f:
+ f.write(
+ f"@daily root {VENV_DIR}/bin/python3 -m "
+ "hiddify_manager.modules.daily_actions "
+ ">> /opt/hiddify-manager/log/system/daily_actions.log 2>&1\n"
+ )
+
+ run_cmd(["service", "cron", "reload"], check=False)
+
+
+def install():
+ os.makedirs(LOG_DIR, exist_ok=True)
+
+ _apt_remove(APT_REMOVE_PACKAGES)
+ _apt_install(APT_BASE_PACKAGES)
+
+ run_cmd(["groupadd", "-f", "hiddify-common"], check=False)
+ run_cmd(["usermod", "-aG", "hiddify-common", "root"], check=False)
+
+ run_cmd(["systemctl", "unmask", "--now", "systemd-resolved.service"], check=False)
+ run_cmd(["systemctl", "enable", "--now", "systemd-resolved"], check=False)
+
+ sysctl_src = os.path.join(COMMON_DIR, "sysctl.conf")
+ sysctl_dst = "/etc/sysctl.d/hiddify.conf"
+ if os.path.exists(sysctl_src):
+ run_cmd(["ln", "-sf", sysctl_src, sysctl_dst], check=False)
+
+ if os.environ.get("MODE") != "docker":
+ run_cmd(["sysctl", "--system"], check=False, capture_output=True)
+
+ only_ipv4 = os.environ.get("ONLY_IPV4", "").lower() == "true"
+ if not only_ipv4:
+ # Probe whether IPv6 actually works; if not, force-disable.
+ probe = run_cmd(
+ ["curl", "--connect-timeout", "1", "-s", "http://ipv6.google.com"],
+ check=False, capture_output=True,
+ )
+ if probe.returncode != 0:
+ only_ipv4 = True
+ _toggle_ipv6(only_ipv4)
+
+ bbr = os.path.join(COMMON_DIR, "google-bbr.sh")
+ if os.path.exists(bbr):
+ run_cmd(["bash", bbr], cwd=COMMON_DIR, check=False, capture_output=True)
+
+ _write_cron_entries()
+
+ if os.environ.get("MODE") != "docker":
+ run_cmd(["localectl", "set-locale", "LANG=C.UTF-8"], check=False)
+ run_cmd(["update-locale", "LANG=C.UTF-8"], check=False)
+
+ with open("/etc/sudoers.d/hiddify", "w") as f:
+ f.write(
+ "hiddify-panel ALL=(root) NOPASSWD: "
+ "/opt/hiddify-manager/common/commander.py\n"
+ )
+ os.chmod("/etc/sudoers.d/hiddify", 0o440)
+
+ # /usr/bin/hiddify wrapper. Legacy symlinked /opt/hiddify-manager/menu.sh
+ # but that script was deleted in 20d2d792. Write a tiny shim that hands
+ # off to ./init.sh menu (the python menu).
+ hiddify_bin = "/usr/bin/hiddify"
+ with open(hiddify_bin, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ f"exec {PROJECT_ROOT}/init.sh menu \"$@\"\n"
+ )
+ os.chmod(hiddify_bin, 0o755)
+
+ # Auto-cd into the project on login + show menu. Legacy appended
+ # /opt/hiddify-manager/menu.sh directly; use the new wrapper instead.
+ _ensure_bashrc_lines(
+ "/root/.bashrc",
+ [f"cd {PROJECT_ROOT}", "hiddify"],
+ stale_patterns=[
+ "/opt/hiddify-manager/menu.sh",
+ "cd /opt/hiddify-manager/",
+ ],
+ )
+
+ for unit in ("rpcbind.socket", "rpcbind"):
+ run_cmd(["systemctl", "disable", "--now", unit], check=False)
+
+
+# ---------------------------------------------------------------------------
+# Post-panel runtime config — replaces common/run.sh.j2.
+# ---------------------------------------------------------------------------
+
+# Country -> tz, matches the legacy if/elif/else chain.
+_TIMEZONE_BY_COUNTRY = {"cn": "Asia/Shanghai", "ru": "Europe/Moscow"}
+_DEFAULT_TIMEZONE = "Asia/Tehran"
+
+# Ports we always open. Mirrors the hard-coded allow_port block at the
+# top of common/run.sh.j2.
+_FIXED_PORTS = [("tcp", 22), ("tcp", 80), ("tcp", 443), ("udp", 443),
+ ("udp", 53), ("tcp", 53)]
+
+
+def _hconfigs(configs):
+ return (configs or {}).get("hconfigs") or {}
+
+
+def _split_csv_ports(raw):
+ """Parse a comma-separated port list from hconfigs, ignoring blanks."""
+ if not raw:
+ return []
+ out = []
+ for chunk in str(raw).split(","):
+ chunk = chunk.strip()
+ if not chunk:
+ continue
+ try:
+ out.append(int(chunk))
+ except ValueError:
+ log.warning(f"common: skipping unparseable port {chunk!r}")
+ return out
+
+
+def _apply_timezone(configs):
+ """Set system timezone based on hconfigs['country']."""
+ if os.environ.get("MODE") == "docker":
+ return
+ hconfigs = _hconfigs(configs)
+ country = (hconfigs.get("country") or "").lower()
+ target = _TIMEZONE_BY_COUNTRY.get(country, _DEFAULT_TIMEZONE)
+ res = run_cmd(["timedatectl", "show", "--property=Timezone", "--value"],
+ check=False, capture_output=True)
+ current = (res.stdout or "").strip()
+ if current == target:
+ return
+ log.info(f"common: changing timezone {current!r} -> {target!r}")
+ run_cmd(["timedatectl", "set-timezone", target], check=False)
+ run_cmd(["systemctl", "restart", "mariadb"], check=False)
+
+
+def _apply_ports(configs):
+ """Open every port the panel config says we should be listening on."""
+ hconfigs = _hconfigs(configs)
+ domains = (configs or {}).get("domains") or []
+
+ for proto, port in _FIXED_PORTS:
+ firewall.allow_port(proto, port)
+
+ if hconfigs.get("wireguard_port"):
+ firewall.allow_port("udp", hconfigs["wireguard_port"])
+
+ if hconfigs.get("shadowsocks2022_enable") and hconfigs.get("shadowsocks2022_port"):
+ port = hconfigs["shadowsocks2022_port"]
+ firewall.allow_port("tcp", port)
+ firewall.allow_port("udp", port)
+
+ for d in domains:
+ for key in ("internal_port_hysteria2", "internal_port_tuic", "internal_port_naive"):
+ port = (d or {}).get(key)
+ if port and int(port) > 0:
+ firewall.allow_port("udp", int(port))
+
+ if hconfigs.get("mieru_enable"):
+ for port in _split_csv_ports(hconfigs.get("mieru_tcp_ports")):
+ firewall.allow_port("tcp", port)
+ for port in _split_csv_ports(hconfigs.get("mieru_udp_ports")):
+ firewall.allow_port("udp", port)
+
+ # Per-protocol panel ports (TLS + HTTP). Legacy opened both TCP for
+ # every port in tls+http and additionally UDP for TLS-only.
+ tls_ports = _split_csv_ports(hconfigs.get("tls_ports"))
+ http_ports = _split_csv_ports(hconfigs.get("http_ports"))
+ for port in tls_ports + http_ports:
+ firewall.allow_port("tcp", port)
+ for port in tls_ports:
+ firewall.allow_port("udp", port)
+
+ # SSH server (proxy, not the OS sshd).
+ ssh_port = hconfigs.get("ssh_server_port")
+ if ssh_port:
+ if hconfigs.get("ssh_server_enable"):
+ firewall.allow_port("tcp", ssh_port)
+ else:
+ firewall.remove_port("tcp", ssh_port)
+
+
+def _apply_static_rules():
+ """The fixed INPUT/OUTPUT/ICMP rules from the bottom of run.sh.j2."""
+ firewall.add_rule([
+ "INPUT", "-p", "udp",
+ "-m", "conntrack", "--ctstatus", "SEEN_REPLY,ASSURED,CONFIRMED",
+ "-j", "ACCEPT",
+ ])
+ firewall.add_rule(["OUTPUT", "-p", "udp", "-j", "ACCEPT"])
+ firewall.add_rule(["OUTPUT", "-p", "tcp", "-j", "ACCEPT"])
+ firewall.add_rule(["INPUT", "-i", "lo", "-j", "ACCEPT"])
+ # ICMP allow (v4 + v6 forms differ)
+ firewall.add_rule(["INPUT", "-p", "icmp", "-j", "ACCEPT"], both=False)
+ firewall.add_rule_v6_only(["INPUT", "-p", "ipv6-icmp", "-j", "ACCEPT"])
+ firewall.add_rule(["INPUT", "-m", "state", "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT"])
+ firewall.add_rule(["INPUT", "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"])
+
+
+_MOTD_WARNING = (
+ "Hiddify! Your server is vulnerable to abuses because "
+ "PasswordAuthentication is enabled. To secure your server, please "
+ "switch to key authentication mechanism and turn off "
+ "PasswordAuthentication in your ssh config file."
+)
+
+
+def _audit_sshd_password_auth():
+ """Mirror the legacy MOTD audit: write a warning if sshd allows passwords."""
+ # Find any sshd config line with PasswordAuthentication no.
+ pw_disabled = False
+ for path in ("/etc/ssh/sshd_config", *_glob_sshd_includes()):
+ try:
+ with open(path) as f:
+ for line in f:
+ line = line.strip()
+ if re.fullmatch(r"PasswordAuthentication\s+no", line):
+ pw_disabled = True
+ break
+ except OSError:
+ continue
+ if pw_disabled:
+ break
+
+ motd_path = "/etc/motd"
+ try:
+ with open(motd_path) as f:
+ motd = f.read()
+ except OSError:
+ motd = ""
+
+ if not pw_disabled:
+ if "Your server is vulnerable" not in motd:
+ try:
+ with open(motd_path, "a") as f:
+ f.write(_MOTD_WARNING + "\n")
+ except OSError as e:
+ log.warning(f"common: could not append MOTD warning: {e}")
+ else:
+ if "Your server is vulnerable" in motd:
+ new_motd = "\n".join(
+ ln for ln in motd.splitlines()
+ if "Your server is vulnerable" not in ln
+ )
+ try:
+ with open(motd_path, "w") as f:
+ f.write(new_motd + ("\n" if new_motd else ""))
+ except OSError as e:
+ log.warning(f"common: could not rewrite MOTD: {e}")
+
+ run_cmd(["systemctl", "restart", "sshd.service"], check=False)
+ run_cmd(["systemctl", "restart", "ssh.service"], check=False)
+
+
+def _glob_sshd_includes():
+ """Mirror the legacy `grep -rxq ... /etc/ssh/sshd*` semantics."""
+ import glob
+ return sorted(glob.glob("/etc/ssh/sshd*"))
+
+
+def _apply_auto_update_cron(configs):
+ cron = "/etc/cron.d/hiddify_auto_update"
+ if _hconfigs(configs).get("auto_update"):
+ # Legacy ran `$(pwd)/../update.sh`; relative to common/, that's the
+ # repo root. With the python orchestrator, init.sh update is the
+ # entrypoint.
+ with open(cron, "w") as f:
+ f.write(
+ f"0 3 * * * root {PROJECT_ROOT}/init.sh update "
+ f">> {LOG_DIR}/auto_update.log 2>&1\n"
+ )
+ else:
+ try:
+ os.remove(cron)
+ except FileNotFoundError:
+ pass
+ run_cmd(["service", "cron", "reload"], check=False)
+
+
+def apply_runtime_config(configs):
+ """
+ Post-panel system config. Called from manager._render_all_templates
+ once current.json has been generated and templates have been rendered.
+
+ Steps mirror common/run.sh.j2 top-to-bottom: timezone, the full
+ iptables/ip6tables ruleset, SSH MOTD audit, INPUT/FORWARD policy
+ from hconfigs['firewall'], save the ruleset, manage auto-update cron.
+ """
+ if not configs:
+ log.warning("common: no panel configs available — skipping runtime config")
+ return
+
+ _apply_timezone(configs)
+ _apply_ports(configs)
+ _apply_static_rules()
+ _audit_sshd_password_auth()
+
+ # The firewall policy flag controls whether unknown traffic is dropped.
+ # Apply *after* opening the per-service ports above, otherwise DROP
+ # would close the door before we held it open.
+ policy = "DROP" if _hconfigs(configs).get("firewall") else "ACCEPT"
+ firewall.set_input_policy(policy)
+
+ firewall.save()
+ _apply_auto_update_cron(configs)
diff --git a/hiddify_manager/modules/daily_actions.py b/hiddify_manager/modules/daily_actions.py
new file mode 100644
index 000000000..027481681
--- /dev/null
+++ b/hiddify_manager/modules/daily_actions.py
@@ -0,0 +1,42 @@
+"""
+Cron'd nightly maintenance tasks.
+
+Replaces common/daily_actions.sh. The legacy script `bash`'d into
+acme.sh/run.sh — which no longer exists, so the nightly job has been
+silently broken since the cert flow moved to python. We restore the
+intended behaviour: walk every domain in current.json and refresh
+its cert via modules.cert_issuer.get_cert (which knows how to fall
+back to self-signed when ACME isn't reachable).
+
+Invoked by the /etc/cron.d/hiddify_daily entry written from
+modules.common.apply_runtime_config.
+"""
+import sys
+
+from hiddify_manager.modules.cert_issuer import get_cert
+from hiddify_manager.utils.config import hiddify_config
+from hiddify_manager.utils.logger import log
+
+
+def run():
+ configs = hiddify_config() or {}
+ domains = configs.get("domains") or []
+ if not domains:
+ log.info("daily_actions: no domains in current.json — nothing to do")
+ return 0
+
+ for entry in domains:
+ domain = (entry or {}).get("domain") if isinstance(entry, dict) else None
+ if not domain:
+ continue
+ log.info(f"daily_actions: refreshing cert for {domain}")
+ get_cert(domain)
+ return 0
+
+
+def main():
+ return run()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/hiddify_manager/modules/dnstt.py b/hiddify_manager/modules/dnstt.py
new file mode 100644
index 000000000..f3fb37688
--- /dev/null
+++ b/hiddify_manager/modules/dnstt.py
@@ -0,0 +1,62 @@
+import os
+
+from hiddify_manager.utils.config import hiddify_config
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import module_dir as _module_dir
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.package_manager import download_package
+
+
+UNIT = "hiddify-dnstm-router.service"
+
+
+def _disable():
+ """Stop the unit if it's running. Idempotent — fine if the unit was never installed."""
+ run_cmd(["systemctl", "stop", UNIT], check=False)
+ run_cmd(["systemctl", "disable", UNIT], check=False)
+
+
+def install():
+ """
+ Set up the dnstt DNS-tunnel server *if* the panel has dnstt_enable set.
+ Legacy install_run other/dnstt $(hconfig "dnstt_enable") gated the
+ whole install on that flag; without the gate we end up crash-looping
+ the service on dev boxes where dnstt_enable is false (UDP/53 is bound
+ by systemd-resolved or the real DNS resolver).
+ """
+ configs = hiddify_config() or {}
+ hconfigs = configs.get("hconfigs") or {}
+ if not hconfigs.get("dnstt_enable"):
+ log.info("dnstt: dnstt_enable is false — stopping unit and skipping install")
+ _disable()
+ return
+
+ module_dir = _module_dir("other/dnstt")
+ os.makedirs(module_dir, exist_ok=True)
+
+ dnstm = os.path.join(module_dir, "dnstm")
+ if download_package("dnstm", dnstm):
+ os.chmod(dnstm, 0o755)
+
+ server_bin = os.path.join(module_dir, "dnstt-server")
+ if download_package("vaydns", server_bin):
+ os.chmod(server_bin, 0o755)
+
+ priv = os.path.join(module_dir, "server.key")
+ pub = os.path.join(module_dir, "server.pub")
+ if not os.path.exists(pub):
+ log.info("generating dnstt server keypair")
+ run_cmd(
+ [server_bin, "-gen-key", "-privkey-file", priv, "-pubkey-file", pub],
+ cwd=module_dir,
+ check=False,
+ )
+
+ run_cmd(["useradd", "dnstt"], check=False)
+ for p in (priv, pub):
+ if os.path.exists(p):
+ run_cmd(["chown", "dnstt:dnstt", p], check=False)
+ if os.path.exists(priv):
+ os.chmod(priv, 0o600)
+ if os.path.exists(pub):
+ os.chmod(pub, 0o644)
diff --git a/hiddify_manager/modules/haproxy.py b/hiddify_manager/modules/haproxy.py
new file mode 100644
index 000000000..9415bfd16
--- /dev/null
+++ b/hiddify_manager/modules/haproxy.py
@@ -0,0 +1,34 @@
+import os
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.paths import module_dir as _module_dir
+import glob
+
+def install():
+ module_dir = _module_dir("haproxy")
+
+ for template in glob.glob(os.path.join(module_dir, "*.template")):
+ os.remove(template)
+
+ run_cmd(["systemctl", "stop", "hiddify-sniproxy"], check=False)
+ run_cmd(["systemctl", "disable", "hiddify-sniproxy"], check=False)
+ run_cmd(["pkill", "-9", "sniproxy"], check=False)
+
+ run_cmd(["add-apt-repository", "-y", "ppa:vbernat/haproxy-3.0"], check=False)
+ run_cmd(["apt-get", "update", "-y"], check=False)
+ run_cmd(["apt-get", "install", "-y", "haproxy"])
+
+ run_cmd(["systemctl", "kill", "haproxy"], check=False)
+ run_cmd(["systemctl", "stop", "haproxy"], check=False)
+ run_cmd(["systemctl", "disable", "haproxy"], check=False)
+
+ svc_file = os.path.join(module_dir, "hiddify-haproxy.service")
+ if os.path.exists(svc_file):
+ run_cmd(["ln", "-sf", svc_file, "/etc/systemd/system/hiddify-haproxy.service"])
+ run_cmd(["systemctl", "enable", "hiddify-haproxy.service"])
+
+ # Legacy run.sh chmod'd the rendered configs and restarted the unit.
+ for cfg in glob.glob(os.path.join(module_dir, "*.cfg")):
+ os.chmod(cfg, 0o600)
+ run_cmd(["systemctl", "restart", "hiddify-haproxy.service"], check=False)
+ log.info("HAProxy setup complete.")
diff --git a/hiddify_manager/modules/hiddify_cli.py b/hiddify_manager/modules/hiddify_cli.py
new file mode 100644
index 000000000..0707cbdcb
--- /dev/null
+++ b/hiddify_manager/modules/hiddify_cli.py
@@ -0,0 +1,119 @@
+import json
+import os
+import tarfile
+import urllib.request
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import module_dir as _module_dir
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.package_manager import get_arch
+from hiddify_manager.utils.config import hiddify_config
+
+
+def _latest_release(repo):
+ url = f"https://api.github.com/repos/hiddify/{repo}/releases"
+ try:
+ with urllib.request.urlopen(url, timeout=10) as r:
+ data = json.load(r)
+ non_pre = [x for x in data if not x.get("prerelease")]
+ non_pre.sort(key=lambda x: x.get("created_at", ""))
+ if non_pre:
+ return non_pre[-1]["tag_name"].lstrip("v")
+ except Exception as e:
+ log.warning(f"github api lookup failed for {repo}: {e}")
+ return None
+
+
+def _download_and_extract(module_dir, version):
+ arch = get_arch()
+ url = (
+ f"https://github.com/hiddify/hiddify-core/releases/download/"
+ f"v{version}/hiddify-core-linux-{arch}.tar.gz"
+ )
+ tarball = os.path.join(module_dir, "hiddify-core.tar.gz")
+ log.info(f"downloading HiddifyCli {version} for {arch}")
+ urllib.request.urlretrieve(url, tarball)
+ with tarfile.open(tarball, "r:gz") as t:
+ t.extractall(module_dir)
+ os.remove(tarball)
+ for entry in os.listdir(module_dir):
+ sub = os.path.join(module_dir, entry)
+ if entry.startswith("hiddify-core-") and os.path.isdir(sub):
+ for name in os.listdir(sub):
+ os.rename(os.path.join(sub, name), os.path.join(module_dir, name))
+ os.rmdir(sub)
+ with open(os.path.join(module_dir, "VERSION"), "w") as f:
+ f.write(version)
+
+
+def _write_env(module_dir, configs):
+ if not configs:
+ log.warning("no panel configs available — skipping hiddify-cli .env")
+ return
+ hconfigs = configs.get("hconfigs", {})
+ chconfigs = configs.get("chconfigs", {})
+ if not hconfigs and isinstance(chconfigs, dict):
+ try:
+ hconfigs = chconfigs.get(0) or chconfigs.get("0", {})
+ except AttributeError:
+ hconfigs = {}
+
+ # Legacy run.sh.j2 computes a public PANEL_DOMAIN from panel_links then
+ # immediately overrides it to http://127.0.0.1:9000, so the public value
+ # is dead code. We use localhost directly — that way hiddify-cli works
+ # before nginx/haproxy bind 443, and won't get stranded on a public DNS
+ # name pointing somewhere else.
+ panel_domain = "http://127.0.0.1:9000"
+ _ = configs.get("panel_links") # kept for parity with the legacy template
+
+ proxy_path = hconfigs.get("proxy_path_client", "")
+ users = configs.get("users") or []
+ uuid = users[0]["uuid"] if users else ""
+ sub_link = f"{panel_domain}/{proxy_path}/{uuid}/singbox/"
+
+ env_file = os.path.join(module_dir, ".env")
+ with open(env_file, "w") as f:
+ f.write(f"SUB_LINK={sub_link}\n")
+ run_cmd(["chown", "hiddify-cli", env_file], check=False)
+ os.chmod(env_file, 0o600)
+
+
+UNIT = "hiddify-cli.service"
+
+
+def _disable():
+ run_cmd(["systemctl", "stop", UNIT], check=False)
+ run_cmd(["systemctl", "disable", UNIT], check=False)
+
+
+def install():
+ configs = hiddify_config() or {}
+ hconfigs = configs.get("hconfigs") or {}
+ if not hconfigs.get("hiddifycli_enable"):
+ log.info("hiddify-cli: hiddifycli_enable is false — stopping unit and skipping install")
+ _disable()
+ return
+
+ module_dir = _module_dir("other/hiddify-cli")
+ run_cmd(["useradd", "-m", "hiddify-cli", "-s", "/bin/bash"], check=False)
+
+ bin_path = os.path.join(module_dir, "HiddifyCli")
+ version_file = os.path.join(module_dir, "VERSION")
+ have_version = ""
+ if os.path.exists(version_file):
+ have_version = open(version_file).read().strip()
+
+ latest = _latest_release("hiddify-core")
+ if latest and (have_version != latest or not os.path.exists(bin_path)):
+ _download_and_extract(module_dir, latest)
+ else:
+ log.info("HiddifyCli already up to date" if latest else "skipping HiddifyCli download (no version)")
+
+ svc = os.path.join(module_dir, "hiddify-cli.service")
+ if os.path.exists(svc):
+ run_cmd(["ln", "-sf", svc, "/etc/systemd/system/hiddify-cli.service"])
+ run_cmd(["systemctl", "enable", "hiddify-cli.service"], check=False)
+
+ _write_env(module_dir, configs)
+ run_cmd(["chown", "-R", "hiddify-cli", module_dir], check=False)
+ run_cmd(["systemctl", "restart", "hiddify-cli.service"], check=False)
diff --git a/hiddify_manager/modules/hiddify_panel.py b/hiddify_manager/modules/hiddify_panel.py
new file mode 100644
index 000000000..90106e5a3
--- /dev/null
+++ b/hiddify_manager/modules/hiddify_panel.py
@@ -0,0 +1,224 @@
+import os
+from urllib.request import urlretrieve
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.paths import (
+ module_dir as _module_dir, PROJECT_ROOT, LOG_DIR, VENV_DIR,
+)
+
+def check_file_age_days(filepath, days=1):
+ import time
+ if not os.path.exists(filepath):
+ return True
+ return (time.time() - os.path.getmtime(filepath)) > (days * 86400)
+
+
+def _read_mysql_password():
+ """The mysql module writes the panel's db password to other/mysql/mysql_pass."""
+ pw_file = os.path.join(_module_dir("other/mysql"), "mysql_pass")
+ if not os.path.exists(pw_file):
+ return None
+ with open(pw_file) as f:
+ return f.read().strip() or None
+
+
+def _read_redis_password():
+ """Parse `requirepass ` out of other/redis/redis.conf."""
+ conf = os.path.join(_module_dir("other/redis"), "redis.conf")
+ if not os.path.exists(conf):
+ return None
+ with open(conf) as f:
+ for line in f:
+ parts = line.strip().split(None, 1)
+ if len(parts) == 2 and parts[0] == "requirepass":
+ return parts[1]
+ return None
+
+
+def _set_app_cfg_keys(cfg_path, kv):
+ """
+ Rewrite cfg_path so that, for each KEY in kv, any existing line starting
+ with 'KEY' (mirrors `sed -i '/^KEY/d'`) is dropped and replaced with the
+ given value at the bottom. Keeps the rest of the file intact.
+
+ Writes atomically via tempfile + os.replace so a crash mid-write can't
+ leave the panel with a half-truncated app.cfg.
+ """
+ existing = []
+ if os.path.exists(cfg_path):
+ with open(cfg_path) as f:
+ existing = f.readlines()
+
+ keys = list(kv.keys())
+ kept = [
+ ln for ln in existing
+ if not any(ln.lstrip().startswith(k) for k in keys)
+ ]
+ tail = [f"{k} = '{v}'\n" for k, v in kv.items()]
+
+ tmp = cfg_path + ".tmp"
+ with open(tmp, "w") as f:
+ f.writelines(kept)
+ if kept and not kept[-1].endswith("\n"):
+ f.write("\n")
+ f.writelines(tail)
+ os.chmod(tmp, 0o600)
+ os.replace(tmp, cfg_path)
+
+def install():
+ module_dir = _module_dir("hiddify-panel")
+
+ # Dependencies
+ run_cmd(["apt-get", "install", "-y", "wireguard", "libev-dev", "libevdev2", "default-libmysqlclient-dev", "build-essential", "pkg-config", "ssh"])
+
+ # Create user
+ run_cmd(["useradd", "-m", "hiddify-panel", "-s", "/bin/bash"], check=False)
+ run_cmd(["usermod", "-aG", "hiddify-common", "hiddify-panel"], check=False)
+
+ # Setup logs
+ panel_log = os.path.join(LOG_DIR, "panel.log")
+ os.makedirs(LOG_DIR, exist_ok=True)
+ if not os.path.exists(panel_log):
+ with open(panel_log, "w") as f:
+ pass
+ run_cmd(["chown", "hiddify-panel", panel_log])
+
+ run_cmd(["chsh", "hiddify-panel", "-s", "/bin/bash"], check=False)
+
+ # Permissions
+ run_cmd(["chown", "-R", "hiddify-panel:hiddify-panel", "/home/hiddify-panel/"], check=False)
+ run_cmd(["localectl", "set-locale", "LANG=C.UTF-8"], check=False)
+ run_cmd(["su", "hiddify-panel", "-c", "update-locale LANG=C.UTF-8"], check=False)
+ run_cmd(["chown", "-R", "hiddify-panel:hiddify-panel", module_dir], check=False)
+
+ # Venv profile
+ bashrc = "/home/hiddify-panel/.bashrc"
+ if os.path.exists(bashrc):
+ with open(bashrc, "r") as f:
+ content = f.read()
+ venv_bin = os.path.join(PROJECT_ROOT, ".venv313", "bin")
+ if f"source {venv_bin}/activate" not in content:
+ with open(bashrc, "a") as f:
+ f.write(f"\nsource {venv_bin}/activate\n")
+ f.write(f"export PATH={venv_bin}:$PATH\n")
+
+ # Systemd services
+ svc1 = os.path.join(module_dir, "hiddify-panel.service")
+ if os.path.exists(svc1):
+ run_cmd(["ln", "-sf", svc1, "/etc/systemd/system/hiddify-panel.service"])
+ run_cmd(["systemctl", "enable", "hiddify-panel.service"])
+
+ svc2 = os.path.join(module_dir, "hiddify-panel-background-tasks.service")
+ if os.path.exists(svc2):
+ run_cmd(["ln", "-sf", svc2, "/etc/systemd/system/hiddify-panel-background-tasks.service"])
+ run_cmd(["systemctl", "enable", "hiddify-panel-background-tasks.service"])
+
+ # Check if we should build from source
+ source_dir = os.environ.get("HIDDIFY_PANLE_SOURCE_DIR")
+ if source_dir:
+ log.info(f"NOTICE: building hiddifypanel package from source dir: {source_dir}")
+ run_cmd(["uv", "pip", "install", "-e", source_dir])
+
+ # Cron cleanup
+ for cron_file in ["hiddify_usage_update", "hiddify_auto_backup"]:
+ p = f"/etc/cron.d/{cron_file}"
+ if os.path.exists(p):
+ os.remove(p)
+ run_cmd(["service", "cron", "reload"], check=False)
+
+ # GeoLite Databases
+ asn_db = os.path.join(module_dir, "GeoLite2-ASN.mmdb")
+ if check_file_age_days(asn_db, 1):
+ log.info("Downloading GeoLite2-ASN.mmdb...")
+ try:
+ urlretrieve("https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-ASN.mmdb", asn_db)
+ except Exception as e:
+ log.error(f"Failed to download GeoLite2-ASN.mmdb: {e}")
+
+ country_db = os.path.join(module_dir, "GeoLite2-Country.mmdb")
+ if check_file_age_days(country_db, 1):
+ log.info("Downloading GeoLite2-Country.mmdb...")
+ try:
+ urlretrieve("https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb", country_db)
+ except Exception as e:
+ log.error(f"Failed to download GeoLite2-Country.mmdb: {e}")
+
+ # --- Post-install: app.cfg seeding + db init + start services ----------
+ # Previously this was bash hiddify-panel/run.sh. Inlined here so the
+ # panel boot is fully python-driven.
+
+ # Touch + chown the panel log file (uvicorn/bjoern writes here).
+ panel_log = os.path.join(LOG_DIR, "panel.log")
+ os.makedirs(LOG_DIR, exist_ok=True)
+ if not os.path.exists(panel_log):
+ open(panel_log, "w").close()
+ run_cmd(["chown", "hiddify-panel", panel_log], check=False)
+
+ # Reassert ownership in case it drifted, lock down app.cfg.
+ run_cmd(["chown", "-R", "hiddify-panel:hiddify-panel", module_dir], check=False)
+ app_cfg = os.path.join(module_dir, "app.cfg")
+ if os.path.exists(app_cfg):
+ os.chmod(app_cfg, 0o600)
+
+ # Build connection URIs. Env vars win over the on-disk credentials —
+ # mirrors the bash precedence (`if [ -z "$SQLALCHEMY_DATABASE_URI" ]`).
+ sqlalchemy_uri = os.environ.get("SQLALCHEMY_DATABASE_URI")
+ if not sqlalchemy_uri:
+ mysql_pw = os.environ.get("MYSQL_PASS") or _read_mysql_password()
+ if mysql_pw:
+ sqlalchemy_uri = (
+ f"mysql+mysqldb://hiddifypanel:{mysql_pw}"
+ "@localhost/hiddifypanel?charset=utf8mb4"
+ )
+
+ redis_main = os.environ.get("REDIS_URI_MAIN")
+ redis_ssh = os.environ.get("REDIS_URI_SSH")
+ if not redis_main or not redis_ssh:
+ redis_pw = os.environ.get("REDIS_PASS") or _read_redis_password()
+ if redis_pw:
+ redis_main = redis_main or f"redis://:{redis_pw}@127.0.0.1:6379/0"
+ redis_ssh = redis_ssh or f"redis://:{redis_pw}@127.0.0.1:6379/1"
+
+ updates = {}
+ if sqlalchemy_uri:
+ updates["SQLALCHEMY_DATABASE_URI"] = sqlalchemy_uri
+ if redis_main:
+ updates["REDIS_URI_MAIN"] = redis_main
+ if redis_ssh:
+ updates["REDIS_URI_SSH"] = redis_ssh
+
+ if updates:
+ _set_app_cfg_keys(app_cfg, updates)
+ run_cmd(["chown", "hiddify-panel:hiddify-panel", app_cfg], check=False)
+ else:
+ log.warning("hiddify-panel: no mysql/redis credentials found — app.cfg left untouched")
+
+ # Run hiddifypanel CLI tasks. cwd=module_dir so app.cfg is picked up.
+ venv_python = os.path.join(VENV_DIR, "bin", "python3")
+ config_env = os.path.join(PROJECT_ROOT, "config.env")
+ if os.path.exists(config_env):
+ log.info("Importing config.env into the panel...")
+ res = run_cmd(
+ [venv_python, "-m", "hiddifypanel", "import-config", "-c", config_env],
+ cwd=module_dir,
+ check=False,
+ )
+ if getattr(res, "returncode", 0) == 0:
+ try:
+ os.rename(config_env, config_env + ".old")
+ except OSError as e:
+ log.warning(f"could not rename config.env: {e}")
+ else:
+ log.error(f"hiddifypanel import-config exited {res.returncode}")
+
+ log.info("Running hiddifypanel init-db...")
+ run_cmd(
+ [venv_python, "-m", "hiddifypanel", "init-db"],
+ cwd=module_dir,
+ check=False,
+ )
+
+ run_cmd(["systemctl", "start", "hiddify-panel.service"], check=False)
+ run_cmd(["systemctl", "restart", "hiddify-panel-background-tasks.service"], check=False)
+
+ log.info("Hiddify Panel setup complete.")
diff --git a/hiddify_manager/modules/logs.py b/hiddify_manager/modules/logs.py
new file mode 100644
index 000000000..4ef3192e2
--- /dev/null
+++ b/hiddify_manager/modules/logs.py
@@ -0,0 +1,88 @@
+"""
+Browse log files under log/system/.
+
+Replaces the menu's old "View system logs" which just ran `ls -lah
+log/system/`. Now: list each log with its mtime + size, let the user
+pick one, tail the last N lines via rich (so colourised + paged).
+"""
+import os
+import time
+
+import questionary
+from rich.console import Console
+
+from hiddify_manager.utils.paths import LOG_DIR
+
+
+TAIL_LINES = 200
+
+
+def _list_logs():
+ """Return a list of (path, size_bytes, mtime) for every regular file."""
+ out = []
+ if not os.path.isdir(LOG_DIR):
+ return out
+ for name in sorted(os.listdir(LOG_DIR)):
+ path = os.path.join(LOG_DIR, name)
+ if not os.path.isfile(path):
+ continue
+ st = os.stat(path)
+ out.append((path, st.st_size, st.st_mtime))
+ return out
+
+
+def _fmt_size(n):
+ for unit in ("B", "K", "M", "G"):
+ if n < 1024:
+ return f"{n:>4}{unit}"
+ n //= 1024
+ return f"{n:>4}T"
+
+
+def _fmt_age(mtime):
+ age = time.time() - mtime
+ if age < 60:
+ return f"{int(age)}s ago"
+ if age < 3600:
+ return f"{int(age / 60)}m ago"
+ if age < 86400:
+ return f"{int(age / 3600)}h ago"
+ return f"{int(age / 86400)}d ago"
+
+
+def _tail(path, lines=TAIL_LINES):
+ try:
+ with open(path, encoding="utf-8", errors="replace") as f:
+ data = f.readlines()
+ except OSError as e:
+ return f"[red]could not read {path}: {e}[/red]"
+ return "".join(data[-lines:])
+
+
+def browse():
+ """Pick a log + tail its last TAIL_LINES lines via rich."""
+ console = Console()
+ logs = _list_logs()
+ if not logs:
+ console.print(f"[yellow]No log files under {LOG_DIR}[/yellow]")
+ return
+
+ choices = []
+ for path, size, mtime in logs:
+ name = os.path.basename(path)
+ label = f"{name:<40}{_fmt_size(size):>6} {_fmt_age(mtime):>9}"
+ choices.append(questionary.Choice(label, value=path))
+ choices.append(questionary.Choice("Back", value=None, shortcut_key="b"))
+
+ pick = questionary.select(
+ f"Logs under {LOG_DIR} — pick one to tail last {TAIL_LINES} lines",
+ choices=choices, use_shortcuts=True,
+ ).ask()
+ if not pick:
+ return
+
+ console.print(f"\n[bold cyan]── tail -{TAIL_LINES} {pick} ──[/bold cyan]")
+ body = _tail(pick)
+ # Render as plain text; we don't know the actual format. Could detect
+ # .json + use rich Syntax later if useful.
+ console.print(body, highlight=False, soft_wrap=False)
diff --git a/hiddify_manager/modules/manager_updater.py b/hiddify_manager/modules/manager_updater.py
new file mode 100644
index 000000000..304578dd0
--- /dev/null
+++ b/hiddify_manager/modules/manager_updater.py
@@ -0,0 +1,177 @@
+"""
+Self-update the hiddify-manager repo from GitHub.
+
+Replaces common/hiddify_installer.sh::update_from_github + update_config.
+Picks the right tarball/zip per release mode, downloads it, extracts
+into /opt/hiddify-manager (overwriting on top of the running install),
+clears the cached rendered configs that the legacy script also wiped,
+and writes the new VERSION file.
+
+The orchestrator caller is expected to re-invoke ./init.sh install
+after this completes — we deliberately don't os.execv to avoid being
+surprising about it.
+"""
+import glob
+import os
+import shutil
+import sys
+import tarfile
+import tempfile
+import urllib.request
+import zipfile
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import PROJECT_ROOT
+
+
+GITHUB_RELEASE_LATEST = (
+ "https://github.com/hiddify/Hiddify-Manager/releases/latest/download/hiddify-manager.zip"
+)
+GITHUB_RELEASE_TAG = (
+ "https://github.com/hiddify/Hiddify-Manager/releases/download/{tag}/hiddify-manager.zip"
+)
+GITHUB_DEV_TARBALL = (
+ "https://github.com/hiddify/hiddify-manager/archive/refs/heads/dev.tar.gz"
+)
+
+# Per the legacy script: stale rendered configs that must be wiped before
+# the new install renders them fresh. Globs against PROJECT_ROOT.
+STALE_CONFIG_GLOBS = [
+ "xray/configs/*.json",
+ "singbox/configs/*.json",
+ "xray/configs/05_inbounds_10*.json*",
+ "xray/configs/05_inbounds_h2*.json*",
+ "xray/configs/05_inbounds_02_realitygrpc*.json*",
+ "xray/configs/05_inbounds_02_realityh2*.json*",
+ "singbox/configs/05_inbounds_2071_realitygrpc_main.json*",
+ "singbox/configs/05_inbounds_20[123][1234]*.json*",
+]
+
+
+def url_for_mode(mode):
+ """Map a release mode to the matching archive URL. Returns None for modes
+ that don't pull from GitHub (e.g. docker uses local src)."""
+ mode = (mode or "release").lower()
+ if mode == "release":
+ return GITHUB_RELEASE_LATEST
+ if mode.startswith("v"):
+ return GITHUB_RELEASE_TAG.format(tag=mode)
+ if mode in ("dev", "develop"):
+ return GITHUB_DEV_TARBALL
+ if mode == "beta":
+ # Beta uses a tagged release; without a known tag we can't pick
+ # the URL. Caller should resolve the tag via the GitHub API and
+ # pass it as `v` instead.
+ log.warning("manager_updater: beta mode needs an explicit v; skipping source update")
+ return None
+ if mode == "docker":
+ return None
+ log.error(f"manager_updater: unknown mode {mode!r}")
+ return None
+
+
+def _download(url, dest):
+ log.info(f"manager_updater: downloading {url}")
+ urllib.request.urlretrieve(url, dest)
+
+
+def _extract(archive_path, dest_dir):
+ """
+ Extract a .zip or .tar.gz into dest_dir. For tar.gz we strip the top-
+ level GitHub directory (so the archive contents land directly under
+ dest_dir, matching legacy `tar --strip-components=1` behaviour).
+ """
+ if archive_path.endswith(".zip"):
+ with zipfile.ZipFile(archive_path) as zf:
+ zf.extractall(dest_dir)
+ elif archive_path.endswith((".tar.gz", ".tgz")):
+ with tarfile.open(archive_path, "r:gz") as tf:
+ # GitHub tarballs wrap everything in `Hiddify-Manager-[/`.
+ members = []
+ for m in tf.getmembers():
+ parts = m.name.split("/", 1)
+ if len(parts) < 2:
+ continue
+ m.name = parts[1]
+ members.append(m)
+ # filter="data" mirrors the safe-extraction policy Python 3.14
+ # will default to. Avoids the DeprecationWarning on 3.12+ and is
+ # the right behaviour for unzipping into a real directory.
+ tf.extractall(dest_dir, members=members, filter="data")
+ else:
+ raise ValueError(f"unsupported archive format: {archive_path}")
+
+
+def _wipe_stale_configs():
+ for pattern in STALE_CONFIG_GLOBS:
+ for path in glob.glob(os.path.join(PROJECT_ROOT, pattern)):
+ try:
+ os.remove(path)
+ except OSError as e:
+ log.warning(f"manager_updater: could not remove {path}: {e}")
+
+
+def _merge_into_project(staging_dir):
+ """
+ Copy everything under staging_dir on top of PROJECT_ROOT. We use
+ shutil.copytree with dirs_exist_ok=True instead of mv-ing, because the
+ current python process has files under PROJECT_ROOT open and a wholesale
+ rename would invalidate them.
+ """
+ for entry in os.listdir(staging_dir):
+ src = os.path.join(staging_dir, entry)
+ dst = os.path.join(PROJECT_ROOT, entry)
+ if os.path.isdir(src):
+ shutil.copytree(src, dst, dirs_exist_ok=True)
+ else:
+ shutil.copy2(src, dst)
+
+
+def update_manager_source(mode, override_version=None):
+ """
+ Download + extract a fresh hiddify-manager release on top of the
+ running install. Returns True on success, False otherwise (including
+ when the mode is one we don't fetch source for).
+ """
+ url = url_for_mode(mode)
+ if not url:
+ return False
+
+ suffix = ".tar.gz" if url.endswith(".tar.gz") else ".zip"
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = os.path.join(tmp, f"hiddify-manager{suffix}")
+ staging = os.path.join(tmp, "extracted")
+ os.makedirs(staging, exist_ok=True)
+ try:
+ _download(url, archive)
+ _extract(archive, staging)
+ except Exception as e:
+ log.error(f"manager_updater: download/extract failed: {e}")
+ return False
+
+ os.makedirs(PROJECT_ROOT, exist_ok=True)
+ _merge_into_project(staging)
+
+ if override_version:
+ try:
+ with open(os.path.join(PROJECT_ROOT, "VERSION"), "w") as f:
+ f.write(override_version + "\n")
+ except OSError as e:
+ log.warning(f"manager_updater: could not write VERSION: {e}")
+
+ _wipe_stale_configs()
+ log.info("manager_updater: source updated. Re-run ./init.sh install to apply.")
+ return True
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("usage: manager_updater >")
+ return 2
+ mode = sys.argv[1]
+ ok = update_manager_source(mode)
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/hiddify_manager/modules/mysql.py b/hiddify_manager/modules/mysql.py
new file mode 100644
index 000000000..242ed9014
--- /dev/null
+++ b/hiddify_manager/modules/mysql.py
@@ -0,0 +1,69 @@
+import os
+import secrets
+import string
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.paths import module_dir as _module_dir
+
+def generate_random_password(length=49):
+ chars = string.ascii_letters + string.digits
+ return ''.join(secrets.choice(chars) for _ in range(length))
+
+def install():
+ module_dir = _module_dir("other/mysql")
+ os.makedirs(module_dir, exist_ok=True)
+
+ run_cmd(["apt-get", "install", "-y", "mariadb-server"])
+
+ pass_file = os.path.join(module_dir, "mysql_pass")
+
+ if not os.path.exists(pass_file):
+ log.info("Generating a random password for MySQL...")
+ random_password = generate_random_password()
+ with open(pass_file, "w") as f:
+ f.write(random_password + "\n")
+ os.chmod(pass_file, 0o600)
+
+ # Secure MariaDB installation via input
+ secure_install_input = f"y\n{random_password}\n{random_password}\ny\ny\ny\ny\n"
+ run_cmd(["mysql_secure_installation"], input_data=secure_install_input, check=False)
+
+ mariadb_conf = "/etc/mysql/mariadb.conf.d/50-server.cnf"
+ if os.path.exists(mariadb_conf):
+ # Disable external access
+ run_cmd(["sed", "-i", "s/bind-address/#bind-address/", mariadb_conf], check=False)
+
+ run_cmd(["systemctl", "restart", "mariadb"], check=False)
+
+ # SQL fed via stdin (not argv); password is escaped defensively so
+ # this remains safe if generate_random_password() ever yields ' or \.
+ escaped = random_password.replace("\\", "\\\\").replace("'", "\\'")
+ sql_script = (
+ f"CREATE USER IF NOT EXISTS 'hiddifypanel'@'localhost' IDENTIFIED BY '{escaped}';"
+ f"ALTER USER 'hiddifypanel'@'localhost' IDENTIFIED BY '{escaped}';"
+ "GRANT ALL PRIVILEGES ON *.* TO 'hiddifypanel'@'localhost';"
+ "CREATE DATABASE IF NOT EXISTS hiddifypanel "
+ "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
+ "GRANT ALL PRIVILEGES ON hiddifypanel.* TO 'hiddifypanel'@'localhost';"
+ "FLUSH PRIVILEGES;"
+ )
+ run_cmd(["mysql", "-u", "root", "-f"], input_data=sql_script, check=False)
+ log.info("MariaDB setup complete.")
+
+ mariadb_conf = "/etc/mysql/mariadb.conf.d/50-server.cnf"
+ if os.path.exists(mariadb_conf):
+ # Check if bind-address is already set to 127.0.0.1
+ grep_res = run_cmd(["grep", "-q", r"^[^#]*bind-address\s*=\s*127.0.0.1", mariadb_conf], check=False)
+ if grep_res.returncode != 0:
+ grep_comment = run_cmd(["grep", "-q", r"^#\+bind-address", mariadb_conf], check=False)
+ if grep_comment.returncode == 0:
+ run_cmd(["sed", "-i", r"s/^#\+bind-address\s*=\s*[0-9.]*/bind-address = 127.0.0.1/", mariadb_conf], check=False)
+ else:
+ run_cmd(["sed", "-i", r"/\[mysqld\]/a bind-address = 127.0.0.1", mariadb_conf], check=False)
+ log.info(f"bind-address set to 127.0.0.1 in {mariadb_conf}")
+ run_cmd(["systemctl", "restart", "mariadb"], check=False)
+ else:
+ log.warning(f"MariaDB configuration file ({mariadb_conf}) not found.")
+
+ run_cmd(["systemctl", "start", "mariadb"], check=False)
+ run_cmd(["systemctl", "enable", "mariadb"], check=False)
diff --git a/hiddify_manager/modules/nginx.py b/hiddify_manager/modules/nginx.py
new file mode 100644
index 000000000..560ba8805
--- /dev/null
+++ b/hiddify_manager/modules/nginx.py
@@ -0,0 +1,42 @@
+import os
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.paths import module_dir as _module_dir
+
+def install():
+ module_dir = _module_dir("nginx")
+
+ run_cmd(["useradd", "nginx"], check=False)
+
+ run_cmd(["apt-get", "update", "-y"], check=False)
+ run_cmd(["apt-get", "install", "-y", "nginx"])
+
+ services_to_kill = ["nginx", "apache2"]
+ for svc in services_to_kill:
+ run_cmd(["systemctl", "kill", svc], check=False)
+ run_cmd(["systemctl", "disable", svc], check=False)
+
+ old_configs = [
+ "/etc/nginx/conf.d/web.conf",
+ "/etc/nginx/sites-available/default",
+ "/etc/nginx/sites-enabled/default",
+ "/etc/nginx/conf.d/default.conf",
+ "/etc/nginx/conf.d/xray-base.conf",
+ "/etc/nginx/conf.d/speedtest.conf"
+ ]
+ for cfg in old_configs:
+ if os.path.exists(cfg):
+ os.remove(cfg)
+
+ os.makedirs(os.path.join(module_dir, "run"), exist_ok=True)
+
+ hiddify_nginx_svc = os.path.join(module_dir, "hiddify-nginx.service")
+ if os.path.exists(hiddify_nginx_svc):
+ run_cmd(["ln", "-sf", hiddify_nginx_svc, "/etc/systemd/system/hiddify-nginx.service"])
+ run_cmd(["systemctl", "enable", "hiddify-nginx.service"])
+
+ # Match the legacy run.sh: chown the tree to nginx so it can read its
+ # rendered configs, then bring the service up.
+ run_cmd(["chown", "-R", "nginx", module_dir], check=False)
+ run_cmd(["systemctl", "restart", "hiddify-nginx.service"], check=False)
+ log.info("Nginx setup complete.")
diff --git a/hiddify_manager/modules/panel_installer.py b/hiddify_manager/modules/panel_installer.py
new file mode 100644
index 000000000..31849a075
--- /dev/null
+++ b/hiddify_manager/modules/panel_installer.py
@@ -0,0 +1,108 @@
+"""
+Panel package installer / updater.
+
+Ports common/hiddify_installer.sh::update_panel — the dispatch that
+installs the hiddifypanel python package per branch/mode. The 5 modes
+mirror the bash exactly:
+
+ release pypi latest (the default for end users)
+ beta pypi latest --pre
+ dev/develop git+https://github.com/hiddify/HiddifyPanel (HEAD)
+ v git+https://github.com/hiddify/HiddifyPanel@
+ docker pip install /opt/hiddify-manager/hiddify-panel/src
+
+Each mode stops the panel services first so an in-flight upgrade
+doesn't race a live process holding a now-stale module path.
+
+Not migrated: get_release_version / get_commit_version / "is an
+update needed" version probing. Pip handles the no-op fast enough
+that the extra GitHub round-trip didn't earn its complexity.
+"""
+import os
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import VENV_DIR, PROJECT_ROOT
+from hiddify_manager.utils.shell import run_cmd
+
+
+PANEL_GIT = "git+https://github.com/hiddify/HiddifyPanel"
+PANEL_PYPI = "hiddifypanel"
+PANEL_UNITS = ("hiddify-panel.service", "hiddify-panel-background-tasks.service")
+SUPPORTED_MODES = ("release", "beta", "dev", "develop", "docker")
+
+
+def _pip(*args, check=False):
+ """Invoke pip from the project venv with the given trailing args."""
+ venv_pip = os.path.join(VENV_DIR, "bin", "pip")
+ return run_cmd([venv_pip, *args], check=check)
+
+
+def _disable_panel_services():
+ """Stop the panel units so the new package code is what next start picks up."""
+ for unit in PANEL_UNITS:
+ run_cmd(["systemctl", "stop", unit], check=False)
+
+
+def _install_release():
+ _pip("install", "-U", "wheel", PANEL_PYPI)
+
+
+def _install_beta():
+ _pip("install", "-U", "--pre", PANEL_PYPI)
+
+
+def _install_dev():
+ # --force-reinstall + --no-deps first to make sure the panel package
+ # itself updates even when deps are already at their tip; then a
+ # regular install picks up any new dependency.
+ _pip("install", "-U", "--force-reinstall", "--no-deps", PANEL_GIT)
+ _pip("install", PANEL_GIT)
+
+
+def _install_tag(tag):
+ ref = f"{PANEL_GIT}@{tag}"
+ _pip("install", "-U", "--force-reinstall", "--no-deps", ref)
+ _pip("install", ref)
+
+
+def _install_docker():
+ src = os.path.join(PROJECT_ROOT, "hiddify-panel", "src")
+ if not os.path.isdir(src):
+ log.error(f"panel_installer: docker mode requires {src}, not found")
+ return False
+ _pip("install", src)
+ return True
+
+
+def update_panel(mode="release"):
+ """
+ Install or upgrade the hiddifypanel package per `mode`. Returns
+ True on success, False otherwise. Always stops the panel units
+ before pip so in-flight requests don't lock paths under
+ site-packages/.
+ """
+ mode = (mode or "release").lower()
+
+ if mode not in SUPPORTED_MODES and not mode.startswith("v"):
+ log.error(
+ f"panel_installer: unknown mode {mode!r}; expected one of "
+ f"{SUPPORTED_MODES} or v"
+ )
+ return False
+
+ log.info(f"panel_installer: updating panel in {mode!r} mode")
+ _disable_panel_services()
+
+ if mode == "release":
+ _install_release()
+ elif mode == "beta":
+ _install_beta()
+ elif mode in ("dev", "develop"):
+ _install_dev()
+ elif mode == "docker":
+ if not _install_docker():
+ return False
+ elif mode.startswith("v"):
+ _install_tag(mode)
+
+ return True
diff --git a/hiddify_manager/modules/redis.py b/hiddify_manager/modules/redis.py
new file mode 100644
index 000000000..bfb068f10
--- /dev/null
+++ b/hiddify_manager/modules/redis.py
@@ -0,0 +1,54 @@
+import os
+import secrets
+import string
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.paths import module_dir as _module_dir, LOG_DIR
+
+def generate_random_password(length=49):
+ chars = string.ascii_letters + string.digits
+ return ''.join(secrets.choice(chars) for _ in range(length))
+
+def install():
+ module_dir = _module_dir("other/redis")
+
+ check_installed = run_cmd(["dpkg", "-s", "redis-server"], check=False)
+ if check_installed.returncode != 0:
+ run_cmd(["add-apt-repository", "-y", "universe"], check=False)
+ run_cmd(["apt-get", "install", "-y", "redis-server"])
+
+ # STOP any running system redis first to avoid a window without a password
+ run_cmd(["systemctl", "disable", "--now", "redis-server"], check=False)
+ run_cmd(["pkill", "-9", "redis-server"], check=False)
+
+ if os.path.exists(module_dir):
+ run_cmd(["chown", "-R", "redis:redis", module_dir])
+
+ redis_conf = os.path.join(module_dir, "redis.conf")
+ if os.path.exists(redis_conf):
+ os.chmod(redis_conf, 0o600)
+
+ # Ensure a password exists in repo config before starting any service
+ with open(redis_conf, "r") as f:
+ content = f.read()
+
+ if not any(line.startswith("requirepass") for line in content.splitlines()):
+ random_password = generate_random_password()
+ with open(redis_conf, "a") as f:
+ f.write(f"\nrequirepass {random_password}\n")
+ log.info("Generated a random password for Redis.")
+
+ # Wire up and start the managed service using the repo config
+ svc_file = os.path.join(module_dir, "hiddify-redis.service")
+ if os.path.exists(svc_file):
+ run_cmd(["ln", "-sf", svc_file, "/etc/systemd/system/hiddify-redis.service"])
+ run_cmd(["systemctl", "enable", "--now", "hiddify-redis"])
+
+ # Ensure logging path exists/owned
+ redis_log = os.path.join(LOG_DIR, "redis-server.log")
+ os.makedirs(LOG_DIR, exist_ok=True)
+ if not os.path.exists(redis_log):
+ with open(redis_log, "w") as f:
+ pass
+ run_cmd(["chown", "redis:redis", redis_log])
+ log.info("Redis setup complete.")
diff --git a/hiddify_manager/modules/remote_assistant.py b/hiddify_manager/modules/remote_assistant.py
new file mode 100644
index 000000000..3f6962fc8
--- /dev/null
+++ b/hiddify_manager/modules/remote_assistant.py
@@ -0,0 +1,106 @@
+"""
+Add/remove the Hiddify support team's SSH access for remote troubleshooting.
+
+Replaces common/{add,remove}_remote_assistant.sh. The pubkey below is
+the one the legacy scripts embedded verbatim — kept as a constant so
+both add() and remove() can match by line content.
+"""
+import os
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+
+
+HIDDIFY_ASSISTANT_KEY = (
+ "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWEXarp7YrTNX+4uNfdYtQ1lVsrD9/6oHaNiR6"
+ "kgzoeShD/+3Ljou3veXofVstCb6CpFZdmOaKXNJyT5N+gm0eXwYJNsnrkCRq9h/6ydkoVdPAINz"
+ "HZoetVqwqAPgmqzR8xTKZPP/Ky3Ks8OQEIg1Swnm9XXuP+ApmvOxGut9pPhOozKSATklojRaAmh"
+ "dz4y9YpkLi94C1Ixd10Ewjld4pnVp4+uDTkXV2i3N3lH5x6zFrk2tefigoZ60brNWC3TGL3SjQ4"
+ "obkD2qKpKqIRy63cUzfI0lP/0vZ7Ms5ESPlLI/ebMGvns9hINi1KRJ8m0//Jy0CDngJNJxG8KGb"
+ "vqvLu/avmdVUHr48y7bk6VTGicMp16LfbszRQRF2d61n5uwBGXUB5DbVNI00yOdqAflDEloBEch"
+ "qiWIEotBXyGTB1e2V1Oe95W27h9QSMbhNwmEk/QGPn4yhRgTbFq1TwNhE6DXZrCUbW8x4KVMQTS"
+ "D+seUB0fMgTTXtzpPEo3mFAME= hiddify@assistant"
+)
+
+
+def _authorized_keys_path():
+ return os.path.join(os.path.expanduser("~"), ".ssh", "authorized_keys")
+
+
+def _public_ipv4():
+ """Same v4.ident.me probe the legacy used to print the connection string."""
+ res = run_cmd(
+ ["curl", "--connect-timeout", "1", "-s", "https://v4.ident.me/"],
+ check=False, capture_output=True,
+ )
+ return (res.stdout or "").strip()
+
+
+def _ssh_listening_port():
+ """
+ Grep `ss -tulpn` for sshd's listen port — matches the legacy
+ ss/grep/awk pipeline. Returns the first integer port or None.
+ """
+ res = run_cmd(["ss", "-tulpn"], check=False, capture_output=True)
+ if res.returncode != 0:
+ return None
+ for line in (res.stdout or "").splitlines():
+ if "sshd" not in line:
+ continue
+ # Local address is the 5th column (index 4) for ss -tulpn output.
+ parts = line.split()
+ if len(parts) < 5:
+ continue
+ addr = parts[4]
+ if ":" not in addr:
+ continue
+ port_str = addr.rsplit(":", 1)[1]
+ if port_str.isdigit():
+ return int(port_str)
+ return None
+
+
+def add():
+ """
+ Append the assistant pubkey to ~/.ssh/authorized_keys (no dedup —
+ matches legacy), then print the SSH command to send to support.
+ Returns the path of the authorized_keys file.
+ """
+ auth = _authorized_keys_path()
+ os.makedirs(os.path.dirname(auth), exist_ok=True)
+ # Don't double-add — the bash version did, but that just creates
+ # noise in the file. Cheap to guard.
+ existing = ""
+ if os.path.exists(auth):
+ with open(auth) as f:
+ existing = f.read()
+ if HIDDIFY_ASSISTANT_KEY not in existing:
+ with open(auth, "a") as f:
+ if existing and not existing.endswith("\n"):
+ f.write("\n")
+ f.write(HIDDIFY_ASSISTANT_KEY + "\n")
+ os.chmod(auth, 0o600)
+
+ log.info("Now please send the following to https://t.me/hiddifybot")
+ ip = _public_ipv4() or ""
+ user = os.environ.get("USER") or "root"
+ port = _ssh_listening_port()
+ if port:
+ log.info(f"ssh {user}@{ip} -p {port}")
+ else:
+ log.info(f"ssh {user}@{ip}")
+ return auth
+
+
+def remove():
+ """Drop the assistant pubkey line from ~/.ssh/authorized_keys."""
+ auth = _authorized_keys_path()
+ if not os.path.exists(auth):
+ log.info("remote_assistant: no authorized_keys file — nothing to remove")
+ return
+ with open(auth) as f:
+ kept = [ln for ln in f.readlines() if HIDDIFY_ASSISTANT_KEY not in ln]
+ with open(auth, "w") as f:
+ f.writelines(kept)
+ os.chmod(auth, 0o600)
+ log.info("remote assistant access is removed")
diff --git a/hiddify_manager/modules/services.py b/hiddify_manager/modules/services.py
new file mode 100644
index 000000000..38c134fe3
--- /dev/null
+++ b/hiddify_manager/modules/services.py
@@ -0,0 +1,149 @@
+"""
+restart() and status() for the systemd units managed by Hiddify-Manager.
+
+Replaces restart.sh + status.sh. Both legacy scripts globbed
+**/*.service files under the project tree to discover units; we
+mirror that with pathlib + a small set of external units (mariadb,
+wg-quick@warp, mtproxy*) that the bash also hard-coded.
+
+restart() restarts in three waves so dependency-y units don't race:
+ 1. everything except hiddify-panel*, hiddify-cli
+ 2. hiddify-panel + hiddify-panel-background-tasks
+ 3. hiddify-cli
+
+status() walks the same set and prints a one-line-per-unit table.
+"""
+import os
+from concurrent.futures import ThreadPoolExecutor
+
+from hiddify_manager.utils.config import hiddify_config
+from hiddify_manager.utils.paths import PROJECT_ROOT
+from hiddify_manager.utils.shell import run_cmd
+
+
+EXTERNAL_UNITS = ("mariadb", "wg-quick@warp", "mtproxy.service", "mtproto-proxy.service")
+PANEL_UNITS = ("hiddify-panel", "hiddify-panel-background-tasks")
+CLI_UNITS = ("hiddify-cli",)
+
+# Glob roots — mirrors the legacy `other/**/*.service **/*.service`.
+_SERVICE_GLOBS = (
+ os.path.join(PROJECT_ROOT, "**", "*.service"),
+)
+
+
+def _unit_name(path_or_unit):
+ """Strip dir + .service suffix. 'a/b/foo.service' -> 'foo', 'foo' -> 'foo'."""
+ base = os.path.basename(path_or_unit)
+ return base.split(".", 1)[0]
+
+
+def discover_units():
+ """All systemd units we manage. Deterministic order; dedups by name."""
+ import glob
+ found = set()
+ for pattern in _SERVICE_GLOBS:
+ for path in glob.glob(pattern, recursive=True):
+ # Skip the .venv and the panel's bundled src files.
+ if ".venv" in path or "/hiddify-panel/src/" in path:
+ continue
+ found.add(_unit_name(path))
+ for unit in EXTERNAL_UNITS:
+ found.add(unit)
+ return sorted(found)
+
+
+def _warp_enabled():
+ """Mirror the bash check: skip wg-quick@warp if panel says warp_mode == 'disable'."""
+ configs = hiddify_config()
+ if not configs:
+ return True # be conservative — touch the unit anyway
+ mode = ((configs.get("hconfigs") or {}).get("warp_mode") or "").lower()
+ return mode != "disable"
+
+
+def _is_enabled(unit):
+ res = run_cmd(["systemctl", "is-enabled", unit], check=False, capture_output=True, quiet=True)
+ return res.returncode == 0
+
+
+def _is_active(unit):
+ res = run_cmd(["systemctl", "is-active", unit], check=False, capture_output=True, quiet=True)
+ return (res.stdout or "").strip()
+
+
+def _should_skip(unit):
+ """warp filter — applied to both restart and status."""
+ if unit == "wg-quick@warp" and not _warp_enabled():
+ return True
+ return False
+
+
+def _restart_unit(unit):
+ if _should_skip(unit) or not _is_enabled(unit):
+ return None
+ before = _is_active(unit)
+ run_cmd(["systemctl", "restart", unit], check=False, quiet=True)
+ after = _is_active(unit)
+ return (unit, before, after)
+
+
+def _restart_group(units, max_workers=8):
+ rows = []
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
+ for row in pool.map(_restart_unit, units):
+ if row:
+ rows.append(row)
+ return rows
+
+
+def _print_table(headers, rows, status_col_idx=None):
+ """Print a fixed-width table to stdout (no log timestamp prefix)."""
+ from rich.console import Console
+ from rich.table import Table
+
+ table = Table(show_header=True, header_style="bold cyan")
+ for h in headers:
+ table.add_column(h)
+ for row in rows:
+ formatted = []
+ for i, cell in enumerate(row):
+ text = str(cell)
+ if status_col_idx is not None and i == status_col_idx:
+ if text == "active":
+ text = f"[green]{text}[/green]"
+ elif text in ("inactive", "failed"):
+ text = f"[red]{text}[/red]"
+ else:
+ text = f"[yellow]{text}[/yellow]"
+ formatted.append(text)
+ table.add_row(*formatted)
+ Console().print(table)
+
+
+def restart():
+ """Restart every managed unit in three waves and return the status rows."""
+ all_units = discover_units()
+ panel_set = set(PANEL_UNITS) | set(CLI_UNITS)
+ others = [u for u in all_units if u not in panel_set]
+ panel = [u for u in PANEL_UNITS if u in all_units or u in PANEL_UNITS]
+ cli = [u for u in CLI_UNITS if u in all_units or u in CLI_UNITS]
+
+ rows = []
+ rows.extend(_restart_group(others))
+ rows.extend(_restart_group(panel))
+ rows.extend(_restart_group(cli))
+
+ _print_table(["Name", "Before", "After"], rows, status_col_idx=2)
+ return rows
+
+
+def status():
+ """Print one row per enabled unit with its current is-active state."""
+ rows = []
+ for unit in discover_units():
+ if _should_skip(unit) or not _is_enabled(unit):
+ continue
+ rows.append((unit, _is_active(unit)))
+
+ _print_table(["Service", "Status"], rows, status_col_idx=1)
+ return rows
diff --git a/hiddify_manager/modules/short_link.py b/hiddify_manager/modules/short_link.py
new file mode 100644
index 000000000..e8c580855
--- /dev/null
+++ b/hiddify_manager/modules/short_link.py
@@ -0,0 +1,72 @@
+"""
+Temporary nginx short-link injector.
+
+Replaces nginx/add2shortlink.sh: appends a `location` block to
+nginx/parts/short-link.conf that 302-redirects a short slug to a real
+URL, then schedules its removal via `at(1)` after N minutes, and asks
+nginx to reload.
+
+Invoked from the panel through common/commander.py.
+"""
+import os
+import re
+import sys
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import PROJECT_ROOT
+from hiddify_manager.utils.shell import run_cmd
+
+
+SHORT_LINK_CONF = os.path.join(PROJECT_ROOT, "nginx", "parts", "short-link.conf")
+NGINX_UNIT = "hiddify-nginx.service"
+
+_SLUG_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
+
+
+def add(real_url, slug, minutes):
+ """
+ Add a short-link entry and schedule its removal.
+
+ Returns 0 on success, non-zero on validation failure.
+ """
+ if not slug or not _SLUG_RE.fullmatch(slug):
+ log.error(f"short_link: refusing invalid slug {slug!r}")
+ return 1
+ try:
+ minutes = int(minutes)
+ except (TypeError, ValueError):
+ log.error(f"short_link: invalid minutes value {minutes!r}")
+ return 2
+ if minutes <= 0:
+ log.error(f"short_link: minutes must be positive, got {minutes}")
+ return 3
+
+ # Append the nginx location block.
+ block = f"location ~* ^/{slug}(/)?$ {{return 302 {real_url};}}\n"
+ os.makedirs(os.path.dirname(SHORT_LINK_CONF), exist_ok=True)
+ with open(SHORT_LINK_CONF, "a") as f:
+ f.write(block)
+ log.info(f"short_link: added {slug} -> {real_url} for {minutes}m")
+
+ # Schedule the removal via at(1). The sed command strips any line
+ # mentioning the slug; the original used the same approach.
+ sed_cmd = f"sed -i '/\\/{slug}(/d' {SHORT_LINK_CONF}"
+ run_cmd(
+ ["at", "now", f"+{minutes}", "minutes"],
+ check=False, input_data=sed_cmd + "\n",
+ )
+
+ run_cmd(["systemctl", "reload", NGINX_UNIT], check=False)
+ return 0
+
+
+def main():
+ """CLI: short_link ."""
+ if len(sys.argv) < 4:
+ print("usage: short_link ")
+ return 2
+ return add(sys.argv[1], sys.argv[2], sys.argv[3])
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/hiddify_manager/modules/singbox.py b/hiddify_manager/modules/singbox.py
new file mode 100644
index 000000000..be88240e7
--- /dev/null
+++ b/hiddify_manager/modules/singbox.py
@@ -0,0 +1,64 @@
+import os
+import shutil
+import glob
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.package_manager import download_package, extract_package
+from hiddify_manager.utils.paths import module_dir as _module_dir
+
+def install():
+ module_dir = _module_dir("singbox")
+
+ # Clean templates
+ configs_dir = os.path.join(module_dir, "configs")
+ if os.path.exists(configs_dir):
+ for template in glob.glob(os.path.join(configs_dir, "*.template")):
+ os.remove(template)
+
+ archive_path = os.path.join(module_dir, "sb.tar.gz")
+
+ if download_package("singbox", archive_path):
+ if extract_package(archive_path, module_dir):
+ os.remove(archive_path)
+
+ extracted_dirs = glob.glob(os.path.join(module_dir, "hiddify-core-*"))
+ if extracted_dirs:
+ src_dir = extracted_dirs[0]
+ for item in os.listdir(src_dir):
+ src_item = os.path.join(src_dir, item)
+ dst_item = os.path.join(module_dir, item)
+ if os.path.exists(dst_item):
+ if os.path.isdir(dst_item):
+ shutil.rmtree(dst_item)
+ else:
+ os.remove(dst_item)
+ shutil.move(src_item, dst_item)
+ shutil.rmtree(src_dir)
+
+ sb_bin = os.path.join(module_dir, "hiddify-core")
+ if os.path.exists(sb_bin):
+ run_cmd(["chown", "root:root", sb_bin])
+ run_cmd(["chmod", "+x", sb_bin])
+
+ run_cmd(["ln", "-sf", sb_bin, "/usr/bin/hiddify-core"])
+
+ geosite_db = os.path.join(module_dir, "geosite.db")
+ if os.path.exists(geosite_db):
+ os.remove(geosite_db)
+
+ log.info("Singbox (hiddify-core) installed successfully.")
+ else:
+ log.error("hiddify-core binary not found after extraction.")
+ else:
+ log.error("Failed to extract Singbox.")
+ else:
+ log.error("Failed to download Singbox.")
+
+ # Wire + start the systemd unit. Without this the service stays in
+ # whatever state it was in before (often inactive), even after a fresh
+ # binary install — same gap I fixed for nginx + haproxy.
+ svc = os.path.join(module_dir, "hiddify-singbox.service")
+ if os.path.exists(svc):
+ run_cmd(["ln", "-sf", svc, "/etc/systemd/system/hiddify-singbox.service"])
+ run_cmd(["systemctl", "enable", "hiddify-singbox.service"], check=False)
+ run_cmd(["systemctl", "restart", "hiddify-singbox.service"], check=False)
diff --git a/hiddify_manager/modules/speedtest.py b/hiddify_manager/modules/speedtest.py
new file mode 100644
index 000000000..5138205b7
--- /dev/null
+++ b/hiddify_manager/modules/speedtest.py
@@ -0,0 +1,34 @@
+import os
+
+from hiddify_manager.utils.config import hiddify_config
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import module_dir as _module_dir
+
+
+CHUNK = 1024 * 1024
+SIZE_MB = 30
+
+
+def install():
+ """
+ Create the 30 MB random blob served as the speedtest upload/download
+ target. Gated on hconfigs['speed_test'] (mirrors legacy
+ install_run other/speedtest $(hconfig "speed_test")) so we don't burn
+ 30 MB on a disabled feature.
+ """
+ configs = hiddify_config() or {}
+ hconfigs = configs.get("hconfigs") or {}
+ if not hconfigs.get("speed_test"):
+ log.info("speedtest: speed_test is false — skipping blob generation")
+ return
+
+ module_dir = _module_dir("other/speedtest")
+ os.makedirs(module_dir, exist_ok=True)
+ target = os.path.join(module_dir, "downloading")
+ if os.path.exists(target) and os.path.getsize(target) >= SIZE_MB * CHUNK:
+ log.info("speedtest blob already present, skipping")
+ return
+ log.info(f"generating {SIZE_MB}MB speedtest blob at {target}")
+ with open(target, "wb") as f:
+ for _ in range(SIZE_MB):
+ f.write(os.urandom(CHUNK))
diff --git a/hiddify_manager/modules/ssfaketls.py b/hiddify_manager/modules/ssfaketls.py
new file mode 100644
index 000000000..15bf34599
--- /dev/null
+++ b/hiddify_manager/modules/ssfaketls.py
@@ -0,0 +1,51 @@
+import glob
+import os
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import module_dir as _module_dir
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.config import hiddify_config
+from hiddify_manager.utils.template import render_template
+
+
+UNIT = "hiddify-ss-faketls.service"
+
+
+def _disable():
+ run_cmd(["systemctl", "stop", UNIT], check=False)
+ run_cmd(["systemctl", "disable", UNIT], check=False)
+
+
+def install():
+ configs = hiddify_config() or {}
+ hconfigs = configs.get("hconfigs") or {}
+ if not hconfigs.get("ssfaketls_enable"):
+ log.info("ssfaketls: ssfaketls_enable is false — stopping unit and skipping install")
+ _disable()
+ return
+
+ module_dir = _module_dir("other/ssfaketls")
+ run_cmd(["apt-get", "install", "-y", "shadowsocks-libev", "simple-obfs"])
+
+ for svc in glob.glob(os.path.join(module_dir, "*.service*")):
+ os.chmod(svc, 0o600)
+
+ tpl = os.path.join(module_dir, "hiddify-ss-faketls.service.j2")
+ if os.path.exists(tpl):
+ if not render_template(tpl, configs):
+ return
+
+ svc_path = os.path.join(module_dir, "hiddify-ss-faketls.service")
+ if os.path.exists(svc_path):
+ run_cmd(["ln", "-sf", svc_path, "/etc/systemd/system/hiddify-ss-faketls.service"])
+
+ # Migrate away from legacy ss-faketls.service that used to ship here.
+ run_cmd(["systemctl", "disable", "--now", "ss-faketls.service"], check=False)
+ for stale in glob.glob(os.path.join(module_dir, "ss-faketls.service*")):
+ try:
+ os.remove(stale)
+ except OSError:
+ pass
+
+ run_cmd(["systemctl", "enable", "hiddify-ss-faketls.service"], check=False)
+ run_cmd(["systemctl", "restart", "hiddify-ss-faketls.service"], check=False)
diff --git a/hiddify_manager/modules/ssh.py b/hiddify_manager/modules/ssh.py
new file mode 100644
index 000000000..665303a5d
--- /dev/null
+++ b/hiddify_manager/modules/ssh.py
@@ -0,0 +1,88 @@
+import os
+
+from hiddify_manager.utils.config import hiddify_config
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import module_dir as _module_dir
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.package_manager import download_package
+
+
+UNIT = "hiddify-ssh-liberty-bridge.service"
+
+
+def _disable():
+ run_cmd(["systemctl", "stop", UNIT], check=False)
+ run_cmd(["systemctl", "disable", UNIT], check=False)
+
+
+def _redis_password():
+ redis_conf = os.path.join(_module_dir("other/redis"), "redis.conf")
+ if not os.path.exists(redis_conf):
+ return None
+ with open(redis_conf) as f:
+ for line in f:
+ if line.strip().startswith("requirepass"):
+ parts = line.split()
+ if len(parts) >= 2:
+ return parts[1]
+ return None
+
+
+def install():
+ """
+ ssh-liberty-bridge is the panel's SSH-as-proxy backend. The legacy
+ install.sh ran `install_run other/ssh 0` — hardcoded false — so this
+ was never installed by default. We gate on hconfigs['ssh_server_enable']
+ so an operator who DOES want it gets it, and the default install
+ (matching legacy behaviour) stops the unit.
+
+ Without this gate ssh-liberty-bridge would race singbox's `inbound/ssh`
+ for the same TCP port and one or the other crashloops.
+ """
+ configs = hiddify_config() or {}
+ hconfigs = configs.get("hconfigs") or {}
+ if not hconfigs.get("ssh_server_enable"):
+ log.info("ssh: ssh_server_enable is false — stopping unit and skipping install")
+ _disable()
+ return
+
+ module_dir = _module_dir("other/ssh")
+ os.makedirs(os.path.join(module_dir, "host_key"), exist_ok=True)
+
+ bin_path = os.path.join(module_dir, "ssh-liberty-bridge")
+ if download_package("ssh-liberty-bridge", bin_path):
+ os.chmod(bin_path, 0o755)
+ run_cmd(["useradd", "liberty-bridge"], check=False)
+
+ for env in ("env", "env.local"):
+ p = os.path.join(module_dir, f".{env}")
+ if os.path.exists(p):
+ run_cmd(["chown", "liberty-bridge", p], check=False)
+
+ svc = os.path.join(module_dir, "hiddify-ssh-liberty-bridge.service")
+ if os.path.exists(svc):
+ run_cmd(["ln", "-sf", svc, "/etc/systemd/system/hiddify-ssh-liberty-bridge.service"])
+
+ run_cmd(["chown", "-R", "liberty-bridge", os.path.join(module_dir, "host_key")], check=False)
+
+ env_file = os.path.join(module_dir, ".env")
+ lines = []
+ if os.path.exists(env_file):
+ with open(env_file) as f:
+ lines = [ln for ln in f if not ln.startswith("REDIS_URL")]
+
+ redis_uri = os.environ.get("REDIS_URI_SSH")
+ if not redis_uri:
+ pw = _redis_password()
+ if pw:
+ redis_uri = f"redis://:{pw}@127.0.0.1:6379/1"
+
+ if redis_uri:
+ lines.append(f"REDIS_URL='{redis_uri}'\n")
+
+ with open(env_file, "w") as f:
+ f.writelines(lines)
+ os.chmod(env_file, 0o600)
+
+ run_cmd(["systemctl", "enable", "hiddify-ssh-liberty-bridge"], check=False)
+ run_cmd(["systemctl", "restart", "hiddify-ssh-liberty-bridge"], check=False)
diff --git a/hiddify_manager/modules/telegram.py b/hiddify_manager/modules/telegram.py
new file mode 100644
index 000000000..cc414e6ed
--- /dev/null
+++ b/hiddify_manager/modules/telegram.py
@@ -0,0 +1,157 @@
+"""
+Telegram MTProto proxy.
+
+Two backends, selected by hconfigs['telegram_lib']:
+
+ - 'python' : github.com/hiddify/mtprotoproxy (pure-python, asyncio-based)
+ - 'tgo' : github.com/9seconds/mtg (Go binary distributed as a tarball)
+
+Each gets its config rendered from a .j2 template, then we link the
+shared mtproxy.service systemd unit and restart it.
+"""
+import glob
+import os
+import shutil
+import tarfile
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import module_dir as _module_dir
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.config import hiddify_config
+from hiddify_manager.utils.template import render_template
+from hiddify_manager.utils.package_manager import download_package
+
+
+def _disable_legacy():
+ """Stop any pre-existing mtproxy/mtproto-proxy units before reinstalling."""
+ for unit in ("mtproxy", "mtproto-proxy"):
+ run_cmd(["systemctl", "stop", unit], check=False)
+ run_cmd(["systemctl", "disable", unit], check=False)
+
+
+def _wire_service(lib_dir, secret_glob):
+ """
+ Common tail for both backends: link the shared mtproxy.service unit,
+ chmod the rendered config to 0600 (no world-readable secrets), enable
+ and restart the unit.
+
+ secret_glob picks which files in lib_dir to chmod (.py for python,
+ .toml for tgo). Includes the .j2 source too — harmless, and matches
+ the legacy `chmod 600 *.py*` / `chmod 600 *toml*` patterns.
+ """
+ svc = os.path.join(lib_dir, "mtproxy.service")
+ if os.path.exists(svc):
+ run_cmd(["ln", "-sf", svc, "/etc/systemd/system/mtproxy.service"], check=False)
+ run_cmd(["systemctl", "enable", "mtproxy.service"], check=False)
+
+ for path in glob.glob(os.path.join(lib_dir, secret_glob)):
+ try:
+ os.chmod(path, 0o600)
+ except OSError as e:
+ log.warning(f"telegram: chmod 600 failed for {path}: {e}")
+
+ run_cmd(["systemctl", "restart", "mtproxy.service"], check=False)
+
+
+def _install_python_backend(lib_dir, configs):
+ """Replaces other/telegram/python/{install.sh,run.sh}."""
+ run_cmd(
+ ["apt-get", "install", "-y",
+ "python3", "python3-uvloop", "python3-cryptography",
+ "python3-socks", "libcap2-bin"],
+ check=False,
+ )
+ run_cmd(
+ ["useradd", "--no-create-home", "-s", "/usr/sbin/nologin", "tgproxy"],
+ check=False,
+ )
+
+ clone_dir = os.path.join(lib_dir, "mtprotoproxy")
+ if not os.path.isdir(clone_dir):
+ run_cmd(
+ ["git", "clone", "https://github.com/hiddify/mtprotoproxy", clone_dir],
+ check=False,
+ )
+
+ # Render config.py.j2 -> config.py, then mirror it into the clone.
+ tpl = os.path.join(lib_dir, "config.py.j2")
+ if os.path.exists(tpl):
+ render_template(tpl, configs)
+ rendered = os.path.join(lib_dir, "config.py")
+ if os.path.exists(rendered) and os.path.isdir(clone_dir):
+ shutil.copy(rendered, os.path.join(clone_dir, "config.py"))
+
+ _wire_service(lib_dir, "*.py*")
+
+
+def _install_tgo_backend(lib_dir, configs):
+ """Replaces other/telegram/tgo/{install.sh,run.sh}."""
+ tarball = os.path.join(lib_dir, "mtg-linux.tar.gz")
+ if not download_package("mtproxygo", tarball):
+ log.error("telegram: failed to download mtproxygo")
+ return
+
+ try:
+ with tarfile.open(tarball, "r:gz") as t:
+ t.extractall(lib_dir)
+ except (tarfile.TarError, OSError) as e:
+ log.error(f"telegram: extracting mtg tarball failed: {e}")
+ return
+ finally:
+ try:
+ os.remove(tarball)
+ except OSError:
+ pass
+
+ # The tarball contains a single mtg-* directory holding the binary;
+ # promote it to lib_dir/mtg and remove the subdir.
+ for entry in os.listdir(lib_dir):
+ sub = os.path.join(lib_dir, entry)
+ if entry.startswith("mtg-") and os.path.isdir(sub):
+ bin_src = os.path.join(sub, "mtg")
+ bin_dst = os.path.join(lib_dir, "mtg")
+ if os.path.exists(bin_src):
+ os.replace(bin_src, bin_dst)
+ os.chmod(bin_dst, 0o755)
+ shutil.rmtree(sub, ignore_errors=True)
+ break
+
+ tpl = os.path.join(lib_dir, "mtg.toml.j2")
+ if os.path.exists(tpl):
+ render_template(tpl, configs)
+
+ _wire_service(lib_dir, "*toml*")
+
+
+_BACKENDS = {
+ "python": _install_python_backend,
+ "tgo": _install_tgo_backend,
+}
+
+
+def install():
+ _disable_legacy()
+
+ configs = hiddify_config()
+ if not configs:
+ log.warning("telegram: no panel configs available — skipping")
+ return
+
+ hconfigs = configs.get("hconfigs") or {}
+ telegram_lib = hconfigs.get("telegram_lib")
+ if not telegram_lib:
+ log.info("telegram: telegram_lib not set in hconfigs — nothing to do")
+ return
+
+ handler = _BACKENDS.get(telegram_lib)
+ if handler is None:
+ log.warning(f"telegram: unknown backend {telegram_lib!r}")
+ return
+
+ lib_dir = os.path.join(_module_dir("other/telegram"), telegram_lib)
+ if not os.path.isdir(lib_dir):
+ log.warning(f"telegram: lib dir {lib_dir} does not exist")
+ return
+
+ log.info(f"telegram: installing {telegram_lib} backend")
+ handler(lib_dir, configs)
diff --git a/hiddify_manager/modules/update_usage.py b/hiddify_manager/modules/update_usage.py
new file mode 100644
index 000000000..f57413b89
--- /dev/null
+++ b/hiddify_manager/modules/update_usage.py
@@ -0,0 +1,136 @@
+"""
+Trigger a usage refresh on the panel.
+
+Replaces hiddify-panel/update_usage.sh: try the local HTTP API first
+(`/api/v2/admin/update_user_usage/`), fall back to the in-process
+`hiddifypanel update-usage` CLI if the API returns non-200 AND no
+update-usage process is already running.
+
+Invoked from the panel through common/commander.py (the
+update-wg-usage cron job calls it every minute).
+"""
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.request
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import CURRENT_JSON, PROJECT_ROOT, VENV_DIR
+from hiddify_manager.utils.shell import run_cmd
+
+
+LOCK_DIR = os.path.join(PROJECT_ROOT, "log")
+LOCK_TTL = 120 # seconds — matches the legacy set_lock check
+
+
+class LockBusy(RuntimeError):
+ pass
+
+
+def _set_lock(name):
+ """
+ Raise LockBusy if a lock < LOCK_TTL old exists; otherwise stamp it.
+ Mirrors common/utils.sh::set_lock.
+ """
+ os.makedirs(LOCK_DIR, exist_ok=True)
+ path = os.path.join(LOCK_DIR, f"{name}.lock")
+ if os.path.exists(path):
+ try:
+ stamp = int(open(path).read().strip() or 0)
+ except (ValueError, OSError):
+ stamp = 0
+ if time.time() - stamp < LOCK_TTL:
+ raise LockBusy(f"{name} lock held (<{LOCK_TTL}s old)")
+ with open(path, "w") as f:
+ f.write(str(int(time.time())))
+
+
+def _remove_lock(name):
+ path = os.path.join(LOCK_DIR, f"{name}.lock")
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+
+
+def _panel_http_api(endpoint):
+ """
+ Hit the local panel via api_path + api_key (both pulled from
+ current.json). Returns (http_status_int, body_bytes); raises on the
+ "config not found" / "fields missing" cases that the bash helper
+ would have echo'd "invalid config file" for.
+ """
+ if not os.path.exists(CURRENT_JSON):
+ raise FileNotFoundError(f"{CURRENT_JSON} not present")
+ with open(CURRENT_JSON) as f:
+ cfg = json.load(f)
+ api_path = cfg.get("api_path") or ""
+ api_key = cfg.get("api_key") or ""
+ if not api_path or not api_key:
+ raise ValueError("api_path / api_key missing from current.json")
+
+ url = f"http://localhost:9000/{api_path}/api/v2/{endpoint}"
+ req = urllib.request.Request(url, headers={"Hiddify-API-Key": api_key})
+ try:
+ with urllib.request.urlopen(req, timeout=30) as r:
+ return r.status, r.read()
+ except urllib.error.HTTPError as e:
+ return e.code, e.read() if hasattr(e, "read") else b""
+ except urllib.error.URLError as e:
+ log.warning(f"update_usage: panel http_api error: {e.reason}")
+ return 0, b""
+
+
+def _is_panel_update_usage_running():
+ """
+ Equivalent to `pgrep -f 'hiddifypanel update-usage'`. Returns True
+ if a python process running that exists, so we skip the CLI fallback
+ instead of stomping on it.
+ """
+ res = run_cmd(
+ ["pgrep", "-f", "hiddifypanel update-usage"],
+ check=False, capture_output=True,
+ )
+ return res.returncode == 0
+
+
+def _cli_fallback():
+ venv_python = os.path.join(VENV_DIR, "bin", "python3")
+ run_cmd([venv_python, "-m", "hiddifypanel", "update-usage"], check=False)
+
+
+def run():
+ """Top-level: try HTTP, fall back to CLI when needed. Caller holds the lock."""
+ try:
+ status, body = _panel_http_api("admin/update_user_usage/")
+ except Exception as e:
+ log.error(f"update_usage: panel http_api unavailable: {e}")
+ status = 0
+ body = b""
+ if status == 200:
+ log.info(f"update_usage: http_api OK ({len(body)} bytes)")
+ return 0
+ log.info(f"update_usage: http_api returned status={status}; falling back to CLI")
+ if _is_panel_update_usage_running():
+ log.info("update_usage: CLI already running — skipping fallback")
+ return 0
+ _cli_fallback()
+ return 0
+
+
+def main():
+ try:
+ _set_lock("update_usage")
+ except LockBusy as e:
+ log.info(f"update_usage: {e}")
+ return 0
+ try:
+ return run()
+ finally:
+ _remove_lock("update_usage")
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/hiddify_manager/modules/warp.py b/hiddify_manager/modules/warp.py
new file mode 100644
index 000000000..c32a9e7f9
--- /dev/null
+++ b/hiddify_manager/modules/warp.py
@@ -0,0 +1,202 @@
+"""
+Cloudflare WARP via wgcf + wg-quick@warp.
+
+Replaces other/warp/install.sh + the warp wireguard run.sh.j2 chain:
+download the wgcf binary, register/update a WARP account, generate
+the wireguard profile (stripping IPv6 if the host doesn't speak it),
+symlink it to /etc/wireguard/warp.conf, and bring up wg-quick@warp.
+Verified by probing http://ip-api.com via the warp interface.
+"""
+import os
+import socket
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import module_dir as _module_dir
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.config import hiddify_config
+from hiddify_manager.utils.package_manager import download_package
+
+
+PROFILE = "wgcf-profile.conf"
+ACCOUNT = "wgcf-account.toml"
+SYS_WARP_CONF = "/etc/wireguard/warp.conf"
+
+
+def _ipv6_usable():
+ """Mirror the legacy probe: false if /proc disables v6 or v6 egress fails."""
+ try:
+ with open("/proc/sys/net/ipv6/conf/all/disable_ipv6") as f:
+ if f.read().strip() == "1":
+ return False
+ except OSError:
+ return False
+ res = run_cmd(
+ ["curl", "--connect-timeout", "1", "-s", "https://v6.ident.me/"],
+ check=False, capture_output=True,
+ )
+ return res.returncode == 0
+
+
+def _strip_v6_from_csv(prefix, line, ipv6_ok):
+ """
+ wgcf emits Address/DNS lines like `Address = 172.16.0.2/32, 2606:...`.
+ If v6 is unusable, drop just the v6 entries (anything containing ':')
+ instead of commenting the whole line — that would strand the interface
+ with no v4 address and break routing.
+
+ If everything in the list is v6, comment the line so wg-quick doesn't
+ error on an empty value.
+ """
+ if ipv6_ok or not line.startswith(prefix):
+ return line
+ rest = line[len(prefix):].rstrip("\n")
+ entries = [e.strip() for e in rest.split(",") if e.strip()]
+ kept = [e for e in entries if ":" not in e]
+ if not kept:
+ return "# " + line
+ return f"{prefix}{', '.join(kept)}\n"
+
+
+def _patch_profile(wg_dir, ipv6_ok):
+ """Equivalent to the three sed -i invocations in the legacy run.sh.j2."""
+ path = os.path.join(wg_dir, PROFILE)
+ if not os.path.exists(path):
+ log.error(f"warp: {PROFILE} not produced by wgcf generate")
+ return False
+
+ with open(path) as f:
+ lines = f.readlines()
+
+ out = []
+ for ln in lines:
+ # [Peer] -> Table = off\n[Peer]
+ if ln.strip() == "[Peer]":
+ out.append("Table = off\n")
+ ln = _strip_v6_from_csv("Address = ", ln, ipv6_ok)
+ ln = _strip_v6_from_csv("DNS = ", ln, ipv6_ok)
+ # Even with v6 working, we don't want to push Cloudflare's
+ # resolver onto every client — comment the DNS line entirely.
+ if ln.lstrip().startswith("DNS = ") and "1.1.1.1" in ln:
+ ln = "# " + ln
+ out.append(ln)
+
+ with open(path, "w") as f:
+ f.writelines(out)
+ return True
+
+
+def _real_test():
+ res = run_cmd(
+ ["curl", "-s", "--interface", "warp", "--connect-timeout", "1",
+ "http://ip-api.com?fields=message,country,org,query"],
+ check=False, capture_output=True,
+ )
+ if res.returncode == 0:
+ log.info(f"WARP probe OK: {res.stdout.strip()[:200]}")
+ else:
+ log.warning("WARP probe failed")
+ return res.returncode == 0
+
+
+def _wgcf(wg_dir, *args, env=None):
+ return run_cmd(
+ [os.path.join(wg_dir, "wgcf"), *args],
+ cwd=wg_dir, check=False, env=env, capture_output=True,
+ )
+
+
+def _bring_up(wg_dir, env):
+ """One pass at registering, generating, and starting wg-quick@warp."""
+ account = os.path.join(wg_dir, ACCOUNT)
+ if not os.path.exists(account):
+ log.info("warp: registering new wgcf account")
+ rc = _wgcf(
+ wg_dir, "register", "--accept-tos", "-m", "hiddify",
+ "-n", socket.gethostname(), env=env,
+ )
+ if rc.returncode != 0:
+ return False
+
+ rc = _wgcf(wg_dir, "update", env=env)
+ if rc.returncode != 0:
+ log.warning(f"wgcf update failed (rc={rc.returncode})")
+ return False
+
+ rc = _wgcf(wg_dir, "generate", env=env)
+ if rc.returncode != 0:
+ log.warning(f"wgcf generate failed (rc={rc.returncode})")
+ return False
+
+ ipv6_ok = _ipv6_usable()
+ if not ipv6_ok:
+ log.info("warp: IPv6 unusable, will comment out v6 lines in profile")
+ if not _patch_profile(wg_dir, ipv6_ok):
+ return False
+
+ os.makedirs("/etc/wireguard", exist_ok=True)
+ run_cmd(["ln", "-sf", os.path.join(wg_dir, PROFILE), SYS_WARP_CONF], check=False)
+ run_cmd(["systemctl", "enable", "wg-quick@warp"], check=False)
+ run_cmd(["systemctl", "restart", "wg-quick@warp"], check=False)
+
+ # Give wg-quick a moment to actually install routes before probing —
+ # matches the legacy `sleep .5 ; test ; sleep .5 ; test` cadence.
+ import time
+ time.sleep(0.5)
+ if _real_test():
+ return True
+ time.sleep(0.5)
+ return _real_test()
+
+
+def _disable_warp():
+ """Tear down wg-quick@warp + the dormant hiddify-warp.service unit."""
+ for unit in ("wg-quick@warp", "hiddify-warp.service"):
+ run_cmd(["systemctl", "stop", unit], check=False)
+ run_cmd(["systemctl", "disable", unit], check=False)
+
+
+def install():
+ configs = hiddify_config() or {}
+ hconfigs = configs.get("hconfigs") or {}
+ # Legacy: install warp unless hconfigs['warp_mode'] == 'disable'.
+ # An absent warp_mode key was treated as "not disabled" → install.
+ warp_mode = (hconfigs.get("warp_mode") or "").lower()
+ if warp_mode == "disable":
+ log.info("warp: warp_mode is 'disable' — stopping wg-quick@warp and skipping install")
+ _disable_warp()
+ return
+ license_key = hconfigs.get("warp_plus_code") or ""
+
+ base = _module_dir("other/warp")
+ wg_dir = os.path.join(base, "wireguard")
+ os.makedirs(wg_dir, exist_ok=True)
+
+ run_cmd(["apt-get", "install", "-y", "wireguard-tools"], check=False)
+
+ wgcf_path = os.path.join(wg_dir, "wgcf")
+ if download_package("wgcf", wgcf_path):
+ os.chmod(wgcf_path, 0o755)
+ if not os.path.exists(wgcf_path):
+ log.error("warp: wgcf binary missing — aborting")
+ return
+
+ # The legacy install.sh disabled the dormant hiddify-warp.service unit.
+ run_cmd(["systemctl", "disable", "hiddify-warp.service"], check=False)
+
+ account = os.path.join(wg_dir, ACCOUNT)
+
+ # Legacy retry pattern: try with the license key, then back off the
+ # account file twice (re-register fresh), finally retry with no key.
+ attempts = [license_key, license_key, ""]
+ for attempt, key in enumerate(attempts):
+ env = dict(os.environ, WGCF_LICENSE_KEY=key)
+ if _bring_up(wg_dir, env):
+ log.info(f"warp: connected (attempt {attempt + 1})")
+ return
+ if os.path.exists(account):
+ try:
+ os.replace(account, account + ".backup")
+ except OSError as e:
+ log.warning(f"could not back off {ACCOUNT}: {e}")
+
+ log.error("WARP failed to come up after 3 attempts")
diff --git a/hiddify_manager/modules/wireguard.py b/hiddify_manager/modules/wireguard.py
new file mode 100644
index 000000000..ccdcb918f
--- /dev/null
+++ b/hiddify_manager/modules/wireguard.py
@@ -0,0 +1,170 @@
+"""
+WireGuard server for end-user clients (hiddifywg interface).
+
+Replaces other/wireguard/{install.sh.j2,run.sh.j2}: writes
+/etc/wireguard/hiddifywg.conf with the panel-derived [Interface]
+block + iptables/ip6tables PostUp/PostDown rules, then renders one
+[Peer] per panel user (address per-peer derived by adding the user
+id to the configured wg base address). Enables IP forwarding via
+sysctl. Restarts wg-quick@hiddifywg.
+
+Unlike the legacy split (install once + run-on-config-change), we
+rebuild the full config on every orchestrator run — simpler, and the
+wg-quick restart is fast enough for the install path.
+"""
+import ipaddress
+import os
+import re
+
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.config import hiddify_config
+
+
+SERVER_WG_NIC = "hiddifywg"
+SERVER_CONF = f"/etc/wireguard/{SERVER_WG_NIC}.conf"
+PARAMS_FILE = "/etc/wireguard/params"
+SYSCTL_FILE = "/etc/sysctl.d/wg.conf"
+REQUIRED_HCONFIGS = [
+ "wireguard_ipv4", "wireguard_ipv6",
+ "wireguard_port", "wireguard_private_key",
+]
+
+
+def _default_iface():
+ """Public-facing NIC name. Equivalent to `ip -4 route show default | awk '/dev/{...}'`."""
+ res = run_cmd(["ip", "-4", "route", "show", "default"], check=False, capture_output=True)
+ if res.returncode != 0 or not res.stdout:
+ return None
+ line = res.stdout.splitlines()[0] if res.stdout.splitlines() else ""
+ m = re.search(r"\bdev\s+(\S+)", line)
+ return m.group(1) if m else None
+
+
+def _add_int_to_ip(ip_str, n):
+ """
+ Add n to an IPv4 or IPv6 address. Proper carries, unlike the legacy
+ bash helpers which only carried v4 once (octets[2..3]) and never
+ carried v6 at all.
+ """
+ return str(ipaddress.ip_address(ip_str) + n)
+
+
+def _interface_block(hconfigs, pub_nic):
+ port = hconfigs["wireguard_port"]
+ ipv4 = hconfigs["wireguard_ipv4"]
+ ipv6 = hconfigs["wireguard_ipv6"]
+ priv = hconfigs["wireguard_private_key"]
+ return (
+ "[Interface]\n"
+ f"Address = {ipv4}/16,{ipv6}/90\n"
+ f"ListenPort = {port}\n"
+ f"PrivateKey = {priv}\n"
+ "\n"
+ f"PostUp = iptables -I INPUT -p udp --dport {port} -j ACCEPT\n"
+ f"PostUp = iptables -I FORWARD -i {pub_nic} -o {SERVER_WG_NIC} -j ACCEPT\n"
+ f"PostUp = iptables -I FORWARD -i {SERVER_WG_NIC} -j ACCEPT\n"
+ f"PostUp = iptables -t nat -A POSTROUTING -o {pub_nic} -j MASQUERADE\n"
+ f"PostUp = ip6tables -I FORWARD -i {SERVER_WG_NIC} -j ACCEPT\n"
+ f"PostUp = ip6tables -t nat -A POSTROUTING -o {pub_nic} -j MASQUERADE\n"
+ f"PostDown = iptables -D INPUT -p udp --dport {port} -j ACCEPT\n"
+ f"PostDown = iptables -D FORWARD -i {pub_nic} -o {SERVER_WG_NIC} -j ACCEPT\n"
+ f"PostDown = iptables -D FORWARD -i {SERVER_WG_NIC} -j ACCEPT\n"
+ f"PostDown = iptables -t nat -D POSTROUTING -o {pub_nic} -j MASQUERADE\n"
+ f"PostDown = ip6tables -D FORWARD -i {SERVER_WG_NIC} -j ACCEPT\n"
+ f"PostDown = ip6tables -t nat -D POSTROUTING -o {pub_nic} -j MASQUERADE\n"
+ )
+
+
+def _peer_blocks(users, hconfigs):
+ base_v4 = hconfigs["wireguard_ipv4"]
+ base_v6 = hconfigs["wireguard_ipv6"]
+ out = []
+ for u in users or []:
+ uid = u.get("id")
+ pub = u.get("wg_pub")
+ psk = u.get("wg_psk")
+ if uid is None or not pub:
+ continue
+ try:
+ v4 = _add_int_to_ip(base_v4, uid)
+ v6 = _add_int_to_ip(base_v6, uid)
+ except ValueError as e:
+ log.warning(f"wireguard: bad address math for user {uid}: {e}")
+ continue
+ block = "\n[Peer]\n"
+ block += f"PublicKey = {pub}\n"
+ if psk:
+ block += f"PresharedKey = {psk}\n"
+ block += f"AllowedIPs = {v4}/32,{v6}/128\n"
+ out.append(block)
+ return "".join(out)
+
+
+def _params_content(hconfigs, pub_nic):
+ """The same env-style file the legacy install.sh wrote to /etc/wireguard/params."""
+ port = hconfigs["wireguard_port"]
+ ipv4 = hconfigs["wireguard_ipv4"]
+ ipv6 = hconfigs["wireguard_ipv6"]
+ priv = hconfigs["wireguard_private_key"]
+ pub_key = hconfigs.get("wireguard_public_key", "")
+ dns = hconfigs.get("dns_server", "1.1.1.1")
+ return (
+ f"SERVER_PUB_NIC={pub_nic}\n\n"
+ f"SERVER_WG_IPV4={ipv4}\n"
+ f"SERVER_WG_IPV6={ipv6}\n"
+ f"SERVER_PORT={port}\n"
+ f"SERVER_PRIV_KEY={priv}\n"
+ f"#SERVER_PUB_KEY={pub_key}\n"
+ f"CLIENT_DNS_1={dns}\n"
+ f"CLIENT_DNS_2=1.1.1.1\n"
+ f"ALLOWED_IPS=0.0.0.0,::/0\n"
+ )
+
+
+def _write(path, content, mode):
+ """Atomic file write with the requested mode."""
+ tmp = path + ".tmp"
+ with open(tmp, "w") as f:
+ f.write(content)
+ os.chmod(tmp, mode)
+ os.replace(tmp, path)
+
+
+def install():
+ configs = hiddify_config()
+ if not configs:
+ log.error("wireguard: no panel configs available — aborting")
+ return
+ hconfigs = configs.get("hconfigs") or {}
+
+ missing = [k for k in REQUIRED_HCONFIGS if not hconfigs.get(k)]
+ if missing:
+ log.warning(f"wireguard: missing required hconfigs {missing} — skipping")
+ return
+
+ run_cmd(["apt-get", "install", "-y", "wireguard"], check=False)
+ os.makedirs("/etc/wireguard", exist_ok=True)
+
+ pub_nic = _default_iface()
+ if not pub_nic:
+ log.error("wireguard: could not detect public default interface")
+ return
+
+ # Bring the interface down before rewriting its config so wg-quick's
+ # PostDown rules run with the values it brought up with.
+ run_cmd(["systemctl", "stop", f"wg-quick@{SERVER_WG_NIC}"], check=False)
+
+ _write(PARAMS_FILE, _params_content(hconfigs, pub_nic), 0o660)
+
+ body = _interface_block(hconfigs, pub_nic) + _peer_blocks(configs.get("users"), hconfigs)
+ _write(SERVER_CONF, body, 0o660)
+
+ _write(SYSCTL_FILE,
+ "net.ipv4.ip_forward = 1\nnet.ipv6.conf.all.forwarding = 1\n",
+ 0o644)
+ if os.environ.get("MODE") != "docker":
+ run_cmd(["sysctl", "--system"], check=False, capture_output=True)
+
+ run_cmd(["systemctl", "enable", f"wg-quick@{SERVER_WG_NIC}"], check=False)
+ run_cmd(["systemctl", "restart", f"wg-quick@{SERVER_WG_NIC}"], check=False)
diff --git a/hiddify_manager/modules/xray.py b/hiddify_manager/modules/xray.py
new file mode 100644
index 000000000..27cfeec1d
--- /dev/null
+++ b/hiddify_manager/modules/xray.py
@@ -0,0 +1,54 @@
+import os
+import shutil
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.package_manager import download_package, extract_package
+from hiddify_manager.utils.paths import module_dir as _module_dir
+
+def install():
+ module_dir = _module_dir("xray")
+ bin_dir = os.path.join(module_dir, "bin")
+ run_dir = os.path.join(module_dir, "run")
+
+ os.makedirs(bin_dir, exist_ok=True)
+ os.makedirs(run_dir, exist_ok=True)
+
+ archive_path = os.path.join(module_dir, "sb.zip")
+
+ run_cmd(["systemctl", "stop", "hiddify-xray.service"], check=False)
+
+ # Remove old bins
+ for item in os.listdir(bin_dir):
+ item_path = os.path.join(bin_dir, item)
+ if os.path.isfile(item_path):
+ os.remove(item_path)
+ elif os.path.isdir(item_path):
+ shutil.rmtree(item_path)
+
+ if download_package("xray", archive_path):
+ if extract_package(archive_path, bin_dir):
+ os.remove(archive_path)
+
+ xray_bin = os.path.join(bin_dir, "xray")
+ if os.path.exists(xray_bin):
+ run_cmd(["chown", "root:root", xray_bin])
+ run_cmd(["chmod", "+x", xray_bin])
+
+ # Symlink
+ run_cmd(["ln", "-sf", xray_bin, "/usr/bin/xray"])
+ log.info("Xray installed successfully.")
+ else:
+ log.error("Xray binary not found after extraction.")
+ else:
+ log.error("Failed to extract Xray.")
+ else:
+ log.error("Failed to download Xray.")
+
+ # We `systemctl stop` at the top to swap the binary cleanly; restart
+ # after install so the service comes back online. Same gap I fixed
+ # for nginx + haproxy + singbox.
+ svc = os.path.join(module_dir, "hiddify-xray.service")
+ if os.path.exists(svc):
+ run_cmd(["ln", "-sf", svc, "/etc/systemd/system/hiddify-xray.service"])
+ run_cmd(["systemctl", "enable", "hiddify-xray.service"], check=False)
+ run_cmd(["systemctl", "restart", "hiddify-xray.service"], check=False)
diff --git a/hiddify_manager/uninstall.py b/hiddify_manager/uninstall.py
new file mode 100644
index 000000000..a842165a0
--- /dev/null
+++ b/hiddify_manager/uninstall.py
@@ -0,0 +1,53 @@
+"""
+Uninstall hiddify-manager-managed units and crons.
+
+Replaces uninstall.sh. `purge=True` is the legacy `uninstall.sh purge`
+flag and additionally drops the panel package + a handful of apt
+packages we know are pulled in by the install path.
+"""
+import glob
+import os
+
+from hiddify_manager.modules.services import discover_units
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import PROJECT_ROOT
+from hiddify_manager.utils.shell import run_cmd
+
+
+# Legacy uninstall.sh purge step apt-removed these packages explicitly.
+PURGE_APT_PACKAGES = ["nginx", "gunicorn", "mariadb-*"]
+
+
+def run(purge=False):
+ """
+ Kill + disable every hiddify-managed unit, then remove cron entries.
+ If purge=True, also apt-purge a known set of packages.
+ """
+ units = discover_units()
+ # Add the legacy "netdata" name even if we never installed it; the bash
+ # uninstall iterated over both modern and historic unit names.
+ if "netdata" not in units:
+ units.append("netdata")
+
+ for unit in units:
+ run_cmd(["systemctl", "kill", unit], check=False)
+ run_cmd(["systemctl", "disable", unit], check=False)
+
+ for cron in glob.glob("/etc/cron.d/hiddify*"):
+ try:
+ os.remove(cron)
+ except OSError as e:
+ log.warning(f"uninstall: could not remove {cron}: {e}")
+ run_cmd(["service", "cron", "reload"], check=False)
+
+ if purge:
+ log.info("uninstall: purging panel + apt packages")
+ run_cmd(["apt-get", "purge", "-y", *PURGE_APT_PACKAGES], check=False)
+ # The legacy script did `rm -rf hiddify-panel` and `rm -rf *` from
+ # the project root, but the wholesale rm -rf * is an obvious foot-
+ # gun (it wipes the script that's running it). Only purge the
+ # panel subdir explicitly.
+ panel_dir = os.path.join(PROJECT_ROOT, "hiddify-panel")
+ if os.path.isdir(panel_dir):
+ run_cmd(["rm", "-rf", panel_dir], check=False)
+ log.info("uninstall: panel removed. The hiddify-manager checkout is left in place — delete it manually if you want a fresh start.")
diff --git a/hiddify_manager/utils/__init__.py b/hiddify_manager/utils/__init__.py
new file mode 100644
index 000000000..f39e5e8d6
--- /dev/null
+++ b/hiddify_manager/utils/__init__.py
@@ -0,0 +1 @@
+# Init
diff --git a/hiddify_manager/utils/certs.py b/hiddify_manager/utils/certs.py
new file mode 100644
index 000000000..5a5401b0f
--- /dev/null
+++ b/hiddify_manager/utils/certs.py
@@ -0,0 +1,128 @@
+"""
+Self-signed certificate generation via the `cryptography` library.
+
+Replaces acme.sh/generate_self_signed_cert.sh + the `get_self_signed_cert`
+bash function in acme.sh/cert_utils.sh: no openssl shell-outs, no acme.sh
+binary, just the standard pyca/cryptography API that's already a transitive
+dep of hiddifypanel.
+
+ensure_self_signed_cert(domain, ssl_dir) is the entry point used by
+manager._render_all_templates after the panel produces current.json,
+so haproxy/nginx have *something* to bind their TLS frontends to before
+real certs land via the ACME flow.
+"""
+import os
+from datetime import datetime, timedelta, timezone
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import ec, rsa
+from cryptography.x509.oid import NameOID
+
+from hiddify_manager.utils.logger import log
+
+
+# Match the legacy subject (so any tool inspecting an existing cert sees
+# the same DN). The Common Name swaps in the requested domain.
+_LEGACY_DN = [
+ (NameOID.COUNTRY_NAME, "GB"),
+ (NameOID.STATE_OR_PROVINCE_NAME, "London"),
+ (NameOID.LOCALITY_NAME, "London"),
+ (NameOID.ORGANIZATION_NAME, "Google Trust Services LLC"),
+]
+
+# 10-year validity. The cert exists to make haproxy/nginx start; it's
+# replaced by a real ACME cert when DNS for the domain resolves to us.
+DEFAULT_LIFETIME = timedelta(days=3650)
+MAX_DOMAIN_LEN = 64
+
+
+def _now():
+ """Indirection for tests."""
+ return datetime.now(timezone.utc)
+
+
+def _generate(domain, cert_path, key_path):
+ """Write a fresh RSA 2048 keypair + self-signed cert for `domain`."""
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ subject = issuer = x509.Name([
+ x509.NameAttribute(oid, val) for oid, val in _LEGACY_DN
+ ] + [x509.NameAttribute(NameOID.COMMON_NAME, domain)])
+ now = _now()
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(subject).issuer_name(issuer)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(now - timedelta(minutes=1)) # tolerate clock skew
+ .not_valid_after(now + DEFAULT_LIFETIME)
+ .sign(key, hashes.SHA256())
+ )
+
+ with open(cert_path, "wb") as f:
+ f.write(cert.public_bytes(serialization.Encoding.PEM))
+ with open(key_path, "wb") as f:
+ f.write(
+ key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+ )
+ os.chmod(key_path, 0o600)
+ os.chmod(cert_path, 0o644)
+ log.info(f"certs: wrote self-signed cert for {domain}")
+
+
+def _cert_expired_or_invalid(cert_path):
+ """True if there's no cert there or it's expired."""
+ if not os.path.exists(cert_path):
+ return True
+ try:
+ with open(cert_path, "rb") as f:
+ cert = x509.load_pem_x509_certificate(f.read())
+ except (ValueError, OSError) as e:
+ log.info(f"certs: {cert_path} unreadable ({e}); will regenerate")
+ return True
+ # Older cryptography versions use not_valid_after (naive UTC); newer use
+ # not_valid_after_utc (timezone-aware). Try the new attribute first.
+ expiry = getattr(cert, "not_valid_after_utc", None)
+ if expiry is None:
+ expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
+ return expiry < _now()
+
+
+def _key_invalid(key_path):
+ """True if the key file is missing, unparseable, or the wrong shape."""
+ if not os.path.exists(key_path):
+ return True
+ try:
+ with open(key_path, "rb") as f:
+ key = serialization.load_pem_private_key(f.read(), password=None)
+ except (ValueError, TypeError, OSError) as e:
+ log.info(f"certs: {key_path} unreadable ({e}); will regenerate")
+ return True
+ return not isinstance(key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey))
+
+
+def ensure_self_signed_cert(domain, ssl_dir):
+ """
+ Make sure ssl_dir/.crt + .crt.key exist, are well-formed, and
+ aren't expired. Generate them if any of those isn't true.
+
+ Returns (cert_path, key_path) on success, or (None, None) if the
+ domain is too long for a CN (matches legacy's silent skip path).
+ """
+ if len(domain) > MAX_DOMAIN_LEN:
+ log.info(f"certs: domain longer than {MAX_DOMAIN_LEN} chars, truncating CN")
+ domain_for_cn = domain[:MAX_DOMAIN_LEN]
+ else:
+ domain_for_cn = domain
+
+ os.makedirs(ssl_dir, exist_ok=True)
+ cert_path = os.path.join(ssl_dir, f"{domain}.crt")
+ key_path = os.path.join(ssl_dir, f"{domain}.crt.key")
+
+ if _cert_expired_or_invalid(cert_path) or _key_invalid(key_path):
+ _generate(domain_for_cn, cert_path, key_path)
+ return cert_path, key_path
diff --git a/hiddify_manager/utils/config.py b/hiddify_manager/utils/config.py
new file mode 100644
index 000000000..87d2584b9
--- /dev/null
+++ b/hiddify_manager/utils/config.py
@@ -0,0 +1,86 @@
+import os
+import json
+from hiddify_manager.utils.shell import run_cmd
+from hiddify_manager.utils.paths import CURRENT_JSON, VENV_DIR, PROJECT_ROOT
+from hiddify_manager.utils.logger import log
+
+
+PANEL_DIR = os.path.join(PROJECT_ROOT, "hiddify-panel")
+
+def generate_current_json():
+ """Generates current.json by calling hiddifypanel all-configs.
+
+ Writes to a tempfile first; only renames into place if the CLI exits 0
+ and the output is parseable JSON. Otherwise the existing current.json
+ (if any) is preserved instead of being replaced by an empty/garbled file.
+ """
+ venv_python = os.path.join(VENV_DIR, "bin", "python3")
+ tmp_path = CURRENT_JSON + ".tmp"
+
+ # cwd must be the panel dir so hiddifypanel picks up ./app.cfg
+ # (which holds SQLALCHEMY_DATABASE_URI / REDIS_URI_MAIN).
+ with open(tmp_path, "w") as out:
+ res = run_cmd(
+ [venv_python, "-m", "hiddifypanel", "all-configs"],
+ check=False,
+ stdout=out,
+ cwd=PANEL_DIR,
+ )
+ if res.returncode != 0:
+ log.error(f"hiddifypanel all-configs exited {res.returncode}")
+ try: os.unlink(tmp_path)
+ except OSError: pass
+ return False
+
+ # Validate JSON before replacing the live file.
+ try:
+ with open(tmp_path) as f:
+ json.load(f)
+ except (json.JSONDecodeError, OSError) as e:
+ log.error(f"hiddifypanel all-configs produced invalid JSON: {e}")
+ try: os.unlink(tmp_path)
+ except OSError: pass
+ return False
+
+ os.replace(tmp_path, CURRENT_JSON)
+ os.chmod(CURRENT_JSON, 0o600)
+ return True
+
+def load_configs():
+ if not os.path.exists(CURRENT_JSON):
+ if not generate_current_json():
+ return None
+
+ with open(CURRENT_JSON, "r") as f:
+ try:
+ return json.load(f)
+ except json.JSONDecodeError:
+ log.error("current.json is corrupted")
+ return None
+
+def hconfig(key):
+ """
+ Retrieves a configuration value from the panel configs.
+ Equivalent to the bash hconfig() function.
+ """
+ data = load_configs()
+ if not data:
+ return None
+
+ try:
+ # The bash script looks in .chconfigs["0"]
+ chconfigs = data.get("chconfigs", {})
+ config_0 = chconfigs.get("0", {})
+
+ if key in config_0:
+ return config_0[key]
+
+ log.warning(f"Config key not found: {key}")
+ return None
+ except Exception as e:
+ log.error(f"Error parsing hconfig: {e}")
+ return None
+
+def hiddify_config():
+ """Returns the full data dictionary for templating."""
+ return load_configs()
diff --git a/hiddify_manager/utils/firewall.py b/hiddify_manager/utils/firewall.py
new file mode 100644
index 000000000..240cc1837
--- /dev/null
+++ b/hiddify_manager/utils/firewall.py
@@ -0,0 +1,112 @@
+"""
+Idempotent iptables/ip6tables helpers used by the post-panel system
+configuration step.
+
+Ports common/utils.sh's `add2iptables`, `allow_port`, `remove_port`, and
+`save_firewall`. Every rule mutation goes through `add_rule` which checks
+`iptables -C` first, so re-running install.sh is safe.
+"""
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.shell import run_cmd
+
+IPTABLES = "iptables"
+IP6TABLES = "ip6tables"
+
+
+def add_rule(rule, *, both=True):
+ """
+ Idempotently insert an iptables rule. `rule` is the argv tail after
+ `iptables -I` / `iptables -C`, as a list — e.g.
+ ["INPUT", "-p", "tcp", "--dport", "443", "-j", "ACCEPT"]
+
+ When both=True (the default), apply to v4 and v6.
+ """
+ binaries = (IPTABLES, IP6TABLES) if both else (IPTABLES,)
+ for ipt in binaries:
+ # -C exits 0 if the rule already exists, non-zero otherwise.
+ check = run_cmd([ipt, "-C", *rule], check=False, capture_output=True)
+ if check.returncode == 0:
+ continue
+ run_cmd([ipt, "-I", *rule], check=False, capture_output=True)
+
+
+def add_rule_v6_only(rule):
+ """ip6tables-only variant for IPv6 ICMP and friends."""
+ check = run_cmd([IP6TABLES, "-C", *rule], check=False, capture_output=True)
+ if check.returncode == 0:
+ return
+ run_cmd([IP6TABLES, "-I", *rule], check=False, capture_output=True)
+
+
+def allow_port(proto, port):
+ """
+ `allow_port("tcp", 443)` opens INPUT for that proto+port on v4 and v6,
+ plus a conntrack NEW rule that matches the legacy allow_port helper.
+ """
+ port_str = str(port)
+ add_rule(["INPUT", "-p", proto, "--dport", port_str, "-j", "ACCEPT"])
+ add_rule([
+ "INPUT", "-p", proto, "-m", proto, "--dport", port_str,
+ "-m", "conntrack", "--ctstate", "NEW", "-j", "ACCEPT",
+ ])
+
+
+def remove_port(proto, port):
+ """Best-effort delete of an allow_port rule on both v4 and v6."""
+ port_str = str(port)
+ for ipt in (IPTABLES, IP6TABLES):
+ run_cmd(
+ [ipt, "-D", "INPUT", "-p", proto, "--dport", port_str, "-j", "ACCEPT"],
+ check=False, capture_output=True,
+ )
+
+
+def set_input_policy(policy):
+ """policy must be 'ACCEPT' or 'DROP'. Applied to INPUT + FORWARD, v4+v6."""
+ if policy not in ("ACCEPT", "DROP"):
+ log.error(f"firewall: refusing to set unknown policy {policy!r}")
+ return
+ for ipt in (IPTABLES, IP6TABLES):
+ for chain in ("INPUT", "FORWARD"):
+ run_cmd([ipt, "-P", chain, policy], check=False, capture_output=True)
+
+
+def save():
+ """
+ Equivalent to legacy save_firewall: dump current ruleset, dedupe lines
+ (in-place), restore. We open the dump files with O_TRUNC via a normal
+ write so the dedupe step doesn't race a half-written file.
+ """
+ import os
+ os.makedirs("/etc/iptables", exist_ok=True)
+ for ipt_save, ipt_restore, target in (
+ ("iptables-save", "iptables-restore", "/etc/iptables/rules.v4"),
+ ("ip6tables-save", "ip6tables-restore", "/etc/iptables/rules.v6"),
+ ):
+ dump = run_cmd([ipt_save], check=False, capture_output=True)
+ if dump.returncode != 0:
+ log.warning(f"firewall: {ipt_save} failed; skipping {target}")
+ continue
+
+ # Dedup ONLY actual rule lines ('-A ...' / '-I ...'), per table.
+ # Structural lines (*table, :CHAIN policy, COMMIT, comments) are
+ # kept verbatim and in order — collapsing or reordering those (the
+ # old "dedup everything + append COMMIT" approach) corrupted the
+ # restore and produced "ip6tables-restore: line N failed".
+ seen_rules = set()
+ out = []
+ for line in (dump.stdout or "").splitlines():
+ if line.startswith("-"):
+ if line in seen_rules:
+ continue
+ seen_rules.add(line)
+ elif line.startswith("*"):
+ # New table — rule-uniqueness resets per table.
+ seen_rules = set()
+ out.append(line)
+
+ with open(target, "w") as f:
+ f.write("\n".join(out) + "\n")
+ # Apply the cleaned ruleset.
+ with open(target) as f:
+ run_cmd([ipt_restore], check=False, input_data=f.read())
diff --git a/hiddify_manager/utils/logger.py b/hiddify_manager/utils/logger.py
new file mode 100644
index 000000000..64998dcf1
--- /dev/null
+++ b/hiddify_manager/utils/logger.py
@@ -0,0 +1,19 @@
+import logging
+import sys
+
+def setup_logger():
+ logger = logging.getLogger("hiddify_manager")
+ logger.setLevel(logging.INFO)
+
+ # Console handler
+ ch = logging.StreamHandler(sys.stdout)
+ ch.setLevel(logging.INFO)
+
+ # Formatter
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
+ ch.setFormatter(formatter)
+
+ logger.addHandler(ch)
+ return logger
+
+log = setup_logger()
diff --git a/hiddify_manager/utils/package_manager.py b/hiddify_manager/utils/package_manager.py
new file mode 100644
index 000000000..299730a8b
--- /dev/null
+++ b/hiddify_manager/utils/package_manager.py
@@ -0,0 +1,98 @@
+import os
+import platform
+import hashlib
+import urllib.request
+import zipfile
+import tarfile
+from packaging import version
+from hiddify_manager.utils.logger import log
+from hiddify_manager.utils.paths import PACKAGES_LOCK
+
+def get_arch():
+ arch = platform.machine().lower()
+ if arch in ['x86_64', 'amd64']:
+ return 'amd64'
+ elif arch in ['aarch64', 'arm64']:
+ return 'arm64'
+ return arch
+
+def calculate_hash(file_path):
+ sha256_hash = hashlib.sha256()
+ with open(file_path, "rb") as f:
+ for byte_block in iter(lambda: f.read(4096), b""):
+ sha256_hash.update(byte_block)
+ return sha256_hash.hexdigest()
+
+def get_latest_package_info(package_name):
+ packages_lock_path = PACKAGES_LOCK
+ arch = get_arch()
+
+ latest_ver = None
+ latest_info = None
+
+ if not os.path.exists(packages_lock_path):
+ log.error(f"packages.lock not found at {packages_lock_path}")
+ return None
+
+ with open(packages_lock_path, 'r') as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ parts = line.split('|')
+ if len(parts) == 5:
+ p_name, p_version, p_arch, p_url, p_hash = parts
+ if p_name == package_name and p_arch == arch:
+ try:
+ v = version.parse(p_version)
+ if latest_ver is None or v > latest_ver:
+ latest_ver = v
+ latest_info = {
+ "name": p_name,
+ "version": p_version,
+ "arch": p_arch,
+ "url": p_url,
+ "hash": p_hash
+ }
+ except Exception:
+ pass
+ return latest_info
+
+def download_package(package_name, output_file):
+ info = get_latest_package_info(package_name)
+ if not info:
+ log.error(f"Package info not found for {package_name}")
+ return False
+
+ log.info(f"Downloading {package_name} version {info['version']} for {info['arch']}...")
+ try:
+ urllib.request.urlretrieve(info['url'], output_file)
+
+ file_hash = calculate_hash(output_file)
+ if file_hash != info['hash']:
+ log.error(f"Hash mismatch for {package_name}. Expected {info['hash']}, got {file_hash}")
+ os.remove(output_file)
+ return False
+
+ log.info(f"Successfully downloaded {package_name} and verified hash.")
+ return True
+ except Exception as e:
+ log.error(f"Failed to download {package_name}: {e}")
+ return False
+
+def extract_package(file_path, extract_dir):
+ try:
+ if file_path.endswith('.zip'):
+ with zipfile.ZipFile(file_path, 'r') as zip_ref:
+ zip_ref.extractall(extract_dir)
+ return True
+ elif file_path.endswith('.tar.gz') or file_path.endswith('.tgz'):
+ with tarfile.open(file_path, 'r:gz') as tar_ref:
+ tar_ref.extractall(extract_dir)
+ return True
+ else:
+ log.error(f"Unsupported extraction format for {file_path}")
+ return False
+ except Exception as e:
+ log.error(f"Failed to extract {file_path}: {e}")
+ return False
diff --git a/hiddify_manager/utils/paths.py b/hiddify_manager/utils/paths.py
new file mode 100644
index 000000000..996c36bc5
--- /dev/null
+++ b/hiddify_manager/utils/paths.py
@@ -0,0 +1,27 @@
+"""
+Dynamic path resolution for Hiddify-Manager.
+
+All path references should go through this module instead of
+hardcoding /opt/hiddify-manager or other absolute paths.
+"""
+import os
+
+# Project root = parent of hiddify_manager/ package
+PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# Common sub-directories
+LOG_DIR = os.path.join(PROJECT_ROOT, "log", "system")
+COMMON_DIR = os.path.join(PROJECT_ROOT, "common")
+PACKAGES_LOCK = os.path.join(COMMON_DIR, "packages.lock")
+VENV_DIR = os.path.join(PROJECT_ROOT, ".venv313")
+CURRENT_JSON = os.path.join(PROJECT_ROOT, "current.json")
+
+
+def module_dir(module_name: str) -> str:
+ """Return the absolute path to a module's directory (e.g. 'xray', 'other/warp')."""
+ return os.path.join(PROJECT_ROOT, module_name)
+
+
+def ensure_dirs():
+ """Create standard directories if they don't exist."""
+ os.makedirs(LOG_DIR, exist_ok=True)
diff --git a/hiddify_manager/utils/progress.py b/hiddify_manager/utils/progress.py
new file mode 100644
index 000000000..a9da8182f
--- /dev/null
+++ b/hiddify_manager/utils/progress.py
@@ -0,0 +1,30 @@
+"""
+Progress markers for the panel's result.html live-tail.
+
+The panel's JS parses each line of the action log for the marker
+
+ ################
+
+(regex `/####(?]
-
-