Compare commits

..

5 Commits

Author SHA1 Message Date
tsosunchia 3549fe5ad8 fix(udp): preserve wildcard reply sources and isolate destination sessions (#565)
* fix(udp): preserve local destinations and unicast reply sources on Linux

* fix(udp): select unicast sources for IPv6 multicast replies

* fix(udp): retain interface zones for link-local multicast sessions

* fix(udp): isolate interface-local multicast sessions

* fix(udp): include ingress interfaces in session identity

* fix(udp): restrict destination zones to IPv6

* ci(udp): follow rc5 patched Go toolchain requirement

---------

Co-authored-by: sagit <36596628+Sagit-chu@users.noreply.github.com>
2026-10-24 18:35:22 +08:00
Sebastian Luo dd3ed4e284 fix(frontend): keep node id when upgrading from list view (#567)
In list view, the per-node upgrade button set upgradeTargetNodeId and then
called openUpgradeModal("single") without an id, which resets the target to
null. handleConfirmUpgrade then matched neither branch, so "确认升级" sent no
request and the modal stayed open. Pass node.id the same way grid view does.

Co-authored-by: Sebastian Luo <sebastianluo@Sebastians-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:18:20 +08:00
sagit c794586cc7 fix(security): require patched Go and Node.js runtimes (#566) 2026-10-22 11:15:28 +08:00
sagit d9303032ca feat: support username-free passkey login (#564) 2026-09-30 21:12:18 +08:00
sagit 6da8b2b6ec docs: clarify passkey setup after upgrades (#563) 2026-09-30 20:23:28 +08:00
31 changed files with 1289 additions and 102 deletions
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
node-version-file: '.node-version'
- name: Install pnpm
run: npm install -g pnpm@10.28.1
@@ -54,7 +54,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-backend/go.mod'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
@@ -89,7 +89,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-backend/go.mod'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
@@ -112,7 +112,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-gost/go.mod'
cache-dependency-path: go-gost/go.sum
- name: Download dependencies
+2 -2
View File
@@ -79,7 +79,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-gost/go.mod'
- name: Cache Go modules
uses: actions/cache@v4
@@ -197,7 +197,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-backend/go.mod'
- name: Cache Go dependencies
uses: actions/cache@v4
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.node-version'
registry-url: 'https://registry.npmjs.org'
- name: Get version from tag
+87
View File
@@ -0,0 +1,87 @@
name: UDP Destination Regression
on:
push:
branches: ['main', 'fix/udp-wildcard-source', 'pr/udp-wildcard-source']
pull_request:
paths:
- 'go-gost/**'
- '.github/workflows/udp-destination.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: udp-destination-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go-gost/go.mod'
cache-dependency-path: |
go-gost/go.sum
go-gost/x/go.sum
- name: Test agent module
working-directory: go-gost
run: go test ./...
- name: Test protocol module
working-directory: go-gost/x
run: go test ./...
- name: Check UDP races
working-directory: go-gost/x
run: go test -race ./internal/net/udp ./listener/udp
- name: Vet UDP implementation
working-directory: go-gost/x
run: go vet ./internal/net/udp ./listener/udp
- name: Build destination regression executable
working-directory: go-gost/x
run: |
go test -c -race -o "$RUNNER_TEMP/udp-destination.test" ./listener/udp
go test -c -race -o "$RUNNER_TEMP/udp-packet.test" ./internal/net/udp
- name: Test multiple destinations in isolated network namespace
run: |
sudo unshare --net -- sh -eu -c '
sysctl -qw net.ipv6.conf.all.disable_ipv6=0
ip link set lo up
ip addr add 198.18.0.1/32 dev lo
ip addr add 169.254.1.2/32 dev lo
ip -6 addr add fd00:1::1/128 dev lo nodad
ip -6 addr add fd00:2::1/128 dev lo nodad
ip -6 addr add fd00:3::1/128 dev lo nodad
for suffix in "" 2; do
ip link add "mc-tx$suffix" type veth peer name "mc-rx$suffix"
ip link set "mc-tx$suffix" addrgenmode none up
ip link set "mc-rx$suffix" addrgenmode none up
ip -6 addr add fe80::1/64 dev "mc-tx$suffix" nodad
ip -6 addr add fe80::2/64 dev "mc-rx$suffix" nodad
done
ip addr add 198.18.1.1/24 dev mc-tx
ip addr add 198.18.2.1/24 dev mc-tx2
export FLUX_TEST_IPV6_ADDRS=fd00:1::1,fd00:2::1,fd00:3::1
export FLUX_TEST_IPV4_LINK_LOCAL=169.254.1.2
export FLUX_TEST_MULTICAST_RX=mc-rx FLUX_TEST_MULTICAST_TX=mc-tx
export FLUX_TEST_MULTICAST_RX2=mc-rx2 FLUX_TEST_MULTICAST_TX2=mc-tx2
"$1" -test.v -test.timeout=60s -test.count=5
exec "$2" -test.v -test.timeout=60s -test.count=5
' sh "$RUNNER_TEMP/udp-destination.test" "$RUNNER_TEMP/udp-packet.test"
- name: Build Linux agent
working-directory: go-gost
env:
CGO_ENABLED: '0'
GOOS: linux
GOARCH: amd64
run: go build -trimpath -o "$RUNNER_TEMP/flux-agent" .
+1
View File
@@ -0,0 +1 @@
22.23.4
+2 -1
View File
@@ -40,7 +40,8 @@ FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent.
- **Backend DB**: handlers use Repository methods, never `repo.DB()` directly.
- **GORM models**: always define `TableName()` (GORM pluralizes by default).
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
- **Go versions**: `go.mod` says 1.25.0 for all three modules; CI builds with 1.23.
- **Go versions**: all three modules require Go 1.26.10 or newer (CVE-2026-111943); CI reads the module's `go.mod`. Do not use Go 1.27.0–1.27.2, which also lack this fix.
- **Node.js versions**: `.node-version` pins 22.23.4 for CI; local builds also support patched 24.22.1+ and 26.12.1+ (CVE-2026-111497).
## Anti-patterns
+10 -2
View File
@@ -17,6 +17,12 @@
- 支持隧道修改配置、转发修改隧道
## 构建环境
Go 后端和 agent 的最低编译版本为 **Go 1.26.10**;若使用 Go 1.27,须为 **1.27.3 或更新补丁版**。前端与 skill 发布使用 **Node.js 22.23.4**(见 `.node-version`),本地也支持 24.22.1+ 或 26.12.1+。前端使用 `pnpm@10.28.1`。
这些版本包含 2026 年 10 月的运行时安全修复。已有部署还需重新构建并更新后端镜像及所有 agent 二进制,详见 [安全排查与升级说明](docs/security/2026-10-runtime-updates.md)。
## 部署流程
---
### Docker Compose部署
@@ -93,9 +99,11 @@ docker compose up -d
在该部署目录运行 `docker compose up -d --force-recreate backend`,使新环境变量进入后端容器。后端重建期间,面板 API 会短暂中断。容器恢复后重新加载前端页面;如果 PWA 提示“发现新版本”,选择“刷新”,也可以用新的无痕窗口核对是否仍加载旧页面。
3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。
3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。新绑定要求设备支持可发现凭据(resident key),不支持的设备会在绑定时失败。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。
4. 以后在登录页输入用户名,点击“使用通行证密钥登录”并解锁设备。账号被停用时密钥登录也会被拒绝。撤销密钥仍到个人资料页输入当前密码并删除。
4. 以后在登录页直接点击“选择通行证密钥登录”,由设备选择账号并解锁,**无需先填写用户名**。账号被停用时密钥登录也会被拒绝。撤销密钥仍到个人资料页输入当前密码并删除。
旧版本绑定时只“优先”请求可发现凭据,现有密钥是否能直接选账号取决于当时的设备,不能按绑定版本统一判定或批量删除。若设备不显示某把旧密钥,请使用密码登录,在个人资料页删除该密钥并重新绑定;确认新密钥与密码恢复途径可用后再清理其他旧密钥。登录页不提供用户名密钥回退。
**入口未显示时,按顺序检查:**
+37
View File
@@ -0,0 +1,37 @@
# 2026-10-22 运行时漏洞排查与升级
## 排查结论
| 漏洞 | 本项目涉及范围 | 修复措施 |
| --- | --- | --- |
| CVE-2026-109618、CVE-2026-109619(PHP RCE) | 仓库无 PHP 源码、Composer 依赖或 PHP 容器。项目后端为 Go,前端生产容器为 Nginx 静态服务。 | 本项目无 PHP 运行时需要更新;同机部署的其他 PHP 服务需单独排查。转发 PHP 网站的流量不会修复目标网站的 PHP。 |
| CVE-2026-111497(Node.js Brotli 越界写入) | 前端 Docker 构建使用 Node.js 22;CI 原使用已停止支持的 Node.js 20。Node.js 用于构建、开发及 skill 安装/发布,不是面板生产服务端。未发现项目源码直接调用 Brotli 解码,但构建依赖和下载工具仍运行于 Node.js。 | Docker、CI 和 `.node-version` 固定为 22.23.4;包声明仅接受已修复的 22.23.4+、24.22.1+、26.12.1+ 分支,前端启用 `engine-strict`。 |
| CVE-2026-111943(Go crypto/x509 中间证书信任) | 后端的版本检查、升级下载、授权验证等使用 HTTPS;agent 的更新下载、TLS 拨号和证书验证使用 Go 标准库。原 Go 1.25 工具链缺少此次拒绝信任修复。 | 三个 Go 模块最低版本升至 1.26.10,CI 从对应 `go.mod` 读取,后端 Docker 使用 1.26.10-bookworm。 |
Go 漏洞涉及一组特定中间证书,并非拒绝所有包含八层中间证书的证书链。官方修复在 `crypto/x509.Certificate.Verify` 中拒绝包含这些证书的链。Go 1.27.0–1.27.2 也未修复;手动选择 Go 1.27 时必须使用 1.27.3+。`go.mod` 的最低版本无法排除该较新分支里的旧补丁版本,因此发布必须使用仓库固定的工具链。
## 让已部署实例获得修复
1. 使用本次修改后的构建配置发布新版本,重新构建后端及前端镜像、Linux amd64/arm64 agent。Go 标准库编译进二进制,仅更新宿主机 Go 或 `ca-certificates` 不会替换已有二进制中的标准库。已有镜像标签也不会因源码更新自动修复。
2. 在面板实际部署目录,将 `.env` 中的 `FLUX_VERSION` 设置为包含本次修复的**已发布版本**,再执行 `docker compose pull backend frontend` 和 `docker compose up -d backend frontend`。如使用独立构建或私有仓库,应部署对应的新镜像。
3. 将所有节点的 agent 更新为同一安全版本并重启服务,包括离线节点。使用解压后的构建产物执行 `go version -m /path/to/gost` 或 `go version -m /path/to/paneld`,核对构建版本为 go1.26.10(或已修复的更新版本);UPX 压缩可能需要先对副本解压以读取构建信息。
4. 开发机和自托管构建机也需升级 Node.js。本次排查时本机为 v26.0.0,属于受影响版本;应升级至 26.12.1+,或切换到 `.node-version` 的 22.23.4。无需在运行静态前端的 Nginx 容器内安装 Node.js。
本次代码修改不表示远端部署已经更新;须完成新版本发布、镜像替换与节点升级后,才能确认线上获得修复。更新系统 CA 信任包可作为额外措施,但不能替代 Go 二进制重建。
## 本次验证
- Go 1.26.10 下,`go-backend`、`go-gost`、`go-gost/x` 各自的 `go test ./...` 全部通过。PostgreSQL 外部数据库契约测试仍需配置 `FLVX_POSTGRES_TEST_DSN` 后单独执行。
- Node.js 22.23.4、pnpm 10.28.1 下,前端 `pnpm install --frozen-lockfile` 与 `pnpm run build` 通过,无需更改依赖锁文件。
- 本机 Node.js 26.0.0 执行前端构建时,被 `engine-strict` 拒绝。
- 使用 `CGO_ENABLED=0` 交叉编译后端和 agent 的 Linux amd64/arm64 共四个二进制成功;`go version -m` 确认均为 go1.26.10。
- Docker Hub 上两个固定构建镜像标签均存在,且包含 Linux amd64/arm64。未执行 Docker 镜像构建或线上部署。
- 工作流 YAML、包 JSON 解析及 `git diff --check` 通过。
## 官方依据
- [PHP 官方安全公告](https://www.php.net/archive/2026.php#2026-10-21-4)
- [Node.js 2026-10-21 安全更新及修复版本](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases)
- [Go 官方问题 #82345](https://go.dev/issue/82345)
- [Go 漏洞库 GO-2026-6809](https://pkg.go.dev/vuln/GO-2026-6809)
- [Go 官方发布记录](https://go.dev/doc/devel/release)
+133
View File
@@ -0,0 +1,133 @@
# Preserve UDP reply source addresses on Linux
The Linux UDP listener preserves the destination address of each incoming
datagram so that replies use the correct local source. This change was developed
against FLVX `3.0.1-rc4`, commit
`d9303032cad09b33cd0b7c91704fd671fbfdd078`.
It also includes the `3.0.1-rc5` runtime update at
`c794586cc7773e5f967783895d3071538442bbf7`.
## Problem
A wildcard UDP socket bound to `0.0.0.0:port` or `[::]:port` can receive datagrams
addressed to several local IP addresses. Previously, the listener retained only
the client address. A reply sent through `WriteTo` could therefore use a source
IP chosen by the kernel instead of the IP the client contacted. DNS clients, for
example, can reject such replies as coming from an unexpected source.
The UDP session pool was also keyed only by the client address and port. When
one client socket contacted multiple local addresses on the same listener port,
these requests could share a session despite having different destinations.
## Changes
- Linux wildcard UDP sockets enable IPv4/IPv6 packet information. `recvmsg`
preserves the datagram's local destination and interface; `sendmsg` uses the
corresponding source address and interface when replying.
- IPv4 keeps the original destination (`ipi_addr`) separate from the local
reply address (`ipi_spec_dst`), so broadcast requests receive unicast replies
rather than attempting to send with a broadcast source address.
- IPv6 multicast destinations remain part of session identity, but replies
leave the source unspecified and retain the received interface index so the
kernel can select a valid local unicast source.
- The destination metadata travels through existing metrics, statistics,
admission and traffic-limiter wrappers. The exposed remote address remains
the real client address.
- Sessions are keyed by the local destination endpoint, remote client endpoint
and received interface index. Identical IPv4 broadcast or multicast endpoints
on different interfaces therefore retain separate reply control information.
An accepted session reports its actual local destination.
Link-local IPv6 unicast and link-local or interface-local multicast
destinations retain their interface zone, keeping identical multicast groups
on different interfaces separate even when the client uses the same
non-link-local IP and source port.
IPv4 destinations, including link-local unicast and multicast, never receive
an IPv6 zone; their interface isolation uses the separate index in the key.
- Explicitly bound UDP sockets retain their existing behavior. Non-Linux
platforms retain their existing implementation. No configuration keys or
panel API changes are required.
The fix affects the shared UDP listener, including services dynamically created
after startup. It does not configure listening addresses, NAT, routing or
firewalls. Multi-homed hosts still need correct source policy routing for their
uplinks; this patch does not fix SSH/TCP routing or packet loss before ingress.
## Build
Use Go 1.26.10 or a compatible newer toolchain. From the repository root:
```sh
cd go-gost
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags '-s -w' -o flux_agent .
```
Use the agent with the normal panel integration and forwarding configuration.
Selecting a wildcard listener enables destination preservation automatically
on Linux. The patch does not select a listener address or change the upstream
connection's outbound interface.
## Regression tests
The Linux destination tests cover IPv4 wildcard and dual-stack wildcard sockets,
explicit IPv4/IPv6 bindings, one client source port contacting multiple local
destinations, repeated requests, session expiry, and active metrics, statistics,
admission and traffic-limit wrappers. A separate IPv6 case binds the client to
`::1` and checks the exact remote IP and port for every destination. Broadcast
tests exercise both IPv4 and dual-stack listeners, retaining the broadcast
destination while checking that replies use a local unicast address.
An IPv6 multicast regression sends across an isolated veth pair and checks the
multicast session destination and the unicast reply, including interface zones.
A two-interface case uses one fixed ULA client endpoint to contact the same
link-local or interface-local multicast group on both interfaces and checks
distinct sessions and their individual reply-interface metadata.
IPv4 and dual-stack cases likewise use a single fixed client endpoint to send
to one multicast group or the limited broadcast address on two interfaces,
checking separate sessions and their reply-interface indices.
Link-local IPv4 unicast and multicast regressions also check that accepted
local endpoints have no IPv6 zone, on both IPv4 and dual-stack listeners.
Run the standard suites on Linux:
```sh
(cd go-gost && go test ./...)
(cd go-gost/x && go test ./...)
```
To exercise multiple IPv6 addresses without modifying the host's live network,
compile the tests first and run them inside a disposable network namespace.
The following commands require Linux, root access for `unshare`, and `iproute2`:
```sh
cd go-gost/x
go test -c -race -o /tmp/flvx-udp-destination.test ./listener/udp
go test -c -race -o /tmp/flvx-udp-packet.test ./internal/net/udp
sudo unshare --net -- sh -eu -c '
sysctl -qw net.ipv6.conf.all.disable_ipv6=0
ip link set lo up
ip addr add 198.18.0.1/32 dev lo
ip addr add 169.254.1.2/32 dev lo
ip -6 addr add fd00:1::1/128 dev lo nodad
ip -6 addr add fd00:2::1/128 dev lo nodad
ip -6 addr add fd00:3::1/128 dev lo nodad
for suffix in "" 2; do
ip link add "mc-tx$suffix" type veth peer name "mc-rx$suffix"
ip link set "mc-tx$suffix" addrgenmode none up
ip link set "mc-rx$suffix" addrgenmode none up
ip -6 addr add fe80::1/64 dev "mc-tx$suffix" nodad
ip -6 addr add fe80::2/64 dev "mc-rx$suffix" nodad
done
ip addr add 198.18.1.1/24 dev mc-tx
ip addr add 198.18.2.1/24 dev mc-tx2
export FLUX_TEST_IPV6_ADDRS=fd00:1::1,fd00:2::1,fd00:3::1
export FLUX_TEST_IPV4_LINK_LOCAL=169.254.1.2
export FLUX_TEST_MULTICAST_RX=mc-rx FLUX_TEST_MULTICAST_TX=mc-tx
export FLUX_TEST_MULTICAST_RX2=mc-rx2 FLUX_TEST_MULTICAST_TX2=mc-tx2
/tmp/flvx-udp-destination.test -test.v -test.timeout=60s -test.count=5
exec /tmp/flvx-udp-packet.test -test.v -test.timeout=60s -test.count=5
'
```
The addresses above are isolated test addresses, not deployment settings.
The `UDP Destination Regression` GitHub Actions workflow runs these namespace
tests, the race detector, both agent module suites, and a Linux agent build.
+2 -1
View File
@@ -1,4 +1,5 @@
FROM golang:1.25-bookworm AS builder
# CVE-2026-111943: crypto/x509 is linked into paneld; rebuild with the fixed Go release.
FROM golang:1.26.10-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
+1 -1
View File
@@ -1,6 +1,6 @@
module go-backend
go 1.25.0
go 1.26.10
require (
github.com/fxamacker/cbor/v2 v2.9.0
+40 -26
View File
@@ -60,7 +60,7 @@ func passkeyConfig() (*webauthn.WebAuthn, string) {
RPID: host,
RPDisplayName: "FLVX",
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementPreferred},
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementRequired},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
@@ -112,6 +112,17 @@ func (h *Handler) takePasskeyCeremony(id, kind, origin string, userID int64) (we
return c.session, true
}
func (h *Handler) takePasskeyLoginCeremony(id, origin string) (passkeyCeremony, bool) {
h.passkeyMu.Lock()
c, ok := h.passkeyPending[id]
delete(h.passkeyPending, id)
h.passkeyMu.Unlock()
if !ok || c.kind != "login-discoverable" || c.origin != origin || time.Now().After(c.session.Expires) {
return passkeyCeremony{}, false
}
return c, true
}
func (h *Handler) loadPasskeyUser(userID int64) (passkeyUser, error) {
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
@@ -178,7 +189,7 @@ func (h *Handler) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥"))
return
}
options, session, err := wa.BeginRegistration(u)
options, session, err := wa.BeginRegistration(u, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
@@ -278,26 +289,16 @@ func (h *Handler) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
}
if !passkeyBody(r, &req) {
if !passkeyBody(r, &req) || strings.TrimSpace(req.Username) != "" {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByUsername(strings.TrimSpace(req.Username))
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录"))
return
}
u, err := h.loadPasskeyUser(user.ID)
if err != nil || len(u.credentials) == 0 {
response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录"))
return
}
options, session, err := wa.BeginLogin(u, webauthn.WithUserVerification(protocol.VerificationRequired))
options, session, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: user.ID, kind: "login", origin: origin, session: *session})
id, ok := h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
@@ -320,22 +321,35 @@ func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
// Login sessions carry the user ID; the caller cannot choose a different account at finish.
h.passkeyMu.Lock()
pending := h.passkeyPending[req.SessionID]
h.passkeyMu.Unlock()
session, ok := h.takePasskeyCeremony(req.SessionID, "login", origin, pending.userID)
pending, ok := h.takePasskeyLoginCeremony(req.SessionID, origin)
if !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
return
}
u, err := h.loadPasskeyUser(pending.userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
resolved, credential, err := wa.FinishPasskeyLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
// The credential ID determines ownership. A supplied userHandle cannot select another account.
if len(rawID) == 0 || len(userHandle) != 8 {
return nil, errInvalidPasskey
}
row, lookupErr := h.repo.GetPasskeyByID(base64.RawURLEncoding.EncodeToString(rawID))
if lookupErr != nil || row == nil {
return nil, errInvalidPasskey
}
owner, loadErr := h.loadPasskeyUser(row.UserID)
if loadErr != nil || !bytes.Equal(userHandle, owner.WebAuthnID()) {
return nil, errInvalidPasskey
}
return owner, nil
}, pending.session, credentialRequest(r, req.Credential))
var u passkeyUser
if err == nil {
var valid bool
u, valid = resolved.(passkeyUser)
if !valid {
err = errInvalidPasskey
}
}
credential, err := wa.FinishLogin(u, session, credentialRequest(r, req.Credential))
if err != nil || credential.Authenticator.CloneWarning {
if err != nil || credential == nil || credential.Authenticator.CloneWarning {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
@@ -9,21 +9,48 @@ import (
func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) {
h := &Handler{passkeyPending: make(map[string]passkeyCeremony)}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create challenge")
}
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); !ok {
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); !ok {
t.Fatal("valid challenge was rejected")
}
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok {
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
t.Fatal("challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
if !ok {
t.Fatal("could not create expired challenge")
}
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok {
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
t.Fatal("expired challenge was accepted")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://wrong.example.test"); ok {
t.Fatal("wrong origin was accepted")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("failed discoverable challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); !ok {
t.Fatal("valid discoverable challenge was rejected")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("discoverable challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
if !ok {
t.Fatal("could not create expired discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("expired discoverable challenge was accepted")
}
}
@@ -22,6 +22,17 @@ func (r *Repository) GetPasskey(userID int64, id string) (*model.Passkey, error)
return &key, err
}
// Credential IDs are unique across all users. Resolve the owner from the stored
// credential before checking the untrusted user handle in a discoverable login.
func (r *Repository) GetPasskeyByID(id string) (*model.Passkey, error) {
var key model.Passkey
err := r.db.Where("id = ?", id).Take(&key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &key, err
}
func (r *Repository) CreatePasskey(key *model.Passkey) error {
return r.db.Create(key).Error
}
@@ -62,7 +62,7 @@ func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T)
if status["enabled"] != false {
t.Fatalf("invalid origin enabled passkeys: %v", status)
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-disabled"}); result.Code == 0 {
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{}); result.Code == 0 {
t.Fatal("login begin succeeded with invalid origin")
}
if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-disabled", "password": "test-password"}); result.Code != 0 {
@@ -70,7 +70,7 @@ func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T)
}
}
func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
func TestPasskeyRegistrationAndOwnership(t *testing.T) {
t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin)
router, r := setupContractRouter(t, "passkey-test-secret")
seedLegacyUser(t, r, 9302, "passkey-alice", "alice-password")
@@ -90,6 +90,10 @@ func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
}
// The failed cross-user attempt consumes the challenge.
begin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
selection := begin["options"].(map[string]interface{})["publicKey"].(map[string]interface{})["authenticatorSelection"].(map[string]interface{})
if selection["residentKey"] != "required" || selection["requireResidentKey"] != true {
t.Fatalf("registration did not require a discoverable credential: %v", selection)
}
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
@@ -113,45 +117,148 @@ func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", bobToken, map[string]string{"id": keyID, "password": "bob-password"}); result.Code == 0 {
t.Fatal("Bob deleted Alice's credential")
}
loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
bad := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), "https://wrong.example.test", "panel.example.test", true, 1)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": bad}); result.Code == 0 {
t.Fatal("wrong origin was accepted")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
assertion := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1)
loginBody := map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": assertion}
loginResult := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody)
if passkeyData(t, loginResult)["token"] == "" {
t.Fatal("passkey login returned no JWT")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody); result.Code == 0 {
t.Fatal("login challenge was reusable")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
badRP := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "wrong.example.test", true, 2)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": badRP}); result.Code == 0 {
t.Fatal("wrong RP ID was accepted")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
noUV := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", false, 2)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": noUV}); result.Code == 0 {
t.Fatal("assertion without user verification was accepted")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
reusedCounter := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": reusedCounter}); result.Code == 0 {
t.Fatal("reused nonzero signature counter was accepted")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "wrong"}); result.Code == 0 {
t.Fatal("delete did not require password re-verification")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "alice-password"}); result.Code != 0 {
t.Fatalf("delete: %s", result.Msg)
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}); result.Code == 0 {
t.Fatal("deleted credential could still start login")
loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{}))
aliceHandle := make([]byte, 8)
binary.BigEndian.PutUint64(aliceHandle, 9302)
assertion := makeAssertionResponseWithHandle(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1, aliceHandle)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": assertion}); result.Code == 0 {
t.Fatal("deleted credential could still complete login")
}
// Deletion must not prevent the same account from binding a new key.
rebind := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
newID := make([]byte, 32)
if _, err := rand.Read(newID); err != nil {
t.Fatal(err)
}
newResponse := makeRegistrationResponse(t, privateKey, newID, passkeyChallenge(t, rebind), passkeyTestOrigin)
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, map[string]interface{}{"sessionId": rebind["sessionId"], "credential": newResponse}); result.Code != 0 {
t.Fatalf("rebind after delete: %s", result.Msg)
}
if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-alice", "password": "alice-password"}); result.Code != 0 {
t.Fatalf("password login regressed after enabling passkeys: %s", result.Msg)
}
}
func TestPasskeyDiscoverableLoginAndOwnership(t *testing.T) {
t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin)
router, r := setupContractRouter(t, "passkey-test-secret")
seedLegacyUser(t, r, 9311, "discover-alice", "alice-password")
seedLegacyUser(t, r, 9312, "discover-bob", "bob-password")
aliceToken, _ := auth.GenerateToken(9311, "discover-alice", 1, "passkey-test-secret")
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
credentialID := make([]byte, 32)
if _, err := rand.Read(credentialID); err != nil {
t.Fatal(err)
}
register := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
registration := makeRegistrationResponse(t, privateKey, credentialID, passkeyChallenge(t, register), passkeyTestOrigin)
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, map[string]interface{}{"sessionId": register["sessionId"], "credential": registration}); result.Code != 0 {
t.Fatalf("register: %s", result.Msg)
}
aliceHandle := make([]byte, 8)
binary.BigEndian.PutUint64(aliceHandle, 9311)
bobHandle := make([]byte, 8)
binary.BigEndian.PutUint64(bobHandle, 9312)
begin := func(body interface{}) map[string]interface{} {
t.Helper()
return passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", body))
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "discover-alice"}); result.Code == 0 {
t.Fatal("username-based passkey fallback was accepted")
}
finish := func(session map[string]interface{}, assertion map[string]interface{}) response.R {
t.Helper()
return passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": session["sessionId"], "credential": assertion})
}
assertionFor := func(session map[string]interface{}, handle []byte, origin, rpID string, uv bool, count uint32) map[string]interface{} {
t.Helper()
return makeAssertionResponseWithHandle(t, privateKey, credentialID, passkeyChallenge(t, session), origin, rpID, uv, count, handle)
}
// No username and no allowCredentials let the authenticator offer an account.
session := begin(map[string]string{})
options := session["options"].(map[string]interface{})["publicKey"].(map[string]interface{})
if list, exists := options["allowCredentials"]; exists && list != nil {
t.Fatalf("discoverable login unexpectedly restricted credentials: %v", list)
}
if options["userVerification"] != "required" {
t.Fatalf("discoverable login did not require UV: %v", options)
}
wrongHandle := assertionFor(session, bobHandle, passkeyTestOrigin, "panel.example.test", true, 1)
if result := finish(session, wrongHandle); result.Code == 0 {
t.Fatal("Bob's user handle selected Alice's credential")
}
if result := finish(session, wrongHandle); result.Code == 0 {
t.Fatal("failed discoverable challenge was reusable")
}
session = begin(map[string]string{})
// Older preferred resident-key registrations may have no discoverable userHandle.
missingHandle := assertionFor(session, nil, passkeyTestOrigin, "panel.example.test", true, 1)
if result := finish(session, missingHandle); result.Code == 0 {
t.Fatal("discoverable login accepted a missing user handle")
}
session = begin(map[string]string{})
unknownID := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
unknownID["id"] = base64.RawURLEncoding.EncodeToString([]byte("unknown-credential"))
unknownID["rawId"] = unknownID["id"]
if result := finish(session, unknownID); result.Code == 0 {
t.Fatal("unknown credential ID selected Alice's account")
}
for _, test := range []struct {
name string
origin string
rpID string
uv bool
}{
{"origin", "https://wrong.example.test", "panel.example.test", true},
{"RP ID", passkeyTestOrigin, "wrong.example.test", true},
{"user verification", passkeyTestOrigin, "panel.example.test", false},
} {
session := begin(map[string]string{})
assertion := assertionFor(session, aliceHandle, test.origin, test.rpID, test.uv, 1)
if result := finish(session, assertion); result.Code == 0 {
t.Fatalf("invalid discoverable assertion was accepted: %s", test.name)
}
}
session = begin(map[string]string{})
badSignature := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
badSignature["response"].(map[string]interface{})["signature"] = base64.RawURLEncoding.EncodeToString([]byte("invalid-signature"))
if result := finish(session, badSignature); result.Code == 0 {
t.Fatal("invalid signature was accepted")
}
session = begin(map[string]string{})
valid := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
if got := passkeyData(t, finish(session, valid))["name"]; got != "discover-alice" {
t.Fatalf("discoverable login selected the wrong account: %v", got)
}
if result := finish(session, valid); result.Code == 0 {
t.Fatal("discoverable assertion was replayed")
}
session = begin(map[string]string{})
reusedCounter := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
if result := finish(session, reusedCounter); result.Code == 0 {
t.Fatal("discoverable login accepted a reused nonzero signature counter")
}
if err := r.DB().Exec("UPDATE user SET status = 0 WHERE id = ?", 9311).Error; err != nil {
t.Fatal(err)
}
session = begin(map[string]string{})
disabled := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 2)
if result := finish(session, disabled); result.Code == 0 {
t.Fatal("disabled account logged in with a discoverable credential")
}
}
@@ -180,6 +287,10 @@ func makeRegistrationResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []b
}
func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32) map[string]interface{} {
return makeAssertionResponseWithHandle(t, privateKey, id, challenge, origin, rpID, verified, count, nil)
}
func makeAssertionResponseWithHandle(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32, handle []byte) map[string]interface{} {
t.Helper()
rpHash := sha256.Sum256([]byte(rpID))
authData := append([]byte{}, rpHash[:]...)
@@ -197,5 +308,9 @@ func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte
if err != nil {
t.Fatal(err)
}
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"authenticatorData": base64.RawURLEncoding.EncodeToString(authData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client), "signature": base64.RawURLEncoding.EncodeToString(signature), "userHandle": nil}}
var userHandle interface{}
if handle != nil {
userHandle = base64.RawURLEncoding.EncodeToString(handle)
}
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"authenticatorData": base64.RawURLEncoding.EncodeToString(authData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client), "signature": base64.RawURLEncoding.EncodeToString(signature), "userHandle": userHandle}}
}
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/go-gost/gost
go 1.25.0
go 1.26.10
require (
github.com/go-gost/core v0.3.1
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/go-gost/x
go 1.25.0
go 1.26.10
require github.com/shirou/gopsutil/v3 v3.24.5
+21 -3
View File
@@ -3,6 +3,7 @@ package udp
import (
"errors"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
@@ -73,6 +74,7 @@ func (ln *listener) listenLoop() {
n, raddr, err := ln.conn.ReadFrom(b)
if err != nil {
bufpool.Put(b)
ln.errChan <- err
close(ln.errChan)
return
@@ -110,15 +112,27 @@ func (ln *listener) Close() error {
}
func (ln *listener) getConn(raddr net.Addr) *conn {
c, ok := ln.connPool.Get(raddr.String())
laddr := ln.Addr()
peer := raddr
var packet *packetAddr
if received, ok := raddr.(*packetAddr); ok {
packet = received
laddr, peer = received.local, received.Addr
}
key := laddr.String() + "|" + peer.String()
if packet != nil {
key += "|" + strconv.Itoa(packet.ifIndex)
}
c, ok := ln.connPool.Get(key)
if ok && !c.isClosed() {
return c
}
c = newConn(ln.conn, ln.Addr(), raddr, ln.config.ReadQueueSize, ln.config.Keepalive)
c = newConn(ln.conn, laddr, peer, ln.config.ReadQueueSize, ln.config.Keepalive)
c.packet = packet
select {
case ln.cqueue <- c:
ln.connPool.Set(raddr.String(), c)
ln.connPool.Set(key, c)
return c
default:
c.Close()
@@ -132,6 +146,7 @@ type conn struct {
net.PacketConn
localAddr net.Addr
remoteAddr net.Addr
packet *packetAddr
rc chan []byte // data receive queue
idle int32 // indicate the connection is idle
closed chan struct{}
@@ -176,6 +191,9 @@ func (c *conn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
if !c.keepalive {
defer c.Close()
}
if c.packet != nil {
addr = &packetAddr{Addr: addr, local: c.packet.local, control: c.packet.control, ifIndex: c.packet.ifIndex}
}
return c.PacketConn.WriteTo(b, addr)
}
+12
View File
@@ -0,0 +1,12 @@
package udp
import "net"
// packetAddr carries the received destination through the PacketConn wrappers.
// Its public address remains the real peer for admission, metrics and limits.
type packetAddr struct {
net.Addr
local *net.UDPAddr
control []byte
ifIndex int
}
+116
View File
@@ -0,0 +1,116 @@
package udp
import (
"encoding/binary"
"errors"
"net"
"golang.org/x/net/ipv6"
"golang.org/x/sys/unix"
)
// PreserveDestination pins wildcard UDP replies to the address they arrived on.
func PreserveDestination(c *net.UDPConn) (net.PacketConn, error) {
local := c.LocalAddr().(*net.UDPAddr)
if !local.IP.IsUnspecified() {
return c, nil
}
raw, err := c.SyscallConn()
if err != nil {
return nil, err
}
var sockErr error
if err = raw.Control(func(fd uintptr) {
if local.IP.To4() != nil {
sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_PKTINFO, 1)
return
}
if sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_RECVPKTINFO, 1); sockErr != nil {
return
}
var onlyV6 int
onlyV6, sockErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_V6ONLY)
if sockErr == nil && onlyV6 == 0 {
sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_PKTINFO, 1)
}
}); err != nil {
return nil, err
}
if sockErr != nil {
return nil, sockErr
}
return &destinationConn{UDPConn: c}, nil
}
type destinationConn struct {
*net.UDPConn
}
func (c *destinationConn) ReadFrom(b []byte) (int, net.Addr, error) {
var oob [128]byte
n, oobn, flags, remote, err := c.ReadMsgUDP(b, oob[:])
if err != nil {
return 0, remote, err
}
if flags&unix.MSG_CTRUNC != 0 {
return 0, nil, errors.New("UDP destination control message truncated")
}
var dst net.IP
var control []byte
var ifIndex int
if remote.IP.To4() != nil {
messages, err := unix.ParseSocketControlMessage(oob[:oobn])
if err != nil {
return 0, nil, err
}
for _, message := range messages {
if message.Header.Level != unix.IPPROTO_IP || message.Header.Type != unix.IP_PKTINFO {
continue
}
var info unix.Inet4Pktinfo
if _, err := binary.Decode(message.Data, binary.NativeEndian, &info); err != nil {
return 0, nil, err
}
dst, ifIndex = net.IP(info.Addr[:]), int(info.Ifindex)
// Spec_dst remains a local unicast address for broadcast destinations.
control = unix.PktInfo4(&unix.Inet4Pktinfo{Ifindex: info.Ifindex, Spec_dst: info.Spec_dst})
break
}
} else {
var cm ipv6.ControlMessage
if err = cm.Parse(oob[:oobn]); err != nil {
return 0, nil, err
}
dst, ifIndex = cm.Dst, cm.IfIndex
source := dst
if dst.IsMulticast() {
source = nil
}
control = (&ipv6.ControlMessage{Src: source, IfIndex: ifIndex}).Marshal()
}
if len(dst) == 0 || dst.IsUnspecified() {
return 0, nil, errors.New("UDP destination address missing")
}
local := &net.UDPAddr{IP: append(net.IP(nil), dst...), Port: c.LocalAddr().(*net.UDPAddr).Port}
if dst.To4() == nil && (dst.IsLinkLocalUnicast() || dst.IsLinkLocalMulticast() || dst.IsInterfaceLocalMulticast()) {
if iface, lookupErr := net.InterfaceByIndex(ifIndex); lookupErr == nil {
local.Zone = iface.Name
}
}
return n, &packetAddr{Addr: remote, local: local, control: control, ifIndex: ifIndex}, nil
}
func (c *destinationConn) WriteTo(b []byte, addr net.Addr) (int, error) {
if packet, ok := addr.(*packetAddr); ok {
remote, ok := packet.Addr.(*net.UDPAddr)
if !ok {
return 0, errors.New("invalid UDP peer address")
}
n, _, err := c.WriteMsgUDP(b, packet.control, remote)
if n < 0 {
n = 0
}
return n, err
}
return c.UDPConn.WriteTo(b, addr)
}
@@ -0,0 +1,304 @@
package udp
import (
"encoding/binary"
"fmt"
"io"
"net"
"os"
"testing"
"time"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"golang.org/x/sys/unix"
xlogger "github.com/go-gost/x/logger"
)
func TestMulticastDestinationRepliesFromUnicast(t *testing.T) {
rxName, txName := os.Getenv("FLUX_TEST_MULTICAST_RX"), os.Getenv("FLUX_TEST_MULTICAST_TX")
if rxName == "" || txName == "" {
t.Skip("requires isolated multicast veth interfaces")
}
rx, err := net.InterfaceByName(rxName)
if err != nil {
t.Fatal(err)
}
tx, err := net.InterfaceByName(txName)
if err != nil {
t.Fatal(err)
}
group := net.ParseIP("ff02::114")
socket, err := net.ListenMulticastUDP("udp6", rx, &net.UDPAddr{IP: group})
if err != nil {
t.Fatal(err)
}
defer socket.Close()
pc, err := PreserveDestination(socket)
if err != nil {
t.Fatal(err)
}
ln := NewListener(pc, &ListenConfig{
Backlog: 1, ReadQueueSize: 1, ReadBufferSize: 128, TTL: time.Second, Keepalive: true,
Logger: xlogger.NewLogger(xlogger.OutputOption(io.Discard)),
})
defer ln.Close()
client, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.ParseIP("fe80::1"), Zone: txName})
if err != nil {
t.Fatal(err)
}
defer client.Close()
if err := ipv6.NewPacketConn(client).SetMulticastInterface(tx); err != nil {
t.Fatal(err)
}
client.SetDeadline(time.Now().Add(3 * time.Second))
port := socket.LocalAddr().(*net.UDPAddr).Port
serverResult := make(chan error, 1)
go func() {
c, err := ln.Accept()
if err != nil {
serverResult <- err
return
}
defer c.Close()
local, peer := c.LocalAddr().(*net.UDPAddr), c.RemoteAddr().(*net.UDPAddr)
if !local.IP.Equal(group) || local.Port != port || local.Zone != rxName || !peer.IP.Equal(net.ParseIP("fe80::1")) ||
peer.Port != client.LocalAddr().(*net.UDPAddr).Port || peer.Zone != rxName {
serverResult <- fmt.Errorf("unexpected multicast session: local=%s peer=%s", local, peer)
return
}
var buf [128]byte
n, err := c.Read(buf[:])
if err == nil {
_, err = c.Write(buf[:n])
}
serverResult <- err
}()
if _, err := client.WriteToUDP([]byte("multicast"), &net.UDPAddr{IP: group, Port: port, Zone: txName}); err != nil {
t.Fatal(err)
}
select {
case err := <-serverResult:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("multicast session timed out")
}
var buf [128]byte
n, from, err := client.ReadFromUDP(buf[:])
if err != nil {
t.Fatal(err)
}
if !from.IP.Equal(net.ParseIP("fe80::2")) || from.Port != port || from.Zone != txName || string(buf[:n]) != "multicast" {
t.Fatalf("unexpected multicast reply %q from %s", buf[:n], from)
}
}
func TestLinkLocalMulticastSessionsRetainZones(t *testing.T) {
testScopedMulticastSessions(t, "ff02::114", false)
}
func TestIPv4DestinationSessionsRetainInterfaces(t *testing.T) {
ifNames := []string{os.Getenv("FLUX_TEST_MULTICAST_TX"), os.Getenv("FLUX_TEST_MULTICAST_TX2")}
if ifNames[0] == "" || ifNames[1] == "" {
t.Skip("requires two isolated IPv4 multicast interfaces")
}
for _, network := range []string{"udp4", "udp"} {
for _, destination := range []string{"239.255.0.114", "224.0.0.114", "255.255.255.255"} {
t.Run(network+"/"+destination, func(t *testing.T) {
bind := net.IPv4zero
if network == "udp" {
bind = net.IPv6unspecified
}
server, err := net.ListenUDP(network, &net.UDPAddr{IP: bind})
if err != nil {
t.Fatal(err)
}
defer server.Close()
pc, err := PreserveDestination(server)
if err != nil {
t.Fatal(err)
}
client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("198.18.0.1")})
if err != nil {
t.Fatal(err)
}
defer client.Close()
raw, err := client.SyscallConn()
if err != nil {
t.Fatal(err)
}
var sockErr error
if err := raw.Control(func(fd uintptr) {
sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_BROADCAST, 1)
}); err != nil {
t.Fatal(err)
}
if sockErr != nil {
t.Fatal(sockErr)
}
target := &net.UDPAddr{IP: net.ParseIP(destination), Port: server.LocalAddr().(*net.UDPAddr).Port}
for _, name := range ifNames {
iface, err := net.InterfaceByName(name)
if err != nil {
t.Fatal(err)
}
if target.IP.IsMulticast() {
if err := ipv4.NewPacketConn(server).JoinGroup(iface, target); err != nil {
t.Fatal(err)
}
}
if _, err := ipv4.NewPacketConn(client).WriteTo([]byte(name), &ipv4.ControlMessage{
Src: client.LocalAddr().(*net.UDPAddr).IP, IfIndex: iface.Index,
}, target); err != nil {
t.Fatal(err)
}
}
server.SetReadDeadline(time.Now().Add(3 * time.Second))
ln := NewListener(pc, &ListenConfig{
Backlog: 2, ReadQueueSize: 2, ReadBufferSize: 128, TTL: 10 * time.Second, Keepalive: true,
Logger: xlogger.NewLogger(xlogger.OutputOption(io.Discard)),
})
defer ln.Close()
seen := make(map[string]bool)
for range ifNames {
c, err := ln.Accept()
if err != nil {
t.Fatalf("interface did not get a separate session: %v", err)
}
defer c.Close()
var buf [128]byte
n, err := c.Read(buf[:])
if err != nil {
t.Fatal(err)
}
name := string(buf[:n])
if (name != ifNames[0] && name != ifNames[1]) || seen[name] ||
c.LocalAddr().String() != target.String() || c.RemoteAddr().String() != client.LocalAddr().String() {
t.Fatalf("unexpected interface session: data=%q local=%s peer=%s", buf[:n], c.LocalAddr(), c.RemoteAddr())
}
iface, err := net.InterfaceByName(name)
if err != nil {
t.Fatal(err)
}
messages, err := unix.ParseSocketControlMessage(c.(*conn).packet.control)
if err != nil || len(messages) != 1 {
t.Fatalf("invalid reply control: %v", err)
}
var info unix.Inet4Pktinfo
if _, err := binary.Decode(messages[0].Data, binary.NativeEndian, &info); err != nil {
t.Fatal(err)
}
if int(info.Ifindex) != iface.Index {
t.Fatalf("reply interface = %d, want %d", info.Ifindex, iface.Index)
}
seen[name] = true
}
})
}
}
}
func TestInterfaceLocalMulticastSessionsRetainZones(t *testing.T) {
testScopedMulticastSessions(t, "ff01::114", true)
}
func testScopedMulticastSessions(t *testing.T, destination string, interfaceLocal bool) {
t.Helper()
rxNames := []string{os.Getenv("FLUX_TEST_MULTICAST_RX"), os.Getenv("FLUX_TEST_MULTICAST_RX2")}
txNames := []string{os.Getenv("FLUX_TEST_MULTICAST_TX"), os.Getenv("FLUX_TEST_MULTICAST_TX2")}
for i := range rxNames {
if rxNames[i] == "" || txNames[i] == "" {
t.Skip("requires two isolated multicast veth pairs")
}
}
if interfaceLocal {
// Interface-local multicast is looped back on the sending interface.
rxNames = txNames
}
group := net.ParseIP(destination)
server, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6unspecified})
if err != nil {
t.Fatal(err)
}
defer server.Close()
clientIP := net.ParseIP("fd00:1::1")
client, err := net.ListenUDP("udp6", &net.UDPAddr{IP: clientIP})
if err != nil {
t.Fatal(err)
}
defer client.Close()
pc, err := PreserveDestination(server)
if err != nil {
t.Fatal(err)
}
for i, rxName := range rxNames {
rx, err := net.InterfaceByName(rxName)
if err != nil {
t.Fatal(err)
}
if err := ipv6.NewPacketConn(server).JoinGroup(rx, &net.UDPAddr{IP: group}); err != nil {
t.Fatal(err)
}
tx, err := net.InterfaceByName(txNames[i])
if err != nil {
t.Fatal(err)
}
if err := ipv6.NewPacketConn(client).SetMulticastInterface(tx); err != nil {
t.Fatal(err)
}
if _, err := client.WriteToUDP([]byte(rxName), &net.UDPAddr{
IP: group, Port: server.LocalAddr().(*net.UDPAddr).Port, Zone: txNames[i],
}); err != nil {
t.Fatal(err)
}
}
server.SetReadDeadline(time.Now().Add(3 * time.Second))
ln := NewListener(pc, &ListenConfig{
Backlog: 2, ReadQueueSize: 2, ReadBufferSize: 128, TTL: 10 * time.Second, Keepalive: true,
Logger: xlogger.NewLogger(xlogger.OutputOption(io.Discard)),
})
defer ln.Close()
first, err := ln.Accept()
if err != nil {
t.Fatal(err)
}
defer first.Close()
second, err := ln.Accept()
if err != nil {
t.Fatalf("second interface did not get a separate session: %v", err)
}
defer second.Close()
if first == second {
t.Fatal("interface-scoped multicast sessions were shared")
}
seen := make(map[string]bool)
for _, c := range []net.Conn{first, second} {
var buf [128]byte
n, err := c.Read(buf[:])
if err != nil {
t.Fatal(err)
}
zone := string(buf[:n])
local, peer := c.LocalAddr().(*net.UDPAddr), c.RemoteAddr().(*net.UDPAddr)
if (zone != rxNames[0] && zone != rxNames[1]) || seen[zone] || !local.IP.Equal(group) ||
local.Port != server.LocalAddr().(*net.UDPAddr).Port || local.Zone != zone ||
!peer.IP.Equal(clientIP) || peer.Port != client.LocalAddr().(*net.UDPAddr).Port || peer.Zone != "" {
t.Fatalf("unexpected scoped session: data=%q local=%s peer=%s", buf[:n], local, peer)
}
var reply ipv6.ControlMessage
if err := reply.Parse(c.(*conn).packet.control); err != nil {
t.Fatal(err)
}
iface, err := net.InterfaceByName(zone)
if err != nil {
t.Fatal(err)
}
if reply.IfIndex != iface.Index {
t.Fatalf("reply interface = %d, want %d", reply.IfIndex, iface.Index)
}
seen[zone] = true
}
}
@@ -0,0 +1,9 @@
//go:build !linux
package udp
import "net"
func PreserveDestination(c *net.UDPConn) (net.PacketConn, error) {
return c, nil
}
@@ -0,0 +1,289 @@
package udp
import (
"context"
"fmt"
"io"
"net"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"golang.org/x/sys/unix"
corelimiter "github.com/go-gost/core/limiter"
coretraffic "github.com/go-gost/core/limiter/traffic"
corelistener "github.com/go-gost/core/listener"
corestats "github.com/go-gost/core/observer/stats"
xadmission "github.com/go-gost/x/admission"
xtraffic "github.com/go-gost/x/limiter/traffic"
xlogger "github.com/go-gost/x/logger"
xmetadata "github.com/go-gost/x/metadata"
"github.com/go-gost/x/metrics"
xstats "github.com/go-gost/x/observer/stats"
)
type countedTraffic struct {
coretraffic.TrafficLimiter
in, out atomic.Int64
}
func (l *countedTraffic) In(ctx context.Context, key string, opts ...corelimiter.Option) coretraffic.Limiter {
l.in.Add(1)
return l.TrafficLimiter.In(ctx, key, opts...)
}
func (l *countedTraffic) Out(ctx context.Context, key string, opts ...corelimiter.Option) coretraffic.Limiter {
l.out.Add(1)
return l.TrafficLimiter.Out(ctx, key, opts...)
}
func TestDestinationPreservedThroughWrappers(t *testing.T) {
v4 := []string{"127.0.0.1", "127.0.0.2"}
if address := os.Getenv("FLUX_TEST_IPV4_LINK_LOCAL"); address != "" {
v4 = append(v4, address)
}
v6 := strings.FieldsFunc(os.Getenv("FLUX_TEST_IPV6_ADDRS"), func(r rune) bool { return r == ',' })
if len(v6) == 0 {
v6 = []string{"::1"}
}
for _, tc := range []struct {
name, bind string
targets []string
clientBind string
}{
{"ipv4-wildcard", "0.0.0.0:0", v4, ""},
{"dual-stack-wildcard", "[::]:0", append(v4, v6...), ""},
{"fixed-ipv6-source", "[::]:0", v6, "::1"},
{"explicit-ipv4", "127.0.0.1:0", []string{"127.0.0.1"}, ""},
{"explicit-ipv6", "[::1]:0", []string{"::1"}, ""},
} {
t.Run(tc.name, func(t *testing.T) {
metrics.Enable(true)
defer metrics.Enable(false)
log := xlogger.NewLogger(xlogger.OutputOption(io.Discard))
stats := xstats.NewStats(false)
lim := &countedTraffic{TrafficLimiter: xtraffic.NewTrafficLimiter(
xtraffic.LimitsOption("$ 1MB 1MB", "$$ 1MB 1MB"), xtraffic.LoggerOption(log),
)}
ln := NewListener(corelistener.AddrOption(tc.bind), corelistener.LoggerOption(log),
corelistener.ServiceOption(t.Name()), corelistener.StatsOption(stats),
corelistener.TrafficLimiterOption(lim),
corelistener.AdmissionOption(xadmission.NewAdmission(
xadmission.WhitelistOption(true),
xadmission.MatchersOption([]string{"127.0.0.0/8", "169.254.0.0/16", "::1", "fd00::/8"}),
xadmission.LoggerOption(log),
)),
)
if err := ln.Init(xmetadata.NewMetadata(map[string]any{"keepAlive": true})); err != nil {
t.Fatal(err)
}
defer ln.Close()
clientIP := net.IPv6unspecified
if tc.clientBind != "" {
clientIP = net.ParseIP(tc.clientBind)
}
client, err := net.ListenUDP("udp", &net.UDPAddr{IP: clientIP})
if err != nil {
t.Fatal(err)
}
defer client.Close()
client.SetDeadline(time.Now().Add(5 * time.Second))
clientAddr := client.LocalAddr().(*net.UDPAddr)
var accepted atomic.Int64
errors := make(chan error, 128)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
accepted.Add(1)
go func() {
defer c.Close()
buf := make([]byte, 128)
for {
n, err := c.Read(buf)
if err != nil {
return
}
var round, target int
if _, err := fmt.Sscanf(string(buf[:n]), "%d/%d", &round, &target); err != nil {
errors <- err
return
}
local := c.LocalAddr().(*net.UDPAddr)
if local.IP.To4() != nil && local.Zone != "" {
errors <- fmt.Errorf("IPv4 destination has a zone: %s", local)
return
}
if !local.IP.Equal(net.ParseIP(tc.targets[target])) {
errors <- fmt.Errorf("local destination = %s, want %s", local.IP, tc.targets[target])
return
}
peer, ok := c.RemoteAddr().(*net.UDPAddr)
if !ok {
errors <- fmt.Errorf("peer type changed: %T", c.RemoteAddr())
return
}
if tc.clientBind != "" && (!peer.IP.Equal(clientAddr.IP) || peer.Port != clientAddr.Port) {
errors <- fmt.Errorf("peer endpoint = %s, want %s", peer, clientAddr)
return
}
if _, err := c.Write(buf[:n]); err != nil {
errors <- err
return
}
}
}()
}
}()
port := ln.Addr().(*net.UDPAddr).Port
var bytes int
for round := 0; round < 3; round++ {
for target, ip := range tc.targets {
payload := []byte(fmt.Sprintf("%d/%d", round, target))
bytes += len(payload)
if _, err := client.WriteToUDP(payload, &net.UDPAddr{IP: net.ParseIP(ip), Port: port}); err != nil {
t.Fatal(err)
}
}
seen := make(map[int]bool)
for range tc.targets {
buf := make([]byte, 128)
n, from, err := client.ReadFromUDP(buf)
if err != nil {
select {
case serverErr := <-errors:
t.Fatal(serverErr)
default:
t.Fatal(err)
}
}
var gotRound, target int
if _, err := fmt.Sscanf(string(buf[:n]), "%d/%d", &gotRound, &target); err != nil {
t.Fatal(err)
}
if gotRound != round || seen[target] || from.Port != port || !from.IP.Equal(net.ParseIP(tc.targets[target])) {
t.Fatalf("unexpected reply %q from %s", buf[:n], from)
}
seen[target] = true
}
}
if accepted.Load() != int64(len(tc.targets)) {
t.Fatalf("accepted %d sessions, want %d", accepted.Load(), len(tc.targets))
}
deadline := time.Now().Add(time.Second)
for stats.Get(corestats.KindOutputBytes) < uint64(bytes) && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if stats.Get(corestats.KindInputBytes) != uint64(bytes) || stats.Get(corestats.KindOutputBytes) != uint64(bytes) {
t.Fatalf("traffic stats lost: in=%d out=%d want=%d", stats.Get(corestats.KindInputBytes), stats.Get(corestats.KindOutputBytes), bytes)
}
if lim.in.Load() == 0 || lim.out.Load() == 0 {
t.Fatal("traffic limiter bypassed")
}
})
}
}
func TestBroadcastDestinationRepliesFromUnicast(t *testing.T) {
for _, bind := range []string{"0.0.0.0:0", "[::]:0"} {
t.Run(bind, func(t *testing.T) {
ln := NewListener(corelistener.AddrOption(bind),
corelistener.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard))))
if err := ln.Init(xmetadata.NewMetadata(map[string]any{"keepAlive": true})); err != nil {
t.Fatal(err)
}
defer ln.Close()
client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer client.Close()
raw, err := client.SyscallConn()
if err != nil {
t.Fatal(err)
}
var sockErr error
if err := raw.Control(func(fd uintptr) {
sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_BROADCAST, 1)
}); err != nil {
t.Fatal(err)
}
if sockErr != nil {
t.Fatal(sockErr)
}
client.SetDeadline(time.Now().Add(5 * time.Second))
target := &net.UDPAddr{IP: net.IPv4(127, 255, 255, 255), Port: ln.Addr().(*net.UDPAddr).Port}
if _, err := client.WriteToUDP([]byte("broadcast"), target); err != nil {
t.Fatal(err)
}
conn, err := acceptWithTimeout(t, ln, time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if conn.LocalAddr().String() != target.String() {
t.Fatalf("local destination = %s, want %s", conn.LocalAddr(), target)
}
if conn.RemoteAddr().String() != client.LocalAddr().String() {
t.Fatalf("peer endpoint = %s, want %s", conn.RemoteAddr(), client.LocalAddr())
}
var buf [128]byte
n, err := conn.Read(buf[:])
if err != nil {
t.Fatal(err)
}
if _, err := conn.Write(buf[:n]); err != nil {
t.Fatalf("reply to broadcast request: %v", err)
}
n, from, err := client.ReadFromUDP(buf[:])
if err != nil {
t.Fatal(err)
}
if !from.IP.Equal(net.IPv4(127, 0, 0, 1)) || from.Port != target.Port || string(buf[:n]) != "broadcast" {
t.Fatalf("unexpected broadcast reply %q from %s", buf[:n], from)
}
})
}
}
func TestDestinationSessionExpires(t *testing.T) {
ln := NewListener(corelistener.AddrOption("0.0.0.0:0"),
corelistener.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard))))
if err := ln.Init(xmetadata.NewMetadata(map[string]any{"keepAlive": true, "ttl": "20ms"})); err != nil {
t.Fatal(err)
}
defer ln.Close()
client, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 2), Port: ln.Addr().(*net.UDPAddr).Port})
if err != nil {
t.Fatal(err)
}
defer client.Close()
client.Write([]byte("first"))
first, err := acceptWithTimeout(t, ln, time.Second)
if err != nil {
t.Fatal(err)
}
var b [16]byte
first.Read(b[:])
time.Sleep(80 * time.Millisecond)
if _, err := first.Read(b[:]); err == nil {
t.Fatal("idle session did not expire")
}
client.Write([]byte("second"))
second, err := acceptWithTimeout(t, ln, time.Second)
if err != nil {
t.Fatal(err)
}
defer second.Close()
if first == second {
t.Fatal("expired session reused")
}
if second.LocalAddr().(*net.UDPAddr).IP.String() != "127.0.0.2" {
t.Fatalf("new session lost destination: %s", second.LocalAddr())
}
}
+6 -2
View File
@@ -56,11 +56,15 @@ func (l *udpListener) Init(md md.Metadata) (err error) {
return
}
var conn net.PacketConn
conn, err = net.ListenUDP(network, laddr)
socket, err := net.ListenUDP(network, laddr)
if err != nil {
return
}
conn, err := udp.PreserveDestination(socket)
if err != nil {
socket.Close()
return err
}
conn = metrics.WrapPacketConn(l.options.Service, conn)
conn = stats.WrapPacketConn(conn, l.options.Stats)
conn = admission.WrapPacketConn(l.options.Admission, conn)
+3
View File
@@ -1,6 +1,9 @@
{
"name": "@flvx/skill-api",
"version": "2.1.9-rc4",
"engines": {
"node": "^22.23.4 || ^24.22.1 || ^26.12.1"
},
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
"keywords": [
"opencode",
+2 -1
View File
@@ -1 +1,2 @@
package-lock=true
package-lock=true
engine-strict=true
+2 -1
View File
@@ -1,5 +1,6 @@
# 多阶段构建 - 构建阶段
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
# CVE-2026-111497: use the patched Node.js Brotli decoder during builds.
FROM --platform=$BUILDPLATFORM node:22.23.4-alpine AS builder
WORKDIR /app
+3
View File
@@ -3,6 +3,9 @@
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.28.1",
"engines": {
"node": "^22.23.4 || ^24.22.1 || ^26.12.1"
},
"type": "module",
"scripts": {
"dev": "vite",
+2 -2
View File
@@ -84,8 +84,8 @@ export interface PasskeyItem {
export const getPasskeyStatus = () =>
Network.post<{ enabled: boolean }>("/user/passkey/status");
export const beginPasskeyLogin = (username: string) =>
Network.post<PasskeyOptions>("/user/passkey/login/begin", { username });
export const beginPasskeyLogin = () =>
Network.post<PasskeyOptions>("/user/passkey/login/begin", {});
export const finishPasskeyLogin = (sessionId: string, credential: unknown) =>
Network.post<LoginResponse>("/user/passkey/login/finish", {
sessionId,
+3 -8
View File
@@ -55,14 +55,9 @@ export default function IndexPage() {
}, []);
const handlePasskeyLogin = async () => {
if (!form.username.trim()) {
toast.error("请输入用户名");
return;
}
setLoading(true);
try {
const begin = await beginPasskeyLogin(form.username.trim());
const begin = await beginPasskeyLogin();
if (begin.code !== 0) {
toast.error(begin.msg || "无法使用通行证密钥登录");
@@ -282,9 +277,9 @@ export default function IndexPage() {
className="h-12 rounded-xl"
disabled={loading}
variant="bordered"
onPress={handlePasskeyLogin}
onPress={() => void handlePasskeyLogin()}
>
使用通行证密钥登录
选择通行证密钥登录
</Button>
)}
</div>
+1 -4
View File
@@ -1977,10 +1977,7 @@ export default function NodePage() {
isLoading={node.upgradeLoading}
size="sm"
variant="flat"
onPress={() => {
setUpgradeTargetNodeId(node.id);
openUpgradeModal("single");
}}
onPress={() => openUpgradeModal("single", node.id)}
>
升级
</Button>