____ _ _______ __
/ __ \ | /| / / __/ |/ /
/ /_/ / |/ |/ / _// /
\____/|__/|__/___/_||_/
owenclave / app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria2/Hysteria2Fmt.kt
/******************************************************************************
* *
* Copyright (C) 2023 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.hysteria2
import io.nekohasekai.sagernet.ktx.isValidHysteriaMultiPort
import io.nekohasekai.sagernet.ktx.isValidHysteriaPort
import io.nekohasekai.sagernet.ktx.listByLineOrComma
import io.nekohasekai.sagernet.ktx.queryParameter
import libexclavecore.Libexclavecore
fun parseHysteria2(rawURL: String): Hysteria2Bean {
var url = rawURL
// fuck port hopping URL
val hostPort = url.substringAfter("://").substringAfter("@")
.substringBefore("#").substringBefore("?").substringBefore("/")
var port = ""
if (!hostPort.endsWith("]") && hostPort.lastIndexOf(":") > 0) {
port = hostPort.substringAfterLast(":")
if (port.isNotEmpty() && port.isValidHysteriaMultiPort()) {
url = url.replaceFirst(":$port", ":0")
}
}
val link = Libexclavecore.parseURL(url)
return Hysteria2Bean().apply {
name = link.fragment
serverAddress = link.host.ifEmpty { error("empty host") }
serverPorts = if (port.isNotEmpty() && port.isValidHysteriaMultiPort()) {
port
} else if (link.port > 0) {
link.port.toString()
} else {
"443"
}
link.queryParameter("mport")?.takeIf { it.isValidHysteriaMultiPort() }?.also {
serverPorts = it
}
when {
// Warning: Do not use colon in username or password in so-called `userpass` authentication.
// Official Hysteria2 server can not handle it correctly.
// need to handle so-called broken "userpass" authentication
link.username.isEmpty() && link.password.isEmpty() -> {
if (rawURL.substringAfter("://").substringBefore("@") == ":") {
auth = ":"
}
}
link.username.isNotEmpty() && link.password.isEmpty() -> {
auth = if (rawURL.substringAfter("://").substringBefore("@").endsWith(":")) {
link.username + ":"
} else {
link.username
}
}
link.username.isEmpty() && link.password.isNotEmpty() -> {
auth = ":" + link.password
}
link.username.isNotEmpty() && link.password.isNotEmpty() -> {
auth = link.username + ":" + link.password
}
}
link.queryParameter("sni")?.also {
sni = it
}
link.queryParameter("insecure")?.takeIf { it == "1" }?.also {
allowInsecure = true
}
link.queryParameter("pinSHA256")?.also {
// https://github.com/apernet/hysteria/blob/922128e425a700c5bc01290e7a9560f182fe451b/app/cmd/client.go#L882-L889
pinnedPeerCertificateSha256 = it.replace(":", "").replace("-", "").lowercase()
}
link.queryParameter("obfs")?.also {
when (it) {
"" -> {}
"salamander", "gecko"-> {
obfsType = it
link.queryParameter("obfs-password")?.also { password ->
obfsPassword = password
}
}
else -> error("unsupported obfs")
}
}
link.queryParameter("ech")?.also {
echEnabled = true
echConfig = it
}
}
}
fun Hysteria2Bean.toUri(): String? {
if (!serverPorts.isValidHysteriaPort()) {
error("invalid port")
}
if (serverAddress.isEmpty()) {
error("empty server address")
}
val builder = Libexclavecore.newURL("hysteria2").apply {
// fuck port hopping URL
rawHost = if (serverAddress.contains(":")) {
"[$serverAddress]:$serverPorts"
} else {
"$serverAddress:$serverPorts"
}
if (auth.isNotEmpty()) {
// No need to care about so-called broken "userpass" here.
username = auth
}
}
if (sni.isNotEmpty()) {
builder.addQueryParameter("sni", sni)
}
// as `pinnedPeerCertificate[Chain|PublicKey]Sha256` is not exportable,
// only add `allow_insecure=1` if `pinnedPeerCertificate[Chain|PublicKey]Sha256` is not used
if (allowInsecure &&
pinnedPeerCertificateChainSha256.isEmpty() && pinnedPeerCertificatePublicKeySha256.isEmpty()
&& serverNameToVerify.listByLineOrComma().isEmpty()) {
builder.addQueryParameter("insecure", "1")
}
if (pinnedPeerCertificateSha256.isNotEmpty()) {
builder.addQueryParameter("pinSHA256", pinnedPeerCertificateSha256.listByLineOrComma()[0].replace(":", "").lowercase())
}
if (obfsType.isNotEmpty()) {
builder.addQueryParameter("obfs", obfsType)
if (obfsPassword.isEmpty()) {
error("empty obfs password")
}
builder.addQueryParameter("obfs-password", obfsPassword)
}
if (echEnabled && echConfig.isNotEmpty()) {
builder.addQueryParameter("ech", echConfig)
}
if (name.isNotEmpty()) {
builder.fragment = name
}
builder.rawPath = "/"
return builder.string
}