From 34bf3a5d2b86fb7f3f34e83dcaa336a0904a5ea6 Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:58:20 +0200 Subject: [PATCH 1/8] dcs: Implement the Update method in GatewayRegistry --- .../registry/gateways/gateways.go | 15 +++++++++++++++ pkg/deviceclaimingserver/util_test.go | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/pkg/deviceclaimingserver/registry/gateways/gateways.go b/pkg/deviceclaimingserver/registry/gateways/gateways.go index 419bb44409..61d79bd35d 100644 --- a/pkg/deviceclaimingserver/registry/gateways/gateways.go +++ b/pkg/deviceclaimingserver/registry/gateways/gateways.go @@ -47,6 +47,8 @@ type GatewayRegistry interface { Delete(ctx context.Context, in *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) // Get the gateway. This may not release the gateway ID for reuse, but it does release the EUI. Get(ctx context.Context, req *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) + // Update the gateway. + Update(ctx context.Context, req *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) } // Registry implements GatewayRegistry. @@ -129,3 +131,16 @@ func (reg Registry) Get(ctx context.Context, req *ttnpb.GetGatewayRequest) (*ttn } return gatewayRegistry.Get(ctx, req, callOpt) } + +// Update implements GatewayRegistry. +func (reg Registry) Update(ctx context.Context, req *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { + callOpt, err := reg.callOptFromContext(ctx) + if err != nil { + return nil, err + } + gatewayRegistry, err := reg.newEntityRegistryClient(ctx) + if err != nil { + return nil, err + } + return gatewayRegistry.Update(ctx, req, callOpt) +} diff --git a/pkg/deviceclaimingserver/util_test.go b/pkg/deviceclaimingserver/util_test.go index 102190da55..43bdf6ed33 100644 --- a/pkg/deviceclaimingserver/util_test.go +++ b/pkg/deviceclaimingserver/util_test.go @@ -113,6 +113,7 @@ type mockGatewayRegistry struct { createFunc func(ctx context.Context, in *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) deleteFunc func(ctx context.Context, in *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) getFunc func(ctx context.Context, req *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) + updateFunc func(ctx context.Context, req *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) } var ( @@ -163,3 +164,8 @@ func (mock mockGatewayRegistry) Delete(ctx context.Context, in *ttnpb.GatewayIde func (mock mockGatewayRegistry) Get(ctx context.Context, in *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) { return mock.getFunc(ctx, in) } + +// Update implements GatewayRegistry. +func (mock mockGatewayRegistry) Update(ctx context.Context, req *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { + return mock.updateFunc(ctx, req) +} From a3670ab42dcac5239fbf3ab52d2c1d95a094c8a2 Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Fri, 16 Jan 2026 22:03:07 +0200 Subject: [PATCH 2/8] dcs: Create gateway at the start of the Claim to attach keys to gateway --- pkg/deviceclaimingserver/grpc_gateways.go | 50 +++++++++++-- .../grpc_gateways_test.go | 70 +++++++++++++++++-- pkg/deviceclaimingserver/types/types.go | 3 +- pkg/deviceclaimingserver/util_test.go | 1 + 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/pkg/deviceclaimingserver/grpc_gateways.go b/pkg/deviceclaimingserver/grpc_gateways.go index 3b93150ee8..35ad56fb29 100644 --- a/pkg/deviceclaimingserver/grpc_gateways.go +++ b/pkg/deviceclaimingserver/grpc_gateways.go @@ -27,6 +27,7 @@ import ( "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" "go.thethings.network/lorawan-stack/v3/pkg/types" "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/fieldmaskpb" ) type peerAccess interface { @@ -107,6 +108,27 @@ func (gcls *gatewayClaimingServer) Claim( return nil, err } + // Create the gateway in the IS. + gateway := &ttnpb.Gateway{ + Ids: ids, + } + + _, err = gcls.registry.Create(ctx, &ttnpb.CreateGatewayRequest{ + Gateway: gateway, + Collaborator: req.GetCollaborator(), + }) + if err != nil { + return nil, errCreateGateway.WithCause(err) + } + defer func() { + if retErr != nil { + logger.Warn("Failed to claim gateway, deleting created gateway") + if _, delErr := gcls.registry.Delete(ctx, ids); delErr != nil { + logger.WithError(delErr).Warn("Failed to delete created gateway after failed claim") + } + } + }() + // Support clients that only set a single frequency plan. if len(req.TargetFrequencyPlanIds) == 0 && req.TargetFrequencyPlanId != "" { // nolint:staticcheck req.TargetFrequencyPlanIds = []string{req.TargetFrequencyPlanId} // nolint:staticcheck @@ -125,7 +147,7 @@ func (gcls *gatewayClaimingServer) Claim( return nil, errClaim.WithCause(err) } - // Unclaim if creation fails. + // Unclaim if update fails. defer func(ids *ttnpb.GatewayIdentifiers) { if retErr != nil { observability.RegisterAbortClaim(ctx, ids.GetEntityIdentifiers(), retErr) @@ -137,8 +159,9 @@ func (gcls *gatewayClaimingServer) Claim( observability.RegisterSuccessClaim(ctx, ids.GetEntityIdentifiers()) }(ids) - // Create the gateway in the IS. - gateway := &ttnpb.Gateway{ + // Update the gateway in the IS. If the update fails, the gateway will be unclaimed in the above deferred function + // and deleted in the previous one. + gateway = &ttnpb.Gateway{ Ids: ids, GatewayServerAddress: req.TargetGatewayServerAddress, EnforceDutyCycle: true, @@ -147,9 +170,24 @@ func (gcls *gatewayClaimingServer) Claim( Antennas: res.Antennas, } - _, err = gcls.registry.Create(ctx, &ttnpb.CreateGatewayRequest{ - Gateway: gateway, - Collaborator: req.GetCollaborator(), + fieldMask := &fieldmaskpb.FieldMask{ + Paths: []string{ + "gateway_server_address", + "enforce_duty_cycle", + "require_authenticated_connection", + "frequency_plan_ids", + "antennas", + }, + } + + if res.LBSLNSKey != nil { + gateway.LbsLnsSecret = &ttnpb.Secret{Value: []byte(res.LBSLNSKey.Key)} + fieldMask.Paths = append(fieldMask.Paths, "lbs_lns_secret") + } + + _, err = gcls.registry.Update(ctx, &ttnpb.UpdateGatewayRequest{ + Gateway: gateway, + FieldMask: fieldMask, }) if err != nil { return nil, errCreateGateway.WithCause(err) diff --git a/pkg/deviceclaimingserver/grpc_gateways_test.go b/pkg/deviceclaimingserver/grpc_gateways_test.go index 1e18df9adb..786acbd47a 100644 --- a/pkg/deviceclaimingserver/grpc_gateways_test.go +++ b/pkg/deviceclaimingserver/grpc_gateways_test.go @@ -34,6 +34,7 @@ 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/emptypb" ) var ( @@ -182,7 +183,9 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest CallOpt grpc.CallOption ClaimFunc func(context.Context, types.EUI64, string, string) (*dcstypes.GatewayMetadata, error) CreateFunc func(context.Context, *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) + UpdateFunc func(context.Context, *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) UnclaimFunc func(context.Context, types.EUI64) error + DeleteFunc func(context.Context, *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) ErrorAssertion func(error) bool }{ { @@ -241,6 +244,25 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest CallOpt: authorizedCallOpt, ErrorAssertion: errors.IsAlreadyExists, }, + { + Name: "Claim/GatewayCreationFailed", + Req: &ttnpb.ClaimGatewayRequest{ + Collaborator: userID.GetOrganizationOrUserIdentifiers(), + SourceGateway: &ttnpb.ClaimGatewayRequest_AuthenticatedIdentifiers_{ + AuthenticatedIdentifiers: &ttnpb.ClaimGatewayRequest_AuthenticatedIdentifiers{ + GatewayEui: supportedEUI.Bytes(), + AuthenticationCode: claimAuthCode, + }, + }, + TargetGatewayId: "test-gateway", + TargetGatewayServerAddress: "things.example.com", + }, + CallOpt: authorizedCallOpt, + CreateFunc: func(_ context.Context, in *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) { + return nil, errCreate.New() + }, + ErrorAssertion: errors.IsAborted, + }, { Name: "Claim/EUINotRegisteredForClaiming", Req: &ttnpb.ClaimGatewayRequest{ @@ -254,7 +276,13 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest TargetGatewayId: "test-gateway", TargetGatewayServerAddress: "things.example.com", }, - CallOpt: authorizedCallOpt, + CallOpt: authorizedCallOpt, + CreateFunc: func(_ context.Context, in *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) { + return in.Gateway, nil + }, + DeleteFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil + }, ErrorAssertion: errors.IsAborted, }, { @@ -271,13 +299,22 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest TargetGatewayServerAddress: "things.example.com", }, CallOpt: authorizedCallOpt, + CreateFunc: func(_ context.Context, in *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) { + return in.Gateway, nil + }, ClaimFunc: func(_ context.Context, _ types.EUI64, _, _ string) (*dcstypes.GatewayMetadata, error) { return nil, errClaim.New() }, + UpdateFunc: func(_ context.Context, in *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { + return in.Gateway, nil + }, + DeleteFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil + }, ErrorAssertion: errors.IsAborted, }, { - Name: "Claim/CreateFailed", + Name: "Claim/UpdateFailed", Req: &ttnpb.ClaimGatewayRequest{ Collaborator: userID.GetOrganizationOrUserIdentifiers(), SourceGateway: &ttnpb.ClaimGatewayRequest_AuthenticatedIdentifiers_{ @@ -294,7 +331,13 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest return &dcstypes.GatewayMetadata{}, nil }, CreateFunc: func(context.Context, *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) { - return nil, errCreate.New() + return nil, nil + }, + UpdateFunc: func(_ context.Context, in *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { + return nil, errUpdate.New() + }, + DeleteFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil }, UnclaimFunc: func(_ context.Context, eui types.EUI64) error { if eui.Equal(supportedEUI) { @@ -305,7 +348,7 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest ErrorAssertion: errors.IsAborted, }, { - Name: "Claim/CreateFailedWithUnclaimFailed", + Name: "Claim/UpdateFailedWithUnclaimFailed", Req: &ttnpb.ClaimGatewayRequest{ Collaborator: userID.GetOrganizationOrUserIdentifiers(), SourceGateway: &ttnpb.ClaimGatewayRequest_AuthenticatedIdentifiers_{ @@ -322,7 +365,13 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest return &dcstypes.GatewayMetadata{}, nil }, CreateFunc: func(context.Context, *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) { - return nil, errCreate.New() + return nil, nil + }, + UpdateFunc: func(_ context.Context, in *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { + return nil, errUpdate.New() + }, + DeleteFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil }, UnclaimFunc: func(context.Context, types.EUI64) error { return errUnclaim.New() @@ -330,7 +379,7 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest ErrorAssertion: errors.IsAborted, }, { - Name: "Claim/SuccessfullyClaimedAndCreated", + Name: "Claim/SuccessfullyClaimedAndUpdated", Req: &ttnpb.ClaimGatewayRequest{ Collaborator: userID.GetOrganizationOrUserIdentifiers(), SourceGateway: &ttnpb.ClaimGatewayRequest_AuthenticatedIdentifiers_{ @@ -348,6 +397,9 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest CreateFunc: func(_ context.Context, in *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) { return in.Gateway, nil }, + UpdateFunc: func(_ context.Context, in *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { + return in.Gateway, nil + }, CallOpt: authorizedCallOpt, }, } { @@ -361,6 +413,12 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest if tc.CreateFunc != nil { mockGatewayRegistry.createFunc = tc.CreateFunc } + if tc.UpdateFunc != nil { + mockGatewayRegistry.updateFunc = tc.UpdateFunc + } + if tc.DeleteFunc != nil { + mockGatewayRegistry.deleteFunc = tc.DeleteFunc + } _, err := gclsClient.Claim(ctx, tc.Req, tc.CallOpt) if err != nil { diff --git a/pkg/deviceclaimingserver/types/types.go b/pkg/deviceclaimingserver/types/types.go index e27cf94f92..6f68b521f7 100644 --- a/pkg/deviceclaimingserver/types/types.go +++ b/pkg/deviceclaimingserver/types/types.go @@ -62,5 +62,6 @@ func RangeFromEUI64Range(start, end types.EUI64) EUI64Range { // GatewayMetadata contains metadata of a gateway, typically returned on claiming. type GatewayMetadata struct { - Antennas []*ttnpb.GatewayAntenna + Antennas []*ttnpb.GatewayAntenna + LBSLNSKey *ttnpb.APIKey } diff --git a/pkg/deviceclaimingserver/util_test.go b/pkg/deviceclaimingserver/util_test.go index 43bdf6ed33..f5cda85220 100644 --- a/pkg/deviceclaimingserver/util_test.go +++ b/pkg/deviceclaimingserver/util_test.go @@ -121,6 +121,7 @@ var ( errGatewayNotFound = errors.DefineNotFound("gateway_not_found", "gateway not found") errClaim = errors.DefineAborted("claim", "claim") errCreate = errors.DefineAborted("create_gateway", "create gateway") + errUpdate = errors.DefineAborted("update_gateway", "update gateway") errUnclaim = errors.DefineAborted("unclaim", "unclaim gateway") ) From 2e93568e4d7d3e082b5e91998cc5b22feb5a7b33 Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:46:17 +0200 Subject: [PATCH 3/8] dcs: Implement TTGC LBS CUPS claimer --- pkg/deviceclaimingserver/gateways/gateways.go | 26 +- .../gateways/lbscups/lbscups.go | 320 ++++++++++++++++++ .../gateways/lbscups/root_ca.go | 59 ++++ pkg/ttgc/config.go | 12 +- 4 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 pkg/deviceclaimingserver/gateways/lbscups/lbscups.go create mode 100644 pkg/deviceclaimingserver/gateways/lbscups/root_ca.go diff --git a/pkg/deviceclaimingserver/gateways/gateways.go b/pkg/deviceclaimingserver/gateways/gateways.go index a57ca40f69..e56036b8cd 100644 --- a/pkg/deviceclaimingserver/gateways/gateways.go +++ b/pkg/deviceclaimingserver/gateways/gateways.go @@ -20,12 +20,16 @@ import ( "crypto/tls" "strings" + "go.thethings.network/lorawan-stack/v3/pkg/cluster" "go.thethings.network/lorawan-stack/v3/pkg/config" "go.thethings.network/lorawan-stack/v3/pkg/config/tlsconfig" + "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/gateways/lbscups" "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/gateways/ttgc" dcstypes "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/types" "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" "go.thethings.network/lorawan-stack/v3/pkg/types" + "google.golang.org/grpc" ) // Component is the interface to the component. @@ -33,6 +37,8 @@ type Component interface { GetBaseConfig(context.Context) config.ServiceBase GetTLSConfig(context.Context) tlsconfig.Config GetTLSClientConfig(context.Context, ...tlsconfig.Option) (*tls.Config, error) + GetPeerConn(ctx context.Context, role ttnpb.ClusterRole, ids cluster.EntityIdentifiers) (*grpc.ClientConn, error) + AllowInsecureForCredentials() bool } // Config is the configuration for the Gateway Claiming Server. @@ -43,8 +49,9 @@ type Config struct { } var ( - errInvalidUpstream = errors.DefineInvalidArgument("invalid_upstream", "upstream `{name}` is invalid") - errTTGCNotEnabled = errors.DefineFailedPrecondition("ttgc_not_enabled", "TTGC is not enabled") + errInvalidUpstream = errors.DefineInvalidArgument("invalid_upstream", "upstream `{name}` is invalid") + errTTGCNotEnabled = errors.DefineFailedPrecondition("ttgc_not_enabled", "TTGC is not enabled") + errLBSCUPSNotEnabled = errors.DefineFailedPrecondition("lbs_cups_not_enabled", "TTGC LBS CUPS is not enabled") ) // ParseGatewayEUIRanges parses the configured upstream map and returns map of ranges. @@ -134,6 +141,13 @@ func NewUpstream( } hosts["ttgc"] = ttgcRanges } + if _, lbscupsAdded := hosts["lbs-cups"]; ttgcConf.LBSCUPSEnabled && !lbscupsAdded { + lbscupsRanges := make([]dcstypes.EUI64Range, len(ttgcConf.LBSCUPSGatewayEUIs)) + for i, prefix := range ttgcConf.LBSCUPSGatewayEUIs { + lbscupsRanges[i] = dcstypes.RangeFromEUI64Prefix(prefix) + } + hosts["lbs-cups"] = lbscupsRanges + } // Setup upstream table. for name, ranges := range hosts { @@ -150,6 +164,14 @@ func NewUpstream( if err != nil { return nil, err } + case "lbs-cups": + if !ttgcConf.LBSCUPSEnabled { + return nil, errLBSCUPSNotEnabled.New() + } + claimer, err = lbscups.New(ctx, c, ttgcConf) + if err != nil { + return nil, err + } default: return nil, errInvalidUpstream.WithAttributes("name", name) } diff --git a/pkg/deviceclaimingserver/gateways/lbscups/lbscups.go b/pkg/deviceclaimingserver/gateways/lbscups/lbscups.go new file mode 100644 index 0000000000..63d42bcc72 --- /dev/null +++ b/pkg/deviceclaimingserver/gateways/lbscups/lbscups.go @@ -0,0 +1,320 @@ +// 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 lbscups provides functions to claim gateways using LBS CUPS protocol. +package lbscups + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "net" + "time" + + northboundv1 "go.thethings.industries/pkg/api/gen/tti/gateway/controller/northbound/v1" + "go.thethings.network/lorawan-stack/v3/pkg/cluster" + "go.thethings.network/lorawan-stack/v3/pkg/config/tlsconfig" + dcstypes "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/types" + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/log" + "go.thethings.network/lorawan-stack/v3/pkg/rpcmetadata" + "go.thethings.network/lorawan-stack/v3/pkg/ttgc" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "go.thethings.network/lorawan-stack/v3/pkg/types" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const profileGroup = "tts" + +type component interface { + GetTLSConfig(context.Context) tlsconfig.Config + GetTLSClientConfig(context.Context, ...tlsconfig.Option) (*tls.Config, error) + GetPeerConn(ctx context.Context, role ttnpb.ClusterRole, ids cluster.EntityIdentifiers) (*grpc.ClientConn, error) + AllowInsecureForCredentials() bool +} + +// Upstream is the client for LBS CUPS gateway claiming. +type Upstream struct { + component + client *ttgc.Client + + gatewayAccess ttnpb.GatewayAccessClient +} + +// New returns a new upstream client for LBS CUPS gateway claiming. +func New(ctx context.Context, c component, config ttgc.Config) (*Upstream, error) { + client, err := ttgc.NewClient(ctx, c, config) + if err != nil { + return nil, err + } + upstream := &Upstream{ + component: c, + client: client, + } + return upstream, nil +} + +var errCreateAPIKey = errors.DefineAborted("create_api_key", "create API key") + +// Claim implements gateways.Claimer. +// Claim does the following: +// 1. Create CUPS and LNS API keys for the gateway +// 2. Claim the gateway on TTGC with the CUPS key as the gateway token +// 3. Return the LNS key in GatewayMetadata +func (u *Upstream) Claim( + ctx context.Context, eui types.EUI64, ownerToken, clusterAddress string, +) (*dcstypes.GatewayMetadata, error) { + logger := log.FromContext(ctx) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: eui.Bytes(), + } + + // Create CUPS and LNS API keys for the gateway. The CUPS key will be used as gateway token when claiming on TTGC and + // the LNS key will be returned in the metadata. The caller is responsible for updating the LNS key in the gateway. + cupsKey, lnsKey, err := u.createAPIKeys(ctx, ids) + if err != nil { + return nil, err + } + + // Claim the gateway on TTGC with the CUPS key as the gateway token. + gtwClient := northboundv1.NewGatewayServiceClient(u.client) + _, err = gtwClient.Claim(ctx, &northboundv1.GatewayServiceClaimRequest{ + GatewayId: eui.MarshalNumber(), + Domain: u.client.Domain(ctx), + OwnerToken: ownerToken, + GatewayToken: []byte(cupsKey.Key), + }) + if err != nil { + logger.WithError(err).Warn("Failed to claim gateway on TTGC") + return nil, err + } + + // Get the Root CA from the Gateway Server. + host, _, err := net.SplitHostPort(clusterAddress) + if err != nil { + host = clusterAddress + } + clusterAddress = net.JoinHostPort(host, "8889") + rootCA, err := u.getRootCA(ctx, clusterAddress) + if err != nil { + return nil, err + } + + var ( + loraPFProfileID []byte + loraPFProfile = &northboundv1.LoraPacketForwarderProfile{ + ProfileName: clusterAddress, + Shared: false, + Protocol: northboundv1.LoraPacketForwarderProtocol_LORA_PACKET_FORWARDER_PROTOCOL_BASIC_STATION, + Address: clusterAddress, + RootCa: rootCA.Raw, + } + loraPFProfileClient = northboundv1.NewLoraPacketForwarderProfileServiceClient(u.client) + ) + loraPFGetRes, err := loraPFProfileClient.GetByName( + ctx, + &northboundv1.LoraPacketForwarderProfileServiceGetByNameRequest{ + Domain: u.client.Domain(ctx), + Group: profileGroup, + ProfileName: clusterAddress, + }, + ) + if err != nil { + if status.Code(err) != codes.NotFound { + logger.WithError(err).Warn("Failed to get LoRa Packet Forwarder profile") + return nil, err + } + res, err := loraPFProfileClient.Create(ctx, &northboundv1.LoraPacketForwarderProfileServiceCreateRequest{ + Domain: u.client.Domain(ctx), + Group: profileGroup, + LoraPacketForwarderProfile: loraPFProfile, + }) + if err != nil { + logger.WithError(err).Warn("Failed to create LoRa Packet Forwarder profile") + return nil, err + } + loraPFProfileID = res.ProfileId + } else { + if profile := loraPFGetRes.LoraPacketForwarderProfile; profile.Shared != loraPFProfile.Shared || + profile.Protocol != loraPFProfile.Protocol || + !bytes.Equal(profile.RootCa, loraPFProfile.RootCa) { + _, err := loraPFProfileClient.Update(ctx, &northboundv1.LoraPacketForwarderProfileServiceUpdateRequest{ + Domain: u.client.Domain(ctx), + Group: profileGroup, + ProfileId: loraPFGetRes.ProfileId, + LoraPacketForwarderProfile: loraPFProfile, + }) + if err != nil { + logger.WithError(err).Warn("Failed to update LoRa Packet Forwarder profile") + return nil, err + } + } + loraPFProfileID = loraPFGetRes.ProfileId + } + + // Update the gateway with the Lora Packet Forwarder profile. + _, err = gtwClient.Update(ctx, &northboundv1.GatewayServiceUpdateRequest{ + GatewayId: eui.MarshalNumber(), + Domain: u.client.Domain(ctx), + LoraPacketForwarderProfileId: &northboundv1.ProfileIDValue{ + Value: loraPFProfileID, + }, + }) + if err != nil { + logger.WithError(err).Warn("Failed to update gateway with profiles") + return nil, err + } + + return &dcstypes.GatewayMetadata{ + LBSLNSKey: lnsKey, + }, nil +} + +// createAPIKeys creates the CUPS and LNS API keys for the gateway. +func (u *Upstream) createAPIKeys( + ctx context.Context, ids *ttnpb.GatewayIdentifiers, +) (cupsKey, lnsKey *ttnpb.APIKey, err error) { + logger := log.FromContext(ctx) + + gatewayAccess, err := u.getGatewayAccess(ctx) + if err != nil { + return nil, nil, err + } + + callOpt, err := rpcmetadata.WithForwardedAuth(ctx, u.AllowInsecureForCredentials()) + if err != nil { + return nil, nil, err + } + + cupsKey, err = gatewayAccess.CreateAPIKey(ctx, &ttnpb.CreateGatewayAPIKeyRequest{ + GatewayIds: ids, + Name: fmt.Sprintf("LBS CUPS Key (TTGC claim), generated %s", time.Now().UTC().Format(time.RFC3339)), + Rights: []ttnpb.Right{ + ttnpb.Right_RIGHT_GATEWAY_INFO, + ttnpb.Right_RIGHT_GATEWAY_SETTINGS_BASIC, + ttnpb.Right_RIGHT_GATEWAY_READ_SECRETS, + }, + }, callOpt) + if err != nil { + logger.WithError(err).Warn("Failed to create CUPS API key") + return nil, nil, errCreateAPIKey.WithCause(err) + } + + lnsKey, err = gatewayAccess.CreateAPIKey(ctx, &ttnpb.CreateGatewayAPIKeyRequest{ + GatewayIds: ids, + Name: fmt.Sprintf("LBS LNS Key (TTGC claim), generated %s", time.Now().UTC().Format(time.RFC3339)), + Rights: []ttnpb.Right{ + ttnpb.Right_RIGHT_GATEWAY_LINK, + }, + }, callOpt) + if err != nil { + logger.WithError(err).Warn("Failed to create LNS API key") + return nil, nil, errCreateAPIKey.WithCause(err) + } + + return cupsKey, lnsKey, nil +} + +func (u *Upstream) getGatewayAccess(ctx context.Context) (ttnpb.GatewayAccessClient, error) { + if u.gatewayAccess != nil { + return u.gatewayAccess, nil + } + conn, err := u.GetPeerConn(ctx, ttnpb.ClusterRole_ACCESS, nil) + if err != nil { + return nil, err + } + return ttnpb.NewGatewayAccessClient(conn), nil +} + +// Unclaim implements gateways.Claimer. +// Unclaim revokes the API keys and unclaims the gateway on TTGC. +func (u *Upstream) Unclaim(ctx context.Context, eui types.EUI64) error { + ids := &ttnpb.GatewayIdentifiers{ + Eui: eui.Bytes(), + } + + if err := u.deleteAPIKeys(ctx, ids); err != nil { + return err + } + + // Unclaim the gateway on TTGC. + gtwClient := northboundv1.NewGatewayServiceClient(u.client) + _, err := gtwClient.Unclaim(ctx, &northboundv1.GatewayServiceUnclaimRequest{ + GatewayId: eui.MarshalNumber(), + Domain: u.client.Domain(ctx), + }) + if err != nil { + if errors.IsNotFound(err) { + // The gateway does not exist or is already unclaimed. + return nil + } + return err + } + return nil +} + +var errDeleteAPIKey = errors.DefineAborted("delete_api_key", "delete API key") + +// deleteAPIKeys deletes the CUPS and LNS API keys for the gateway. +func (u *Upstream) deleteAPIKeys(ctx context.Context, ids *ttnpb.GatewayIdentifiers) error { + logger := log.FromContext(ctx) + + gatewayAccess, err := u.getGatewayAccess(ctx) + if err != nil { + return err + } + + callOpt, err := rpcmetadata.WithForwardedAuth(ctx, u.AllowInsecureForCredentials()) + if err != nil { + return err + } + + apiKeys, err := gatewayAccess.ListAPIKeys(ctx, &ttnpb.ListGatewayAPIKeysRequest{ + GatewayIds: ids, + }, callOpt) + if err != nil { + logger.WithError(err).Warn("Failed to list API keys") + return errDeleteAPIKey.WithCause(err) + } + + // Delete the LBS CUPS and LBS LNS keys. + for _, key := range apiKeys.ApiKeys { + if key.Name == "" { + continue + } + // Match keys created by this claimer. + if len(key.Name) > 8 && (key.Name[:8] == "LBS CUPS" || key.Name[:7] == "LBS LNS") { + _, err := gatewayAccess.DeleteAPIKey(ctx, &ttnpb.DeleteGatewayAPIKeyRequest{ + GatewayIds: ids, + KeyId: key.Id, + }, callOpt) + if err != nil { + logger.WithError(err).WithField("key_id", key.Id).Warn("Failed to delete API key") + // Continue deleting other keys. + } + } + } + + return nil +} + +// IsManagedGateway implements gateways.Claimer. +// This method always returns true. +func (*Upstream) IsManagedGateway(context.Context, types.EUI64) (bool, error) { + return true, nil +} diff --git a/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go b/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go new file mode 100644 index 0000000000..7a06d3a85b --- /dev/null +++ b/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go @@ -0,0 +1,59 @@ +// Copyright © 2025 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 lbscups provides functions to use The Things Gateway Controller LBS CUPS server. +package lbscups + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net" + + "go.thethings.network/lorawan-stack/v3/pkg/errors" +) + +var ( + errDialGatewayServer = errors.DefineAborted("dial_gateway_server", "dial Gateway Server `{address}`") + errVerifyGatewayServerTLS = errors.DefineAborted( + "verify_gateway_server_tls", "verify TLS server certificate of Gateway Server `{address}`", + ) +) + +func (u *Upstream) getRootCA(ctx context.Context, address string) (*x509.Certificate, error) { + d := new(net.Dialer) + netConn, err := d.DialContext(ctx, "tcp", address) + if err != nil { + return nil, errDialGatewayServer.WithAttributes("address", address).WithCause(err) + } + defer netConn.Close() + + tlsConfig, err := u.GetTLSClientConfig(ctx) + if err != nil { + return nil, err + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + tlsConfig.ServerName = host + tlsConn := tls.Client(netConn, tlsConfig) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return nil, errVerifyGatewayServerTLS.WithAttributes("address", address).WithCause(err) + } + + state := tlsConn.ConnectionState() + verifiedChain := state.VerifiedChains[0] + return verifiedChain[len(verifiedChain)-1], nil +} diff --git a/pkg/ttgc/config.go b/pkg/ttgc/config.go index 4e1dced72d..f32188129f 100644 --- a/pkg/ttgc/config.go +++ b/pkg/ttgc/config.go @@ -21,9 +21,11 @@ import ( // Config is the configuration for The Things Gateway Controller. type Config struct { - Enabled bool `name:"enabled" description:"Enable The Things Gateway Controller"` - GatewayEUIs []types.EUI64Prefix `name:"gateway-euis" description:"Gateway EUI prefixes that are managed by The Things Gateway Controller"` //nolint:lll - Address string `name:"address" description:"The address of The Things Gateway Controller"` - Domain string `name:"domain" description:"The domain of this cluster"` - TLS tlsconfig.ClientAuth `name:"tls" description:"TLS configuration"` + Enabled bool `name:"enabled" description:"Enable The Things Gateway Controller"` + GatewayEUIs []types.EUI64Prefix `name:"gateway-euis" description:"Gateway EUI prefixes that are managed by The Things Gateway Controller"` //nolint:lll + LBSCUPSEnabled bool `name:"lbs-cups-enabled" description:"Enable LBS CUPS protocol for gateway claiming"` + LBSCUPSGatewayEUIs []types.EUI64Prefix `name:"lbs-cups-gateway-euis" description:"Gateway EUI prefixes that are managed by LBS CUPS protocol"` //nolint:lll + Address string `name:"address" description:"The address of The Things Gateway Controller"` + Domain string `name:"domain" description:"The domain of this cluster"` + TLS tlsconfig.ClientAuth `name:"tls" description:"TLS configuration"` } From 139a01e32ad9853fb84dacf28e7d24055791e61f Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:22:35 +0200 Subject: [PATCH 4/8] util: Update the changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec7f097aba..da30ff07d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,12 @@ For details about compatibility between different releases, see the **Commitment ### Added +- TTGC LBS Root CUPS claimer. + ### Changed +- During the process of claiming a managed gateway, create the gateway in the registry before claiming it, not after. + ### Deprecated ### Removed From 457486f3e9e7e4437ad71d6b12a2dbcefbf9c090 Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:49:54 +0200 Subject: [PATCH 5/8] dcs: Fix code quality errors --- pkg/deviceclaimingserver/gateways/lbscups/lbscups.go | 2 +- pkg/deviceclaimingserver/gateways/lbscups/root_ca.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/deviceclaimingserver/gateways/lbscups/lbscups.go b/pkg/deviceclaimingserver/gateways/lbscups/lbscups.go index 63d42bcc72..17d8840a4a 100644 --- a/pkg/deviceclaimingserver/gateways/lbscups/lbscups.go +++ b/pkg/deviceclaimingserver/gateways/lbscups/lbscups.go @@ -1,4 +1,4 @@ -// Copyright © 2024 The Things Network Foundation, The Things Industries B.V. +// Copyright © 2026 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. diff --git a/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go b/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go index 7a06d3a85b..6f9d1e4273 100644 --- a/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go +++ b/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go @@ -1,4 +1,4 @@ -// Copyright © 2025 The Things Network Foundation, The Things Industries B.V. +// Copyright © 2026 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. From b143a7d9faada913ab132b8284a9462e55c5e307 Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:57:21 +0200 Subject: [PATCH 6/8] dcs: Add LBS CUPS unit tests --- .../gateways/lbscups/lbscups_test.go | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 pkg/deviceclaimingserver/gateways/lbscups/lbscups_test.go diff --git a/pkg/deviceclaimingserver/gateways/lbscups/lbscups_test.go b/pkg/deviceclaimingserver/gateways/lbscups/lbscups_test.go new file mode 100644 index 0000000000..33d52a657f --- /dev/null +++ b/pkg/deviceclaimingserver/gateways/lbscups/lbscups_test.go @@ -0,0 +1,443 @@ +// 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 lbscups + +import ( + "context" + "crypto/tls" + "fmt" + "strings" + "testing" + + "github.com/smarty/assertions" + "go.thethings.network/lorawan-stack/v3/pkg/cluster" + "go.thethings.network/lorawan-stack/v3/pkg/config/tlsconfig" + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "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/emptypb" +) + +var errTest = errors.DefineInternal("test", "test error") + +// testContext returns a context with authentication metadata for testing. +func testContext() context.Context { + return metadata.NewIncomingContext(test.Context(), metadata.Pairs("authorization", "Bearer test-token")) +} + +func TestCreateAPIKeys_Success(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), + } + + callCount := 0 + mockAccess := &mockGatewayAccessClient{ + createAPIKeyFunc: func(_ context.Context, req *ttnpb.CreateGatewayAPIKeyRequest, _ ...grpc.CallOption) (*ttnpb.APIKey, error) { + callCount++ + return &ttnpb.APIKey{ + Id: fmt.Sprintf("key-%d", callCount), + Key: fmt.Sprintf("secret-%d", callCount), + Name: req.Name, + Rights: req.Rights, + }, nil + }, + } + + upstream := &Upstream{ + component: &mockComponent{allowInsecureFunc: func() bool { return true }}, + gatewayAccess: mockAccess, + } + + cupsKey, lnsKey, err := upstream.createAPIKeys(testContext(), ids) + + a.So(err, should.BeNil) + a.So(cupsKey, should.NotBeNil) + a.So(cupsKey.Id, should.Equal, "key-1") + a.So(cupsKey.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_INFO) + a.So(cupsKey.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_SETTINGS_BASIC) + a.So(cupsKey.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_READ_SECRETS) + a.So(lnsKey, should.NotBeNil) + a.So(lnsKey.Id, should.Equal, "key-2") + a.So(lnsKey.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_LINK) + a.So(len(lnsKey.Rights), should.Equal, 1) +} + +func TestCreateAPIKeys_CUPSKeyCreationFails(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), + } + + mockAccess := &mockGatewayAccessClient{ + createAPIKeyFunc: func(_ context.Context, _ *ttnpb.CreateGatewayAPIKeyRequest, _ ...grpc.CallOption) (*ttnpb.APIKey, error) { + return nil, errTest.New() + }, + } + + upstream := &Upstream{ + component: &mockComponent{allowInsecureFunc: func() bool { return true }}, + gatewayAccess: mockAccess, + } + + _, _, err := upstream.createAPIKeys(testContext(), ids) + + a.So(errors.IsAborted(err), should.BeTrue) +} + +func TestCreateAPIKeys_LNSKeyCreationFails(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), + } + + callCount := 0 + mockAccess := &mockGatewayAccessClient{ + createAPIKeyFunc: func(_ context.Context, req *ttnpb.CreateGatewayAPIKeyRequest, _ ...grpc.CallOption) (*ttnpb.APIKey, error) { + callCount++ + if callCount == 1 { + return &ttnpb.APIKey{ + Id: "cups-key", + Key: "cups-secret", + Name: req.Name, + Rights: req.Rights, + }, nil + } + return nil, errTest.New() + }, + } + + upstream := &Upstream{ + component: &mockComponent{allowInsecureFunc: func() bool { return true }}, + gatewayAccess: mockAccess, + } + + _, _, err := upstream.createAPIKeys(testContext(), ids) + + a.So(errors.IsAborted(err), should.BeTrue) +} + +func TestCreateAPIKeys_VerifyRequestedRights(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), + } + + var requests []*ttnpb.CreateGatewayAPIKeyRequest + mockAccess := &mockGatewayAccessClient{ + createAPIKeyFunc: func(_ context.Context, req *ttnpb.CreateGatewayAPIKeyRequest, _ ...grpc.CallOption) (*ttnpb.APIKey, error) { + requests = append(requests, req) + return &ttnpb.APIKey{ + Id: "key", + Key: "secret", + Name: req.Name, + Rights: req.Rights, + }, nil + }, + } + + upstream := &Upstream{ + component: &mockComponent{allowInsecureFunc: func() bool { return true }}, + gatewayAccess: mockAccess, + } + + _, _, err := upstream.createAPIKeys(testContext(), ids) + + a.So(err, should.BeNil) + a.So(len(requests), should.Equal, 2) + + // First request should be CUPS key + cupsReq := requests[0] + a.So(strings.HasPrefix(cupsReq.Name, "LBS CUPS Key"), should.BeTrue) + a.So(cupsReq.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_INFO) + a.So(cupsReq.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_SETTINGS_BASIC) + a.So(cupsReq.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_READ_SECRETS) + a.So(len(cupsReq.Rights), should.Equal, 3) + + // Second request should be LNS key + lnsReq := requests[1] + a.So(strings.HasPrefix(lnsReq.Name, "LBS LNS Key"), should.BeTrue) + a.So(lnsReq.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_LINK) + a.So(len(lnsReq.Rights), should.Equal, 1) +} + +func TestDeleteAPIKeys_Success(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), + } + + existingKeys := []*ttnpb.APIKey{ + {Id: "cups-key-1", Name: "LBS CUPS Key (TTGC claim), generated 2024-01-01"}, + {Id: "lns-key-1", Name: "LBS LNS Key (TTGC claim), generated 2024-01-01"}, + {Id: "other-key", Name: "Some other key"}, + } + + var deletedKeyIDs []string + mockAccess := &mockGatewayAccessClient{ + listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { + return &ttnpb.APIKeys{ApiKeys: existingKeys}, nil + }, + deleteAPIKeyFunc: func(_ context.Context, req *ttnpb.DeleteGatewayAPIKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + deletedKeyIDs = append(deletedKeyIDs, req.KeyId) + return &emptypb.Empty{}, nil + }, + } + + upstream := &Upstream{ + component: &mockComponent{allowInsecureFunc: func() bool { return true }}, + gatewayAccess: mockAccess, + } + + err := upstream.deleteAPIKeys(testContext(), ids) + + a.So(err, should.BeNil) + a.So(len(deletedKeyIDs), should.Equal, 2) + a.So(deletedKeyIDs, should.Contain, "cups-key-1") + a.So(deletedKeyIDs, should.Contain, "lns-key-1") +} + +func TestDeleteAPIKeys_ListAPIKeysFails(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), + } + + mockAccess := &mockGatewayAccessClient{ + listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { + return nil, errTest.New() + }, + } + + upstream := &Upstream{ + component: &mockComponent{allowInsecureFunc: func() bool { return true }}, + gatewayAccess: mockAccess, + } + + err := upstream.deleteAPIKeys(testContext(), ids) + + a.So(errors.IsAborted(err), should.BeTrue) +} + +func TestDeleteAPIKeys_DeleteAPIKeyFailsContinues(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), + } + + existingKeys := []*ttnpb.APIKey{ + {Id: "cups-key-1", Name: "LBS CUPS Key"}, + {Id: "lns-key-1", Name: "LBS LNS Key"}, + } + + var deletedKeyIDs []string + callCount := 0 + mockAccess := &mockGatewayAccessClient{ + listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { + return &ttnpb.APIKeys{ApiKeys: existingKeys}, nil + }, + deleteAPIKeyFunc: func(_ context.Context, req *ttnpb.DeleteGatewayAPIKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + deletedKeyIDs = append(deletedKeyIDs, req.KeyId) + callCount++ + if callCount == 1 { + return nil, errTest.New() + } + return &emptypb.Empty{}, nil + }, + } + + upstream := &Upstream{ + component: &mockComponent{allowInsecureFunc: func() bool { return true }}, + gatewayAccess: mockAccess, + } + + err := upstream.deleteAPIKeys(testContext(), ids) + + a.So(err, should.BeNil) + // Both keys should be attempted to delete (even if first fails) + a.So(len(deletedKeyIDs), should.Equal, 2) +} + +func TestDeleteAPIKeys_SkipsEmptyNames(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), + } + + existingKeys := []*ttnpb.APIKey{ + {Id: "empty-name-key", Name: ""}, + {Id: "cups-key-1", Name: "LBS CUPS Key"}, + } + + var deletedKeyIDs []string + mockAccess := &mockGatewayAccessClient{ + listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { + return &ttnpb.APIKeys{ApiKeys: existingKeys}, nil + }, + deleteAPIKeyFunc: func(_ context.Context, req *ttnpb.DeleteGatewayAPIKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + deletedKeyIDs = append(deletedKeyIDs, req.KeyId) + return &emptypb.Empty{}, nil + }, + } + + upstream := &Upstream{ + component: &mockComponent{allowInsecureFunc: func() bool { return true }}, + gatewayAccess: mockAccess, + } + + err := upstream.deleteAPIKeys(testContext(), ids) + + a.So(err, should.BeNil) + a.So(len(deletedKeyIDs), should.Equal, 1) + a.So(deletedKeyIDs, should.Contain, "cups-key-1") + a.So(deletedKeyIDs, should.NotContain, "empty-name-key") +} + +func TestDeleteAPIKeys_SkipsNonLBSKeys(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + ids := &ttnpb.GatewayIdentifiers{ + Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), + } + + existingKeys := []*ttnpb.APIKey{ + {Id: "other-key-1", Name: "Console generated key"}, + {Id: "other-key-2", Name: "CLI generated key"}, + {Id: "cups-key", Name: "LBS CUPS Key"}, + } + + var deletedKeyIDs []string + mockAccess := &mockGatewayAccessClient{ + listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { + return &ttnpb.APIKeys{ApiKeys: existingKeys}, nil + }, + deleteAPIKeyFunc: func(_ context.Context, req *ttnpb.DeleteGatewayAPIKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { + deletedKeyIDs = append(deletedKeyIDs, req.KeyId) + return &emptypb.Empty{}, nil + }, + } + + upstream := &Upstream{ + component: &mockComponent{allowInsecureFunc: func() bool { return true }}, + gatewayAccess: mockAccess, + } + + err := upstream.deleteAPIKeys(testContext(), ids) + + a.So(err, should.BeNil) + a.So(len(deletedKeyIDs), should.Equal, 1) + a.So(deletedKeyIDs, should.Contain, "cups-key") +} + +// mockGatewayAccessClient implements ttnpb.GatewayAccessClient for testing. +type mockGatewayAccessClient struct { + ttnpb.GatewayAccessClient + + createAPIKeyFunc func( + ctx context.Context, + req *ttnpb.CreateGatewayAPIKeyRequest, + opts ...grpc.CallOption, + ) (*ttnpb.APIKey, error) + listAPIKeysFunc func( + ctx context.Context, + req *ttnpb.ListGatewayAPIKeysRequest, + opts ...grpc.CallOption, + ) (*ttnpb.APIKeys, error) + deleteAPIKeyFunc func( + ctx context.Context, + req *ttnpb.DeleteGatewayAPIKeyRequest, + opts ...grpc.CallOption, + ) (*emptypb.Empty, error) +} + +func (m *mockGatewayAccessClient) CreateAPIKey( + ctx context.Context, + req *ttnpb.CreateGatewayAPIKeyRequest, + opts ...grpc.CallOption, +) (*ttnpb.APIKey, error) { + if m.createAPIKeyFunc != nil { + return m.createAPIKeyFunc(ctx, req, opts...) + } + return nil, nil +} + +func (m *mockGatewayAccessClient) ListAPIKeys( + ctx context.Context, + req *ttnpb.ListGatewayAPIKeysRequest, + opts ...grpc.CallOption, +) (*ttnpb.APIKeys, error) { + if m.listAPIKeysFunc != nil { + return m.listAPIKeysFunc(ctx, req, opts...) + } + return nil, nil +} + +func (m *mockGatewayAccessClient) DeleteAPIKey( + ctx context.Context, + req *ttnpb.DeleteGatewayAPIKeyRequest, + opts ...grpc.CallOption, +) (*emptypb.Empty, error) { + if m.deleteAPIKeyFunc != nil { + return m.deleteAPIKeyFunc(ctx, req, opts...) + } + return nil, nil +} + +// mockComponent implements the component interface for testing. +type mockComponent struct { + allowInsecureFunc func() bool +} + +func (m *mockComponent) GetTLSConfig(context.Context) tlsconfig.Config { + return tlsconfig.Config{} +} + +func (m *mockComponent) GetTLSClientConfig(context.Context, ...tlsconfig.Option) (*tls.Config, error) { + return nil, nil +} + +func (m *mockComponent) GetPeerConn( + context.Context, ttnpb.ClusterRole, cluster.EntityIdentifiers, +) (*grpc.ClientConn, error) { + return nil, nil +} + +func (m *mockComponent) AllowInsecureForCredentials() bool { + if m.allowInsecureFunc != nil { + return m.allowInsecureFunc() + } + return true +} From ae23edb0437d81752b779283edd15e4fad7355a9 Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:28:20 +0200 Subject: [PATCH 7/8] dcs: Drop the separate lbs claimer, update the ttgc claimer --- CHANGELOG.md | 2 +- pkg/deviceclaimingserver/gateways/gateways.go | 21 +- .../gateways/lbscups/lbscups_test.go | 443 ------------------ .../gateways/lbscups/root_ca.go | 59 --- .../gateways/{lbscups => ttgc}/lbscups.go | 85 +--- .../gateways/ttgc/ttgc.go | 181 ++----- .../gateways/ttgc/ttiv1.go | 189 ++++++++ pkg/ttgc/config.go | 12 +- 8 files changed, 239 insertions(+), 753 deletions(-) delete mode 100644 pkg/deviceclaimingserver/gateways/lbscups/lbscups_test.go delete mode 100644 pkg/deviceclaimingserver/gateways/lbscups/root_ca.go rename pkg/deviceclaimingserver/gateways/{lbscups => ttgc}/lbscups.go (76%) create mode 100644 pkg/deviceclaimingserver/gateways/ttgc/ttiv1.go diff --git a/CHANGELOG.md b/CHANGELOG.md index da30ff07d4..f24566c933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ For details about compatibility between different releases, see the **Commitment ### Added -- TTGC LBS Root CUPS claimer. +- TTGC LBS Root CUPS claiming support. ### Changed diff --git a/pkg/deviceclaimingserver/gateways/gateways.go b/pkg/deviceclaimingserver/gateways/gateways.go index e56036b8cd..919995f679 100644 --- a/pkg/deviceclaimingserver/gateways/gateways.go +++ b/pkg/deviceclaimingserver/gateways/gateways.go @@ -23,7 +23,6 @@ import ( "go.thethings.network/lorawan-stack/v3/pkg/cluster" "go.thethings.network/lorawan-stack/v3/pkg/config" "go.thethings.network/lorawan-stack/v3/pkg/config/tlsconfig" - "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/gateways/lbscups" "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/gateways/ttgc" dcstypes "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/types" "go.thethings.network/lorawan-stack/v3/pkg/errors" @@ -49,9 +48,8 @@ type Config struct { } var ( - errInvalidUpstream = errors.DefineInvalidArgument("invalid_upstream", "upstream `{name}` is invalid") - errTTGCNotEnabled = errors.DefineFailedPrecondition("ttgc_not_enabled", "TTGC is not enabled") - errLBSCUPSNotEnabled = errors.DefineFailedPrecondition("lbs_cups_not_enabled", "TTGC LBS CUPS is not enabled") + errInvalidUpstream = errors.DefineInvalidArgument("invalid_upstream", "upstream `{name}` is invalid") + errTTGCNotEnabled = errors.DefineFailedPrecondition("ttgc_not_enabled", "TTGC is not enabled") ) // ParseGatewayEUIRanges parses the configured upstream map and returns map of ranges. @@ -141,13 +139,6 @@ func NewUpstream( } hosts["ttgc"] = ttgcRanges } - if _, lbscupsAdded := hosts["lbs-cups"]; ttgcConf.LBSCUPSEnabled && !lbscupsAdded { - lbscupsRanges := make([]dcstypes.EUI64Range, len(ttgcConf.LBSCUPSGatewayEUIs)) - for i, prefix := range ttgcConf.LBSCUPSGatewayEUIs { - lbscupsRanges[i] = dcstypes.RangeFromEUI64Prefix(prefix) - } - hosts["lbs-cups"] = lbscupsRanges - } // Setup upstream table. for name, ranges := range hosts { @@ -164,14 +155,6 @@ func NewUpstream( if err != nil { return nil, err } - case "lbs-cups": - if !ttgcConf.LBSCUPSEnabled { - return nil, errLBSCUPSNotEnabled.New() - } - claimer, err = lbscups.New(ctx, c, ttgcConf) - if err != nil { - return nil, err - } default: return nil, errInvalidUpstream.WithAttributes("name", name) } diff --git a/pkg/deviceclaimingserver/gateways/lbscups/lbscups_test.go b/pkg/deviceclaimingserver/gateways/lbscups/lbscups_test.go deleted file mode 100644 index 33d52a657f..0000000000 --- a/pkg/deviceclaimingserver/gateways/lbscups/lbscups_test.go +++ /dev/null @@ -1,443 +0,0 @@ -// 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 lbscups - -import ( - "context" - "crypto/tls" - "fmt" - "strings" - "testing" - - "github.com/smarty/assertions" - "go.thethings.network/lorawan-stack/v3/pkg/cluster" - "go.thethings.network/lorawan-stack/v3/pkg/config/tlsconfig" - "go.thethings.network/lorawan-stack/v3/pkg/errors" - "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/emptypb" -) - -var errTest = errors.DefineInternal("test", "test error") - -// testContext returns a context with authentication metadata for testing. -func testContext() context.Context { - return metadata.NewIncomingContext(test.Context(), metadata.Pairs("authorization", "Bearer test-token")) -} - -func TestCreateAPIKeys_Success(t *testing.T) { - t.Parallel() - a := assertions.New(t) - - ids := &ttnpb.GatewayIdentifiers{ - Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), - } - - callCount := 0 - mockAccess := &mockGatewayAccessClient{ - createAPIKeyFunc: func(_ context.Context, req *ttnpb.CreateGatewayAPIKeyRequest, _ ...grpc.CallOption) (*ttnpb.APIKey, error) { - callCount++ - return &ttnpb.APIKey{ - Id: fmt.Sprintf("key-%d", callCount), - Key: fmt.Sprintf("secret-%d", callCount), - Name: req.Name, - Rights: req.Rights, - }, nil - }, - } - - upstream := &Upstream{ - component: &mockComponent{allowInsecureFunc: func() bool { return true }}, - gatewayAccess: mockAccess, - } - - cupsKey, lnsKey, err := upstream.createAPIKeys(testContext(), ids) - - a.So(err, should.BeNil) - a.So(cupsKey, should.NotBeNil) - a.So(cupsKey.Id, should.Equal, "key-1") - a.So(cupsKey.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_INFO) - a.So(cupsKey.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_SETTINGS_BASIC) - a.So(cupsKey.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_READ_SECRETS) - a.So(lnsKey, should.NotBeNil) - a.So(lnsKey.Id, should.Equal, "key-2") - a.So(lnsKey.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_LINK) - a.So(len(lnsKey.Rights), should.Equal, 1) -} - -func TestCreateAPIKeys_CUPSKeyCreationFails(t *testing.T) { - t.Parallel() - a := assertions.New(t) - - ids := &ttnpb.GatewayIdentifiers{ - Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), - } - - mockAccess := &mockGatewayAccessClient{ - createAPIKeyFunc: func(_ context.Context, _ *ttnpb.CreateGatewayAPIKeyRequest, _ ...grpc.CallOption) (*ttnpb.APIKey, error) { - return nil, errTest.New() - }, - } - - upstream := &Upstream{ - component: &mockComponent{allowInsecureFunc: func() bool { return true }}, - gatewayAccess: mockAccess, - } - - _, _, err := upstream.createAPIKeys(testContext(), ids) - - a.So(errors.IsAborted(err), should.BeTrue) -} - -func TestCreateAPIKeys_LNSKeyCreationFails(t *testing.T) { - t.Parallel() - a := assertions.New(t) - - ids := &ttnpb.GatewayIdentifiers{ - Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), - } - - callCount := 0 - mockAccess := &mockGatewayAccessClient{ - createAPIKeyFunc: func(_ context.Context, req *ttnpb.CreateGatewayAPIKeyRequest, _ ...grpc.CallOption) (*ttnpb.APIKey, error) { - callCount++ - if callCount == 1 { - return &ttnpb.APIKey{ - Id: "cups-key", - Key: "cups-secret", - Name: req.Name, - Rights: req.Rights, - }, nil - } - return nil, errTest.New() - }, - } - - upstream := &Upstream{ - component: &mockComponent{allowInsecureFunc: func() bool { return true }}, - gatewayAccess: mockAccess, - } - - _, _, err := upstream.createAPIKeys(testContext(), ids) - - a.So(errors.IsAborted(err), should.BeTrue) -} - -func TestCreateAPIKeys_VerifyRequestedRights(t *testing.T) { - t.Parallel() - a := assertions.New(t) - - ids := &ttnpb.GatewayIdentifiers{ - Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), - } - - var requests []*ttnpb.CreateGatewayAPIKeyRequest - mockAccess := &mockGatewayAccessClient{ - createAPIKeyFunc: func(_ context.Context, req *ttnpb.CreateGatewayAPIKeyRequest, _ ...grpc.CallOption) (*ttnpb.APIKey, error) { - requests = append(requests, req) - return &ttnpb.APIKey{ - Id: "key", - Key: "secret", - Name: req.Name, - Rights: req.Rights, - }, nil - }, - } - - upstream := &Upstream{ - component: &mockComponent{allowInsecureFunc: func() bool { return true }}, - gatewayAccess: mockAccess, - } - - _, _, err := upstream.createAPIKeys(testContext(), ids) - - a.So(err, should.BeNil) - a.So(len(requests), should.Equal, 2) - - // First request should be CUPS key - cupsReq := requests[0] - a.So(strings.HasPrefix(cupsReq.Name, "LBS CUPS Key"), should.BeTrue) - a.So(cupsReq.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_INFO) - a.So(cupsReq.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_SETTINGS_BASIC) - a.So(cupsReq.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_READ_SECRETS) - a.So(len(cupsReq.Rights), should.Equal, 3) - - // Second request should be LNS key - lnsReq := requests[1] - a.So(strings.HasPrefix(lnsReq.Name, "LBS LNS Key"), should.BeTrue) - a.So(lnsReq.Rights, should.Contain, ttnpb.Right_RIGHT_GATEWAY_LINK) - a.So(len(lnsReq.Rights), should.Equal, 1) -} - -func TestDeleteAPIKeys_Success(t *testing.T) { - t.Parallel() - a := assertions.New(t) - - ids := &ttnpb.GatewayIdentifiers{ - Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), - } - - existingKeys := []*ttnpb.APIKey{ - {Id: "cups-key-1", Name: "LBS CUPS Key (TTGC claim), generated 2024-01-01"}, - {Id: "lns-key-1", Name: "LBS LNS Key (TTGC claim), generated 2024-01-01"}, - {Id: "other-key", Name: "Some other key"}, - } - - var deletedKeyIDs []string - mockAccess := &mockGatewayAccessClient{ - listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { - return &ttnpb.APIKeys{ApiKeys: existingKeys}, nil - }, - deleteAPIKeyFunc: func(_ context.Context, req *ttnpb.DeleteGatewayAPIKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { - deletedKeyIDs = append(deletedKeyIDs, req.KeyId) - return &emptypb.Empty{}, nil - }, - } - - upstream := &Upstream{ - component: &mockComponent{allowInsecureFunc: func() bool { return true }}, - gatewayAccess: mockAccess, - } - - err := upstream.deleteAPIKeys(testContext(), ids) - - a.So(err, should.BeNil) - a.So(len(deletedKeyIDs), should.Equal, 2) - a.So(deletedKeyIDs, should.Contain, "cups-key-1") - a.So(deletedKeyIDs, should.Contain, "lns-key-1") -} - -func TestDeleteAPIKeys_ListAPIKeysFails(t *testing.T) { - t.Parallel() - a := assertions.New(t) - - ids := &ttnpb.GatewayIdentifiers{ - Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), - } - - mockAccess := &mockGatewayAccessClient{ - listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { - return nil, errTest.New() - }, - } - - upstream := &Upstream{ - component: &mockComponent{allowInsecureFunc: func() bool { return true }}, - gatewayAccess: mockAccess, - } - - err := upstream.deleteAPIKeys(testContext(), ids) - - a.So(errors.IsAborted(err), should.BeTrue) -} - -func TestDeleteAPIKeys_DeleteAPIKeyFailsContinues(t *testing.T) { - t.Parallel() - a := assertions.New(t) - - ids := &ttnpb.GatewayIdentifiers{ - Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), - } - - existingKeys := []*ttnpb.APIKey{ - {Id: "cups-key-1", Name: "LBS CUPS Key"}, - {Id: "lns-key-1", Name: "LBS LNS Key"}, - } - - var deletedKeyIDs []string - callCount := 0 - mockAccess := &mockGatewayAccessClient{ - listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { - return &ttnpb.APIKeys{ApiKeys: existingKeys}, nil - }, - deleteAPIKeyFunc: func(_ context.Context, req *ttnpb.DeleteGatewayAPIKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { - deletedKeyIDs = append(deletedKeyIDs, req.KeyId) - callCount++ - if callCount == 1 { - return nil, errTest.New() - } - return &emptypb.Empty{}, nil - }, - } - - upstream := &Upstream{ - component: &mockComponent{allowInsecureFunc: func() bool { return true }}, - gatewayAccess: mockAccess, - } - - err := upstream.deleteAPIKeys(testContext(), ids) - - a.So(err, should.BeNil) - // Both keys should be attempted to delete (even if first fails) - a.So(len(deletedKeyIDs), should.Equal, 2) -} - -func TestDeleteAPIKeys_SkipsEmptyNames(t *testing.T) { - t.Parallel() - a := assertions.New(t) - - ids := &ttnpb.GatewayIdentifiers{ - Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), - } - - existingKeys := []*ttnpb.APIKey{ - {Id: "empty-name-key", Name: ""}, - {Id: "cups-key-1", Name: "LBS CUPS Key"}, - } - - var deletedKeyIDs []string - mockAccess := &mockGatewayAccessClient{ - listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { - return &ttnpb.APIKeys{ApiKeys: existingKeys}, nil - }, - deleteAPIKeyFunc: func(_ context.Context, req *ttnpb.DeleteGatewayAPIKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { - deletedKeyIDs = append(deletedKeyIDs, req.KeyId) - return &emptypb.Empty{}, nil - }, - } - - upstream := &Upstream{ - component: &mockComponent{allowInsecureFunc: func() bool { return true }}, - gatewayAccess: mockAccess, - } - - err := upstream.deleteAPIKeys(testContext(), ids) - - a.So(err, should.BeNil) - a.So(len(deletedKeyIDs), should.Equal, 1) - a.So(deletedKeyIDs, should.Contain, "cups-key-1") - a.So(deletedKeyIDs, should.NotContain, "empty-name-key") -} - -func TestDeleteAPIKeys_SkipsNonLBSKeys(t *testing.T) { - t.Parallel() - a := assertions.New(t) - - ids := &ttnpb.GatewayIdentifiers{ - Eui: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x01}.Bytes(), - } - - existingKeys := []*ttnpb.APIKey{ - {Id: "other-key-1", Name: "Console generated key"}, - {Id: "other-key-2", Name: "CLI generated key"}, - {Id: "cups-key", Name: "LBS CUPS Key"}, - } - - var deletedKeyIDs []string - mockAccess := &mockGatewayAccessClient{ - listAPIKeysFunc: func(_ context.Context, _ *ttnpb.ListGatewayAPIKeysRequest, _ ...grpc.CallOption) (*ttnpb.APIKeys, error) { - return &ttnpb.APIKeys{ApiKeys: existingKeys}, nil - }, - deleteAPIKeyFunc: func(_ context.Context, req *ttnpb.DeleteGatewayAPIKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { - deletedKeyIDs = append(deletedKeyIDs, req.KeyId) - return &emptypb.Empty{}, nil - }, - } - - upstream := &Upstream{ - component: &mockComponent{allowInsecureFunc: func() bool { return true }}, - gatewayAccess: mockAccess, - } - - err := upstream.deleteAPIKeys(testContext(), ids) - - a.So(err, should.BeNil) - a.So(len(deletedKeyIDs), should.Equal, 1) - a.So(deletedKeyIDs, should.Contain, "cups-key") -} - -// mockGatewayAccessClient implements ttnpb.GatewayAccessClient for testing. -type mockGatewayAccessClient struct { - ttnpb.GatewayAccessClient - - createAPIKeyFunc func( - ctx context.Context, - req *ttnpb.CreateGatewayAPIKeyRequest, - opts ...grpc.CallOption, - ) (*ttnpb.APIKey, error) - listAPIKeysFunc func( - ctx context.Context, - req *ttnpb.ListGatewayAPIKeysRequest, - opts ...grpc.CallOption, - ) (*ttnpb.APIKeys, error) - deleteAPIKeyFunc func( - ctx context.Context, - req *ttnpb.DeleteGatewayAPIKeyRequest, - opts ...grpc.CallOption, - ) (*emptypb.Empty, error) -} - -func (m *mockGatewayAccessClient) CreateAPIKey( - ctx context.Context, - req *ttnpb.CreateGatewayAPIKeyRequest, - opts ...grpc.CallOption, -) (*ttnpb.APIKey, error) { - if m.createAPIKeyFunc != nil { - return m.createAPIKeyFunc(ctx, req, opts...) - } - return nil, nil -} - -func (m *mockGatewayAccessClient) ListAPIKeys( - ctx context.Context, - req *ttnpb.ListGatewayAPIKeysRequest, - opts ...grpc.CallOption, -) (*ttnpb.APIKeys, error) { - if m.listAPIKeysFunc != nil { - return m.listAPIKeysFunc(ctx, req, opts...) - } - return nil, nil -} - -func (m *mockGatewayAccessClient) DeleteAPIKey( - ctx context.Context, - req *ttnpb.DeleteGatewayAPIKeyRequest, - opts ...grpc.CallOption, -) (*emptypb.Empty, error) { - if m.deleteAPIKeyFunc != nil { - return m.deleteAPIKeyFunc(ctx, req, opts...) - } - return nil, nil -} - -// mockComponent implements the component interface for testing. -type mockComponent struct { - allowInsecureFunc func() bool -} - -func (m *mockComponent) GetTLSConfig(context.Context) tlsconfig.Config { - return tlsconfig.Config{} -} - -func (m *mockComponent) GetTLSClientConfig(context.Context, ...tlsconfig.Option) (*tls.Config, error) { - return nil, nil -} - -func (m *mockComponent) GetPeerConn( - context.Context, ttnpb.ClusterRole, cluster.EntityIdentifiers, -) (*grpc.ClientConn, error) { - return nil, nil -} - -func (m *mockComponent) AllowInsecureForCredentials() bool { - if m.allowInsecureFunc != nil { - return m.allowInsecureFunc() - } - return true -} diff --git a/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go b/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go deleted file mode 100644 index 6f9d1e4273..0000000000 --- a/pkg/deviceclaimingserver/gateways/lbscups/root_ca.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright © 2026 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 lbscups provides functions to use The Things Gateway Controller LBS CUPS server. -package lbscups - -import ( - "context" - "crypto/tls" - "crypto/x509" - "net" - - "go.thethings.network/lorawan-stack/v3/pkg/errors" -) - -var ( - errDialGatewayServer = errors.DefineAborted("dial_gateway_server", "dial Gateway Server `{address}`") - errVerifyGatewayServerTLS = errors.DefineAborted( - "verify_gateway_server_tls", "verify TLS server certificate of Gateway Server `{address}`", - ) -) - -func (u *Upstream) getRootCA(ctx context.Context, address string) (*x509.Certificate, error) { - d := new(net.Dialer) - netConn, err := d.DialContext(ctx, "tcp", address) - if err != nil { - return nil, errDialGatewayServer.WithAttributes("address", address).WithCause(err) - } - defer netConn.Close() - - tlsConfig, err := u.GetTLSClientConfig(ctx) - if err != nil { - return nil, err - } - host, _, err := net.SplitHostPort(address) - if err != nil { - return nil, err - } - tlsConfig.ServerName = host - tlsConn := tls.Client(netConn, tlsConfig) - if err := tlsConn.HandshakeContext(ctx); err != nil { - return nil, errVerifyGatewayServerTLS.WithAttributes("address", address).WithCause(err) - } - - state := tlsConn.ConnectionState() - verifiedChain := state.VerifiedChains[0] - return verifiedChain[len(verifiedChain)-1], nil -} diff --git a/pkg/deviceclaimingserver/gateways/lbscups/lbscups.go b/pkg/deviceclaimingserver/gateways/ttgc/lbscups.go similarity index 76% rename from pkg/deviceclaimingserver/gateways/lbscups/lbscups.go rename to pkg/deviceclaimingserver/gateways/ttgc/lbscups.go index 17d8840a4a..4d79613f80 100644 --- a/pkg/deviceclaimingserver/gateways/lbscups/lbscups.go +++ b/pkg/deviceclaimingserver/gateways/ttgc/lbscups.go @@ -12,70 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package lbscups provides functions to claim gateways using LBS CUPS protocol. -package lbscups +package ttgc import ( "bytes" "context" - "crypto/tls" "fmt" "net" "time" northboundv1 "go.thethings.industries/pkg/api/gen/tti/gateway/controller/northbound/v1" - "go.thethings.network/lorawan-stack/v3/pkg/cluster" - "go.thethings.network/lorawan-stack/v3/pkg/config/tlsconfig" dcstypes "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/types" "go.thethings.network/lorawan-stack/v3/pkg/errors" "go.thethings.network/lorawan-stack/v3/pkg/log" "go.thethings.network/lorawan-stack/v3/pkg/rpcmetadata" - "go.thethings.network/lorawan-stack/v3/pkg/ttgc" "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" "go.thethings.network/lorawan-stack/v3/pkg/types" - "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) -const profileGroup = "tts" - -type component interface { - GetTLSConfig(context.Context) tlsconfig.Config - GetTLSClientConfig(context.Context, ...tlsconfig.Option) (*tls.Config, error) - GetPeerConn(ctx context.Context, role ttnpb.ClusterRole, ids cluster.EntityIdentifiers) (*grpc.ClientConn, error) - AllowInsecureForCredentials() bool -} - -// Upstream is the client for LBS CUPS gateway claiming. -type Upstream struct { - component - client *ttgc.Client - - gatewayAccess ttnpb.GatewayAccessClient -} - -// New returns a new upstream client for LBS CUPS gateway claiming. -func New(ctx context.Context, c component, config ttgc.Config) (*Upstream, error) { - client, err := ttgc.NewClient(ctx, c, config) - if err != nil { - return nil, err - } - upstream := &Upstream{ - component: c, - client: client, - } - return upstream, nil -} - -var errCreateAPIKey = errors.DefineAborted("create_api_key", "create API key") +var ( + errCreateAPIKey = errors.DefineFailedPrecondition("create_api_key", "failed to create API key for gateway") + errDeleteAPIKey = errors.DefineAborted("delete_api_key", "delete API key") +) -// Claim implements gateways.Claimer. -// Claim does the following: -// 1. Create CUPS and LNS API keys for the gateway -// 2. Claim the gateway on TTGC with the CUPS key as the gateway token -// 3. Return the LNS key in GatewayMetadata -func (u *Upstream) Claim( +func (u *Upstream) claimLBSCUPSGateway( ctx context.Context, eui types.EUI64, ownerToken, clusterAddress string, ) (*dcstypes.GatewayMetadata, error) { logger := log.FromContext(ctx) @@ -241,35 +203,6 @@ func (u *Upstream) getGatewayAccess(ctx context.Context) (ttnpb.GatewayAccessCli return ttnpb.NewGatewayAccessClient(conn), nil } -// Unclaim implements gateways.Claimer. -// Unclaim revokes the API keys and unclaims the gateway on TTGC. -func (u *Upstream) Unclaim(ctx context.Context, eui types.EUI64) error { - ids := &ttnpb.GatewayIdentifiers{ - Eui: eui.Bytes(), - } - - if err := u.deleteAPIKeys(ctx, ids); err != nil { - return err - } - - // Unclaim the gateway on TTGC. - gtwClient := northboundv1.NewGatewayServiceClient(u.client) - _, err := gtwClient.Unclaim(ctx, &northboundv1.GatewayServiceUnclaimRequest{ - GatewayId: eui.MarshalNumber(), - Domain: u.client.Domain(ctx), - }) - if err != nil { - if errors.IsNotFound(err) { - // The gateway does not exist or is already unclaimed. - return nil - } - return err - } - return nil -} - -var errDeleteAPIKey = errors.DefineAborted("delete_api_key", "delete API key") - // deleteAPIKeys deletes the CUPS and LNS API keys for the gateway. func (u *Upstream) deleteAPIKeys(ctx context.Context, ids *ttnpb.GatewayIdentifiers) error { logger := log.FromContext(ctx) @@ -312,9 +245,3 @@ func (u *Upstream) deleteAPIKeys(ctx context.Context, ids *ttnpb.GatewayIdentifi return nil } - -// IsManagedGateway implements gateways.Claimer. -// This method always returns true. -func (*Upstream) IsManagedGateway(context.Context, types.EUI64) (bool, error) { - return true, nil -} diff --git a/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go b/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go index 8b35fbd695..e5a76d849f 100644 --- a/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go +++ b/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go @@ -16,12 +16,11 @@ package ttgc import ( - "bytes" "context" "crypto/tls" - "net" northboundv1 "go.thethings.industries/pkg/api/gen/tti/gateway/controller/northbound/v1" + "go.thethings.network/lorawan-stack/v3/pkg/cluster" "go.thethings.network/lorawan-stack/v3/pkg/config/tlsconfig" dcstypes "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/types" "go.thethings.network/lorawan-stack/v3/pkg/errors" @@ -29,25 +28,33 @@ import ( "go.thethings.network/lorawan-stack/v3/pkg/ttgc" "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" "go.thethings.network/lorawan-stack/v3/pkg/types" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/grpc" +) + +var ( + errNoSupportedProtocol = errors.DefineFailedPrecondition("no_supported_protocol", "no supported gateway protocol found for claiming") + errNoSupportedAuthMethod = errors.DefineFailedPrecondition("no_supported_auth_method", "no supported authentication method found for gateway") ) const profileGroup = "tts" type component interface { + GetTLSConfig(context.Context) tlsconfig.Config GetTLSClientConfig(context.Context, ...tlsconfig.Option) (*tls.Config, error) + GetPeerConn(ctx context.Context, role ttnpb.ClusterRole, ids cluster.EntityIdentifiers) (*grpc.ClientConn, error) + AllowInsecureForCredentials() bool } // Upstream is the client for The Things Gateway Controller. type Upstream struct { component client *ttgc.Client + + gatewayAccess ttnpb.GatewayAccessClient } // New returns a new upstream client for The Things Gateway Controller. -func New(ctx context.Context, c ttgc.Component, config ttgc.Config) (*Upstream, error) { +func New(ctx context.Context, c component, config ttgc.Config) (*Upstream, error) { client, err := ttgc.NewClient(ctx, c, config) if err != nil { return nil, err @@ -59,166 +66,50 @@ func New(ctx context.Context, c ttgc.Component, config ttgc.Config) (*Upstream, } // Claim implements gateways.GatewayClaimer. -// Claim does four things: -// 1. Claim the gateway -// 2. Upsert a LoRa Packet Forwarder profile with the root CA presented by the given Gateway Server -// 3. Upsert a Geolocation profile -// 4. Update the gateway with the profiles func (u *Upstream) Claim( ctx context.Context, eui types.EUI64, ownerToken, clusterAddress string, ) (*dcstypes.GatewayMetadata, error) { - logger := log.FromContext(ctx) - - // Claim the gateway. + // Get the gateway description to verify what protocol it supports. gtwClient := northboundv1.NewGatewayServiceClient(u.client) - _, err := gtwClient.Claim(ctx, &northboundv1.GatewayServiceClaimRequest{ - GatewayId: eui.MarshalNumber(), - Domain: u.client.Domain(ctx), - OwnerToken: ownerToken, + desc, err := gtwClient.Describe(ctx, &northboundv1.GatewayServiceDescribeRequest{ + GatewayId: eui.MarshalNumber(), }) if err != nil { return nil, err } - // Get the root CA from the Gateway Server and upsert the LoRa Packet Forwarder profile. - host, _, err := net.SplitHostPort(clusterAddress) - if err != nil { - host = clusterAddress - } - clusterAddress = net.JoinHostPort(host, "8889") - rootCA, err := u.getRootCA(ctx, clusterAddress) - if err != nil { - return nil, err - } - var ( - loraPFProfileID []byte - loraPFProfile = &northboundv1.LoraPacketForwarderProfile{ - ProfileName: clusterAddress, - Shared: true, - Protocol: northboundv1.LoraPacketForwarderProtocol_LORA_PACKET_FORWARDER_PROTOCOL_TTI_V1, - Address: clusterAddress, - RootCa: rootCA.Raw, - } - loraPFProfileClient = northboundv1.NewLoraPacketForwarderProfileServiceClient(u.client) - ) - loraPFGetRes, err := loraPFProfileClient.GetByName( - ctx, - &northboundv1.LoraPacketForwarderProfileServiceGetByNameRequest{ - Domain: u.client.Domain(ctx), - Group: profileGroup, - ProfileName: clusterAddress, - }, - ) - if err != nil { - if status.Code(err) != codes.NotFound { - logger.WithError(err).Warn("Failed to get LoRa Packet Forwarder profile") - return nil, err - } - res, err := loraPFProfileClient.Create(ctx, &northboundv1.LoraPacketForwarderProfileServiceCreateRequest{ - Domain: u.client.Domain(ctx), - Group: profileGroup, - LoraPacketForwarderProfile: loraPFProfile, - }) - if err != nil { - logger.WithError(err).Warn("Failed to create LoRa Packet Forwarder profile") - return nil, err - } - loraPFProfileID = res.ProfileId - } else { - if profile := loraPFGetRes.LoraPacketForwarderProfile; profile.Shared != loraPFProfile.Shared || - profile.Protocol != loraPFProfile.Protocol || - !bytes.Equal(profile.RootCa, loraPFProfile.RootCa) { - _, err := loraPFProfileClient.Update(ctx, &northboundv1.LoraPacketForwarderProfileServiceUpdateRequest{ - Domain: u.client.Domain(ctx), - Group: profileGroup, - ProfileId: loraPFGetRes.ProfileId, - LoraPacketForwarderProfile: loraPFProfile, - }) - if err != nil { - logger.WithError(err).Warn("Failed to update LoRa Packet Forwarder profile") - return nil, err - } - } - loraPFProfileID = loraPFGetRes.ProfileId + if u.supportsProtocol(desc, northboundv1.GatewayProtocolIdentifier_GATEWAY_PROTOCOL_TTI_V1) { + return u.claimTTIV1Gateway(ctx, eui, ownerToken, clusterAddress) } - // Upsert the Geolocation profile. - var ( - geolocationProfileID []byte - geolocationProfile = &northboundv1.GeolocationProfile{ - ProfileName: "on connect", - Shared: true, - DisconnectedFor: durationpb.New(0), - } - geolocationProfileClient = northboundv1.NewGeolocationProfileServiceClient(u.client) - ) - geolocationGetRes, err := geolocationProfileClient.GetByName( - ctx, - &northboundv1.GeolocationProfileServiceGetByNameRequest{ - Domain: u.client.Domain(ctx), - Group: profileGroup, - ProfileName: geolocationProfile.ProfileName, - }, - ) - if err != nil { - if status.Code(err) != codes.NotFound { - logger.WithError(err).Warn("Failed to get geolocation profile") - return nil, err - } - res, err := geolocationProfileClient.Create(ctx, &northboundv1.GeolocationProfileServiceCreateRequest{ - Domain: u.client.Domain(ctx), - Group: profileGroup, - GeolocationProfile: geolocationProfile, - }) - if err != nil { - logger.WithError(err).Warn("Failed to create geolocation profile") - return nil, err - } - geolocationProfileID = res.ProfileId - } else { - geolocationProfileID = geolocationGetRes.ProfileId + if u.supportsProtocol(desc, northboundv1.GatewayProtocolIdentifier_GATEWAY_PROTOCOL_LBS_CUPS) { + return u.claimLBSCUPSGateway(ctx, eui, ownerToken, clusterAddress) } - // Update the gateway with the profiles. - _, err = gtwClient.Update(ctx, &northboundv1.GatewayServiceUpdateRequest{ - GatewayId: eui.MarshalNumber(), - Domain: u.client.Domain(ctx), - LoraPacketForwarderProfileId: &northboundv1.ProfileIDValue{ - Value: loraPFProfileID, - }, - GeolocationProfileId: &northboundv1.ProfileIDValue{ - Value: geolocationProfileID, - }, - }) - if err != nil { - logger.WithError(err).Warn("Failed to update gateway with profiles") - return nil, err - } + return nil, errNoSupportedProtocol.New() +} - gatewayMetadata := &dcstypes.GatewayMetadata{} - locationRes, err := gtwClient.GetLastLocation(ctx, &northboundv1.GatewayServiceGetLastLocationRequest{ - GatewayId: eui.MarshalNumber(), - Domain: u.client.Domain(ctx), - }) - if err != nil && !errors.IsNotFound(err) { - logger.WithError(err).Warn("Failed to get gateway location") - } else if err == nil { - gatewayMetadata.Antennas = []*ttnpb.GatewayAntenna{ - { - Location: &ttnpb.Location{ - Latitude: locationRes.Location.Latitude, - Longitude: locationRes.Location.Longitude, - Accuracy: int32(locationRes.Location.Accuracy), - }, - }, +func (*Upstream) supportsProtocol( + desc *northboundv1.GatewayServiceDescribeResponse, + protocolID northboundv1.GatewayProtocolIdentifier, +) bool { + for _, p := range desc.SupportedGatewayProtocols { + if p.GatewayProtocolId == protocolID { + return true } } - return gatewayMetadata, nil + return false } // Unclaim implements gateways.GatewayClaimer. func (u *Upstream) Unclaim(ctx context.Context, eui types.EUI64) error { + // Delete the CUPS and LNS API keys for the gateway. + if err := u.deleteAPIKeys(ctx, &ttnpb.GatewayIdentifiers{Eui: eui.Bytes()}); err != nil { + // Don't fail unclaiming if deleting the API keys fails. + log.FromContext(ctx).WithError(err).Warn("Failed to delete API keys for gateway") + } + gtwClient := northboundv1.NewGatewayServiceClient(u.client) _, err := gtwClient.Unclaim(ctx, &northboundv1.GatewayServiceUnclaimRequest{ GatewayId: eui.MarshalNumber(), diff --git a/pkg/deviceclaimingserver/gateways/ttgc/ttiv1.go b/pkg/deviceclaimingserver/gateways/ttgc/ttiv1.go new file mode 100644 index 0000000000..12d32b965c --- /dev/null +++ b/pkg/deviceclaimingserver/gateways/ttgc/ttiv1.go @@ -0,0 +1,189 @@ +// Copyright © 2026 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 ttgc + +import ( + "bytes" + "context" + "net" + + northboundv1 "go.thethings.industries/pkg/api/gen/tti/gateway/controller/northbound/v1" + dcstypes "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/types" + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/log" + "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" + "go.thethings.network/lorawan-stack/v3/pkg/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" +) + +// claimTTIV1Gateway does four things: +// 1. Claim the gateway +// 2. Upsert a LoRa Packet Forwarder profile with the root CA presented by the given Gateway Server +// 3. Upsert a Geolocation profile +// 4. Update the gateway with the profiles +func (u *Upstream) claimTTIV1Gateway( + ctx context.Context, eui types.EUI64, ownerToken, clusterAddress string, +) (*dcstypes.GatewayMetadata, error) { + logger := log.FromContext(ctx) + + // Claim the gateway. + gtwClient := northboundv1.NewGatewayServiceClient(u.client) + _, err := gtwClient.Claim(ctx, &northboundv1.GatewayServiceClaimRequest{ + GatewayId: eui.MarshalNumber(), + Domain: u.client.Domain(ctx), + OwnerToken: ownerToken, + }) + if err != nil { + return nil, err + } + + // Get the root CA from the Gateway Server and upsert the LoRa Packet Forwarder profile. + host, _, err := net.SplitHostPort(clusterAddress) + if err != nil { + host = clusterAddress + } + clusterAddress = net.JoinHostPort(host, "8889") + rootCA, err := u.getRootCA(ctx, clusterAddress) + if err != nil { + return nil, err + } + var ( + loraPFProfileID []byte + loraPFProfile = &northboundv1.LoraPacketForwarderProfile{ + ProfileName: clusterAddress, + Shared: true, + Protocol: northboundv1.LoraPacketForwarderProtocol_LORA_PACKET_FORWARDER_PROTOCOL_TTI_V1, + Address: clusterAddress, + RootCa: rootCA.Raw, + } + loraPFProfileClient = northboundv1.NewLoraPacketForwarderProfileServiceClient(u.client) + ) + loraPFGetRes, err := loraPFProfileClient.GetByName( + ctx, + &northboundv1.LoraPacketForwarderProfileServiceGetByNameRequest{ + Domain: u.client.Domain(ctx), + Group: profileGroup, + ProfileName: clusterAddress, + }, + ) + if err != nil { + if status.Code(err) != codes.NotFound { + logger.WithError(err).Warn("Failed to get LoRa Packet Forwarder profile") + return nil, err + } + res, err := loraPFProfileClient.Create(ctx, &northboundv1.LoraPacketForwarderProfileServiceCreateRequest{ + Domain: u.client.Domain(ctx), + Group: profileGroup, + LoraPacketForwarderProfile: loraPFProfile, + }) + if err != nil { + logger.WithError(err).Warn("Failed to create LoRa Packet Forwarder profile") + return nil, err + } + loraPFProfileID = res.ProfileId + } else { + if profile := loraPFGetRes.LoraPacketForwarderProfile; profile.Shared != loraPFProfile.Shared || + profile.Protocol != loraPFProfile.Protocol || + !bytes.Equal(profile.RootCa, loraPFProfile.RootCa) { + _, err := loraPFProfileClient.Update(ctx, &northboundv1.LoraPacketForwarderProfileServiceUpdateRequest{ + Domain: u.client.Domain(ctx), + Group: profileGroup, + ProfileId: loraPFGetRes.ProfileId, + LoraPacketForwarderProfile: loraPFProfile, + }) + if err != nil { + logger.WithError(err).Warn("Failed to update LoRa Packet Forwarder profile") + return nil, err + } + } + loraPFProfileID = loraPFGetRes.ProfileId + } + + // Upsert the Geolocation profile. + var ( + geolocationProfileID []byte + geolocationProfile = &northboundv1.GeolocationProfile{ + ProfileName: "on connect", + Shared: true, + DisconnectedFor: durationpb.New(0), + } + geolocationProfileClient = northboundv1.NewGeolocationProfileServiceClient(u.client) + ) + geolocationGetRes, err := geolocationProfileClient.GetByName( + ctx, + &northboundv1.GeolocationProfileServiceGetByNameRequest{ + Domain: u.client.Domain(ctx), + Group: profileGroup, + ProfileName: geolocationProfile.ProfileName, + }, + ) + if err != nil { + if status.Code(err) != codes.NotFound { + logger.WithError(err).Warn("Failed to get geolocation profile") + return nil, err + } + res, err := geolocationProfileClient.Create(ctx, &northboundv1.GeolocationProfileServiceCreateRequest{ + Domain: u.client.Domain(ctx), + Group: profileGroup, + GeolocationProfile: geolocationProfile, + }) + if err != nil { + logger.WithError(err).Warn("Failed to create geolocation profile") + return nil, err + } + geolocationProfileID = res.ProfileId + } else { + geolocationProfileID = geolocationGetRes.ProfileId + } + + // Update the gateway with the profiles. + _, err = gtwClient.Update(ctx, &northboundv1.GatewayServiceUpdateRequest{ + GatewayId: eui.MarshalNumber(), + Domain: u.client.Domain(ctx), + LoraPacketForwarderProfileId: &northboundv1.ProfileIDValue{ + Value: loraPFProfileID, + }, + GeolocationProfileId: &northboundv1.ProfileIDValue{ + Value: geolocationProfileID, + }, + }) + if err != nil { + logger.WithError(err).Warn("Failed to update gateway with profiles") + return nil, err + } + + gatewayMetadata := &dcstypes.GatewayMetadata{} + locationRes, err := gtwClient.GetLastLocation(ctx, &northboundv1.GatewayServiceGetLastLocationRequest{ + GatewayId: eui.MarshalNumber(), + Domain: u.client.Domain(ctx), + }) + if err != nil && !errors.IsNotFound(err) { + logger.WithError(err).Warn("Failed to get gateway location") + } else if err == nil { + gatewayMetadata.Antennas = []*ttnpb.GatewayAntenna{ + { + Location: &ttnpb.Location{ + Latitude: locationRes.Location.Latitude, + Longitude: locationRes.Location.Longitude, + Accuracy: int32(locationRes.Location.Accuracy), + }, + }, + } + } + + return gatewayMetadata, nil +} diff --git a/pkg/ttgc/config.go b/pkg/ttgc/config.go index f32188129f..4e1dced72d 100644 --- a/pkg/ttgc/config.go +++ b/pkg/ttgc/config.go @@ -21,11 +21,9 @@ import ( // Config is the configuration for The Things Gateway Controller. type Config struct { - Enabled bool `name:"enabled" description:"Enable The Things Gateway Controller"` - GatewayEUIs []types.EUI64Prefix `name:"gateway-euis" description:"Gateway EUI prefixes that are managed by The Things Gateway Controller"` //nolint:lll - LBSCUPSEnabled bool `name:"lbs-cups-enabled" description:"Enable LBS CUPS protocol for gateway claiming"` - LBSCUPSGatewayEUIs []types.EUI64Prefix `name:"lbs-cups-gateway-euis" description:"Gateway EUI prefixes that are managed by LBS CUPS protocol"` //nolint:lll - Address string `name:"address" description:"The address of The Things Gateway Controller"` - Domain string `name:"domain" description:"The domain of this cluster"` - TLS tlsconfig.ClientAuth `name:"tls" description:"TLS configuration"` + Enabled bool `name:"enabled" description:"Enable The Things Gateway Controller"` + GatewayEUIs []types.EUI64Prefix `name:"gateway-euis" description:"Gateway EUI prefixes that are managed by The Things Gateway Controller"` //nolint:lll + Address string `name:"address" description:"The address of The Things Gateway Controller"` + Domain string `name:"domain" description:"The domain of this cluster"` + TLS tlsconfig.ClientAuth `name:"tls" description:"TLS configuration"` } From 97fe2e060f4769dcad3b676d0805ee331e2c9ab3 Mon Sep 17 00:00:00 2001 From: Vlad Vitan <23100181+vlasebian@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:18:18 +0200 Subject: [PATCH 8/8] dcs: Prioritise the claiming option for ttgc managed gateways --- .../gateways/ttgc/ttgc.go | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go b/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go index e5a76d849f..db1fedf5b6 100644 --- a/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go +++ b/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go @@ -32,8 +32,7 @@ import ( ) var ( - errNoSupportedProtocol = errors.DefineFailedPrecondition("no_supported_protocol", "no supported gateway protocol found for claiming") - errNoSupportedAuthMethod = errors.DefineFailedPrecondition("no_supported_auth_method", "no supported authentication method found for gateway") + errNoSupportedClaimOption = errors.DefineFailedPrecondition("no_supported_claim_option", "no supported claim option (protocol + auth method) found for gateway") ) const profileGroup = "tts" @@ -65,10 +64,18 @@ func New(ctx context.Context, c component, config ttgc.Config) (*Upstream, error }, nil } +// claimOption represents the protocol and authentication method for claiming a gateway. +type claimOption struct { + protocol northboundv1.GatewayProtocolIdentifier + authMethod northboundv1.AuthenticationMethod + handler func(context.Context, types.EUI64, string, string) (*dcstypes.GatewayMetadata, error) +} + // Claim implements gateways.GatewayClaimer. func (u *Upstream) Claim( ctx context.Context, eui types.EUI64, ownerToken, clusterAddress string, ) (*dcstypes.GatewayMetadata, error) { + // Get the gateway description to verify what protocol it supports. gtwClient := northboundv1.NewGatewayServiceClient(u.client) desc, err := gtwClient.Describe(ctx, &northboundv1.GatewayServiceDescribeRequest{ @@ -78,24 +85,42 @@ func (u *Upstream) Claim( return nil, err } - if u.supportsProtocol(desc, northboundv1.GatewayProtocolIdentifier_GATEWAY_PROTOCOL_TTI_V1) { - return u.claimTTIV1Gateway(ctx, eui, ownerToken, clusterAddress) + // Defines the preferred claiming options in order. + var claimPreferences = []claimOption{ + { + protocol: northboundv1.GatewayProtocolIdentifier_GATEWAY_PROTOCOL_TTI_V1, + authMethod: northboundv1.AuthenticationMethod_AUTHENTICATION_METHOD_MUTUAL_TLS, + handler: u.claimTTIV1Gateway, + }, + { + protocol: northboundv1.GatewayProtocolIdentifier_GATEWAY_PROTOCOL_LBS_CUPS, + authMethod: northboundv1.AuthenticationMethod_AUTHENTICATION_METHOD_GATEWAY_TOKEN, + handler: u.claimLBSCUPSGateway, + }, } - if u.supportsProtocol(desc, northboundv1.GatewayProtocolIdentifier_GATEWAY_PROTOCOL_LBS_CUPS) { - return u.claimLBSCUPSGateway(ctx, eui, ownerToken, clusterAddress) + // Select the first supported claiming option and use its handler. + for _, option := range claimPreferences { + if u.supportsOption(desc, option) { + return option.handler(ctx, eui, ownerToken, clusterAddress) + } } - return nil, errNoSupportedProtocol.New() + return nil, errNoSupportedClaimOption.New() } -func (*Upstream) supportsProtocol( +func (u *Upstream) supportsOption( desc *northboundv1.GatewayServiceDescribeResponse, - protocolID northboundv1.GatewayProtocolIdentifier, + option claimOption, ) bool { for _, p := range desc.SupportedGatewayProtocols { - if p.GatewayProtocolId == protocolID { - return true + if p.GatewayProtocolId != option.protocol { + continue + } + for _, a := range p.SupportedAuthenticationMethods { + if a == option.authMethod { + return true + } } }