diff --git a/CHANGELOG.md b/CHANGELOG.md index ec7f097aba..f24566c933 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 claiming support. + ### Changed +- During the process of claiming a managed gateway, create the gateway in the registry before claiming it, not after. + ### Deprecated ### Removed diff --git a/pkg/deviceclaimingserver/gateways/gateways.go b/pkg/deviceclaimingserver/gateways/gateways.go index a57ca40f69..919995f679 100644 --- a/pkg/deviceclaimingserver/gateways/gateways.go +++ b/pkg/deviceclaimingserver/gateways/gateways.go @@ -20,12 +20,15 @@ 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/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 +36,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. diff --git a/pkg/deviceclaimingserver/gateways/ttgc/lbscups.go b/pkg/deviceclaimingserver/gateways/ttgc/lbscups.go new file mode 100644 index 0000000000..4d79613f80 --- /dev/null +++ b/pkg/deviceclaimingserver/gateways/ttgc/lbscups.go @@ -0,0 +1,247 @@ +// 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" + "fmt" + "net" + "time" + + 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/rpcmetadata" + "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" +) + +var ( + errCreateAPIKey = errors.DefineFailedPrecondition("create_api_key", "failed to create API key for gateway") + errDeleteAPIKey = errors.DefineAborted("delete_api_key", "delete API key") +) + +func (u *Upstream) claimLBSCUPSGateway( + 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 +} + +// 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 +} diff --git a/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go b/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go index 8b35fbd695..db1fedf5b6 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,32 @@ 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 ( + errNoSupportedClaimOption = errors.DefineFailedPrecondition("no_supported_claim_option", "no supported claim option (protocol + auth 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 @@ -58,167 +64,77 @@ func New(ctx context.Context, c ttgc.Component, config ttgc.Config) (*Upstream, }, 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. -// 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, + // 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 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 + // 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) } - 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 - } + return nil, errNoSupportedClaimOption.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 (u *Upstream) supportsOption( + desc *northboundv1.GatewayServiceDescribeResponse, + option claimOption, +) bool { + for _, p := range desc.SupportedGatewayProtocols { + if p.GatewayProtocolId != option.protocol { + continue + } + for _, a := range p.SupportedAuthenticationMethods { + if a == option.authMethod { + 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/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/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/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 102190da55..f5cda85220 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 ( @@ -120,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") ) @@ -163,3 +165,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) +}