Skip to content

Commit e495487

Browse files
committed
feat: add masterdns:// share link + QR import for MasterDnsVPN
Implement toUri()/parseMasterDnsVpn() so MasterDnsVPN profiles can be shared and imported via a friendly masterdns:// scheme, in addition to the existing universal sn://masterdnsvpn link. The encryption key is carried as URL userinfo; tunnel domains and resolvers as comma-joined query params; only non-default tuning values are emitted to keep links short (defaults re-applied on parse via initializeDefaultValues). - Wire parseMasterDnsVpn into parseProxies dispatch (Formats.kt) - Wire toUri into ProxyEntity.toStdLink so the standard QR/clipboard share menu uses the new scheme - Register masterdns scheme in the profile-import intent-filter for external deep links QR scanning already routes through RawUpdater.parseRaw -> parseProxies, so scanning a masterdns:// code now imports directly.
1 parent 9c0ba5b commit e495487

4 files changed

Lines changed: 171 additions & 0 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
<data android:scheme="naive+https" />
135135
<data android:scheme="naive+quic" />
136136
<data android:scheme="hysteria" />
137+
<data android:scheme="masterdns" />
137138

138139
</intent-filter>
139140

app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import io.nekohasekai.sagernet.fmt.http.toUri
1212
import io.nekohasekai.sagernet.fmt.hysteria.*
1313
import io.nekohasekai.sagernet.fmt.internal.ChainBean
1414
import io.nekohasekai.sagernet.fmt.masterdnsvpn.MasterDnsVpnBean
15+
import io.nekohasekai.sagernet.fmt.masterdnsvpn.toUri as toMasterDnsVpnUri
1516
import io.nekohasekai.sagernet.fmt.mieru.MieruBean
1617
import io.nekohasekai.sagernet.fmt.mieru.buildMieruConfig
1718
import io.nekohasekai.sagernet.fmt.naive.NaiveBean
@@ -288,6 +289,7 @@ data class ProxyEntity(
288289
is JuicityBean -> toUri()
289290
is AnyTLSBean -> toUri()
290291
is SnellBean -> toUri()
292+
is MasterDnsVpnBean -> toMasterDnsVpnUri()
291293
is NekoBean -> ""
292294
else -> toUniversalLink()
293295
}

app/src/main/java/io/nekohasekai/sagernet/fmt/masterdnsvpn/MasterDnsVpnFmt.kt

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ package io.nekohasekai.sagernet.fmt.masterdnsvpn
1111

1212
import io.nekohasekai.sagernet.fmt.LOCALHOST
1313
import io.nekohasekai.sagernet.ktx.Logs
14+
import io.nekohasekai.sagernet.ktx.linkBuilder
1415
import io.nekohasekai.sagernet.ktx.toStringPretty
16+
import io.nekohasekai.sagernet.ktx.toLink
17+
import io.nekohasekai.sagernet.ktx.urlSafe
18+
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
1519
import org.json.JSONArray
1620
import org.json.JSONObject
1721

@@ -29,6 +33,11 @@ import org.json.JSONObject
2933
* @param protectPath absolute path to libcore's protect unix socket.
3034
*/
3135
fun MasterDnsVpnBean.buildMasterDnsVpnConfig(port: Int, protectPath: String): String {
36+
// Encryption (method != 0 == not None) requires a key that matches the server; emitting
37+
// a blank ENCRYPTION_KEY would let the sidecar fail opaquely at runtime. Fail fast here.
38+
if (dataEncryptionMethod != 0 && encryptionKey.isBlank()) {
39+
error("MasterDnsVPN: encryption method $dataEncryptionMethod requires a non-empty key")
40+
}
3241
val cfg = JSONObject().apply {
3342
put("PROTOCOL_TYPE", "SOCKS5")
3443
put("LISTEN_IP", LOCALHOST)
@@ -103,3 +112,154 @@ fun MasterDnsVpnBean.resolverLines(): String {
103112
.filter { it.isNotEmpty() }
104113
.joinToString("\n")
105114
}
115+
116+
/**
117+
* Serializes a MasterDnsVPN profile to a shareable `masterdns://` link.
118+
*
119+
* Shape: `masterdns://<encryptionKey>@<firstDomain>?<params>#<name>`. The encryption key
120+
* is carried as URL userinfo; the first tunnel domain is used as a human-readable host
121+
* (the protocol has no real server host). Comma-joined lists are used for the multi-value
122+
* `domains` and `resolvers` fields. Only non-default tuning values are emitted to keep the
123+
* link short; [parseMasterDnsVpn] re-applies defaults via [MasterDnsVpnBean.initializeDefaultValues].
124+
*/
125+
fun MasterDnsVpnBean.toUri(): String {
126+
val domainList = domains.split("\n", ",").map { it.trim() }.filter { it.isNotEmpty() }
127+
val host = domainList.firstOrNull() ?: "masterdnsvpn"
128+
129+
val builder = linkBuilder()
130+
.host(host)
131+
.username(encryptionKey)
132+
133+
builder.addQueryParameter("domains", domainList.joinToString(","))
134+
builder.addQueryParameter("enc", dataEncryptionMethod.toString())
135+
136+
val resolverList = resolvers.split("\n", ",").map { it.trim() }.filter { it.isNotEmpty() }
137+
if (resolverList.isNotEmpty()) {
138+
builder.addQueryParameter("resolvers", resolverList.joinToString(","))
139+
}
140+
141+
val defaults = MasterDnsVpnBean().apply { initializeDefaultValues() }
142+
if (resolverBalancingStrategy != defaults.resolverBalancingStrategy) {
143+
builder.addQueryParameter("balance", resolverBalancingStrategy.toString())
144+
}
145+
if (packetDuplicationCount != defaults.packetDuplicationCount) {
146+
builder.addQueryParameter("dup", packetDuplicationCount.toString())
147+
}
148+
if (setupPacketDuplicationCount != defaults.setupPacketDuplicationCount) {
149+
builder.addQueryParameter("setupDup", setupPacketDuplicationCount.toString())
150+
}
151+
if (autoDisableTimeoutServers != defaults.autoDisableTimeoutServers) {
152+
builder.addQueryParameter("autoDisableTimeout", if (autoDisableTimeoutServers) "1" else "0")
153+
}
154+
if (autoRemoveLowMtuServers != defaults.autoRemoveLowMtuServers) {
155+
builder.addQueryParameter("autoRemoveLowMtu", if (autoRemoveLowMtuServers) "1" else "0")
156+
}
157+
if (baseEncodeData != defaults.baseEncodeData) {
158+
builder.addQueryParameter("baseEncode", if (baseEncodeData) "1" else "0")
159+
}
160+
if (uploadCompressionType != defaults.uploadCompressionType) {
161+
builder.addQueryParameter("upComp", uploadCompressionType.toString())
162+
}
163+
if (downloadCompressionType != defaults.downloadCompressionType) {
164+
builder.addQueryParameter("downComp", downloadCompressionType.toString())
165+
}
166+
if (compressionMinSize != defaults.compressionMinSize) {
167+
builder.addQueryParameter("compMinSize", compressionMinSize.toString())
168+
}
169+
if (minUploadMtu != defaults.minUploadMtu) {
170+
builder.addQueryParameter("minUpMtu", minUploadMtu.toString())
171+
}
172+
if (minDownloadMtu != defaults.minDownloadMtu) {
173+
builder.addQueryParameter("minDownMtu", minDownloadMtu.toString())
174+
}
175+
if (maxUploadMtu != defaults.maxUploadMtu) {
176+
builder.addQueryParameter("maxUpMtu", maxUploadMtu.toString())
177+
}
178+
if (maxDownloadMtu != defaults.maxDownloadMtu) {
179+
builder.addQueryParameter("maxDownMtu", maxDownloadMtu.toString())
180+
}
181+
if (localDnsEnabled != defaults.localDnsEnabled) {
182+
builder.addQueryParameter("localDns", if (localDnsEnabled) "1" else "0")
183+
}
184+
if (localDnsPort != defaults.localDnsPort) {
185+
builder.addQueryParameter("localDnsPort", localDnsPort.toString())
186+
}
187+
if (logLevel != defaults.logLevel) {
188+
builder.addQueryParameter("log", logLevel)
189+
}
190+
191+
if (name.isNotBlank()) {
192+
builder.encodedFragment(name.urlSafe())
193+
}
194+
195+
return builder.toLink("masterdns", appendDefaultPort = false)
196+
}
197+
198+
/**
199+
* Parses a `masterdns://` share link into a [MasterDnsVpnBean]. Tolerant of missing optional
200+
* params (defaults are filled by [MasterDnsVpnBean.initializeDefaultValues]).
201+
*/
202+
fun parseMasterDnsVpn(url: String): MasterDnsVpnBean {
203+
val link = url.replace("masterdns://", "https://").toHttpUrlOrNull()
204+
?: error("invalid masterdns link $url")
205+
return MasterDnsVpnBean().apply {
206+
// serverAddress/Port are unused by this protocol; keep the placeholder.
207+
serverAddress = "masterdnsvpn"
208+
209+
encryptionKey = link.username
210+
211+
link.queryParameter("domains")?.also {
212+
domains = it.split(",").map { d -> d.trim() }.filter { d -> d.isNotEmpty() }
213+
.joinToString("\n")
214+
}
215+
if (domains.isNullOrEmpty() && link.host.isNotBlank() && link.host != "masterdnsvpn") {
216+
// Fall back to the host segment as the single tunnel domain.
217+
domains = link.host
218+
}
219+
link.queryParameter("enc")?.toIntOrNull()?.also { dataEncryptionMethod = it }
220+
link.queryParameter("resolvers")?.also {
221+
resolvers = it.split(",").map { r -> r.trim() }.filter { r -> r.isNotEmpty() }
222+
.joinToString("\n")
223+
}
224+
225+
link.queryParameter("balance")?.toIntOrNull()?.also { resolverBalancingStrategy = it }
226+
link.queryParameter("dup")?.toIntOrNull()?.also { packetDuplicationCount = it }
227+
link.queryParameter("setupDup")?.toIntOrNull()?.also { setupPacketDuplicationCount = it }
228+
link.queryParameter("autoDisableTimeout")?.also {
229+
autoDisableTimeoutServers = it == "1" || it == "true"
230+
}
231+
link.queryParameter("autoRemoveLowMtu")?.also {
232+
autoRemoveLowMtuServers = it == "1" || it == "true"
233+
}
234+
link.queryParameter("baseEncode")?.also { baseEncodeData = it == "1" || it == "true" }
235+
link.queryParameter("upComp")?.toIntOrNull()?.also { uploadCompressionType = it }
236+
link.queryParameter("downComp")?.toIntOrNull()?.also { downloadCompressionType = it }
237+
link.queryParameter("compMinSize")?.toIntOrNull()?.also { compressionMinSize = it }
238+
link.queryParameter("minUpMtu")?.toIntOrNull()?.also { minUploadMtu = it }
239+
link.queryParameter("minDownMtu")?.toIntOrNull()?.also { minDownloadMtu = it }
240+
link.queryParameter("maxUpMtu")?.toIntOrNull()?.also { maxUploadMtu = it }
241+
link.queryParameter("maxDownMtu")?.toIntOrNull()?.also { maxDownloadMtu = it }
242+
link.queryParameter("localDns")?.also { localDnsEnabled = it == "1" || it == "true" }
243+
link.queryParameter("localDnsPort")?.toIntOrNull()?.also { localDnsPort = it }
244+
link.queryParameter("log")?.also { logLevel = it }
245+
246+
name = link.fragment ?: ""
247+
248+
// Ensure every field is non-null before any consumer (toUri / config builder /
249+
// resolverLines) touches it; the import pipeline also calls this, but a freshly
250+
// parsed bean may be read before that happens.
251+
initializeDefaultValues()
252+
253+
// A profile with no tunnel domain can never connect; fail the import early with a
254+
// clear message instead of silently creating a broken entry.
255+
if (domains.isBlank()) {
256+
error("masterdns link missing tunnel domain(s)")
257+
}
258+
259+
// When encryption is enabled the key is mandatory (and must match the server).
260+
// dataEncryptionMethod 0 == None, where an empty key is intentionally allowed.
261+
if (dataEncryptionMethod != 0 && encryptionKey.isBlank()) {
262+
error("masterdns link missing encryption key for encryption method $dataEncryptionMethod")
263+
}
264+
}
265+
}

app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import io.nekohasekai.sagernet.fmt.Serializable
66
import io.nekohasekai.sagernet.fmt.http.parseHttp
77
import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria1
88
import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria2
9+
import io.nekohasekai.sagernet.fmt.masterdnsvpn.parseMasterDnsVpn
910
import io.nekohasekai.sagernet.fmt.naive.parseNaive
1011
import io.nekohasekai.sagernet.fmt.parseUniversal
1112
import io.nekohasekai.sagernet.fmt.shadowsocks.parseShadowsocks
@@ -241,6 +242,13 @@ suspend fun parseProxies(text: String): List<AbstractBean> {
241242
}.onFailure {
242243
Logs.w(it)
243244
}
245+
} else if (startsWith("masterdns://")) {
246+
Logs.d("Try parse MasterDnsVPN link: $this")
247+
runCatching {
248+
entities.add(parseMasterDnsVpn(this))
249+
}.onFailure {
250+
Logs.w(it)
251+
}
244252
}
245253
}
246254

0 commit comments

Comments
 (0)