Featured image of post Ditch the Client Applications: Using the Mihomo Core Directly

Ditch the Client Applications: Using the Mihomo Core Directly

Rather than relying on unsatisfactory Mihomo clients, why not take matters into your own hands? Directly write and manage your own configuration files for the core, avoiding the opaque and often buggy “client apps”. This approach is cleaner, more stable, and gives you full control.

Like most people, when I started using Clash/Mihomo, I opted for graphical clients based on the core for the sake of convenience. Essentially, these Mihomo clients are very similar: they all use the same backend, with the primary purpose of offering a GUI, managing config files, handling subscription updates, and system proxy settings. With this in mind, I think the usefulness of a Mihomo client largely depends on how well it implements config overrides. Every configuration file obtained or subscribed to through a client goes through various override steps—such as changing the mixed-port, adding sniffer settings, and so on—before it’s handed over to the Mihomo core for startup.

However, not all clients do this basic job adequately. Take ShellCrash, for example—the override mechanism is frequently buggy and feels more like an afterthought. If the client can’t even reliably update and tweak config files, it hardly deserves to be called a decent client.

Instead of depending on these unreliable, black-box Mihomo clients, why not just take direct control? Manage your own configuration files and start the core yourself. This way, you get a cleaner, more reliable, and fully transparent setup.

What You Need to Know

  • Basic Linux skills
  • Familiarity with CLI editors, like nano
  • Have a Substore instance set up (optional)

Installing the Mihomo Core

On Debian-based systems, you can install precompiled .deb packages. For other systemd-enabled distributions, just download the compiled binary, rename it to mihomo, and place it in /usr/local/bin:

1
curl -o /usr/local/bin/mihomo <download_link>

Next, create /etc/systemd/system/mihomo.service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[Unit]
Description=mihomo Daemon, Another Clash Kernel.
After=network.target NetworkManager.service systemd-networkd.service iwd.service

[Service]
Type=simple
LimitNPROC=500
LimitNOFILE=1000000
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE
Restart=always
ExecStartPre=/usr/bin/sleep 1s
ExecStart=/usr/local/bin/mihomo -d /etc/mihomo
ExecReload=/bin/kill -HUP $MAINPID

[Install]
WantedBy=multi-user.target

Run systemctl daemon-reload to refresh systemd. Since there’s no config file yet, you can’t actually start the core, but you can enable it to start on boot using systemctl enable mihomo—ready for when your config is set up.

Configuration Files

When starting, the core reads /etc/mihomo/config.yaml. With the black-box clients out of the picture, you’re free to customise your config files however you like. Some VPN/proxy providers supply a complete config file you can download with curl.

For subscription management, I’m currently using Substore. I’ve previously shared a Quickstart Guide to Substore Subscription Management if you want to refer to that for custom subscription workflows. To support a pure-core setup, my Substore custom override script now includes a full parameter, generating a standalone config file with all necessary ports, unified delay, external-controller settings, and more—ready to use out of the box.

Once you’ve set up Substore, just download your config file and start the core:

1
2
curl -o /etc/mihomo/config.yaml <your-config-link>
systemctl start mihomo

Custom Override Rules

If my example config doesn’t work for your needs, that’s no problem—just tweak or add any overrides you like.

Over the years, I’ve tested various override rules, sometimes even writing my own from scratch. Even with that, there are always edge cases—private domains for stuff like SSH on non-standard ports, for instance—that you won’t want to publish on GitHub. In those cases, you’ll want to append your own private overrides on top.

The good thing is that Substore supports chaining multiple override scripts. All you need to do is add your custom script during config generation.

1
2
3
4
function main(config) {
  config["rules"].unshift("DOMAIN-SUFFIX,hzdwpx.com,DIRECT");
  return config;
}

Note: When overriding rules, always use .unshift() to add to the top of the list—don’t use .push() to add at the end, or they’ll never match (since anything after MATCH is ignored).

Building Your Own Config From Scratch

Don’t like my override rules, or prefer not to use Substore at all? No problem! Just refer to the Mihomo Docs and build your own config from the ground up:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
mode: rule
mixed-port: 7890
redir-port: 7892
tproxy-port: 7893
allow-lan: true
log-level: info
ipv6: true
external-controller: 127.0.0.1:8000
# secret: yoursecret
unified-delay: true
routing-mark: 7894
tcp-concurrent: true
disable-keep-alive: true # Recommended when proxying mobile devices to prevent excessive standby drain

dns:
  # Your DNS configuration

sniffer:
  # Your domain sniffing config

geodata-mode: true
geox-url:
  # Custom GeoData file URL

proxy-providers:
  # Your proxy subscriptions

rule-providers:
  # External routing rules

rules:
  # Proxy rules

proxy-groups:
  # Custom proxy groups

Control Panel / Dashboard

Choose whichever dashboard you like. For example, I ran into some odd issues with Mihomo’s built-in external-ui. So, I just deploy a separate Docker web dashboard—after all, it’s just a simple web UI. Just make sure your core’s API uses HTTP, and set your web panel to use HTTP as well (if you try HTTPS, you’ll be blocked by CORS policy).

1
2
3
$ mkdir zashboard && cd zashboard
$ nvim compose.yml
$ docker compose up -d

compose.yml example:

1
2
3
4
5
6
services:
  zashboard:
    image: ghcr.io/zephyruso/zashboard:latest
    ports:
      - "8899:80"
    restart: "unless-stopped"

Automatic Maintenance

With the core running, how do you handle auto-updates for your subscription configs?

Simple—write a shell script and automate it with cron. For instance, if you want to update your config and restart Mihomo at 3am daily, create /etc/mihomo/auto_update.sh:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#!/bin/bash

# === Config Info ===
CONFIG_URL=""
CONFIG_PATH="/etc/mihomo/config.yaml"
BACKUP_DIR="/etc/mihomo"
BACKUP_PREFIX="config.yaml"
MAX_BACKUPS=7
TMP_PATH="/tmp/config.yaml.tmp"
LOG_FILE="/var/log/mihomo_update.log"

# === Logging ===
log() {
    echo "$(date '+%F %T') $1" | tee -a "$LOG_FILE"
}

# === Backup existing config and clean old backups ===
backup_config() {
    if [ -f "$CONFIG_PATH" ]; then
        backup_file="$BACKUP_DIR/${BACKUP_PREFIX}.$(date '+%Y%m%d_%H%M%S').bak"
        cp "$CONFIG_PATH" "$backup_file"
        log "Config backed up to $backup_file"
        # Retain only the latest $MAX_BACKUPS backups
        old_backups=$(ls -1t $BACKUP_DIR/${BACKUP_PREFIX}.*.bak 2>/dev/null | tail -n +$(($MAX_BACKUPS+1)))
        for f in $old_backups; do
            rm -f "$f" && log "Deleted old backup $f"
        done
    else
        log "No existing config found, skipping backup"
    fi
}

# === Download new config ===
download_config() {
    log "Downloading new config..."
    curl -fsSL -o "$TMP_PATH" "$CONFIG_URL"
    if [ $? -ne 0 ]; then
        log "Download failed—check network or URL"
        return 1
    fi
    # Basic validation: check file size
    if [ ! -s "$TMP_PATH" ]; then
        log "Config file is empty—update aborted"
        return 2
    fi
    log "Config downloaded"
    return 0
}

# === Update config file ===
replace_config() {
    mv "$TMP_PATH" "$CONFIG_PATH"
    log "Config updated"
}

# === Restart Mihomo service ===
restart_service() {
    systemctl restart mihomo
    if [ $? -eq 0 ]; then
        log "Mihomo restarted"
    else
        log "Failed to restart Mihomo—please check manually"
    fi
}

main() {
    backup_config

    download_config
    DL_STATUS=$?
    if [ "$DL_STATUS" -ne 0 ]; then
        log "Aborted: config not updated, keeping old config"
        exit 1
    fi

    replace_config

    restart_service

    log "=== Update complete ==="
}

main "$@"

Use crontab -e to edit your crontab and schedule auto-updates at 3am daily:

1
0 3 * * * /etc/mihomo/update_config.sh

Firewall Configuration

Manually setting up firewall rules to direct traffic through the Mihomo core isn’t as tricky as it sounds. I’ve previously covered the details in my article “From Beginner to Advanced: Tailscale + ShellCrash for Remote Networking and Bypassing Internet Censorship”. Here, I’ll just summarise the practical steps.

For my setup, I want all traffic from the tailscale0 interface to be transparently proxied by Mihomo. If all you need is TCP interception, iptables REDIRECT is sufficient; but for UDP, QUIC, etc., you’ll need TPROXY. Don’t forget IPv6!

Here’s my firewall config:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Create custom chain
iptables -t mangle -N MIHOMO

# Exclude local traffic as needed
iptables -t mangle -A MIHOMO -d 127.0.0.1/8 -j RETURN
iptables -t mangle -A MIHOMO -d 100.64.0.0/10 -j RETURN
iptables -t mangle -A MIHOMO -d 192.168.1.0/24 -j RETURN
iptables -t mangle -A MIHOMO -d 172.17.0.0/16 -j RETURN

# Mark TCP and UDP for proxy
iptables -t mangle -A MIHOMO -p tcp -j TPROXY --on-port 7893 --tproxy-mark 233
iptables -t mangle -A MIHOMO -p udp -j TPROXY --on-port 7893 --tproxy-mark 233

# Hook into the interface
iptables -t mangle -A PREROUTING -i tailscale0 -j MIHOMO

# Routing table
echo "233 mihomo" | tee -a /etc/iproute2/rt_tables
ip rule add fwmark 233 lookup mihomo
ip route add local 0.0.0.0/0 dev lo table mihomo

# IPv6
# Create chain
ip6tables -t mangle -N MIHOMO6

# Skip local addresses
ip6tables -t mangle -A MIHOMO6 -d ::1/128 -j RETURN
ip6tables -t mangle -A MIHOMO6 -d fd7a:115c:a1e0::/48 -j RETURN

# Mark TCP/UDP
ip6tables -t mangle -A MIHOMO6 -i tailscale0 -p tcp -j TPROXY --on-port 7893 --tproxy-mark 233
ip6tables -t mangle -A MIHOMO6 -i tailscale0 -p udp -j TPROXY --on-port 7893 --tproxy-mark 233

# Interface hook
ip6tables -t mangle -A PREROUTING -i tailscale0 -j MIHOMO6

# Routing table
echo "233 mihomo" | tee -a /etc/iproute2/rt_tables
ip -6 rule add fwmark 233 lookup mihomo
ip -6 route add local ::/0 dev lo table mihomo

Most modern distributions do not persist firewall rules by default. For details about rule persistence, see the “Routing Rule Persistence” and “iptables Rule Persistence” sections referenced in my Tailscale article.

How Do I Configure Local Proxying?

Most proxy clients, such as the default for ShellCrash, use REDIRECT as standard, which is usually sufficient for most use cases. Example for REDIRECT:

1
2
3
4
5
# IPv4, intercept eth0
iptables -t nat -A PREROUTING -i eth0 -p tcp -j REDIRECT --to-ports 7892

# IPv6, intercept eth0
ip6tables -t nat -A PREROUTING -i eth0 -p tcp -j REDIRECT --to-ports 7892

Here, 7892 should match the redir-port in your Mihomo config, and eth0 is the interface you want to intercept. Compared to TPROXY, this is remarkably straightforward.

Personally, I dislike forcing all local traffic through the firewall proxy route. Instead, I prefer to use environment variables, proxychains, or per-application proxy settings as needed to route traffic through Mihomo. For example, if you want Docker to use the proxy, you only need to edit Docker’s /etc/docker/daemon.json and specify the proxy endpoints, rather than intercepting all network traffic at the interface level:

1
2
3
4
5
6
7
{
  "proxies": {
    "http-proxy": "http://127.0.0.1:7890",
    "https-proxy": "http://127.0.0.1:7890",
    "no-proxy": "127.0.0.0/8"
  }
}

Why Go to All This Trouble? Isn’t Using a GUI Client Easier?

Don’t ask. Some of us just prefer living in the terminal void.

Built with Hugo
Theme Stack designed by Jimmy