mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86477728c9 | |||
| 3549fe5ad8 | |||
| dd3ed4e284 | |||
| c794586cc7 | |||
| d9303032ca | |||
| 6da8b2b6ec | |||
| 861d61239a |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" .
|
||||
@@ -0,0 +1 @@
|
||||
22.23.4
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 安装面板,推荐直接使用脚本菜单一键迁移:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
package-lock=true
|
||||
package-lock=true
|
||||
engine-strict=true
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user