mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:36 +01:00
`just mihomo-geo` copied geodata into the state dir so a cold start never fetched it. tmpfiles now links v2ray-rules-dat and the DB-IP lite mmdbs there, so geo-auto-update moves to the gateway config only. Assisted-by: Claude Code (claude-opus-5-5)
134 lines
5.6 KiB
Nix
134 lines
5.6 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
myvars,
|
|
pkgs,
|
|
...
|
|
}:
|
|
# Native mihomo core + metacubexd dashboard, replacing the Clash Verge GUI: the
|
|
# GUI crashed (SIGFPE) and reopened a fake-IP leak window on every core restart.
|
|
#
|
|
# The config stays OUT of the Nix store (it holds subscription URLs and the
|
|
# controller secret) and reaches the service through systemd's LoadCredential.
|
|
# This module only wires up the service; generate the file with ./generate.nu.
|
|
# See README.md for usage and the routing notes, in particular the IPv6
|
|
# condition: the fake-ip v6 pool needs a line whose IPv6 actually works, and
|
|
# mihomo has no reachability probe to notice when the line loses it.
|
|
let
|
|
cfg = config.modules.networking.mihomo;
|
|
configFile = "${config.users.users.${myvars.username}.home}/.config/mihomo/config.yaml";
|
|
host = config.networking.hostName;
|
|
|
|
# metacubexd hardcodes its tab title ("MetaCubeXD") in the SPA and exposes no
|
|
# config hook, so dashboards on different hosts look identical. Append the
|
|
# hostname: patch the static <title>, then re-apply it from config.js because
|
|
# Nuxt's useHead rewrites document.title on every route change. config.js is
|
|
# metacubexd's post-build customization point (and is excluded from the PWA
|
|
# precache), so this survives version bumps — unlike patching its hashed,
|
|
# minified JS.
|
|
webui = pkgs.runCommandLocal "metacubexd-${host}" { } ''
|
|
cp -r ${pkgs.metacubexd} $out
|
|
chmod -R u+w $out
|
|
substituteInPlace $out/index.html $out/200.html $out/404.html \
|
|
--replace-fail '<title>MetaCubeXD</title>' '<title>MetaCubeXD · ${host}</title>'
|
|
cat >> $out/config.js <<'EOF'
|
|
|
|
;(function () {
|
|
var suffix = ' · ${host}'
|
|
var apply = function () {
|
|
var title = document.querySelector('title')
|
|
if (title && title.textContent.indexOf(suffix) === -1) {
|
|
title.textContent += suffix
|
|
}
|
|
}
|
|
var head = document.head || document.documentElement
|
|
new MutationObserver(apply).observe(head, { subtree: true, childList: true, characterData: true })
|
|
apply()
|
|
})()
|
|
EOF
|
|
'';
|
|
in
|
|
{
|
|
options.modules.networking.mihomo = {
|
|
enable = lib.mkEnableOption "the native Mihomo core with TUN and a local web UI";
|
|
resolvedIntegration = lib.mkEnableOption "route systemd-resolved DNS through the mihomo TUN interface";
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
services.mihomo = {
|
|
enable = true;
|
|
package = pkgs.mihomo;
|
|
inherit configFile;
|
|
inherit webui;
|
|
tunMode = true;
|
|
};
|
|
|
|
# The config deliberately stays out of the store, under the user's home. On
|
|
# tmpfs-root hosts that path is a persistent bind mount, so make the service
|
|
# wait for it: otherwise activation can start mihomo before the mount is up
|
|
# and fail with "Failed to set up credentials: No such file or directory".
|
|
systemd.services.mihomo.unitConfig.RequiresMountsFor = builtins.dirOf configFile;
|
|
|
|
# Geodata from nixpkgs: a cold start must not fetch it (its DNS is not up yet).
|
|
systemd.tmpfiles.settings.mihomo-geodata = {
|
|
"/var/lib/private/mihomo/GeoSite.dat"."L+".argument =
|
|
"${pkgs.v2ray-rules-dat}/share/v2ray/geosite.dat";
|
|
"/var/lib/private/mihomo/geoip.metadb"."L+".argument = pkgs.dbip-country-lite.mmdb;
|
|
"/var/lib/private/mihomo/ASN.mmdb"."L+".argument = pkgs.dbip-asn-lite.mmdb;
|
|
};
|
|
|
|
services.resolved.enable = lib.mkIf cfg.resolvedIntegration true;
|
|
networking.networkmanager =
|
|
lib.mkIf (cfg.resolvedIntegration && config.networking.networkmanager.enable)
|
|
{
|
|
dns = "systemd-resolved";
|
|
# NetworkManager owns physical links; mihomo owns this virtual link.
|
|
unmanaged = [ "interface-name:Meta" ];
|
|
};
|
|
systemd.services.mihomo.after = lib.optional cfg.resolvedIntegration "systemd-resolved.service";
|
|
systemd.services.mihomo.requires = lib.optional cfg.resolvedIntegration "systemd-resolved.service";
|
|
|
|
# Force the TUN's v6 address up: left to itself mihomo skips the v6 half when
|
|
# no NIC has an IPv6 address at startup, yet still installs its strict-route v6
|
|
# blackhole, which locks the host out of IPv6. Upstream knob (`inet6-address`,
|
|
# meta-docs tun.md); the full chain is WA-030.
|
|
systemd.services.mihomo.serviceConfig = {
|
|
Environment = [ "SKIP_SYSTEM_IPV6_CHECK=1" ];
|
|
}
|
|
// lib.optionalAttrs cfg.resolvedIntegration {
|
|
# Attach the catch-all to the TUN, leaving DHCP and more-specific VPN DNS
|
|
# routes untouched. Removing the TUN also removes its resolved settings.
|
|
# '+' allows the privileged D-Bus call outside mihomo's service sandbox.
|
|
# '-': a timed-out hook must not take the proxy down with it.
|
|
ExecStartPost = "-+${pkgs.coreutils}/bin/timeout 30 ${
|
|
lib.getExe (
|
|
pkgs.writeShellApplication {
|
|
name = "mihomo-resolved";
|
|
runtimeInputs = [
|
|
pkgs.dig
|
|
pkgs.systemd
|
|
];
|
|
text = ''
|
|
until (( SECONDS >= 25 )); do
|
|
if [[ -e /sys/class/net/Meta ]] \
|
|
&& dig +time=1 +tries=1 @127.0.0.1 -p 1053 localhost A >/dev/null \
|
|
&& resolvectl dns Meta 127.0.0.1:1053 \
|
|
&& resolvectl dnssec Meta no \
|
|
&& resolvectl dnsovertls Meta no \
|
|
&& resolvectl domain Meta '~.'; then
|
|
exit 0
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
echo "mihomo TUN or DNS did not become ready" >&2
|
|
exit 1
|
|
'';
|
|
}
|
|
)
|
|
}";
|
|
# The interface may already be gone after a clean stop or a crash.
|
|
ExecStopPost = "-+${pkgs.systemd}/bin/resolvectl revert Meta";
|
|
};
|
|
};
|
|
}
|