Skip to content

Commit 5d1c476

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

8 files changed

Lines changed: 308 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: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,58 @@ 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+
link.queryParameter(name)?.trim()?.removeSuffix("s")?.toIntOrNull()
161+
}
162+
}
163+
118164
fun HysteriaBean.toUri(): String {
119165
var un = ""
120166
var pw = ""
@@ -185,6 +231,35 @@ fun HysteriaBean.toUri(): String {
185231
}
186232
}
187233
}
234+
if (hy2InitialStreamReceiveWindow > 0) {
235+
builder.addQueryParameter("initStreamReceiveWindow", hy2InitialStreamReceiveWindow.toString())
236+
}
237+
if (hy2MaxStreamReceiveWindow > 0) {
238+
builder.addQueryParameter("maxStreamReceiveWindow", hy2MaxStreamReceiveWindow.toString())
239+
}
240+
if (hy2InitialConnectionReceiveWindow > 0) {
241+
builder.addQueryParameter("initConnReceiveWindow", hy2InitialConnectionReceiveWindow.toString())
242+
}
243+
if (hy2MaxConnectionReceiveWindow > 0) {
244+
builder.addQueryParameter("maxConnReceiveWindow", hy2MaxConnectionReceiveWindow.toString())
245+
}
246+
if (hy2MaxIdleTimeout > 0) {
247+
builder.addQueryParameter("maxIdleTimeout", "${hy2MaxIdleTimeout}s")
248+
}
249+
if (hy2KeepAlivePeriod > 0) {
250+
builder.addQueryParameter("keepAlivePeriod", "${hy2KeepAlivePeriod}s")
251+
}
252+
if (disableMtuDiscovery) {
253+
builder.addQueryParameter("disablePathMTUDiscovery", "1")
254+
}
255+
if (isMultiPort(displayAddress())) {
256+
if (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0) {
257+
if (hy2MinHopInterval > 0) builder.addQueryParameter("minHopInterval", "${hy2MinHopInterval}s")
258+
if (hy2MaxHopInterval > 0) builder.addQueryParameter("maxHopInterval", "${hy2MaxHopInterval}s")
259+
} else if (hopInterval != 10) {
260+
builder.addQueryParameter("hopInterval", "${hopInterval}s")
261+
}
262+
}
188263
}
189264
return builder.toLink(if (protocolVersion == 2) "hy2" else "hysteria")
190265
}
@@ -289,16 +364,35 @@ fun isMultiPort(hyAddr: String): Boolean {
289364
}
290365

291366
fun getFirstPort(portStr: String): Int {
292-
return portStr.substringBefore(":").substringBefore(",").toIntOrNull() ?: 443
367+
return portStr.substringBefore(",").substringBefore("-").substringBefore(":").toIntOrNull() ?: 443
293368
}
294369

295370
fun HysteriaBean.canUseSingBox(): Boolean {
296371
if (protocol != HysteriaBean.PROTOCOL_UDP) return false
372+
if (protocolVersion == 2 && hasAdvancedHysteria2Options()) {
373+
// The bundled sing-box Hysteria2 option model supports Gecko obfs and fixed
374+
// port hopping, but not Hysteria's advanced QUIC knobs or random hop
375+
// intervals. Use the Hysteria2 sidecar when those fields are configured.
376+
return false
377+
}
297378
// 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.
379+
// hawkff/sing-quic gecko backport), so default HY2 profiles do not need the sidecar.
299380
return true
300381
}
301382

383+
fun HysteriaBean.hasAdvancedHysteria2Options(): Boolean {
384+
if (protocolVersion != 2) return false
385+
return hy2InitialStreamReceiveWindow > 0 ||
386+
hy2MaxStreamReceiveWindow > 0 ||
387+
hy2InitialConnectionReceiveWindow > 0 ||
388+
hy2MaxConnectionReceiveWindow > 0 ||
389+
hy2MaxIdleTimeout > 0 ||
390+
hy2KeepAlivePeriod > 0 ||
391+
disableMtuDiscovery ||
392+
hy2MinHopInterval > 0 ||
393+
hy2MaxHopInterval > 0
394+
}
395+
302396
fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): SingBoxOptions.SingBoxOption {
303397
return when (bean.protocolVersion) {
304398
1 -> SingBoxOptions.Outbound_HysteriaOptions().apply {
@@ -413,9 +507,9 @@ fun hopPortsToSingboxList(s: String): List<String> {
413507
/**
414508
* Builds a config for the bundled official apernet/hysteria client binary (sidecar).
415509
*
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.
510+
* NOTE: Default Hysteria2 (including Gecko obfs and fixed port hopping) runs natively in
511+
* the sing-box core. This sidecar path is used when HY2 advanced QUIC options or random
512+
* hop intervals are configured, because those fields are not exposed by the pinned core.
419513
*
420514
* Emitted as JSON (hysteria uses viper, which detects format by the .json extension).
421515
* The client's upstream QUIC sockets are protected from the VPN via
@@ -471,7 +565,30 @@ fun HysteriaBean.buildHysteria2SidecarConfig(
471565
if (downloadMbps > 0) put("down", "$downloadMbps mbps")
472566
})
473567
}
568+
if (isMultiPort(displayAddress()) || hy2MinHopInterval > 0 || hy2MaxHopInterval > 0) {
569+
put("transport", JSONObject().apply {
570+
put("type", "udp")
571+
put("udp", JSONObject().apply {
572+
if (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0) {
573+
val rawMin = hy2MinHopInterval.takeIf { it > 0 } ?: hy2MaxHopInterval
574+
val min = rawMin.coerceAtLeast(5)
575+
val max = (hy2MaxHopInterval.takeIf { it > 0 } ?: min).coerceAtLeast(min)
576+
put("minHopInterval", "${min}s")
577+
put("maxHopInterval", "${max}s")
578+
} else if (hopInterval > 0) {
579+
put("hopInterval", "${hopInterval.coerceAtLeast(5)}s")
580+
}
581+
})
582+
})
583+
}
474584
put("quic", JSONObject().apply {
585+
if (hy2InitialStreamReceiveWindow > 0) put("initStreamReceiveWindow", hy2InitialStreamReceiveWindow)
586+
if (hy2MaxStreamReceiveWindow > 0) put("maxStreamReceiveWindow", hy2MaxStreamReceiveWindow)
587+
if (hy2InitialConnectionReceiveWindow > 0) put("initConnReceiveWindow", hy2InitialConnectionReceiveWindow)
588+
if (hy2MaxConnectionReceiveWindow > 0) put("maxConnReceiveWindow", hy2MaxConnectionReceiveWindow)
589+
if (hy2MaxIdleTimeout > 0) put("maxIdleTimeout", "${hy2MaxIdleTimeout}s")
590+
if (hy2KeepAlivePeriod > 0) put("keepAlivePeriod", "${hy2KeepAlivePeriod}s")
591+
if (disableMtuDiscovery) put("disablePathMTUDiscovery", true)
475592
put("sockopts", JSONObject().apply {
476593
put("fdControlUnixSocket", protectPath)
477594
})

0 commit comments

Comments
 (0)