fix(postgres): encode relay tunnel chain roles as text (#579)

This commit is contained in:
sagit
2026-11-11 18:01:04 +08:00
committed by GitHub
parent 2e85234612
commit 8339f52280
5 changed files with 122 additions and 6 deletions
+2 -2
View File
@@ -103,10 +103,10 @@ jobs:
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download
- name: Run PostgreSQL contract test - name: Run PostgreSQL contract tests
env: env:
FLVX_POSTGRES_TEST_DSN: 'postgres://flux_test:flux_test_pass@127.0.0.1:5432/flux_test?sslmode=disable' 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: agent:
name: Build Agent name: Build Agent
@@ -2,6 +2,8 @@ package repo
import ( import (
"errors" "errors"
"strconv"
"go-backend/internal/relaysecurity" "go-backend/internal/relaysecurity"
"go-backend/internal/store/model" "go-backend/internal/store/model"
"gorm.io/gorm" "gorm.io/gorm"
@@ -22,7 +24,8 @@ func (r *Repository) RelayEndpointCredentials(tx *gorm.DB, tunnelID, nodeID int6
err := find() err := find()
if errors.Is(err, gorm.ErrRecordNotFound) && create { if errors.Is(err, gorm.ErrRecordNotFound) && create {
var existing int64 var existing int64
if e := db.Model(&model.ChainTunnel{}).Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, role).Count(&existing).Error; e != nil { // 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 return nil, e
} }
if existing > 0 { if existing > 0 {
@@ -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")
}
})
}
}
@@ -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 { func insertContractNode(t *testing.T, r *repo.Repository, name, ip, portRange, secret string, status int) int64 {
t.Helper() t.Helper()
now := time.Now().UnixMilli() 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) 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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) 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 { func insertPeerShare(t *testing.T, r *repo.Repository, share *repo.PeerShare) int64 {
@@ -11,11 +11,24 @@ import (
"time" "time"
"go-backend/internal/auth" "go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/store/repo" "go-backend/internal/store/repo"
) )
func TestNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) { func TestNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) {
router, r := setupContractRouter(t, "relay-auth-contract") 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) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract") token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract")