Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ object Key {
const val SERVER_DISABLE_MTU_DISCOVERY = "serverDisableMtuDiscovery"
const val SERVER_HOP_INTERVAL = "hopInterval"

const val SERVER_HY2_INIT_STREAM_RECEIVE_WINDOW = "serverHy2InitStreamReceiveWindow"
const val SERVER_HY2_MAX_STREAM_RECEIVE_WINDOW = "serverHy2MaxStreamReceiveWindow"
const val SERVER_HY2_INIT_CONNECTION_RECEIVE_WINDOW = "serverHy2InitConnectionReceiveWindow"
const val SERVER_HY2_MAX_CONNECTION_RECEIVE_WINDOW = "serverHy2MaxConnectionReceiveWindow"
const val SERVER_HY2_MAX_IDLE_TIMEOUT = "serverHy2MaxIdleTimeout"
const val SERVER_HY2_KEEP_ALIVE_PERIOD = "serverHy2KeepAlivePeriod"
const val SERVER_HY2_MIN_HOP_INTERVAL = "serverHy2MinHopInterval"
const val SERVER_HY2_MAX_HOP_INTERVAL = "serverHy2MaxHopInterval"

const val SERVER_HY2_OBFS_TYPE = "serverHy2ObfsType"
const val SERVER_HY2_GECKO_MIN_PACKET = "serverHy2GeckoMinPacket"
const val SERVER_HY2_GECKO_MAX_PACKET = "serverHy2GeckoMaxPacket"
Expand Down
54 changes: 36 additions & 18 deletions app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.nekohasekai.sagernet.fmt.LOCALHOST
import io.nekohasekai.sagernet.fmt.buildConfig
import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean
import io.nekohasekai.sagernet.fmt.hysteria.buildHysteria1Config
import io.nekohasekai.sagernet.fmt.hysteria.buildHysteria2SidecarConfig
import io.nekohasekai.sagernet.fmt.masterdnsvpn.MasterDnsVpnBean
import io.nekohasekai.sagernet.fmt.masterdnsvpn.buildMasterDnsVpnConfig
import io.nekohasekai.sagernet.fmt.masterdnsvpn.resolverLines
Expand Down Expand Up @@ -82,15 +83,21 @@ abstract class BoxInstance(

is HysteriaBean -> {
// Only reached via the external path (needExternal == !canUseSingBox).
// Hysteria2 (incl. Gecko obfs) runs natively in sing-box now, so the
// external path is Hysteria v1 only.
initPlugin("hysteria-plugin")
pluginConfigs[port] = profile.type to bean.buildHysteria1Config(port) {
File(
app.cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca"
).apply {
parentFile?.mkdirs()
cacheFiles.add(this)
if (bean.protocolVersion == 2) {
initPlugin("hysteria2-plugin")
pluginConfigs[port] = profile.type to bean.buildHysteria2SidecarConfig(
port,
File(app.noBackupFilesDir, "protect_path").absolutePath
)
} else {
initPlugin("hysteria-plugin")
pluginConfigs[port] = profile.type to bean.buildHysteria1Config(port) {
File(
app.cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca"
).apply {
parentFile?.mkdirs()
cacheFiles.add(this)
}
}
}
}
Expand Down Expand Up @@ -197,15 +204,26 @@ abstract class BoxInstance(
configFile.writeText(config)
cacheFiles.add(configFile)

val commands = mutableListOf(
initPlugin("hysteria-plugin").path,
"--no-check",
"--config",
configFile.absolutePath,
"--log-level",
if (DataStore.logLevel > 0) "trace" else "warn",
"client"
)
val commands = if (bean.protocolVersion == 2) {
mutableListOf(
initPlugin("hysteria2-plugin").path,
"client",
"--config",
configFile.absolutePath,
"--log-level",
if (DataStore.logLevel > 0) "trace" else "warn",
)
} else {
mutableListOf(
initPlugin("hysteria-plugin").path,
"--no-check",
"--config",
configFile.absolutePath,
"--log-level",
if (DataStore.logLevel > 0) "trace" else "warn",
"client"
)
}

if (bean.protocol == HysteriaBean.PROTOCOL_FAKETCP) {
commands.addAll(0, listOf("su", "-c"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,15 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var serverDisableMtuDiscovery by profileCacheStore.boolean(Key.SERVER_DISABLE_MTU_DISCOVERY)
var serverHopInterval by profileCacheStore.stringToInt(Key.SERVER_HOP_INTERVAL) { 10 }

var serverHy2InitStreamReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_INIT_STREAM_RECEIVE_WINDOW)
var serverHy2MaxStreamReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MAX_STREAM_RECEIVE_WINDOW)
var serverHy2InitConnectionReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_INIT_CONNECTION_RECEIVE_WINDOW)
var serverHy2MaxConnectionReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MAX_CONNECTION_RECEIVE_WINDOW)
var serverHy2MaxIdleTimeout by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MAX_IDLE_TIMEOUT)
var serverHy2KeepAlivePeriod by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_KEEP_ALIVE_PERIOD)
var serverHy2MinHopInterval by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MIN_HOP_INTERVAL)
var serverHy2MaxHopInterval by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MAX_HOP_INTERVAL)

var serverHy2ObfsType by profileCacheStore.stringToInt(Key.SERVER_HY2_OBFS_TYPE)
var serverHy2GeckoMinPacket by profileCacheStore.stringToInt(Key.SERVER_HY2_GECKO_MIN_PACKET) { 512 }
var serverHy2GeckoMaxPacket by profileCacheStore.stringToInt(Key.SERVER_HY2_GECKO_MAX_PACKET) { 1200 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ public class HysteriaBean extends AbstractBean {
public Boolean disableMtuDiscovery;
public Integer hopInterval;

// HY2 advanced QUIC options. Zero means unset/use Hysteria defaults.
public Integer hy2InitialStreamReceiveWindow;
public Integer hy2MaxStreamReceiveWindow;
public Integer hy2InitialConnectionReceiveWindow;
public Integer hy2MaxConnectionReceiveWindow;
public Integer hy2MaxIdleTimeout;
public Integer hy2KeepAlivePeriod;
public Integer hy2MinHopInterval;
public Integer hy2MaxHopInterval;

// HY1

public String alpn;
Expand Down Expand Up @@ -92,12 +102,20 @@ public void initializeDefaultValues() {
if (connectionReceiveWindow == null) connectionReceiveWindow = 0;
if (disableMtuDiscovery == null) disableMtuDiscovery = false;
if (hopInterval == null) hopInterval = 10;
if (hy2InitialStreamReceiveWindow == null) hy2InitialStreamReceiveWindow = 0;
if (hy2MaxStreamReceiveWindow == null) hy2MaxStreamReceiveWindow = 0;
if (hy2InitialConnectionReceiveWindow == null) hy2InitialConnectionReceiveWindow = 0;
if (hy2MaxConnectionReceiveWindow == null) hy2MaxConnectionReceiveWindow = 0;
if (hy2MaxIdleTimeout == null) hy2MaxIdleTimeout = 0;
if (hy2KeepAlivePeriod == null) hy2KeepAlivePeriod = 0;
if (hy2MinHopInterval == null) hy2MinHopInterval = 0;
if (hy2MaxHopInterval == null) hy2MaxHopInterval = 0;
if (serverPorts == null) serverPorts = "443";
}

@Override
public void serialize(ByteBufferOutput output) {
output.writeInt(8);
output.writeInt(9);
super.serialize(output);

output.writeInt(protocolVersion);
Expand All @@ -123,6 +141,15 @@ public void serialize(ByteBufferOutput output) {
output.writeInt(hysteria2ObfsType);
output.writeInt(geckoMinPacketSize);
output.writeInt(geckoMaxPacketSize);

output.writeInt(hy2InitialStreamReceiveWindow);
output.writeInt(hy2MaxStreamReceiveWindow);
output.writeInt(hy2InitialConnectionReceiveWindow);
output.writeInt(hy2MaxConnectionReceiveWindow);
output.writeInt(hy2MaxIdleTimeout);
output.writeInt(hy2KeepAlivePeriod);
output.writeInt(hy2MinHopInterval);
output.writeInt(hy2MaxHopInterval);
}

@Override
Expand Down Expand Up @@ -172,6 +199,16 @@ public void deserialize(ByteBufferInput input) {
geckoMinPacketSize = input.readInt();
geckoMaxPacketSize = input.readInt();
}
if (version >= 9) {
hy2InitialStreamReceiveWindow = input.readInt();
hy2MaxStreamReceiveWindow = input.readInt();
hy2InitialConnectionReceiveWindow = input.readInt();
hy2MaxConnectionReceiveWindow = input.readInt();
hy2MaxIdleTimeout = input.readInt();
hy2KeepAlivePeriod = input.readInt();
hy2MinHopInterval = input.readInt();
hy2MaxHopInterval = input.readInt();
}
// For version < 8, hysteria2ObfsType/gecko* stay null and are derived in
// initializeDefaultValues() (Salamander when an obfuscation password exists).
}
Expand Down
142 changes: 137 additions & 5 deletions app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,76 @@ fun parseHysteria2(url: String): HysteriaBean {
link.queryParameter("obfs-max-packet-size")?.toIntOrNull()?.also {
geckoMaxPacketSize = it
}
queryInt(link, "initStreamReceiveWindow", "init_stream_receive_window", "init-stream-receive-window")?.also {
hy2InitialStreamReceiveWindow = it
}
queryInt(link, "maxStreamReceiveWindow", "max_stream_receive_window", "max-stream-receive-window")?.also {
hy2MaxStreamReceiveWindow = it
}
queryInt(link, "initConnReceiveWindow", "init_conn_receive_window", "init-conn-receive-window")?.also {
hy2InitialConnectionReceiveWindow = it
}
queryInt(link, "maxConnReceiveWindow", "max_conn_receive_window", "max-conn-receive-window")?.also {
hy2MaxConnectionReceiveWindow = it
}
querySeconds(link, "maxIdleTimeout", "max_idle_timeout", "max-idle-timeout")?.also {
hy2MaxIdleTimeout = it
}
querySeconds(link, "keepAlivePeriod", "keep_alive_period", "keep-alive-period")?.also {
hy2KeepAlivePeriod = it
}
queryBool(link, "disablePathMTUDiscovery", "disable_path_mtu_discovery", "disable-path-mtu-discovery")?.also {
disableMtuDiscovery = it
}
querySeconds(link, "hopInterval", "hop_interval", "hop-interval")?.also {
hopInterval = it
}
querySeconds(link, "minHopInterval", "min_hop_interval", "min-hop-interval")?.also {
hy2MinHopInterval = it
}
querySeconds(link, "maxHopInterval", "max_hop_interval", "max-hop-interval")?.also {
hy2MaxHopInterval = it
}
// link.queryParameter("pinSHA256")?.also {
// // TODO your box do not support it
// }
}
}

private fun queryInt(link: okhttp3.HttpUrl, vararg names: String): Int? {
return names.firstNotNullOfOrNull { link.queryParameter(it)?.toIntOrNull() }
}

private fun queryBool(link: okhttp3.HttpUrl, vararg names: String): Boolean? {
return names.firstNotNullOfOrNull { name ->
link.queryParameter(name)?.let { it == "1" || it.equals("true", ignoreCase = true) }
}
}

private fun querySeconds(link: okhttp3.HttpUrl, vararg names: String): Int? {
return names.firstNotNullOfOrNull { name ->
parseDurationSeconds(link.queryParameter(name))
}
}

private fun parseDurationSeconds(value: String?): Int? {
val raw = value?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: return null
val (number, multiplier, roundUpDivisor) = when {
raw.endsWith("ms") -> Triple(raw.removeSuffix("ms"), 1L, 1000L)
raw.endsWith("s") -> Triple(raw.removeSuffix("s"), 1L, 1L)
raw.endsWith("m") -> Triple(raw.removeSuffix("m"), 60L, 1L)
raw.endsWith("h") -> Triple(raw.removeSuffix("h"), 3600L, 1L)
else -> Triple(raw, 1L, 1L)
}
val base = number.toLongOrNull() ?: return null
val seconds = if (roundUpDivisor > 1) {
(base + roundUpDivisor - 1) / roundUpDivisor
} else {
base * multiplier
}
return seconds.coerceIn(0, Int.MAX_VALUE.toLong()).toInt()
}

fun HysteriaBean.toUri(): String {
var un = ""
var pw = ""
Expand Down Expand Up @@ -185,6 +249,33 @@ fun HysteriaBean.toUri(): String {
}
}
}
if (hy2InitialStreamReceiveWindow > 0) {
builder.addQueryParameter("initStreamReceiveWindow", hy2InitialStreamReceiveWindow.toString())
}
if (hy2MaxStreamReceiveWindow > 0) {
builder.addQueryParameter("maxStreamReceiveWindow", hy2MaxStreamReceiveWindow.toString())
}
if (hy2InitialConnectionReceiveWindow > 0) {
builder.addQueryParameter("initConnReceiveWindow", hy2InitialConnectionReceiveWindow.toString())
}
if (hy2MaxConnectionReceiveWindow > 0) {
builder.addQueryParameter("maxConnReceiveWindow", hy2MaxConnectionReceiveWindow.toString())
}
if (hy2MaxIdleTimeout > 0) {
builder.addQueryParameter("maxIdleTimeout", "${hy2MaxIdleTimeout}s")
}
if (hy2KeepAlivePeriod > 0) {
builder.addQueryParameter("keepAlivePeriod", "${hy2KeepAlivePeriod}s")
}
if (disableMtuDiscovery) {
builder.addQueryParameter("disablePathMTUDiscovery", "1")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
if (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0) {
if (hy2MinHopInterval > 0) builder.addQueryParameter("minHopInterval", "${hy2MinHopInterval}s")
if (hy2MaxHopInterval > 0) builder.addQueryParameter("maxHopInterval", "${hy2MaxHopInterval}s")
} else if (hopInterval != 10) {
builder.addQueryParameter("hopInterval", "${hopInterval}s")
}
}
return builder.toLink(if (protocolVersion == 2) "hy2" else "hysteria")
}
Expand Down Expand Up @@ -289,16 +380,34 @@ fun isMultiPort(hyAddr: String): Boolean {
}

fun getFirstPort(portStr: String): Int {
return portStr.substringBefore(":").substringBefore(",").toIntOrNull() ?: 443
return portStr.substringBefore(",").substringBefore("-").substringBefore(":").toIntOrNull() ?: 443
}

fun HysteriaBean.canUseSingBox(): Boolean {
if (protocol != HysteriaBean.PROTOCOL_UDP) return false
if (protocolVersion == 2 && hasAdvancedHysteria2Options()) {
// The bundled sing-box Hysteria2 option model supports Gecko obfs and fixed
// port hopping, but not Hysteria's advanced QUIC knobs or random hop
// intervals. Use the Hysteria2 sidecar when those fields are configured.
return false
}
// Gecko obfs is now supported natively by this fork's sing-box core (via the
// hawkff/sing-quic gecko backport), so it no longer needs the sidecar.
// hawkff/sing-quic gecko backport), so default HY2 profiles do not need the sidecar.
return true
}

fun HysteriaBean.hasAdvancedHysteria2Options(): Boolean {
if (protocolVersion != 2) return false
return hy2InitialStreamReceiveWindow > 0 ||
hy2MaxStreamReceiveWindow > 0 ||
hy2InitialConnectionReceiveWindow > 0 ||
hy2MaxConnectionReceiveWindow > 0 ||
hy2MaxIdleTimeout > 0 ||
hy2KeepAlivePeriod > 0 ||
disableMtuDiscovery ||
(isMultiPort(displayAddress()) && (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0))
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): SingBoxOptions.SingBoxOption {
return when (bean.protocolVersion) {
1 -> SingBoxOptions.Outbound_HysteriaOptions().apply {
Expand Down Expand Up @@ -413,9 +522,9 @@ fun hopPortsToSingboxList(s: String): List<String> {
/**
* Builds a config for the bundled official apernet/hysteria client binary (sidecar).
*
* NOTE: Hysteria2 (including Gecko obfs) now runs natively in the sing-box core, so this
* sidecar path is no longer used for Gecko. This builder is retained for the bundled
* hysteria2 binary infrastructure and potential fallback use.
* NOTE: Default Hysteria2 (including Gecko obfs and fixed port hopping) runs natively in
* the sing-box core. This sidecar path is used when HY2 advanced QUIC options or random
* hop intervals are configured, because those fields are not exposed by the pinned core.
*
* Emitted as JSON (hysteria uses viper, which detects format by the .json extension).
* The client's upstream QUIC sockets are protected from the VPN via
Expand Down Expand Up @@ -471,7 +580,30 @@ fun HysteriaBean.buildHysteria2SidecarConfig(
if (downloadMbps > 0) put("down", "$downloadMbps mbps")
})
}
if (isMultiPort(displayAddress())) {
put("transport", JSONObject().apply {
put("type", "udp")
put("udp", JSONObject().apply {
if (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0) {
val rawMin = hy2MinHopInterval.takeIf { it > 0 } ?: hy2MaxHopInterval
val min = rawMin.coerceAtLeast(5)
val max = (hy2MaxHopInterval.takeIf { it > 0 } ?: min).coerceAtLeast(min)
put("minHopInterval", "${min}s")
put("maxHopInterval", "${max}s")
} else if (hopInterval > 0) {
put("hopInterval", "${hopInterval.coerceAtLeast(5)}s")
}
})
})
}
put("quic", JSONObject().apply {
if (hy2InitialStreamReceiveWindow > 0) put("initStreamReceiveWindow", hy2InitialStreamReceiveWindow)
if (hy2MaxStreamReceiveWindow > 0) put("maxStreamReceiveWindow", hy2MaxStreamReceiveWindow)
if (hy2InitialConnectionReceiveWindow > 0) put("initConnReceiveWindow", hy2InitialConnectionReceiveWindow)
if (hy2MaxConnectionReceiveWindow > 0) put("maxConnReceiveWindow", hy2MaxConnectionReceiveWindow)
if (hy2MaxIdleTimeout > 0) put("maxIdleTimeout", "${hy2MaxIdleTimeout}s")
if (hy2KeepAlivePeriod > 0) put("keepAlivePeriod", "${hy2KeepAlivePeriod}s")
if (disableMtuDiscovery) put("disablePathMTUDiscovery", true)
put("sockopts", JSONObject().apply {
put("fdControlUnixSocket", protectPath)
})
Expand Down
Loading
Loading