From ab7499655bf0ab06135c80ef31858cba9b5454f1 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 3 Sep 2026 15:06:35 +0800 Subject: [PATCH] fix: store network exit credentials directly --- cmd/control-plane/hub.go | 60 ++---- cmd/control-plane/hub_test.go | 94 ++++------ cmd/control-plane/main.go | 2 +- cmd/control-plane/network.go | 66 ++----- cmd/control-plane/network_test.go | 174 +++++++++++++++++- internal/hub/environment.go | 88 ++++----- internal/hub/migration_test.go | 7 +- .../016_network_exit_plain_credentials.sql | 9 + internal/hub/store.go | 5 +- internal/hub/store_test.go | 42 ++++- web/src/AccountsPage.jsx | 6 +- web/src/BrowserImagesPage.jsx | 5 +- web/src/BrowserImagesPage.test.jsx | 16 ++ web/src/GatewaysPage.jsx | 1 + web/src/GatewaysPage.test.jsx | 30 +++ web/src/NetworkExitsPage.jsx | 28 ++- web/src/NetworkExitsPage.test.jsx | 42 ++++- 17 files changed, 432 insertions(+), 243 deletions(-) create mode 100644 internal/hub/migrations/016_network_exit_plain_credentials.sql diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go index 1f7c7b6..8635119 100644 --- a/cmd/control-plane/hub.go +++ b/cmd/control-plane/hub.go @@ -33,7 +33,7 @@ type hubStore interface { ListEnvs(ctx context.Context) ([]hub.Env, error) GetEnv(ctx context.Context, alias string) (hub.Env, error) UpgradeEnv(ctx context.Context, alias, version string) error - CreateNetworkExit(ctx context.Context, exit hub.NetworkExit, credentialReferenceID string) (hub.NetworkExit, error) + CreateNetworkExit(ctx context.Context, exit hub.NetworkExit) (hub.NetworkExit, error) ListNetworkExits(ctx context.Context) ([]hub.NetworkExit, error) GetNetworkExit(ctx context.Context, id string) (hub.NetworkExit, error) GetNetworkExitAccess(ctx context.Context, id string) (hub.NetworkExitAccess, error) @@ -451,7 +451,7 @@ func releaseRuntimeWithReconcileAudit(ctx context.Context, store hubStore, envir } func registerHub(app *fiber.App, store hubStore) { - registerHubWithNetwork(app, store, defaultNetworkExitProbe(), resolveExitCredential) + registerHubWithNetwork(app, store, defaultNetworkExitProbe(), nil) } func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) { @@ -827,12 +827,11 @@ func getNetworkExit(store hubStore) fiber.Handler { func createNetworkExit(store hubStore) fiber.Handler { return func(c fiber.Ctx) error { var input struct { - Protocol string `json:"protocol"` - Host string `json:"host"` - Port int `json:"port"` - CredentialReference struct { - ID string `json:"id"` - } `json:"credential_reference"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` ExpectedPublicIP string `json:"expected_public_ip"` ExpectedRegion string `json:"expected_region"` } @@ -841,8 +840,9 @@ func createNetworkExit(store hubStore) fiber.Handler { } exit, err := store.CreateNetworkExit(c.Context(), hub.NetworkExit{ Protocol: input.Protocol, Host: input.Host, Port: input.Port, + Username: input.Username, Password: input.Password, ExpectedPublicIP: input.ExpectedPublicIP, ExpectedRegion: input.ExpectedRegion, - }, input.CredentialReference.ID) + }) if err != nil { return hubError(c, err) } @@ -1104,7 +1104,7 @@ func runtimeRecoveryFailure(ctx context.Context, store hubStore, alias string, e } func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkExitProbe, - resolve func(hub.NetworkExitAccess) (string, error), environment hub.EnvironmentContext, container containerStatus) (bool, error) { + _ func(hub.NetworkExitAccess) (string, error), environment hub.EnvironmentContext, container containerStatus) (bool, error) { if environment.RuntimeCleanupPending { target, err := store.GetGateway(ctx, environment.Gateway) if err != nil { @@ -1131,10 +1131,7 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE } networkExit := gatewayNetworkExit{} if environment.Exit.ID != "" { - networkExit, err = gatewayNetworkExitFor(access, resolve) - if err != nil { - return false, discardRuntime(ctx, store, environment) - } + networkExit = gatewayNetworkExitFor(access) } if containerMatchesBinding(container, environment) { if environment.Exit.ID == "" { @@ -1270,11 +1267,7 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw } networkExit := gatewayNetworkExit{} if input.NetworkExitID != "" { - networkExit, err = gatewayNetworkExitFor(access, resolve) - if err != nil { - _ = finish("failed", "credential_unavailable", environment) - return hubError(c, hub.ErrConflict) - } + networkExit = gatewayNetworkExitFor(access) } if !created { container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, env.Alias) @@ -1491,7 +1484,7 @@ func stopEnvironmentRuntime(ctx context.Context, store runtimeStopStore, environ return finish("succeeded", "environment_stopped") } -func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), c fiber.Ctx, +func startBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkExitAccess) (string, error), c fiber.Ctx, environment hub.EnvironmentContext, finish func(string, string, hub.EnvironmentContext) error) error { if !accountRunnable(environment) { return hubError(c, hub.ErrConflict) @@ -1539,15 +1532,7 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo } networkExit := gatewayNetworkExit{} if environment.Exit.ID != "" { - networkExit, err = gatewayNetworkExitFor(access, resolve) - if err != nil { - if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil { - _ = finish("unknown", "cleanup_result_unknown", environment) - return hubError(c, cleanupErr) - } - _ = finish("failed", "credential_unavailable", environment) - return hubError(c, hub.ErrConflict) - } + networkExit = gatewayNetworkExitFor(access) } container, found, err := reconcileGatewayContainer(c.Context(), gateway, environment.Alias) if err != nil { @@ -1605,7 +1590,7 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo return c.SendStatus(fiber.StatusNoContent) } -func upgradeBrowser(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error { +func upgradeBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error { var input struct { Version string `json:"version"` } @@ -1666,11 +1651,7 @@ func upgradeBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Net } } running := accountRunnable(environment) - networkExit, err := gatewayNetworkExitFor(access, resolve) - if err != nil { - _ = finish("failed", "credential_unavailable", environment) - return hubError(c, hub.ErrConflict) - } + networkExit := gatewayNetworkExitFor(access) // 先删容器(保留卷);404 视为已删除,保证升级可重试。 if _, removeErr := removeGatewayRuntime(c.Context(), store, gateway, environment); removeErr != nil { _ = finish("unknown", "cleanup_result_unknown", environment) @@ -1728,7 +1709,7 @@ type runtimeCreateSpec struct { networkExit gatewayNetworkExit } -func prepareRuntimeCreate(ctx context.Context, store hubStore, resolve func(hub.NetworkExitAccess) (string, error), +func prepareRuntimeCreate(ctx context.Context, store hubStore, _ func(hub.NetworkExitAccess) (string, error), environment hub.EnvironmentContext, access hub.NetworkExitAccess) (runtimeCreateSpec, error) { imageRef, err := store.ImageRef(ctx, environment.ImageVersion) if err != nil { @@ -1736,10 +1717,7 @@ func prepareRuntimeCreate(ctx context.Context, store hubStore, resolve func(hub. } networkExit := gatewayNetworkExit{} if environment.Exit.ID != "" { - networkExit, err = gatewayNetworkExitFor(access, resolve) - if err != nil { - return runtimeCreateSpec{}, err - } + networkExit = gatewayNetworkExitFor(access) } return runtimeCreateSpec{imageRef: imageRef, networkExit: networkExit}, nil } @@ -2022,7 +2000,7 @@ func rebindBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw } else if found { previousAccess, accessErr := store.GetNetworkExitAccess(c.Context(), before.Exit.ID) if accessErr != nil { - _ = finish("failed", "credential_unavailable", before) + _ = finish("failed", "exit_unavailable", before) return hubError(c, accessErr) } prepared, prepareErr := prepareRuntimeCreate(c.Context(), store, resolve, before, previousAccess) diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go index 1ebfc1e..c6d335d 100644 --- a/cmd/control-plane/hub_test.go +++ b/cmd/control-plane/hub_test.go @@ -292,13 +292,13 @@ func (s *memoryStore) UpgradeEnv(_ context.Context, alias, version string) error s.bindings[alias] = bound return nil } -func (s *memoryStore) CreateNetworkExit(_ context.Context, exit hub.NetworkExit, credentialID string) (hub.NetworkExit, error) { +func (s *memoryStore) CreateNetworkExit(_ context.Context, exit hub.NetworkExit) (hub.NetworkExit, error) { s.mu.Lock() defer s.mu.Unlock() - exit.ID, exit.HealthStatus, exit.Version = "exit-created", "unchecked", 1 - if credentialID != "" { - exit.CredentialReference = &hub.CredentialReference{ID: credentialID, Provider: "os_keyring"} + if len(exit.Username) > 255 || len(exit.Password) > 255 || (exit.Username == "" && exit.Password != "") { + return hub.NetworkExit{}, hub.ErrInvalid } + exit.ID, exit.HealthStatus, exit.Version = "exit-created", "unchecked", 1 s.exits[exit.ID] = exit return exit, nil } @@ -1690,7 +1690,7 @@ func TestUpgradeBrowserUsesCommittedPostgresBinding(t *testing.T) { t.Fatal(err) } } - exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}, "") + exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}) if err != nil { t.Fatal(err) } @@ -1872,7 +1872,7 @@ func newPostgresRebindFixture(t *testing.T, databaseURL string) postgresRebindFi if err := store.CreateImage(ctx, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true}); err != nil { t.Fatal(err) } - exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}, "") + exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}) if err != nil { t.Fatal(err) } @@ -4596,42 +4596,22 @@ func TestCleanupPendingBlocksEveryLifecyclePath(t *testing.T) { } func TestRebindPreparesRunningRuntimeBeforeDelete(t *testing.T) { - for _, test := range []struct { - name string - withImage bool - credential bool - }{ - {name: "image unavailable"}, - {name: "credential unavailable", withImage: true, credential: true}, - } { - t.Run(test.name, func(t *testing.T) { - store := newMemoryStore() - if test.withImage { - _ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true}) - } - store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} - if test.credential { - store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1, - CredentialReference: &hub.CredentialReference{ID: "credential-exit", Provider: "os_keyring"}} - } - store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} - store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", - BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container"} - gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ - ID: "old-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, - }}} - app := newTestAppWithNetwork(t, store, gateway, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { - return "", errors.New("credential unavailable") - }) + store := newMemoryStore() + store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} + store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} + store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", + BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container"} + gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ + ID: "old-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, + }}} + app := newTestAppWithNetwork(t, store, gateway, fakeExitProbe{}, nil) - response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-2"}`) - if response.Code < 400 || len(gateway.recorded()) != 1 || gateway.recorded()[0].method != http.MethodGet { - t.Fatalf("runtime preparation failure touched the old container: status=%d requests=%#v", response.Code, gateway.recorded()) - } - if after := store.bindings["account-a"]; after.BindingVersion != 1 || after.Exit.ID != "exit-1" || after.RuntimeID != "old-container" { - t.Fatalf("runtime preparation failure changed state: %#v", after) - } - }) + response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-2"}`) + if response.Code < 400 || len(gateway.recorded()) != 1 || gateway.recorded()[0].method != http.MethodGet { + t.Fatalf("runtime preparation failure touched the old container: status=%d requests=%#v", response.Code, gateway.recorded()) + } + if after := store.bindings["account-a"]; after.BindingVersion != 1 || after.Exit.ID != "exit-1" || after.RuntimeID != "old-container" { + t.Fatalf("runtime preparation failure changed state: %#v", after) } } @@ -4669,23 +4649,13 @@ func TestExistingEnvironmentCleanupNeverReturnsReusedSuccess(t *testing.T) { resolve func(hub.NetworkExitAccess) (string, error) }{ {name: "second probe fails", probe: &sequenceExitProbe{failures: []string{"", "exit_auth_failed"}}, - resolve: func(hub.NetworkExitAccess) (string, error) { return "username:password", nil }}, - {name: "second credential restore fails", probe: &sequenceExitProbe{}, resolve: func() func(hub.NetworkExitAccess) (string, error) { - calls := 0 - return func(hub.NetworkExitAccess) (string, error) { - calls++ - if calls == 2 { - return "", errors.New("credential unavailable") - } - return "username:password", nil - } - }()}, + resolve: func(hub.NetworkExitAccess) (string, error) { return "", nil }}, } { t.Run(test.name, func(t *testing.T) { store := newMemoryStore() store.exits["exit-1"] = hub.NetworkExit{ ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080, HealthStatus: "healthy", Version: 1, - CredentialReference: &hub.CredentialReference{ID: "credential-exit", Provider: "os_keyring"}, + Username: "username", Password: "password", } store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148.0.7778.215", Fingerprint: hub.Fingerprint{Seed: 2024, Platform: "windows", Timezone: "Asia/Shanghai"}} store.bindings["account-a"] = hub.EnvironmentContext{ @@ -4782,24 +4752,26 @@ func TestGatewayAndImageCRUDRoutes(t *testing.T) { } } -func TestNetworkExitRoutesAreStrictAndSecretFree(t *testing.T) { +func TestNetworkExitRoutesStoreAndExposePlainCredentials(t *testing.T) { store := newMemoryStore() gateway := &fakeGateway{token: "unit-test-gateway-token"} app := newTestApp(t, store, gateway) invalid := do(app, http.MethodPost, "/api/network-exits", - `{"protocol":"socks5","host":"proxy.example","port":1080,"credential_reference":{"id":"credential-a","key":"raw-value"}}`) + `{"protocol":"socks5","host":"proxy.example","port":1080,"password":"password-only"}`) if invalid.Code != http.StatusBadRequest || len(store.exits) != 1 { - t.Fatalf("raw credential fields must be rejected before persistence: status=%d exits=%#v", invalid.Code, store.exits) + t.Fatalf("password without username must be rejected: status=%d exits=%#v", invalid.Code, store.exits) } created := do(app, http.MethodPost, "/api/network-exits", - `{"protocol":"socks5","host":"proxy.example","port":1080,"credential_reference":{"id":"credential-a"},"expected_public_ip":"203.0.113.1","expected_region":"test"}`) - if created.Code != http.StatusCreated || strings.Contains(created.Body.String(), "raw-value") { - t.Fatalf("unexpected secret-bearing network exit response: status=%d body=%s", created.Code, created.Body.String()) + `{"protocol":"socks5","host":"proxy.example","port":1080,"username":"proxy-user","password":"plain-password","expected_public_ip":"203.0.113.1","expected_region":"test"}`) + if created.Code != http.StatusCreated || !strings.Contains(created.Body.String(), `"username":"proxy-user"`) || + !strings.Contains(created.Body.String(), `"password":"plain-password"`) { + t.Fatalf("network exit response must expose stored credentials: status=%d body=%s", created.Code, created.Body.String()) } detail := do(app, http.MethodGet, "/api/network-exits/exit-created", "") - if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"credential_reference":{"id":"credential-a"`) || strings.Contains(detail.Body.String(), "raw-value") { - t.Fatalf("network exit detail must expose only the credential reference: status=%d body=%s", detail.Code, detail.Body.String()) + if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"username":"proxy-user"`) || + !strings.Contains(detail.Body.String(), `"password":"plain-password"`) { + t.Fatalf("network exit detail must expose stored credentials: status=%d body=%s", detail.Code, detail.Body.String()) } checked := do(app, http.MethodPost, "/api/network-exits/exit-created/check", "") if checked.Code != http.StatusOK || !strings.Contains(checked.Body.String(), `"health_status":"healthy"`) { diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index af4da77..795ca59 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -119,7 +119,7 @@ func runtimeLeaseHeartbeat(ctx context.Context, store hubStore) { case <-ctx.Done(): return case <-ticker.C: - if err := reconcileRuntimeLeases(ctx, store, defaultNetworkExitProbe(), resolveExitCredential); err != nil && ctx.Err() == nil { + if err := reconcileRuntimeLeases(ctx, store, defaultNetworkExitProbe(), nil); err != nil && ctx.Err() == nil { logrus.WithField("service", "control-plane").WithError(err).Warn("runtime lease reconciliation failed") } } diff --git a/cmd/control-plane/network.go b/cmd/control-plane/network.go index fec5514..4d9ca00 100644 --- a/cmd/control-plane/network.go +++ b/cmd/control-plane/network.go @@ -2,9 +2,7 @@ package main import ( "context" - "crypto/sha256" "encoding/binary" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -12,7 +10,6 @@ import ( "net" "net/http" "net/url" - "os" "strings" "time" @@ -28,29 +25,23 @@ type networkExitProbe interface { type httpNetworkExitProbe struct { endpoint string client *http.Client - resolve func(hub.NetworkExitAccess) (string, error) } func defaultNetworkExitProbe() networkExitProbe { - return httpNetworkExitProbe{endpoint: networkExitObservationURL, client: &http.Client{Timeout: 20 * time.Second}, resolve: resolveExitCredential} + return httpNetworkExitProbe{endpoint: networkExitObservationURL, client: &http.Client{Timeout: 20 * time.Second}} } func (probe httpNetworkExitProbe) Check(ctx context.Context, exit hub.NetworkExitAccess) (hub.ExitObservation, string) { proxyURL := &url.URL{Scheme: exit.Protocol, Host: net.JoinHostPort(exit.Host, fmt.Sprint(exit.Port))} - proxyUsername := "" - if exit.CredentialReference != nil { - secret, err := probe.resolve(exit) - if err != nil { - return hub.ExitObservation{}, "credential_unavailable" - } - username, password, found := strings.Cut(secret, ":") - if !found || username == "" { - return hub.ExitObservation{}, "credential_invalid" - } - proxyUsername = username - proxyURL.User = url.UserPassword(username, password) + proxyUsername := exit.Username + if exit.Username != "" { + proxyURL.User = url.UserPassword(exit.Username, exit.Password) } - transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} + transport := http.DefaultTransport.(*http.Transport).Clone() + if configured, ok := probe.client.Transport.(*http.Transport); ok { + transport = configured.Clone() + } + transport.Proxy = http.ProxyURL(proxyURL) if exit.Protocol == "socks4" { transport.Proxy = nil transport.DialContext = socks4DialContext(proxyURL.Host, proxyUsername) @@ -134,24 +125,6 @@ func socks4DialContext(proxyAddress, userID string) func(context.Context, string } } -// Secret managers and keyring bridges inject the referenced value at process start. -// Only the resolved username:password value is kept in the request-local call stack. -func resolveExitCredential(exit hub.NetworkExitAccess) (string, error) { - if exit.CredentialReference == nil || exit.CredentialKey == "" { - return "", errors.New("credential reference unavailable") - } - value, ok := os.LookupEnv(credentialEnvironmentName(exit.CredentialKey)) - if !ok || value == "" { - return "", errors.New("credential value unavailable") - } - return value, nil -} - -func credentialEnvironmentName(key string) string { - digest := sha256.Sum256([]byte(key)) - return "CREATORHUB_CREDENTIAL_" + strings.ToUpper(hex.EncodeToString(digest[:])) -} - type gatewayNetworkExit struct { Protocol string `json:"protocol"` Host string `json:"host"` @@ -160,19 +133,12 @@ type gatewayNetworkExit struct { Password string `json:"password,omitempty"` } -func gatewayNetworkExitFor(exit hub.NetworkExitAccess, resolve func(hub.NetworkExitAccess) (string, error)) (gatewayNetworkExit, error) { - result := gatewayNetworkExit{Protocol: exit.Protocol, Host: exit.Host, Port: exit.Port} - if exit.CredentialReference == nil { - return result, nil +func gatewayNetworkExitFor(exit hub.NetworkExitAccess) gatewayNetworkExit { + return gatewayNetworkExit{ + Protocol: exit.Protocol, + Host: exit.Host, + Port: exit.Port, + Username: exit.Username, + Password: exit.Password, } - secret, err := resolve(exit) - if err != nil { - return gatewayNetworkExit{}, errors.New("credential unavailable") - } - username, password, found := strings.Cut(secret, ":") - if !found || username == "" { - return gatewayNetworkExit{}, errors.New("credential invalid") - } - result.Username, result.Password = username, password - return result, nil } diff --git a/cmd/control-plane/network_test.go b/cmd/control-plane/network_test.go index df02a27..f8078bc 100644 --- a/cmd/control-plane/network_test.go +++ b/cmd/control-plane/network_test.go @@ -1,17 +1,175 @@ package main import ( + "bufio" "context" + "encoding/base64" "encoding/binary" "encoding/json" "io" "net" + "net/http" + "net/http/httptest" + "net/url" "strings" "testing" + "time" "git.ipao.vip/rogee/creator-hub/internal/hub" ) +func TestHTTPNetworkExitProbeUsesStoredBasicAuth(t *testing.T) { + proxy := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + want := "Basic " + base64.StdEncoding.EncodeToString([]byte("operator:plain-password")) + if request.Header.Get("Proxy-Authorization") != want { + response.WriteHeader(http.StatusProxyAuthRequired) + return + } + _, _ = response.Write([]byte(`{"ip":"203.0.113.10","region":"Shanghai"}`)) + })) + defer proxy.Close() + + exit := networkExitForURL(t, "http", proxy.URL) + exit.Username, exit.Password = "operator", "plain-password" + observation, reason := (httpNetworkExitProbe{endpoint: "http://observation.test/json", client: &http.Client{Timeout: time.Second}}).Check(context.Background(), exit) + if reason != "" || observation.PublicIP != "203.0.113.10" || observation.Region != "Shanghai" { + t.Fatalf("authenticated HTTP probe failed: observation=%#v reason=%q", observation, reason) + } +} + +func TestHTTPSNetworkExitProbeUsesStoredBasicAuth(t *testing.T) { + proxy := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + want := "Basic " + base64.StdEncoding.EncodeToString([]byte("operator:plain-password")) + if request.Header.Get("Proxy-Authorization") != want { + response.WriteHeader(http.StatusProxyAuthRequired) + return + } + _, _ = response.Write([]byte(`{"ip":"203.0.113.12","region":"Shenzhen"}`)) + })) + defer proxy.Close() + + exit := networkExitForURL(t, "https", proxy.URL) + exit.Username, exit.Password = "operator", "plain-password" + client := proxy.Client() + client.Timeout = time.Second + observation, reason := (httpNetworkExitProbe{endpoint: "http://observation.test/json", client: client}).Check(context.Background(), exit) + if reason != "" || observation.PublicIP != "203.0.113.12" || observation.Region != "Shenzhen" { + t.Fatalf("authenticated HTTPS probe failed: observation=%#v reason=%q", observation, reason) + } +} + +func TestSOCKS5NetworkExitProbeUsesStoredCredentials(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan error, 1) + go func() { done <- serveAuthenticatedSOCKS5(listener, "operator", "plain-password") }() + + host, portText, _ := net.SplitHostPort(listener.Addr().String()) + exit := hub.NetworkExitAccess{NetworkExit: hub.NetworkExit{ + Protocol: "socks5", Host: host, Port: mustPort(t, portText), Username: "operator", Password: "plain-password", + }} + observation, reason := (httpNetworkExitProbe{endpoint: "http://observation.test/json", client: &http.Client{Timeout: time.Second}}).Check(context.Background(), exit) + if reason != "" || observation.PublicIP != "203.0.113.11" || observation.Region != "Beijing" { + t.Fatalf("authenticated SOCKS5 probe failed: observation=%#v reason=%q", observation, reason) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func networkExitForURL(t *testing.T, protocol, rawURL string) hub.NetworkExitAccess { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + host, portText, err := net.SplitHostPort(parsed.Host) + if err != nil { + t.Fatal(err) + } + return hub.NetworkExitAccess{NetworkExit: hub.NetworkExit{Protocol: protocol, Host: host, Port: mustPort(t, portText)}} +} + +func mustPort(t *testing.T, value string) int { + t.Helper() + port, err := net.LookupPort("tcp", value) + if err != nil { + t.Fatal(err) + } + return port +} + +func serveAuthenticatedSOCKS5(listener net.Listener, username, password string) error { + connection, err := listener.Accept() + if err != nil { + return err + } + defer connection.Close() + reader := bufio.NewReader(connection) + greeting := make([]byte, 2) + if _, err := io.ReadFull(reader, greeting); err != nil { + return err + } + methods := make([]byte, int(greeting[1])) + if _, err := io.ReadFull(reader, methods); err != nil { + return err + } + if _, err := connection.Write([]byte{5, 2}); err != nil { + return err + } + authHeader := make([]byte, 2) + if _, err := io.ReadFull(reader, authHeader); err != nil { + return err + } + user := make([]byte, int(authHeader[1])) + if _, err := io.ReadFull(reader, user); err != nil { + return err + } + passwordLength, err := reader.ReadByte() + if err != nil { + return err + } + secret := make([]byte, int(passwordLength)) + if _, err := io.ReadFull(reader, secret); err != nil { + return err + } + if string(user) != username || string(secret) != password { + return io.ErrUnexpectedEOF + } + if _, err := connection.Write([]byte{1, 0}); err != nil { + return err + } + requestHeader := make([]byte, 4) + if _, err := io.ReadFull(reader, requestHeader); err != nil { + return err + } + addressLength := 4 + switch requestHeader[3] { + case 3: + length, err := reader.ReadByte() + if err != nil { + return err + } + addressLength = int(length) + case 4: + addressLength = 16 + } + if _, err := io.CopyN(io.Discard, reader, int64(addressLength+2)); err != nil { + return err + } + if _, err := connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0}); err != nil { + return err + } + if _, err := http.ReadRequest(reader); err != nil { + return err + } + _, err = io.WriteString(connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 40\r\nConnection: close\r\n\r\n{\"ip\":\"203.0.113.11\",\"region\":\"Beijing\"}") + return err +} + func TestSOCKS4DialerUsesBoundProxy(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -62,20 +220,18 @@ func TestSOCKS4DialerUsesBoundProxy(t *testing.T) { } } -func TestGatewayNetworkExitResolvesCredentialWithoutPersistingIt(t *testing.T) { +func TestGatewayNetworkExitUsesStoredPlainCredentials(t *testing.T) { exit := hub.NetworkExitAccess{NetworkExit: hub.NetworkExit{ Protocol: "socks5", Host: "proxy.example", Port: 1080, - CredentialReference: &hub.CredentialReference{ID: "credential-a", Provider: "os_keyring"}, + Username: "operator", Password: "plain-password", }} - gatewayExit, err := gatewayNetworkExitFor(exit, func(hub.NetworkExitAccess) (string, error) { - return "operator:ephemeral-value", nil - }) - if err != nil || gatewayExit.Username != "operator" || gatewayExit.Password != "ephemeral-value" || gatewayExit.Host != "proxy.example" { - t.Fatalf("credential was not resolved into the request-local gateway payload: %#v err=%v", gatewayExit, err) + gatewayExit := gatewayNetworkExitFor(exit) + if gatewayExit.Username != "operator" || gatewayExit.Password != "plain-password" || gatewayExit.Host != "proxy.example" { + t.Fatalf("stored credential was not copied into the gateway payload: %#v", gatewayExit) } encoded := string(mustJSON(t, exit.NetworkExit)) - if strings.Contains(encoded, "ephemeral-value") { - t.Fatalf("network exit persistence model contains resolved credential: %s", encoded) + if !strings.Contains(encoded, `"username":"operator"`) || !strings.Contains(encoded, `"password":"plain-password"`) { + t.Fatalf("network exit API model must expose stored credentials: %s", encoded) } } diff --git a/internal/hub/environment.go b/internal/hub/environment.go index aca4b7c..5afea3e 100644 --- a/internal/hub/environment.go +++ b/internal/hub/environment.go @@ -17,33 +17,28 @@ import ( var exitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) -type CredentialReference struct { - ID string `json:"id"` - Provider string `json:"provider"` -} - type NetworkExit struct { - ID string `json:"id"` - Protocol string `json:"protocol"` - Host string `json:"host"` - Port int `json:"port"` - CredentialReference *CredentialReference `json:"credential_reference,omitempty"` - ExpectedPublicIP string `json:"expected_public_ip,omitempty"` - ExpectedRegion string `json:"expected_region,omitempty"` - ObservedPublicIP string `json:"observed_public_ip,omitempty"` - ObservedRegion string `json:"observed_region,omitempty"` - HealthStatus string `json:"health_status"` - LastCheckReason string `json:"last_check_reason,omitempty"` - Version int64 `json:"version"` - LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + ExpectedPublicIP string `json:"expected_public_ip,omitempty"` + ExpectedRegion string `json:"expected_region,omitempty"` + ObservedPublicIP string `json:"observed_public_ip,omitempty"` + ObservedRegion string `json:"observed_region,omitempty"` + HealthStatus string `json:"health_status"` + LastCheckReason string `json:"last_check_reason,omitempty"` + Version int64 `json:"version"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// NetworkExitAccess is internal-only: reference keys are never serialized or audited. +// NetworkExitAccess is the internal runtime view of a persisted network exit. type NetworkExitAccess struct { NetworkExit - CredentialKey string `json:"-"` } type ExitObservation struct { @@ -83,18 +78,17 @@ type EnvironmentAction struct { ReasonCode string } -func (s *Store) CreateNetworkExit(ctx context.Context, exit NetworkExit, credentialReferenceID string) (NetworkExit, error) { +func (s *Store) CreateNetworkExit(ctx context.Context, exit NetworkExit) (NetworkExit, error) { exit.ID = "exit-" + newHubID() exit.Protocol, exit.Host = strings.ToLower(strings.TrimSpace(exit.Protocol)), strings.TrimSpace(exit.Host) exit.ExpectedPublicIP, exit.ExpectedRegion = strings.TrimSpace(exit.ExpectedPublicIP), strings.TrimSpace(exit.ExpectedRegion) - credentialReferenceID = strings.TrimSpace(credentialReferenceID) - if !validNetworkExit(exit) || (credentialReferenceID != "" && !exitIDPattern.MatchString(credentialReferenceID)) { + if !validNetworkExit(exit) { return NetworkExit{}, ErrInvalid } row := s.db.QueryRowContext(ctx, ` - INSERT INTO network_exit (id, protocol, host, port, credential_reference_id, expected_public_ip, expected_region) - VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, '')::inet, $7) - RETURNING id`, exit.ID, exit.Protocol, exit.Host, exit.Port, credentialReferenceID, exit.ExpectedPublicIP, exit.ExpectedRegion) + INSERT INTO network_exit (id, protocol, host, port, username, password, expected_public_ip, expected_region) + VALUES ($1, $2, $3, $4, $5, $6, NULLIF($7, '')::inet, $8) + RETURNING id`, exit.ID, exit.Protocol, exit.Host, exit.Port, exit.Username, exit.Password, exit.ExpectedPublicIP, exit.ExpectedRegion) if err := row.Scan(&exit.ID); err != nil { return NetworkExit{}, publicDatabaseError(err) } @@ -105,7 +99,7 @@ func validNetworkExit(exit NetworkExit) bool { if exit.Protocol != "http" && exit.Protocol != "https" && exit.Protocol != "socks4" && exit.Protocol != "socks5" { return false } - if !validExitHost(exit.Host) || exit.Port < 1 || exit.Port > 65535 { + if !validExitHost(exit.Host) || exit.Port < 1 || exit.Port > 65535 || !validExitCredentials(exit.Username, exit.Password) { return false } if exit.ExpectedPublicIP != "" && net.ParseIP(exit.ExpectedPublicIP) == nil { @@ -114,6 +108,18 @@ func validNetworkExit(exit NetworkExit) bool { return validOptionalRegion(exit.ExpectedRegion) } +func validExitCredentials(username, password string) bool { + if len(username) > 255 || len(password) > 255 || (username == "" && password != "") { + return false + } + for _, value := range username + password { + if value < 0x20 || value == 0x7f { + return false + } + } + return true +} + func validExitHost(host string) bool { if host == "" || len(host) > 253 || strings.ContainsAny(host, "@/[]?# \t\r\n") { return false @@ -176,43 +182,27 @@ func (s *Store) GetNetworkExit(ctx context.Context, id string) (NetworkExit, err func (s *Store) GetNetworkExitAccess(ctx context.Context, id string) (NetworkExitAccess, error) { exit, err := s.GetNetworkExit(ctx, id) - if err != nil { - return NetworkExitAccess{}, err - } - access := NetworkExitAccess{NetworkExit: exit} - if exit.CredentialReference != nil { - if err := s.db.QueryRowContext(ctx, `SELECT reference_key FROM credential_reference WHERE id = $1`, exit.CredentialReference.ID). - Scan(&access.CredentialKey); err != nil { - return NetworkExitAccess{}, rowError(err) - } - } - return access, nil + return NetworkExitAccess{NetworkExit: exit}, err } const networkExitSelect = ` - SELECT network.id, network.protocol, network.host, network.port, - reference.id, reference.provider, + SELECT network.id, network.protocol, network.host, network.port, network.username, network.password, COALESCE(host(network.expected_public_ip), ''), network.expected_region, COALESCE(host(network.observed_public_ip), ''), network.observed_region, network.health_status, COALESCE(network.last_check_reason, ''), network.version, network.last_checked_at, network.created_at, network.updated_at - FROM network_exit network - LEFT JOIN credential_reference reference ON reference.id = network.credential_reference_id` + FROM network_exit network` type rowScanner interface{ Scan(...any) error } func scanNetworkExit(row rowScanner) (NetworkExit, error) { var exit NetworkExit - var referenceID, provider sql.NullString var checked sql.NullTime - if err := row.Scan(&exit.ID, &exit.Protocol, &exit.Host, &exit.Port, &referenceID, &provider, + if err := row.Scan(&exit.ID, &exit.Protocol, &exit.Host, &exit.Port, &exit.Username, &exit.Password, &exit.ExpectedPublicIP, &exit.ExpectedRegion, &exit.ObservedPublicIP, &exit.ObservedRegion, &exit.HealthStatus, &exit.LastCheckReason, &exit.Version, &checked, &exit.CreatedAt, &exit.UpdatedAt); err != nil { return NetworkExit{}, rowError(err) } - if referenceID.Valid { - exit.CredentialReference = &CredentialReference{ID: referenceID.String, Provider: provider.String} - } if checked.Valid { exit.LastCheckedAt = &checked.Time } diff --git a/internal/hub/migration_test.go b/internal/hub/migration_test.go index 7a51fc6..929eb84 100644 --- a/internal/hub/migration_test.go +++ b/internal/hub/migration_test.go @@ -33,16 +33,17 @@ func TestUnifiedAccountMigration(t *testing.T) { t.Fatal(err) } defer db.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 15`, 15) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 16`, 16) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 4) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name IN ('name', 'tags')`, 2) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name = 'runtime_cleanup_pending'`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name LIKE 'runtime_cleanup_%'`, 5) + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'network_exit' AND column_name IN ('username', 'password')`, 2) + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'network_exit' AND column_name = 'credential_reference_id'`, 0) store = openFullyMigratedHub(t, ctx, testURL) store.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 15`, 15) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 16`, 16) }) t.Run("legacy migration 013 without account secrets is repaired forward", func(t *testing.T) { diff --git a/internal/hub/migrations/016_network_exit_plain_credentials.sql b/internal/hub/migrations/016_network_exit_plain_credentials.sql new file mode 100644 index 0000000..b65cb55 --- /dev/null +++ b/internal/hub/migrations/016_network_exit_plain_credentials.sql @@ -0,0 +1,9 @@ +-- 内部系统直接保存并使用网络出口认证信息;移除外部凭据引用链路。 +ALTER TABLE network_exit + ADD COLUMN username text NOT NULL DEFAULT '' CHECK (length(username) <= 255), + ADD COLUMN password text NOT NULL DEFAULT '' CHECK (length(password) <= 255), + DROP COLUMN credential_reference_id; + +ALTER TABLE network_exit + ADD CONSTRAINT network_exit_credentials_pair_check + CHECK (password = '' OR username <> ''); diff --git a/internal/hub/store.go b/internal/hub/store.go index 5704f72..cabade0 100644 --- a/internal/hub/store.go +++ b/internal/hub/store.go @@ -63,6 +63,9 @@ var migration014 string //go:embed migrations/015_gateway_rename_cascade.sql var migration015 string +//go:embed migrations/016_network_exit_plain_credentials.sql +var migration016 string + var ( ErrConflict = errors.New("resource conflicts with existing state") ErrInvalid = errors.New("invalid hub input") @@ -219,7 +222,7 @@ func (s *Store) migrate(ctx context.Context) error { for _, migration := range []struct { version int sql string - }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}} { + }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}, {16, migration016}} { var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { return errors.New("read hub schema migration state") diff --git a/internal/hub/store_test.go b/internal/hub/store_test.go index 163fb7b..5fe0a39 100644 --- a/internal/hub/store_test.go +++ b/internal/hub/store_test.go @@ -16,6 +16,28 @@ import ( "git.ipao.vip/rogee/creator-hub/internal/taskstate" ) +func TestNetworkExitCredentialValidation(t *testing.T) { + valid := NetworkExit{Protocol: "socks5", Host: "proxy.example", Port: 1080, Username: "operator", Password: "plain-password"} + if !validNetworkExit(valid) { + t.Fatal("valid stored credentials were rejected") + } + for name, mutate := range map[string]func(*NetworkExit){ + "password without username": func(exit *NetworkExit) { exit.Username = "" }, + "username too long": func(exit *NetworkExit) { exit.Username = strings.Repeat("u", 256) }, + "password too long": func(exit *NetworkExit) { exit.Password = strings.Repeat("p", 256) }, + "username control character": func(exit *NetworkExit) { exit.Username = "operator\n" }, + "password control character": func(exit *NetworkExit) { exit.Password = "plain\x7fpassword" }, + } { + t.Run(name, func(t *testing.T) { + exit := valid + mutate(&exit) + if validNetworkExit(exit) { + t.Fatalf("invalid credentials were accepted: %#v", exit) + } + }) + } +} + func TestEnvironmentLocksCoordinateAcrossStoreInstances(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { @@ -262,7 +284,7 @@ func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { "ip": {Protocol: "socks5", Host: "proxy.example", Port: 1080, ExpectedPublicIP: "not-an-ip"}, } { t.Run("network exit "+name, func(t *testing.T) { - if _, err := store.CreateNetworkExit(ctx, exit, ""); !errors.Is(err, ErrInvalid) { + if _, err := store.CreateNetworkExit(ctx, exit); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid network exit, got %v", err) } }) @@ -415,8 +437,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { } if _, err := store.db.ExecContext(ctx, ` INSERT INTO credential_reference (id, provider, reference_key) - VALUES ('credential-exit', 'os_keyring', 'creatorhub/proxy-main'), - ('credential-account', 'os_keyring', 'creatorhub/account-a'); + VALUES ('credential-account', 'os_keyring', 'creatorhub/account-a'); INSERT INTO social_account (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status) VALUES ('account-a', 'credential-account', 'mock', 'account-a', 'owned', 'authorized')`); err != nil { @@ -431,18 +452,19 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { exit, err := store.CreateNetworkExit(ctx, NetworkExit{ Protocol: "socks5", Host: "proxy.example", Port: 1080, + Username: "proxy-user", Password: "plain-password", ExpectedPublicIP: "203.0.113.10", ExpectedRegion: "test-region", - }, "credential-exit") - if err != nil || exit.HealthStatus != "unchecked" || exit.CredentialReference == nil || exit.CredentialReference.ID != "credential-exit" { + }) + if err != nil || exit.HealthStatus != "unchecked" || exit.Username != "proxy-user" || exit.Password != "plain-password" { t.Fatalf("unexpected network exit: %#v err=%v", exit, err) } exported, _ := json.Marshal(exit) - if strings.Contains(string(exported), "creatorhub/proxy-main") { - t.Fatalf("network exit response leaked a credential reference key: %s", exported) + if !strings.Contains(string(exported), `"username":"proxy-user"`) || !strings.Contains(string(exported), `"password":"plain-password"`) { + t.Fatalf("network exit response must include stored credentials: %s", exported) } access, err := store.GetNetworkExitAccess(ctx, exit.ID) - if err != nil || access.CredentialKey != "creatorhub/proxy-main" { - t.Fatalf("runtime-only credential resolution data unavailable: %#v err=%v", access, err) + if err != nil || access.Username != "proxy-user" || access.Password != "plain-password" { + t.Fatalf("runtime network exit credentials unavailable: %#v err=%v", access, err) } exit, reason, err := store.RecordNetworkExitCheck(ctx, exit.ID, ExitObservation{PublicIP: "203.0.113.11", Region: "test-region"}, "") @@ -475,7 +497,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { AND network_exit_id = $1 AND runtime_instance_id = $2 AND binding_version = $3 AND details = '{}'::jsonb`, 1, active.Exit.ID, active.RuntimeInstanceID, active.BindingVersion) - second, err := store.CreateNetworkExit(ctx, NetworkExit{Protocol: "http", Host: "proxy-2.example", Port: 8080}, "") + second, err := store.CreateNetworkExit(ctx, NetworkExit{Protocol: "http", Host: "proxy-2.example", Port: 8080}) if err != nil { t.Fatal(err) } diff --git a/web/src/AccountsPage.jsx b/web/src/AccountsPage.jsx index c42167f..fa2d840 100644 --- a/web/src/AccountsPage.jsx +++ b/web/src/AccountsPage.jsx @@ -133,7 +133,10 @@ function AccountCreateModal({ open, onClose, onSubmit, busy, error }) { // cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐 if (form.cookies.trim()) data.cookies = form.cookies.trim(); const created = await onSubmit(data); - if (created) setForm(createInitial); + if (created) { + setForm(createInitial); + onClose(); + } } return ( @@ -290,7 +293,6 @@ export function AccountList() { variant: "success", text: "账号已创建;绑定健康出口和运行环境后方可恢复。", }); - setCreateOpen(false); return true; } catch (reason) { setCreateError(reason); diff --git a/web/src/BrowserImagesPage.jsx b/web/src/BrowserImagesPage.jsx index dfd8318..79637fb 100644 --- a/web/src/BrowserImagesPage.jsx +++ b/web/src/BrowserImagesPage.jsx @@ -21,7 +21,10 @@ function ImageCreateModal({ open, onClose, onSubmit, busy, error }) { event.preventDefault() if (!valid) return const created = await onSubmit({ version: form.version, image_ref: form.image_ref, note: form.note, enabled: true }) - if (created) setForm({ version: '', image_ref: '', note: '' }) + if (created) { + setForm({ version: '', image_ref: '', note: '' }) + onClose() + } } return ( diff --git a/web/src/BrowserImagesPage.test.jsx b/web/src/BrowserImagesPage.test.jsx index 76133af..4ed4bf0 100644 --- a/web/src/BrowserImagesPage.test.jsx +++ b/web/src/BrowserImagesPage.test.jsx @@ -44,6 +44,22 @@ describe('BrowserImageList', () => { fireEvent.click(within(dialog).getByRole('button', { name: '添加版本' })) await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith({ resource: 'browser-images', variables: { version: '150.0.0.1', image_ref: 'reg/img:150', note: '', enabled: true } })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + it('keeps the create modal open after a failure', async () => { + const dataProvider = provider({ create: vi.fn().mockRejectedValue(new Error('镜像创建失败')) }) + renderImages(dataProvider) + + await screen.findAllByText('reg/img:148') + fireEvent.click(screen.getByRole('button', { name: '添加版本' })) + const dialog = screen.getByRole('dialog') + fireEvent.change(within(dialog).getByRole('textbox', { name: '版本' }), { target: { value: '150.0.0.1' } }) + fireEvent.change(within(dialog).getByRole('textbox', { name: '镜像引用' }), { target: { value: 'reg/img:150' } }) + fireEvent.click(within(dialog).getByRole('button', { name: '添加版本' })) + + expect((await within(dialog).findByRole('alert')).textContent).toContain('镜像创建失败') + expect(screen.getByRole('dialog')).toBeTruthy() }) it('toggles enabled through update', async () => { diff --git a/web/src/GatewaysPage.jsx b/web/src/GatewaysPage.jsx index 067e5d9..ed16684 100644 --- a/web/src/GatewaysPage.jsx +++ b/web/src/GatewaysPage.jsx @@ -105,6 +105,7 @@ function GatewayFormModal({ open, onClose, onSubmit, busy, error, initial }) { token, }) ) { + setForm({ name: "", endpoint: "", token: "" }); onClose(); } } diff --git a/web/src/GatewaysPage.test.jsx b/web/src/GatewaysPage.test.jsx index bfc2d1a..0e62bd6 100644 --- a/web/src/GatewaysPage.test.jsx +++ b/web/src/GatewaysPage.test.jsx @@ -105,6 +105,36 @@ describe("GatewayList", () => { expect((await screen.findByRole("alert")).textContent).toContain( "generated-token-abcdef", ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + fireEvent.click(screen.getByRole("button", { name: "注册网关" })); + const reopened = screen.getByRole("dialog"); + expect(within(reopened).getByRole("textbox", { name: "名称" }).value).toBe(""); + expect(within(reopened).getByRole("textbox", { name: "Endpoint" }).value).toBe(""); + }); + + it("keeps the registration modal open after a failure", async () => { + const dataProvider = provider({ + create: vi.fn().mockRejectedValue(new Error("网关名称已存在")), + }); + renderGateways(dataProvider); + await screen.findAllByText("gw-1"); + + fireEvent.click(screen.getByRole("button", { name: "注册网关" })); + const dialog = screen.getByRole("dialog"); + fireEvent.change(within(dialog).getByRole("textbox", { name: "名称" }), { + target: { value: "gw-2" }, + }); + fireEvent.change( + within(dialog).getByRole("textbox", { name: "Endpoint" }), + { target: { value: "http://gw2:8081" } }, + ); + fireEvent.click(within(dialog).getByRole("button", { name: "注册网关" })); + + expect((await within(dialog).findByRole("alert")).textContent).toContain( + "网关名称已存在", + ); + expect(screen.getByRole("dialog")).toBeTruthy(); }); it("rejects an invalid endpoint before submit", async () => { diff --git a/web/src/NetworkExitsPage.jsx b/web/src/NetworkExitsPage.jsx index 417356b..b271c02 100644 --- a/web/src/NetworkExitsPage.jsx +++ b/web/src/NetworkExitsPage.jsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { Link, useNavigate, useParams } from 'react-router' +import { Link, useParams } from 'react-router' import { useDataProvider, useList, useOne } from '@refinedev/core' import { Alert, Button, Card, CardContent, ConfirmDialog, DetailList, Field, Input, Modal, PageHeader, @@ -7,7 +7,7 @@ import { } from './lib/ui.jsx' import { useTitle } from './lib/hooks.js' -const createInitial = { protocol: 'socks5', host: '', port: '', credential_reference_id: '', expected_public_ip: '', expected_region: '' } +const createInitial = { protocol: 'socks5', host: '', port: '', username: '', password: '', expected_public_ip: '', expected_region: '' } const healthText = { unchecked: '未检测', healthy: '健康', unhealthy: '不健康', disabled: '已停用' } const protocolOptions = ['http', 'https', 'socks4', 'socks5'].map(value => ({ value, label: value })) @@ -31,17 +31,21 @@ function ExitCreateModal({ open, onClose, onSubmit, busy, error }) { const [form, setForm] = useState(createInitial) const update = (key, value) => setForm(current => ({ ...current, [key]: value })) const port = Number(form.port) - const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535 + const credentialsValid = !form.password || !!form.username + const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535 && credentialsValid async function submit(event) { event.preventDefault() if (!valid) return const created = await onSubmit({ protocol: form.protocol, host: form.host.trim(), port, - credential_reference: { id: form.credential_reference_id.trim() }, + username: form.username, password: form.password, expected_public_ip: form.expected_public_ip.trim(), expected_region: form.expected_region.trim(), }) - if (created) setForm(createInitial) + if (created) { + setForm(createInitial) + onClose() + } } return ( @@ -51,7 +55,7 @@ function ExitCreateModal({ open, onClose, onSubmit, busy, error }) { }>
- {error ? {conflictMessage(error, '出口或认证引用与现有资源冲突;表单内容已保留。')} : null} + {error ? {conflictMessage(error, '出口地址或认证信息与现有资源冲突;表单内容已保留。')} : null}
update('port', event.target.value)} /> - - update('credential_reference_id', event.target.value)} /> + + update('username', event.target.value)} /> + + + update('password', event.target.value)} invalid={!credentialsValid} /> update('expected_public_ip', event.target.value)} /> @@ -84,7 +91,7 @@ function ExitCard({ exit, boundAccounts, bindingsError, busy, onAction }) {
{exit.protocol}://{exit.host}:{exit.port} -

{exit.id} · 认证 {exit.credential_reference?.id || '无'}

+

{exit.id} · 用户名 {exit.username || '无'} · 密码 {exit.password || '无'}

@@ -204,7 +211,8 @@ export function NetworkExitDetail() { {exit.id}], ['健康 / 版本', {healthText[exit.health_status] ?? exit.health_status} · {exit.version}], - ['认证', {exit.credential_reference?.id || '无'}], + ['用户名', {exit.username || '无'}], + ['密码', {exit.password || '无'}], ['出口IP', {exit.observed_public_ip || '尚无观测'}], ['最近检测', exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'], ]} /> diff --git a/web/src/NetworkExitsPage.test.jsx b/web/src/NetworkExitsPage.test.jsx index a1b477f..fa934fe 100644 --- a/web/src/NetworkExitsPage.test.jsx +++ b/web/src/NetworkExitsPage.test.jsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Refine } from '@refinedev/core' import { MemoryRouter } from 'react-router' @@ -9,7 +9,7 @@ afterEach(() => { cleanup(); vi.restoreAllMocks() }) const httpError = (message, status, body) => Object.assign(new Error(message), { status, body }) -const networkExit = { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy', observed_public_ip: '203.0.113.1', credential_reference: { id: 'credential-a', provider: 'os_keyring' } } +const networkExit = { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, username: 'proxy-user', password: 'plain-password', health_status: 'healthy', observed_public_ip: '203.0.113.1' } function provider(exits = [], overrides = {}) { return { @@ -35,16 +35,48 @@ describe('NetworkExitList', () => { expect(await screen.findByText(/创建并检测健康后/)).toBeTruthy() }) - it('shows only credential references and runs an explicit health check', async () => { + it('shows stored credentials and runs an explicit health check', async () => { const dataProvider = provider([networkExit]) renderExits(dataProvider) - expect((await screen.findAllByText(/credential-a/)).length).toBeGreaterThan(0) - expect(screen.queryByText(/password|token|raw-value/i)).toBeNull() + expect((await screen.findAllByText(/proxy-user/)).length).toBeGreaterThan(0) + expect((await screen.findAllByText(/plain-password/)).length).toBeGreaterThan(0) fireEvent.click(screen.getAllByRole('button', { name: '检测' })[0]) await waitFor(() => expect(dataProvider.networkExitAction).toHaveBeenCalledWith('exit-a', 'check')) }) + it('creates with plain credentials and closes on success', async () => { + const dataProvider = provider() + renderExits(dataProvider) + + fireEvent.click(await screen.findByRole('button', { name: '创建网络出口' })) + const dialog = screen.getByRole('dialog') + fireEvent.change(within(dialog).getByRole('textbox', { name: '主机' }), { target: { value: 'proxy.example' } }) + fireEvent.change(within(dialog).getByRole('spinbutton', { name: '端口' }), { target: { value: '1080' } }) + fireEvent.change(within(dialog).getByRole('textbox', { name: '用户名(可选)' }), { target: { value: 'proxy-user' } }) + fireEvent.change(within(dialog).getByRole('textbox', { name: '密码(可选)' }), { target: { value: 'plain-password' } }) + fireEvent.click(within(dialog).getByRole('button', { name: '创建网络出口' })) + + await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith({ resource: 'network-exits', variables: { + protocol: 'socks5', host: 'proxy.example', port: 1080, username: 'proxy-user', password: 'plain-password', expected_public_ip: '', expected_region: '', + } })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + it('keeps the create modal open after a failure', async () => { + const dataProvider = provider([], { create: vi.fn().mockRejectedValue(new Error('创建失败')) }) + renderExits(dataProvider) + + fireEvent.click(await screen.findByRole('button', { name: '创建网络出口' })) + const dialog = screen.getByRole('dialog') + fireEvent.change(within(dialog).getByRole('textbox', { name: '主机' }), { target: { value: 'proxy.example' } }) + fireEvent.change(within(dialog).getByRole('spinbutton', { name: '端口' }), { target: { value: '1080' } }) + fireEvent.click(within(dialog).getByRole('button', { name: '创建网络出口' })) + + expect((await within(dialog).findByRole('alert')).textContent).toContain('创建失败') + expect(screen.getByRole('dialog')).toBeTruthy() + }) + it('shows unknown bindings and retries when browsers return 502', async () => { const dataProvider = provider([networkExit], { getList: vi.fn(({ resource }) => resource === 'browsers'