Compare commits

..

21 Commits

Author SHA1 Message Date
sagit fd5cfc2a40 fix(backend): include forward_port in backup export/import 2026-02-15 01:44:57 +00:00
sagit 4e4193e0b0 Merge pull request #120 from Sagit-chu/opencode/pg-id-repair-ci-2-1-3-rc2
fix(backend): harden postgres id default repair and CI coverage
2026-02-14 19:04:30 +08:00
sagit 3f80278dd4 fix(backend): harden postgres id default repair and CI coverage
Run Postgres id-sequence repair on every startup migration and add contract coverage plus a GitHub Actions Postgres job to catch schema-drift regressions before release.
2026-02-14 11:02:46 +00:00
sagit 6abe3e7713 Merge pull request #117 from Sagit-chu/opencode/happy-island
fix(backend): restore user tunnel visibility on PostgreSQL
2026-02-14 12:33:59 +08:00
sagit 47c05c3d02 Merge remote-tracking branch 'origin/main' into opencode/happy-island 2026-02-14 04:32:50 +00:00
sagit d05c8a2ea4 fix(backend): restore user tunnel visibility on PostgreSQL 2026-02-14 04:31:52 +00:00
sagit e00e41bb64 fix(frontend): remove focus styles from Select trigger and Textarea (#116) 2026-02-14 03:40:14 +00:00
sagit 5271efec1e fix(frontend): aggressive removal of input focus styles (#115) 2026-02-14 03:22:15 +00:00
sagit 2d39cb3005 fix(frontend): force remove input focus border and ring (#114)
* fix(frontend): remove blue outline on input focus

* fix(frontend): force remove input focus border and ring

* fix: resolve conflict markers in globals.css
2026-02-14 03:15:34 +00:00
sagit 3e52c8eace fix(frontend): remove blue outline on input focus (#113) 2026-02-14 02:37:59 +00:00
sagit 7808d57a79 Merge pull request #112 from Sagit-chu/opencode/proud-planet
fix(frontend): remove deep blue focus border
2026-02-14 10:16:33 +08:00
sagit 46bc4ca6e4 fix(frontend): remove deep blue focus border 2026-02-14 02:14:50 +00:00
sagit 28e66ab172 Merge pull request #111 from Sagit-chu/opencode/proud-planet
fix: apply remote entry runtime updates and input focus styling
2026-02-14 09:26:40 +08:00
sagit f19bccec4c Merge remote-tracking branch 'origin/main' into opencode/proud-planet 2026-02-14 01:25:21 +00:00
sagit e37d6cf666 fix(frontend): remove input inner shadow and improve focus visibility 2026-02-14 01:16:08 +00:00
sagit 177c2bc35f Merge pull request #110 from Sagit-chu/opencode/proud-planet
fix(backend): stabilize tls forwarding and udp ttl defaults
2026-02-13 22:42:27 +08:00
sagit 76c0978763 Merge branch 'main' into opencode/proud-planet 2026-02-13 22:40:19 +08:00
sagit fd1168d855 fix(backend): set udp ttl default for tls tunnel protocol 2026-02-13 14:22:13 +00:00
sagit 92c9590c1a fix(backend): apply entry chains for remote federation nodes
Ensure remote entry nodes receive AddChains during tunnel runtime apply while tolerating offline/timeout cases. Add focused contract coverage for online and offline remote entry behavior.
2026-02-13 13:59:17 +00:00
sagit 2afb1d275a Merge pull request #109 from Sagit-chu/opencode/playful-circuit
fix(backup): handle nullable tunnel export fields safely
2026-02-13 18:38:02 +08:00
sagit 880cd4cac5 fix(backup): handle nullable tunnel export fields safely 2026-02-13 10:34:58 +00:00
9 changed files with 797 additions and 31 deletions
+37
View File
@@ -48,6 +48,43 @@ jobs:
- name: Build
run: go build -v ./...
backend-postgres-contract:
name: Go Backend PostgreSQL Contract
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: flux_test
POSTGRES_PASSWORD: flux_test_pass
POSTGRES_DB: flux_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U flux_test -d flux_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: go-backend
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
run: go mod download
- name: Run PostgreSQL contract test
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
agent:
name: Build Agent
runs-on: ubuntu-latest
@@ -200,6 +200,26 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
return result, nil
}
func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {
row := h.repo.DB().QueryRow(`
SELECT protocol
FROM chain_tunnel
WHERE tunnel_id = ? AND chain_type = '3'
ORDER BY id ASC
LIMIT 1
`, tunnelID)
var protocol sql.NullString
if err := row.Scan(&protocol); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return isTLSTunnelProtocol(protocol.String), nil
}
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
row := h.repo.DB().QueryRow(`
SELECT id, name, server_ip, server_ip_v4, server_ip_v6, status, port, tcp_listen_addr, udp_listen_addr, interface_name, is_remote, remote_url, remote_token, remote_config
@@ -346,6 +366,10 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
return err
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
if err != nil {
return err
}
for _, fp := range ports {
if limiterID != nil && speed != nil {
@@ -356,7 +380,7 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
if err != nil {
return err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID)
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
@@ -1095,7 +1119,7 @@ func isNotFoundError(err error) bool {
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64) []map[string]interface{} {
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
@@ -1128,7 +1152,11 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
},
}
if protocol == "udp" {
service["listener"].(map[string]interface{})["metadata"] = map[string]interface{}{"keepAlive": true}
listenerMetadata := map[string]interface{}{"keepAlive": true}
if tunnelTLSProtocol {
listenerMetadata["ttl"] = "10s"
}
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)
@@ -2613,9 +2613,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
for _, inNode := range state.InNodes {
if node := state.Nodes[inNode.NodeID]; node != nil && node.IsRemote == 1 {
continue
}
node := state.Nodes[inNode.NodeID]
targets := state.OutNodes
if len(state.ChainHops) > 0 {
targets = state.ChainHops[0]
@@ -2625,6 +2623,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, err
}
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
}
createdChains = append(createdChains, inNode.NodeID)
+113 -22
View File
@@ -784,7 +784,7 @@ func (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]inter
}
rows, err := r.db.Query(`
SELECT DISTINCT t.id, t.name
SELECT t.id, t.name
FROM user_tunnel ut
JOIN tunnel t ON t.id = ut.tunnel_id
WHERE ut.user_id = ? AND t.status = 1
@@ -1307,6 +1307,8 @@ func bootstrapSchema(db *store.DB, schemaSQL, seedSQL string) error {
const currentSchemaVersion = 2
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
func getSchemaVersion(db *store.DB) int {
_, _ = db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`)
var v int
@@ -1327,6 +1329,11 @@ func migrateSchema(db *store.DB) error {
}
ver := getSchemaVersion(db)
if db.Dialect() == store.DialectPostgres {
if err := ensurePostgresIDDefaultsFn(db); err != nil {
return err
}
}
if ver >= currentSchemaVersion {
return nil
}
@@ -1396,11 +1403,6 @@ func migrateSchema(db *store.DB) error {
return err
}
if db.Dialect() == store.DialectPostgres {
if err := ensurePostgresIDDefaults(db); err != nil {
return err
}
}
setSchemaVersion(db, currentSchemaVersion)
return nil
}
@@ -2010,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 {
@@ -2330,13 +2338,25 @@ func (r *Repository) exportTunnels() ([]TunnelBackup, error) {
var tunnels []TunnelBackup
for rows.Next() {
var t TunnelBackup
var protocol sql.NullString
var updatedTime sql.NullInt64
var inIP sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.TrafficRatio, &t.Type, &t.Protocol, &t.Flow, &t.CreatedTime, &t.UpdatedTime, &t.Status, &inIP, &t.Inx); err != nil {
var inx sql.NullInt64
if err := rows.Scan(&t.ID, &t.Name, &t.TrafficRatio, &t.Type, &protocol, &t.Flow, &t.CreatedTime, &updatedTime, &t.Status, &inIP, &inx); err != nil {
return nil, err
}
if protocol.Valid {
t.Protocol = protocol.String
}
if updatedTime.Valid {
t.UpdatedTime = updatedTime.Int64
}
if inIP.Valid {
t.InIP = inIP.String
}
if inx.Valid {
t.Inx = int(inx.Int64)
}
// Export chain tunnels
chainTunnels, err := r.exportChainTunnels(t.ID)
if err != nil {
@@ -2362,12 +2382,23 @@ func (r *Repository) exportChainTunnels(tunnelID int64) ([]ChainTunnelBackup, er
for rows.Next() {
var ct ChainTunnelBackup
var port sql.NullInt64
if err := rows.Scan(&ct.ID, &ct.TunnelID, &ct.ChainType, &ct.NodeID, &port, &ct.Strategy, &ct.Inx, &ct.Protocol); err != nil {
var strategy, protocol sql.NullString
var inx sql.NullInt64
if err := rows.Scan(&ct.ID, &ct.TunnelID, &ct.ChainType, &ct.NodeID, &port, &strategy, &inx, &protocol); err != nil {
return nil, err
}
if port.Valid {
ct.Port = int(port.Int64)
}
if strategy.Valid {
ct.Strategy = strategy.String
}
if inx.Valid {
ct.Inx = int(inx.Int64)
}
if protocol.Valid {
ct.Protocol = protocol.String
}
chainTunnels = append(chainTunnels, ct)
}
return chainTunnels, rows.Err()
@@ -2386,14 +2417,62 @@ func (r *Repository) exportForwards() ([]ForwardBackup, error) {
var forwards []ForwardBackup
for rows.Next() {
var f ForwardBackup
if err := rows.Scan(&f.ID, &f.UserID, &f.UserName, &f.Name, &f.TunnelID, &f.RemoteAddr, &f.Strategy, &f.InFlow, &f.OutFlow, &f.CreatedTime, &f.UpdatedTime, &f.Status, &f.Inx); err != nil {
var strategy sql.NullString
var updatedTime sql.NullInt64
var inx sql.NullInt64
if err := rows.Scan(&f.ID, &f.UserID, &f.UserName, &f.Name, &f.TunnelID, &f.RemoteAddr, &strategy, &f.InFlow, &f.OutFlow, &f.CreatedTime, &updatedTime, &f.Status, &inx); err != nil {
return nil, err
}
if strategy.Valid {
f.Strategy = strategy.String
}
if updatedTime.Valid {
f.UpdatedTime = updatedTime.Int64
}
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
@@ -2808,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
@@ -0,0 +1,78 @@
package sqlite
import (
"database/sql"
"errors"
"testing"
"go-backend/internal/store"
_ "modernc.org/sqlite"
)
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
raw, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = raw.Close()
})
db := store.Wrap(raw, store.DialectPostgres)
if _, err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`); err != nil {
t.Fatalf("create schema_version: %v", err)
}
if _, err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion); err != nil {
t.Fatalf("seed schema_version: %v", err)
}
called := 0
original := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *store.DB) error {
called++
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = original
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
if called != 1 {
t.Fatalf("expected postgres id repair to run once, got %d", called)
}
}
func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
raw, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = raw.Close()
})
db := store.Wrap(raw, store.DialectPostgres)
if _, err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`); err != nil {
t.Fatalf("create schema_version: %v", err)
}
if _, err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion); err != nil {
t.Fatalf("seed schema_version: %v", err)
}
wantErr := errors.New("repair failed")
original := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *store.DB) error {
return wantErr
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = original
})
err = migrateSchema(db)
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
@@ -316,6 +316,136 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
}
}
func TestFederationDualPanelRemoteEntryRuntimeContract(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-rt", "198.51.100.21", "43020-43030", "provider-entry-rt-secret", 1)
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-rt", "198.51.100.22", "44020-44030", "provider-middle-rt-secret", 1)
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-rt", "198.51.100.23", "45020-45030", "provider-exit-rt-secret", 1)
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "entry-share-rt",
NodeID: providerEntryNodeID,
Token: "share-entry-rt-token",
PortRangeStart: 43020,
PortRangeEnd: 43030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "middle-share-rt",
NodeID: providerMiddleNodeID,
Token: "share-middle-rt-token",
PortRangeStart: 44020,
PortRangeEnd: 44030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "exit-share-rt",
NodeID: providerExitNodeID,
Token: "share-exit-rt-token",
PortRangeStart: 45020,
PortRangeEnd: 45030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-rt-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-rt-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-rt-token")
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-rt-token")
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-rt-token")
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-rt-token")
var commandMu sync.Mutex
entryCommands := make([]string, 0, 8)
stopEntry := startMockNodeSessionWithHook(t, providerServer.URL, "provider-entry-rt-secret", func(cmdType string) {
commandMu.Lock()
entryCommands = append(entryCommands, cmdType)
commandMu.Unlock()
})
defer stopEntry()
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-rt-secret")
defer stopMiddle()
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-rt-secret")
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)
var tunnelID int64
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name).Scan(&tunnelID); err != nil {
t.Fatalf("query tunnel id (%s): %v", name, err)
}
if tunnelID <= 0 {
t.Fatalf("invalid tunnel id for %s", name)
}
return tunnelID
}
createTunnel("dual-panel-remote-entry-online")
commandMu.Lock()
seenAddChains := false
seenCommands := append([]string(nil), entryCommands...)
for _, cmdType := range entryCommands {
if strings.EqualFold(strings.TrimSpace(cmdType), "AddChains") {
seenAddChains = true
break
}
}
commandMu.Unlock()
if !seenAddChains {
t.Fatalf("expected entry remote node to receive AddChains, commands=%v", seenCommands)
}
stopEntry()
waitNodeStatus(t, providerRepo, providerEntryNodeID, 0)
createTunnel("dual-panel-remote-entry-offline")
}
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
t.Helper()
now := time.Now().UnixMilli()
@@ -409,6 +539,10 @@ func assertCount(t *testing.T, repo *sqlite.Repository, query string, arg interf
}
func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func() {
return startMockNodeSessionWithHook(t, baseURL, nodeSecret, nil)
}
func startMockNodeSessionWithHook(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string)) func() {
t.Helper()
u, err := url.Parse(baseURL)
if err != nil {
@@ -468,6 +602,9 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
if onCommand != nil {
onCommand(strings.TrimSpace(cmd.Type))
}
respType := fmt.Sprintf("%sResponse", cmd.Type)
respPayload := map[string]interface{}{
@@ -492,9 +629,27 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
}
}()
var stopOnce sync.Once
return func() {
_ = conn.Close()
wg.Wait()
stopOnce.Do(func() {
_ = conn.Close()
wg.Wait()
})
}
}
func waitNodeStatus(t *testing.T, repo *sqlite.Repository, nodeID int64, expectedStatus int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
var status int
if err := repo.DB().QueryRow(`SELECT status FROM node WHERE id = ?`, nodeID).Scan(&status); err == nil && status == expectedStatus {
return
}
if time.Now().After(deadline) {
t.Fatalf("node %d status did not reach %d before timeout", nodeID, expectedStatus)
}
time.Sleep(20 * time.Millisecond)
}
}
@@ -310,6 +310,245 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
t.Fatalf("expected restored config value v3, got %+v", cfg)
}
})
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(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "legacy-null-chain", 1.0, 1, "tls", 1000, now, now, 1, nil, 1)
if err != nil {
t.Fatalf("seed tunnel for nullable chain export: %v", err)
}
tunnelID, err := res.LastInsertId()
if err != nil {
t.Fatalf("read tunnel id for nullable chain export: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, ?, ?, ?, ?, ?, ?)
`, tunnelID, "1", 1, nil, nil, nil, nil); err != nil {
t.Fatalf("seed nullable chain_tunnel row: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", resp.Code)
}
var payload struct {
Version string `json:"version"`
Tunnels []struct {
ID int64 `json:"id"`
ChainTunnels []struct {
Inx int `json:"inx"`
Strategy string `json:"strategy"`
Protocol string `json:"protocol"`
} `json:"chainTunnels"`
} `json:"tunnels"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode tunnels backup payload: %v", err)
}
if strings.TrimSpace(payload.Version) == "" {
t.Fatalf("expected backup payload version, got empty")
}
found := false
for _, tunnel := range payload.Tunnels {
if tunnel.ID != tunnelID {
continue
}
if len(tunnel.ChainTunnels) != 1 {
t.Fatalf("expected one chain tunnel for seeded tunnel %d, got %d", tunnelID, len(tunnel.ChainTunnels))
}
if tunnel.ChainTunnels[0].Inx != 0 {
t.Fatalf("expected nullable chain inx to export as 0, got %d", tunnel.ChainTunnels[0].Inx)
}
if tunnel.ChainTunnels[0].Strategy != "" {
t.Fatalf("expected nullable chain strategy to export as empty string, got %q", tunnel.ChainTunnels[0].Strategy)
}
if tunnel.ChainTunnels[0].Protocol != "" {
t.Fatalf("expected nullable chain protocol to export as empty string, got %q", tunnel.ChainTunnels[0].Protocol)
}
found = true
break
}
if !found {
t.Fatalf("expected seeded tunnel %d in backup export", tunnelID)
}
})
}
type backupExportPayload struct {
@@ -0,0 +1,115 @@
package contract_test
import (
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/store/sqlite"
)
func TestPostgresNodeCreateRepairsMissingIDDefaultContract(t *testing.T) {
baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN"))
if baseDSN == "" {
t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres contract tests")
}
schemaName := "contract_node_id_" + strconv.FormatInt(time.Now().UnixNano(), 36)
adminDB, err := sql.Open("pgx", baseDSN)
if err != nil {
t.Fatalf("open postgres admin connection: %v", err)
}
t.Cleanup(func() {
_, _ = adminDB.Exec(`DROP SCHEMA IF EXISTS "` + schemaName + `" CASCADE`)
_ = adminDB.Close()
})
if _, err := adminDB.Exec(`CREATE SCHEMA "` + schemaName + `"`); err != nil {
t.Fatalf("create schema %s: %v", schemaName, err)
}
testDSN, err := withSearchPath(baseDSN, schemaName)
if err != nil {
t.Fatalf("build schema dsn: %v", err)
}
repo, err := sqlite.OpenPostgres(testDSN)
if err != nil {
t.Fatalf("open postgres repository: %v", err)
}
if _, err := repo.DB().Exec(`ALTER TABLE node ALTER COLUMN id DROP DEFAULT`); err != nil {
_ = repo.Close()
t.Fatalf("drop node.id default to simulate drift: %v", err)
}
if err := repo.Close(); err != nil {
t.Fatalf("close repository before reopen: %v", err)
}
repo, err = sqlite.OpenPostgres(testDSN)
if err != nil {
t.Fatalf("reopen postgres repository: %v", err)
}
t.Cleanup(func() {
_ = repo.Close()
})
var columnDefault sql.NullString
if err := repo.DB().QueryRow(`
SELECT column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'node'
AND column_name = 'id'
LIMIT 1
`).Scan(&columnDefault); err != nil {
t.Fatalf("query node.id default: %v", err)
}
if !columnDefault.Valid || !strings.Contains(strings.ToLower(columnDefault.String), "nextval(") {
t.Fatalf("expected node.id default to be nextval(...), got %q", columnDefault.String)
}
jwtSecret := "postgres-contract-secret"
router := httpserver.NewRouter(handler.New(repo, jwtSecret), jwtSecret)
token, err := auth.GenerateToken(1, "admin_user", 0, jwtSecret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
body := strings.NewReader(`{"name":"pg-repair-node","serverIp":"10.77.0.10"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/node/create", body)
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCode(t, resp, 0)
var nodeID int64
if err := repo.DB().QueryRow(`SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "pg-repair-node").Scan(&nodeID); err != nil {
t.Fatalf("query created node: %v", err)
}
if nodeID <= 0 {
t.Fatalf("expected positive node id, got %d", nodeID)
}
}
func withSearchPath(dsn, schema string) (string, error) {
u, err := url.Parse(dsn)
if err != nil {
return "", err
}
q := u.Query()
q.Set("search_path", schema)
u.RawQuery = q.Encode()
return u.String(), nil
}
+23 -1
View File
@@ -46,6 +46,28 @@ html, body {
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
}
[data-slot="input-wrapper"] {
box-shadow: none;
}
[data-slot="input-wrapper"]:focus-within:not([data-invalid="true"]),
[data-slot="input-wrapper"][data-focus="true"]:not([data-invalid="true"]),
[data-slot="input-wrapper"][data-focused="true"]:not([data-invalid="true"]),
button[data-slot="trigger"][data-focus="true"]:not([data-invalid="true"]),
button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) {
border-color: var(--heroui-default-200, #e5e7eb) !important;
outline: none !important;
outline-offset: 0 !important;
box-shadow: none !important;
}
[data-slot="input-wrapper"] input:focus,
[data-slot="input-wrapper"] textarea:focus {
outline: none !important;
box-shadow: none !important;
border-color: transparent !important;
}
.safe-top {
padding-top: var(--safe-area-top);
}
@@ -85,4 +107,4 @@ html, body {
}
}
@config "../../tailwind.config.js"
@config "../../tailwind.config.js"