diff --git a/AGENTS.md b/AGENTS.md index ac973023e0..2dcee033e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,8 @@ tools/bin/mage js:translations # after adding/changing frontend react-intl m tools/bin/mage go:eventData # after adding/changing events ``` +Changing backend error definitions (`errors.Define*`) requires running **both** `tools/bin/mage go:messages` and `tools/bin/mage js:translations`: the locale files in `pkg/webui/locales/*.json` embed the backend error keys from `config/messages.json`, so CI fails if only the first is regenerated. + Go tests that need Redis **skip themselves silently** unless `TEST_REDIS=1` is set and the databases are running (`dev:dbStart`) — passing output may mean skipped, not tested. `REDIS_ADDRESS`/`REDIS_DB` override the target instance; `TEST_SLOWDOWN` scales test timeouts on slow machines. Add `-v` to mage for verbose output when a target fails silently. If a build is in a broken state, rebuild the frontend from scratch: `tools/bin/mage jsSDK:cleanDeps jsSDK:clean js:cleanDeps js:clean js:build`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 90cbdd8c83..aa3b4856b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ For details about compatibility between different releases, see the **Commitment ### Added - `gs_gateways_disconnected_total` metric, counting gateway disconnections by protocol and by the error the connection was disconnected with. This makes disconnection reasons (such as gateways disappearing without a close handshake, or missing too many pongs) observable as a rate, instead of only through logs. +- `ttgc.managed-gateway-euis` configuration option: Gateway EUI prefixes of managed gateways, defaulting to the EUI prefix of The Things Industries managed gateways. Gateways outside these prefixes are reported as not managed in the claiming info. +- `ttgc.lbscups.lns-port` configuration option: the LoRa Basics Station LNS port of the Gateway Server, defaulting to `8887`. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/cmd/internal/shared/config.go b/cmd/internal/shared/config.go index f8da8598ac..3241daeeb9 100644 --- a/cmd/internal/shared/config.go +++ b/cmd/internal/shared/config.go @@ -178,12 +178,19 @@ var DefaultTelemetryConfig = telemetry.Config{ // DefaultTTGCConfig is the default config for The Things Gateway Controller. var DefaultTTGCConfig = ttgc.Config{ - GatewayEUIs: []types.EUI64Prefix{ - // The Things Industries gateways - { + GatewayEUIs: []types.EUI64Range{ + // TTIGPro gateways. + types.EUI64Prefix{ EUI64: types.EUI64{0xEC, 0x65, 0x6E, 0xFF, 0xFE, 0x00, 0x00, 0x00}, Length: 40, - }, + }.EUI64Range(), + }, + ManagedGatewayEUIs: []types.EUI64Range{ + // TTIGPro gateways. + types.EUI64Prefix{ + EUI64: types.EUI64{0xEC, 0x65, 0x6E, 0xFF, 0xFE, 0x00, 0x00, 0x00}, + Length: 40, + }.EUI64Range(), }, Address: "gc.thethings.industries:443", TLS: tlsconfig.ClientAuth{ @@ -191,6 +198,9 @@ var DefaultTTGCConfig = ttgc.Config{ Certificate: "cert.pem", Key: "key.pem", }, + LBSCUPS: ttgc.LBSCUPSConfig{ + LNSPort: 8887, + }, } // DefaultServiceBase is the default base config for a service. diff --git a/config/messages.json b/config/messages.json index fbfa9f16ae..5823fc602b 100644 --- a/config/messages.json +++ b/config/messages.json @@ -4076,6 +4076,15 @@ "file": "gateways.go" } }, + "error:pkg/deviceclaimingserver/retry:operation_unsuccessful": { + "translations": { + "en": "operation `{name}` unsuccessful after `{number}` attempts" + }, + "description": { + "package": "pkg/deviceclaimingserver/retry", + "file": "task.go" + } + }, "error:pkg/deviceclaimingserver:claim gateway": { "translations": { "en": "claim gateway" @@ -4112,6 +4121,15 @@ "file": "grpc_end_devices.go" } }, + "error:pkg/deviceclaimingserver:fetch_created_gateway": { + "translations": { + "en": "fetch gateway after creation" + }, + "description": { + "package": "pkg/deviceclaimingserver", + "file": "grpc_gateways.go" + } + }, "error:pkg/deviceclaimingserver:gateway_already_exists": { "translations": { "en": "gateway with EUI `{eui}` already exists" @@ -4193,6 +4211,15 @@ "file": "grpc_end_devices.go" } }, + "error:pkg/deviceclaimingserver:update_gateway": { + "translations": { + "en": "update gateway" + }, + "description": { + "package": "pkg/deviceclaimingserver", + "file": "grpc_gateways.go" + } + }, "error:pkg/devicerepository/store/bleve:cannot_open_index": { "translations": { "en": "cannot open index" @@ -9800,6 +9827,24 @@ "file": "eui.go" } }, + "error:pkg/types:invalid_eui64_range": { + "translations": { + "en": "invalid EUI64 range `{value}`" + }, + "description": { + "package": "pkg/types", + "file": "eui.go" + } + }, + "error:pkg/types:invalid_eui64_ranges": { + "translations": { + "en": "invalid EUI64 ranges of `{name}`" + }, + "description": { + "package": "pkg/types", + "file": "eui.go" + } + }, "error:pkg/types:invalid_join_nonce": { "translations": { "en": "invalid JoinNonce" diff --git a/pkg/deviceclaimingserver/gateways/gateways.go b/pkg/deviceclaimingserver/gateways/gateways.go index 9e31ac45ef..3b8ead35b8 100644 --- a/pkg/deviceclaimingserver/gateways/gateways.go +++ b/pkg/deviceclaimingserver/gateways/gateways.go @@ -18,7 +18,6 @@ package gateways import ( "context" "crypto/tls" - "strings" "go.thethings.network/lorawan-stack/v3/pkg/cluster" "go.thethings.network/lorawan-stack/v3/pkg/config" @@ -52,42 +51,6 @@ var ( errTTGCNotEnabled = errors.DefineFailedPrecondition("ttgc_not_enabled", "TTGC is not enabled") ) -// ParseGatewayEUIRanges parses the configured upstream map and returns map of ranges. -func ParseGatewayEUIRanges(conf map[string][]string) (map[string][]dcstypes.EUI64Range, error) { - res := make(map[string][]dcstypes.EUI64Range, len(conf)) - for host, ranges := range conf { - res[host] = make([]dcstypes.EUI64Range, 0, len(ranges)) - for _, val := range ranges { - var r dcstypes.EUI64Range - switch { - case strings.Contains(val, "/"): - var prefix types.EUI64Prefix - if err := prefix.UnmarshalText([]byte(val)); err != nil { - return nil, errInvalidUpstream.WithAttributes("name", host).WithCause(err) - } - r = dcstypes.RangeFromEUI64Prefix(prefix) - case strings.Contains(val, "-"): - parts := strings.Split(val, "-") - if len(parts) != 2 { - return nil, errInvalidUpstream.WithAttributes("name", host) - } - var start, end types.EUI64 - if err := start.UnmarshalText([]byte(parts[0])); err != nil { - return nil, errInvalidUpstream.WithAttributes("name", host).WithCause(err) - } - if err := end.UnmarshalText([]byte(parts[1])); err != nil { - return nil, errInvalidUpstream.WithAttributes("name", host).WithCause(err) - } - r = dcstypes.RangeFromEUI64Range(start, end) - default: - return nil, errInvalidUpstream.WithAttributes("name", host) - } - res[host] = append(res[host], r) - } - } - return res, nil -} - // Claimer provides methods for claiming Gateways. type Claimer interface { // Claim claims a gateway. @@ -102,7 +65,7 @@ type Claimer interface { // rangeClaimer supports claiming a range of EUIs. type rangeClaimer struct { - ranges []dcstypes.EUI64Range + ranges []types.EUI64Range Claimer } @@ -125,7 +88,7 @@ func NewUpstream( opt(upstream) } - hosts, err := ParseGatewayEUIRanges(conf.Upstreams) + hosts, err := types.ParseEUI64RangesMap(conf.Upstreams) if err != nil { return nil, err } @@ -133,11 +96,7 @@ func NewUpstream( // Implicitly add TTGC if it is enabled and not already configured. ttgcConf := c.GetBaseConfig(ctx).TTGC if _, ttgcAdded := hosts["ttgc"]; ttgcConf.Enabled && !ttgcAdded { - ttgcRanges := make([]dcstypes.EUI64Range, len(ttgcConf.GatewayEUIs)) - for i, prefix := range ttgcConf.GatewayEUIs { - ttgcRanges[i] = dcstypes.RangeFromEUI64Prefix(prefix) - } - hosts["ttgc"] = ttgcRanges + hosts["ttgc"] = ttgcConf.GatewayEUIs } // Setup upstream table. @@ -170,7 +129,7 @@ func NewUpstream( type Option func(*Upstream) // WithClaimer adds a claimer to Upstream. -func WithClaimer(name string, ranges []dcstypes.EUI64Range, claimer Claimer) Option { +func WithClaimer(name string, ranges []types.EUI64Range, claimer Claimer) Option { return func(upstream *Upstream) { upstream.claimers[name] = rangeClaimer{ Claimer: claimer, diff --git a/pkg/deviceclaimingserver/gateways/gateways_test.go b/pkg/deviceclaimingserver/gateways/gateways_test.go index bcbe541894..b7073b6de9 100644 --- a/pkg/deviceclaimingserver/gateways/gateways_test.go +++ b/pkg/deviceclaimingserver/gateways/gateways_test.go @@ -22,7 +22,6 @@ import ( "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" - 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/ttgc" "go.thethings.network/lorawan-stack/v3/pkg/types" @@ -39,11 +38,11 @@ func TestUpstream(t *testing.T) { ServiceBase: config.ServiceBase{ TTGC: ttgc.Config{ Enabled: true, - GatewayEUIs: []types.EUI64Prefix{ - { + GatewayEUIs: []types.EUI64Range{ + types.EUI64Prefix{ EUI64: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x00}, Length: 48, - }, + }.EUI64Range(), }, TLS: tlsconfig.ClientAuth{ Source: "file", @@ -56,32 +55,32 @@ func TestUpstream(t *testing.T) { // Invalid ranges. ranges := map[string][]string{"ttgc": {"&S(FU*)"}} - euiPrefixes, err := gateways.ParseGatewayEUIRanges(ranges) + euiPrefixes, err := types.ParseEUI64RangesMap(ranges) a.So(err, should.NotBeNil) a.So(euiPrefixes, should.BeEmpty) ranges = map[string][]string{"ttgc": {"58A0CBFFFE800000"}} - euiPrefixes, err = gateways.ParseGatewayEUIRanges(ranges) + euiPrefixes, err = types.ParseEUI64RangesMap(ranges) a.So(err, should.NotBeNil) a.So(euiPrefixes, should.BeEmpty) ranges = map[string][]string{"ttgc": {"58A0CBFFFE800000/123456"}} - euiPrefixes, err = gateways.ParseGatewayEUIRanges(ranges) + euiPrefixes, err = types.ParseEUI64RangesMap(ranges) a.So(err, should.NotBeNil) a.So(euiPrefixes, should.BeEmpty) ranges = map[string][]string{"ttgc": {"58A0CBFFFE800000-58A0CBFFFE800000-58A0CBFFFE800000"}} - euiPrefixes, err = gateways.ParseGatewayEUIRanges(ranges) + euiPrefixes, err = types.ParseEUI64RangesMap(ranges) a.So(err, should.NotBeNil) a.So(euiPrefixes, should.BeEmpty) ranges = map[string][]string{"ttgc": {"001616FFFEWXUSD-001616FFFETGENDE"}} - euiPrefixes, err = gateways.ParseGatewayEUIRanges(ranges) + euiPrefixes, err = types.ParseEUI64RangesMap(ranges) a.So(err, should.NotBeNil) a.So(euiPrefixes, should.BeEmpty) ranges = map[string][]string{"ttgc": {"001616FFFE42DFAD-001616FFFETGENDE"}} - euiPrefixes, err = gateways.ParseGatewayEUIRanges(ranges) + euiPrefixes, err = types.ParseEUI64RangesMap(ranges) a.So(err, should.NotBeNil) a.So(euiPrefixes, should.BeEmpty) @@ -92,15 +91,15 @@ func TestUpstream(t *testing.T) { "001616FFFE42DFAD-001616FFFE42E395", }, } - euiPrefixes, err = gateways.ParseGatewayEUIRanges(ranges) + euiPrefixes, err = types.ParseEUI64RangesMap(ranges) a.So(err, should.BeNil) - a.So(euiPrefixes, should.Resemble, map[string][]dcstypes.EUI64Range{ + a.So(euiPrefixes, should.Resemble, map[string][]types.EUI64Range{ "ttgc": { - dcstypes.RangeFromEUI64Prefix(types.EUI64Prefix{ + types.EUI64Prefix{ EUI64: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x00}, Length: 48, - }), - dcstypes.RangeFromEUI64Range( + }.EUI64Range(), + types.EUI64RangeFromInterval( types.EUI64{0x00, 0x16, 0x16, 0xff, 0xfe, 0x42, 0xdf, 0xad}, types.EUI64{0x00, 0x16, 0x16, 0xff, 0xfe, 0x42, 0xe3, 0x95}, ), diff --git a/pkg/deviceclaimingserver/gateways/ttgc/lbscups.go b/pkg/deviceclaimingserver/gateways/ttgc/lbscups.go index 5902b2f561..94526d5108 100644 --- a/pkg/deviceclaimingserver/gateways/ttgc/lbscups.go +++ b/pkg/deviceclaimingserver/gateways/ttgc/lbscups.go @@ -19,6 +19,7 @@ import ( "context" "fmt" "net" + "strings" "time" northboundv1 "go.thethings.industries/pkg/api/gen/tti/gateway/controller/northbound/v1" @@ -32,6 +33,11 @@ import ( "google.golang.org/grpc/status" ) +const ( + // Prefix of the LNS API key name created by this claimer. + lnsKeyNamePrefix = "LNS Key (TTGC)" +) + var ( errCreateAPIKey = errors.DefineFailedPrecondition("create_api_key", "failed to create API key for gateway") errDeleteAPIKey = errors.DefineAborted("delete_api_key", "delete API key") @@ -40,47 +46,66 @@ var ( func (u *Upstream) claimLBSCUPSGateway( ctx context.Context, ids *ttnpb.GatewayIdentifiers, ownerToken, clusterAddress string, ) (*dcstypes.GatewayMetadata, error) { - logger := log.FromContext(ctx) - eui := types.MustEUI64(ids.Eui).OrZero() + // Create the LNS API key for the gateway. It is sent as the gateway token when claiming on TTGC and returned in + // the metadata; the caller is responsible for updating the LNS key in the gateway. + lnsKey, err := u.createLNSAPIKey(ctx, ids) + if err != nil { + return nil, err + } - // 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) + host, _, err := net.SplitHostPort(clusterAddress) if err != nil { + host = clusterAddress + } + lnsAddress := net.JoinHostPort(host, u.lnsPort) + + if err := u.ClaimLBSGateway(ctx, ids, ownerToken, lnsKey.Key, lnsAddress); err != nil { return nil, err } - // Claim the gateway on TTGC with the CUPS key as the gateway token. + return &dcstypes.GatewayMetadata{ + LBSLNSKey: lnsKey, + }, nil +} + +// ClaimLBSGateway claims a LoRa Basics Station gateway on TTGC and configures its LNS settings. +// The owner token is verified by TTGC against the owner token stored for the gateway. +// The LNS key is sent as the gateway token: TTGC serves it as the LNS authentication token in the CUPS response. +// It is a raw API key, without an authentication scheme prefix. +// The LNS address and its root CA are stored in a shared LoRa Packet Forwarder profile that is attached +// to the gateway. +func (u *Upstream) ClaimLBSGateway( + ctx context.Context, ids *ttnpb.GatewayIdentifiers, ownerToken, lnsKey, lnsAddress string, +) error { + logger := log.FromContext(ctx) + eui := types.MustEUI64(ids.Eui).OrZero() + + // Claim the gateway on TTGC. gtwClient := northboundv1.NewGatewayServiceClient(u.client) - _, err = gtwClient.Claim(ctx, &northboundv1.GatewayServiceClaimRequest{ + _, err := gtwClient.Claim(ctx, &northboundv1.GatewayServiceClaimRequest{ GatewayId: eui.MarshalNumber(), Domain: u.client.Domain(ctx), OwnerToken: ownerToken, - GatewayToken: []byte(cupsKey.Key), + GatewayToken: []byte(lnsKey), }) if err != nil { logger.WithError(err).Warn("Failed to claim gateway on TTGC") - return nil, err + return err } // Get the Root CA from the Gateway Server. - host, _, err := net.SplitHostPort(clusterAddress) + rootCA, err := u.getRootCA(ctx, lnsAddress) if err != nil { - host = clusterAddress - } - clusterAddress = net.JoinHostPort(host, "8889") - rootCA, err := u.getRootCA(ctx, clusterAddress) - if err != nil { - return nil, err + return err } var ( loraPFProfileID []byte loraPFProfile = &northboundv1.LoraPacketForwarderProfile{ - ProfileName: clusterAddress, - Shared: false, + ProfileName: lnsAddress, + Shared: true, Protocol: northboundv1.LoraPacketForwarderProtocol_LORA_PACKET_FORWARDER_PROTOCOL_BASIC_STATION, - Address: clusterAddress, + Address: lnsAddress, RootCa: rootCA.Raw, } loraPFProfileClient = northboundv1.NewLoraPacketForwarderProfileServiceClient(u.client) @@ -90,13 +115,13 @@ func (u *Upstream) claimLBSCUPSGateway( &northboundv1.LoraPacketForwarderProfileServiceGetByNameRequest{ Domain: u.client.Domain(ctx), Group: profileGroup, - ProfileName: clusterAddress, + ProfileName: lnsAddress, }, ) if err != nil { if status.Code(err) != codes.NotFound { logger.WithError(err).Warn("Failed to get LoRa Packet Forwarder profile") - return nil, err + return err } res, err := loraPFProfileClient.Create(ctx, &northboundv1.LoraPacketForwarderProfileServiceCreateRequest{ Domain: u.client.Domain(ctx), @@ -105,7 +130,7 @@ func (u *Upstream) claimLBSCUPSGateway( }) if err != nil { logger.WithError(err).Warn("Failed to create LoRa Packet Forwarder profile") - return nil, err + return err } loraPFProfileID = res.ProfileId } else { @@ -120,7 +145,7 @@ func (u *Upstream) claimLBSCUPSGateway( }) if err != nil { logger.WithError(err).Warn("Failed to update LoRa Packet Forwarder profile") - return nil, err + return err } } loraPFProfileID = loraPFGetRes.ProfileId @@ -136,63 +161,44 @@ func (u *Upstream) claimLBSCUPSGateway( }) if err != nil { logger.WithError(err).Warn("Failed to update gateway with profiles") - return nil, err + return err } - return &dcstypes.GatewayMetadata{ - LBSLNSKey: lnsKey, - }, nil + return nil } -// createAPIKeys creates the CUPS and LNS API keys for the gateway. -func (u *Upstream) createAPIKeys( +// createLNSAPIKey creates the LNS API key for the gateway. +func (u *Upstream) createLNSAPIKey( ctx context.Context, ids *ttnpb.GatewayIdentifiers, -) (cupsKey, lnsKey *ttnpb.APIKey, err error) { +) (*ttnpb.APIKey, error) { logger := log.FromContext(ctx) gatewayAccess, err := u.getGatewayAccess(ctx) if err != nil { - return nil, nil, err + return 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), %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) + return nil, err } - lnsKey, err = gatewayAccess.CreateAPIKey(ctx, &ttnpb.CreateGatewayAPIKeyRequest{ + lnsKey, err := gatewayAccess.CreateAPIKey(ctx, &ttnpb.CreateGatewayAPIKeyRequest{ GatewayIds: ids, - Name: fmt.Sprintf("LBS LNS Key (TTGC), %s", time.Now().UTC().Format(time.RFC3339)), + Name: fmt.Sprintf("%s, %s", lnsKeyNamePrefix, 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 nil, errCreateAPIKey.WithCause(err) } - return cupsKey, lnsKey, nil + return 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 @@ -200,7 +206,7 @@ func (u *Upstream) getGatewayAccess(ctx context.Context) (ttnpb.GatewayAccessCli return ttnpb.NewGatewayAccessClient(conn), nil } -// deleteAPIKeys deletes the CUPS and LNS API keys for the gateway. +// deleteAPIKeys deletes the LNS API keys for the gateway. func (u *Upstream) deleteAPIKeys(ctx context.Context, ids *ttnpb.GatewayIdentifiers) error { logger := log.FromContext(ctx) @@ -222,13 +228,10 @@ func (u *Upstream) deleteAPIKeys(ctx context.Context, ids *ttnpb.GatewayIdentifi return errDeleteAPIKey.WithCause(err) } - // Delete the LBS CUPS and LBS LNS keys. + // Delete the 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") { + if strings.HasPrefix(key.Name, lnsKeyNamePrefix) { _, err := gatewayAccess.DeleteAPIKey(ctx, &ttnpb.DeleteGatewayAPIKeyRequest{ GatewayIds: ids, KeyId: key.Id, diff --git a/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go b/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go index 99a631c606..f1f116aaee 100644 --- a/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go +++ b/pkg/deviceclaimingserver/gateways/ttgc/ttgc.go @@ -19,6 +19,7 @@ import ( "context" "crypto/tls" "slices" + "strconv" northboundv1 "go.thethings.industries/pkg/api/gen/tti/gateway/controller/northbound/v1" "go.thethings.network/lorawan-stack/v3/pkg/cluster" @@ -37,7 +38,12 @@ var errNoSupportedClaimOption = errors.DefineFailedPrecondition( "no supported claim option (protocol + auth method) found for gateway", ) -const profileGroup = "tts" +const ( + profileGroup = "tts" + + // Default LoRa Basics Station LNS port of the Gateway Server. + defaultLNSPort uint16 = 8887 +) type component interface { GetTLSConfig(context.Context) tlsconfig.Config @@ -49,9 +55,9 @@ type component interface { // Upstream is the client for The Things Gateway Controller. type Upstream struct { component - client *ttgc.Client - - gatewayAccess ttnpb.GatewayAccessClient + client *ttgc.Client + lnsPort string + managedRanges []types.EUI64Range } // New returns a new upstream client for The Things Gateway Controller. @@ -60,9 +66,15 @@ func New(ctx context.Context, c component, config ttgc.Config) (*Upstream, error if err != nil { return nil, err } + lnsPort := config.LBSCUPS.LNSPort + if lnsPort == 0 { + lnsPort = defaultLNSPort + } return &Upstream{ - component: c, - client: client, + component: c, + client: client, + lnsPort: strconv.Itoa(int(lnsPort)), + managedRanges: config.ManagedGatewayEUIs, }, nil } @@ -132,12 +144,6 @@ func (*Upstream) supportsOption( func (u *Upstream) Unclaim(ctx context.Context, ids *ttnpb.GatewayIdentifiers) error { eui := types.MustEUI64(ids.Eui).OrZero() - // Delete the CUPS and LNS API keys for the gateway. - if err := u.deleteAPIKeys(ctx, ids); 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(), @@ -149,11 +155,23 @@ func (u *Upstream) Unclaim(ctx context.Context, ids *ttnpb.GatewayIdentifiers) e } return err } + + // Delete the CUPS and LNS API keys for the gateway. + if err := u.deleteAPIKeys(ctx, ids); 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") + } + return nil } // IsManagedGateway implements gateways.GatewayClaimer. -// This method always returns true. -func (*Upstream) IsManagedGateway(context.Context, types.EUI64) (bool, error) { - return true, nil +// This method returns true for gateways in the configured managed Gateway EUI ranges and false otherwise. +func (u *Upstream) IsManagedGateway(_ context.Context, eui types.EUI64) (bool, error) { + for _, r := range u.managedRanges { + if r.Contains(eui) { + return true, nil + } + } + return false, nil } diff --git a/pkg/deviceclaimingserver/grpc_gateways.go b/pkg/deviceclaimingserver/grpc_gateways.go index 389551afad..f215653196 100644 --- a/pkg/deviceclaimingserver/grpc_gateways.go +++ b/pkg/deviceclaimingserver/grpc_gateways.go @@ -18,10 +18,12 @@ import ( "context" "fmt" "strings" + "time" "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/gateways" "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/observability" gtwregistry "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/registry/gateways" + "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/retry" "go.thethings.network/lorawan-stack/v3/pkg/errors" "go.thethings.network/lorawan-stack/v3/pkg/log" "go.thethings.network/lorawan-stack/v3/pkg/rpcmetadata" @@ -66,33 +68,47 @@ var ( "create_gateway", "create gateway", ) + errUpdateGateway = errors.DefineAborted( + "update_gateway", + "update gateway", + ) errNoEUI = errors.DefineInvalidArgument( "no_eui", "no EUI found for gateway", ) + errFetchCreatedGateway = errors.DefineDeadlineExceeded( + "fetch_created_gateway", + "fetch gateway after creation", + ) ) -// Claim implements GatewayClaimingServer. -func (gcls *gatewayClaimingServer) Claim( - ctx context.Context, - req *ttnpb.ClaimGatewayRequest, -) (ids *ttnpb.GatewayIdentifiers, retErr error) { - logger := log.FromContext(ctx) +// claimCleanupTimeout bounds the compensating operations that revert a partially claimed gateway. +const claimCleanupTimeout = 10 * time.Second - // Extract the EUI and the owner token (claim authentication code) from the request. - var ( - authCode []byte - gatewayEUI types.EUI64 - ) +// parseClaimRequest extracts the EUI and the owner token (claim authentication code) from the request. +func parseClaimRequest(req *ttnpb.ClaimGatewayRequest) (types.EUI64, []byte, error) { switch claim := req.SourceGateway.(type) { case *ttnpb.ClaimGatewayRequest_AuthenticatedIdentifiers_: authIDs := claim.AuthenticatedIdentifiers - gatewayEUI, authCode = types.MustEUI64(authIDs.GatewayEui).OrZero(), authIDs.AuthenticationCode + return types.MustEUI64(authIDs.GatewayEui).OrZero(), authIDs.AuthenticationCode, nil case *ttnpb.ClaimGatewayRequest_QrCode: - return nil, errGatewayClaimingWithQRCode.New() + return types.EUI64{}, nil, errGatewayClaimingWithQRCode.New() default: panic(fmt.Sprintf("proto: unexpected type %T", claim)) } +} + +// Claim implements GatewayClaimingServer. +func (gcls *gatewayClaimingServer) Claim( + ctx context.Context, + req *ttnpb.ClaimGatewayRequest, +) (ids *ttnpb.GatewayIdentifiers, retErr error) { + logger := log.FromContext(ctx) + + gatewayEUI, authCode, err := parseClaimRequest(req) + if err != nil { + return nil, err + } logger = logger.WithFields(log.Fields( "gateway_eui", gatewayEUI, )) @@ -106,14 +122,15 @@ func (gcls *gatewayClaimingServer) Claim( } // Check if the gateway already exists. - _, err := gcls.registry.GetIdentifiersForEUI(ctx, gatewayEUI) + _, err = gcls.registry.GetIdentifiersForEUI(ctx, gatewayEUI) if err == nil { return nil, errGatewayAlreadyExists.WithAttributes("eui", gatewayEUI) } else if !errors.IsNotFound(err) { return nil, err } - // Create the gateway in the IS. + // Create the gateway in the IS. The gateway is created before claiming on the upstream because the upstream + // needs the gateway to exist in order to create API keys for it. gateway := &ttnpb.Gateway{ Ids: ids, } @@ -128,15 +145,23 @@ func (gcls *gatewayClaimingServer) Claim( if createdIDs := created.GetIds(); createdIDs != nil { ids = createdIDs } + defer func(ids *ttnpb.GatewayIdentifiers) { + cleanupCtx, cancelCleanup := context.WithTimeout(context.WithoutCancel(ctx), claimCleanupTimeout) + defer cancelCleanup() + if retErr != nil { logger.Warn("Failed to claim gateway, deleting created gateway") - if _, delErr := gcls.registry.Delete(ctx, ids); delErr != nil { + if _, delErr := gcls.registry.Purge(cleanupCtx, ids); delErr != nil { logger.WithError(delErr).Warn("Failed to delete created gateway after failed claim") } } }(ids) + if err := gcls.waitForCreatedGateway(ctx, ids); err != nil { + return nil, err + } + // 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 @@ -157,9 +182,12 @@ func (gcls *gatewayClaimingServer) Claim( // Unclaim if update fails. defer func(ids *ttnpb.GatewayIdentifiers) { + cleanupCtx, cancelCleanup := context.WithTimeout(context.WithoutCancel(ctx), claimCleanupTimeout) + defer cancelCleanup() + if retErr != nil { observability.RegisterAbortClaim(ctx, ids.GetEntityIdentifiers(), retErr) - if err := claimer.Unclaim(ctx, ids); err != nil { + if err := claimer.Unclaim(cleanupCtx, ids); err != nil { logger.WithError(err).Warn("Failed to unclaim gateway") } return @@ -198,12 +226,42 @@ func (gcls *gatewayClaimingServer) Claim( FieldMask: fieldMask, }) if err != nil { - return nil, errCreateGateway.WithCause(err) + return nil, errUpdateGateway.WithCause(err) } return ids, nil } +// waitForCreatedGateway waits until the created gateway is visible to the caller's credentials. Rights on the new +// gateway are computed against a possibly lagging IS read replica; a missing gateway surfaces as a not-found error +// for admin callers and is masked as a permission-denied error otherwise. +func (gcls *gatewayClaimingServer) waitForCreatedGateway(ctx context.Context, ids *ttnpb.GatewayIdentifiers) error { + getCreatedGatewayTask := retry.Task{ + Name: "get created gateway", + F: func() (bool, error) { + _, err := gcls.registry.Get(ctx, &ttnpb.GetGatewayRequest{ + GatewayIds: ids, + FieldMask: ttnpb.FieldMask("ids"), + }) + switch { + case err == nil: + return false, nil + case errors.IsNotFound(err), errors.IsPermissionDenied(err): + return true, err + default: + return false, err + } + }, + WaitTime: 500 * time.Millisecond, + Jitter: 0.2, + MaxAttempts: 5, + } + if err := getCreatedGatewayTask.Do(ctx); err != nil { + return errFetchCreatedGateway.WithCause(err) + } + return nil +} + // GetInfoByGatewayEUI implements GatewayClaimingServer. func (gcls gatewayClaimingServer) GetInfoByGatewayEUI( ctx context.Context, in *ttnpb.GetInfoByGatewayEUIRequest, diff --git a/pkg/deviceclaimingserver/grpc_gateways_test.go b/pkg/deviceclaimingserver/grpc_gateways_test.go index 780c68d4b9..404e1d61cd 100644 --- a/pkg/deviceclaimingserver/grpc_gateways_test.go +++ b/pkg/deviceclaimingserver/grpc_gateways_test.go @@ -49,7 +49,7 @@ var ( authorizedCallOpt = grpc.PerRPCCredentials(authorizedMD) ) -func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest +func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest,gocyclo a := assertions.New(t) ctx := log.NewContext(test.Context(), test.GetLogger(t)) ctx, cancelCtx := context.WithCancel(ctx) @@ -83,11 +83,11 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest gateways.Config{}, gateways.WithClaimer( "mock", - []dcstypes.EUI64Range{ - dcstypes.RangeFromEUI64Prefix(types.EUI64Prefix{ + []types.EUI64Range{ + types.EUI64Prefix{ EUI64: types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0x00}, Length: 48, - }), + }.EUI64Range(), }, mockGatewayClaimer, ), @@ -97,6 +97,10 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest } existingEUI := types.EUI64{0x58, 0xa0, 0xcb, 0xff, 0xfe, 0x80, 0x00, 0xFF} + // Default Get behavior: the created gateway is immediately visible. + getCreatedGatewayFunc := func(_ context.Context, req *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) { + return &ttnpb.Gateway{Ids: req.GatewayIds}, nil + } mockGatewayRegistry := &mockGatewayRegistry{ authorizedMD: authorizedMD, gateways: []*ttnpb.Gateway{ @@ -177,15 +181,17 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest a.So(resp.IsManaged, should.BeTrue) // Test claiming + getGatewayCalls := 0 for _, tc := range []struct { Name string Req *ttnpb.ClaimGatewayRequest CallOpt grpc.CallOption ClaimFunc func(context.Context, *ttnpb.GatewayIdentifiers, string, string) (*dcstypes.GatewayMetadata, error) CreateFunc func(context.Context, *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) + GetFunc func(context.Context, *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) UpdateFunc func(context.Context, *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) UnclaimFunc func(context.Context, *ttnpb.GatewayIdentifiers) error - DeleteFunc func(context.Context, *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) + PurgeFunc func(context.Context, *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) ErrorAssertion func(error) bool }{ { @@ -280,7 +286,7 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest CreateFunc: func(_ context.Context, in *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) { return in.Gateway, nil }, - DeleteFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + PurgeFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { return &emptypb.Empty{}, nil }, ErrorAssertion: errors.IsAborted, @@ -308,7 +314,7 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest UpdateFunc: func(_ context.Context, in *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { return in.Gateway, nil }, - DeleteFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + PurgeFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { return &emptypb.Empty{}, nil }, ErrorAssertion: errors.IsAborted, @@ -336,7 +342,7 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest UpdateFunc: func(_ context.Context, _ *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { return nil, errUpdate.New() }, - DeleteFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + PurgeFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { return &emptypb.Empty{}, nil }, UnclaimFunc: func(_ context.Context, ids *ttnpb.GatewayIdentifiers) error { @@ -370,7 +376,7 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest UpdateFunc: func(_ context.Context, _ *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { return nil, errUpdate.New() }, - DeleteFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + PurgeFunc: func(_ context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { return &emptypb.Empty{}, nil }, UnclaimFunc: func(context.Context, *ttnpb.GatewayIdentifiers) error { @@ -402,6 +408,38 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest }, CallOpt: authorizedCallOpt, }, + { + Name: "Claim/CreatedGatewayVisibleAfterReplicationLag", + 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) { + getGatewayCalls = 0 + return in.Gateway, nil + }, + GetFunc: func(_ context.Context, req *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) { + // The created gateway becomes visible on the third attempt. + if getGatewayCalls++; getGatewayCalls < 3 { + return nil, errGatewayNotFound.New() + } + return &ttnpb.Gateway{Ids: req.GatewayIds}, nil + }, + ClaimFunc: func(context.Context, *ttnpb.GatewayIdentifiers, string, string) (*dcstypes.GatewayMetadata, error) { + return &dcstypes.GatewayMetadata{}, nil + }, + UpdateFunc: func(_ context.Context, in *ttnpb.UpdateGatewayRequest) (*ttnpb.Gateway, error) { + return in.Gateway, nil + }, + }, { Name: "Claim/EmptyTargetGatewayIDDefaultsToEUIAndDeletesOnFailedClaim", Req: &ttnpb.ClaimGatewayRequest{ @@ -426,7 +464,7 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest a.So(in.Gateway.GetIds().GetGatewayId(), should.Equal, "58a0cbfffe800001") return in.Gateway, nil }, - DeleteFunc: func(_ context.Context, ids *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + PurgeFunc: func(_ context.Context, ids *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { a.So(ids.GatewayId, should.Equal, "58a0cbfffe800001") a.So(ids.Eui, should.Resemble, supportedEUI.Bytes()) return &emptypb.Empty{}, nil @@ -472,11 +510,16 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest if tc.CreateFunc != nil { mockGatewayRegistry.createFunc = tc.CreateFunc } + if tc.GetFunc != nil { + mockGatewayRegistry.getFunc = tc.GetFunc + } else { + mockGatewayRegistry.getFunc = getCreatedGatewayFunc + } if tc.UpdateFunc != nil { mockGatewayRegistry.updateFunc = tc.UpdateFunc } - if tc.DeleteFunc != nil { - mockGatewayRegistry.deleteFunc = tc.DeleteFunc + if tc.PurgeFunc != nil { + mockGatewayRegistry.purgeFunc = tc.PurgeFunc } _, err := gclsClient.Claim(ctx, tc.Req, tc.CallOpt) @@ -490,6 +533,41 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest }) } + t.Run("Claim/CreatedGatewayNotVisible", func(t *testing.T) { //nolint:paralleltest + a := assertions.New(t) + var deleted bool + mockGatewayRegistry.createFunc = func(_ context.Context, in *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) { + return in.Gateway, nil + } + // Rights on a not yet replicated gateway are masked as a permission-denied error for non-admin callers. + mockGatewayRegistry.getFunc = func(context.Context, *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) { + return nil, errNoRights.New() + } + mockGatewayRegistry.purgeFunc = func(context.Context, *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + deleted = true + return &emptypb.Empty{}, nil + } + _, err := gclsClient.Claim(ctx, &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", + }, authorizedCallOpt) + a.So(errors.IsDeadlineExceeded(err), should.BeTrue) + a.So(deleted, should.BeTrue) + }) + + t.Run("Claim/CleanupAfterCanceledRequest", func(t *testing.T) { //nolint:paralleltest + assertClaimCleanupNotBoundToRequest( + ctx, t, gclsClient, mockGatewayRegistry, mockGatewayClaimer, supportedEUI, + ) + }) + // Test unclaiming. for _, tc := range []struct { //nolint:paralleltest Name string @@ -632,3 +710,66 @@ func TestGatewayClaimingServer(t *testing.T) { //nolint:paralleltest }) } } + +// assertClaimCleanupNotBoundToRequest asserts that the cleanup of a partially claimed gateway is not bound to the +// request context, since cancellation of the request is itself one of the failures that the cleanup reverts. +func assertClaimCleanupNotBoundToRequest( + ctx context.Context, + t *testing.T, + gclsClient ttnpb.GatewayClaimingServerClient, + registry *mockGatewayRegistry, + claimer *MockGatewayClaimer, + eui types.EUI64, +) { + t.Helper() + a := assertions.New(t) + + callCtx, cancelCall := context.WithCancel(ctx) + t.Cleanup(cancelCall) + + // Records the error of the context that the delete is called with, at the time of the call. + deleteCtxErrCh := make(chan error, 1) + registry.createFunc = func(_ context.Context, in *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) { + return in.Gateway, nil + } + registry.getFunc = func(_ context.Context, req *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) { + return &ttnpb.Gateway{Ids: req.GatewayIds}, nil + } + registry.purgeFunc = func(ctx context.Context, _ *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + deleteCtxErrCh <- ctx.Err() + return &emptypb.Empty{}, nil + } + claimer.ClaimFunc = func( + reqCtx context.Context, _ *ttnpb.GatewayIdentifiers, _, _ string, + ) (*dcstypes.GatewayMetadata, error) { + // Cancel the request and wait until the cancellation reaches the handler, so that the deferred cleanup runs + // with an already canceled request context. + cancelCall() + select { + case <-reqCtx.Done(): + case <-time.After(timeout): + t.Error("Request context was not canceled") + } + return nil, reqCtx.Err() + } + + _, err := gclsClient.Claim(callCtx, &ttnpb.ClaimGatewayRequest{ + Collaborator: userID.GetOrganizationOrUserIdentifiers(), + SourceGateway: &ttnpb.ClaimGatewayRequest_AuthenticatedIdentifiers_{ + AuthenticatedIdentifiers: &ttnpb.ClaimGatewayRequest_AuthenticatedIdentifiers{ + GatewayEui: eui.Bytes(), + AuthenticationCode: claimAuthCode, + }, + }, + TargetGatewayId: "test-gateway", + TargetGatewayServerAddress: "things.example.com", + }, authorizedCallOpt) + a.So(err, should.NotBeNil) + + select { + case deleteCtxErr := <-deleteCtxErrCh: + a.So(deleteCtxErr, should.BeNil) + case <-time.After(timeout): + t.Fatal("Created gateway was not deleted after the request was canceled") + } +} diff --git a/pkg/deviceclaimingserver/registry/gateways/gateways.go b/pkg/deviceclaimingserver/registry/gateways/gateways.go index 61d79bd35d..1a500e86ce 100644 --- a/pkg/deviceclaimingserver/registry/gateways/gateways.go +++ b/pkg/deviceclaimingserver/registry/gateways/gateways.go @@ -45,6 +45,8 @@ type GatewayRegistry interface { Create(ctx context.Context, in *ttnpb.CreateGatewayRequest) (*ttnpb.Gateway, error) // Delete the gateway. This may not release the gateway ID for reuse, but it does release the EUI. Delete(ctx context.Context, in *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) + // Purge the gateway. This will release both gateway ID and EUI for reuse. + Purge(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. @@ -119,6 +121,19 @@ func (reg Registry) Delete(ctx context.Context, req *ttnpb.GatewayIdentifiers) ( return gatewayRegistry.Delete(ctx, req, callOpt) } +// Purge implements GatewayRegistry. +func (reg Registry) Purge(ctx context.Context, req *ttnpb.GatewayIdentifiers) (*emptypb.Empty, 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.Purge(ctx, req, callOpt) +} + // Get implements GatewayRegistry. func (reg Registry) Get(ctx context.Context, req *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) { callOpt, err := reg.callOptFromContext(ctx) diff --git a/pkg/deviceclaimingserver/retry/task.go b/pkg/deviceclaimingserver/retry/task.go new file mode 100644 index 0000000000..ce85cd27ce --- /dev/null +++ b/pkg/deviceclaimingserver/retry/task.go @@ -0,0 +1,62 @@ +// 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 retry provides a task that retries an operation with a backoff. +package retry + +import ( + "context" + "time" + + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/random" +) + +var errOperationUnsuccessful = errors.DefineInternal( + "operation_unsuccessful", + "operation `{name}` unsuccessful after `{number}` attempts", +) + +// Task retries a function with a backoff. +type Task struct { + Name string + F func() (bool, error) + WaitTime time.Duration + MaxAttempts int + Jitter float64 +} + +// Do runs the task function until one of the conditions are met. +func (t Task) Do(ctx context.Context) error { + for count := 0; ; count++ { + retry, err := t.F() + switch { + case !retry: + return err + case retry && count >= t.MaxAttempts: + if err != nil { + return errOperationUnsuccessful.WithAttributes("name", t.Name, "number", t.MaxAttempts).WithCause(err) + } + return errOperationUnsuccessful.WithAttributes("name", t.Name, "number", t.MaxAttempts) + default: + // Retry. This is just for completeness. + } + waitTime := random.Jitter(t.WaitTime, t.Jitter) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(waitTime): + } + } +} diff --git a/pkg/deviceclaimingserver/retry/task_test.go b/pkg/deviceclaimingserver/retry/task_test.go new file mode 100644 index 0000000000..8896d092e5 --- /dev/null +++ b/pkg/deviceclaimingserver/retry/task_test.go @@ -0,0 +1,105 @@ +// 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 retry_test + +import ( + "fmt" + "testing" + + "go.thethings.network/lorawan-stack/v3/pkg/deviceclaimingserver/retry" + "go.thethings.network/lorawan-stack/v3/pkg/errors" + "go.thethings.network/lorawan-stack/v3/pkg/util/test" + "go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should" +) + +var errTestFunction = errors.DefineResourceExhausted("test_function_failed", "test function failed") + +func TestTask(t *testing.T) { // nolint:paralleltest + var attempt int + + a, ctx := test.New(t) + for _, tc := range []struct { // nolint:paralleltest + Name string + f func() (bool, error) + ErrorAssertion func(err error) bool + }{ + { + Name: "CompletedSuccessfully", + f: func() (bool, error) { + return false, nil + }, + }, + { + Name: "CompletedWithError", + f: func() (bool, error) { + return false, errTestFunction.New() + }, + ErrorAssertion: func(err error) bool { + return errors.IsResourceExhausted(err) + }, + }, + { + Name: "Timeout", + f: func() (bool, error) { + return true, nil + }, + ErrorAssertion: func(err error) bool { + return errors.IsInternal(err) + }, + }, + { + Name: "CompletedSuccessfullySecondAttempt", + f: func() (bool, error) { + if attempt == 0 { + attempt++ + return true, nil + } + attempt = 0 // reset for the next case + return false, nil + }, + }, + { + Name: "CompletedWithErrorSecondAttempt", + f: func() (bool, error) { + if attempt == 0 { + attempt++ + return true, nil + } + return false, errTestFunction.New() + }, + ErrorAssertion: func(err error) bool { + return errors.IsResourceExhausted(err) + }, + }, + } { + t.Run(fmt.Sprintf("Setup/%s", tc.Name), func(t *testing.T) { + testTask := retry.Task{ + Name: tc.Name, + F: tc.f, + WaitTime: (1 << 3) * test.Delay, + MaxAttempts: 3, + Jitter: 0.2, + } + err := testTask.Do(ctx) + if err != nil { + if tc.ErrorAssertion == nil || !a.So(tc.ErrorAssertion(err), should.BeTrue) { + t.Fatalf("Unexpected error: %v", err) + } + } else if tc.ErrorAssertion != nil { + a.So(tc.ErrorAssertion(err), should.BeTrue) + } + }) + } +} diff --git a/pkg/deviceclaimingserver/types/types.go b/pkg/deviceclaimingserver/types/types.go index 6f68b521f7..8ea31a6fef 100644 --- a/pkg/deviceclaimingserver/types/types.go +++ b/pkg/deviceclaimingserver/types/types.go @@ -17,49 +17,8 @@ package types import ( "go.thethings.network/lorawan-stack/v3/pkg/ttnpb" - "go.thethings.network/lorawan-stack/v3/pkg/types" ) -// EUI64Range is a range of EUI64s. -type EUI64Range interface { - // Contains returns true if the EUI64 is in the range. - Contains(types.EUI64) bool -} - -type eui64PrefixRange types.EUI64Prefix - -var _ EUI64Range = eui64PrefixRange{} - -// Contains implements EUI64Range. -func (r eui64PrefixRange) Contains(eui types.EUI64) bool { - return eui.HasPrefix(types.EUI64Prefix(r)) -} - -// RangeFromEUI64Prefix returns a range that contains all EUI64s with the given prefix. -func RangeFromEUI64Prefix(prefix types.EUI64Prefix) EUI64Range { - return eui64PrefixRange(prefix) -} - -type eui64Range struct { - start, end uint64 -} - -var _ EUI64Range = eui64Range{} - -// Contains implements EUI64Range. -func (r eui64Range) Contains(eui types.EUI64) bool { - n := eui.MarshalNumber() - return n >= r.start && n <= r.end -} - -// RangeFromEUI64Range returns a range that contains all EUI64s between start and end. -func RangeFromEUI64Range(start, end types.EUI64) EUI64Range { - return eui64Range{ - start: start.MarshalNumber(), - end: end.MarshalNumber(), - } -} - // GatewayMetadata contains metadata of a gateway, typically returned on claiming. type GatewayMetadata struct { Antennas []*ttnpb.GatewayAntenna diff --git a/pkg/deviceclaimingserver/util_test.go b/pkg/deviceclaimingserver/util_test.go index 5f0d119a30..317d041c2c 100644 --- a/pkg/deviceclaimingserver/util_test.go +++ b/pkg/deviceclaimingserver/util_test.go @@ -114,6 +114,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) + purgeFunc 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) } @@ -121,6 +122,7 @@ type mockGatewayRegistry struct { var ( errInvalidCredentials = errors.DefineUnauthenticated("invalid_credentials", "invalid credentials") errGatewayNotFound = errors.DefineNotFound("gateway_not_found", "gateway not found") + errNoRights = errors.DefinePermissionDenied("no_rights", "no rights") errClaim = errors.DefineAborted("claim", "claim") errCreate = errors.DefineAborted("create_gateway", "create gateway") errUpdate = errors.DefineAborted("update_gateway", "update gateway") @@ -163,6 +165,11 @@ func (mock mockGatewayRegistry) Delete(ctx context.Context, in *ttnpb.GatewayIde return mock.deleteFunc(ctx, in) } +// Purge implements GatewayRegistry. +func (mock mockGatewayRegistry) Purge(ctx context.Context, in *ttnpb.GatewayIdentifiers) (*emptypb.Empty, error) { + return mock.purgeFunc(ctx, in) +} + // Get implements GatewayRegistry. func (mock mockGatewayRegistry) Get(ctx context.Context, in *ttnpb.GetGatewayRequest) (*ttnpb.Gateway, error) { return mock.getFunc(ctx, in) diff --git a/pkg/gatewayconfigurationserver/managed/grpc.go b/pkg/gatewayconfigurationserver/managed/grpc.go index 128b15a16f..5ca33b697a 100644 --- a/pkg/gatewayconfigurationserver/managed/grpc.go +++ b/pkg/gatewayconfigurationserver/managed/grpc.go @@ -31,8 +31,8 @@ type managedGCSServer struct { Component ttnpb.UnsafeManagedGatewayConfigurationServiceServer - gatewayEUIs []types.EUI64Prefix - client *ttgc.Client + managedGatewayEUIs []types.EUI64Range + client *ttgc.Client } var ( @@ -45,7 +45,8 @@ var ( // managedGatewayID looks up the gateway EUI for the given gateway identifiers and returns a copy of the gateway // identifiers with the EUI filled. -// If the EUI does not match the prefix configured for The Things Gateway Controller, this method returns NotFound. +// If the EUI is not in the managed gateway EUI ranges configured for The Things Gateway Controller, +// this method returns NotFound. func (s *managedGCSServer) managedGatewayID( ctx context.Context, ids *ttnpb.GatewayIdentifiers, ) (*ttnpb.GatewayIdentifiers, error) { @@ -68,16 +69,16 @@ func (s *managedGCSServer) managedGatewayID( return nil, errGatewayNotManaged.WithAttributes("gateway_id", ids.GatewayId) } var ( - matchesPrefix bool - eui = types.MustEUI64(gtw.Ids.Eui).OrZero() + matchesRange bool + eui = types.MustEUI64(gtw.Ids.Eui).OrZero() ) - for _, prefix := range s.gatewayEUIs { - if prefix.Matches(eui) { - matchesPrefix = true + for _, r := range s.managedGatewayEUIs { + if r.Contains(eui) { + matchesRange = true break } } - if !matchesPrefix { + if !matchesRange { return nil, errGatewayNotManaged.WithAttributes("gateway_id", ids.GatewayId) } return &ttnpb.GatewayIdentifiers{ diff --git a/pkg/gatewayconfigurationserver/managed/managed.go b/pkg/gatewayconfigurationserver/managed/managed.go index 97704836ca..9c2942eb2f 100644 --- a/pkg/gatewayconfigurationserver/managed/managed.go +++ b/pkg/gatewayconfigurationserver/managed/managed.go @@ -64,9 +64,9 @@ func New(ctx context.Context, c Component, conf ttgc.Config) (*Server, error) { return nil, err } srv.grpc.server = &managedGCSServer{ - Component: c, - client: client, - gatewayEUIs: conf.GatewayEUIs, + Component: c, + client: client, + managedGatewayEUIs: conf.ManagedGatewayEUIs, } srv.grpc.wifiProfiles = &managedGatewayWiFiProfileServer{ client: client, diff --git a/pkg/ttgc/config.go b/pkg/ttgc/config.go index 4e1dced72d..9b6c526e6c 100644 --- a/pkg/ttgc/config.go +++ b/pkg/ttgc/config.go @@ -19,11 +19,19 @@ import ( "go.thethings.network/lorawan-stack/v3/pkg/types" ) +// LBSCUPSConfig is the configuration for LoRa Basics Station gateways that connect +// to The Things Gateway Controller via CUPS. +type LBSCUPSConfig struct { + LNSPort uint16 `name:"lns-port" description:"LoRa Basics Station LNS port of the Gateway Server"` +} + // 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.EUI64Range `name:"gateway-euis" description:"Gateway EUI prefixes or ranges of gateways that connect to The Things Gateway Controller"` //nolint:lll + ManagedGatewayEUIs []types.EUI64Range `name:"managed-gateway-euis" description:"Gateway EUI prefixes or ranges of managed gateways"` //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"` + LBSCUPS LBSCUPSConfig `name:"lbscups" description:"Configuration for LoRa Basics Station gateways connecting via CUPS"` //nolint:lll } diff --git a/pkg/types/eui.go b/pkg/types/eui.go index e6becc8948..464e95f8b2 100644 --- a/pkg/types/eui.go +++ b/pkg/types/eui.go @@ -273,6 +273,9 @@ func (prefix *EUI64Prefix) UnmarshalJSON(data []byte) error { if err != nil { return err } + if length < 0 || length > 64 { + return errInvalidEUIPrefix.New() + } prefix.Length = uint8(length) return nil } @@ -324,11 +327,17 @@ func (prefix *EUI64Prefix) UnmarshalText(data []byte) error { return err } // transform length from number character range - if len(data) == 18 { - prefix.Length = data[17] - '0' - } else { - prefix.Length = (data[17]-'0')*10 + (data[18] - '0') + var length uint8 + for _, c := range data[17:] { + if c < '0' || c > '9' { + return errInvalidEUIPrefix.New() + } + length = length*10 + (c - '0') } + if length > 64 { + return errInvalidEUIPrefix.New() + } + prefix.Length = length return nil } @@ -369,6 +378,127 @@ func (prefix EUI64Prefix) Matches(eui EUI64) bool { return eui.Mask(prefix.Length) == prefix.EUI64.Mask(prefix.Length) } +// EUI64Range returns the range that contains all EUI64s with the prefix. +func (prefix EUI64Prefix) EUI64Range() EUI64Range { + start := prefix.EUI64.Mask(prefix.Length).MarshalNumber() + return EUI64Range{ + start: start, + end: start | (^uint64(0) >> prefix.Length), + prefixLength: prefix.Length, + isPrefix: true, + } +} + +// EUI64Range is an inclusive range of EUI64s. +type EUI64Range struct { + start, end uint64 + // prefixLength is set if the range represents an EUI64 prefix. + prefixLength uint8 + isPrefix bool +} + +// EUI64RangeFromInterval returns the range that contains all EUI64s between start and end, inclusive. +func EUI64RangeFromInterval(start, end EUI64) EUI64Range { + return EUI64Range{ + start: start.MarshalNumber(), + end: end.MarshalNumber(), + } +} + +// Contains returns true if the EUI64 is in the range. +func (r EUI64Range) Contains(eui EUI64) bool { + n := eui.MarshalNumber() + return n >= r.start && n <= r.end +} + +func (r EUI64Range) String() string { + var start EUI64 + start.UnmarshalNumber(r.start) + if r.isPrefix { + return EUI64Prefix{EUI64: start, Length: r.prefixLength}.String() + } + var end EUI64 + end.UnmarshalNumber(r.end) + return start.String() + "-" + end.String() +} + +// ConfigString implements the config.Stringer interface. +func (r EUI64Range) ConfigString() string { + return r.String() +} + +// UnmarshalConfigString implements the config.Configurable interface. +func (r *EUI64Range) UnmarshalConfigString(s string) error { + parsed, err := ParseEUI64Range(s) + if err != nil { + return err + } + *r = parsed + return nil +} + +var errInvalidEUI64Range = errors.DefineInvalidArgument("invalid_eui64_range", "invalid EUI64 range `{value}`") + +// ParseEUI64Range parses an EUI64 range from a string. Supported formats are a prefix +// (e.g. "58A0CBFFFE800000/48") and an inclusive range (e.g. "001616FFFE300500-001616FFFE30FFFF"). +func ParseEUI64Range(val string) (EUI64Range, error) { + switch { + case strings.Contains(val, "/"): + var prefix EUI64Prefix + if err := prefix.UnmarshalText([]byte(val)); err != nil { + return EUI64Range{}, errInvalidEUI64Range.WithAttributes("value", val).WithCause(err) + } + return prefix.EUI64Range(), nil + case strings.Contains(val, "-"): + parts := strings.Split(val, "-") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return EUI64Range{}, errInvalidEUI64Range.WithAttributes("value", val) + } + var start, end EUI64 + if err := start.UnmarshalText([]byte(parts[0])); err != nil { + return EUI64Range{}, errInvalidEUI64Range.WithAttributes("value", val).WithCause(err) + } + if err := end.UnmarshalText([]byte(parts[1])); err != nil { + return EUI64Range{}, errInvalidEUI64Range.WithAttributes("value", val).WithCause(err) + } + if start.MarshalNumber() > end.MarshalNumber() { + return EUI64Range{}, errInvalidEUI64Range.WithAttributes("value", val) + } + return EUI64RangeFromInterval(start, end), nil + default: + return EUI64Range{}, errInvalidEUI64Range.WithAttributes("value", val) + } +} + +// ParseEUI64Ranges parses EUI64 ranges from strings. See ParseEUI64Range for the supported formats. +func ParseEUI64Ranges(vals []string) ([]EUI64Range, error) { + ranges := make([]EUI64Range, len(vals)) + for i, val := range vals { + r, err := ParseEUI64Range(val) + if err != nil { + return nil, err + } + ranges[i] = r + } + return ranges, nil +} + +var errInvalidEUI64Ranges = errors.DefineInvalidArgument("invalid_eui64_ranges", "invalid EUI64 ranges of `{name}`") + +// ParseEUI64RangesMap parses named lists of EUI64 ranges from strings. +// See ParseEUI64Range for the supported formats. +func ParseEUI64RangesMap(conf map[string][]string) (map[string][]EUI64Range, error) { + res := make(map[string][]EUI64Range, len(conf)) + for name, vals := range conf { + ranges, err := ParseEUI64Ranges(vals) + if err != nil { + return nil, errInvalidEUI64Ranges.WithAttributes("name", name).WithCause(err) + } + res[name] = ranges + } + return res, nil +} + // Copy stores a copy of eui in x and returns it. func (eui EUI64) Copy(x *EUI64) *EUI64 { copy(x[:], eui[:]) diff --git a/pkg/types/eui_test.go b/pkg/types/eui_test.go index a128f34c0f..06d8be930b 100644 --- a/pkg/types/eui_test.go +++ b/pkg/types/eui_test.go @@ -27,6 +27,8 @@ import ( var ( _ config.Configurable = &EUI64Prefix{} _ config.Stringer = EUI64Prefix{} + _ config.Configurable = &EUI64Range{} + _ config.Stringer = EUI64Range{} ) func TestEUI64(t *testing.T) { @@ -120,3 +122,241 @@ func TestEUI64(t *testing.T) { a.So(decodedEUI, should.Equal, eui) }) } + +func TestParseEUI64Range(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + for _, tc := range []struct { + value string + str string // Expected canonical string, if different from value. + contains []EUI64 + excludes []EUI64 + }{ + { + value: "001616FFFE42DFAD-001616FFFE42E395", + contains: []EUI64{ + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x42, 0xDF, 0xAD}, + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x42, 0xE0, 0x00}, + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x42, 0xE3, 0x95}, + }, + excludes: []EUI64{ + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x42, 0xDF, 0xAC}, + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x42, 0xE3, 0x96}, + }, + }, + { + value: "001616FFFE300500-001616FFFE30FFFF", + contains: []EUI64{ + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x30, 0x05, 0x00}, + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x30, 0xFF, 0xFF}, + }, + excludes: []EUI64{ + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x30, 0x04, 0xFF}, + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x31, 0x00, 0x00}, + }, + }, + { + value: "001616FFFE2A32AA-001616FFFE2F5454", + contains: []EUI64{ + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x2A, 0x32, 0xAA}, + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x2B, 0x00, 0x00}, + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x2F, 0x54, 0x54}, + }, + excludes: []EUI64{ + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x2A, 0x32, 0xA9}, + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x2F, 0x54, 0x55}, + }, + }, + { + value: "58A0CBFFFE800000/48", + contains: []EUI64{ + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x00}, + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0xFF, 0xFF}, + }, + excludes: []EUI64{ + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x81, 0x00, 0x00}, + {0x58, 0xA0, 0xCB, 0xFF, 0xFD, 0x80, 0x00, 0x00}, + }, + }, + { + value: "EC656EFFFE000000/40", + contains: []EUI64{ + {0xEC, 0x65, 0x6E, 0xFF, 0xFE, 0x00, 0x00, 0x00}, + {0xEC, 0x65, 0x6E, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF}, + }, + excludes: []EUI64{ + {0xEC, 0x65, 0x6E, 0xFF, 0xFF, 0x00, 0x00, 0x00}, + {0xEC, 0x65, 0x6D, 0xFF, 0xFE, 0x00, 0x00, 0x00}, + }, + }, + // A prefix of the full EUI64 length contains only the exact EUI64. + { + value: "58A0CBFFFE800000/64", + contains: []EUI64{ + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x00}, + }, + excludes: []EUI64{ + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x01}, + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF}, + }, + }, + // A zero length prefix contains all EUI64s. + { + value: "0000000000000000/0", + contains: []EUI64{ + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x00}, + {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, + }, + }, + // Bits beyond the prefix length are masked out. + { + value: "58A0CBFFFE80FFFF/48", + str: "58A0CBFFFE800000/48", + contains: []EUI64{ + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x00}, + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0xFF, 0xFF}, + }, + excludes: []EUI64{ + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x81, 0x00, 0x00}, + }, + }, + // Lowercase hexadecimal is accepted and canonicalized to uppercase. + { + value: "58a0cbfffe800000/48", + str: "58A0CBFFFE800000/48", + contains: []EUI64{ + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x00}, + }, + }, + { + value: "001616fffe42dfad-001616fffe42e395", + str: "001616FFFE42DFAD-001616FFFE42E395", + contains: []EUI64{ + {0x00, 0x16, 0x16, 0xFF, 0xFE, 0x42, 0xE0, 0x00}, + }, + }, + // The start of the range equals the end. + { + value: "58A0CBFFFE800000-58A0CBFFFE800000", + contains: []EUI64{ + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x00}, + }, + excludes: []EUI64{ + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF}, + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x01}, + }, + }, + // The range covers the full EUI64 space without overflowing. + { + value: "0000000000000000-FFFFFFFFFFFFFFFF", + contains: []EUI64{ + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x00}, + {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, + }, + }, + } { + r, err := ParseEUI64Range(tc.value) + if !a.So(err, should.BeNil) { + t.Fatalf("Failed to parse %q: %v", tc.value, err) + } + for _, eui := range tc.contains { + a.So(r.Contains(eui), should.BeTrue) + } + for _, eui := range tc.excludes { + a.So(r.Contains(eui), should.BeFalse) + } + // The string representation must be canonical and must round-trip. + str := tc.str + if str == "" { + str = tc.value + } + a.So(r.String(), should.Equal, str) + roundtrip, err := ParseEUI64Range(str) + if a.So(err, should.BeNil) { + a.So(roundtrip, should.Resemble, r) + } + var configured EUI64Range + if err := configured.UnmarshalConfigString(tc.value); a.So(err, should.BeNil) { + a.So(configured, should.Resemble, r) + } + } + + for _, value := range []string{ + "", + "58A0CBFFFE800000", + "58A0CBFFFE800000/", + // The prefix length is out of bounds. + "58A0CBFFFE800000/65", + "58A0CBFFFE800000/99", + "58A0CBFFFE800000/-1", + "58A0CBFFFE800000/4X", + "58A0CBFFFE800000/123456", + // The EUI64 of the prefix is too short or too long. + "58A0CBFFFE8000/48", + "58A0CBFFFE80000000/48", + // The range has too many or empty parts. + "58A0CBFFFE800000-58A0CBFFFE800000-58A0CBFFFE800000", + "-58A0CBFFFE800000", + "58A0CBFFFE800000-", + "-", + // The range bounds are not valid EUI64s. + "001616FFFEWXUSDA-001616FFFETGENDE", + "001616FFFE42DFAD-001616FFFETGENDE", + "58A0CBFFFE8000-58A0CBFFFE80FFFF", + " 58A0CBFFFE800000-58A0CBFFFE80FFFF", + // The start of the range is after the end. + "001616FFFE42E395-001616FFFE42DFAD", + "FFFFFFFFFFFFFFFF-0000000000000000", + } { + _, err := ParseEUI64Range(value) + if !a.So(err, should.NotBeNil) { + t.Fatalf("Expected error for %q", value) + } + } +} + +func TestParseEUI64RangesMap(t *testing.T) { + t.Parallel() + a := assertions.New(t) + + m, err := ParseEUI64RangesMap(map[string][]string{ + "ttgc": { + "58A0CBFFFE800000/48", + "001616FFFE42DFAD-001616FFFE42E395", + }, + "semtech-rjs": { + "EC656EFFFE000000/40", + }, + }) + if a.So(err, should.BeNil) { + a.So(m, should.Resemble, map[string][]EUI64Range{ + "ttgc": { + EUI64Prefix{ + EUI64: EUI64{0x58, 0xA0, 0xCB, 0xFF, 0xFE, 0x80, 0x00, 0x00}, + Length: 48, + }.EUI64Range(), + EUI64RangeFromInterval( + EUI64{0x00, 0x16, 0x16, 0xFF, 0xFE, 0x42, 0xDF, 0xAD}, + EUI64{0x00, 0x16, 0x16, 0xFF, 0xFE, 0x42, 0xE3, 0x95}, + ), + }, + "semtech-rjs": { + EUI64Prefix{ + EUI64: EUI64{0xEC, 0x65, 0x6E, 0xFF, 0xFE, 0x00, 0x00, 0x00}, + Length: 40, + }.EUI64Range(), + }, + }) + } + + _, err = ParseEUI64RangesMap(map[string][]string{"ttgc": {"invalid"}}) + a.So(err, should.NotBeNil) + + m, err = ParseEUI64RangesMap(nil) + if a.So(err, should.BeNil) { + a.So(m, should.BeEmpty) + } +} diff --git a/pkg/webui/locales/ja.json b/pkg/webui/locales/ja.json index 59d2673406..5e72c0823e 100644 --- a/pkg/webui/locales/ja.json +++ b/pkg/webui/locales/ja.json @@ -2338,10 +2338,12 @@ "error:pkg/deviceclaimingserver/gateways/ttgc:verify_gateway_server_tls": "", "error:pkg/deviceclaimingserver/gateways:invalid_upstream": "", "error:pkg/deviceclaimingserver/gateways:ttgc_not_enabled": "", + "error:pkg/deviceclaimingserver/retry:operation_unsuccessful": "", "error:pkg/deviceclaimingserver:claim gateway": "", "error:pkg/deviceclaimingserver:claiming_not_supported": "JoinEUI `{eui}` に対してクレームはサポートされていません", "error:pkg/deviceclaimingserver:create_gateway": "", "error:pkg/deviceclaimingserver:device_not_found": "デバイスが見つかりません", + "error:pkg/deviceclaimingserver:fetch_created_gateway": "", "error:pkg/deviceclaimingserver:gateway_already_exists": "", "error:pkg/deviceclaimingserver:gateway_claiming_not_supported": "", "error:pkg/deviceclaimingserver:gateway_claiming_with_qrcodes_not_implemented": "", @@ -2351,6 +2353,7 @@ "error:pkg/deviceclaimingserver:no_join_eui": "リクエストからJoinEUIを抽出", "error:pkg/deviceclaimingserver:parse_qr_code": "QRコードの解析に失敗しました", "error:pkg/deviceclaimingserver:qr_code_data": "無効なQRコードデータ", + "error:pkg/deviceclaimingserver:update_gateway": "", "error:pkg/devicerepository/store/bleve:cannot_open_index": "インデックスを開ません", "error:pkg/devicerepository/store/bleve:corrupted_index": "インデックスファイルが破損しています", "error:pkg/devicerepository/store/bleve:end_device_profile_not_found": "ベンダーID `{vendor_id}` およびベンダープロファイルID `{vendor_profile_id}` のエンドデバイスプロファイルが見つかりません", @@ -2974,6 +2977,8 @@ "error:pkg/types:invalid_dev_addr": "無効なDevAddr", "error:pkg/types:invalid_dev_nonce": "無効なDevNonce", "error:pkg/types:invalid_eui": "無効なEUI", + "error:pkg/types:invalid_eui64_range": "", + "error:pkg/types:invalid_eui64_ranges": "", "error:pkg/types:invalid_join_nonce": "無効なJoinNonce", "error:pkg/types:invalid_json": "無効なJSON: `{json}`", "error:pkg/types:invalid_length": "無効なスライス長",