diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go new file mode 100644 index 0000000..8cd63c8 --- /dev/null +++ b/apps/api/cmd/migrate/main_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +func TestSecurityEventIdempotencyRepairMigrationExists(t *testing.T) { + payload, err := os.ReadFile("../../migrations/0064_security_event_connection_idempotency_repair.sql") + if err != nil { + t.Fatalf("read repair migration: %v", err) + } + sql := string(payload) + for _, statement := range []string{ + "CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency", + "CREATE INDEX IF NOT EXISTS idx_gateway_security_event_connection_idempotency_created", + } { + if !strings.Contains(sql, statement) { + t.Fatalf("repair migration is missing %q", statement) + } + } +} diff --git a/apps/api/internal/securityevents/connection_manager.go b/apps/api/internal/securityevents/connection_manager.go index 1a02463..d0e0448 100644 --- a/apps/api/internal/securityevents/connection_manager.go +++ b/apps/api/internal/securityevents/connection_manager.go @@ -879,11 +879,26 @@ func safeHTTPClient(issuer *url.URL, appEnv string) *http.Client { } } dialer := &net.Dialer{Timeout: 5 * time.Second} - return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port)) + return dialResolvedAddresses(ctx, network, port, addresses, dialer) } return &http.Client{Timeout: 10 * time.Second, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} } +func dialResolvedAddresses(ctx context.Context, network, port string, addresses []net.IPAddr, dialer *net.Dialer) (net.Conn, error) { + var attempts []error + for _, address := range addresses { + connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(address.IP.String(), port)) + if err == nil { + return connection, nil + } + attempts = append(attempts, err) + if ctx.Err() != nil { + break + } + } + return nil, fmt.Errorf("transmitter connection failed for every resolved address: %w", errors.Join(attempts...)) +} + func blockedAddress(ip net.IP) bool { if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() { diff --git a/apps/api/internal/securityevents/connection_manager_test.go b/apps/api/internal/securityevents/connection_manager_test.go index 4d40535..4fbd96a 100644 --- a/apps/api/internal/securityevents/connection_manager_test.go +++ b/apps/api/internal/securityevents/connection_manager_test.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/http/httptest" + "strconv" "strings" "sync" "sync/atomic" @@ -148,6 +149,39 @@ func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) { } } +func TestDialResolvedAddressesFallsBackFromIPv6ToIPv4(t *testing.T) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + accepted := make(chan struct{}) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr == nil { + _ = connection.Close() + close(accepted) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + connection, err := dialResolvedAddresses(ctx, "tcp", strconv.Itoa(port), []net.IPAddr{ + {IP: net.ParseIP("::1")}, + {IP: net.ParseIP("127.0.0.1")}, + }, &net.Dialer{Timeout: 250 * time.Millisecond}) + if err != nil { + t.Fatalf("IPv4 fallback was not attempted: %v", err) + } + _ = connection.Close() + select { + case <-accepted: + case <-ctx.Done(): + t.Fatal("IPv4 listener did not receive the fallback connection") + } +} + func TestRetiringConnectionStillAppliesRevocationWatermark(t *testing.T) { audience := "urn:easyai:ssf:receiver:" + uuid.NewString() repository := &memoryConnectionRepository{ diff --git a/apps/api/migrations/0064_security_event_connection_idempotency_repair.sql b/apps/api/migrations/0064_security_event_connection_idempotency_repair.sql new file mode 100644 index 0000000..f6a5b0c --- /dev/null +++ b/apps/api/migrations/0064_security_event_connection_idempotency_repair.sql @@ -0,0 +1,15 @@ +-- 0063 was released before its idempotency table was added. Installations that +-- already recorded 0063 need a new immutable migration to repair that drift. +CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency ( + operation text NOT NULL, + idempotency_key text NOT NULL, + request_hash text NOT NULL, + response jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (operation, idempotency_key), + CONSTRAINT gateway_security_event_connection_idempotency_operation + CHECK (operation IN ('connect','verify','rotate','disconnect')) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_security_event_connection_idempotency_created + ON gateway_security_event_connection_idempotency(created_at); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9792c91..c74a7b6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1033,7 +1033,8 @@ export function App() { setCoreState('loading'); setCoreMessage(''); try { - const connection = await connectSecurityEventTransmitter(token, transmitterIssuer); + const version = securityEventConnection?.connection?.version; + const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, version); setSecurityEventConnection(connection); setCoreState('ready'); setCoreMessage('认证中心安全事件连接正在验证。'); @@ -1045,8 +1046,17 @@ export function App() { } async function refreshSecurityEvents() { - const connection = await getSecurityEventConnection(token); - setSecurityEventConnection(connection); + setCoreState('loading'); + setCoreMessage(''); + try { + const connection = await getSecurityEventConnection(token); + setSecurityEventConnection(connection); + setCoreState('ready'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '刷新安全事件连接失败'); + throw err; + } } async function verifySecurityEvents() { diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index ddaa1f1..a9c1ea6 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -55,6 +55,21 @@ describe('security event connection transport', () => { expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' }); expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i); expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-'); + expect(new Headers(init.headers).has('If-Match')).toBe(false); + }); + + it('sends If-Match when retrying an existing failed connection', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 7); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); }); }); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index defcd37..d71a66f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1007,10 +1007,12 @@ export async function getSecurityEventConnection(token: string): Promise(securityEventConnectionPath, { token }); } -export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string): Promise { +export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string, version?: number): Promise { + const headers: Record = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` }; + if (version !== undefined) headers['If-Match'] = `W/"${version}"`; return request(securityEventConnectionPath, { body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token, - headers: { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` }, + headers, }); } diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index 0367987..37b20e2 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -474,6 +474,9 @@ function SecurityEventConnectionPanel(props: { {connection.lastErrorCategory && 最近错误: {connection.lastErrorCategory}}
+ {connection.lifecycleStatus === 'error' && ( + + )}