mirror of
https://github.com/SagerNet/sing-box.git
synced 2026-07-01 21:22:21 +00:00
Add hysteria2 realm service and support
This commit is contained in:
parent
89d83aa6d2
commit
094808a4fa
20 changed files with 1167 additions and 33 deletions
|
|
@ -32,6 +32,7 @@ const (
|
|||
TypeCCM = "ccm"
|
||||
TypeOCM = "ocm"
|
||||
TypeOOMKiller = "oom-killer"
|
||||
TypeHysteriaRealm = "hysteria-realm"
|
||||
TypeACME = "acme"
|
||||
TypeCloudflareOriginCA = "cloudflare-origin-ca"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/) 了解详情。
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/)。
|
||||
|
|
|
|||
66
docs/configuration/service/hysteria-realm.md
Normal file
66
docs/configuration/service/hysteria-realm.md
Normal file
|
|
@ -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 <token>`.
|
||||
|
||||
#### users.max_realms
|
||||
|
||||
Maximum number of realm slots this user may hold concurrently.
|
||||
66
docs/configuration/service/hysteria-realm.zh.md
Normal file
66
docs/configuration/service/hysteria-realm.zh.md
Normal file
|
|
@ -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 <token>` 出示的 Bearer 令牌。
|
||||
|
||||
#### users.max_realms
|
||||
|
||||
此用户可同时持有的 realm 槽位数量上限。
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
go.mod
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ func ServiceRegistry() *service.Registry {
|
|||
resolved.RegisterService(registry)
|
||||
ssmapi.RegisterService(registry)
|
||||
|
||||
registerQUICServices(registry)
|
||||
registerDERPService(registry)
|
||||
registerCCMService(registry)
|
||||
registerOCMService(registry)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
173
protocol/hysteria2/realm.go
Normal file
173
protocol/hysteria2/realm.go
Normal file
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
536
protocol/hysteria2/realm_server.go
Normal file
536
protocol/hysteria2/realm_server.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue