Skip to content

Commit ff91fdc

Browse files
committed
Add advanced Hysteria2 client options
1 parent 8dc2b50 commit ff91fdc

8 files changed

Lines changed: 323 additions & 36 deletions

File tree

app/src/main/java/io/nekohasekai/sagernet/Constants.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ object Key {
129129
const val SERVER_DISABLE_MTU_DISCOVERY = "serverDisableMtuDiscovery"
130130
const val SERVER_HOP_INTERVAL = "hopInterval"
131131

132+
const val SERVER_HY2_INIT_STREAM_RECEIVE_WINDOW = "serverHy2InitStreamReceiveWindow"
133+
const val SERVER_HY2_MAX_STREAM_RECEIVE_WINDOW = "serverHy2MaxStreamReceiveWindow"
134+
const val SERVER_HY2_INIT_CONNECTION_RECEIVE_WINDOW = "serverHy2InitConnectionReceiveWindow"
135+
const val SERVER_HY2_MAX_CONNECTION_RECEIVE_WINDOW = "serverHy2MaxConnectionReceiveWindow"
136+
const val SERVER_HY2_MAX_IDLE_TIMEOUT = "serverHy2MaxIdleTimeout"
137+
const val SERVER_HY2_KEEP_ALIVE_PERIOD = "serverHy2KeepAlivePeriod"
138+
const val SERVER_HY2_MIN_HOP_INTERVAL = "serverHy2MinHopInterval"
139+
const val SERVER_HY2_MAX_HOP_INTERVAL = "serverHy2MaxHopInterval"
140+
132141
const val SERVER_HY2_OBFS_TYPE = "serverHy2ObfsType"
133142
const val SERVER_HY2_GECKO_MIN_PACKET = "serverHy2GeckoMinPacket"
134143
const val SERVER_HY2_GECKO_MAX_PACKET = "serverHy2GeckoMaxPacket"

app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import io.nekohasekai.sagernet.fmt.LOCALHOST
1111
import io.nekohasekai.sagernet.fmt.buildConfig
1212
import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean
1313
import io.nekohasekai.sagernet.fmt.hysteria.buildHysteria1Config
14+
import io.nekohasekai.sagernet.fmt.hysteria.buildHysteria2SidecarConfig
1415
import io.nekohasekai.sagernet.fmt.masterdnsvpn.MasterDnsVpnBean
1516
import io.nekohasekai.sagernet.fmt.masterdnsvpn.buildMasterDnsVpnConfig
1617
import io.nekohasekai.sagernet.fmt.masterdnsvpn.resolverLines
@@ -82,15 +83,21 @@ abstract class BoxInstance(
8283

8384
is HysteriaBean -> {
8485
// Only reached via the external path (needExternal == !canUseSingBox).
85-
// Hysteria2 (incl. Gecko obfs) runs natively in sing-box now, so the
86-
// external path is Hysteria v1 only.
87-
initPlugin("hysteria-plugin")
88-
pluginConfigs[port] = profile.type to bean.buildHysteria1Config(port) {
89-
File(
90-
app.cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca"
91-
).apply {
92-
parentFile?.mkdirs()
93-
cacheFiles.add(this)
86+
if (bean.protocolVersion == 2) {
87+
initPlugin("hysteria2-plugin")
88+
pluginConfigs[port] = profile.type to bean.buildHysteria2SidecarConfig(
89+
port,
90+
File(app.noBackupFilesDir, "protect_path").absolutePath
91+
)
92+
} else {
93+
initPlugin("hysteria-plugin")
94+
pluginConfigs[port] = profile.type to bean.buildHysteria1Config(port) {
95+
File(
96+
app.cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca"
97+
).apply {
98+
parentFile?.mkdirs()
99+
cacheFiles.add(this)
100+
}
94101
}
95102
}
96103
}
@@ -197,15 +204,26 @@ abstract class BoxInstance(
197204
configFile.writeText(config)
198205
cacheFiles.add(configFile)
199206

200-
val commands = mutableListOf(
201-
initPlugin("hysteria-plugin").path,
202-
"--no-check",
203-
"--config",
204-
configFile.absolutePath,
205-
"--log-level",
206-
if (DataStore.logLevel > 0) "trace" else "warn",
207-
"client"
208-
)
207+
val commands = if (bean.protocolVersion == 2) {
208+
mutableListOf(
209+
initPlugin("hysteria2-plugin").path,
210+
"client",
211+
"--config",
212+
configFile.absolutePath,
213+
"--log-level",
214+
if (DataStore.logLevel > 0) "trace" else "warn",
215+
)
216+
} else {
217+
mutableListOf(
218+
initPlugin("hysteria-plugin").path,
219+
"--no-check",
220+
"--config",
221+
configFile.absolutePath,
222+
"--log-level",
223+
if (DataStore.logLevel > 0) "trace" else "warn",
224+
"client"
225+
)
226+
}
209227

210228
if (bean.protocol == HysteriaBean.PROTOCOL_FAKETCP) {
211229
commands.addAll(0, listOf("su", "-c"))

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,15 @@ object DataStore : OnPreferenceDataStoreChangeListener {
256256
var serverDisableMtuDiscovery by profileCacheStore.boolean(Key.SERVER_DISABLE_MTU_DISCOVERY)
257257
var serverHopInterval by profileCacheStore.stringToInt(Key.SERVER_HOP_INTERVAL) { 10 }
258258

259+
var serverHy2InitStreamReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_INIT_STREAM_RECEIVE_WINDOW)
260+
var serverHy2MaxStreamReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MAX_STREAM_RECEIVE_WINDOW)
261+
var serverHy2InitConnectionReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_INIT_CONNECTION_RECEIVE_WINDOW)
262+
var serverHy2MaxConnectionReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MAX_CONNECTION_RECEIVE_WINDOW)
263+
var serverHy2MaxIdleTimeout by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MAX_IDLE_TIMEOUT)
264+
var serverHy2KeepAlivePeriod by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_KEEP_ALIVE_PERIOD)
265+
var serverHy2MinHopInterval by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MIN_HOP_INTERVAL)
266+
var serverHy2MaxHopInterval by profileCacheStore.stringToIntIfExists(Key.SERVER_HY2_MAX_HOP_INTERVAL)
267+
259268
var serverHy2ObfsType by profileCacheStore.stringToInt(Key.SERVER_HY2_OBFS_TYPE)
260269
var serverHy2GeckoMinPacket by profileCacheStore.stringToInt(Key.SERVER_HY2_GECKO_MIN_PACKET) { 512 }
261270
var serverHy2GeckoMaxPacket by profileCacheStore.stringToInt(Key.SERVER_HY2_GECKO_MAX_PACKET) { 1200 }

app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaBean.java

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ public class HysteriaBean extends AbstractBean {
3232
public Boolean disableMtuDiscovery;
3333
public Integer hopInterval;
3434

35+
// HY2 advanced QUIC options. Zero means unset/use Hysteria defaults.
36+
public Integer hy2InitialStreamReceiveWindow;
37+
public Integer hy2MaxStreamReceiveWindow;
38+
public Integer hy2InitialConnectionReceiveWindow;
39+
public Integer hy2MaxConnectionReceiveWindow;
40+
public Integer hy2MaxIdleTimeout;
41+
public Integer hy2KeepAlivePeriod;
42+
public Integer hy2MinHopInterval;
43+
public Integer hy2MaxHopInterval;
44+
3545
// HY1
3646

3747
public String alpn;
@@ -92,12 +102,20 @@ public void initializeDefaultValues() {
92102
if (connectionReceiveWindow == null) connectionReceiveWindow = 0;
93103
if (disableMtuDiscovery == null) disableMtuDiscovery = false;
94104
if (hopInterval == null) hopInterval = 10;
105+
if (hy2InitialStreamReceiveWindow == null) hy2InitialStreamReceiveWindow = 0;
106+
if (hy2MaxStreamReceiveWindow == null) hy2MaxStreamReceiveWindow = 0;
107+
if (hy2InitialConnectionReceiveWindow == null) hy2InitialConnectionReceiveWindow = 0;
108+
if (hy2MaxConnectionReceiveWindow == null) hy2MaxConnectionReceiveWindow = 0;
109+
if (hy2MaxIdleTimeout == null) hy2MaxIdleTimeout = 0;
110+
if (hy2KeepAlivePeriod == null) hy2KeepAlivePeriod = 0;
111+
if (hy2MinHopInterval == null) hy2MinHopInterval = 0;
112+
if (hy2MaxHopInterval == null) hy2MaxHopInterval = 0;
95113
if (serverPorts == null) serverPorts = "443";
96114
}
97115

98116
@Override
99117
public void serialize(ByteBufferOutput output) {
100-
output.writeInt(8);
118+
output.writeInt(9);
101119
super.serialize(output);
102120

103121
output.writeInt(protocolVersion);
@@ -123,6 +141,15 @@ public void serialize(ByteBufferOutput output) {
123141
output.writeInt(hysteria2ObfsType);
124142
output.writeInt(geckoMinPacketSize);
125143
output.writeInt(geckoMaxPacketSize);
144+
145+
output.writeInt(hy2InitialStreamReceiveWindow);
146+
output.writeInt(hy2MaxStreamReceiveWindow);
147+
output.writeInt(hy2InitialConnectionReceiveWindow);
148+
output.writeInt(hy2MaxConnectionReceiveWindow);
149+
output.writeInt(hy2MaxIdleTimeout);
150+
output.writeInt(hy2KeepAlivePeriod);
151+
output.writeInt(hy2MinHopInterval);
152+
output.writeInt(hy2MaxHopInterval);
126153
}
127154

128155
@Override
@@ -172,6 +199,16 @@ public void deserialize(ByteBufferInput input) {
172199
geckoMinPacketSize = input.readInt();
173200
geckoMaxPacketSize = input.readInt();
174201
}
202+
if (version >= 9) {
203+
hy2InitialStreamReceiveWindow = input.readInt();
204+
hy2MaxStreamReceiveWindow = input.readInt();
205+
hy2InitialConnectionReceiveWindow = input.readInt();
206+
hy2MaxConnectionReceiveWindow = input.readInt();
207+
hy2MaxIdleTimeout = input.readInt();
208+
hy2KeepAlivePeriod = input.readInt();
209+
hy2MinHopInterval = input.readInt();
210+
hy2MaxHopInterval = input.readInt();
211+
}
175212
// For version < 8, hysteria2ObfsType/gecko* stay null and are derived in
176213
// initializeDefaultValues() (Salamander when an obfuscation password exists).
177214
}

app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt

Lines changed: 137 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,76 @@ fun parseHysteria2(url: String): HysteriaBean {
109109
link.queryParameter("obfs-max-packet-size")?.toIntOrNull()?.also {
110110
geckoMaxPacketSize = it
111111
}
112+
queryInt(link, "initStreamReceiveWindow", "init_stream_receive_window", "init-stream-receive-window")?.also {
113+
hy2InitialStreamReceiveWindow = it
114+
}
115+
queryInt(link, "maxStreamReceiveWindow", "max_stream_receive_window", "max-stream-receive-window")?.also {
116+
hy2MaxStreamReceiveWindow = it
117+
}
118+
queryInt(link, "initConnReceiveWindow", "init_conn_receive_window", "init-conn-receive-window")?.also {
119+
hy2InitialConnectionReceiveWindow = it
120+
}
121+
queryInt(link, "maxConnReceiveWindow", "max_conn_receive_window", "max-conn-receive-window")?.also {
122+
hy2MaxConnectionReceiveWindow = it
123+
}
124+
querySeconds(link, "maxIdleTimeout", "max_idle_timeout", "max-idle-timeout")?.also {
125+
hy2MaxIdleTimeout = it
126+
}
127+
querySeconds(link, "keepAlivePeriod", "keep_alive_period", "keep-alive-period")?.also {
128+
hy2KeepAlivePeriod = it
129+
}
130+
queryBool(link, "disablePathMTUDiscovery", "disable_path_mtu_discovery", "disable-path-mtu-discovery")?.also {
131+
disableMtuDiscovery = it
132+
}
133+
querySeconds(link, "hopInterval", "hop_interval", "hop-interval")?.also {
134+
hopInterval = it
135+
}
136+
querySeconds(link, "minHopInterval", "min_hop_interval", "min-hop-interval")?.also {
137+
hy2MinHopInterval = it
138+
}
139+
querySeconds(link, "maxHopInterval", "max_hop_interval", "max-hop-interval")?.also {
140+
hy2MaxHopInterval = it
141+
}
112142
// link.queryParameter("pinSHA256")?.also {
113143
// // TODO your box do not support it
114144
// }
115145
}
116146
}
117147

148+
private fun queryInt(link: okhttp3.HttpUrl, vararg names: String): Int? {
149+
return names.firstNotNullOfOrNull { link.queryParameter(it)?.toIntOrNull() }
150+
}
151+
152+
private fun queryBool(link: okhttp3.HttpUrl, vararg names: String): Boolean? {
153+
return names.firstNotNullOfOrNull { name ->
154+
link.queryParameter(name)?.let { it == "1" || it.equals("true", ignoreCase = true) }
155+
}
156+
}
157+
158+
private fun querySeconds(link: okhttp3.HttpUrl, vararg names: String): Int? {
159+
return names.firstNotNullOfOrNull { name ->
160+
parseDurationSeconds(link.queryParameter(name))
161+
}
162+
}
163+
164+
private fun parseDurationSeconds(value: String?): Int? {
165+
val raw = value?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: return null
166+
val (number, multiplier, roundUpDivisor) = when {
167+
raw.endsWith("ms") -> Triple(raw.removeSuffix("ms"), 1L, 1000L)
168+
raw.endsWith("s") -> Triple(raw.removeSuffix("s"), 1L, 1L)
169+
raw.endsWith("m") -> Triple(raw.removeSuffix("m"), 60L, 1L)
170+
raw.endsWith("h") -> Triple(raw.removeSuffix("h"), 3600L, 1L)
171+
else -> Triple(raw, 1L, 1L)
172+
}
173+
val base = number.toLongOrNull() ?: return null
174+
val seconds = if (roundUpDivisor > 1) {
175+
(base + roundUpDivisor - 1) / roundUpDivisor
176+
} else {
177+
base * multiplier
178+
}
179+
return seconds.coerceIn(0, Int.MAX_VALUE.toLong()).toInt()
180+
}
181+
118182
fun HysteriaBean.toUri(): String {
119183
var un = ""
120184
var pw = ""
@@ -185,6 +249,33 @@ fun HysteriaBean.toUri(): String {
185249
}
186250
}
187251
}
252+
if (hy2InitialStreamReceiveWindow > 0) {
253+
builder.addQueryParameter("initStreamReceiveWindow", hy2InitialStreamReceiveWindow.toString())
254+
}
255+
if (hy2MaxStreamReceiveWindow > 0) {
256+
builder.addQueryParameter("maxStreamReceiveWindow", hy2MaxStreamReceiveWindow.toString())
257+
}
258+
if (hy2InitialConnectionReceiveWindow > 0) {
259+
builder.addQueryParameter("initConnReceiveWindow", hy2InitialConnectionReceiveWindow.toString())
260+
}
261+
if (hy2MaxConnectionReceiveWindow > 0) {
262+
builder.addQueryParameter("maxConnReceiveWindow", hy2MaxConnectionReceiveWindow.toString())
263+
}
264+
if (hy2MaxIdleTimeout > 0) {
265+
builder.addQueryParameter("maxIdleTimeout", "${hy2MaxIdleTimeout}s")
266+
}
267+
if (hy2KeepAlivePeriod > 0) {
268+
builder.addQueryParameter("keepAlivePeriod", "${hy2KeepAlivePeriod}s")
269+
}
270+
if (disableMtuDiscovery) {
271+
builder.addQueryParameter("disablePathMTUDiscovery", "1")
272+
}
273+
if (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0) {
274+
if (hy2MinHopInterval > 0) builder.addQueryParameter("minHopInterval", "${hy2MinHopInterval}s")
275+
if (hy2MaxHopInterval > 0) builder.addQueryParameter("maxHopInterval", "${hy2MaxHopInterval}s")
276+
} else if (hopInterval != 10) {
277+
builder.addQueryParameter("hopInterval", "${hopInterval}s")
278+
}
188279
}
189280
return builder.toLink(if (protocolVersion == 2) "hy2" else "hysteria")
190281
}
@@ -289,16 +380,34 @@ fun isMultiPort(hyAddr: String): Boolean {
289380
}
290381

291382
fun getFirstPort(portStr: String): Int {
292-
return portStr.substringBefore(":").substringBefore(",").toIntOrNull() ?: 443
383+
return portStr.substringBefore(",").substringBefore("-").substringBefore(":").toIntOrNull() ?: 443
293384
}
294385

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

399+
fun HysteriaBean.hasAdvancedHysteria2Options(): Boolean {
400+
if (protocolVersion != 2) return false
401+
return hy2InitialStreamReceiveWindow > 0 ||
402+
hy2MaxStreamReceiveWindow > 0 ||
403+
hy2InitialConnectionReceiveWindow > 0 ||
404+
hy2MaxConnectionReceiveWindow > 0 ||
405+
hy2MaxIdleTimeout > 0 ||
406+
hy2KeepAlivePeriod > 0 ||
407+
disableMtuDiscovery ||
408+
(isMultiPort(displayAddress()) && (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0))
409+
}
410+
302411
fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): SingBoxOptions.SingBoxOption {
303412
return when (bean.protocolVersion) {
304413
1 -> SingBoxOptions.Outbound_HysteriaOptions().apply {
@@ -413,9 +522,9 @@ fun hopPortsToSingboxList(s: String): List<String> {
413522
/**
414523
* Builds a config for the bundled official apernet/hysteria client binary (sidecar).
415524
*
416-
* NOTE: Hysteria2 (including Gecko obfs) now runs natively in the sing-box core, so this
417-
* sidecar path is no longer used for Gecko. This builder is retained for the bundled
418-
* hysteria2 binary infrastructure and potential fallback use.
525+
* NOTE: Default Hysteria2 (including Gecko obfs and fixed port hopping) runs natively in
526+
* the sing-box core. This sidecar path is used when HY2 advanced QUIC options or random
527+
* hop intervals are configured, because those fields are not exposed by the pinned core.
419528
*
420529
* Emitted as JSON (hysteria uses viper, which detects format by the .json extension).
421530
* The client's upstream QUIC sockets are protected from the VPN via
@@ -471,7 +580,30 @@ fun HysteriaBean.buildHysteria2SidecarConfig(
471580
if (downloadMbps > 0) put("down", "$downloadMbps mbps")
472581
})
473582
}
583+
if (isMultiPort(displayAddress())) {
584+
put("transport", JSONObject().apply {
585+
put("type", "udp")
586+
put("udp", JSONObject().apply {
587+
if (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0) {
588+
val rawMin = hy2MinHopInterval.takeIf { it > 0 } ?: hy2MaxHopInterval
589+
val min = rawMin.coerceAtLeast(5)
590+
val max = (hy2MaxHopInterval.takeIf { it > 0 } ?: min).coerceAtLeast(min)
591+
put("minHopInterval", "${min}s")
592+
put("maxHopInterval", "${max}s")
593+
} else if (hopInterval > 0) {
594+
put("hopInterval", "${hopInterval.coerceAtLeast(5)}s")
595+
}
596+
})
597+
})
598+
}
474599
put("quic", JSONObject().apply {
600+
if (hy2InitialStreamReceiveWindow > 0) put("initStreamReceiveWindow", hy2InitialStreamReceiveWindow)
601+
if (hy2MaxStreamReceiveWindow > 0) put("maxStreamReceiveWindow", hy2MaxStreamReceiveWindow)
602+
if (hy2InitialConnectionReceiveWindow > 0) put("initConnReceiveWindow", hy2InitialConnectionReceiveWindow)
603+
if (hy2MaxConnectionReceiveWindow > 0) put("maxConnReceiveWindow", hy2MaxConnectionReceiveWindow)
604+
if (hy2MaxIdleTimeout > 0) put("maxIdleTimeout", "${hy2MaxIdleTimeout}s")
605+
if (hy2KeepAlivePeriod > 0) put("keepAlivePeriod", "${hy2KeepAlivePeriod}s")
606+
if (disableMtuDiscovery) put("disablePathMTUDiscovery", true)
475607
put("sockopts", JSONObject().apply {
476608
put("fdControlUnixSocket", protectPath)
477609
})

0 commit comments

Comments
 (0)