Compare commits

...

7 Commits

Author SHA1 Message Date
sagitchu 86477728c9 fix(user): revoke tunnel access when disabling accounts
Pause every reachable entry without self-healing missing services, aggregate unconfirmed nodes for retry, and show persistent partial-disable feedback. Refs #562.
2026-11-01 17:53:40 +08:00
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
sagit 861d61239a feat: support secure passkey login (#561)
Add WebAuthn passkey enrollment and login for issue #536, with trusted origin configuration, user verification, single-use challenges, and documented deployment and recovery behavior.
2026-09-30 19:23:10 +08:00
49 changed files with 3150 additions and 60 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
+45
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部署
@@ -71,6 +77,45 @@ docker compose up -d
3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。
#### 通行证密钥登录(可选)
通行证密钥登录默认关闭,需要管理员在部署环境中主动启用。它不依赖 Cloudflare Turnstile,但用户必须在验证码可用时先用密码登录并绑定密钥;未绑定的账号不能用此功能绕过验证码。原密码登录始终可用。
**新部署与旧版本升级:**新安装生成的 `.env` 默认没有 `FLVX_WEBAUTHN_ORIGIN`。升级到支持通行证密钥的版本也**不会自动启用**:安装脚本保留已有 `.env`,不会向其中添加该变量。如果手动升级时保留了旧版 `docker-compose.yml`,还需检查 `backend.environment` 是否包含下文的变量传递项。未设置该变量时,仅更新镜像或版本号后看不到入口是预期行为。
1. 在实际部署目录的 `.env` 中添加浏览器访问面板时的公开前端 origin,将示例域名换成自己的域名:
```dotenv
FLVX_WEBAUTHN_ORIGIN=https://panel.example.com
```
必须使用完整 HTTPS origin(协议、域名、必要时的端口),不能带路径、查询参数或尾部 `/`。仅本机开发可用 `http://localhost:3000` 或 `http://127.0.0.1:3000`。经反向代理访问时仍填浏览器地址栏中的**前端** origin,不填容器地址、后端监听地址或代理转发头;API 即使在另一个域名,也不改变此值。自行部署时将该变量传给 `paneld` 进程。
2. 确认实际使用的 `docker-compose.yml` 在 `backend` 服务的 `environment` 下有以下传递项;新版 Compose 模板已包含,旧模板若缺失需补上:
```yaml
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
```
在该部署目录运行 `docker compose up -d --force-recreate backend`,使新环境变量进入后端容器。后端重建期间,面板 API 会短暂中断。容器恢复后重新加载前端页面;如果 PWA 提示“发现新版本”,选择“刷新”,也可以用新的无痕窗口核对是否仍加载旧页面。
3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。新绑定要求设备支持可发现凭据(resident key),不支持的设备会在绑定时失败。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。
4. 以后在登录页直接点击“选择通行证密钥登录”,由设备选择账号并解锁,**无需先填写用户名**。账号被停用时密钥登录也会被拒绝。撤销密钥仍到个人资料页输入当前密码并删除。
旧版本绑定时只“优先”请求可发现凭据,现有密钥是否能直接选账号取决于当时的设备,不能按绑定版本统一判定或批量删除。若设备不显示某把旧密钥,请使用密码登录,在个人资料页删除该密钥并重新绑定;确认新密钥与密码恢复途径可用后再清理其他旧密钥。登录页不提供用户名密钥回退。
**入口未显示时,按顺序检查:**
1. 在部署目录运行 `docker compose exec backend printenv FLVX_WEBAUTHN_ORIGIN`,仅检查这一项是否为预期的公开前端 origin;不要贴出完整 `docker compose config` 或其他环境变量,以免泄露密码和密钥。若为空,检查 `.env` 和实际 Compose 模板的 `backend.environment`,然后按步骤 2 重建后端。
2. 向浏览器实际调用的后端地址发送公开状态请求,例如将以下示例地址替换为自己的 API 地址后运行 `curl -sS -X POST 'https://api.example.com/api/v1/user/passkey/status' -H 'Content-Type: application/json' -d '{}'`。应返回 `code: 0` 且 `data.enabled: true`;若为 `false`,后端尚未接受有效配置。此接口无需登录或提供密码。
3. 在访问面板的浏览器控制台检查 `window.isSecureContext` 为 `true`,且 `typeof window.PublicKeyCredential` 不是 `"undefined"`。普通公网 HTTP 页面或不支持 WebAuthn 的浏览器不会显示入口。确认后刷新页面,并查看 `/api/v1/user/passkey/status` 的实际网络响应。
4. 若状态已启用且浏览器满足条件,检查 `docker compose images frontend` 中的前端镜像版本,并按步骤 2 的 PWA 提示刷新或使用无痕窗口。登录页按钮与个人资料页卡片均不存在时,通常是旧前端页面或状态请求未成功;只有其中一个缺失时,记录页面地址和该请求的响应再排查。
**安全配置与恢复:**未设置或设置错误时,通行证密钥接口拒绝新的绑定和登录,登录页与个人资料页不显示对应入口;密码登录不受影响。不要把不可信的请求 `Host` / `X-Forwarded-Host` 当作可信 origin。域名变化会改变 RP ID,旧域名下绑定的密钥不能用于新域名;迁移时需保留原域名或可用的密码登录途径,再在新域名重新绑定。仅更改同一域名的端口也需同步更新此 origin。正在进行的密钥挑战只保存在后端内存中,重启后需重新开始;多副本部署需要共享会话方案,未实现前请保持单个后端实例。
建议保留至少一种可用的密码恢复途径。数据库级备份会保留密钥记录;当前面板的 JSON 导出不包含通行证密钥,使用 JSON 导入迁移后,用户需要通过密码登录并重新绑定。
#### 从 SQLite 迁移到 PostgreSQL
如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移:
+1
View File
@@ -13,6 +13,7 @@ services:
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
SERVER_ADDR: :6365
TZ: Asia/Shanghai
FLUX_VERSION: ${FLUX_VERSION:-dev}
+1
View File
@@ -13,6 +13,7 @@ services:
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
SERVER_ADDR: :6365
TZ: Asia/Shanghai
FLUX_VERSION: ${FLUX_VERSION:-dev}
+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 ./
+9 -2
View File
@@ -1,13 +1,15 @@
module go-backend
go 1.25.0
go 1.26.10
require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/glebarez/sqlite v1.11.0
github.com/go-webauthn/webauthn v0.14.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.9.2
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.42.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@@ -15,14 +17,19 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
+20 -2
View File
@@ -3,10 +3,20 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -27,6 +37,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -38,8 +50,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
@@ -49,6 +65,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
@@ -534,7 +534,22 @@ func buildForwardServiceDeleteNames(bases []string) []string {
return names
}
type forwardServiceControlOptions struct {
tolerateNotFound bool
tolerateOffline bool
allowSelfHeal bool
continueOnError bool
}
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
return h.controlForwardServicesWithOptions(forward, commandType, forwardServiceControlOptions{
tolerateNotFound: tolerateNotFound,
tolerateOffline: true,
allowSelfHeal: true,
})
}
func (h *Handler) controlForwardServicesWithOptions(forward *forwardRecord, commandType string, opts forwardServiceControlOptions) error {
if h == nil || forward == nil {
return errors.New("invalid forward control context")
}
@@ -561,6 +576,7 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
var nodeErrors []error
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
seen := map[int64]struct{}{}
for _, fp := range ports {
@@ -569,16 +585,20 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
}
seen[fp.NodeID] = struct{}{}
if err := h.deleteForwardServiceBasesOnNode(fp.NodeID, bases); err != nil {
if isNodeOfflineOrTimeoutError(err) {
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
continue
}
if tolerateNotFound && isNotFoundError(err) {
if opts.tolerateNotFound && isNotFoundError(err) {
continue
}
if opts.continueOnError {
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, err))
continue
}
return err
}
}
return nil
return errors.Join(nodeErrors...)
}
seen := map[int64]struct{}{}
healed := false
@@ -588,37 +608,50 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
}
seen[fp.NodeID] = struct{}{}
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
if isNodeOfflineOrTimeoutError(err) {
continue
}
return err
}
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
nodeErr := func() error {
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
return nil
}
return err
}
}
if nodeHandled {
continue
if opts.allowSelfHeal && !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
}
if nodeHandled || opts.tolerateNotFound {
return nil
}
if lastNotFoundErr != nil {
return lastNotFoundErr
}
return errors.New("service control failed")
}()
if nodeErr != nil {
if !opts.continueOnError {
return nodeErr
}
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, nodeErr))
}
if tolerateNotFound {
continue
}
if lastNotFoundErr != nil {
return lastNotFoundErr
}
return errors.New("service control failed")
}
return nil
return errors.Join(nodeErrors...)
}
func (h *Handler) nodeCommandError(nodeID int64, err error) error {
name := strconv.FormatInt(nodeID, 10)
if node, lookupErr := h.getNodeRecord(nodeID); lookupErr == nil && node != nil {
name = defaultString(strings.TrimSpace(node.Name), name)
}
return fmt.Errorf("节点 %s (ID %d): %w", name, nodeID, err)
}
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
@@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"log"
"math"
"strconv"
@@ -393,6 +394,48 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
}, nil
}
// disableUserAccess retains unconfirmed forwards as active so a repeated disable
// can retry them. Login and tunnel access remain disabled when a node is offline.
func (h *Handler) disableUserAccess(userID int64, now int64) error {
permissionErr := h.repo.DisableUserTunnels(userID)
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
return errors.Join(permissionErr, err)
}
errs := []error{permissionErr}
for i := range forwards {
forward := &forwards[i]
nftMode, entryNodeIDs, pauseErr := h.tunnelUsesNftables(forward.TunnelID)
if pauseErr == nil {
if nftMode {
// Exclude the rule from the desired plan before marking its pause
// confirmed. A failed SSH apply must remain eligible for retry.
pauseErr = h.syncNftablesNode(entryNodeIDs[0], forward.ID)
if pauseErr != nil {
pauseErr = h.nodeCommandError(entryNodeIDs[0], pauseErr)
}
} else {
// Missing services are already stopped. Self-healing would recreate
// services on every entry and could restart one already paused.
pauseErr = h.controlForwardServicesWithOptions(forward, "PauseService", forwardServiceControlOptions{
tolerateNotFound: true,
tolerateOffline: false,
allowSelfHeal: false,
continueOnError: true,
})
}
}
if pauseErr != nil {
errs = append(errs, fmt.Errorf("转发 %d 暂停失败: %w", forward.ID, pauseErr))
continue
}
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (h *Handler) pauseUserForwards(userID int64, now int64) {
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
@@ -42,6 +42,8 @@ type Handler struct {
peerResourceMu sync.Mutex
captchaMu sync.Mutex
captchaTokens map[string]int64
passkeyMu sync.Mutex
passkeyPending map[string]passkeyCeremony
jobsMu sync.Mutex
jobsCancel context.CancelFunc
@@ -116,6 +118,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
healthCheck: nil,
nftablesManager: runtimenft.NewManager(nil),
captchaTokens: make(map[string]int64),
passkeyPending: make(map[string]passkeyCeremony),
pendingUpgradeRedeploy: make(map[int64]struct{}),
nodeOnlineRedeployAt: make(map[int64]time.Time),
nodeOnlineRedeployQueued: make(map[int64]struct{}),
@@ -157,6 +160,13 @@ func (h *Handler) GetUserAuthState(userID int64) (*auth.UserAuthState, error) {
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/login", h.login)
mux.HandleFunc("/api/v1/user/passkey/status", h.passkeyStatus)
mux.HandleFunc("/api/v1/user/passkey/login/begin", h.passkeyLoginBegin)
mux.HandleFunc("/api/v1/user/passkey/login/finish", h.passkeyLoginFinish)
mux.HandleFunc("/api/v1/user/passkey/register/begin", h.passkeyRegisterBegin)
mux.HandleFunc("/api/v1/user/passkey/register/finish", h.passkeyRegisterFinish)
mux.HandleFunc("/api/v1/user/passkey/list", h.passkeyList)
mux.HandleFunc("/api/v1/user/passkey/delete", h.passkeyDelete)
mux.HandleFunc("/api/v1/user/list", h.userList)
mux.HandleFunc("/api/v1/user/create", h.userCreate)
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
@@ -205,6 +205,15 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
}
}
if status != 1 {
// Revoke access before optional quota/group updates can fail or return.
if err := h.disableUserAccess(id, now); err != nil {
result := response.Err(-2, fmt.Sprintf("账号已禁用,以下权限或节点暂停未确认;其他转发已继续处理,请排查后重试禁用:\n%v", err))
result.Data = map[string]interface{}{"userDisabled": true}
response.WriteJSON(w, result)
return
}
}
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime, flowMiB)
if hasDailyQuota || hasMonthlyQuota {
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
@@ -243,6 +252,13 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
}
}
}
if status != 1 {
// Group changes may have created additional tunnel grants.
if err := h.repo.DisableUserTunnels(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
if oldUser.MaxConn != maxConn {
warnings, syncErr := h.syncUserMaxConnForwards(id)
if syncErr != nil {
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"slices"
"strings"
"time"
@@ -150,7 +151,7 @@ func (h *Handler) validateNftablesTunnelStateTx(tx *gorm.DB, entryNodeIDs []int6
return nil
}
func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
func (h *Handler) buildNftablesNodePlan(nodeID int64, excludedForwardIDs ...int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
if err != nil {
return runtimenft.NodePlan{}, nil, err
@@ -162,6 +163,9 @@ func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *mod
plan := runtimenft.NodePlan{NodeID: nodeID, Rules: make([]runtimenft.Rule, 0, len(forwards))}
for i := range forwards {
forward := &forwards[i]
if slices.Contains(excludedForwardIDs, forward.ID) {
continue
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil || tunnel == nil || tunnel.Status != 1 {
continue
@@ -201,11 +205,11 @@ func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *mod
return plan, cfg, nil
}
func (h *Handler) syncNftablesNode(nodeID int64) error {
func (h *Handler) syncNftablesNode(nodeID int64, excludedForwardIDs ...int64) error {
if h == nil || h.repo == nil {
return errors.New("handler not initialized")
}
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID)
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID, excludedForwardIDs...)
if err != nil {
return err
}
+440
View File
@@ -0,0 +1,440 @@
package handler
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/security"
"go-backend/internal/store/model"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const passkeyTTL = 2 * time.Minute
type passkeyCeremony struct {
userID int64
kind string
origin string
session webauthn.SessionData
}
type passkeyUser struct {
id int64
name string
credentials []webauthn.Credential
}
func (u passkeyUser) WebAuthnID() []byte {
id := make([]byte, 8)
binary.BigEndian.PutUint64(id, uint64(u.id))
return id
}
func (u passkeyUser) WebAuthnName() string { return u.name }
func (u passkeyUser) WebAuthnDisplayName() string { return u.name }
func (u passkeyUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
func passkeyConfig() (*webauthn.WebAuthn, string) {
raw := strings.TrimSpace(os.Getenv("FLVX_WEBAUTHN_ORIGIN"))
u, err := url.Parse(raw)
if err != nil || u == nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return nil, ""
}
host := u.Hostname()
if host == "" || strings.ContainsAny(host, " /\\") || (u.Scheme != "https" && !(u.Scheme == "http" && (host == "localhost" || host == "127.0.0.1"))) {
return nil, ""
}
origin := u.Scheme + "://" + u.Host
wa, err := webauthn.New(&webauthn.Config{
RPID: host,
RPDisplayName: "FLVX",
RPOrigins: []string{origin},
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},
},
})
if err != nil {
return nil, ""
}
return wa, origin
}
func (h *Handler) passkeyStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, _ := passkeyConfig()
response.WriteJSON(w, response.OK(map[string]bool{"enabled": wa != nil}))
}
func (h *Handler) putPasskeyCeremony(c passkeyCeremony) (string, bool) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", false
}
id := base64.RawURLEncoding.EncodeToString(buf)
h.passkeyMu.Lock()
defer h.passkeyMu.Unlock()
for k, v := range h.passkeyPending {
if time.Now().After(v.session.Expires) {
delete(h.passkeyPending, k)
}
}
if len(h.passkeyPending) >= 1000 {
return "", false
}
h.passkeyPending[id] = c
return id, true
}
func (h *Handler) takePasskeyCeremony(id, kind, origin string, userID int64) (webauthn.SessionData, bool) {
h.passkeyMu.Lock()
c, ok := h.passkeyPending[id]
delete(h.passkeyPending, id)
h.passkeyMu.Unlock()
if !ok || c.kind != kind || c.origin != origin || c.userID != userID || time.Now().After(c.session.Expires) {
return webauthn.SessionData{}, false
}
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 {
return passkeyUser{}, errInvalidPasskey
}
rows, err := h.repo.ListPasskeys(userID)
if err != nil {
return passkeyUser{}, err
}
u := passkeyUser{id: user.ID, name: user.User}
for _, row := range rows {
var credential webauthn.Credential
if err := json.Unmarshal([]byte(row.CredentialJSON), &credential); err != nil {
return passkeyUser{}, err
}
u.credentials = append(u.credentials, credential)
}
return u, nil
}
var errInvalidPasskey = &passkeyError{}
type passkeyError struct{}
func (*passkeyError) Error() string { return "invalid passkey user" }
func passkeyBody(r *http.Request, out interface{}) bool {
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64*1024)).Decode(out) == nil
}
func (h *Handler) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req struct {
Password string `json:"password"`
}
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
u, err := h.loadPasskeyUser(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥"))
return
}
options, session, err := wa.BeginRegistration(u, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: userID, kind: "register", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
}
type passkeyFinishRequest struct {
SessionID string `json:"sessionId"`
Credential json.RawMessage `json:"credential"`
Name string `json:"name"`
}
func (h *Handler) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req passkeyFinishRequest
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
session, ok := h.takePasskeyCeremony(req.SessionID, "register", origin, userID)
if !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
return
}
u, err := h.loadPasskeyUser(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
credential, err := wa.FinishRegistration(u, session, credentialRequest(r, req.Credential))
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
credentialJSON, err := json.Marshal(credential)
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
return
}
name := strings.TrimSpace(req.Name)
if len([]rune(name)) > 100 {
name = string([]rune(name)[:100])
}
if name == "" {
name = "Passkey"
}
err = h.repo.CreatePasskey(&model.Passkey{ID: base64.RawURLEncoding.EncodeToString(credential.ID), UserID: userID, Name: name, CredentialJSON: string(credentialJSON), CreatedAt: time.Now().UnixMilli()})
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
return
}
response.WriteJSON(w, response.OK(nil))
}
func credentialRequest(original *http.Request, body []byte) *http.Request {
r := original.Clone(original.Context())
r.Body = http.NoBody
if len(body) > 0 {
r.Body = ioNopCloser{bytes.NewReader(body)}
}
return r
}
type ioNopCloser struct{ *bytes.Reader }
func (ioNopCloser) Close() error { return nil }
func (h *Handler) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
var req struct {
Username string `json:"username"`
}
if !passkeyBody(r, &req) || strings.TrimSpace(req.Username) != "" {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
options, session, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
}
func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
var req passkeyFinishRequest
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
pending, ok := h.takePasskeyLoginCeremony(req.SessionID, origin)
if !ok {
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
}
}
if err != nil || credential == nil || credential.Authenticator.CloneWarning {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
credentialID := base64.RawURLEncoding.EncodeToString(credential.ID)
stored, err := h.repo.GetPasskey(u.id, credentialID)
if err != nil || stored == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
updated, err := json.Marshal(credential)
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
ok, err = h.repo.UpdatePasskeyCredential(u.id, credentialID, stored.CredentialJSON, string(updated), time.Now().UnixMilli())
if err != nil || !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
user, err := h.repo.GetUserByID(u.id)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
token, err := auth.GenerateTokenAt(user.ID, user.User, user.RoleID, h.jwtSecret, time.Now())
if err != nil {
response.WriteJSON(w, response.ErrDefault("登录失败"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"token": token, "name": user.User, "role_id": user.RoleID, "requirePasswordChange": user.User == "admin_user"}))
}
func (h *Handler) passkeyList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
rows, err := h.repo.ListPasskeys(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("获取通行证密钥失败"))
return
}
out := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
out = append(out, map[string]interface{}{"id": row.ID, "name": row.Name, "createdAt": row.CreatedAt, "lastUsedAt": row.LastUsedAt})
}
response.WriteJSON(w, response.OK(out))
}
func (h *Handler) passkeyDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req struct {
ID string `json:"id"`
Password string `json:"password"`
}
if !passkeyBody(r, &req) || req.ID == "" {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
deleted, err := h.repo.DeletePasskey(userID, req.ID)
if err != nil || !deleted {
response.WriteJSON(w, response.ErrDefault("通行证密钥不存在"))
return
}
response.WriteJSON(w, response.OK(nil))
}
@@ -0,0 +1,56 @@
package handler
import (
"testing"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) {
h := &Handler{passkeyPending: make(map[string]passkeyCeremony)}
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, "register", "https://panel.example.test", 7); !ok {
t.Fatal("valid challenge was rejected")
}
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: "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, "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")
}
}
@@ -0,0 +1,527 @@
package handler
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
"go-backend/internal/ws"
)
func TestUserUpdateDisablesPermissionsAndRunningForwards(t *testing.T) {
for _, password := range []string{"", "replacement-password"} {
t.Run(fmt.Sprintf("password_changed=%t", password != ""), func(t *testing.T) {
a := newCleanupAgent(t)
now := time.Now().UnixMilli()
for _, userID := range []int64{2, 3} {
if err := a.h.repo.DB().Create(&model.User{ID: userID, User: fmt.Sprintf("user%d", userID), Pwd: "pwd", RoleID: 1, Status: 1, ExpTime: now + 86400000, Flow: 100, Num: 10}).Error; err != nil {
t.Fatal(err)
}
}
if err := a.h.repo.DB().Create(&model.Tunnel{ID: 1, Name: "test", Type: 1, Protocol: "tls", Status: 1, Flow: 1}).Error; err != nil {
t.Fatal(err)
}
for _, permission := range []model.UserTunnel{
{ID: 10, UserID: 2, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
{ID: 11, UserID: 2, TunnelID: 2, Status: 0, Flow: 100, Num: 10},
{ID: 12, UserID: 3, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
} {
if err := a.h.repo.DB().Create(&permission).Error; err != nil {
t.Fatal(err)
}
}
for _, forward := range []model.Forward{
{ID: 20, UserID: 2, UserName: "user2", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
{ID: 21, UserID: 2, UserName: "user2", Name: "paused", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 0},
{ID: 22, UserID: 3, UserName: "user3", Name: "other-user", TunnelID: 1, RemoteAddr: "127.0.0.1:82", Status: 1},
} {
if err := a.h.repo.DB().Create(&forward).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: forward.ID, NodeID: 1, Port: 31000 + int(forward.ID)}).Error; err != nil {
t.Fatal(err)
}
}
update := func(status int) {
t.Helper()
payload, err := json.Marshal(map[string]interface{}{"id": 2, "user": "user2", "status": status, "pwd": password, "flow": 100, "num": 10, "expTime": now + 86400000})
if err != nil {
t.Fatal(err)
}
res := httptest.NewRecorder()
a.h.userUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/user/update", bytes.NewReader(payload)))
var result response.R
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Code != 0 {
t.Fatalf("update user: %d %s", result.Code, result.Msg)
}
}
update(1)
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 1 {
t.Errorf("ordinary account update disabled an enabled permission: %d", got)
}
if len(a.commandsOfType("PauseService")) != 0 {
t.Error("ordinary account update paused services")
}
update(0)
for _, check := range []struct {
table string
id int64
want int
}{{"user", 2, 0}, {"user_tunnel", 10, 0}, {"user_tunnel", 11, 0}, {"user_tunnel", 12, 1}, {"forward", 20, 0}, {"forward", 21, 0}, {"forward", 22, 1}} {
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM "+check.table+" WHERE id = ?", check.id); got != check.want {
t.Errorf("%s %d status = %d, want %d", check.table, check.id, got, check.want)
}
}
commands := a.commandsOfType("PauseService")
if len(commands) == 0 {
t.Error("running forward never received PauseService")
}
for _, command := range commands {
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Fatal(err)
}
for _, name := range data.Services {
if !strings.HasPrefix(name, "20_2_") {
t.Errorf("paused unrelated service %q", name)
}
}
}
update(0)
if len(a.commandsOfType("PauseService")) != len(commands) {
t.Error("repeated disable replayed pauses for already paused forwards")
}
// Account edits and re-enabling must not grant disabled permissions or
// resume forwards that an administrator previously paused.
update(1)
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
t.Errorf("account re-enable unexpectedly granted a disabled permission: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 11"); got != 0 {
t.Errorf("re-enabled independently disabled permission: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
t.Errorf("resumed previously paused forward: %d", got)
}
if len(a.commandsOfType("ResumeService")) != 0 {
t.Error("account re-enable unexpectedly resumed services")
}
})
}
}
func TestUserDisableMultiEntryNeverRecreatesMissingServices(t *testing.T) {
for _, secondNodeState := range []string{"missing", "offline", "timeout"} {
t.Run(secondNodeState, func(t *testing.T) {
a := newCleanupAgent(t)
var remoteMu sync.Mutex
remoteServices := make(map[string]bool)
var remoteCommands []string
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var command struct {
Type string `json:"commandType"`
Data json.RawMessage `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
t.Error(err)
return
}
remoteMu.Lock()
defer remoteMu.Unlock()
remoteCommands = append(remoteCommands, command.Type)
switch command.Type {
case "UpdateService", "AddService":
var services []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(command.Data, &services); err != nil {
t.Error(err)
return
}
for _, service := range services {
remoteServices[service.Name] = true
}
case "PauseService":
if secondNodeState != "missing" {
message := "节点不在线"
if secondNodeState == "timeout" {
message = "等待节点响应超时"
}
response.WriteJSON(w, response.ErrDefault(message))
return
}
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Error(err)
return
}
for _, name := range data.Services {
if _, exists := remoteServices[name]; !exists {
response.WriteJSON(w, response.OK(map[string]interface{}{"success": false, "message": "service not found"}))
return
}
remoteServices[name] = false
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
}))
defer remote.Close()
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("multi-entry-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Node{ID: 2, Name: "second-entry", Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: "test-token", Valid: true}},
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 1, Status: 1, Protocol: "tls", Flow: 1},
&model.ChainTunnel{TunnelID: 1, NodeID: 1, ChainType: "1"},
&model.ChainTunnel{TunnelID: 1, NodeID: 2, ChainType: "1"},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "multi-entry-disable", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
&model.ForwardPort{ForwardID: 20, NodeID: 2, Port: 31020},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "multi-entry-disable", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
wantStatus := 1
if secondNodeState == "missing" {
wantStatus = 0
if out.Code != 0 {
t.Errorf("missing service should count as stopped: %s", out.Msg)
}
} else if out.Code == 0 {
t.Error("unconfirmed second entry pause returned success")
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != wantStatus {
t.Errorf("forward status = %d, want %d", got, wantStatus)
}
a.mu.Lock()
commands := append([]cleanupAgentCommand(nil), a.commands...)
a.mu.Unlock()
firstEntryRunning := true
for _, command := range commands {
switch command.Type {
case "PauseService":
firstEntryRunning = false
case "UpdateService", "AddService", "ResumeService":
firstEntryRunning = true
t.Errorf("disable sent %s to already paused entry A", command.Type)
}
}
if firstEntryRunning {
t.Error("entry A is running after disable")
}
remoteMu.Lock()
defer remoteMu.Unlock()
if len(remoteCommands) == 0 {
t.Error("entry B was never checked")
}
for _, command := range remoteCommands {
if command != "PauseService" {
t.Errorf("disable sent %s to entry B", command)
}
}
for name, running := range remoteServices {
if running {
t.Errorf("entry B service %s is still running", name)
}
}
})
}
}
func TestUserDisableContinuesAcrossNodesAndForwards(t *testing.T) {
for _, failure := range []string{"节点不在线", "等待节点响应超时", "pause rejected"} {
for _, failedNodeFirst := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/failed_node_first=%t", failure, failedNodeFirst), func(t *testing.T) {
a := newCleanupAgent(t)
var mu sync.Mutex
recovered := false
attempts := make(map[string]int)
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var command struct {
Type string `json:"commandType"`
}
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
t.Error(err)
return
}
if command.Type != "PauseService" {
t.Errorf("disable sent %s", command.Type)
}
mu.Lock()
attempts[r.Header.Get("Authorization")]++
isRecovered := recovered
mu.Unlock()
if !isRecovered {
response.WriteJSON(w, response.ErrDefault(failure))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
}))
defer remote.Close()
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("continue-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 2, Status: 1, Flow: 1},
&model.Node{ID: 4, Name: "offline-middle", Secret: "test", ServerIP: "127.0.0.1", Status: 0},
&model.ChainTunnel{TunnelID: 1, NodeID: 4, ChainType: "2"},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "continue-disable", Name: "mixed-nodes", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.Forward{ID: 21, UserID: userID, UserName: "continue-disable", Name: "healthy-forward", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 1},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
for _, nodeID := range []int64{2, 3} {
node := model.Node{ID: nodeID, Name: fmt.Sprintf("failed-node-%d", nodeID), Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: fmt.Sprintf("test-node-%d", nodeID), Valid: true}}
if err := a.h.repo.DB().Create(&node).Error; err != nil {
t.Fatal(err)
}
}
nodeOrder := []int64{1, 2, 3}
if failedNodeFirst {
nodeOrder = []int64{2, 1, 3}
}
for _, nodeID := range nodeOrder {
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 20, NodeID: nodeID, Port: 31020}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ChainTunnel{TunnelID: 1, NodeID: nodeID, ChainType: "1"}).Error; err != nil {
t.Fatal(err)
}
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 21, NodeID: 1, Port: 31021}).Error; err != nil {
t.Fatal(err)
}
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "continue-disable", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
out := update()
if out.Code == 0 {
t.Error("partial node failure reported complete success")
}
state, ok := out.Data.(map[string]interface{})
if !ok || state["userDisabled"] != true {
t.Errorf("partial response did not tell the UI the account is disabled: %#v", out.Data)
}
for _, nodeID := range []int64{2, 3} {
if !strings.Contains(out.Msg, fmt.Sprintf("failed-node-%d", nodeID)) || !strings.Contains(out.Msg, fmt.Sprintf("ID %d", nodeID)) {
t.Errorf("failure response did not identify node %d: %s", nodeID, out.Msg)
}
mu.Lock()
count := attempts[fmt.Sprintf("Bearer test-node-%d", nodeID)]
mu.Unlock()
if count == 0 {
t.Errorf("failure on another node prevented node %d from being attempted", nodeID)
}
}
paused := make(map[string]bool)
for _, command := range a.commandsOfType("PauseService") {
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Fatal(err)
}
for _, name := range data.Services {
paused[strings.Split(name, "_")[0]] = true
}
}
for _, id := range []string{"20", "21"} {
if !paused[id] {
t.Errorf("healthy node never paused forward %s", id)
}
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
t.Errorf("unconfirmed forward status = %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
t.Errorf("healthy later forward was not paused: %d", got)
}
mu.Lock()
recovered = true
mu.Unlock()
if retry := update(); retry.Code != 0 {
t.Errorf("retry after recovery failed: %s", retry.Msg)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("retry did not confirm all nodes: %d", got)
}
for _, kind := range []string{"UpdateService", "AddService", "ResumeService"} {
if len(a.commandsOfType(kind)) != 0 {
t.Errorf("disable sent %s", kind)
}
}
})
}
}
}
func TestUserDisableRemovesNftablesRuleOnlyAfterConfirmedApply(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
t.Cleanup(func() { _ = h.repo.Close() })
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "disable-nft", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
now := time.Now().UnixMilli()
userID, err := h.repo.CreateUser("disable-nft", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
if err := h.repo.DB().Model(&model.Forward{}).Where("id = ?", forward.ID).Update("user_id", userID).Error; err != nil {
t.Fatal(err)
}
if err := h.repo.DB().Create(&model.UserTunnel{UserID: userID, TunnelID: tunnelID, Status: 1, Flow: 100, Num: 10}).Error; err != nil {
t.Fatal(err)
}
otherForwardID := forward.ID + 100
for _, value := range []interface{}{
&model.Forward{ID: otherForwardID, UserID: 1, UserName: "admin", Name: "other-user", TunnelID: tunnelID, RemoteAddr: "203.0.113.9:8081", Status: 1},
&model.ForwardPort{ForwardID: otherForwardID, NodeID: fixture.nodeID, Port: 20001},
} {
if err := h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
manager := &fakeNftablesManager{reconcileErr: errors.New("ssh unavailable")}
h.nftablesManager = manager
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-nft", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := update(); out.Code == 0 {
t.Error("failed SSH apply returned success")
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 1 {
t.Errorf("unconfirmed nftables pause marked completed: %d", got)
}
if manager.reconcileCount() != 1 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
t.Fatalf("did not attempt to remove disabled user's nftables rule: %+v", manager.lastPlan)
}
manager.reconcileErr = nil
if out := update(); out.Code != 0 {
t.Fatalf("retry nftables disable: %s", out.Msg)
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 0 {
t.Errorf("confirmed nftables pause was not stored: %d", got)
}
if manager.reconcileCount() != 2 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
t.Errorf("retry did not apply a plan without disabled forward: %+v", manager.lastPlan)
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", otherForwardID); got != 1 {
t.Errorf("disabled another user's nftables forward: %d", got)
}
}
func TestUserDisableReportsPauseFailureAndCanRetry(t *testing.T) {
for _, offline := range []bool{false, true} {
t.Run(fmt.Sprintf("offline=%t", offline), func(t *testing.T) {
a := newCleanupAgent(t)
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("disable-retry", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "test", Type: 1, Status: 1, Flow: 1},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "disable-retry", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
onlineServer := a.h.wsServer
if offline {
a.h.wsServer = ws.NewServer(a.h.repo, "test-jwt-secret")
} else {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
response.WriteJSON(w, response.ErrDefault("pause rejected"))
}))
defer ts.Close()
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Updates(map[string]interface{}{"is_remote": 1, "remote_url": ts.URL, "remote_token": "test-token"}).Error; err != nil {
t.Fatal(err)
}
}
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-retry", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := update(); out.Code == 0 {
t.Error("pause failure returned success")
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user WHERE id = ?", userID); got != 0 {
t.Errorf("failed pause restored account access: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
t.Errorf("failed pause restored tunnel access: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
t.Errorf("unconfirmed pause marked completed, status = %d", got)
}
a.h.wsServer = onlineServer
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Update("is_remote", 0).Error; err != nil {
t.Fatal(err)
}
if out := update(); out.Code != 0 {
t.Fatalf("retry failed: %s", out.Msg)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("retry did not pause forward: %d", got)
}
if len(a.commandsOfType("PauseService")) == 0 {
t.Error("retry never sent PauseService")
}
})
}
}
@@ -116,6 +116,8 @@ func shouldSkip(path string) bool {
return false
case path == "/api/v1/user/login":
return true
case path == "/api/v1/user/passkey/status" || path == "/api/v1/user/passkey/login/begin" || path == "/api/v1/user/passkey/login/finish":
return true
case path == "/api/v1/public/config/get":
return true
case path == "/api/v1/federation/connect":
@@ -0,0 +1,13 @@
package model
// Passkey stores a WebAuthn credential for exactly one panel user.
type Passkey struct {
ID string `gorm:"primaryKey;type:varchar(512)"`
UserID int64 `gorm:"column:user_id;not null;index"`
Name string `gorm:"type:varchar(100);not null"`
CredentialJSON string `gorm:"column:credential_json;type:text;not null"`
CreatedAt int64 `gorm:"column:created_at;not null"`
LastUsedAt int64 `gorm:"column:last_used_at;not null;default:0"`
}
func (Passkey) TableName() string { return "passkey" }
@@ -288,6 +288,7 @@ func autoMigrateAll(db *gorm.DB) error {
models := []interface{}{
&model.User{},
&model.Passkey{},
&model.UserQuota{},
&model.Forward{},
&model.ForwardPort{},
@@ -151,11 +151,21 @@ func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num in
}).Error
}
func (r *Repository) DisableUserTunnels(userID int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.UserTunnel{}).Where("user_id = ?", userID).Update("status", 0).Error
}
func (r *Repository) DeleteUserCascade(userID int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Passkey{}).Error; err != nil {
return err
}
forwardIDs := tx.Model(&model.Forward{}).Select("id").Where("user_id = ?", userID)
if err := tx.Where("forward_id IN (?)", forwardIDs).Delete(&model.ForwardPort{}).Error; err != nil {
return err
@@ -0,0 +1,51 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
"gorm.io/gorm"
)
func (r *Repository) ListPasskeys(userID int64) ([]model.Passkey, error) {
var keys []model.Passkey
err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&keys).Error
return keys, err
}
func (r *Repository) GetPasskey(userID int64, id string) (*model.Passkey, error) {
var key model.Passkey
err := r.db.Where("user_id = ? AND id = ?", userID, id).Take(&key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
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
}
func (r *Repository) DeletePasskey(userID int64, id string) (bool, error) {
result := r.db.Where("user_id = ? AND id = ?", userID, id).Delete(&model.Passkey{})
return result.RowsAffected == 1, result.Error
}
// Compare-and-swap prevents a concurrent assertion from reusing an old sign counter.
func (r *Repository) UpdatePasskeyCredential(userID int64, id, oldJSON, newJSON string, now int64) (bool, error) {
result := r.db.Model(&model.Passkey{}).
Where("user_id = ? AND id = ? AND credential_json = ?", userID, id, oldJSON).
Updates(map[string]interface{}{"credential_json": newJSON, "last_used_at": now})
return result.RowsAffected == 1, result.Error
}
@@ -0,0 +1,316 @@
package contract_test
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/fxamacker/cbor/v2"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
const passkeyTestOrigin = "https://panel.example.test"
func passkeyPost(t *testing.T, router http.Handler, path, token string, body interface{}) response.R {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out response.R
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return out
}
func passkeyData(t *testing.T, out response.R) map[string]interface{} {
t.Helper()
if out.Code != 0 {
t.Fatalf("unexpected response: code=%d msg=%s", out.Code, out.Msg)
}
return out.Data.(map[string]interface{})
}
func passkeyChallenge(t *testing.T, data map[string]interface{}) string {
t.Helper()
options := data["options"].(map[string]interface{})
return options["publicKey"].(map[string]interface{})["challenge"].(string)
}
func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T) {
t.Setenv("FLVX_WEBAUTHN_ORIGIN", "https://panel.example.test/path")
router, r := setupContractRouter(t, "passkey-test-secret")
seedLegacyUser(t, r, 9301, "passkey-disabled", "test-password")
status := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/status", "", map[string]string{}))
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{}); 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 {
t.Fatalf("password login regressed: %s", result.Msg)
}
}
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")
seedLegacyUser(t, r, 9303, "passkey-bob", "bob-password")
aliceToken, _ := auth.GenerateToken(9302, "passkey-alice", 1, "passkey-test-secret")
bobToken, _ := auth.GenerateToken(9303, "passkey-bob", 1, "passkey-test-secret")
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/begin", "", map[string]string{"password": "alice-password"}); result.Code == 0 {
t.Fatal("unauthenticated registration was allowed")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "wrong"}); result.Code == 0 {
t.Fatal("registration did not require password re-verification")
}
begin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", bobToken, map[string]interface{}{"sessionId": begin["sessionId"], "credential": map[string]string{}}); result.Code == 0 {
t.Fatal("another user completed Alice's registration")
}
// 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)
}
credentialID := make([]byte, 32)
if _, err := rand.Read(credentialID); err != nil {
t.Fatal(err)
}
registerResponse := makeRegistrationResponse(t, privateKey, credentialID, passkeyChallenge(t, begin), passkeyTestOrigin)
finishBody := map[string]interface{}{"sessionId": begin["sessionId"], "credential": registerResponse, "name": "Laptop"}
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, finishBody); result.Code != 0 {
t.Fatalf("register: %s", result.Msg)
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, finishBody); result.Code == 0 {
t.Fatal("registration challenge was reusable")
}
keyID := base64.RawURLEncoding.EncodeToString(credentialID)
if items := passkeyPost(t, router, "/api/v1/user/passkey/list", bobToken, map[string]string{}).Data.([]interface{}); len(items) != 0 {
t.Fatal("Alice's credential appeared in Bob's list")
}
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")
}
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)
}
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")
}
}
func makeRegistrationResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin string) map[string]interface{} {
t.Helper()
pub := privateKey.PublicKey
cose, err := cbor.Marshal(map[int]interface{}{1: 2, 3: -7, -1: 1, -2: pub.X.FillBytes(make([]byte, 32)), -3: pub.Y.FillBytes(make([]byte, 32))})
if err != nil {
t.Fatal(err)
}
rpHash := sha256.Sum256([]byte("panel.example.test"))
authData := append([]byte{}, rpHash[:]...)
authData = append(authData, 0x45, 0, 0, 0, 0)
authData = append(authData, make([]byte, 16)...)
length := make([]byte, 2)
binary.BigEndian.PutUint16(length, uint16(len(id)))
authData = append(authData, length...)
authData = append(authData, id...)
authData = append(authData, cose...)
attestation, err := cbor.Marshal(map[string]interface{}{"fmt": "none", "authData": authData, "attStmt": map[string]interface{}{}})
if err != nil {
t.Fatal(err)
}
client, _ := json.Marshal(map[string]interface{}{"type": "webauthn.create", "challenge": challenge, "origin": origin})
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"attestationObject": base64.RawURLEncoding.EncodeToString(attestation), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client)}}
}
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[:]...)
flags := byte(0x01)
if verified {
flags |= 0x04
}
authData = append(authData, flags, 0, 0, 0, 0)
binary.BigEndian.PutUint32(authData[33:37], count)
client, _ := json.Marshal(map[string]interface{}{"type": "webauthn.get", "challenge": challenge, "origin": origin})
clientHash := sha256.Sum256(client)
signed := append(append([]byte{}, authData...), clientHash[:]...)
hash := sha256.Sum256(signed)
signature, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:])
if err != nil {
t.Fatal(err)
}
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}}
}
@@ -0,0 +1,87 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
)
func TestManualUserDisableRevokesLoginTokenAndForwardAccess(t *testing.T) {
const secret = "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
seedContractUser(t, r, 2, "disable-contract", 1, 1)
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "disable-contract", Type: 1, Status: 1, Flow: 1},
&model.UserTunnel{ID: 10, UserID: 2, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: 2, UserName: "disable-contract", Name: "paused", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 0},
} {
if err := r.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
userToken, err := auth.GenerateToken(2, "disable-contract", 1, secret)
if err != nil {
t.Fatal(err)
}
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatal(err)
}
request := func(path, token, body string) response.R {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := request("/api/v1/forward/list", userToken, `{}`); out.Code != 0 {
t.Fatalf("token invalid before disable: %s", out.Msg)
}
if out := request("/api/v1/user/update", adminToken, `{"id":2,"user":"disable-contract","status":0}`); out.Code != 0 {
t.Fatalf("disable user: %s", out.Msg)
}
for _, check := range []struct{ path, body string }{
{"/api/v1/forward/list", `{}`},
{"/api/v1/forward/create", `{"tunnelId":1,"name":"bypass","remoteAddr":"127.0.0.1:80"}`},
{"/api/v1/forward/resume", `{"id":20}`},
{"/api/v1/forward/batch-resume", `{"ids":[20]}`},
} {
if out := request(check.path, userToken, check.body); out.Code != 401 {
t.Errorf("disabled user's old token accepted at %s: %+v", check.path, out)
}
}
if out := request("/api/v1/user/login", "", `{"username":"disable-contract","password":"contract-pass","captchaId":""}`); out.Code == 0 || !strings.Contains(out.Msg, "停用") {
t.Errorf("disabled account login: %+v", out)
}
if out := request("/api/v1/forward/resume", adminToken, `{"id":20}`); out.Code == 0 || !strings.Contains(out.Msg, "禁用") {
t.Errorf("admin bypassed disabled owner's policy: %+v", out)
}
out := request("/api/v1/tunnel/user/list", adminToken, `{"userId":2}`)
if out.Code != 0 {
t.Fatalf("list permissions: %s", out.Msg)
}
items, ok := out.Data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("permission list: %#v", out.Data)
}
permission, ok := items[0].(map[string]interface{})
if !ok || permission["status"] != float64(0) {
t.Errorf("permission still displayed as enabled: %#v", items[0])
}
if got := mustQueryInt(t, r, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("disabled forward resumed: %d", got)
}
}
+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",
+38
View File
@@ -70,6 +70,44 @@ export interface LoginResponse {
export const login = (data: LoginData) =>
Network.post<LoginResponse>("/user/login", data);
export interface PasskeyOptions {
sessionId: string;
options: { publicKey: Record<string, unknown> };
}
export interface PasskeyItem {
id: string;
name: string;
createdAt: number;
lastUsedAt: number;
}
export const getPasskeyStatus = () =>
Network.post<{ enabled: boolean }>("/user/passkey/status");
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,
credential,
});
export const beginPasskeyRegistration = (password: string) =>
Network.post<PasskeyOptions>("/user/passkey/register/begin", { password });
export const finishPasskeyRegistration = (
sessionId: string,
credential: unknown,
name: string,
) =>
Network.post("/user/passkey/register/finish", {
sessionId,
credential,
name,
});
export const listPasskeys = () =>
Network.post<PasskeyItem[]>("/user/passkey/list");
export const deletePasskey = (id: string, password: string) =>
Network.post("/user/passkey/delete", { id, password });
// 用户CRUD操作 - 全部使用POST请求
export const createUser = (data: UserMutationPayload) =>
Network.post("/user/create", data);
@@ -0,0 +1,168 @@
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import {
beginPasskeyRegistration,
deletePasskey,
finishPasskeyRegistration,
getPasskeyStatus,
listPasskeys,
type PasskeyItem,
} from "@/api";
import { createPasskey } from "@/utils/passkey";
export function PasskeyManager() {
const [enabled, setEnabled] = useState(false);
const [items, setItems] = useState<PasskeyItem[]>([]);
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const refresh = () =>
listPasskeys()
.then((res) => {
if (res.code === 0) setItems(res.data || []);
})
.catch(() => setItems([]));
useEffect(() => {
if (
!window.isSecureContext ||
typeof window.PublicKeyCredential === "undefined"
)
return;
getPasskeyStatus()
.then((res) => {
if (res.code === 0 && res.data.enabled) {
setEnabled(true);
void refresh();
}
})
.catch(() => setEnabled(false));
}, []);
if (!enabled) return null;
const register = async () => {
if (!password) {
toast.error("请输入当前密码");
return;
}
setBusy(true);
try {
const begin = await beginPasskeyRegistration(password);
if (begin.code !== 0) {
toast.error(begin.msg || "无法绑定通行证密钥");
return;
}
const credential = await createPasskey(begin.data.options);
const finish = await finishPasskeyRegistration(
begin.data.sessionId,
credential,
name.trim(),
);
if (finish.code !== 0) {
toast.error(finish.msg || "绑定失败");
return;
}
toast.success("通行证密钥已绑定");
setPassword("");
setName("");
await refresh();
} catch {
toast.error("绑定已取消或失败");
} finally {
setBusy(false);
}
};
const remove = async (id: string) => {
if (!password) {
toast.error("请输入当前密码以删除密钥");
return;
}
setBusy(true);
try {
const result = await deletePasskey(id, password);
if (result.code !== 0) {
toast.error(result.msg || "删除失败");
return;
}
toast.success("通行证密钥已删除");
setPassword("");
await refresh();
} catch {
toast.error("删除失败");
} finally {
setBusy(false);
}
};
return (
<Card>
<CardBody className="p-4 space-y-4">
<div>
<h3 className="text-base font-medium">通行证密钥</h3>
<p className="text-sm text-default-500">
绑定后可使用设备解锁登录,无需 Cloudflare
验证。绑定和删除均需输入当前密码。
</p>
</div>
<Input
label="当前密码"
type="password"
value={password}
variant="bordered"
onChange={(e) => setPassword(e.target.value)}
/>
<Input
label="密钥名称(可选)"
value={name}
variant="bordered"
onChange={(e) => setName(e.target.value)}
/>
<Button disabled={busy} onPress={register}>
绑定通行证密钥
</Button>
<div className="space-y-2">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-3 rounded-lg border border-default-200 p-3"
>
<div>
<div className="text-sm font-medium">{item.name}</div>
<div className="text-xs text-default-500">
创建于 {new Date(item.createdAt).toLocaleDateString()}{" "}
{item.lastUsedAt
? ` · 最近使用 ${new Date(item.lastUsedAt).toLocaleDateString()}`
: ""}
</div>
</div>
<Button
color="danger"
disabled={busy}
size="sm"
variant="light"
onPress={() => void remove(item.id)}
>
删除
</Button>
</div>
))}
</div>
</CardBody>
</Card>
);
}
+63 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Turnstile } from "@marsidev/react-turnstile";
@@ -10,8 +10,17 @@ import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site";
import { VersionFooter } from "@/components/version-footer";
import { BrandLogo } from "@/components/brand-logo";
import { login, LoginData, checkCaptcha, getPublicConfigByName } from "@/api";
import {
login,
LoginData,
checkCaptcha,
getPublicConfigByName,
getPasskeyStatus,
beginPasskeyLogin,
finishPasskeyLogin,
} from "@/api";
import { writeLoginSession } from "@/utils/session";
import { getPasskey } from "@/utils/passkey";
import { useWebViewMode } from "@/hooks/useWebViewMode";
interface LoginForm {
@@ -30,9 +39,51 @@ export default function IndexPage() {
const [errors, setErrors] = useState<Partial<LoginForm>>({});
const [showCaptcha, setShowCaptcha] = useState(false);
const [siteKey, setSiteKey] = useState("");
const [passkeyEnabled, setPasskeyEnabled] = useState(false);
const navigate = useNavigate();
const isWebView = useWebViewMode();
useEffect(() => {
if (
window.isSecureContext &&
typeof window.PublicKeyCredential !== "undefined"
) {
getPasskeyStatus()
.then((res) => setPasskeyEnabled(res.code === 0 && res.data.enabled))
.catch(() => setPasskeyEnabled(false));
}
}, []);
const handlePasskeyLogin = async () => {
setLoading(true);
try {
const begin = await beginPasskeyLogin();
if (begin.code !== 0) {
toast.error(begin.msg || "无法使用通行证密钥登录");
return;
}
const credential = await getPasskey(begin.data.options);
const result = await finishPasskeyLogin(begin.data.sessionId, credential);
if (result.code !== 0) {
toast.error(result.msg || "通行证密钥登录失败");
return;
}
writeLoginSession(result.data);
toast.success("登录成功");
navigate(
result.data.requirePasswordChange ? "/change-password" : "/dashboard",
);
} catch {
toast.error("通行证密钥登录已取消或失败");
} finally {
setLoading(false);
}
};
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<LoginForm> = {};
@@ -221,6 +272,16 @@ export default function IndexPage() {
: "Signing in..."
: "Sign In"}
</Button>
{passkeyEnabled && (
<Button
className="h-12 rounded-xl"
disabled={loading}
variant="bordered"
onPress={() => void handlePasskeyLogin()}
>
选择通行证密钥登录
</Button>
)}
</div>
</CardBody>
</Card>
+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>
+2
View File
@@ -19,6 +19,7 @@ import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { getAdminFlag, getSessionName } from "@/utils/session";
import { PasskeyManager } from "@/components/passkey-manager";
interface PasswordForm {
newUsername: string;
currentPassword: string;
@@ -241,6 +242,7 @@ export default function ProfilePage() {
</Card>
{/* 功能网格 */}
<PasskeyManager />
<Card>
<CardBody className="p-4">
<div className="grid grid-cols-3 gap-3">
+27 -1
View File
@@ -231,6 +231,9 @@ export default function UserPage() {
maxConn: 0,
});
const [userFormLoading, setUserFormLoading] = useState(false);
const [userSubmitWarning, setUserSubmitWarning] = useState<string | null>(
null,
);
const [userFlowInput, setUserFlowInput] = useState("1000");
const [userFlowUnit, setUserFlowUnit] = useState<TrafficUnit>("GB");
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
@@ -517,6 +520,7 @@ export default function UserPage() {
};
const handleAdd = () => {
setUserSubmitWarning(null);
setIsEdit(false);
setUserFlowInput("1000");
setUserFlowUnit("GB");
@@ -537,6 +541,7 @@ export default function UserPage() {
};
const handleEdit = async (user: User) => {
setUserSubmitWarning(null);
setIsEdit(true);
const trafficInput = trafficInputFor(user.flow, user.flowMiB);
@@ -618,6 +623,7 @@ export default function UserPage() {
}
setUserFormLoading(true);
setUserSubmitWarning(null);
try {
const submitData: any = {
...userForm,
@@ -660,7 +666,19 @@ export default function UserPage() {
await loadUsers();
}
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
const userDisabled =
isEdit &&
response.data !== null &&
typeof response.data === "object" &&
"userDisabled" in response.data &&
response.data.userDisabled === true;
if (userDisabled) {
setUserSubmitWarning(response.msg);
await loadUsers();
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
}
} catch {
toast.error(isEdit ? "更新失败" : "创建失败");
@@ -1532,6 +1550,14 @@ export default function UserPage() {
<ModalContent>
<ModalHeader>{isEdit ? "编辑用户" : "新增用户"}</ModalHeader>
<ModalBody>
{userSubmitWarning ? (
<div
className="whitespace-pre-wrap break-words rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/50 dark:text-amber-100"
role="alert"
>
{userSubmitWarning}
</div>
) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
isRequired
+97
View File
@@ -0,0 +1,97 @@
// Convert the WebAuthn JSON wire format to the browser's ArrayBuffer format.
function decode(value: string): ArrayBuffer {
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(padded), (character) =>
character.charCodeAt(0),
);
return bytes.buffer;
}
function encode(value: ArrayBuffer): string {
const bytes = new Uint8Array(value);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export async function createPasskey(options: {
publicKey: Record<string, unknown>;
}) {
const source = options.publicKey as unknown as {
challenge: string;
user: { id: string };
excludeCredentials?: Array<{ id: string }>;
};
const publicKey: PublicKeyCredentialCreationOptions = {
...(options.publicKey as unknown as PublicKeyCredentialCreationOptions),
challenge: decode(source.challenge),
user: {
...(source.user as unknown as PublicKeyCredentialUserEntity),
id: decode(source.user.id),
},
excludeCredentials: source.excludeCredentials?.map((item) => ({
...(item as unknown as PublicKeyCredentialDescriptor),
id: decode(item.id),
})),
};
const credential = (await navigator.credentials.create({
publicKey,
})) as PublicKeyCredential | null;
if (!credential) throw new Error("未创建通行证密钥");
const result = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: encode(credential.rawId),
type: credential.type,
response: {
attestationObject: encode(result.attestationObject),
clientDataJSON: encode(result.clientDataJSON),
transports: result.getTransports?.() || [],
},
};
}
export async function getPasskey(options: {
publicKey: Record<string, unknown>;
}) {
const source = options.publicKey as unknown as {
challenge: string;
allowCredentials?: Array<{ id: string }>;
};
const publicKey: PublicKeyCredentialRequestOptions = {
...(options.publicKey as unknown as PublicKeyCredentialRequestOptions),
challenge: decode(source.challenge),
allowCredentials: source.allowCredentials?.map((item) => ({
...(item as unknown as PublicKeyCredentialDescriptor),
id: decode(item.id),
})),
};
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null;
if (!credential) throw new Error("未选择通行证密钥");
const result = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: encode(credential.rawId),
type: credential.type,
response: {
authenticatorData: encode(result.authenticatorData),
clientDataJSON: encode(result.clientDataJSON),
signature: encode(result.signature),
userHandle: result.userHandle ? encode(result.userHandle) : null,
},
};
}