Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions core/src/main/golang/native/config/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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://<name>" 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<hex>.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 = ""
Expand Down Expand Up @@ -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://<node>" 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://<node>.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ data class ConfigurationOverride(
@SerialName("hosts")
var hosts: Map<String, String>? = null,

@SerialName("tailscale")
var tailscale: Tailscale = Tailscale(),

@SerialName("unified-delay")
var unifiedDelay: Boolean? = null,

Expand Down Expand Up @@ -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<String>? = null,
)

override fun writeToParcel(parcel: Parcel, flags: Int) {
Parcelizer.encodeToParcel(serializer(), parcel, this)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,79 @@ class OverrideSettingsDesign(
placeholder = R.string.dont_modify,
)

category(R.string.tailscale)

val tailscaleDeps: MutableList<Preference> = 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<Preference> = mutableListOf()
Expand Down
Loading