mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8339f52280 | |||
| 2e85234612 | |||
| b26e9ee9e4 | |||
| 577c189737 | |||
| 3209ff8a59 | |||
| 7e81e1c65c | |||
| a784350eaf | |||
| 743bf6f278 | |||
| fadc2b6c12 | |||
| 46bfb86c1b | |||
| 3549fe5ad8 | |||
| dd3ed4e284 |
@@ -63,6 +63,14 @@ jobs:
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test repository and SQLite upgrade migrations
|
||||
run: go test ./internal/store/repo -count=1
|
||||
|
||||
- name: Test panel upgrade lifecycle and permissions
|
||||
run: |
|
||||
go test ./internal/panelupgrade -count=1
|
||||
go test ./internal/http/handler ./tests/contract -run 'SystemUpgrade|SelectComposeAsset|DownloadReleaseAsset|UpgradeBackup|RestoreBackup' -count=1
|
||||
|
||||
backend-postgres-contract:
|
||||
name: Go Backend PostgreSQL Contract
|
||||
runs-on: ubuntu-latest
|
||||
@@ -95,10 +103,10 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run PostgreSQL contract test
|
||||
- name: Run PostgreSQL contract tests
|
||||
env:
|
||||
FLVX_POSTGRES_TEST_DSN: 'postgres://flux_test:flux_test_pass@127.0.0.1:5432/flux_test?sslmode=disable'
|
||||
run: go test ./tests/contract -run TestPostgresNodeCreateRepairsMissingIDDefaultContract -count=1
|
||||
run: go test ./tests/contract -run 'TestPostgres(NodeCreateRepairsMissingIDDefaultContract|NewRelayTunnelEmitsMatchingPrivateEndpointAuthentication)$' -count=1
|
||||
|
||||
agent:
|
||||
name: Build Agent
|
||||
|
||||
@@ -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" .
|
||||
@@ -25,6 +25,27 @@ Go 后端和 agent 的最低编译版本为 **Go 1.26.10**;若使用 Go 1.27
|
||||
|
||||
## 部署流程
|
||||
---
|
||||
### 让 AI Agent 帮你安装(复制即用)
|
||||
|
||||
将下面整段提示词复制给能执行终端命令的 AI Agent(如 Codex、Claude Code)。Agent 已运行在目标 Linux 服务器上时可直接安装;远程安装时,提供 SSH 主机别名或连接方式,使用已有 SSH 密钥登录即可,无需把私钥贴进对话。
|
||||
|
||||
```text
|
||||
请帮我在目标 Linux 服务器上安装 FLVX 流量转发面板,直接执行安装并完成验证。
|
||||
|
||||
项目仓库:https://github.com/Sagit-chu/flvx
|
||||
安装文档:https://github.com/Sagit-chu/flvx/blob/main/doc/install.md
|
||||
|
||||
1. 确认目标服务器:如果当前终端就在目标 Linux 服务器上,直接使用当前环境;否则使用我提供的 SSH 连接。缺少连接信息时,只询问必要信息,确认目标后再执行安装。
|
||||
2. 先阅读项目 README、安装文档及将要执行的安装脚本,检查系统、架构、root/sudo 权限、Docker、Compose、端口占用和已有 FLVX 容器、数据卷及部署文件。已有部署时先确认是更新还是新装,保留原配置和数据。
|
||||
3. 新安装默认使用仓库标记为 latest 的 Release、Docker Compose、SQLite,部署目录 /opt/flvx-panel,前端端口 6366、后端端口 6365。我已指定的版本、目录或端口优先;端口冲突时先与我确认替代端口。
|
||||
4. 缺少依赖时按该发行版的官方方式安装 Docker Engine 和 Compose 插件。进入部署目录,从项目 Releases 下载所选版本的 panel_install.sh,先检查下载成功和脚本内容,再执行。使用交互式终端操作菜单,选择“安装面板”并按上述配置输入;以脚本实际提示为准。
|
||||
5. 默认直连 GitHub,可设置 PROXY_ENABLED=false;连接失败时说明原因,再与我确认是否使用代理。脚本可能修改 Docker IPv6 配置并重启 Docker;若会影响现有服务,先说明影响并确认。保留现有防火墙和 SSH 规则,仅按需放行实际使用的面板端口;云安全组无法操作时列出需要我放行的端口。
|
||||
6. 安装后检查 Compose 服务状态、后端健康检查、前端 HTTP 页面及后端 /flow/test 的响应,并从实际访问端验证连通性。失败时查看相关日志并修复后重试;受网络或权限限制无法完成的检查要明确说明,不能直接宣称安装成功。
|
||||
7. 最后给我实际安装版本、部署目录、访问地址、验证结果,以及在该目录查看状态、查看日志和重启服务的命令。说明默认管理员账号和密码均为 admin_user,提醒首次登录立即修改密码;不要输出 .env 中的 JWT_SECRET、数据库密码等密钥。
|
||||
```
|
||||
|
||||
需要安装转发节点时,告诉 Agent 按[节点端部署文档](doc/install.md#二节点端部署)操作,并提供节点服务器、面板后端地址及从面板获取的节点接入密钥;安装完成后以服务正常运行且面板显示节点在线为准。
|
||||
|
||||
### Docker Compose部署
|
||||
#### 快速部署(安装最新版)
|
||||
面板端:
|
||||
|
||||
@@ -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.
|
||||
@@ -12,9 +12,17 @@ import (
|
||||
|
||||
"go-backend/internal/app"
|
||||
"go-backend/internal/config"
|
||||
"go-backend/internal/panelupgrade"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "panel-upgrade-helper" {
|
||||
if panelupgrade.RunHelper(os.Args[2]) != nil {
|
||||
log.Print("panel upgrade helper stopped; inspect durable job status")
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
cfg := config.FromEnv()
|
||||
if cfg.JWTSecret == "" {
|
||||
log.Println("warning: JWT_SECRET is empty")
|
||||
|
||||
@@ -3,6 +3,7 @@ package client
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-backend/internal/relaysecurity"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -14,17 +15,18 @@ type FederationClient struct {
|
||||
}
|
||||
|
||||
type RemoteNodeInfo struct {
|
||||
ShareID int64 `json:"shareId"`
|
||||
ShareName string `json:"shareName"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
NodeName string `json:"nodeName"`
|
||||
ServerIP string `json:"serverIp"`
|
||||
Status int `json:"status"`
|
||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||
CurrentFlow int64 `json:"currentFlow"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
PortRangeStart int `json:"portRangeStart"`
|
||||
PortRangeEnd int `json:"portRangeEnd"`
|
||||
RelayAuthVersion int `json:"relayAuthVersion"`
|
||||
ShareID int64 `json:"shareId"`
|
||||
ShareName string `json:"shareName"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
NodeName string `json:"nodeName"`
|
||||
ServerIP string `json:"serverIp"`
|
||||
Status int `json:"status"`
|
||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||
CurrentFlow int64 `json:"currentFlow"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
PortRangeStart int `json:"portRangeStart"`
|
||||
PortRangeEnd int `json:"portRangeEnd"`
|
||||
}
|
||||
|
||||
type RemoteTunnelResponse struct {
|
||||
@@ -38,30 +40,34 @@ type RuntimeReservePortRequest struct {
|
||||
}
|
||||
|
||||
type RuntimeReservePortResponse struct {
|
||||
ReservationID string `json:"reservationId"`
|
||||
BindingID string `json:"bindingId"`
|
||||
AllocatedPort int `json:"allocatedPort"`
|
||||
RelayAuthVersion int `json:"relayAuthVersion"`
|
||||
ReservationID string `json:"reservationId"`
|
||||
BindingID string `json:"bindingId"`
|
||||
AllocatedPort int `json:"allocatedPort"`
|
||||
}
|
||||
|
||||
type RuntimeTarget struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
type RuntimeApplyRoleRequest struct {
|
||||
ReservationID string `json:"reservationId"`
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
Role string `json:"role"`
|
||||
Protocol string `json:"protocol"`
|
||||
Strategy string `json:"strategy"`
|
||||
Targets []RuntimeTarget `json:"targets"`
|
||||
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
|
||||
ReservationID string `json:"reservationId"`
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
Role string `json:"role"`
|
||||
Protocol string `json:"protocol"`
|
||||
Strategy string `json:"strategy"`
|
||||
Targets []RuntimeTarget `json:"targets"`
|
||||
}
|
||||
|
||||
type RuntimeApplyRoleResponse struct {
|
||||
BindingID string `json:"bindingId"`
|
||||
ReservationID string `json:"reservationId"`
|
||||
AllocatedPort int `json:"allocatedPort"`
|
||||
RelayAuthVersion int `json:"relayAuthVersion"`
|
||||
BindingID string `json:"bindingId"`
|
||||
ReservationID string `json:"reservationId"`
|
||||
AllocatedPort int `json:"allocatedPort"`
|
||||
}
|
||||
|
||||
type RuntimeReleaseRoleRequest struct {
|
||||
@@ -247,6 +253,9 @@ func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData Run
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if reqData.RelayAuth != nil {
|
||||
return nil, fmt.Errorf("remote authenticated relay apply failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
@@ -260,9 +269,16 @@ func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData Run
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
if reqData.RelayAuth != nil {
|
||||
return nil, fmt.Errorf("remote authenticated relay apply failed: code %d", res.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
if reqData.RelayAuth != nil && res.Data.RelayAuthVersion != relaysecurity.Version {
|
||||
return nil, fmt.Errorf("remote did not acknowledge required relay authentication")
|
||||
}
|
||||
|
||||
return &res.Data, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -1939,7 +1972,12 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
|
||||
}
|
||||
if tunnel != nil && tunnel.Type == 2 {
|
||||
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
|
||||
handlerConfig := service["handler"].(map[string]interface{})
|
||||
handlerConfig["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
|
||||
if handlerConfig["metadata"] == nil {
|
||||
handlerConfig["metadata"] = map[string]interface{}{}
|
||||
}
|
||||
handlerConfig["metadata"].(map[string]interface{})["chain.strict"] = true
|
||||
}
|
||||
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
|
||||
serviceMetadata := ensureServiceMetadata(service)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *Handler) userDisplayOrder(w http.ResponseWriter, r *http.Request) {
|
||||
updateDisplayOrder(w, r, h.repo.UpdateUserDisplayOrder)
|
||||
}
|
||||
func (h *Handler) speedLimitDisplayOrder(w http.ResponseWriter, r *http.Request) {
|
||||
updateDisplayOrder(w, r, h.repo.UpdateSpeedLimitDisplayOrder)
|
||||
}
|
||||
func updateDisplayOrder(w http.ResponseWriter, r *http.Request, save func([]int64, []int64) error) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.Err(400, "请求方法错误"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
ExpectedIDs []int64 `json:"expectedIds"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(400, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
if err := save(req.IDs, req.ExpectedIDs); err != nil {
|
||||
code := 500
|
||||
if errors.Is(err, repo.ErrInvalidDisplayOrder) {
|
||||
code = 400
|
||||
}
|
||||
if errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||
code = 409
|
||||
}
|
||||
msg := "排序保存失败,请刷新后重试"
|
||||
if code != 500 {
|
||||
msg = err.Error()
|
||||
}
|
||||
response.WriteJSON(w, response.Err(code, msg))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/relaysecurity"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
@@ -68,18 +69,20 @@ type federationRuntimeReservePortRequest struct {
|
||||
}
|
||||
|
||||
type federationRuntimeTarget struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
type federationRuntimeApplyRoleRequest struct {
|
||||
ReservationID string `json:"reservationId"`
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
Role string `json:"role"`
|
||||
Protocol string `json:"protocol"`
|
||||
Strategy string `json:"strategy"`
|
||||
Targets []federationRuntimeTarget `json:"targets"`
|
||||
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
|
||||
ReservationID string `json:"reservationId"`
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
Role string `json:"role"`
|
||||
Protocol string `json:"protocol"`
|
||||
Strategy string `json:"strategy"`
|
||||
Targets []federationRuntimeTarget `json:"targets"`
|
||||
}
|
||||
|
||||
type federationRuntimeReleaseRoleRequest struct {
|
||||
@@ -115,6 +118,13 @@ func buildFederationMiddleChainConfig(chainName string, runtimeID int64, protoco
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if target.RelayAuth != nil {
|
||||
if err := target.RelayAuth.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connector["auth"] = target.RelayAuth.Config()
|
||||
}
|
||||
|
||||
if isTCPTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{
|
||||
"nodelay": true,
|
||||
@@ -221,7 +231,7 @@ type remoteUsageNodeItem struct {
|
||||
SyncError string `json:"syncError,omitempty"`
|
||||
}
|
||||
|
||||
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
|
||||
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string, credentials *relaysecurity.Credentials) map[string]interface{} {
|
||||
service := map[string]interface{}{
|
||||
"name": serviceName,
|
||||
"addr": addr,
|
||||
@@ -230,6 +240,10 @@ func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName s
|
||||
},
|
||||
"listener": buildTunnelListenerConfig(protocol),
|
||||
}
|
||||
if credentials != nil {
|
||||
service["handler"].(map[string]interface{})["auth"] = credentials.Config()
|
||||
}
|
||||
|
||||
if isTCPTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
|
||||
"nodelay": true,
|
||||
@@ -243,6 +257,13 @@ func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName s
|
||||
}
|
||||
}
|
||||
if role == "middle" {
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
md, _ := handler["metadata"].(map[string]interface{})
|
||||
if md == nil {
|
||||
md = map[string]interface{}{}
|
||||
handler["metadata"] = md
|
||||
}
|
||||
md["chain.strict"] = true
|
||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||
if targetCount > 1 {
|
||||
service["handler"].(map[string]interface{})["retries"] = targetCount - 1
|
||||
@@ -915,17 +936,18 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"shareId": share.ID,
|
||||
"shareName": share.Name,
|
||||
"nodeId": share.NodeID,
|
||||
"nodeName": nodeInfo.Name,
|
||||
"serverIp": nodeInfo.ServerIP,
|
||||
"status": nodeInfo.Status,
|
||||
"maxBandwidth": share.MaxBandwidth,
|
||||
"currentFlow": share.CurrentFlow,
|
||||
"expiryTime": share.ExpiryTime,
|
||||
"portRangeStart": share.PortRangeStart,
|
||||
"portRangeEnd": share.PortRangeEnd,
|
||||
"shareId": share.ID,
|
||||
"relayAuthVersion": relaysecurity.Version,
|
||||
"shareName": share.Name,
|
||||
"nodeId": share.NodeID,
|
||||
"nodeName": nodeInfo.Name,
|
||||
"serverIp": nodeInfo.ServerIP,
|
||||
"status": nodeInfo.Status,
|
||||
"maxBandwidth": share.MaxBandwidth,
|
||||
"currentFlow": share.CurrentFlow,
|
||||
"expiryTime": share.ExpiryTime,
|
||||
"portRangeStart": share.PortRangeStart,
|
||||
"portRangeEnd": share.PortRangeEnd,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1079,9 +1101,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
if existing != nil && existing.Status == 1 {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"reservationId": existing.ReservationID,
|
||||
"allocatedPort": existing.Port,
|
||||
"bindingId": existing.BindingID,
|
||||
"reservationId": existing.ReservationID,
|
||||
"allocatedPort": existing.Port,
|
||||
"relayAuthVersion": relaysecurity.Version,
|
||||
"bindingId": existing.BindingID,
|
||||
}))
|
||||
return
|
||||
}
|
||||
@@ -1103,6 +1126,8 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
existing.ServiceName = ""
|
||||
existing.Strategy = "round"
|
||||
existing.Target = ""
|
||||
existing.RelayAuth = ""
|
||||
existing.RelayAuthVersion = 0
|
||||
existing.Applied = 0
|
||||
existing.Status = 1
|
||||
existing.UpdatedTime = now
|
||||
@@ -1111,9 +1136,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"reservationId": existing.ReservationID,
|
||||
"allocatedPort": existing.Port,
|
||||
"bindingId": existing.BindingID,
|
||||
"reservationId": existing.ReservationID,
|
||||
"allocatedPort": existing.Port,
|
||||
"relayAuthVersion": relaysecurity.Version,
|
||||
"bindingId": existing.BindingID,
|
||||
}))
|
||||
return
|
||||
}
|
||||
@@ -1142,9 +1168,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"reservationId": runtime.ReservationID,
|
||||
"allocatedPort": runtime.Port,
|
||||
"bindingId": runtime.BindingID,
|
||||
"reservationId": runtime.ReservationID,
|
||||
"allocatedPort": runtime.Port,
|
||||
"relayAuthVersion": relaysecurity.Version,
|
||||
"bindingId": runtime.BindingID,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1211,6 +1238,20 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
response.WriteJSON(w, response.ErrDefault("Reserved port is outside share range"))
|
||||
return
|
||||
}
|
||||
if _, err := decodeRuntimeRelayAuth(runtime); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if err := validateFederationRelayAuth(runtime.RelayAuth, req.RelayAuth, req.Targets); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if req.RelayAuth != nil {
|
||||
encoded, _ := json.Marshal(req.RelayAuth)
|
||||
runtime.RelayAuth = string(encoded)
|
||||
runtime.RelayAuthVersion = relaysecurity.Version
|
||||
}
|
||||
|
||||
if strings.TrimSpace(runtime.BindingID) == "" {
|
||||
runtime.BindingID = randomToken(24)
|
||||
}
|
||||
@@ -1230,9 +1271,10 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"bindingId": runtime.BindingID,
|
||||
"reservationId": runtime.ReservationID,
|
||||
"allocatedPort": runtime.Port,
|
||||
"relayAuthVersion": relayAuthVersion(req.RelayAuth),
|
||||
"bindingId": runtime.BindingID,
|
||||
"reservationId": runtime.ReservationID,
|
||||
"allocatedPort": runtime.Port,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -495,19 +496,22 @@ func TestPeerResourcePendingRetryLeavesAppliedSiblingAlone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
var failCompletion atomic.Bool
|
||||
a := newCleanupAgent(t, func(r *repo.Repository) {
|
||||
if err := r.DB().Callback().Update().Before("gorm:update").Register("fail-runtime-release", func(tx *gorm.DB) {
|
||||
if failCompletion.Load() && tx.Statement.Table == "peer_share_runtime" {
|
||||
tx.AddError(errors.New("simulated completion failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.repo.DB().Callback().Update().Before("gorm:update").Register("fail-runtime-release", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "peer_share_runtime" {
|
||||
tx.AddError(errors.New("simulated completion failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failCompletion.Store(true)
|
||||
got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"}})
|
||||
if got.Code == 0 {
|
||||
t.Fatal("completion database failure reported success")
|
||||
@@ -525,9 +529,7 @@ func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
|
||||
if !proof || !pending {
|
||||
t.Fatalf("failure lost ownership or retry marker: %+v", items)
|
||||
}
|
||||
if err := a.h.repo.DB().Callback().Update().Remove("fail-runtime-release"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failCompletion.Store(false)
|
||||
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "")
|
||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "", nil)
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if handler["chain"] != "chain-next" {
|
||||
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
||||
@@ -239,7 +239,7 @@ func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "")
|
||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "", nil)
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if handler["chain"] != "chain-next" {
|
||||
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
||||
@@ -250,7 +250,7 @@ func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *te
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0")
|
||||
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0", nil)
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if _, hasChain := handler["chain"]; hasChain {
|
||||
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
|
||||
@@ -265,7 +265,7 @@ func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *t
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "")
|
||||
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "", nil)
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
meta := handler["metadata"].(map[string]interface{})
|
||||
if meta["nodelay"] != true {
|
||||
@@ -274,10 +274,14 @@ func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "")
|
||||
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "", nil)
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if _, hasMeta := handler["metadata"]; hasMeta {
|
||||
t.Fatalf("expected no metadata for non-TLS protocol, got %v", handler["metadata"])
|
||||
metadata, _ := handler["metadata"].(map[string]interface{})
|
||||
if _, hasNodelay := metadata["nodelay"]; hasNodelay {
|
||||
t.Fatal("non-TLS protocol must not set nodelay")
|
||||
}
|
||||
if metadata["chain.strict"] != true {
|
||||
t.Fatal("middle relay must require its configured chain")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -305,11 +305,17 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
// Model an unavailable provider entirely on loopback instead of relying on
|
||||
// external DNS or network restrictions to produce the sync error.
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
|
||||
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, provider.URL, "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
|
||||
@@ -348,7 +354,7 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
|
||||
|
||||
syncErr, _ := first["syncError"].(string)
|
||||
if syncErr == "" {
|
||||
t.Fatalf("expected non-empty syncError field on unreachable provider")
|
||||
t.Fatalf("expected non-empty syncError field on unavailable provider")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,11 +864,15 @@ func TestFederationRemoteUsageList(t *testing.T) {
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
|
||||
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, provider.URL, "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "remote-consumer-node")
|
||||
@@ -882,8 +892,8 @@ func TestFederationRemoteUsageList(t *testing.T) {
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
|
||||
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
|
||||
tunnelAID, nodeID, 2, 1, provider.URL, "rk-a", "rb-a", 31001, 1, now, now,
|
||||
tunnelBID, nodeID, 3, 0, provider.URL, "rk-b", "rb-b", 31002, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert federation bindings: %v", err)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ type cleanupAgent struct {
|
||||
commands []cleanupAgentCommand
|
||||
}
|
||||
|
||||
func newCleanupAgent(t *testing.T) *cleanupAgent {
|
||||
func newCleanupAgent(t *testing.T, setup ...func(*repo.Repository)) *cleanupAgent {
|
||||
t.Helper()
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "cleanup.db"))
|
||||
if err != nil {
|
||||
@@ -48,6 +48,11 @@ func newCleanupAgent(t *testing.T) *cleanupAgent {
|
||||
if err := r.DB().Create(&model.Node{ID: 1, Name: "relay", Secret: secret, ServerIP: "127.0.0.1", Port: "30000", Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Register test-local database callbacks before WebSocket goroutines can
|
||||
// execute them. Keep registries immutable until the fixture is closed.
|
||||
for _, configure := range setup {
|
||||
configure(r)
|
||||
}
|
||||
server := ws.NewServer(r, "test-jwt-secret")
|
||||
online := make(chan struct{})
|
||||
server.SetNodeOnlineHook(func(int64) { close(online) })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -39,6 +39,9 @@ type Handler struct {
|
||||
healthCheck *health.Checker
|
||||
nftablesManager nftablesRuntimeManager
|
||||
|
||||
tunnelLifecycleMu sync.Mutex
|
||||
tunnelLifecycleLocks map[int64]*tunnelLifecycleLock
|
||||
|
||||
peerResourceMu sync.Mutex
|
||||
captchaMu sync.Mutex
|
||||
captchaTokens map[string]int64
|
||||
@@ -183,6 +186,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/system/version", h.systemVersion)
|
||||
mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates)
|
||||
mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade)
|
||||
mux.HandleFunc("/api/v1/system/upgrade/status", h.systemUpgradeStatus)
|
||||
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
|
||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||
@@ -224,6 +228,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-redeploy/stream", h.tunnelBatchRedeployStream)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/assign", h.userTunnelAssign)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/batch-assign", h.userTunnelBatchAssign)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/remove", h.userTunnelRemove)
|
||||
@@ -244,6 +249,8 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/forward/batch-resume", h.forwardBatchResume)
|
||||
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
|
||||
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
|
||||
mux.HandleFunc("/api/v1/user/update-order", h.userDisplayOrder)
|
||||
mux.HandleFunc("/api/v1/speed-limit/update-order", h.speedLimitDisplayOrder)
|
||||
mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList)
|
||||
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
||||
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
||||
|
||||
@@ -48,6 +48,9 @@ func (h *Handler) runFederationCleanupRetryLoop(ctx context.Context) {
|
||||
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
|
||||
log.Printf("federation cleanup remains pending: %v", err)
|
||||
}
|
||||
if err := h.retryPendingTunnelRuntimeCleanup(); err != nil {
|
||||
log.Printf("tunnel runtime cleanup remains pending: %v", err)
|
||||
}
|
||||
h.retryPendingPeerShareOperations()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/relaysecurity"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
@@ -205,6 +206,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 +253,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 {
|
||||
@@ -832,10 +849,70 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var tunnelInIP sql.NullString
|
||||
if trimmed := strings.TrimSpace(inIP); trimmed != "" {
|
||||
tunnelInIP = sql.NullString{String: trimmed, Valid: true}
|
||||
}
|
||||
tunnelProtocol := "tls"
|
||||
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
||||
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
||||
}
|
||||
tunnel := model.Tunnel{
|
||||
RelayAuthMode: relaysecurity.Legacy,
|
||||
Name: name,
|
||||
TrafficRatio: trafficRatio,
|
||||
Type: typeVal,
|
||||
Protocol: tunnelProtocol,
|
||||
Flow: flow,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: status,
|
||||
InIP: tunnelInIP,
|
||||
Inx: inx,
|
||||
IPPreference: ipPreference,
|
||||
ProbeTargetHost: probeTargetHost,
|
||||
ProbeTargetPort: probeTargetPort,
|
||||
}
|
||||
if typeVal == 2 {
|
||||
tunnel.RelayAuthMode = relaysecurity.Required
|
||||
}
|
||||
if err := tx.Create(&tunnel).Error; err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
tunnelID := tunnel.ID
|
||||
unlock, locked := h.tryLockNewTunnelLifecycle(tunnelID)
|
||||
if !locked {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道正在变更,请重试"))
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
runtimeState.TunnelID = tunnelID
|
||||
runtimeState.RelayAuthMode = tunnel.RelayAuthMode
|
||||
if err := h.resolveTunnelRelayAuth(tx, runtimeState, true); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if typeVal == 2 {
|
||||
if err := h.persistPreparedTunnelChainsTx(tx, runtimeState); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
// The lifecycle lock is held before publishing this ID. Network calls below
|
||||
// must not monopolize SQLite's only connection or a database transaction.
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
failCreate := func(err error, code int) {
|
||||
err = errors.Join(err, h.deleteTunnelByID(tunnelID))
|
||||
response.WriteJSON(w, response.Err(code, err.Error()))
|
||||
}
|
||||
if len(runtimeState.InNodes) > 0 {
|
||||
firstNodeID := runtimeState.InNodes[0].NodeID
|
||||
isRemote, rUrl, rToken, _ := h.repo.GetNodeRemoteFieldsTx(tx, firstNodeID)
|
||||
if isRemote == 1 {
|
||||
entryNode := runtimeState.Nodes[firstNodeID]
|
||||
if entryNode != nil && entryNode.IsRemote == 1 {
|
||||
fc := client.NewFederationClient()
|
||||
|
||||
targetProto := "tcp"
|
||||
@@ -862,17 +939,12 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if targetPort > 0 && targetAddr != "" {
|
||||
inNodeRec := runtimeState.Nodes[firstNodeID]
|
||||
if err := validateRemoteNodePort(inNodeRec, targetPort); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
failCreate(err, -1)
|
||||
return
|
||||
}
|
||||
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
|
||||
localDomain := ""
|
||||
if domainCfg != nil {
|
||||
localDomain = domainCfg.Value
|
||||
}
|
||||
_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)
|
||||
_, err := fc.CreateTunnel(entryNode.RemoteURL, entryNode.RemoteToken, localDomain, targetProto, targetPort, targetAddr)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Remote tunnel creation failed: "+err.Error()))
|
||||
failCreate(fmt.Errorf("remote tunnel creation failed: %w", err), -1)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -880,66 +952,53 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
var tunnelInIP sql.NullString
|
||||
if trimmed := strings.TrimSpace(inIP); trimmed != "" {
|
||||
tunnelInIP = sql.NullString{String: trimmed, Valid: true}
|
||||
}
|
||||
tunnelProtocol := "tls"
|
||||
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
||||
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
||||
}
|
||||
tunnel := model.Tunnel{
|
||||
Name: name,
|
||||
TrafficRatio: trafficRatio,
|
||||
Type: typeVal,
|
||||
Protocol: tunnelProtocol,
|
||||
Flow: flow,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: status,
|
||||
InIP: tunnelInIP,
|
||||
Inx: inx,
|
||||
IPPreference: ipPreference,
|
||||
ProbeTargetHost: probeTargetHost,
|
||||
ProbeTargetPort: probeTargetPort,
|
||||
}
|
||||
if err := tx.Create(&tunnel).Error; err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
tunnelID := tunnel.ID
|
||||
runtimeState.TunnelID = tunnelID
|
||||
var federationBindings []repo.FederationTunnelBinding
|
||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
failCreate(err, -1)
|
||||
return
|
||||
}
|
||||
tx = h.repo.BeginTx()
|
||||
if tx.Error != nil {
|
||||
failCreate(errors.Join(tx.Error, h.releaseFederationRuntimeRefs(federationReleaseRefs)), -2)
|
||||
return
|
||||
}
|
||||
if typeVal == 2 {
|
||||
if err := h.repo.DeleteChainTunnelsByTunnelTx(tx, tunnelID); err != nil {
|
||||
tx.Rollback()
|
||||
failCreate(errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs)), -2)
|
||||
return
|
||||
}
|
||||
}
|
||||
applyTunnelPortsToRequest(req, runtimeState)
|
||||
if err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {
|
||||
tx.Rollback()
|
||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
failCreate(err, -2)
|
||||
return
|
||||
}
|
||||
if err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {
|
||||
tx.Rollback()
|
||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
failCreate(err, -2)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
tx.Rollback()
|
||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
failCreate(err, -2)
|
||||
return
|
||||
}
|
||||
if typeVal == 2 {
|
||||
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
||||
if applyErr != nil {
|
||||
if isTunnelRuntimePending(applyErr) {
|
||||
response.WriteJSON(w, tunnelRuntimePendingResponse(tunnelID, applyErr))
|
||||
return
|
||||
}
|
||||
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID, tunnelProtocol)
|
||||
if cleanupErr := h.cleanupFederationRuntime(tunnelID); cleanupErr != nil {
|
||||
applyErr = errors.Join(applyErr, cleanupErr)
|
||||
@@ -953,31 +1012,6 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil || tunnel.Type != 2 {
|
||||
return
|
||||
}
|
||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
chainName := fmt.Sprintf("chains_%d", tunnelID)
|
||||
serviceNames := tunnelRuntimeServiceNames(tunnelID)
|
||||
|
||||
for _, row := range chainRows {
|
||||
if row.ChainType == 1 {
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||
} else if row.ChainType == 2 {
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
||||
} else if row.ChainType == 3 {
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tunnelRuntimeServiceNames(tunnelID int64) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("tunnel_%d", tunnelID),
|
||||
@@ -1028,20 +1062,6 @@ func removedTunnelRuntimeNodeIDs(oldRows, newRows []chainNodeRecord, needsRuntim
|
||||
return removed
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupObsoleteTunnelRuntime(tunnelID int64, oldRows, newRows []chainNodeRecord) {
|
||||
if h == nil || tunnelID <= 0 || len(oldRows) == 0 {
|
||||
return
|
||||
}
|
||||
chainName := fmt.Sprintf("chains_%d", tunnelID)
|
||||
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||
}
|
||||
serviceNames := tunnelRuntimeServiceNames(tunnelID)
|
||||
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -1085,6 +1105,8 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
|
||||
return
|
||||
}
|
||||
unlock := h.lockTunnelLifecycle(id)
|
||||
defer unlock()
|
||||
typeVal := asInt(req["type"], 1)
|
||||
ipPreference := asString(req["ipPreference"])
|
||||
_, hasProbeTargetHost := req["probeTargetHost"]
|
||||
@@ -1140,6 +1162,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
runtimeState.TunnelID = id
|
||||
runtimeState.RelayAuthMode = oldTunnel.RelayAuthMode
|
||||
if typeVal == 2 && oldTunnel.Type != 2 {
|
||||
runtimeState.RelayAuthMode = relaysecurity.Required
|
||||
}
|
||||
runtimeState.IPPreference = ipPreference
|
||||
entryNodeIDs := make([]int64, 0, len(runtimeState.InNodes))
|
||||
for _, inNode := range runtimeState.InNodes {
|
||||
@@ -1183,12 +1209,18 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.cleanupFederationRuntime(id); err != nil {
|
||||
if err := h.resolveTunnelRelayAuth(nil, runtimeState, true); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if oldTunnel.Type == 2 && typeVal != 2 {
|
||||
h.cleanupTunnelRuntime(id)
|
||||
if err := h.preflightFederationRelayAuth(runtimeState, localDomain); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.cleanupFederationRuntime(id); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var federationBindings []repo.FederationTunnelBinding
|
||||
@@ -1263,6 +1295,19 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.SetTunnelRelayAuthModeTx(tx, id, runtimeState.RelayAuthMode); err != nil {
|
||||
tx.Rollback()
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if oldTunnel.Type == 2 {
|
||||
if err := h.queueObsoleteTunnelRuntimeTx(tx, id, oldChainRows, tunnelStateRuntimeRows(runtimeState)); err != nil {
|
||||
tx.Rollback()
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
tx.Rollback()
|
||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||
@@ -1276,13 +1321,17 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
h.syncTunnelForwardsEntryPorts(id, newEntryNodeIDs)
|
||||
}
|
||||
|
||||
var runtimePending error
|
||||
if typeVal == 2 {
|
||||
applyRuntime := h.applyTunnelRuntime
|
||||
if oldTunnel != nil && oldTunnel.Type == 2 {
|
||||
applyRuntime = h.applyTunnelRuntimeUpsert
|
||||
}
|
||||
createdChains, createdServices, applyErr := applyRuntime(runtimeState)
|
||||
if applyErr != nil {
|
||||
if isTunnelRuntimePending(applyErr) {
|
||||
runtimePending = applyErr
|
||||
}
|
||||
if applyErr != nil && runtimePending == nil {
|
||||
updateProtocol := "tls"
|
||||
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
||||
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
||||
@@ -1291,16 +1340,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
h.rollbackTunnelRuntime(createdChains, createdServices, id, updateProtocol)
|
||||
}
|
||||
applyErr = errors.Join(applyErr, h.cleanupFederationRuntime(id))
|
||||
if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
||||
return
|
||||
}
|
||||
newChainRows, _ := h.listChainNodesForTunnel(id)
|
||||
h.cleanupObsoleteTunnelRuntime(id, oldChainRows, newChainRows)
|
||||
runtimePending = errors.Join(runtimePending, h.acknowledgeTunnelServiceReplacements(id, createdServices))
|
||||
}
|
||||
runtimePending = errors.Join(runtimePending, h.retryTunnelRuntimeCleanupLocked(id))
|
||||
|
||||
oldType := 0
|
||||
if oldTunnel != nil {
|
||||
@@ -1309,14 +1354,22 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if tunnelForwardRuntimeNeedsSync(oldType, typeVal, oldEntryNodeIDs, newEntryNodeIDs) {
|
||||
forwards, fwdErr := h.listForwardsByTunnel(id)
|
||||
if fwdErr != nil {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
response.WriteJSON(w, tunnelRuntimePendingResponse(id, errors.Join(runtimePending, fwdErr)))
|
||||
return
|
||||
}
|
||||
var forwardErrors []error
|
||||
for i := range forwards {
|
||||
_ = h.syncForwardServices(&forwards[i], "UpdateService", true)
|
||||
if err := h.syncForwardServices(&forwards[i], "UpdateService", true); err != nil {
|
||||
forwardErrors = append(forwardErrors, err)
|
||||
}
|
||||
}
|
||||
runtimePending = errors.Join(runtimePending, errors.Join(forwardErrors...))
|
||||
}
|
||||
|
||||
if runtimePending != nil {
|
||||
response.WriteJSON(w, tunnelRuntimePendingResponse(id, runtimePending))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1478,6 +1531,15 @@ func (h *Handler) validateForwardPortAvailabilityTx(tx *gorm.DB, node *nodeRecor
|
||||
if h == nil || h.repo == nil || tx == nil || node == nil || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
pendingPorts, err := h.repo.PendingTunnelCleanupPortsTx(tx, node.ID, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, reserved := range pendingPorts {
|
||||
if reserved == port {
|
||||
return fmt.Errorf("节点 %s 端口 %d 仍在等待旧运行时清理", node.Name, port)
|
||||
}
|
||||
}
|
||||
occupied, err := h.repo.HasOtherForwardOnNodePortTx(tx, node.ID, port, currentForwardID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1675,11 +1737,12 @@ func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
unlock := h.lockTunnelLifecycle(id)
|
||||
defer unlock()
|
||||
if err := h.cleanupFederationRuntime(id); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
h.cleanupTunnelRuntime(id)
|
||||
if err := h.deleteTunnelByID(id); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1740,24 +1803,27 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
|
||||
fail := 0
|
||||
failures := make([]batchFailureDetail, 0)
|
||||
for _, id := range ids {
|
||||
tunnelName, _ := h.repo.GetTunnelName(id)
|
||||
if _, err := h.getTunnelRecord(id); err != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||
continue
|
||||
}
|
||||
if err := h.cleanupFederationRuntime(id); err != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||
continue
|
||||
}
|
||||
h.cleanupTunnelRuntime(id)
|
||||
if err := h.deleteTunnelByID(id); err != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||
} else {
|
||||
success++
|
||||
}
|
||||
func() {
|
||||
unlock := h.lockTunnelLifecycle(id)
|
||||
defer unlock()
|
||||
tunnelName, _ := h.repo.GetTunnelName(id)
|
||||
if _, err := h.getTunnelRecord(id); err != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||
return
|
||||
}
|
||||
if err := h.cleanupFederationRuntime(id); err != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||
return
|
||||
}
|
||||
if err := h.deleteTunnelByID(id); err != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||
} else {
|
||||
success++
|
||||
}
|
||||
}()
|
||||
}
|
||||
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
|
||||
}
|
||||
@@ -1776,14 +1842,15 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
|
||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||
|
||||
state := &tunnelCreateState{
|
||||
TunnelID: tunnelID,
|
||||
Type: tunnel.Type,
|
||||
IPPreference: ipPreference,
|
||||
InNodes: make([]tunnelRuntimeNode, 0),
|
||||
ChainHops: make([][]tunnelRuntimeNode, 0),
|
||||
OutNodes: make([]tunnelRuntimeNode, 0),
|
||||
Nodes: make(map[int64]*nodeRecord),
|
||||
NodeIDList: make([]int64, 0),
|
||||
RelayAuthMode: tunnel.RelayAuthMode,
|
||||
TunnelID: tunnelID,
|
||||
Type: tunnel.Type,
|
||||
IPPreference: ipPreference,
|
||||
InNodes: make([]tunnelRuntimeNode, 0),
|
||||
ChainHops: make([][]tunnelRuntimeNode, 0),
|
||||
OutNodes: make([]tunnelRuntimeNode, 0),
|
||||
Nodes: make(map[int64]*nodeRecord),
|
||||
NodeIDList: make([]int64, 0),
|
||||
}
|
||||
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
@@ -1840,10 +1907,23 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
|
||||
state.Nodes[id] = node
|
||||
}
|
||||
|
||||
if err := h.resolveTunnelRelayAuth(nil, state, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
|
||||
unlock := h.lockTunnelLifecycle(tunnelID)
|
||||
defer unlock()
|
||||
return h.redeployTunnelAndForwardsLocked(tunnelID)
|
||||
}
|
||||
|
||||
func (h *Handler) redeployTunnelAndForwardsLocked(tunnelID int64) error {
|
||||
if err := h.retryTunnelRuntimeCleanupLocked(tunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1857,28 +1937,44 @@ func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
|
||||
if err := validateTunnelRuntimeBeforeApply(state); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.cleanupFederationRuntime(tunnelID); err != nil {
|
||||
existingBindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.cleanupTunnelRuntime(tunnelID)
|
||||
// Reserve/apply use stable resource keys and upsert the peer runtime.
|
||||
// A failed refresh must never release bindings that were already live.
|
||||
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
|
||||
newRefs := make([]federationRuntimeReleaseRef, 0, len(federationReleaseRefs))
|
||||
for _, ref := range federationReleaseRefs {
|
||||
existing := false
|
||||
for _, binding := range existingBindings {
|
||||
if strings.TrimSpace(binding.RemoteURL) == strings.TrimSpace(ref.RemoteURL) && binding.ResourceKey == ref.ResourceKey {
|
||||
existing = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !existing {
|
||||
newRefs = append(newRefs, ref)
|
||||
}
|
||||
}
|
||||
if fedErr != nil {
|
||||
return errors.Join(fedErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||
return errors.Join(fedErr, h.releaseFederationRuntimeRefs(newRefs))
|
||||
}
|
||||
tx := h.repo.BeginTx()
|
||||
if tx.Error != nil {
|
||||
return errors.Join(tx.Error, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||
return errors.Join(tx.Error, h.releaseFederationRuntimeRefs(newRefs))
|
||||
}
|
||||
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
|
||||
tx.Rollback()
|
||||
return errors.Join(replaceErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||
return errors.Join(replaceErr, h.releaseFederationRuntimeRefs(newRefs))
|
||||
}
|
||||
if commitErr := tx.Commit().Error; commitErr != nil {
|
||||
return errors.Join(commitErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||
return errors.Join(commitErr, h.releaseFederationRuntimeRefs(newRefs))
|
||||
}
|
||||
_, _, applyErr := h.applyTunnelRuntime(state)
|
||||
_, services, applyErr := h.applyTunnelRuntimeUpsert(state)
|
||||
applyErr = errors.Join(applyErr, h.acknowledgeTunnelServiceReplacements(tunnelID, services))
|
||||
applyErr = errors.Join(applyErr, h.retryTunnelRuntimeCleanupLocked(tunnelID))
|
||||
if applyErr != nil {
|
||||
applyErr = errors.Join(applyErr, h.cleanupFederationRuntime(tunnelID))
|
||||
return applyErr
|
||||
}
|
||||
}
|
||||
@@ -1950,51 +2046,37 @@ func normalizeBatchFailureReason(reason string) string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func (h *Handler) redeployTunnelConfiguration(tunnelID int64) error {
|
||||
if _, err := h.getTunnelRecord(tunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
if nftMode, entryNodeIDs, err := h.tunnelUsesNftables(tunnelID); err != nil {
|
||||
return err
|
||||
} else if nftMode {
|
||||
if len(entryNodeIDs) == 0 {
|
||||
return errors.New("nftables 转发缺少入口节点")
|
||||
}
|
||||
return h.reconcileNftablesNodeByRequest(entryNodeIDs[0])
|
||||
}
|
||||
return h.redeployTunnelAndForwards(tunnelID)
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||
ids := idsFromBody(r, w)
|
||||
if ids == nil {
|
||||
return
|
||||
}
|
||||
success := 0
|
||||
fail := 0
|
||||
failures := make([]batchFailureDetail, 0)
|
||||
result := batchOperationResult{}
|
||||
for _, tunnelID := range ids {
|
||||
tunnel, tunnelErr := h.getTunnelRecord(tunnelID)
|
||||
tunnelName := ""
|
||||
if tunnelErr == nil && tunnel != nil {
|
||||
tunnelName, _ = h.repo.GetTunnelName(tunnelID)
|
||||
name, _ := h.repo.GetTunnelName(tunnelID)
|
||||
if err := h.redeployTunnelConfiguration(tunnelID); err != nil {
|
||||
result.FailCount++
|
||||
result.Failures = appendBatchFailure(result.Failures, tunnelID, name, err)
|
||||
} else {
|
||||
result.SuccessCount++
|
||||
}
|
||||
if tunnelErr != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, tunnelID, tunnelName, tunnelErr)
|
||||
continue
|
||||
}
|
||||
if nftMode, entryNodeIDs, modeErr := h.tunnelUsesNftables(tunnelID); modeErr != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, tunnelID, tunnelName, modeErr)
|
||||
continue
|
||||
} else if nftMode {
|
||||
if len(entryNodeIDs) == 0 {
|
||||
fail++
|
||||
failures = appendBatchFailureReason(failures, tunnelID, tunnelName, "nftables 转发缺少入口节点")
|
||||
continue
|
||||
}
|
||||
if reconcileErr := h.reconcileNftablesNodeByRequest(entryNodeIDs[0]); reconcileErr != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, tunnelID, tunnelName, reconcileErr)
|
||||
continue
|
||||
}
|
||||
success++
|
||||
continue
|
||||
}
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
||||
fail++
|
||||
failures = appendBatchFailure(failures, tunnelID, tunnelName, err)
|
||||
continue
|
||||
}
|
||||
success++
|
||||
}
|
||||
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
func (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -3333,6 +3415,7 @@ func (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {
|
||||
}
|
||||
|
||||
type tunnelRuntimeNode struct {
|
||||
RelayAuth *relaysecurity.Credentials
|
||||
NodeID int64
|
||||
Protocol string
|
||||
Strategy string
|
||||
@@ -3343,14 +3426,15 @@ type tunnelRuntimeNode struct {
|
||||
}
|
||||
|
||||
type tunnelCreateState struct {
|
||||
TunnelID int64
|
||||
Type int
|
||||
IPPreference string // "" = auto, "v4" = prefer IPv4, "v6" = prefer IPv6
|
||||
InNodes []tunnelRuntimeNode
|
||||
ChainHops [][]tunnelRuntimeNode
|
||||
OutNodes []tunnelRuntimeNode
|
||||
Nodes map[int64]*nodeRecord
|
||||
NodeIDList []int64
|
||||
RelayAuthMode string
|
||||
TunnelID int64
|
||||
Type int
|
||||
IPPreference string // "" = auto, "v4" = prefer IPv4, "v6" = prefer IPv6
|
||||
InNodes []tunnelRuntimeNode
|
||||
ChainHops [][]tunnelRuntimeNode
|
||||
OutNodes []tunnelRuntimeNode
|
||||
Nodes map[int64]*nodeRecord
|
||||
NodeIDList []int64
|
||||
}
|
||||
|
||||
func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
|
||||
@@ -3524,6 +3608,21 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range tunnelStateRuntimeRows(state) {
|
||||
if row.Port <= 0 {
|
||||
continue
|
||||
}
|
||||
ports, err := h.repo.PendingTunnelCleanupPortsTx(tx, row.NodeID, excludeTunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, port := range ports {
|
||||
if port == row.Port {
|
||||
return nil, errors.New("节点端口仍在等待旧运行时清理")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -3717,6 +3816,10 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
if h == nil || state == nil {
|
||||
return bindings, releaseRefs, nil
|
||||
}
|
||||
if err := h.preflightFederationRelayAuth(state, localDomain); err != nil {
|
||||
return bindings, releaseRefs, err
|
||||
}
|
||||
|
||||
fc := client.NewFederationClient()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
@@ -3752,6 +3855,9 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
|
||||
})
|
||||
|
||||
if outNode.RelayAuth != nil && reserveRes.RelayAuthVersion != relaysecurity.Version {
|
||||
return bindings, releaseRefs, errors.New("remote reservation does not support required relay authentication")
|
||||
}
|
||||
state.OutNodes[outIdx].Port = reserveRes.AllocatedPort
|
||||
outNode = state.OutNodes[outIdx]
|
||||
|
||||
@@ -3759,6 +3865,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
ReservationID: reserveRes.ReservationID,
|
||||
ResourceKey: resourceKey,
|
||||
Role: "exit",
|
||||
RelayAuth: outNode.RelayAuth,
|
||||
Protocol: defaultString(outNode.Protocol, "tls"),
|
||||
Strategy: defaultString(outNode.Strategy, "round"),
|
||||
}
|
||||
@@ -3820,6 +3927,9 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
|
||||
})
|
||||
|
||||
if chainNode.RelayAuth != nil && reserveRes.RelayAuthVersion != relaysecurity.Version {
|
||||
return bindings, releaseRefs, errors.New("remote reservation does not support required relay authentication")
|
||||
}
|
||||
state.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort
|
||||
chainNode = state.ChainHops[hopIdx][nodeIdx]
|
||||
|
||||
@@ -3843,9 +3953,10 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
return bindings, releaseRefs, errors.New("节点端口不能为空")
|
||||
}
|
||||
applyTargets = append(applyTargets, client.RuntimeTarget{
|
||||
Host: host,
|
||||
Port: target.Port,
|
||||
Protocol: defaultString(target.Protocol, "tls"),
|
||||
Host: host,
|
||||
RelayAuth: target.RelayAuth,
|
||||
Port: target.Port,
|
||||
Protocol: defaultString(target.Protocol, "tls"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3853,6 +3964,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
ReservationID: reserveRes.ReservationID,
|
||||
ResourceKey: resourceKey,
|
||||
Role: "middle",
|
||||
RelayAuth: chainNode.RelayAuth,
|
||||
Protocol: defaultString(chainNode.Protocol, "tls"),
|
||||
Strategy: runtimeStrategyForTargets(chainNode, nextTargets),
|
||||
Targets: applyTargets,
|
||||
@@ -4017,6 +4129,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
||||
return createdChains, createdServices, nil
|
||||
}
|
||||
|
||||
var pending []error
|
||||
for _, inNode := range state.InNodes {
|
||||
targets := state.OutNodes
|
||||
if len(state.ChainHops) > 0 {
|
||||
@@ -4030,6 +4143,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
||||
}
|
||||
if err := h.applyTunnelChainOnNode(inNode.NodeID, chainData, upsert); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
pending = append(pending, err)
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
||||
@@ -4046,7 +4160,11 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
||||
nextTargets = h.orderBestExitTargets(state.TunnelID, chainNode.NodeID, nextTargets)
|
||||
}
|
||||
node := state.Nodes[chainNode.NodeID]
|
||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||
if node != nil && node.IsRemote == 1 {
|
||||
continue
|
||||
}
|
||||
if node != nil && node.Status != 1 {
|
||||
pending = append(pending, fmt.Errorf("节点 %s 不在线", nodeDisplayName(node)))
|
||||
continue
|
||||
}
|
||||
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
|
||||
@@ -4055,6 +4173,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
||||
}
|
||||
if err := h.applyTunnelChainOnNode(chainNode.NodeID, chainData, upsert); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
pending = append(pending, err)
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
@@ -4064,6 +4183,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
||||
if err := h.addTunnelServiceOnNodeWithMode(chainNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
pending = append(pending, err)
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
@@ -4074,12 +4194,17 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
||||
|
||||
for _, outNode := range state.OutNodes {
|
||||
node := state.Nodes[outNode.NodeID]
|
||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||
if node != nil && node.IsRemote == 1 {
|
||||
continue
|
||||
}
|
||||
if node != nil && node.Status != 1 {
|
||||
pending = append(pending, fmt.Errorf("节点 %s 不在线", nodeDisplayName(node)))
|
||||
continue
|
||||
}
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
||||
if err := h.addTunnelServiceOnNodeWithMode(outNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
pending = append(pending, err)
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||
@@ -4087,6 +4212,9 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
||||
createdServices = append(createdServices, outNode.NodeID)
|
||||
}
|
||||
|
||||
if len(pending) > 0 {
|
||||
return createdChains, createdServices, &tunnelRuntimePendingError{cause: errors.Join(pending...)}
|
||||
}
|
||||
return createdChains, createdServices, nil
|
||||
}
|
||||
|
||||
@@ -4107,7 +4235,15 @@ func (h *Handler) applyBestExitChainOrder(tunnelID, ownerNodeID int64, outNodes
|
||||
log.Printf("best_exit: invalid chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
|
||||
return fmt.Errorf("invalid best exit chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
|
||||
}
|
||||
unlock := h.lockTunnelLifecycle(tunnelID)
|
||||
defer unlock()
|
||||
if err := h.validateBestExitSnapshot(tunnelID, ownerNodeID, outNodes, ipPreference); err != nil {
|
||||
return err
|
||||
}
|
||||
targets := chainRecordsToRuntimeTargets(outNodes)
|
||||
if err := h.resolveBestExitRelayAuth(tunnelID, targets); err != nil {
|
||||
return err
|
||||
}
|
||||
orderedIDs := make([]int64, 0, len(scores))
|
||||
for _, score := range scores {
|
||||
if score.ExitNodeID > 0 {
|
||||
@@ -4188,9 +4324,10 @@ func (h *Handler) applyRemoteBestExitChainOrder(tunnelID, ownerNodeID int64, own
|
||||
return errors.New("节点端口不能为空")
|
||||
}
|
||||
applyTargets = append(applyTargets, client.RuntimeTarget{
|
||||
Host: host,
|
||||
Port: target.Port,
|
||||
Protocol: defaultString(target.Protocol, "tls"),
|
||||
Host: host,
|
||||
RelayAuth: target.RelayAuth,
|
||||
Port: target.Port,
|
||||
Protocol: defaultString(target.Protocol, "tls"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4212,8 +4349,30 @@ func (h *Handler) applyRemoteBestExitChainOrder(tunnelID, ownerNodeID int64, own
|
||||
}
|
||||
}
|
||||
|
||||
var credentials *relaysecurity.Credentials
|
||||
tunnel, loadErr := h.repo.GetTunnelRecord(tunnelID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if tunnel == nil {
|
||||
return errors.New("tunnel not found")
|
||||
}
|
||||
if tunnel.RelayAuthMode == relaysecurity.Required {
|
||||
credentials, err = h.repo.RelayEndpointCredentials(nil, tunnelID, ownerNodeID, 2, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, e := client.NewFederationClient().Connect(remoteURL, remoteToken, h.federationLocalDomain())
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if info.RelayAuthVersion != relaysecurity.Version {
|
||||
return errors.New("remote does not support required relay authentication")
|
||||
}
|
||||
}
|
||||
_, err = client.NewFederationClient().ApplyRole(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeApplyRoleRequest{
|
||||
ResourceKey: strings.TrimSpace(binding.ResourceKey),
|
||||
RelayAuth: credentials,
|
||||
Role: "middle",
|
||||
Protocol: defaultString(ownerRuntimeNode.Protocol, "tls"),
|
||||
Strategy: runtimeStrategyForTargets(ownerRuntimeNode, targets),
|
||||
@@ -4263,19 +4422,20 @@ func (h *Handler) addTunnelServiceOnNodeWithMode(nodeID, tunnelID int64, service
|
||||
if h == nil {
|
||||
return errors.New("invalid tunnel service context")
|
||||
}
|
||||
if upsert {
|
||||
// Update failure must not trigger deletion of an existing listener.
|
||||
_, err := h.sendNodeCommand(nodeID, "UpdateService", serviceData, true, false)
|
||||
return err
|
||||
}
|
||||
serviceName := fmt.Sprintf("%d_tls", tunnelID)
|
||||
if len(serviceData) > 0 {
|
||||
if name, ok := serviceData[0]["name"].(string); ok && strings.TrimSpace(name) != "" {
|
||||
serviceName = strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
command := "AddService"
|
||||
if upsert {
|
||||
command = "UpdateService"
|
||||
}
|
||||
return retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
_, err := h.sendNodeCommand(nodeID, command, serviceData, true, false)
|
||||
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
|
||||
return err
|
||||
},
|
||||
func() error {
|
||||
@@ -4366,6 +4526,12 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if target.RelayAuth != nil {
|
||||
if err := target.RelayAuth.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connector["auth"] = target.RelayAuth.Config()
|
||||
}
|
||||
connectorMetadata := map[string]interface{}{}
|
||||
if isTCPTunnelProtocol(protocol) {
|
||||
connectorMetadata["nodelay"] = true
|
||||
@@ -4438,6 +4604,9 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
||||
handlerCfg := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if chainNode.RelayAuth != nil {
|
||||
handlerCfg["auth"] = chainNode.RelayAuth.Config()
|
||||
}
|
||||
handlerMetadata := map[string]interface{}{}
|
||||
if isTCPTunnelProtocol(protocol) {
|
||||
handlerMetadata["nodelay"] = true
|
||||
@@ -4466,6 +4635,8 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
||||
"listener": buildTunnelListenerConfig(protocol),
|
||||
}
|
||||
if chainNode.ChainType == 2 {
|
||||
handlerMetadata["chain.strict"] = true
|
||||
handlerCfg["metadata"] = handlerMetadata
|
||||
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", tunnelID)
|
||||
}
|
||||
if chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != "" {
|
||||
@@ -4765,6 +4936,9 @@ func (h *Handler) deleteTunnelByID(id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.retryTunnelRuntimeCleanupLocked(id); err != nil {
|
||||
return &tunnelDeletionCleanupPendingError{cause: err}
|
||||
}
|
||||
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -5456,6 +5630,15 @@ func (h *Handler) validateForwardPortAvailability(node *nodeRecord, port int, cu
|
||||
if h == nil || h.repo == nil || node == nil || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
pendingPorts, err := h.repo.PendingTunnelCleanupPortsTx(nil, node.ID, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, reserved := range pendingPorts {
|
||||
if reserved == port {
|
||||
return fmt.Errorf("节点 %s 端口 %d 仍在等待旧运行时清理", node.Name, port)
|
||||
}
|
||||
}
|
||||
occupied, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, currentForwardID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -29,12 +29,19 @@ func (h *Handler) applyPeerShareRoleRuntime(runtime *repo.PeerShareRuntime) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
credentials, err := decodeRuntimeRelayAuth(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var targets []federationRuntimeTarget
|
||||
if strings.TrimSpace(runtime.Target) != "" {
|
||||
if err := json.Unmarshal([]byte(runtime.Target), &targets); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateFederationRelayAuth(runtime.RelayAuth, credentials, targets); err != nil {
|
||||
return err
|
||||
}
|
||||
var chain map[string]interface{}
|
||||
if runtime.Role == "middle" {
|
||||
chain, err = buildFederationMiddleChainConfig(runtime.ChainName, runtime.ID, runtime.Protocol, runtime.Strategy, targets, node.InterfaceName)
|
||||
@@ -42,7 +49,7 @@ func (h *Handler) applyPeerShareRoleRuntime(runtime *repo.PeerShareRuntime) erro
|
||||
return err
|
||||
}
|
||||
}
|
||||
service := buildFederationServiceConfig(runtime.ServiceName, fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port), runtime.Protocol, runtime.Role, runtime.ChainName, len(targets), node.InterfaceName)
|
||||
service := buildFederationServiceConfig(runtime.ServiceName, fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port), runtime.Protocol, runtime.Role, runtime.ChainName, len(targets), node.InterfaceName, credentials)
|
||||
runtime.Applied = 0
|
||||
runtime.UpdatedTime = time.Now().UnixMilli()
|
||||
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
|
||||
@@ -145,18 +145,20 @@ func TestPeerShareReleaseOfflineKeepsPortAndReconnectDeletes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPeerShareApplyPersistsBeforeCommand(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "exit")
|
||||
callback := "test:fail-role-desired-write"
|
||||
if err := agent.h.repo.DB().Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "peer_share_runtime" {
|
||||
tx.AddError(errors.New("desired-state write failed"))
|
||||
var failDesiredWrite atomic.Bool
|
||||
agent := newCleanupAgent(t, func(r *repo.Repository) {
|
||||
if err := r.DB().Callback().Update().Before("gorm:update").Register("test:fail-role-desired-write", func(tx *gorm.DB) {
|
||||
if failDesiredWrite.Load() && tx.Statement.Table == "peer_share_runtime" {
|
||||
tx.AddError(errors.New("desired-state write failed"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// This database is test-local; keep the callback registered through the
|
||||
// websocket teardown so callback mutation cannot race node status writes.
|
||||
})
|
||||
runtime := roleRuntimeFixture(t, agent, "exit")
|
||||
failDesiredWrite.Store(true)
|
||||
// Only the atomic flag changes while the WebSocket is running; GORM's
|
||||
// callback registry stays immutable through node-status teardown writes.
|
||||
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"exit"}`, false); code == 0 {
|
||||
t.Fatal("failed ownership persistence reported success")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/relaysecurity"
|
||||
"go-backend/internal/store/repo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (h *Handler) resolveTunnelRelayAuth(tx *gorm.DB, state *tunnelCreateState, create bool) error {
|
||||
if state.Type != 2 || state.RelayAuthMode == relaysecurity.Legacy {
|
||||
return nil
|
||||
}
|
||||
if state.RelayAuthMode != relaysecurity.Required {
|
||||
return fmt.Errorf("unsupported relay authentication mode")
|
||||
}
|
||||
for i := range state.OutNodes {
|
||||
c, err := h.repo.RelayEndpointCredentials(tx, state.TunnelID, state.OutNodes[i].NodeID, 3, create)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.OutNodes[i].RelayAuth = c
|
||||
}
|
||||
for hop := range state.ChainHops {
|
||||
for i := range state.ChainHops[hop] {
|
||||
c, err := h.repo.RelayEndpointCredentials(tx, state.TunnelID, state.ChainHops[hop][i].NodeID, 2, create)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.ChainHops[hop][i].RelayAuth = c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveBestExitRelayAuth(tunnelID int64, targets []tunnelRuntimeNode) error {
|
||||
tunnel, err := h.repo.GetTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tunnel == nil {
|
||||
return fmt.Errorf("tunnel not found")
|
||||
}
|
||||
return h.resolveTunnelRelayAuth(nil, &tunnelCreateState{TunnelID: tunnelID, Type: tunnel.Type, RelayAuthMode: tunnel.RelayAuthMode, OutNodes: targets}, false)
|
||||
}
|
||||
|
||||
func relayAuthVersion(c *relaysecurity.Credentials) int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.Version
|
||||
}
|
||||
func decodePersistedRelayAuth(raw string) (*relaysecurity.Credentials, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var c relaysecurity.Credentials
|
||||
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||
return nil, fmt.Errorf("invalid persisted relay authentication")
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
func validateFederationRelayAuth(persisted string, c *relaysecurity.Credentials, targets []federationRuntimeTarget) error {
|
||||
old, err := decodePersistedRelayAuth(persisted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if old != nil && (c == nil || *old != *c) {
|
||||
return fmt.Errorf("authenticated relay cannot be downgraded or rotated by reapply")
|
||||
}
|
||||
if c != nil {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, target := range targets {
|
||||
if err := target.RelayAuth.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, target := range targets {
|
||||
if target.RelayAuth != nil {
|
||||
if err := target.RelayAuth.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) preflightFederationRelayAuth(state *tunnelCreateState, domain string) error {
|
||||
if state.RelayAuthMode != relaysecurity.Required || state.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
for _, node := range state.Nodes {
|
||||
if node == nil || node.IsRemote != 1 {
|
||||
continue
|
||||
}
|
||||
info, err := client.NewFederationClient().Connect(node.RemoteURL, node.RemoteToken, domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relay authentication capability check failed for remote node %d", node.ID)
|
||||
}
|
||||
if info.RelayAuthVersion != relaysecurity.Version {
|
||||
return fmt.Errorf("remote node %d does not support required relay authentication", node.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeRuntimeRelayAuth(runtime *repo.PeerShareRuntime) (*relaysecurity.Credentials, error) {
|
||||
c, err := decodePersistedRelayAuth(runtime.RelayAuth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if runtime.RelayAuthVersion != relayAuthVersion(c) {
|
||||
return nil, fmt.Errorf("persisted relay authentication version or credentials unavailable")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/panelupgrade"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -75,6 +76,7 @@ type systemUpgradeCheckData struct {
|
||||
}
|
||||
|
||||
type systemUpgradeRunData struct {
|
||||
JobID string `json:"jobId"`
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
ComposeAsset string `json:"composeAsset"`
|
||||
@@ -118,17 +120,7 @@ func validateBackendContainerName(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUpgradeVersion(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("upgrade version is empty")
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("unsafe upgrade version: contains control character")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validateUpgradeVersion(value string) error { return panelupgrade.ValidateVersion(value) }
|
||||
|
||||
func (e *systemUpgradeExecutor) composePath() string {
|
||||
return filepath.Join(e.deployDir, "docker-compose.yml")
|
||||
@@ -136,6 +128,8 @@ func (e *systemUpgradeExecutor) composePath() string {
|
||||
func (e *systemUpgradeExecutor) envPath() string { return filepath.Join(e.deployDir, ".env") }
|
||||
|
||||
func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCapabilityData {
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
reasons := make([]string, 0)
|
||||
if !filepath.IsAbs(e.deployDir) {
|
||||
reasons = append(reasons, "部署目录必须是绝对路径")
|
||||
@@ -143,8 +137,8 @@ func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCap
|
||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "docker", "--version").CombinedOutput(); err != nil {
|
||||
reasons = append(reasons, fmt.Sprintf("docker CLI不可用: %v: %s", err, strings.TrimSpace(string(out))))
|
||||
if err := exec.CommandContext(ctx, "docker", "--version").Run(); err != nil {
|
||||
reasons = append(reasons, "docker CLI不可用")
|
||||
}
|
||||
if info, err := os.Stat(dockerSocketPath); err != nil {
|
||||
reasons = append(reasons, "docker socket不可用: "+err.Error())
|
||||
@@ -161,10 +155,10 @@ func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCap
|
||||
} else if info.IsDir() {
|
||||
reasons = append(reasons, "部署.env不是文件")
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "docker", "compose", "version").CombinedOutput(); err != nil {
|
||||
reasons = append(reasons, fmt.Sprintf("docker compose不可用: %v: %s", err, strings.TrimSpace(string(out))))
|
||||
if err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil {
|
||||
reasons = append(reasons, "docker compose不可用")
|
||||
}
|
||||
if _, err := e.currentBackendImage(ctx); err != nil {
|
||||
if _, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Identity(ctx); err != nil {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
|
||||
@@ -183,144 +177,6 @@ func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
|
||||
return "docker-compose-v4.yml"
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) helperScript() string {
|
||||
return `set -eu
|
||||
LOGFILE="$PANEL_DEPLOY_DIR/upgrade.log"
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOGFILE"; }
|
||||
|
||||
cd "$PANEL_DEPLOY_DIR"
|
||||
echo "" > "$LOGFILE"
|
||||
log "开始面板升级"
|
||||
log "工作目录: $(pwd)"
|
||||
|
||||
if [ ! -f docker-compose.yml ]; then
|
||||
log "错误: docker-compose.yml 不存在"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f .env ]; then
|
||||
log "错误: .env 不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "拉取新镜像..."
|
||||
if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then
|
||||
log "错误: 拉取镜像失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "等待旧容器释放资源..."
|
||||
sleep 3
|
||||
|
||||
log "重启服务(force-recreate)..."
|
||||
if ! docker compose up -d --force-recreate --remove-orphans backend frontend >> "$LOGFILE" 2>&1; then
|
||||
log "错误: 重启服务失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "升级完成"
|
||||
`
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) ([]string, error) {
|
||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{
|
||||
"run", "-d", "--rm", "--name", helperName,
|
||||
"--volumes-from", e.backendContainer,
|
||||
"-v", dockerSocketPath + ":" + dockerSocketPath,
|
||||
"-e", panelDeployDirEnv + "=" + e.deployDir,
|
||||
"--entrypoint", "/bin/sh", imageID,
|
||||
"-c", e.helperScript(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error {
|
||||
if err := validateUpgradeVersion(version); err != nil {
|
||||
return err
|
||||
}
|
||||
mode, err := fileModeOrDefault(envPath, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
replaced := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "FLUX_VERSION=") {
|
||||
lines[i] = "FLUX_VERSION=" + version
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
trimmed := strings.TrimRight(strings.Join(lines, "\n"), "\n")
|
||||
if trimmed == "" {
|
||||
trimmed = "FLUX_VERSION=" + version
|
||||
} else {
|
||||
trimmed += "\nFLUX_VERSION=" + version
|
||||
}
|
||||
return writeFileWithMode(envPath, []byte(trimmed+"\n"), mode)
|
||||
}
|
||||
content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n"
|
||||
return writeFileWithMode(envPath, []byte(content), mode)
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) backupFile(path string) (string, error) {
|
||||
mode, err := fileModeOrDefault(path, 0o600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
backupPath := path + ".upgrade.bak"
|
||||
if err := writeFileWithMode(backupPath, data, mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) restoreBackup(path string) error {
|
||||
backupPath := path + ".upgrade.bak"
|
||||
mode, err := fileModeOrDefault(backupPath, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileWithMode(path, data, mode)
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) restoreUpgradeBackups(paths ...string) error {
|
||||
var errs []string
|
||||
for _, path := range paths {
|
||||
if err := e.restoreBackup(path); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error {
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return fmt.Errorf("compose asset is empty")
|
||||
}
|
||||
mode, err := fileModeOrDefault(path, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileWithMode(path, data, mode)
|
||||
}
|
||||
|
||||
func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string {
|
||||
enabled, proxyURL := h.getGithubProxyConfig()
|
||||
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
|
||||
@@ -383,55 +239,6 @@ func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (st
|
||||
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
||||
}
|
||||
|
||||
func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fallback, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return info.Mode().Perm(), nil
|
||||
}
|
||||
|
||||
func writeFileWithMode(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.WriteFile(path, data, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) {
|
||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect backend image failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
imageID := strings.TrimSpace(string(out))
|
||||
if imageID == "" {
|
||||
return "", fmt.Errorf("backend image id is empty")
|
||||
}
|
||||
return imageID, nil
|
||||
}
|
||||
|
||||
func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helperName string) (string, error) {
|
||||
args, err := e.buildHelperRunArgs(imageID, helperName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("start helper failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
containerID := strings.TrimSpace(string(out))
|
||||
if containerID == "" {
|
||||
containerID = helperName
|
||||
}
|
||||
return containerID, nil
|
||||
}
|
||||
|
||||
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
|
||||
url := h.buildSystemUpgradeDownloadURL(version, filename)
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
@@ -485,16 +292,26 @@ func releasesForChannel(releases []githubRelease, channel string) []systemUpgrad
|
||||
|
||||
func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error {
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4097))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) > 4096 {
|
||||
return fmt.Errorf("request too large")
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return nil
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(req)
|
||||
if err := decoder.Decode(req); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if decoder.Decode(&extra) != io.EOF {
|
||||
return fmt.Errorf("unexpected trailing JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData {
|
||||
@@ -595,63 +412,63 @@ func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
imageID, err := exec.currentBackendImage(r.Context())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
|
||||
if err := validateUpgradeVersion(version); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("版本号格式无效"))
|
||||
return
|
||||
}
|
||||
|
||||
composePath := exec.composePath()
|
||||
envPath := exec.envPath()
|
||||
composeData, err := os.ReadFile(composePath)
|
||||
composeData, err := os.ReadFile(exec.composePath())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "读取compose失败: "+err.Error()))
|
||||
response.WriteJSON(w, response.ErrDefault("读取部署配置失败"))
|
||||
return
|
||||
}
|
||||
composeAsset := exec.selectComposeAsset(composeData)
|
||||
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
response.WriteJSON(w, response.ErrDefault("下载目标 Compose 失败"))
|
||||
return
|
||||
}
|
||||
if _, err := exec.backupFile(composePath); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "备份compose失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if _, err := exec.backupFile(envPath); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "备份.env失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := exec.replaceCompose(composePath, newCompose); err != nil {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, "替换compose失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := exec.updateEnvVersion(envPath, version); err != nil {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, "更新版本配置失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix())
|
||||
helperContainer, err := exec.startHelper(r.Context(), imageID, helperName)
|
||||
// A detached helper must not be canceled when the browser disconnects.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
job, imageID, err := (panelupgrade.Manager{Dir: exec.deployDir, Backend: exec.backendContainer}).Start(ctx, version, newCompose)
|
||||
if err != nil {
|
||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(systemUpgradeRunData{
|
||||
Version: version,
|
||||
Channel: channel,
|
||||
ComposeAsset: composeAsset,
|
||||
HelperContainer: helperContainer,
|
||||
BackendImageID: imageID,
|
||||
Message: systemUpgradeMessage,
|
||||
}))
|
||||
response.WriteJSON(w, response.OK(systemUpgradeRunData{JobID: job.ID, Version: version, Channel: channel, ComposeAsset: composeAsset, HelperContainer: job.HelperContainer, BackendImageID: imageID, Message: systemUpgradeMessage}))
|
||||
}
|
||||
|
||||
func (h *Handler) systemUpgradeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
JobID string `json:"jobId"`
|
||||
}
|
||||
defer r.Body.Close()
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 4097))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil && err != io.EOF {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
var extra any
|
||||
if decoder.Decode(&extra) != io.EOF {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
e := newSystemUpgradeExecutor()
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
job, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Status(ctx, req.JobID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("无法读取升级任务状态"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
Job *panelupgrade.Job `json:"job"`
|
||||
}{currentPanelVersion(), job}))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -128,149 +127,12 @@ func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionReplacesExistingValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
want := "FLUX_VERSION=2.1.9\nJWT_SECRET=test\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionAppendsMissingValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
want := "JWT_SECRET=test\nFLUX_VERSION=2.1.9\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionRejectsUnsafeValue(t *testing.T) {
|
||||
for _, version := range []string{"", "2.1.9\nJWT_SECRET=bad", "2.1.9\rbad", "2.1.9\x00bad", "2.1.9\x1fbad"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
original := []byte("JWT_SECRET=test\n")
|
||||
if err := os.WriteFile(envPath, original, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, version); err == nil {
|
||||
t.Fatal("expected unsafe version to fail validation")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != string(original) {
|
||||
t.Fatalf("env content changed to %q, want %q", string(data), string(original))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionAcceptsVersionLabels(t *testing.T) {
|
||||
for _, version := range []string{"2.1.9", "2.1.9-beta14", "v-test"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, version); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEnvVersionPreservesFileMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(envPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("env mode = %o, want 0600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) {
|
||||
if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil {
|
||||
t.Fatal("expected unsafe container name to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHelperRunArgsUsesDetachedContainer(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
||||
args, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper")
|
||||
if err != nil {
|
||||
t.Fatalf("buildHelperRunArgs() error = %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"run", "-d", "--rm", "--name", "flvx-upgrade-helper",
|
||||
"--volumes-from", "flux-panel-backend",
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
|
||||
"--entrypoint", "/bin/sh", "sha256:abc",
|
||||
"-c", exec.helperScript(),
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("buildHelperRunArgs() = %#v, want %#v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHelperRunArgsRejectsUnsafeBackendContainer(t *testing.T) {
|
||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend;rm -rf /"}
|
||||
if _, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper"); err == nil {
|
||||
t.Fatal("expected unsafe backend container name to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemVersionRejectsWrongMethod(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil)
|
||||
@@ -350,64 +212,6 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeBackupUsesStablePathAndRestoreRestoresOriginal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "docker-compose.yml")
|
||||
if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
backupPath, err := exec.backupFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("backupFile() error = %v", err)
|
||||
}
|
||||
if backupPath != path+".upgrade.bak" {
|
||||
t.Fatalf("backup path = %q, want %q", backupPath, path+".upgrade.bak")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("mutated"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if err := exec.restoreBackup(path); err != nil {
|
||||
t.Fatalf("restoreBackup() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "original" {
|
||||
t.Fatalf("restored content = %q, want original", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackupPreservesOriginalFileMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(path, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
||||
if _, err := exec.backupFile(path); err != nil {
|
||||
t.Fatalf("backupFile() error = %v", err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
if err := exec.restoreBackup(path); err != nil {
|
||||
t.Fatalf("restoreBackup() error = %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("restored mode = %o, want 0600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSystemUpgradeRequestRejectsTruncatedJSON(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(`{"channel":"stable"`))
|
||||
var payload systemUpgradeRequest
|
||||
|
||||
@@ -371,6 +371,8 @@ func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string {
|
||||
}
|
||||
|
||||
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
|
||||
unlock := h.lockTunnelLifecycle(tunnelID, targetTunnelID)
|
||||
defer unlock()
|
||||
preview, err := h.buildTunnelDeletePreview(tunnelID)
|
||||
if err != nil {
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
@@ -431,9 +433,14 @@ func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int6
|
||||
}
|
||||
|
||||
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
|
||||
h.rollbackTunnelForwardMigrationPlans(plans)
|
||||
_ = h.redeployTunnelAndForwards(tunnelID)
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
var pending *tunnelDeletionCleanupPendingError
|
||||
if errors.As(err, &pending) {
|
||||
warnings = append(warnings, err.Error())
|
||||
} else {
|
||||
h.rollbackTunnelForwardMigrationPlans(plans)
|
||||
_ = h.redeployTunnelAndForwardsLocked(tunnelID)
|
||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result.MigratedCount = len(plans)
|
||||
@@ -639,8 +646,9 @@ func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigra
|
||||
}
|
||||
|
||||
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
|
||||
h.cleanupTunnelRuntime(tunnelID)
|
||||
h.cleanupFederationRuntime(tunnelID)
|
||||
if err := h.cleanupFederationRuntime(tunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.deleteTunnelByID(tunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"go-backend/internal/http/response"
|
||||
"gorm.io/gorm"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type tunnelLifecycleLock struct {
|
||||
mu sync.Mutex
|
||||
users int
|
||||
}
|
||||
|
||||
// lockTunnelLifecycle serializes snapshots, persistence and node acknowledgements.
|
||||
// Multiple tunnels (forward migration) are locked in ID order to avoid deadlocks.
|
||||
// Callers must not acquire these locks recursively or hold a database transaction.
|
||||
func (h *Handler) lockTunnelLifecycle(ids ...int64) func() {
|
||||
unique := make(map[int64]bool, len(ids))
|
||||
ordered := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 && !unique[id] {
|
||||
unique[id] = true
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] })
|
||||
locks := make([]*tunnelLifecycleLock, 0, len(ordered))
|
||||
for _, id := range ordered {
|
||||
h.tunnelLifecycleMu.Lock()
|
||||
if h.tunnelLifecycleLocks == nil {
|
||||
h.tunnelLifecycleLocks = make(map[int64]*tunnelLifecycleLock)
|
||||
}
|
||||
lock := h.tunnelLifecycleLocks[id]
|
||||
if lock == nil {
|
||||
lock = &tunnelLifecycleLock{}
|
||||
h.tunnelLifecycleLocks[id] = lock
|
||||
}
|
||||
lock.users++
|
||||
h.tunnelLifecycleMu.Unlock()
|
||||
lock.mu.Lock()
|
||||
locks = append(locks, lock)
|
||||
}
|
||||
return func() {
|
||||
for i := len(locks) - 1; i >= 0; i-- {
|
||||
h.tunnelLifecycleMu.Lock()
|
||||
locks[i].mu.Unlock()
|
||||
locks[i].users--
|
||||
if locks[i].users == 0 {
|
||||
delete(h.tunnelLifecycleLocks, ordered[i])
|
||||
}
|
||||
h.tunnelLifecycleMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Probes run without the lifecycle lock. Recheck their topology under the lock
|
||||
// so a late result cannot restore a removed middle hop or deleted tunnel.
|
||||
func (h *Handler) validateBestExitSnapshot(tunnelID, ownerNodeID int64, exits []chainNodeRecord, ipPreference string) error {
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tunnel.Type != 2 || h.repo.GetTunnelIPPreference(tunnelID) != ipPreference {
|
||||
return errors.New("best exit topology changed; retry next probe")
|
||||
}
|
||||
rows, err := h.listChainNodesForTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ins, hops, currentExits := splitChainNodeGroups(rows)
|
||||
if !slices.Equal(exits, currentExits) {
|
||||
return errors.New("best exit targets changed; retry next probe")
|
||||
}
|
||||
for _, owner := range bestExitChainOwners(ins, hops) {
|
||||
if owner.NodeID == ownerNodeID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("best exit owner changed; retry next probe")
|
||||
}
|
||||
|
||||
// A newly allocated ID is not visible yet. Claim it without waiting while the
|
||||
// insert transaction is open: an overlapping request for that ID must not make
|
||||
// creation wait on a lifecycle lock whose owner is waiting for this transaction.
|
||||
func (h *Handler) tryLockNewTunnelLifecycle(id int64) (func(), bool) {
|
||||
h.tunnelLifecycleMu.Lock()
|
||||
defer h.tunnelLifecycleMu.Unlock()
|
||||
if h.tunnelLifecycleLocks == nil {
|
||||
h.tunnelLifecycleLocks = make(map[int64]*tunnelLifecycleLock)
|
||||
}
|
||||
if h.tunnelLifecycleLocks[id] != nil {
|
||||
return nil, false
|
||||
}
|
||||
lock := &tunnelLifecycleLock{users: 1}
|
||||
lock.mu.Lock()
|
||||
h.tunnelLifecycleLocks[id] = lock
|
||||
return func() {
|
||||
h.tunnelLifecycleMu.Lock()
|
||||
defer h.tunnelLifecycleMu.Unlock()
|
||||
lock.mu.Unlock()
|
||||
lock.users--
|
||||
if lock.users == 0 {
|
||||
delete(h.tunnelLifecycleLocks, id)
|
||||
}
|
||||
}, true
|
||||
}
|
||||
|
||||
// Pending is not successful application. HTTP create/update retain their
|
||||
// accepted-config contract, while redeploy returns this error to its batch result.
|
||||
type tunnelRuntimePendingError struct{ cause error }
|
||||
|
||||
func (e *tunnelRuntimePendingError) Error() string { return "运行时待同步: " + e.cause.Error() }
|
||||
func isTunnelRuntimePending(err error) bool {
|
||||
var pending *tunnelRuntimePendingError
|
||||
return errors.As(err, &pending)
|
||||
}
|
||||
func tunnelRuntimePendingResponse(tunnelID int64, err error) response.R {
|
||||
message := "配置已保存,部分节点运行时待同步:" + err.Error()
|
||||
out := response.OK(map[string]interface{}{"runtimeStatus": "pending", "tunnelId": tunnelID, "message": message})
|
||||
out.Msg = message
|
||||
return out
|
||||
}
|
||||
|
||||
// Reserve locally allocated ports with the initial desired state, before any
|
||||
// remote acknowledgment. Remote ports remain unallocated until their provider
|
||||
// returns; the second transaction replaces these rows with the final bindings.
|
||||
func (h *Handler) persistPreparedTunnelChainsTx(tx *gorm.DB, state *tunnelCreateState) error {
|
||||
save := func(node tunnelRuntimeNode, kind string, inx int) error {
|
||||
return h.repo.CreateChainTunnelTx(tx, state.TunnelID, kind, node.NodeID, sql.NullInt64{Int64: int64(node.Port), Valid: node.Port > 0}, defaultString(node.Strategy, "round"), inx, defaultString(node.Protocol, "tls"), node.ConnectIP)
|
||||
}
|
||||
for i, node := range state.InNodes {
|
||||
if err := save(node, "1", i+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, node := range state.OutNodes {
|
||||
if err := save(node, "3", i+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, hop := range state.ChainHops {
|
||||
for _, node := range hop {
|
||||
if err := save(node, "2", i+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
type redeployProgress struct {
|
||||
Total int `json:"total"`
|
||||
Completed int `json:"completed"`
|
||||
Success int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type redeployItemResult struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Success bool `json:"success"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// tunnelBatchRedeployStream reports completed tunnels, not estimated runtime.
|
||||
// A disconnected caller may have an in-flight mutation whose result it did not
|
||||
// receive. Finish that mutation, but never start another or automatically retry.
|
||||
func (h *Handler) tunnelBatchRedeployStream(w http.ResponseWriter, r *http.Request) {
|
||||
ids := idsFromBody(r, w)
|
||||
if ids == nil {
|
||||
return
|
||||
}
|
||||
// idsFromBody sorts IDs; ignore duplicate selections so each tunnel runs once.
|
||||
unique := ids[:0]
|
||||
for _, id := range ids {
|
||||
if len(unique) == 0 || unique[len(unique)-1] != id {
|
||||
unique = append(unique, id)
|
||||
}
|
||||
}
|
||||
ids = unique
|
||||
if len(ids) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("ids不能为空"))
|
||||
return
|
||||
}
|
||||
_, err := prepareDiagnosisStreamResponse(w)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
encoder := json.NewEncoder(w)
|
||||
controller := http.NewResponseController(w)
|
||||
emit := func(kind string, data interface{}) bool {
|
||||
if r.Context().Err() != nil {
|
||||
return false
|
||||
}
|
||||
// The server's WriteTimeout is an absolute request deadline. Renew only
|
||||
// this stream's bounded write window as progress is emitted, so a long
|
||||
// batch is not cut off despite making steady progress. Recorders and
|
||||
// other non-network writers may not support deadlines.
|
||||
if err := controller.SetWriteDeadline(time.Now().Add(2 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||
return false
|
||||
}
|
||||
event := diagnosisStreamEvent{Type: kind, Data: data, TS: time.Now().UnixMilli()}
|
||||
if err := encoder.Encode(event); err != nil {
|
||||
return false
|
||||
}
|
||||
// Buffered writes can succeed while the actual socket flush fails.
|
||||
// Do not start a mutation after its processing event failed to flush.
|
||||
return controller.Flush() == nil
|
||||
}
|
||||
progress := redeployProgress{Total: len(ids)}
|
||||
result := batchOperationResult{}
|
||||
if !emit("start", progress) {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
name, _ := h.repo.GetTunnelName(id)
|
||||
if !emit("processing", map[string]interface{}{"id": id, "name": name, "progress": progress}) {
|
||||
return
|
||||
}
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
item := redeployItemResult{ID: id, Name: name}
|
||||
if err := h.redeployTunnelConfiguration(id); err != nil {
|
||||
result.FailCount++
|
||||
result.Failures = appendBatchFailure(result.Failures, id, name, err)
|
||||
item.Reason = result.Failures[len(result.Failures)-1].Reason
|
||||
progress.Failed++
|
||||
} else {
|
||||
result.SuccessCount++
|
||||
item.Success = true
|
||||
progress.Success++
|
||||
}
|
||||
progress.Completed++
|
||||
if !emit("item", map[string]interface{}{"result": item, "progress": progress}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
emit("done", map[string]interface{}{"progress": progress, "result": result})
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/middleware"
|
||||
runtimenft "go-backend/internal/runtime/nftables"
|
||||
)
|
||||
|
||||
type blockingRedeployManager struct {
|
||||
fakeNftablesManager
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (m *blockingRedeployManager) Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
|
||||
m.once.Do(func() { close(m.entered); <-m.release })
|
||||
return m.fakeNftablesManager.Reconcile(ctx, cfg, plan)
|
||||
}
|
||||
|
||||
func TestTunnelRedeployStreamFlushesBeforeSlowMutationAndReportsMixedResults(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
id := seedTunnelForNftables(t, h, "stream-tunnel", fixture.nodeID)
|
||||
manager := &blockingRedeployManager{entered: make(chan struct{}), release: make(chan struct{})}
|
||||
h.nftablesManager = manager
|
||||
var releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(manager.release) }) }
|
||||
defer release()
|
||||
server := httptest.NewServer(http.HandlerFunc(h.tunnelBatchRedeployStream))
|
||||
defer server.Close()
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Post(server.URL, "application/json", strings.NewReader(fmt.Sprintf(`{"ids":[%d,999999,%d]}`, id, id)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
read := func(want string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var event struct {
|
||||
Type string
|
||||
Data map[string]interface{}
|
||||
}
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Type != want {
|
||||
t.Fatalf("expected %s, got %s", want, event.Type)
|
||||
}
|
||||
return event.Data
|
||||
}
|
||||
if start := read("start"); start["total"] != float64(2) {
|
||||
t.Fatalf("duplicate IDs not deduplicated: %+v", start)
|
||||
}
|
||||
processing := read("processing")
|
||||
if processing["name"] != "stream-tunnel" || processing["id"] != float64(id) {
|
||||
t.Fatalf("wrong current tunnel: %+v", processing)
|
||||
}
|
||||
select {
|
||||
case <-manager.entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("did not start mutation")
|
||||
}
|
||||
// Both events arrived over HTTP while the actual mutation is still blocked.
|
||||
release()
|
||||
item := read("item")
|
||||
if item["result"].(map[string]interface{})["success"] != true || item["progress"].(map[string]interface{})["completed"] != float64(1) {
|
||||
t.Fatalf("wrong success result: %+v", item)
|
||||
}
|
||||
read("processing")
|
||||
item = read("item")
|
||||
if item["result"].(map[string]interface{})["success"] != false || item["result"].(map[string]interface{})["reason"] == "" {
|
||||
t.Fatalf("wrong failure result: %+v", item)
|
||||
}
|
||||
done := read("done")
|
||||
progress := done["progress"].(map[string]interface{})
|
||||
result := done["result"].(map[string]interface{})
|
||||
if progress["completed"] != float64(2) || progress["success"] != float64(1) || progress["failed"] != float64(1) || result["successCount"] != float64(1) || result["failCount"] != float64(1) {
|
||||
t.Fatalf("incorrect final result: %+v", done)
|
||||
}
|
||||
if manager.reconcileCount() != 1 {
|
||||
t.Fatalf("expected one execution, got %d", manager.reconcileCount())
|
||||
}
|
||||
if err := decoder.Decode(new(interface{})); err != io.EOF {
|
||||
t.Fatalf("expected EOF after done, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelRedeployStreamStopsAfterDisconnect(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
first := seedTunnelForNftables(t, h, "first", fixture.nodeID)
|
||||
second := seedTunnelForNftables(t, h, "second", fixture.nodeID)
|
||||
manager := &blockingRedeployManager{entered: make(chan struct{}), release: make(chan struct{})}
|
||||
h.nftablesManager = manager
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(fmt.Sprintf(`{"ids":[%d,%d]}`, first, second))).WithContext(ctx)
|
||||
res := httptest.NewRecorder()
|
||||
finished := make(chan struct{})
|
||||
go func() { defer close(finished); h.tunnelBatchRedeployStream(res, req) }()
|
||||
select {
|
||||
case <-manager.entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
close(manager.release)
|
||||
t.Fatal("mutation did not start")
|
||||
}
|
||||
cancel()
|
||||
close(manager.release)
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("handler did not stop")
|
||||
}
|
||||
if manager.reconcileCount() != 1 {
|
||||
t.Fatalf("started another mutation after disconnect: %d", manager.reconcileCount())
|
||||
}
|
||||
if strings.Contains(res.Body.String(), `"type":"done"`) {
|
||||
t.Fatal("disconnected stream claimed completion")
|
||||
}
|
||||
}
|
||||
|
||||
type failedRedeployWriter struct{ *httptest.ResponseRecorder }
|
||||
|
||||
func (w failedRedeployWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||
|
||||
func TestTunnelRedeployStreamDoesNotMutateWhenInitialWriteFails(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
id := seedTunnelForNftables(t, h, "stream-tunnel", fixture.nodeID)
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"ids":[%d]}`, id)))
|
||||
h.tunnelBatchRedeployStream(failedRedeployWriter{httptest.NewRecorder()}, req)
|
||||
if manager.reconcileCount() != 0 {
|
||||
t.Fatal("mutation ran despite failed progress stream")
|
||||
}
|
||||
}
|
||||
|
||||
type delayedRedeployManager struct{ fakeNftablesManager }
|
||||
|
||||
func (m *delayedRedeployManager) Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return m.fakeNftablesManager.Reconcile(ctx, cfg, plan)
|
||||
}
|
||||
|
||||
type shortRedeployDeadlineWriter struct {
|
||||
http.ResponseWriter
|
||||
deadlineCalls *atomic.Int32
|
||||
}
|
||||
|
||||
func (w shortRedeployDeadlineWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
func (w shortRedeployDeadlineWriter) SetWriteDeadline(deadline time.Time) error {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining < time.Minute || remaining > 2*time.Minute {
|
||||
return fmt.Errorf("expected bounded two-minute write window, got %v", remaining)
|
||||
}
|
||||
w.deadlineCalls.Add(1)
|
||||
// Shorten the production idle window for a real, fast timeout regression.
|
||||
return http.NewResponseController(w.ResponseWriter).SetWriteDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
}
|
||||
|
||||
func TestTunnelRedeployStreamRenewsDeadlineThroughRequestLog(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
ids := make([]int64, 3)
|
||||
for i := range ids {
|
||||
ids[i] = seedTunnelForNftables(t, h, fmt.Sprintf("slow-%d", i), fixture.nodeID)
|
||||
}
|
||||
manager := &delayedRedeployManager{}
|
||||
h.nftablesManager = manager
|
||||
var deadlineCalls atomic.Int32
|
||||
logged := middleware.RequestLog(http.HandlerFunc(h.tunnelBatchRedeployStream))
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logged.ServeHTTP(shortRedeployDeadlineWriter{w, &deadlineCalls}, r)
|
||||
}))
|
||||
server.Config.WriteTimeout = 50 * time.Millisecond
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
body, _ := json.Marshal(map[string]interface{}{"ids": ids})
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Post(server.URL, "application/json", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("stream timed out across batch: %v (body %s)", err, payload)
|
||||
}
|
||||
if !strings.Contains(string(payload), `"type":"done"`) {
|
||||
t.Fatalf("batch did not finish: %s", payload)
|
||||
}
|
||||
if manager.reconcileCount() != 3 {
|
||||
t.Fatalf("wrong execution count: %d", manager.reconcileCount())
|
||||
}
|
||||
if deadlineCalls.Load() != 8 {
|
||||
t.Fatalf("each of 8 events must renew its deadline, got %d", deadlineCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
type flushFailureRedeployWriter struct {
|
||||
*httptest.ResponseRecorder
|
||||
flushes int
|
||||
}
|
||||
|
||||
func (w *flushFailureRedeployWriter) FlushError() error {
|
||||
w.flushes++
|
||||
if w.flushes == 2 {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
w.ResponseRecorder.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTunnelRedeployStreamStopsBeforeMutationOnProcessingFlushError(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
id := seedTunnelForNftables(t, h, "flush-error", fixture.nodeID)
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"ids":[%d]}`, id)))
|
||||
writer := &flushFailureRedeployWriter{ResponseRecorder: httptest.NewRecorder()}
|
||||
middleware.RequestLog(http.HandlerFunc(h.tunnelBatchRedeployStream)).ServeHTTP(writer, req)
|
||||
if writer.flushes != 2 {
|
||||
t.Fatalf("expected start and processing flush, got %d", writer.flushes)
|
||||
}
|
||||
if manager.reconcileCount() != 0 {
|
||||
t.Fatal("mutation ran after failed processing flush")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"go-backend/internal/store/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func tunnelStateRuntimeRows(state *tunnelCreateState) []chainNodeRecord {
|
||||
if state.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
var rows []chainNodeRecord
|
||||
for _, n := range state.InNodes {
|
||||
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 1})
|
||||
}
|
||||
for _, hop := range state.ChainHops {
|
||||
for _, n := range hop {
|
||||
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 2, Port: n.Port})
|
||||
}
|
||||
}
|
||||
for _, n := range state.OutNodes {
|
||||
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 3, Port: n.Port})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
func (h *Handler) queueObsoleteTunnelRuntimeTx(tx *gorm.DB, tunnelID int64, oldRows, newRows []chainNodeRecord) error {
|
||||
var items []model.TunnelRuntimeCleanup
|
||||
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService) {
|
||||
port := 0
|
||||
for _, row := range oldRows {
|
||||
if row.NodeID == nodeID {
|
||||
port = row.Port
|
||||
break
|
||||
}
|
||||
}
|
||||
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: nodeID, Kind: "service", Port: port})
|
||||
}
|
||||
for _, old := range oldRows {
|
||||
if !tunnelRuntimeNeedsService(old) {
|
||||
continue
|
||||
}
|
||||
for _, current := range newRows {
|
||||
if current.NodeID == old.NodeID && tunnelRuntimeNeedsService(current) && (current.Port != old.Port || current.ChainType != old.ChainType) {
|
||||
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: old.NodeID, Kind: "service", Port: old.Port})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain) {
|
||||
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: nodeID, Kind: "chain"})
|
||||
}
|
||||
return h.repo.QueueTunnelRuntimeCleanupTx(tx, items)
|
||||
}
|
||||
|
||||
// Caller holds the tunnel lifecycle lock. Recheck current desired roles before
|
||||
// every retry so a delayed deletion cannot remove a re-added runtime.
|
||||
func (h *Handler) retryTunnelRuntimeCleanupLocked(tunnelID int64) error {
|
||||
items, err := h.repo.ListTunnelRuntimeCleanup(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
tunnel, err := h.repo.GetTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var rows []chainNodeRecord
|
||||
if tunnel != nil && tunnel.Type == 2 {
|
||||
rows, err = h.listChainNodesForTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var failures []error
|
||||
liveServices := map[int64]bool{}
|
||||
for _, item := range items {
|
||||
desired := false
|
||||
for _, row := range rows {
|
||||
if row.NodeID == item.NodeID && ((item.Kind == "service" && tunnelRuntimeNeedsService(row)) || (item.Kind == "chain" && tunnelRuntimeNeedsChain(row))) {
|
||||
desired = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if desired && item.Kind == "service" {
|
||||
// Desired state does not prove that the old receiver was replaced.
|
||||
// Keep this barrier (even at the same port) and protect its old
|
||||
// chain until acknowledgeTunnelServiceReplacements observes ACK.
|
||||
liveServices[item.NodeID] = true
|
||||
continue
|
||||
}
|
||||
if !desired {
|
||||
if item.Kind == "chain" && liveServices[item.NodeID] {
|
||||
continue
|
||||
}
|
||||
var commandErr error
|
||||
if item.Kind == "service" {
|
||||
_, commandErr = h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": tunnelRuntimeServiceNames(tunnelID)}, false, true)
|
||||
} else {
|
||||
_, commandErr = h.sendNodeCommand(item.NodeID, "DeleteChains", map[string]interface{}{"chain": fmt.Sprintf("chains_%d", tunnelID)}, false, true)
|
||||
}
|
||||
if commandErr != nil {
|
||||
if item.Kind == "service" {
|
||||
liveServices[item.NodeID] = true
|
||||
}
|
||||
failures = append(failures, fmt.Errorf("节点 %d 旧运行时清理失败: %w", item.NodeID, commandErr))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := h.repo.DeleteTunnelRuntimeCleanup(item.ID); err != nil {
|
||||
if item.Kind == "service" {
|
||||
liveServices[item.NodeID] = true
|
||||
}
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func (h *Handler) acknowledgeTunnelServiceReplacements(tunnelID int64, nodeIDs []int64) error {
|
||||
if len(nodeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
items, err := h.repo.ListTunnelRuntimeCleanup(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Kind == "service" {
|
||||
for _, nodeID := range nodeIDs {
|
||||
if item.NodeID == nodeID {
|
||||
if err := h.repo.DeleteTunnelRuntimeCleanup(item.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (h *Handler) retryPendingTunnelRuntimeCleanup() error {
|
||||
return h.retryPendingTunnelRuntimeCleanupOnNode(0)
|
||||
}
|
||||
|
||||
func (h *Handler) retryPendingTunnelRuntimeCleanupOnNode(nodeID int64) error {
|
||||
items, err := h.repo.ListTunnelRuntimeCleanup(0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
var failures []error
|
||||
for _, item := range items {
|
||||
if nodeID > 0 && item.NodeID != nodeID {
|
||||
continue
|
||||
}
|
||||
if seen[item.TunnelID] {
|
||||
continue
|
||||
}
|
||||
seen[item.TunnelID] = true
|
||||
unlock := h.lockTunnelLifecycle(item.TunnelID)
|
||||
err := h.retryTunnelRuntimeCleanupLocked(item.TunnelID)
|
||||
unlock()
|
||||
if err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
// The desired tunnel has already been deleted; callers must not roll back a
|
||||
// completed forward migration merely because asynchronous cleanup is pending.
|
||||
type tunnelDeletionCleanupPendingError struct{ cause error }
|
||||
|
||||
func (e *tunnelDeletionCleanupPendingError) Error() string {
|
||||
return "隧道配置已删除,运行时清理待重试: " + e.cause.Error()
|
||||
}
|
||||
func (e *tunnelDeletionCleanupPendingError) Unwrap() error { return e.cause }
|
||||
@@ -411,6 +411,9 @@ func (h *Handler) onNodeOnline(nodeID int64) {
|
||||
|
||||
// A fresh agent needs a full restore. Shared failures remain persisted and
|
||||
// are retried by maintenance without restarting acknowledged services.
|
||||
if err := h.retryPendingTunnelRuntimeCleanupOnNode(nodeID); err != nil {
|
||||
fmt.Printf("reconnect obsolete tunnel cleanup remains pending: %v\n", err)
|
||||
}
|
||||
h.reconcileSharedNodeRuntime(nodeID)
|
||||
if !h.redeployLocalNodeRuntime(nodeID) {
|
||||
h.scheduleNodeLocalRuntimeRetry(nodeID)
|
||||
@@ -568,9 +571,10 @@ func (h *Handler) finishNodeOnlineRedeploy(nodeID int64) {
|
||||
}
|
||||
|
||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) bool {
|
||||
cleanupOK := h.retryPendingTunnelRuntimeCleanupOnNode(nodeID) == nil
|
||||
sharedOK := h.reconcileSharedNodeRuntime(nodeID)
|
||||
localOK := h.redeployLocalNodeRuntime(nodeID)
|
||||
return sharedOK && localOK
|
||||
return cleanupOK && sharedOK && localOK
|
||||
}
|
||||
|
||||
func (h *Handler) reconcileSharedNodeRuntime(nodeID int64) bool {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func requiresAdmin(path string) bool {
|
||||
}
|
||||
|
||||
switch path {
|
||||
case "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
|
||||
case "/api/v1/user/update-order", "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
|
||||
return true
|
||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||
return true
|
||||
|
||||
@@ -14,6 +14,11 @@ type statusWriter struct {
|
||||
status int
|
||||
}
|
||||
|
||||
// Unwrap preserves ResponseController features such as per-response deadlines.
|
||||
func (w *statusWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
@@ -27,10 +32,12 @@ func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return hj.Hijack()
|
||||
}
|
||||
|
||||
func (w *statusWriter) FlushError() error {
|
||||
return http.NewResponseController(w.ResponseWriter).Flush()
|
||||
}
|
||||
|
||||
func (w *statusWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
_ = w.FlushError()
|
||||
}
|
||||
|
||||
func (w *statusWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Panel upgrade safety and recovery
|
||||
|
||||
The helper is a subcommand in the **currently running image**, dispatched before
|
||||
application/database initialization. This fix must first be installed through the
|
||||
normal administrator deployment procedure on older releases lacking the helper.
|
||||
It cannot retroactively repair an already-running legacy upgrade helper.
|
||||
|
||||
Supported deployments have one `docker-compose.yml`, the original Compose project
|
||||
labels, and a writable host bind mount containing that file and `.env`. The
|
||||
helper mounts that directory at its **host absolute path** and passes explicit
|
||||
project, project-directory, env-file and compose-file options. Multiple Compose
|
||||
files, mismatched mounts/resources, or changed database settings fail closed.
|
||||
|
||||
Jobs are under `<deployment>/.panel-upgrades/<jobId>/` (directory 0700, files
|
||||
0600). `*.original` contains immutable per-job configuration snapshots, not a live
|
||||
database backup. `events.log` contains safe phase messages only. Legacy
|
||||
`.upgrade.bak` files and `upgrade.log` are never changed. Helper containers remain
|
||||
for diagnosis; they are intentionally not automatically removed. Operators may
|
||||
remove completed helpers after retaining the job's files.
|
||||
|
||||
The `latest` pointer plus filesystem lock prevents simultaneous jobs across API
|
||||
processes and backend replacement. Failed validation/pull leaves live files
|
||||
untouched. A failed configuration application attempts to restore both files
|
||||
before any recreate. Once `restarting` is recorded, failures require recovery:
|
||||
no automatic database rollback, image rollback, or container rollback is safe.
|
||||
The helper has a 30 minute overall deadline, including a maximum five minute
|
||||
post-recreate verification window. Docker client timeout during recreation can
|
||||
leave daemon work in flight; it is always treated as uncertain, not retried.
|
||||
|
||||
## Operator recovery after an interrupted/uncertain job
|
||||
|
||||
There is intentionally no remotely callable “clear lock” endpoint. Do not delete
|
||||
job directories or rewrite status JSON to force another attempt.
|
||||
|
||||
1. Work on the Docker host and identify the job from `latest`, `status.json` and
|
||||
`plan.json`. Preserve the entire job directory, live Compose/env files, and
|
||||
container inspection information in a private location. These files can
|
||||
contain credentials. Never paste `.env` or raw Compose config into public logs.
|
||||
2. Restore Docker daemon access if necessary. Inspect the exact helper container
|
||||
named by the job. If it is running, let it finish or explicitly stop it as an
|
||||
administrator. Verify it has stopped. If launch was uncertain, establish that
|
||||
no matching helper exists and no Docker create/up operation remains in flight.
|
||||
Do not clear the job while any of these facts are uncertain.
|
||||
3. Inspect the original project's backend and frontend container IDs, images,
|
||||
mounts and health. Check the actual database and application logs privately.
|
||||
After any attempted recreation, assume migrations may already have occurred.
|
||||
Preserve a current database snapshot before further manual deployment work.
|
||||
Use the normal documented deployment procedure to bring both services to a
|
||||
consistent, healthy state. Do not copy an old database over the current one or
|
||||
blindly redeploy an older image. Configuration snapshots alone cannot undo a
|
||||
schema migration.
|
||||
4. Only after verifying no helper/daemon work remains and both services and their
|
||||
database are consistent, archive the `latest` pointer under a unique filename
|
||||
in `.panel-upgrades/` (for example `resolved-<jobId>.pointer`). Do this while
|
||||
the API is stopped, or hold an exclusive OS flock on `.panel-upgrades/lock`,
|
||||
so no other start can race this manual recovery. Keep the job directory and
|
||||
archived pointer. This permits a new job without changing recovery evidence.
|
||||
5. Restart the API if stopped and reload the browser settings page so it discovers
|
||||
the current `latest` task instead of following the resolved task ID. No current
|
||||
job is expected. A fresh start creates a new ID and new backups, never
|
||||
overwriting old evidence.
|
||||
|
||||
A lost helper, unavailable daemon, or stale status is displayed as
|
||||
`recovery_required` while retaining the active lock. This is intentionally a
|
||||
conservative derived status, so a helper that is still completing work can report
|
||||
its actual durable result without racing a status request.
|
||||
@@ -0,0 +1,311 @@
|
||||
package panelupgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const helperTimeout = 30 * time.Minute
|
||||
|
||||
var verificationTimeout = 5 * time.Minute
|
||||
|
||||
type composeConfig struct {
|
||||
FullServices map[string]map[string]any `json:"-"`
|
||||
Services map[string]struct {
|
||||
Image string `json:"image"`
|
||||
ContainerName string `json:"container_name"`
|
||||
Volumes []map[string]any `json:"volumes"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
} `json:"services"`
|
||||
Volumes map[string]any `json:"volumes"`
|
||||
Networks map[string]any `json:"networks"`
|
||||
}
|
||||
|
||||
func composeArgs(p Plan, dir string, args ...string) []string {
|
||||
return append([]string{"compose", "--project-name", p.Project, "--project-directory", p.HostDir, "--env-file", filepath.Join(dir, ".env"), "-f", filepath.Join(dir, "docker-compose.yml")}, args...)
|
||||
}
|
||||
func config(ctx context.Context, p Plan, dir string) (composeConfig, error) {
|
||||
var c composeConfig
|
||||
b, e := docker(ctx, composeArgs(p, dir, "config", "--format", "json")...)
|
||||
if e != nil {
|
||||
return c, e
|
||||
}
|
||||
e = json.Unmarshal(b, &c)
|
||||
if e == nil {
|
||||
var raw struct {
|
||||
Services map[string]map[string]any `json:"services"`
|
||||
}
|
||||
e = json.Unmarshal(b, &raw)
|
||||
c.FullServices = raw.Services
|
||||
}
|
||||
return c, e
|
||||
}
|
||||
func validateConfig(old, next composeConfig, p Plan) error {
|
||||
// Compare complete normalized services, not a projection that can silently drop
|
||||
// host bindings, commands, security options, networks or dependency changes.
|
||||
if old.FullServices == nil || next.FullServices == nil {
|
||||
return errors.New("missing full Compose configuration")
|
||||
}
|
||||
raw, _ := json.Marshal(old.FullServices)
|
||||
var expected map[string]map[string]any
|
||||
if json.Unmarshal(raw, &expected) != nil {
|
||||
return errors.New("invalid configuration")
|
||||
}
|
||||
for _, service := range []string{"backend", "frontend"} {
|
||||
if expected[service] == nil || next.FullServices[service] == nil {
|
||||
return errors.New("missing service")
|
||||
}
|
||||
expected[service]["image"] = next.FullServices[service]["image"]
|
||||
}
|
||||
if env, ok := expected["backend"]["environment"].(map[string]any); ok {
|
||||
env["FLUX_VERSION"] = p.Job.TargetVersion
|
||||
} else {
|
||||
return errors.New("missing backend environment")
|
||||
}
|
||||
if !reflect.DeepEqual(expected, next.FullServices) {
|
||||
return errors.New("unsupported service configuration change; manual upgrade required")
|
||||
}
|
||||
|
||||
for service, image := range map[string]string{"backend": "ghcr.io/sagit-chu/flux-panel-backend:", "frontend": "ghcr.io/sagit-chu/vite-frontend:"} {
|
||||
a, ok := old.Services[service]
|
||||
b, present := next.Services[service]
|
||||
if !ok || !present || b.Image != image+p.Job.TargetVersion || a.ContainerName != b.ContainerName || !reflect.DeepEqual(a.Volumes, b.Volumes) {
|
||||
return errors.New("service image, container identity or persistent mounts do not match the supported deployment")
|
||||
}
|
||||
}
|
||||
if next.Services["backend"].ContainerName != p.Backend || next.Services["backend"].Environment["FLUX_VERSION"] != p.Job.TargetVersion {
|
||||
return errors.New("backend identity/version mismatch")
|
||||
}
|
||||
|
||||
for service := range next.Services {
|
||||
if service != "backend" && service != "frontend" && service != "postgres" {
|
||||
return errors.New("unsupported service")
|
||||
}
|
||||
}
|
||||
for key, value := range old.Services["backend"].Environment {
|
||||
if key != "FLUX_VERSION" && next.Services["backend"].Environment[key] != value {
|
||||
return errors.New("backend environment changed; manual upgrade required")
|
||||
}
|
||||
}
|
||||
for key, value := range next.Services["backend"].Environment {
|
||||
if key != "FLUX_VERSION" && old.Services["backend"].Environment[key] != value {
|
||||
return errors.New("backend environment changed; manual upgrade required")
|
||||
}
|
||||
}
|
||||
// Database configuration and shared resource identities are not upgrade targets.
|
||||
if !reflect.DeepEqual(old.Services["postgres"], next.Services["postgres"]) || !reflect.DeepEqual(old.Volumes, next.Volumes) || !reflect.DeepEqual(old.Networks, next.Networks) {
|
||||
return errors.New("database or shared resource configuration changed; manual upgrade required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLive proves the normalized source configuration still describes the running
|
||||
// backend. In particular, Compose must not attach a fresh database volume by accident.
|
||||
func validateLive(ctx context.Context, p Plan, c composeConfig) error {
|
||||
live, err := inspect(ctx, p.Backend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if live.Image != p.Image || !live.State.Running || live.Config.Labels["com.docker.compose.project"] != p.Project || live.Config.Labels["com.docker.compose.service"] != "backend" {
|
||||
return errors.New("running backend changed")
|
||||
}
|
||||
if len(live.Mounts) != len(c.Services["backend"].Volumes) {
|
||||
return errors.New("unexpected live mounts; manual upgrade required")
|
||||
}
|
||||
for _, v := range c.Services["backend"].Volumes {
|
||||
typ, _ := v["type"].(string)
|
||||
src, _ := v["source"].(string)
|
||||
dst, _ := v["target"].(string)
|
||||
if typ == "volume" {
|
||||
spec, ok := c.Volumes[src].(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("unknown volume")
|
||||
}
|
||||
src, _ = spec["name"].(string)
|
||||
}
|
||||
if src == "" || dst == "" || (typ != "volume" && typ != "bind") {
|
||||
return errors.New("unsupported mount")
|
||||
}
|
||||
found := false
|
||||
for _, mount := range live.Mounts {
|
||||
actual := mount.Source
|
||||
if typ == "volume" {
|
||||
actual = mount.Name
|
||||
}
|
||||
if mount.Type == typ && mount.Destination == dst && actual == src && mount.RW != (v["read_only"] == true) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("live persistent mount does not match Compose")
|
||||
}
|
||||
}
|
||||
env := map[string]string{}
|
||||
for _, v := range live.Config.Env {
|
||||
parts := strings.SplitN(v, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
env[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"DB_TYPE", "DB_PATH", "DATABASE_URL", "PANEL_DEPLOY_DIR", "PANEL_BACKEND_CONTAINER"} {
|
||||
if env[key] != c.Services["backend"].Environment[key] {
|
||||
return errors.New("live database/deployment environment does not match Compose")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunHelper runs before app/database initialization and does not depend on the old backend process.
|
||||
func RunHelper(planPath string) error {
|
||||
b, e := os.ReadFile(planPath)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
var p Plan
|
||||
if e = json.Unmarshal(b, &p); e != nil {
|
||||
return e
|
||||
}
|
||||
if !idPattern.MatchString(p.Job.ID) || ValidateVersion(p.Job.TargetVersion) != nil || !projectPattern.MatchString(p.Project) {
|
||||
return errors.New("invalid plan")
|
||||
}
|
||||
m := Manager{Dir: p.HostDir, Backend: p.Backend}
|
||||
dir := m.jobDir(p.Job.ID)
|
||||
if filepath.Clean(planPath) != filepath.Join(dir, "plan.json") {
|
||||
return errors.New("invalid plan location")
|
||||
}
|
||||
// Reject duplicate helper invocations without affecting the live job.
|
||||
f, e := os.OpenFile(filepath.Join(dir, "helper.started"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
f.Close()
|
||||
j, e := m.read(p.Job.ID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !j.Active {
|
||||
return errors.New("job is not active")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), helperTimeout)
|
||||
defer cancel()
|
||||
fail := func(msg string) error {
|
||||
if e := m.write(j, "failed", msg); e != nil {
|
||||
return e
|
||||
}
|
||||
return errors.New(msg)
|
||||
}
|
||||
recovery := func(msg string) error {
|
||||
m.write(j, "recovery_required", msg+";请管理员核对容器、数据库和任务备份后人工恢复,禁止自动回滚或重复升级")
|
||||
return errors.New(msg)
|
||||
}
|
||||
if e = m.write(j, "preparing", "校验部署配置和持久化资源"); e != nil {
|
||||
return e
|
||||
}
|
||||
old, e := config(ctx, p, p.HostDir)
|
||||
if e != nil {
|
||||
return fail("现有 Compose 配置校验失败,线上配置未变更")
|
||||
}
|
||||
next, e := config(ctx, p, dir)
|
||||
if e != nil {
|
||||
return fail("目标 Compose 配置校验失败,线上配置未变更")
|
||||
}
|
||||
if validateConfig(old, next, p) != nil {
|
||||
return fail("目标镜像、服务或持久化资源不匹配,需手动升级")
|
||||
}
|
||||
if validateLive(ctx, p, old) != nil {
|
||||
return fail("运行中后端与部署配置不一致,需手动升级")
|
||||
}
|
||||
if e = m.write(j, "pulling", "正在拉取并检查目标镜像,线上配置尚未变更"); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = docker(ctx, composeArgs(p, dir, "pull", "backend", "frontend")...); e != nil {
|
||||
return fail("镜像拉取失败或超时,线上配置未变更")
|
||||
}
|
||||
targets := map[string]string{}
|
||||
for _, service := range []string{"backend", "frontend"} {
|
||||
out, err := docker(ctx, "image", "inspect", "--format", "{{.Id}}", next.Services[service].Image)
|
||||
if err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "sha256:") {
|
||||
return fail("目标镜像校验失败,线上配置未变更")
|
||||
}
|
||||
targets[service] = strings.TrimSpace(string(out))
|
||||
}
|
||||
if validateLive(ctx, p, old) != nil {
|
||||
return fail("拉取期间运行中后端发生变化,已安全取消")
|
||||
}
|
||||
// Detect edits since snapshots before replacing either live file.
|
||||
for _, name := range []string{"docker-compose.yml", ".env"} {
|
||||
live, err := os.ReadFile(filepath.Join(p.HostDir, name))
|
||||
if err != nil {
|
||||
return fail("读取线上配置失败")
|
||||
}
|
||||
backup, err := os.ReadFile(filepath.Join(dir, name+".original"))
|
||||
if err != nil || string(live) != string(backup) {
|
||||
return fail("线上配置已被修改,本任务已安全取消")
|
||||
}
|
||||
}
|
||||
if e = m.write(j, "applying", "正在应用已校验配置"); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, name := range []string{"docker-compose.yml", ".env"} {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err == nil {
|
||||
err = atomic(filepath.Join(p.HostDir, name), data)
|
||||
}
|
||||
if err != nil {
|
||||
for _, restore := range []string{"docker-compose.yml", ".env"} {
|
||||
original, re := os.ReadFile(filepath.Join(dir, restore+".original"))
|
||||
if re != nil || atomic(filepath.Join(p.HostDir, restore), original) != nil {
|
||||
return recovery("配置恢复失败,尚未执行容器重建")
|
||||
}
|
||||
}
|
||||
return fail("应用配置失败,已恢复原配置,未执行容器重建")
|
||||
}
|
||||
}
|
||||
// Persist the point of no automatic rollback BEFORE Docker can recreate any service.
|
||||
if e = m.write(j, "restarting", "正在重建面板服务"); e != nil {
|
||||
return recovery("无法记录重建状态,已停止执行")
|
||||
}
|
||||
if _, e = docker(ctx, composeArgs(p, p.HostDir, "up", "-d", "--no-deps", "--force-recreate", "--pull", "never", "backend", "frontend")...); e != nil {
|
||||
return recovery("容器重建失败或超时,可能已部分生效")
|
||||
}
|
||||
if e = m.write(j, "verifying", "正在验证服务版本及健康状态"); e != nil {
|
||||
return e
|
||||
}
|
||||
deadline := time.Now().Add(verificationTimeout)
|
||||
for time.Now().Before(deadline) && ctx.Err() == nil {
|
||||
ready := true
|
||||
for _, service := range []string{"backend", "frontend"} {
|
||||
c, err := inspect(ctx, next.Services[service].ContainerName)
|
||||
if err != nil || !c.State.Running || c.Image != targets[service] || c.Config.Labels["com.docker.compose.project"] != p.Project || c.Config.Labels["com.docker.compose.service"] != service {
|
||||
ready = false
|
||||
continue
|
||||
}
|
||||
if c.State.Health != nil && c.State.Health.Status != "healthy" {
|
||||
ready = false
|
||||
}
|
||||
if service == "backend" && c.State.Health == nil {
|
||||
ready = false
|
||||
}
|
||||
}
|
||||
if ready { // Frontend has no Docker healthcheck in the stock template.
|
||||
probeCtx, probeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, probeErr := docker(probeCtx, "exec", next.Services["frontend"].ContainerName, "wget", "-q", "-T", "5", "-O", "/dev/null", "http://127.0.0.1:80/")
|
||||
probeCancel()
|
||||
ready = probeErr == nil
|
||||
}
|
||||
if ready {
|
||||
return m.write(j, "succeeded", "升级完成,目标镜像和服务健康状态已验证")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
return recovery("升级后服务健康检查失败或超时")
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Package panelupgrade implements durable, conservative Docker Compose upgrades.
|
||||
package panelupgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var versionPattern = regexp.MustCompile(`^v?[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?$`)
|
||||
var idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
|
||||
var projectPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
func ValidateVersion(v string) error {
|
||||
if !versionPattern.MatchString(v) || len(v) > 128 {
|
||||
return errors.New("invalid release version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
TargetVersion string `json:"targetVersion"`
|
||||
Stage string `json:"stage"`
|
||||
Active bool `json:"active"`
|
||||
RequiresRecovery bool `json:"requiresRecovery"`
|
||||
Message string `json:"message"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
HelperContainer string `json:"helperContainer,omitempty"`
|
||||
BackupDir string `json:"backupDir,omitempty"`
|
||||
}
|
||||
type Plan struct {
|
||||
Job Job
|
||||
DeployDir, HostDir, Project, Backend, Image string
|
||||
}
|
||||
type Manager struct{ Dir, Backend string }
|
||||
|
||||
func (m Manager) root() string { return filepath.Join(m.Dir, ".panel-upgrades") }
|
||||
func (m Manager) jobDir(id string) string { return filepath.Join(m.root(), id) }
|
||||
func atomic(path string, b []byte) error {
|
||||
f, e := os.CreateTemp(filepath.Dir(path), ".write-")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
n := f.Name()
|
||||
defer os.Remove(n)
|
||||
if e = f.Chmod(0600); e == nil {
|
||||
_, e = f.Write(b)
|
||||
}
|
||||
if e == nil {
|
||||
e = f.Sync()
|
||||
}
|
||||
ce := f.Close()
|
||||
if e == nil {
|
||||
e = ce
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = os.Rename(n, path); e != nil {
|
||||
return e
|
||||
}
|
||||
d, e := os.Open(filepath.Dir(path))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
func save(path string, v any) error {
|
||||
b, e := json.Marshal(v)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return atomic(path, b)
|
||||
}
|
||||
func (m Manager) lock() (func(), error) {
|
||||
if e := os.MkdirAll(m.root(), 0700); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
f, e := os.OpenFile(filepath.Join(m.root(), "lock"), os.O_CREATE|os.O_RDWR, 0600)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if e = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); e != nil {
|
||||
f.Close()
|
||||
return nil, errors.New("another upgrade operation is in progress")
|
||||
}
|
||||
return func() { syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil
|
||||
}
|
||||
func docker(ctx context.Context, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
b, e := cmd.Output()
|
||||
if e != nil {
|
||||
return nil, errors.New("Docker operation failed (details suppressed to protect credentials)")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
type container struct {
|
||||
Image string
|
||||
Config struct {
|
||||
Labels map[string]string
|
||||
Env []string
|
||||
}
|
||||
State struct {
|
||||
Running bool
|
||||
Health *struct{ Status string }
|
||||
}
|
||||
Mounts []struct {
|
||||
Type, Source, Destination, Name string
|
||||
RW bool
|
||||
}
|
||||
}
|
||||
|
||||
func inspect(ctx context.Context, name string) (container, error) {
|
||||
var c []container
|
||||
b, e := docker(ctx, "inspect", name)
|
||||
if e != nil {
|
||||
return container{}, e
|
||||
}
|
||||
if json.Unmarshal(b, &c) != nil || len(c) != 1 {
|
||||
return container{}, errors.New("invalid Docker inspection")
|
||||
}
|
||||
return c[0], nil
|
||||
}
|
||||
func (m Manager) Identity(ctx context.Context) (Plan, error) {
|
||||
p := Plan{DeployDir: m.Dir, Backend: m.Backend}
|
||||
if !filepath.IsAbs(m.Dir) || filepath.Clean(m.Dir) != m.Dir {
|
||||
return p, errors.New("deployment directory must be an absolute clean path")
|
||||
}
|
||||
c, e := inspect(ctx, m.Backend)
|
||||
if e != nil {
|
||||
return p, e
|
||||
}
|
||||
p.Image = c.Image
|
||||
p.Project = c.Config.Labels["com.docker.compose.project"]
|
||||
if !c.State.Running || !projectPattern.MatchString(p.Project) || c.Config.Labels["com.docker.compose.service"] != "backend" || !strings.HasPrefix(p.Image, "sha256:") {
|
||||
return p, errors.New("cannot establish original Compose identity")
|
||||
}
|
||||
for _, v := range c.Mounts {
|
||||
if v.Destination == m.Dir && v.Type == "bind" && v.RW {
|
||||
p.HostDir = v.Source
|
||||
}
|
||||
}
|
||||
if p.HostDir == "/" || !filepath.IsAbs(p.HostDir) || strings.ContainsAny(p.HostDir, ",\n\r") || filepath.Clean(p.HostDir) != p.HostDir {
|
||||
return p, errors.New("deployment directory must be a writable host bind mount")
|
||||
}
|
||||
|
||||
for _, protected := range []string{"/app/paneld", "/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins/docker-compose", "/var/run/docker.sock", "/bin", "/lib", "/etc", "/proc", "/sys", "/dev"} {
|
||||
if p.HostDir == protected || strings.HasPrefix(protected, p.HostDir+"/") || strings.HasPrefix(p.HostDir, protected+"/") {
|
||||
return p, errors.New("host deployment mount would shadow helper runtime paths")
|
||||
}
|
||||
}
|
||||
if c.Config.Labels["com.docker.compose.project.working_dir"] != p.HostDir || c.Config.Labels["com.docker.compose.project.config_files"] != filepath.Join(p.HostDir, "docker-compose.yml") {
|
||||
return p, errors.New("custom or multiple Compose paths require manual upgrade")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func (m Manager) read(id string) (*Job, error) {
|
||||
if !idPattern.MatchString(id) {
|
||||
return nil, errors.New("invalid job ID")
|
||||
}
|
||||
b, e := os.ReadFile(filepath.Join(m.jobDir(id), "status.json"))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var j Job
|
||||
e = json.Unmarshal(b, &j)
|
||||
return &j, e
|
||||
}
|
||||
func (m Manager) Status(ctx context.Context, id string) (*Job, error) {
|
||||
if id == "" {
|
||||
b, e := os.ReadFile(filepath.Join(m.root(), "latest"))
|
||||
if os.IsNotExist(e) {
|
||||
return nil, nil
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
id = string(b)
|
||||
}
|
||||
j, e := m.read(id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !j.Active || time.Now().UnixMilli()-j.UpdatedAt < 90000 {
|
||||
return j, nil
|
||||
}
|
||||
// An unavailable daemon, missing helper, or uncertain launch must never release the task lock.
|
||||
c, e := inspect(ctx, j.HelperContainer)
|
||||
if e == nil && c.State.Running {
|
||||
return j, nil
|
||||
}
|
||||
j.RequiresRecovery = true
|
||||
j.Message = "升级 helper 已停止或无法确认状态。请管理员检查任务备份和容器状态;在确认安全前禁止再次升级。"
|
||||
// This is a derived view: do not race the helper's durable state writes.
|
||||
j.Stage = "recovery_required"
|
||||
return j, nil
|
||||
}
|
||||
func (m Manager) write(j *Job, stage, msg string) error {
|
||||
j.Stage = stage
|
||||
j.Message = msg
|
||||
j.UpdatedAt = time.Now().UnixMilli()
|
||||
j.Active = stage != "succeeded" && stage != "failed"
|
||||
j.RequiresRecovery = stage == "recovery_required"
|
||||
if e := save(filepath.Join(m.jobDir(j.ID), "status.json"), j); e != nil {
|
||||
return e
|
||||
}
|
||||
f, e := os.OpenFile(filepath.Join(m.jobDir(j.ID), "events.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer f.Close()
|
||||
_, e = fmt.Fprintf(f, "%d %s %s\n", j.UpdatedAt, stage, msg)
|
||||
return e
|
||||
}
|
||||
func (m Manager) Start(ctx context.Context, version string, compose []byte) (*Job, string, error) {
|
||||
if e := ValidateVersion(version); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
unlock, e := m.lock()
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
defer unlock()
|
||||
old, e := m.Status(ctx, "")
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
if old != nil && (old.Active || old.RequiresRecovery) {
|
||||
return old, "", errors.New("已有面板升级任务执行中或需要人工恢复")
|
||||
}
|
||||
p, e := m.Identity(ctx)
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
if _, e = rand.Read(b); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
id := hex.EncodeToString(b)
|
||||
dir := m.jobDir(id)
|
||||
if e = os.Mkdir(dir, 0700); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
j := Job{ID: id, TargetVersion: version, StartedAt: time.Now().UnixMilli(), HelperContainer: "flvx-upgrade-" + id, BackupDir: filepath.Join(p.HostDir, ".panel-upgrades", id)}
|
||||
p.Job = j
|
||||
if e = m.write(&j, "preparing", "正在准备独立升级任务"); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
if e = atomic(filepath.Join(m.root(), "latest"), []byte(id)); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
fail := func(err error) (*Job, string, error) {
|
||||
m.write(&j, "failed", "准备失败,线上配置未变更")
|
||||
return &j, p.Image, err
|
||||
}
|
||||
for _, name := range []string{"docker-compose.yml", ".env"} {
|
||||
data, err := os.ReadFile(filepath.Join(m.Dir, name))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err = atomic(filepath.Join(dir, name+".original"), data); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
env, e := os.ReadFile(filepath.Join(dir, ".env.original"))
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
lines := strings.Split(string(env), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "FLUX_VERSION=") || strings.HasPrefix(strings.TrimSpace(line), "export FLUX_VERSION=") {
|
||||
lines[i] = ""
|
||||
}
|
||||
}
|
||||
env = []byte(strings.Join(lines, "\n") + "\nFLUX_VERSION=" + version + "\n")
|
||||
if e = atomic(filepath.Join(dir, ".env"), env); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
if e = atomic(filepath.Join(dir, "docker-compose.yml"), compose); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
p.Job = j
|
||||
if e = save(filepath.Join(dir, "plan.json"), p); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
// No --rm: an exited helper is evidence needed after a daemon/client timeout.
|
||||
args := []string{"run", "-d", "--name", j.HelperContainer, "--label", "io.flvx.upgrade-job=" + id, "--mount", "type=bind,src=" + p.HostDir + ",dst=" + p.HostDir, "--mount", "type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock", "--entrypoint", "/app/paneld", p.Image, "panel-upgrade-helper", filepath.Join(p.HostDir, ".panel-upgrades", id, "plan.json")}
|
||||
if _, e = docker(ctx, args...); e != nil {
|
||||
c, ie := inspect(ctx, j.HelperContainer)
|
||||
if ie != nil || !c.State.Running {
|
||||
return &j, p.Image, errors.New("helper 启动结果不确定;任务已保留,请查询升级状态,勿重复启动")
|
||||
}
|
||||
}
|
||||
return &j, p.Image, nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package panelupgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeDocker(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
d := t.TempDir()
|
||||
if e := os.WriteFile(filepath.Join(d, "docker"), []byte("#!/bin/sh\n"+body), 0700); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Setenv("PATH", d+":"+os.Getenv("PATH"))
|
||||
}
|
||||
func fixture(t *testing.T) (Manager, Plan) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
m := Manager{Dir: dir, Backend: "flux-panel-backend"}
|
||||
id := strings.Repeat("a", 32)
|
||||
os.MkdirAll(m.jobDir(id), 0700)
|
||||
j := Job{ID: id, TargetVersion: "2.5.0", HelperContainer: "helper", StartedAt: time.Now().UnixMilli()}
|
||||
m.write(&j, "preparing", "ready")
|
||||
atomic(filepath.Join(m.root(), "latest"), []byte(id))
|
||||
p := Plan{Job: j, HostDir: dir, DeployDir: "/opt/flvx-panel", Project: "original-project", Backend: m.Backend, Image: "sha256:old"}
|
||||
save(filepath.Join(m.jobDir(id), "plan.json"), p)
|
||||
for _, n := range []string{".env", "docker-compose.yml"} {
|
||||
atomic(filepath.Join(dir, n), []byte("original"))
|
||||
atomic(filepath.Join(m.jobDir(id), n+".original"), []byte("original"))
|
||||
atomic(filepath.Join(m.jobDir(id), n), []byte("target"))
|
||||
}
|
||||
return m, p
|
||||
}
|
||||
func TestVersionRejectsComposeAndPathInjection(t *testing.T) {
|
||||
for _, v := range []string{"latest", "../v1", "1.2.3\nSECRET=x", "1.2.3${TOKEN}", "1.2.3 x", "1.2.3;id"} {
|
||||
if ValidateVersion(v) == nil {
|
||||
t.Fatal(v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"2.5.0-rc10", "v2.5.0", "2.1.9-beta6"} {
|
||||
if ValidateVersion(v) != nil {
|
||||
t.Fatal(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestPersistentLockAcrossManagers(t *testing.T) {
|
||||
m, p := fixture(t)
|
||||
fakeDocker(t, "exit 1\n")
|
||||
_, _, e := m.Start(context.Background(), "2.5.0", nil)
|
||||
if e == nil {
|
||||
t.Fatal("accepted concurrent job")
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(m.root(), "latest"))
|
||||
if string(b) != p.Job.ID {
|
||||
t.Fatal("changed active job")
|
||||
}
|
||||
}
|
||||
func TestUncertainHelperNeverUnlocks(t *testing.T) {
|
||||
m, p := fixture(t)
|
||||
j, _ := m.read(p.Job.ID)
|
||||
j.UpdatedAt = 1
|
||||
save(filepath.Join(m.jobDir(j.ID), "status.json"), j)
|
||||
fakeDocker(t, "exit 1\n")
|
||||
j, e := m.Status(context.Background(), "")
|
||||
if e != nil || !j.Active || !j.RequiresRecovery || j.Stage != "recovery_required" {
|
||||
t.Fatalf("%+v %v", j, e)
|
||||
}
|
||||
if _, _, e = m.Start(context.Background(), "2.5.0", nil); e == nil {
|
||||
t.Fatal("uncertain launch unlocked")
|
||||
}
|
||||
}
|
||||
func configs(p Plan) (string, string) {
|
||||
old := composeConfig{}
|
||||
json.Unmarshal([]byte(`{"services":{"backend":{"image":"old","container_name":"flux-panel-backend","environment":{"FLUX_VERSION":"old"}},"frontend":{"image":"old","container_name":"vite-frontend"}}}`), &old)
|
||||
a, _ := json.Marshal(old)
|
||||
next := old
|
||||
next.Services = make(map[string]struct {
|
||||
Image string `json:"image"`
|
||||
ContainerName string `json:"container_name"`
|
||||
Volumes []map[string]any `json:"volumes"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
})
|
||||
for k, v := range old.Services {
|
||||
next.Services[k] = v
|
||||
}
|
||||
v := next.Services["backend"]
|
||||
v.Image = "ghcr.io/sagit-chu/flux-panel-backend:" + p.Job.TargetVersion
|
||||
v.Environment = map[string]string{"FLUX_VERSION": p.Job.TargetVersion}
|
||||
next.Services["backend"] = v
|
||||
v = next.Services["frontend"]
|
||||
v.Image = "ghcr.io/sagit-chu/vite-frontend:" + p.Job.TargetVersion
|
||||
next.Services["frontend"] = v
|
||||
b, _ := json.Marshal(next)
|
||||
return string(a), string(b)
|
||||
}
|
||||
func TestHelperFailureBoundaries(t *testing.T) {
|
||||
for _, stage := range []string{"config", "pull", "apply", "up", "health", "success"} {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
m, p := fixture(t)
|
||||
old, next := configs(p)
|
||||
dir := m.jobDir(p.Job.ID)
|
||||
if stage == "apply" {
|
||||
os.Remove(filepath.Join(dir, ".env"))
|
||||
os.Mkdir(filepath.Join(dir, ".env"), 0700)
|
||||
}
|
||||
if stage == "health" {
|
||||
before := verificationTimeout
|
||||
verificationTimeout = 0
|
||||
t.Cleanup(func() { verificationTimeout = before })
|
||||
}
|
||||
script := `case "$*" in
|
||||
*"config --format json"*)
|
||||
case "$*" in *"` + p.Job.ID + `"*) echo '` + next + `';; *) echo '` + old + `';; esac
|
||||
;;
|
||||
*"pull backend frontend"*) `
|
||||
if stage == "pull" {
|
||||
script += "echo SECRET >&2; exit 1"
|
||||
} else {
|
||||
script += "exit 0"
|
||||
}
|
||||
script += `;;
|
||||
"image inspect"*) echo sha256:target;;
|
||||
*"up -d"*) `
|
||||
if stage == "up" {
|
||||
script += "exit 1"
|
||||
} else {
|
||||
script += "touch \"" + filepath.Join(dir, "recreated") + "\"; exit 0"
|
||||
}
|
||||
script += `;;
|
||||
"inspect flux-panel-backend") if [ -f "` + filepath.Join(dir, "recreated") + `" ]; then IMG=sha256:target; else IMG=sha256:old; fi; echo '[{"Image":"'"$IMG"'","State":{"Running":true,"Health":{"Status":"healthy"}},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}}}]';;
|
||||
"inspect vite-frontend") echo '[{"Image":"sha256:target","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"frontend"}}}]';;
|
||||
"exec vite-frontend wget"*) exit 0;;
|
||||
*) exit 1;; esac
|
||||
`
|
||||
if stage == "config" {
|
||||
script = "echo SECRET >&2;exit 1\n"
|
||||
}
|
||||
fakeDocker(t, script)
|
||||
e := RunHelper(filepath.Join(dir, "plan.json"))
|
||||
j, _ := m.read(p.Job.ID)
|
||||
want := "failed"
|
||||
if stage == "up" || stage == "health" {
|
||||
want = "recovery_required"
|
||||
}
|
||||
if stage == "success" {
|
||||
want = "succeeded"
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
if j.Stage != want {
|
||||
t.Fatalf("stage=%s error=%v", j.Stage, e)
|
||||
}
|
||||
for _, n := range []string{".env", "docker-compose.yml"} {
|
||||
b, _ := os.ReadFile(filepath.Join(m.Dir, n))
|
||||
expected := "original"
|
||||
if stage == "up" || stage == "success" || stage == "health" {
|
||||
expected = "target"
|
||||
}
|
||||
if string(b) != expected {
|
||||
t.Fatalf("%s mutated unexpectedly", n)
|
||||
}
|
||||
b, _ = os.ReadFile(filepath.Join(dir, n+".original"))
|
||||
if string(b) != "original" {
|
||||
t.Fatal("backup overwritten")
|
||||
}
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "events.log"))
|
||||
if strings.Contains(string(b), "SECRET") {
|
||||
t.Fatal("secret leaked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestComposeUsesOriginalHostPathAndProject(t *testing.T) {
|
||||
p := Plan{Project: "my-project", HostDir: "/srv/panel"}
|
||||
s := strings.Join(composeArgs(p, "/srv/panel/.panel-upgrades/job", "pull", "backend", "frontend"), " ")
|
||||
if !strings.Contains(s, "--project-name my-project --project-directory /srv/panel") || strings.Contains(s, "remove-orphans") {
|
||||
t.Fatal(s)
|
||||
}
|
||||
}
|
||||
func TestStatusWithoutJobNeedsNoDocker(t *testing.T) {
|
||||
fakeDocker(t, "exit 99\n")
|
||||
j, e := (Manager{Dir: t.TempDir()}).Status(context.Background(), "")
|
||||
if e != nil || j != nil {
|
||||
t.Fatalf("%v %v", j, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullConfigRejectsUnintendedChanges(t *testing.T) {
|
||||
for _, field := range []string{"ports", "network_mode", "command", "entrypoint", "privileged", "networks"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
_, p := fixture(t)
|
||||
oldJSON, nextJSON := configs(p)
|
||||
var old, next composeConfig
|
||||
json.Unmarshal([]byte(oldJSON), &old)
|
||||
json.Unmarshal([]byte(nextJSON), &next)
|
||||
var a, b struct {
|
||||
Services map[string]map[string]any `json:"services"`
|
||||
}
|
||||
json.Unmarshal([]byte(oldJSON), &a)
|
||||
json.Unmarshal([]byte(nextJSON), &b)
|
||||
old.FullServices = a.Services
|
||||
next.FullServices = b.Services
|
||||
old.FullServices["frontend"][field] = "private-custom-setting"
|
||||
if validateConfig(old, next, p) == nil {
|
||||
t.Fatal("accepted service setting removal: " + field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestLiveRejectsShadowMount(t *testing.T) {
|
||||
_, p := fixture(t)
|
||||
fakeDocker(t, `echo '[{"Image":"sha256:old","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}},"Mounts":[{"Type":"bind","Source":"/srv/db/gost.db","Destination":"/app/data/gost.db","RW":true}]}]'`)
|
||||
oldJSON, _ := configs(p)
|
||||
var old composeConfig
|
||||
json.Unmarshal([]byte(oldJSON), &old)
|
||||
if validateLive(context.Background(), p, old) == nil {
|
||||
t.Fatal("accepted undeclared shadow mount")
|
||||
}
|
||||
}
|
||||
func identityJSON(dir, host, project, files string) string {
|
||||
c := map[string]any{"Image": "sha256:old", "State": map[string]any{"Running": true}, "Config": map[string]any{"Labels": map[string]string{"com.docker.compose.project": project, "com.docker.compose.service": "backend", "com.docker.compose.project.working_dir": host, "com.docker.compose.project.config_files": files}}, "Mounts": []map[string]any{{"Type": "bind", "Source": host, "Destination": dir, "RW": true}}}
|
||||
b, _ := json.Marshal([]any{c})
|
||||
return string(b)
|
||||
}
|
||||
func TestStartUsesHostMountAndKeepsUniqueBackups(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := Manager{Dir: dir, Backend: "backend"}
|
||||
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
|
||||
os.WriteFile(filepath.Join(dir, n), []byte("original-secret"), 0600)
|
||||
}
|
||||
args := filepath.Join(t.TempDir(), "args")
|
||||
fakeDocker(t, `case "$1" in inspect) echo '`+identityJSON(dir, "/srv/original-panel", "original-project", "/srv/original-panel/docker-compose.yml")+`';; run) printf '%s\n' "$@" > '`+args+`'; echo container-id;; *) exit 1;; esac`)
|
||||
j, _, e := m.Start(context.Background(), "2.5.0-rc10", []byte("target"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
b, _ := os.ReadFile(args)
|
||||
s := string(b)
|
||||
for _, expected := range []string{"type=bind,src=/srv/original-panel,dst=/srv/original-panel", "/app/paneld", "sha256:old", "panel-upgrade-helper"} {
|
||||
if !strings.Contains(s, expected) {
|
||||
t.Fatal(s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, "--rm") || strings.Contains(s, "--volumes-from") {
|
||||
t.Fatal(s)
|
||||
}
|
||||
first := j.ID
|
||||
m.write(j, "failed", "safe failure")
|
||||
j, _, e = m.Start(context.Background(), "2.5.1", []byte("target2"))
|
||||
if e != nil || j.ID == first {
|
||||
t.Fatalf("%v %v", j, e)
|
||||
}
|
||||
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
|
||||
b, _ := os.ReadFile(filepath.Join(dir, n))
|
||||
if string(b) != "original-secret" {
|
||||
t.Fatal("live or legacy overwritten")
|
||||
}
|
||||
}
|
||||
b, _ = os.ReadFile(filepath.Join(m.jobDir(first), ".env.original"))
|
||||
if string(b) != "original-secret" {
|
||||
t.Fatal("old job backup changed")
|
||||
}
|
||||
}
|
||||
func TestIdentityFailsClosed(t *testing.T) {
|
||||
for _, host := range []string{"/", "/app", "/usr", "/var/run", "/proc", "relative", "/srv/bad,host"} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fakeDocker(t, "echo '"+identityJSON(dir, host, "project", filepath.Join(host, "docker-compose.yml"))+"'\n")
|
||||
if _, e := (Manager{Dir: dir, Backend: "backend"}).Identity(context.Background()); e == nil {
|
||||
t.Fatal(host)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestUncertainRunIsNotRetriedOrUnlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := Manager{Dir: dir, Backend: "backend"}
|
||||
for _, n := range []string{".env", "docker-compose.yml"} {
|
||||
os.WriteFile(filepath.Join(dir, n), []byte("original"), 0600)
|
||||
}
|
||||
calls := filepath.Join(t.TempDir(), "runs")
|
||||
fakeDocker(t, `case "$*" in "inspect backend") echo '`+identityJSON(dir, "/srv/panel", "project", "/srv/panel/docker-compose.yml")+`';; run*) echo run >> '`+calls+`'; exit 1;; *) exit 1;; esac`)
|
||||
j, _, e := m.Start(context.Background(), "2.5.0", []byte("target"))
|
||||
if e == nil || j == nil || !j.Active {
|
||||
t.Fatalf("%v %v", j, e)
|
||||
}
|
||||
_, _, e = m.Start(context.Background(), "2.5.0", nil)
|
||||
if e == nil {
|
||||
t.Fatal("retry accepted")
|
||||
}
|
||||
b, _ := os.ReadFile(calls)
|
||||
if string(b) != "run\n" {
|
||||
t.Fatal("retried uncertain docker run")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package relaysecurity defines the versioned, endpoint-scoped relay contract.
|
||||
// It authenticates relay clients, not the confidentiality of their transport.
|
||||
package relaysecurity
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = 1
|
||||
const Required = "required"
|
||||
const Legacy = "legacy"
|
||||
|
||||
type Credentials struct {
|
||||
Version int `json:"version"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func New() (*Credentials, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, errors.New("relay credential randomness unavailable")
|
||||
}
|
||||
u := make([]byte, 12)
|
||||
if _, err := rand.Read(u); err != nil {
|
||||
return nil, errors.New("relay credential randomness unavailable")
|
||||
}
|
||||
return &Credentials{Version: Version, Username: "relay-" + hex.EncodeToString(u), Password: hex.EncodeToString(b)}, nil
|
||||
}
|
||||
func (c *Credentials) Validate() error {
|
||||
if c == nil || c.Version != Version || strings.TrimSpace(c.Username) == "" || len(c.Username) > 255 || len(c.Password) < 32 || len(c.Password) > 255 {
|
||||
return errors.New("invalid required relay authentication")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Credentials) Config() map[string]interface{} {
|
||||
return map[string]interface{}{"username": c.Username, "password": c.Password}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import "database/sql"
|
||||
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
||||
// word, so TableName() is required for correct quoting.
|
||||
type User struct {
|
||||
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
User string `gorm:"column:user;type:varchar(100);not null"`
|
||||
Pwd string `gorm:"type:varchar(100);not null"`
|
||||
@@ -151,14 +152,15 @@ type NftCounterState struct {
|
||||
func (NftCounterState) TableName() string { return "nft_counter_state" }
|
||||
|
||||
type SpeedLimit struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SpeedLimit) TableName() string { return "speed_limit" }
|
||||
@@ -175,6 +177,7 @@ type StatisticsFlow struct {
|
||||
func (StatisticsFlow) TableName() string { return "statistics_flow" }
|
||||
|
||||
type Tunnel struct {
|
||||
RelayAuthMode string `gorm:"column:relay_auth_mode;type:varchar(20);not null;default:legacy"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"`
|
||||
@@ -354,24 +357,26 @@ type PeerShare struct {
|
||||
func (PeerShare) TableName() string { return "peer_share" }
|
||||
|
||||
type PeerShareRuntime struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
|
||||
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
||||
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
|
||||
Role string `gorm:"type:text;not null;default:''"`
|
||||
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
|
||||
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
|
||||
Protocol string `gorm:"type:text;not null;default:'tls'"`
|
||||
Strategy string `gorm:"type:text;not null;default:'round'"`
|
||||
Port int `gorm:"not null;default:0"`
|
||||
Target string `gorm:"type:text;not null;default:''"`
|
||||
Applied int `gorm:"not null;default:0"`
|
||||
ReleasePending int `gorm:"column:release_pending;not null;default:0"`
|
||||
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
RelayAuthVersion int `gorm:"column:relay_auth_version;not null;default:0" json:"-"`
|
||||
RelayAuth string `gorm:"column:relay_auth;type:text;not null;default:''" json:"-"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
|
||||
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
||||
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
|
||||
Role string `gorm:"type:text;not null;default:''"`
|
||||
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
|
||||
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
|
||||
Protocol string `gorm:"type:text;not null;default:'tls'"`
|
||||
Strategy string `gorm:"type:text;not null;default:'round'"`
|
||||
Port int `gorm:"not null;default:0"`
|
||||
Target string `gorm:"type:text;not null;default:''" json:"-"`
|
||||
Applied int `gorm:"not null;default:0"`
|
||||
ReleasePending int `gorm:"column:release_pending;not null;default:0"`
|
||||
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (PeerShareRuntime) TableName() string { return "peer_share_runtime" }
|
||||
@@ -414,6 +419,7 @@ type BackupData struct {
|
||||
}
|
||||
|
||||
type UserBackup struct {
|
||||
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
User string `json:"user"`
|
||||
Pwd string `json:"pwd"`
|
||||
@@ -464,6 +470,7 @@ type NodeBackup struct {
|
||||
}
|
||||
|
||||
type TunnelBackup struct {
|
||||
RelayAuthMode string `json:"relayAuthMode,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrafficRatio float64 `json:"trafficRatio"`
|
||||
@@ -536,14 +543,15 @@ type UserTunnelBackup struct {
|
||||
}
|
||||
|
||||
type SpeedLimitBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speed int64 `json:"speed"`
|
||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||
TunnelName string `json:"tunnelName,omitempty"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speed int64 `json:"speed"`
|
||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||
TunnelName string `json:"tunnelName,omitempty"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type TunnelGroupBackup struct {
|
||||
@@ -621,6 +629,7 @@ type ForwardRecord struct {
|
||||
|
||||
// TunnelRecord is a minimal tunnel view used by control plane.
|
||||
type TunnelRecord struct {
|
||||
RelayAuthMode string
|
||||
ID int64
|
||||
Type int
|
||||
Status int
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
// RelayEndpointAuth is private desired state, never an API or backup DTO.
|
||||
// Retained endpoint identities keep their credentials across topology edits.
|
||||
type RelayEndpointAuth struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"-"`
|
||||
TunnelID int64 `gorm:"not null;uniqueIndex:idx_relay_endpoint" json:"-"`
|
||||
NodeID int64 `gorm:"not null;uniqueIndex:idx_relay_endpoint" json:"-"`
|
||||
ChainType int `gorm:"not null;uniqueIndex:idx_relay_endpoint" json:"-"`
|
||||
Version int `gorm:"not null" json:"-"`
|
||||
Username string `gorm:"type:text;not null" json:"-"`
|
||||
Password string `gorm:"type:text;not null" json:"-"`
|
||||
}
|
||||
|
||||
func (RelayEndpointAuth) TableName() string { return "relay_endpoint_auth" }
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
// TunnelRuntimeCleanup retains ownership until an obsolete local runtime is
|
||||
// acknowledged removed. It intentionally survives deletion of its tunnel.
|
||||
type TunnelRuntimeCleanup struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
TunnelID int64 `gorm:"not null;uniqueIndex:idx_tunnel_cleanup"`
|
||||
NodeID int64 `gorm:"not null;uniqueIndex:idx_tunnel_cleanup"`
|
||||
Kind string `gorm:"type:varchar(16);not null;uniqueIndex:idx_tunnel_cleanup"`
|
||||
Port int `gorm:"not null;default:0;uniqueIndex:idx_tunnel_cleanup"`
|
||||
}
|
||||
|
||||
func (TunnelRuntimeCleanup) TableName() string { return "tunnel_runtime_cleanup" }
|
||||
@@ -299,6 +299,8 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
&model.SpeedLimit{},
|
||||
&model.StatisticsFlow{},
|
||||
&model.Tunnel{},
|
||||
&model.RelayEndpointAuth{},
|
||||
&model.TunnelRuntimeCleanup{},
|
||||
&model.ChainTunnel{},
|
||||
&model.UserTunnel{},
|
||||
&model.TunnelGroup{},
|
||||
@@ -417,7 +419,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
if m.HasTable(&model.Tunnel{}) {
|
||||
for _, field := range []string{"Inx", "IPPreference", "ProbeTargetHost", "ProbeTargetPort"} {
|
||||
for _, field := range []string{"Inx", "IPPreference", "ProbeTargetHost", "ProbeTargetPort", "RelayAuthMode"} {
|
||||
if m.HasColumn(&model.Tunnel{}, field) {
|
||||
continue
|
||||
}
|
||||
@@ -912,7 +914,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var users []model.User
|
||||
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
|
||||
if err := r.db.Where("role_id != ?", 0).Order("display_order ASC, id DESC").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs := make([]int64, 0, len(users))
|
||||
@@ -952,7 +954,7 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var limits []model.SpeedLimit
|
||||
if err := r.db.Order("id DESC").Find(&limits).Error; err != nil {
|
||||
if err := r.db.Order("display_order ASC, id DESC").Find(&limits).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(limits))
|
||||
@@ -999,7 +1001,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
|
||||
var rows []fwdRow
|
||||
err := r.db.Model(&model.Forward{}).
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send").
|
||||
Select("forward.id, forward.user_id, COALESCE(owner.\"user\", forward.user_name) AS user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send").
|
||||
Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`).
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||
Joins("LEFT JOIN speed_limit AS ip_speed_limit ON ip_speed_limit.id = forward.ip_speed_id").
|
||||
Order("forward.inx ASC, forward.id ASC").
|
||||
@@ -1252,7 +1255,8 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
||||
tunnelMap[t.ID] = map[string]interface{}{
|
||||
"id": t.ID, "inx": t.Inx, "name": t.Name,
|
||||
"type": t.Type, "flow": t.Flow, "trafficRatio": t.TrafficRatio,
|
||||
"status": t.Status, "createdTime": t.CreatedTime,
|
||||
"relayAuthMode": t.RelayAuthMode,
|
||||
"status": t.Status, "createdTime": t.CreatedTime,
|
||||
"inIp": nullableString(t.InIP),
|
||||
"ipPreference": t.IPPreference,
|
||||
"probeTargetHost": t.ProbeTargetHost,
|
||||
@@ -1616,8 +1620,10 @@ func (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error
|
||||
return errors.New("runtime item is nil")
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||||
"reservation_id": item.ReservationID,
|
||||
"binding_id": item.BindingID, "role": item.Role,
|
||||
"reservation_id": item.ReservationID,
|
||||
"relay_auth": item.RelayAuth,
|
||||
"relay_auth_version": item.RelayAuthVersion,
|
||||
"binding_id": item.BindingID, "role": item.Role,
|
||||
"chain_name": item.ChainName, "service_name": item.ServiceName,
|
||||
"protocol": item.Protocol, "strategy": item.Strategy,
|
||||
"port": item.Port, "target": item.Target,
|
||||
@@ -2087,7 +2093,8 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
|
||||
out := make([]model.UserBackup, 0, len(users))
|
||||
for _, u := range users {
|
||||
b := model.UserBackup{
|
||||
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
|
||||
DisplayOrder: &u.DisplayOrder,
|
||||
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
|
||||
ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, InFlow: u.InFlow, OutFlow: u.OutFlow,
|
||||
FlowResetTime: u.FlowResetTime, Num: u.Num,
|
||||
CreatedTime: u.CreatedTime, Status: u.Status,
|
||||
@@ -2161,7 +2168,8 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {
|
||||
out := make([]model.TunnelBackup, 0, len(tunnels))
|
||||
for _, t := range tunnels {
|
||||
b := model.TunnelBackup{
|
||||
ID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,
|
||||
RelayAuthMode: t.RelayAuthMode,
|
||||
ID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,
|
||||
Type: t.Type, Protocol: t.Protocol, Flow: t.Flow,
|
||||
CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,
|
||||
Status: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,
|
||||
@@ -2283,7 +2291,8 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
|
||||
out := make([]model.SpeedLimitBackup, 0, len(sls))
|
||||
for _, sl := range sls {
|
||||
b := model.SpeedLimitBackup{
|
||||
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
||||
DisplayOrder: &sl.DisplayOrder,
|
||||
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
||||
CreatedTime: sl.CreatedTime, Status: sl.Status,
|
||||
}
|
||||
if sl.UpdatedTime.Valid {
|
||||
@@ -2366,7 +2375,24 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
||||
typeSet[t] = true
|
||||
}
|
||||
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
err := backupImportTransaction(r.db, func(tx *gorm.DB) error {
|
||||
result = &model.ImportResult{}
|
||||
tables, err := lockPostgresBackupTables(tx, backup, typeSet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// SQLite still needs its writer reservation for display order.
|
||||
// PostgreSQL membership locks were taken with all imported tables above.
|
||||
if tx.Dialector.Name() != "postgres" && typeSet["users"] {
|
||||
if err := lockDisplayOrder(tx, "user"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Dialector.Name() != "postgres" && typeSet["speedLimits"] {
|
||||
if err := lockDisplayOrder(tx, "speed_limit"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := unixMilliNow()
|
||||
|
||||
if typeSet["users"] && len(backup.Users) > 0 {
|
||||
@@ -2439,7 +2465,7 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
||||
}
|
||||
result.ConfigsImported = count
|
||||
}
|
||||
return nil
|
||||
return advancePostgresBackupSequences(tx, tables)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2471,12 +2497,17 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
||||
Status: status,
|
||||
PasswordChangedAt: now,
|
||||
}
|
||||
// An omitted old-backup rank preserves existing order on conflict.
|
||||
// New legacy records use rank zero and ID descending, matching upgrades.
|
||||
columns := []string{"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
|
||||
"flow_reset_time", "num", "updated_time", "status", "password_changed_at"}
|
||||
if u.DisplayOrder != nil {
|
||||
item.DisplayOrder = *u.DisplayOrder
|
||||
columns = append(columns, "display_order")
|
||||
}
|
||||
err = tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
|
||||
"flow_reset_time", "num", "updated_time", "status", "password_changed_at",
|
||||
}),
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns(columns),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return count, err
|
||||
@@ -2595,6 +2626,18 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
||||
func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {
|
||||
count := 0
|
||||
for _, t := range tunnels {
|
||||
if t.RelayAuthMode != "" && t.RelayAuthMode != "legacy" {
|
||||
return count, errors.New("authenticated relay tunnels require a full database restore; configuration backup has no private relay credentials")
|
||||
}
|
||||
var existing model.Tunnel
|
||||
lookupErr := tx.First(&existing, t.ID).Error
|
||||
if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
|
||||
return count, lookupErr
|
||||
}
|
||||
if lookupErr == nil && existing.RelayAuthMode == "required" {
|
||||
return count, errors.New("configuration import cannot replace an authenticated relay tunnel")
|
||||
}
|
||||
|
||||
item := model.Tunnel{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
@@ -2747,11 +2790,16 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: sl.Status,
|
||||
}
|
||||
// An omitted old-backup rank preserves existing order on conflict.
|
||||
// New legacy records use rank zero and ID descending, matching upgrades.
|
||||
columns := []string{"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status"}
|
||||
if sl.DisplayOrder != nil {
|
||||
item.DisplayOrder = *sl.DisplayOrder
|
||||
columns = append(columns, "display_order")
|
||||
}
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status",
|
||||
}),
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns(columns),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return count, err
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var errBackupImportBusy = errors.New("database is busy; retry the backup import")
|
||||
|
||||
// Only retry lock acquisition failures, which occur before any backup writes.
|
||||
// NOWAIT releases partial lock sets instead of deadlocking with existing
|
||||
// child-before-parent deletes or forward-before-user traffic updates.
|
||||
func backupImportTransaction(db *gorm.DB, importRows func(*gorm.DB) error) error {
|
||||
var err error
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
err = db.Transaction(importRows)
|
||||
if !errors.Is(err, errBackupImportBusy) {
|
||||
return err
|
||||
}
|
||||
if attempt < 9 {
|
||||
time.Sleep(min(10*time.Millisecond<<attempt, 100*time.Millisecond))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Lock every table that import can write, including generated-ID child tables.
|
||||
// No writes happen until the complete set is held. Existing CRUD transactions
|
||||
// can therefore finish regardless of their own parent/child mutation order.
|
||||
// The returned subset contains only tables receiving explicit backup IDs.
|
||||
func lockPostgresBackupTables(tx *gorm.DB, backup *model.BackupData, types map[string]bool) ([]string, error) {
|
||||
if tx.Dialector.Name() != "postgres" {
|
||||
return nil, nil
|
||||
}
|
||||
chains, grants, ports := 0, 0, 0
|
||||
for _, tunnel := range backup.Tunnels {
|
||||
chains += len(tunnel.ChainTunnels)
|
||||
}
|
||||
for _, permission := range backup.Permissions {
|
||||
grants += len(permission.Grants)
|
||||
}
|
||||
for _, forward := range backup.Forwards {
|
||||
if forward.ForwardPorts != nil {
|
||||
ports++
|
||||
}
|
||||
}
|
||||
var tables []string
|
||||
for _, item := range []struct {
|
||||
kind string
|
||||
count int
|
||||
name string
|
||||
explicitIDs bool
|
||||
}{
|
||||
{"users", len(backup.Users), "user", true},
|
||||
{"speedLimits", len(backup.SpeedLimits), "speed_limit", true},
|
||||
{"nodes", len(backup.Nodes), "node", true},
|
||||
{"tunnels", len(backup.Tunnels), "tunnel", true},
|
||||
{"tunnels", chains, "chain_tunnel", true},
|
||||
{"forwards", len(backup.Forwards), "forward", true},
|
||||
{"userTunnels", len(backup.UserTunnels), "user_tunnel", true},
|
||||
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group", true},
|
||||
{"userGroups", len(backup.UserGroups), "user_group", true},
|
||||
{"permissions", len(backup.Permissions), "group_permission", true},
|
||||
{"permissions", grants, "group_permission_grant", true},
|
||||
{"users", len(backup.Users), "user_quota", false},
|
||||
{"forwards", ports, "forward_port", false},
|
||||
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group_tunnel", false},
|
||||
{"userGroups", len(backup.UserGroups), "user_group_user", false},
|
||||
{"configs", len(FilterBackupConfigs(backup.Configs)), "vite_config", false},
|
||||
} {
|
||||
if !types[item.kind] || item.count == 0 {
|
||||
continue
|
||||
}
|
||||
// Resolve the same relation as normal unqualified repository queries,
|
||||
// even when the first search_path schema has no copy of this table.
|
||||
var relation struct {
|
||||
Schema string
|
||||
Name string
|
||||
}
|
||||
if err := tx.Raw(`SELECT n.nspname AS schema, c.relname AS name
|
||||
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.oid = pg_catalog.to_regclass(?)`, quoteSQLIdentifier(item.name)).Scan(&relation).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if relation.Name == "" {
|
||||
return nil, fmt.Errorf("missing import table %s", item.name)
|
||||
}
|
||||
table := quoteSQLIdentifier(relation.Schema) + "." + quoteSQLIdentifier(relation.Name)
|
||||
if err := tx.Exec("LOCK TABLE " + table + " IN SHARE ROW EXCLUSIVE MODE NOWAIT").Error; err != nil {
|
||||
var pgError interface{ SQLState() string }
|
||||
if errors.As(err, &pgError) && pgError.SQLState() == "55P03" {
|
||||
return nil, fmt.Errorf("%w: %w", errBackupImportBusy, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if item.explicitIDs {
|
||||
tables = append(tables, table)
|
||||
}
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func advancePostgresBackupSequences(tx *gorm.DB, tables []string) error {
|
||||
for _, table := range tables {
|
||||
if err := advancePostgresBackupSequence(tx, table); err != nil {
|
||||
return fmt.Errorf("advance imported %s id sequence: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The caller holds the table's SHARE ROW EXCLUSIVE lock until commit. Unlike
|
||||
// startup's setval repair, RESTART is transactional and invalidates other
|
||||
// connections' cached values. Never return to IDs already allocated, even if
|
||||
// their rows were deleted or their transactions rolled back.
|
||||
func advancePostgresBackupSequence(tx *gorm.DB, table string) error {
|
||||
var maxID int64
|
||||
if err := tx.Raw("SELECT COALESCE(MAX(id), 0) FROM " + table).Scan(&maxID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if maxID <= 0 {
|
||||
return nil
|
||||
}
|
||||
type sequenceInfo struct {
|
||||
Schema string
|
||||
Name string
|
||||
Cache int64
|
||||
Increment int64
|
||||
Maximum int64
|
||||
}
|
||||
var sequences []sequenceInfo
|
||||
// Prefer the actual default's catalog dependency: a legacy custom nextval
|
||||
// default need not own its sequence, and the old owned serial may remain.
|
||||
// Identity columns have no pg_attrdef, so use their owned sequence instead.
|
||||
if err := tx.Raw(`WITH defaults AS (
|
||||
SELECT dep.refobjid AS oid FROM pg_catalog.pg_attrdef def
|
||||
JOIN pg_catalog.pg_attribute attr ON attr.attrelid = def.adrelid AND attr.attnum = def.adnum
|
||||
JOIN pg_catalog.pg_depend dep ON dep.classid = 'pg_catalog.pg_attrdef'::regclass
|
||||
AND dep.objid = def.oid AND dep.refclassid = 'pg_catalog.pg_class'::regclass
|
||||
JOIN pg_catalog.pg_sequence seq ON seq.seqrelid = dep.refobjid
|
||||
WHERE def.adrelid = ?::regclass AND attr.attname = 'id'
|
||||
), candidates AS (
|
||||
SELECT oid FROM defaults
|
||||
UNION SELECT pg_catalog.pg_get_serial_sequence(?, 'id')::regclass WHERE NOT EXISTS (SELECT 1 FROM defaults)
|
||||
)
|
||||
SELECT n.nspname AS schema, c.relname AS name,
|
||||
s.seqcache AS cache, s.seqincrement AS increment, s.seqmax AS maximum
|
||||
FROM pg_catalog.pg_sequence s
|
||||
JOIN pg_catalog.pg_class c ON c.oid = s.seqrelid
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE s.seqrelid IN (SELECT oid FROM candidates)`, table, table).Scan(&sequences).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sequences) != 1 || sequences[0].Increment <= 0 {
|
||||
return fmt.Errorf("missing or ambiguous ascending ID sequence")
|
||||
}
|
||||
sequence := sequences[0]
|
||||
name := quoteSQLIdentifier(sequence.Schema) + "." + quoteSQLIdentifier(sequence.Name)
|
||||
// Reapplying CACHE without changing its setting takes a sequence DDL lock.
|
||||
// Read last_value only after that lock: even direct concurrent nextval calls
|
||||
// must finish first. The table locks already exclude normal INSERTs.
|
||||
if err := tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s CACHE %d", name, sequence.Cache)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var state struct {
|
||||
LastValue int64
|
||||
IsCalled bool
|
||||
}
|
||||
if err := tx.Raw("SELECT last_value, is_called FROM " + name).Scan(&state).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if state.LastValue < 1 {
|
||||
return fmt.Errorf("ID sequence must allocate positive IDs")
|
||||
}
|
||||
if maxID >= sequence.Maximum || (state.IsCalled && state.LastValue > sequence.Maximum-sequence.Increment) {
|
||||
return fmt.Errorf("ID sequence exhausted")
|
||||
}
|
||||
next := state.LastValue
|
||||
if state.IsCalled {
|
||||
next += sequence.Increment
|
||||
}
|
||||
if next <= maxID {
|
||||
steps := (maxID-next)/sequence.Increment + 1
|
||||
if steps > (sequence.Maximum-next)/sequence.Increment {
|
||||
return fmt.Errorf("ID sequence exhausted")
|
||||
}
|
||||
next += steps * sequence.Increment
|
||||
}
|
||||
return tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s RESTART WITH %d", name, next)).Error
|
||||
}
|
||||
@@ -37,8 +37,14 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
||||
if tx == nil {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
var forwards []struct {
|
||||
model.Forward
|
||||
CurrentUserName string
|
||||
}
|
||||
err := tx.Model(&model.Forward{}).
|
||||
Select(`forward.*, COALESCE(owner."user", forward.user_name) AS current_user_name`).
|
||||
Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`).
|
||||
Where("forward.tunnel_id = ?", tunnelID).Order("forward.id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -48,7 +54,7 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
UserName: f.CurrentUserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var ErrDisplayOrderConflict = errors.New("列表已变更,请刷新后重试")
|
||||
var ErrInvalidDisplayOrder = errors.New("排序必须包含全部条目且不能重复")
|
||||
|
||||
// Lock the entire membership, including an empty list and concurrent INSERTs.
|
||||
// PostgreSQL row locks alone cannot protect the missing-row/phantom case.
|
||||
// SQLite's first write acquires its writer reservation before reading the list.
|
||||
func lockDisplayOrder(tx *gorm.DB, table string) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
return tx.Exec("LOCK TABLE \"" + table + "\" IN SHARE ROW EXCLUSIVE MODE").Error
|
||||
}
|
||||
return tx.Table(table).Where("1 = 0").UpdateColumn("display_order", gorm.Expr("display_order")).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUserDisplayOrder(ids, expected []int64) error {
|
||||
return r.updateDisplayOrder("user", ids, expected)
|
||||
}
|
||||
func (r *Repository) UpdateSpeedLimitDisplayOrder(ids, expected []int64) error {
|
||||
return r.updateDisplayOrder("speed_limit", ids, expected)
|
||||
}
|
||||
func (r *Repository) updateDisplayOrder(table string, ids, expected []int64) error {
|
||||
if ids == nil || expected == nil {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
for _, id := range ids {
|
||||
if id <= 0 || seen[id] {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
seenExpected := map[int64]bool{}
|
||||
for _, id := range expected {
|
||||
if id <= 0 || seenExpected[id] {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
seenExpected[id] = true
|
||||
}
|
||||
if len(ids) != len(expected) {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
for id := range seen {
|
||||
if !seenExpected[id] {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockDisplayOrder(tx, table); err != nil {
|
||||
return err
|
||||
}
|
||||
q := tx.Table(table)
|
||||
if table == "user" {
|
||||
var admins int64
|
||||
if err := tx.Table(table).Where("role_id = ? AND id IN ?", 0, ids).Count(&admins).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if admins != 0 {
|
||||
return ErrInvalidDisplayOrder
|
||||
}
|
||||
q = q.Where("role_id != ?", 0)
|
||||
}
|
||||
var current []int64
|
||||
if err := q.Order("display_order ASC, id DESC").Pluck("id", ¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Equal(current, expected) {
|
||||
return ErrDisplayOrderConflict
|
||||
}
|
||||
for i, id := range ids {
|
||||
result := tx.Table(table).Where("id = ?", id).UpdateColumn("display_order", int64(i))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrDisplayOrderConflict
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// New items precede all saved ranks. Zero is only the legacy migration default.
|
||||
func createFirst(tx *gorm.DB, table string, item interface{}, rank *int64) error {
|
||||
return tx.Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockDisplayOrder(tx, table); err != nil {
|
||||
return err
|
||||
}
|
||||
q := tx.Table(table)
|
||||
if table == "user" {
|
||||
q = q.Where("role_id != ?", 0)
|
||||
}
|
||||
var first int64
|
||||
if err := q.Select("COALESCE(MIN(display_order), 0)").Scan(&first).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if first == -1<<63 {
|
||||
return errors.New("display order exhausted")
|
||||
}
|
||||
*rank = first - 1
|
||||
return tx.Create(item).Error
|
||||
})
|
||||
}
|
||||
@@ -202,6 +202,13 @@ func (r *Repository) ListUsedPortsOnNode(nodeID int64) ([]int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
pendingPorts, err := r.PendingTunnelCleanupPortsTx(nil, nodeID, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, port := range pendingPorts {
|
||||
used[port] = struct{}{}
|
||||
}
|
||||
result := make([]int, 0, len(used))
|
||||
for p := range used {
|
||||
result = append(result, p)
|
||||
|
||||
@@ -272,6 +272,7 @@ func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error
|
||||
return nil, err
|
||||
}
|
||||
tr := model.TunnelRecord{
|
||||
RelayAuthMode: t.RelayAuthMode,
|
||||
ID: t.ID,
|
||||
Type: t.Type,
|
||||
Status: t.Status,
|
||||
|
||||
@@ -65,7 +65,7 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
|
||||
Status: status,
|
||||
PasswordChangedAt: now,
|
||||
}
|
||||
if err := r.db.Create(&user).Error; err != nil {
|
||||
if err := createFirst(r.db, "user", &user, &user.DisplayOrder); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return user.ID, nil
|
||||
@@ -151,6 +151,13 @@ 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")
|
||||
@@ -577,6 +584,13 @@ func (r *Repository) pickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
|
||||
}
|
||||
}
|
||||
|
||||
pendingPorts, err := r.PendingTunnelCleanupPortsTx(tx, nodeID, excludeTunnelID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, port := range pendingPorts {
|
||||
used[port] = struct{}{}
|
||||
}
|
||||
var available []int
|
||||
for _, candidate := range candidates {
|
||||
if candidate <= 0 {
|
||||
@@ -622,6 +636,9 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := r.queueDeletedTunnelRuntimeTx(tx, tunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
forwardIDs := tx.Model(&model.Forward{}).Select("id").Where("tunnel_id = ?", tunnelID)
|
||||
if err := tx.Where("forward_id IN (?)", forwardIDs).Delete(&model.ForwardPort{}).Error; err != nil {
|
||||
return err
|
||||
@@ -638,6 +655,9 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
|
||||
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.FederationTunnelBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.RelayEndpointAuth{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", tunnelID).Delete(&model.Tunnel{}).Error
|
||||
})
|
||||
}
|
||||
@@ -897,6 +917,13 @@ func (r *Repository) GetUsedPortsOnNodeAsMap(nodeID int64) (map[int]bool, error)
|
||||
for _, p := range chainPorts {
|
||||
used[p] = true
|
||||
}
|
||||
pendingPorts, err := r.PendingTunnelCleanupPortsTx(nil, nodeID, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, port := range pendingPorts {
|
||||
used[port] = true
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
@@ -913,7 +940,7 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, now int64, status
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
}
|
||||
if err := r.db.Create(&sl).Error; err != nil {
|
||||
if err := createFirst(r.db, "speed_limit", &sl, &sl.DisplayOrder); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return sl.ID, nil
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"go-backend/internal/relaysecurity"
|
||||
"go-backend/internal/store/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// RelayEndpointCredentials loads existing state, creating only when explicitly
|
||||
// requested by a new endpoint operation. Reconnects must never recreate a secret.
|
||||
func (r *Repository) RelayEndpointCredentials(tx *gorm.DB, tunnelID, nodeID int64, role int, create bool) (*relaysecurity.Credentials, error) {
|
||||
db := tx
|
||||
if db == nil {
|
||||
db = r.db
|
||||
}
|
||||
var row model.RelayEndpointAuth
|
||||
find := func() error {
|
||||
return db.Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, role).First(&row).Error
|
||||
}
|
||||
err := find()
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) && create {
|
||||
var existing int64
|
||||
// chain_tunnel stores roles as text; relay_endpoint_auth uses integers.
|
||||
if e := db.Model(&model.ChainTunnel{}).Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, strconv.Itoa(role)).Count(&existing).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if existing > 0 {
|
||||
var tunnel model.Tunnel
|
||||
if e := db.First(&tunnel, tunnelID).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if tunnel.Type == 2 || tunnel.RelayAuthMode == relaysecurity.Required {
|
||||
return nil, errors.New("required relay endpoint credentials unavailable; refusing regeneration")
|
||||
}
|
||||
}
|
||||
|
||||
c, e := relaysecurity.New()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
row = model.RelayEndpointAuth{TunnelID: tunnelID, NodeID: nodeID, ChainType: role, Version: c.Version, Username: c.Username, Password: c.Password}
|
||||
if e = db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
row = model.RelayEndpointAuth{}
|
||||
err = find()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.New("required relay endpoint credentials unavailable")
|
||||
}
|
||||
c := &relaysecurity.Credentials{Version: row.Version, Username: row.Username, Password: row.Password}
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SetTunnelRelayAuthModeTx(tx *gorm.DB, tunnelID int64, mode string) error {
|
||||
if mode != relaysecurity.Required && mode != relaysecurity.Legacy {
|
||||
return errors.New("invalid relay authentication mode")
|
||||
}
|
||||
return tx.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Update("relay_auth_mode", mode).Error
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
gsqlite "github.com/glebarez/sqlite"
|
||||
"go-backend/internal/relaysecurity"
|
||||
"go-backend/internal/store/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestOpenBackfillsSQLiteTunnelRelayAuthMode(t *testing.T) {
|
||||
for _, version := range []int{0, currentSchemaVersion} {
|
||||
t.Run(fmt.Sprintf("schema_version_%d", version), func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-relay.db")
|
||||
db, err := gorm.Open(gsqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy sqlite: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get legacy sqlite connection: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
// This is the tunnel schema immediately before relay authentication.
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio REAL NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT,
|
||||
inx INTEGER NOT NULL DEFAULT 0,
|
||||
ip_preference VARCHAR(10) NOT NULL DEFAULT '',
|
||||
probe_target_host TEXT NOT NULL DEFAULT '',
|
||||
probe_target_port INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy tunnel table: %v", err)
|
||||
}
|
||||
legacy := model.Tunnel{
|
||||
ID: 41, Name: "existing-relay", TrafficRatio: 1.5,
|
||||
Type: 2, Protocol: "tcp", Flow: 123,
|
||||
CreatedTime: 111, UpdatedTime: 222, Status: 1,
|
||||
InIP: sql.NullString{String: "192.0.2.1", Valid: true},
|
||||
Inx: 3, IPPreference: "ipv4",
|
||||
ProbeTargetHost: "example.com", ProbeTargetPort: 8443,
|
||||
}
|
||||
if err := db.Omit("RelayAuthMode").Create(&legacy).Error; err != nil {
|
||||
t.Fatalf("seed legacy tunnel: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SchemaVersion{}); err != nil {
|
||||
t.Fatalf("create schema version table: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.SchemaVersion{Version: version}).Error; err != nil {
|
||||
t.Fatalf("seed schema version: %v", err)
|
||||
}
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Fatalf("close legacy sqlite: %v", err)
|
||||
}
|
||||
|
||||
r, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
if !r.DB().Migrator().HasColumn(&model.Tunnel{}, "RelayAuthMode") {
|
||||
t.Fatal("expected tunnel.relay_auth_mode after upgrading an existing database")
|
||||
}
|
||||
legacy.RelayAuthMode = relaysecurity.Legacy
|
||||
assertSQLiteRelayTunnel(t, r, legacy)
|
||||
|
||||
var createdID int64
|
||||
if err := r.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
createdID, err = r.CreateTunnelTx(tx, "after-upgrade", 1, 1, 1, 333, 1, nil, 0, "", "", 0)
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("create tunnel after upgrade: %v", err)
|
||||
}
|
||||
if createdID <= legacy.ID {
|
||||
t.Fatalf("expected new tunnel ID above %d, got %d", legacy.ID, createdID)
|
||||
}
|
||||
var created model.Tunnel
|
||||
if err := r.DB().First(&created, createdID).Error; err != nil {
|
||||
t.Fatalf("read newly created tunnel: %v", err)
|
||||
}
|
||||
if created.RelayAuthMode != relaysecurity.Legacy {
|
||||
t.Fatalf("expected legacy default for created tunnel, got %q", created.RelayAuthMode)
|
||||
}
|
||||
if err := r.DB().Transaction(func(tx *gorm.DB) error {
|
||||
return r.SetTunnelRelayAuthModeTx(tx, createdID, relaysecurity.Required)
|
||||
}); err != nil {
|
||||
t.Fatalf("enable relay authentication: %v", err)
|
||||
}
|
||||
created.RelayAuthMode = relaysecurity.Required
|
||||
|
||||
// Startup repair must be idempotent and never downgrade required mode.
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatalf("close migrated sqlite: %v", err)
|
||||
}
|
||||
r, err = Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen migrated sqlite: %v", err)
|
||||
}
|
||||
assertSQLiteRelayTunnel(t, r, legacy)
|
||||
assertSQLiteRelayTunnel(t, r, created)
|
||||
}
|
||||
var count int64
|
||||
if err := r.DB().Model(&model.Tunnel{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count migrated tunnels: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected two tunnels after repeated startup, got %d", count)
|
||||
}
|
||||
var integrity string
|
||||
if err := r.DB().Raw("PRAGMA integrity_check").Scan(&integrity).Error; err != nil {
|
||||
t.Fatalf("check migrated database integrity: %v", err)
|
||||
}
|
||||
if integrity != "ok" {
|
||||
t.Fatalf("unexpected database integrity result: %s", integrity)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenNewSQLiteTunnelRelayAuthMode(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "new-relay.db")
|
||||
r, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open new sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
if !r.DB().Migrator().HasColumn(&model.Tunnel{}, "RelayAuthMode") {
|
||||
t.Fatal("expected tunnel.relay_auth_mode on a fresh database")
|
||||
}
|
||||
// Omit the field explicitly to test SQLite's default, not GORM's default.
|
||||
tunnel := model.Tunnel{
|
||||
Name: "new-tunnel", TrafficRatio: 1, Type: 1, Protocol: "tls",
|
||||
Flow: 1, CreatedTime: 111, UpdatedTime: 111, Status: 1,
|
||||
}
|
||||
if err := r.DB().Omit("RelayAuthMode").Create(&tunnel).Error; err != nil {
|
||||
t.Fatalf("create tunnel without explicit relay mode: %v", err)
|
||||
}
|
||||
tunnel.RelayAuthMode = relaysecurity.Legacy
|
||||
assertSQLiteRelayTunnel(t, r, tunnel)
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatalf("close new sqlite: %v", err)
|
||||
}
|
||||
r, err = Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen new sqlite: %v", err)
|
||||
}
|
||||
assertSQLiteRelayTunnel(t, r, tunnel)
|
||||
}
|
||||
|
||||
func assertSQLiteRelayTunnel(t *testing.T, r *Repository, want model.Tunnel) {
|
||||
t.Helper()
|
||||
var got model.Tunnel
|
||||
if err := r.DB().First(&got, want.ID).Error; err != nil {
|
||||
t.Fatalf("read tunnel %d: %v", want.ID, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("tunnel data changed: got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-backend/internal/relaysecurity"
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestRelayEndpointCredentialsBindsLegacyChainTypeAsText(t *testing.T) {
|
||||
for _, role := range []int{2, 3} {
|
||||
t.Run(strconv.Itoa(role), func(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "relay.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
// SQLite coerces integer parameters for this legacy text column.
|
||||
// Check the actual repository query with pgx's text encoder as well,
|
||||
// so the PostgreSQL regression is caught without a running server.
|
||||
checked := 0
|
||||
if err := r.DB().Callback().Query().After("gorm:query").Register("test:relay_chain_text_parameter", func(db *gorm.DB) {
|
||||
if db.Statement.Table != "chain_tunnel" {
|
||||
return
|
||||
}
|
||||
checked++
|
||||
if len(db.Statement.Vars) != 3 {
|
||||
db.AddError(fmt.Errorf("expected three chain lookup arguments, got %d", len(db.Statement.Vars)))
|
||||
return
|
||||
}
|
||||
encoded, err := pgtype.NewMap().Encode(pgtype.TextOID, pgtype.TextFormatCode, db.Statement.Vars[2], nil)
|
||||
if err != nil {
|
||||
db.AddError(fmt.Errorf("encode chain_type for PostgreSQL: %w", err))
|
||||
} else if string(encoded) != strconv.Itoa(role) {
|
||||
db.AddError(fmt.Errorf("unexpected chain_type argument: %q", encoded))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tunnel := model.Tunnel{Name: "relay", Type: 2, Flow: 1, RelayAuthMode: relaysecurity.Required}
|
||||
if err := r.DB().Create(&tunnel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const nodeID = int64(7)
|
||||
if err := r.DB().Transaction(func(tx *gorm.DB) error {
|
||||
created, err := r.RelayEndpointCredentials(tx, tunnel.ID, nodeID, role, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, create := range []bool{false, true} {
|
||||
loaded, err := r.RelayEndpointCredentials(tx, tunnel.ID, nodeID, role, create)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *created != *loaded {
|
||||
return fmt.Errorf("reloading endpoint changed credentials")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("create and reload endpoint: %v", err)
|
||||
}
|
||||
if checked != 1 {
|
||||
t.Fatalf("expected one chain lookup for a new endpoint, got %d", checked)
|
||||
}
|
||||
|
||||
chain := model.ChainTunnel{TunnelID: tunnel.ID, NodeID: nodeID, ChainType: strconv.Itoa(role)}
|
||||
if err := r.DB().Create(&chain).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.DB().Where("tunnel_id = ?", tunnel.ID).Delete(&model.RelayEndpointAuth{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.RelayEndpointCredentials(nil, tunnel.ID, nodeID, role, true); err == nil || !strings.Contains(err.Error(), "refusing regeneration") {
|
||||
t.Fatalf("missing protected credentials must fail closed, got %v", err)
|
||||
}
|
||||
if checked != 2 {
|
||||
t.Fatalf("expected chain lookup before refusing regeneration, got %d", checked)
|
||||
}
|
||||
var count int64
|
||||
if err := r.DB().Model(&model.RelayEndpointAuth{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatal("regenerated credentials for an existing protected endpoint")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"go-backend/internal/store/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (r *Repository) QueueTunnelRuntimeCleanupTx(tx *gorm.DB, items []model.TunnelRuntimeCleanup) error {
|
||||
for _, item := range items {
|
||||
var node model.Node
|
||||
if err := tx.Select("id", "is_remote").Where("id = ?", item.NodeID).First(&node).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if node.IsRemote == 1 {
|
||||
continue
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "kind"}, {Name: "port"}}, DoNothing: true}).Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *Repository) ListTunnelRuntimeCleanup(tunnelID int64) ([]model.TunnelRuntimeCleanup, error) {
|
||||
var items []model.TunnelRuntimeCleanup
|
||||
q := r.db.Model(&model.TunnelRuntimeCleanup{})
|
||||
if tunnelID > 0 {
|
||||
q = q.Where("tunnel_id = ?", tunnelID)
|
||||
}
|
||||
err := q.Order("tunnel_id, node_id, kind DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
func (r *Repository) DeleteTunnelRuntimeCleanup(id int64) error {
|
||||
return r.db.Where("id = ?", id).Delete(&model.TunnelRuntimeCleanup{}).Error
|
||||
}
|
||||
func (r *Repository) PendingTunnelCleanupPortsTx(tx *gorm.DB, nodeID, excludeTunnelID int64) ([]int, error) {
|
||||
if tx == nil {
|
||||
tx = r.db
|
||||
}
|
||||
q := tx.Model(&model.TunnelRuntimeCleanup{}).Where("node_id = ? AND kind = ? AND port > 0", nodeID, "service")
|
||||
if excludeTunnelID > 0 {
|
||||
q = q.Where("tunnel_id != ?", excludeTunnelID)
|
||||
}
|
||||
var ports []int
|
||||
err := q.Pluck("port", &ports).Error
|
||||
return ports, err
|
||||
}
|
||||
|
||||
func (r *Repository) queueDeletedTunnelRuntimeTx(tx *gorm.DB, tunnelID int64) error {
|
||||
var tunnels []model.Tunnel
|
||||
if err := tx.Where("id = ? AND type = ?", tunnelID, 2).Find(&tunnels).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tunnels) == 0 {
|
||||
return nil
|
||||
}
|
||||
var rows []model.ChainTunnel
|
||||
if err := tx.Where("tunnel_id = ?", tunnelID).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var items []model.TunnelRuntimeCleanup
|
||||
for _, row := range rows {
|
||||
if row.ChainType == "2" || row.ChainType == "3" {
|
||||
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: row.NodeID, Kind: "service", Port: int(row.Port.Int64)})
|
||||
}
|
||||
if row.ChainType == "1" || row.ChainType == "2" {
|
||||
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: row.NodeID, Kind: "chain"})
|
||||
}
|
||||
}
|
||||
return r.QueueTunnelRuntimeCleanupTx(tx, items)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func orderRequest(t *testing.T, router http.Handler, token, resource, body string) response.R {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/"+resource+"/update-order", bytes.NewBufferString(body))
|
||||
req.Header.Set("Authorization", token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
var out response.R
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
func TestDisplayOrderUserContract(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "order-secret")
|
||||
token := mustAdminToken(t, "order-secret")
|
||||
a, err := r.CreateUser("order-a", "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := r.CreateUser("order-b", "hash", 1, 0, 20, 1, 2, 1, 3, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := orderRequest(t, router, token, "user", fmt.Sprintf(`{"ids":[%d,%d],"expectedIds":[%d,%d]}`, a, b, b, a))
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("reorder: %+v", out)
|
||||
}
|
||||
users, err := r.ListUsers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := []int64{users[0]["id"].(int64), users[1]["id"].(int64)}; !reflect.DeepEqual(got, []int64{a, b}) {
|
||||
t.Fatalf("order %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayOrderValidationAndLifecycle(t *testing.T) {
|
||||
for _, resource := range []string{"user", "speed-limit"} {
|
||||
t.Run(resource, func(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "order-secret")
|
||||
token := mustAdminToken(t, "order-secret")
|
||||
create := func(name string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
var err error
|
||||
if resource == "user" {
|
||||
id, err = r.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||
} else {
|
||||
id, err = r.CreateSpeedLimit(name, 100, 0, 1)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
list := func() []int64 {
|
||||
t.Helper()
|
||||
out := postBatchRequest(t, router, token, "/api/v1/"+resource+"/list", "{}")
|
||||
rows, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("list data: %T", out.Data)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, row := range rows {
|
||||
ids = append(ids, int64(row.(map[string]interface{})["id"].(float64)))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
a, b := create("a"), create("b")
|
||||
body := func(ids, expected []int64) string {
|
||||
v, _ := json.Marshal(map[string]interface{}{"ids": ids, "expectedIds": expected})
|
||||
return string(v)
|
||||
}
|
||||
check := func(payload string, code int) {
|
||||
t.Helper()
|
||||
out := orderRequest(t, router, token, resource, payload)
|
||||
if out.Code != code {
|
||||
t.Fatalf("want %d got %+v", code, out)
|
||||
}
|
||||
}
|
||||
check(body([]int64{a, b}, []int64{b, a}), 0)
|
||||
for _, payload := range []string{`{}`, `{"ids":null,"expectedIds":[]}`, `{"ids":[1.1],"expectedIds":[1]}`, body([]int64{a, a}, []int64{a, b}), body([]int64{a}, []int64{a, b}), body([]int64{a, b, 99999}, []int64{a, b}), body([]int64{a, 99999}, []int64{a, b}), body([]int64{a, b}, []int64{a, a})} {
|
||||
check(payload, 400)
|
||||
}
|
||||
if resource == "user" {
|
||||
check(body([]int64{1, a, b}, []int64{a, b}), 400)
|
||||
check(body([]int64{1, a, b}, []int64{1, a, b}), 400)
|
||||
}
|
||||
check(body([]int64{b, a}, []int64{b, a}), 409)
|
||||
c := create("c")
|
||||
if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) {
|
||||
t.Fatalf("new item first: %v", got)
|
||||
}
|
||||
check(body([]int64{b, a}, []int64{a, b}), 409)
|
||||
if resource == "user" {
|
||||
if err := r.UpdateUserWithoutPassword(a, "edited", 10, 2, 0, 1, 1, 3, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := r.UpdateSpeedLimit(a, "edited", 200, 1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) {
|
||||
t.Fatalf("edit changed order: %v", got)
|
||||
}
|
||||
if got := orderRequest(t, router, "", resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 401 {
|
||||
t.Fatalf("missing auth: %+v", got)
|
||||
}
|
||||
normal, err := auth.GenerateToken(a, "edited", 1, "order-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Speed-limit fixture needs a real ordinary account for auth lookup.
|
||||
if resource == "speed-limit" {
|
||||
uid, err := r.CreateUser("normal", "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normal, err = auth.GenerateToken(uid, "normal", 1, "order-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := orderRequest(t, router, normal, resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 403 {
|
||||
t.Fatalf("non-admin auth: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDisplayOrderBackupAndRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "order.db")
|
||||
r, err := repo.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := r.CreateSpeedLimit("a", 100, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := r.CreateSpeedLimit("b", 200, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.UpdateSpeedLimitDisplayOrder([]int64{a, b}, []int64{b, a}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := func(r *repo.Repository) {
|
||||
t.Helper()
|
||||
rows, err := r.ListSpeedLimits()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row["id"].(int64))
|
||||
}
|
||||
if !reflect.DeepEqual(ids, []int64{a, b}) {
|
||||
t.Fatalf("order: %v", ids)
|
||||
}
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err = repo.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
check(r)
|
||||
backup, err := r.ExportPartial([]string{"speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dest, err := repo.Open(filepath.Join(t.TempDir(), "restore.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dest.Close()
|
||||
if _, err := dest.Import(backup, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(dest)
|
||||
}
|
||||
|
||||
// Lifecycle and migration fixtures use DDL only to reproduce a pre-upgrade
|
||||
// database and inject a write failure. All observations use public interfaces.
|
||||
func TestDisplayOrderLifecycleDialects(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
open := orderTestDatabase(t, dialect)
|
||||
r := open()
|
||||
for _, resource := range []string{"user", "speed_limit"} {
|
||||
t.Run(resource, func(t *testing.T) {
|
||||
create := func(name string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
var err error
|
||||
if resource == "user" {
|
||||
id, err = r.CreateUser(name, security.MD5("test-password"), 1, 0, 123, 1, 4, 1, 5, 0)
|
||||
} else {
|
||||
id, err = r.CreateSpeedLimit(name, 123, 0, 1)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
list := func(db *repo.Repository) []int64 {
|
||||
t.Helper()
|
||||
var rows []map[string]interface{}
|
||||
var err error
|
||||
if resource == "user" {
|
||||
rows, err = db.ListUsers()
|
||||
} else {
|
||||
rows, err = db.ListSpeedLimits()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row["id"].(int64))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
save := func(db *repo.Repository, ids, expected []int64) error {
|
||||
if resource == "user" {
|
||||
return db.UpdateUserDisplayOrder(ids, expected)
|
||||
}
|
||||
return db.UpdateSpeedLimitDisplayOrder(ids, expected)
|
||||
}
|
||||
a, b, c := create("a"), create("b"), create("c")
|
||||
// Drop the new column to reproduce a database last opened by the old release.
|
||||
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP COLUMN display_order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = open()
|
||||
if got := list(r); !reflect.DeepEqual(got, []int64{c, b, a}) {
|
||||
t.Fatalf("legacy order %v", got)
|
||||
}
|
||||
if err := save(r, []int64{a, c, b}, []int64{c, b, a}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restore := orderTestDatabase(t, dialect)()
|
||||
defer restore.Close()
|
||||
types := []string{"speedLimits"}
|
||||
if resource == "user" {
|
||||
types = []string{"users"}
|
||||
}
|
||||
if _, err := restore.Import(backup, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := list(restore); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||
t.Fatalf("new backup order: %v", got)
|
||||
}
|
||||
before, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = open()
|
||||
second := open()
|
||||
defer second.Close()
|
||||
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||
t.Fatalf("cross-client/restart order %v", got)
|
||||
}
|
||||
// The next save fails on its second row; the first row must roll back.
|
||||
if dialect == "sqlite" {
|
||||
if err := r.DB().Exec(fmt.Sprintf("CREATE TRIGGER reject_order BEFORE UPDATE OF display_order ON \"%s\" WHEN NEW.id = %d BEGIN SELECT RAISE(ABORT, 'injected failure'); END", resource, a)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := r.DB().Exec(fmt.Sprintf("ALTER TABLE \"%s\" ADD CONSTRAINT reject_order CHECK (id != %d OR display_order = 0)", resource, a)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := save(r, []int64{b, a, c}, []int64{a, c, b}); err == nil {
|
||||
t.Fatal("expected injected failure")
|
||||
}
|
||||
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||
t.Fatalf("partial write: %v", got)
|
||||
}
|
||||
if dialect == "sqlite" {
|
||||
if err := r.DB().Exec("DROP TRIGGER reject_order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP CONSTRAINT reject_order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Exactly one cross-client save against the same observed order may win.
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
for _, db := range []*repo.Repository{r, second} {
|
||||
go func(db *repo.Repository) { <-start; results <- save(db, []int64{b, a, c}, []int64{a, c, b}) }(db)
|
||||
}
|
||||
close(start)
|
||||
e1, e2 := <-results, <-results
|
||||
if !((e1 == nil && errors.Is(e2, repo.ErrDisplayOrderConflict)) || (e2 == nil && errors.Is(e1, repo.ErrDisplayOrderConflict))) {
|
||||
t.Fatalf("concurrent saves: %v / %v", e1, e2)
|
||||
}
|
||||
// Reordering must not mutate any exported fields besides rank.
|
||||
after, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
strip := func(b *model.BackupData) {
|
||||
b.ExportedAt = 0
|
||||
for i := range b.Users {
|
||||
b.Users[i].DisplayOrder = nil
|
||||
}
|
||||
for i := range b.SpeedLimits {
|
||||
b.SpeedLimits[i].DisplayOrder = nil
|
||||
}
|
||||
}
|
||||
strip(before)
|
||||
strip(after)
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Fatal("reorder modified non-order backup fields")
|
||||
}
|
||||
// Old backups omit ranks and must not overwrite an existing saved order.
|
||||
legacy := orderTestDatabase(t, dialect)()
|
||||
defer legacy.Close()
|
||||
if _, err := legacy.Import(after, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := list(legacy); !reflect.DeepEqual(got, []int64{c, b, a}) {
|
||||
t.Fatalf("new legacy records: %v", got)
|
||||
}
|
||||
if _, err := r.Import(after, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := list(r); !reflect.DeepEqual(got, []int64{b, a, c}) {
|
||||
t.Fatalf("old backup reset existing order: %v", got)
|
||||
}
|
||||
d := create("d")
|
||||
if got := list(r); !reflect.DeepEqual(got, []int64{d, b, a, c}) {
|
||||
t.Fatalf("create after reorder: %v", got)
|
||||
}
|
||||
if err := save(second, []int64{a, b, c}, []int64{b, a, c}); !errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||
t.Fatalf("stale membership: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
r.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func orderTestDatabase(t *testing.T, dialect string) func() *repo.Repository {
|
||||
t.Helper()
|
||||
if dialect == "sqlite" {
|
||||
path := filepath.Join(t.TempDir(), "lifecycle.db")
|
||||
return func() *repo.Repository {
|
||||
r, err := repo.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { r.Close() })
|
||||
return r
|
||||
}
|
||||
}
|
||||
dsn := os.Getenv("FLVX_POSTGRES_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set FLVX_POSTGRES_TEST_DSN for isolated PostgreSQL contracts")
|
||||
}
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema := "contract_display_order_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
if _, err := admin.Exec("CREATE SCHEMA \"" + schema + "\""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { admin.Exec("DROP SCHEMA \"" + schema + "\" CASCADE"); admin.Close() })
|
||||
scoped, err := withSearchPath(dsn, schema)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func() *repo.Repository {
|
||||
r, err := repo.OpenPostgres(scoped)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { r.Close() })
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayOrderConcurrentCreate(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
for _, resource := range []string{"user", "speed-limit"} {
|
||||
t.Run(resource, func(t *testing.T) {
|
||||
open := orderTestDatabase(t, dialect)
|
||||
r, other := open(), open()
|
||||
create := func(db *repo.Repository, name string) (int64, error) {
|
||||
if resource == "user" {
|
||||
return db.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||
}
|
||||
return db.CreateSpeedLimit(name, 100, 0, 1)
|
||||
}
|
||||
save := func(ids, expected []int64) error {
|
||||
if resource == "user" {
|
||||
return r.UpdateUserDisplayOrder(ids, expected)
|
||||
}
|
||||
return r.UpdateSpeedLimitDisplayOrder(ids, expected)
|
||||
}
|
||||
a, err := create(r, "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := create(r, "b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
saved := make(chan error, 1)
|
||||
type creation struct {
|
||||
id int64
|
||||
err error
|
||||
}
|
||||
created := make(chan creation, 1)
|
||||
go func() { <-start; saved <- save([]int64{a, b}, []int64{b, a}) }()
|
||||
go func() { <-start; id, err := create(other, "c"); created <- creation{id, err} }()
|
||||
close(start)
|
||||
saveErr, c := <-saved, <-created
|
||||
if c.err != nil {
|
||||
t.Fatal(c.err)
|
||||
}
|
||||
if saveErr != nil && !errors.Is(saveErr, repo.ErrDisplayOrderConflict) {
|
||||
t.Fatalf("concurrent save: %v", saveErr)
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if resource == "user" {
|
||||
rows, err = r.ListUsers()
|
||||
} else {
|
||||
rows, err = r.ListSpeedLimits()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := []int64{}
|
||||
for _, row := range rows {
|
||||
got = append(got, row["id"].(int64))
|
||||
}
|
||||
want := []int64{c.id, a, b}
|
||||
if saveErr != nil {
|
||||
want = []int64{c.id, b, a}
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("concurrent create order %v want %v", got, want)
|
||||
}
|
||||
if resource == "user" {
|
||||
err = r.DeleteUserCascade(c.id)
|
||||
} else {
|
||||
err = r.DeleteSpeedLimit(c.id)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := save(got, got); !errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||
t.Fatalf("deleted membership not rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -440,13 +440,15 @@ func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
|
||||
func insertContractNode(t *testing.T, r *repo.Repository, name, ip, portRange, secret string, status int) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
var id int64
|
||||
if err := r.DB().Raw(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0).Error; err != nil {
|
||||
RETURNING id
|
||||
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0).Row().Scan(&id); err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
return id
|
||||
}
|
||||
|
||||
func insertPeerShare(t *testing.T, r *repo.Repository, share *repo.PeerShare) int64 {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestForwardOwnerNamesFollowAccountRenames(t *testing.T) {
|
||||
for _, database := range []string{"sqlite", "postgres"} {
|
||||
t.Run(database, func(t *testing.T) {
|
||||
for _, mode := range []string{"admin-name-only", "admin-name-and-password", "self-name-and-password"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
const secret = "issue574-isolated-test"
|
||||
open := ownerNamesRepository(t, database)
|
||||
r := open()
|
||||
router := httpserver.NewRouter(handler.New(r, secret), secret)
|
||||
for _, u := range []model.User{
|
||||
{ID: 9103, User: "before-name", Pwd: security.MD5("old-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()},
|
||||
{ID: 9104, User: "other-user", Pwd: security.MD5("other-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()},
|
||||
} {
|
||||
if err := r.DB().Create(&u).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
exp := time.Now().Add(time.Hour).UnixMilli()
|
||||
f := model.Forward{ID: 9301, UserID: 9103, UserName: "before-name", Name: "existing-rule", TunnelID: 9201, RemoteAddr: "127.0.0.1:8080", Status: 1, InFlow: 123, OutFlow: 456}
|
||||
for _, v := range []interface{}{&model.Tunnel{ID: 9201, Name: "isolated", Type: 1, Protocol: "tls", Status: 1, Flow: 1}, &model.UserTunnel{ID: 9401, UserID: 9103, TunnelID: 9201, Status: 1, Flow: 100, Num: 10, ExpTime: exp}, &f} {
|
||||
if err := r.DB().Create(v).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
token := func(id int64, name string, role int) string {
|
||||
v, e := auth.GenerateTokenAt(id, name, role, secret, time.Now().Add(time.Second))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return v
|
||||
}
|
||||
admin := token(1, "admin_user", 0)
|
||||
oldToken, e := auth.GenerateTokenAt(9103, "before-name", 1, secret, time.Now().Add(-time.Second))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
call := func(path, tok string, body interface{}) (int, json.RawMessage) {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||
q.Header.Set("Authorization", tok)
|
||||
q.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, q)
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if e := json.Unmarshal(w.Body.Bytes(), &out); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return out.Code, out.Data
|
||||
}
|
||||
path, tok := "/api/v1/user/update", admin
|
||||
body := map[string]interface{}{"id": 9103, "user": "after-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0}
|
||||
if mode == "admin-name-and-password" {
|
||||
body["pwd"] = "new-password"
|
||||
}
|
||||
if mode == "self-name-and-password" {
|
||||
path = "/api/v1/user/updatePassword"
|
||||
tok = oldToken
|
||||
body = map[string]interface{}{"newUsername": "after-name", "currentPassword": "old-password", "newPassword": "new-password", "confirmPassword": "new-password"}
|
||||
}
|
||||
if code, _ := call(path, tok, body); code != 0 {
|
||||
t.Fatalf("rename code=%d", code)
|
||||
}
|
||||
|
||||
fresh := token(9103, "after-name", 1)
|
||||
code, data := call("/api/v1/forward/list", admin, map[string]interface{}{})
|
||||
if code != 0 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0]["userName"] != "after-name" || rows[0]["userId"] != float64(9103) {
|
||||
t.Fatalf("expected current owner name: %s", data)
|
||||
}
|
||||
if rows[0]["id"] != float64(9301) || rows[0]["inFlow"] != float64(123) || rows[0]["outFlow"] != float64(456) || rows[0]["status"] != float64(1) {
|
||||
t.Fatalf("rule identity/traffic/status changed: %s", data)
|
||||
}
|
||||
if code, data = call("/api/v1/forward/list", fresh, map[string]interface{}{}); code != 0 {
|
||||
t.Fatalf("owner denied: %d", code)
|
||||
}
|
||||
rows = nil
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0]["userName"] != "after-name" {
|
||||
t.Fatalf("owner cannot see renamed rule: %s", data)
|
||||
}
|
||||
code, data = call("/api/v1/tunnel/delete-preview", admin, map[string]interface{}{"id": 9201})
|
||||
if code != 0 {
|
||||
t.Fatalf("preview code=%d", code)
|
||||
}
|
||||
var preview struct {
|
||||
SampleForwards []struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
} `json:"sampleForwards"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(preview.SampleForwards) != 1 || preview.SampleForwards[0].ID != 9301 || preview.SampleForwards[0].UserID != 9103 || preview.SampleForwards[0].UserName != "after-name" {
|
||||
t.Fatalf("preview must show current owner: %s", data)
|
||||
}
|
||||
code, data = call("/api/v1/tunnel/batch-delete-preview", admin, map[string]interface{}{"ids": []int64{9201}})
|
||||
if code != 0 {
|
||||
t.Fatalf("batch preview code=%d", code)
|
||||
}
|
||||
var batch struct {
|
||||
Items []struct {
|
||||
SampleForwards []struct {
|
||||
UserName string `json:"userName"`
|
||||
} `json:"sampleForwards"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(batch.Items) != 1 || len(batch.Items[0].SampleForwards) != 1 || batch.Items[0].SampleForwards[0].UserName != "after-name" {
|
||||
t.Fatalf("batch preview must show current owner: %s", data)
|
||||
}
|
||||
exported, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exported.UserTunnels) != 1 || exported.UserTunnels[0].UserID != 9103 || exported.UserTunnels[0].TunnelID != 9201 || exported.UserTunnels[0].Flow != 100 || exported.UserTunnels[0].Status != 1 {
|
||||
t.Fatalf("rename changed tunnel grant: %+v", exported.UserTunnels)
|
||||
}
|
||||
oldCode, _ := call("/api/v1/forward/list", oldToken, map[string]interface{}{})
|
||||
wantOld := 0
|
||||
if mode != "admin-name-only" {
|
||||
wantOld = 401
|
||||
}
|
||||
if oldCode != wantOld {
|
||||
t.Fatalf("old token=%d want %d", oldCode, wantOld)
|
||||
}
|
||||
// Re-use the abandoned username for another identity; it must not acquire rules.
|
||||
if code, _ := call("/api/v1/user/update", admin, map[string]interface{}{"id": 9104, "user": "before-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0}); code != 0 {
|
||||
t.Fatalf("reuse old name: %d", code)
|
||||
}
|
||||
other := token(9104, "before-name", 1)
|
||||
code, data = call("/api/v1/forward/list", other, map[string]interface{}{})
|
||||
if code != 0 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
rows = nil
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("old name gained rules: %s", data)
|
||||
}
|
||||
if code, _ := call("/api/v1/forward/reset-flow", other, map[string]interface{}{"id": 9301}); code == 0 {
|
||||
t.Fatal("other user reset original rule")
|
||||
}
|
||||
if code, _ := call("/api/v1/forward/reset-flow", fresh, map[string]interface{}{"id": 9301}); code != 0 {
|
||||
t.Fatalf("owner reset denied: %d", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Each PostgreSQL test owns only its unique schema, never the shared test database.
|
||||
func ownerNamesRepository(t *testing.T, database string) func() *repo.Repository {
|
||||
t.Helper()
|
||||
dsn := filepath.Join(t.TempDir(), "owners.db")
|
||||
if database == "postgres" {
|
||||
baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN"))
|
||||
if baseDSN == "" {
|
||||
t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres owner-name contracts")
|
||||
}
|
||||
schema := "contract_owner_names_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
db, err := sql.Open("pgx", baseDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil {
|
||||
db.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(`DROP SCHEMA IF EXISTS "` + schema + `" CASCADE`); _ = db.Close() })
|
||||
dsn, err = withSearchPath(baseDSN, schema)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return func() *repo.Repository {
|
||||
t.Helper()
|
||||
var r *repo.Repository
|
||||
var err error
|
||||
if database == "postgres" {
|
||||
r, err = repo.OpenPostgres(dsn)
|
||||
} else {
|
||||
r, err = repo.Open(dsn)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardOwnerNamesSurviveLegacyBackupAndRestart(t *testing.T) {
|
||||
for _, database := range []string{"sqlite", "postgres"} {
|
||||
t.Run(database, func(t *testing.T) {
|
||||
open := ownerNamesRepository(t, database)
|
||||
r := open()
|
||||
// A legacy backup stores the rule's original name, not the user's current one.
|
||||
const legacy = `{"users":[{"id":9103,"user":"current-owner","pwd":"test-only-password","roleId":1,"status":1}],
|
||||
"forwards":[
|
||||
{"id":9301,"userId":9103,"userName":"former-owner","name":"historical-rule","tunnelId":9201,"inFlow":123,"outFlow":456,"status":1},
|
||||
{"id":9302,"userId":9199,"userName":"missing-owner","name":"orphan-rule","tunnelId":9201,"status":1},
|
||||
{"id":9303,"userId":9198,"userName":"","name":"unknown-owner-rule","tunnelId":9201,"status":1}]}`
|
||||
var backup model.BackupData
|
||||
if err := json.Unmarshal([]byte(legacy), &backup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Import(&backup, []string{"users", "forwards"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := func(stage string) {
|
||||
t.Helper()
|
||||
rows, err := r.ListForwards()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("%s: rows=%+v", stage, rows)
|
||||
}
|
||||
for i, want := range []string{"current-owner", "missing-owner", ""} {
|
||||
if rows[i]["userName"] != want {
|
||||
t.Fatalf("%s: row %d owner=%v want %q", stage, i, rows[i]["userName"], want)
|
||||
}
|
||||
}
|
||||
if rows[0]["id"] != int64(9301) || rows[0]["userId"] != int64(9103) || rows[0]["inFlow"] != int64(123) || rows[0]["outFlow"] != int64(456) {
|
||||
t.Fatalf("%s: rule changed: %+v", stage, rows[0])
|
||||
}
|
||||
previews, err := r.ListForwardsByTunnel(9201)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(previews) != 3 || previews[0].UserName != "current-owner" || previews[1].UserName != "missing-owner" || previews[2].UserName != "" {
|
||||
t.Fatalf("%s: preview owners=%+v", stage, previews)
|
||||
}
|
||||
exported, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exported.Forwards) != 3 || exported.Forwards[0].UserName != "former-owner" {
|
||||
t.Fatalf("%s: stored legacy snapshot was rewritten: %+v", stage, exported.Forwards)
|
||||
}
|
||||
}
|
||||
check("import")
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = open()
|
||||
check("restart")
|
||||
// A forwards-only restore must resolve against the existing user by ID.
|
||||
if _, err := r.Import(&backup, []string{"forwards"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("partial restore")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestForwardEntryRequiresTunnelChainContract(t *testing.T) {
|
||||
for _, tunnelType := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("type-%d", tunnelType), func(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(202, 'forward_bind_retry_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-bind-retry-tunnel", 1.0, tunnelType, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "forward-bind-retry-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-bind-retry-node", "forward-bind-retry-secret", "10.42.0.1", "10.42.0.1", "", "44000-44010", "", "v1", 1, 1, 1, now, now, 1, "10.42.0.9", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, repo, "forward-bind-retry-node")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 44001, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if tunnelType == 2 {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 44002, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(41, 202, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "forward-bind-retry-target",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var emitted []map[string]interface{}
|
||||
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, "forward-bind-retry-secret", func(kind string, data json.RawMessage) (bool, string) {
|
||||
if kind == "AddService" || kind == "UpdateService" {
|
||||
var services []map[string]interface{}
|
||||
if json.Unmarshal(data, &services) == nil {
|
||||
mu.Lock()
|
||||
emitted = append(emitted, services...)
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stop()
|
||||
waitNodeStatus(t, repo, nodeID, 1)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
found := 0
|
||||
for _, svc := range emitted {
|
||||
h, ok := svc["handler"].(map[string]interface{})
|
||||
if !ok || (h["type"] != "tcp" && h["type"] != "udp") {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
md, _ := h["metadata"].(map[string]interface{})
|
||||
if tunnelType == 2 {
|
||||
if h["chain"] != fmt.Sprintf("chains_%d", tunnelID) || md["chain.strict"] != true {
|
||||
t.Fatalf("tunnel forward must require chain, got %v", h)
|
||||
}
|
||||
} else if md["chain.strict"] == true {
|
||||
t.Fatal("direct forward cannot require a tunnel chain")
|
||||
}
|
||||
}
|
||||
if found < 2 {
|
||||
t.Fatalf("expected TCP and UDP forwarding commands, got %d", found)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestPostgresBackupImportAllowsImmediateCreate(t *testing.T) {
|
||||
source := ownerNamesRepository(t, "postgres")()
|
||||
for _, name := range []string{"backup-one", "backup-two"} {
|
||||
if _, err := source.CreateUser(name, security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := source.CreateSpeedLimit(name, 100, 0, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backup, err := source.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := ownerNamesRepository(t, "postgres")()
|
||||
if _, err := target.Import(backup, []string{"users", "speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := target.CreateUser("after-import", security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil || id <= 2 {
|
||||
t.Errorf("create user immediately after backup import: id=%d err=%v", id, err)
|
||||
}
|
||||
if id, err := target.CreateSpeedLimit("after-import", 100, 0, 1); err != nil || id <= 2 {
|
||||
t.Errorf("create speed limit immediately after backup import: id=%d err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportPreservesAllocatedIDs(t *testing.T) {
|
||||
for _, called := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("called=%t", called), func(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
// A deleted or rolled-back allocation may be higher than every row.
|
||||
for _, table := range []string{"user", "speed_limit"} {
|
||||
if err := r.DB().Exec(`SELECT setval(pg_get_serial_sequence(?, 'id'), 500, ?)`, `"`+table+`"`, called).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b := &model.BackupData{Users: []model.UserBackup{{ID: 1, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}}, SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "restored"}}}
|
||||
if _, err := r.Import(b, []string{"users", "speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := int64(500)
|
||||
if called {
|
||||
want++
|
||||
}
|
||||
if id, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != want {
|
||||
t.Fatalf("user id=%d, want %d; err=%v", id, want, err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != want {
|
||||
t.Fatalf("speed limit id=%d, want %d; err=%v", id, want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportInvalidatesCachedIDs(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq CACHE 20`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, err := r.DB().DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cached, err := pool.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cached.Close()
|
||||
// Hold this backend out of the pool with unused cached IDs 2..20.
|
||||
var allocated int64
|
||||
if err := cached.QueryRowContext(context.Background(), `SELECT nextval('speed_limit_id_seq')`).Scan(&allocated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if allocated != 1 {
|
||||
t.Fatal(allocated)
|
||||
}
|
||||
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 2, Name: "restored"}}}
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Return only the cached backend to the pool, so the public create must use it.
|
||||
pool.SetMaxIdleConns(0)
|
||||
pool.SetMaxIdleConns(1)
|
||||
if err := cached.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id <= 20 {
|
||||
t.Fatalf("cached ID reused: id=%d err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportRollsBackSequenceOnCommitFailure(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
// Deferred validation fails after import has already advanced the sequence.
|
||||
for _, query := range []string{
|
||||
`CREATE FUNCTION reject_import_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'reject imported row at commit'; END $$`,
|
||||
`CREATE CONSTRAINT TRIGGER reject_import_commit AFTER INSERT ON speed_limit DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (NEW.name = 'fail-at-commit') EXECUTE FUNCTION reject_import_commit()`,
|
||||
} {
|
||||
if err := r.DB().Exec(query).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "fail-at-commit"}}}
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err == nil || !strings.Contains(err.Error(), "reject imported row at commit") {
|
||||
t.Fatalf("expected commit failure, got %v", err)
|
||||
}
|
||||
exported, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exported.SpeedLimits) != 0 {
|
||||
t.Fatal("failed import persisted rows")
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
|
||||
t.Fatalf("failed import changed next allocation: id=%d err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportPreservesSequenceIncrement(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq INCREMENT BY 10`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 25, Name: "restored"}}}
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 31 {
|
||||
t.Fatalf("changed ID progression: id=%d, want 31; err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportAdvancesEveryExplicitIDTable(t *testing.T) {
|
||||
source := ownerNamesRepository(t, "postgres")()
|
||||
types := []string{"users", "speedLimits", "nodes", "tunnels", "forwards", "userTunnels", "tunnelGroups", "userGroups", "permissions", "configs"}
|
||||
fixture := &model.BackupData{
|
||||
Users: []model.UserBackup{{ID: 1, User: "old", Pwd: "$2-test-fixture", RoleID: 1}},
|
||||
SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "old"}},
|
||||
Nodes: []model.NodeBackup{{ID: 1, Name: "old", Secret: "old-node"}},
|
||||
Tunnels: []model.TunnelBackup{{ID: 1, Name: "old", ChainTunnels: []model.ChainTunnelBackup{{ID: 1, TunnelID: 1, NodeID: 1, ChainType: "1"}}}},
|
||||
Forwards: []model.ForwardBackup{{ID: 1, Name: "old", UserID: 1, TunnelID: 1}},
|
||||
UserTunnels: []model.UserTunnelBackup{{ID: 1, UserID: 1, TunnelID: 1}},
|
||||
TunnelGroups: []model.TunnelGroupBackup{{ID: 1, Name: "old"}},
|
||||
UserGroups: []model.UserGroupBackup{{ID: 1, Name: "old"}},
|
||||
Permissions: []model.PermissionBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, Grants: []model.PermissionGrantBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, UserTunnelID: 1}}}},
|
||||
}
|
||||
if _, err := source.Import(fixture, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup, err := source.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
if _, err := r.Import(backup, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Use the normal public creation methods, including the nested child tables.
|
||||
for _, create := range []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{"user", func() error { _, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); return err }},
|
||||
{"speed limit", func() error { _, err := r.CreateSpeedLimit("new", 100, 0, 1); return err }},
|
||||
{"node", func() error {
|
||||
return r.CreateRemoteNode("new", "new-node", "127.0.0.1", "10000-20000", 0, 1, 0, "", "", "")
|
||||
}},
|
||||
{"tunnel", func() error { _, err := r.CreateTunnelTx(r.DB(), "new", 1, 1, 0, 0, 1, nil, 0, "", "", 0); return err }},
|
||||
{"chain tunnel", func() error { return r.CreateChainTunnelTx(r.DB(), 1, "1", 2, sql.NullInt64{}, "", 1, "tls", "") }},
|
||||
{"forward", func() error {
|
||||
_, err := r.CreateForwardTx(1, "old", "new", 1, "127.0.0.1:80", "", 0, 0, nil, 0, "", nil, 0, 0, nil, 0, 0, 0)
|
||||
return err
|
||||
}},
|
||||
{"user tunnel", func() error { return r.InsertUserTunnel(2, 1, nil, 0, 0, 0, 0, 1) }},
|
||||
{"tunnel group", func() error { return r.GroupCreate("tunnel_group", "new", 1, 0) }},
|
||||
{"user group", func() error { return r.GroupCreate("user_group", "new", 1, 0) }},
|
||||
{"permission", func() error { return r.InsertGroupPermission(2, 2, 0) }},
|
||||
{"permission grant", func() error { r.InsertGroupPermissionGrant(1, 1, 2, 1, 0); return nil }},
|
||||
} {
|
||||
t.Run(create.name, func(t *testing.T) {
|
||||
if err := create.run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string][]int64{}
|
||||
for _, v := range got.Users {
|
||||
ids["user"] = append(ids["user"], v.ID)
|
||||
}
|
||||
for _, v := range got.SpeedLimits {
|
||||
ids["speed limit"] = append(ids["speed limit"], v.ID)
|
||||
}
|
||||
for _, v := range got.Nodes {
|
||||
ids["node"] = append(ids["node"], v.ID)
|
||||
}
|
||||
for _, v := range got.Tunnels {
|
||||
ids["tunnel"] = append(ids["tunnel"], v.ID)
|
||||
for _, child := range v.ChainTunnels {
|
||||
ids["chain tunnel"] = append(ids["chain tunnel"], child.ID)
|
||||
}
|
||||
}
|
||||
for _, v := range got.Forwards {
|
||||
ids["forward"] = append(ids["forward"], v.ID)
|
||||
}
|
||||
for _, v := range got.UserTunnels {
|
||||
ids["user tunnel"] = append(ids["user tunnel"], v.ID)
|
||||
}
|
||||
for _, v := range got.TunnelGroups {
|
||||
ids["tunnel group"] = append(ids["tunnel group"], v.ID)
|
||||
}
|
||||
for _, v := range got.UserGroups {
|
||||
ids["user group"] = append(ids["user group"], v.ID)
|
||||
}
|
||||
for _, v := range got.Permissions {
|
||||
ids["permission"] = append(ids["permission"], v.ID)
|
||||
for _, child := range v.Grants {
|
||||
ids["permission grant"] = append(ids["permission grant"], child.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) != 11 {
|
||||
t.Fatalf("missing tables in export: %v", ids)
|
||||
}
|
||||
for name, values := range ids {
|
||||
slices.Sort(values)
|
||||
if !slices.Equal(values, []int64{1, 2}) {
|
||||
t.Errorf("%s: expected old and new IDs [1 2], got %v", name, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportKeepsExistingRowsAndEmptySelections(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
// Fixture simulates an existing explicit-ID row with an unsynchronized sequence.
|
||||
if err := r.DB().Create(&model.SpeedLimit{ID: 500, Name: "existing"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
|
||||
if _, err := r.Import(old, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 501 {
|
||||
t.Fatalf("existing maximum ignored: id=%d err=%v", id, err)
|
||||
}
|
||||
for _, b := range []*model.BackupData{{}, {Users: []model.UserBackup{{ID: 900, User: "not-selected", Pwd: "$2-test-fixture"}}}} {
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if id, err := r.CreateUser("first-user", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
|
||||
t.Fatalf("unselected sequence changed: id=%d err=%v", id, err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("after-empty", 100, 0, 1); err != nil || id != 502 {
|
||||
t.Fatalf("empty import changed allocation: id=%d err=%v", id, err)
|
||||
}
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Users) != 2 || len(got.SpeedLimits) != 4 || got.SpeedLimits[1].Name != "existing" {
|
||||
t.Fatalf("existing or unselected data changed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportResolvesLegacySequenceAndSearchPath(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
pool, err := r.DB().DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool.SetMaxOpenConns(1)
|
||||
var schema string
|
||||
if err := r.DB().Raw(`SELECT current_schema()`).Scan(&schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prefix := "Empty-Prefix-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
if err := r.DB().Exec(`CREATE SCHEMA "` + prefix + `"`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.DB().Exec(`DROP SCHEMA "` + prefix + `" CASCADE`).Error })
|
||||
// The historical default uses a custom, unowned sequence; the original
|
||||
// owned serial sequence still exists but is no longer used by INSERT.
|
||||
for _, query := range []string{
|
||||
`CREATE SEQUENCE "Custom Limit Sequence"`,
|
||||
`ALTER TABLE speed_limit ALTER COLUMN id SET DEFAULT nextval('"Custom Limit Sequence"'::regclass)`,
|
||||
`SET search_path TO "` + prefix + `", "` + schema + `"`,
|
||||
} {
|
||||
if err := r.DB().Exec(query).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
|
||||
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 2 {
|
||||
t.Fatalf("wrong sequence or schema: id=%d err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForBackupDBCondition(t *testing.T, r *repo.Repository, query string, args ...interface{}) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var ready bool
|
||||
if err := r.DB().Raw(query, args...).Scan(&ready).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ready {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("database barrier was not reached")
|
||||
}
|
||||
|
||||
func backupImportBarrier(t *testing.T, r *repo.Repository, table, timing, event, condition string) (*sql.Tx, int64) {
|
||||
t.Helper()
|
||||
pool, err := r.DB().DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
barrier, err := pool.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = barrier.Rollback() })
|
||||
key := time.Now().UnixNano() & 0x3fffffff
|
||||
if _, err := barrier.Exec(`SELECT pg_advisory_xact_lock($1)`, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guardEnd := ""
|
||||
if condition != "" {
|
||||
guardEnd = "END IF;"
|
||||
}
|
||||
function := fmt.Sprintf(`CREATE FUNCTION pause_backup_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN %s PERFORM pg_advisory_xact_lock(%d); %s RETURN NEW; END $$`, condition, key, guardEnd)
|
||||
if err := r.DB().Exec(function).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trigger := fmt.Sprintf(`CREATE TRIGGER pause_backup_test %s %s ON %s FOR EACH ROW EXECUTE FUNCTION pause_backup_test()`, timing, event, table)
|
||||
if err := r.DB().Exec(trigger).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return barrier, key
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportSerializesConcurrentCreatesAndImports(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
barrier, key := backupImportBarrier(t, r, "speed_limit", "BEFORE", "INSERT", "IF NEW.name = 'hold-import' THEN")
|
||||
first := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "hold-import"}}}, []string{"speedLimits"})
|
||||
first <- err
|
||||
}()
|
||||
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
|
||||
created := make(chan error, 1)
|
||||
go func() {
|
||||
id, err := r.CreateSpeedLimit("concurrent-create", 100, 0, 1)
|
||||
if err == nil && id <= 100 {
|
||||
err = fmt.Errorf("reused imported ID: %d", id)
|
||||
}
|
||||
created <- err
|
||||
}()
|
||||
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE relation='speed_limit'::regclass AND NOT granted)`)
|
||||
second := make(chan error, 1)
|
||||
go func() {
|
||||
result, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 200, Name: "second-import"}}}, []string{"speedLimits"})
|
||||
if err == nil && result.SpeedLimitsImported != 1 {
|
||||
err = fmt.Errorf("wrong retry count: %+v", result)
|
||||
}
|
||||
second <- err
|
||||
}()
|
||||
if err := barrier.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, done := range map[string]<-chan error{"first import": first, "create": created, "second import": second} {
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("%s blocked", name)
|
||||
}
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("after-both", 100, 0, 1); err != nil || id <= 200 {
|
||||
t.Fatalf("second import sequence not advanced: id=%d err=%v", id, err)
|
||||
}
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.SpeedLimits) != 4 {
|
||||
t.Fatalf("lost concurrent rows: %+v", got.SpeedLimits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportAvoidsCascadeDeleteDeadlock(t *testing.T) {
|
||||
for _, mode := range []string{"retry-success", "busy-bounded"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
user, err := r.CreateUser("delete-me", "hash", 1, 0, 0, 0, 0, 1, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forward, err := r.CreateForwardTx(user, "delete-me", "old", 1, "127.0.0.1:80", "", 0, 0, []int64{1}, 12345, "", nil, 0, 0, nil, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pause a real public cascade after deleting a child row, before it
|
||||
// reaches forward/user. Import must not retain partial parent locks.
|
||||
barrier, key := backupImportBarrier(t, r, "forward_port", "AFTER", "DELETE", "")
|
||||
deleted := make(chan error, 1)
|
||||
go func() { deleted <- r.DeleteUserCascade(user) }()
|
||||
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
|
||||
emptyPorts := []model.ForwardPortBackup{}
|
||||
backup := &model.BackupData{
|
||||
Users: []model.UserBackup{{ID: 100, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}},
|
||||
Forwards: []model.ForwardBackup{{ID: forward, UserID: 100, Name: "restored", ForwardPorts: &emptyPorts}},
|
||||
}
|
||||
imported := make(chan error, 1)
|
||||
runImport := func() {
|
||||
result, err := r.Import(backup, []string{"users", "forwards"})
|
||||
if err == nil && (result.UsersImported != 1 || result.ForwardsImported != 1) {
|
||||
err = fmt.Errorf("wrong retry count: %+v", result)
|
||||
}
|
||||
imported <- err
|
||||
}
|
||||
go runImport()
|
||||
if mode == "busy-bounded" {
|
||||
select {
|
||||
case err := <-imported:
|
||||
if err == nil || !strings.Contains(err.Error(), "database is busy") {
|
||||
t.Fatalf("expected bounded busy error, got %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = barrier.Rollback()
|
||||
t.Fatal("import waited while holding partial table locks")
|
||||
}
|
||||
} else {
|
||||
// The held child lock makes every attempt fail before writes.
|
||||
// Let several bounded attempts encounter it before release.
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
}
|
||||
if err := barrier.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-deleted:
|
||||
if err != nil {
|
||||
t.Fatalf("cascade: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("cascade deadlocked")
|
||||
}
|
||||
if mode == "busy-bounded" {
|
||||
go runImport()
|
||||
}
|
||||
select {
|
||||
case err := <-imported:
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("import deadlocked")
|
||||
}
|
||||
if id, err := r.CreateUser("after", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id <= 100 {
|
||||
t.Fatalf("create after concurrent cascade: id=%d err=%v", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportRollsBackEarlierSequenceWhenLaterSequenceFails(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq MAXVALUE 10`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup := &model.BackupData{
|
||||
Users: []model.UserBackup{{ID: 100, User: "rollback-user", Pwd: "$2-test-fixture", RoleID: 1}},
|
||||
SpeedLimits: []model.SpeedLimitBackup{{ID: 10, Name: "exhausted"}},
|
||||
}
|
||||
// User sequence advances first. The exhausted limit sequence then fails.
|
||||
if _, err := r.Import(backup, []string{"users", "speedLimits"}); err == nil || !strings.Contains(err.Error(), "sequence exhausted") {
|
||||
t.Fatalf("expected sequence exhaustion: %v", err)
|
||||
}
|
||||
if id, err := r.CreateUser("after-failure", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
|
||||
t.Fatalf("earlier sequence failed to roll back: id=%d err=%v", id, err)
|
||||
}
|
||||
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
|
||||
t.Fatalf("later sequence changed: id=%d err=%v", id, err)
|
||||
}
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Users) != 2 || len(got.SpeedLimits) != 1 {
|
||||
t.Fatalf("partial import committed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupImportAPIAllowsImmediateCreate(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
const secret = "backup-sequence-contract"
|
||||
router := httpserver.NewRouter(handler.New(r, secret), secret)
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := func(path, body string) {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
request.Header.Set("Authorization", token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assertCode(t, response, 0)
|
||||
}
|
||||
post("/api/v1/backup/import", `{"version":"1.0","types":["users","speedLimits"],"users":[{"id":2,"user":"restored","pwd":"$2-test-fixture","roleId":1,"status":1}],"speedLimits":[{"id":1,"name":"restored","speed":100,"status":1}]}`)
|
||||
post("/api/v1/user/create", `{"user":"after-import","pwd":"test-only-password","status":1}`)
|
||||
post("/api/v1/speed-limit/create", `{"name":"after-import","speed":200}`)
|
||||
got, err := r.ExportAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Users) != 3 || got.Users[2].ID != 3 || len(got.SpeedLimits) != 2 || got.SpeedLimits[1].ID != 2 {
|
||||
t.Fatalf("API create did not retain restored records: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "relay-auth-contract")
|
||||
assertNewRelayTunnelEndpointAuthentication(t, router, r)
|
||||
}
|
||||
|
||||
func TestPostgresNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) {
|
||||
r := ownerNamesRepository(t, "postgres")()
|
||||
router := httpserver.NewRouter(handler.New(r, "relay-auth-contract"), "relay-auth-contract")
|
||||
assertNewRelayTunnelEndpointAuthentication(t, router, r)
|
||||
}
|
||||
|
||||
func assertNewRelayTunnelEndpointAuthentication(t *testing.T, router http.Handler, r *repo.Repository) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := insertContractNode(t, r, "auth-entry", "192.0.2.10", "41000-41010", "entry-management-secret", 1)
|
||||
exit := insertContractNode(t, r, "auth-exit", "192.0.2.11", "42000-42010", "exit-management-secret", 1)
|
||||
var mu sync.Mutex
|
||||
var connector, receiver map[string]interface{}
|
||||
reconnectEvents := make(chan string, 8)
|
||||
watchReconnect := false
|
||||
record := func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if kind == "AddChains" || kind == "UpdateChains" {
|
||||
var c map[string]interface{}
|
||||
_ = json.Unmarshal(data, &c)
|
||||
if nested, ok := c["data"].(map[string]interface{}); ok {
|
||||
c = nested
|
||||
}
|
||||
hops, _ := c["hops"].([]interface{})
|
||||
if len(hops) > 0 {
|
||||
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||
connector, _ = nodes[0].(map[string]interface{})["connector"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
if kind == "AddService" || kind == "UpdateService" {
|
||||
var services []map[string]interface{}
|
||||
_ = json.Unmarshal(data, &services)
|
||||
if len(services) > 0 {
|
||||
receiver, _ = services[0]["handler"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||
if watchReconnect {
|
||||
p, _ := receiver["password"].(string)
|
||||
select {
|
||||
case reconnectEvents <- p:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "entry-management-secret", record)
|
||||
defer stopEntry()
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "exit-management-secret", record)
|
||||
defer stopExit()
|
||||
payload := map[string]interface{}{"name": "authenticated-new-tunnel", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
mu.Lock()
|
||||
if connector == nil || receiver == nil {
|
||||
t.Fatal("new relay tunnel emitted no endpoint authentication")
|
||||
}
|
||||
if connector["username"] == "" || connector["password"] == "" || connector["username"] != receiver["username"] || connector["password"] != receiver["password"] {
|
||||
t.Fatal("relay endpoint credentials missing or mismatched")
|
||||
}
|
||||
if receiver["password"] == "entry-management-secret" || receiver["password"] == "exit-management-secret" {
|
||||
t.Fatal("management secret reused for relay")
|
||||
}
|
||||
listReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", bytes.NewBufferString(`{}`))
|
||||
listReq.Header.Set("Authorization", token)
|
||||
listRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRes, listReq)
|
||||
if !bytes.Contains(listRes.Body.Bytes(), []byte(`"relayAuthMode":"required"`)) {
|
||||
t.Fatalf("tunnel list does not expose required authentication state: %s", listRes.Body.String())
|
||||
}
|
||||
password, _ := receiver["password"].(string)
|
||||
if bytes.Contains(listRes.Body.Bytes(), []byte(password)) {
|
||||
t.Fatal("tunnel list disclosed relay credential")
|
||||
}
|
||||
exportReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`))
|
||||
exportReq.Header.Set("Authorization", token)
|
||||
exportRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(exportRes, exportReq)
|
||||
if !bytes.Contains(exportRes.Body.Bytes(), []byte(`"relayAuthMode":"required"`)) {
|
||||
t.Fatal("configuration export lost required authentication policy")
|
||||
}
|
||||
if bytes.Contains(exportRes.Body.Bytes(), []byte(password)) {
|
||||
t.Fatal("configuration export disclosed private relay password")
|
||||
}
|
||||
originalPassword := password
|
||||
mu.Unlock()
|
||||
var listed struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(listRes.Body.Bytes(), &listed); err != nil || len(listed.Data) != 1 {
|
||||
t.Fatal("missing created tunnel in list")
|
||||
}
|
||||
payload["id"] = listed.Data[0].ID
|
||||
body, _ = json.Marshal(payload)
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||
updateReq.Header.Set("Authorization", token)
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
|
||||
assertCode(t, updateRes, 0)
|
||||
mu.Lock()
|
||||
if connector == nil || receiver == nil || connector["password"] != originalPassword || receiver["password"] != originalPassword {
|
||||
t.Fatal("topology reapply lost or changed persisted endpoint authentication")
|
||||
}
|
||||
|
||||
watchReconnect = true
|
||||
mu.Unlock()
|
||||
stopExit()
|
||||
stopReconnected := startMockNodeSessionWithCommandRecorder(t, server.URL, "exit-management-secret", record)
|
||||
defer stopReconnected()
|
||||
// Request reconciliation explicitly rather than waiting for the production
|
||||
// reconnect debounce (30 seconds). This crosses the same HTTP/command seam.
|
||||
redeployBody, _ := json.Marshal(map[string]interface{}{"ids": []int64{listed.Data[0].ID}})
|
||||
redeployReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(redeployBody))
|
||||
redeployReq.Header.Set("Authorization", token)
|
||||
redeployRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(redeployRes, redeployReq)
|
||||
assertCode(t, redeployRes, 0)
|
||||
|
||||
select {
|
||||
case password := <-reconnectEvents:
|
||||
if password != originalPassword {
|
||||
t.Fatal("node reconnect lost persisted authentication")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("node reconnect did not reconcile relay service")
|
||||
}
|
||||
// Corrupt private desired state as a fault fixture. Reapply must stop rather
|
||||
// than regenerating a password or emitting an anonymous listener.
|
||||
if err := r.DB().Exec("DELETE FROM relay_endpoint_auth WHERE tunnel_id = ?", listed.Data[0].ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missingReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||
missingReq.Header.Set("Authorization", token)
|
||||
missingRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(missingRes, missingReq)
|
||||
var missing struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(missingRes.Body.Bytes(), &missing)
|
||||
if missing.Code == 0 {
|
||||
t.Fatal("missing required credentials silently regenerated or downgraded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederatedRelayRequiresVersionedAuthAndRejectsDowngrade(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "provider-auth-contract")
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
node := insertContractNode(t, r, "provider-auth", "192.0.2.30", "45000-45010", "provider-management", 1)
|
||||
insertPeerShare(t, r, &repo.PeerShare{Name: "auth-share", NodeID: node, Token: "auth-share-token", PortRangeStart: 45000, PortRangeEnd: 45010, IsActive: 1})
|
||||
var mu sync.Mutex
|
||||
var received map[string]interface{}
|
||||
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, "provider-management", func(kind string, data json.RawMessage) (bool, string) {
|
||||
if kind == "UpdateService" {
|
||||
var services []map[string]interface{}
|
||||
_ = json.Unmarshal(data, &services)
|
||||
if len(services) > 0 {
|
||||
mu.Lock()
|
||||
received, _ = services[0]["handler"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stop()
|
||||
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||
b, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", "Bearer auth-share-token")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
reserve := post("/api/v1/federation/runtime/reserve-port", map[string]interface{}{"resourceKey": "auth-contract-endpoint", "protocol": "tls"})
|
||||
var reservation struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
ReservationID string `json:"reservationId"`
|
||||
RelayAuthVersion int `json:"relayAuthVersion"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(reserve.Body.Bytes(), &reservation); err != nil || reservation.Code != 0 {
|
||||
t.Fatal("reserve failed")
|
||||
}
|
||||
if reservation.Data.RelayAuthVersion != 1 {
|
||||
t.Fatal("provider did not advertise relay authentication capability")
|
||||
}
|
||||
creds := map[string]interface{}{"version": 1, "username": "test-relay-user", "password": "test-only-relay-password-32-bytes-long"}
|
||||
request := map[string]interface{}{"reservationId": reservation.Data.ReservationID, "resourceKey": "auth-contract-endpoint", "role": "exit", "protocol": "tls", "relayAuth": creds}
|
||||
applied := post("/api/v1/federation/runtime/apply-role", request)
|
||||
if !bytes.Contains(applied.Body.Bytes(), []byte(`"relayAuthVersion":1`)) {
|
||||
t.Fatal("provider did not acknowledge authenticated runtime")
|
||||
}
|
||||
assertCode(t, applied, 0)
|
||||
mu.Lock()
|
||||
actual := received
|
||||
mu.Unlock()
|
||||
if actual == nil || actual["username"] != creds["username"] || actual["password"] != creds["password"] {
|
||||
t.Fatal("provider omitted requested relay authentication")
|
||||
}
|
||||
delete(request, "relayAuth")
|
||||
rejected := post("/api/v1/federation/runtime/apply-role", request)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(rejected.Body.Bytes(), &result)
|
||||
if result.Code == 0 {
|
||||
t.Fatal("provider silently downgraded authenticated runtime")
|
||||
}
|
||||
|
||||
// Loss of private persisted state must not be mistaken for legacy mode.
|
||||
if err := r.DB().Exec("UPDATE peer_share_runtime SET relay_auth = '' WHERE resource_key = ?", "auth-contract-endpoint").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request["relayAuth"] = creds
|
||||
missing := post("/api/v1/federation/runtime/apply-role", request)
|
||||
_ = json.Unmarshal(missing.Body.Bytes(), &result)
|
||||
if result.Code == 0 {
|
||||
t.Fatal("provider recreated missing protected state instead of failing closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFederatedTunnelAuthenticatesEveryRelayHop(t *testing.T) {
|
||||
providerSecret := "provider-contract-jwt"
|
||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||
providerServer := httptest.NewServer(providerRouter)
|
||||
defer providerServer.Close()
|
||||
|
||||
consumerSecret := "consumer-contract-jwt"
|
||||
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
|
||||
|
||||
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate consumer admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry", "198.51.100.11", "43000-43010", "provider-entry-secret", 1)
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
|
||||
|
||||
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "entry-share",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-token",
|
||||
PortRangeStart: 43000,
|
||||
PortRangeEnd: 43010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "middle-share",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-token",
|
||||
PortRangeStart: 44000,
|
||||
PortRangeEnd: 44010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "exit-share",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-token",
|
||||
PortRangeStart: 45000,
|
||||
PortRangeEnd: 45010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-token")
|
||||
|
||||
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-token")
|
||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||
|
||||
var mu sync.Mutex
|
||||
services := map[string]string{}
|
||||
strictMiddle, strictExit := false, false
|
||||
connectors := map[string]string{}
|
||||
record := func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if kind == "UpdateService" || kind == "AddService" {
|
||||
var items []map[string]interface{}
|
||||
_ = json.Unmarshal(data, &items)
|
||||
for _, item := range items {
|
||||
handler, _ := item["handler"].(map[string]interface{})
|
||||
if handler["type"] == "relay" {
|
||||
md, _ := handler["metadata"].(map[string]interface{})
|
||||
if handler["chain"] != nil {
|
||||
strictMiddle, _ = md["chain.strict"].(bool)
|
||||
} else {
|
||||
strictExit, _ = md["chain.strict"].(bool)
|
||||
}
|
||||
a, _ := handler["auth"].(map[string]interface{})
|
||||
if a != nil {
|
||||
user, _ := a["username"].(string)
|
||||
pass, _ := a["password"].(string)
|
||||
services[user] = pass
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == "UpdateChains" || kind == "AddChains" {
|
||||
var c map[string]interface{}
|
||||
_ = json.Unmarshal(data, &c)
|
||||
if d, ok := c["data"].(map[string]interface{}); ok {
|
||||
c = d
|
||||
}
|
||||
hops, _ := c["hops"].([]interface{})
|
||||
for _, hop := range hops {
|
||||
nodes, _ := hop.(map[string]interface{})["nodes"].([]interface{})
|
||||
for _, node := range nodes {
|
||||
a, _ := node.(map[string]interface{})["connector"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||
if a != nil {
|
||||
user, _ := a["username"].(string)
|
||||
pass, _ := a["password"].(string)
|
||||
connectors[user] = pass
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
stopEntry := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-entry-secret", record)
|
||||
defer stopEntry()
|
||||
stopMiddle := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-middle-secret", record)
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-exit-secret", record)
|
||||
defer stopExit()
|
||||
|
||||
createTunnel := func(name string) int64 {
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", consumerAdminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id for %s", name)
|
||||
}
|
||||
return tunnelID
|
||||
}
|
||||
|
||||
_ = createTunnel("dual-panel-middle-exit-1")
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !strictMiddle || strictExit {
|
||||
t.Fatal("middle must require its chain and exit must permit direct dialing")
|
||||
}
|
||||
if len(services) != 2 || len(connectors) != 2 {
|
||||
t.Fatalf("expected two authenticated receivers and matching connectors, got %d and %d", len(services), len(connectors))
|
||||
}
|
||||
for user, pass := range services {
|
||||
if pass == "" || connectors[user] != pass {
|
||||
t.Fatal("federation relay credentials do not match")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayAuthenticationSurvivesInsertingBeforeExistingMiddle(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "insert-auth-contract")
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
token, _ := auth.GenerateToken(1, "admin", 0, "insert-auth-contract")
|
||||
ids := []int64{}
|
||||
var mu sync.Mutex
|
||||
received := map[string]string{}
|
||||
for i, name := range []string{"entry-a", "middle-b", "middle-c", "exit-d"} {
|
||||
id := insertContractNode(t, r, name, fmt.Sprintf("192.0.2.%d", 40+i), fmt.Sprintf("%d-%d", 46000+i*100, 46010+i*100), name+"-secret", 1)
|
||||
ids = append(ids, id)
|
||||
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, name+"-secret", func(kind string, data json.RawMessage) (bool, string) {
|
||||
if kind == "AddService" || kind == "UpdateService" {
|
||||
var items []map[string]interface{}
|
||||
_ = json.Unmarshal(data, &items)
|
||||
for _, item := range items {
|
||||
handler, _ := item["handler"].(map[string]interface{})
|
||||
if a, ok := handler["auth"].(map[string]interface{}); ok {
|
||||
mu.Lock()
|
||||
received[name], _ = a["password"].(string)
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stop()
|
||||
}
|
||||
node := func(id int64) map[string]interface{} { return map[string]interface{}{"nodeId": id, "protocol": "tls"} }
|
||||
payload := map[string]interface{}{"name": "insert-auth", "type": 2, "inNodeId": []map[string]interface{}{node(ids[0])}, "chainNodes": [][]map[string]interface{}{{node(ids[1])}}, "outNodeId": []map[string]interface{}{node(ids[3])}}
|
||||
post := func(path string) *httptest.ResponseRecorder {
|
||||
b, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
assertCode(t, post("/api/v1/tunnel/create"), 0)
|
||||
mu.Lock()
|
||||
original := received["middle-b"]
|
||||
mu.Unlock()
|
||||
if original == "" {
|
||||
t.Fatal("original middle missing auth")
|
||||
}
|
||||
payload["id"] = mustQueryInt64(t, r, "SELECT id FROM tunnel WHERE name = ?", "insert-auth")
|
||||
payload["chainNodes"] = [][]map[string]interface{}{{node(ids[2])}, {node(ids[1])}}
|
||||
assertCode(t, post("/api/v1/tunnel/update"), 0)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if received["middle-b"] != original || received["middle-c"] == "" || received["middle-c"] == original {
|
||||
t.Fatal("hop insertion changed existing receiver identity or reused another credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertingDirectTunnelRequiresRelayAuthentication(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "relay-auth-contract")
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := insertContractNode(t, r, "auth-entry", "192.0.2.10", "41000-41010", "entry-management-secret", 1)
|
||||
exit := insertContractNode(t, r, "auth-exit", "192.0.2.11", "42000-42010", "exit-management-secret", 1)
|
||||
var mu sync.Mutex
|
||||
var connector, receiver map[string]interface{}
|
||||
record := func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if kind == "AddChains" || kind == "UpdateChains" {
|
||||
var c map[string]interface{}
|
||||
_ = json.Unmarshal(data, &c)
|
||||
if nested, ok := c["data"].(map[string]interface{}); ok {
|
||||
c = nested
|
||||
}
|
||||
hops, _ := c["hops"].([]interface{})
|
||||
if len(hops) > 0 {
|
||||
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||
connector, _ = nodes[0].(map[string]interface{})["connector"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
if kind == "AddService" || kind == "UpdateService" {
|
||||
var services []map[string]interface{}
|
||||
_ = json.Unmarshal(data, &services)
|
||||
if len(services) > 0 {
|
||||
receiver, _ = services[0]["handler"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "entry-management-secret", record)
|
||||
defer stopEntry()
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "exit-management-secret", record)
|
||||
defer stopExit()
|
||||
payload := map[string]interface{}{"name": "authenticated-new-tunnel", "type": 1, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
payload["type"] = 2
|
||||
payload["id"] = mustQueryInt64(t, r, "SELECT id FROM tunnel WHERE name = ?", "authenticated-new-tunnel")
|
||||
conversionBody, _ := json.Marshal(payload)
|
||||
conversionReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(conversionBody))
|
||||
conversionReq.Header.Set("Authorization", token)
|
||||
conversionRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(conversionRes, conversionReq)
|
||||
assertCode(t, conversionRes, 0)
|
||||
|
||||
mu.Lock()
|
||||
if connector == nil || receiver == nil {
|
||||
t.Fatal("new relay tunnel emitted no endpoint authentication")
|
||||
}
|
||||
if connector["username"] == "" || connector["password"] == "" || connector["username"] != receiver["username"] || connector["password"] != receiver["password"] {
|
||||
t.Fatal("relay endpoint credentials missing or mismatched")
|
||||
}
|
||||
if receiver["password"] == "entry-management-secret" || receiver["password"] == "exit-management-secret" {
|
||||
t.Fatal("management secret reused for relay")
|
||||
}
|
||||
listReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", bytes.NewBufferString(`{}`))
|
||||
listReq.Header.Set("Authorization", token)
|
||||
listRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRes, listReq)
|
||||
if !bytes.Contains(listRes.Body.Bytes(), []byte(`"relayAuthMode":"required"`)) {
|
||||
t.Fatalf("tunnel list does not expose required authentication state: %s", listRes.Body.String())
|
||||
}
|
||||
password, _ := receiver["password"].(string)
|
||||
if bytes.Contains(listRes.Body.Bytes(), []byte(password)) {
|
||||
t.Fatal("tunnel list disclosed relay credential")
|
||||
}
|
||||
originalPassword := password
|
||||
mu.Unlock()
|
||||
var listed struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(listRes.Body.Bytes(), &listed); err != nil || len(listed.Data) != 1 {
|
||||
t.Fatal("missing created tunnel in list")
|
||||
}
|
||||
payload["id"] = listed.Data[0].ID
|
||||
body, _ = json.Marshal(payload)
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||
updateReq.Header.Set("Authorization", token)
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
|
||||
assertCode(t, updateRes, 0)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if connector == nil || receiver == nil || connector["password"] != originalPassword || receiver["password"] != originalPassword {
|
||||
t.Fatal("topology reapply lost or changed persisted endpoint authentication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupImportCannotSilentlyDowngradeRequiredRelay(t *testing.T) {
|
||||
router, _ := setupContractRouter(t, "backup-relay-auth")
|
||||
token, _ := auth.GenerateToken(1, "admin", 0, "backup-relay-auth")
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewBufferString(`{"version":"1","types":["tunnels"],"tunnels":[{"id":92,"name":"protected-backup","type":2,"protocol":"tls","flow":1,"trafficRatio":1,"createdTime":1000,"status":1,"relayAuthMode":"required"}]}`))
|
||||
request.Header.Set("Authorization", token)
|
||||
result := httptest.NewRecorder()
|
||||
router.ServeHTTP(result, request)
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if out.Code == 0 {
|
||||
t.Fatal("configuration backup restored required relay without its private credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRelayTunnelRefusesLegacyFederationBeforeReservation(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
mutations := 0
|
||||
oldPeer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Path != "/api/v1/federation/connect" {
|
||||
mu.Lock()
|
||||
mutations++
|
||||
mu.Unlock()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"shareId":1,"shareName":"legacy","nodeId":1,"nodeName":"legacy-exit","serverIp":"192.0.2.80","status":1,"portRangeStart":49000,"portRangeEnd":49010}}`))
|
||||
}))
|
||||
defer oldPeer.Close()
|
||||
router, r := setupContractRouter(t, "legacy-peer-contract")
|
||||
token, _ := auth.GenerateToken(1, "admin", 0, "legacy-peer-contract")
|
||||
entry := insertContractNode(t, r, "legacy-test-entry", "192.0.2.79", "48000-48010", "legacy-test-entry-secret", 1)
|
||||
importRemoteNodeForContract(t, router, token, oldPeer.URL, "legacy-share-token")
|
||||
exit := queryRemoteNodeIDByToken(t, r, "legacy-share-token")
|
||||
payload := map[string]interface{}{"name": "must-not-downgrade", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||
b, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(res.Body.Bytes(), &out)
|
||||
if out.Code == 0 {
|
||||
t.Fatal("new tunnel silently accepted legacy federation")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if mutations != 0 {
|
||||
t.Fatal("legacy peer was mutated before capability refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRelayStateRemainsExplicitInList(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "legacy-mode-contract")
|
||||
token, _ := auth.GenerateToken(1, "admin", 0, "legacy-mode-contract")
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(name,traffic_ratio,type,protocol,flow,created_time,updated_time,status,inx) VALUES('legacy-existing',1,2,'tls',1,1000,1000,1,0)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
if !bytes.Contains(res.Body.Bytes(), []byte(`"relayAuthMode":"legacy"`)) {
|
||||
t.Fatal("legacy relay exposure is hidden from API clients")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestSystemUpgradeStatusRequiresAdminAndReturnsLocalState(t *testing.T) {
|
||||
secret := "upgrade-status-contract-secret"
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PANEL_DEPLOY_DIR", dir)
|
||||
t.Setenv("FLUX_VERSION", "3.0.1-rc10")
|
||||
// A status read with no job must not require Docker or contact GitHub.
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
router, r := setupContractRouter(t, secret)
|
||||
seedContractUser(t, r, 2, "normal_user", 1, 1)
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, token string
|
||||
want int
|
||||
}{{"anonymous", "", 401}, {"normal user", userToken, 403}, {"admin", adminToken, 0}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade/status", strings.NewReader(`{}`))
|
||||
req.Header.Set("Authorization", tc.token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code != tc.want {
|
||||
t.Fatalf("expected code %d, got %d: %s", tc.want, out.Code, out.Msg)
|
||||
}
|
||||
if tc.want == 0 {
|
||||
data, ok := out.Data.(map[string]interface{})
|
||||
if !ok || data["currentVersion"] != "3.0.1-rc10" || data["job"] != nil {
|
||||
t.Fatalf("unexpected idle status: %#v", out.Data)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("idle status should not mutate deployment: entries=%v err=%v", entries, err)
|
||||
}
|
||||
for _, body := range []string{`{"jobId":"../../.env"}`, `{"jobId":`, `{"jobId":123}`} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade/status", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code == 0 || out.Data != nil || strings.Contains(out.Msg, dir) || strings.Contains(out.Msg, ".env") {
|
||||
t.Fatalf("invalid request must fail without leaking filesystem detail: %#v", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"go-backend/internal/auth"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTunnelDeleteWaitsForCreateRuntime(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "create-lifecycle")
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
token, err := auth.GenerateToken(1, "admin", 0, "create-lifecycle")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := insertContractNode(t, r, "create-entry", "192.0.2.10", "41000-41010", "create-entry-secret", 1)
|
||||
exit := insertContractNode(t, r, "create-exit", "192.0.2.11", "42000-42010", "create-exit-secret", 1)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var once, releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
deleted := make(chan struct{}, 1)
|
||||
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "create-entry-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||
if kind == "DeleteChains" {
|
||||
select {
|
||||
case deleted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopEntry()
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "create-exit-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||
if kind == "AddService" {
|
||||
once.Do(func() { close(entered); <-release })
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopExit()
|
||||
defer unblock()
|
||||
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
created := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() {
|
||||
created <- post("/api/v1/tunnel/create", map[string]interface{}{"name": "create-race", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}})
|
||||
}()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("create did not reach runtime gate")
|
||||
}
|
||||
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||
var list struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
|
||||
t.Fatal("committed create not listed")
|
||||
}
|
||||
deleteDone := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { deleteDone <- post("/api/v1/tunnel/delete", map[string]interface{}{"id": list.Data[0].ID}) }()
|
||||
select {
|
||||
case <-deleted:
|
||||
t.Error("delete removed runtime while create was still applying")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
unblock()
|
||||
for _, done := range []chan *httptest.ResponseRecorder{created, deleteDone} {
|
||||
select {
|
||||
case res := <-done:
|
||||
assertCode(t, res, 0)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("lifecycle operation stuck")
|
||||
}
|
||||
}
|
||||
listed = post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 0 {
|
||||
t.Fatal("deleted tunnel remains listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineTunnelCreateReportsPendingRuntime(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "pending-lifecycle")
|
||||
token, err := auth.GenerateToken(1, "admin", 0, "pending-lifecycle")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := insertContractNode(t, r, "pending-entry", "192.0.2.10", "41000-41010", "pending-entry-secret", 1)
|
||||
exit := insertContractNode(t, r, "pending-exit", "192.0.2.11", "42000-42010", "pending-exit-secret", 1)
|
||||
payload := map[string]interface{}{"name": "pending-tunnel", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
RuntimeStatus string `json:"runtimeStatus"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code != 0 || out.Data.RuntimeStatus != "pending" || out.Data.TunnelID <= 0 || out.Msg == "操作成功" {
|
||||
t.Fatalf("offline create must preserve config but explicitly report pending: %s", res.Body.String())
|
||||
}
|
||||
|
||||
payload["id"] = out.Data.TunnelID
|
||||
raw, _ = json.Marshal(payload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", token)
|
||||
res = httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
out.Data.RuntimeStatus = ""
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code != 0 || out.Data.RuntimeStatus != "pending" {
|
||||
t.Fatalf("offline update must report pending: %s", res.Body.String())
|
||||
}
|
||||
raw, _ = json.Marshal(map[string]interface{}{"ids": []int64{out.Data.TunnelID}})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", token)
|
||||
res = httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
var batch struct {
|
||||
Data struct {
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailCount int `json:"failCount"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.Data.SuccessCount != 0 || batch.Data.FailCount != 1 {
|
||||
t.Fatalf("pending redeploy must not count as success: %s", res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingTunnelUpdateStillRemovesObsoleteRelay(t *testing.T) {
|
||||
testPendingTunnelRemoval(t, false, false)
|
||||
}
|
||||
func TestFailedObsoleteRelayRemovalPreservesItsChain(t *testing.T) {
|
||||
testPendingTunnelRemoval(t, true, false)
|
||||
}
|
||||
func TestRedesiredRelayKeepsOldPortReservedUntilReplacementAcknowledged(t *testing.T) {
|
||||
testPendingTunnelRemoval(t, true, true)
|
||||
}
|
||||
|
||||
func testPendingTunnelRemoval(t *testing.T, failCleanup, readd bool) {
|
||||
router, r := setupContractRouter(t, "pending-removal")
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
token, err := auth.GenerateToken(1, "admin", 0, "pending-removal")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := insertContractNode(t, r, "removal-entry", "192.0.2.10", "41000-41010", "removal-entry-secret", 1)
|
||||
middle := insertContractNode(t, r, "removal-middle", "192.0.2.20", "42000-42010", "removal-middle-secret", 1)
|
||||
exit := insertContractNode(t, r, "removal-exit", "192.0.2.30", "43000-43010", "removal-exit-secret", 1)
|
||||
var entryTimeout atomic.Bool
|
||||
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "removal-entry-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||
if entryTimeout.Load() && (kind == "AddChains" || kind == "UpdateChains") {
|
||||
return true, "等待节点响应超时"
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopEntry()
|
||||
var mu sync.Mutex
|
||||
deleted := map[string]bool{}
|
||||
var timeoutReplacement bool
|
||||
stopMiddle := startMockNodeSessionWithCommandRecorder(t, server.URL, "removal-middle-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if kind == "DeleteService" || kind == "DeleteChains" {
|
||||
deleted[kind] = true
|
||||
}
|
||||
if timeoutReplacement && kind == "UpdateService" {
|
||||
return true, "等待节点响应超时"
|
||||
}
|
||||
if failCleanup && kind == "DeleteService" {
|
||||
return true, "cleanup rejected"
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSession(t, server.URL, "removal-exit-secret")
|
||||
defer stopExit()
|
||||
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
payload := map[string]interface{}{"name": "pending-removal", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42000}}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
|
||||
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||
var list struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
|
||||
t.Fatal("missing tunnel")
|
||||
}
|
||||
if readd {
|
||||
entryTimeout.Store(true)
|
||||
} else {
|
||||
stopEntry()
|
||||
waitNodeStatus(t, r, entry, 0)
|
||||
}
|
||||
payload["id"] = list.Data[0].ID
|
||||
payload["chainNodes"] = [][]map[string]interface{}{}
|
||||
res := post("/api/v1/tunnel/update", payload)
|
||||
var out struct {
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
RuntimeStatus string `json:"runtimeStatus"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code != 0 || out.Data.RuntimeStatus != "pending" {
|
||||
t.Fatalf("expected pending update: %s", res.Body.String())
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if failCleanup {
|
||||
if !deleted["DeleteService"] || deleted["DeleteChains"] {
|
||||
t.Fatalf("failed listener deletion must preserve its required chain: %v", deleted)
|
||||
}
|
||||
if !strings.Contains(out.Msg, "cleanup rejected") {
|
||||
t.Fatalf("cleanup failure hidden: %s", out.Msg)
|
||||
}
|
||||
if readd {
|
||||
deleted = map[string]bool{}
|
||||
timeoutReplacement = true
|
||||
mu.Unlock()
|
||||
payload["chainNodes"] = [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42001}}}
|
||||
changed := post("/api/v1/tunnel/update", payload)
|
||||
if err := json.Unmarshal(changed.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code != 0 || out.Data.RuntimeStatus != "pending" {
|
||||
t.Fatalf("expected pending replacement: %s", changed.Body.String())
|
||||
}
|
||||
contender := map[string]interface{}{"name": "old-port-contender", "type": 2, "inNodeId": payload["inNodeId"], "outNodeId": payload["outNodeId"], "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42000}}}}
|
||||
blocked := post("/api/v1/tunnel/create", contender)
|
||||
if err := json.Unmarshal(blocked.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code == 0 || !strings.Contains(out.Msg, "旧运行时清理") {
|
||||
t.Fatalf("unacknowledged old port was not reserved: %s", blocked.Body.String())
|
||||
}
|
||||
mu.Lock()
|
||||
timeoutReplacement = false
|
||||
mu.Unlock()
|
||||
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{list.Data[0].ID}})
|
||||
accepted := post("/api/v1/tunnel/create", contender)
|
||||
if err := json.Unmarshal(accepted.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("acknowledged replacement did not release old port: %s", accepted.Body.String())
|
||||
}
|
||||
mu.Lock()
|
||||
if len(deleted) != 0 {
|
||||
t.Fatalf("delayed cleanup deleted re-desired relay: %v", deleted)
|
||||
}
|
||||
return
|
||||
}
|
||||
failCleanup = false
|
||||
deleted = map[string]bool{}
|
||||
mu.Unlock()
|
||||
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{list.Data[0].ID}})
|
||||
mu.Lock()
|
||||
if !deleted["DeleteService"] || !deleted["DeleteChains"] {
|
||||
t.Fatalf("redeploy lost obsolete cleanup ownership after failure: %v", deleted)
|
||||
}
|
||||
deleted = map[string]bool{}
|
||||
mu.Unlock()
|
||||
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{list.Data[0].ID}})
|
||||
mu.Lock()
|
||||
if len(deleted) != 0 {
|
||||
t.Fatalf("acknowledged cleanup repeated: %v", deleted)
|
||||
}
|
||||
} else if !deleted["DeleteService"] || !deleted["DeleteChains"] {
|
||||
t.Fatalf("pending update left removed relay live: %v", deleted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/store/repo"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConcurrentCreatesReserveLocalPortsBeforeFederationAcknowledgment(t *testing.T) {
|
||||
provider, pr := setupContractRouter(t, "port-provider")
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var gateOnce, releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
ps := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/federation/runtime/apply-role" {
|
||||
gateOnce.Do(func() { close(entered); <-release })
|
||||
}
|
||||
provider.ServeHTTP(w, r)
|
||||
}))
|
||||
defer ps.Close()
|
||||
exit := insertContractNode(t, pr, "provider-port-exit", "192.0.2.30", "43000-43010", "provider-port-exit-secret", 1)
|
||||
now := time.Now().UnixMilli()
|
||||
insertPeerShare(t, pr, &repo.PeerShare{Name: "port-share", NodeID: exit, Token: "port-share-token", PortRangeStart: 43000, PortRangeEnd: 43010, IsActive: 1, CreatedTime: now, UpdatedTime: now})
|
||||
stop := startMockNodeSession(t, ps.URL, "provider-port-exit-secret")
|
||||
defer stop()
|
||||
defer unblock()
|
||||
consumer, cr := setupContractRouter(t, "port-consumer")
|
||||
token, err := auth.GenerateToken(1, "admin", 0, "port-consumer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := insertContractNode(t, cr, "local-port-entry", "192.0.2.10", "41000-41010", "local-port-entry-secret", 1)
|
||||
middle := insertContractNode(t, cr, "local-port-middle", "192.0.2.20", "42000", "local-port-middle-secret", 1)
|
||||
importRemoteNodeForContract(t, consumer, token, ps.URL, "port-share-token")
|
||||
remoteExit := queryRemoteNodeIDByToken(t, cr, "port-share-token")
|
||||
create := func(name string) *httptest.ResponseRecorder {
|
||||
payload := map[string]interface{}{"name": name, "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls"}}}, "outNodeId": []map[string]interface{}{{"nodeId": remoteExit, "protocol": "tls"}}}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
consumer.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
first := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { first <- create("port-owner") }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("first create did not reach provider gate")
|
||||
}
|
||||
second := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { second <- create("port-contender") }()
|
||||
secondCompleted := false
|
||||
select {
|
||||
case res := <-second:
|
||||
secondCompleted = true
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Errorf("second create reused the only reserved local port: %s", res.Body.String())
|
||||
}
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
t.Error("second create did not reject the reserved local port before provider acknowledgment")
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case res := <-first:
|
||||
assertCode(t, res, 0)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("first create stuck")
|
||||
}
|
||||
// If the regression let the second request reach the gated provider, drain it
|
||||
// before closing test servers; its success still cannot erase the failure above.
|
||||
if !secondCompleted {
|
||||
select {
|
||||
case <-second:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("second create stuck after gate release")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"go-backend/internal/auth"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeletedTunnelCleanupSurvivesHandlerReplacement(t *testing.T) {
|
||||
const secret = "deleted-cleanup"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
token, err := auth.GenerateToken(1, "admin", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := insertContractNode(t, r, "deleted-entry", "192.0.2.10", "41000-41010", "deleted-entry-secret", 1)
|
||||
exit := insertContractNode(t, r, "deleted-exit", "192.0.2.20", "42000", "deleted-exit-secret", 1)
|
||||
stopEntry := startMockNodeSession(t, server.URL, "deleted-entry-secret")
|
||||
defer stopEntry()
|
||||
var rejectDelete atomic.Bool
|
||||
rejectDelete.Store(true)
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "deleted-exit-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||
if kind == "DeleteService" && rejectDelete.Load() {
|
||||
return true, "cleanup rejected"
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopExit()
|
||||
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
payload := map[string]interface{}{"name": "deleted-owner", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls", "port": 42000}}}
|
||||
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
|
||||
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||
var list struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
|
||||
t.Fatal("missing tunnel")
|
||||
}
|
||||
id := list.Data[0].ID
|
||||
res := post("/api/v1/tunnel/delete", map[string]interface{}{"id": id})
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code == 0 || !strings.Contains(out.Msg, "待重试") {
|
||||
t.Fatalf("failed post-delete cleanup not reported: %s", res.Body.String())
|
||||
}
|
||||
listed = post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 0 {
|
||||
t.Fatal("desired tunnel not deleted")
|
||||
}
|
||||
payload["name"] = "deleted-port-contender"
|
||||
res = post("/api/v1/tunnel/create", payload)
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code == 0 || !strings.Contains(out.Msg, "旧运行时清理") {
|
||||
t.Fatalf("deleted tunnel lost port ownership: %s", res.Body.String())
|
||||
}
|
||||
stopEntry()
|
||||
stopExit()
|
||||
waitNodeStatus(t, r, entry, 0)
|
||||
waitNodeStatus(t, r, exit, 0)
|
||||
// A fresh handler has no old in-memory topology, locks, or cleanup state.
|
||||
router = httpserver.NewRouter(handler.New(r, secret), secret)
|
||||
recoveredServer := httptest.NewServer(router)
|
||||
defer recoveredServer.Close()
|
||||
newEntry := startMockNodeSession(t, recoveredServer.URL, "deleted-entry-secret")
|
||||
defer newEntry()
|
||||
var deleted atomic.Int32
|
||||
newExit := startMockNodeSessionWithCommandRecorder(t, recoveredServer.URL, "deleted-exit-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||
if kind == "DeleteService" {
|
||||
deleted.Add(1)
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer newExit()
|
||||
waitNodeStatus(t, r, entry, 1)
|
||||
waitNodeStatus(t, r, exit, 1)
|
||||
assertCode(t, post("/api/v1/tunnel/delete", map[string]interface{}{"id": id}), 0)
|
||||
if deleted.Load() != 1 {
|
||||
t.Fatalf("fresh handler did not recover obsolete listener ownership: %d", deleted.Load())
|
||||
}
|
||||
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
|
||||
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{id}})
|
||||
if deleted.Load() != 1 {
|
||||
t.Fatal("acknowledged cleanup replayed against replacement tunnel")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/store/repo"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFederationRedeployPreservesExistingBindings(t *testing.T) {
|
||||
testFederationRedeploy(t, false, false)
|
||||
}
|
||||
func TestFederationFailedRedeployPreservesExistingBindings(t *testing.T) {
|
||||
testFederationRedeploy(t, true, false)
|
||||
}
|
||||
func TestFederationCreateDoesNotHoldDatabaseDuringAcknowledgment(t *testing.T) {
|
||||
testFederationRedeploy(t, false, true)
|
||||
}
|
||||
|
||||
func testFederationRedeploy(t *testing.T, failRedeploy, gateCreate bool) {
|
||||
providerSecret := "provider-contract-jwt"
|
||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var gateOnce, releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if gateCreate && r.URL.Path == "/api/v1/federation/runtime/apply-role" {
|
||||
gateOnce.Do(func() { close(entered); <-release })
|
||||
}
|
||||
providerRouter.ServeHTTP(w, r)
|
||||
}))
|
||||
defer providerServer.Close()
|
||||
|
||||
consumerSecret := "consumer-contract-jwt"
|
||||
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
|
||||
|
||||
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate consumer admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry", "198.51.100.11", "43000-43010", "provider-entry-secret", 1)
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
|
||||
|
||||
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "entry-share",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-token",
|
||||
PortRangeStart: 43000,
|
||||
PortRangeEnd: 43010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "middle-share",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-token",
|
||||
PortRangeStart: 44000,
|
||||
PortRangeEnd: 44010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "exit-share",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-token",
|
||||
PortRangeStart: 45000,
|
||||
PortRangeEnd: 45010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-token")
|
||||
|
||||
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-token")
|
||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||
|
||||
var mu sync.Mutex
|
||||
var destructive []string
|
||||
var armed bool
|
||||
recorder := func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if kind == "DeleteService" || kind == "DeleteChains" {
|
||||
destructive = append(destructive, kind)
|
||||
}
|
||||
if armed && failRedeploy && kind == "UpdateService" {
|
||||
return true, "simulated node update failure"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
stopEntry := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-entry-secret", recorder)
|
||||
defer stopEntry()
|
||||
stopMiddle := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-middle-secret", recorder)
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-exit-secret", recorder)
|
||||
defer stopExit()
|
||||
|
||||
createTunnel := func(name string) int64 {
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", consumerAdminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id for %s", name)
|
||||
}
|
||||
return tunnelID
|
||||
}
|
||||
|
||||
defer unblock()
|
||||
var firstTunnelID int64
|
||||
if gateCreate {
|
||||
created := make(chan int64, 1)
|
||||
go func() { created <- createTunnel("dual-panel-middle-exit-1") }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("create did not reach federation gate")
|
||||
}
|
||||
listed := make(chan struct{})
|
||||
go func() {
|
||||
defer close(listed)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("Authorization", consumerAdminToken)
|
||||
consumerRouter.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}()
|
||||
select {
|
||||
case <-listed:
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
t.Error("federation create held database while waiting for acknowledgment")
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case firstTunnelID = <-created:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("create stuck after release")
|
||||
}
|
||||
select {
|
||||
case <-listed:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("list stuck after release")
|
||||
}
|
||||
return
|
||||
}
|
||||
firstTunnelID = createTunnel("dual-panel-middle-exit-1")
|
||||
|
||||
mu.Lock()
|
||||
destructive = nil
|
||||
armed = true
|
||||
mu.Unlock()
|
||||
body, _ := json.Marshal(map[string]interface{}{"ids": []int64{firstTunnelID}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", consumerAdminToken)
|
||||
res := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(res, req)
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailCount int `json:"failCount"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedSuccess, expectedFailure := 1, 0
|
||||
if failRedeploy {
|
||||
expectedSuccess, expectedFailure = 0, 1
|
||||
}
|
||||
if out.Code != 0 || out.Data.SuccessCount != expectedSuccess || out.Data.FailCount != expectedFailure {
|
||||
t.Fatalf("redeploy failed: %s", res.Body.String())
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(destructive) > 0 {
|
||||
t.Fatalf("redeploy destroyed existing federation runtime: %v", destructive)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-backend/internal/auth"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTunnelInsertMiddleHopRecordedRuntimeContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
||||
|
||||
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
||||
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
||||
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
||||
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryA).Error; err != nil {
|
||||
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
// This mock records control-plane commands only; it does not forward traffic.
|
||||
type command struct {
|
||||
node, kind string
|
||||
data json.RawMessage
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var commands []command
|
||||
for _, n := range []struct {
|
||||
name, secret string
|
||||
id int64
|
||||
}{
|
||||
{"A", "issue281-transition-entry-a-secret", entryA},
|
||||
{"B", "issue281-transition-entry-b-secret", entryB},
|
||||
{"C", "issue281-transition-entry-c-secret", entryC},
|
||||
{"D", "issue281-transition-exit-secret", exitNodeID},
|
||||
} {
|
||||
name := n.name
|
||||
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
||||
return false, ""
|
||||
})
|
||||
defer stop()
|
||||
waitNodeStatus(t, r, n.id, 1)
|
||||
}
|
||||
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
||||
time.Sleep(time.Second)
|
||||
node := func(id int64) map[string]interface{} {
|
||||
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
|
||||
}
|
||||
update := func(hops [][]map[string]interface{}) {
|
||||
mu.Lock()
|
||||
commands = nil
|
||||
mu.Unlock()
|
||||
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
||||
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
||||
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
check := func(edges map[string]int64) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
||||
got := map[string]bool{}
|
||||
services := map[string]bool{}
|
||||
lastChains := map[string]int{}
|
||||
lastServices := map[string]int{}
|
||||
for i, c := range commands {
|
||||
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||
lastChains[c.node] = i
|
||||
}
|
||||
if c.kind == "UpdateService" || c.kind == "AddService" {
|
||||
lastServices[c.node] = i
|
||||
}
|
||||
}
|
||||
for i, c := range commands {
|
||||
if c.kind == "DeleteChains" {
|
||||
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
||||
}
|
||||
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||
if lastChains[c.node] != i {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||
raw = nested
|
||||
}
|
||||
target, ok := edges[c.node]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected chain owner %s", c.node)
|
||||
}
|
||||
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
|
||||
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
||||
hops := raw["hops"].([]interface{})
|
||||
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||
addr := nodes[0].(map[string]interface{})["addr"]
|
||||
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
||||
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
||||
}
|
||||
got[c.node] = true
|
||||
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
||||
}
|
||||
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
||||
if lastServices[c.node] != i {
|
||||
continue
|
||||
}
|
||||
var ss []map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &ss); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range ss {
|
||||
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
||||
continue
|
||||
}
|
||||
h := s["handler"].(map[string]interface{})
|
||||
if c.node == "B" || c.node == "C" {
|
||||
if h["chain"] != expectedChain {
|
||||
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
||||
}
|
||||
}
|
||||
if c.node == "D" && h["chain"] != nil {
|
||||
t.Fatalf("exit has unexpected chain")
|
||||
}
|
||||
services[c.node] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for owner := range edges {
|
||||
if !got[owner] {
|
||||
t.Errorf("no chain update for %s", owner)
|
||||
}
|
||||
if owner != "A" && !services[owner] {
|
||||
t.Errorf("no relay service for %s", owner)
|
||||
}
|
||||
}
|
||||
if !services["D"] {
|
||||
t.Error("no exit relay service")
|
||||
}
|
||||
}
|
||||
update([][]map[string]interface{}{{node(entryB)}})
|
||||
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||
update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}})
|
||||
check(map[string]int64{"A": entryB, "B": entryC, "C": exitNodeID})
|
||||
// A remains the entry and the user's forward remains bound there.
|
||||
after, _ := mustQueryInt64Int(t, r, `SELECT node_id,port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
|
||||
if after != entryA {
|
||||
t.Fatalf("forward entry changed: %d", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelRedeployEditMissingChainWindowInvestigation(t *testing.T) {
|
||||
t.Skip("Pre-repair characterization; preserved in plans/evidence/070-tunnel-insert-middle-hop-investigation.go.txt. Replaced by TestTunnelRedeployPreservesLiveRoutes and TestTunnelEditWaitsForRedeploy.")
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
||||
|
||||
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
||||
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
||||
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
||||
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryA).Error; err != nil {
|
||||
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
// This mock records control-plane commands only; it does not forward traffic.
|
||||
type command struct {
|
||||
node, kind string
|
||||
data json.RawMessage
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var commands []command
|
||||
armed := false
|
||||
chainPresent, servicePresent := map[string]bool{}, map[string]bool{}
|
||||
atDeleteService, releaseDeleteService, editReachedA := make(chan struct{}), make(chan struct{}), make(chan struct{}, 1)
|
||||
var gateOnce, releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(releaseDeleteService) }) }
|
||||
defer release()
|
||||
|
||||
for _, n := range []struct {
|
||||
name, secret string
|
||||
id int64
|
||||
}{
|
||||
{"A", "issue281-transition-entry-a-secret", entryA},
|
||||
{"B", "issue281-transition-entry-b-secret", entryB},
|
||||
{"C", "issue281-transition-entry-c-secret", entryC},
|
||||
{"D", "issue281-transition-exit-secret", exitNodeID},
|
||||
} {
|
||||
name := n.name
|
||||
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
active := armed
|
||||
shouldGate := active && name == "B" && kind == "DeleteService"
|
||||
if active && name == "A" && kind == "UpdateChains" {
|
||||
select {
|
||||
case editReachedA <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if shouldGate {
|
||||
gateOnce.Do(func() { close(atDeleteService); <-releaseDeleteService })
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if kind == "DeleteChains" {
|
||||
chainPresent[name] = false
|
||||
}
|
||||
if kind == "AddChains" || kind == "UpdateChains" {
|
||||
chainPresent[name] = true
|
||||
}
|
||||
if kind == "DeleteService" {
|
||||
servicePresent[name] = false
|
||||
}
|
||||
if kind == "AddService" || kind == "UpdateService" {
|
||||
servicePresent[name] = true
|
||||
}
|
||||
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
||||
return false, ""
|
||||
})
|
||||
defer stop()
|
||||
waitNodeStatus(t, r, n.id, 1)
|
||||
}
|
||||
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
||||
time.Sleep(time.Second)
|
||||
node := func(id int64) map[string]interface{} {
|
||||
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
|
||||
}
|
||||
update := func(hops [][]map[string]interface{}) {
|
||||
mu.Lock()
|
||||
commands = nil
|
||||
mu.Unlock()
|
||||
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
||||
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
||||
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
check := func(edges map[string]int64) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
||||
got := map[string]bool{}
|
||||
services := map[string]bool{}
|
||||
lastChains := map[string]int{}
|
||||
lastServices := map[string]int{}
|
||||
for i, c := range commands {
|
||||
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||
lastChains[c.node] = i
|
||||
}
|
||||
if c.kind == "UpdateService" || c.kind == "AddService" {
|
||||
lastServices[c.node] = i
|
||||
}
|
||||
}
|
||||
for i, c := range commands {
|
||||
if c.kind == "DeleteChains" {
|
||||
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
||||
}
|
||||
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||
if lastChains[c.node] != i {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||
raw = nested
|
||||
}
|
||||
target, ok := edges[c.node]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected chain owner %s", c.node)
|
||||
}
|
||||
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
|
||||
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
||||
hops := raw["hops"].([]interface{})
|
||||
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||
addr := nodes[0].(map[string]interface{})["addr"]
|
||||
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
||||
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
||||
}
|
||||
got[c.node] = true
|
||||
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
||||
}
|
||||
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
||||
if lastServices[c.node] != i {
|
||||
continue
|
||||
}
|
||||
var ss []map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &ss); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range ss {
|
||||
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
||||
continue
|
||||
}
|
||||
h := s["handler"].(map[string]interface{})
|
||||
if c.node == "B" || c.node == "C" {
|
||||
if h["chain"] != expectedChain {
|
||||
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
||||
}
|
||||
}
|
||||
if c.node == "D" && h["chain"] != nil {
|
||||
t.Fatalf("exit has unexpected chain")
|
||||
}
|
||||
services[c.node] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for owner := range edges {
|
||||
if !got[owner] {
|
||||
t.Errorf("no chain update for %s", owner)
|
||||
}
|
||||
if owner != "A" && !services[owner] {
|
||||
t.Errorf("no relay service for %s", owner)
|
||||
}
|
||||
}
|
||||
if !services["D"] {
|
||||
t.Error("no exit relay service")
|
||||
}
|
||||
}
|
||||
update([][]map[string]interface{}{{node(entryB)}})
|
||||
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||
|
||||
mu.Lock()
|
||||
armed = true
|
||||
mu.Unlock()
|
||||
redeployDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(redeployDone)
|
||||
raw, _ := json.Marshal(map[string]interface{}{"ids": []int64{tunnelID}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
t.Logf("redeploy response: %s", res.Body.String())
|
||||
}()
|
||||
await := func(ch <-chan struct{}, label string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(10 * time.Second):
|
||||
release()
|
||||
t.Fatalf("timeout waiting for %s", label)
|
||||
}
|
||||
}
|
||||
await(atDeleteService, "B DeleteService gate")
|
||||
editDone := make(chan struct{})
|
||||
go func() { defer close(editDone); update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}}) }()
|
||||
await(editReachedA, "edit UpdateChains on A")
|
||||
mu.Lock()
|
||||
missing, live := !chainPresent["B"], servicePresent["B"]
|
||||
mu.Unlock()
|
||||
if !missing || !live {
|
||||
release()
|
||||
t.Fatalf("expected B live service with missing chain during overlap, missing=%v live=%v", missing, live)
|
||||
}
|
||||
t.Log("Deterministic overlap confirmed: edit reached A while redeploy had deleted B chain but had not deleted B relay service")
|
||||
release()
|
||||
await(editDone, "edit completion")
|
||||
await(redeployDone, "redeploy completion")
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
t.Logf("final mock runtime chain presence: A=%v B=%v C=%v; services B=%v C=%v D=%v", chainPresent["A"], chainPresent["B"], chainPresent["C"], servicePresent["B"], servicePresent["C"], servicePresent["D"])
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-backend/internal/auth"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTunnelRedeployPreservesLiveRoutes(t *testing.T) {
|
||||
testTunnelLifecycle(t, false, false, false)
|
||||
}
|
||||
|
||||
func TestTunnelEditWaitsForRedeploy(t *testing.T) {
|
||||
testTunnelLifecycle(t, true, false, false)
|
||||
}
|
||||
|
||||
func TestTunnelFailedRedeployPreservesLiveRoutes(t *testing.T) {
|
||||
testTunnelLifecycle(t, false, true, false)
|
||||
}
|
||||
|
||||
func TestTunnelDeleteWaitsForRedeploy(t *testing.T) { testTunnelLifecycle(t, true, false, true) }
|
||||
|
||||
func testTunnelLifecycle(t *testing.T, concurrentEdit, failRedeploy, concurrentDelete bool) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
||||
|
||||
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
||||
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
||||
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
||||
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryA).Error; err != nil {
|
||||
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
// This mock records control-plane commands only; it does not forward traffic.
|
||||
type command struct {
|
||||
node, kind string
|
||||
data json.RawMessage
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var commands []command
|
||||
var armed, gateClaimed bool
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var gateOnce, releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
editApplied := make(chan struct{}, 1)
|
||||
|
||||
for _, n := range []struct {
|
||||
name, secret string
|
||||
id int64
|
||||
}{
|
||||
{"A", "issue281-transition-entry-a-secret", entryA},
|
||||
{"B", "issue281-transition-entry-b-secret", entryB},
|
||||
{"C", "issue281-transition-entry-c-secret", entryC},
|
||||
{"D", "issue281-transition-exit-secret", exitNodeID},
|
||||
} {
|
||||
name := n.name
|
||||
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
gate := armed && !gateClaimed && name == "D" && kind == "UpdateService"
|
||||
if gate {
|
||||
gateClaimed = true
|
||||
}
|
||||
if armed && ((name == "C" && (kind == "UpdateChains" || kind == "AddChains")) || (concurrentDelete && kind == "DeleteChains")) {
|
||||
select {
|
||||
case editApplied <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if gate {
|
||||
gateOnce.Do(func() { close(entered); <-release })
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
||||
if armed && failRedeploy && name == "B" && kind == "UpdateService" {
|
||||
return true, "bind: address already in use"
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stop()
|
||||
waitNodeStatus(t, r, n.id, 1)
|
||||
}
|
||||
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
||||
time.Sleep(time.Second)
|
||||
node := func(id int64) map[string]interface{} {
|
||||
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round", "port": 54002}
|
||||
}
|
||||
update := func(hops [][]map[string]interface{}) {
|
||||
mu.Lock()
|
||||
commands = nil
|
||||
mu.Unlock()
|
||||
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
||||
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
||||
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
check := func(edges map[string]int64) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
||||
got := map[string]bool{}
|
||||
services := map[string]bool{}
|
||||
lastChains := map[string]int{}
|
||||
lastServices := map[string]int{}
|
||||
for i, c := range commands {
|
||||
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||
lastChains[c.node] = i
|
||||
}
|
||||
if c.kind == "UpdateService" || c.kind == "AddService" {
|
||||
lastServices[c.node] = i
|
||||
}
|
||||
}
|
||||
for i, c := range commands {
|
||||
if c.kind == "DeleteChains" {
|
||||
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
||||
}
|
||||
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||
if lastChains[c.node] != i {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||
raw = nested
|
||||
}
|
||||
target, ok := edges[c.node]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected chain owner %s", c.node)
|
||||
}
|
||||
port := 54002
|
||||
if target == exitNodeID {
|
||||
port = 57001
|
||||
}
|
||||
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
||||
hops := raw["hops"].([]interface{})
|
||||
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||
addr := nodes[0].(map[string]interface{})["addr"]
|
||||
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
||||
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
||||
}
|
||||
got[c.node] = true
|
||||
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
||||
}
|
||||
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
||||
if lastServices[c.node] != i {
|
||||
continue
|
||||
}
|
||||
var ss []map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &ss); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range ss {
|
||||
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
||||
continue
|
||||
}
|
||||
h := s["handler"].(map[string]interface{})
|
||||
if c.node == "B" || c.node == "C" {
|
||||
if h["chain"] != expectedChain {
|
||||
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
||||
}
|
||||
}
|
||||
if c.node == "D" && h["chain"] != nil {
|
||||
t.Fatalf("exit has unexpected chain")
|
||||
}
|
||||
services[c.node] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for owner := range edges {
|
||||
if !got[owner] {
|
||||
t.Errorf("no chain update for %s", owner)
|
||||
}
|
||||
if owner != "A" && !services[owner] {
|
||||
t.Errorf("no relay service for %s", owner)
|
||||
}
|
||||
}
|
||||
if !services["D"] {
|
||||
t.Error("no exit relay service")
|
||||
}
|
||||
}
|
||||
update([][]map[string]interface{}{{node(entryB)}})
|
||||
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||
mu.Lock()
|
||||
commands = nil
|
||||
mu.Unlock()
|
||||
raw, _ := json.Marshal(map[string]interface{}{"ids": []int64{tunnelID}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res := httptest.NewRecorder()
|
||||
if !concurrentEdit {
|
||||
mu.Lock()
|
||||
armed = failRedeploy
|
||||
mu.Unlock()
|
||||
router.ServeHTTP(res, req)
|
||||
if failRedeploy {
|
||||
var out struct {
|
||||
Data struct {
|
||||
FailCount int `json:"failCount"`
|
||||
SuccessCount int `json:"successCount"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Data.FailCount != 1 || out.Data.SuccessCount != 0 {
|
||||
t.Fatalf("expected redeploy failure: %s", res.Body.String())
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, c := range commands {
|
||||
if c.kind == "DeleteService" || c.kind == "DeleteChains" {
|
||||
t.Errorf("failed redeploy removed existing runtime: %s on %s", c.kind, c.node)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assertCode(t, res, 0)
|
||||
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||
}
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
armed = true
|
||||
mu.Unlock()
|
||||
redeployDone := make(chan struct{})
|
||||
go func() { defer close(redeployDone); router.ServeHTTP(res, req) }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("redeploy did not reach command gate")
|
||||
}
|
||||
editDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(editDone)
|
||||
if concurrentDelete {
|
||||
body, _ := json.Marshal(map[string]interface{}{"id": tunnelID})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/delete", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
} else {
|
||||
update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}})
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-editApplied:
|
||||
t.Error("concurrent mutation changed runtime before redeploy finished")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
unblock()
|
||||
for _, done := range []chan struct{}{redeployDone, editDone} {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("lifecycle operation did not complete")
|
||||
}
|
||||
}
|
||||
assertCode(t, res, 0)
|
||||
if !concurrentDelete {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
latest := map[string]string{}
|
||||
for _, c := range commands {
|
||||
if c.kind != "UpdateChains" && c.kind != "AddChains" {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||
raw = nested
|
||||
}
|
||||
hops := raw["hops"].([]interface{})
|
||||
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||
latest[c.node] = nodes[0].(map[string]interface{})["addr"].(string)
|
||||
}
|
||||
if latest["B"] != "10.52.0.3:54002" {
|
||||
t.Fatalf("stale redeploy overwrote edit: B targets %s", latest["B"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestTunnelRedeployStreamContract(t *testing.T) {
|
||||
const secret = "contract-jwt-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(`{"ids":[999998,999999]}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
if !strings.HasPrefix(res.Header().Get("Content-Type"), "application/x-ndjson") {
|
||||
t.Fatalf("expected streamed progress, got %s: %s", res.Header().Get("Content-Type"), res.Body.String())
|
||||
}
|
||||
decoder := json.NewDecoder(res.Body)
|
||||
for _, want := range []string{"start", "processing", "item", "processing", "item", "done"} {
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Type != want {
|
||||
t.Fatalf("got %s, want %s", event.Type, want)
|
||||
}
|
||||
if want == "item" {
|
||||
var data struct{ Result map[string]interface{} }
|
||||
if err := json.Unmarshal(event.Data, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := data.Result["name"].(string); !ok {
|
||||
t.Fatalf("item name must be a string, including missing tunnels: %s", event.Data)
|
||||
}
|
||||
}
|
||||
if want == "done" {
|
||||
var data struct {
|
||||
Progress struct{ Total, Completed, Success, Failed int }
|
||||
Result struct {
|
||||
SuccessCount, FailCount int
|
||||
Failures []struct {
|
||||
ID int64
|
||||
Reason string
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(event.Data, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data.Progress.Total != 2 || data.Progress.Completed != 2 || data.Progress.Failed != 2 || data.Progress.Success != 0 || data.Result.FailCount != 2 || len(data.Result.Failures) != 2 {
|
||||
t.Fatalf("incorrect completion: %+v", data)
|
||||
}
|
||||
if data.Result.Failures[0].Reason == "" {
|
||||
t.Fatal("missing failure detail")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !res.Flushed {
|
||||
t.Fatal("stream did not flush")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelRedeployStreamAuthorizationContract(t *testing.T) {
|
||||
const secret = "contract-jwt-secret"
|
||||
router, repository := setupContractRouter(t, secret)
|
||||
seedLegacyUser(t, repository, 9111, "stream-normal-user", "password")
|
||||
token, err := auth.GenerateToken(9111, "stream-normal-user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, token string
|
||||
code int
|
||||
}{{"anonymous", "", 401}, {"non-admin", token, 403}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(`{"ids":[999999]}`))
|
||||
req.Header.Set("Authorization", tc.token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, tc.code)
|
||||
if strings.Contains(res.Body.String(), `"type":"start"`) {
|
||||
t.Fatal("unauthorized request started stream")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-backend/internal/auth"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMiddleToExitKeepsChainUntilReceiverReplacementAcknowledged(t *testing.T) {
|
||||
for _, port := range []int{42000, 42001} {
|
||||
for _, failure := range []string{"等待节点响应超时", "replacement rejected"} {
|
||||
t.Run(fmt.Sprintf("port=%d/failure=%s", port, failure), func(t *testing.T) {
|
||||
router, r := setupContractRouter(t, "role-transition")
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
token, err := auth.GenerateToken(1, "admin", 0, "role-transition")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := insertContractNode(t, r, "role-entry", "192.0.2.10", "41000-41010", "role-entry-secret", 1)
|
||||
middle := insertContractNode(t, r, "role-middle", "192.0.2.20", "42000-42010", "role-middle-secret", 1)
|
||||
exit := insertContractNode(t, r, "role-exit", "192.0.2.30", "43000-43010", "role-exit-secret", 1)
|
||||
stopEntry := startMockNodeSession(t, server.URL, "role-entry-secret")
|
||||
defer stopEntry()
|
||||
var mu sync.Mutex
|
||||
failReplacement := false
|
||||
replacementAccepted := false
|
||||
unsafeDeletion := false
|
||||
chainDeletes := 0
|
||||
stopMiddle := startMockNodeSessionWithCommandRecorder(t, server.URL, "role-middle-secret", func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if kind == "UpdateService" {
|
||||
if failReplacement {
|
||||
return true, failure
|
||||
}
|
||||
var services []struct {
|
||||
Handler struct {
|
||||
Chain string `json:"chain"`
|
||||
} `json:"handler"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &services); err == nil && len(services) > 0 && services[0].Handler.Chain == "" {
|
||||
replacementAccepted = true
|
||||
}
|
||||
}
|
||||
if kind == "DeleteChains" {
|
||||
chainDeletes++
|
||||
if !replacementAccepted {
|
||||
unsafeDeletion = true
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSession(t, server.URL, "role-exit-secret")
|
||||
defer stopExit()
|
||||
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
payload := map[string]interface{}{"name": "role-transition", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42000}}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls", "port": 43000}}}
|
||||
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
|
||||
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||
var list struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
|
||||
t.Fatal("missing tunnel")
|
||||
}
|
||||
id := list.Data[0].ID
|
||||
mu.Lock()
|
||||
failReplacement = true
|
||||
mu.Unlock()
|
||||
payload["id"] = id
|
||||
payload["chainNodes"] = [][]map[string]interface{}{}
|
||||
payload["outNodeId"] = []map[string]interface{}{{"nodeId": middle, "protocol": "tls", "port": port}}
|
||||
post("/api/v1/tunnel/update", payload)
|
||||
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{id}})
|
||||
mu.Lock()
|
||||
if chainDeletes != 0 || unsafeDeletion {
|
||||
t.Errorf("old middle chain removed before receiver replacement ACK: deletes=%d unsafe=%v", chainDeletes, unsafeDeletion)
|
||||
}
|
||||
failReplacement = false
|
||||
mu.Unlock()
|
||||
recovered := post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{id}})
|
||||
var result struct {
|
||||
Data struct {
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailCount int `json:"failCount"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recovered.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Data.SuccessCount != 1 || result.Data.FailCount != 0 {
|
||||
t.Fatalf("replacement retry failed: %s", recovered.Body.String())
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !replacementAccepted || chainDeletes != 1 || unsafeDeletion {
|
||||
t.Fatalf("obsolete chain must be removed exactly after successful exit replacement: accepted=%v deletes=%d unsafe=%v", replacementAccepted, chainDeletes, unsafeDeletion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package chain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
corechain "github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
"github.com/go-gost/core/dialer"
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/listener"
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
xauth "github.com/go-gost/x/auth"
|
||||
xchain "github.com/go-gost/x/chain"
|
||||
relayconnector "github.com/go-gost/x/connector/relay"
|
||||
kcpdialer "github.com/go-gost/x/dialer/kcp"
|
||||
relayhandler "github.com/go-gost/x/handler/relay"
|
||||
netdialer "github.com/go-gost/x/internal/net/dialer"
|
||||
kcplistener "github.com/go-gost/x/listener/kcp"
|
||||
tcplistener "github.com/go-gost/x/listener/tcp"
|
||||
tlslistener "github.com/go-gost/x/listener/tls"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/metadata"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Local-only matrix: native GOST listeners, relay handler and connectors. The
|
||||
// credential below is synthetic test data, never used outside loopback sockets.
|
||||
func TestRelayAuthTransportMatrixInvestigation(t *testing.T) {
|
||||
log := xlogger.Nop()
|
||||
corelogger.SetDefault(log)
|
||||
certServer := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
cert := certServer.TLS.Certificates[0]
|
||||
leaf := certServer.Certificate()
|
||||
certServer.Close()
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(leaf)
|
||||
tcpTarget, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer tcpTarget.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, e := tcpTarget.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
go func() { defer c.Close(); io.Copy(c, c) }()
|
||||
}
|
||||
}()
|
||||
udpTarget, e := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer udpTarget.Close()
|
||||
go func() {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, a, e := udpTarget.ReadFrom(buf)
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
udpTarget.WriteTo(buf[:n], a)
|
||||
}
|
||||
}()
|
||||
for _, transport := range []string{"tcp", "tls", "kcp"} {
|
||||
for _, network := range []string{"tcp", "udp"} {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
auth bool
|
||||
credentials *url.Userinfo
|
||||
success bool
|
||||
}{
|
||||
{"legacy-no-credentials", false, nil, true},
|
||||
{"legacy-valid-credentials", false, url.UserPassword("test-peer", "synthetic-test-secret"), true},
|
||||
{"secured-no-credentials", true, nil, false},
|
||||
{"secured-wrong-credentials", true, url.UserPassword("test-peer", "incorrect"), false},
|
||||
{"secured-valid-credentials", true, url.UserPassword("test-peer", "synthetic-test-secret"), true},
|
||||
} {
|
||||
t.Run(transport+"/"+network+"/"+test.name, func(t *testing.T) {
|
||||
opts := []listener.Option{listener.AddrOption("127.0.0.1:0"), listener.LoggerOption(log), listener.TLSConfigOption(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})}
|
||||
var ln listener.Listener
|
||||
switch transport {
|
||||
case "tls":
|
||||
ln = tlslistener.NewListener(opts...)
|
||||
case "kcp":
|
||||
ln = kcplistener.NewListener(opts...)
|
||||
default:
|
||||
ln = tcplistener.NewListener(opts...)
|
||||
}
|
||||
md := metadata.NewMetadata(map[string]any{"kcp.tcp": false, "kcp.nocomp": true})
|
||||
if e := ln.Init(md); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer ln.Close()
|
||||
hopts := []handler.Option{handler.RouterOption(xchain.NewRouter(corechain.LoggerRouterOption(log))), handler.LoggerOption(log), handler.ServiceOption("auth-investigation")}
|
||||
if test.auth {
|
||||
au := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{"test-peer": "synthetic-test-secret"}))
|
||||
if closer, ok := au.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
hopts = append(hopts, handler.AutherOption(au))
|
||||
}
|
||||
h := relayhandler.NewHandler(hopts...)
|
||||
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if closer, ok := h.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
handled := make(chan struct{})
|
||||
go func() {
|
||||
defer close(handled)
|
||||
c, e := ln.Accept()
|
||||
if e == nil {
|
||||
h.Handle(context.Background(), c)
|
||||
}
|
||||
}()
|
||||
var raw net.Conn
|
||||
var err error
|
||||
switch transport {
|
||||
case "tls":
|
||||
raw, err = tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", ln.Addr().String(), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12})
|
||||
case "kcp":
|
||||
d := kcpdialer.NewDialer(dialer.LoggerOption(log))
|
||||
if e := d.Init(md); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if closer, ok := d.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
raw, err = d.Dial(context.Background(), ln.Addr().String(), dialer.NetDialerDialOption(&netdialer.Dialer{Logger: log}))
|
||||
default:
|
||||
raw, err = net.DialTimeout("tcp", ln.Addr().String(), time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer raw.Close()
|
||||
raw.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
copts := []connector.Option{connector.LoggerOption(log)}
|
||||
if test.credentials != nil {
|
||||
copts = append(copts, connector.AuthOption(test.credentials))
|
||||
}
|
||||
client := relayconnector.NewConnector(copts...)
|
||||
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
target := tcpTarget.Addr().String()
|
||||
if network == "udp" {
|
||||
target = udpTarget.LocalAddr().String()
|
||||
}
|
||||
conn, err := client.Connect(context.Background(), raw, network, target)
|
||||
if !test.success {
|
||||
if err == nil {
|
||||
t.Fatal("unauthorized client was accepted")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
|
||||
t.Fatalf("expected authentication refusal, got %v", err)
|
||||
}
|
||||
raw.Close()
|
||||
<-handled
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = conn.Write([]byte("probe")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := make([]byte, 64)
|
||||
n, err := conn.Read(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b[:n]) != "probe" {
|
||||
t.Fatalf("unexpected response %q", b[:n])
|
||||
}
|
||||
raw.Close()
|
||||
select {
|
||||
case <-handled:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("handler did not close")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package chain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
corechain "github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
"github.com/go-gost/core/handler"
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
xchain "github.com/go-gost/x/chain"
|
||||
"github.com/go-gost/x/config"
|
||||
parser "github.com/go-gost/x/config/parsing/chain"
|
||||
relayconnector "github.com/go-gost/x/connector/relay"
|
||||
_ "github.com/go-gost/x/dialer/tcp"
|
||||
relayhandler "github.com/go-gost/x/handler/relay"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/metadata"
|
||||
"github.com/go-gost/x/registry"
|
||||
"io"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This test documents existing fail-open behavior, not desired secure behavior.
|
||||
// Every socket is bound to 127.0.0.1, no production config or external target.
|
||||
func TestRelayMissingChainDataPathInvestigation(t *testing.T) {
|
||||
log := xlogger.Nop()
|
||||
originalLogger := corelogger.Default()
|
||||
corelogger.SetDefault(log)
|
||||
if originalLogger != nil {
|
||||
defer corelogger.SetDefault(originalLogger)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
target, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer target.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, e := target.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
go func() { defer c.Close(); io.Copy(c, c) }()
|
||||
}
|
||||
}()
|
||||
type relay struct {
|
||||
addr string
|
||||
count *atomic.Int32
|
||||
}
|
||||
start := func(name, chainName string) relay {
|
||||
ln, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
router := xchain.NewRouter(corechain.ChainRouterOption(registry.ChainRegistry().Get(chainName)), corechain.LoggerRouterOption(log))
|
||||
h := relayhandler.NewHandler(handler.RouterOption(router), handler.LoggerOption(log), handler.ServiceOption(name))
|
||||
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if closer, ok := h.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
})
|
||||
count := &atomic.Int32{}
|
||||
go func() {
|
||||
for {
|
||||
c, e := ln.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
count.Add(1)
|
||||
go h.Handle(ctx, c)
|
||||
}
|
||||
}()
|
||||
return relay{ln.Addr().String(), count}
|
||||
}
|
||||
replace := func(name, addr string) {
|
||||
c, e := parser.ParseChain(&config.ChainConfig{Name: name, Hops: []*config.HopConfig{{Name: name + "-hop", Nodes: []*config.NodeConfig{{Name: name + "-next", Addr: addr, Connector: &config.ConnectorConfig{Type: "relay", Metadata: map[string]any{"nodelay": true}}, Dialer: &config.DialerConfig{Type: "tcp"}}}}}}, log)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = registry.ReplaceChain(name, c); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"investigation-A", "investigation-B", "investigation-C"} {
|
||||
name := name
|
||||
t.Cleanup(func() { registry.ChainRegistry().Unregister(name) })
|
||||
}
|
||||
d := start("D", "")
|
||||
c := start("C", "investigation-C")
|
||||
replace("investigation-C", d.addr)
|
||||
b := start("B", "investigation-B")
|
||||
replace("investigation-B", d.addr)
|
||||
a := start("A", "investigation-A")
|
||||
replace("investigation-A", b.addr)
|
||||
request := func() {
|
||||
raw, e := net.DialTimeout("tcp", a.addr, time.Second)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer raw.Close()
|
||||
raw.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
client := relayconnector.NewConnector(connector.LoggerOption(log))
|
||||
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
conn, e := client.Connect(ctx, raw, "tcp", target.Addr().String())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = conn.Write([]byte("probe")); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
buf := make([]byte, 5)
|
||||
if _, e = io.ReadFull(conn, buf); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if string(buf) != "probe" {
|
||||
t.Fatalf("bad response %q", buf)
|
||||
}
|
||||
}
|
||||
request()
|
||||
if b.count.Load() != 1 || c.count.Load() != 0 || d.count.Load() != 1 {
|
||||
t.Fatalf("initial B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||
}
|
||||
replace("investigation-B", c.addr)
|
||||
request()
|
||||
if b.count.Load() != 2 || c.count.Load() != 1 || d.count.Load() != 2 {
|
||||
t.Fatalf("insert C B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||
}
|
||||
// Deterministic cleanup boundary: DeleteChains has completed, while the B
|
||||
// relay service is still alive (before a subsequent DeleteService command).
|
||||
registry.ChainRegistry().Unregister("investigation-B")
|
||||
request()
|
||||
if b.count.Load() != 3 || c.count.Load() != 1 || d.count.Load() != 2 {
|
||||
t.Fatalf("missing chain B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||
}
|
||||
t.Log("Confirmed real relay data flow: A-B-D, then A-B-C-D, then A-B-target with B chain missing; no credentials supplied")
|
||||
replace("investigation-B", c.addr)
|
||||
request()
|
||||
if b.count.Load() != 4 || c.count.Load() != 2 || d.count.Load() != 3 {
|
||||
t.Fatalf("restored B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package chain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corechain "github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
"github.com/go-gost/core/handler"
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
xauth "github.com/go-gost/x/auth"
|
||||
xchain "github.com/go-gost/x/chain"
|
||||
"github.com/go-gost/x/config"
|
||||
parser "github.com/go-gost/x/config/parsing/chain"
|
||||
relayconnector "github.com/go-gost/x/connector/relay"
|
||||
_ "github.com/go-gost/x/dialer/tcp"
|
||||
relayhandler "github.com/go-gost/x/handler/relay"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/metadata"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
// This regression uses real loopback GOST relay endpoints and verifies the required-route policy.
|
||||
// Every socket is bound to 127.0.0.1, no production config or external target.
|
||||
func TestRequiredRelayRouteRejectsMissingHopAndRecovers(t *testing.T) {
|
||||
testRequiredRelayRecovery(t, false)
|
||||
}
|
||||
|
||||
func TestRequiredAuthenticatedRelayRouteRejectsMissingHopAndRecovers(t *testing.T) {
|
||||
testRequiredRelayRecovery(t, true)
|
||||
}
|
||||
|
||||
func testRequiredRelayRecovery(t *testing.T, authenticated bool) {
|
||||
t.Helper()
|
||||
log := xlogger.Nop()
|
||||
originalLogger := corelogger.Default()
|
||||
corelogger.SetDefault(log)
|
||||
if originalLogger != nil {
|
||||
defer corelogger.SetDefault(originalLogger)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
target, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer target.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, e := target.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
go func() { defer c.Close(); io.Copy(c, c) }()
|
||||
}
|
||||
}()
|
||||
type relay struct {
|
||||
name string
|
||||
addr string
|
||||
count *atomic.Int32
|
||||
}
|
||||
start := func(name, chainName string) relay {
|
||||
ln, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
router := xchain.NewRouter(corechain.ChainRouterOption(registry.ChainRegistry().Get(chainName)), corechain.LoggerRouterOption(log))
|
||||
if chainName != "" {
|
||||
router.WithRequiredChain()
|
||||
}
|
||||
opts := []handler.Option{handler.RouterOption(router), handler.LoggerOption(log), handler.ServiceOption(name)}
|
||||
if authenticated {
|
||||
au := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{name: "synthetic-local-password-for-" + name}))
|
||||
if closer, ok := au.(io.Closer); ok {
|
||||
t.Cleanup(func() { closer.Close() })
|
||||
}
|
||||
opts = append(opts, handler.AutherOption(au))
|
||||
}
|
||||
h := relayhandler.NewHandler(opts...)
|
||||
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if closer, ok := h.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
})
|
||||
count := &atomic.Int32{}
|
||||
go func() {
|
||||
for {
|
||||
c, e := ln.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
count.Add(1)
|
||||
go h.Handle(ctx, c)
|
||||
}
|
||||
}()
|
||||
return relay{name, ln.Addr().String(), count}
|
||||
}
|
||||
replace := func(name string, next relay) {
|
||||
connectorConfig := &config.ConnectorConfig{Type: "relay", Metadata: map[string]any{"nodelay": true}}
|
||||
if authenticated {
|
||||
connectorConfig.Auth = &config.AuthConfig{Username: next.name, Password: "synthetic-local-password-for-" + next.name}
|
||||
}
|
||||
c, e := parser.ParseChain(&config.ChainConfig{Name: name, Hops: []*config.HopConfig{{Name: name + "-hop", Nodes: []*config.NodeConfig{{Name: name + "-next", Addr: next.addr, Connector: connectorConfig, Dialer: &config.DialerConfig{Type: "tcp"}}}}}}, log)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = registry.ReplaceChain(name, c); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"strict-regression-A", "strict-regression-B", "strict-regression-C"} {
|
||||
name := name
|
||||
t.Cleanup(func() { registry.ChainRegistry().Unregister(name) })
|
||||
}
|
||||
d := start("D", "")
|
||||
c := start("C", "strict-regression-C")
|
||||
replace("strict-regression-C", d)
|
||||
b := start("B", "strict-regression-B")
|
||||
replace("strict-regression-B", d)
|
||||
a := start("A", "strict-regression-A")
|
||||
replace("strict-regression-A", b)
|
||||
request := func(wantSuccess bool) {
|
||||
raw, e := net.DialTimeout("tcp", a.addr, time.Second)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer raw.Close()
|
||||
raw.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
opts := []connector.Option{connector.LoggerOption(log)}
|
||||
if authenticated {
|
||||
opts = append(opts, connector.AuthOption(url.UserPassword("A", "synthetic-local-password-for-A")))
|
||||
}
|
||||
client := relayconnector.NewConnector(opts...)
|
||||
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
conn, e := client.Connect(ctx, raw, "tcp", target.Addr().String())
|
||||
if !wantSuccess {
|
||||
if e == nil {
|
||||
conn.Close()
|
||||
t.Fatal("relay connected through a missing required hop")
|
||||
}
|
||||
return
|
||||
}
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = conn.Write([]byte("probe")); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
buf := make([]byte, 5)
|
||||
if _, e = io.ReadFull(conn, buf); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if string(buf) != "probe" {
|
||||
t.Fatalf("bad response %q", buf)
|
||||
}
|
||||
}
|
||||
request(true)
|
||||
if b.count.Load() != 1 || c.count.Load() != 0 || d.count.Load() != 1 {
|
||||
t.Fatalf("initial B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||
}
|
||||
replace("strict-regression-B", c)
|
||||
request(true)
|
||||
if b.count.Load() != 2 || c.count.Load() != 1 || d.count.Load() != 2 {
|
||||
t.Fatalf("insert C B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||
}
|
||||
// Deterministic cleanup boundary: DeleteChains has completed, while the B
|
||||
// relay service is still alive (before a subsequent DeleteService command).
|
||||
registry.ChainRegistry().Unregister("strict-regression-B")
|
||||
request(false)
|
||||
if b.count.Load() != 3 || c.count.Load() != 1 || d.count.Load() != 2 {
|
||||
t.Fatalf("missing chain B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||
}
|
||||
t.Log("Required route refuses the missing B chain and recovers after replacement")
|
||||
replace("strict-regression-B", c)
|
||||
request(true)
|
||||
if b.count.Load() != 4 || c.count.Load() != 2 || d.count.Load() != 3 {
|
||||
t.Fatalf("restored B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package chain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/connector"
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/config/parsing"
|
||||
chainparser "github.com/go-gost/x/config/parsing/chain"
|
||||
serviceparser "github.com/go-gost/x/config/parsing/service"
|
||||
relayconnector "github.com/go-gost/x/connector/relay"
|
||||
_ "github.com/go-gost/x/handler/relay"
|
||||
_ "github.com/go-gost/x/listener/tcp"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/metadata"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
func TestRequiredRelayChainCannotDialTargetWhenMissing(t *testing.T) {
|
||||
assertRequiredRelayCannotDial(t, "missing-required-chain", "tcp")
|
||||
}
|
||||
|
||||
func TestRequiredRelayChainRejectsEmptySelectionAndAbsentConfiguration(t *testing.T) {
|
||||
corelogger.SetDefault(xlogger.Nop())
|
||||
const name = "empty-required-chain"
|
||||
c, err := chainparser.ParseChain(&config.ChainConfig{Name: name, Hops: []*config.HopConfig{{Name: "empty-hop", Nodes: []*config.NodeConfig{}}}}, xlogger.Nop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.ReplaceChain(name, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer registry.ChainRegistry().Unregister(name)
|
||||
for _, network := range []string{"tcp", "udp"} {
|
||||
for _, chainName := range []string{name, ""} {
|
||||
t.Run(network+"/"+chainName, func(t *testing.T) { assertRequiredRelayCannotDial(t, chainName, network) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertRequiredRelayCannotDial(t *testing.T, chainName, network string) {
|
||||
t.Helper()
|
||||
corelogger.SetDefault(xlogger.Nop())
|
||||
previousTLS := parsing.DefaultTLSConfig()
|
||||
parsing.SetDefaultTLSConfig(&tls.Config{})
|
||||
defer parsing.SetDefaultTLSConfig(previousTLS)
|
||||
target, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer target.Close()
|
||||
var accepted atomic.Int32
|
||||
go func() {
|
||||
for {
|
||||
c, e := target.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
accepted.Add(1)
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
svc, err := serviceparser.ParseService(&config.ServiceConfig{Name: "required-route-regression", Addr: "127.0.0.1:0", Listener: &config.ListenerConfig{Type: "tcp"}, Handler: &config.HandlerConfig{Type: "relay", Chain: chainName, Metadata: map[string]any{"chain.strict": true, "nodelay": true}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); svc.Serve() }()
|
||||
defer func() { svc.Close(); <-done }()
|
||||
raw, err := net.DialTimeout("tcp", svc.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer raw.Close()
|
||||
raw.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
client := relayconnector.NewConnector(connector.LoggerOption(xlogger.Nop()))
|
||||
if err := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := client.Connect(context.Background(), raw, network, target.Addr().String())
|
||||
if err == nil {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
t.Fatal("required chain is missing, but relay accepted a direct connection")
|
||||
}
|
||||
if accepted.Load() != 0 {
|
||||
t.Fatalf("ultimate target received %d connections without the required chain", accepted.Load())
|
||||
}
|
||||
if closer, ok := client.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package chain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corechain "github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/connector"
|
||||
"github.com/go-gost/core/dialer"
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/listener"
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
xauth "github.com/go-gost/x/auth"
|
||||
xchain "github.com/go-gost/x/chain"
|
||||
relayconnector "github.com/go-gost/x/connector/relay"
|
||||
kcpdialer "github.com/go-gost/x/dialer/kcp"
|
||||
relayhandler "github.com/go-gost/x/handler/relay"
|
||||
netdialer "github.com/go-gost/x/internal/net/dialer"
|
||||
kcplistener "github.com/go-gost/x/listener/kcp"
|
||||
tcplistener "github.com/go-gost/x/listener/tcp"
|
||||
tlslistener "github.com/go-gost/x/listener/tls"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/metadata"
|
||||
)
|
||||
|
||||
// Required intermediates reject missing chains on every supported transport.
|
||||
// Legitimate authenticated exits remain direct. All credentials and endpoints are local test data.
|
||||
func TestRequiredRelayRouteTransportMatrix(t *testing.T) {
|
||||
log := xlogger.Nop()
|
||||
corelogger.SetDefault(log)
|
||||
certServer := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
cert := certServer.TLS.Certificates[0]
|
||||
leaf := certServer.Certificate()
|
||||
certServer.Close()
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(leaf)
|
||||
tcpTarget, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer tcpTarget.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, e := tcpTarget.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
go func() { defer c.Close(); io.Copy(c, c) }()
|
||||
}
|
||||
}()
|
||||
udpTarget, e := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer udpTarget.Close()
|
||||
go func() {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, a, e := udpTarget.ReadFrom(buf)
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
udpTarget.WriteTo(buf[:n], a)
|
||||
}
|
||||
}()
|
||||
for _, transport := range []string{"tcp", "tls", "kcp"} {
|
||||
for _, network := range []string{"tcp", "udp"} {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
required bool
|
||||
credentials *url.Userinfo
|
||||
success bool
|
||||
}{
|
||||
{"authenticated-exit", false, url.UserPassword("test-peer", "synthetic-test-secret"), true},
|
||||
{"required-missing-chain", true, url.UserPassword("test-peer", "synthetic-test-secret"), false},
|
||||
} {
|
||||
t.Run(transport+"/"+network+"/"+test.name, func(t *testing.T) {
|
||||
opts := []listener.Option{listener.AddrOption("127.0.0.1:0"), listener.LoggerOption(log), listener.TLSConfigOption(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})}
|
||||
var ln listener.Listener
|
||||
switch transport {
|
||||
case "tls":
|
||||
ln = tlslistener.NewListener(opts...)
|
||||
case "kcp":
|
||||
ln = kcplistener.NewListener(opts...)
|
||||
default:
|
||||
ln = tcplistener.NewListener(opts...)
|
||||
}
|
||||
md := metadata.NewMetadata(map[string]any{"kcp.tcp": false, "kcp.nocomp": true})
|
||||
if e := ln.Init(md); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer ln.Close()
|
||||
router := xchain.NewRouter(corechain.LoggerRouterOption(log))
|
||||
if test.required {
|
||||
router.WithRequiredChain()
|
||||
}
|
||||
hopts := []handler.Option{handler.RouterOption(router), handler.LoggerOption(log), handler.ServiceOption("strict-transport-regression")}
|
||||
{
|
||||
au := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{"test-peer": "synthetic-test-secret"}))
|
||||
if closer, ok := au.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
hopts = append(hopts, handler.AutherOption(au))
|
||||
}
|
||||
h := relayhandler.NewHandler(hopts...)
|
||||
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if closer, ok := h.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
handled := make(chan struct{})
|
||||
go func() {
|
||||
defer close(handled)
|
||||
c, e := ln.Accept()
|
||||
if e == nil {
|
||||
h.Handle(context.Background(), c)
|
||||
}
|
||||
}()
|
||||
var raw net.Conn
|
||||
var err error
|
||||
switch transport {
|
||||
case "tls":
|
||||
raw, err = tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", ln.Addr().String(), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12})
|
||||
case "kcp":
|
||||
d := kcpdialer.NewDialer(dialer.LoggerOption(log))
|
||||
if e := d.Init(md); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if closer, ok := d.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
raw, err = d.Dial(context.Background(), ln.Addr().String(), dialer.NetDialerDialOption(&netdialer.Dialer{Logger: log}))
|
||||
default:
|
||||
raw, err = net.DialTimeout("tcp", ln.Addr().String(), time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer raw.Close()
|
||||
raw.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
copts := []connector.Option{connector.LoggerOption(log)}
|
||||
if test.credentials != nil {
|
||||
copts = append(copts, connector.AuthOption(test.credentials))
|
||||
}
|
||||
client := relayconnector.NewConnector(copts...)
|
||||
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
target := tcpTarget.Addr().String()
|
||||
if network == "udp" {
|
||||
target = udpTarget.LocalAddr().String()
|
||||
}
|
||||
conn, err := client.Connect(context.Background(), raw, network, target)
|
||||
if !test.success {
|
||||
if err == nil {
|
||||
t.Fatal("required route missing but direct connection accepted")
|
||||
}
|
||||
|
||||
raw.Close()
|
||||
<-handled
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = conn.Write([]byte("probe")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := make([]byte, 64)
|
||||
n, err := conn.Read(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b[:n]) != "probe" {
|
||||
t.Fatalf("unexpected response %q", b[:n])
|
||||
}
|
||||
raw.Close()
|
||||
select {
|
||||
case <-handled:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("handler did not close")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
options chain.RouterOptions
|
||||
options chain.RouterOptions
|
||||
requireChain bool
|
||||
}
|
||||
|
||||
func NewRouter(opts ...chain.RouterOption) *Router {
|
||||
@@ -35,6 +36,13 @@ func NewRouter(opts ...chain.RouterOption) *Router {
|
||||
return r
|
||||
}
|
||||
|
||||
// WithRequiredChain disables direct fallback when an explicitly required chain
|
||||
// is absent or selects no route. Configure it before serving requests.
|
||||
func (r *Router) WithRequiredChain() *Router {
|
||||
r.requireChain = true
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Router) Options() *chain.RouterOptions {
|
||||
if r == nil {
|
||||
return nil
|
||||
@@ -117,6 +125,10 @@ func (r *Router) dial(ctx context.Context, network, address string, log logger.L
|
||||
route = r.options.Chain.Route(ctx, network, ipAddr, chain.WithHostRouteOption(address))
|
||||
}
|
||||
|
||||
if r.requireChain && (route == nil || len(route.Nodes()) == 0) {
|
||||
return nil, ErrEmptyRoute
|
||||
}
|
||||
|
||||
if buf == nil {
|
||||
buf = &bytes.Buffer{}
|
||||
}
|
||||
@@ -164,6 +176,9 @@ func (r *Router) Bind(ctx context.Context, network, address string, opts ...chai
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if r.requireChain && r.options.Chain == nil {
|
||||
return nil, ErrEmptyRoute
|
||||
}
|
||||
var route chain.Route
|
||||
if r.options.Chain != nil {
|
||||
route = r.options.Chain.Route(ctx, network, address)
|
||||
|
||||
@@ -327,10 +327,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
)
|
||||
}
|
||||
|
||||
handlerRouter := xchain.NewRouter(routerOpts...)
|
||||
if mdutil.GetBool(metadata.NewMetadata(cfg.Handler.Metadata), "chain.strict") {
|
||||
handlerRouter.WithRequiredChain()
|
||||
}
|
||||
|
||||
var h handler.Handler
|
||||
if rf := registry.HandlerRegistry().Get(cfg.Handler.Type); rf != nil {
|
||||
h = rf(
|
||||
handler.RouterOption(xchain.NewRouter(routerOpts...)),
|
||||
handler.RouterOption(handlerRouter),
|
||||
handler.AutherOption(auther),
|
||||
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
|
||||
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type agentServiceManager struct{ kind, control, runner string }
|
||||
|
||||
// Alpine must never select a systemctl compatibility shim. Elsewhere prefer
|
||||
// an active systemd instance, matching the installer, then OpenRC.
|
||||
func detectAgentServiceManager(alpine, systemd bool, lookup func(string) (string, error)) (agentServiceManager, error) {
|
||||
if !alpine && systemd {
|
||||
control, err := lookup("systemctl")
|
||||
if err != nil {
|
||||
return agentServiceManager{}, fmt.Errorf("systemctl 不可用: %w", err)
|
||||
}
|
||||
runner, err := lookup("systemd-run")
|
||||
if err != nil {
|
||||
return agentServiceManager{}, fmt.Errorf("systemd-run 不可用: %w", err)
|
||||
}
|
||||
return agentServiceManager{kind: "systemd", control: control, runner: runner}, nil
|
||||
}
|
||||
control, err := lookup("rc-service")
|
||||
if err != nil {
|
||||
return agentServiceManager{}, fmt.Errorf("未检测到受支持的服务管理器 (systemd/OpenRC): %w", err)
|
||||
}
|
||||
return agentServiceManager{kind: "openrc", control: control}, nil
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) agentRestartLauncher(cleanLegacy bool) (func(string, string) error, error) {
|
||||
// Existing per-reporter seam is also used by checksum-only tests.
|
||||
if w.startUpgradeRestart != nil {
|
||||
return w.startUpgradeRestart, nil
|
||||
}
|
||||
detect := w.detectAgentManager
|
||||
if detect == nil {
|
||||
detect = func() (agentServiceManager, error) {
|
||||
_, alpineErr := os.Stat("/etc/alpine-release")
|
||||
_, systemdErr := os.Stat("/run/systemd/system")
|
||||
return detectAgentServiceManager(alpineErr == nil, systemdErr == nil, exec.LookPath)
|
||||
}
|
||||
}
|
||||
manager, err := detect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if manager.kind == "openrc" {
|
||||
if err := checkAgentDetach(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return func(staged, binary string) error {
|
||||
script := buildManagedAgentRestartScript(manager, staged, binary, cleanLegacy)
|
||||
if manager.kind == "systemd" {
|
||||
// Wait only for submission, never for helper completion. A separate unit is
|
||||
// essential: systemctl stop kills every process in the agent's cgroup.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, manager.runner, "--quiet", "/bin/sh", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交重启任务失败: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return startDetachedAgentHelper(script)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" }
|
||||
|
||||
func buildManagedAgentRestartScript(manager agentServiceManager, staged, binary string, cleanLegacy bool) string {
|
||||
control := shellQuote(manager.control)
|
||||
stop, start := control+" stop flux_agent", control+" start flux_agent"
|
||||
if manager.kind == "openrc" {
|
||||
stop, start = control+" flux_agent stop", control+" flux_agent start"
|
||||
}
|
||||
legacy := ""
|
||||
if manager.kind == "systemd" && cleanLegacy {
|
||||
legacy = `legacy_service=''; for service_file in /etc/systemd/system/gost.service /lib/systemd/system/gost.service /usr/lib/systemd/system/gost.service; do if [ -f "$service_file" ] && grep -Fq "WorkingDirectory=/etc/gost" "$service_file" && (grep -Fq "ExecStart=/etc/gost/gost" "$service_file" || (grep -Fq "ExecStart=/usr/local/bin/gost" "$service_file" && [ -f /etc/gost/config.json ] && [ -f /etc/gost/gost.json ])); then legacy_service="$service_file"; break; fi; done; if [ -n "$legacy_service" ]; then (` + control + ` stop gost 2>/dev/null || true); (` + control + ` disable gost 2>/dev/null || true); rm -f /usr/local/bin/gost /etc/gost/gost "$legacy_service"; (` + control + ` daemon-reload 2>/dev/null || true); fi; `
|
||||
}
|
||||
// The caller owns staging until launch. Afterwards the helper always removes
|
||||
// it, including stop/replace failures. Never truncate the running executable.
|
||||
return "trap " + shellQuote("rm -f -- "+shellQuote(staged)) + " EXIT; sleep 1; " + stop + " || exit $?; " + legacy +
|
||||
"if [ ! -d " + shellQuote(binary) + " ] && mv -f -- " + shellQuote(staged) + " " + shellQuote(binary) + "; then " + start + "; else status=$?; " + start + "; exit \"$status\"; fi"
|
||||
}
|
||||
|
||||
// Copy to a unique file on the destination filesystem, then let the caller
|
||||
// atomically rename. Failure never truncates a previous usable backup.
|
||||
func stageAgentCopy(source, destination string) (path string, err error) {
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("不是普通文件: %s", source)
|
||||
}
|
||||
out, err := os.CreateTemp(filepath.Dir(destination), ".flux-agent-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = out.Name()
|
||||
defer func() {
|
||||
out.Close()
|
||||
if err != nil {
|
||||
os.Remove(path)
|
||||
}
|
||||
}()
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return path, err
|
||||
}
|
||||
if err = out.Chmod(0755); err != nil {
|
||||
return path, err
|
||||
}
|
||||
if err = out.Close(); err != nil {
|
||||
return path, err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func backupAgentBinary(binary string) error {
|
||||
staged, err := stageAgentCopy(binary, binary+".old")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(staged)
|
||||
return os.Rename(staged, binary+".old")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func checkAgentDetach() error { return nil }
|
||||
|
||||
func startDetachedAgentHelper(script string) error {
|
||||
cmd := exec.Command("/bin/sh", "-c", script)
|
||||
// The installed OpenRC service stops the agent PID. A separate session also
|
||||
// survives a process-group stop. No inherited stdio or parent-death signal.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This subprocess stands in for the running agent. The rc-service double kills
|
||||
// its entire process group, not merely a fake PID, while the real helper runs.
|
||||
func TestAgentDetachedParent(t *testing.T) {
|
||||
dir := os.Getenv("FLVX_TEST_DETACH_ROOT")
|
||||
if dir == "" {
|
||||
t.Skip("subprocess helper")
|
||||
}
|
||||
binary := filepath.Join(dir, "agent")
|
||||
control := filepath.Join(dir, "rc-service")
|
||||
writeAgentFixture(t, control, "#!/bin/sh\nif [ \"$2\" = stop ]; then kill -TERM -"+strconv.Itoa(os.Getpid())+"; else echo started > "+shellQuote(filepath.Join(dir, "started"))+"; fi\n")
|
||||
reporter := &WebSocketReporter{upgradeBinaryPath: binary, detectAgentManager: func() (agentServiceManager, error) { return agentServiceManager{kind: "openrc", control: control}, nil }}
|
||||
response := routeAgentOperation(t, reporter, "RollbackAgent", nil)
|
||||
if !response.Success {
|
||||
t.Fatal(response.Message)
|
||||
}
|
||||
time.Sleep(15 * time.Second)
|
||||
t.Fatal("original agent was not stopped")
|
||||
}
|
||||
|
||||
func TestOpenRCHelperSurvivesAgentProcessGroupStop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binary := filepath.Join(dir, "agent")
|
||||
writeAgentFixture(t, binary, "current")
|
||||
writeAgentFixture(t, binary+".old", "backup")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestAgentDetachedParent$", "-test.v")
|
||||
cmd.Env = append(os.Environ(), "FLVX_TEST_DETACH_ROOT="+dir)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil || ctx.Err() != nil {
|
||||
t.Fatalf("parent termination = %v, timeout=%v, %s", err, ctx.Err(), output)
|
||||
}
|
||||
exit, ok := err.(*exec.ExitError)
|
||||
if !ok || exit.Sys().(syscall.WaitStatus).Signal() != syscall.SIGTERM {
|
||||
t.Fatalf("parent did not receive SIGTERM: %v %s", err, output)
|
||||
}
|
||||
waitAgentFixture(t, filepath.Join(dir, "started"), "started\n")
|
||||
if readAgentFixture(t, binary) != "backup" || readAgentFixture(t, binary+".old") != "backup" {
|
||||
t.Fatal("rollback failed after parent termination")
|
||||
}
|
||||
fmt.Println("detached OpenRC helper survived original agent process-group SIGTERM")
|
||||
}
|
||||
|
||||
// Run only in an explicitly opted-in disposable Alpine container. The docker
|
||||
// invocation mounts only the test binary, never a host /bin directory. Removing
|
||||
// the container's /bin/sh symlink forces the real detached cmd.Start to fail.
|
||||
func TestOpenRCCommandsCleanStageOnHelperExecFailure(t *testing.T) {
|
||||
if os.Getenv("FLVX_TEST_DISPOSABLE_ALPINE_SHELL_FAILURE") != "1" {
|
||||
t.Skip("requires explicit disposable Alpine container opt-in")
|
||||
}
|
||||
for _, path := range []string{"/etc/alpine-release", "/.dockerenv"} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("refusing shell mutation outside disposable Alpine: %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
info, err := os.Lstat("/bin/sh")
|
||||
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("refusing to rename a non-symlink /bin/sh: %v", err)
|
||||
}
|
||||
saved := "/bin/.flvx-test-sh-" + strconv.Itoa(os.Getpid())
|
||||
if _, err := os.Lstat(saved); !os.IsNotExist(err) {
|
||||
t.Fatalf("refusing to overwrite shell backup: %v", err)
|
||||
}
|
||||
if err := os.Rename("/bin/sh", saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Rename(saved, "/bin/sh"); err != nil {
|
||||
t.Errorf("restore container shell: %v", err)
|
||||
}
|
||||
}()
|
||||
for _, operation := range []string{"UpgradeAgent", "RollbackAgent"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binary := filepath.Join(dir, "agent")
|
||||
writeAgentFixture(t, binary, "current")
|
||||
writeAgentFixture(t, binary+".old", "prior backup")
|
||||
writeAgentFixture(t, filepath.Join(dir, "rc-service"), "#!/bin/sh\nexit 0\n")
|
||||
t.Setenv("PATH", dir)
|
||||
// No manager or launcher injection: Alpine detection and exec.LookPath are
|
||||
// production code too. Only the installation path points to an isolated dir.
|
||||
reporter := &WebSocketReporter{upgradeBinaryPath: binary}
|
||||
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/checksum" {
|
||||
fmt.Fprint(w, abcSHA256)
|
||||
} else {
|
||||
fmt.Fprint(w, "abc")
|
||||
}
|
||||
}))
|
||||
defer fixture.Close()
|
||||
response := routeAgentOperation(t, reporter, operation, map[string]string{"downloadUrl": fixture.URL + "/binary", "checksumUrl": fixture.URL + "/checksum"})
|
||||
if response.Success || !strings.Contains(response.Message, "/bin/sh") || !strings.Contains(response.Message, "no such file") {
|
||||
t.Fatalf("expected real helper exec failure, got %+v", response)
|
||||
}
|
||||
if readAgentFixture(t, binary) != "current" {
|
||||
t.Fatal("live binary changed")
|
||||
}
|
||||
backup := "prior backup"
|
||||
if operation == "UpgradeAgent" {
|
||||
backup = "current"
|
||||
}
|
||||
if readAgentFixture(t, binary+".old") != backup {
|
||||
t.Fatal("unexpected backup contents")
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(dir, ".flux-agent-*"))
|
||||
if err != nil || len(matches) != 0 {
|
||||
t.Fatalf("rollback staging residue: %v %v", matches, err)
|
||||
}
|
||||
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
|
||||
t.Fatalf("upgrade staging residue: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package socket
|
||||
|
||||
import "fmt"
|
||||
|
||||
func checkAgentDetach() error { return fmt.Errorf("OpenRC Agent 重启仅支持 Linux") }
|
||||
func startDetachedAgentHelper(string) error { return checkAgentDetach() }
|
||||
@@ -0,0 +1,329 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func routeAgentOperation(t *testing.T, reporter *WebSocketReporter, operation string, data interface{}) CommandResponse {
|
||||
t.Helper()
|
||||
peer := make(chan *websocket.Conn, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err == nil {
|
||||
peer <- c
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
reader := <-peer
|
||||
defer reader.Close()
|
||||
reporter.ctx = context.Background()
|
||||
reporter.conn = c
|
||||
reporter.connected = true
|
||||
reporter.routeCommand(CommandMessage{Type: operation, RequestId: "restart-request", Data: data})
|
||||
reader.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
for {
|
||||
var response CommandResponse
|
||||
if err := reader.ReadJSON(&response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Type == operation+"Response" {
|
||||
if response.RequestId != "restart-request" {
|
||||
t.Fatalf("request id lost: %s", response.RequestId)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackAgentStagesBackupBeforeRestart(t *testing.T) {
|
||||
binary := filepath.Join(t.TempDir(), "flux_agent")
|
||||
for path, body := range map[string]string{binary: "current", binary + ".old": "backup"} {
|
||||
if err := os.WriteFile(path, []byte(body), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
called := false
|
||||
reporter := &WebSocketReporter{upgradeBinaryPath: binary, startUpgradeRestart: func(staged, dest string) error {
|
||||
called = true
|
||||
body, err := os.ReadFile(staged)
|
||||
if err != nil || string(body) != "backup" || staged == binary+".old" || dest != binary {
|
||||
t.Fatalf("bad rollback staging: %s %s %q %v", staged, dest, body, err)
|
||||
}
|
||||
return os.Rename(staged, dest)
|
||||
}}
|
||||
response := routeAgentOperation(t, reporter, "RollbackAgent", nil)
|
||||
if !response.Success || !called {
|
||||
t.Fatalf("rollback failed: called=%v %s", called, response.Message)
|
||||
}
|
||||
body, err := os.ReadFile(binary + ".old")
|
||||
if err != nil || string(body) != "backup" {
|
||||
t.Fatalf("backup consumed: %q %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentServiceManagerDetection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
alpine, systemd bool
|
||||
tools string
|
||||
want string
|
||||
}{
|
||||
{"alpine shim", true, true, "rc-service systemctl systemd-run", "openrc"},
|
||||
{"alpine missing openrc", true, true, "systemctl systemd-run", ""},
|
||||
{"systemd", false, true, "rc-service systemctl systemd-run", "systemd"},
|
||||
{"missing runner", false, true, "systemctl", ""},
|
||||
{"openrc fallback", false, false, "rc-service", "openrc"},
|
||||
{"unmanaged", false, false, "systemctl systemd-run", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
manager, err := detectAgentServiceManager(tc.alpine, tc.systemd, func(name string) (string, error) {
|
||||
if strings.Contains(" "+tc.tools+" ", " "+name+" ") {
|
||||
return name, nil
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
})
|
||||
if manager.kind != tc.want || (err != nil) != (tc.want == "") {
|
||||
t.Fatalf("manager=%+v err=%v", manager, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeAgentFixture(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func readAgentFixture(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
func waitAgentFixture(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
body, _ := os.ReadFile(path)
|
||||
if string(body) == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
body, _ := os.ReadFile(path)
|
||||
t.Fatalf("%s = %q, want %q", path, body, want)
|
||||
}
|
||||
|
||||
func TestAgentCommandsManagerLaunch(t *testing.T) {
|
||||
for _, operation := range []string{"UpgradeAgent", "RollbackAgent"} {
|
||||
for _, mode := range []string{"systemd", "openrc", "missing", "rejected"} {
|
||||
t.Run(operation+"/"+mode, func(t *testing.T) {
|
||||
if mode == "openrc" && runtime.GOOS != "linux" {
|
||||
t.Skip("detached launcher is Linux-only")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
binary := filepath.Join(dir, "flux 'agent")
|
||||
log := filepath.Join(dir, "events")
|
||||
writeAgentFixture(t, binary, "current")
|
||||
writeAgentFixture(t, binary+".old", "backup")
|
||||
control := filepath.Join(dir, "manager")
|
||||
writeAgentFixture(t, control, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> "+shellQuote(log)+"\n")
|
||||
runner := filepath.Join(dir, "runner")
|
||||
writeAgentFixture(t, runner, "#!/bin/sh\nshift\n\"$@\" >/dev/null 2>&1 &\n")
|
||||
if mode == "rejected" {
|
||||
writeAgentFixture(t, runner, "#!/bin/sh\necho rejected >&2\nexit 9\n")
|
||||
}
|
||||
reporter := &WebSocketReporter{upgradeBinaryPath: binary, detectAgentManager: func() (agentServiceManager, error) {
|
||||
return detectAgentServiceManager(mode == "openrc", mode != "missing", func(name string) (string, error) {
|
||||
if mode == "missing" {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
if name == "systemd-run" {
|
||||
return runner, nil
|
||||
}
|
||||
return control, nil
|
||||
})
|
||||
}}
|
||||
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/checksum" {
|
||||
fmt.Fprint(w, abcSHA256)
|
||||
} else {
|
||||
fmt.Fprint(w, "abc")
|
||||
}
|
||||
}))
|
||||
defer fixture.Close()
|
||||
response := routeAgentOperation(t, reporter, operation, map[string]string{"downloadUrl": fixture.URL + "/binary", "checksumUrl": fixture.URL + "/checksum"})
|
||||
if mode == "missing" || mode == "rejected" {
|
||||
if response.Success {
|
||||
t.Fatal("accepted failed manager launch")
|
||||
}
|
||||
if readAgentFixture(t, binary) != "current" {
|
||||
t.Fatal("live binary mutated")
|
||||
}
|
||||
if mode == "missing" && readAgentFixture(t, binary+".old") != "backup" {
|
||||
t.Fatal("backup mutated before manager validation")
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, ".flux-agent-*"))
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("staging residue %v", matches)
|
||||
}
|
||||
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
|
||||
t.Fatal("upgrade residue")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !response.Success {
|
||||
t.Fatal(response.Message)
|
||||
}
|
||||
want, backup := "abc", "current"
|
||||
if operation == "RollbackAgent" {
|
||||
want, backup = "backup", "backup"
|
||||
}
|
||||
waitAgentFixture(t, binary, want)
|
||||
events := "stop flux_agent\nstart flux_agent\n"
|
||||
if mode == "openrc" {
|
||||
events = "flux_agent stop\nflux_agent start\n"
|
||||
}
|
||||
waitAgentFixture(t, log, events)
|
||||
if readAgentFixture(t, binary+".old") != backup {
|
||||
t.Fatal("wrong backup")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRestartScriptFailureOrdering(t *testing.T) {
|
||||
for _, mode := range []string{"stop", "replace"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binary := filepath.Join(dir, "agent")
|
||||
staged := filepath.Join(dir, "staged")
|
||||
log := filepath.Join(dir, "events")
|
||||
writeAgentFixture(t, binary, "current")
|
||||
writeAgentFixture(t, staged, "replacement")
|
||||
control := filepath.Join(dir, "rc-service")
|
||||
script := "#!/bin/sh\necho \"$*\" >> " + shellQuote(log) + "\n"
|
||||
if mode == "stop" {
|
||||
script += "[ \"$2\" != stop ]\n"
|
||||
}
|
||||
writeAgentFixture(t, control, script)
|
||||
if mode == "replace" {
|
||||
writeAgentFixture(t, filepath.Join(dir, "mv"), "#!/bin/sh\nexit 23\n")
|
||||
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
|
||||
}
|
||||
cmd := exec.Command("/bin/sh", "-c", buildManagedAgentRestartScript(agentServiceManager{kind: "openrc", control: control}, staged, binary, true))
|
||||
if err := cmd.Run(); err == nil {
|
||||
t.Fatal("expected failed stop/replacement")
|
||||
}
|
||||
if readAgentFixture(t, binary) != "current" {
|
||||
t.Fatal("changed binary after failure")
|
||||
}
|
||||
want := "flux_agent stop\n"
|
||||
if mode == "replace" {
|
||||
want += "flux_agent start\n"
|
||||
}
|
||||
if readAgentFixture(t, log) != want {
|
||||
t.Fatalf("wrong sequence %q", readAgentFixture(t, log))
|
||||
}
|
||||
if _, err := os.Stat(staged); !os.IsNotExist(err) {
|
||||
t.Fatal("staging residue")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeAgentBackupFailurePreservesExistingBackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binary := filepath.Join(dir, "agent")
|
||||
writeAgentFixture(t, binary+".old", "prior")
|
||||
called := false
|
||||
reporter := &WebSocketReporter{upgradeBinaryPath: binary, startUpgradeRestart: func(string, string) error { called = true; return nil }}
|
||||
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/checksum" {
|
||||
fmt.Fprint(w, abcSHA256)
|
||||
} else {
|
||||
fmt.Fprint(w, "abc")
|
||||
}
|
||||
}))
|
||||
defer fixture.Close()
|
||||
response := routeAgentOperation(t, reporter, "UpgradeAgent", map[string]string{"downloadUrl": fixture.URL + "/binary", "checksumUrl": fixture.URL + "/checksum"})
|
||||
if response.Success || called {
|
||||
t.Fatal("upgrade accepted without backup")
|
||||
}
|
||||
if readAgentFixture(t, binary+".old") != "prior" {
|
||||
t.Fatal("backup lost")
|
||||
}
|
||||
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
|
||||
t.Fatal("staging residue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackAgentRejectsDirectoryDestination(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binary := filepath.Join(dir, "agent")
|
||||
if err := os.Mkdir(binary, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeAgentFixture(t, binary+".old", "backup")
|
||||
called := false
|
||||
reporter := &WebSocketReporter{upgradeBinaryPath: binary, startUpgradeRestart: func(string, string) error { called = true; return nil }}
|
||||
response := routeAgentOperation(t, reporter, "RollbackAgent", nil)
|
||||
if response.Success || called {
|
||||
t.Fatal("accepted directory destination")
|
||||
}
|
||||
if readAgentFixture(t, binary+".old") != "backup" {
|
||||
t.Fatal("changed backup")
|
||||
}
|
||||
entries, err := os.ReadDir(binary)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("destination changed: %v %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRestartScriptRejectsDirectoryDestination(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binary := filepath.Join(dir, "agent")
|
||||
staged := filepath.Join(dir, "stage")
|
||||
if err := os.Mkdir(binary, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeAgentFixture(t, staged, "replacement")
|
||||
script := buildManagedAgentRestartScript(agentServiceManager{kind: "openrc", control: "/usr/bin/true"}, staged, binary, false)
|
||||
if err := exec.Command("/bin/sh", "-c", script).Run(); err == nil {
|
||||
t.Fatal("accepted directory destination")
|
||||
}
|
||||
entries, err := os.ReadDir(binary)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("binary moved inside directory: %v %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRCRestartNeverCleansSystemdLegacyService(t *testing.T) {
|
||||
script := buildManagedAgentRestartScript(agentServiceManager{kind: "openrc", control: "rc-service"}, "staged", "binary", true)
|
||||
for _, forbidden := range []string{"systemctl", "gost", "daemon-reload", "/etc/systemd"} {
|
||||
if strings.Contains(script, forbidden) {
|
||||
t.Fatalf("OpenRC helper contains %s: %s", forbidden, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// All writes are confined to TempDir; the restart callback never starts a process.
|
||||
func exerciseUpgrade(t *testing.T, checksum string, serveChecksum http.HandlerFunc, valid bool) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
binary := filepath.Join(dir, "flux_agent")
|
||||
for path, body := range map[string]string{binary: "old binary", binary + ".old": "prior backup"} {
|
||||
if err := os.WriteFile(path, []byte(body), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
downloads := 0
|
||||
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/binary-"+runtime.GOARCH {
|
||||
downloads++
|
||||
fmt.Fprint(w, "abc")
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/checksum-"+runtime.GOARCH {
|
||||
t.Errorf("unexpected checksum path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveChecksum(w, r)
|
||||
}))
|
||||
defer fixture.Close()
|
||||
if checksum == "fixture" {
|
||||
checksum = fixture.URL + "/checksum-{ARCH}"
|
||||
}
|
||||
peer := make(chan *websocket.Conn, 1)
|
||||
ws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
peer <- c
|
||||
}))
|
||||
defer ws.Close()
|
||||
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ws.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
reader := <-peer
|
||||
defer reader.Close()
|
||||
restarted := false
|
||||
reporter := &WebSocketReporter{ctx: context.Background(), conn: conn, connected: true, upgradeBinaryPath: binary,
|
||||
startUpgradeRestart: func(staged, destination string) error {
|
||||
restarted = true
|
||||
if destination != binary {
|
||||
t.Errorf("wrong restart destination: %s", destination)
|
||||
}
|
||||
b, e := os.ReadFile(staged)
|
||||
if e != nil || string(b) != "abc" {
|
||||
t.Errorf("staged binary = %q, %v", b, e)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
reporter.routeCommand(CommandMessage{Type: "UpgradeAgent", RequestId: "upgrade-test-request", Data: map[string]any{"downloadUrl": fixture.URL + "/binary-{ARCH}", "checksumUrl": checksum}})
|
||||
reader.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
verified, installing := false, false
|
||||
for {
|
||||
var response CommandResponse
|
||||
if err := reader.ReadJSON(&response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Type == "UpgradeAgentResponse" {
|
||||
if response.RequestId != "upgrade-test-request" {
|
||||
t.Errorf("response request ID = %q", response.RequestId)
|
||||
}
|
||||
if response.Success != valid {
|
||||
t.Errorf("success = %v, want %v: %s", response.Success, valid, response.Message)
|
||||
}
|
||||
break
|
||||
}
|
||||
if response.Type == "UpgradeProgress" {
|
||||
data := response.Data.(map[string]interface{})
|
||||
if data["stage"] == "verifying" && data["percent"] == float64(100) {
|
||||
verified = true
|
||||
}
|
||||
if data["stage"] == "installing" {
|
||||
installing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if restarted != valid || verified != valid || installing != valid {
|
||||
t.Errorf("restart=%v verified=%v installing=%v, want %v", restarted, verified, installing, valid)
|
||||
}
|
||||
b, e := os.ReadFile(binary)
|
||||
if e != nil || string(b) != "old binary" {
|
||||
t.Errorf("old binary changed: %q %v", b, e)
|
||||
}
|
||||
if !valid {
|
||||
b, e = os.ReadFile(binary + ".old")
|
||||
if e != nil || string(b) != "prior backup" {
|
||||
t.Errorf("backup changed: %q %v", b, e)
|
||||
}
|
||||
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
|
||||
t.Errorf("staged binary remains: %v", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(checksum) == "" && downloads != 0 {
|
||||
t.Errorf("downloaded without checksum URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeAgentRequiresChecksumURL(t *testing.T) {
|
||||
exerciseUpgrade(t, "", func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected checksum request") }, false)
|
||||
}
|
||||
|
||||
func TestUpgradeAgentRejectsChecksumHTTPFailure(t *testing.T) {
|
||||
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", http.StatusNotFound) }, false)
|
||||
}
|
||||
|
||||
const abcSHA256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
|
||||
func TestUpgradeAgentRejectsAmbiguousChecksum(t *testing.T) {
|
||||
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, abcSHA256+" gost-amd64\n"+abcSHA256+" gost-arm64\n")
|
||||
}, false)
|
||||
}
|
||||
|
||||
func TestUpgradeAgentRejectsOversizedChecksum(t *testing.T) {
|
||||
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, abcSHA256+" "+strings.Repeat("a", 4096)) }, false)
|
||||
}
|
||||
|
||||
func TestUpgradeAgentChecksumRecords(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
valid bool
|
||||
}{
|
||||
{"bare", abcSHA256, true},
|
||||
{"GNU text", abcSHA256 + " gost-arm64\n", true},
|
||||
{"GNU binary", abcSHA256 + " *gost-arm64\n", true},
|
||||
{"uppercase CRLF surrounding whitespace", " \t" + strings.ToUpper(abcSHA256) + " gost-arm64\r\n", true},
|
||||
{"bare surrounding whitespace", "\r\n\t" + abcSHA256 + " \r\n", true},
|
||||
{"empty", "", false},
|
||||
{"whitespace", " \r\n\t", false},
|
||||
{"truncated", abcSHA256[:63], false},
|
||||
{"nonhex", "z" + abcSHA256[1:], false},
|
||||
{"mismatch", strings.Repeat("0", 64), false},
|
||||
{"missing GNU marker", abcSHA256 + " gost-arm64", false},
|
||||
{"invalid GNU marker", abcSHA256 + " ?gost-arm64", false},
|
||||
{"missing filename", abcSHA256 + " *", false},
|
||||
{"appended garbage", abcSHA256 + "garbage", false},
|
||||
{"embedded CR", abcSHA256 + " gost\rarm64", false},
|
||||
{"NUL", abcSHA256 + " gost\x00arm64", false},
|
||||
{"two bare digests", abcSHA256 + "\n" + abcSHA256, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, tc.body) }, tc.valid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeAgentRejectsChecksumTransportFailure(t *testing.T) {
|
||||
exerciseUpgrade(t, "://invalid", nil, false)
|
||||
}
|
||||
|
||||
func TestUpgradeAgentRejectsChecksumReadFailure(t *testing.T) {
|
||||
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) {
|
||||
// A valid-looking record is incomplete relative to the declared body length.
|
||||
w.Header().Set("Content-Length", "999")
|
||||
fmt.Fprint(w, abcSHA256)
|
||||
}, false)
|
||||
}
|
||||
|
||||
func TestUpgradeAgentRejectsBlankChecksumURL(t *testing.T) {
|
||||
exerciseUpgrade(t, " \t\r\n", nil, false)
|
||||
}
|
||||
|
||||
func TestUpgradeAgentRejectsChecksumConnectionFailure(t *testing.T) {
|
||||
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}, false)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
@@ -158,6 +157,11 @@ const (
|
||||
)
|
||||
|
||||
type WebSocketReporter struct {
|
||||
// Per-reporter upgrade dependencies keep staging and process launch isolated.
|
||||
upgradeBinaryPath string
|
||||
startUpgradeRestart func(string, string) error
|
||||
detectAgentManager func() (agentServiceManager, error)
|
||||
|
||||
url string
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
@@ -1377,6 +1381,23 @@ func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, messa
|
||||
w.sendResponse(response)
|
||||
}
|
||||
|
||||
// parseUpgradeChecksum accepts one bare SHA256 or one GNU sha256sum record.
|
||||
// A manifest cannot safely identify the requested binary by its first record.
|
||||
func parseUpgradeChecksum(body []byte) (string, error) {
|
||||
record := strings.TrimSpace(string(body))
|
||||
if len(record) < 64 || strings.ContainsAny(record, "\r\n\x00") {
|
||||
return "", fmt.Errorf("校验文件格式无效: 需要单条 SHA256 记录")
|
||||
}
|
||||
digest := record[:64]
|
||||
if _, err := hex.DecodeString(digest); err != nil {
|
||||
return "", fmt.Errorf("校验文件格式无效: SHA256 必须为 64 位十六进制")
|
||||
}
|
||||
if len(record) != 64 && (len(record) < 67 || record[64] != ' ' || (record[65] != ' ' && record[65] != '*') || strings.TrimSpace(record[66:]) == "") {
|
||||
return "", fmt.Errorf("校验文件格式无效: 需要单条 GNU SHA256 记录")
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
@@ -1394,6 +1415,10 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||
return fmt.Errorf("下载地址不能为空")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.ChecksumURL) == "" {
|
||||
return fmt.Errorf("校验文件地址不能为空")
|
||||
}
|
||||
|
||||
// 替换架构占位符
|
||||
downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH)
|
||||
checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH)
|
||||
@@ -1402,9 +1427,11 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||
fmt.Printf("📦 开始下载升级包: %s\n", downloadURL)
|
||||
|
||||
// 下载新版本二进制
|
||||
const binaryPath = "/etc/flux_agent/flux_agent"
|
||||
binaryPath := w.upgradeBinaryPath
|
||||
if binaryPath == "" {
|
||||
binaryPath = "/etc/flux_agent/flux_agent"
|
||||
}
|
||||
tmpPath := binaryPath + ".new"
|
||||
backupPath := binaryPath + ".old"
|
||||
|
||||
resp, err := http.Get(downloadURL)
|
||||
if err != nil {
|
||||
@@ -1421,6 +1448,14 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||
return fmt.Errorf("创建临时文件失败: %v", err)
|
||||
}
|
||||
|
||||
// The restart process owns the staged file only after launch succeeds.
|
||||
restartStarted := false
|
||||
defer func() {
|
||||
if !restartStarted {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// 带进度的下载
|
||||
totalSize := resp.ContentLength
|
||||
var downloaded int64
|
||||
@@ -1433,7 +1468,6 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||
if n > 0 {
|
||||
if _, wErr := outFile.Write(buf[:n]); wErr != nil {
|
||||
outFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("写入升级包失败: %v", wErr)
|
||||
}
|
||||
hasher.Write(buf[:n])
|
||||
@@ -1451,104 +1485,97 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||
break
|
||||
}
|
||||
outFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("读取升级包失败: %v", readErr)
|
||||
}
|
||||
}
|
||||
outFile.Close()
|
||||
if err := outFile.Close(); err != nil {
|
||||
return fmt.Errorf("关闭升级包失败: %w", err)
|
||||
}
|
||||
|
||||
if downloaded == 0 {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("下载的升级包为空")
|
||||
}
|
||||
|
||||
w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded))
|
||||
|
||||
// Checksum 校验
|
||||
if checksumURL != "" {
|
||||
w.sendUpgradeProgress("verifying", 0, "校验文件完整性...")
|
||||
checksumResp, err := http.Get(checksumURL)
|
||||
if err == nil {
|
||||
defer checksumResp.Body.Close()
|
||||
if checksumResp.StatusCode == http.StatusOK {
|
||||
checksumBody, err := io.ReadAll(checksumResp.Body)
|
||||
if err == nil {
|
||||
// 格式: "<hash> <filename>" 或 "<hash>"
|
||||
expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0])
|
||||
actualHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
if !strings.EqualFold(expectedHash, actualHash) {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
|
||||
}
|
||||
fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
w.sendUpgradeProgress("verifying", 100, "校验通过")
|
||||
// Every checksum failure must abort before installation.
|
||||
w.sendUpgradeProgress("verifying", 0, "校验文件完整性...")
|
||||
checksumResp, err := http.Get(checksumURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载校验文件失败: %w", err)
|
||||
}
|
||||
defer checksumResp.Body.Close()
|
||||
if checksumResp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载校验文件失败, HTTP状态码: %d", checksumResp.StatusCode)
|
||||
}
|
||||
const maxChecksumBytes = 4096
|
||||
checksumBody, err := io.ReadAll(io.LimitReader(checksumResp.Body, maxChecksumBytes+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取校验文件失败: %w", err)
|
||||
}
|
||||
if len(checksumBody) > maxChecksumBytes {
|
||||
return fmt.Errorf("校验文件过大: 最大 %d bytes", maxChecksumBytes)
|
||||
}
|
||||
expectedHash, err := parseUpgradeChecksum(checksumBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actualHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
if !strings.EqualFold(expectedHash, actualHash) {
|
||||
return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
|
||||
}
|
||||
w.sendUpgradeProgress("verifying", 100, "校验通过")
|
||||
|
||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("设置执行权限失败: %v", err)
|
||||
}
|
||||
|
||||
// 备份旧版本
|
||||
w.sendUpgradeProgress("installing", 50, "备份旧版本...")
|
||||
if _, err := os.Stat(binaryPath); err == nil {
|
||||
// 复制旧文件作为备份(不用 rename,因为可能正在运行)
|
||||
oldData, err := os.ReadFile(binaryPath)
|
||||
if err == nil {
|
||||
_ = os.WriteFile(backupPath, oldData, 0755)
|
||||
fmt.Println("📦 旧版本已备份到", backupPath)
|
||||
}
|
||||
// Resolve the manager before mutating an existing rollback backup.
|
||||
startRestart, err := w.agentRestartLauncher(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.sendUpgradeProgress("installing", 50, "备份旧版本...")
|
||||
if err := backupAgentBinary(binaryPath); err != nil {
|
||||
return fmt.Errorf("备份旧版本失败: %w", err)
|
||||
}
|
||||
|
||||
w.sendUpgradeProgress("installing", 80, "准备重启...")
|
||||
fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded)
|
||||
|
||||
// 执行重启脚本
|
||||
// 使用 systemd-run 在独立的 transient unit 中运行重启脚本,
|
||||
// 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。
|
||||
script := buildAgentRestartScript(tmpPath, binaryPath)
|
||||
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
if err := startRestart(tmpPath, binaryPath); err != nil {
|
||||
return fmt.Errorf("启动重启脚本失败: %v", err)
|
||||
}
|
||||
|
||||
restartStarted = true
|
||||
w.sendUpgradeProgress("installing", 100, "重启中...")
|
||||
fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
|
||||
const binaryPath = "/etc/flux_agent/flux_agent"
|
||||
backupPath := binaryPath + ".old"
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("没有可用的备份文件,无法回退")
|
||||
binaryPath := w.upgradeBinaryPath
|
||||
if binaryPath == "" {
|
||||
binaryPath = "/etc/flux_agent/flux_agent"
|
||||
}
|
||||
|
||||
fmt.Println("🔄 开始回退到旧版本...")
|
||||
|
||||
// 执行回退脚本(同升级逻辑,使用 systemd-run 避免 cgroup 问题)
|
||||
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath)
|
||||
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("启动回退脚本失败: %v", err)
|
||||
startRestart, err := w.agentRestartLauncher(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Stat(binaryPath); err != nil || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("回退目标不是可用的普通文件: %s", binaryPath)
|
||||
}
|
||||
staged, err := stageAgentCopy(binaryPath+".old", binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("准备回退备份失败: %w", err)
|
||||
}
|
||||
if err := startRestart(staged, binaryPath); err != nil {
|
||||
_ = os.Remove(staged)
|
||||
return fmt.Errorf("启动回退脚本失败: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...")
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildAgentRestartScript(tmpPath, binaryPath string) string {
|
||||
return fmt.Sprintf(
|
||||
"sleep 1 && systemctl stop flux_agent && legacy_service='' && for service_file in /etc/systemd/system/gost.service /lib/systemd/system/gost.service /usr/lib/systemd/system/gost.service; do if [ -f \"$service_file\" ] && grep -Fq \"WorkingDirectory=/etc/gost\" \"$service_file\" && (grep -Fq \"ExecStart=/etc/gost/gost\" \"$service_file\" || (grep -Fq \"ExecStart=/usr/local/bin/gost\" \"$service_file\" && [ -f /etc/gost/config.json ] && [ -f /etc/gost/gost.json ])); then legacy_service=\"$service_file\"; break; fi; done && if [ -n \"$legacy_service\" ]; then (systemctl stop gost 2>/dev/null || true) && (systemctl disable gost 2>/dev/null || true) && rm -f /usr/local/bin/gost /etc/gost/gost \"$legacy_service\" && (systemctl daemon-reload 2>/dev/null || true); fi && mv %s %s && systemctl start flux_agent",
|
||||
tmpPath,
|
||||
binaryPath,
|
||||
)
|
||||
return buildManagedAgentRestartScript(agentServiceManager{kind: "systemd", control: "systemctl"}, tmpPath, binaryPath, true)
|
||||
}
|
||||
|
||||
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||
|
||||
@@ -186,16 +186,16 @@ func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) {
|
||||
func TestAgentUpgradeRestartScriptStopsLegacyGostService(t *testing.T) {
|
||||
script := buildAgentRestartScript("/tmp/flux_agent.new", "/etc/flux_agent/flux_agent")
|
||||
|
||||
if !strings.Contains(script, "systemctl stop flux_agent") {
|
||||
if !strings.Contains(script, "'systemctl' stop flux_agent") {
|
||||
t.Fatalf("expected script to stop flux_agent, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "mv /tmp/flux_agent.new /etc/flux_agent/flux_agent") {
|
||||
if !strings.Contains(script, "mv -f -- '/tmp/flux_agent.new' '/etc/flux_agent/flux_agent'") {
|
||||
t.Fatalf("expected script to replace the flux_agent binary, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "systemctl stop gost") {
|
||||
if !strings.Contains(script, "'systemctl' stop gost") {
|
||||
t.Fatalf("expected script to stop the legacy gost service, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "systemctl disable gost") {
|
||||
if !strings.Contains(script, "'systemctl' disable gost") {
|
||||
t.Fatalf("expected script to disable the legacy gost service, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "rm -f /usr/local/bin/gost") {
|
||||
@@ -204,7 +204,7 @@ func TestAgentUpgradeRestartScriptStopsLegacyGostService(t *testing.T) {
|
||||
if !strings.Contains(script, "WorkingDirectory=/etc/gost") {
|
||||
t.Fatalf("expected script to scope cleanup to the legacy FLVX gost service definition, got %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "systemctl start flux_agent") {
|
||||
if !strings.Contains(script, "'systemctl' start flux_agent") {
|
||||
t.Fatalf("expected script to restart flux_agent, got %s", script)
|
||||
}
|
||||
if strings.Contains(script, "systemctl stop flux_agent && systemctl stop gost 2>/dev/null || true") {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Relay authentication and required-route security repair
|
||||
|
||||
Status: implementation, two-axis review and local integrated validation complete. Remote CI must pass before the authorized merge and release. Approved initial review base: 3549fe5; upstream rc7 is preserved.
|
||||
Base inspected: 3549fe5 (existing checkout); existing investigation tests are preserved.
|
||||
Workflow: installed Matt Pocock skills 1.3.2, diagnosing-bugs → TDD → code-review. The owner subsequently authorized PR submission, merge and the next release tag after review.
|
||||
|
||||
## Problems and evidence
|
||||
|
||||
1. Local and federated relay services and connectors are emitted without authentication. Native TCP, TLS, and KCP listener tests with both TCP and UDP destinations confirm unauthenticated forwarding. Existing authenticator support rejects absent/wrong credentials and permits correct credentials. TLS encryption alone does not authenticate clients.
|
||||
2. A named chain missing at runtime produces a nil route, and an empty selected route also falls back to direct dialing. The loopback A–B–D → A–B–C–D test confirms that removing only B's chain permits A–B–target. A gated backend test confirms the live-service/missing-chain interval during redeployment overlaps topology editing. This is a demonstrated trigger, not proof of the reported production incident.
|
||||
3. TLS client generation defaults to unverified server identity. KCP defaults are public, not unique deployment credentials. Authentication must not be advertised as protected against credential interception without addressing transport trust.
|
||||
|
||||
## Scope and safety
|
||||
|
||||
- Keep work in the existing checkout, preserve all local changes, and add no dependencies or infrastructure without asking.
|
||||
- Do not mutate live production configuration or create production credentials. The owner subsequently authorized committing, pushing a PR, merging after checks, and publishing the next release tag through the existing workflow.
|
||||
- Existing production topology and agent versions are unknown. All tests use temporary databases and loopback endpoints.
|
||||
- Investigations characterize insecure behavior; permanent regression tests must assert desired secure behavior without deleting the historical evidence.
|
||||
|
||||
## Confirmed test seams
|
||||
|
||||
1. Panel HTTP and agent/federation command interfaces: generated authentication and strict-route policy, persistence, migration/reconnect, and serialized topology transitions. Use temporary repository storage and mock external node connections; do not test private helper implementation shapes.
|
||||
2. Native GOST service/config and relay data interfaces: TCP/TLS/KCP carrying TCP/UDP, absent/wrong/correct credentials, required-route disappearance/empty selection, recovery, and legitimate exit direct dialing. Use local real sockets and public parsing/registry interfaces.
|
||||
|
||||
## Vertical implementation slices
|
||||
|
||||
1. Required-route invariant: explicit FLVX intermediate-route policy fails closed for missing or empty routes; ordinary direct exits remain valid. Do not globally redefine every GOST bypass/empty-route behavior. First write a failing behavioral test, make the minimal change, then verify recovery and transport variants.
|
||||
2. Runtime lifecycle: serialize operations for the same tunnel; replace chains without delete/recreate gaps when possible. Avoid stale redeploy snapshots overwriting newer edits. Check create/update/redeploy/delete and failover owner paths. Do not hold a database transaction while awaiting node acknowledgements.
|
||||
3. Authentication model and persistence: define a single runtime-auth contract usable by local and federation generation. Credentials must be purpose-separated from node management secrets, nonempty, stable across normal resync, absent from public list responses/logs, and fail safely on malformed or unavailable state.
|
||||
4. Compatibility/migration: verify the exact old/new capability contract. Where staged migration is needed, teach all authorized upstream connectors before enforcing receiver auth, and expose incomplete migration rather than silently claim protection. Do not silently fall back to anonymous mode on an error. Validate restart, reconnect, edits, sharing and rollback.
|
||||
5. Transport trust: use verified encryption for reusable credentials; evaluate how existing raw TCP/KCP configurations can migrate without pretending public defaults are authentication. Any unresolvable product decision or component addition must be raised before implementation.
|
||||
6. Final validation and two-axis review: focused red/green evidence, race tests, relevant full module suites/build/vet, standards review and spec coverage review. Document blocked PostgreSQL/production checks separately.
|
||||
|
||||
## Candidate decisions requiring research
|
||||
|
||||
- Best credential storage granularity: tunnel listener/binding vs per-node identity; federation must carry the correct receiving identity without exposing management keys.
|
||||
- Existing schema/API compatibility and capability reporting; no assumption that old agent/remote panel versions accept new required fields.
|
||||
- Opt-in strict-route metadata protects FLVX-specific invariants while preserving generic GOST use; missing policy support on old agents must be visible.
|
||||
- Safest no-new-component authenticated transport plan and handling of legacy unencrypted TCP.
|
||||
|
||||
## Completion conditions
|
||||
|
||||
- An unauthenticated relay client cannot use newly secured local/federated endpoints; wrong credentials are rejected and authorized forwarding works.
|
||||
- Missing a required chain cannot connect directly to the ultimate target; restored chains resume the intended path.
|
||||
- Editing and redeploying the same tunnel cannot leave a stale or partially rebuilt runtime presented as successful.
|
||||
- No production-side actions or new external dependencies are introduced. Limitations and remaining migration steps are explicit.
|
||||
|
||||
## Authentication implementation decision (confirmed test seams)
|
||||
|
||||
The owner confirmed both seams. New relay tunnels require endpoint-scoped relay
|
||||
credentials; existing rows remain explicitly `legacy` in the tunnel list API.
|
||||
No reconnect automatically migrates an existing tunnel. This is an auth-only
|
||||
repair against anonymous relay abuse, not protection against interception: TCP,
|
||||
unverified relay TLS, public-default KCP and HTTP/WS control-plane paths retain
|
||||
transport confidentiality limitations. We do not silently change those protocols.
|
||||
|
||||
Persist independent credentials per tunnel/receiving node/role, never reuse
|
||||
management/share tokens, and fail on RNG failure. Credentials survive reconnect,
|
||||
best-exit ordering and edits. Missing required credentials fail closed. New
|
||||
federation provisioning requires an explicit auth-version capability and apply
|
||||
acknowledgment; authenticated runtimes reject legacy reapply. Credentials are
|
||||
excluded from ordinary public DTOs. Backups without the security state must not
|
||||
be represented as protected after restoration; migration/credential backup and
|
||||
rotation require a later explicit maintenance workflow.
|
||||
|
||||
Legacy live migration is not automatic in this patch. A future explicit migration
|
||||
must coordinate all upstreams and receivers in a maintenance cutover, persist
|
||||
progress, and never report protection before all acknowledgments. Existing legacy
|
||||
tunnels remain unprotected until that work is completed. No production credentials
|
||||
or configuration are generated by this local development task.
|
||||
|
||||
## Validation and release boundaries
|
||||
|
||||
Implemented required-route behavior is opt-in handler metadata `chain.strict`.
|
||||
The panel emits it for tunnel entry forwards and relay intermediates; direct
|
||||
exits do not set it. A new agent build is required: existing binaries can ignore
|
||||
unknown metadata. A panel command acknowledgment alone is not proof that an
|
||||
unknown older binary enforces this policy.
|
||||
|
||||
The runtime lock serializes operations within one panel process; it is not a
|
||||
distributed transaction or a cross-panel lock. Updating desired topology and
|
||||
applying it to multiple nodes still permits partial failure. Errors must remain
|
||||
visible, and a later explicit resync may be needed. No claim is made that old
|
||||
connections switch route atomically during an edit.
|
||||
|
||||
Before a production rollout, back up the database, deploy matching agent builds,
|
||||
test a small explicitly selected tunnel, then deploy the panel using a planned
|
||||
maintenance window. The local implementation task does not perform these steps.
|
||||
Existing legacy tunnels require a separately designed, explicitly initiated
|
||||
credential migration. Required auth is a policy, not proof of every live
|
||||
receiver's current enforcement.
|
||||
|
||||
Validation completed so far:
|
||||
- Public parsed-service missing/empty/no-chain rejection and restored A–B–C–D
|
||||
forwarding: race repeated passes.
|
||||
- Native TCP/TLS/KCP carrying TCP/UDP: required missing route rejects and
|
||||
authenticated direct exit works; race repeated three times.
|
||||
- Panel forward-create commands emit strict policy only for type-2 tunnel
|
||||
forwards, preserving ordinary direct forwards; race repeated three times.
|
||||
- go-gost/x: complete `go test ./...` and `go vet ./...` passed.
|
||||
- go-gost: complete `go test ./...` passed, including agent lifecycle tests.
|
||||
|
||||
PostgreSQL behavior and deployment compatibility need separate verification;
|
||||
no production endpoint was used. The test TLS certificate is verified but is
|
||||
not evidence that generated production TLS configurations validate peers.
|
||||
|
||||
### Final identity and recovery details
|
||||
|
||||
The stable credential key is tunnel + receiving node + role, NOT hop index:
|
||||
inserting a hop ahead of an existing receiver must preserve its password. A
|
||||
direct tunnel converted to a relay tunnel is a new protected relay deployment;
|
||||
converting back does not erase required mode or credentials. Existing relay
|
||||
legacy rows are unchanged and listed as `relayAuthMode: legacy`. Required means
|
||||
configuration policy, not proof of deployment or old-agent capability.
|
||||
|
||||
Both consumer policy and provider authentication version are persisted separately
|
||||
from private credentials. Missing credentials fail closed on update/reconnect;
|
||||
ordinary reapply cannot rotate or remove established authentication. Config
|
||||
exports retain the required policy but omit credentials. Config imports of
|
||||
protected tunnels (or over existing protected rows) are explicitly rejected with
|
||||
an explanation to use full-database recovery. Secure configuration-only credential
|
||||
export/import and rotation are not implemented. Full database backups contain
|
||||
private credentials and must be protected as secrets.
|
||||
|
||||
The federation provider advertises auth version 1 on connect/reserve and
|
||||
acknowledges it on apply. Consumers preflight before reservation and before
|
||||
tearing down update resources. This is panel capability negotiation; unknown
|
||||
old local agents are not remotely attested by this patch. The new strict-route
|
||||
metadata requires the patched agent, and an old agent can ignore it. Deployment
|
||||
must upgrade the participating agents before claiming the required-route fix.
|
||||
|
||||
Log audit: normal agent service/node parsing logs metadata and relay usernames,
|
||||
not the `auth` password fields. The backend's GORM logger is Silent. Authenticated
|
||||
federation apply errors suppress peer-supplied response text to avoid reflected
|
||||
credential disclosure. Private provider incoming credentials and downstream
|
||||
Target JSON are excluded from whole-model JSON serialization. Authentication
|
||||
still resides in desired-state database/agent configuration; this is not an
|
||||
at-rest encryption feature.
|
||||
|
||||
Downgrading the panel to an older build is not a safe rollback for protected
|
||||
tunnels: older generation code can omit authentication and overwrite receiver
|
||||
configuration. Preserve the full security-aware database and deploy compatible
|
||||
panel/agent versions together. Any rollback or old-tunnel migration needs an
|
||||
explicit maintenance plan and validation of the live receiver, not only API
|
||||
policy fields.
|
||||
|
||||
## Final lifecycle guarantees and limits
|
||||
|
||||
Creation reserves prepared topology and local ports before waiting for remote
|
||||
acknowledgments. Create/update report saved-but-pending outcomes explicitly,
|
||||
including a frontend warning. Obsolete runtime ownership persists until cleanup
|
||||
is acknowledged, even after desired topology or tunnel deletion. Unconfirmed
|
||||
receiver ports remain reserved. Re-added resources and middle-to-exit role
|
||||
changes cannot lose a required chain before receiver replacement acknowledgment.
|
||||
|
||||
This still does not provide distributed atomic topology cutover. During pending
|
||||
application, old paths or established connections can remain. Upgrade agents,
|
||||
resynchronize their configurations, and verify required-route policy is active
|
||||
before claiming the deployed protection.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent upgrade checksum fail-closed
|
||||
|
||||
## Scope
|
||||
|
||||
Fix the panel-issued UpgradeAgent flow only. Do not change Alpine support, installer scripts, release artifacts, production state, or publish changes.
|
||||
|
||||
## Observed defect
|
||||
|
||||
`handleUpgradeAgent` treats checksum fetch errors, non-200 responses and body read errors as success and emits `校验通过`. An absent checksum URL bypasses verification entirely. The panel already supplies a checksum URL for individual and batch upgrades, and the release workflow generates GNU sha256sum files.
|
||||
|
||||
## Intended behavior
|
||||
|
||||
- Require a nonblank checksum URL before downloading an upgrade.
|
||||
- Abort before chmod, backup, or restart on fetch/status/read/format/hash failure; remove the staged binary.
|
||||
- Only emit verification success after a complete, valid SHA256 record matches the downloaded bytes.
|
||||
- Accept one bare 64-hex SHA256 or a single GNU sha256sum record (text or binary marker), case-insensitive hex and surrounding whitespace/CRLF. Reject empty, truncated, nonhex, multiline and oversized payloads. Do not treat an ambiguous multi-file manifest as a checksum.
|
||||
- Preserve architecture substitution and current installation flow. This is integrity checking, not a signature or an independent guarantee against a compromised download source.
|
||||
|
||||
## Verification plan
|
||||
|
||||
User-confirmed seam: actual UpgradeAgent command handling, loopback HTTP fixtures, isolated binary paths and an intercepted restart action. Assert error/response and progress, untouched old executable/backup, absence of staged bytes and no restart on every invalid checksum condition. Valid records should reach the intercepted restart with exact downloaded bytes.
|
||||
|
||||
Use red-green slices, then race tests, module checks, build and independent scope review. No push, PR, merge, tag or deployment is authorized for this task.
|
||||
|
||||
## Review scope
|
||||
|
||||
User confirmed review against rc8 commit `fadc2b6c122df99140e0c9cbd481a1214d17d175`, covering all changes for this task and this plan. Run independent Standards and Spec reviews after the implementation is ready.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent panel upgrade and rollback on OpenRC
|
||||
|
||||
## Scope
|
||||
|
||||
Repair the agent UpgradeAgent/RollbackAgent paths on Alpine/OpenRC without regressing systemd. Preserve the pending checksum fail-closed repair. Do not change live nodes, install components, publish or deploy.
|
||||
|
||||
## Evidence
|
||||
|
||||
In a disposable cached Alpine 3.22.5 linux/amd64 container, the actual handlers fail launching `systemd-run` after successful download and verification, and also when rolling back an existing backup. Installer scripts already support OpenRC, but their CI does not exercise these command handlers.
|
||||
|
||||
## Safety requirements
|
||||
|
||||
- Prefer OpenRC on Alpine, including when a systemctl shim exists; refuse missing required manager tools.
|
||||
- Keep systemd's independent transient unit behavior to survive stopping the agent cgroup.
|
||||
- Launch an independent OpenRC restart helper that survives termination of the original agent, without installing extra utilities.
|
||||
- Use the selected manager for stop/start; do not run systemd legacy-service cleanup on OpenRC.
|
||||
- Retain validated upgrade staging and backup behavior, and do not consume the rollback backup.
|
||||
- Reject setup/launch errors before claiming restart was launched. Report asynchronous-completion limits honestly.
|
||||
- Preserve existing command response, request ID and progress contracts.
|
||||
|
||||
## Proposed tests and review
|
||||
|
||||
User-confirmed boundaries: real UpgradeAgent/RollbackAgent command routing, isolated files, systemd/OpenRC tools as test doubles, no-manager rejection and launch/stop/replacement failures. Use cached Alpine to execute the helper with rc-service doubles, not a real OpenRC installation. Explicitly distinguish this from service-manager integration testing.
|
||||
|
||||
User-confirmed review baseline is fadc2b6, covering both pending fixes and plans. No commits, push, PR, merge, release or deployment.
|
||||
|
||||
## Publication authorization
|
||||
|
||||
After the local implementation, verification and independent reviews were complete, the user authorized submitting a PR, merging if checks pass, and publishing the next tag. Earlier no-publication limits above describe the implementation phase. Deployment to live nodes remains outside scope.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Panel self-upgrade reliability plan
|
||||
|
||||
## Scope
|
||||
|
||||
Fix the panel's Docker Compose self-upgrade flow. The report is from a third-party deployment; this work uses repository analysis and isolated tests, not access to that deployment. Base: `a784350` (`3.0.1-rc10`). No publishing or deployment is included.
|
||||
|
||||
## Confirmed problems
|
||||
|
||||
- The UI stops at helper launch. It cannot distinguish a running, successful, or failed upgrade.
|
||||
- The helper resolves the Compose project and relative bind mounts from `/opt/flvx-panel`, which need not match the original host deployment.
|
||||
- The HTTP mutex ends before the helper. Repeated requests can change the same configuration and overwrite logs and fixed backups.
|
||||
- Active configuration is replaced before images are pulled; helper failures do not restore it.
|
||||
- Successful `compose up` is not an explicit verification of both target images and frontend availability.
|
||||
|
||||
These are code defects and failure risks, not proof of the reported deployment's particular failure.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. Identify and validate the existing Compose project, host deployment mount, backend service, and configuration paths before a task can mutate deployment files. Fail safely on unsupported or ambiguous deployment layouts.
|
||||
2. Add a durable task with a unique ID, per-task log and immutable backups. Keep the task lock across the HTTP request, helper execution, and backend replacement. Reconcile stopped helpers conservatively; uncertainty must not permit overlapping upgrades.
|
||||
3. Run a dedicated helper command in the current backend image. Validate a staged Compose configuration and pull images before replacing active files. Use atomic file replacement. Restore configuration only when failure is known to precede service replacement. A partial or uncertain service replacement requires explicit recovery rather than an unsafe automatic database/container rollback.
|
||||
4. Record progress and terminal results locally, with a bounded execution deadline. Verify target services before success. Expose only safe progress messages through an admin status endpoint, never `.env` or arbitrary command output.
|
||||
5. Add frontend progress, reconnect handling, stale-response protection, reload recovery, and repeat-click prevention. A launch acknowledgment is not completion.
|
||||
6. Run focused fault-injection tests, the full backend checks, frontend lint/type/build checks, and UI lifecycle exercises. Document real Docker and PostgreSQL tests that cannot run locally.
|
||||
|
||||
## API contract
|
||||
|
||||
- Existing `POST /api/v1/system/upgrade`: preserve existing response fields and add `jobId`.
|
||||
- New `POST /api/v1/system/upgrade/status`: accept optional `jobId`; return `currentVersion` and nullable `job`.
|
||||
- A job includes `id`, `targetVersion`, `stage`, `active`, `requiresRecovery`, `message`, `startedAt`, `updatedAt`, and optional helper/backup references.
|
||||
- Stages: `preparing`, `pulling`, `applying`, `restarting`, `verifying`, `succeeded`, `failed`, `recovery_required`.
|
||||
- Status reads do not call GitHub. Timestamps use Unix milliseconds. A recovery-required task must not silently permit another upgrade.
|
||||
|
||||
## Required regression coverage
|
||||
|
||||
- Original project names and host paths that differ from the in-container path; invalid/missing labels and mounts; IPv4/IPv6 templates.
|
||||
- Validation and pull failure without changing active config; application failure with safe restoration; partial recreation and verification failures with preserved recovery evidence.
|
||||
- Duplicate requests, interrupted starts, stopped helpers, backend restart, task timeout, and preservation of older backups and logs.
|
||||
- Frontend reload, transient disconnect, success, failure, recovery required, repeated clicks, unmount, and stale responses.
|
||||
- No sensitive environment contents in status responses or browser-visible error messages.
|
||||
|
||||
## Rollout boundary
|
||||
|
||||
The launcher and helper are supplied by the version currently running, not by the target Compose download. Existing rc10 installations do not acquire this fix until the corrected backend image is actually installed. The first transition to the fixed release should use the established host-side upgrade process with the original deployment directory and Compose project. Do not recommend repeated retries of the old web updater or a blind rollback after partial service replacement.
|
||||
|
||||
## Verification record
|
||||
|
||||
Implementation completed in `fix/panel-upgrade-jobs`, based on `a784350`.
|
||||
|
||||
- Backend: full `go test ./...`, `go test -race ./internal/panelupgrade`, `go vet ./...`, native build, and CGO-disabled Linux amd64/arm64 builds passed with Go 1.26.10.
|
||||
- Added an admin/non-admin/anonymous status contract, idle read with no Docker dependency, malformed job-ID rejection, and focused upgrade tests in CI.
|
||||
- Fake-Docker tests cover configuration/pull/recreate/verification failures, partial configuration restoration, immutable task backups, uncertain launch, cross-manager blocking, original project/path, full service-setting preservation, unexpected database mounts, and safe public messages.
|
||||
- Frontend: changed-file ESLint (zero warnings), TypeScript and production build passed with Node 24.22.1. Nine temporary lifecycle replays passed, including two-admin refresh, repeated submissions, unknown transport outcomes, stale replies, unmount, and bounded polling.
|
||||
- Mocked rendered checks passed at 1280×900 and 390×844: confirmation, pulling, an injected 503 disconnect, reconnect, verified completion, reload discovery, and recovery blocking. The pre-existing Switch `classNames` React warning remains; the deliberate 503 was expected. No new framework error overlay or mobile horizontal overflow was found.
|
||||
- Independent review identified and closed full-service configuration preservation, shadow database mounts, and terminal refresh discovery issues. `git diff --check` passed.
|
||||
|
||||
Limitations: no real Docker daemon upgrade was run; local daemon access was unavailable to this task. Fake Docker commands do not prove real Compose/Engine behavior. The PostgreSQL-specific contract was explicitly skipped because `FLVX_POSTGRES_TEST_DSN` was not set. No third-party deployment, SSH, publishing, release, or production restart was performed during implementation.
|
||||
|
||||
The conservative operator recovery procedure is in `go-backend/internal/panelupgrade/README.md`. Full service-setting changes, unsupported deployment layouts, or a possible partial recreation require administrator handling instead of silently changing live settings or automatically rolling back a database. Per-task logs contain safe phase summaries, not raw secret-bearing Docker output.
|
||||
@@ -0,0 +1,98 @@
|
||||
# FLVX #574 / #575: current owner names and custom order
|
||||
|
||||
Status: implemented and verified locally on 2026-11-10. The user confirmed the three test boundaries before implementation and later authorized the PostgreSQL import repair discovered during validation.
|
||||
Base: `3209ff8a59bf02287e6934d0b80b941006db239e` (main, verified 2026-11-10).
|
||||
Sources: https://github.com/Sagit-chu/flvx/issues/574 and https://github.com/Sagit-chu/flvx/issues/575.
|
||||
|
||||
## Problem statement
|
||||
|
||||
Existing forwarding rules retain the username copied when they were created. Renaming an account leaves the rules page's owner filter and related displays showing the old name, including after restart. Users and speed limits have only ID-descending order, so an administrator cannot arrange them intentionally.
|
||||
|
||||
## Solution
|
||||
|
||||
Resolve a forwarding rule's current owner name by immutable user ID without recreating rules. Add administrator-managed global drag order for users and speed limits, persisted on the server across clients. Newly created entries appear first. Searching disables dragging so a partial result cannot overwrite the complete order.
|
||||
|
||||
## User stories
|
||||
|
||||
1. As an administrator, I see the current owner name on existing rules after renaming a user.
|
||||
2. As a user changing my account name and password, my existing rules display the new name.
|
||||
3. As an administrator, I see consistent names in rule groups, owner filters and tunnel deletion previews.
|
||||
4. As an administrator restoring an old backup, I do not have to delete or recreate rules to correct owner names.
|
||||
5. As a user, a different account reusing my former name cannot acquire my rules or permissions.
|
||||
6. As an administrator, I can drag ordinary users into a global order in both card and table views.
|
||||
7. As an administrator, I can drag speed limits into a global order in both card and table views.
|
||||
8. As an administrator switching browsers or signing in again, I see the server's saved order.
|
||||
9. As an administrator creating an item, I find it at the top; editing it keeps its position.
|
||||
10. As an administrator searching a list, I see why dragging is unavailable and hidden items retain their positions.
|
||||
11. As an administrator with more than ten users, I do not lose entries from the reorder set after creating a user.
|
||||
12. As an administrator whose save fails or conflicts, I see the failure and recover the authoritative order rather than a false success.
|
||||
13. As an operator upgrading an existing SQLite or PostgreSQL database, I retain the prior default order until I rearrange it.
|
||||
14. As an operator restoring a new backup, I retain its order; older backups remain importable.
|
||||
15. As a user, an administrative display-order change does not change my password, ownership, permissions, traffic totals, limits, or rule bindings.
|
||||
16. As an operator restoring PostgreSQL data, I can immediately create new records without restarting, with existing sequence allocations preserved and failed imports rolled back.
|
||||
|
||||
## Implementation decisions
|
||||
|
||||
- Rule ownership remains numeric user ID. Display uses the current associated username, with the stored name (and existing unknown-owner UI) as a compatibility fallback when no associated user exists.
|
||||
- Keep the legacy stored name field and backup format compatible. Display correction must work for stale data without a destructive rewrite or rule recreation.
|
||||
- Cover all relevant presentation projections, not just the owner selector. Do not change data-plane service identity or authentication behavior.
|
||||
- Add integer ordering to ordinary users and speed limits. Preserve old ID-descending order for tied/default ranks, set new items before the current first rank, and preserve rank on ordinary edits.
|
||||
- Add administrator-only reorder endpoints for both resources. A request supplies the complete ordered ID set and its previously observed order so stale sets/orders can be rejected safely; return an actionable conflict/error rather than partial success.
|
||||
- Validate input as a complete permutation of eligible current resources. Reject malformed IDs, duplicates, missing/extra IDs and administrator user IDs. All writes are transactional.
|
||||
- Serialize client saves or disable dragging while a save is outstanding. On failure/conflict, reload the server's order. Cancel or ignore stale list requests when search/navigation changes.
|
||||
- Reuse existing drag sensors and affordances. Support mouse, touch and keyboard. Keep delete/edit controls usable and prevent accidental drag activation.
|
||||
- Search disables drag. User creation reloads the authoritative complete list, avoiding the existing ten-item truncation.
|
||||
- Preserve existing API envelopes and raw JWT convention. Explicitly protect the new user reorder path in authorization middleware.
|
||||
- User and speed-limit backup DTOs, export mappings and import updates carry order. Missing old-backup fields must not accidentally zero an existing custom order. New records imported without rank follow a documented deterministic rule.
|
||||
- No new frontend test framework; use the repository's build/lint and isolated browser checks.
|
||||
|
||||
## Testing decisions (confirmed boundaries)
|
||||
|
||||
1. **Public HTTP API**: current-name responses, all rename entry points, ownership isolation, admin-only reorder, complete-set validation, failures/conflicts, and list/create/edit/delete behavior. This catches application behavior but not browser interactions or every database upgrade path.
|
||||
2. **Repository lifecycle and backup public interfaces**: fresh/legacy databases, restart persistence, old/new backup round trips and partial import behavior for SQLite and PostgreSQL. This catches persisted compatibility but not UI behavior. Observe through public reads/exports rather than implementation-coupled assertions where possible; legacy schema fixtures may require direct fixture setup.
|
||||
3. **Rendered browser UI**: table/card drag, touch/keyboard affordances, search-disabled state, recovery from failed saves, repeat actions, and navigation. This catches actual UI wiring, not production connectivity or real forwarding.
|
||||
|
||||
Use Matt Pocock's vertical red-to-green cycles: one failing behavior test, minimum implementation, then the next behavior. Prefer existing contract fixtures and public interfaces. Review standards and requirements independently after implementation. These boundaries were confirmed by the user before implementation tests were added.
|
||||
|
||||
## Work graph
|
||||
|
||||
- **T1 Current owner names**: implement #574 with API regression coverage and historical/backup cases. Independent.
|
||||
- **T2 Persistent order backend**: implement ordering models, migrations, endpoints, permissions, transaction/conflict handling and backup compatibility. Runs in an isolated worktree; integrate after T1 and resolve any shared repository-layer edits before reporting complete.
|
||||
- **T3 Drag order frontend**: implement both list views and failure/search behavior against the agreed API contract. Can proceed after T2 contract is pinned; no backend edits.
|
||||
- **T4 Verify and review**: integrate all slices, full backend checks, frontend checks, isolated SQLite/PG/browser verification, independent standards/spec reviews and fixes. Depends on T1–T3. Complete.
|
||||
- **T5 PostgreSQL import sequences**: user-authorized follow-on repair for the pre-existing explicit-ID import bug. Import takes the complete set of write-table locks with NOWAIT before any data writes, retries only pre-write lock contention within a bounded budget, and advances actual ID sequences transactionally without moving allocated IDs backward. Complete.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Production access or changes, external comments/issues/spec publication, personal per-admin preferences, drag while searching, server pagination/cross-page drag, field-based sorting, changing actual speed-limit enforcement, agent changes, and unrelated refactors. Push/PR/merge/release were excluded from implementation and authorized separately afterward as recorded below.
|
||||
|
||||
## Further notes
|
||||
|
||||
Work is isolated on `fix/user-names-custom-order`; original checkout remains untouched. Local commits were permitted by the implementation workflow; publication required the separate authorization recorded below. Existing assessment reproductions are available in a temporary isolated checkout for reference, not as final tests. PostgreSQL testing used an isolated, disposable local PostgreSQL 16.15 container with per-test schemas. No production database or user data was used.
|
||||
|
||||
|
||||
## Verification results
|
||||
|
||||
Final implementation merge: `2ad30e4b2f587b0118cb53049194c915fb32565e`.
|
||||
|
||||
- Full backend suite passed with PostgreSQL enabled and the cache disabled: `FLVX_POSTGRES_TEST_DSN=... go test -count=1 ./...`.
|
||||
- Race-enabled owner-name, display-order and PostgreSQL backup-import contracts passed. The new PostgreSQL import contracts also passed three repeated race-enabled runs in their implementation worktree.
|
||||
- `go vet ./...`, native backend build and Linux amd64/arm64 cross-builds passed using Go 1.26.10.
|
||||
- Frontend `tsc --noEmit`, full `eslint .`, and production build passed using Node 24.22.1. The build still reports the existing outdated Browserslist data notice.
|
||||
- SQLite and PostgreSQL contracts cover three rename paths, old-name reuse/ownership isolation, historical fallback, previews, restart and old/new backups. Order contracts cover administrator checks, complete permutations, stale order/membership, concurrent creates/reorders, new-first and edit-preserved rank.
|
||||
- PostgreSQL import tests reproduce the prior 23505 failure and cover immediate public API creates without reopening the repository, all 11 explicit-ID tables, allocated/cached IDs, non-unit positive increments, unowned legacy defaults, search-path resolution, concurrent imports/creates, cascade-delete contention, bounded busy recovery, intermediate-sequence failure and commit-time rollback. Removing NOWAIT makes the contention regression fail.
|
||||
- Chrome tests against synthetic localhost data passed: user and limit card keyboard reorder; user and limit table pointer reorder; 390px viewport touch reorder; refresh persistence; 13-user creation without truncation; new-first and edit-preserved order; search-disabled handles; drag cancellation; a second client's conflict followed by authoritative recovery; transport failure and successful load-error retry.
|
||||
- The rendered rules page and owner filter show the current username for a historical rule whose stored snapshot still has the former name.
|
||||
- Restarting the final backend binary preserved the 13-user order, four-limit order and current-name projection.
|
||||
- Independent standards and requirements reviews passed after fixes. The recovered-load error is now cleared without hiding save warnings. The PostgreSQL lock-order and legacy-default findings were resolved and re-reviewed.
|
||||
|
||||
## Validation limits
|
||||
|
||||
- Linux amd64/arm64 binaries were cross-built, not run on those target machines. No live forwarding agents, production deployment, real traffic, Safari or physical mobile device were exercised. Mobile touch was tested through Chrome's emulated viewport and touch input.
|
||||
- PostgreSQL identity-column fallback was reviewed but does not have a dedicated fixture. Normal repository writes and imports are covered; external SQL that reserves sequence values inside an open transaction and later acquires tables in reverse order, or concurrent manual sequence-definition changes, are outside the tested contract. Negative/non-positive ID sequences are rejected with a rolled-back import.
|
||||
- Browser logs included existing React Router/dialog accessibility warnings and a password-manager-extension error. A development HMR hook-order error during the hook edit cleared on full reload; final page flows did not reproduce it.
|
||||
- At completion of local verification, all changes remained on a local branch. No push, pull request, issue comment, release or production change had been performed.
|
||||
|
||||
## Publication stage
|
||||
|
||||
After reviewing the PostgreSQL identity-column testing boundary, the user separately authorized pushing the branch, creating and merging a pull request, and publishing a new tag/release on 2026-11-10. Publication must respect required checks and branch protection. The tag workflow publishes versioned/latest images and release attachments; it does not deploy production services. Production access or changes remain out of scope.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Two-axis review
|
||||
|
||||
Fixed base: 3549fe5ad8a6e9b863b3e4f7b9bf738facff87ee plus all task working-tree
|
||||
changes, including new files. The owner approved this scope and subsequently
|
||||
authorized PR submission, merge and the next release tag. Initial review was
|
||||
performed as independent Standards and Spec passes.
|
||||
|
||||
## Standards
|
||||
|
||||
No new hard violations found against applicable AGENTS.md rules. Repository
|
||||
access, explicit table names, migrations, response envelopes and agent extension
|
||||
boundaries comply.
|
||||
|
||||
Two non-blocking judgement calls:
|
||||
1. Possible duplicated code: required_relay_transport_test.go repeats the native
|
||||
TCP/TLS/KCP fixture in relay_auth_matrix_investigation_test.go. The historical
|
||||
investigation fixture is deliberately retained separately in this patch, to
|
||||
preserve the original evidence; helper extraction is deferred.
|
||||
2. Possible speculative generality: buildFederationServiceConfig accepted a
|
||||
variadic credential argument but only used its first item. Fixed by accepting
|
||||
one nullable credential pointer, updating legacy callers explicitly. Focused
|
||||
generator and federation authentication tests pass.
|
||||
|
||||
Summary: zero hard violations; two low-priority smells, one corrected and one
|
||||
explicitly deferred.
|
||||
|
||||
## Spec
|
||||
|
||||
Three partial requirements found:
|
||||
1. Create did not take the lifecycle lock, committing a visible tunnel before
|
||||
runtime application. Concurrent delete/edit/reconnect could interleave with
|
||||
stale creation. This violated the create/update/redeploy/delete scope.
|
||||
2. Federated create held an open database transaction across provider/node
|
||||
acknowledgments, contrary to the no-network-wait-in-transaction requirement.
|
||||
3. Offline/timeouts and forward synchronization failures could be swallowed as
|
||||
success, contrary to the requirement to expose partial application.
|
||||
|
||||
These were inherited behaviors within the explicitly promised repair scope,
|
||||
not regressions introduced by the patch. No additional confirmed auth or
|
||||
strict-route defect, and no unjustified scope creep found. Explicit legacy,
|
||||
transport, old-agent and distributed-rollback limits remain documented.
|
||||
|
||||
Summary: three lifecycle completion gaps, all assigned for correction before
|
||||
publication. Follow-up verification will be appended after the fixes.
|
||||
|
||||
## Review-driven recovery additions
|
||||
|
||||
Follow-up inspection found two additional lifecycle issues during the transaction
|
||||
and pending-state changes: local port reservations must be published before the
|
||||
remote acknowledgment wait, and pending updates must still clean obsolete
|
||||
runtime and reconcile forwards. Both have focused regressions.
|
||||
|
||||
A cleanup failure must not discard the removed runtime's ownership. Persistent
|
||||
cleanup records are being added in the existing database so retries can identify
|
||||
old endpoints even after desired topology changes. Records are removed only after
|
||||
acknowledgment and are canceled if current desired topology needs the resource
|
||||
again. This is targeted cleanup recovery, not distributed transaction rollback.
|
||||
|
||||
## Final follow-up
|
||||
|
||||
Final code commit: 89a1a7b, integrated on rc7 (46bfb86).
|
||||
All reported correctness/completion gaps were corrected. Durable cleanup keeps
|
||||
port reservations, survives topology/tunnel deletion, and is retried through
|
||||
existing maintenance and reconnect paths. Re-adding a receiver cannot cause a
|
||||
delayed deletion, and changing its port does not free the old port before ACK.
|
||||
The additional middle-to-exit role transition preserves the old chain until
|
||||
receiver replacement is acknowledged, for both same and changed ports.
|
||||
|
||||
Standards: no hard violations remain; one fixture-duplication smell is deferred.
|
||||
Spec: no remaining release blocker found in the reviewed scope.
|
||||
The final full backend race run passed (excluding the documented external
|
||||
Cloudflare test). Frontend build/typecheck, GOST x race/vet, agent tests/build/vet
|
||||
and backend build/vet passed. Publication remains conditional on remote CI.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Final local validation
|
||||
|
||||
Local darwin/arm64 checkout, initial baseline 3549fe5; final integration includes upstream rc7 (46bfb86); 2026-11-08.
|
||||
Go toolchain inside all modules: 1.26.10. No dependencies installed.
|
||||
No production configuration was changed. Publication is authorized after review and CI.
|
||||
|
||||
## Final commands
|
||||
- go-backend: go test -race ./... -skip '^TestCaptchaVerifyLoginContract$'
|
||||
PASS on the final code at 89a1a7b. Handler 140.273s; contract 110.907s. This includes upstream user-disable regressions.
|
||||
- go-backend: go vet ./... and go build ./cmd/paneld
|
||||
PASS; executable output was directed to /tmp, not the repository.
|
||||
- go-gost/x: go test -race ./... and go vet ./...
|
||||
PASS, including authenticated A–B–C–D transition/rejection/recovery.
|
||||
- go-gost: go test ./..., go vet ./..., go build .
|
||||
PASS; executable output was directed to /tmp.
|
||||
- git diff --check: PASS.
|
||||
|
||||
## Test-environment adjustments
|
||||
Two pre-existing federation cache-fallback tests formerly relied on external
|
||||
unreachable.invalid and peer.example requests failing. They now use loopback
|
||||
httptest providers returning HTTP 503, preserving the fallback assertions.
|
||||
The existing TestCaptchaVerifyLoginContract makes a real Cloudflare verification
|
||||
request; it was excluded, not reported as passing. No external access restriction
|
||||
was bypassed.
|
||||
|
||||
The broad race run exposed a pre-existing test-fixture GORM Update callback
|
||||
registration racing WebSocket teardown's node-status update. Test setup now
|
||||
registers those callbacks before WebSocket startup, with atomic flags for failure
|
||||
injection. Two matching fixture tests passed with race detection repeated three
|
||||
times, followed by the passing full backend race run above. Production behavior
|
||||
was not changed for this fixture repair.
|
||||
|
||||
FLVX_POSTGRES_TEST_DSN is absent; PostgreSQL execution was not verified.
|
||||
Three original investigation test files remain. The old bug-expecting gated test
|
||||
is skipped and its complete original source preserved in the evidence directory.
|
||||
|
||||
## Review status and operational limits
|
||||
The approved two-axis review is complete. Standards found no hard violations;
|
||||
one fixture-duplication judgement call remains explicitly deferred. Spec
|
||||
follow-up at 89a1a7b found no remaining blocker in the reviewed scope after the
|
||||
create, pending, reservation, durable-cleanup and role-transition fixes.
|
||||
Review details are in 070-code-review.md.
|
||||
|
||||
This local patch does not secure existing production listeners by itself.
|
||||
Existing legacy tunnels remain unauthenticated until a separately initiated
|
||||
migration is implemented and performed. Required is a policy, not runtime
|
||||
attestation. Agents must support chain.strict, protected tunnels require full
|
||||
database recovery rather than credential-free configuration restore, and older
|
||||
panel rollback can reintroduce anonymous receivers. The patch does not provide
|
||||
cross-node transactional rollback or fix transport confidentiality.
|
||||
|
||||
Frontend TypeScript and production/PWA build passed after rc7 integration using
|
||||
the existing Node 24.22.1 runtime. No dependency install or engine bypass.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Relay authentication validation
|
||||
|
||||
Local darwin/arm64 checkout, base 3549fe5. No deploy/push/release or production
|
||||
credentials. Three pre-existing investigation tests were preserved.
|
||||
|
||||
## Confirmed seam
|
||||
|
||||
Panel HTTP plus node/federation commands, using temporary SQLite repositories,
|
||||
loopback HTTP/WebSocket peers, and captured command data. Native data-plane auth
|
||||
matrix is recorded separately in the required-route validation evidence.
|
||||
|
||||
## Vertical regression evidence
|
||||
|
||||
- New local tunnel: observed failure because generated relay connector/receiver
|
||||
authentication was absent; implemented purpose-separated endpoint credentials.
|
||||
- Required tunnel configuration export: observed missing authentication-policy
|
||||
marker; exported marker added, credentials remain excluded. Configuration
|
||||
restore of protected tunnels explicitly rejects missing private state.
|
||||
- Federation reserve/apply: observed missing capability advertisement; added
|
||||
versioned contract, receiver authentication, persistent state, and no-downgrade.
|
||||
- Full dual-panel middle/exit tunnel: observed zero authenticated receivers;
|
||||
threaded receiving and next-target credentials through consumer requests.
|
||||
- Federation strict middle policy: observed absent chain.strict; local and
|
||||
federation middle generators now set it while exits retain direct dialing.
|
||||
- Insert a new middle before an existing middle: observed rejected update with
|
||||
hop-index credentials; receiver identity changed to tunnel/node/role, preserving
|
||||
the existing credential and assigning the new receiver a distinct credential.
|
||||
- Direct-to-relay conversion: observed absent relay authentication; conversion now
|
||||
requires credentials while existing legacy relay tunnels remain explicit.
|
||||
- Provider private JSON loss: observed successful reapply recreating protected
|
||||
state; separately persisted auth version now rejects missing private material.
|
||||
|
||||
Additional command-seam checks cover local update stability, reconnect followed
|
||||
by explicit public batch-redeploy (avoids the 30-second production debounce),
|
||||
missing consumer private state rejection, old peer refusal before reservation,
|
||||
legacy list visibility, matched per-hop credentials and secret-free list/export.
|
||||
|
||||
## Results
|
||||
|
||||
Eight authentication contract tests with race detection, repeated three times:
|
||||
`go test -race ./tests/contract -run '^(TestNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication|TestFederatedRelayRequiresVersionedAuthAndRejectsDowngrade|TestNewFederatedTunnelAuthenticatesEveryRelayHop|TestRelayAuthenticationSurvivesInsertingBeforeExistingMiddle|TestConvertingDirectTunnelRequiresRelayAuthentication|TestBackupImportCannotSilentlyDowngradeRequiredRelay|TestNewRelayTunnelRefusesLegacyFederationBeforeReservation|TestLegacyRelayStateRemainsExplicitInList)$' -count=3`
|
||||
|
||||
Earlier pass: 17.878 seconds. Final run after provider version guard and log
|
||||
redaction: PASS, 19.584 seconds. Focused non-TLS generation
|
||||
regression passes after narrowing its old overbroad no-metadata assertion.
|
||||
`git diff --check` passes.
|
||||
|
||||
## Explicit limitations
|
||||
|
||||
- Authentication-only protection against anonymous relay clients, not credential
|
||||
confidentiality over raw TCP/unverified TLS/public-default KCP or insecure
|
||||
control-plane paths.
|
||||
- Required mode is desired policy, not attestation of every participating agent.
|
||||
Old agent strict-route support requires upgrading the agent.
|
||||
- Existing legacy relay tunnels remain unprotected. No automatic migration,
|
||||
credential rotation, or configuration-only credential recovery is implemented.
|
||||
- SQLite/loopback verification only in this task; production topology, live
|
||||
upgrades, and PostgreSQL runtime checks are not exercised here.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Required-route repair validation
|
||||
|
||||
Local darwin/arm64 checkout, baseline 3549fe5; 2026-11-08 UTC.
|
||||
|
||||
## Red / green
|
||||
- New parsed-service regression failed before implementation with:
|
||||
"required chain is missing, but relay accepted a direct connection".
|
||||
- Added opt-in handler metadata chain.strict and a router required-chain guard.
|
||||
The same test passed with race detection repeated three times.
|
||||
- New panel command contract failed before generation change:
|
||||
"tunnel forward must require chain, got map[chain:chains_1 type:tcp]".
|
||||
- Emitting strict metadata for type-2 forwards fixed it; type-1 direct forwards
|
||||
remain unrestricted. Race detection repeated three times passed.
|
||||
|
||||
## Native data evidence
|
||||
- Real loopback A–B–D, then A–B–C–D, refuses traffic while B's chain is missing;
|
||||
C/D counters remain unchanged on the rejected request; restoring B's chain
|
||||
restores the complete path.
|
||||
- Parsed services reject missing chain names, empty candidate selection and an
|
||||
absent chain configuration with strict policy; TCP and UDP requests covered.
|
||||
- Authenticated native TCP/TLS/KCP relay transports carrying TCP/UDP targets
|
||||
reject a required missing route and continue to support legitimate direct exit
|
||||
services. TLS certificates are verified in the fixture.
|
||||
- All TestRequiredRelay tests: race detection, count=3 passed, 1.836s.
|
||||
- All go-gost/x tests: ordinary and race passed; go vet passed.
|
||||
- Agent module tests (including lifecycle), build and go vet passed.
|
||||
|
||||
## Limits
|
||||
The guard prevents direct destination dialing when the required selected route
|
||||
is nil or empty. It does not promise zero DNS activity before rejection, nor
|
||||
change generic GOST behavior without the strict option. FLVX's per-node chain
|
||||
has one next-hop selection; arbitrary partially populated multi-hop GOST routes
|
||||
are not claimed to be validated by this change. Existing established connections
|
||||
are not rerouted by an edit. Older agents must be upgraded to enforce the new
|
||||
metadata; ordinary command acknowledgment cannot prove unknown-binary support.
|
||||
|
||||
Additional composed regression: each A/B/C/D receiver has independent synthetic
|
||||
credentials, and every upstream connector uses the next receiver's credential.
|
||||
The authenticated A–B–D → A–B–C–D transition, missing-B-chain rejection and
|
||||
recovery all pass on real TCP loopback sockets. All required-route regressions,
|
||||
including this composition: race repeated three times, PASS, 1.912s.
|
||||
@@ -0,0 +1,477 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-backend/internal/auth"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTunnelInsertMiddleHopRecordedRuntimeContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
||||
|
||||
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
||||
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
||||
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
||||
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryA).Error; err != nil {
|
||||
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
// This mock records control-plane commands only; it does not forward traffic.
|
||||
type command struct {
|
||||
node, kind string
|
||||
data json.RawMessage
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var commands []command
|
||||
for _, n := range []struct {
|
||||
name, secret string
|
||||
id int64
|
||||
}{
|
||||
{"A", "issue281-transition-entry-a-secret", entryA},
|
||||
{"B", "issue281-transition-entry-b-secret", entryB},
|
||||
{"C", "issue281-transition-entry-c-secret", entryC},
|
||||
{"D", "issue281-transition-exit-secret", exitNodeID},
|
||||
} {
|
||||
name := n.name
|
||||
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
||||
return false, ""
|
||||
})
|
||||
defer stop()
|
||||
waitNodeStatus(t, r, n.id, 1)
|
||||
}
|
||||
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
||||
time.Sleep(time.Second)
|
||||
node := func(id int64) map[string]interface{} {
|
||||
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
|
||||
}
|
||||
update := func(hops [][]map[string]interface{}) {
|
||||
mu.Lock()
|
||||
commands = nil
|
||||
mu.Unlock()
|
||||
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
||||
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
||||
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
check := func(edges map[string]int64) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
||||
got := map[string]bool{}
|
||||
services := map[string]bool{}
|
||||
for _, c := range commands {
|
||||
if c.kind == "DeleteChains" {
|
||||
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
||||
}
|
||||
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||
raw = nested
|
||||
}
|
||||
target, ok := edges[c.node]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected chain owner %s", c.node)
|
||||
}
|
||||
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
|
||||
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
||||
hops := raw["hops"].([]interface{})
|
||||
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||
addr := nodes[0].(map[string]interface{})["addr"]
|
||||
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
||||
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
||||
}
|
||||
got[c.node] = true
|
||||
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
||||
}
|
||||
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
||||
var ss []map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &ss); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range ss {
|
||||
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
||||
continue
|
||||
}
|
||||
h := s["handler"].(map[string]interface{})
|
||||
if c.node == "B" || c.node == "C" {
|
||||
if h["chain"] != expectedChain {
|
||||
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
||||
}
|
||||
}
|
||||
if c.node == "D" && h["chain"] != nil {
|
||||
t.Fatalf("exit has unexpected chain")
|
||||
}
|
||||
services[c.node] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for owner := range edges {
|
||||
if !got[owner] {
|
||||
t.Errorf("no chain update for %s", owner)
|
||||
}
|
||||
if owner != "A" && !services[owner] {
|
||||
t.Errorf("no relay service for %s", owner)
|
||||
}
|
||||
}
|
||||
if !services["D"] {
|
||||
t.Error("no exit relay service")
|
||||
}
|
||||
}
|
||||
update([][]map[string]interface{}{{node(entryB)}})
|
||||
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||
update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}})
|
||||
check(map[string]int64{"A": entryB, "B": entryC, "C": exitNodeID})
|
||||
// A remains the entry and the user's forward remains bound there.
|
||||
after, _ := mustQueryInt64Int(t, r, `SELECT node_id,port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
|
||||
if after != entryA {
|
||||
t.Fatalf("forward entry changed: %d", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelRedeployEditMissingChainWindowInvestigation(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
||||
|
||||
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
||||
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
||||
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
||||
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryA).Error; err != nil {
|
||||
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
// This mock records control-plane commands only; it does not forward traffic.
|
||||
type command struct {
|
||||
node, kind string
|
||||
data json.RawMessage
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var commands []command
|
||||
armed := false
|
||||
chainPresent, servicePresent := map[string]bool{}, map[string]bool{}
|
||||
atDeleteService, releaseDeleteService, editReachedA := make(chan struct{}), make(chan struct{}), make(chan struct{}, 1)
|
||||
var gateOnce, releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(releaseDeleteService) }) }
|
||||
defer release()
|
||||
|
||||
for _, n := range []struct {
|
||||
name, secret string
|
||||
id int64
|
||||
}{
|
||||
{"A", "issue281-transition-entry-a-secret", entryA},
|
||||
{"B", "issue281-transition-entry-b-secret", entryB},
|
||||
{"C", "issue281-transition-entry-c-secret", entryC},
|
||||
{"D", "issue281-transition-exit-secret", exitNodeID},
|
||||
} {
|
||||
name := n.name
|
||||
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
||||
mu.Lock()
|
||||
active := armed
|
||||
shouldGate := active && name == "B" && kind == "DeleteService"
|
||||
if active && name == "A" && kind == "UpdateChains" {
|
||||
select {
|
||||
case editReachedA <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if shouldGate {
|
||||
gateOnce.Do(func() { close(atDeleteService); <-releaseDeleteService })
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if kind == "DeleteChains" {
|
||||
chainPresent[name] = false
|
||||
}
|
||||
if kind == "AddChains" || kind == "UpdateChains" {
|
||||
chainPresent[name] = true
|
||||
}
|
||||
if kind == "DeleteService" {
|
||||
servicePresent[name] = false
|
||||
}
|
||||
if kind == "AddService" || kind == "UpdateService" {
|
||||
servicePresent[name] = true
|
||||
}
|
||||
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
||||
return false, ""
|
||||
})
|
||||
defer stop()
|
||||
waitNodeStatus(t, r, n.id, 1)
|
||||
}
|
||||
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
||||
time.Sleep(time.Second)
|
||||
node := func(id int64) map[string]interface{} {
|
||||
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
|
||||
}
|
||||
update := func(hops [][]map[string]interface{}) {
|
||||
mu.Lock()
|
||||
commands = nil
|
||||
mu.Unlock()
|
||||
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
||||
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
||||
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
check := func(edges map[string]int64) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
||||
got := map[string]bool{}
|
||||
services := map[string]bool{}
|
||||
for _, c := range commands {
|
||||
if c.kind == "DeleteChains" {
|
||||
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
||||
}
|
||||
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||
raw = nested
|
||||
}
|
||||
target, ok := edges[c.node]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected chain owner %s", c.node)
|
||||
}
|
||||
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
|
||||
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
||||
hops := raw["hops"].([]interface{})
|
||||
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||
addr := nodes[0].(map[string]interface{})["addr"]
|
||||
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
||||
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
||||
}
|
||||
got[c.node] = true
|
||||
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
||||
}
|
||||
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
||||
var ss []map[string]interface{}
|
||||
if err := json.Unmarshal(c.data, &ss); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range ss {
|
||||
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
||||
continue
|
||||
}
|
||||
h := s["handler"].(map[string]interface{})
|
||||
if c.node == "B" || c.node == "C" {
|
||||
if h["chain"] != expectedChain {
|
||||
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
||||
}
|
||||
}
|
||||
if c.node == "D" && h["chain"] != nil {
|
||||
t.Fatalf("exit has unexpected chain")
|
||||
}
|
||||
services[c.node] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for owner := range edges {
|
||||
if !got[owner] {
|
||||
t.Errorf("no chain update for %s", owner)
|
||||
}
|
||||
if owner != "A" && !services[owner] {
|
||||
t.Errorf("no relay service for %s", owner)
|
||||
}
|
||||
}
|
||||
if !services["D"] {
|
||||
t.Error("no exit relay service")
|
||||
}
|
||||
}
|
||||
update([][]map[string]interface{}{{node(entryB)}})
|
||||
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||
|
||||
mu.Lock()
|
||||
armed = true
|
||||
mu.Unlock()
|
||||
redeployDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(redeployDone)
|
||||
raw, _ := json.Marshal(map[string]interface{}{"ids": []int64{tunnelID}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
t.Logf("redeploy response: %s", res.Body.String())
|
||||
}()
|
||||
await := func(ch <-chan struct{}, label string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(10 * time.Second):
|
||||
release()
|
||||
t.Fatalf("timeout waiting for %s", label)
|
||||
}
|
||||
}
|
||||
await(atDeleteService, "B DeleteService gate")
|
||||
editDone := make(chan struct{})
|
||||
go func() { defer close(editDone); update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}}) }()
|
||||
await(editReachedA, "edit UpdateChains on A")
|
||||
mu.Lock()
|
||||
missing, live := !chainPresent["B"], servicePresent["B"]
|
||||
mu.Unlock()
|
||||
if !missing || !live {
|
||||
release()
|
||||
t.Fatalf("expected B live service with missing chain during overlap, missing=%v live=%v", missing, live)
|
||||
}
|
||||
t.Log("Deterministic overlap confirmed: edit reached A while redeploy had deleted B chain but had not deleted B relay service")
|
||||
release()
|
||||
await(editDone, "edit completion")
|
||||
await(redeployDone, "redeploy completion")
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
t.Logf("final mock runtime chain presence: A=%v B=%v C=%v; services B=%v C=%v D=%v", chainPresent["A"], chainPresent["B"], chainPresent["C"], servicePresent["B"], servicePresent["C"], servicePresent["D"])
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user