____ _ _______ __
/ __ \ | /| / / __/ |/ /
/ /_/ / |/ |/ / _// /
\____/|__/|__/___/_||_/
owenclave / app/src/main/java/io/nekohasekai/sagernet/fmt/shadowquic/ShadowQUICFmt.kt
/******************************************************************************
* *
* Copyright (C) 2025 dyhkwong *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see . *
* *
******************************************************************************/
package io.nekohasekai.sagernet.fmt.shadowquic
import io.nekohasekai.sagernet.Key
import io.nekohasekai.sagernet.LogLevel
import io.nekohasekai.sagernet.SagerNet
import io.nekohasekai.sagernet.TunImplementation
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.fmt.LOCALHOST
import io.nekohasekai.sagernet.ktx.joinHostPort
import io.nekohasekai.sagernet.ktx.listByLineOrComma
import io.nekohasekai.sagernet.ktx.queryParameter
import libexclavecore.Libexclavecore
import org.yaml.snakeyaml.DumperOptions
import org.yaml.snakeyaml.Yaml
import java.io.File
import kotlin.text.ifEmpty
// https://github.com/RealBikiniBottom/QuicProxy/discussions/2
// https://github.com/spongebob888/shadowquic/discussions/160
// third-party share link standard endorsed by the ShadowQUIC author
fun parseShadowQUIC(url: String): ShadowQUICBean {
val link = Libexclavecore.parseURL(url)
return ShadowQUICBean().apply {
name = link.fragment
serverAddress = link.host.ifEmpty { error("empty host") }
serverPort = link.port.takeIf { it > 0 } ?: 443
username = link.username.ifEmpty { error("missing username") }
password = link.password.ifEmpty { error("missing password") }
sni = link.queryParameter("sni")?.ifEmpty { error("missing sni") } ?: error("missing sni")
udpOverStream = when (link.queryParameter("udp_mode")) {
"datagram" -> false
"stream" -> true
else -> true
}
zeroRTT = link.hasQueryParameter("zero_rtt")
link.queryParameter("alpn")?.also {
alpn = it.split(",").joinToString("\n")
} ?: {
// TODO: What is the meaning of "默认值为空"?
disableALPN = true
}
}
}
fun ShadowQUICBean.toUri(): String? {
if (useSunnyQUIC) {
error("SunnyQUIC is not yet supported")
}
val builder = Libexclavecore.newURL("sq").apply {
if (name.isNotEmpty()) {
fragment = name
}
setHostPort(serverAddress.ifEmpty { error("empty server address") }, serverPort)
addQueryParameter("sni", sni.ifEmpty { error("missing sni") })
addQueryParameter("udp_mode", if (udpOverStream) "stream" else "datagram")
if (zeroRTT) {
addQueryParameter("zero_rtt", "true")
}
if (!disableALPN) {
if (alpn.listByLineOrComma().isEmpty()) {
addQueryParameter("alpn", "h3")
} else {
addQueryParameter("alpn", alpn.listByLineOrComma().joinToString(","))
}
} else {
// TODO: What is the meaning of "默认值为空"?
}
}
builder.username = username.ifEmpty { error("missing username") }
builder.password = password.ifEmpty { error("missing password") }
return builder.string
}
fun ShadowQUICBean.buildShadowQUICConfig(port: Int, username: String = "", password: String = "", cacheFile: (() -> File)? = null, forExport: Boolean = false): String {
val confObject: MutableMap = HashMap()
val inboundObject: MutableMap = HashMap()
inboundObject["type"] = "socks"
inboundObject["bind-addr"] = joinHostPort(LOCALHOST, port)
if (username.isNotEmpty() && password.isNotEmpty()) {
val userObject: MutableMap = HashMap()
userObject["username"] = username
userObject["password"] = password
inboundObject["users"] = listOf(userObject)
}
confObject["inbound"] = inboundObject
val outboundObject: MutableMap = HashMap()
outboundObject["type"] = if (useSunnyQUIC) "sunnyquic" else "shadowquic"
outboundObject["addr"] = joinHostPort(finalAddress, finalPort)
if (this.username.isNotEmpty()) outboundObject["username"] = this.username
if (this.password.isNotEmpty()) outboundObject["password"] = this.password
if (sni.isNotEmpty()) outboundObject["server-name"] = sni
if (disableALPN) {
outboundObject["alpn"] = listOf()
} else if (alpn.isNotEmpty()) {
outboundObject["alpn"] = alpn.listByLineOrComma()
}
when (congestionControl) {
"" -> {}
"brutal" -> {
val brutalObject: MutableMap = HashMap()
brutalObject["bandwidth"] = "${brutalUploadBandwidth}m"
val congestionControlObject: MutableMap = HashMap()
congestionControlObject["brutal"] = brutalObject
outboundObject["congestion-control"] = congestionControlObject
}
else -> {
outboundObject["congestion-control"] = congestionControl
}
}
if (zeroRTT) outboundObject["zero-rtt"] = zeroRTT
if (udpOverStream) outboundObject["over-stream"] = udpOverStream
if (useSunnyQUIC && certificate.isNotEmpty() && cacheFile != null) {
val certificateFile = cacheFile()
certificateFile.writeText(certificate)
outboundObject["cert-path"] = certificateFile.absolutePath
}
if (!forExport && DataStore.serviceMode == Key.MODE_VPN && DataStore.tunImplementation == TunImplementation.SYSTEM && SagerNet.started && DataStore.startedProfile > 0) {
outboundObject["protect-path"] = SagerNet.deviceStorage.noBackupFilesDir.toString() + "/protect_path"
}
confObject["outbound"] = outboundObject
confObject["log-level"] = when (DataStore.logLevel) {
LogLevel.DEBUG -> "trace"
LogLevel.INFO -> "info"
LogLevel.WARNING -> "warn"
LogLevel.ERROR -> "error"
else -> "error"
}
val options = DumperOptions()
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK)
options.isPrettyFlow = true
val yaml = Yaml(options)
return yaml.dump(confObject)
}