@@ -11,7 +11,11 @@ package io.nekohasekai.sagernet.fmt.masterdnsvpn
1111
1212import io.nekohasekai.sagernet.fmt.LOCALHOST
1313import io.nekohasekai.sagernet.ktx.Logs
14+ import io.nekohasekai.sagernet.ktx.linkBuilder
1415import 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
1519import org.json.JSONArray
1620import org.json.JSONObject
1721
@@ -29,6 +33,11 @@ import org.json.JSONObject
2933 * @param protectPath absolute path to libcore's protect unix socket.
3034 */
3135fun 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+ }
0 commit comments