diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 3fb5f32..9d425a7 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -417,6 +417,12 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) { newHTTP := asInt(req["http"], currentHTTP) newTLS := asInt(req["tls"], currentTLS) newSocks := asInt(req["socks"], currentSocks) + forwardMode := defaultNodeForwardMode(strings.TrimSpace(asString(req["forwardMode"]))) + currentForwardMode, err := h.repo.GetNodeForwardMode(id) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } serverIP := asString(req["serverIp"]) if serverIP != "" { if err := IsValidNodeAddress(serverIP); err != nil { @@ -424,7 +430,8 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) { return } } - if currentStatus == 1 && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) { + usesNftablesRuntime := forwardMode == "nftables" || defaultNodeForwardMode(currentForwardMode) == "nftables" + if currentStatus == 1 && !usesNftablesRuntime && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) { if err := h.applyNodeProtocolChange(id, newHTTP, newTLS, newSocks); err != nil { response.WriteJSON(w, response.ErrDefault(err.Error())) return @@ -432,7 +439,6 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) { } now := time.Now().UnixMilli() - forwardMode := defaultNodeForwardMode(strings.TrimSpace(asString(req["forwardMode"]))) if err := h.repo.UpdateNode(id, asString(req["name"]), serverIP, diff --git a/go-backend/internal/http/handler/nftables_runtime_test.go b/go-backend/internal/http/handler/nftables_runtime_test.go index b6b64a0..c75f7ab 100644 --- a/go-backend/internal/http/handler/nftables_runtime_test.go +++ b/go-backend/internal/http/handler/nftables_runtime_test.go @@ -345,6 +345,41 @@ func TestNodeUpdatePreservesExistingNftablesSecretsWhenFieldsOmitted(t *testing. } } +func TestNodeUpdateSkipsAgentProtocolCommandForNftablesNode(t *testing.T) { + fixture := setupNftablesHandler(t) + seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID) + + req := newAuthenticatedJSONRequest(t, map[string]interface{}{ + "id": fixture.nodeID, + "name": "nft-node-updated", + "serverIp": "198.51.100.10", + "serverIpV4": "198.51.100.10", + "port": "1000-65535", + "forwardMode": "nftables", + "http": 1, + "tls": 1, + "socks": 1, + "sshConfig": map[string]interface{}{ + "host": "203.0.113.30", + "port": 22, + "username": "admin", + "authType": "password", + "sudoMode": "none", + }, + }) + res := httptest.NewRecorder() + fixture.handler.nodeUpdate(res, req) + assertNftablesSuccessWithBody(t, res) + + cfg, err := fixture.handler.repo.GetNodeSSHConfig(fixture.nodeID) + if err != nil { + t.Fatalf("load ssh config: %v", err) + } + if !cfg.Password.Valid || cfg.Password.String != "secret" { + t.Fatalf("expected password secret to be preserved, got %+v", cfg) + } +} + func TestValidateNftablesForwardRequestRejectsHostnameTarget(t *testing.T) { fixture := setupNftablesHandler(t) h := fixture.handler diff --git a/go-backend/internal/store/repo/repository_nftables_test.go b/go-backend/internal/store/repo/repository_nftables_test.go index b73f385..786ef07 100644 --- a/go-backend/internal/store/repo/repository_nftables_test.go +++ b/go-backend/internal/store/repo/repository_nftables_test.go @@ -1,6 +1,7 @@ package repo import ( + "path/filepath" "strings" "testing" "time" @@ -123,6 +124,114 @@ func TestNftablesNodeModeSSHConfigAndBindingPersistence(t *testing.T) { } } +func TestNftablesNodeSSHConfigSurvivesRepositoryReopen(t *testing.T) { + tests := []struct { + name string + cfg NftSSHConfigInput + }{ + { + name: "password", + cfg: NftSSHConfigInput{ + Host: "203.0.113.10", + Port: 2222, + Username: "root", + AuthType: "password", + Password: "ssh-password", + Passphrase: "key-passphrase", + SudoMode: "sudo", + }, + }, + { + name: "private-key", + cfg: NftSSHConfigInput{ + Host: "203.0.113.11", + Port: 2223, + Username: "admin", + AuthType: "private_key", + PrivateKey: "PRIVATE-KEY-SHOULD-PERSIST", + Passphrase: "key-passphrase", + SudoMode: "sudo_su", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "nftables-ssh.sqlite") + r, err := Open(dbPath) + if err != nil { + t.Fatalf("open repo: %v", err) + } + + now := time.Now().UnixMilli() + if err := r.CreateNode( + "nft-node", + "secret", + tt.cfg.Host, + 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) + } + nodeID := nodes[0]["id"].(int64) + if err := r.UpsertNodeSSHConfig(nodeID, tt.cfg, now); err != nil { + t.Fatalf("UpsertNodeSSHConfig: %v", err) + } + if err := r.Close(); err != nil { + t.Fatalf("close repo: %v", err) + } + + reopened, err := Open(dbPath) + if err != nil { + t.Fatalf("reopen repo: %v", err) + } + defer reopened.Close() + + loaded, err := reopened.GetNodeSSHConfig(nodeID) + if err != nil { + t.Fatalf("GetNodeSSHConfig after reopen: %v", err) + } + if loaded.Host != tt.cfg.Host || loaded.Port != tt.cfg.Port || loaded.Username != tt.cfg.Username || loaded.AuthType != tt.cfg.AuthType || loaded.SudoMode != tt.cfg.SudoMode { + t.Fatalf("unexpected ssh config after reopen: %+v", loaded) + } + if tt.cfg.Password != "" && (!loaded.Password.Valid || loaded.Password.String != tt.cfg.Password) { + t.Fatalf("expected password to persist after reopen, got %+v", loaded.Password) + } + if tt.cfg.PrivateKey != "" && (!loaded.PrivateKey.Valid || loaded.PrivateKey.String != tt.cfg.PrivateKey) { + t.Fatalf("expected private key to persist after reopen, got %+v", loaded.PrivateKey) + } + if tt.cfg.Passphrase != "" && (!loaded.Passphrase.Valid || loaded.Passphrase.String != tt.cfg.Passphrase) { + t.Fatalf("expected passphrase to persist after reopen, got %+v", loaded.Passphrase) + } + }) + } +} + func TestUpdateNodeWithoutForwardModePreservesExistingMode(t *testing.T) { r, err := Open(":memory:") if err != nil {