From 094808a4fa199282e37faae69bebd522e43c7938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 10 May 2026 13:31:53 +0800 Subject: [PATCH] Add hysteria2 realm service and support --- constant/proxy.go | 1 + docs/configuration/inbound/hysteria2.md | 56 +- docs/configuration/inbound/hysteria2.zh.md | 56 +- docs/configuration/outbound/hysteria2.md | 60 +- docs/configuration/outbound/hysteria2.zh.md | 60 +- docs/configuration/service/hysteria-realm.md | 66 +++ .../service/hysteria-realm.zh.md | 66 +++ docs/configuration/service/index.md | 15 +- docs/configuration/service/index.zh.md | 15 +- go.mod | 2 +- go.sum | 4 +- include/quic.go | 5 + include/quic_stub.go | 7 + include/registry.go | 1 + mkdocs.yml | 1 + option/hysteria2.go | 26 +- protocol/hysteria2/inbound.go | 15 +- protocol/hysteria2/outbound.go | 35 +- protocol/hysteria2/realm.go | 173 ++++++ protocol/hysteria2/realm_server.go | 536 ++++++++++++++++++ 20 files changed, 1167 insertions(+), 33 deletions(-) create mode 100644 docs/configuration/service/hysteria-realm.md create mode 100644 docs/configuration/service/hysteria-realm.zh.md create mode 100644 protocol/hysteria2/realm.go create mode 100644 protocol/hysteria2/realm_server.go diff --git a/constant/proxy.go b/constant/proxy.go index ffec80250..868a3bb85 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -32,6 +32,7 @@ const ( TypeCCM = "ccm" TypeOCM = "ocm" TypeOOMKiller = "oom-killer" + TypeHysteriaRealm = "hysteria-realm" TypeACME = "acme" TypeCloudflareOriginCA = "cloudflare-origin-ca" ) diff --git a/docs/configuration/inbound/hysteria2.md b/docs/configuration/inbound/hysteria2.md index 62fbb209e..4d2494022 100644 --- a/docs/configuration/inbound/hysteria2.md +++ b/docs/configuration/inbound/hysteria2.md @@ -4,7 +4,8 @@ icon: material/alert-decagram !!! quote "Changes in sing-box 1.14.0" - :material-plus: [bbr_profile](#bbr_profile) + :material-plus: [bbr_profile](#bbr_profile) + :material-plus: [realm](#realm) !!! quote "Changes in sing-box 1.11.0" @@ -39,7 +40,14 @@ icon: material/alert-decagram "masquerade": "", // or {} "bbr_profile": "", - "brutal_debug": false + "brutal_debug": false, + "realm": { + "server_url": "https://realm.example.com", + "token": "", + "realm_id": "", + "stun_servers": [], + "http_client": {} + } } ``` @@ -164,3 +172,47 @@ BBR congestion control algorithm profile, one of `conservative` `standard` `aggr #### brutal_debug Enable debug information logging for Hysteria Brutal CC. + +#### realm + +!!! question "Since sing-box 1.14.0" + +Register this inbound to a Hysteria Realm rendezvous service to enable NAT traversal. + +The inbound discovers its public addresses via STUN, registers them on the realm, and uses UDP hole-punching to accept incoming clients without a publicly reachable listen address. + +See [Hysteria Realm](/configuration/service/hysteria-realm/) for the rendezvous service. + +#### realm.server_url + +==Required== + +Realm rendezvous service URL. + +#### realm.token + +Bearer token for the realm. Must match one of `users[].token` configured on the realm. + +#### realm.realm_id + +==Required== + +Slot identifier on the realm. + +1–64 characters, must match `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`. + +Outbounds must use the same `realm_id` to find this server. + +#### realm.stun_servers + +==Required== + +List of STUN servers (`host` or `host:port`) used to discover public addresses. + +Port defaults to `3478`. + +#### realm.http_client + +HTTP client used to talk to the realm. + +See [HTTP Client](/configuration/shared/http-client/) for details. diff --git a/docs/configuration/inbound/hysteria2.zh.md b/docs/configuration/inbound/hysteria2.zh.md index 0c5fdb014..cfe7f9f0d 100644 --- a/docs/configuration/inbound/hysteria2.zh.md +++ b/docs/configuration/inbound/hysteria2.zh.md @@ -4,7 +4,8 @@ icon: material/alert-decagram !!! quote "sing-box 1.14.0 中的更改" - :material-plus: [bbr_profile](#bbr_profile) + :material-plus: [bbr_profile](#bbr_profile) + :material-plus: [realm](#realm) !!! quote "sing-box 1.11.0 中的更改" @@ -39,7 +40,14 @@ icon: material/alert-decagram "masquerade": "", // 或 {} "bbr_profile": "", - "brutal_debug": false + "brutal_debug": false, + "realm": { + "server_url": "https://realm.example.com", + "token": "", + "realm_id": "", + "stun_servers": [], + "http_client": {} + } } ``` @@ -161,3 +169,47 @@ BBR 拥塞控制算法配置,可选 `conservative` `standard` `aggressive`。 #### brutal_debug 启用 Hysteria Brutal CC 的调试信息日志记录。 + +#### realm + +!!! question "自 sing-box 1.14.0 起" + +将此入站注册到 Hysteria Realm 会合服务,以启用 NAT 穿透。 + +入站通过 STUN 发现自己的公网地址并注册到 realm,借助 UDP 打洞接受客户端连接,无需可公网直达的监听地址。 + +会合服务参阅 [Hysteria Realm](/zh/configuration/service/hysteria-realm/)。 + +#### realm.server_url + +==必填== + +Realm 会合服务 URL。 + +#### realm.token + +Realm 的 Bearer 令牌,需与 realm 上配置的 `users[].token` 之一匹配。 + +#### realm.realm_id + +==必填== + +Realm 上的槽位标识符。 + +1–64 字符,需匹配 `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`。 + +出站需使用相同的 `realm_id` 才能找到本服务器。 + +#### realm.stun_servers + +==必填== + +用于发现公网地址的 STUN 服务器列表(`host` 或 `host:port`)。 + +端口默认为 `3478`。 + +#### realm.http_client + +与 realm 通信使用的 HTTP 客户端。 + +参阅 [HTTP 客户端](/zh/configuration/shared/http-client/) 了解详情。 diff --git a/docs/configuration/outbound/hysteria2.md b/docs/configuration/outbound/hysteria2.md index 2d5a9bcb1..4e162b554 100644 --- a/docs/configuration/outbound/hysteria2.md +++ b/docs/configuration/outbound/hysteria2.md @@ -1,7 +1,8 @@ !!! quote "Changes in sing-box 1.14.0" :material-plus: [hop_interval_max](#hop_interval_max) - :material-plus: [bbr_profile](#bbr_profile) + :material-plus: [bbr_profile](#bbr_profile) + :material-plus: [realm](#realm) !!! quote "Changes in sing-box 1.11.0" @@ -36,6 +37,13 @@ "bbr_profile": "", "brutal_debug": false, + "realm": { + "server_url": "https://realm.example.com", + "token": "", + "realm_id": "", + "stun_servers": [], + "http_client": {} + }, ... // Dial Fields } @@ -61,6 +69,8 @@ The server address. +Conflicts with `realm`. + #### server_port ==Required== @@ -69,13 +79,15 @@ The server port. Ignored if `server_ports` is set. +Conflicts with `realm`. + #### server_ports !!! question "Since sing-box 1.11.0" Server port range list. -Conflicts with `server_port`. +Conflicts with `server_port` and `realm`. #### hop_interval @@ -143,6 +155,50 @@ BBR congestion control algorithm profile, one of `conservative` `standard` `aggr Enable debug information logging for Hysteria Brutal CC. +#### realm + +!!! question "Since sing-box 1.14.0" + +Connect to a Hysteria2 server through a Hysteria Realm rendezvous service. + +The outbound queries the realm for the server's current public addresses, performs UDP hole-punching, and proceeds with the normal QUIC handshake. + +Conflicts with `server`, `server_port` and `server_ports`. + +The TLS SNI defaults to the host portion of `server_url`. Set `tls.server_name` to match the certificate the Hysteria2 server presents. + +See [Hysteria Realm](/configuration/service/hysteria-realm/) for the rendezvous service. + +#### realm.server_url + +==Required== + +Realm rendezvous service URL. + +#### realm.token + +Bearer token for the realm. Must match one of `users[].token` configured on the realm. + +#### realm.realm_id + +==Required== + +The same slot identifier the target Hysteria2 server registered. + +#### realm.stun_servers + +==Required== + +List of STUN servers (`host` or `host:port`) used to discover this client's public addresses. + +Port defaults to `3478`. + +#### realm.http_client + +HTTP client used to talk to the realm. + +See [HTTP Client](/configuration/shared/http-client/) for details. + ### Dial Fields See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/hysteria2.zh.md b/docs/configuration/outbound/hysteria2.zh.md index aa0e6e11f..aff60a8a1 100644 --- a/docs/configuration/outbound/hysteria2.zh.md +++ b/docs/configuration/outbound/hysteria2.zh.md @@ -1,7 +1,8 @@ !!! quote "sing-box 1.14.0 中的更改" :material-plus: [hop_interval_max](#hop_interval_max) - :material-plus: [bbr_profile](#bbr_profile) + :material-plus: [bbr_profile](#bbr_profile) + :material-plus: [realm](#realm) !!! quote "sing-box 1.11.0 中的更改" @@ -36,6 +37,13 @@ "bbr_profile": "", "brutal_debug": false, + "realm": { + "server_url": "https://realm.example.com", + "token": "", + "realm_id": "", + "stun_servers": [], + "http_client": {} + }, ... // 拨号字段 } @@ -59,6 +67,8 @@ 服务器地址。 +与 `realm` 冲突。 + #### server_port ==必填== @@ -67,13 +77,15 @@ 如果设置了 `server_ports`,则忽略此项。 +与 `realm` 冲突。 + #### server_ports !!! question "自 sing-box 1.11.0 起" 服务器端口范围列表。 -与 `server_port` 冲突。 +与 `server_port` 和 `realm` 冲突。 #### hop_interval @@ -141,6 +153,50 @@ BBR 拥塞控制算法配置,可选 `conservative` `standard` `aggressive`。 启用 Hysteria Brutal CC 的调试信息日志记录。 +#### realm + +!!! question "自 sing-box 1.14.0 起" + +通过 Hysteria Realm 会合服务连接 Hysteria2 服务器。 + +出站从 realm 查询服务器当前的公网地址,执行 UDP 打洞,然后进行常规的 QUIC 握手。 + +与 `server`、`server_port` 和 `server_ports` 冲突。 + +TLS SNI 默认使用 `server_url` 中的主机名。需设置 `tls.server_name` 以匹配 Hysteria2 服务器证书覆盖的名字。 + +会合服务参阅 [Hysteria Realm](/zh/configuration/service/hysteria-realm/)。 + +#### realm.server_url + +==必填== + +Realm 会合服务 URL。 + +#### realm.token + +Realm 的 Bearer 令牌,需与 realm 上配置的 `users[].token` 之一匹配。 + +#### realm.realm_id + +==必填== + +目标 Hysteria2 服务器注册时使用的相同槽位标识符。 + +#### realm.stun_servers + +==必填== + +用于发现本客户端公网地址的 STUN 服务器列表(`host` 或 `host:port`)。 + +端口默认为 `3478`。 + +#### realm.http_client + +与 realm 通信使用的 HTTP 客户端。 + +参阅 [HTTP 客户端](/zh/configuration/shared/http-client/) 了解详情。 + ### 拨号字段 参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/service/hysteria-realm.md b/docs/configuration/service/hysteria-realm.md new file mode 100644 index 000000000..a69d13069 --- /dev/null +++ b/docs/configuration/service/hysteria-realm.md @@ -0,0 +1,66 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# Hysteria Realm + +Hysteria Realm is a rendezvous service for Hysteria2 NAT traversal. + +A Hysteria2 server behind NAT registers its STUN-discovered public addresses to a stable realm endpoint; clients query the realm to learn the server's current addresses and perform UDP hole-punching to establish a direct QUIC connection. + +The realm only carries control-plane signaling. Once hole-punching succeeds, all proxy traffic flows directly between client and server. + +### Structure + +```json +{ + "type": "hysteria-realm", + + ... // Listen Fields + + "tls": {}, + "users": [ + { + "name": "", + "token": "", + "max_realms": 0 + } + ] +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +When configured, the realm serves HTTP/2 over TLS; otherwise plain HTTP/1.1. + +#### users + +==Required== + +Authorized users. + +#### users.name + +==Required== + +Username, used in logs and as the quota key. + +#### users.token + +==Required== + +Bearer token presented by Hysteria2 inbounds and outbounds via `Authorization: Bearer `. + +#### users.max_realms + +Maximum number of realm slots this user may hold concurrently. diff --git a/docs/configuration/service/hysteria-realm.zh.md b/docs/configuration/service/hysteria-realm.zh.md new file mode 100644 index 000000000..a73ddbdbf --- /dev/null +++ b/docs/configuration/service/hysteria-realm.zh.md @@ -0,0 +1,66 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# Hysteria Realm + +Hysteria Realm 是用于 Hysteria2 NAT 穿透的会合服务。 + +位于 NAT 后面的 Hysteria2 服务器将其通过 STUN 发现的公网地址注册到一个稳定的 realm 端点;客户端从 realm 查询服务器当前的地址并执行 UDP 打洞,以建立直连的 QUIC 连接。 + +Realm 只承载控制信令。打洞成功后,所有代理流量在客户端和服务器之间直连传输。 + +### 结构 + +```json +{ + "type": "hysteria-realm", + + ... // 监听字段 + + "tls": {}, + "users": [ + { + "name": "", + "token": "", + "max_realms": 0 + } + ] +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。 + +### 字段 + +#### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 + +配置后,realm 将通过 TLS 提供 HTTP/2 服务;否则提供明文 HTTP/1.1。 + +#### users + +==必填== + +授权用户。 + +#### users.name + +==必填== + +用户名,用于日志记录和配额键。 + +#### users.token + +==必填== + +Hysteria2 入站和出站通过 `Authorization: Bearer ` 出示的 Bearer 令牌。 + +#### users.max_realms + +此用户可同时持有的 realm 槽位数量上限。 diff --git a/docs/configuration/service/index.md b/docs/configuration/service/index.md index de3583b2b..dea66d971 100644 --- a/docs/configuration/service/index.md +++ b/docs/configuration/service/index.md @@ -21,13 +21,14 @@ icon: material/new-box ### Fields -| Type | Format | -|------------|------------------------| -| `ccm` | [CCM](./ccm) | -| `derp` | [DERP](./derp) | -| `ocm` | [OCM](./ocm) | -| `resolved` | [Resolved](./resolved) | -| `ssm-api` | [SSM API](./ssm-api) | +| Type | Format | +|-------------------|---------------------------------------| +| `ccm` | [CCM](./ccm) | +| `derp` | [DERP](./derp) | +| `hysteria-realm` | [Hysteria Realm](./hysteria-realm) | +| `ocm` | [OCM](./ocm) | +| `resolved` | [Resolved](./resolved) | +| `ssm-api` | [SSM API](./ssm-api) | #### tag diff --git a/docs/configuration/service/index.zh.md b/docs/configuration/service/index.zh.md index a0d18cbba..240dce2cd 100644 --- a/docs/configuration/service/index.zh.md +++ b/docs/configuration/service/index.zh.md @@ -21,13 +21,14 @@ icon: material/new-box ### 字段 -| 类型 | 格式 | -|-----------|------------------------| -| `ccm` | [CCM](./ccm) | -| `derp` | [DERP](./derp) | -| `ocm` | [OCM](./ocm) | -| `resolved`| [Resolved](./resolved) | -| `ssm-api` | [SSM API](./ssm-api) | +| 类型 | 格式 | +|-------------------|---------------------------------------| +| `ccm` | [CCM](./ccm) | +| `derp` | [DERP](./derp) | +| `hysteria-realm` | [Hysteria Realm](./hysteria-realm) | +| `ocm` | [OCM](./ocm) | +| `resolved` | [Resolved](./resolved) | +| `ssm-api` | [SSM API](./ssm-api) | #### tag diff --git a/go.mod b/go.mod index 1a6465053..a2fa9d283 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/sagernet/sing v0.8.12-0.20260625092856-31bbf21d4b12 github.com/sagernet/sing-cloudflared v0.1.1 github.com/sagernet/sing-mux v0.3.5 - github.com/sagernet/sing-quic v0.6.2-0.20260412143638-8f65b6be7cd6 + github.com/sagernet/sing-quic v0.6.2-0.20260510042401-4055a2bb2381 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 diff --git a/go.sum b/go.sum index 00fa2ebce..7b23b6373 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/sagernet/sing-cloudflared v0.1.1 h1:By29ZWMJl8QU6UcC5pmBv803rYigAoSmz github.com/sagernet/sing-cloudflared v0.1.1/go.mod h1:bH2NKX+NpDTY1Zkxfboxw6MXB/ZywaNLmrDJYgKMJ2Y= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-quic v0.6.2-0.20260412143638-8f65b6be7cd6 h1:j3ISQRDyY5rs27NzUS/le+DHR0iOO0K0x+mWDLzu4Ok= -github.com/sagernet/sing-quic v0.6.2-0.20260412143638-8f65b6be7cd6/go.mod h1:r5Adw0EMUyhGBCjPI2JEupDtC040DrrvreXtua7Ifdc= +github.com/sagernet/sing-quic v0.6.2-0.20260510042401-4055a2bb2381 h1:bwja5I7Sgr4Z1nyCLlN6T5eBhhSE/ilYZmkEFIoPAhQ= +github.com/sagernet/sing-quic v0.6.2-0.20260510042401-4055a2bb2381/go.mod h1:+oqD54aHel4ALKkp1hVXWCgLU/EjLojvm6AUzDfvj0I= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= diff --git a/include/quic.go b/include/quic.go index 6a3f30175..42182c299 100644 --- a/include/quic.go +++ b/include/quic.go @@ -5,6 +5,7 @@ package include import ( "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/service" "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/dns/transport/quic" "github.com/sagernet/sing-box/protocol/hysteria" @@ -30,3 +31,7 @@ func registerQUICTransports(registry *dns.TransportRegistry) { quic.RegisterTransport(registry) quic.RegisterHTTP3Transport(registry) } + +func registerQUICServices(registry *service.Registry) { + hysteria2.RegisterRealmService(registry) +} diff --git a/include/quic_stub.go b/include/quic_stub.go index d2c03b98e..b603cf48d 100644 --- a/include/quic_stub.go +++ b/include/quic_stub.go @@ -10,6 +10,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/service" "github.com/sagernet/sing-box/common/listener" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" @@ -69,3 +70,9 @@ func registerQUICTransports(registry *dns.TransportRegistry) { return nil, C.ErrQUICNotIncluded }) } + +func registerQUICServices(registry *service.Registry) { + service.Register[option.HysteriaRealmServiceOptions](registry, C.TypeHysteriaRealm, func(ctx context.Context, logger log.ContextLogger, tag string, options option.HysteriaRealmServiceOptions) (adapter.Service, error) { + return nil, C.ErrQUICNotIncluded + }) +} diff --git a/include/registry.go b/include/registry.go index 91d66bbd5..2d478bfe8 100644 --- a/include/registry.go +++ b/include/registry.go @@ -136,6 +136,7 @@ func ServiceRegistry() *service.Registry { resolved.RegisterService(registry) ssmapi.RegisterService(registry) + registerQUICServices(registry) registerDERPService(registry) registerCCMService(registry) registerOCMService(registry) diff --git a/mkdocs.yml b/mkdocs.yml index 4280979e0..a14ada33e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -192,6 +192,7 @@ nav: - SSM API: configuration/service/ssm-api.md - CCM: configuration/service/ccm.md - OCM: configuration/service/ocm.md + - Hysteria Realm: configuration/service/hysteria-realm.md markdown_extensions: - toc: slugify: !!python/object/apply:pymdownx.slugs.slugify diff --git a/option/hysteria2.go b/option/hysteria2.go index e1a54e4b8..eee810543 100644 --- a/option/hysteria2.go +++ b/option/hysteria2.go @@ -22,6 +22,15 @@ type Hysteria2InboundOptions struct { Masquerade *Hysteria2Masquerade `json:"masquerade,omitempty"` BBRProfile string `json:"bbr_profile,omitempty"` BrutalDebug bool `json:"brutal_debug,omitempty"` + Realm *Hysteria2Realm `json:"realm,omitempty"` +} + +type Hysteria2Realm struct { + ServerURL string `json:"server_url"` + Token string `json:"token,omitempty"` + RealmID string `json:"realm_id"` + STUNServers badoption.Listable[string] `json:"stun_servers"` + HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` } type Hysteria2Obfs struct { @@ -124,6 +133,19 @@ type Hysteria2OutboundOptions struct { Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer QUICOptions - BBRProfile string `json:"bbr_profile,omitempty"` - BrutalDebug bool `json:"brutal_debug,omitempty"` + BBRProfile string `json:"bbr_profile,omitempty"` + BrutalDebug bool `json:"brutal_debug,omitempty"` + Realm *Hysteria2Realm `json:"realm,omitempty"` +} + +type HysteriaRealmUser struct { + Name string `json:"name"` + Token string `json:"token"` + MaxRealms int `json:"max_realms,omitempty"` +} + +type HysteriaRealmServiceOptions struct { + ListenOptions + InboundTLSOptionsContainer + Users []HysteriaRealmUser `json:"users"` } diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go index a94c26dd7..a6e800c5a 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -114,7 +114,11 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo } else { udpTimeout = C.UDPTimeout } - service, err := hysteria2.NewService[int](hysteria2.ServiceOptions{ + realmOptions, err := buildRealmOptions(ctx, logger, options.Realm) + if err != nil { + return nil, err + } + hysteriaService, err := hysteria2.NewService[int](hysteria2.ServiceOptions{ Context: ctx, Logger: logger, BrutalDebug: options.BrutalDebug, @@ -136,6 +140,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo Handler: inbound, MasqueradeHandler: masqueradeHandler, BBRProfile: options.BBRProfile, + RealmOptions: realmOptions, }) if err != nil { return nil, err @@ -148,8 +153,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo userNameList = append(userNameList, user.Name) userPasswordList = append(userPasswordList, user.Password) } - service.UpdateUsers(userList, userPasswordList) - inbound.service = service + hysteriaService.UpdateUsers(userList, userPasswordList) + inbound.service = hysteriaService inbound.userNameList = userNameList return inbound, nil } @@ -215,6 +220,10 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return h.service.Start(packetConn) } +func (h *Inbound) InterfaceUpdated() { + h.service.Reset() +} + func (h *Inbound) Close() error { return common.Close( h.listener, diff --git a/protocol/hysteria2/outbound.go b/protocol/hysteria2/outbound.go index fe23109a2..28bf4a5c7 100644 --- a/protocol/hysteria2/outbound.go +++ b/protocol/hysteria2/outbound.go @@ -3,6 +3,7 @@ package hysteria2 import ( "context" "net" + "net/url" "os" "time" @@ -45,7 +46,11 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } - tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) + tlsServerAddress, tlsOptions, err := outboundTLSOptions(options) + if err != nil { + return nil, err + } + tlsConfig, err := tls.NewClient(ctx, logger, tlsServerAddress, tlsOptions) if err != nil { return nil, err } @@ -65,6 +70,10 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if err != nil { return nil, err } + realmOptions, err := buildRealmOptions(ctx, logger, options.Realm) + if err != nil { + return nil, err + } networkList := options.Network.Build() client, err := hysteria2.NewClient(hysteria2.ClientOptions{ Context: ctx, @@ -89,8 +98,9 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL InitialPacketSize: options.InitialPacketSize, DisablePathMTUDiscovery: options.DisablePathMTUDiscovery, }, - UDPDisabled: !common.Contains(networkList, N.NetworkUDP), - BBRProfile: options.BBRProfile, + UDPDisabled: !common.Contains(networkList, N.NetworkUDP), + BBRProfile: options.BBRProfile, + RealmOptions: realmOptions, }) if err != nil { return nil, err @@ -102,6 +112,25 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL }, nil } +func outboundTLSOptions(options option.Hysteria2OutboundOptions) (string, option.OutboundTLSOptions, error) { + tlsOptions := common.PtrValueOrDefault(options.TLS) + if options.Realm == nil { + return options.Server, tlsOptions, nil + } + if options.Server != "" || options.ServerPort != 0 || len(options.ServerPorts) > 0 { + return "", tlsOptions, E.New("realm conflicts with server, server_port, and server_ports") + } + serverURL, err := url.Parse(options.Realm.ServerURL) + if err != nil { + return "", tlsOptions, E.Cause(err, "parse realm server_url") + } + serverName := serverURL.Hostname() + if serverName == "" { + return "", tlsOptions, E.New("missing host in realm server_url") + } + return serverName, tlsOptions, nil +} + func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { switch N.NetworkName(network) { case N.NetworkTCP: diff --git a/protocol/hysteria2/realm.go b/protocol/hysteria2/realm.go new file mode 100644 index 000000000..7eb0f7425 --- /dev/null +++ b/protocol/hysteria2/realm.go @@ -0,0 +1,173 @@ +package hysteria2 + +import ( + "context" + "errors" + "net" + "net/http" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-quic/hysteria2/realm" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + sHTTP "github.com/sagernet/sing/protocol/http" + "github.com/sagernet/sing/service" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/render" + "golang.org/x/net/http2" +) + +func RegisterRealmService(registry *boxService.Registry) { + boxService.Register[option.HysteriaRealmServiceOptions](registry, C.TypeHysteriaRealm, NewRealmService) +} + +func buildRealmOptions(ctx context.Context, logger log.ContextLogger, options *option.Hysteria2Realm) (*realm.Options, error) { + if options == nil { + return nil, nil + } + transport, err := service.FromContext[adapter.HTTPClientManager](ctx).ResolveTransport(ctx, logger, common.PtrValueOrDefault(options.HTTPClient)) + if err != nil { + return nil, E.Cause(err, "create realm http client") + } + return &realm.Options{ + ServerURL: options.ServerURL, + Token: options.Token, + RealmID: options.RealmID, + STUNServers: options.STUNServers, + HTTPClient: &http.Client{Transport: transport}, + Logger: logger, + }, nil +} + +type RealmService struct { + boxService.Adapter + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + listener *listener.Listener + tlsConfig tls.ServerConfig + httpServer *http.Server + server *server +} + +func NewRealmService(ctx context.Context, logger log.ContextLogger, tag string, options option.HysteriaRealmServiceOptions) (adapter.Service, error) { + if len(options.Users) == 0 { + return nil, E.New("missing users") + } + tokenMap := make(map[string]*realmUser, len(options.Users)) + for i, user := range options.Users { + if user.Name == "" { + return nil, E.New("missing name for user[", i, "]") + } + if user.Token == "" { + return nil, E.New("missing token for user[", i, "]") + } + tokenMap[user.Token] = &realmUser{ + name: user.Name, + maxRealms: user.MaxRealms, + } + } + server := newServer(logger, tokenMap) + ctx, cancel := context.WithCancel(ctx) + chiRouter := chi.NewRouter() + chiRouter.Use(middleware.RequestSize(maxRequestBodyBytes)) + chiRouter.Use(func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger.DebugContext(r.Context(), r.Method, " ", r.RequestURI, " ", sHTTP.SourceAddress(r)) + handler.ServeHTTP(w, r) + }) + }) + chiRouter.Route("/v1/{id}", func(r chi.Router) { + r.Use(validateRealmID) + r.With(server.authUser).Post("/", server.handleRegister) + r.With(server.authSession).Delete("/", server.handleDeregister) + r.With(server.authSession).Get("/events", server.handleEvents) + r.With(server.authSession).Post("/heartbeat", server.handleHeartbeat) + r.With(server.authUser).Post("/connect", server.handleConnect) + r.With(server.authSession).Post("/connects/{nonce}", server.handleConnectResponse) + }) + chiRouter.NotFound(func(w http.ResponseWriter, r *http.Request) { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, render.M{"error": "not_found", "message": "unknown path"}) + }) + chiRouter.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { + render.Status(r, http.StatusMethodNotAllowed) + render.JSON(w, r, render.M{"error": "bad_request", "message": "method not allowed"}) + }) + s := &RealmService{ + Adapter: boxService.NewAdapter(C.TypeHysteriaRealm, tag), + ctx: ctx, + cancel: cancel, + logger: logger, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: []string{N.NetworkTCP}, + Listen: options.ListenOptions, + }), + httpServer: &http.Server{ + Handler: chiRouter, + ConnContext: func(ctx context.Context, _ net.Conn) context.Context { + return log.ContextWithNewID(ctx) + }, + }, + server: server, + } + if options.TLS != nil { + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + s.tlsConfig = tlsConfig + } + return s, nil +} + +func (s *RealmService) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + if s.tlsConfig != nil { + err := s.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + } + tcpListener, err := s.listener.ListenTCP() + if err != nil { + return err + } + if s.tlsConfig != nil { + if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) { + s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...)) + } + tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) + } + go func() { + err = s.httpServer.Serve(tcpListener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("serve error: ", err) + } + }() + return nil +} + +func (s *RealmService) Close() error { + s.cancel() + err := common.Close(common.PtrOrNil(s.httpServer)) + s.server.closeAll() + return E.Errors(err, common.Close( + common.PtrOrNil(s.listener), + s.tlsConfig, + )) +} diff --git a/protocol/hysteria2/realm_server.go b/protocol/hysteria2/realm_server.go new file mode 100644 index 000000000..5143b86b6 --- /dev/null +++ b/protocol/hysteria2/realm_server.go @@ -0,0 +1,536 @@ +package hysteria2 + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/netip" + "regexp" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +const ( + sessionTTL = time.Minute + realmNamePattern = `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$` + maxRequestBodyBytes = 4 << 10 + maxAddresses = 8 + nonceHexLength = 32 + obfsHexLength = 64 + eventChannelSize = 16 + maxPendingAttempts = 16 + connectResponseTimeout = 10 * time.Second +) + +var realmPattern = regexp.MustCompile(realmNamePattern) + +type contextKey int + +const ( + contextKeyUser contextKey = iota + contextKeySession +) + +type realmUser struct { + name string + maxRealms int +} + +type realmSession struct { + id string + realmID string + username string + addresses []string + expires time.Time + events chan realmEvent + timer *time.Timer + done chan struct{} + closed bool + pending map[string]chan punchResponsePayload +} + +type realmEvent struct { + kind string + data any +} + +type punchEvent struct { + Addresses []string `json:"addresses"` + Nonce string `json:"nonce"` + Obfs string `json:"obfs"` +} + +type punchResponsePayload struct { + addresses []string +} + +type server struct { + access sync.Mutex + realms map[string]*realmSession + sessions map[string]*realmSession + userCounts map[string]int + logger log.ContextLogger + tokenMap map[string]*realmUser +} + +func newServer(logger log.ContextLogger, tokenMap map[string]*realmUser) *server { + return &server{ + realms: make(map[string]*realmSession), + sessions: make(map[string]*realmSession), + userCounts: make(map[string]int), + logger: logger, + tokenMap: tokenMap, + } +} + +func validateRealmID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if !realmPattern.MatchString(id) { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid realm name"}) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *server) authBearer(name string, key contextKey, lookup func(r *http.Request, token string) (any, bool)) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + bearer, token, found := strings.Cut(header, " ") + if bearer != "Bearer" || !found { + render.Status(r, http.StatusUnauthorized) + render.JSON(w, r, render.M{"error": "invalid_token", "message": "invalid " + name + " token"}) + return + } + value, authenticated := lookup(r, token) + if !authenticated { + render.Status(r, http.StatusUnauthorized) + render.JSON(w, r, render.M{"error": "invalid_token", "message": "invalid " + name + " token"}) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), key, value))) + }) + } +} + +func (s *server) authUser(next http.Handler) http.Handler { + return s.authBearer("realm", contextKeyUser, func(_ *http.Request, token string) (any, bool) { + user, authenticated := s.tokenMap[token] + return user, authenticated + })(next) +} + +func (s *server) authSession(next http.Handler) http.Handler { + return s.authBearer("session", contextKeySession, func(r *http.Request, token string) (any, bool) { + sess := s.getSessionByToken(token) + if sess == nil || sess.realmID != chi.URLParam(r, "id") { + return nil, false + } + return sess, true + })(next) +} + +func (s *server) getSessionByToken(token string) *realmSession { + s.access.Lock() + defer s.access.Unlock() + sess := s.sessions[token] + if sess == nil || sess.closed || time.Now().After(sess.expires) { + return nil + } + return sess +} + +func (s *server) removeSessionLocked(sess *realmSession) { + if sess.closed { + return + } + sess.closed = true + close(sess.done) + if s.realms[sess.realmID] == sess { + delete(s.realms, sess.realmID) + } + if _, found := s.sessions[sess.id]; found { + s.userCounts[sess.username]-- + if s.userCounts[sess.username] <= 0 { + delete(s.userCounts, sess.username) + } + } + delete(s.sessions, sess.id) + sess.timer.Stop() + close(sess.events) + for nonce, ch := range sess.pending { + close(ch) + delete(sess.pending, nonce) + } +} + +func (s *server) removeSession(sess *realmSession) { + s.access.Lock() + defer s.access.Unlock() + s.removeSessionLocked(sess) +} + +func (s *server) removeExpiredSession(sess *realmSession) bool { + s.access.Lock() + defer s.access.Unlock() + if sess.closed || !time.Now().After(sess.expires) { + return false + } + s.removeSessionLocked(sess) + return true +} + +func (s *server) closeAll() { + s.access.Lock() + defer s.access.Unlock() + for _, sess := range s.sessions { + s.removeSessionLocked(sess) + } +} + +func (s *server) registerPending(sess *realmSession, nonce string) (chan punchResponsePayload, bool) { + s.access.Lock() + defer s.access.Unlock() + if sess.closed || len(sess.pending) >= maxPendingAttempts { + return nil, false + } + if _, exists := sess.pending[nonce]; exists { + return nil, false + } + ch := make(chan punchResponsePayload, 1) + sess.pending[nonce] = ch + return ch, true +} + +func (s *server) deliverPending(sess *realmSession, nonce string, payload punchResponsePayload) bool { + s.access.Lock() + defer s.access.Unlock() + if sess.closed { + return false + } + ch, found := sess.pending[nonce] + if !found { + return false + } + delete(sess.pending, nonce) + select { + case ch <- payload: + default: + } + return true +} + +func (s *server) cancelPending(sess *realmSession, nonce string) { + s.access.Lock() + defer s.access.Unlock() + delete(sess.pending, nonce) +} + +func (s *server) sendEvent(sess *realmSession, ev realmEvent) bool { + s.access.Lock() + defer s.access.Unlock() + if sess.closed { + return false + } + select { + case sess.events <- ev: + return true + default: + return false + } +} + +func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value(contextKeyUser).(*realmUser) + id := chi.URLParam(r, "id") + var req struct { + Addresses []string `json:"addresses"` + } + err := render.DecodeJSON(r.Body, &req) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid json"}) + return + } + err = validateAddresses(req.Addresses) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()}) + return + } + s.access.Lock() + if _, exists := s.realms[id]; exists { + s.access.Unlock() + render.Status(r, http.StatusConflict) + render.JSON(w, r, render.M{"error": "realm_taken", "message": "realm already registered"}) + return + } + if user.maxRealms > 0 && s.userCounts[user.name] >= user.maxRealms { + s.access.Unlock() + render.Status(r, http.StatusTooManyRequests) + render.JSON(w, r, render.M{"error": "realm_limit_reached", "message": "per-user realm limit reached"}) + return + } + var b [16]byte + _, err = rand.Read(b[:]) + if err != nil { + s.access.Unlock() + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, render.M{"error": "internal", "message": "entropy failure"}) + return + } + sess := &realmSession{ + id: hex.EncodeToString(b[:]), + realmID: id, + username: user.name, + addresses: append([]string(nil), req.Addresses...), + expires: time.Now().Add(sessionTTL), + events: make(chan realmEvent, eventChannelSize), + done: make(chan struct{}), + pending: make(map[string]chan punchResponsePayload), + } + s.realms[id] = sess + s.sessions[sess.id] = sess + s.userCounts[user.name]++ + sess.timer = time.AfterFunc(sessionTTL, func() { + if s.removeExpiredSession(sess) { + s.logger.Debug("[", sess.username, "] session expired realm=", sess.realmID) + } + }) + s.access.Unlock() + s.logger.InfoContext(r.Context(), "[", user.name, "] registered realm=", id) + render.JSON(w, r, render.M{ + "session_id": sess.id, + "ttl": int(sessionTTL.Seconds()), + }) +} + +func (s *server) handleDeregister(w http.ResponseWriter, r *http.Request) { + sess := r.Context().Value(contextKeySession).(*realmSession) + s.logger.InfoContext(r.Context(), "[", sess.username, "] deregistered realm=", sess.realmID) + s.removeSession(sess) + render.NoContent(w, r) +} + +func (s *server) handleHeartbeat(w http.ResponseWriter, r *http.Request) { + sess := r.Context().Value(contextKeySession).(*realmSession) + var req struct { + Addresses []string `json:"addresses"` + } + err := render.DecodeJSON(r.Body, &req) + if err != nil && !errors.Is(err, io.EOF) { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid json"}) + return + } + if req.Addresses != nil { + err = validateAddresses(req.Addresses) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()}) + return + } + } + s.access.Lock() + sess.expires = time.Now().Add(sessionTTL) + if req.Addresses != nil { + sess.addresses = append([]string(nil), req.Addresses...) + } + sess.timer.Reset(sessionTTL) + s.access.Unlock() + s.logger.DebugContext(r.Context(), "[", sess.username, "] heartbeat realm=", sess.realmID) + s.sendEvent(sess, realmEvent{kind: "heartbeat_ack", data: render.M{"ttl": int(sessionTTL.Seconds())}}) + render.JSON(w, r, render.M{"ttl": int(sessionTTL.Seconds())}) +} + +func (s *server) handleEvents(w http.ResponseWriter, r *http.Request) { + sess := r.Context().Value(contextKeySession).(*realmSession) + flusher, supportsFlusher := w.(http.Flusher) + if !supportsFlusher { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, render.M{"error": "internal", "message": "streaming unsupported"}) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + flusher.Flush() + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case ev, open := <-sess.events: + if !open { + return + } + data, _ := json.Marshal(ev.data) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.kind, data) + flusher.Flush() + } + } +} + +func (s *server) handleConnect(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value(contextKeyUser).(*realmUser) + id := chi.URLParam(r, "id") + var req struct { + Addresses []string `json:"addresses"` + Nonce string `json:"nonce"` + Obfs string `json:"obfs"` + } + err := render.DecodeJSON(r.Body, &req) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid json"}) + return + } + err = validateAddresses(req.Addresses) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()}) + return + } + err = validateHexField("nonce", req.Nonce, nonceHexLength) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()}) + return + } + err = validateHexField("obfs", req.Obfs, obfsHexLength) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()}) + return + } + s.access.Lock() + // Any authenticated realm user may connect to a registered realm. The user name + // is for logging and per-user registration quota, not an ownership boundary here. + sess := s.realms[id] + if sess == nil || sess.closed || time.Now().After(sess.expires) { + s.access.Unlock() + render.Status(r, http.StatusNotFound) + render.JSON(w, r, render.M{"error": "realm_not_found", "message": "realm not registered"}) + return + } + serverAddresses := append([]string(nil), sess.addresses...) + s.access.Unlock() + + respCh, ready := s.registerPending(sess, req.Nonce) + if !ready { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, render.M{"error": "rate_limited", "message": "too many in-flight connect attempts"}) + return + } + defer s.cancelPending(sess, req.Nonce) + + if !s.sendEvent(sess, realmEvent{kind: "punch", data: punchEvent{Addresses: req.Addresses, Nonce: req.Nonce, Obfs: req.Obfs}}) { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, render.M{"error": "rate_limited", "message": "server event buffer full"}) + return + } + s.logger.DebugContext(r.Context(), "[", user.name, "] connect realm=", id) + + timer := time.NewTimer(connectResponseTimeout) + defer timer.Stop() + select { + case payload, open := <-respCh: + if !open { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, render.M{"error": "realm_not_found", "message": "realm not registered"}) + return + } + if len(payload.addresses) > 0 { + serverAddresses = payload.addresses + } + case <-timer.C: + case <-sess.done: + render.Status(r, http.StatusNotFound) + render.JSON(w, r, render.M{"error": "realm_not_found", "message": "realm not registered"}) + return + case <-r.Context().Done(): + return + } + render.JSON(w, r, render.M{ + "addresses": serverAddresses, + "nonce": req.Nonce, + "obfs": req.Obfs, + }) +} + +func (s *server) handleConnectResponse(w http.ResponseWriter, r *http.Request) { + sess := r.Context().Value(contextKeySession).(*realmSession) + nonce := chi.URLParam(r, "nonce") + err := validateHexField("nonce", nonce, nonceHexLength) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()}) + return + } + var req struct { + Addresses []string `json:"addresses"` + } + err = render.DecodeJSON(r.Body, &req) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": "invalid json"}) + return + } + err = validateAddresses(req.Addresses) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, render.M{"error": "bad_request", "message": err.Error()}) + return + } + delivered := s.deliverPending(sess, nonce, punchResponsePayload{addresses: append([]string(nil), req.Addresses...)}) + if !delivered { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, render.M{"error": "attempt_not_found", "message": "no pending attempt for nonce"}) + return + } + s.logger.DebugContext(r.Context(), "[", sess.username, "] connect-response realm=", sess.realmID) + render.NoContent(w, r) +} + +func validateAddresses(addresses []string) error { + if len(addresses) == 0 { + return E.New("at least one address required") + } + if len(addresses) > maxAddresses { + return E.New("too many addresses (max ", maxAddresses, ")") + } + for _, address := range addresses { + _, err := netip.ParseAddrPort(address) + if err != nil { + return E.New("invalid address: ", address) + } + } + return nil +} + +func validateHexField(name, value string, length int) error { + if len(value) != length { + return E.New(name, " must be ", length, " hex characters") + } + _, err := hex.DecodeString(value) + if err != nil { + return E.New(name, " must be valid hex") + } + return nil +}