Merge pull request 'fix: 网络出口明文认证存储与创建 Modal 统一关闭' (#45) from fix/network-exit-credentials-modal-close into main

This commit is contained in:
2026-09-03 17:04:28 +08:00
17 changed files with 432 additions and 243 deletions
+19 -41
View File
@@ -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)
+33 -61
View File
@@ -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"`) {
+1 -1
View File
@@ -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")
}
}
+16 -50
View File
@@ -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
}
+165 -9
View File
@@ -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)
}
}
+39 -49
View File
@@ -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
}
+4 -3
View File
@@ -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) {
@@ -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 <> '');
+4 -1
View File
@@ -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")
+32 -10
View File
@@ -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)
}
+4 -2
View File
@@ -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);
+4 -1
View File
@@ -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 (
+16
View File
@@ -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 () => {
+1
View File
@@ -105,6 +105,7 @@ function GatewayFormModal({ open, onClose, onSubmit, busy, error, initial }) {
token,
})
) {
setForm({ name: "", endpoint: "", token: "" });
onClose();
}
}
+30
View File
@@ -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 () => {
+18 -10
View File
@@ -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 }) {
<Button variant="primary" type="submit" form="exit-create-form" busy={busy} busyText="创建中…" disabled={!valid}>创建网络出口</Button>
</>}>
<form id="exit-create-form" onSubmit={submit} noValidate>
{error ? <Alert variant="destructive" className="mb-4">{conflictMessage(error, '出口或认证引用与现有资源冲突;表单内容已保留。')}</Alert> : null}
{error ? <Alert variant="destructive" className="mb-4">{conflictMessage(error, '出口地址或认证信息与现有资源冲突;表单内容已保留。')}</Alert> : null}
<div className="grid gap-4 sm:grid-cols-3">
<Field id="exit-protocol" label="协议" required helper="代理协议">
<Select id="exit-protocol" value={form.protocol} onChange={event => update('protocol', event.target.value)} options={protocolOptions} />
@@ -62,8 +66,11 @@ function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
<Field id="exit-port" label="端口" required helper="1..65535">
<Input id="exit-port" type="number" min={1} max={65535} required value={form.port} onChange={event => update('port', event.target.value)} />
</Field>
<Field id="exit-credential" label="认证(可选)" helper="只填已保存的凭据引用 ID,不填认证秘密">
<Input id="exit-credential" maxLength={128} value={form.credential_reference_id} onChange={event => update('credential_reference_id', event.target.value)} />
<Field id="exit-username" label="用户名(可选)" helper="直接保存到系统并用于代理认证">
<Input id="exit-username" maxLength={255} value={form.username} onChange={event => update('username', event.target.value)} />
</Field>
<Field id="exit-password" label="密码(可选)" error={!credentialsValid ? '填写密码时必须同时填写用户名' : undefined} helper="明文保存并直接用于代理认证">
<Input id="exit-password" maxLength={255} value={form.password} onChange={event => update('password', event.target.value)} invalid={!credentialsValid} />
</Field>
<Field id="exit-ip" label="出口IP(可选)" helper="健康检测时比对的预期公网 IP">
<Input id="exit-ip" value={form.expected_public_ip} onChange={event => update('expected_public_ip', event.target.value)} />
@@ -84,7 +91,7 @@ function ExitCard({ exit, boundAccounts, bindingsError, busy, onAction }) {
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<Link to={`/network-exits/${encodeURIComponent(exit.id)}`} className="anywhere font-semibold text-ink hover:text-primary">{exit.protocol}://{exit.host}:{exit.port}</Link>
<p className="anywhere text-xs text-muted">{exit.id} · 认证 {exit.credential_reference?.id || '无'}</p>
<p className="anywhere text-xs text-muted">{exit.id} · 用户名 {exit.username || '无'} · 密码 {exit.password || '无'}</p>
</div>
<ExitHealthPill exit={exit} />
</div>
@@ -204,7 +211,8 @@ export function NetworkExitDetail() {
<DetailList rows={[
['ID', <span className="anywhere">{exit.id}</span>],
['健康 / 版本', <span className="anywhere">{healthText[exit.health_status] ?? exit.health_status} · {exit.version}</span>],
['认证', <span className="anywhere">{exit.credential_reference?.id || '无'}</span>],
['用户名', <span className="anywhere">{exit.username || '无'}</span>],
['密码', <span className="anywhere">{exit.password || '无'}</span>],
['出口IP', <span className="anywhere">{exit.observed_public_ip || '尚无观测'}</span>],
['最近检测', exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'],
]} />
+37 -5
View File
@@ -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'