Compare commits

..

6 Commits

Author SHA1 Message Date
sagit 961c06655a fix(backend): enforce port range restrictions in federation mode (#125)
Added dual-layer port range enforcement for federation sharing:

Server-side (Provider):
- federationRuntimeApplyRole: validate runtime.Port against share range
- validateFederationCommandPorts: hardened against malformed JSON bypass
- New helpers: validateRemoteNodePort, remoteNodePortRange

Client-side (Consumer):
- prepareTunnelCreateState: pre-check ports for remote nodes
- tunnelCreate type=1: validate targetPort for remote entry
- forwardCreate/Update/BatchChangeTunnel: port range validation

Prevents consumers from using arbitrary ports outside provider's allowed range.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-15 10:42:39 +00:00
sagit 8dc31383e0 fix(backend): correct federation port forwarding tunnel type handling (#123)
- Fix Type 1 (port forwarding) tunnels to call applyFederationRuntime
  Previously only Type 2 tunnels applied federation runtime, causing
  port forwarding tunnels to not be properly configured in federation mode

- Remove incorrect UDP tunnel type override in federationTunnelCreate
  UDP tunnels were being incorrectly set to Type 2, which conflicted with
  the federation runtime logic that expects Type 1 for port forwarding

These fixes ensure all tunnel types are properly handled in federation mode
with correct runtime configuration applied.
2026-02-15 12:12:54 +08:00
sagit 184ac3c3e5 Merge pull request #122 from Sagit-chu/claude/stupefied-jemison
fix(backend): enforce port range in federation runtime commands
2026-02-15 11:26:44 +08:00
sagitchu 77dbd719ed fix(backend): enforce port range in federation runtime commands
The federationRuntimeCommand handler forwarded AddService/UpdateService
commands from consumers to provider nodes without validating that the
port in the service payload falls within the share's allowed port range.
This allowed consumers to use any port on shared nodes, bypassing the
provider's port_range_start/port_range_end restrictions.

Add port extraction and validation in federationRuntimeCommand for
service commands, rejecting requests with ports outside the allowed
range with a 403 error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:22:50 +08:00
sagit 04ce125416 Merge pull request #121 from Sagit-chu/fix/backup-forward-ports
fix(backend): include forward_port in backup export/import
2026-02-15 10:02:18 +08:00
sagit fd5cfc2a40 fix(backend): include forward_port in backup export/import 2026-02-15 01:44:57 +00:00
5 changed files with 492 additions and 30 deletions
+86 -3
View File
@@ -7,6 +7,7 @@ import (
"net"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -543,6 +544,28 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
response.WriteJSON(w, response.OK(items))
}
func remoteNodePortRange(node *nodeRecord) (int, int) {
if node == nil || node.IsRemote != 1 || node.RemoteConfig == "" {
return 0, 0
}
_, _, _, _, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(node.RemoteConfig)
return portRangeStart, portRangeEnd
}
func validateRemoteNodePort(node *nodeRecord, port int) error {
if node == nil || node.IsRemote != 1 || port <= 0 {
return nil
}
start, end := remoteNodePortRange(node)
if start <= 0 || end <= 0 {
return nil
}
if port < start || port > end {
return fmt.Errorf("远程节点端口 %d 超出允许范围 %d-%d", port, start, end)
}
return nil
}
func parseRemoteShareUsageConfig(raw string) (int64, int64, int64, int64, int, int) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -779,9 +802,6 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
}
tunnelType := 1
if strings.ToLower(req.Protocol) == "udp" {
tunnelType = 2
}
tx, err := h.repo.DB().Begin()
if err != nil {
@@ -982,6 +1002,13 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
return
}
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && runtime.Port > 0 {
if runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd {
response.WriteJSON(w, response.Err(403, fmt.Sprintf("port %d out of allowed range %d-%d", runtime.Port, share.PortRangeStart, share.PortRangeEnd)))
return
}
}
node, err := h.getNodeRecord(share.NodeID)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
@@ -1232,6 +1259,13 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
return
}
if isFederationServiceCommand(cmd) {
if err := validateFederationCommandPorts(share, req.Data); err != nil {
response.WriteJSON(w, response.Err(403, err.Error()))
return
}
}
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
@@ -1249,6 +1283,55 @@ func isFederationRuntimeCommandAllowed(commandType string) bool {
}
}
func isFederationServiceCommand(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice":
return true
default:
return false
}
}
func validateFederationCommandPorts(share *sqlite.PeerShare, data interface{}) error {
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
return nil
}
dataMap, ok := data.(map[string]interface{})
if !ok {
return nil
}
if services, ok := dataMap["services"]; ok {
serviceList, ok := services.([]interface{})
if !ok {
return fmt.Errorf("invalid services format")
}
for _, svc := range serviceList {
svcMap, ok := svc.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid service entry format")
}
addr, ok := svcMap["addr"].(string)
if !ok || addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
}
}
return nil
}
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
if share == nil {
return 0, fmt.Errorf("share not found")
+65 -14
View File
@@ -545,6 +545,11 @@ 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()))
return
}
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
localDomain := ""
if domainCfg != nil {
@@ -569,12 +574,10 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
runtimeState.TunnelID = tunnelID
var federationBindings []sqlite.FederationTunnelBinding
var federationReleaseRefs []federationRuntimeReleaseRef
if typeVal == 2 {
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
applyTunnelPortsToRequest(req, runtimeState)
if err := replaceTunnelChainsTx(tx, tunnelID, req); err != nil {
@@ -693,12 +696,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
var federationBindings []sqlite.FederationTunnelBinding
var federationReleaseRefs []federationRuntimeReleaseRef
if typeVal == 2 {
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
applyTunnelPortsToRequest(req, runtimeState)
@@ -1109,6 +1110,17 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
if port <= 0 {
port = 10000
}
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
for _, nodeID := range entryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
continue
}
if err := validateRemoteNodePort(node, port); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
now := time.Now().UnixMilli()
inx := nextIndex(h.repo.DB(), "forward")
var userName string
@@ -1130,7 +1142,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
for _, nodeID := range entryNodes {
_, _ = tx.Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port)
}
@@ -1220,6 +1231,17 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
port = h.pickTunnelPort(tunnelID)
}
}
fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
for _, nodeID := range fwdEntryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
continue
}
if err := validateRemoteNodePort(node, port); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
now := time.Now().UnixMilli()
_, err = h.repo.DB().Exec(`
UPDATE forward SET name = ?, tunnel_id = ?, remote_addr = ?, strategy = ?, updated_time = ? WHERE id = ?
@@ -1544,6 +1566,22 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
if p <= 0 {
p = h.pickTunnelPort(req.TargetTunnelID)
}
bctEntryNodes, _ := h.tunnelEntryNodeIDs(req.TargetTunnelID)
portRangeOk := true
for _, nid := range bctEntryNodes {
nd, ndErr := h.getNodeRecord(nid)
if ndErr != nil {
continue
}
if validateRemoteNodePort(nd, p) != nil {
portRangeOk = false
break
}
}
if !portRangeOk {
fail++
continue
}
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p); err != nil {
h.rollbackForwardMutation(forward, oldPorts)
fail++
@@ -2239,6 +2277,19 @@ func (h *Handler) prepareTunnelCreateState(tx *store.Tx, req map[string]interfac
state.Nodes[nodeID] = node
}
for _, outNode := range state.OutNodes {
if err := validateRemoteNodePort(state.Nodes[outNode.NodeID], outNode.Port); err != nil {
return nil, err
}
}
for _, hop := range state.ChainHops {
for _, chainNode := range hop {
if err := validateRemoteNodePort(state.Nodes[chainNode.NodeID], chainNode.Port); err != nil {
return nil, err
}
}
}
return state, nil
}
@@ -2340,7 +2391,7 @@ func (h *Handler) federationLocalDomain() string {
func (h *Handler) applyFederationRuntime(state *tunnelCreateState) ([]sqlite.FederationTunnelBinding, []federationRuntimeReleaseRef, error) {
bindings := make([]sqlite.FederationTunnelBinding, 0)
releaseRefs := make([]federationRuntimeReleaseRef, 0)
if h == nil || state == nil || state.Type != 2 {
if h == nil || state == nil {
return bindings, releaseRefs, nil
}
fc := client.NewFederationClient()
+67 -13
View File
@@ -2012,19 +2012,25 @@ type ChainTunnelBackup struct {
}
type ForwardBackup struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
Name string `json:"name"`
TunnelID int64 `json:"tunnelId"`
RemoteAddr string `json:"remoteAddr"`
Strategy string `json:"strategy"`
InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime"`
Status int `json:"status"`
Inx int `json:"inx"`
ID int64 `json:"id"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
Name string `json:"name"`
TunnelID int64 `json:"tunnelId"`
RemoteAddr string `json:"remoteAddr"`
Strategy string `json:"strategy"`
InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime"`
Status int `json:"status"`
Inx int `json:"inx"`
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
}
type ForwardPortBackup struct {
NodeID int64 `json:"nodeId"`
Port int `json:"port"`
}
type UserTunnelBackup struct {
@@ -2426,11 +2432,47 @@ func (r *Repository) exportForwards() ([]ForwardBackup, error) {
if inx.Valid {
f.Inx = int(inx.Int64)
}
forwardPorts, err := r.exportForwardPorts(f.ID)
if err != nil {
return nil, err
}
portsCopy := append([]ForwardPortBackup(nil), forwardPorts...)
f.ForwardPorts = &portsCopy
forwards = append(forwards, f)
}
return forwards, rows.Err()
}
func (r *Repository) exportForwardPorts(forwardID int64) ([]ForwardPortBackup, error) {
rows, err := r.db.Query(`
SELECT node_id, port
FROM forward_port
WHERE forward_id = ?
ORDER BY id ASC
`, forwardID)
if err != nil {
return nil, err
}
defer rows.Close()
ports := make([]ForwardPortBackup, 0)
for rows.Next() {
var fp ForwardPortBackup
if err := rows.Scan(&fp.NodeID, &fp.Port); err != nil {
return nil, err
}
ports = append(ports, fp)
}
if err := rows.Err(); err != nil {
return nil, err
}
return ports, nil
}
func (r *Repository) exportUserTunnels() ([]UserTunnelBackup, error) {
rows, err := r.db.Query(`
SELECT id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status
@@ -2845,6 +2887,18 @@ func (r *Repository) importForwards(db Execer, forwards []ForwardBackup, now int
if err != nil {
return count, err
}
if f.ForwardPorts != nil {
if _, err := db.Exec(`DELETE FROM forward_port WHERE forward_id = ?`, f.ID); err != nil {
return count, err
}
for _, fp := range *f.ForwardPorts {
if _, err := db.Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, f.ID, fp.NodeID, fp.Port); err != nil {
return count, err
}
}
}
count++
}
return count, nil
@@ -688,3 +688,112 @@ func valueAsBool(v interface{}) bool {
return false
}
}
func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) {
providerSecret := "provider-portrange-jwt"
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
providerServer := httptest.NewServer(providerRouter)
defer providerServer.Close()
now := time.Now().UnixMilli()
providerNodeID := insertContractNode(t, providerRepo, "provider-portrange-node", "198.51.100.50", "44000-44010", "provider-portrange-secret", 1)
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "portrange-share",
NodeID: providerNodeID,
Token: "share-portrange-token",
PortRangeStart: 44000,
PortRangeEnd: 44010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
stopNode := startMockNodeSession(t, providerServer.URL, "provider-portrange-secret")
defer stopNode()
sendCommand := func(token string, cmdType string, data interface{}) *httptest.ResponseRecorder {
payload := map[string]interface{}{
"commandType": cmdType,
"data": data,
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal command payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/command", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
providerRouter.ServeHTTP(res, req)
return res
}
// Test: AddService with port OUTSIDE allowed range should be rejected
outOfRangeData := map[string]interface{}{
"services": []map[string]interface{}{
{
"name": "test_service_tcp",
"addr": "[::]:55555",
"handler": map[string]interface{}{
"type": "tcp",
},
"listener": map[string]interface{}{
"type": "tcp",
},
},
},
}
res := sendCommand("share-portrange-token", "AddService", outOfRangeData)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 403 {
t.Fatalf("expected code 403 for out-of-range port, got %d (msg: %s)", out.Code, out.Msg)
}
// Test: UpdateService with port OUTSIDE allowed range should be rejected
res = sendCommand("share-portrange-token", "UpdateService", outOfRangeData)
out = response.R{}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 403 {
t.Fatalf("expected code 403 for out-of-range UpdateService, got %d (msg: %s)", out.Code, out.Msg)
}
// Test: AddService with port INSIDE allowed range should succeed
inRangeData := map[string]interface{}{
"services": []map[string]interface{}{
{
"name": "test_service_ok_tcp",
"addr": "[::]:44005",
"handler": map[string]interface{}{
"type": "tcp",
},
"listener": map[string]interface{}{
"type": "tcp",
},
},
},
}
res = sendCommand("share-portrange-token", "AddService", inRangeData)
out = response.R{}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0 for in-range port, got %d (msg: %s)", out.Code, out.Msg)
}
// Test: Non-service commands should pass through without port validation
res = sendCommand("share-portrange-token", "reload", nil)
out = response.R{}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0 for reload command, got %d (msg: %s)", out.Code, out.Msg)
}
}
@@ -311,6 +311,171 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
}
})
t.Run("backup export and import preserve forward ports", func(t *testing.T) {
now := time.Now().UnixMilli()
tunnelRes, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "backup-forward-tunnel", 1.0, 1, "tls", 0, now, now, 1, "", 88)
if err != nil {
t.Fatalf("seed tunnel for forward backup: %v", err)
}
tunnelID, err := tunnelRes.LastInsertId()
if err != nil {
t.Fatalf("read tunnel id for forward backup: %v", err)
}
forwardRes, err := repo.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", "backup-forward", tunnelID, "127.0.0.1:9000", "fifo", 0, 0, now, now, 1, 88)
if err != nil {
t.Fatalf("seed forward for backup: %v", err)
}
forwardID, err := forwardRes.LastInsertId()
if err != nil {
t.Fatalf("read forward id for backup: %v", err)
}
expected := map[int64]int{
2001: 21001,
2002: 21002,
}
for nodeID, port := range expected {
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port); err != nil {
t.Fatalf("seed forward_port %d:%d: %v", nodeID, port, err)
}
}
exportReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["forwards"]}`))
exportReq.Header.Set("Authorization", adminToken)
exportReq.Header.Set("Content-Type", "application/json")
exportResp := httptest.NewRecorder()
router.ServeHTTP(exportResp, exportReq)
if exportResp.Code != http.StatusOK {
t.Fatalf("expected export status 200, got %d", exportResp.Code)
}
exportBody, err := io.ReadAll(exportResp.Body)
if err != nil {
t.Fatalf("read forwards backup body: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(exportBody, &payload); err != nil {
t.Fatalf("decode forwards backup payload: %v", err)
}
version, _ := payload["version"].(string)
if strings.TrimSpace(version) == "" {
t.Fatalf("expected backup payload version, body=%s", string(exportBody))
}
forwardsRaw, ok := payload["forwards"].([]interface{})
if !ok {
t.Fatalf("expected forwards array in payload, body=%s", string(exportBody))
}
foundForward := false
foundPorts := map[int64]int{}
for _, item := range forwardsRaw {
forwardMap, ok := item.(map[string]interface{})
if !ok {
continue
}
idValue, ok := forwardMap["id"].(float64)
if !ok || int64(idValue) != forwardID {
continue
}
foundForward = true
portsRaw, ok := forwardMap["forwardPorts"].([]interface{})
if !ok {
t.Fatalf("expected forwardPorts for forward %d in payload", forwardID)
}
for _, p := range portsRaw {
portMap, ok := p.(map[string]interface{})
if !ok {
continue
}
nodeID, nodeOK := portMap["nodeId"].(float64)
port, portOK := portMap["port"].(float64)
if nodeOK && portOK {
foundPorts[int64(nodeID)] = int(port)
}
}
break
}
if !foundForward {
t.Fatalf("expected forward %d in exported forwards payload", forwardID)
}
if len(foundPorts) != len(expected) {
t.Fatalf("expected %d exported forward ports, got %d", len(expected), len(foundPorts))
}
for nodeID, port := range expected {
if got, ok := foundPorts[nodeID]; !ok || got != port {
t.Fatalf("expected exported forward port node=%d port=%d, got %v", nodeID, port, foundPorts)
}
}
if _, err := repo.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID); err != nil {
t.Fatalf("clear forward_port before import: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999); err != nil {
t.Fatalf("seed wrong forward_port before import: %v", err)
}
payload["types"] = []string{"forwards"}
importBody, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal forwards import payload: %v", err)
}
importReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(importBody))
importReq.Header.Set("Authorization", adminToken)
importReq.Header.Set("Content-Type", "application/json")
importResp := httptest.NewRecorder()
router.ServeHTTP(importResp, importReq)
var out response.R
if err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {
t.Fatalf("decode forwards import response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected forwards import code 0, got %d (%s)", out.Code, out.Msg)
}
rows, err := repo.DB().Query(`SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
if err != nil {
t.Fatalf("query forward ports after import: %v", err)
}
defer rows.Close()
after := make(map[int64]int)
for rows.Next() {
var nodeID int64
var port int
if err := rows.Scan(&nodeID, &port); err != nil {
t.Fatalf("scan forward_port row: %v", err)
}
after[nodeID] = port
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate forward_port rows: %v", err)
}
if len(after) != len(expected) {
t.Fatalf("expected %d forward ports after import, got %d (%v)", len(expected), len(after), after)
}
for nodeID, port := range expected {
if got, ok := after[nodeID]; !ok || got != port {
t.Fatalf("expected forward_port node=%d port=%d after import, got %v", nodeID, port, after)
}
}
})
t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) {
now := time.Now().UnixMilli()
res, err := repo.DB().Exec(`