diff --git a/pkg/gatewayserver/gatewayserver_internal_test.go b/pkg/gatewayserver/gatewayserver_internal_test.go deleted file mode 100644 index 3ff986c400a..00000000000 --- a/pkg/gatewayserver/gatewayserver_internal_test.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright © 2019 The Things Network Foundation, The Things Industries B.V. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package gatewayserver - -var ErrSchedule = errSchedule diff --git a/pkg/gatewayserver/gatewayserver_test.go b/pkg/gatewayserver/gatewayserver_test.go index 053a54f318d..09b073f6a15 100644 --- a/pkg/gatewayserver/gatewayserver_test.go +++ b/pkg/gatewayserver/gatewayserver_test.go @@ -16,1915 +16,51 @@ package gatewayserver_test import ( "context" - "encoding/json" "fmt" - "net" "os" - "sync" "testing" "time" mqtt "github.com/eclipse/paho.mqtt.golang" - "github.com/gorilla/websocket" - "github.com/smarty/assertions" ttnpbv2 "go.thethings.network/lorawan-stack-legacy/v2/pkg/ttnpb" - clusterauth "go.thethings.network/lorawan-stack/v3/pkg/auth/cluster" - "go.thethings.network/lorawan-stack/v3/pkg/band" "go.thethings.network/lorawan-stack/v3/pkg/cluster" "go.thethings.network/lorawan-stack/v3/pkg/component" componenttest "go.thethings.network/lorawan-stack/v3/pkg/component/test" "go.thethings.network/lorawan-stack/v3/pkg/config" - "go.thethings.network/lorawan-stack/v3/pkg/encoding/lorawan" "go.thethings.network/lorawan-stack/v3/pkg/errorcontext" "go.thethings.network/lorawan-stack/v3/pkg/errors" - "go.thethings.network/lorawan-stack/v3/pkg/events" "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver" - gsio "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io" - "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/semtechws" - "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/semtechws/lbslns" - "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/udp" gsredis "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/redis" - "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/upstream/mock" mockis "go.thethings.network/lorawan-stack/v3/pkg/identityserver/mock" - "go.thethings.network/lorawan-stack/v3/pkg/rpcmetadata" "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" - encoding "go.thethings.network/lorawan-stack/v3/pkg/ttnpb/udp" "go.thethings.network/lorawan-stack/v3/pkg/types" "go.thethings.network/lorawan-stack/v3/pkg/unique" "go.thethings.network/lorawan-stack/v3/pkg/util/test" "go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should" - "google.golang.org/grpc" "google.golang.org/grpc/metadata" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/timestamppb" - "google.golang.org/protobuf/types/known/wrapperspb" ) -var ( - registeredGatewayID = "eui-aaee000000000000" - registeredGatewayKey = "secret" - registeredGatewayEUI = types.EUI64{0xAA, 0xEE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} - - unregisteredGatewayEUI = types.EUI64{0xBB, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} - - timeout = (1 << 5) * test.Delay - wsPingInterval = (1 << 3) * test.Delay -) - -func TestGatewayServer(t *testing.T) { - // The component's gRPC address is bound for each iteration and cannot be used in parallel. - for _, rtc := range []struct { //nolint:paralleltest - Name string - Setup func(context.Context, string, string, gatewayserver.GatewayConnectionStatsRegistry) (*component.Component, *gatewayserver.GatewayServer, error) - PostSetup func(context.Context, *component.Component, *mockis.MockDefinition) - SkipProtocols func(string) bool - SupportsLocationUpdate bool - }{ - { - Name: "IdentityServer", - Setup: func(ctx context.Context, isAddr string, nsAddr string, statsRegistry gatewayserver.GatewayConnectionStatsRegistry) (*component.Component, *gatewayserver.GatewayServer, error) { - c := componenttest.NewComponent(t, &component.Config{ - ServiceBase: config.ServiceBase{ - GRPC: config.GRPC{ - Listen: ":0", - AllowInsecureForCredentials: true, - }, - Cluster: cluster.Config{ - IdentityServer: isAddr, - NetworkServer: nsAddr, - }, - FrequencyPlans: config.FrequencyPlansConfig{ - ConfigSource: "static", - Static: test.StaticFrequencyPlans, - }, - }, - }) - gsConfig := &gatewayserver.Config{ - RequireRegisteredGateways: false, - UpdateGatewayLocationDebounceTime: 0, - UpdateConnectionStatsInterval: (1 << 5) * test.Delay, - ConnectionStatsTTL: (1 << 6) * test.Delay, - ConnectionStatsDisconnectTTL: (1 << 7) * test.Delay, - Stats: statsRegistry, - FetchGatewayInterval: (1 << 5) * test.Delay, - FetchGatewayJitter: 0.1, - MQTT: config.MQTT{ - Listen: ":1882", - }, - UDP: gatewayserver.UDPConfig{ - Config: udp.Config{ - PacketHandlers: 2, - PacketBuffer: 10, - DownlinkPathExpires: 1 * time.Second, - ConnectionExpires: 2 * time.Second, - ScheduleLateTime: 0, - AddrChangeBlock: 2 * time.Second, - }, - Listeners: map[string]string{ - ":1700": test.EUFrequencyPlanID, - }, - }, - BasicStation: gatewayserver.BasicStationConfig{ - Listen: ":1887", - Config: semtechws.Config{ - WSPingInterval: wsPingInterval, - MissedPongThreshold: 2, - AllowUnauthenticated: true, - }, - }, - } - - er := gatewayserver.NewIS(c) - gs, err := gatewayserver.New(c, gsConfig, - gatewayserver.WithRegistry(er), - ) - if err != nil { - return nil, nil, err - } - return c, gs, nil - }, - PostSetup: func(ctx context.Context, c *component.Component, is *mockis.MockDefinition) { - mustHavePeer(ctx, c, ttnpb.ClusterRole_NETWORK_SERVER) - mustHavePeer(ctx, c, ttnpb.ClusterRole_ENTITY_REGISTRY) - - ids := &ttnpb.GatewayIdentifiers{ - GatewayId: registeredGatewayID, - Eui: registeredGatewayEUI.Bytes(), - } - gtw := mockis.DefaultGateway(ids, true, true) - is.GatewayRegistry().Add(ctx, ids, registeredGatewayKey, gtw, testRights...) - }, - SkipProtocols: func(string) bool { - return false - }, - SupportsLocationUpdate: true, - }, - } { - rtc := rtc - t.Run(rtc.Name, func(t *testing.T) { - a, ctx := test.New(t) - - is, isAddr, closeIS := mockis.New(ctx) - defer closeIS() - ns, nsAddr := mock.StartNS(ctx) - - var statsRegistry gatewayserver.GatewayConnectionStatsRegistry - if os.Getenv("TEST_REDIS") == "1" { - statsRedisClient, statsFlush := test.NewRedis(ctx, "gatewayserver_test") - defer statsFlush() - defer statsRedisClient.Close() - registry := &gsredis.GatewayConnectionStatsRegistry{ - Redis: statsRedisClient, - LockTTL: test.Delay << 10, - } - if err := registry.Init(ctx); !a.So(err, should.BeNil) { - t.FailNow() - } - statsRegistry = registry - } - - c, gs, err := rtc.Setup(ctx, isAddr, nsAddr, statsRegistry) - if !a.So(err, should.BeNil) { - t.Fatalf("Failed to setup server :%v", err) - } - defer c.Close() - roles := gs.Roles() - a.So(len(roles), should.Equal, 1) - a.So(roles[0], should.Equal, ttnpb.ClusterRole_GATEWAY_SERVER) - - config, err := gs.GetConfig(ctx) - if !a.So(err, should.BeNil) { - t.FailNow() - } - - componenttest.StartComponent(t, c) - - rtc.PostSetup(ctx, c, is) - - time.Sleep(timeout) // Wait for setup to be completed. - - for _, ptc := range []struct { - Protocol string - SupportsStatus bool - DetectsInvalidMessages bool - DetectsDisconnect bool - TimeoutOnInvalidAuth bool - HasAuth bool - DeduplicatesUplinks bool - ValidAuth func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string) bool - Link func(ctx context.Context, t *testing.T, ids *ttnpb.GatewayIdentifiers, key string, upCh <-chan *ttnpb.GatewayUp, downCh chan<- *ttnpb.GatewayDown) error - }{ - { - Protocol: "grpc", - SupportsStatus: true, - HasAuth: true, - DetectsDisconnect: true, - DeduplicatesUplinks: true, - - ValidAuth: func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string) bool { - return ids.GatewayId == registeredGatewayID && key == registeredGatewayKey - }, - Link: func(ctx context.Context, t *testing.T, ids *ttnpb.GatewayIdentifiers, key string, upCh <-chan *ttnpb.GatewayUp, downCh chan<- *ttnpb.GatewayDown) error { - md := rpcmetadata.MD{ - ID: ids.GatewayId, - AuthType: "Bearer", - AuthValue: key, - AllowInsecure: true, - } - client := ttnpb.NewGtwGsClient(gs.LoopbackConn()) - _, err = client.GetConcentratorConfig(ctx, ttnpb.Empty, grpc.PerRPCCredentials(md)) - if err != nil { - return err - } - link, err := client.LinkGateway(ctx, grpc.PerRPCCredentials(md)) - if err != nil { - return err - } - ctx, cancel := errorcontext.New(ctx) - // Write upstream. - go func() { - for { - select { - case <-ctx.Done(): - return - case msg := <-upCh: - if err := link.Send(msg); err != nil { - cancel(err) - return - } - } - } - }() - // Read downstream. - go func() { - for { - msg, err := link.Recv() - if err != nil { - cancel(err) - return - } - downCh <- msg - } - }() - <-ctx.Done() - return ctx.Err() - }, - }, - { - Protocol: "mqtt", - SupportsStatus: true, - HasAuth: true, - DetectsDisconnect: true, - TimeoutOnInvalidAuth: true, // The MQTT client keeps reconnecting on invalid auth. - ValidAuth: func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string) bool { - return ids.GatewayId == registeredGatewayID && key == registeredGatewayKey - }, - Link: func(ctx context.Context, t *testing.T, ids *ttnpb.GatewayIdentifiers, key string, upCh <-chan *ttnpb.GatewayUp, downCh chan<- *ttnpb.GatewayDown) error { - if ids.GatewayId == "" { - t.SkipNow() - } - ctx, cancel := errorcontext.New(ctx) - clientOpts := mqtt.NewClientOptions() - clientOpts.AddBroker("tcp://0.0.0.0:1882") - clientOpts.SetUsername(unique.ID(ctx, ids)) - clientOpts.SetPassword(key) - clientOpts.SetAutoReconnect(false) - clientOpts.SetConnectionLostHandler(func(_ mqtt.Client, err error) { - cancel(err) - }) - client := mqtt.NewClient(clientOpts) - if token := client.Connect(); !token.WaitTimeout(timeout) { - return context.DeadlineExceeded - } else if err := token.Error(); err != nil { - return err - } - defer client.Disconnect(uint(timeout / time.Millisecond)) - // Write upstream. - go func() { - for { - select { - case <-ctx.Done(): - return - case up := <-upCh: - for _, msg := range up.UplinkMessages { - buf, err := proto.Marshal(msg) - if err != nil { - cancel(err) - return - } - if token := client.Publish(fmt.Sprintf("v3/%v/up", unique.ID(ctx, ids)), 1, false, buf); token.Wait() && token.Error() != nil { - cancel(token.Error()) - return - } - } - if up.GatewayStatus != nil { - buf, err := proto.Marshal(up.GatewayStatus) - if err != nil { - cancel(err) - return - } - if token := client.Publish(fmt.Sprintf("v3/%v/status", unique.ID(ctx, ids)), 1, false, buf); token.Wait() && token.Error() != nil { - cancel(token.Error()) - return - } - } - if up.TxAcknowledgment != nil { - buf, err := proto.Marshal(up.TxAcknowledgment) - if err != nil { - cancel(err) - return - } - if token := client.Publish(fmt.Sprintf("v3/%v/down/ack", unique.ID(ctx, ids)), 1, false, buf); token.Wait() && token.Error() != nil { - cancel(token.Error()) - return - } - } - } - } - }() - // Read downstream. - token := client.Subscribe(fmt.Sprintf("v3/%v/down", unique.ID(ctx, ids)), 1, func(_ mqtt.Client, raw mqtt.Message) { - var msg ttnpb.GatewayDown - if err := proto.Unmarshal(raw.Payload(), &msg); err != nil { - cancel(err) - return - } - downCh <- &msg - }) - if token.Wait() && token.Error() != nil { - return token.Error() - } - <-ctx.Done() - return ctx.Err() - }, - }, - { - Protocol: "udp", - SupportsStatus: true, - DeduplicatesUplinks: true, - ValidAuth: func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string) bool { - return ids.Eui != nil - }, - Link: func(ctx context.Context, t *testing.T, ids *ttnpb.GatewayIdentifiers, key string, upCh <-chan *ttnpb.GatewayUp, downCh chan<- *ttnpb.GatewayDown) error { - if ids.Eui == nil { - t.SkipNow() - } - upConn, err := net.Dial("udp", ":1700") - if err != nil { - return err - } - downConn, err := net.Dial("udp", ":1700") - if err != nil { - return err - } - ctx, cancel := errorcontext.New(ctx) - // Write upstream. - go func() { - var token byte - var readBuf [65507]byte - for { - select { - case <-ctx.Done(): - return - case up := <-upCh: - token++ - packet := encoding.Packet{ - GatewayEUI: types.MustEUI64(ids.Eui), - ProtocolVersion: encoding.Version1, - Token: [2]byte{0x00, token}, - PacketType: encoding.PushData, - Data: &encoding.Data{}, - } - packet.Data.RxPacket, packet.Data.Stat, packet.Data.TxPacketAck = encoding.FromGatewayUp(up) - if packet.Data.TxPacketAck != nil { - packet.PacketType = encoding.TxAck - } - writeBuf, err := packet.MarshalBinary() - if err != nil { - cancel(err) - return - } - switch packet.PacketType { - case encoding.PushData: - if _, err := upConn.Write(writeBuf); err != nil { - cancel(err) - return - } - if _, err := upConn.Read(readBuf[:]); err != nil { - cancel(err) - return - } - case encoding.TxAck: - if _, err := downConn.Write(writeBuf); err != nil { - cancel(err) - return - } - } - } - } - }() - // Engage downstream by sending PULL_DATA every 10ms. - go func() { - var token byte - ticker := time.NewTicker(10 * time.Millisecond) - for { - select { - case <-ctx.Done(): - ticker.Stop() - return - case <-ticker.C: - token++ - pull := encoding.Packet{ - GatewayEUI: types.MustEUI64(ids.Eui), - ProtocolVersion: encoding.Version1, - Token: [2]byte{0x01, token}, - PacketType: encoding.PullData, - } - buf, err := pull.MarshalBinary() - if err != nil { - cancel(err) - return - } - if _, err := downConn.Write(buf); err != nil { - cancel(err) - return - } - } - } - }() - // Read downstream; PULL_RESP and PULL_ACK. - go func() { - var buf [65507]byte - for { - n, err := downConn.Read(buf[:]) - if err != nil { - cancel(err) - return - } - packetBuf := make([]byte, n) - copy(packetBuf, buf[:]) - var packet encoding.Packet - if err := packet.UnmarshalBinary(packetBuf); err != nil { - cancel(err) - return - } - switch packet.PacketType { - case encoding.PullResp: - msg, err := encoding.ToDownlinkMessage(packet.Data.TxPacket) - if err != nil { - cancel(err) - return - } - downCh <- &ttnpb.GatewayDown{ - DownlinkMessage: msg, - } - } - } - }() - <-ctx.Done() - time.Sleep(config.UDP.ConnectionExpires * 150 / 100) // Ensure that connection expires. - return ctx.Err() - }, - }, - { - Protocol: "basicstation", - SupportsStatus: false, - DetectsDisconnect: true, - DetectsInvalidMessages: true, - HasAuth: true, - ValidAuth: func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string) bool { - return ids.Eui != nil - }, - Link: func(ctx context.Context, t *testing.T, ids *ttnpb.GatewayIdentifiers, key string, upCh <-chan *ttnpb.GatewayUp, downCh chan<- *ttnpb.GatewayDown) error { - if ids.Eui == nil { - t.SkipNow() - } - wsConn, _, err := websocket.DefaultDialer.Dial("ws://0.0.0.0:1887/traffic/"+registeredGatewayID, nil) - if err != nil { - return err - } - defer wsConn.Close() - ctx, cancel := errorcontext.New(ctx) - // Write upstream. - go func() { - for { - select { - case <-ctx.Done(): - return - case msg := <-upCh: - for _, uplink := range msg.UplinkMessages { - var payload ttnpb.Message - if err := lorawan.UnmarshalMessage(uplink.RawPayload, &payload); err != nil { - // Ignore invalid uplinks - continue - } - var bsUpstream []byte - switch payload.MHdr.MType { - case ttnpb.MType_JOIN_REQUEST: - var jreq lbslns.JoinRequest - err := jreq.FromUplinkMessage(uplink, band.EU_863_870) - if err != nil { - cancel(err) - return - } - bsUpstream, err = jreq.MarshalJSON() - if err != nil { - cancel(err) - return - } - case ttnpb.MType_UNCONFIRMED_UP, ttnpb.MType_CONFIRMED_UP: - var updf lbslns.UplinkDataFrame - err := updf.FromUplinkMessage(uplink, band.EU_863_870) - if err != nil { - cancel(err) - return - } - bsUpstream, err = updf.MarshalJSON() - if err != nil { - cancel(err) - return - } - } - if err := wsConn.WriteMessage(websocket.TextMessage, bsUpstream); err != nil { - cancel(err) - return - } - } - if msg.TxAcknowledgment != nil { - txConf := lbslns.TxConfirmation{ - Diid: 0, - XTime: time.Now().Unix(), - } - bsUpstream, err := txConf.MarshalJSON() - if err != nil { - cancel(err) - return - } - if err := wsConn.WriteMessage(websocket.TextMessage, bsUpstream); err != nil { - cancel(err) - return - } - } - } - } - }() - // Read downstream. - go func() { - for { - _, data, err := wsConn.ReadMessage() - if err != nil { - cancel(err) - return - } - var msg lbslns.DownlinkMessage - if err := json.Unmarshal(data, &msg); err != nil { - cancel(err) - return - } - dlmesg, err := msg.ToDownlinkMessage(band.EU_863_870) - if err != nil { - cancel(err) - return - } - downCh <- &ttnpb.GatewayDown{ - DownlinkMessage: dlmesg, - } - } - }() - <-ctx.Done() - return ctx.Err() - }, - }, - } { - ptc := ptc - if rtc.SkipProtocols(ptc.Protocol) { - continue - } - t.Run(fmt.Sprintf("Authenticate/%v", ptc.Protocol), func(t *testing.T) { - for _, ctc := range []struct { - Name string - ID *ttnpb.GatewayIdentifiers - Key string - }{ - { - Name: "ValidIDAndKey", - ID: &ttnpb.GatewayIdentifiers{GatewayId: registeredGatewayID}, - Key: registeredGatewayKey, - }, - { - Name: "InvalidKey", - ID: &ttnpb.GatewayIdentifiers{GatewayId: registeredGatewayID}, - Key: "invalid-key", - }, - { - Name: "InvalidIDAndKey", - ID: &ttnpb.GatewayIdentifiers{GatewayId: "invalid-gateway"}, - Key: "invalid-key", - }, - { - Name: "RegisteredEUI", - ID: &ttnpb.GatewayIdentifiers{Eui: registeredGatewayEUI.Bytes()}, - }, - { - Name: "UnregisteredEUI", - ID: &ttnpb.GatewayIdentifiers{Eui: unregisteredGatewayEUI.Bytes()}, - }, - } { - t.Run(ctc.Name, func(t *testing.T) { - ctx, cancel := context.WithCancel(ctx) - upCh := make(chan *ttnpb.GatewayUp) - downCh := make(chan *ttnpb.GatewayDown) - - upEvents := map[string]events.Channel{} - for _, event := range []string{"gs.gateway.connect"} { - upEvents[event] = make(events.Channel, 5) - } - defer test.SetDefaultEventsPubSub(&test.MockEventPubSub{ - PublishFunc: func(evs ...events.Event) { - for _, ev := range evs { - ev := ev - - switch name := ev.Name(); name { - case "gs.gateway.connect": - go func() { - upEvents[name] <- ev - }() - default: - t.Logf("%s event published", name) - } - } - }, - })() - - connectedWithInvalidAuth := make(chan struct{}, 1) - expectedProperLink := make(chan struct{}, 1) - go func() { - select { - case <-upEvents["gs.gateway.connect"]: - if !ptc.ValidAuth(ctx, ctc.ID, ctc.Key) { - connectedWithInvalidAuth <- struct{}{} - } - case <-time.After(timeout): - if ptc.ValidAuth(ctx, ctc.ID, ctc.Key) { - expectedProperLink <- struct{}{} - } - } - time.Sleep(test.Delay) - cancel() - }() - err := ptc.Link(ctx, t, ctc.ID, ctc.Key, upCh, downCh) - if !errors.IsCanceled(err) && ptc.ValidAuth(ctx, ctc.ID, ctc.Key) { - t.Fatalf("Expect canceled context but have %v", err) - } - select { - case <-connectedWithInvalidAuth: - t.Fatal("Expected link error due to invalid auth") - case <-expectedProperLink: - t.Fatal("Expected proper link") - default: - } - }) - } - }) - - // Wait for gateway disconnection to be processed. - time.Sleep(timeout) - - t.Run(fmt.Sprintf("DetectDisconnect/%v", ptc.Protocol), func(t *testing.T) { - if !ptc.DetectsDisconnect { - t.SkipNow() - } - - id := &ttnpb.GatewayIdentifiers{ - GatewayId: registeredGatewayID, - Eui: registeredGatewayEUI.Bytes(), - } - - ctx1, fail1 := errorcontext.New(ctx) - defer fail1(context.Canceled) - go func() { - upCh := make(chan *ttnpb.GatewayUp) - downCh := make(chan *ttnpb.GatewayDown) - err := ptc.Link(ctx1, t, id, registeredGatewayKey, upCh, downCh) - fail1(err) - }() - select { - case <-ctx1.Done(): - t.Fatalf("Expected no link error on first connection but have %v", ctx1.Err()) - case <-time.After(timeout): - } - - ctx2, cancel2 := context.WithDeadline(ctx, time.Now().Add(4*timeout)) - upCh := make(chan *ttnpb.GatewayUp) - downCh := make(chan *ttnpb.GatewayDown) - err := ptc.Link(ctx2, t, id, registeredGatewayKey, upCh, downCh) - cancel2() - if !errors.IsDeadlineExceeded(err) { - t.Fatalf("Expected deadline exceeded on second connection but have %v", err) - } - select { - case <-ctx1.Done(): - t.Logf("First connection failed when second connected with %v", ctx1.Err()) - case <-time.After(4 * timeout): - t.Fatalf("Expected link failure on first connection when second connected") - } - }) - - // Wait for gateway disconnection to be processed. - time.Sleep(2 * config.ConnectionStatsDisconnectTTL) - - t.Run(fmt.Sprintf("Traffic/%v", ptc.Protocol), func(t *testing.T) { - a := assertions.New(t) - - ctx, cancel := context.WithCancel(ctx) - upCh := make(chan *ttnpb.GatewayUp) - downCh := make(chan *ttnpb.GatewayDown) - ids := &ttnpb.GatewayIdentifiers{ - GatewayId: registeredGatewayID, - Eui: registeredGatewayEUI.Bytes(), - } - // Setup a stats client with independent context to query whether the gateway is connected and statistics on - // upstream and downstream. - statsCtx := metadata.AppendToOutgoingContext(test.Context(), - "id", ids.GatewayId, - "authorization", fmt.Sprintf("Bearer %v", registeredGatewayKey), - ) - statsClient := ttnpb.NewGsClient(gs.LoopbackConn()) - - // The gateway should not be connected before testing traffic. - t.Run("NotConnected", func(t *testing.T) { - _, err := statsClient.GetGatewayConnectionStats(statsCtx, ids) - if !a.So(errors.IsNotFound(err), should.BeTrue) { - t.Fatal("Expected gateway not to be connected yet, but it is") - } - }) - - if ptc.SupportsStatus && ptc.HasAuth && rtc.SupportsLocationUpdate { - t.Run("UpdateLocation", func(t *testing.T) { - for _, tc := range []struct { - Name string - UpdateLocation bool - Up *ttnpb.GatewayUp - ExpectLocation *ttnpb.Location - }{ - { - Name: "NoUpdate", - UpdateLocation: false, - Up: &ttnpb.GatewayUp{ - GatewayStatus: &ttnpb.GatewayStatus{ - Time: timestamppb.New(time.Unix(424242, 0)), - AntennaLocations: []*ttnpb.Location{ - { - Source: ttnpb.LocationSource_SOURCE_GPS, - Altitude: 10, - Latitude: 12, - Longitude: 14, - }, - }, - }, - }, - ExpectLocation: &ttnpb.Location{ - Source: ttnpb.LocationSource_SOURCE_GPS, - }, - }, - { - Name: "NoLocation", - UpdateLocation: true, - Up: &ttnpb.GatewayUp{ - GatewayStatus: &ttnpb.GatewayStatus{ - Time: timestamppb.New(time.Unix(424242, 0)), - }, - }, - ExpectLocation: &ttnpb.Location{ - Source: ttnpb.LocationSource_SOURCE_GPS, - }, - }, - { - Name: "Update", - UpdateLocation: true, - Up: &ttnpb.GatewayUp{ - GatewayStatus: &ttnpb.GatewayStatus{ - Time: timestamppb.New(time.Unix(42424242, 0)), - AntennaLocations: []*ttnpb.Location{ - { - Source: ttnpb.LocationSource_SOURCE_GPS, - Altitude: 10, - Latitude: 12, - Longitude: 14, - }, - }, - }, - }, - ExpectLocation: &ttnpb.Location{ - Source: ttnpb.LocationSource_SOURCE_GPS, - Altitude: 10, - Latitude: 12, - Longitude: 14, - }, - }, - } { - t.Run(tc.Name, func(t *testing.T) { - a := assertions.New(t) - - gtw, err := is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ - GatewayIds: ids, - }) - a.So(err, should.BeNil) - - gtw.Antennas[0].Location = &ttnpb.Location{ - Source: ttnpb.LocationSource_SOURCE_GPS, - } - gtw.UpdateLocationFromStatus = tc.UpdateLocation - gtw, err = is.GatewayRegistry().Update(ctx, &ttnpb.UpdateGatewayRequest{ - Gateway: gtw, - FieldMask: ttnpb.FieldMask("antennas", "update_location_from_status"), - }) - a.So(err, should.BeNil) - a.So(gtw.UpdateLocationFromStatus, should.Equal, tc.UpdateLocation) - - ctx, cancel := context.WithCancel(ctx) - upCh := make(chan *ttnpb.GatewayUp) - downCh := make(chan *ttnpb.GatewayDown) - - wg := &sync.WaitGroup{} - wg.Add(1) - var linkErr error - go func() { - defer wg.Done() - linkErr = ptc.Link(ctx, t, ids, registeredGatewayKey, upCh, downCh) - }() - - select { - case upCh <- tc.Up: - case <-time.After(timeout): - t.Fatalf("Failed to send message to upstream channel") - } - - time.Sleep(timeout) - gtw, err = is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ - GatewayIds: ids, - }) - a.So(err, should.BeNil) - a.So(gtw.Antennas[0].Location, should.Resemble, tc.ExpectLocation) - - cancel() - wg.Wait() - if !errors.IsCanceled(linkErr) { - t.Fatalf("Expected context canceled, but have %v", linkErr) - } - }) - } - }) - } - - t.Run("Disconnection", func(t *testing.T) { - for _, tc := range []struct { - Name string - AntennaGain float32 - ExpectDisconnected bool - }{ - { - Name: "NoDisconnect", - AntennaGain: 0, - ExpectDisconnected: false, - }, - { - Name: "Disconnect", - AntennaGain: 3, - ExpectDisconnected: true, - }, - } { - tc := tc - t.Run(tc.Name, func(t *testing.T) { - if !rtc.SupportsLocationUpdate { - t.Skip("Antenna update not supported") - } - - a := assertions.New(t) - - ctx, cancel := context.WithCancel(ctx) - upCh := make(chan *ttnpb.GatewayUp) - downCh := make(chan *ttnpb.GatewayDown) - - wg := &sync.WaitGroup{} - wg.Add(1) - var linkErr error - go func() { - defer wg.Done() - linkErr = ptc.Link(ctx, t, ids, registeredGatewayKey, upCh, downCh) - }() - - // Wait for gateway connection to be processed. - time.Sleep((1 << 2) * test.Delay) - - gtw, err := is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ - GatewayIds: ids, - FieldMask: ttnpb.FieldMask("antennas"), - }) - a.So(err, should.BeNil) - gtw.Antennas[0].Gain = tc.AntennaGain - gtw, err = is.GatewayRegistry().Update(ctx, &ttnpb.UpdateGatewayRequest{ - Gateway: gtw, - FieldMask: ttnpb.FieldMask("antennas"), - }) - a.So(err, should.BeNil) - a.So(gtw.Antennas[0].Gain, should.Equal, tc.AntennaGain) - - // Wait for gateway disconnection to be processed. - time.Sleep(2 * config.FetchGatewayInterval) - - _, connected := gs.GetConnection(ctx, ids) - if !a.So(connected, should.Equal, !tc.ExpectDisconnected) { - t.Fatal("Expected gateway to be disconnected, but it is not") - } - - cancel() - wg.Wait() - if !tc.ExpectDisconnected && !errors.IsCanceled(linkErr) { - t.Fatalf("Expected context canceled, but have %v", linkErr) - } - }) - } - }) - - if rtc.SupportsLocationUpdate { - t.Run("LocationMetadata", func(t *testing.T) { - location := &ttnpb.Location{ - Source: ttnpb.LocationSource_SOURCE_GPS, - Altitude: 10, - Latitude: 12, - Longitude: 14, - } - up := &ttnpb.GatewayUp{ - UplinkMessages: []*ttnpb.UplinkMessage{ - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 250000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 867900000, - Timestamp: 100, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - AntennaIndex: 0, - GatewayIds: ids, - Timestamp: 100, - Rssi: -69, - ChannelRssi: -69, - Snr: 11, - }, - }, - }, - }, - } - for _, locationPublic := range []bool{false, true} { - t.Run(fmt.Sprintf("LocationPublic=%v", locationPublic), func(t *testing.T) { - a := assertions.New(t) - mockGtw := mockis.DefaultGateway(ids, false, false) - mockGtw.LocationPublic = locationPublic - is.GatewayRegistry().Add(ctx, ids, registeredGatewayKey, mockGtw, testRights...) - - gtw, err := is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ - GatewayIds: ids, - }) - a.So(err, should.BeNil) - a.So(gtw.LocationPublic, should.Equal, locationPublic) - gtw.LocationPublic = locationPublic - gtw.Antennas[0].Location = location - gtw, err = is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ - GatewayIds: ids, - }) - a.So(err, should.BeNil) - a.So(gtw.LocationPublic, should.Equal, locationPublic) - - ctx, cancel := context.WithCancel(ctx) - upCh := make(chan *ttnpb.GatewayUp) - downCh := make(chan *ttnpb.GatewayDown) - - wg := &sync.WaitGroup{} - wg.Add(1) - var linkErr error - go func() { - defer wg.Done() - linkErr = ptc.Link(ctx, t, ids, registeredGatewayKey, upCh, downCh) - }() - - for _, locationInRxMetadata := range []bool{false, true} { - t.Run(fmt.Sprintf("RxMetadata=%v", locationInRxMetadata), func(t *testing.T) { - if !locationInRxMetadata && locationPublic { - // Disabled, because this is inconsistent amongst frontends - // - gRPC and MQTT: location is in RxMetadata - // - UDP and BasicStation: location is not in RxMetadata - t.SkipNow() - } - a := assertions.New(t) - - if locationInRxMetadata { - up.UplinkMessages[0].RxMetadata[0].Location = location - } else { - up.UplinkMessages[0].RxMetadata[0].Location = nil - } - up.UplinkMessages[0].RawPayload = randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6) - - select { - case upCh <- up: - case <-time.After(timeout): - t.Fatalf("Failed to send message to upstream channel") - } - - select { - case msg := <-ns.Up(): - if a.So(len(msg.RxMetadata), should.Equal, 1) { - if locationPublic { - a.So(msg.RxMetadata[0].Location, should.Resemble, location) - } else { - a.So(msg.RxMetadata[0].Location, should.BeNil) - } - } - case <-time.After(2 * timeout): - t.Fatalf("Failed to get message") - } - }) - } - - cancel() - wg.Wait() - if !errors.IsCanceled(linkErr) { - t.Fatalf("Expected context canceled, but have %v", linkErr) - } - }) - } - }) - } - // Wait for gateway disconnection to be processed. - time.Sleep(timeout) - - wg := &sync.WaitGroup{} - wg.Add(1) - var linkErr error - go func() { - defer wg.Done() - linkErr = ptc.Link(ctx, t, ids, registeredGatewayKey, upCh, downCh) - }() - - // Expected location for RxMetadata - var location *ttnpb.Location - if rtc.SupportsLocationUpdate { - gtw, err := is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ - GatewayIds: ids, - }) - a.So(err, should.BeNil) - location = gtw.Antennas[0].Location - } - - duplicatePayload := randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6) - - t.Run("Upstream", func(t *testing.T) { - uplinkCount := 0 - for _, tc := range []struct { - Name string - Up *ttnpb.GatewayUp - Received []uint32 // Timestamps of uplink messages in Up that are received. - Dropped []uint32 // Timestamps of uplink messages in Up that are dropped. - PublicLocation bool // If gateway location is public, it should be in RxMetadata - UplinkCount int // Number of expected uplinks - RepeatUpEvent bool // Expect event for repeated uplinks - SkipIfDetectsInvalidMessages bool // Skip this test if the frontend detects invalid messages - }{ - { - Name: "GatewayStatus", - Up: &ttnpb.GatewayUp{ - GatewayStatus: &ttnpb.GatewayStatus{ - Time: timestamppb.New(time.Unix(424242, 0)), - }, - }, - }, - { - Name: "TxAck", - Up: &ttnpb.GatewayUp{ - TxAcknowledgment: &ttnpb.TxAcknowledgment{ - Result: ttnpb.TxAcknowledgment_SUCCESS, - }, - }, - }, - { - Name: "CRCFailure", - Up: &ttnpb.GatewayUp{ - UplinkMessages: []*ttnpb.UplinkMessage{ - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 250000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 867900000, - Timestamp: 100, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: ids, - Timestamp: 100, - Rssi: -69, - ChannelRssi: -69, - Snr: 11, - Location: location, - }, - }, - RawPayload: randomUpDataPayload(types.DevAddr{0x26, 0x02, 0xff, 0xff}, 2, 2), - CrcStatus: wrapperspb.Bool(false), - }, - }, - }, - Received: []uint32{100}, - Dropped: []uint32{100}, - SkipIfDetectsInvalidMessages: true, - }, - { - Name: "OneValidLoRa", - Up: &ttnpb.GatewayUp{ - UplinkMessages: []*ttnpb.UplinkMessage{ - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 250000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 867900000, - Timestamp: 200, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: ids, - Timestamp: 200, - Rssi: -69, - ChannelRssi: -69, - Snr: 11, - Location: location, - }, - }, - RawPayload: randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), - }, - }, - }, - Received: []uint32{200}, - }, - { - Name: "OneValidLoRaAndTwoRepeated", - Up: &ttnpb.GatewayUp{ - UplinkMessages: []*ttnpb.UplinkMessage{ - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 250000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 867900000, - Timestamp: 301, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: ids, - Timestamp: 301, - Rssi: -42, - ChannelRssi: -42, - Snr: 11, - Location: location, - }, - }, - RawPayload: duplicatePayload, - }, - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 250000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 867900000, - Timestamp: 300, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: ids, - Timestamp: 300, - Rssi: -69, - ChannelRssi: -69, - Snr: 11, - Location: location, - }, - }, - RawPayload: duplicatePayload, - }, - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 250000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 867900000, - Timestamp: 300, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: ids, - Timestamp: 300, - Rssi: -69, - ChannelRssi: -69, - Snr: 11, - Location: location, - }, - }, - RawPayload: duplicatePayload, - }, - }, - }, - Received: []uint32{301}, - UplinkCount: 1, - RepeatUpEvent: true, - }, - { - Name: "OneValidFSK", - Up: &ttnpb.GatewayUp{ - UplinkMessages: []*ttnpb.UplinkMessage{ - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Fsk{ - Fsk: &ttnpb.FSKDataRate{ - BitRate: 50000, - }, - }, - }, - Frequency: 867900000, - Timestamp: 400, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: ids, - Timestamp: 400, - Rssi: -69, - ChannelRssi: -69, - Snr: 11, - Location: location, - }, - }, - RawPayload: randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), - }, - }, - }, - Received: []uint32{400}, - }, - { - Name: "OneGarbageWithStatus", - Up: &ttnpb.GatewayUp{ - UplinkMessages: []*ttnpb.UplinkMessage{ - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 9, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 868500000, - Timestamp: 500, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: ids, - Timestamp: 500, - Rssi: -112, - ChannelRssi: -112, - Snr: 2, - Location: location, - }, - }, - RawPayload: []byte{0xff, 0x02, 0x03}, // Garbage; doesn't get forwarded. - }, - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 868100000, - Timestamp: 501, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: ids, - Timestamp: 501, - Rssi: -69, - ChannelRssi: -69, - Snr: 11, - Location: location, - }, - }, - RawPayload: randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), - }, - { - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 12, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 867700000, - Timestamp: 502, - }, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: ids, - Timestamp: 502, - Rssi: -36, - ChannelRssi: -36, - Snr: 5, - Location: location, - }, - }, - RawPayload: randomJoinRequestPayload( - types.EUI64{0x42, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, - types.EUI64{0x42, 0x42, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, - ), - }, - }, - GatewayStatus: &ttnpb.GatewayStatus{ - Time: timestamppb.New(time.Unix(4242424, 0)), - }, - }, - Received: []uint32{501, 502}, - }, - } { - t.Run(tc.Name, func(t *testing.T) { - a := assertions.New(t) - - if tc.SkipIfDetectsInvalidMessages && ptc.DetectsInvalidMessages { - t.Skip("Skipping test case because gateway detects invalid messages") - } - - upEvents := map[string]events.Channel{} - for _, event := range []string{"gs.up.receive", "gs.down.tx.success", "gs.down.tx.fail", "gs.status.receive", "gs.io.up.repeat"} { - upEvents[event] = make(events.Channel, 5) - } - defer test.SetDefaultEventsPubSub(&test.MockEventPubSub{ - PublishFunc: func(evs ...events.Event) { - for _, ev := range evs { - ev := ev - switch name := ev.Name(); name { - case "gs.up.receive", "gs.down.tx.success", "gs.down.tx.fail", "gs.status.receive", "gs.io.up.repeat": - go func() { - upEvents[name] <- ev - }() - default: - t.Logf("%s event published", name) - } - } - }, - })() - - select { - case upCh <- tc.Up: - case <-time.After(timeout): - t.Fatalf("Failed to send message to upstream channel") - } - if tc.UplinkCount > 0 { - uplinkCount += tc.UplinkCount - } else if ptc.DetectsInvalidMessages { - uplinkCount += len(tc.Received) - } else { - uplinkCount += len(tc.Up.UplinkMessages) - } - - if tc.RepeatUpEvent && !ptc.DeduplicatesUplinks { - select { - case evt := <-upEvents["gs.io.up.repeat"]: - a.So(evt.Name(), should.Equal, "gs.io.up.repeat") - case <-time.After(timeout): - t.Fatal("Expected repeat uplink event timeout") - } - } - - received := make(map[uint32]struct{}) - forwarded := make(map[uint32]struct{}) - for _, t := range tc.Received { - received[t] = struct{}{} - forwarded[t] = struct{}{} - } - for _, t := range tc.Dropped { - delete(forwarded, t) - } - for len(received) > 0 { - if len(forwarded) > 0 { - select { - case msg := <-ns.Up(): - var expected *ttnpb.UplinkMessage - for _, up := range tc.Up.UplinkMessages { - if ts := up.Settings.Timestamp; ts == msg.Settings.Timestamp { - if _, ok := forwarded[ts]; !ok { - t.Fatalf("Not expecting message %v", msg) - } - expected = up - delete(forwarded, ts) - break - } - } - if expected == nil { - t.Fatalf("Received unexpected message with timestamp %d", msg.Settings.Timestamp) - } - a.So(time.Since(*ttnpb.StdTime(msg.ReceivedAt)), should.BeLessThan, timeout) - a.So(msg.Settings, should.Resemble, expected.Settings) - a.So(len(msg.RxMetadata), should.Equal, len(expected.RxMetadata)) - for i, md := range msg.RxMetadata { - a.So(md.UplinkToken, should.NotBeEmpty) - md.UplinkToken = nil - md.ReceivedAt = nil - a.So(md, should.Resemble, expected.RxMetadata[i]) - } - a.So(msg.RawPayload, should.Resemble, expected.RawPayload) - case <-time.After(timeout): - t.Fatal("Expected uplink timeout") - } - } - select { - case evt := <-upEvents["gs.up.receive"]: - a.So(evt.Name(), should.Equal, "gs.up.receive") - msg := evt.Data().(*ttnpb.GatewayUplinkMessage) - delete(received, msg.Message.Settings.Timestamp) - case <-time.After(timeout): - t.Fatal("Expected uplink event timeout") - } - } - if expected := tc.Up.TxAcknowledgment; expected != nil { - select { - case <-upEvents["gs.down.tx.success"]: - case evt := <-upEvents["gs.down.tx.fail"]: - received, ok := evt.Data().(ttnpb.TxAcknowledgment_Result) - if !ok { - t.Fatal("No acknowledgment attached to the downlink emission fail event") - } - a.So(received, should.Resemble, expected.Result) - case <-time.After(timeout): - t.Fatal("Expected Tx acknowledgment event timeout") - } - select { - case ack := <-ns.TxAck(): - if txAck := ack.GetTxAck(); a.So(txAck, should.NotBeNil) { - a.So(txAck.Result, should.Resemble, expected.Result) - a.So(txAck.DownlinkMessage, should.Resemble, expected.DownlinkMessage) - } - case <-time.After(timeout): - t.Fatal("Expected Tx acknowledgment event timeout") - } - } - if tc.Up.GatewayStatus != nil && ptc.SupportsStatus { - select { - case <-upEvents["gs.status.receive"]: - case <-time.After(timeout): - t.Fatal("Expected gateway status event timeout") - } - } - - time.Sleep(2 * timeout) - - conn, ok := gs.GetConnection(ctx, ids) - a.So(ok, should.BeTrue) - - stats, paths := conn.Stats() - a.So(stats, should.NotBeNil) - a.So(paths, should.NotBeEmpty) - - stats, err := statsClient.GetGatewayConnectionStats(statsCtx, ids) - if !a.So(err, should.BeNil) { - t.FailNow() - } - a.So(stats.UplinkCount, should.Equal, uplinkCount) - - if tc.Up.GatewayStatus != nil && ptc.SupportsStatus { - if !a.So(stats.LastStatus, should.NotBeNil) { - t.FailNow() - } - a.So(stats.LastStatus.Time, should.Resemble, tc.Up.GatewayStatus.Time) - } - }) - } - }) - - t.Run("Downstream", func(t *testing.T) { - ctx := clusterauth.NewContext(test.Context(), nil) - downlinkCount := 0 - for _, tc := range []struct { - Name string - Message *ttnpb.DownlinkMessage - ErrorAssertion func(error) bool - RxWindowDetailsAssertion []func(error) bool - }{ - { - Name: "InvalidSettingsType", - Message: &ttnpb.DownlinkMessage{ - RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), - Settings: &ttnpb.DownlinkMessage_Scheduled{ - Scheduled: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 12, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 869525000, - Downlink: &ttnpb.TxSettings_Downlink{ - TxPower: 10, - }, - Timestamp: 100, - }, - }, - }, - ErrorAssertion: errors.IsInvalidArgument, // Network Server may send Tx request only. - }, - { - Name: "NotConnected", - Message: &ttnpb.DownlinkMessage{ - Settings: &ttnpb.DownlinkMessage_Request{ - Request: &ttnpb.TxRequest{ - Class: ttnpb.Class_CLASS_C, - DownlinkPaths: []*ttnpb.DownlinkPath{ - { - Path: &ttnpb.DownlinkPath_Fixed{ - Fixed: &ttnpb.GatewayAntennaIdentifiers{ - GatewayIds: &ttnpb.GatewayIdentifiers{ - GatewayId: "not-connected", - }, - }, - }, - }, - }, - FrequencyPlanId: test.EUFrequencyPlanID, - }, - }, - }, - ErrorAssertion: errors.IsAborted, // The gateway is not connected. - }, - { - Name: "ValidClassA", - Message: &ttnpb.DownlinkMessage{ - RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), - Settings: &ttnpb.DownlinkMessage_Request{ - Request: &ttnpb.TxRequest{ - Class: ttnpb.Class_CLASS_A, - DownlinkPaths: []*ttnpb.DownlinkPath{ - { - Path: &ttnpb.DownlinkPath_UplinkToken{ - UplinkToken: gsio.MustUplinkToken( - &ttnpb.GatewayAntennaIdentifiers{ - GatewayIds: &ttnpb.GatewayIdentifiers{ - GatewayId: registeredGatewayID, - }, - }, - 10000000, - 10000000000, - time.Unix(0, 10000000*1000), - nil, - ), - }, - }, - }, - Priority: ttnpb.TxSchedulePriority_NORMAL, - Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, - Rx1DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Rx1Frequency: 868100000, - FrequencyPlanId: test.EUFrequencyPlanID, - }, - }, - }, - }, - { - Name: "ValidClassAWithoutFrequencyPlanInTxRequest", - Message: &ttnpb.DownlinkMessage{ - RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), - Settings: &ttnpb.DownlinkMessage_Request{ - Request: &ttnpb.TxRequest{ - Class: ttnpb.Class_CLASS_A, - DownlinkPaths: []*ttnpb.DownlinkPath{ - { - Path: &ttnpb.DownlinkPath_UplinkToken{ - UplinkToken: gsio.MustUplinkToken( - &ttnpb.GatewayAntennaIdentifiers{ - GatewayIds: &ttnpb.GatewayIdentifiers{ - GatewayId: registeredGatewayID, - }, - }, - 20000000, - 20000000000, - time.Unix(0, 20000000*1000), - nil, - ), - }, - }, - }, - Priority: ttnpb.TxSchedulePriority_NORMAL, - Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, - Rx1DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Rx1Frequency: 868100000, - }, - }, - }, - }, - { - Name: "ConflictClassA", - Message: &ttnpb.DownlinkMessage{ - RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x02, 0xff, 0xff}, 1, 6), - Settings: &ttnpb.DownlinkMessage_Request{ - Request: &ttnpb.TxRequest{ - Class: ttnpb.Class_CLASS_A, - DownlinkPaths: []*ttnpb.DownlinkPath{ - { - Path: &ttnpb.DownlinkPath_UplinkToken{ - UplinkToken: gsio.MustUplinkToken( - &ttnpb.GatewayAntennaIdentifiers{ - GatewayIds: &ttnpb.GatewayIdentifiers{ - GatewayId: registeredGatewayID, - }, - }, - 10000000, - 10000000000, - time.Unix(0, 10000000*1000), - nil, - ), - }, - }, - }, - Priority: ttnpb.TxSchedulePriority_NORMAL, - Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, - Rx1DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Rx1Frequency: 868100000, - FrequencyPlanId: test.EUFrequencyPlanID, - }, - }, - }, - ErrorAssertion: errors.IsAborted, - RxWindowDetailsAssertion: []func(error) bool{ - errors.IsAlreadyExists, // Rx1 conflicts with previous. - errors.IsFailedPrecondition, // Rx2 not provided. - }, - }, - { - Name: "ValidClassC", - Message: &ttnpb.DownlinkMessage{ - RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x02, 0xff, 0xff}, 1, 6), - Settings: &ttnpb.DownlinkMessage_Request{ - Request: &ttnpb.TxRequest{ - Class: ttnpb.Class_CLASS_C, - DownlinkPaths: []*ttnpb.DownlinkPath{ - { - Path: &ttnpb.DownlinkPath_Fixed{ - Fixed: &ttnpb.GatewayAntennaIdentifiers{ - GatewayIds: &ttnpb.GatewayIdentifiers{ - GatewayId: registeredGatewayID, - }, - }, - }, - }, - }, - Priority: ttnpb.TxSchedulePriority_NORMAL, - Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, - Rx1DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Rx1Frequency: 868100000, - FrequencyPlanId: test.EUFrequencyPlanID, - }, - }, - }, - }, - { - Name: "ValidClassCWithoutFrequencyPlanInTxRequest", - Message: &ttnpb.DownlinkMessage{ - RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x02, 0xff, 0xff}, 1, 6), - Settings: &ttnpb.DownlinkMessage_Request{ - Request: &ttnpb.TxRequest{ - Class: ttnpb.Class_CLASS_C, - DownlinkPaths: []*ttnpb.DownlinkPath{ - { - Path: &ttnpb.DownlinkPath_Fixed{ - Fixed: &ttnpb.GatewayAntennaIdentifiers{ - GatewayIds: &ttnpb.GatewayIdentifiers{ - GatewayId: registeredGatewayID, - }, - }, - }, - }, - }, - Priority: ttnpb.TxSchedulePriority_NORMAL, - Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, - Rx1DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Rx1Frequency: 868100000, - }, - }, - }, - }, - } { - t.Run(tc.Name, func(t *testing.T) { - a := assertions.New(t) - - _, err := gs.ScheduleDownlink(ctx, tc.Message) - if err != nil { - if tc.ErrorAssertion == nil || !a.So(tc.ErrorAssertion(err), should.BeTrue) { - t.Fatalf("Unexpected error: %v", err) - } - if tc.RxWindowDetailsAssertion != nil { - a.So(err, should.HaveSameErrorDefinitionAs, gatewayserver.ErrSchedule) - if !a.So(errors.Details(err), should.HaveLength, 1) { - t.FailNow() - } - details := errors.Details(err)[0].(*ttnpb.ScheduleDownlinkErrorDetails) - if !a.So(details, should.NotBeNil) || !a.So(details.PathErrors, should.HaveLength, 1) { - t.FailNow() - } - errSchedulePathCause := errors.Cause(ttnpb.ErrorDetailsFromProto(details.PathErrors[0])) - a.So(errors.IsAborted(errSchedulePathCause), should.BeTrue) - for i, assert := range tc.RxWindowDetailsAssertion { - if !a.So(errors.Details(errSchedulePathCause), should.HaveLength, 1) { - t.FailNow() - } - errSchedulePathCauseDetails := errors.Details(errSchedulePathCause)[0].(*ttnpb.ScheduleDownlinkErrorDetails) - if !a.So(errSchedulePathCauseDetails, should.NotBeNil) { - t.FailNow() - } - if i >= len(errSchedulePathCauseDetails.PathErrors) { - t.Fatalf("Expected error in Rx window %d", i+1) - } - errRxWindow := ttnpb.ErrorDetailsFromProto(errSchedulePathCauseDetails.PathErrors[i]) - if !a.So(assert(errRxWindow), should.BeTrue) { - t.Fatalf("Unexpected Rx window %d error: %v", i+1, errRxWindow) - } - } - } - return - } else if tc.ErrorAssertion != nil { - t.Fatalf("Expected error") - } - downlinkCount++ - - select { - case msg := <-downCh: - settings := msg.DownlinkMessage.GetScheduled() - a.So(settings, should.NotBeNil) - case <-time.After(timeout): - t.Fatal("Expected downlink timeout") - } - - time.Sleep(2 * timeout) - - conn, ok := gs.GetConnection(ctx, ids) - a.So(ok, should.BeTrue) - - stats, paths := conn.Stats() - a.So(stats, should.NotBeNil) - a.So(paths, should.NotBeEmpty) - - stats, err = statsClient.GetGatewayConnectionStats(statsCtx, ids) - if !a.So(err, should.BeNil) { - t.FailNow() - } - a.So(stats.DownlinkCount, should.Equal, downlinkCount) - }) - } - }) - - cancel() - wg.Wait() - if !errors.IsCanceled(linkErr) { - t.Fatalf("Expected context canceled, but have %v", linkErr) - } - - // Wait for disconnection to be processed. - time.Sleep(2 * config.ConnectionStatsDisconnectTTL) - - // After canceling the context and awaiting the link, the connection should be gone. - t.Run("Disconnected", func(t *testing.T) { - _, err := statsClient.GetGatewayConnectionStats(statsCtx, ids) - if !a.So(errors.IsNotFound(err), should.BeTrue) { - t.Fatalf("Expected gateway to be disconnected, but it's not") - } - }) - }) - } - - t.Run("Shutdown", func(t *testing.T) { - if statsRegistry == nil { - t.Skip("Stats registry disabled") - } - - ids := &ttnpb.GatewayIdentifiers{ - GatewayId: registeredGatewayID, - Eui: registeredGatewayEUI.Bytes(), - } - - md := rpcmetadata.MD{ - ID: ids.GatewayId, - AuthType: "Bearer", - AuthValue: registeredGatewayKey, - AllowInsecure: true, - } - _, err = ttnpb.NewGtwGsClient(gs.LoopbackConn()).LinkGateway(ctx, grpc.PerRPCCredentials(md)) - if !a.So(err, should.BeNil) { - t.FailNow() - } - - gs.Close() - time.Sleep(2 * config.ConnectionStatsTTL) - - _, err = statsRegistry.Get(ctx, ids) - a.So(errors.IsNotFound(err), should.BeTrue) - }) - }) +func mustHavePeer(ctx context.Context, t *testing.T, c *component.Component, role ttnpb.ClusterRole) { + t.Helper() + for i := 0; i < 20; i++ { + time.Sleep(20 * time.Millisecond) + if _, err := c.GetPeer(ctx, role, nil); err == nil { + return + } } + t.Fatal("Could not connect to peer") } func TestUpdateVersionInfo(t *testing.T) { //nolint:paralleltest + var ( + timeout = (1 << 4) * test.Delay + testRights = []ttnpb.Right{ttnpb.Right_RIGHT_GATEWAY_LINK, ttnpb.Right_RIGHT_GATEWAY_STATUS_READ} + registeredGatewayID = "eui-aaee000000000000" + registeredGatewayKey = "secret" + registeredGatewayEUI = types.EUI64{0xAA, 0xEE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + ) + a, ctx := test.New(t) ctx, cancel := context.WithCancel(ctx) @@ -1978,7 +114,7 @@ func TestUpdateVersionInfo(t *testing.T) { //nolint:paralleltest componenttest.StartComponent(t, c) time.Sleep(timeout) // Wait for component to start. - mustHavePeer(ctx, c, ttnpb.ClusterRole_ENTITY_REGISTRY) + mustHavePeer(ctx, t, c, ttnpb.ClusterRole_ENTITY_REGISTRY) gtwIDs := &ttnpb.GatewayIdentifiers{ GatewayId: registeredGatewayID, @@ -1989,7 +125,7 @@ func TestUpdateVersionInfo(t *testing.T) { //nolint:paralleltest is.GatewayRegistry().Add(ctx, gtwIDs, registeredGatewayKey, mockGtw, testRights...) time.Sleep(timeout) // Wait for setup to be completed. - linkFn := func(ctx context.Context, t *testing.T, ids *ttnpb.GatewayIdentifiers, key string, statCh <-chan *ttnpbv2.StatusMessage) error { + link := func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string, statCh <-chan *ttnpbv2.StatusMessage) error { ctx, cancel := errorcontext.New(ctx) clientOpts := mqtt.NewClientOptions() clientOpts.AddBroker("tcp://0.0.0.0:1881") @@ -2021,7 +157,6 @@ func TestUpdateVersionInfo(t *testing.T) { //nolint:paralleltest cancel(token.Error()) return } - } } }() @@ -2034,9 +169,7 @@ func TestUpdateVersionInfo(t *testing.T) { //nolint:paralleltest GatewayId: registeredGatewayID, Eui: registeredGatewayEUI.Bytes(), } - go func() { - linkFn(ctx, t, ids, registeredGatewayKey, statCh) - }() + go link(ctx, ids, registeredGatewayKey, statCh) for _, tc := range []struct { Name string @@ -2105,6 +238,14 @@ func TestUpdateVersionInfo(t *testing.T) { //nolint:paralleltest } func TestBatchGetStatus(t *testing.T) { // nolint:paralleltest + var ( + timeout = (1 << 4) * test.Delay + testRights = []ttnpb.Right{ttnpb.Right_RIGHT_GATEWAY_LINK, ttnpb.Right_RIGHT_GATEWAY_STATUS_READ} + registeredGatewayID = "eui-aaee000000000000" + registeredGatewayKey = "secret" + registeredGatewayEUI = types.EUI64{0xAA, 0xEE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + ) + a, ctx := test.New(t) for _, tc := range []struct { //nolint:paralleltest @@ -2181,7 +322,7 @@ func TestBatchGetStatus(t *testing.T) { // nolint:paralleltest componenttest.StartComponent(t, c) time.Sleep(timeout) // Wait for component to start. - mustHavePeer(ctx, c, ttnpb.ClusterRole_ENTITY_REGISTRY) + mustHavePeer(ctx, t, c, ttnpb.ClusterRole_ENTITY_REGISTRY) linkFn := func(ctx context.Context, t *testing.T, ids *ttnpb.GatewayIdentifiers, key string, statCh <-chan *ttnpbv2.StatusMessage, diff --git a/pkg/gatewayserver/io/grpc/grpc_test.go b/pkg/gatewayserver/io/grpc/grpc_test.go index 830644e9bb1..e6121d72f7f 100644 --- a/pkg/gatewayserver/io/grpc/grpc_test.go +++ b/pkg/gatewayserver/io/grpc/grpc_test.go @@ -17,20 +17,19 @@ package grpc_test import ( "context" "fmt" - "strconv" "sync" "testing" - "time" "github.com/smarty/assertions" - "go.thethings.network/lorawan-stack/v3/pkg/band" "go.thethings.network/lorawan-stack/v3/pkg/cluster" "go.thethings.network/lorawan-stack/v3/pkg/component" componenttest "go.thethings.network/lorawan-stack/v3/pkg/component/test" "go.thethings.network/lorawan-stack/v3/pkg/config" + "go.thethings.network/lorawan-stack/v3/pkg/errorcontext" "go.thethings.network/lorawan-stack/v3/pkg/errors" - "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver" . "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/grpc" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/iotest" "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/mock" mockis "go.thethings.network/lorawan-stack/v3/pkg/identityserver/mock" "go.thethings.network/lorawan-stack/v3/pkg/log" @@ -41,18 +40,15 @@ import ( "go.thethings.network/lorawan-stack/v3/pkg/util/test" "go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should" "google.golang.org/grpc" - "google.golang.org/protobuf/types/known/timestamppb" -) - -var ( - registeredGatewayID = &ttnpb.GatewayIdentifiers{GatewayId: "test-gateway"} - registeredGatewayUID = unique.ID(test.Context(), registeredGatewayID) - registeredGatewayKey = "test-key" - - timeout = (1 << 4) * test.Delay ) func TestAuthentication(t *testing.T) { + var ( + registeredGatewayID = &ttnpb.GatewayIdentifiers{GatewayId: "test-gateway"} + registeredGatewayKey = "test-key" + timeout = (1 << 4) * test.Delay + ) + ctx := log.NewContext(test.Context(), test.GetLogger(t)) ctx, cancelCtx := context.WithCancel(ctx) defer cancelCtx() @@ -150,465 +146,75 @@ func TestAuthentication(t *testing.T) { } } -type erroredGatewayDown struct { - *ttnpb.GatewayDown - error -} - -func TestTraffic(t *testing.T) { - ctx := log.NewContext(test.Context(), test.GetLogger(t)) - ctx, cancelCtx := context.WithCancel(ctx) - defer cancelCtx() - - is, isAddr, closeIS := mockis.New(ctx) - defer closeIS() - testGtw := mockis.DefaultGateway(registeredGatewayID, false, false) - is.GatewayRegistry().Add(ctx, registeredGatewayID, registeredGatewayKey, testGtw, testRights...) - - c := componenttest.NewComponent(t, &component.Config{ - ServiceBase: config.ServiceBase{ - GRPC: config.GRPC{ - Listen: ":0", - AllowInsecureForCredentials: true, - }, - Cluster: cluster.Config{ - IdentityServer: isAddr, - }, - FrequencyPlans: config.FrequencyPlansConfig{ - ConfigSource: "static", - Static: test.StaticFrequencyPlans, - }, - }, - }) - gs := mock.NewServer(c, is) - srv := New(gs) - c.RegisterGRPC(&mockRegisterer{ctx, srv}) - componenttest.StartComponent(t, c) - defer c.Close() - - mustHavePeer(ctx, c, ttnpb.ClusterRole_ENTITY_REGISTRY) - - client := ttnpb.NewGtwGsClient(c.LoopbackConn()) - - ctx = rpcmetadata.MD{ - ID: registeredGatewayID.GatewayId, - }.ToOutgoingContext(ctx) - creds := grpc.PerRPCCredentials(rpcmetadata.MD{ - AuthType: "Bearer", - AuthValue: registeredGatewayKey, - AllowInsecure: true, - }) - - upCh := make(chan *ttnpb.GatewayUp, 10) - downCh := make(chan erroredGatewayDown, 10) - - stream, err := client.LinkGateway(ctx, creds) - if err != nil { - t.Fatalf("Failed to link gateway: %v", err) - } - go func() { - for up := range upCh { - if err := stream.Send(up); err != nil { - panic(err) - } - } - }() - go func() { - for ctx.Err() == nil { - down, err := stream.Recv() - downCh <- erroredGatewayDown{down, err} - } - }() - - var conn *io.Connection - select { - case conn = <-gs.Connections(): - case <-time.After(timeout): - t.Fatal("Connection timeout") - } - - t.Run("Upstream", func(t *testing.T) { - for _, tc := range []*ttnpb.GatewayUp{ - {}, - { - GatewayStatus: &ttnpb.GatewayStatus{ - Ip: []string{"1.1.1.1"}, - Time: timestamppb.Now(), - }, - }, - { - UplinkMessages: []*ttnpb.UplinkMessage{ - { - RawPayload: []byte{0x01}, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: registeredGatewayID, - }, - }, - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{Lora: &ttnpb.LoRaDataRate{ - Bandwidth: 125000, - SpreadingFactor: 11, - CodingRate: band.Cr4_5, - }}, - }, - EnableCrc: true, - Frequency: 868500000, - Timestamp: 42, - }, - }, - }, - }, - { - UplinkMessages: []*ttnpb.UplinkMessage{ - { - RawPayload: []byte{0x02}, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: registeredGatewayID, - }, - }, - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{Lora: &ttnpb.LoRaDataRate{ - Bandwidth: 125000, - SpreadingFactor: 11, - CodingRate: band.Cr4_5, - }}, - }, - EnableCrc: true, - Frequency: 868500000, - Timestamp: 42, - }, - }, - }, - GatewayStatus: &ttnpb.GatewayStatus{ - Ip: []string{"2.2.2.2"}, - Time: timestamppb.Now(), - }, - }, - { - UplinkMessages: []*ttnpb.UplinkMessage{ - { - RawPayload: []byte{0x03}, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: registeredGatewayID, - }, - }, - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{Lora: &ttnpb.LoRaDataRate{ - Bandwidth: 125000, - SpreadingFactor: 11, - CodingRate: band.Cr4_5, - }}, - }, - EnableCrc: true, - Frequency: 868500000, - Timestamp: 42, - }, - }, - { - RawPayload: []byte{0x04}, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: registeredGatewayID, - }, - }, - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{Lora: &ttnpb.LoRaDataRate{ - Bandwidth: 125000, - SpreadingFactor: 11, - CodingRate: band.Cr4_5, - }}, - }, - EnableCrc: true, - Frequency: 868500000, - Timestamp: 42, - }, - }, - { - RawPayload: []byte{0x05}, - RxMetadata: []*ttnpb.RxMetadata{ - { - GatewayIds: registeredGatewayID, - }, - }, - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{Lora: &ttnpb.LoRaDataRate{ - Bandwidth: 125000, - SpreadingFactor: 11, - CodingRate: band.Cr4_5, - }}, - }, - EnableCrc: true, - Frequency: 868500000, - Timestamp: 42, - }, - }, - }, - GatewayStatus: &ttnpb.GatewayStatus{ - Ip: []string{"3.3.3.3"}, - Time: timestamppb.Now(), - }, - }, - { - TxAcknowledgment: &ttnpb.TxAcknowledgment{ - Result: ttnpb.TxAcknowledgment_SUCCESS, - }, - }, - } { - t.Run(fmt.Sprintf("%v/%v", len(tc.UplinkMessages), tc.GatewayStatus != nil), func(t *testing.T) { - a := assertions.New(t) - - upCh <- tc - - var ups int - needStatus := tc.GatewayStatus != nil - needTxAck := tc.TxAcknowledgment != nil - for ups != len(tc.UplinkMessages) || needStatus || needTxAck { - select { - case up := <-conn.Up(): - expected := ttnpb.Clone(tc.UplinkMessages[ups]) - expected.ReceivedAt = up.Message.ReceivedAt - for i, md := range expected.RxMetadata { - md.UplinkToken = up.Message.RxMetadata[i].UplinkToken - md.ReceivedAt = up.Message.ReceivedAt - } - a.So(up.Message, should.Resemble, expected) - ups++ - case status := <-conn.Status(): - a.So(needStatus, should.BeTrue) - a.So(status, should.Resemble, tc.GatewayStatus) - needStatus = false - case ack := <-conn.TxAck(): - a.So(needTxAck, should.BeTrue) - a.So(ack, should.Resemble, tc.TxAcknowledgment) - needTxAck = false - case <-time.After(timeout): - t.Fatalf("Receive expected upstream timeout; ups = %v, needStatus = %t, needAck = %t", ups, needStatus, needTxAck) - } - } - }) - } - t.Run("DeduplicateByRSSI", func(t *testing.T) { - a := assertions.New(t) - upCh <- &ttnpb.GatewayUp{ - UplinkMessages: []*ttnpb.UplinkMessage{ - { - RawPayload: []byte{0x06}, - RxMetadata: []*ttnpb.RxMetadata{{GatewayIds: registeredGatewayID, Rssi: -100}}, - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{Lora: &ttnpb.LoRaDataRate{ - Bandwidth: 125000, - SpreadingFactor: 11, - CodingRate: band.Cr4_5, - }}, - }, - EnableCrc: true, - Frequency: 868500000, - Timestamp: 42, - }, - }, - { - RawPayload: []byte{0x06}, - RxMetadata: []*ttnpb.RxMetadata{{GatewayIds: registeredGatewayID, Rssi: -10}}, - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{Lora: &ttnpb.LoRaDataRate{ - Bandwidth: 125000, - SpreadingFactor: 11, - CodingRate: band.Cr4_5, - }}, - }, - EnableCrc: true, - Frequency: 868700000, - Timestamp: 42, - }, - }, - }, +func TestFrontend(t *testing.T) { + iotest.Frontend(t, iotest.FrontendConfig{ + SupportsStatus: true, + IsAuthenticated: true, + DetectsDisconnect: true, + DeduplicatesUplinks: true, + Link: func( + ctx context.Context, + t *testing.T, + gs *gatewayserver.GatewayServer, + ids *ttnpb.GatewayIdentifiers, + key string, + upCh <-chan *ttnpb.GatewayUp, + downCh chan<- *ttnpb.GatewayDown, + ) error { + md := rpcmetadata.MD{ + ID: ids.GatewayId, + AuthType: "Bearer", + AuthValue: key, + AllowInsecure: true, } - select { - case up := <-conn.Up(): - a.So(up.Message.RxMetadata[0].Rssi, should.Equal, -10) - a.So(up.Message.RawPayload, should.Resemble, []byte{0x06}) - a.So(up.Message.Settings.Frequency, should.Equal, 868700000) - case <-time.After(timeout): - t.Fatalf("Receive unexpected upstream timeout") + client := ttnpb.NewGtwGsClient(gs.LoopbackConn()) + _, err := client.GetConcentratorConfig(ctx, ttnpb.Empty, grpc.PerRPCCredentials(md)) + if err != nil { + return err } - select { - case <-conn.Up(): - t.Fatalf("Received unexpected upstream message") - case <-time.After(timeout): + link, err := client.LinkGateway(ctx, grpc.PerRPCCredentials(md)) + if err != nil { + return err } - }) - }) - - t.Run("Downstream", func(t *testing.T) { - for i, tc := range []struct { - Path *ttnpb.DownlinkPath - Message *ttnpb.DownlinkMessage - ErrorAssertion func(error) bool - SendTxAck bool - TxAckErrorAssertion func(error) bool - }{ - { - Path: &ttnpb.DownlinkPath{ - Path: &ttnpb.DownlinkPath_UplinkToken{ - UplinkToken: io.MustUplinkToken( - &ttnpb.GatewayAntennaIdentifiers{GatewayIds: registeredGatewayID}, - 100, - 100000, - time.Unix(0, 100*1000), - nil, - ), - }, - }, - Message: &ttnpb.DownlinkMessage{ - RawPayload: []byte{0x01}, - Settings: &ttnpb.DownlinkMessage_Request{ - Request: &ttnpb.TxRequest{ - Class: ttnpb.Class_CLASS_A, - Priority: ttnpb.TxSchedulePriority_NORMAL, - Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, - Rx1DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Rx1Frequency: 868100000, - Rx2DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 0, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Rx2Frequency: 869525000, - FrequencyPlanId: test.EUFrequencyPlanID, - }, - }, - }, - SendTxAck: true, - }, - { - Path: &ttnpb.DownlinkPath{ - Path: &ttnpb.DownlinkPath_UplinkToken{ - UplinkToken: io.MustUplinkToken( - &ttnpb.GatewayAntennaIdentifiers{GatewayIds: registeredGatewayID}, - 100, - 100000, - time.Unix(0, 100*1000), - nil, - ), - }, - }, - Message: &ttnpb.DownlinkMessage{ - RawPayload: []byte{0x01}, - Settings: &ttnpb.DownlinkMessage_Scheduled{ - Scheduled: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - Bandwidth: 125000, - SpreadingFactor: 7, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 869525000, - }, - }, - }, - ErrorAssertion: errors.IsInvalidArgument, // Does not support scheduled downlink; the Gateway Server or gateway will take care of that. - }, - { - Path: &ttnpb.DownlinkPath{ - Path: &ttnpb.DownlinkPath_UplinkToken{ - UplinkToken: io.MustUplinkToken( - &ttnpb.GatewayAntennaIdentifiers{GatewayIds: registeredGatewayID}, - 100, - 100000, - time.Unix(0, 100*1000), - nil, - ), - }, - }, - Message: &ttnpb.DownlinkMessage{ - RawPayload: []byte{0x02}, - }, - ErrorAssertion: errors.IsInvalidArgument, // Tx request missing. - }, - } { - t.Run(strconv.Itoa(i), func(t *testing.T) { - a := assertions.New(t) - - _, _, _, err := conn.ScheduleDown(tc.Path, tc.Message) - if err != nil && (tc.ErrorAssertion == nil || !a.So(tc.ErrorAssertion(err), should.BeTrue)) { - t.Fatalf("Unexpected error: %v", err) - } - var cids []string - select { - case down := <-downCh: - if tc.ErrorAssertion == nil { - cids = down.DownlinkMessage.CorrelationIds - a.So(down.DownlinkMessage, should.Resemble, tc.Message) - } else { - t.Fatalf("Unexpected message: %v", down.DownlinkMessage) - } - case <-time.After(timeout): - if tc.ErrorAssertion == nil { - t.Fatal("Receive expected downlink timeout") - } - } - - if tc.ErrorAssertion != nil || !tc.SendTxAck { - return - } - select { - case upCh <- &ttnpb.GatewayUp{ - TxAcknowledgment: &ttnpb.TxAcknowledgment{ - CorrelationIds: cids, - Result: ttnpb.TxAcknowledgment_SUCCESS, - }, - }: - case <-time.After(timeout): - if tc.TxAckErrorAssertion == nil { - t.Fatal("Receive unexpected timeout while sending Tx acknowledgment") + ctx, cancel := errorcontext.New(ctx) + // Write upstream. + go func() { + for { + select { + case <-ctx.Done(): + return + case msg := <-upCh: + if err := link.Send(msg); err != nil { + cancel(err) + return + } } } - - select { - case ack := <-conn.TxAck(): - a.So(ack.DownlinkMessage, should.Resemble, tc.Message) - a.So(ack.Result, should.Equal, ttnpb.TxAcknowledgment_SUCCESS) - case <-time.After(timeout): - if tc.TxAckErrorAssertion == nil { - t.Fatal("Timeout waiting for Tx acknowledgment") + }() + // Read downstream. + go func() { + for { + msg, err := link.Recv() + if err != nil { + cancel(err) + return } + downCh <- msg } - }) - } + }() + <-ctx.Done() + return ctx.Err() + }, }) - - cancelCtx() } func TestConcentratorConfig(t *testing.T) { - a := assertions.New(t) + var ( + registeredGatewayID = &ttnpb.GatewayIdentifiers{GatewayId: "test-gateway"} + registeredGatewayKey = "test-key" + ) - ctx := log.NewContext(test.Context(), test.GetLogger(t)) + a, ctx := test.New(t) ctx, cancelCtx := context.WithCancel(ctx) defer cancelCtx() @@ -664,9 +270,13 @@ func (p mockMQTTConfigProvider) GetMQTTConfig(context.Context) (*config.MQTT, er } func TestMQTTConfig(t *testing.T) { - a := assertions.New(t) + var ( + registeredGatewayID = &ttnpb.GatewayIdentifiers{GatewayId: "test-gateway"} + registeredGatewayUID = unique.ID(test.Context(), registeredGatewayID) + registeredGatewayKey = "test-key" + ) - ctx := log.NewContext(test.Context(), test.GetLogger(t)) + a, ctx := test.New(t) ctx, cancelCtx := context.WithCancel(ctx) defer cancelCtx() diff --git a/pkg/gatewayserver/io/iotest/iotest.go b/pkg/gatewayserver/io/iotest/iotest.go new file mode 100644 index 00000000000..4c325a6bb8b --- /dev/null +++ b/pkg/gatewayserver/io/iotest/iotest.go @@ -0,0 +1,1493 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iotest + +import ( + "context" + "fmt" + "os" + "sync" + "testing" + "time" + + "github.com/smarty/assertions" + clusterauth "go.thethings.network/lorawan-stack/v3/pkg/auth/cluster" + "go.thethings.network/lorawan-stack/v3/pkg/band" + "go.thethings.network/lorawan-stack/v3/pkg/cluster" + "go.thethings.network/lorawan-stack/v3/pkg/component" + componenttest "go.thethings.network/lorawan-stack/v3/pkg/component/test" + "go.thethings.network/lorawan-stack/v3/pkg/config" + "go.thethings.network/lorawan-stack/v3/pkg/errorcontext" + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/events" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver" + gsio "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io" + gsredis "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/redis" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/upstream/mock" + mockis "go.thethings.network/lorawan-stack/v3/pkg/identityserver/mock" + "go.thethings.network/lorawan-stack/v3/pkg/rpcmetadata" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "go.thethings.network/lorawan-stack/v3/pkg/types" + "go.thethings.network/lorawan-stack/v3/pkg/util/test" + "go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// FrontendConfig is a test bench configuration. +type FrontendConfig struct { + // SupportsStatus indicates that the frontend sends gateway status messages. + SupportsStatus bool + // DetectsInvalidMessages indicates that the frontend detects invalid messages. + DetectsInvalidMessages bool + // DetectsDisconnect indicates that the frontend detects gateway disconnections. + DetectsDisconnect bool + // TimeoutOnInvalidAuth indicates that the frontend does not have a mechanism to convey an authentication failure to + // the gateway. + TimeoutOnInvalidAuth bool + // IsAuthenticated indicates whether the gateway connection provides authentication. + // This is typically true for all frontends except UDP which is inherently unauthenticated. + IsAuthenticated bool + // DeduplicatesUplinks indicates that the frontend deduplicates uplinks that are received at once by using the IO + // middleware's UniqueUplinkMessagesByRSSI. + DeduplicatesUplinks bool + // CustomConfig applies custom configuration for the Gateway Server before it gets started. + // This is typically used for configuring frontend listeners. + CustomConfig func(gsConfig *gatewayserver.Config) + // CustomValidAuth is a custom function to validate gateway authentication. + // If unset, it defaults to checking the gateway ID and key against what has been registered. + CustomValidAuth func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string) bool + // Link links the gateway. + Link func( + ctx context.Context, + t *testing.T, + gs *gatewayserver.GatewayServer, + ids *ttnpb.GatewayIdentifiers, + key string, + upCh <-chan *ttnpb.GatewayUp, + downCh chan<- *ttnpb.GatewayDown, + ) error +} + +// Frontend tests a frontend. +func Frontend(t *testing.T, frontend FrontendConfig) { + t.Helper() + + var ( + registeredGatewayID = "eui-aaee000000000000" + registeredGatewayKey = "secret" + registeredGatewayEUI = types.EUI64{0xAA, 0xEE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + unregisteredGatewayEUI = types.EUI64{0xBB, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + timeout = (1 << 5) * test.Delay + testRights = []ttnpb.Right{ttnpb.Right_RIGHT_GATEWAY_LINK, ttnpb.Right_RIGHT_GATEWAY_STATUS_READ} + ) + + a, ctx := test.New(t) + + is, isAddr, closeIS := mockis.New(ctx) + defer closeIS() + ns, nsAddr := mock.StartNS(ctx) + + var statsRegistry gatewayserver.GatewayConnectionStatsRegistry + if os.Getenv("TEST_REDIS") == "1" { + statsRedisClient, statsFlush := test.NewRedis(ctx, "gatewayserver_test") + defer statsFlush() + defer statsRedisClient.Close() + registry := &gsredis.GatewayConnectionStatsRegistry{ + Redis: statsRedisClient, + LockTTL: test.Delay << 10, + } + if err := registry.Init(ctx); !a.So(err, should.BeNil) { + t.FailNow() + } + statsRegistry = registry + } + + c := componenttest.NewComponent(t, &component.Config{ + ServiceBase: config.ServiceBase{ + GRPC: config.GRPC{ + Listen: ":0", + AllowInsecureForCredentials: true, + }, + Cluster: cluster.Config{ + IdentityServer: isAddr, + NetworkServer: nsAddr, + }, + FrequencyPlans: config.FrequencyPlansConfig{ + ConfigSource: "static", + Static: test.StaticFrequencyPlans, + }, + }, + }) + gsConfig := &gatewayserver.Config{ + RequireRegisteredGateways: false, + UpdateGatewayLocationDebounceTime: 0, + UpdateConnectionStatsInterval: (1 << 4) * test.Delay, + ConnectionStatsTTL: (1 << 6) * test.Delay, + ConnectionStatsDisconnectTTL: (1 << 7) * test.Delay, + Stats: statsRegistry, + FetchGatewayInterval: (1 << 5) * test.Delay, + FetchGatewayJitter: 0.1, + } + if frontend.CustomConfig != nil { + frontend.CustomConfig(gsConfig) + } + + er := gatewayserver.NewIS(c) + gs, err := gatewayserver.New(c, gsConfig, + gatewayserver.WithRegistry(er), + ) + a.So(err, should.BeNil) + + defer c.Close() + roles := gs.Roles() + a.So(len(roles), should.Equal, 1) + a.So(roles[0], should.Equal, ttnpb.ClusterRole_GATEWAY_SERVER) + + componenttest.StartComponent(t, c) + + mustHavePeer(ctx, t, c, ttnpb.ClusterRole_NETWORK_SERVER) + mustHavePeer(ctx, t, c, ttnpb.ClusterRole_ENTITY_REGISTRY) + + ids := &ttnpb.GatewayIdentifiers{ + GatewayId: registeredGatewayID, + Eui: registeredGatewayEUI.Bytes(), + } + gtw := mockis.DefaultGateway(ids, true, true) + is.GatewayRegistry().Add(ctx, ids, registeredGatewayKey, gtw, testRights...) + + time.Sleep(timeout) // Wait for setup to be completed. + + t.Run("Authenticate", func(t *testing.T) { + for _, ctc := range []struct { + Name string + ID *ttnpb.GatewayIdentifiers + Key string + }{ + { + Name: "ValidIDAndKey", + ID: &ttnpb.GatewayIdentifiers{GatewayId: registeredGatewayID}, + Key: registeredGatewayKey, + }, + { + Name: "InvalidKey", + ID: &ttnpb.GatewayIdentifiers{GatewayId: registeredGatewayID}, + Key: "invalid-key", + }, + { + Name: "InvalidIDAndKey", + ID: &ttnpb.GatewayIdentifiers{GatewayId: "invalid-gateway"}, + Key: "invalid-key", + }, + { + Name: "RegisteredEUI", + ID: &ttnpb.GatewayIdentifiers{Eui: registeredGatewayEUI.Bytes()}, + }, + { + Name: "UnregisteredEUI", + ID: &ttnpb.GatewayIdentifiers{Eui: unregisteredGatewayEUI.Bytes()}, + }, + } { + t.Run(ctc.Name, func(t *testing.T) { + ctx, cancel := context.WithCancel(ctx) + upCh := make(chan *ttnpb.GatewayUp) + downCh := make(chan *ttnpb.GatewayDown) + + upEvents := map[string]events.Channel{} + for _, event := range []string{"gs.gateway.connect"} { + upEvents[event] = make(events.Channel, 5) + } + defer test.SetDefaultEventsPubSub(&test.MockEventPubSub{ + PublishFunc: func(evs ...events.Event) { + for _, ev := range evs { + ev := ev + switch name := ev.Name(); name { + case "gs.gateway.connect": + go func() { + upEvents[name] <- ev + }() + default: + t.Logf("%s event published", name) + } + } + }, + })() + + validAuth := frontend.CustomValidAuth + if validAuth == nil { + validAuth = func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string) bool { + return ids.GatewayId == registeredGatewayID && key == registeredGatewayKey + } + } + + connectedWithInvalidAuth := make(chan struct{}, 1) + expectedProperLink := make(chan struct{}, 1) + go func() { + select { + case <-upEvents["gs.gateway.connect"]: + if !validAuth(ctx, ctc.ID, ctc.Key) { + connectedWithInvalidAuth <- struct{}{} + } + case <-time.After(timeout): + if validAuth(ctx, ctc.ID, ctc.Key) { + expectedProperLink <- struct{}{} + } + } + time.Sleep(test.Delay) + cancel() + }() + err := frontend.Link(ctx, t, gs, ctc.ID, ctc.Key, upCh, downCh) + if !errors.IsCanceled(err) && validAuth(ctx, ctc.ID, ctc.Key) { + t.Fatalf("Expect canceled context but have %v", err) + } + select { + case <-connectedWithInvalidAuth: + t.Fatal("Expected link error due to invalid auth") + case <-expectedProperLink: + t.Fatal("Expected proper link") + default: + } + }) + } + }) + + // Wait for gateway disconnection to be processed. + time.Sleep(timeout) + + t.Run("DetectDisconnect", func(t *testing.T) { + if !frontend.DetectsDisconnect { + t.SkipNow() + } + + id := &ttnpb.GatewayIdentifiers{ + GatewayId: registeredGatewayID, + Eui: registeredGatewayEUI.Bytes(), + } + + ctx1, fail1 := errorcontext.New(ctx) + defer fail1(context.Canceled) + go func() { + upCh := make(chan *ttnpb.GatewayUp) + downCh := make(chan *ttnpb.GatewayDown) + err := frontend.Link(ctx1, t, gs, id, registeredGatewayKey, upCh, downCh) + fail1(err) + }() + select { + case <-ctx1.Done(): + t.Fatalf("Expected no link error on first connection but have %v", ctx1.Err()) + case <-time.After(timeout): + } + + ctx2, cancel2 := context.WithDeadline(ctx, time.Now().Add(4*timeout)) + upCh := make(chan *ttnpb.GatewayUp) + downCh := make(chan *ttnpb.GatewayDown) + err := frontend.Link(ctx2, t, gs, id, registeredGatewayKey, upCh, downCh) + cancel2() + if !errors.IsDeadlineExceeded(err) { + t.Fatalf("Expected deadline exceeded on second connection but have %v", err) + } + select { + case <-ctx1.Done(): + t.Logf("First connection failed when second connected with %v", ctx1.Err()) + case <-time.After(4 * timeout): + t.Fatalf("Expected link failure on first connection when second connected") + } + }) + + // Wait for gateway disconnection to be processed. + time.Sleep(2 * gsConfig.ConnectionStatsDisconnectTTL) + + t.Run("Traffic", func(t *testing.T) { + a := assertions.New(t) + + ctx, cancel := context.WithCancel(ctx) + upCh := make(chan *ttnpb.GatewayUp) + downCh := make(chan *ttnpb.GatewayDown) + ids := &ttnpb.GatewayIdentifiers{ + GatewayId: registeredGatewayID, + Eui: registeredGatewayEUI.Bytes(), + } + // Setup a stats client with independent context to query whether the gateway is connected and statistics on + // upstream and downstream. + statsCtx := metadata.AppendToOutgoingContext(test.Context(), + "id", ids.GatewayId, + "authorization", fmt.Sprintf("Bearer %v", registeredGatewayKey), + ) + statsClient := ttnpb.NewGsClient(gs.LoopbackConn()) + + // The gateway should not be connected before testing traffic. + t.Run("NotConnected", func(t *testing.T) { + _, err := statsClient.GetGatewayConnectionStats(statsCtx, ids) + if !a.So(errors.IsNotFound(err), should.BeTrue) { + t.Fatal("Expected gateway not to be connected yet, but it is") + } + }) + + if frontend.SupportsStatus && frontend.IsAuthenticated { + t.Run("UpdateLocation", func(t *testing.T) { + for _, tc := range []struct { + Name string + UpdateLocation bool + Up *ttnpb.GatewayUp + ExpectLocation *ttnpb.Location + }{ + { + Name: "NoUpdate", + UpdateLocation: false, + Up: &ttnpb.GatewayUp{ + GatewayStatus: &ttnpb.GatewayStatus{ + Time: timestamppb.New(time.Unix(424242, 0)), + AntennaLocations: []*ttnpb.Location{ + { + Source: ttnpb.LocationSource_SOURCE_GPS, + Altitude: 10, + Latitude: 12, + Longitude: 14, + }, + }, + }, + }, + ExpectLocation: &ttnpb.Location{ + Source: ttnpb.LocationSource_SOURCE_GPS, + }, + }, + { + Name: "NoLocation", + UpdateLocation: true, + Up: &ttnpb.GatewayUp{ + GatewayStatus: &ttnpb.GatewayStatus{ + Time: timestamppb.New(time.Unix(424242, 0)), + }, + }, + ExpectLocation: &ttnpb.Location{ + Source: ttnpb.LocationSource_SOURCE_GPS, + }, + }, + { + Name: "Update", + UpdateLocation: true, + Up: &ttnpb.GatewayUp{ + GatewayStatus: &ttnpb.GatewayStatus{ + Time: timestamppb.New(time.Unix(42424242, 0)), + AntennaLocations: []*ttnpb.Location{ + { + Source: ttnpb.LocationSource_SOURCE_GPS, + Altitude: 10, + Latitude: 12, + Longitude: 14, + }, + }, + }, + }, + ExpectLocation: &ttnpb.Location{ + Source: ttnpb.LocationSource_SOURCE_GPS, + Altitude: 10, + Latitude: 12, + Longitude: 14, + }, + }, + } { + t.Run(tc.Name, func(t *testing.T) { + a := assertions.New(t) + + gtw, err := is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ + GatewayIds: ids, + }) + a.So(err, should.BeNil) + + gtw.Antennas[0].Location = &ttnpb.Location{ + Source: ttnpb.LocationSource_SOURCE_GPS, + } + gtw.UpdateLocationFromStatus = tc.UpdateLocation + gtw, err = is.GatewayRegistry().Update(ctx, &ttnpb.UpdateGatewayRequest{ + Gateway: gtw, + FieldMask: ttnpb.FieldMask("antennas", "update_location_from_status"), + }) + a.So(err, should.BeNil) + a.So(gtw.UpdateLocationFromStatus, should.Equal, tc.UpdateLocation) + + ctx, cancel := context.WithCancel(ctx) + upCh := make(chan *ttnpb.GatewayUp) + downCh := make(chan *ttnpb.GatewayDown) + + wg := &sync.WaitGroup{} + wg.Add(1) + var linkErr error + go func() { + defer wg.Done() + linkErr = frontend.Link(ctx, t, gs, ids, registeredGatewayKey, upCh, downCh) + }() + + select { + case upCh <- tc.Up: + case <-time.After(timeout): + t.Fatalf("Failed to send message to upstream channel") + } + + time.Sleep(timeout) + gtw, err = is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ + GatewayIds: ids, + }) + a.So(err, should.BeNil) + a.So(gtw.Antennas[0].Location, should.Resemble, tc.ExpectLocation) + + cancel() + wg.Wait() + if !errors.IsCanceled(linkErr) { + t.Fatalf("Expected context canceled, but have %v", linkErr) + } + }) + } + }) + } + + t.Run("Disconnection", func(t *testing.T) { + for _, tc := range []struct { + Name string + AntennaGain float32 + ExpectDisconnected bool + }{ + { + Name: "NoDisconnect", + AntennaGain: 0, + ExpectDisconnected: false, + }, + { + Name: "Disconnect", + AntennaGain: 3, + ExpectDisconnected: true, + }, + } { + t.Run(tc.Name, func(t *testing.T) { + a := assertions.New(t) + + ctx, cancel := context.WithCancel(ctx) + upCh := make(chan *ttnpb.GatewayUp) + downCh := make(chan *ttnpb.GatewayDown) + + wg := &sync.WaitGroup{} + wg.Add(1) + var linkErr error + go func() { + defer wg.Done() + linkErr = frontend.Link(ctx, t, gs, ids, registeredGatewayKey, upCh, downCh) + }() + + // Wait for gateway connection to be established. + time.Sleep((1 << 2) * test.Delay) + + gtw, err := is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ + GatewayIds: ids, + FieldMask: ttnpb.FieldMask("antennas"), + }) + a.So(err, should.BeNil) + gtw.Antennas[0].Gain = tc.AntennaGain + gtw, err = is.GatewayRegistry().Update(ctx, &ttnpb.UpdateGatewayRequest{ + Gateway: gtw, + FieldMask: ttnpb.FieldMask("antennas"), + }) + a.So(err, should.BeNil) + a.So(gtw.Antennas[0].Gain, should.Equal, tc.AntennaGain) + + // Wait for gateway disconnection to be processed. + time.Sleep(2 * gsConfig.FetchGatewayInterval) + + _, connected := gs.GetConnection(ctx, ids) + if !a.So(connected, should.Equal, !tc.ExpectDisconnected) { + t.Fatal("Expected gateway to be disconnected, but it is not") + } + + cancel() + wg.Wait() + if !tc.ExpectDisconnected && !errors.IsCanceled(linkErr) { + t.Fatalf("Expected context canceled, but have %v", linkErr) + } + }) + } + }) + + t.Run("LocationMetadata", func(t *testing.T) { + location := &ttnpb.Location{ + Source: ttnpb.LocationSource_SOURCE_GPS, + Altitude: 10, + Latitude: 12, + Longitude: 14, + } + up := &ttnpb.GatewayUp{ + UplinkMessages: []*ttnpb.UplinkMessage{ + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 250000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 867900000, + Timestamp: 100, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + AntennaIndex: 0, + GatewayIds: ids, + Timestamp: 100, + Rssi: -69, + ChannelRssi: -69, + Snr: 11, + }, + }, + }, + }, + } + for _, locationPublic := range []bool{false, true} { + t.Run(fmt.Sprintf("LocationPublic=%v", locationPublic), func(t *testing.T) { + a := assertions.New(t) + mockGtw := mockis.DefaultGateway(ids, false, false) + mockGtw.LocationPublic = locationPublic + is.GatewayRegistry().Add(ctx, ids, registeredGatewayKey, mockGtw, testRights...) + + gtw, err := is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ + GatewayIds: ids, + }) + a.So(err, should.BeNil) + a.So(gtw.LocationPublic, should.Equal, locationPublic) + gtw.LocationPublic = locationPublic + gtw.Antennas[0].Location = location + gtw, err = is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ + GatewayIds: ids, + }) + a.So(err, should.BeNil) + a.So(gtw.LocationPublic, should.Equal, locationPublic) + + ctx, cancel := context.WithCancel(ctx) + upCh := make(chan *ttnpb.GatewayUp) + downCh := make(chan *ttnpb.GatewayDown) + + wg := &sync.WaitGroup{} + wg.Add(1) + var linkErr error + go func() { + defer wg.Done() + linkErr = frontend.Link(ctx, t, gs, ids, registeredGatewayKey, upCh, downCh) + }() + + for _, locationInRxMetadata := range []bool{false, true} { + t.Run(fmt.Sprintf("RxMetadata=%v", locationInRxMetadata), func(t *testing.T) { + if !locationInRxMetadata && locationPublic { + // Disabled, because this is inconsistent amongst frontends + // - gRPC and MQTT: location is in RxMetadata + // - UDP and BasicStation: location is not in RxMetadata + t.SkipNow() + } + a := assertions.New(t) + + if locationInRxMetadata { + up.UplinkMessages[0].RxMetadata[0].Location = location + } else { + up.UplinkMessages[0].RxMetadata[0].Location = nil + } + up.UplinkMessages[0].RawPayload = randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6) + + select { + case upCh <- up: + case <-time.After(timeout): + t.Fatalf("Failed to send message to upstream channel") + } + + select { + case msg := <-ns.Up(): + if a.So(len(msg.RxMetadata), should.Equal, 1) { + if locationPublic { + a.So(msg.RxMetadata[0].Location, should.Resemble, location) + } else { + a.So(msg.RxMetadata[0].Location, should.BeNil) + } + } + case <-time.After(2 * timeout): + t.Fatalf("Failed to get message") + } + }) + } + + cancel() + wg.Wait() + if !errors.IsCanceled(linkErr) { + t.Fatalf("Expected context canceled, but have %v", linkErr) + } + }) + } + }) + + // Wait for gateway disconnection to be processed. + time.Sleep(timeout) + + wg := &sync.WaitGroup{} + wg.Add(1) + var linkErr error + go func() { + defer wg.Done() + linkErr = frontend.Link(ctx, t, gs, ids, registeredGatewayKey, upCh, downCh) + }() + + // Expected location for RxMetadata + gtw, err := is.GatewayRegistry().Get(ctx, &ttnpb.GetGatewayRequest{ + GatewayIds: ids, + }) + a.So(err, should.BeNil) + location := gtw.Antennas[0].Location + + duplicatePayload := randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6) + + t.Run("Upstream", func(t *testing.T) { + uplinkCount := 0 + for _, tc := range []struct { + Name string + Up *ttnpb.GatewayUp + Received []uint32 // Timestamps of uplink messages in Up that are received. + Dropped []uint32 // Timestamps of uplink messages in Up that are dropped. + PublicLocation bool // If gateway location is public, it should be in RxMetadata + UplinkCount int // Number of expected uplinks + RepeatUpEvent bool // Expect event for repeated uplinks + SkipIfDetectsInvalidMessages bool // Skip this test if the frontend detects invalid messages + }{ + { + Name: "GatewayStatus", + Up: &ttnpb.GatewayUp{ + GatewayStatus: &ttnpb.GatewayStatus{ + Time: timestamppb.New(time.Unix(424242, 0)), + }, + }, + }, + { + Name: "TxAck", + Up: &ttnpb.GatewayUp{ + TxAcknowledgment: &ttnpb.TxAcknowledgment{ + Result: ttnpb.TxAcknowledgment_SUCCESS, + }, + }, + }, + { + Name: "CRCFailure", + Up: &ttnpb.GatewayUp{ + UplinkMessages: []*ttnpb.UplinkMessage{ + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 250000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 867900000, + Timestamp: 100, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + GatewayIds: ids, + Timestamp: 100, + Rssi: -69, + ChannelRssi: -69, + Snr: 11, + Location: location, + }, + }, + RawPayload: randomUpDataPayload(types.DevAddr{0x26, 0x02, 0xff, 0xff}, 2, 2), + CrcStatus: wrapperspb.Bool(false), + }, + }, + }, + Received: []uint32{100}, + Dropped: []uint32{100}, + SkipIfDetectsInvalidMessages: true, + }, + { + Name: "OneValidLoRa", + Up: &ttnpb.GatewayUp{ + UplinkMessages: []*ttnpb.UplinkMessage{ + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 250000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 867900000, + Timestamp: 200, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + GatewayIds: ids, + Timestamp: 200, + Rssi: -69, + ChannelRssi: -69, + Snr: 11, + Location: location, + }, + }, + RawPayload: randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), + }, + }, + }, + Received: []uint32{200}, + }, + { + Name: "OneValidLoRaAndTwoRepeated", + Up: &ttnpb.GatewayUp{ + UplinkMessages: []*ttnpb.UplinkMessage{ + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 250000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 867900000, + Timestamp: 301, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + GatewayIds: ids, + Timestamp: 301, + Rssi: -42, + ChannelRssi: -42, + Snr: 11, + Location: location, + }, + }, + RawPayload: duplicatePayload, + }, + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 250000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 867900000, + Timestamp: 300, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + GatewayIds: ids, + Timestamp: 300, + Rssi: -69, + ChannelRssi: -69, + Snr: 11, + Location: location, + }, + }, + RawPayload: duplicatePayload, + }, + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 250000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 867900000, + Timestamp: 300, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + GatewayIds: ids, + Timestamp: 300, + Rssi: -69, + ChannelRssi: -69, + Snr: 11, + Location: location, + }, + }, + RawPayload: duplicatePayload, + }, + }, + }, + Received: []uint32{301}, + UplinkCount: 1, + RepeatUpEvent: true, + }, + { + Name: "OneValidFSK", + Up: &ttnpb.GatewayUp{ + UplinkMessages: []*ttnpb.UplinkMessage{ + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Fsk{ + Fsk: &ttnpb.FSKDataRate{ + BitRate: 50000, + }, + }, + }, + Frequency: 867900000, + Timestamp: 400, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + GatewayIds: ids, + Timestamp: 400, + Rssi: -69, + ChannelRssi: -69, + Snr: 11, + Location: location, + }, + }, + RawPayload: randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), + }, + }, + }, + Received: []uint32{400}, + }, + { + Name: "OneGarbageWithStatus", + Up: &ttnpb.GatewayUp{ + UplinkMessages: []*ttnpb.UplinkMessage{ + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 9, + Bandwidth: 125000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 868500000, + Timestamp: 500, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + GatewayIds: ids, + Timestamp: 500, + Rssi: -112, + ChannelRssi: -112, + Snr: 2, + Location: location, + }, + }, + RawPayload: []byte{0xff, 0x02, 0x03}, // Garbage; doesn't get forwarded. + }, + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 125000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 868100000, + Timestamp: 501, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + GatewayIds: ids, + Timestamp: 501, + Rssi: -69, + ChannelRssi: -69, + Snr: 11, + Location: location, + }, + }, + RawPayload: randomUpDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), + }, + { + Settings: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 12, + Bandwidth: 125000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 867700000, + Timestamp: 502, + }, + RxMetadata: []*ttnpb.RxMetadata{ + { + GatewayIds: ids, + Timestamp: 502, + Rssi: -36, + ChannelRssi: -36, + Snr: 5, + Location: location, + }, + }, + RawPayload: randomJoinRequestPayload( + types.EUI64{0x42, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, + types.EUI64{0x42, 0x42, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, + ), + }, + }, + GatewayStatus: &ttnpb.GatewayStatus{ + Time: timestamppb.New(time.Unix(4242424, 0)), + }, + }, + Received: []uint32{501, 502}, + }, + } { + t.Run(tc.Name, func(t *testing.T) { + a := assertions.New(t) + + if tc.SkipIfDetectsInvalidMessages && frontend.DetectsInvalidMessages { + t.Skip("Skipping test case because gateway detects invalid messages") + } + + upEvents := map[string]events.Channel{} + for _, event := range []string{ + "gs.up.receive", + "gs.down.tx.success", + "gs.down.tx.fail", + "gs.status.receive", + "gs.io.up.repeat", + } { + upEvents[event] = make(events.Channel, 5) + } + defer test.SetDefaultEventsPubSub(&test.MockEventPubSub{ + PublishFunc: func(evs ...events.Event) { + for _, ev := range evs { + ev := ev + switch name := ev.Name(); name { + case "gs.up.receive", "gs.down.tx.success", "gs.down.tx.fail", "gs.status.receive", "gs.io.up.repeat": + go func() { + upEvents[name] <- ev + }() + default: + t.Logf("%s event published", name) + } + } + }, + })() + + select { + case upCh <- tc.Up: + case <-time.After(timeout): + t.Fatalf("Failed to send message to upstream channel") + } + if tc.UplinkCount > 0 { + uplinkCount += tc.UplinkCount + } else if frontend.DetectsInvalidMessages { + uplinkCount += len(tc.Received) + } else { + uplinkCount += len(tc.Up.UplinkMessages) + } + + if tc.RepeatUpEvent && !frontend.DeduplicatesUplinks { + select { + case evt := <-upEvents["gs.io.up.repeat"]: + a.So(evt.Name(), should.Equal, "gs.io.up.repeat") + case <-time.After(timeout): + t.Fatal("Expected repeat uplink event timeout") + } + } + + received := make(map[uint32]struct{}) + forwarded := make(map[uint32]struct{}) + for _, t := range tc.Received { + received[t] = struct{}{} + forwarded[t] = struct{}{} + } + for _, t := range tc.Dropped { + delete(forwarded, t) + } + for len(received) > 0 { + if len(forwarded) > 0 { + select { + case msg := <-ns.Up(): + var expected *ttnpb.UplinkMessage + for _, up := range tc.Up.UplinkMessages { + if ts := up.Settings.Timestamp; ts == msg.Settings.Timestamp { + if _, ok := forwarded[ts]; !ok { + t.Fatalf("Not expecting message %v", msg) + } + expected = up + delete(forwarded, ts) + break + } + } + if expected == nil { + t.Fatalf("Received unexpected message with timestamp %d", msg.Settings.Timestamp) + } + a.So(time.Since(*ttnpb.StdTime(msg.ReceivedAt)), should.BeLessThan, timeout) + a.So(msg.Settings, should.Resemble, expected.Settings) + a.So(len(msg.RxMetadata), should.Equal, len(expected.RxMetadata)) + for i, md := range msg.RxMetadata { + a.So(md.UplinkToken, should.NotBeEmpty) + md.UplinkToken = nil + md.ReceivedAt = nil + a.So(md, should.Resemble, expected.RxMetadata[i]) + } + a.So(msg.RawPayload, should.Resemble, expected.RawPayload) + case <-time.After(timeout): + t.Fatal("Expected uplink timeout") + } + } + select { + case evt := <-upEvents["gs.up.receive"]: + a.So(evt.Name(), should.Equal, "gs.up.receive") + msg := evt.Data().(*ttnpb.GatewayUplinkMessage) + delete(received, msg.Message.Settings.Timestamp) + case <-time.After(timeout): + t.Fatal("Expected uplink event timeout") + } + } + if expected := tc.Up.TxAcknowledgment; expected != nil { + select { + case <-upEvents["gs.down.tx.success"]: + case evt := <-upEvents["gs.down.tx.fail"]: + received, ok := evt.Data().(ttnpb.TxAcknowledgment_Result) + if !ok { + t.Fatal("No acknowledgment attached to the downlink emission fail event") + } + a.So(received, should.Resemble, expected.Result) + case <-time.After(timeout): + t.Fatal("Expected Tx acknowledgment event timeout") + } + select { + case ack := <-ns.TxAck(): + if txAck := ack.GetTxAck(); a.So(txAck, should.NotBeNil) { + a.So(txAck.Result, should.Resemble, expected.Result) + a.So(txAck.DownlinkMessage, should.Resemble, expected.DownlinkMessage) + } + case <-time.After(timeout): + t.Fatal("Expected Tx acknowledgment event timeout") + } + } + if tc.Up.GatewayStatus != nil && frontend.SupportsStatus { + select { + case <-upEvents["gs.status.receive"]: + case <-time.After(timeout): + t.Fatal("Expected gateway status event timeout") + } + } + + time.Sleep(2 * timeout) + + conn, ok := gs.GetConnection(ctx, ids) + a.So(ok, should.BeTrue) + + stats, paths := conn.Stats() + a.So(stats, should.NotBeNil) + a.So(paths, should.NotBeEmpty) + + stats, err := statsClient.GetGatewayConnectionStats(statsCtx, ids) + if !a.So(err, should.BeNil) { + t.FailNow() + } + a.So(stats.UplinkCount, should.Equal, uplinkCount) + + if tc.Up.GatewayStatus != nil && frontend.SupportsStatus { + if !a.So(stats.LastStatus, should.NotBeNil) { + t.FailNow() + } + a.So(stats.LastStatus.Time, should.Resemble, tc.Up.GatewayStatus.Time) + } + }) + } + }) + + t.Run("Downstream", func(t *testing.T) { + ctx := clusterauth.NewContext(test.Context(), nil) + downlinkCount := 0 + for _, tc := range []struct { + Name string + Message *ttnpb.DownlinkMessage + ErrorAssertion func(error) bool + RxWindowDetailsAssertion []func(error) bool + }{ + { + Name: "InvalidSettingsType", + Message: &ttnpb.DownlinkMessage{ + RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), + Settings: &ttnpb.DownlinkMessage_Scheduled{ + Scheduled: &ttnpb.TxSettings{ + DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 12, + Bandwidth: 125000, + CodingRate: band.Cr4_5, + }, + }, + }, + Frequency: 869525000, + Downlink: &ttnpb.TxSettings_Downlink{ + TxPower: 10, + }, + Timestamp: 100, + }, + }, + }, + ErrorAssertion: errors.IsInvalidArgument, // Network Server may send Tx request only. + }, + { + Name: "NotConnected", + Message: &ttnpb.DownlinkMessage{ + Settings: &ttnpb.DownlinkMessage_Request{ + Request: &ttnpb.TxRequest{ + Class: ttnpb.Class_CLASS_C, + DownlinkPaths: []*ttnpb.DownlinkPath{ + { + Path: &ttnpb.DownlinkPath_Fixed{ + Fixed: &ttnpb.GatewayAntennaIdentifiers{ + GatewayIds: &ttnpb.GatewayIdentifiers{ + GatewayId: "not-connected", + }, + }, + }, + }, + }, + FrequencyPlanId: test.EUFrequencyPlanID, + }, + }, + }, + ErrorAssertion: errors.IsAborted, // The gateway is not connected. + }, + { + Name: "ValidClassA", + Message: &ttnpb.DownlinkMessage{ + RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), + Settings: &ttnpb.DownlinkMessage_Request{ + Request: &ttnpb.TxRequest{ + Class: ttnpb.Class_CLASS_A, + DownlinkPaths: []*ttnpb.DownlinkPath{ + { + Path: &ttnpb.DownlinkPath_UplinkToken{ + UplinkToken: gsio.MustUplinkToken( + &ttnpb.GatewayAntennaIdentifiers{ + GatewayIds: &ttnpb.GatewayIdentifiers{ + GatewayId: registeredGatewayID, + }, + }, + 10000000, + 10000000000, + time.Unix(0, 10000000*1000), + nil, + ), + }, + }, + }, + Priority: ttnpb.TxSchedulePriority_NORMAL, + Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, + Rx1DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 125000, + CodingRate: band.Cr4_5, + }, + }, + }, + Rx1Frequency: 868100000, + FrequencyPlanId: test.EUFrequencyPlanID, + }, + }, + }, + }, + { + Name: "ValidClassAWithoutFrequencyPlanInTxRequest", + Message: &ttnpb.DownlinkMessage{ + RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x01, 0xff, 0xff}, 1, 6), + Settings: &ttnpb.DownlinkMessage_Request{ + Request: &ttnpb.TxRequest{ + Class: ttnpb.Class_CLASS_A, + DownlinkPaths: []*ttnpb.DownlinkPath{ + { + Path: &ttnpb.DownlinkPath_UplinkToken{ + UplinkToken: gsio.MustUplinkToken( + &ttnpb.GatewayAntennaIdentifiers{ + GatewayIds: &ttnpb.GatewayIdentifiers{ + GatewayId: registeredGatewayID, + }, + }, + 20000000, + 20000000000, + time.Unix(0, 20000000*1000), + nil, + ), + }, + }, + }, + Priority: ttnpb.TxSchedulePriority_NORMAL, + Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, + Rx1DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 125000, + CodingRate: band.Cr4_5, + }, + }, + }, + Rx1Frequency: 868100000, + }, + }, + }, + }, + { + Name: "ConflictClassA", + Message: &ttnpb.DownlinkMessage{ + RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x02, 0xff, 0xff}, 1, 6), + Settings: &ttnpb.DownlinkMessage_Request{ + Request: &ttnpb.TxRequest{ + Class: ttnpb.Class_CLASS_A, + DownlinkPaths: []*ttnpb.DownlinkPath{ + { + Path: &ttnpb.DownlinkPath_UplinkToken{ + UplinkToken: gsio.MustUplinkToken( + &ttnpb.GatewayAntennaIdentifiers{ + GatewayIds: &ttnpb.GatewayIdentifiers{ + GatewayId: registeredGatewayID, + }, + }, + 10000000, + 10000000000, + time.Unix(0, 10000000*1000), + nil, + ), + }, + }, + }, + Priority: ttnpb.TxSchedulePriority_NORMAL, + Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, + Rx1DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 125000, + CodingRate: band.Cr4_5, + }, + }, + }, + Rx1Frequency: 868100000, + FrequencyPlanId: test.EUFrequencyPlanID, + }, + }, + }, + ErrorAssertion: errors.IsAborted, + RxWindowDetailsAssertion: []func(error) bool{ + errors.IsAlreadyExists, // Rx1 conflicts with previous. + errors.IsFailedPrecondition, // Rx2 not provided. + }, + }, + { + Name: "ValidClassC", + Message: &ttnpb.DownlinkMessage{ + RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x02, 0xff, 0xff}, 1, 6), + Settings: &ttnpb.DownlinkMessage_Request{ + Request: &ttnpb.TxRequest{ + Class: ttnpb.Class_CLASS_C, + DownlinkPaths: []*ttnpb.DownlinkPath{ + { + Path: &ttnpb.DownlinkPath_Fixed{ + Fixed: &ttnpb.GatewayAntennaIdentifiers{ + GatewayIds: &ttnpb.GatewayIdentifiers{ + GatewayId: registeredGatewayID, + }, + }, + }, + }, + }, + Priority: ttnpb.TxSchedulePriority_NORMAL, + Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, + Rx1DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 125000, + CodingRate: band.Cr4_5, + }, + }, + }, + Rx1Frequency: 868100000, + FrequencyPlanId: test.EUFrequencyPlanID, + }, + }, + }, + }, + { + Name: "ValidClassCWithoutFrequencyPlanInTxRequest", + Message: &ttnpb.DownlinkMessage{ + RawPayload: randomDownDataPayload(types.DevAddr{0x26, 0x02, 0xff, 0xff}, 1, 6), + Settings: &ttnpb.DownlinkMessage_Request{ + Request: &ttnpb.TxRequest{ + Class: ttnpb.Class_CLASS_C, + DownlinkPaths: []*ttnpb.DownlinkPath{ + { + Path: &ttnpb.DownlinkPath_Fixed{ + Fixed: &ttnpb.GatewayAntennaIdentifiers{ + GatewayIds: &ttnpb.GatewayIdentifiers{ + GatewayId: registeredGatewayID, + }, + }, + }, + }, + }, + Priority: ttnpb.TxSchedulePriority_NORMAL, + Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, + Rx1DataRate: &ttnpb.DataRate{ + Modulation: &ttnpb.DataRate_Lora{ + Lora: &ttnpb.LoRaDataRate{ + SpreadingFactor: 7, + Bandwidth: 125000, + CodingRate: band.Cr4_5, + }, + }, + }, + Rx1Frequency: 868100000, + }, + }, + }, + }, + } { + t.Run(tc.Name, func(t *testing.T) { + a := assertions.New(t) + + _, err := gs.ScheduleDownlink(ctx, tc.Message) + if err != nil { + if tc.ErrorAssertion == nil || !a.So(tc.ErrorAssertion(err), should.BeTrue) { + t.Fatalf("Unexpected error: %v", err) + } + if tc.RxWindowDetailsAssertion != nil { + a.So(err, should.HaveSameErrorDefinitionAs, gatewayserver.ErrSchedule) + if !a.So(errors.Details(err), should.HaveLength, 1) { + t.FailNow() + } + details := errors.Details(err)[0].(*ttnpb.ScheduleDownlinkErrorDetails) + if !a.So(details, should.NotBeNil) || !a.So(details.PathErrors, should.HaveLength, 1) { + t.FailNow() + } + errSchedulePathCause := errors.Cause(ttnpb.ErrorDetailsFromProto(details.PathErrors[0])) + a.So(errors.IsAborted(errSchedulePathCause), should.BeTrue) + for i, assert := range tc.RxWindowDetailsAssertion { + if !a.So(errors.Details(errSchedulePathCause), should.HaveLength, 1) { + t.FailNow() + } + errSchedulePathCauseDetails := errors.Details(errSchedulePathCause)[0].(*ttnpb.ScheduleDownlinkErrorDetails) + if !a.So(errSchedulePathCauseDetails, should.NotBeNil) { + t.FailNow() + } + if i >= len(errSchedulePathCauseDetails.PathErrors) { + t.Fatalf("Expected error in Rx window %d", i+1) + } + errRxWindow := ttnpb.ErrorDetailsFromProto(errSchedulePathCauseDetails.PathErrors[i]) + if !a.So(assert(errRxWindow), should.BeTrue) { + t.Fatalf("Unexpected Rx window %d error: %v", i+1, errRxWindow) + } + } + } + return + } else if tc.ErrorAssertion != nil { + t.Fatalf("Expected error") + } + downlinkCount++ + + select { + case msg := <-downCh: + settings := msg.DownlinkMessage.GetScheduled() + a.So(settings, should.NotBeNil) + case <-time.After(timeout): + t.Fatal("Expected downlink timeout") + } + + time.Sleep(2 * timeout) + + conn, ok := gs.GetConnection(ctx, ids) + a.So(ok, should.BeTrue) + + stats, paths := conn.Stats() + a.So(stats, should.NotBeNil) + a.So(paths, should.NotBeEmpty) + + stats, err = statsClient.GetGatewayConnectionStats(statsCtx, ids) + if !a.So(err, should.BeNil) { + t.FailNow() + } + a.So(stats.DownlinkCount, should.Equal, downlinkCount) + }) + } + }) + + cancel() + wg.Wait() + if !errors.IsCanceled(linkErr) { + t.Fatalf("Expected context canceled, but have %v", linkErr) + } + + // Wait for disconnection to be processed. + time.Sleep(2 * gsConfig.ConnectionStatsDisconnectTTL) + + // After canceling the context and awaiting the link, the connection should be gone. + t.Run("Disconnected", func(t *testing.T) { + _, err := statsClient.GetGatewayConnectionStats(statsCtx, ids) + if !a.So(errors.IsNotFound(err), should.BeTrue) { + t.Fatalf("Expected gateway to be disconnected, but it's not") + } + }) + }) + + t.Run("Shutdown", func(t *testing.T) { + ids := &ttnpb.GatewayIdentifiers{ + GatewayId: registeredGatewayID, + Eui: registeredGatewayEUI.Bytes(), + } + + md := rpcmetadata.MD{ + ID: ids.GatewayId, + AuthType: "Bearer", + AuthValue: registeredGatewayKey, + AllowInsecure: true, + } + _, err = ttnpb.NewGtwGsClient(gs.LoopbackConn()).LinkGateway(ctx, grpc.PerRPCCredentials(md)) + if !a.So(err, should.BeNil) { + t.FailNow() + } + + gs.Close() + time.Sleep(2 * gsConfig.ConnectionStatsTTL) + + _, err = statsRegistry.Get(ctx, ids) + a.So(errors.IsNotFound(err), should.BeTrue) + }) +} diff --git a/pkg/gatewayserver/gatewayserver_util_test.go b/pkg/gatewayserver/io/iotest/util.go similarity index 92% rename from pkg/gatewayserver/gatewayserver_util_test.go rename to pkg/gatewayserver/io/iotest/util.go index 9d351784c14..6c8e3ca2adb 100644 --- a/pkg/gatewayserver/gatewayserver_util_test.go +++ b/pkg/gatewayserver/io/iotest/util.go @@ -1,4 +1,4 @@ -// Copyright © 2019 The Things Network Foundation, The Things Industries B.V. +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,11 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -package gatewayserver_test +package iotest import ( "context" "crypto/rand" + "testing" "time" "go.thethings.network/lorawan-stack/v3/pkg/component" @@ -27,48 +28,15 @@ import ( "go.thethings.network/lorawan-stack/v3/pkg/types" ) -var testRights = []ttnpb.Right{ttnpb.Right_RIGHT_GATEWAY_LINK, ttnpb.Right_RIGHT_GATEWAY_STATUS_READ} - -func mustHavePeer(ctx context.Context, c *component.Component, role ttnpb.ClusterRole) { +func mustHavePeer(ctx context.Context, t *testing.T, c *component.Component, role ttnpb.ClusterRole) { + t.Helper() for i := 0; i < 20; i++ { time.Sleep(20 * time.Millisecond) if _, err := c.GetPeer(ctx, role, nil); err == nil { return } } - panic("could not connect to peer") -} - -func randomJoinRequestPayload(joinEUI, devEUI types.EUI64) []byte { - var nwkKey types.AES128Key - rand.Read(nwkKey[:]) - var devNonce types.DevNonce - rand.Read(devNonce[:]) - - msg := &ttnpb.UplinkMessage{ - Payload: &ttnpb.Message{ - MHdr: &ttnpb.MHDR{ - MType: ttnpb.MType_JOIN_REQUEST, - Major: ttnpb.Major_LORAWAN_R1, - }, - Payload: &ttnpb.Message_JoinRequestPayload{ - JoinRequestPayload: &ttnpb.JoinRequestPayload{ - JoinEui: joinEUI.Bytes(), - DevEui: devEUI.Bytes(), - DevNonce: devNonce.Bytes(), - }, - }, - }, - } - buf, err := lorawan.MarshalMessage(msg.Payload) - if err != nil { - panic(err) - } - mic, err := crypto.ComputeJoinRequestMIC(nwkKey, buf) - if err != nil { - panic(err) - } - return append(buf, mic[:]...) + t.Fatal("Could not connect to peer") } func randomUpDataPayload(devAddr types.DevAddr, fPort uint32, size int) []byte { @@ -113,6 +81,38 @@ func randomUpDataPayload(devAddr types.DevAddr, fPort uint32, size int) []byte { return append(buf, mic[:]...) } +func randomJoinRequestPayload(joinEUI, devEUI types.EUI64) []byte { + var nwkKey types.AES128Key + rand.Read(nwkKey[:]) + var devNonce types.DevNonce + rand.Read(devNonce[:]) + + msg := &ttnpb.UplinkMessage{ + Payload: &ttnpb.Message{ + MHdr: &ttnpb.MHDR{ + MType: ttnpb.MType_JOIN_REQUEST, + Major: ttnpb.Major_LORAWAN_R1, + }, + Payload: &ttnpb.Message_JoinRequestPayload{ + JoinRequestPayload: &ttnpb.JoinRequestPayload{ + JoinEui: joinEUI.Bytes(), + DevEui: devEUI.Bytes(), + DevNonce: devNonce.Bytes(), + }, + }, + }, + } + buf, err := lorawan.MarshalMessage(msg.Payload) + if err != nil { + panic(err) + } + mic, err := crypto.ComputeJoinRequestMIC(nwkKey, buf) + if err != nil { + panic(err) + } + return append(buf, mic[:]...) +} + func randomDownDataPayload(devAddr types.DevAddr, fPort uint32, size int) []byte { var sNwkSIntKey, appSKey types.AES128Key rand.Read(sNwkSIntKey[:]) diff --git a/pkg/gatewayserver/io/mqtt/mqtt_test.go b/pkg/gatewayserver/io/mqtt/mqtt_test.go index 8b2e2c22937..dc648cc8272 100644 --- a/pkg/gatewayserver/io/mqtt/mqtt_test.go +++ b/pkg/gatewayserver/io/mqtt/mqtt_test.go @@ -23,37 +23,32 @@ import ( mqtt "github.com/eclipse/paho.mqtt.golang" "github.com/smarty/assertions" - "go.thethings.network/lorawan-stack/v3/pkg/band" "go.thethings.network/lorawan-stack/v3/pkg/cluster" "go.thethings.network/lorawan-stack/v3/pkg/component" componenttest "go.thethings.network/lorawan-stack/v3/pkg/component/test" "go.thethings.network/lorawan-stack/v3/pkg/config" - "go.thethings.network/lorawan-stack/v3/pkg/errors" - "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io" + "go.thethings.network/lorawan-stack/v3/pkg/errorcontext" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/iotest" "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/mock" . "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/mqtt" mockis "go.thethings.network/lorawan-stack/v3/pkg/identityserver/mock" - "go.thethings.network/lorawan-stack/v3/pkg/log" "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" "go.thethings.network/lorawan-stack/v3/pkg/unique" "go.thethings.network/lorawan-stack/v3/pkg/util/test" "go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/timestamppb" -) - -var ( - registeredGatewayID = &ttnpb.GatewayIdentifiers{GatewayId: "test-gateway"} - registeredGatewayUID = unique.ID(test.Context(), registeredGatewayID) - registeredGatewayKey = "test-key" - - timeout = 20 * test.Delay ) func TestAuthentication(t *testing.T) { - a := assertions.New(t) - - ctx := log.NewContext(test.Context(), test.GetLogger(t)) + var ( + registeredGatewayID = &ttnpb.GatewayIdentifiers{GatewayId: "test-gateway"} + registeredGatewayUID = unique.ID(test.Context(), registeredGatewayID) + registeredGatewayKey = "test-key" + timeout = (1 << 4) * test.Delay + ) + + a, ctx := test.New(t) ctx, cancelCtx := context.WithCancel(ctx) defer cancelCtx() @@ -133,349 +128,104 @@ func TestAuthentication(t *testing.T) { } } -func TestTraffic(t *testing.T) { - a := assertions.New(t) - - ctx := log.NewContext(test.Context(), test.GetLogger(t)) - ctx, cancelCtx := context.WithCancel(ctx) - defer cancelCtx() - - is, isAddr, closeIS := mockis.New(ctx) - defer closeIS() - testGtw := mockis.DefaultGateway(registeredGatewayID, false, false) - is.GatewayRegistry().Add(ctx, registeredGatewayID, registeredGatewayKey, testGtw, testRights...) - - c := componenttest.NewComponent(t, &component.Config{ - ServiceBase: config.ServiceBase{ - GRPC: config.GRPC{ - Listen: ":0", - AllowInsecureForCredentials: true, - }, - Cluster: cluster.Config{ - IdentityServer: isAddr, - }, - FrequencyPlans: config.FrequencyPlansConfig{ - ConfigSource: "static", - Static: test.StaticFrequencyPlans, - }, +func TestFrontend(t *testing.T) { + timeout := (1 << 4) * test.Delay + + iotest.Frontend(t, iotest.FrontendConfig{ + DetectsInvalidMessages: false, + SupportsStatus: true, + DetectsDisconnect: true, + TimeoutOnInvalidAuth: true, // The MQTT client keeps reconnecting on invalid auth. + IsAuthenticated: true, + DeduplicatesUplinks: false, + CustomConfig: func(config *gatewayserver.Config) { + config.MQTT.Listen = ":1882" }, - }) - componenttest.StartComponent(t, c) - defer c.Close() - mustHavePeer(ctx, c, ttnpb.ClusterRole_ENTITY_REGISTRY) - - gs := mock.NewServer(c, is) - lis, err := net.Listen("tcp", ":0") - if !a.So(err, should.BeNil) { - t.FailNow() - } - go Serve(ctx, gs, lis, NewProtobuf(ctx), "tcp") - - clientOpts := mqtt.NewClientOptions() - clientOpts.AddBroker(fmt.Sprintf("tcp://%v", lis.Addr())) - clientOpts.SetUsername(registeredGatewayUID) - clientOpts.SetPassword(registeredGatewayKey) - client := mqtt.NewClient(clientOpts) - token := client.Connect() - if !token.WaitTimeout(timeout) { - t.Fatal("Connection timeout") - } - if !a.So(token.Error(), should.BeNil) { - t.FailNow() - } - - var conn *io.Connection - select { - case conn = <-gs.Connections(): - case <-time.After(timeout): - t.Fatal("Connection timeout") - } - defer client.Disconnect(100) - - t.Run("Upstream", func(t *testing.T) { - for _, tc := range []struct { - Topic string - Message proto.Message - OK bool - }{ - { - Topic: fmt.Sprintf("v3/%v/up", registeredGatewayUID), - Message: &ttnpb.UplinkMessage{ - RawPayload: []byte{0x01}, - Settings: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{Modulation: &ttnpb.DataRate_Lora{Lora: &ttnpb.LoRaDataRate{}}}, - Frequency: 868000000, - }, - }, - OK: true, - }, - { - Topic: fmt.Sprintf("v3/%v/up", "invalid-gateway"), - Message: &ttnpb.UplinkMessage{ - RawPayload: []byte{0x03}, - }, - OK: false, // invalid gateway ID - }, - { - Topic: fmt.Sprintf("v3/%v/down", registeredGatewayUID), - Message: &ttnpb.DownlinkMessage{ - RawPayload: []byte{0x04}, - }, - OK: false, // publish to downlink not permitted - }, - { - Topic: fmt.Sprintf("v3/%v/down", "invalid-gateway"), - Message: &ttnpb.DownlinkMessage{ - RawPayload: []byte{0x05}, - }, - OK: false, // invalid gateway ID + publish to downlink not permitted - }, - { - Topic: fmt.Sprintf("v3/%v/status", registeredGatewayUID), - Message: &ttnpb.GatewayStatus{ - Time: timestamppb.Now(), - Ip: []string{"1.1.1.1"}, - }, - OK: true, - }, - { - Topic: fmt.Sprintf("v3/%v/down/ack", registeredGatewayUID), - Message: &ttnpb.TxAcknowledgment{ - Result: ttnpb.TxAcknowledgment_SUCCESS, - }, - OK: true, - }, - { - Topic: fmt.Sprintf("v3/%v/status", "invalid-gateway"), - Message: &ttnpb.GatewayStatus{ - Ip: []string{"2.2.2.2"}, - }, - OK: false, // invalid gateway ID - }, - { - Topic: "invalid/format", - Message: &ttnpb.GatewayStatus{ - Ip: []string{"3.3.3.3"}, - }, - OK: false, // invalid topic format - }, - } { - tcok := t.Run(tc.Topic, func(t *testing.T) { - a := assertions.New(t) - buf, err := proto.Marshal(tc.Message) - a.So(err, should.BeNil) - token := client.Publish(tc.Topic, 1, false, buf) - if !token.WaitTimeout(timeout) { - t.Fatal("Publish timeout") - } - if !a.So(token.Error(), should.BeNil) { - t.FailNow() - } - select { - case up := <-conn.Up(): - if tc.OK { - a.So(time.Since(*ttnpb.StdTime(up.Message.ReceivedAt)), should.BeLessThan, timeout) - expected := ttnpb.Clone(tc.Message).(*ttnpb.UplinkMessage) - expected.ReceivedAt = up.Message.ReceivedAt - a.So(up.Message, should.Resemble, expected) - } else { - t.Fatalf("Did not expect uplink message, but have %v", up) - } - case status := <-conn.Status(): - if tc.OK { - a.So(status, should.Resemble, tc.Message) - } else { - t.Fatalf("Did not expect status message, but have %v", status) - } - case ack := <-conn.TxAck(): - if tc.OK { - a.So(ack, should.Resemble, tc.Message) - } else { - t.Fatalf("Did not expect ack message, but have %v", ack) - } - case <-time.After(timeout): - if tc.OK { - t.Fatal("Receive expected upstream timeout") - } - } + Link: func( + ctx context.Context, + t *testing.T, + gs *gatewayserver.GatewayServer, + ids *ttnpb.GatewayIdentifiers, + key string, + upCh <-chan *ttnpb.GatewayUp, + downCh chan<- *ttnpb.GatewayDown, + ) error { + if ids.GatewayId == "" { + t.SkipNow() + } + ctx, cancel := errorcontext.New(ctx) + clientOpts := mqtt.NewClientOptions() + clientOpts.AddBroker("tcp://0.0.0.0:1882") + clientOpts.SetUsername(unique.ID(ctx, ids)) + clientOpts.SetPassword(key) + clientOpts.SetAutoReconnect(false) + clientOpts.SetConnectionLostHandler(func(_ mqtt.Client, err error) { + cancel(err) }) - if !tcok { - t.FailNow() + client := mqtt.NewClient(clientOpts) + if token := client.Connect(); !token.WaitTimeout(timeout) { + return context.DeadlineExceeded + } else if err := token.Error(); err != nil { + return err } - } - }) - - t.Run("Downstream", func(t *testing.T) { - for _, tc := range []struct { - Topic string - Path *ttnpb.DownlinkPath - Message *ttnpb.DownlinkMessage - ErrorAssertion func(error) bool - TxAckTopic string - TxAckErrorAssertion func(error) bool - }{ - { - Topic: fmt.Sprintf("v3/%v/down", registeredGatewayUID), - Path: &ttnpb.DownlinkPath{ - Path: &ttnpb.DownlinkPath_UplinkToken{ - UplinkToken: io.MustUplinkToken( - &ttnpb.GatewayAntennaIdentifiers{GatewayIds: registeredGatewayID}, - 100, - 100000, - time.Unix(0, 100*1000), - nil, - ), - }, - }, - Message: &ttnpb.DownlinkMessage{ - RawPayload: []byte{0x01}, - Settings: &ttnpb.DownlinkMessage_Request{ - Request: &ttnpb.TxRequest{ - Class: ttnpb.Class_CLASS_A, - Priority: ttnpb.TxSchedulePriority_NORMAL, - Rx1Delay: ttnpb.RxDelay_RX_DELAY_1, - Rx1DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 7, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Rx1Frequency: 868100000, - Rx2DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - SpreadingFactor: 12, - Bandwidth: 125000, - CodingRate: band.Cr4_5, - }, - }, - }, - Rx2Frequency: 869525000, - FrequencyPlanId: test.EUFrequencyPlanID, - }, - }, - }, - TxAckTopic: fmt.Sprintf("v3/%v/down/ack", registeredGatewayUID), - }, - { - Topic: fmt.Sprintf("v3/%v/down", registeredGatewayUID), - Path: &ttnpb.DownlinkPath{ - Path: &ttnpb.DownlinkPath_UplinkToken{ - UplinkToken: io.MustUplinkToken( - &ttnpb.GatewayAntennaIdentifiers{GatewayIds: registeredGatewayID}, - 100, - 100000, - time.Unix(0, 100*1000), - nil, - ), - }, - }, - Message: &ttnpb.DownlinkMessage{ - RawPayload: []byte{0x01}, - Settings: &ttnpb.DownlinkMessage_Scheduled{ - Scheduled: &ttnpb.TxSettings{ - DataRate: &ttnpb.DataRate{ - Modulation: &ttnpb.DataRate_Lora{ - Lora: &ttnpb.LoRaDataRate{ - Bandwidth: 125000, - SpreadingFactor: 7, - CodingRate: band.Cr4_5, - }, - }, - }, - Frequency: 869525000, - }, - }, - }, - ErrorAssertion: errors.IsInvalidArgument, // Does not support scheduled downlink; the Gateway Server or gateway will take care of that. - }, - { - Topic: fmt.Sprintf("v3/%v/down", registeredGatewayUID), - Path: &ttnpb.DownlinkPath{ - Path: &ttnpb.DownlinkPath_UplinkToken{ - UplinkToken: io.MustUplinkToken( - &ttnpb.GatewayAntennaIdentifiers{GatewayIds: registeredGatewayID}, - 100, - 100000, - time.Unix(0, 100*1000), - nil, - ), - }, - }, - Message: &ttnpb.DownlinkMessage{ - RawPayload: []byte{0x02}, - }, - ErrorAssertion: errors.IsInvalidArgument, // Tx request missing. - }, - } { - t.Run(tc.Topic, func(t *testing.T) { - a := assertions.New(t) - - downCh := make(chan *ttnpb.DownlinkMessage) - handler := func(_ mqtt.Client, msg mqtt.Message) { - down := &ttnpb.GatewayDown{} - err := proto.Unmarshal(msg.Payload(), down) - a.So(err, should.BeNil) - downCh <- down.DownlinkMessage - } - token := client.Subscribe(tc.Topic, 1, handler) - if !token.WaitTimeout(timeout) { - t.Fatal("Subscribe timeout") - } - if !a.So(token.Error(), should.BeNil) { - t.FailNow() - } - - _, _, _, err := conn.ScheduleDown(tc.Path, tc.Message) - if err != nil && (tc.ErrorAssertion == nil || !a.So(tc.ErrorAssertion(err), should.BeTrue)) { - t.Fatalf("Unexpected error: %v", err) - } - var cids []string - select { - case down := <-downCh: - if tc.ErrorAssertion == nil { - a.So(down, should.Resemble, tc.Message) - cids = down.GetCorrelationIds() - } else { - t.Fatalf("Unexpected message: %v", down) - } - case <-time.After(timeout): - if tc.ErrorAssertion == nil { - t.Fatal("Receive expected downlink timeout") + defer client.Disconnect(uint(timeout / time.Millisecond)) + // Write upstream. + go func() { + for { + select { + case <-ctx.Done(): + return + case up := <-upCh: + for _, msg := range up.UplinkMessages { + buf, err := proto.Marshal(msg) + if err != nil { + cancel(err) + return + } + if token := client.Publish(fmt.Sprintf("v3/%v/up", unique.ID(ctx, ids)), 1, false, buf); token.Wait() && token.Error() != nil { + cancel(token.Error()) + return + } + } + if up.GatewayStatus != nil { + buf, err := proto.Marshal(up.GatewayStatus) + if err != nil { + cancel(err) + return + } + if token := client.Publish(fmt.Sprintf("v3/%v/status", unique.ID(ctx, ids)), 1, false, buf); token.Wait() && token.Error() != nil { + cancel(token.Error()) + return + } + } + if up.TxAcknowledgment != nil { + buf, err := proto.Marshal(up.TxAcknowledgment) + if err != nil { + cancel(err) + return + } + if token := client.Publish(fmt.Sprintf("v3/%v/down/ack", unique.ID(ctx, ids)), 1, false, buf); token.Wait() && token.Error() != nil { + cancel(token.Error()) + return + } + } } } - - if tc.ErrorAssertion != nil || tc.TxAckTopic == "" { + }() + // Read downstream. + token := client.Subscribe(fmt.Sprintf("v3/%v/down", unique.ID(ctx, ids)), 1, func(_ mqtt.Client, raw mqtt.Message) { + var msg ttnpb.GatewayDown + if err := proto.Unmarshal(raw.Payload(), &msg); err != nil { + cancel(err) return } - buf, err := proto.Marshal(&ttnpb.TxAcknowledgment{ - CorrelationIds: cids, - Result: ttnpb.TxAcknowledgment_SUCCESS, - }) - if !a.So(err, should.BeNil) { - t.FailNow() - } - token = client.Publish(tc.TxAckTopic, 1, false, buf) - if !token.WaitTimeout(timeout) { - t.Fatal("TxAcknowledgment publish timeout") - } - if !a.So(token.Error(), should.BeNil) { - t.FailNow() - } - - select { - case ack := <-conn.TxAck(): - a.So(ack.DownlinkMessage, should.Resemble, tc.Message) - a.So(ack.Result, should.Equal, ttnpb.TxAcknowledgment_SUCCESS) - case <-time.After(timeout): - if tc.TxAckErrorAssertion == nil { - t.Fatal("Timeout waiting for Tx acknowledgment") - } - } + downCh <- &msg }) - } + if token.Wait() && token.Error() != nil { + return token.Error() + } + <-ctx.Done() + return ctx.Err() + }, }) } diff --git a/pkg/gatewayserver/io/semtechws/lbslns/lbslns_test.go b/pkg/gatewayserver/io/semtechws/lbslns/lbslns_test.go new file mode 100644 index 00000000000..dcbe1934e10 --- /dev/null +++ b/pkg/gatewayserver/io/semtechws/lbslns/lbslns_test.go @@ -0,0 +1,165 @@ +// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lbslns_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/gorilla/websocket" + "go.thethings.network/lorawan-stack/v3/pkg/band" + "go.thethings.network/lorawan-stack/v3/pkg/encoding/lorawan" + "go.thethings.network/lorawan-stack/v3/pkg/errorcontext" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/iotest" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/semtechws" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/semtechws/lbslns" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "go.thethings.network/lorawan-stack/v3/pkg/util/test" +) + +func TestFrontend(t *testing.T) { + wsPingInterval := (1 << 3) * test.Delay + iotest.Frontend(t, iotest.FrontendConfig{ + SupportsStatus: false, + DetectsInvalidMessages: true, + DetectsDisconnect: true, + TimeoutOnInvalidAuth: false, + IsAuthenticated: true, + DeduplicatesUplinks: false, + CustomConfig: func(config *gatewayserver.Config) { + config.BasicStation = gatewayserver.BasicStationConfig{ + Listen: ":1887", + Config: semtechws.Config{ + WSPingInterval: wsPingInterval, + MissedPongThreshold: 2, + AllowUnauthenticated: true, + }, + } + }, + CustomValidAuth: func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string) bool { + return ids.Eui != nil + }, + Link: func( + ctx context.Context, + t *testing.T, + gs *gatewayserver.GatewayServer, + ids *ttnpb.GatewayIdentifiers, + key string, + upCh <-chan *ttnpb.GatewayUp, + downCh chan<- *ttnpb.GatewayDown, + ) error { + if ids.Eui == nil { + t.SkipNow() + } + wsConn, _, err := websocket.DefaultDialer.Dial("ws://0.0.0.0:1887/traffic/"+ids.GatewayId, nil) + if err != nil { + return err + } + defer wsConn.Close() + ctx, cancel := errorcontext.New(ctx) + // Write upstream. + go func() { + for { + select { + case <-ctx.Done(): + return + case msg := <-upCh: + for _, uplink := range msg.UplinkMessages { + var payload ttnpb.Message + if err := lorawan.UnmarshalMessage(uplink.RawPayload, &payload); err != nil { + // Ignore invalid uplinks + continue + } + var bsUpstream []byte + switch payload.MHdr.MType { + case ttnpb.MType_JOIN_REQUEST: + var jreq lbslns.JoinRequest + err := jreq.FromUplinkMessage(uplink, band.EU_863_870) + if err != nil { + cancel(err) + return + } + bsUpstream, err = jreq.MarshalJSON() + if err != nil { + cancel(err) + return + } + case ttnpb.MType_UNCONFIRMED_UP, ttnpb.MType_CONFIRMED_UP: + var updf lbslns.UplinkDataFrame + err := updf.FromUplinkMessage(uplink, band.EU_863_870) + if err != nil { + cancel(err) + return + } + bsUpstream, err = updf.MarshalJSON() + if err != nil { + cancel(err) + return + } + } + if err := wsConn.WriteMessage(websocket.TextMessage, bsUpstream); err != nil { + cancel(err) + return + } + } + if msg.TxAcknowledgment != nil { + txConf := lbslns.TxConfirmation{ + Diid: 0, + XTime: time.Now().Unix(), + } + bsUpstream, err := txConf.MarshalJSON() + if err != nil { + cancel(err) + return + } + if err := wsConn.WriteMessage(websocket.TextMessage, bsUpstream); err != nil { + cancel(err) + return + } + } + } + } + }() + // Read downstream. + go func() { + for { + _, data, err := wsConn.ReadMessage() + if err != nil { + cancel(err) + return + } + var msg lbslns.DownlinkMessage + if err := json.Unmarshal(data, &msg); err != nil { + cancel(err) + return + } + dlmesg, err := msg.ToDownlinkMessage(band.EU_863_870) + if err != nil { + cancel(err) + return + } + downCh <- &ttnpb.GatewayDown{ + DownlinkMessage: dlmesg, + } + } + }() + <-ctx.Done() + return ctx.Err() + }, + }) +} diff --git a/pkg/gatewayserver/io/udp/udp_test.go b/pkg/gatewayserver/io/udp/udp_test.go index 2d64cb50e5a..fae6cea7399 100644 --- a/pkg/gatewayserver/io/udp/udp_test.go +++ b/pkg/gatewayserver/io/udp/udp_test.go @@ -28,12 +28,15 @@ import ( "go.thethings.network/lorawan-stack/v3/pkg/component" componenttest "go.thethings.network/lorawan-stack/v3/pkg/component/test" "go.thethings.network/lorawan-stack/v3/pkg/config" + "go.thethings.network/lorawan-stack/v3/pkg/errorcontext" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver" "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/iotest" "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/mock" + "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/udp" . "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/io/udp" "go.thethings.network/lorawan-stack/v3/pkg/gatewayserver/scheduling" mockis "go.thethings.network/lorawan-stack/v3/pkg/identityserver/mock" - "go.thethings.network/lorawan-stack/v3/pkg/log" "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" encoding "go.thethings.network/lorawan-stack/v3/pkg/ttnpb/udp" "go.thethings.network/lorawan-stack/v3/pkg/types" @@ -41,24 +44,19 @@ import ( "go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should" ) -var ( - registeredGatewayID = ttnpb.GatewayIdentifiers{GatewayId: "test-gateway"} - - timeout = (1 << 4) * test.Delay - - testConfig = Config{ - PacketHandlers: 2, - PacketBuffer: 10, - DownlinkPathExpires: 8 * timeout, - ConnectionExpires: 20 * timeout, - ScheduleLateTime: 0, - } -) - func TestConnection(t *testing.T) { - a := assertions.New(t) + var ( + timeout = (1 << 4) * test.Delay + testConfig = Config{ + PacketHandlers: 2, + PacketBuffer: 10, + DownlinkPathExpires: 8 * timeout, + ConnectionExpires: 20 * timeout, + ScheduleLateTime: 0, + } + ) - ctx := log.NewContext(test.Context(), test.GetLogger(t)) + a, ctx := test.New(t) ctx, cancelCtx := context.WithCancel(ctx) defer cancelCtx() @@ -204,10 +202,180 @@ func TestConnection(t *testing.T) { cancelCtx() } -func TestTraffic(t *testing.T) { - a := assertions.New(t) +func TestFrontend(t *testing.T) { + iotest.Frontend(t, iotest.FrontendConfig{ + DetectsInvalidMessages: false, + SupportsStatus: true, + DetectsDisconnect: false, + TimeoutOnInvalidAuth: false, + IsAuthenticated: false, + DeduplicatesUplinks: true, + CustomConfig: func(config *gatewayserver.Config) { + config.UDP = gatewayserver.UDPConfig{ + Config: udp.Config{ + PacketHandlers: 2, + PacketBuffer: 10, + DownlinkPathExpires: 1 * time.Second, + ConnectionExpires: 2 * time.Second, + ScheduleLateTime: 0, + AddrChangeBlock: 2 * time.Second, + }, + Listeners: map[string]string{ + ":1700": test.EUFrequencyPlanID, + }, + } + }, + CustomValidAuth: func(ctx context.Context, ids *ttnpb.GatewayIdentifiers, key string) bool { + return ids.Eui != nil + }, + Link: func( + ctx context.Context, + t *testing.T, + gs *gatewayserver.GatewayServer, + ids *ttnpb.GatewayIdentifiers, + key string, + upCh <-chan *ttnpb.GatewayUp, + downCh chan<- *ttnpb.GatewayDown, + ) error { + if ids.Eui == nil { + t.SkipNow() + } + upConn, err := net.Dial("udp", ":1700") + if err != nil { + return err + } + downConn, err := net.Dial("udp", ":1700") + if err != nil { + return err + } + ctx, cancel := errorcontext.New(ctx) + // Write upstream. + go func() { + var token byte + var readBuf [65507]byte + for { + select { + case <-ctx.Done(): + return + case up := <-upCh: + token++ + packet := encoding.Packet{ + GatewayEUI: types.MustEUI64(ids.Eui), + ProtocolVersion: encoding.Version1, + Token: [2]byte{0x00, token}, + PacketType: encoding.PushData, + Data: &encoding.Data{}, + } + packet.Data.RxPacket, packet.Data.Stat, packet.Data.TxPacketAck = encoding.FromGatewayUp(up) + if packet.Data.TxPacketAck != nil { + packet.PacketType = encoding.TxAck + } + writeBuf, err := packet.MarshalBinary() + if err != nil { + cancel(err) + return + } + switch packet.PacketType { + case encoding.PushData: + if _, err := upConn.Write(writeBuf); err != nil { + cancel(err) + return + } + if _, err := upConn.Read(readBuf[:]); err != nil { + cancel(err) + return + } + case encoding.TxAck: + if _, err := downConn.Write(writeBuf); err != nil { + cancel(err) + return + } + } + } + } + }() + // Engage downstream by sending PULL_DATA every 10ms. + go func() { + var token byte + ticker := time.NewTicker(10 * time.Millisecond) + for { + select { + case <-ctx.Done(): + ticker.Stop() + return + case <-ticker.C: + token++ + pull := encoding.Packet{ + GatewayEUI: types.MustEUI64(ids.Eui), + ProtocolVersion: encoding.Version1, + Token: [2]byte{0x01, token}, + PacketType: encoding.PullData, + } + buf, err := pull.MarshalBinary() + if err != nil { + cancel(err) + return + } + if _, err := downConn.Write(buf); err != nil { + cancel(err) + return + } + } + } + }() + // Read downstream; PULL_RESP and PULL_ACK. + go func() { + var buf [65507]byte + for { + n, err := downConn.Read(buf[:]) + if err != nil { + cancel(err) + return + } + packetBuf := make([]byte, n) + copy(packetBuf, buf[:]) + var packet encoding.Packet + if err := packet.UnmarshalBinary(packetBuf); err != nil { + cancel(err) + return + } + switch packet.PacketType { + case encoding.PullResp: + msg, err := encoding.ToDownlinkMessage(packet.Data.TxPacket) + if err != nil { + cancel(err) + return + } + downCh <- &ttnpb.GatewayDown{ + DownlinkMessage: msg, + } + } + } + }() + <-ctx.Done() + time.Sleep(3 * time.Second) // Ensure that connection expires. + return ctx.Err() + }, + }) +} + +// TestRawData tests the raw data input and output of the UDP frontend. +// This includes garbage data, connection state, and the timing of downlink scheduling. +// This test is complementary to the generic TestFrontend. +func TestRawData(t *testing.T) { + var ( + registeredGatewayID = ttnpb.GatewayIdentifiers{GatewayId: "test-gateway"} + timeout = (1 << 4) * test.Delay + testConfig = Config{ + PacketHandlers: 2, + PacketBuffer: 10, + DownlinkPathExpires: 8 * timeout, + ConnectionExpires: 20 * timeout, + ScheduleLateTime: 0, + } + ) - ctx := log.NewContext(test.Context(), test.GetLogger(t)) + a, ctx := test.New(t) ctx, cancelCtx := context.WithCancel(ctx) defer cancelCtx() diff --git a/pkg/gatewayserver/io/udp/udp_util_test.go b/pkg/gatewayserver/io/udp/udp_util_test.go index 7514dafc06d..72fbe124ce5 100644 --- a/pkg/gatewayserver/io/udp/udp_util_test.go +++ b/pkg/gatewayserver/io/udp/udp_util_test.go @@ -35,6 +35,7 @@ import ( encoding "go.thethings.network/lorawan-stack/v3/pkg/ttnpb/udp" "go.thethings.network/lorawan-stack/v3/pkg/types" "go.thethings.network/lorawan-stack/v3/pkg/util/datarate" + "go.thethings.network/lorawan-stack/v3/pkg/util/test" "go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should" ) @@ -107,7 +108,10 @@ func generateTxAck(eui types.EUI64, err encoding.TxError) encoding.Packet { } func expectAck(t *testing.T, conn net.Conn, expect bool, packetType encoding.PacketType, token [2]byte) { - var buf [65507]byte + var ( + timeout = (1 << 4) * test.Delay + buf [65507]byte + ) conn.SetReadDeadline(time.Now().Add(timeout)) n, err := conn.Read(buf[:]) if err != nil { @@ -132,8 +136,12 @@ func expectAck(t *testing.T, conn net.Conn, expect bool, packetType encoding.Pac } func expectConnection(t *testing.T, server mock.Server, connections *sync.Map, eui types.EUI64, expectNew bool) *io.Connection { - a := assertions.New(t) - var conn *io.Connection + t.Helper() + var ( + timeout = (1 << 4) * test.Delay + a = assertions.New(t) + conn *io.Connection + ) select { case conn = <-server.Connections(): if !a.So(expectNew, should.BeTrue) {