Skip to content

feat(#71): per-subscription DNS resolver + show sing-box version in About#67

Merged
hawkff merged 6 commits into
mainfrom
feat/per-sub-dns-and-version
Jun 25, 2026
Merged

feat(#71): per-subscription DNS resolver + show sing-box version in About#67
hawkff merged 6 commits into
mainfrom
feat/per-sub-dns-and-version

Conversation

@hawkff

@hawkff hawkff commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Implements #71 and fixes the About screen showing sing-box: unknown.

Per-subscription DNS resolver (#71)

Some providers host servers behind domains that only resolve via the provider's own DNS, so the global DNS cannot resolve them and those servers never connect. This lets a subscription specify its own resolver, used only for that subscription's server domains; everything else keeps the global DNS.

  • SubscriptionBean: new optional customDnsResolver (kryo serialize bump v2 → v3, read gated on version ≥ 3; existing stored subscriptions load unchanged). Kept out of shared subscription links.
  • UI: optional field in the subscription settings (neutral label) with strict validation — https://, tls://, quic:// scheme+host, or bare host[:port]; empty = unset.
  • ConfigBuilder: when a subscription has a resolver, emit a dedicated DNS server (detour=direct, address_resolver=dns-local so it never loops through the not-yet-connected outbound) plus a dns.rules entry matching only that subscription's final-hop server hostnames (full:), placed above the global force rule.
    • Empty resolver → nothing emitted, behavior identical to today.
    • No default resolver and no fallback DNS introduced.
    • Hosts shared by multiple subscriptions with different resolvers are ambiguous and stay on the global direct path.
  • Schema: reuses the legacy DNS server style already emitted by this fork, which sing-box 1.13 still accepts and auto-upgrades — no hand-written 1.13 type-based DNS shapes.

About version fix

constant.Version defaulted to unknown because libcore/build.sh did not pass the version ldflag. Now it injects -X .../constant.Version=<read_tag> (git-describe fallback), and get_source.sh fetches tags so read_tag resolves on CI. The About row will show the real version instead of unknown. No workflow changes required.

Verification

  • CodeRabbit CLI: clean (no findings).
  • Build + on-device E2E verification via Namespace CI (in progress).

Greptile Summary

This PR introduces two independent improvements: a per-subscription custom DNS resolver so that providers whose server domains only resolve through their own DNS can declare that resolver in the subscription settings, and a fix for the About screen always showing sing-box: unknown by generating a tiny init() Go file during the build instead of relying on unreliable gomobile ldflags forwarding.

  • Per-subscription DNS resolver: SubscriptionBean gains a customDnsResolver field (kryo v2→v3, backward-compatible), a new UI preference with strict scheme/host validation, and ConfigBuilder emits a dns-sub-<gid> server + rule per subscription group. An exclusivity-check (isExclusiveCustomHost) prevents the custom resolver from being applied to domains shared across subscriptions with different resolvers or non-custom profiles, which correctly fall back to global direct DNS.
  • Version fix: libcore/build.sh now generates version_gen.go with a sanitized version string sourced first from a new VERSION_SING_BOX pin in get_source_env.sh, then from read_tag, then from git describe. The generated file is git-ignored; get_source.sh fetches tags to make the fallbacks work on CI.

Confidence Score: 5/5

Safe to merge. The DNS routing logic is well-structured, exclusivity guards prevent cross-subscription resolver hijacking, and the kryo version bump is backward-compatible.

All changed code paths are correctly guarded: per-subscription DNS rules use TAG_DIRECT with address_resolver=dns-local (no bootstrap loop), hosts shared across subscriptions fall back to global direct DNS, and the version-gen approach sidesteps the longstanding gomobile ldflags forwarding problem cleanly. The only finding is a narrow validation false-positive in the UI input check that sing-box catches at runtime.

No files require special attention. The most complex file, ConfigBuilder.kt, has thorough inline comments and the logic maps directly to the described invariants.

Important Files Changed

Filename Overview
app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt Core of the feature: introduces exclusivity tracking maps, populates them per chain-hop, emits per-group DNS servers and rules inserted at position 0 above the global force-bypass rule. Logic is correct — disjoint host sets, graceful null/IP-address handling, and the domain-strategy fix uses dns-direct explicitly rather than the resolver tag.
app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java Clean v2→v3 Kryo serialization bump; new field only read when version >= 3, so existing stored subscriptions deserialize unchanged. Correctly excluded from serializeForShare.
app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt Adds preference wiring and the isValidCustomDnsResolver validator. Normalization (trim-then-reject) stores the trimmed value without re-triggering the listener. One minor edge case: URLs with userinfo pass validation because substringBeforeLast(':') extracts the username rather than the host, but sing-box catches it at runtime.
libcore/build.sh Replaces the unreliable ldflags approach with a generated init() file. Version sourced from a pinned env var (deterministic), read_tag, or git describe in that order; sanitized to [A-Za-z0-9.+-] before embedding; stale file cleaned up before each run.
buildScript/lib/core/get_source_env.sh Adds VERSION_SING_BOX pin alongside COMMIT_SING_BOX; comment instructs keeping them in sync. Removes CI dependency on tags being present in the clone.
buildScript/lib/core/get_source.sh Adds --tags --force to git fetch so that CI caches with stale tags get updated, enabling the read_tag and git describe fallbacks in build.sh.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[DNS query for hostname H] --> B{H matches a per-subscription rule?}
    B -- yes --> C[Resolve via custom resolver\ndetour=direct, address_resolver=dns-local]
    B -- no --> D{H in force-bypass list?}
    D -- yes --> E[Resolve via dns-direct]
    D -- no --> F{Avoid-loopback: query from proxy outbound?}
    F -- yes --> E
    F -- no --> G{User DNS rules match?}
    G -- yes --> H[Route per user rule]
    G -- no --> I[dns-remote / global proxy DNS]

    subgraph Build-time host classification
        J[Host in bypassDNSBeans] --> K{isExclusiveCustomHost?}
        K -- yes --> L[Added to perGroupServerHosts, kept out of force-bypass]
        K -- no --> M[Added to domainListDNSDirectForce]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[DNS query for hostname H] --> B{H matches a per-subscription rule?}
    B -- yes --> C[Resolve via custom resolver\ndetour=direct, address_resolver=dns-local]
    B -- no --> D{H in force-bypass list?}
    D -- yes --> E[Resolve via dns-direct]
    D -- no --> F{Avoid-loopback: query from proxy outbound?}
    F -- yes --> E
    F -- no --> G{User DNS rules match?}
    G -- yes --> H[Route per user rule]
    G -- no --> I[dns-remote / global proxy DNS]

    subgraph Build-time host classification
        J[Host in bypassDNSBeans] --> K{isExclusiveCustomHost?}
        K -- yes --> L[Added to perGroupServerHosts, kept out of force-bypass]
        K -- no --> M[Added to domainListDNSDirectForce]
    end
Loading

Reviews (3): Last reviewed commit: "fix(#71): scope per-sub resolver correct..." | Re-trigger Greptile

…bout

Per-subscription resolver:
- Add optional customDnsResolver to SubscriptionBean (kryo v2->3, gated read;
  kept out of shared links). Wire DataStore key + group_preferences UI with
  strict validation (https/tls/quic scheme+host, or host[:port], or empty).
- ConfigBuilder: for a subscription with a resolver set, emit a dedicated DNS
  server (detour=direct, address_resolver=dns-local) and a dns.rule matching
  ONLY that subscription's final-hop server domains, above the global force
  rule. Empty resolver = no change. Ambiguous shared hostnames (same domain,
  different resolvers) stay on the global direct path. No default/Google DNS.

About version:
- libcore/build.sh injects constant.Version via read_tag (git-describe
  fallback); get_source.sh fetches tags so read_tag resolves on CI. Fixes the
  About screen showing 'unknown'.
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds per-subscription custom DNS resolver storage, UI editing, validation, and DNS routing. Also updates sing-box source fetching and libcore build linking to carry the sing-box version when available.

Changes

Subscription custom DNS

Layer / File(s) Summary
Stored resolver value
app/src/main/java/io/nekohasekai/sagernet/Constants.kt, app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt, app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java
Adds the subscriptionCustomDns key and DataStore property, and extends SubscriptionBean with versioned serialization for customDnsResolver.
Settings field and validation
app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt, app/src/main/res/values/strings.xml, app/src/main/res/xml/group_preferences.xml
Adds the custom DNS preference, copies the value into and out of subscription data, and validates resolver input before saving.
DNS routing
app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
Tracks resolver hostnames per subscription and adds DNS servers and rules for matching subscription hostnames.

sing-box build versioning

Layer / File(s) Summary
Tag fetch update
buildScript/lib/core/get_source.sh
Changes the sing-box sync step to fetch tags with --force.
Version injection
libcore/build.sh
Detects the sing-box version from ../sing-box and prepends the matching linker flag when building the libcore binding.

Sequence Diagram(s)

sequenceDiagram
  participant GroupSettingsActivity
  participant DataStore
  participant SubscriptionBean
  participant ConfigBuilder

  GroupSettingsActivity->>DataStore: store subscriptionCustomDns
  GroupSettingsActivity->>SubscriptionBean: copy customDnsResolver
  ConfigBuilder->>SubscriptionBean: read customDnsResolver
  ConfigBuilder->>ConfigBuilder: build dns-sub-<gid> servers and rules
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

A bunny hopped through DNS поля, 🥕
and tucked each resolver in a neat soft way.
One hop for settings, one hop for the build,
one hop for routes that are custom-willed.
Thump! sing-box tags and carrots both stay fixed today.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two main changes: per-subscription DNS resolver support and the About version fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly matches the implemented per-subscription DNS resolver and About version fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

…ion ldflag

get_source.sh clones sing-box to the parent of the repo root; build.sh runs
from libcore/, so the clone is ../../sing-box, not ../sing-box. Probe a few
candidates so the constant.Version ldflag resolves on CI (the first build
logged 'could not determine sing-box version').

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt`:
- Around line 395-415: The resolver mapping in ConfigBuilder’s final-hop
handling is using proxyEntity.groupId, which points to the last hop instead of
the originating subscription group. Update this logic to derive the group from
the chain owner entity.groupId and apply the resolver to that subscription’s own
server hosts, so per-subscription DNS metadata is recorded for imported
subscription hostnames even when a frontProxy is present. Keep the existing
resolver lookup and host registration flow, but ensure the cache keys and host
associations are based on the originating subscription group, not the final hop.
- Around line 910-919: The hostname check in ConfigBuilder’s DNS rule generation
is too narrow because hostResolvers only tracks resolver-enabled subscriptions,
so shared hosts can still get exclusive custom routing. Update the logic around
the unambiguousCustom check and the later dns-sub rule emission to also consider
total host usage across all profiles, not just resolver-enabled ones. Only add
the custom per-subscription DNS rule when the hostname is exclusive to
resolver-enabled subscriptions; otherwise keep it in the global direct-DNS path
to avoid affecting normal/manual profiles.
- Around line 61-70: The ConfigBean host extraction in serverHostOf currently
returns too early from the JSON branch, so bean.serverAddress is never used when
the parsed config lacks a top-level server. Update serverHostOf to keep the
ConfigBean-specific JSON lookup but fall back to bean.serverAddress when
map["server"] is missing or blank, preserving the documented custom-resolver
host mapping behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7ac5d934-32c3-4ecf-82b4-c41908acdd1d

📥 Commits

Reviewing files that changed from the base of the PR and between 7896ef1 and b79310a.

📒 Files selected for processing (9)
  • app/src/main/java/io/nekohasekai/sagernet/Constants.kt
  • app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
  • app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java
  • app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
  • app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt
  • app/src/main/res/values/strings.xml
  • app/src/main/res/xml/group_preferences.xml
  • buildScript/lib/core/get_source.sh
  • libcore/build.sh

Comment thread app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt Outdated
Comment thread app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt Outdated
Comment thread app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt Outdated
Comment thread app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt Outdated
Comment thread libcore/build.sh Outdated
hawkff added 4 commits June 24, 2026 20:03
…lags

gomobile bind does not reliably forward -ldflags -X to the gobind-generated
package, so the previous ldflag approach left constant.Version='unknown'
(verified: no version string in libgojni.so). Generate libcore/version_gen.go
that sets constant.Version in init() when still default; compiled directly
into libcore. version_gen.go is gitignored.
…cribe

CI log showed 'sing-box version: unknown (from ../../sing-box)': read_tag
prints the literal 'unknown' when it cannot resolve a tag, which bypassed the
empty-string fallback. Now treat empty OR 'unknown' as failure and use
git describe --tags --always, plus fetch tags before resolving.
…ncy)

CI git describe only resolved a bare hash (no tags in the runner's sing-box
clone). Add explicit VERSION_SING_BOX pinned alongside COMMIT_SING_BOX as the
deterministic source for the About version; read_tag/git describe remain as
fallbacks. Update both together when bumping the core.
…ustom hosts)

Address review findings:
- serverHostOf: fall back to bean.serverAddress when ConfigBean JSON has no
  usable 'server' field.
- Derive resolver ownership from the chain owner (entity.groupId), not the last
  hop's group, so subscriptions with a front/landing proxy still scope their
  resolver to their own server hosts.
- Track all final-hop hosts of non-custom chains (every hop); only emit a
  per-subscription resolver rule for hosts exclusive to a single custom-resolver
  subscription (not shared with any normal profile), preventing DNS hijack.
- Use the direct domain strategy for the per-sub resolver server (reached via
  direct detour), not the server strategy.
@hawkff hawkff merged commit ff59da3 into main Jun 25, 2026
5 checks passed
@hawkff hawkff deleted the feat/per-sub-dns-and-version branch June 25, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant