Deploying Mihomo (Hysteria2) behind nftables and Lighttpd on Arch Linux
Table of Contents
Abstract: When setting up a reliable and performant personal roaming gateway, simplicity, long-term stability, and low maintenance overhead are the top priorities. This guide documents an end-to-end deployment on Arch Linux using the native mihomo core (a clean, native architecture utilizing
listeners.hysteria2andproxies.hysteria2), the Hysteria2 (QUIC) protocol, ECH (Encrypted Client Hello) SNI obfuscation, nftables dynamic port hopping, and a lighttpd static site fallback.
#
Why Choose Arch Linux?
When provisioning a VPS for network relays, many people default to Debian or Ubuntu. However, point-release distributions face major version upgrades every few years (such as jumping from Debian 11 to 12, or Ubuntu 20.04 to 22.04 and 24.04). Inevitably, distribution upgrades come with package dependency conflicts, configuration syntax changes, or even network interface breakage—often forcing administrators to back up and reinstall from scratch.
Arch Linux adopts a rolling-release model, completely eliminating the headache of major version upgrades. Once set up, you only need to spend two minutes a month logging in via SSH to run sudo pacman -Syu. The entire system, underlying kernel, and toolchains stay continuously up-to-date and in sync with upstream. As long as your VPS remains powered on, this gateway will run quietly and reliably for years without ever needing a teardown or reinstallation.
#
1. Architecture & Traffic Model
The guiding principles of this deployment are minimal attack surface exposure, protocol-conforming traffic simulation, active defensive hardening, and high network resilience.
##
1.1 Traffic Flow Diagram
Client mihomo (Desktop TUN / Mobile VpnService)
│
│ [1] UDP Dynamic Port Hopping Traffic (Range: <HOP_PORT_START>-<HOP_PORT_END>)
│ TLS Handshake with ECH (Outer SNI: <OUTER_ECH_DOMAIN>, Encrypted Inner SNI: <YOUR_DOMAIN>)
▼
Server Public Network Interface (<WAN_INTERFACE>)
│
├─► [nftables Filter Chain]
│ ├─ Invalid TCP flags / malformed packets ──► DROP
│ ├─ Unauthorized TCP scan hitting traps ──► Add to scan_blacklist (1 hour ban)
│ ├─ SSH rate limiting (<CUSTOM_SSH_PORT>) ──► Ban IP if > 4 new connections/min
│ └─ Inbound IPv6 external traffic ──► DROP (eliminate side-channel leaks)
│
└─► [nftables NAT Chain (PREROUTING)]
└─ DNAT <HOP_PORT_START>-<HOP_PORT_END>/udp ──► 127.0.0.1:<MIHOMO_LOCAL_PORT>
│
▼
mihomo Server Daemon
(Runs as restricted user, strictly bound to 127.0.0.1:<MIHOMO_LOCAL_PORT>/udp)
│
┌───────────────────────────────────┴───────────────────────────────────┐
▼ ▼
[Valid Hysteria2 Auth] [Probe / Unauthenticated]
Decrypted inner ECH matches masquerade fallback to local
High-speed BBR forwarding out https://127.0.0.1:443
│
▼
lighttpd Static Site
(Serves legit site, A+ TLS grade)
##
1.2 Core Architectural Advantages
- Zero Public Port Exposure: The mihomo service does not bind directly to any public IP address; it listens exclusively on
127.0.0.1. External port scanners cannot discover any listening proxy ports on the public interface. - UDP Dynamic Port Hopping: Clients randomly cycle through high-order ports within
<HOP_PORT_START>-<HOP_PORT_END>. The Linux kernel dynamically rewrites destination addresses to the local listener vianftables DNAT. This balances the transmission load and avoids throttling caused by single-port UDP anomalies. - ECH (Encrypted Client Hello): The true hostname
<YOUR_DOMAIN>is completely encrypted within the TLS payload. Observers on the public transit link only see a benign, high-reputation outer domain (<OUTER_ECH_DOMAIN>), preventing plain-text SNI inspection. - Active Probe Fallback (Masquerade): Any unauthenticated or exploratory HTTP/HTTPS requests hitting the port are natively redirected back to the local
lighttpdstatic web server, behaving like a standard HTTP/2 web presence. - Kernel Sandboxing & Proactive Defense: Systemd isolates the daemon by dropping all unnecessary Linux capabilities. Concurrently,
nftablessets port scanning traps that immediately blacklist any IP scanning unexposed TCP ports.
#
2. Prerequisites & Global Placeholder Reference
To safeguard sensitive credentials, replace the following placeholders with your actual parameters before running any commands:
| Placeholder | Description & Recommended Value | Example |
|---|---|---|
<YOUR_SUDO_USER> |
Non-root administrative user for the server | admin or ops |
<SERVER_IP> |
Server public IPv4 address | 198.51.100.1 |
<WAN_INTERFACE> |
Primary public network interface name | eth0, ens3, or ens18 |
<CUSTOM_SSH_PORT> |
High-order custom SSH port | 54321 |
<YOUR_DOMAIN> |
Your registered domain for the gateway / fallback site | nodes.example.com |
<OUTER_ECH_DOMAIN> |
Benign public domain used as outer ECH SNI | cloudflare.com |
<YOUR_EMAIL> |
Email address for Let’s Encrypt notifications | admin@example.com |
<YOUR_CLOUDFLARE_API_TOKEN> |
Cloudflare API Token with Zone - DNS - Edit permissions |
32+ character string |
<MIHOMO_LOCAL_PORT> |
Local loopback UDP port for mihomo | 18000 |
<HOP_PORT_START>-<HOP_PORT_END> |
UDP Port hopping range | 40000-50000 |
<YOUR_STRONG_PASSWORD> |
Strong password for Hysteria2 authentication | Generate via openssl rand -base64 32 |
<YOUR_CONTROLLER_SECRET> |
Secret token for external-controller API | Generate via openssl rand -base64 16 |
<YOUR_ECH_CONFIG_BASE64> |
Single-line base64 public ECH Config string | Base64 string |
<YOUR_ECH_PRIVATE_KEY> |
Multi-line private ECH Key block | PEM block with BEGIN/END markers |
#
Step 0: Initializing a Non-Root Admin User
Following the principle of least privilege, avoid performing routine management as root. Log into your VPS and create a sudo-enabled user:
# 1. Log in as root, create a dedicated admin user, and append to the wheel group
useradd -m -G wheel -s /bin/bash <YOUR_SUDO_USER>
# 2. Set a strong password
passwd <YOUR_SUDO_USER>
# 3. Grant sudo privileges to the wheel group
EDITOR=nano visudo
# Locate: # %wheel ALL=(ALL:ALL) ALL
# Uncomment the line: %wheel ALL=(ALL:ALL) ALL
# Save and exit (Ctrl+O, Enter, Ctrl+X)
# 4. Switch to the newly created user
su - <YOUR_SUDO_USER>
[!TIP] Append your local SSH public key to
/home/<YOUR_SUDO_USER>/.ssh/authorized_keysnow so that public key authentication works right away.
#
Step 1: SSH Hardening (Custom Port & Public Key Authentication)
Automated scanners continuously target port 22. Harden your SSH daemon using a modular drop-in configuration under /etc/ssh/sshd_config.d/:
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf << 'EOF'
# Custom high-order port (must match nftables rules)
Port <CUSTOM_SSH_PORT>
# Enforce public key authentication only
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
# Prohibit direct root logins
PermitRootLogin prohibit-password
# Session and authentication limits
MaxAuthTries 3
MaxSessions 2
# Keepalive and idle timeout
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
Validate and restart SSH:
sudo sshd -t
sudo systemctl restart sshd
[!CAUTION] Do not close your current terminal session yet! Open a new terminal on your local machine to verify connectivity:
ssh -p <CUSTOM_SSH_PORT> <YOUR_SUDO_USER>@<SERVER_IP>Proceed only after verifying that public key authentication works as expected.
#
Step 2: System Hardening & Environment Initialization (01-setup-system.sh)
01-setup-system.sh)
This step installs essential dependencies, sets up dedicated isolated system users, configures directories with strict access permissions, and applies network stack sysctl tunings.
##
2.1 Script Implementation
Create a temporary script 01-setup-system.sh on the server:
cat << 'EOF' > 01-setup-system.sh
#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Error: Must run as root: sudo bash 01-setup-system.sh" >&2
exit 1
fi
if ! command -v pacman >/dev/null 2>&1; then
echo "Error: This script is tailored for Arch Linux. pacman not found!" >&2
exit 1
fi
echo ">> [1/6] Installing system dependencies..."
pacman -S --needed --noconfirm nftables lighttpd certbot certbot-dns-cloudflare curl openssl ca-certificates
echo ">> [2/6] Configuring dedicated system user and security group..."
if ! id mihomo >/dev/null 2>&1; then
useradd -r -s /usr/bin/nologin -M mihomo
fi
if ! getent group ssl-cert >/dev/null 2>&1; then
groupadd -r ssl-cert
fi
usermod -aG ssl-cert mihomo
usermod -aG ssl-cert http
echo ">> [3/6] Setting up protected directories and permissions..."
mkdir -p /etc/mihomo
chown root:mihomo /etc/mihomo
chmod 750 /etc/mihomo
mkdir -p /var/lib/mihomo
chown mihomo:mihomo /var/lib/mihomo
chmod 750 /var/lib/mihomo
mkdir -p /etc/ssl/mihomo
chown -R root:ssl-cert /etc/ssl/mihomo
chmod 750 /etc/ssl/mihomo
mkdir -p /srv/http/disguise
chown -R http:http /srv/http/disguise
chmod 755 /srv/http/disguise
echo ">> [4/6] Applying kernel network tuning & local routing..."
WAN_IFACE="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' || true)"
WAN_IFACE="${WAN_IFACE:-<WAN_INTERFACE>}"
SYSCTL_FILE=/etc/sysctl.d/99-mihomo-security.conf
cat > "$SYSCTL_FILE" << SYSCTL_EOF
# Enable localnet routing for DNAT to 127.0.0.1 (Required for port hopping)
net.ipv4.conf.all.route_localnet = 1
net.ipv4.conf.${WAN_IFACE}.route_localnet = 1
# SYN Flood mitigation
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 4096
# Reverse Path Filtering
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.${WAN_IFACE}.rp_filter = 1
# Disable ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
# Ignore broadcast ping
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
# File system and kernel pointer protection
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
SYSCTL_EOF
sysctl -p "$SYSCTL_FILE" >/dev/null 2>&1 || true
echo ">> [5/6] Restricting journal log retention..."
mkdir -p /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/99-mihomo-limit.conf << 'JOURNAL_EOF'
[Journal]
SystemMaxUse=256M
MaxRetentionSec=1month
JOURNAL_EOF
systemctl restart systemd-journald || true
echo ">> [6/6] Initializing Certbot deploy hook directory..."
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
chmod 750 /etc/letsencrypt/renewal-hooks/deploy
systemctl daemon-reload
echo ">> System hardening and environment setup complete."
EOF
##
2.2 Execution
sudo bash 01-setup-system.sh
rm -f 01-setup-system.sh
#
Step 3: nftables Advanced Firewall Configuration (Port Hopping & Scan Trap)
nftables provides native, high-performance packet filtering and translation:
- UDP Port Hopping DNAT: Incoming UDP packets on
<HOP_PORT_START>-<HOP_PORT_END>are rewritten to127.0.0.1:<MIHOMO_LOCAL_PORT>. - SSH Rate Limiting: Exceeding 4 new connections per minute results in an automatic 1-hour IP ban.
- Port Scan Trap: Any connection attempt to non-whitelisted TCP ports immediately triggers an automatic 1-hour ban.
- Packet Sanitization: Invalid TCP flag combinations (NULL, Xmas, etc.) are immediately dropped.
Edit /etc/nftables.conf:
sudo nano /etc/nftables.conf
Paste the following ruleset:
#!/usr/sbin/nft -f
flush ruleset
# -------------------------------------------------------------
# Variable Definitions
# -------------------------------------------------------------
define WAN_IFACE = "<WAN_INTERFACE>"
define SSH_PORT = <CUSTOM_SSH_PORT>
define HTTP_PORT = 80
define HTTPS_PORT = 443
define HY2_PORT = <MIHOMO_LOCAL_PORT>
define HY2_RANGE = <HOP_PORT_START>-<HOP_PORT_END>
# -------------------------------------------------------------
# Filter Table (inet filter)
# -------------------------------------------------------------
table inet filter {
# Dynamic scan blacklist (automatic 1-hour expiration)
set scan_blacklist {
type ipv4_addr
flags dynamic, timeout
timeout 1h
}
# SSH connection rate tracker
set ssh_ratelimit {
type ipv4_addr
flags dynamic, timeout
timeout 1m
}
chain input {
type filter hook input priority filter; policy drop;
# 1. Drop packets from blacklisted IPs immediately
ip saddr @scan_blacklist drop
# 2. Allow established and related connections
ct state established,related accept
# 3. Allow loopback traffic
iif "lo" accept
# 4. Drop all inbound external IPv6 traffic
meta nfproto ipv6 drop
# 5. Drop invalid connection states
ct state invalid drop
# 6. Drop malformed TCP flag combinations (NULL, Xmas, SYN-FIN, etc.)
tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 drop
tcp flags & (fin|syn|rst|psh|ack|urg) == (fin|psh|urg) drop
tcp flags & (fin|syn) == (fin|syn) drop
tcp flags & (syn|rst) == (syn|rst) drop
tcp flags & (fin|syn|rst|ack) != syn ct state new drop
# 7. ICMP rate limiting (allows network diagnostics while mitigating floods)
ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } limit rate 5/second accept
ip protocol icmp drop
# 8. SSH brute-force protection
tcp dport $SSH_PORT ct state new add @ssh_ratelimit { ip saddr limit rate 4/minute burst 4 packets } accept
tcp dport $SSH_PORT ct state new add @scan_blacklist { ip saddr } drop
# 9. Whitelist HTTP & HTTPS ports for the fallback site
tcp dport { $HTTP_PORT, $HTTPS_PORT } accept
# 10. Allow DNAT-translated Hysteria2 UDP packets
# Note: Packets must already have their destination translated to 127.0.0.1
ip daddr 127.0.0.1 udp dport $HY2_PORT accept
# 11. Port scan trap (external interface only)
# Any new TCP connection touching an unopened port triggers an automatic ban
iifname $WAN_IFACE tcp flags & syn == syn ct state new add @scan_blacklist { ip saddr } drop
# 12. Default drop
drop
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
# -------------------------------------------------------------
# NAT Table (Port Hopping Forwarding)
# -------------------------------------------------------------
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname $WAN_IFACE udp dport $HY2_RANGE dnat to 127.0.0.1:$HY2_PORT
}
}
Apply and enable the firewall:
sudo chmod 644 /etc/nftables.conf
sudo nft -c -f /etc/nftables.conf
sudo systemctl enable --now nftables
sudo systemctl status nftables
[!NOTE] Check currently banned IPs anytime with:
sudo nft list set inet filter scan_blacklist
#
Step 4: Automated Let’s Encrypt Certificate Issuance & lighttpd Deployment
To achieve an A+ TLS grade for the fallback site and ensure that private keys remain readable only by the ssl-cert group, we use Certbot with an automated deploy hook.
##
4.1 Cloudflare DNS API Token Configuration
Using Let’s Encrypt via DNS-01 challenge is straightforward: it avoids occupying port 80 and handles renewal completely in the background:
- In the Cloudflare Dashboard, go to My Profile -> API Tokens -> Create Token.
- Select the Edit zone DNS template, assign
Zone - DNS - Edit, and select your target zone. - Save the credential on the server with restricted permissions:
sudo mkdir -p /etc/letsencrypt sudo tee /etc/letsencrypt/cloudflare.ini << 'EOF' dns_cloudflare_api_token = <YOUR_CLOUDFLARE_API_TOKEN> EOF sudo chown root:root /etc/letsencrypt/cloudflare.ini sudo chmod 600 /etc/letsencrypt/cloudflare.ini
##
4.2 Deploy Hook for Automatic Permissions & Service Reload
Create /etc/letsencrypt/renewal-hooks/deploy/mihomo-deploy-hook.sh:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/mihomo-deploy-hook.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Error: deploy hook must run as root." >&2
exit 1
fi
TARGET_DIR="/etc/ssl/mihomo"
if [ ! -d "$TARGET_DIR" ]; then
mkdir -p "$TARGET_DIR"
chown root:ssl-cert "$TARGET_DIR"
chmod 750 "$TARGET_DIR"
fi
if [ -z "${RENEWED_LINEAGE:-}" ]; then
DOMAIN="${1:-}"
if [ -n "$DOMAIN" ] && [ -d "/etc/letsencrypt/live/$DOMAIN" ]; then
RENEWED_LINEAGE="/etc/letsencrypt/live/$DOMAIN"
else
echo "Error: Certificate lineage not found." >&2
exit 1
fi
fi
echo ">> Distributing certificate files to $TARGET_DIR ..."
cp -f "$RENEWED_LINEAGE/fullchain.pem" "$TARGET_DIR/fullchain.pem"
cp -f "$RENEWED_LINEAGE/privkey.pem" "$TARGET_DIR/privkey.pem"
# Restrict permissions: private key to 640 root:ssl-cert, public certificate to 644
chown root:ssl-cert "$TARGET_DIR/privkey.pem" "$TARGET_DIR/fullchain.pem"
chmod 640 "$TARGET_DIR/privkey.pem"
chmod 644 "$TARGET_DIR/fullchain.pem"
# Reload web server and relay daemon
systemctl is-active --quiet lighttpd && systemctl reload-or-restart lighttpd || true
systemctl is-active --quiet mihomo && systemctl reload-or-restart mihomo || true
logger -t certbot-deploy-hook "Let's Encrypt certificates deployed and services reloaded."
EOF
sudo chown root:root /etc/letsencrypt/renewal-hooks/deploy/mihomo-deploy-hook.sh
sudo chmod 750 /etc/letsencrypt/renewal-hooks/deploy/mihomo-deploy-hook.sh
##
4.3 Initial Certificate Issuance
Issue the certificate with a single standard Certbot command:
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
--dns-cloudflare-propagation-seconds 30 \
--email <YOUR_EMAIL> \
--agree-tos \
--no-eff-email \
-d <YOUR_DOMAIN> \
--deploy-hook /etc/letsencrypt/renewal-hooks/deploy/mihomo-deploy-hook.sh
Enable the Systemd Auto-Renewal Timer:
Arch Linux includes certbot-renew.timer out of the box. It checks certificate expiration twice a day and automatically renews within 30 days of expiry:
sudo systemctl enable --now certbot-renew.timer
sudo systemctl list-timers | grep certbot
##
4.4 Configuring lighttpd Fallback Site
-
Place Static Web Content: Add a standard static HTML template into
/srv/http/disguise/(ensureindex.htmlexists):sudo chown -R http:http /srv/http/disguise sudo chmod -R 755 /srv/http/disguise -
Configure
/etc/lighttpd/lighttpd.conf:sudo nano /etc/lighttpd/lighttpd.confserver.modules = ( "mod_openssl", "mod_redirect", "mod_setenv", "mod_access" ) server.username = "http" server.groupname = "http" server.document-root = "/srv/http/disguise" server.pid-file = "/run/lighttpd.pid" server.errorlog = "/var/log/lighttpd/error.log" # Disguise server signature server.tag = "nginx" mimetype.assign = ( ".html" => "text/html; charset=utf-8", ".htm" => "text/html; charset=utf-8", ".css" => "text/css; charset=utf-8", ".js" => "application/javascript; charset=utf-8", ".json" => "application/json; charset=utf-8", ".png" => "image/png", ".jpg" => "image/jpeg", ".svg" => "image/svg+xml", ".ico" => "image/x-icon", ".txt" => "text/plain; charset=utf-8" ) index-file.names = ( "index.html", "index.htm" ) # Redirect HTTP (port 80) to HTTPS server.port = 80 $HTTP["scheme"] == "http" { url.redirect-code = 301 url.redirect = ( "" => "https://${url.authority}${url.path}${qsa}" ) } # Secure HTTPS configuration on port 443 $SERVER["socket"] == ":443" { ssl.engine = "enable" ssl.pemfile = "/etc/ssl/mihomo/fullchain.pem" ssl.privkey = "/etc/ssl/mihomo/privkey.pem" # Enforce TLS 1.2+ with modern cipher suites ssl.openssl.ssl-conf-cmd = ( "MinProtocol" => "TLSv1.2", "Options" => "-ServerPreference", "CipherString" => "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384" ) # Security response headers (A+ rating) setenv.add-response-header = ( "Strict-Transport-Security" => "max-age=31536000; includeSubDomains; preload", "X-Content-Type-Options" => "nosniff", "X-Frame-Options" => "DENY", "X-XSS-Protection" => "1; mode=block", "Content-Security-Policy" => "default-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests", "Referrer-Policy" => "strict-origin-when-cross-origin", "Permissions-Policy" => "interest-cohort=()" ) # Virtual host binding $HTTP["host"] == "<YOUR_DOMAIN>" { server.document-root = "/srv/http/disguise" } # Block raw IP or unauthorized host probes $HTTP["host"] != "<YOUR_DOMAIN>" { url.access-deny = ( "" ) } } -
Hosts Resolution & Service Activation:
sudo chown root:root /etc/lighttpd/lighttpd.conf sudo chmod 644 /etc/lighttpd/lighttpd.conf # Ensure local masquerade requests resolve to 127.0.0.1 echo "127.0.0.1 <YOUR_DOMAIN>" | sudo tee -a /etc/hosts # Verify syntax and start service sudo lighttpd -tt -f /etc/lighttpd/lighttpd.conf sudo systemctl enable --now lighttpd
#
Step 5: Installing the mihomo Core Binary (02-install-mihomo.sh)
02-install-mihomo.sh)
Create an architecture-adaptive installation script 02-install-mihomo.sh:
cat << 'EOF' > 02-install-mihomo.sh
#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Error: Must run as root: sudo bash 02-install-mihomo.sh" >&2
exit 1
fi
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) TARGET_ARCH="linux-amd64-compatible" ;;
aarch64) TARGET_ARCH="linux-arm64" ;;
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
esac
echo ">> Fetching latest mihomo release tag..."
LATEST_TAG=$(curl -sSL "https://api.github.com/repos/MetaCubeX/mihomo/releases/latest" | grep '"tag_name":' | head -n 1 | cut -d '"' -f 4)
DOWNLOAD_URL="https://github.com/MetaCubeX/mihomo/releases/download/${LATEST_TAG}/mihomo-${TARGET_ARCH}-${LATEST_TAG}.gz"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
echo ">> Downloading mihomo (${LATEST_TAG} / ${TARGET_ARCH})..."
curl -sSL -o "$TMP_DIR/mihomo.gz" "$DOWNLOAD_URL"
echo ">> Extracting and installing to /usr/local/bin/mihomo ..."
gzip -dc "$TMP_DIR/mihomo.gz" > "$TMP_DIR/mihomo"
install -m 755 "$TMP_DIR/mihomo" /usr/local/bin/mihomo
/usr/local/bin/mihomo -v
echo ">> Installation successful."
EOF
sudo bash 02-install-mihomo.sh
rm -f 02-install-mihomo.sh
#
Step 6: Generating ECH Keypair & Launching the Server Daemon
##
6.1 Generating ECH Keypair
Use an unassociated high-reputation domain (such as <OUTER_ECH_DOMAIN>, e.g., cloudflare.com) as the outer SNI shield:
mihomo generate ech-keypair <OUTER_ECH_DOMAIN>
Example Output:
Config: <YOUR_ECH_CONFIG_BASE64>
Key:
-----BEGIN ECH KEYS-----
<YOUR_ECH_PRIVATE_KEY_LINES>
-----END ECH KEYS-----
- Config: Public key string used in client configuration.
- Key: Private key block used in the server configuration.
##
6.2 Hardened Systemd Service Unit
Create /etc/systemd/system/mihomo.service:
[Unit]
Description=mihomo Server Daemon (Hysteria2 Listener, Least Privilege)
After=network.target network-online.target
Wants=network-online.target
StartLimitIntervalSec=120s
StartLimitBurst=5
[Service]
Type=simple
User=mihomo
Group=mihomo
SupplementaryGroups=ssl-cert
Environment="SAFE_PATHS=/etc/ssl/mihomo:/etc/mihomo"
# Drop all high-risk capabilities; grant only port binding capability
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
# Filesystem and process isolation
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_UNIX
LockPersonality=true
PrivateDevices=true
# System call filtering (Seccomp)
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
# Working directory and file access restrictions
WorkingDirectory=/var/lib/mihomo
StateDirectory=mihomo
ReadWritePaths=/var/lib/mihomo
ReadOnlyPaths=/etc/mihomo /etc/ssl/mihomo
LimitNPROC=500
LimitNOFILE=1000000
Restart=on-failure
RestartSec=5s
ExecStartPre=/usr/bin/sleep 1s
ExecStart=/usr/local/bin/mihomo -d /var/lib/mihomo -f /etc/mihomo/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
[Install]
WantedBy=multi-user.target
Reload systemd:
sudo chown root:root /etc/systemd/system/mihomo.service
sudo chmod 644 /etc/systemd/system/mihomo.service
sudo systemctl daemon-reload
##
6.3 Server Configuration /etc/mihomo/config.yaml
/etc/mihomo/config.yaml
Create /etc/mihomo/config.yaml:
log-level: warning
ipv6: false
find-process-mode: off
external-controller: 127.0.0.1:9090
secret: <YOUR_CONTROLLER_SECRET>
profile:
store-selected: false
store-fake-ip: false
listeners:
- name: hy2-in
type: hysteria2
# Strictly listen on loopback interface
port: <MIHOMO_LOCAL_PORT>
listen: 127.0.0.1
# Authentication credentials
users:
hy2-user: "<YOUR_STRONG_PASSWORD>"
up: 100
down: 100
ignore-client-bandwidth: true
# Fallback to local static website
masquerade: "https://<YOUR_DOMAIN>"
alpn:
- h3
certificate: /etc/ssl/mihomo/fullchain.pem
private-key: /etc/ssl/mihomo/privkey.pem
# Paste the ECH private key block generated in Step 6.1
ech-key: |
-----BEGIN ECH KEYS-----
<YOUR_ECH_PRIVATE_KEY>
-----END ECH KEYS-----
Set permissions and launch:
sudo chown root:mihomo /etc/mihomo/config.yaml
sudo chmod 640 /etc/mihomo/config.yaml
# Test configuration syntax
sudo -u mihomo /usr/local/bin/mihomo -t -d /var/lib/mihomo -f /etc/mihomo/config.yaml
# Start and enable the service
sudo systemctl enable --now mihomo
sudo systemctl status mihomo
#
Step 7: Cross-Platform Client Deployment Guide (Desktop & Mobile)
This setup tailors two separate, fully audited configuration profiles according to the differing network stacks and power-consumption characteristics of desktop and mobile platforms:
- Desktop Profile (
mihomo-desktop.yaml): Enables native transparent TUN routing, TCP concurrency, and standard keep-alive timeouts. Because all network traffic is routed through the virtual TUN interface, domestic traffic is cleanly and comprehensively resolved byGEOSITE,CNandGEOIP,CN. - Mobile Profile (
mihomo-mobile.yaml): Omits thetunsection entirely (leaving virtual interface management to the mobile operating system’s nativeVpnServiceto prevent double-TUN battery drain), enablesquic-go-disable-gso: trueto bypass cellular base station UDP fragmentation flaws, and utilizes short keep-alive heartbeats to prevent idle drops. Furthermore, it incorporates explicitDOMAIN-SUFFIXdirect routing rules for high-frequency domestic apps (such as Bilibili, WeChat, QQ) to prevent private in-app HTTPDNS implementations from bypassing system DNS and triggering account fraud-prevention flags.
##
7.1 Linux Desktop Deployment & Full Configuration (TUN Transparent Mode)
On the client Linux machine, execute the environment initialization:
-
Setup TUN Permissions (
setup-client.sh):sudo pacman -S --needed iproute2 curl openssl ca-certificates sudo useradd -r -s /usr/bin/nologin -M mihomo || true sudo mkdir -p /etc/mihomo /var/lib/mihomo sudo chown root:mihomo /etc/mihomo && sudo chmod 750 /etc/mihomo sudo chown mihomo:mihomo /var/lib/mihomo && sudo chmod 750 /var/lib/mihomo # Grant restricted mihomo user permissions to operate TUN devices sudo tee /etc/udev/rules.d/90-mihomo-tun.rules << 'EOF' KERNEL=="tun", GROUP="mihomo", MODE="0660" EOF sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=tun -
Install mihomo Core Binary: Run the same
02-install-mihomo.shscript as on the server to install the binary to/usr/local/bin/mihomo. -
Client Systemd Service Unit (Grants CAP_NET_ADMIN for TUN): Create
/etc/systemd/system/mihomo.service:[Unit] Description=mihomo Client Daemon (TUN Mode) After=network.target network-online.target Wants=network-online.target [Service] Type=simple User=mihomo Group=mihomo CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true WorkingDirectory=/var/lib/mihomo StateDirectory=mihomo ReadWritePaths=/var/lib/mihomo ReadOnlyPaths=/etc/mihomo ExecStart=/usr/local/bin/mihomo -d /var/lib/mihomo -f /etc/mihomo/config.yaml Restart=on-failure RestartSec=5s [Install] WantedBy=multi-user.targetReload systemd:
sudo systemctl daemon-reload. -
Desktop Full Profile (
mihomo-desktop.yaml): Save the following complete configuration to/etc/mihomo/config.yaml:
mixed-port: 7890
allow-lan: false
bind-address: 127.0.0.1
mode: rule
log-level: warning
ipv6: false
unified-delay: true
tcp-concurrent: true
find-process-mode: off
keep-alive-interval: 30
keep-alive-idle: 600
disable-keep-alive: false
geodata-mode: true
geo-auto-update: true
geo-update-interval: 24
geox-url:
geoip: "https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat"
geosite: "https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat"
mmdb: "https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/country.mmdb"
asn: "https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/GeoLite2-ASN.mmdb"
profile:
store-selected: true
store-fake-ip: true
experimental:
quic-go-disable-gso: false
quic-go-disable-ecn: true
dialer-ip4p-convert: false
# Static hosts mapping for DoH endpoints: Bypasses bootstrap queries & prevents DNS poisoning
hosts:
'dns.quad9.net':
- 9.9.9.9
- 149.112.112.112
'protective.joindns4.eu':
- 86.54.11.1
- 86.54.11.201
# ---- TUN Mode ----
tun:
enable: true
stack: mixed
dns-hijack:
- "any:53"
auto-route: true
auto-detect-interface: true
strict-route: true
route-exclude-address:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 127.0.0.0/8
- 169.254.0.0/16
- 224.0.0.0/4
- 240.0.0.0/4
mtu: 1500
# ---- DNS ----
dns:
enable: true
cache-algorithm: arc
prefer-h3: false
use-hosts: true
use-system-hosts: true
respect-rules: true
listen: 127.0.0.1:1053
ipv6: false
default-nameserver:
- 223.5.5.5
- 119.29.29.29
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
fake-ip-filter-mode: rule
fake-ip-filter:
- GEOSITE,CN,real-ip
- GEOSITE,private,real-ip
- GEOSITE,apple,real-ip
- GEOSITE,onedrive,real-ip
- GEOSITE,category-ntp,real-ip
- GEOSITE,connectivity-check,real-ip
- DOMAIN,<YOUR_DOMAIN>,real-ip
- MATCH,fake-ip
nameserver-policy:
"geosite:cn,private,apple,onedrive,microsoft@cn":
- https://dns.alidns.com/dns-query
- https://doh.pub/dns-query
"geosite:google,youtube,telegram,gfw,geolocation-!cn":
- https://dns.quad9.net/dns-query
- https://protective.joindns4.eu/dns-query
nameserver:
- https://dns.quad9.net/dns-query
- https://protective.joindns4.eu/dns-query
fallback:
- https://protective.joindns4.eu/dns-query
- https://dns.quad9.net/dns-query
fallback-filter:
geoip: true
geoip-code: CN
geosite:
- gfw
ipcidr:
- 240.0.0.0/4
- 0.0.0.0/32
- 127.0.0.1/32
- 100.64.0.0/10
# Node domain resolution: Domestic DoH (encrypted transport against inspection)
proxy-server-nameserver:
- https://dns.alidns.com/dns-query
- https://doh.pub/dns-query
direct-nameserver:
- https://dns.alidns.com/dns-query
- https://doh.pub/dns-query
direct-nameserver-follow-policy: false
# ---- Proxy Node ----
proxies:
- name: HY2-Port-Hopping
type: hysteria2
server: <YOUR_DOMAIN>
ports: <HOP_PORT_START>-<HOP_PORT_END>
hop-interval: 30
password: "<YOUR_STRONG_PASSWORD>"
up: 200
down: 100
sni: <YOUR_DOMAIN>
skip-cert-verify: false
alpn:
- h3
ech-opts:
enable: true
config: "<YOUR_ECH_CONFIG_BASE64>"
# ---- Proxy Groups ----
proxy-groups:
- name: 代理
type: select
proxies:
- 自动选择
- HY2-Port-Hopping
- DIRECT
- name: 自动选择
type: url-test
proxies:
- HY2-Port-Hopping
url: https://www.gstatic.com/generate_204
interval: 300
tolerance: 50
rules:
# Ad blocking
- GEOSITE,category-ads-all,REJECT
# Direct connection to node domain (prevents loops)
- DOMAIN,<YOUR_DOMAIN>,DIRECT
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- GEOIP,private,DIRECT,no-resolve
- GEOSITE,private,DIRECT
- GEOSITE,google,代理
- GEOSITE,youtube,代理
- GEOSITE,telegram,代理
- GEOSITE,github,代理
- GEOSITE,openai,代理
- GEOSITE,anthropic,代理
- IP-CIDR,160.79.104.0/21,代理,no-resolve
- GEOIP,telegram,代理
- GEOSITE,microsoft@cn,DIRECT
- GEOSITE,apple-cn,DIRECT
- GEOSITE,steam@cn,DIRECT
- GEOSITE,category-games@cn,DIRECT
- GEOSITE,CN,DIRECT
- GEOIP,CN,DIRECT
- GEOSITE,geolocation-!cn,代理
- MATCH,代理
Enable and verify:
sudo chown root:mihomo /etc/mihomo/config.yaml
sudo chmod 640 /etc/mihomo/config.yaml
sudo systemctl enable --now mihomo
sudo systemctl status mihomo
##
7.2 Mobile Full Profile (mihomo-mobile.yaml / Android & iOS Power-Optimized)
mihomo-mobile.yaml / Android & iOS Power-Optimized)
For mobile clients (Clash Meta for Android, Flclash, Sing-box, Loon, Shadowrocket, etc.), save or import the following complete configuration:
mixed-port: 7890
allow-lan: false
bind-address: 127.0.0.1
mode: rule
log-level: warning
ipv6: false
unified-delay: true
tcp-concurrent: false
find-process-mode: off
# Mobile-specific keep-alive: Prevents carrier mobile base stations from evicting NAT states
keep-alive-interval: 25
keep-alive-idle: 120
disable-keep-alive: false
geodata-mode: true
geo-auto-update: true
geo-update-interval: 24
geox-url:
geoip: "https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat"
geosite: "https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat"
mmdb: "https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/country.mmdb"
asn: "https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/GeoLite2-ASN.mmdb"
profile:
store-selected: true
store-fake-ip: true
experimental:
# Disabling GSO and ECN is strongly advised on cellular mobile networks
quic-go-disable-gso: true
quic-go-disable-ecn: true
dialer-ip4p-convert: false
# Static hosts mapping for DoH endpoints: Bypasses bootstrap queries & prevents DNS poisoning
hosts:
'dns.quad9.net':
- 9.9.9.9
- 149.112.112.112
'protective.joindns4.eu':
- 86.54.11.1
- 86.54.11.201
# Note: Mobile configurations omit the 'tun:' block entirely.
# The virtual network interface is delegated to the mobile OS VpnService.
# ---- DNS ----
dns:
enable: true
cache-algorithm: arc
prefer-h3: false
use-hosts: true
use-system-hosts: true
respect-rules: true
listen: 127.0.0.1:1053
ipv6: false
default-nameserver:
- 223.5.5.5
- 119.29.29.29
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
fake-ip-filter-mode: rule
fake-ip-filter:
- GEOSITE,CN,real-ip
- GEOSITE,private,real-ip
- GEOSITE,apple,real-ip
- GEOSITE,onedrive,real-ip
- GEOSITE,category-ntp,real-ip
- GEOSITE,connectivity-check,real-ip
- DOMAIN,<YOUR_DOMAIN>,real-ip
- MATCH,fake-ip
nameserver-policy:
"geosite:cn,private,apple,onedrive,microsoft@cn":
- https://dns.alidns.com/dns-query
- https://doh.pub/dns-query
"geosite:google,youtube,telegram,gfw,geolocation-!cn":
- https://dns.quad9.net/dns-query
- https://protective.joindns4.eu/dns-query
nameserver:
- https://dns.quad9.net/dns-query
- https://protective.joindns4.eu/dns-query
fallback:
- https://protective.joindns4.eu/dns-query
- https://dns.quad9.net/dns-query
fallback-filter:
geoip: true
geoip-code: CN
geosite:
- gfw
ipcidr:
- 240.0.0.0/4
- 0.0.0.0/32
- 127.0.0.1/32
- 100.64.0.0/10
proxy-server-nameserver:
- https://dns.alidns.com/dns-query
- https://doh.pub/dns-query
direct-nameserver:
- https://dns.alidns.com/dns-query
- https://doh.pub/dns-query
direct-nameserver-follow-policy: false
# ---- Proxy Node ----
proxies:
- name: HY2-Port-Hopping
type: hysteria2
server: <YOUR_DOMAIN>
ports: <HOP_PORT_START>-<HOP_PORT_END>
hop-interval: 30
password: "<YOUR_STRONG_PASSWORD>"
up: 100
down: 100
sni: <YOUR_DOMAIN>
skip-cert-verify: false
alpn:
- h3
ech-opts:
enable: true
config: "<YOUR_ECH_CONFIG_BASE64>"
# ---- Proxy Groups ----
proxy-groups:
- name: 代理
type: select
proxies:
- 自动选择
- HY2-Port-Hopping
- DIRECT
- name: 自动选择
type: url-test
proxies:
- HY2-Port-Hopping
url: https://www.gstatic.com/generate_204
interval: 300
tolerance: 50
rules:
# Ad blocking
- GEOSITE,category-ads-all,REJECT
# Direct connection to node domain (prevents loops)
- DOMAIN,<YOUR_DOMAIN>,DIRECT
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- GEOIP,private,DIRECT,no-resolve
- GEOSITE,private,DIRECT
- GEOSITE,google,代理
- GEOSITE,youtube,代理
- GEOSITE,telegram,代理
- GEOSITE,github,代理
- GEOSITE,openai,代理
- GEOSITE,anthropic,代理
- IP-CIDR,160.79.104.0/21,代理,no-resolve
- GEOIP,telegram,代理
- GEOSITE,microsoft@cn,DIRECT
- GEOSITE,apple-cn,DIRECT
- GEOSITE,steam@cn,DIRECT
- GEOSITE,category-games@cn,DIRECT
- GEOSITE,bilibili,DIRECT
- DOMAIN-SUFFIX,bilibili.com,DIRECT
- DOMAIN-SUFFIX,biliapi.net,DIRECT
- DOMAIN-SUFFIX,hdslb.com,DIRECT
- DOMAIN-SUFFIX,bilivideo.com,DIRECT
- DOMAIN-SUFFIX,bilivideo.cn,DIRECT
- DOMAIN-SUFFIX,acgvideo.com,DIRECT
- DOMAIN-SUFFIX,qq.com,DIRECT
- DOMAIN-SUFFIX,gtimg.cn,DIRECT
- DOMAIN-SUFFIX,weixin.qq.com,DIRECT
- GEOSITE,CN,DIRECT
- GEOIP,CN,DIRECT
- GEOSITE,geolocation-!cn,代理
- MATCH,代理
##
7.3 Windows / macOS GUI Client Setup
On Windows or macOS, graphical clients like Mihomo Party, Clash Verge Rev, or Flclash are recommended:
- Create a new local profile, copy and paste the complete content of
mihomo-desktop.yamlabove into it; - Verify that
<YOUR_DOMAIN>,<HOP_PORT_START>-<HOP_PORT_END>,<YOUR_STRONG_PASSWORD>, and<YOUR_ECH_CONFIG_BASE64>are appropriately filled in; - Save and activate the profile, then toggle “System Proxy” or “TUN Mode / Service Mode” on the dashboard for system-wide routing.
#
Step 8: Verification & Troubleshooting
##
8.1 Diagnostic Commands
Run the following checks on the server:
# 1. Verify nftables rules and NAT translations
sudo nft list ruleset
# 2. Check local listening ports (verify 127.0.0.1 binding and web ports)
sudo ss -tulnp | grep -E '(<MIHOMO_LOCAL_PORT>|80|443|<CUSTOM_SSH_PORT>)'
# 3. Test the fallback website and SSL certificate
curl -Iv https://<YOUR_DOMAIN>
# 4. Monitor live daemon logs
sudo journalctl -u mihomo -f -n 50 --no-pager
##
8.2 Common Issues & Resolutions
| Symptom | Probable Cause | Solution |
|---|---|---|
| Client times out; no handshake logs on server | Kernel drops cross-interface routing to 127.0.0.1 | Check sysctl net.ipv4.conf.all.route_localnet; verify it is set to 1 |
| Client times out; no handshake logs on server | Interface name mismatch | Confirm WAN_IFACE in /etc/nftables.conf exactly matches ip -br link |
| SSH connection suddenly dropped or blocked | Triggered rate-limit trap | Over 4 connections/min triggers an auto-ban. Unban your IP: sudo nft delete element inet filter scan_blacklist { <YOUR_CLIENT_IP> } |
| mihomo fails to start (permission denied on cert) | Incorrect key permissions or user groups | Ensure key permissions are 640 root:ssl-cert and mihomo is in the ssl-cert group |
| Fallback site returns 403 Forbidden | Request missing matching Host header | Expected behavior; visiting via browser with the full domain name loads correctly |
#
Step 9: Seamless Hot Upgrades (upgrade-mihomo.sh)
upgrade-mihomo.sh)
When new upstream versions are released, perform an in-place upgrade without downtime using this script:
cat << 'EOF' > upgrade-mihomo.sh
#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Error: Must run as root: sudo bash upgrade-mihomo.sh" >&2
exit 1
fi
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) TARGET_ARCH="linux-amd64-compatible" ;;
aarch64) TARGET_ARCH="linux-arm64" ;;
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
esac
LATEST_TAG=$(curl -sSL "https://api.github.com/repos/MetaCubeX/mihomo/releases/latest" | grep '"tag_name":' | head -n 1 | cut -d '"' -f 4)
echo ">> Latest available version: $LATEST_TAG"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
curl -sSL -o "$TMP_DIR/mihomo.gz" "https://github.com/MetaCubeX/mihomo/releases/download/${LATEST_TAG}/mihomo-${TARGET_ARCH}-${LATEST_TAG}.gz"
gzip -dc "$TMP_DIR/mihomo.gz" > "$TMP_DIR/mihomo"
chmod +x "$TMP_DIR/mihomo"
# Overwrite binary and seamlessly restart service
install -m 755 "$TMP_DIR/mihomo" /usr/local/bin/mihomo
systemctl restart mihomo
systemctl status mihomo --no-pager
echo ">> Successfully upgraded mihomo to $LATEST_TAG"
EOF
sudo bash upgrade-mihomo.sh
rm -f upgrade-mihomo.sh
#
Conclusion
For a personal roaming gateway, reliability and zero-headache operation trump everything else. Arch Linux provides the ideal foundation here: its rolling-release model eliminates the dread of breaking configurations during major distribution upgrades. An occasional two-minute monthly update keeps your entire platform pristine, while the one-click hot-upgrade script handles the daemon smoothly. This deployment gives you a resilient, quiet, and dependable gateway you can rely on long-term.