diff --git a/docs/superpowers/plans/2026-05-30-nftables-forwarding.md b/docs/superpowers/plans/2026-05-30-nftables-forwarding.md new file mode 100644 index 0000000..d2b4481 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-nftables-forwarding.md @@ -0,0 +1,2294 @@ +# nftables Forwarding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an `nftables` node forwarding mode that lets FLVX manage pure DNAT/SNAT forwarding rules over SSH without installing the GOST agent. + +**Architecture:** Keep the existing node/tunnel/forward business model, but split runtime execution by node `forward_mode`: `agent` continues using WebSocket/GOST commands, while `nftables` uses a focused backend runtime package that plans rules from database state, renders a complete FLVX-owned nftables table, and applies it over SSH. The first implementation deliberately supports only single-target pure port forwarding and rejects unsupported GOST features at the API boundary. + +**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL, `golang.org/x/crypto/ssh`, nftables CLI over SSH, React/TypeScript, Vite, existing shadcn bridge components. + +--- + +## File Structure + +- Modify `go-backend/internal/store/model/model.go`: add node forwarding mode, SSH config, nft binding models, and record fields. +- Modify `go-backend/internal/store/repo/repository.go`: migrate new tables, include node mode in list output, add mode/config/binding repository methods if a narrower file is not used. +- Modify `go-backend/internal/store/repo/repository_mutations.go`: persist node mode and SSH config on create/update. +- Create `go-backend/internal/store/repo/repository_nftables.go`: focused repository methods for SSH configs and nft rule bindings. +- Create `go-backend/internal/store/repo/repository_nftables_test.go`: repository tests for mode/config/binding persistence. +- Create `go-backend/internal/runtime/nftables/types.go`: runtime constants, request/plan/result types. +- Create `go-backend/internal/runtime/nftables/parser.go`: strict `host:port` target parsing. +- Create `go-backend/internal/runtime/nftables/renderer.go`: render complete `table inet flvx` scripts. +- Create `go-backend/internal/runtime/nftables/runner.go`: SSH runner and command abstraction. +- Create `go-backend/internal/runtime/nftables/manager.go`: Reconcile/Test/Clear orchestration. +- Create `go-backend/internal/runtime/nftables/*_test.go`: parser, renderer, manager tests with fake runner. +- Modify `go-backend/internal/http/handler/handler.go`: initialize nftables manager and register nftables maintenance routes. +- Create `go-backend/internal/http/handler/nftables_runtime.go`: handler helpers for capability checks and runtime sync. +- Modify `go-backend/internal/http/handler/mutations.go`: enforce nftables restrictions in node/tunnel/forward create/update/delete/batch redeploy flows. +- Create `go-backend/internal/http/handler/nftables_runtime_test.go`: API-level validation and rollback tests. +- Modify `vite-frontend/src/api/index.ts`: add nftables node operations. +- Modify `vite-frontend/src/api/types.ts`: add `forwardMode` and `sshConfig` types. +- Modify `vite-frontend/src/pages/node.tsx`: add forwarding mode fields, SSH form section, and hide agent-only operations. +- Modify `vite-frontend/src/pages/tunnel.tsx` and `vite-frontend/src/pages/tunnel/form.ts`: prevent nftables tunnel forwarding/chain configuration. +- Modify `vite-frontend/src/pages/forward.tsx`: hide unsupported controls and enforce single-target nftables rules. + +Implementation should commit only when explicitly requested. If commits are requested later, commit after each task. + +--- + +### Task 1: Data Model And Repository + +**Files:** +- Modify: `go-backend/internal/store/model/model.go` +- Modify: `go-backend/internal/store/repo/repository.go` +- Modify: `go-backend/internal/store/repo/repository_mutations.go` +- Create: `go-backend/internal/store/repo/repository_nftables.go` +- Create: `go-backend/internal/store/repo/repository_nftables_test.go` + +- [ ] **Step 1: Write failing repository tests** + +Create `go-backend/internal/store/repo/repository_nftables_test.go`: + +```go +package repo + +import ( + "strings" + "testing" + "time" +) + +func TestNftablesNodeModeSSHConfigAndBindingPersistence(t *testing.T) { + r, err := Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + if err := r.CreateNode( + "nft-node", + "secret", + "203.0.113.10", + nil, + nil, + "10000-20000", + nil, + nil, + nil, + nil, + nil, + 0, + 0, + 0, + now, + 1, + "[::]", + "[::]", + 1, + 0, + nil, + nil, + nil, + nil, + "nftables", + ); err != nil { + t.Fatalf("CreateNode: %v", err) + } + + nodes, err := r.ListNodes() + if err != nil { + t.Fatalf("ListNodes: %v", err) + } + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + nodeID := nodes[0]["id"].(int64) + if got := nodes[0]["forwardMode"]; got != "nftables" { + t.Fatalf("expected forwardMode nftables, got %#v", got) + } + + cfg := NftSSHConfigInput{ + Host: "203.0.113.10", + Port: 22, + Username: "root", + AuthType: "private_key", + PrivateKey: "encrypted-private-key", + SudoMode: "none", + } + if err := r.UpsertNodeSSHConfig(nodeID, cfg, now); err != nil { + t.Fatalf("UpsertNodeSSHConfig: %v", err) + } + loaded, err := r.GetNodeSSHConfig(nodeID) + if err != nil { + t.Fatalf("GetNodeSSHConfig: %v", err) + } + if loaded.Host != cfg.Host || loaded.Port != cfg.Port || loaded.Username != cfg.Username || loaded.AuthType != cfg.AuthType { + t.Fatalf("unexpected ssh config: %+v", loaded) + } + + binding := NftRuleBindingInput{ + ForwardID: 42, + NodeID: nodeID, + InPort: 24000, + Protocols: "tcp,udp", + TargetAddr: "198.51.100.20:443", + BindIP: "", + RuleHash: "hash-a", + Status: "applied", + LastError: "", + } + if err := r.UpsertNftRuleBinding(binding, now); err != nil { + t.Fatalf("UpsertNftRuleBinding: %v", err) + } + bindings, err := r.ListNftRuleBindingsByNode(nodeID) + if err != nil { + t.Fatalf("ListNftRuleBindingsByNode: %v", err) + } + if len(bindings) != 1 { + t.Fatalf("expected 1 binding, got %d", len(bindings)) + } + if bindings[0].ForwardID != 42 || bindings[0].RuleHash != "hash-a" || bindings[0].Status != "applied" { + t.Fatalf("unexpected binding: %+v", bindings[0]) + } + + if err := r.MarkNftRuleBindingError(42, nodeID, "nft failed", now+1); err != nil { + t.Fatalf("MarkNftRuleBindingError: %v", err) + } + bindings, err = r.ListNftRuleBindingsByNode(nodeID) + if err != nil { + t.Fatalf("ListNftRuleBindingsByNode after error: %v", err) + } + if bindings[0].Status != "error" || !strings.Contains(bindings[0].LastError, "nft failed") { + t.Fatalf("expected error binding, got %+v", bindings[0]) + } + + if err := r.DeleteNftRuleBindingsByForward(42); err != nil { + t.Fatalf("DeleteNftRuleBindingsByForward: %v", err) + } + bindings, err = r.ListNftRuleBindingsByNode(nodeID) + if err != nil { + t.Fatalf("ListNftRuleBindingsByNode after delete: %v", err) + } + if len(bindings) != 0 { + t.Fatalf("expected no bindings after delete, got %+v", bindings) + } +} +``` + +- [ ] **Step 2: Run repository test and verify failure** + +Run: + +```bash +(cd go-backend && go test ./internal/store/repo -run TestNftablesNodeModeSSHConfigAndBindingPersistence -count=1) +``` + +Expected: FAIL with undefined `NftSSHConfigInput`, `UpsertNodeSSHConfig`, `GetNodeSSHConfig`, `NftRuleBindingInput`, `UpsertNftRuleBinding`, `ListNftRuleBindingsByNode`, `MarkNftRuleBindingError`, `DeleteNftRuleBindingsByForward`, and the old `CreateNode` signature. + +- [ ] **Step 3: Add models and record fields** + +In `go-backend/internal/store/model/model.go`, add `ForwardMode` to `Node` after `IsRemote`: + +```go + ForwardMode string `gorm:"column:forward_mode;type:varchar(20);not null;default:'agent'"` +``` + +Add new GORM models after `Node`: + +```go +type NodeSSHConfig struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + NodeID int64 `gorm:"column:node_id;not null;uniqueIndex"` + Host string `gorm:"type:varchar(255);not null"` + Port int `gorm:"not null;default:22"` + Username string `gorm:"type:varchar(100);not null"` + AuthType string `gorm:"column:auth_type;type:varchar(20);not null"` + Password sql.NullString `gorm:"type:text"` + PrivateKey sql.NullString `gorm:"column:private_key;type:text"` + Passphrase sql.NullString `gorm:"type:text"` + SudoMode string `gorm:"column:sudo_mode;type:varchar(20);not null;default:'none'"` + CreatedTime int64 `gorm:"column:created_time;not null"` + UpdatedTime int64 `gorm:"column:updated_time;not null"` +} + +func (NodeSSHConfig) TableName() string { return "node_ssh_config" } + +type NftRuleBinding struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + ForwardID int64 `gorm:"column:forward_id;not null;uniqueIndex:idx_nft_rule_binding_forward_node;index"` + NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_nft_rule_binding_forward_node;index"` + InPort int `gorm:"column:in_port;not null"` + Protocols string `gorm:"type:varchar(20);not null;default:'tcp,udp'"` + TargetAddr string `gorm:"column:target_addr;type:text;not null"` + BindIP string `gorm:"column:bind_ip;type:text;not null;default:''"` + RuleHash string `gorm:"column:rule_hash;type:varchar(128);not null;default:''"` + Status string `gorm:"type:varchar(20);not null;default:'pending'"` + LastError string `gorm:"column:last_error;type:text;not null;default:''"` + AppliedTime int64 `gorm:"column:applied_time;not null;default:0"` + CreatedTime int64 `gorm:"column:created_time;not null"` + UpdatedTime int64 `gorm:"column:updated_time;not null"` +} + +func (NftRuleBinding) TableName() string { return "nft_rule_binding" } +``` + +Add `ForwardMode string` to `NodeRecord` in `model.go`. + +- [ ] **Step 4: Auto-migrate new tables** + +In `go-backend/internal/store/repo/repository.go`, add the new models to `autoMigrateAll` immediately after `&model.Node{}`: + +```go + &model.NodeSSHConfig{}, + &model.NftRuleBinding{}, +``` + +Because SQLite startup currently skips `Node` AutoMigrate when a legacy node table exists, add a legacy column preparation helper that runs before the model loop: + +```go + if db.Dialector.Name() == "sqlite" { + if err := prepareSQLiteNftablesColumns(db); err != nil { + return err + } + } +``` + +Add this helper near the existing SQLite legacy helpers: + +```go +func prepareSQLiteNftablesColumns(db *gorm.DB) error { + if db == nil || db.Dialector.Name() != "sqlite" { + return nil + } + if !db.Migrator().HasTable(&model.Node{}) { + return nil + } + if !db.Migrator().HasColumn(&model.Node{}, "forward_mode") { + if err := db.Exec("ALTER TABLE node ADD COLUMN forward_mode varchar(20) NOT NULL DEFAULT 'agent'").Error; err != nil { + return err + } + } + return nil +} +``` + +- [ ] **Step 5: Persist node mode and expose it in list output** + +Change `CreateNode` in `repository_mutations.go` to accept `forwardMode string` after `extraIPs interface{}` and set: + +```go + ForwardMode: defaultNodeForwardMode(forwardMode), +``` + +Change `UpdateNode` to accept `forwardMode string` after `renewalCycle interface{}` and add: + +```go + "forward_mode": defaultNodeForwardMode(forwardMode), +``` + +Add this helper in `repository_mutations.go`: + +```go +func defaultNodeForwardMode(mode string) string { + switch strings.TrimSpace(strings.ToLower(mode)) { + case "nftables": + return "nftables" + default: + return "agent" + } +} +``` + +If `repository_mutations.go` does not already import `strings`, add it. + +In `ListNodes`, include: + +```go + "forwardMode": defaultNodeForwardMode(n.ForwardMode), +``` + +- [ ] **Step 6: Add nftables repository methods** + +Create `go-backend/internal/store/repo/repository_nftables.go`: + +```go +package repo + +import ( + "database/sql" + "errors" + "strings" + + "go-backend/internal/store/model" + + "gorm.io/gorm" +) + +type NftSSHConfigInput struct { + Host string + Port int + Username string + AuthType string + Password string + PrivateKey string + Passphrase string + SudoMode string +} + +type NftRuleBindingInput struct { + ForwardID int64 + NodeID int64 + InPort int + Protocols string + TargetAddr string + BindIP string + RuleHash string + Status string + LastError string +} + +func (r *Repository) UpsertNodeSSHConfig(nodeID int64, cfg NftSSHConfigInput, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + if nodeID <= 0 { + return errors.New("node id is required") + } + port := cfg.Port + if port <= 0 { + port = 22 + } + authType := strings.TrimSpace(strings.ToLower(cfg.AuthType)) + if authType == "" { + authType = "private_key" + } + sudoMode := strings.TrimSpace(strings.ToLower(cfg.SudoMode)) + if sudoMode == "" { + sudoMode = "none" + } + var existing model.NodeSSHConfig + err := r.db.Where("node_id = ?", nodeID).First(&existing).Error + row := model.NodeSSHConfig{ + NodeID: nodeID, + Host: strings.TrimSpace(cfg.Host), + Port: port, + Username: strings.TrimSpace(cfg.Username), + AuthType: authType, + Password: nullStringFromInterface(cfg.Password), + PrivateKey: nullStringFromInterface(cfg.PrivateKey), + Passphrase: nullStringFromInterface(cfg.Passphrase), + SudoMode: sudoMode, + CreatedTime: now, + UpdatedTime: now, + } + if err == nil { + row.ID = existing.ID + row.CreatedTime = existing.CreatedTime + return r.db.Save(&row).Error + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return r.db.Create(&row).Error + } + return err +} + +func (r *Repository) GetNodeSSHConfig(nodeID int64) (*model.NodeSSHConfig, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var cfg model.NodeSSHConfig + if err := r.db.Where("node_id = ?", nodeID).First(&cfg).Error; err != nil { + return nil, normalizeNotFoundErr(err) + } + return &cfg, nil +} + +func (r *Repository) DeleteNodeSSHConfig(nodeID int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + return r.db.Where("node_id = ?", nodeID).Delete(&model.NodeSSHConfig{}).Error +} + +func (r *Repository) UpsertNftRuleBinding(input NftRuleBindingInput, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + var existing model.NftRuleBinding + err := r.db.Where("forward_id = ? AND node_id = ?", input.ForwardID, input.NodeID).First(&existing).Error + row := model.NftRuleBinding{ + ForwardID: input.ForwardID, + NodeID: input.NodeID, + InPort: input.InPort, + Protocols: defaultString(strings.TrimSpace(input.Protocols), "tcp,udp"), + TargetAddr: strings.TrimSpace(input.TargetAddr), + BindIP: strings.TrimSpace(input.BindIP), + RuleHash: strings.TrimSpace(input.RuleHash), + Status: defaultString(strings.TrimSpace(input.Status), "pending"), + LastError: strings.TrimSpace(input.LastError), + AppliedTime: now, + CreatedTime: now, + UpdatedTime: now, + } + if err == nil { + row.ID = existing.ID + row.CreatedTime = existing.CreatedTime + return r.db.Save(&row).Error + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return r.db.Create(&row).Error + } + return err +} + +func (r *Repository) MarkNftRuleBindingError(forwardID, nodeID int64, message string, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + return r.db.Model(&model.NftRuleBinding{}). + Where("forward_id = ? AND node_id = ?", forwardID, nodeID). + Updates(map[string]interface{}{ + "status": "error", + "last_error": strings.TrimSpace(message), + "updated_time": now, + }).Error +} + +func (r *Repository) ListNftRuleBindingsByNode(nodeID int64) ([]model.NftRuleBinding, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var rows []model.NftRuleBinding + err := r.db.Where("node_id = ?", nodeID).Order("forward_id ASC").Find(&rows).Error + return rows, err +} + +func (r *Repository) DeleteNftRuleBindingsByForward(forwardID int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + return r.db.Where("forward_id = ?", forwardID).Delete(&model.NftRuleBinding{}).Error +} + +func (r *Repository) GetNodeForwardMode(nodeID int64) (string, error) { + if r == nil || r.db == nil { + return "", errors.New("repository not initialized") + } + var row struct { + ForwardMode sql.NullString `gorm:"column:forward_mode"` + } + err := r.db.Model(&model.Node{}).Select("forward_mode").Where("id = ?", nodeID).First(&row).Error + if err != nil { + return "", normalizeNotFoundErr(err) + } + return defaultNodeForwardMode(row.ForwardMode.String), nil +} +``` + +- [ ] **Step 7: Update call sites for CreateNode and UpdateNode** + +In `go-backend/internal/http/handler/mutations.go`, update `h.repo.CreateNode(...)` to pass: + +```go + nullableText(asString(req["extraIPs"])), + defaultNodeForwardMode(asString(req["forwardMode"])), +``` + +Update `h.repo.UpdateNode(...)` to pass `defaultNodeForwardMode(asString(req["forwardMode"]))` after `renewalCycle`. + +Add this handler helper in `mutations.go` near other helpers: + +```go +func defaultNodeForwardMode(mode string) string { + switch strings.TrimSpace(strings.ToLower(mode)) { + case "nftables": + return "nftables" + default: + return "agent" + } +} +``` + +- [ ] **Step 8: Run repository tests** + +Run: + +```bash +(cd go-backend && go test ./internal/store/repo -run TestNftablesNodeModeSSHConfigAndBindingPersistence -count=1) +``` + +Expected: PASS. + +Run: + +```bash +(cd go-backend && go test ./internal/store/repo -count=1) +``` + +Expected: PASS. + +--- + +### Task 2: nftables Parser And Renderer + +**Files:** +- Create: `go-backend/internal/runtime/nftables/types.go` +- Create: `go-backend/internal/runtime/nftables/parser.go` +- Create: `go-backend/internal/runtime/nftables/renderer.go` +- Create: `go-backend/internal/runtime/nftables/parser_test.go` +- Create: `go-backend/internal/runtime/nftables/renderer_test.go` + +- [ ] **Step 1: Write failing parser tests** + +Create `go-backend/internal/runtime/nftables/parser_test.go`: + +```go +package nftables + +import "testing" + +func TestParseSingleTargetAcceptsHostPortAndIPv6(t *testing.T) { + tests := []struct { + name string + raw string + host string + port int + }{ + {name: "hostname", raw: "example.com:443", host: "example.com", port: 443}, + {name: "ipv4", raw: "198.51.100.20:8443", host: "198.51.100.20", port: 8443}, + {name: "ipv6", raw: "[2001:db8::1]:443", host: "2001:db8::1", port: 443}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, err := ParseSingleTarget(tt.raw) + if err != nil { + t.Fatalf("ParseSingleTarget: %v", err) + } + if target.Host != tt.host || target.Port != tt.port { + t.Fatalf("expected %s/%d, got %+v", tt.host, tt.port, target) + } + }) + } +} + +func TestParseSingleTargetRejectsUnsupportedValues(t *testing.T) { + for _, raw := range []string{"", "example.com", "example.com:0", "example.com:65536", "a:1,b:2", "http://example.com:443"} { + t.Run(raw, func(t *testing.T) { + if _, err := ParseSingleTarget(raw); err == nil { + t.Fatalf("expected error for %q", raw) + } + }) + } +} +``` + +- [ ] **Step 2: Write failing renderer tests** + +Create `go-backend/internal/runtime/nftables/renderer_test.go`: + +```go +package nftables + +import ( + "strings" + "testing" +) + +func TestRenderTableIncludesDNATAndMasquerade(t *testing.T) { + script := RenderTable(NodePlan{ + NodeID: 10, + Rules: []Rule{ + { + ForwardID: 42, + InPort: 24000, + TargetHost: "198.51.100.20", + TargetPort: 443, + Protocols: []string{"tcp", "udp"}, + }, + }, + }) + + expectedParts := []string{ + "table inet flvx", + "type nat hook prerouting priority dstnat; policy accept;", + "type nat hook postrouting priority srcnat; policy accept;", + "tcp dport 24000 dnat to 198.51.100.20:443 comment \"flvx forward:42 tcp\"", + "udp dport 24000 dnat to 198.51.100.20:443 comment \"flvx forward:42 udp\"", + "masquerade comment \"flvx masquerade\"", + } + for _, part := range expectedParts { + if !strings.Contains(script, part) { + t.Fatalf("script missing %q:\n%s", part, script) + } + } +} + +func TestRenderTableBracketsIPv6Target(t *testing.T) { + script := RenderTable(NodePlan{ + NodeID: 10, + Rules: []Rule{ + {ForwardID: 42, InPort: 24000, TargetHost: "2001:db8::1", TargetPort: 443, Protocols: []string{"tcp"}}, + }, + }) + if !strings.Contains(script, "dnat to [2001:db8::1]:443") { + t.Fatalf("expected bracketed IPv6 dnat target, got:\n%s", script) + } +} + +func TestRuleHashIsStable(t *testing.T) { + rule := Rule{ForwardID: 42, InPort: 24000, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp", "udp"}} + if RuleHash(rule) != RuleHash(rule) { + t.Fatalf("expected stable rule hash") + } + if RuleHash(rule) == RuleHash(Rule{ForwardID: 42, InPort: 24001, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp", "udp"}}) { + t.Fatalf("expected hash to change when port changes") + } +} +``` + +- [ ] **Step 3: Run tests and verify failure** + +Run: + +```bash +(cd go-backend && go test ./internal/runtime/nftables -count=1) +``` + +Expected: FAIL because the package and functions do not exist. + +- [ ] **Step 4: Implement runtime types** + +Create `go-backend/internal/runtime/nftables/types.go`: + +```go +package nftables + +const ( + ModeAgent = "agent" + ModeNftables = "nftables" + + StatusPending = "pending" + StatusApplied = "applied" + StatusError = "error" +) + +type Target struct { + Host string + Port int +} + +type Rule struct { + ForwardID int64 + InPort int + BindIP string + TargetHost string + TargetPort int + Protocols []string +} + +type NodePlan struct { + NodeID int64 + Rules []Rule +} + +type SSHConfig struct { + Host string + Port int + Username string + AuthType string + Password string + PrivateKey string + Passphrase string + SudoMode string +} + +type ApplyResult struct { + NodeID int64 + Script string + Hashes map[int64]string +} +``` + +- [ ] **Step 5: Implement parser** + +Create `go-backend/internal/runtime/nftables/parser.go`: + +```go +package nftables + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +func ParseSingleTarget(raw string) (Target, error) { + value := strings.TrimSpace(raw) + if value == "" { + return Target{}, fmt.Errorf("目标地址不能为空") + } + if strings.Contains(value, ",") || strings.Contains(value, "\n") { + return Target{}, fmt.Errorf("nftables 纯转发第一阶段仅支持单目标") + } + if u, err := url.Parse(value); err == nil && u.Scheme != "" { + return Target{}, fmt.Errorf("目标地址必须是 host:port,不能包含 URL scheme") + } + host, portText, err := net.SplitHostPort(value) + if err != nil { + return Target{}, fmt.Errorf("目标地址必须是 host:port") + } + host = strings.TrimSpace(strings.Trim(host, "[]")) + if host == "" { + return Target{}, fmt.Errorf("目标主机不能为空") + } + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + return Target{}, fmt.Errorf("目标端口必须在 1-65535 之间") + } + return Target{Host: host, Port: port}, nil +} +``` + +- [ ] **Step 6: Implement renderer** + +Create `go-backend/internal/runtime/nftables/renderer.go`: + +```go +package nftables + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "sort" + "strings" +) + +func RenderTable(plan NodePlan) string { + var b strings.Builder + b.WriteString("table inet flvx {\n") + b.WriteString(" chain prerouting {\n") + b.WriteString(" type nat hook prerouting priority dstnat; policy accept;\n") + for _, rule := range sortedRules(plan.Rules) { + for _, protocol := range normalizedProtocols(rule.Protocols) { + b.WriteString(fmt.Sprintf(" %s dport %d dnat to %s comment \"flvx forward:%d %s\"\n", + protocol, + rule.InPort, + formatDNATTarget(rule.TargetHost, rule.TargetPort), + rule.ForwardID, + protocol, + )) + } + } + b.WriteString(" }\n\n") + b.WriteString(" chain postrouting {\n") + b.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n") + if len(plan.Rules) > 0 { + b.WriteString(" masquerade comment \"flvx masquerade\"\n") + } + b.WriteString(" }\n\n") + b.WriteString(" chain forward {\n") + b.WriteString(" type filter hook forward priority filter; policy accept;\n") + b.WriteString(" }\n") + b.WriteString("}\n") + return b.String() +} + +func RuleHash(rule Rule) string { + protocols := normalizedProtocols(rule.Protocols) + sum := sha256.Sum256([]byte(fmt.Sprintf("%d|%d|%s|%d|%s|%s", + rule.ForwardID, + rule.InPort, + strings.TrimSpace(rule.TargetHost), + rule.TargetPort, + strings.TrimSpace(rule.BindIP), + strings.Join(protocols, ","), + ))) + return hex.EncodeToString(sum[:]) +} + +func PlanHashes(plan NodePlan) map[int64]string { + hashes := make(map[int64]string, len(plan.Rules)) + for _, rule := range plan.Rules { + hashes[rule.ForwardID] = RuleHash(rule) + } + return hashes +} + +func sortedRules(rules []Rule) []Rule { + out := append([]Rule(nil), rules...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].InPort == out[j].InPort { + return out[i].ForwardID < out[j].ForwardID + } + return out[i].InPort < out[j].InPort + }) + return out +} + +func normalizedProtocols(protocols []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, 2) + for _, protocol := range protocols { + p := strings.ToLower(strings.TrimSpace(protocol)) + if p != "tcp" && p != "udp" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + if len(out) == 0 { + return []string{"tcp", "udp"} + } + sort.Strings(out) + return out +} + +func formatDNATTarget(host string, port int) string { + trimmed := strings.Trim(strings.TrimSpace(host), "[]") + if ip := net.ParseIP(trimmed); ip != nil && ip.To4() == nil { + return fmt.Sprintf("[%s]:%d", trimmed, port) + } + return fmt.Sprintf("%s:%d", trimmed, port) +} +``` + +- [ ] **Step 7: Run parser and renderer tests** + +Run: + +```bash +(cd go-backend && go test ./internal/runtime/nftables -count=1) +``` + +Expected: PASS. + +--- + +### Task 3: SSH Runner And nftables Manager + +**Files:** +- Create: `go-backend/internal/runtime/nftables/runner.go` +- Create: `go-backend/internal/runtime/nftables/manager.go` +- Create: `go-backend/internal/runtime/nftables/manager_test.go` + +- [ ] **Step 1: Write failing manager tests with a fake runner** + +Create `go-backend/internal/runtime/nftables/manager_test.go`: + +```go +package nftables + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeRunner struct { + scripts []string + err error +} + +func (f *fakeRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error { + f.scripts = append(f.scripts, script) + return f.err +} + +func (f *fakeRunner) Test(ctx context.Context, cfg SSHConfig) error { + return f.err +} + +func TestManagerReconcileAppliesRenderedScript(t *testing.T) { + runner := &fakeRunner{} + manager := NewManager(runner) + plan := NodePlan{ + NodeID: 7, + Rules: []Rule{{ForwardID: 42, InPort: 24000, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp", "udp"}}}, + } + result, err := manager.Reconcile(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}, plan) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(runner.scripts) != 1 { + t.Fatalf("expected 1 script, got %d", len(runner.scripts)) + } + if !strings.Contains(runner.scripts[0], "flvx forward:42 tcp") { + t.Fatalf("script missing forward comment:\n%s", runner.scripts[0]) + } + if result.NodeID != 7 || result.Hashes[42] == "" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestManagerReconcileReturnsRunnerError(t *testing.T) { + runner := &fakeRunner{err: errors.New("ssh failed")} + manager := NewManager(runner) + _, err := manager.Reconcile(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}, NodePlan{NodeID: 7}) + if err == nil || !strings.Contains(err.Error(), "ssh failed") { + t.Fatalf("expected ssh failed error, got %v", err) + } +} + +func TestManagerClearAppliesEmptyTable(t *testing.T) { + runner := &fakeRunner{} + manager := NewManager(runner) + if err := manager.Clear(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}); err != nil { + t.Fatalf("Clear: %v", err) + } + if len(runner.scripts) != 1 { + t.Fatalf("expected 1 script, got %d", len(runner.scripts)) + } + if strings.Contains(runner.scripts[0], "masquerade comment") { + t.Fatalf("empty table should not include masquerade:\n%s", runner.scripts[0]) + } +} +``` + +- [ ] **Step 2: Run manager tests and verify failure** + +Run: + +```bash +(cd go-backend && go test ./internal/runtime/nftables -run 'TestManager' -count=1) +``` + +Expected: FAIL with undefined `NewManager`. + +- [ ] **Step 3: Implement runner interfaces and real SSH runner** + +Create `go-backend/internal/runtime/nftables/runner.go`: + +```go +package nftables + +import ( + "bytes" + "context" + "fmt" + "net" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +type Runner interface { + ApplyScript(ctx context.Context, cfg SSHConfig, script string) error + Test(ctx context.Context, cfg SSHConfig) error +} + +type SSHRunner struct { + Timeout time.Duration +} + +func NewSSHRunner() *SSHRunner { + return &SSHRunner{Timeout: 15 * time.Second} +} + +func (r *SSHRunner) Test(ctx context.Context, cfg SSHConfig) error { + return r.run(ctx, cfg, "command -v nft >/dev/null 2>&1 && nft --version >/dev/null 2>&1") +} + +func (r *SSHRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error { + escaped := strings.ReplaceAll(script, "'", "'\"'\"'") + command := "tmp=/tmp/flvx-nft-$(date +%s%N).nft; " + + "cat > \"$tmp\" <<'EOF'\n" + escaped + "\nEOF\n" + + nftCommand(cfg, "list table inet flvx >/dev/null 2>&1 && nft delete table inet flvx || true") + "; " + + nftCommand(cfg, " -f \"$tmp\"") + "; " + + "rm -f \"$tmp\"" + return r.run(ctx, cfg, command) +} + +func (r *SSHRunner) run(ctx context.Context, cfg SSHConfig, command string) error { + timeout := r.Timeout + if timeout <= 0 { + timeout = 15 * time.Second + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + clientConfig, err := buildSSHClientConfig(cfg) + if err != nil { + return err + } + addr := net.JoinHostPort(strings.TrimSpace(cfg.Host), fmt.Sprintf("%d", normalizedSSHPort(cfg.Port))) + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(runCtx, "tcp", addr) + if err != nil { + return fmt.Errorf("SSH 连接失败: %w", err) + } + defer conn.Close() + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, clientConfig) + if err != nil { + return fmt.Errorf("SSH 认证失败: %w", err) + } + client := ssh.NewClient(sshConn, chans, reqs) + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("SSH 会话创建失败: %w", err) + } + defer session.Close() + + var stderr bytes.Buffer + session.Stderr = &stderr + if err := session.Run(command); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("nftables 下发失败: %s", sanitizeSSHError(msg)) + } + return nil +} + +func buildSSHClientConfig(cfg SSHConfig) (*ssh.ClientConfig, error) { + authType := strings.TrimSpace(strings.ToLower(cfg.AuthType)) + var methods []ssh.AuthMethod + switch authType { + case "password": + if strings.TrimSpace(cfg.Password) == "" { + return nil, fmt.Errorf("SSH 密码不能为空") + } + methods = append(methods, ssh.Password(cfg.Password)) + case "private_key", "": + signer, err := parsePrivateKey(cfg.PrivateKey, cfg.Passphrase) + if err != nil { + return nil, err + } + methods = append(methods, ssh.PublicKeys(signer)) + default: + return nil, fmt.Errorf("不支持的 SSH 认证方式: %s", authType) + } + if strings.TrimSpace(cfg.Username) == "" { + return nil, fmt.Errorf("SSH 用户名不能为空") + } + return &ssh.ClientConfig{ + User: strings.TrimSpace(cfg.Username), + Auth: methods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 15 * time.Second, + }, nil +} + +func parsePrivateKey(key, passphrase string) (ssh.Signer, error) { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return nil, fmt.Errorf("SSH 私钥不能为空") + } + if strings.TrimSpace(passphrase) != "" { + signer, err := ssh.ParsePrivateKeyWithPassphrase([]byte(trimmed), []byte(passphrase)) + if err != nil { + return nil, fmt.Errorf("SSH 私钥解析失败") + } + return signer, nil + } + signer, err := ssh.ParsePrivateKey([]byte(trimmed)) + if err != nil { + return nil, fmt.Errorf("SSH 私钥解析失败") + } + return signer, nil +} + +func nftCommand(cfg SSHConfig, command string) string { + if strings.TrimSpace(strings.ToLower(cfg.SudoMode)) == "sudo" { + return "sudo nft " + command + } + return "nft " + command +} + +func normalizedSSHPort(port int) int { + if port <= 0 { + return 22 + } + return port +} + +func sanitizeSSHError(message string) string { + msg := strings.TrimSpace(message) + if len(msg) > 800 { + msg = msg[:800] + } + return msg +} +``` + +- [ ] **Step 4: Implement manager** + +Create `go-backend/internal/runtime/nftables/manager.go`: + +```go +package nftables + +import ( + "context" + "errors" +) + +type Manager struct { + runner Runner +} + +func NewManager(runner Runner) *Manager { + if runner == nil { + runner = NewSSHRunner() + } + return &Manager{runner: runner} +} + +func (m *Manager) Test(ctx context.Context, cfg SSHConfig) error { + if m == nil || m.runner == nil { + return errors.New("nftables manager not initialized") + } + return m.runner.Test(ctx, cfg) +} + +func (m *Manager) Reconcile(ctx context.Context, cfg SSHConfig, plan NodePlan) (ApplyResult, error) { + if m == nil || m.runner == nil { + return ApplyResult{}, errors.New("nftables manager not initialized") + } + script := RenderTable(plan) + if err := m.runner.ApplyScript(ctx, cfg, script); err != nil { + return ApplyResult{}, err + } + return ApplyResult{NodeID: plan.NodeID, Script: script, Hashes: PlanHashes(plan)}, nil +} + +func (m *Manager) Clear(ctx context.Context, cfg SSHConfig) error { + if m == nil || m.runner == nil { + return errors.New("nftables manager not initialized") + } + return m.runner.ApplyScript(ctx, cfg, RenderTable(NodePlan{})) +} +``` + +- [ ] **Step 5: Fix shell command if tests expose quoting issues** + +If `go test` fails because `runner.go` builds an invalid shell string, replace `ApplyScript` command construction with this safer variant: + +```go +func (r *SSHRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error { + escaped := strings.ReplaceAll(script, "'", "'\"'\"'") + nftPrefix := "nft" + if strings.TrimSpace(strings.ToLower(cfg.SudoMode)) == "sudo" { + nftPrefix = "sudo nft" + } + command := fmt.Sprintf( + "tmp=/tmp/flvx-nft-$(date +%%s%%N).nft; cat > \"$tmp\" <<'EOF'\n%s\nEOF\n%s list table inet flvx >/dev/null 2>&1 && %s delete table inet flvx || true; %s -f \"$tmp\"; rc=$?; rm -f \"$tmp\"; exit $rc", + escaped, + nftPrefix, + nftPrefix, + nftPrefix, + ) + return r.run(ctx, cfg, command) +} +``` + +- [ ] **Step 6: Run runtime tests** + +Run: + +```bash +(cd go-backend && go test ./internal/runtime/nftables -count=1) +``` + +Expected: PASS. + +--- + +### Task 4: Handler Integration And Capability Validation + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` +- Create: `go-backend/internal/http/handler/nftables_runtime.go` +- Create: `go-backend/internal/http/handler/nftables_runtime_test.go` +- Modify: `go-backend/internal/http/handler/mutations.go` +- Modify: `go-backend/internal/http/handler/control_plane.go` + +- [ ] **Step 1: Write failing handler validation tests** + +Create `go-backend/internal/http/handler/nftables_runtime_test.go`: + +```go +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go-backend/internal/store/repo" +) + +func TestNftablesTunnelRejectsTunnelForwarding(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + h := New(r, "test-secret") + now := time.Now().UnixMilli() + if err := r.CreateNode("nft", "secret", "203.0.113.10", nil, nil, "10000-20000", nil, nil, nil, nil, nil, 0, 0, 0, now, 1, "[::]", "[::]", 1, 0, nil, nil, nil, nil, "nftables"); err != nil { + t.Fatalf("create node: %v", err) + } + + body := strings.NewReader(`{"name":"bad-nft-tunnel","type":2,"inNodeId":[{"nodeId":1,"protocol":"tcp"}],"outNodeId":[{"nodeId":1,"protocol":"tcp"}]}`) + res := httptest.NewRecorder() + h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body)) + if !strings.Contains(res.Body.String(), "nftables") || !strings.Contains(res.Body.String(), "隧道转发") { + t.Fatalf("expected nftables tunnel forwarding rejection, got %s", res.Body.String()) + } +} + +func TestNftablesForwardRejectsUnsupportedFields(t *testing.T) { + if err := validateNftablesForwardRequest(map[string]interface{}{ + "remoteAddr": "198.51.100.20:443", + "speedId": float64(1), + "proxyProtocol": float64(1), + }); err == nil { + t.Fatalf("expected unsupported fields error") + } + if err := validateNftablesForwardRequest(map[string]interface{}{ + "remoteAddr": "198.51.100.20:443,198.51.100.21:443", + }); err == nil { + t.Fatalf("expected multi target error") + } + if err := validateNftablesForwardRequest(map[string]interface{}{ + "remoteAddr": "198.51.100.20:443", + }); err != nil { + t.Fatalf("expected valid nftables forward request, got %v", err) + } +} +``` + +- [ ] **Step 2: Run handler tests and verify failure** + +Run: + +```bash +(cd go-backend && go test ./internal/http/handler -run 'TestNftables' -count=1) +``` + +Expected: FAIL because `validateNftablesForwardRequest` and handler integration do not exist. + +- [ ] **Step 3: Initialize nftables manager on Handler** + +In `go-backend/internal/http/handler/handler.go`, import: + +```go + "go-backend/internal/runtime/nftables" +``` + +Add to `Handler`: + +```go + nftables *nftables.Manager +``` + +Initialize in `New`: + +```go + nftables: nftables.NewManager(nil), +``` + +- [ ] **Step 4: Add handler helpers** + +Create `go-backend/internal/http/handler/nftables_runtime.go`: + +```go +package handler + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go-backend/internal/http/response" + runtimenft "go-backend/internal/runtime/nftables" + "go-backend/internal/store/repo" +) + +func isNftablesMode(mode string) bool { + return strings.TrimSpace(strings.ToLower(mode)) == runtimenft.ModeNftables +} + +func (h *Handler) nodeForwardMode(nodeID int64) string { + mode, err := h.repo.GetNodeForwardMode(nodeID) + if err != nil { + return runtimenft.ModeAgent + } + return mode +} + +func (h *Handler) tunnelUsesNftables(tunnelID int64) bool { + nodes, err := h.tunnelEntryNodeIDs(tunnelID) + if err != nil || len(nodes) == 0 { + return false + } + return isNftablesMode(h.nodeForwardMode(nodes[0])) +} + +func (h *Handler) validateNftablesTunnelState(state *tunnelCreateState) error { + if state == nil || len(state.InNodes) == 0 { + return nil + } + mode := h.nodeForwardMode(state.InNodes[0].NodeID) + for _, n := range state.InNodes { + if h.nodeForwardMode(n.NodeID) != mode { + return errors.New("同一隧道不能混用 agent 和 nftables 节点") + } + } + if !isNftablesMode(mode) { + return nil + } + if state.Type != 1 { + return errors.New("nftables 节点不支持隧道转发") + } + if len(state.OutNodes) > 0 || len(state.ChainHops) > 0 { + return errors.New("nftables 纯转发不支持出口节点或转发链") + } + return nil +} + +func validateNftablesForwardRequest(req map[string]interface{}) error { + if req == nil { + return errors.New("请求参数错误") + } + if asAnyToInt64Ptr(req["speedId"]) != nil { + return errors.New("nftables 纯转发不支持限速") + } + if asAnyToInt64Ptr(req["ipSpeedId"]) != nil { + return errors.New("nftables 纯转发不支持每 IP 限速") + } + if asInt(req["maxConn"], 0) > 0 || asInt(req["ipMaxConn"], 0) > 0 { + return errors.New("nftables 纯转发不支持连接数限制") + } + if asInt(req["proxyProtocol"], 0) > 0 { + return errors.New("nftables 纯转发不支持 Proxy Protocol") + } + if _, err := runtimenft.ParseSingleTarget(asString(req["remoteAddr"])); err != nil { + return err + } + return nil +} + +func (h *Handler) sshConfigForNode(nodeID int64) (runtimenft.SSHConfig, error) { + cfg, err := h.repo.GetNodeSSHConfig(nodeID) + if err != nil { + return runtimenft.SSHConfig{}, err + } + return runtimenft.SSHConfig{ + Host: cfg.Host, + Port: cfg.Port, + Username: cfg.Username, + AuthType: cfg.AuthType, + Password: cfg.Password.String, + PrivateKey: cfg.PrivateKey.String, + Passphrase: cfg.Passphrase.String, + SudoMode: cfg.SudoMode, + }, nil +} + +func (h *Handler) syncNftablesNode(nodeID int64) error { + cfg, err := h.sshConfigForNode(nodeID) + if err != nil { + return fmt.Errorf("读取 SSH 配置失败: %w", err) + } + plan, err := h.buildNftablesNodePlan(nodeID) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + result, err := h.nftables.Reconcile(ctx, cfg, plan) + now := time.Now().UnixMilli() + if err != nil { + for _, rule := range plan.Rules { + _ = h.repo.MarkNftRuleBindingError(rule.ForwardID, nodeID, err.Error(), now) + } + return err + } + for _, rule := range plan.Rules { + _ = h.repo.UpsertNftRuleBinding(repoBindingFromRule(nodeID, rule, result.Hashes[rule.ForwardID]), now) + } + return nil +} + +func repoBindingFromRule(nodeID int64, rule runtimenft.Rule, hash string) repo.NftRuleBindingInput { + return repo.NftRuleBindingInput{ + ForwardID: rule.ForwardID, + NodeID: nodeID, + InPort: rule.InPort, + Protocols: strings.Join(rule.Protocols, ","), + TargetAddr: fmt.Sprintf("%s:%d", rule.TargetHost, rule.TargetPort), + BindIP: rule.BindIP, + RuleHash: hash, + Status: runtimenft.StatusApplied, + LastError: "", + } +} +``` + +- [ ] **Step 5: Add build plan helper** + +Append to `nftables_runtime.go`: + +```go +func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, error) { + forwards, err := h.repo.ListActiveForwardsByEntryNode(nodeID) + if err != nil { + return runtimenft.NodePlan{}, err + } + plan := runtimenft.NodePlan{NodeID: nodeID, Rules: make([]runtimenft.Rule, 0, len(forwards))} + for _, f := range forwards { + target, err := runtimenft.ParseSingleTarget(f.RemoteAddr) + if err != nil { + return runtimenft.NodePlan{}, fmt.Errorf("规则 %d 目标地址无效: %w", f.ID, err) + } + port := 0 + bindIP := "" + ports, err := h.listForwardPorts(f.ID) + if err != nil { + return runtimenft.NodePlan{}, err + } + for _, p := range ports { + if p.NodeID == nodeID { + port = p.Port + bindIP = p.InIP.String + break + } + } + if port <= 0 { + continue + } + plan.Rules = append(plan.Rules, runtimenft.Rule{ + ForwardID: f.ID, + InPort: port, + BindIP: bindIP, + TargetHost: target.Host, + TargetPort: target.Port, + Protocols: []string{"tcp", "udp"}, + }) + } + return plan, nil +} +``` + +Add `ListActiveForwardsByEntryNode` to `repository_nftables.go`: + +```go +func (r *Repository) ListActiveForwardsByEntryNode(nodeID int64) ([]model.ForwardRecord, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var rows []model.Forward + err := r.db. + Joins("JOIN forward_port ON forward_port.forward_id = forward.id"). + Where("forward_port.node_id = ? AND forward.status = ?", nodeID, 1). + Order("forward.id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + out := make([]model.ForwardRecord, 0, len(rows)) + for _, f := range rows { + out = append(out, model.ForwardRecord{ + ID: f.ID, + UserID: f.UserID, + UserName: f.UserName, + Name: f.Name, + TunnelID: f.TunnelID, + RemoteAddr: f.RemoteAddr, + Strategy: f.Strategy, + Status: f.Status, + SpeedID: f.SpeedID, + MaxConn: f.MaxConn, + IPMaxConn: f.IPMaxConn, + IPSpeedID: f.IPSpeedID, + ProxyProtocol: f.ProxyProtocol, + }) + } + return out, nil +} +``` + +- [ ] **Step 6: Route nftables forwards away from GOST sync** + +In `forwardCreate`, after loading `tunnel`, add: + +```go + nftTunnel := h.tunnelUsesNftables(tunnelID) + if nftTunnel { + if err := validateNftablesForwardRequest(req); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + } +``` + +After creating `createdForward`, replace the existing unconditional `syncForwardServices` block with: + +```go + if nftTunnel { + for _, nodeID := range entryNodes { + if err := h.syncNftablesNode(nodeID); err != nil { + _ = h.deleteForwardByID(forwardID) + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + } + } else if err := h.syncForwardServices(createdForward, "UpdateService", true); err != nil { + _ = h.deleteForwardByID(forwardID) + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } +``` + +Make the same pattern in `forwardUpdate`: validate when target tunnel uses nftables, and after `updatedForward` use `syncNftablesNode` for new entry nodes instead of `syncForwardServicesWithWarnings`. + +- [ ] **Step 7: Validate nftables tunnel creation** + +In `tunnelCreate`, after `runtimeState, err := h.prepareTunnelCreateState(...)` and before federation runtime, add: + +```go + if err := h.validateNftablesTunnelState(runtimeState); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } +``` + +In `tunnelUpdate`, add the same check after preparing update runtime state. + +- [ ] **Step 8: Skip GOST service sync for nftables forwards** + +At the top of `syncForwardServicesWithWarnings` in `control_plane.go`, after nil checks and before building services, add: + +```go + if h.tunnelUsesNftables(forward.TunnelID) { + entryNodes, _ := h.tunnelEntryNodeIDs(forward.TunnelID) + for _, nodeID := range entryNodes { + if err := h.syncNftablesNode(nodeID); err != nil { + return nil, err + } + } + return nil, nil + } +``` + +- [ ] **Step 9: Run handler tests** + +Run: + +```bash +(cd go-backend && go test ./internal/http/handler -run 'TestNftables' -count=1) +``` + +Expected: PASS. + +--- + +### Task 5: Delete, Redeploy, Test, And Clear Operations + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` +- Modify: `go-backend/internal/http/handler/mutations.go` +- Modify: `go-backend/internal/http/handler/nftables_runtime.go` +- Modify: `vite-frontend/src/api/index.ts` + +- [ ] **Step 1: Add routes** + +In `handler.go` route registration, add: + +```go + mux.HandleFunc("/api/v1/node/nftables/test", h.nodeNftablesTest) + mux.HandleFunc("/api/v1/node/nftables/reconcile", h.nodeNftablesReconcile) + mux.HandleFunc("/api/v1/node/nftables/clear", h.nodeNftablesClear) +``` + +- [ ] **Step 2: Implement maintenance handlers** + +Append to `nftables_runtime.go`: + +```go +func (h *Handler) nodeNftablesTest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + id := idFromBody(r, w) + if id <= 0 { + return + } + if !isNftablesMode(h.nodeForwardMode(id)) { + response.WriteJSON(w, response.ErrDefault("该节点不是 nftables 节点")) + return + } + cfg, err := h.sshConfigForNode(id) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + if err := h.nftables.Test(ctx, cfg); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} + +func (h *Handler) nodeNftablesReconcile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + id := idFromBody(r, w) + if id <= 0 { + return + } + if !isNftablesMode(h.nodeForwardMode(id)) { + response.WriteJSON(w, response.ErrDefault("该节点不是 nftables 节点")) + return + } + if err := h.syncNftablesNode(id); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} + +func (h *Handler) nodeNftablesClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + id := idFromBody(r, w) + if id <= 0 { + return + } + if !isNftablesMode(h.nodeForwardMode(id)) { + response.WriteJSON(w, response.ErrDefault("该节点不是 nftables 节点")) + return + } + cfg, err := h.sshConfigForNode(id) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + if err := h.nftables.Clear(ctx, cfg); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} +``` + +Ensure `nftables_runtime.go` imports `go-backend/internal/http/response`. + +- [ ] **Step 3: Integrate delete cleanup** + +In `deleteForwardByID`, before deleting DB rows, load the forward and entry nodes: + +```go + forward, fErr := h.getForwardRecord(id) + var nftNodeIDs []int64 + if fErr == nil && forward != nil && h.tunnelUsesNftables(forward.TunnelID) { + nftNodeIDs, _ = h.tunnelEntryNodeIDs(forward.TunnelID) + } +``` + +After DB delete succeeds, add: + +```go + if len(nftNodeIDs) > 0 { + for _, nodeID := range nftNodeIDs { + if err := h.syncNftablesNode(nodeID); err != nil { + return err + } + } + _ = h.repo.DeleteNftRuleBindingsByForward(id) + } +``` + +For `forwardForceDelete`, call `DeleteNftRuleBindingsByForward(id)` after the forced DB cleanup, but do not require SSH success. + +- [ ] **Step 4: Route batch redeploy through reconcile** + +In `forwardBatchRedeploy`, before `h.syncForwardServices`, add: + +```go + if h.tunnelUsesNftables(forward.TunnelID) { + entryNodes, _ := h.tunnelEntryNodeIDs(forward.TunnelID) + for _, nodeID := range entryNodes { + if err := h.syncNftablesNode(nodeID); err != nil { + failures = append(failures, batchOperationFailure{ID: id, Name: forward.Name, Error: err.Error()}) + } + } + continue + } +``` + +In `tunnelBatchRedeploy`, when a tunnel uses nftables, reconcile entry nodes and skip `applyTunnelRuntimeUpsert`. + +- [ ] **Step 5: Add frontend API methods** + +In `vite-frontend/src/api/index.ts`, add: + +```ts +export const testNodeNftables = (id: number) => + Network.post("/node/nftables/test", { id }); +export const reconcileNodeNftables = (id: number) => + Network.post("/node/nftables/reconcile", { id }); +export const clearNodeNftables = (id: number) => + Network.post("/node/nftables/clear", { id }); +``` + +- [ ] **Step 6: Run backend tests** + +Run: + +```bash +(cd go-backend && go test ./internal/http/handler -run 'TestNftables|TestForward' -count=1) +``` + +Expected: PASS. + +Run: + +```bash +(cd go-backend && go test ./...) +``` + +Expected: PASS. + +--- + +### Task 6: Frontend Node Mode And SSH Form + +**Files:** +- Modify: `vite-frontend/src/api/types.ts` +- Modify: `vite-frontend/src/pages/node.tsx` + +- [ ] **Step 1: Add API types** + +In `vite-frontend/src/api/types.ts`, add near node types: + +```ts +export type NodeForwardMode = "agent" | "nftables"; + +export interface NodeSSHConfigPayload { + host: string; + port: number; + username: string; + authType: "password" | "private_key"; + password?: string; + privateKey?: string; + passphrase?: string; + sudoMode: "none" | "sudo"; +} +``` + +Add to `NodeApiItem`: + +```ts + forwardMode?: NodeForwardMode; + sshConfig?: Partial; +``` + +- [ ] **Step 2: Extend node form state** + +In `vite-frontend/src/pages/node.tsx`, extend `NodeForm`: + +```ts + forwardMode: "agent" | "nftables"; + sshHost: string; + sshPort: string; + sshUsername: string; + sshAuthType: "password" | "private_key"; + sshPassword: string; + sshPrivateKey: string; + sshPassphrase: string; + sshSudoMode: "none" | "sudo"; +``` + +Update the reset/default form object to include: + +```ts + forwardMode: "agent", + sshHost: "", + sshPort: "22", + sshUsername: "root", + sshAuthType: "private_key", + sshPassword: "", + sshPrivateKey: "", + sshPassphrase: "", + sshSudoMode: "none", +``` + +In `handleEdit`, set these fields from `node.forwardMode` and `node.sshConfig`: + +```ts + forwardMode: node.forwardMode === "nftables" ? "nftables" : "agent", + sshHost: + typeof node.sshConfig?.host === "string" + ? node.sshConfig.host + : normalizedV4 || normalizedV6 || normalizedHost, + sshPort: + typeof node.sshConfig?.port === "number" + ? String(node.sshConfig.port) + : "22", + sshUsername: + typeof node.sshConfig?.username === "string" + ? node.sshConfig.username + : "root", + sshAuthType: + node.sshConfig?.authType === "password" ? "password" : "private_key", + sshPassword: "", + sshPrivateKey: "", + sshPassphrase: "", + sshSudoMode: node.sshConfig?.sudoMode === "sudo" ? "sudo" : "none", +``` + +- [ ] **Step 3: Add validation for nftables SSH fields** + +In `validateForm`, after host/port validation, add: + +```ts + if (form.forwardMode === "nftables") { + if (!form.sshHost.trim()) { + newErrors.sshHost = "请输入 SSH 主机"; + } + const sshPort = Number(form.sshPort); + if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) { + newErrors.sshPort = "SSH 端口必须在 1-65535 之间"; + } + if (!form.sshUsername.trim()) { + newErrors.sshUsername = "请输入 SSH 用户名"; + } + if (form.sshAuthType === "password" && !isEdit && !form.sshPassword.trim()) { + newErrors.sshPassword = "请输入 SSH 密码"; + } + if ( + form.sshAuthType === "private_key" && + !isEdit && + !form.sshPrivateKey.trim() + ) { + newErrors.sshPrivateKey = "请输入 SSH 私钥"; + } + } +``` + +- [ ] **Step 4: Submit mode and SSH payload** + +In `handleSubmit`, build `sshConfig` before `data`: + +```ts + const sshConfig = + form.forwardMode === "nftables" + ? { + host: form.sshHost.trim(), + port: Number(form.sshPort || 22), + username: form.sshUsername.trim(), + authType: form.sshAuthType, + password: + form.sshAuthType === "password" + ? form.sshPassword + : undefined, + privateKey: + form.sshAuthType === "private_key" + ? form.sshPrivateKey + : undefined, + passphrase: form.sshPassphrase, + sudoMode: form.sshSudoMode, + } + : undefined; +``` + +Add to submitted `data`: + +```ts + forwardMode: form.forwardMode, + ...(sshConfig ? { sshConfig } : {}), +``` + +- [ ] **Step 5: Add form controls** + +In the node modal after the node address fields, add controls using existing imported `Select`, `SelectItem`, `Input`, and `Textarea` components: + +```tsx + + + {form.forwardMode === "nftables" && ( +
+ + setForm((prev) => ({ ...prev, sshHost: value })) + } + /> + + setForm((prev) => ({ ...prev, sshPort: value })) + } + /> + + setForm((prev) => ({ ...prev, sshUsername: value })) + } + /> + + {form.sshAuthType === "password" ? ( + + setForm((prev) => ({ ...prev, sshPassword: value })) + } + /> + ) : ( +