From 678fb2a2116016b438787c58a3866141974aefc8 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 3 Aug 2026 22:33:35 +0800 Subject: [PATCH 1/3] feat(core): add Tailscale override model Add a flat ConfigurationOverride.Tailscale data class that the Kotlin app serializes into the override JSON. A single 'enabled' toggle controls creation (allowing a fully-default node, e.g. after clearing a one-time auth-key or relying on tsnet interactive login). The node and group names are fixed constants on the kernel side, so there is no user-configurable name. Fields: enabled, hostname, authKey, controlUrl, stateDir, exitNode, ipCidrs. Kept as a plain @Serializable so it rides through the Android Parcel (IPC) path correctly. --- .../clash/core/model/ConfigurationOverride.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/core/src/main/java/com/github/kr328/clash/core/model/ConfigurationOverride.kt b/core/src/main/java/com/github/kr328/clash/core/model/ConfigurationOverride.kt index a5367b6654..e76e5d18c1 100644 --- a/core/src/main/java/com/github/kr328/clash/core/model/ConfigurationOverride.kt +++ b/core/src/main/java/com/github/kr328/clash/core/model/ConfigurationOverride.kt @@ -56,6 +56,9 @@ data class ConfigurationOverride( @SerialName("hosts") var hosts: Map? = null, + @SerialName("tailscale") + var tailscale: Tailscale = Tailscale(), + @SerialName("unified-delay") var unifiedDelay: Boolean? = null, @@ -249,6 +252,31 @@ data class ConfigurationOverride( var overrideDestination: Boolean? = null, ) + /** + * UI-facing flat representation of the tailscale override (single node + + * routing). Serialized as a plain `tailscale` object in the override JSON; + * the kernel-side patchTailscale processor (see + * core/.../config/process.go) expands it into proxies / proxy-groups / + * rules. Kept as a plain @Serializable so it rides through the Android + * Parcel (IPC) path correctly. + * + * The proxy/group names are fixed constants on the kernel side + * ("Tailscale" / "Tailscale-Group"); there is no user-configurable name. + * A single `enabled` toggle controls creation — this lets the user keep a + * fully-default node (e.g. after clearing a one-time auth-key, or relying + * on tsnet interactive login with no options at all). + */ + @Serializable + data class Tailscale( + var enabled: Boolean = false, + var hostname: String? = null, + var authKey: String? = null, + var controlUrl: String? = null, + var stateDir: String? = null, + var exitNode: String? = null, + var ipCidrs: List? = null, + ) + override fun writeToParcel(parcel: Parcel, flags: Int) { Parcelizer.encodeToParcel(serializer(), parcel, this) } From a540688f6be4b548198d93561d0c63dbf5a64da7 Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 3 Aug 2026 22:33:42 +0800 Subject: [PATCH 2/3] feat(core): inject Tailscale proxy, routing and DNS via override Expand the override 'tailscale' block into a proxy node, a select group, prepend rules and DNS wiring, all on the kernel side so the Kotlin model stays a plain flat object. Node and group names are fixed constants ('Tailscale' / 'Tailscale-Group') to keep them URL-safe in the 'tailscale://' nameserver-policy and free of collisions with mihomo built-in names. Before appending, any same-named proxy/group already present in the subscription is removed from BOTH cfg.Proxy and cfg.ProxyGroup, since mihomo registers proxies and groups in one shared map. DNS wiring: - nameserver-policy '+.ts.net' -> 'tailscale://Tailscale' (always overwritten, so enabling the feature owns MagicDNS resolution) - fake-ip-filter entry shaped per FakeIPFilterMode: '+.ts.net' for blacklist, nothing for whitelist (policy short-circuits), and 'DOMAIN-SUFFIX,ts.net,real-ip' for rule mode (a bare suffix would fail parseFakeIPRules). Routing rules prepend DOMAIN-SUFFIX,ts.net and the CGNAT range 100.64.0.0/10 (overridable via ipCidrs) to the Tailscale-Group. --- core/src/main/golang/native/config/process.go | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) diff --git a/core/src/main/golang/native/config/process.go b/core/src/main/golang/native/config/process.go index 0b60b1c520..254d14aadf 100644 --- a/core/src/main/golang/native/config/process.go +++ b/core/src/main/golang/native/config/process.go @@ -10,6 +10,7 @@ import ( "cfa/native/common" + "github.com/metacubex/mihomo/common/orderedmap" "github.com/metacubex/mihomo/common/utils" "github.com/metacubex/mihomo/config" C "github.com/metacubex/mihomo/constant" @@ -19,6 +20,7 @@ import ( var processors = []processor{ patchExternalController, // must before patchOverride, so we only apply ExternalController in Override settings patchOverride, + patchTailscale, // expand override `tailscale` (single node + routing) into proxies/groups/rules patchGeneral, patchProfile, patchDns, @@ -41,6 +43,174 @@ func patchOverride(cfg *config.RawConfig, _ string) error { return nil } +// tailscaleOverride mirrors the flat `tailscale` object the Kotlin app writes +// into the override JSON. json tags match the kotlinx serialization output +// (property names, since the Kotlin data class has no @SerialName annotations). +type tailscaleOverride struct { + Enabled bool `json:"enabled"` + Hostname string `json:"hostname,omitempty"` + AuthKey string `json:"authKey,omitempty"` + ControlURL string `json:"controlUrl,omitempty"` + StateDir string `json:"stateDir,omitempty"` + ExitNode string `json:"exitNode,omitempty"` + IpCidrs []string `json:"ipCidrs"` +} + +type tailscaleOverrideEnvelope struct { + Tailscale tailscaleOverride `json:"tailscale"` +} + +// loadTailscaleOverride parses the tailscale block from the persist override +// JSON. It is a pure function (no file I/O) so it can be unit-tested directly; +// callers pass ReadOverride(OverrideSlotPersist). The bool result reports +// whether tailscale is enabled. A single `enabled` toggle (rather than "any +// field non-empty") lets the user keep a fully-default node — e.g. after +// clearing a one-time auth-key while still using the persistent state dir, or +// relying on tsnet's interactive login with no options at all. +func loadTailscaleOverride(overrideJSON string) (tailscaleOverride, bool) { + var envelope tailscaleOverrideEnvelope + if err := json.Unmarshal([]byte(overrideJSON), &envelope); err != nil { + log.Warnln("Apply tailscale override: %s", err.Error()) + return tailscaleOverride{}, false + } + if !envelope.Tailscale.Enabled { + return tailscaleOverride{}, false + } + return envelope.Tailscale, true +} + +// Fixed names for the injected tailscale proxy and its select group. They are +// constants (not user-configurable) so they are always URL-safe in the +// "tailscale://" nameserver-policy and never collide with mihomo's +// built-in proxy names (DIRECT/REJECT/...). A proxy and a group may NOT share +// a name — mihomo registers both in one map (config.go proxies[...]) — hence +// the distinct "-Group" suffix. +const ( + tailscaleProxyName = "Tailscale" + tailscaleGroupName = "Tailscale-Group" +) + +// patchTailscale expands the override `tailscale` block into a proxy node, a +// select group and prepend rules, all appended/prepended on top of the +// subscription config. Keeping the expansion on the Go side lets the Kotlin +// model stay a plain flat object (no custom serializer), which is safe for the +// Android Parcel IPC path. +// +// Any same-named proxy/group already present in the subscription is removed +// first, so re-applying the override (or a subscription that happens to define +// its own "Tailscale") replaces rather than triggers mihomo's "duplicate name" +// error. Both fixed names are cleared from BOTH cfg.Proxy and cfg.ProxyGroup, +// because mihomo registers proxies and groups in one shared map — a group named +// "Tailscale" would clash with our proxy even though we only append a group +// named "Tailscale-Group", and vice versa. +func patchTailscale(cfg *config.RawConfig, _ string) error { + ts, ok := loadTailscaleOverride(ReadOverride(OverrideSlotPersist)) + if !ok { + return nil + } + + removeProxyByName(cfg, tailscaleProxyName) + removeProxyGroupByName(cfg, tailscaleProxyName) + removeProxyByName(cfg, tailscaleGroupName) + removeProxyGroupByName(cfg, tailscaleGroupName) + + // 1. append the tailscale proxy node. Only non-empty option fields are set + // so mihomo applies its own defaults for the rest. + proxy := map[string]any{ + "name": tailscaleProxyName, + "type": "tailscale", + } + if ts.Hostname != "" { + proxy["hostname"] = ts.Hostname + } + if ts.AuthKey != "" { + proxy["auth-key"] = ts.AuthKey + } + if ts.ControlURL != "" { + proxy["control-url"] = ts.ControlURL + } + if ts.StateDir != "" { + proxy["state-dir"] = ts.StateDir + } + if ts.ExitNode != "" { + proxy["exit-node"] = ts.ExitNode + } + cfg.Proxy = append(cfg.Proxy, proxy) + log.Infoln("Append tailscale proxy: %s", tailscaleProxyName) + + // 2. select group + prepend rules routing tailnet traffic to it. + // + // CMFA forces fake-ip mode. mihomo returns a synthetic IP for a name, + // then maps it back to the host on the connection; rules then match on + // that host. Two rules cover tailnet access: + // - DOMAIN-SUFFIX,ts.net: all tailnet FQDNs (nas.tail.ts.net). + // Tailscale short names (e.g. "nas") are NOT supported: the tsnet + // QueryDNS path does not append the MagicDNS search suffix, so they + // cannot be resolved. Users must use full *.ts.net names or IPs. + // - IP-CIDR 100.64.0.0/10: the CGNAT range tailscale allocates from. + // + // Note: the +.ts.net fake-ip filter and the nameserver-policy are added in + // patchDns (below), not here, because patchDns runs AFTER patchTailscale + // and would otherwise reset cfg.DNS for subscriptions that don't enable + // their own DNS. + cfg.ProxyGroup = append(cfg.ProxyGroup, map[string]any{ + "name": tailscaleGroupName, + "type": "select", + "proxies": []string{tailscaleProxyName}, + }) + + // Default IP range: Tailscale allocates node IPs from the CGNAT block + // 100.64.0.0/10, which is identical for every tailnet. + ips := ts.IpCidrs + if len(ips) == 0 { + ips = []string{"100.64.0.0/10"} + } + rules := make([]string, 0, len(ips)+1) + + // Cover all tailnet FQDNs (*.ts.net) automatically. + rules = append(rules, "DOMAIN-SUFFIX,ts.net,"+tailscaleGroupName) + + // Direct IP access into the tailnet. + for _, c := range ips { + rules = append(rules, "IP-CIDR,"+c+","+tailscaleGroupName+",no-resolve") + } + + merged := make([]string, 0, len(rules)+len(cfg.Rule)) + merged = append(merged, rules...) + merged = append(merged, cfg.Rule...) + cfg.Rule = merged + + log.Infoln("Apply tailscale routing: group=%s rules=%d", tailscaleGroupName, len(rules)) + + return nil +} + +// removeProxyByName drops every proxy whose "name" equals name from cfg.Proxy. +// Used to overwrite a same-named entry the subscription already ships before +// appending our own, avoiding mihomo's "duplicate name" load error. +func removeProxyByName(cfg *config.RawConfig, name string) { + filtered := cfg.Proxy[:0] + for _, p := range cfg.Proxy { + if n, _ := p["name"].(string); n == name { + continue + } + filtered = append(filtered, p) + } + cfg.Proxy = filtered +} + +// removeProxyGroupByName is the proxy-group counterpart of removeProxyByName. +func removeProxyGroupByName(cfg *config.RawConfig, name string) { + filtered := cfg.ProxyGroup[:0] + for _, g := range cfg.ProxyGroup { + if n, _ := g["name"].(string); n == name { + continue + } + filtered = append(filtered, g) + } + cfg.ProxyGroup = filtered +} + func patchExternalController(cfg *config.RawConfig, _ string) error { cfg.ExternalController = "" cfg.ExternalControllerTLS = "" @@ -81,9 +251,86 @@ func patchDns(cfg *config.RawConfig, _ string) error { cfg.DNS.NameServer = append(cfg.DNS.NameServer, "system://") } + // Route *.ts.net to the Tailscale MagicDNS transport when tailscale is + // configured. This runs after the default/reset block above so it survives + // the DNS replacement for subscriptions without their own DNS. + if _, ok := loadTailscaleOverride(ReadOverride(OverrideSlotPersist)); ok { + applyTailscaleDNS(&cfg.DNS) + } + return nil } +// tsPolicyKey / tsFilterEntry / tsFilterRule are the domain keys injected into +// DNS policy and fake-ip-filter. The key form "+.ts.net" is accepted by both +// parseNameServerPolicy (via ValidAndSplitDomain) and the fake-ip trie. +const ( + tsPolicyKey = "+.ts.net" + tsFilterEntry = "+.ts.net" + // tsFilterRule is the rule-mode form of the same intent: a real-ip action so + // the domain is excluded from fake-ip. parseFakeIPRules requires an explicit + // action suffix; a bare "+.ts.net" would fail config parsing. + tsFilterRule = "DOMAIN-SUFFIX,ts.net,real-ip" +) + +// applyTailscaleDNS wires *.ts.net to the registered tailscale:// DNS transport +// and keeps the domain out of fake-ip. Both must be done for MagicDNS FQDNs to +// resolve correctly: +// +// 1. nameserver-policy: mihomo only instantiates the tailscale DNS client when +// the scheme "tailscale://" appears in a nameserver / policy slot. +// Without this entry the transport is registered but never queried, so FQDNs +// fall through to the system DNS and fail. +// +// 2. fake-ip-filter: the fake-ip middleware consults the skipper before any +// upstream, so an unlisted *.ts.net would get a synthetic IP and never reach +// the policy. The correct filter entry depends on the mode: +// - blacklist (default): "+.ts.net" is a skip entry → real DNS (the policy). +// - whitelist: only listed domains use fake-ip; since we don't list *.ts.net +// it is skipped out of fake-ip and falls through to the resolver/policy. +// Adding "+.ts.net" here would be wrong — it would FORCE fake-ip for the +// domain, the opposite of what we want. +// - rule: filter entries must be full rules with an action, otherwise +// parseFakeIPRules rejects the whole config. +// +// The policy entry is always written (overwriting any subscription value for +// "+.ts.net"), because enabling tailscale means THIS node must own MagicDNS +// resolution. The node name is the fixed tailscaleProxyName constant. +func applyTailscaleDNS(dns *config.RawDNS) { + // (1) nameserver-policy → tailscale://. + if dns.NameServerPolicy == nil { + dns.NameServerPolicy = orderedmap.New[string, any]() + } + dns.NameServerPolicy.Set(tsPolicyKey, "tailscale://"+tailscaleProxyName) + + // (2) fake-ip-filter, mode-dependent. + switch dns.FakeIPFilterMode { + case C.FilterBlackList: + if !containsString(dns.FakeIPFilter, tsFilterEntry) { + dns.FakeIPFilter = append(dns.FakeIPFilter, tsFilterEntry) + } + case C.FilterWhiteList: + // Intentionally do nothing. In whitelist mode only listed domains use + // fake-ip; *.ts.net is not listed, so the skipper already routes it to + // real DNS where the policy above answers it. Adding "+.ts.net" here + // would invert the intent and force fake-ip for the domain. + case C.FilterRule: + if !containsString(dns.FakeIPFilter, tsFilterRule) { + dns.FakeIPFilter = append(dns.FakeIPFilter, tsFilterRule) + } + } +} + +// containsString reports whether s is in list. +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + func patchTun(cfg *config.RawConfig, _ string) error { cfg.Tun.Enable = false cfg.Tun.AutoRoute = false From 8ab99c1fda4a47d2d195b6228e32cd262c3b198f Mon Sep 17 00:00:00 2001 From: yourtion Date: Mon, 3 Aug 2026 22:33:47 +0800 Subject: [PATCH 3/3] feat(design): add Tailscale override UI Add a Tailscale category to the override settings screen with an 'enabled' switch and editable fields for auth-key, hostname, control URL, state directory, exit node and route IP CIDRs. All fields below the switch are disabled until it is turned on. String resources added for all 8 locales (en, zh, zh-rTW, zh-rHK, ja, ko, ru, vi). --- .../clash/design/OverrideSettingsDesign.kt | 73 +++++++++++++++++++ design/src/main/res/values-ja-rJP/strings.xml | 11 +++ design/src/main/res/values-ko-rKR/strings.xml | 11 +++ design/src/main/res/values-ru/strings.xml | 11 +++ design/src/main/res/values-vi/strings.xml | 11 +++ design/src/main/res/values-zh-rHK/strings.xml | 11 +++ design/src/main/res/values-zh-rTW/strings.xml | 11 +++ design/src/main/res/values-zh/strings.xml | 11 +++ design/src/main/res/values/strings.xml | 11 +++ 9 files changed, 161 insertions(+) diff --git a/design/src/main/java/com/github/kr328/clash/design/OverrideSettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/OverrideSettingsDesign.kt index ea4d41215c..f3e2c286fa 100644 --- a/design/src/main/java/com/github/kr328/clash/design/OverrideSettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/OverrideSettingsDesign.kt @@ -225,6 +225,79 @@ class OverrideSettingsDesign( placeholder = R.string.dont_modify, ) + category(R.string.tailscale) + + val tailscaleDeps: MutableList = mutableListOf() + + switch( + value = configuration.tailscale::enabled, + title = R.string.tailscale_enable, + summary = R.string.tailscale_enable_summary, + ) { + listener = OnChangedListener { + tailscaleDeps.forEach { + it.enabled = configuration.tailscale.enabled + } + } + } + + editableText( + value = configuration.tailscale::authKey, + adapter = NullableTextAdapter.String, + title = R.string.tailscale_field_auth_key, + placeholder = R.string.dont_modify, + empty = R.string.disabled, + configure = tailscaleDeps::add, + ) + + editableText( + value = configuration.tailscale::hostname, + adapter = NullableTextAdapter.String, + title = R.string.tailscale_field_hostname, + placeholder = R.string.dont_modify, + empty = R.string.disabled, + configure = tailscaleDeps::add, + ) + + editableText( + value = configuration.tailscale::controlUrl, + adapter = NullableTextAdapter.String, + title = R.string.tailscale_field_control_url, + placeholder = R.string.dont_modify, + empty = R.string.disabled, + configure = tailscaleDeps::add, + ) + + editableText( + value = configuration.tailscale::stateDir, + adapter = NullableTextAdapter.String, + title = R.string.tailscale_field_state_dir, + placeholder = R.string.dont_modify, + empty = R.string.disabled, + configure = tailscaleDeps::add, + ) + + editableText( + value = configuration.tailscale::exitNode, + adapter = NullableTextAdapter.String, + title = R.string.tailscale_field_exit_node, + placeholder = R.string.dont_modify, + empty = R.string.disabled, + configure = tailscaleDeps::add, + ) + + editableTextList( + value = configuration.tailscale::ipCidrs, + adapter = TextAdapter.String, + title = R.string.tailscale_ip_cidrs, + placeholder = R.string.tailscale_cidrs_hint, + configure = tailscaleDeps::add, + ) + + tailscaleDeps.forEach { + it.enabled = configuration.tailscale.enabled + } + category(R.string.dns) val dnsDependencies: MutableList = mutableListOf() diff --git a/design/src/main/res/values-ja-rJP/strings.xml b/design/src/main/res/values-ja-rJP/strings.xml index e38b8bf030..d7e4a5bf29 100644 --- a/design/src/main/res/values-ja-rJP/strings.xml +++ b/design/src/main/res/values-ja-rJP/strings.xml @@ -264,4 +264,15 @@ Override Destination カメラのアクセスが制限されています。設定から有効にしてください。 システムで予期しない例外が発生しました。 + + Tailscale +tEnable Tailscale +tCreate a Tailscale proxy node and route tailnet traffic to it + Hostname + Auth key + Control URL (optional, for headscale) + State directory + Exit node + Route IP CIDRs + empty = 100.64.0.0/10 diff --git a/design/src/main/res/values-ko-rKR/strings.xml b/design/src/main/res/values-ko-rKR/strings.xml index bc22550af8..900532f365 100644 --- a/design/src/main/res/values-ko-rKR/strings.xml +++ b/design/src/main/res/values-ko-rKR/strings.xml @@ -264,4 +264,15 @@ Override Destination 카메라 접근이 제한되었습니다. 설정에서 허용해 주세요. 처리되지 않은 시스템 예외가 발생했습니다. + + Tailscale +tEnable Tailscale +tCreate a Tailscale proxy node and route tailnet traffic to it + Hostname + Auth key + Control URL (optional, for headscale) + State directory + Exit node + Route IP CIDRs + empty = 100.64.0.0/10 diff --git a/design/src/main/res/values-ru/strings.xml b/design/src/main/res/values-ru/strings.xml index 4f680de2c5..0e7ca1f666 100644 --- a/design/src/main/res/values-ru/strings.xml +++ b/design/src/main/res/values-ru/strings.xml @@ -328,4 +328,15 @@ Override Destination Доступ к камере ограничен. Разрешите его в настройках. Произошла не обрабатываемая системная ошибка. + + Tailscale +tEnable Tailscale +tCreate a Tailscale proxy node and route tailnet traffic to it + Hostname + Auth key + Control URL (optional, for headscale) + State directory + Exit node + Route IP CIDRs + empty = 100.64.0.0/10 diff --git a/design/src/main/res/values-vi/strings.xml b/design/src/main/res/values-vi/strings.xml index 2abca48934..0f5d8fc646 100644 --- a/design/src/main/res/values-vi/strings.xml +++ b/design/src/main/res/values-vi/strings.xml @@ -250,4 +250,15 @@ Nhập từ Mã QR Quyền truy cập camera bị hạn chế. Vui lòng bật trong Cài đặt. Đã xảy ra ngoại lệ hệ thống không xử lý được. + + Tailscale +tEnable Tailscale +tCreate a Tailscale proxy node and route tailnet traffic to it + Hostname + Auth key + Control URL (optional, for headscale) + State directory + Exit node + Route IP CIDRs + empty = 100.64.0.0/10 diff --git a/design/src/main/res/values-zh-rHK/strings.xml b/design/src/main/res/values-zh-rHK/strings.xml index a63dff70a4..62d51a7e12 100644 --- a/design/src/main/res/values-zh-rHK/strings.xml +++ b/design/src/main/res/values-zh-rHK/strings.xml @@ -261,4 +261,15 @@ Override Destination 相機權限受限,請前往設定開啟。 發生系統未知異常,操作失敗。 + + Tailscale +t啟用 Tailscale +t建立 Tailscale 代理節點並將尾網流量路由到它 + 主機名稱 + 認證金鑰 + 控制伺服器位址(可選,用於自建 headscale) + 狀態目錄 + 出口節點 + 路由 IP 段 + 留空 = 100.64.0.0/10 diff --git a/design/src/main/res/values-zh-rTW/strings.xml b/design/src/main/res/values-zh-rTW/strings.xml index 4bc185b77d..89d091bd84 100644 --- a/design/src/main/res/values-zh-rTW/strings.xml +++ b/design/src/main/res/values-zh-rTW/strings.xml @@ -261,4 +261,15 @@ Override Destination 相機權限受限,請前往設定開啟。 發生系統未知異常,操作失敗。 + + Tailscale +t啟用 Tailscale +t建立 Tailscale 代理節點並將尾網流量路由到它 + 主機名稱 + 認證金鑰 + 控制伺服器位址(可選,用於自建 headscale) + 狀態目錄 + 出口節點 + 路由 IP 段 + 留空 = 100.64.0.0/10 diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index be62f50f54..5709e12d40 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -280,4 +280,15 @@ 启动 Clash 服务 停止 Clash 停止 Clash 服务 + + Tailscale +t启用 Tailscale +t创建 Tailscale 代理节点并将尾网流量路由到它 + 主机名 + 认证密钥 + 控制服务器地址(可选,用于自建 headscale) + 状态目录 + 出口节点 + 路由 IP 段 + 留空 = 100.64.0.0/10 diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 2fe54fd226..e5a1176d67 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -370,4 +370,15 @@ Start Clash service Stop Clash Stop Clash service + + Tailscale + Enable Tailscale + Create a Tailscale proxy node and route tailnet traffic to it + Hostname + Auth key + Control URL (optional, for headscale) + State directory + Exit node + Route IP CIDRs + empty = 100.64.0.0/10