diff --git a/pkg/address-resolver/api/api.go b/pkg/address-resolver/api/api.go index 5b69f934cf..34a06a89b7 100644 --- a/pkg/address-resolver/api/api.go +++ b/pkg/address-resolver/api/api.go @@ -232,6 +232,7 @@ func New(log *logging.Logger, s store.Store, nonceStore httpauth.NonceStore, r.Post("/bind/stcpr", api.bind) r.Delete("/bind/stcpr", api.delBind) r.Post("/bind/quic", api.bindQUIC) + r.Post("/bind/wt", api.bindWT) r.Get("/resolve/{type}/{pk}", api.resolve) }) @@ -346,6 +347,14 @@ func (a *API) bind(w http.ResponseWriter, r *http.Request) { // bindQUIC handles POST /bind/quic — registers the visor's QUIC UDP address // (#2607 QUIC follow-on). Same validation as STCPR; stored under the QUIC type // for peers to Resolve. +// bindWT handles POST /bind/wt — registers the visor's WebTransport UDP address +// AND its self-signed cert hash (carried in the bind payload's CertHash, stored +// via VisorData's embedded LocalAddresses so /resolve/wt returns it). Same +// validation as STCPR/QUIC; stored under the WT type. +func (a *API) bindWT(w http.ResponseWriter, r *http.Request) { + a.bindForType(w, r, types.WT) +} + func (a *API) bindQUIC(w http.ResponseWriter, r *http.Request) { a.bindForType(w, r, types.QUIC) } diff --git a/pkg/address-resolver/store/address_test.go b/pkg/address-resolver/store/address_test.go index 37ad8779c7..900631d171 100644 --- a/pkg/address-resolver/store/address_test.go +++ b/pkg/address-resolver/store/address_test.go @@ -57,4 +57,22 @@ func (s *AddressSuite) TestRegister() { require.NoError(t, err) require.Equal(t, visorData, got) }) + + // WT carries a self-signed cert hash (no CA) that the dialing peer pins. It + // rides VisorData via the embedded LocalAddresses, so a bind→resolve must + // preserve it. Guards both store backends + the WT type in the switch. + wtData := addrresolver.VisorData{ + RemoteAddr: "[::1]:5678", + LocalAddresses: addrresolver.LocalAddresses{CertHash: "deadbeefcafe"}, + } + t.Run(".BindWT", func(t *testing.T) { + require.NoError(t, s.Bind(ctx, types.WT, pk, wtData)) + }) + + t.Run(".ResolveWT", func(t *testing.T) { + got, err := s.Resolve(ctx, types.WT, pk) + require.NoError(t, err) + require.Equal(t, wtData, got) + require.Equal(t, "deadbeefcafe", got.CertHash) + }) } diff --git a/pkg/address-resolver/store/redis_store.go b/pkg/address-resolver/store/redis_store.go index f112056336..168ade170f 100644 --- a/pkg/address-resolver/store/redis_store.go +++ b/pkg/address-resolver/store/redis_store.go @@ -66,7 +66,7 @@ func newRedisStore(ctx context.Context, addr, password string, poolSize int, ttl func (s *redisStore) Bind(ctx context.Context, netType types.Type, pk cipher.PubKey, visorData addrresolver.VisorData) error { switch netType { - case types.STCPR, types.SUDPH, types.QUIC: + case types.STCPR, types.SUDPH, types.QUIC, types.WT: return s.bindWithIndex(ctx, netType, pk, visorData) default: return ErrUnknownTransportType @@ -75,7 +75,7 @@ func (s *redisStore) Bind(ctx context.Context, netType types.Type, pk cipher.Pub func (s *redisStore) DelBind(ctx context.Context, netType types.Type, pk cipher.PubKey) error { switch netType { - case types.STCPR, types.SUDPH, types.QUIC: + case types.STCPR, types.SUDPH, types.QUIC, types.WT: return s.delBindWithIndex(ctx, netType, pk) default: return ErrUnknownTransportType @@ -84,7 +84,7 @@ func (s *redisStore) DelBind(ctx context.Context, netType types.Type, pk cipher. func (s *redisStore) Resolve(ctx context.Context, netType types.Type, pk cipher.PubKey) (addrresolver.VisorData, error) { switch netType { - case types.STCPR, types.SUDPH, types.QUIC: + case types.STCPR, types.SUDPH, types.QUIC, types.WT: key := getKey(string(netType), pk) return s.resolve(ctx, key) default: @@ -94,7 +94,7 @@ func (s *redisStore) Resolve(ctx context.Context, netType types.Type, pk cipher. func (s *redisStore) GetAll(ctx context.Context, netType types.Type) ([]string, error) { switch netType { - case types.STCPR, types.SUDPH, types.QUIC: + case types.STCPR, types.SUDPH, types.QUIC, types.WT: if pks, ok := s.getAllCache.Get(netType); ok { return pks, nil } diff --git a/pkg/transport/network/addrresolver/client.go b/pkg/transport/network/addrresolver/client.go index 51cb57cc02..bb3d6a5f7e 100644 --- a/pkg/transport/network/addrresolver/client.go +++ b/pkg/transport/network/addrresolver/client.go @@ -32,6 +32,7 @@ const ( sudphPriority = 1 stcprBindPath = "/bind/stcpr" quicBindPath = "/bind/quic" + wtBindPath = "/bind/wt" addrChSize = 1024 udpKeepHeartbeatInterval = 10 * time.Second sudphReRegisterInterval = 90 * time.Second @@ -83,6 +84,10 @@ type Error struct { type APIClient interface { BindSTCPR(ctx context.Context, port string) error BindQUIC(ctx context.Context, port string) error + // BindWT registers this visor's WebTransport UDP port and the SHA-256 hex of + // its self-signed cert (the hash dialing peers pin). Mirrors BindQUIC + the + // cert hash. + BindWT(ctx context.Context, port, certHash string) error BindSUDPH(filter *pfilter.PacketFilter, handshake Handshake) (<-chan RemoteVisor, error) Resolve(ctx context.Context, netType string, pk cipher.PubKey) (VisorData, error) Transports(ctx context.Context) (map[cipher.PubKey][]string, error) @@ -403,6 +408,11 @@ type LocalAddresses struct { // source isn't v6. Empty when the visor is v4-only — preserves the // pre-Phase-2c single-stack contract. PublicIPv6 string `json:"public_ip_v6,omitempty"` + // CertHash is the lowercase SHA-256 hex of the visor's self-signed + // WebTransport certificate. WT has no CA; a dialing peer pins this hash. Only + // the WT bind sets it (it rides VisorData via the embedded LocalAddresses, so + // /resolve/wt returns it); empty for every other transport type. + CertHash string `json:"cert_hash,omitempty"` } func (c *httpClient) Addresses(_ context.Context) string { @@ -532,6 +542,69 @@ func (c *httpClient) BindQUIC(ctx context.Context, port string) error { return nil } +// BindWT registers this visor's WebTransport UDP port + self-signed cert hash +// with the address resolver. Mirrors BindQUIC, plus the cert hash (CertHash): +// WebTransport has no CA, so the dialing peer pins this SHA-256 to trust the +// server cert. The hash rides VisorData via the embedded LocalAddresses, so +// /resolve/wt returns it alongside the endpoint. +func (c *httpClient) BindWT(ctx context.Context, port, certHash string) error { + log := c.log.WithField("func", "httpClient.BindWT") + if !c.isReady() { + log.Debug("Address resolver is not ready yet, waiting...") + <-c.ready + log.Debug("Address resolver became ready, binding") + } + + c.awaitPublicIP(stcprBindPublicIPWait) + + addresses, err := netutil.LocalAddresses() + if err != nil { + return err + } + clientPublicIP := c.localPublicIPRaw() + if clientPublicIP != "" { + publicIP := clientPublicIP + if host, _, err := net.SplitHostPort(publicIP); err == nil { + publicIP = host + } + found := false + for _, addr := range addresses { + if addr == publicIP { + found = true + break + } + } + if !found { + addresses = append(addresses, publicIP) + } + } + + localAddresses := LocalAddresses{ + Addresses: addresses, + Port: port, + PublicIP: c.LocalPublicIP(), + PublicIPv6: c.localPublicIPv6Raw(), + CertHash: certHash, + } + log.Debugf("Address resolver binding WT with: %v port %s cert %s", addresses, port, certHash) + resp, err := c.Post(ctx, wtBindPath, localAddresses) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.WithError(err).Warn("Failed to close response body") + } + }() + if resp.StatusCode == http.StatusTooManyRequests { + return fmt.Errorf("rate limited by address resolver (status 429)") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status: %d, error: %w", resp.StatusCode, httpauthclient.ExtractError(resp.Body)) + } + return nil +} + func (c *httpClient) BindSTCPR(ctx context.Context, port string) error { log := c.log.WithField("func", "httpClient.BindSTCPR") if !c.isReady() { diff --git a/pkg/transport/network/addrresolver/mock_api_client.go b/pkg/transport/network/addrresolver/mock_api_client.go index c95f799a59..e8adc1d391 100644 --- a/pkg/transport/network/addrresolver/mock_api_client.go +++ b/pkg/transport/network/addrresolver/mock_api_client.go @@ -77,6 +77,24 @@ func (_m *MockAPIClient) BindQUIC(ctx context.Context, port string) error { return r0 } +// BindWT provides a mock function with given fields: ctx, port, certHash +func (_m *MockAPIClient) BindWT(ctx context.Context, port string, certHash string) error { + ret := _m.Called(ctx, port, certHash) + + if len(ret) == 0 { + panic("no return value specified for BindWT") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, port, certHash) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // BindSUDPH provides a mock function with given fields: filter, handshake func (_m *MockAPIClient) BindSUDPH(filter *pfilter.PacketFilter, handshake Handshake) (<-chan RemoteVisor, error) { ret := _m.Called(filter, handshake)