From e219e36f34a5d0bc28b12fb3beabceca0a1d098b Mon Sep 17 00:00:00 2001 From: Ankit Goswami Date: Fri, 5 Jun 2026 15:18:10 -0700 Subject: [PATCH] feat(fleetnode): pair discovered miners (orchestration + operator RPCs) Cloud orchestration for pairing fleet-node-discovered miners over the ControlStream, built on the #388 foundation (AgentCommand pair arm, node pairer, SQL listing + cloud-dial guards). Server domain: - PersistFleetNodePairResult records each device's outcome in its own transaction: PAIRED creates the device, binds it to the node before marking device_pairing PAIRED (so the cloud-paired guard never sees a PAIRED-but-unbound row), seeds an ACTIVE status, and stores encrypted credentials for password-auth drivers only; AUTH_NEEDED/AUTH_FAILED persist AUTHENTICATION_NEEDED for retry; ERROR persists nothing. It never downgrades an already-PAIRED device (race with the cloud or another node) and preserves a learned serial when a report omits it. - ResolvePairTargets builds the batch from the node's not-yet-paired devices; pair-all without credentials excludes AUTHENTICATION_NEEDED rows so a capped batch can't starve never-attempted devices on re-issue for large fleets. - pairDeviceLocked is factored out of PairDevice so both share the binding logic. Registry + dispatch (authoritative, decoupled from the operator stream): - The gateway ReportPairedDevices handler is the source of truth: it admits + scopes results to the dispatched targets (consuming each to bar replay), rejects empty batches, enforces a per-command quota = target count, persists each result before acking, and forwards only successfully-persisted results (with the persisted status) for live display. - PublishPairResults / report-kind admission keep discovery and pairing reports from cross-admitting. - The operator command is dispatched on a disconnect-immune context so pairing runs to completion server-side and the gateway keeps persisting even if the operator/browser disconnects; the admin stream is a best-effort observer. Operator admin RPCs: - ListFleetNodeDiscoveredDevices (paged) and streaming PairDiscoveredDevicesOnFleetNode, gated by miner:pair + fleetnode:manage, session-only, with credential bodies redacted/suppressed in logs. - DevicePairingResult.pairing_status uses the shared fleetmanagement PairingStatus enum. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fleetnodeadmin/v1/fleetnodeadmin_pb.ts | 4 +- .../v1/fleetnodegateway_pb.ts | 60 +- proto/fleetnodeadmin/v1/fleetnodeadmin.proto | 4 +- .../v1/fleetnodegateway.proto | 26 +- server/cmd/fleetd/main.go | 2 + server/cmd/fleetnode/control_test.go | 7 +- server/cmd/fleetnode/pair.go | 87 ++- server/cmd/fleetnode/pair_test.go | 257 ++++++++ server/cmd/fleetnode/run.go | 1 + .../fleetnodeadmin/v1/fleetnodeadmin.pb.go | 4 +- .../v1/fleetnodegateway.pb.go | 580 ++++++++++------- server/generated/sqlc/db.go | 20 + server/generated/sqlc/device.sql.go | 36 +- server/generated/sqlc/fleetnodepairing.sql.go | 83 ++- .../domain/fleetnode/control/dispatch.go | 112 ++++ .../dispatch_test.go} | 14 +- .../domain/fleetnode/control/registry.go | 48 +- .../domain/fleetnode/control/registry_test.go | 161 ++++- .../domain/fleetnode/control/session.go | 24 +- .../domain/fleetnode/control/stream.go | 77 ++- .../domain/fleetnode/discovery/service.go | 116 +--- .../fleetnode/pairing/integration_test.go | 7 +- .../domain/fleetnode/pairing/models.go | 14 +- .../fleetnode/pairing/pair_discovered.go | 245 +++++++ .../fleetnode/pairing/pair_discovered_test.go | 614 ++++++++++++++++++ .../domain/fleetnode/pairing/pair_dispatch.go | 97 +++ .../domain/fleetnode/pairing/service.go | 104 +-- .../domain/stores/interfaces/device.go | 3 + .../interfaces/mocks/mock_device_store.go | 15 + .../domain/stores/sqlstores/device.go | 27 +- .../stores/sqlstores/fleetnodepairing.go | 23 +- .../fleetnode/admin/handler_discover_test.go | 2 +- .../handlers/fleetnode/admin/handler_pair.go | 142 ++++ .../fleetnode/admin/handler_pair_test.go | 400 ++++++++++++ .../fleetnode/admin/handler_pairing_test.go | 11 +- .../handlers/fleetnode/gateway/handler.go | 76 ++- .../gateway/handler_controlstream_test.go | 13 +- .../gateway/handler_discovery_test.go | 7 +- .../gateway/handler_pair_report_test.go | 176 +++++ .../internal/handlers/interceptors/config.go | 10 +- .../000084_widen_device_identifier.down.sql | 2 + .../000084_widen_device_identifier.up.sql | 5 + server/sqlc/queries/device.sql | 25 +- server/sqlc/queries/fleetnodepairing.sql | 52 +- 44 files changed, 3277 insertions(+), 516 deletions(-) create mode 100644 server/internal/domain/fleetnode/control/dispatch.go rename server/internal/domain/fleetnode/{discovery/ackfailure_test.go => control/dispatch_test.go} (88%) create mode 100644 server/internal/domain/fleetnode/pairing/pair_discovered.go create mode 100644 server/internal/domain/fleetnode/pairing/pair_discovered_test.go create mode 100644 server/internal/domain/fleetnode/pairing/pair_dispatch.go create mode 100644 server/internal/handlers/fleetnode/admin/handler_pair.go create mode 100644 server/internal/handlers/fleetnode/admin/handler_pair_test.go create mode 100644 server/internal/handlers/fleetnode/gateway/handler_pair_report_test.go create mode 100644 server/migrations/000084_widen_device_identifier.down.sql create mode 100644 server/migrations/000084_widen_device_identifier.up.sql diff --git a/client/src/protoFleet/api/generated/fleetnodeadmin/v1/fleetnodeadmin_pb.ts b/client/src/protoFleet/api/generated/fleetnodeadmin/v1/fleetnodeadmin_pb.ts index 9c326be86..68cd75d35 100644 --- a/client/src/protoFleet/api/generated/fleetnodeadmin/v1/fleetnodeadmin_pb.ts +++ b/client/src/protoFleet/api/generated/fleetnodeadmin/v1/fleetnodeadmin_pb.ts @@ -420,8 +420,8 @@ export type ListFleetNodeDiscoveredDevicesRequest = fleetNodeId: bigint; /** - * Max devices to return; 0 = no limit. A node can discover thousands of - * devices, so operators should page. + * Max devices to return; 0 = server default page size (1024, also the cap). + * A node can discover thousands of devices, so operators page via next_cursor. * * @generated from field: int32 limit = 2; */ diff --git a/client/src/protoFleet/api/generated/fleetnodegateway/v1/fleetnodegateway_pb.ts b/client/src/protoFleet/api/generated/fleetnodegateway/v1/fleetnodegateway_pb.ts index 2eab3b2ee..7f09f4c82 100644 --- a/client/src/protoFleet/api/generated/fleetnodegateway/v1/fleetnodegateway_pb.ts +++ b/client/src/protoFleet/api/generated/fleetnodegateway/v1/fleetnodegateway_pb.ts @@ -21,7 +21,7 @@ import type { Message } from "@bufbuild/protobuf"; export const file_fleetnodegateway_v1_fleetnodegateway: GenFile = /*@__PURE__*/ fileDesc( - "CipmbGVldG5vZGVnYXRld2F5L3YxL2ZsZWV0bm9kZWdhdGV3YXkucHJvdG8SE2ZsZWV0bm9kZWdhdGV3YXkudjEimgEKD1JlZ2lzdGVyUmVxdWVzdBIkChBlbnJvbGxtZW50X3Rva2VuGAEgASgJQgq6SAdyBRAUGIAEEhgKBG5hbWUYAiABKAlCCrpIB3IFEAEY/wESIAoPaWRlbnRpdHlfcHVia2V5GAMgASgMQge6SAR6AmggEiUKFG1pbmVyX3NpZ25pbmdfcHVia2V5GAQgASgMQge6SAR6AmggIokBChBSZWdpc3RlclJlc3BvbnNlEhUKDWZsZWV0X25vZGVfaWQYASABKAMSQAoRZW5yb2xsbWVudF9zdGF0dXMYAiABKA4yJS5mbGVldG5vZGVnYXRld2F5LnYxLkVucm9sbG1lbnRTdGF0dXMSHAoUaWRlbnRpdHlfZmluZ2VycHJpbnQYAyABKAkiWgoZQmVnaW5BdXRoSGFuZHNoYWtlUmVxdWVzdBIbCgdhcGlfa2V5GAEgASgJQgq6SAdyBRAUGIAEEiAKD2lkZW50aXR5X3B1YmtleRgCIAEoDEIHukgEegJoICJfChpCZWdpbkF1dGhIYW5kc2hha2VSZXNwb25zZRIRCgljaGFsbGVuZ2UYASABKAwSLgoKZXhwaXJlc19hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWAocQ29tcGxldGVBdXRoSGFuZHNoYWtlUmVxdWVzdBIcCgljaGFsbGVuZ2UYASABKAxCCbpIBnoEEBAYQBIaCglzaWduYXR1cmUYAiABKAxCB7pIBHoCaEAiZgodQ29tcGxldGVBdXRoSGFuZHNoYWtlUmVzcG9uc2USFQoNc2Vzc2lvbl90b2tlbhgBIAEoCRIuCgpleHBpcmVzX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJtChZVcGxvYWRUZWxlbWV0cnlSZXF1ZXN0EhoKB3BheWxvYWQYASABKAxCCbpIBnoEGICAQBI3CgtjYXB0dXJlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCBrpIA8gBASIxChdVcGxvYWRUZWxlbWV0cnlSZXNwb25zZRIWCg5hY2NlcHRlZF9jb3VudBgBIAEoAyJqChNVcGxvYWRFdmVudHNSZXF1ZXN0EhoKB3BheWxvYWQYASABKAxCCbpIBnoEGICAQBI3CgtjYXB0dXJlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCBrpIA8gBASIuChRVcGxvYWRFdmVudHNSZXNwb25zZRIWCg5hY2NlcHRlZF9jb3VudBgBIAEoAyJNChZVcGxvYWRIZWFydGJlYXRSZXF1ZXN0EjMKB3NlbnRfYXQYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQga6SAPIAQEiSgoXVXBsb2FkSGVhcnRiZWF0UmVzcG9uc2USLwoLcmVjZWl2ZWRfYXQYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIokBCh5SZXBvcnREaXNjb3ZlcmVkRGV2aWNlc1JlcXVlc3QSRwoHZGV2aWNlcxgBIAMoCzIrLmZsZWV0bm9kZWdhdGV3YXkudjEuRGlzY292ZXJlZERldmljZVJlcG9ydEIJukgGkgEDEIAIEh4KCmNvbW1hbmRfaWQYAiABKAlCCrpIB3IFEAEYgAEigwMKFkRpc2NvdmVyZWREZXZpY2VSZXBvcnQSJQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAlCCrpIB3IFEAEY/wESHwoKaXBfYWRkcmVzcxgCIAEoCUILukgIcgYQARgtcAEShgEKBHBvcnQYAyABKAlCeLpIdboBbAoKcG9ydC5yYW5nZRIpcG9ydCBtdXN0IGJlIGEgZGVjaW1hbCBudW1iZXIgaW4gMS4uNjU1MzUaM3RoaXMubWF0Y2hlcygnXlsxLTldWzAtOV0qJCcpICYmIGludCh0aGlzKSA8PSA2NTUzNXIEEAEYBRIbCgp1cmxfc2NoZW1lGAQgASgJQge6SARyAhggEh4KC2RyaXZlcl9uYW1lGAUgASgJQgm6SAZyBBABGDISFwoFbW9kZWwYBiABKAlCCLpIBXIDGP8BEh4KDG1hbnVmYWN0dXJlchgHIAEoCUIIukgFcgMY/wESIgoQZmlybXdhcmVfdmVyc2lvbhgIIAEoCUIIukgFcgMY/wEiUQofUmVwb3J0RGlzY292ZXJlZERldmljZXNSZXNwb25zZRIWCg5hY2NlcHRlZF9jb3VudBgBIAEoAxIWCg5yZWplY3RlZF9jb3VudBgCIAEoAyKsAgoTRmxlZXROb2RlUGFpclJlc3VsdBIlChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCUIKukgHcgUQARj/ARIxCgdvdXRjb21lGAIgASgOMiAuZmxlZXRub2RlZ2F0ZXdheS52MS5QYWlyT3V0Y29tZRIfCg1zZXJpYWxfbnVtYmVyGAMgASgJQgi6SAVyAxj/ARIcCgttYWNfYWRkcmVzcxgEIAEoCUIHukgEcgIYQBIXCgVtb2RlbBgFIAEoCUIIukgFcgMY/wESHgoMbWFudWZhY3R1cmVyGAYgASgJQgi6SAVyAxj/ARIiChBmaXJtd2FyZV92ZXJzaW9uGAcgASgJQgi6SAVyAxj/ARIfCg1lcnJvcl9tZXNzYWdlGAggASgJQgi6SAVyAxiAICKCAQoaUmVwb3J0UGFpcmVkRGV2aWNlc1JlcXVlc3QSHgoKY29tbWFuZF9pZBgBIAEoCUIKukgHcgUQARiAARJECgdyZXN1bHRzGAIgAygLMiguZmxlZXRub2RlZ2F0ZXdheS52MS5GbGVldE5vZGVQYWlyUmVzdWx0Qgm6SAaSAQMQgAgiTQobUmVwb3J0UGFpcmVkRGV2aWNlc1Jlc3BvbnNlEhYKDmFjY2VwdGVkX2NvdW50GAEgASgDEhYKDnJlamVjdGVkX2NvdW50GAIgASgDIokBChRDb250cm9sU3RyZWFtUmVxdWVzdBIyCgVoZWxsbxgBIAEoCzIhLmZsZWV0bm9kZWdhdGV3YXkudjEuQ29udHJvbEhlbGxvSAASLgoDYWNrGAIgASgLMh8uZmxlZXRub2RlZ2F0ZXdheS52MS5Db250cm9sQWNrSABCDQoEa2luZBIFukgCCAEimAEKFUNvbnRyb2xTdHJlYW1SZXNwb25zZRI4CghhY2NlcHRlZBgBIAEoCzIkLmZsZWV0bm9kZWdhdGV3YXkudjEuQ29udHJvbEFjY2VwdGVkSAASNgoHY29tbWFuZBgCIAEoCzIjLmZsZWV0bm9kZWdhdGV3YXkudjEuQ29udHJvbENvbW1hbmRIAEINCgRraW5kEgW6SAIIASIOCgxDb250cm9sSGVsbG8iQgoPQ29udHJvbEFjY2VwdGVkEi8KC3NlcnZlcl90aW1lGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJMCg5Db250cm9sQ29tbWFuZBIeCgpjb21tYW5kX2lkGAEgASgJQgq6SAdyBRABGIABEhoKB3BheWxvYWQYAiABKAxCCbpIBnoEGICAQCKMAQoKQ29udHJvbEFjaxIeCgpjb21tYW5kX2lkGAEgASgJQgq6SAdyBRABGIABEhEKCXN1Y2NlZWRlZBgCIAEoCBIfCg1lcnJvcl9tZXNzYWdlGAMgASgJQgi6SAVyAxiAIBIqCgRjb2RlGAQgASgOMhwuZmxlZXRub2RlZ2F0ZXdheS52MS5BY2tDb2RlKpQBChBFbnJvbGxtZW50U3RhdHVzEiEKHUVOUk9MTE1FTlRfU1RBVFVTX1VOU1BFQ0lGSUVEEAASHQoZRU5ST0xMTUVOVF9TVEFUVVNfUEVORElORxABEh8KG0VOUk9MTE1FTlRfU1RBVFVTX0NPTkZJUk1FRBACEh0KGUVOUk9MTE1FTlRfU1RBVFVTX1JFVk9LRUQQAyqYAQoLUGFpck91dGNvbWUSHAoYUEFJUl9PVVRDT01FX1VOU1BFQ0lGSUVEEAASFwoTUEFJUl9PVVRDT01FX1BBSVJFRBABEhwKGFBBSVJfT1VUQ09NRV9BVVRIX05FRURFRBACEhwKGFBBSVJfT1VUQ09NRV9BVVRIX0ZBSUxFRBADEhYKElBBSVJfT1VUQ09NRV9FUlJPUhAEKuIBCgdBY2tDb2RlEhgKFEFDS19DT0RFX1VOU1BFQ0lGSUVEEAASDwoLQUNLX0NPREVfT0sQARIUChBBQ0tfQ09ERV9QQVJUSUFMEAISGAoUQUNLX0NPREVfQkFEX1JFUVVFU1QQAxIcChhBQ0tfQ09ERV9BR0VOVF9JTkNBUEFCTEUQBBIYChRBQ0tfQ09ERV9TQ0FOX0ZBSUxFRBAFEhoKFkFDS19DT0RFX1JFUE9SVF9GQUlMRUQQBhIVChFBQ0tfQ09ERV9JTlRFUk5BTBAHEhEKDUFDS19DT0RFX0JVU1kQCDKbCAoXRmxlZXROb2RlR2F0ZXdheVNlcnZpY2USVwoIUmVnaXN0ZXISJC5mbGVldG5vZGVnYXRld2F5LnYxLlJlZ2lzdGVyUmVxdWVzdBolLmZsZWV0bm9kZWdhdGV3YXkudjEuUmVnaXN0ZXJSZXNwb25zZRJ1ChJCZWdpbkF1dGhIYW5kc2hha2USLi5mbGVldG5vZGVnYXRld2F5LnYxLkJlZ2luQXV0aEhhbmRzaGFrZVJlcXVlc3QaLy5mbGVldG5vZGVnYXRld2F5LnYxLkJlZ2luQXV0aEhhbmRzaGFrZVJlc3BvbnNlEn4KFUNvbXBsZXRlQXV0aEhhbmRzaGFrZRIxLmZsZWV0bm9kZWdhdGV3YXkudjEuQ29tcGxldGVBdXRoSGFuZHNoYWtlUmVxdWVzdBoyLmZsZWV0bm9kZWdhdGV3YXkudjEuQ29tcGxldGVBdXRoSGFuZHNoYWtlUmVzcG9uc2USbgoPVXBsb2FkVGVsZW1ldHJ5EisuZmxlZXRub2RlZ2F0ZXdheS52MS5VcGxvYWRUZWxlbWV0cnlSZXF1ZXN0GiwuZmxlZXRub2RlZ2F0ZXdheS52MS5VcGxvYWRUZWxlbWV0cnlSZXNwb25zZSgBEmUKDFVwbG9hZEV2ZW50cxIoLmZsZWV0bm9kZWdhdGV3YXkudjEuVXBsb2FkRXZlbnRzUmVxdWVzdBopLmZsZWV0bm9kZWdhdGV3YXkudjEuVXBsb2FkRXZlbnRzUmVzcG9uc2UoARJsCg9VcGxvYWRIZWFydGJlYXQSKy5mbGVldG5vZGVnYXRld2F5LnYxLlVwbG9hZEhlYXJ0YmVhdFJlcXVlc3QaLC5mbGVldG5vZGVnYXRld2F5LnYxLlVwbG9hZEhlYXJ0YmVhdFJlc3BvbnNlEoQBChdSZXBvcnREaXNjb3ZlcmVkRGV2aWNlcxIzLmZsZWV0bm9kZWdhdGV3YXkudjEuUmVwb3J0RGlzY292ZXJlZERldmljZXNSZXF1ZXN0GjQuZmxlZXRub2RlZ2F0ZXdheS52MS5SZXBvcnREaXNjb3ZlcmVkRGV2aWNlc1Jlc3BvbnNlEngKE1JlcG9ydFBhaXJlZERldmljZXMSLy5mbGVldG5vZGVnYXRld2F5LnYxLlJlcG9ydFBhaXJlZERldmljZXNSZXF1ZXN0GjAuZmxlZXRub2RlZ2F0ZXdheS52MS5SZXBvcnRQYWlyZWREZXZpY2VzUmVzcG9uc2USagoNQ29udHJvbFN0cmVhbRIpLmZsZWV0bm9kZWdhdGV3YXkudjEuQ29udHJvbFN0cmVhbVJlcXVlc3QaKi5mbGVldG5vZGVnYXRld2F5LnYxLkNvbnRyb2xTdHJlYW1SZXNwb25zZSgBMAFC+AEKF2NvbS5mbGVldG5vZGVnYXRld2F5LnYxQhVGbGVldG5vZGVnYXRld2F5UHJvdG9QAVpZZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvZmxlZXRub2RlZ2F0ZXdheS92MTtmbGVldG5vZGVnYXRld2F5djGiAgNGWFiqAhNGbGVldG5vZGVnYXRld2F5LlYxygITRmxlZXRub2RlZ2F0ZXdheVxWMeICH0ZsZWV0bm9kZWdhdGV3YXlcVjFcR1BCTWV0YWRhdGHqAhRGbGVldG5vZGVnYXRld2F5OjpWMWIGcHJvdG8z", + "CipmbGVldG5vZGVnYXRld2F5L3YxL2ZsZWV0bm9kZWdhdGV3YXkucHJvdG8SE2ZsZWV0bm9kZWdhdGV3YXkudjEimgEKD1JlZ2lzdGVyUmVxdWVzdBIkChBlbnJvbGxtZW50X3Rva2VuGAEgASgJQgq6SAdyBRAUGIAEEhgKBG5hbWUYAiABKAlCCrpIB3IFEAEY/wESIAoPaWRlbnRpdHlfcHVia2V5GAMgASgMQge6SAR6AmggEiUKFG1pbmVyX3NpZ25pbmdfcHVia2V5GAQgASgMQge6SAR6AmggIokBChBSZWdpc3RlclJlc3BvbnNlEhUKDWZsZWV0X25vZGVfaWQYASABKAMSQAoRZW5yb2xsbWVudF9zdGF0dXMYAiABKA4yJS5mbGVldG5vZGVnYXRld2F5LnYxLkVucm9sbG1lbnRTdGF0dXMSHAoUaWRlbnRpdHlfZmluZ2VycHJpbnQYAyABKAkiWgoZQmVnaW5BdXRoSGFuZHNoYWtlUmVxdWVzdBIbCgdhcGlfa2V5GAEgASgJQgq6SAdyBRAUGIAEEiAKD2lkZW50aXR5X3B1YmtleRgCIAEoDEIHukgEegJoICJfChpCZWdpbkF1dGhIYW5kc2hha2VSZXNwb25zZRIRCgljaGFsbGVuZ2UYASABKAwSLgoKZXhwaXJlc19hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWAocQ29tcGxldGVBdXRoSGFuZHNoYWtlUmVxdWVzdBIcCgljaGFsbGVuZ2UYASABKAxCCbpIBnoEEBAYQBIaCglzaWduYXR1cmUYAiABKAxCB7pIBHoCaEAiZgodQ29tcGxldGVBdXRoSGFuZHNoYWtlUmVzcG9uc2USFQoNc2Vzc2lvbl90b2tlbhgBIAEoCRIuCgpleHBpcmVzX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJtChZVcGxvYWRUZWxlbWV0cnlSZXF1ZXN0EhoKB3BheWxvYWQYASABKAxCCbpIBnoEGICAQBI3CgtjYXB0dXJlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCBrpIA8gBASIxChdVcGxvYWRUZWxlbWV0cnlSZXNwb25zZRIWCg5hY2NlcHRlZF9jb3VudBgBIAEoAyJqChNVcGxvYWRFdmVudHNSZXF1ZXN0EhoKB3BheWxvYWQYASABKAxCCbpIBnoEGICAQBI3CgtjYXB0dXJlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCBrpIA8gBASIuChRVcGxvYWRFdmVudHNSZXNwb25zZRIWCg5hY2NlcHRlZF9jb3VudBgBIAEoAyJNChZVcGxvYWRIZWFydGJlYXRSZXF1ZXN0EjMKB3NlbnRfYXQYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQga6SAPIAQEiSgoXVXBsb2FkSGVhcnRiZWF0UmVzcG9uc2USLwoLcmVjZWl2ZWRfYXQYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIokBCh5SZXBvcnREaXNjb3ZlcmVkRGV2aWNlc1JlcXVlc3QSRwoHZGV2aWNlcxgBIAMoCzIrLmZsZWV0bm9kZWdhdGV3YXkudjEuRGlzY292ZXJlZERldmljZVJlcG9ydEIJukgGkgEDEIAIEh4KCmNvbW1hbmRfaWQYAiABKAlCCrpIB3IFEAEYgAEigwMKFkRpc2NvdmVyZWREZXZpY2VSZXBvcnQSJQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAlCCrpIB3IFEAEY/wESHwoKaXBfYWRkcmVzcxgCIAEoCUILukgIcgYQARgtcAEShgEKBHBvcnQYAyABKAlCeLpIdboBbAoKcG9ydC5yYW5nZRIpcG9ydCBtdXN0IGJlIGEgZGVjaW1hbCBudW1iZXIgaW4gMS4uNjU1MzUaM3RoaXMubWF0Y2hlcygnXlsxLTldWzAtOV0qJCcpICYmIGludCh0aGlzKSA8PSA2NTUzNXIEEAEYBRIbCgp1cmxfc2NoZW1lGAQgASgJQge6SARyAhggEh4KC2RyaXZlcl9uYW1lGAUgASgJQgm6SAZyBBABGDISFwoFbW9kZWwYBiABKAlCCLpIBXIDGP8BEh4KDG1hbnVmYWN0dXJlchgHIAEoCUIIukgFcgMY/wESIgoQZmlybXdhcmVfdmVyc2lvbhgIIAEoCUIIukgFcgMY/wEiUQofUmVwb3J0RGlzY292ZXJlZERldmljZXNSZXNwb25zZRIWCg5hY2NlcHRlZF9jb3VudBgBIAEoAxIWCg5yZWplY3RlZF9jb3VudBgCIAEoAyKWAwoTRmxlZXROb2RlUGFpclJlc3VsdBIlChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCUIKukgHcgUQARj/ARIxCgdvdXRjb21lGAIgASgOMiAuZmxlZXRub2RlZ2F0ZXdheS52MS5QYWlyT3V0Y29tZRIfCg1zZXJpYWxfbnVtYmVyGAMgASgJQgi6SAVyAxj/ARIcCgttYWNfYWRkcmVzcxgEIAEoCUIHukgEcgIYQBIXCgVtb2RlbBgFIAEoCUIIukgFcgMY/wESHgoMbWFudWZhY3R1cmVyGAYgASgJQgi6SAVyAxj/ARIiChBmaXJtd2FyZV92ZXJzaW9uGAcgASgJQgi6SAVyAxj/ARIfCg1lcnJvcl9tZXNzYWdlGAggASgJQgi6SAVyAxiAIBI+ChB1c2VkX2NyZWRlbnRpYWxzGAsgASgLMiQuZmxlZXRub2RlZ2F0ZXdheS52MS5Vc2VkQ3JlZGVudGlhbHNKBAgJEApKBAgKEAtSDXVzZWRfdXNlcm5hbWVSDXVzZWRfcGFzc3dvcmQiSQoPVXNlZENyZWRlbnRpYWxzEhoKCHVzZXJuYW1lGAEgASgJQgi6SAVyAxj/ARIaCghwYXNzd29yZBgCIAEoCUIIukgFcgMYgAgiggEKGlJlcG9ydFBhaXJlZERldmljZXNSZXF1ZXN0Eh4KCmNvbW1hbmRfaWQYASABKAlCCrpIB3IFEAEYgAESRAoHcmVzdWx0cxgCIAMoCzIoLmZsZWV0bm9kZWdhdGV3YXkudjEuRmxlZXROb2RlUGFpclJlc3VsdEIJukgGkgEDEIAIIk0KG1JlcG9ydFBhaXJlZERldmljZXNSZXNwb25zZRIWCg5hY2NlcHRlZF9jb3VudBgBIAEoAxIWCg5yZWplY3RlZF9jb3VudBgCIAEoAyKJAQoUQ29udHJvbFN0cmVhbVJlcXVlc3QSMgoFaGVsbG8YASABKAsyIS5mbGVldG5vZGVnYXRld2F5LnYxLkNvbnRyb2xIZWxsb0gAEi4KA2FjaxgCIAEoCzIfLmZsZWV0bm9kZWdhdGV3YXkudjEuQ29udHJvbEFja0gAQg0KBGtpbmQSBbpIAggBIpgBChVDb250cm9sU3RyZWFtUmVzcG9uc2USOAoIYWNjZXB0ZWQYASABKAsyJC5mbGVldG5vZGVnYXRld2F5LnYxLkNvbnRyb2xBY2NlcHRlZEgAEjYKB2NvbW1hbmQYAiABKAsyIy5mbGVldG5vZGVnYXRld2F5LnYxLkNvbnRyb2xDb21tYW5kSABCDQoEa2luZBIFukgCCAEiDgoMQ29udHJvbEhlbGxvIkIKD0NvbnRyb2xBY2NlcHRlZBIvCgtzZXJ2ZXJfdGltZRgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiTAoOQ29udHJvbENvbW1hbmQSHgoKY29tbWFuZF9pZBgBIAEoCUIKukgHcgUQARiAARIaCgdwYXlsb2FkGAIgASgMQgm6SAZ6BBiAgEAijAEKCkNvbnRyb2xBY2sSHgoKY29tbWFuZF9pZBgBIAEoCUIKukgHcgUQARiAARIRCglzdWNjZWVkZWQYAiABKAgSHwoNZXJyb3JfbWVzc2FnZRgDIAEoCUIIukgFcgMYgCASKgoEY29kZRgEIAEoDjIcLmZsZWV0bm9kZWdhdGV3YXkudjEuQWNrQ29kZSqUAQoQRW5yb2xsbWVudFN0YXR1cxIhCh1FTlJPTExNRU5UX1NUQVRVU19VTlNQRUNJRklFRBAAEh0KGUVOUk9MTE1FTlRfU1RBVFVTX1BFTkRJTkcQARIfChtFTlJPTExNRU5UX1NUQVRVU19DT05GSVJNRUQQAhIdChlFTlJPTExNRU5UX1NUQVRVU19SRVZPS0VEEAMqmAEKC1BhaXJPdXRjb21lEhwKGFBBSVJfT1VUQ09NRV9VTlNQRUNJRklFRBAAEhcKE1BBSVJfT1VUQ09NRV9QQUlSRUQQARIcChhQQUlSX09VVENPTUVfQVVUSF9ORUVERUQQAhIcChhQQUlSX09VVENPTUVfQVVUSF9GQUlMRUQQAxIWChJQQUlSX09VVENPTUVfRVJST1IQBCriAQoHQWNrQ29kZRIYChRBQ0tfQ09ERV9VTlNQRUNJRklFRBAAEg8KC0FDS19DT0RFX09LEAESFAoQQUNLX0NPREVfUEFSVElBTBACEhgKFEFDS19DT0RFX0JBRF9SRVFVRVNUEAMSHAoYQUNLX0NPREVfQUdFTlRfSU5DQVBBQkxFEAQSGAoUQUNLX0NPREVfU0NBTl9GQUlMRUQQBRIaChZBQ0tfQ09ERV9SRVBPUlRfRkFJTEVEEAYSFQoRQUNLX0NPREVfSU5URVJOQUwQBxIRCg1BQ0tfQ09ERV9CVVNZEAgymwgKF0ZsZWV0Tm9kZUdhdGV3YXlTZXJ2aWNlElcKCFJlZ2lzdGVyEiQuZmxlZXRub2RlZ2F0ZXdheS52MS5SZWdpc3RlclJlcXVlc3QaJS5mbGVldG5vZGVnYXRld2F5LnYxLlJlZ2lzdGVyUmVzcG9uc2USdQoSQmVnaW5BdXRoSGFuZHNoYWtlEi4uZmxlZXRub2RlZ2F0ZXdheS52MS5CZWdpbkF1dGhIYW5kc2hha2VSZXF1ZXN0Gi8uZmxlZXRub2RlZ2F0ZXdheS52MS5CZWdpbkF1dGhIYW5kc2hha2VSZXNwb25zZRJ+ChVDb21wbGV0ZUF1dGhIYW5kc2hha2USMS5mbGVldG5vZGVnYXRld2F5LnYxLkNvbXBsZXRlQXV0aEhhbmRzaGFrZVJlcXVlc3QaMi5mbGVldG5vZGVnYXRld2F5LnYxLkNvbXBsZXRlQXV0aEhhbmRzaGFrZVJlc3BvbnNlEm4KD1VwbG9hZFRlbGVtZXRyeRIrLmZsZWV0bm9kZWdhdGV3YXkudjEuVXBsb2FkVGVsZW1ldHJ5UmVxdWVzdBosLmZsZWV0bm9kZWdhdGV3YXkudjEuVXBsb2FkVGVsZW1ldHJ5UmVzcG9uc2UoARJlCgxVcGxvYWRFdmVudHMSKC5mbGVldG5vZGVnYXRld2F5LnYxLlVwbG9hZEV2ZW50c1JlcXVlc3QaKS5mbGVldG5vZGVnYXRld2F5LnYxLlVwbG9hZEV2ZW50c1Jlc3BvbnNlKAESbAoPVXBsb2FkSGVhcnRiZWF0EisuZmxlZXRub2RlZ2F0ZXdheS52MS5VcGxvYWRIZWFydGJlYXRSZXF1ZXN0GiwuZmxlZXRub2RlZ2F0ZXdheS52MS5VcGxvYWRIZWFydGJlYXRSZXNwb25zZRKEAQoXUmVwb3J0RGlzY292ZXJlZERldmljZXMSMy5mbGVldG5vZGVnYXRld2F5LnYxLlJlcG9ydERpc2NvdmVyZWREZXZpY2VzUmVxdWVzdBo0LmZsZWV0bm9kZWdhdGV3YXkudjEuUmVwb3J0RGlzY292ZXJlZERldmljZXNSZXNwb25zZRJ4ChNSZXBvcnRQYWlyZWREZXZpY2VzEi8uZmxlZXRub2RlZ2F0ZXdheS52MS5SZXBvcnRQYWlyZWREZXZpY2VzUmVxdWVzdBowLmZsZWV0bm9kZWdhdGV3YXkudjEuUmVwb3J0UGFpcmVkRGV2aWNlc1Jlc3BvbnNlEmoKDUNvbnRyb2xTdHJlYW0SKS5mbGVldG5vZGVnYXRld2F5LnYxLkNvbnRyb2xTdHJlYW1SZXF1ZXN0GiouZmxlZXRub2RlZ2F0ZXdheS52MS5Db250cm9sU3RyZWFtUmVzcG9uc2UoATABQvgBChdjb20uZmxlZXRub2RlZ2F0ZXdheS52MUIVRmxlZXRub2RlZ2F0ZXdheVByb3RvUAFaWWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2ZsZWV0bm9kZWdhdGV3YXkvdjE7ZmxlZXRub2RlZ2F0ZXdheXYxogIDRlhYqgITRmxlZXRub2RlZ2F0ZXdheS5WMcoCE0ZsZWV0bm9kZWdhdGV3YXlcVjHiAh9GbGVldG5vZGVnYXRld2F5XFYxXEdQQk1ldGFkYXRh6gIURmxlZXRub2RlZ2F0ZXdheTo6VjFiBnByb3RvMw", [file_buf_validate_validate, file_google_protobuf_timestamp], ); @@ -426,7 +426,7 @@ export type FleetNodePairResult = Message<"fleetnodegateway.v1.FleetNodePairResu outcome: PairOutcome; /** - * Identity learned during pairing; populated on PAIRED. Never carries credentials. + * Identity learned during pairing; populated on PAIRED. * * @generated from field: string serial_number = 3; */ @@ -456,6 +456,18 @@ export type FleetNodePairResult = Message<"fleetnodegateway.v1.FleetNodePairResu * @generated from field: string error_message = 8; */ errorMessage: string; + + /** + * The credentials the node authenticated with. Set for basic-auth drivers + * (operator-supplied OR plugin defaults) and absent for asymmetric-auth + * drivers, which pair with the node's signing key and carry no credentials. + * Presence is meaningful: a present message -- even with an empty username or + * password -- means the cloud must persist it as the device's auth material; + * absent means store nothing. + * + * @generated from field: fleetnodegateway.v1.UsedCredentials used_credentials = 11; + */ + usedCredentials?: UsedCredentials | undefined; }; /** @@ -466,6 +478,34 @@ export const FleetNodePairResultSchema: GenMessage = /*@__PURE__*/ messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 15); +/** + * UsedCredentials carries the basic-auth credentials a node authenticated with, + * echoed so the cloud can persist working auth material for the device. Capped to + * bound the ReportPairedDevices payload. The node refuses to pair (reports ERROR) + * rather than authenticate with a credential it cannot report within these caps. + * + * @generated from message fleetnodegateway.v1.UsedCredentials + */ +export type UsedCredentials = Message<"fleetnodegateway.v1.UsedCredentials"> & { + /** + * @generated from field: string username = 1; + */ + username: string; + + /** + * @generated from field: string password = 2; + */ + password: string; +}; + +/** + * Describes the message fleetnodegateway.v1.UsedCredentials. + * Use `create(UsedCredentialsSchema)` to create a new message. + */ +export const UsedCredentialsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 16); + /** * @generated from message fleetnodegateway.v1.ReportPairedDevicesRequest */ @@ -491,7 +531,7 @@ export type ReportPairedDevicesRequest = Message<"fleetnodegateway.v1.ReportPair */ export const ReportPairedDevicesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 16); + messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 17); /** * @generated from message fleetnodegateway.v1.ReportPairedDevicesResponse @@ -514,7 +554,7 @@ export type ReportPairedDevicesResponse = Message<"fleetnodegateway.v1.ReportPai */ export const ReportPairedDevicesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 17); + messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 18); /** * @generated from message fleetnodegateway.v1.ControlStreamRequest @@ -547,7 +587,7 @@ export type ControlStreamRequest = Message<"fleetnodegateway.v1.ControlStreamReq */ export const ControlStreamRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 18); + messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 19); /** * @generated from message fleetnodegateway.v1.ControlStreamResponse @@ -580,7 +620,7 @@ export type ControlStreamResponse = Message<"fleetnodegateway.v1.ControlStreamRe */ export const ControlStreamResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 19); + messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 20); /** * @generated from message fleetnodegateway.v1.ControlHello @@ -593,7 +633,7 @@ export type ControlHello = Message<"fleetnodegateway.v1.ControlHello"> & {}; */ export const ControlHelloSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 20); + messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 21); /** * @generated from message fleetnodegateway.v1.ControlAccepted @@ -611,7 +651,7 @@ export type ControlAccepted = Message<"fleetnodegateway.v1.ControlAccepted"> & { */ export const ControlAcceptedSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 21); + messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 22); /** * @generated from message fleetnodegateway.v1.ControlCommand @@ -634,7 +674,7 @@ export type ControlCommand = Message<"fleetnodegateway.v1.ControlCommand"> & { */ export const ControlCommandSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 22); + messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 23); /** * @generated from message fleetnodegateway.v1.ControlAck @@ -667,7 +707,7 @@ export type ControlAck = Message<"fleetnodegateway.v1.ControlAck"> & { */ export const ControlAckSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 23); + messageDesc(file_fleetnodegateway_v1_fleetnodegateway, 24); /** * @generated from enum fleetnodegateway.v1.EnrollmentStatus diff --git a/proto/fleetnodeadmin/v1/fleetnodeadmin.proto b/proto/fleetnodeadmin/v1/fleetnodeadmin.proto index a40a7c47c..2e1595d95 100644 --- a/proto/fleetnodeadmin/v1/fleetnodeadmin.proto +++ b/proto/fleetnodeadmin/v1/fleetnodeadmin.proto @@ -113,8 +113,8 @@ message DiscoverOnFleetNodeResponse { message ListFleetNodeDiscoveredDevicesRequest { // 0 = all fleet nodes in org; > 0 restricts to that fleet node. int64 fleet_node_id = 1 [(buf.validate.field).int64.gte = 0]; - // Max devices to return; 0 = no limit. A node can discover thousands of - // devices, so operators should page. + // Max devices to return; 0 = server default page size (1024, also the cap). + // A node can discover thousands of devices, so operators page via next_cursor. int32 limit = 2 [(buf.validate.field).int32 = {gte: 0, lte: 1024}]; // Forward cursor: pass the previous response's next_cursor; 0 = first page. int64 cursor = 3 [(buf.validate.field).int64.gte = 0]; diff --git a/proto/fleetnodegateway/v1/fleetnodegateway.proto b/proto/fleetnodegateway/v1/fleetnodegateway.proto index 70c14473b..f0684946e 100644 --- a/proto/fleetnodegateway/v1/fleetnodegateway.proto +++ b/proto/fleetnodegateway/v1/fleetnodegateway.proto @@ -144,13 +144,37 @@ enum PairOutcome { message FleetNodePairResult { string device_identifier = 1 [(buf.validate.field).string = {min_len: 1, max_len: 255}]; PairOutcome outcome = 2; - // Identity learned during pairing; populated on PAIRED. Never carries credentials. + // Identity learned during pairing; populated on PAIRED. string serial_number = 3 [(buf.validate.field).string.max_len = 255]; string mac_address = 4 [(buf.validate.field).string.max_len = 64]; string model = 5 [(buf.validate.field).string.max_len = 255]; string manufacturer = 6 [(buf.validate.field).string.max_len = 255]; string firmware_version = 7 [(buf.validate.field).string.max_len = 255]; string error_message = 8 [(buf.validate.field).string.max_len = 4096]; + + // Flat scalars couldn't distinguish a basic-auth credential with an empty + // username/password from an absent one, so a successful empty-credential + // pairing was persisted with no auth material. Replaced by used_credentials, + // whose message presence carries that distinction. + reserved 9, 10; + reserved "used_username", "used_password"; + + // The credentials the node authenticated with. Set for basic-auth drivers + // (operator-supplied OR plugin defaults) and absent for asymmetric-auth + // drivers, which pair with the node's signing key and carry no credentials. + // Presence is meaningful: a present message -- even with an empty username or + // password -- means the cloud must persist it as the device's auth material; + // absent means store nothing. + UsedCredentials used_credentials = 11; +} + +// UsedCredentials carries the basic-auth credentials a node authenticated with, +// echoed so the cloud can persist working auth material for the device. Capped to +// bound the ReportPairedDevices payload. The node refuses to pair (reports ERROR) +// rather than authenticate with a credential it cannot report within these caps. +message UsedCredentials { + string username = 1 [(buf.validate.field).string.max_len = 255]; + string password = 2 [(buf.validate.field).string.max_len = 1024]; } message ReportPairedDevicesRequest { diff --git a/server/cmd/fleetd/main.go b/server/cmd/fleetd/main.go index d1a32d296..7485afcb7 100644 --- a/server/cmd/fleetd/main.go +++ b/server/cmd/fleetd/main.go @@ -309,6 +309,8 @@ func start(config *Config) error { discoverer := pluginService.CreateDiscoverer() discoveredDeviceStore := sqlstores.NewSQLDiscoveredDeviceStore(conn) + fleetNodePairingSvc.WithProvisioning(deviceStore, discoveredDeviceStore, encryptSvc, fleetNodeControlRegistry) + timescaledbService, err := timescaledb.NewTelemetryStore(conn, config.TimescaleDB) if err != nil { return err diff --git a/server/cmd/fleetnode/control_test.go b/server/cmd/fleetnode/control_test.go index a1de44003..56d080929 100644 --- a/server/cmd/fleetnode/control_test.go +++ b/server/cmd/fleetnode/control_test.go @@ -466,6 +466,7 @@ type controlFakeBehavior struct { rejectWithCode connect.Code reportErr error pairReportErr error + pairRejected int64 } type pendingCommand struct { @@ -535,11 +536,15 @@ func (f *controlFakeGateway) ReportPairedDevices(_ context.Context, req *connect f.mu.Lock() f.pairReports = append(f.pairReports, req.Msg) reportErr := f.behavior.pairReportErr + rejected := f.behavior.pairRejected f.mu.Unlock() if reportErr != nil { return nil, reportErr } - return connect.NewResponse(&pb.ReportPairedDevicesResponse{AcceptedCount: int64(len(req.Msg.GetResults()))}), nil + return connect.NewResponse(&pb.ReportPairedDevicesResponse{ + AcceptedCount: int64(len(req.Msg.GetResults())) - rejected, + RejectedCount: rejected, + }), nil } func (f *controlFakeGateway) pairReportsCopy() []*pb.ReportPairedDevicesRequest { diff --git a/server/cmd/fleetnode/pair.go b/server/cmd/fleetnode/pair.go index 101b2d16c..15d1046af 100644 --- a/server/cmd/fleetnode/pair.go +++ b/server/cmd/fleetnode/pair.go @@ -33,8 +33,17 @@ const pairConcurrency = 16 const ( maxPairIdentityBytes = 255 maxPairMACBytes = 64 + maxUsedPasswordBytes = 1024 ) +// credentialsReportable reports whether username/password fit the +// FleetNodePairResult caps. We refuse an oversized credential rather than pair with +// it: the node could authenticate but the cloud couldn't persist it back, leaving +// the device PAIRED but unusable. +func credentialsReportable(username, password string) bool { + return len(username) <= maxPairIdentityBytes && len(password) <= maxUsedPasswordBytes +} + // perPairTimeout bounds one device's auth handshake. var so tests can shrink it. var perPairTimeout = 60 * time.Second @@ -88,19 +97,30 @@ func (p *pluginPairer) Pair(ctx context.Context, target *pairingpb.FleetNodePair // Asymmetric-auth drivers (Proto) pair with the node's own miner-signing key; // operator-supplied username/password covers basic-auth drivers. if bundle, ok := secretBundleFor(plugin.Caps, p.minerSigningPubKey, creds); ok { + basicAuth := !plugin.Caps[sdk.CapabilityAsymmetricAuth] + if basicAuth && !credentialsReportable(creds.GetUsername(), creds.GetPassword()) { + res.Outcome = pb.PairOutcome_PAIR_OUTCOME_ERROR + res.ErrorMessage = "supplied credentials exceed the maximum reportable size" + return res + } updated, pairErr := plugin.Driver.PairDevice(ctx, deviceInfo, bundle) if pairErr != nil { classifyNodePairError(pairErr, res) return res } setPaired(res, updated) + if basicAuth { + res.UsedCredentials = &pb.UsedCredentials{Username: creds.GetUsername(), Password: creds.GetPassword()} + } return res } - // No credentials supplied: try plugin-provided defaults. if provider, ok := plugin.Driver.(sdk.DefaultCredentialsProvider); ok { defaults := provider.GetDefaultCredentials(ctx, target.GetManufacturer(), target.GetFirmwareVersion()) for _, c := range defaults { + if !credentialsReportable(c.Username, c.Password) { + continue + } bundle := sdk.SecretBundle{Version: "v1", Kind: sdk.UsernamePassword{Username: c.Username, Password: c.Password}} updated, pairErr := plugin.Driver.PairDevice(ctx, deviceInfo, bundle) if pairErr != nil { @@ -111,6 +131,7 @@ func (p *pluginPairer) Pair(ctx context.Context, target *pairingpb.FleetNodePair return res } setPaired(res, updated) + res.UsedCredentials = &pb.UsedCredentials{Username: c.Username, Password: c.Password} return res } } @@ -205,14 +226,27 @@ func (r *RunCmd) handlePairCommand(ctx context.Context, client gatewayClient, st targets := req.GetTargets() logger.Info("pair command received", "command_id", commandID, "targets", len(targets)) + // One pair command at a time, held until every worker has exited: a truncated + // batch abandons ctx-ignoring workers that may still be mutating miners, and a + // second command must not race them. BUSY maps to a retryable operator error. + if !r.pairMu.TryLock() { + r.sendAck(stream, commandID, pb.AckCode_ACK_CODE_BUSY, "a pair command is still running on this node; retry shortly", logger) + return + } + cmdCtx, cancel := context.WithTimeout(ctx, commandTimeout) defer cancel() - results, truncated := fanOutPairs(cmdCtx, targets, req.GetCredentials(), pairConcurrency, r.pairer.Pair, logger) + results, truncated, workersDone := fanOutPairs(cmdCtx, targets, req.GetCredentials(), pairConcurrency, r.pairer.Pair, logger) + go func() { + <-workersDone + r.pairMu.Unlock() + }() // Stream on the parent ctx, not cmdCtx: a deadline-hit cmdCtx must not // suppress upload of the results already collected. - if err := r.streamPairResults(ctx, client, commandID, results, logger); err != nil { + rejected, err := r.streamPairResults(ctx, client, commandID, results, logger) + if err != nil { r.sendAck(stream, commandID, pb.AckCode_ACK_CODE_REPORT_FAILED, err.Error(), logger) return } @@ -224,45 +258,65 @@ func (r *RunCmd) handlePairCommand(ctx context.Context, client gatewayClient, st r.sendAck(stream, commandID, pb.AckCode_ACK_CODE_PARTIAL, fmt.Sprintf("pair supervisor budget exceeded; %d of %d result(s) uploaded", len(results), len(targets)), logger) return } + // RejectedCount > 0 means the cloud didn't store a miner the node paired, so ack + // PARTIAL (not OK) and let the operator re-list and re-issue the remainder. + if rejected > 0 { + r.sendAck(stream, commandID, pb.AckCode_ACK_CODE_PARTIAL, fmt.Sprintf("cloud did not persist %d of %d reported result(s); re-list and retry", rejected, len(results)), logger) + return + } r.sendAck(stream, commandID, pb.AckCode_ACK_CODE_OK, "", logger) } -func (r *RunCmd) streamPairResults(ctx context.Context, client gatewayClient, commandID string, results []*pb.FleetNodePairResult, logger *slog.Logger) error { +// streamPairResults uploads results in chunks and returns how many the gateway +// failed to persist, so the caller can ack PARTIAL instead of claiming full success. +func (r *RunCmd) streamPairResults(ctx context.Context, client gatewayClient, commandID string, results []*pb.FleetNodePairResult, logger *slog.Logger) (int64, error) { + var rejected int64 for chunk := range slices.Chunk(results, maxDevicesPerReport) { callCtx, cancel := context.WithTimeout(ctx, discoveryReportTimeout) - _, err := client.ReportPairedDevices(callCtx, connect.NewRequest(&pb.ReportPairedDevicesRequest{ + resp, err := client.ReportPairedDevices(callCtx, connect.NewRequest(&pb.ReportPairedDevicesRequest{ CommandId: commandID, Results: chunk, })) cancel() if err != nil { logger.Error("pair report failed", "command_id", commandID, "err", err) - return fmt.Errorf("report paired devices: %w", err) + return rejected, fmt.Errorf("report paired devices: %w", err) } - logger.Info("pair report accepted", "command_id", commandID, "batch_size", len(chunk)) + rejected += resp.Msg.GetRejectedCount() + logger.Info("pair report accepted", "command_id", commandID, "batch_size", len(chunk), "rejected", resp.Msg.GetRejectedCount()) } - return nil + return rejected, nil } // fanOutPairs pairs targets with bounded concurrency, returning collected -// results and whether the batch was truncated (a hung plugin or a cancelled -// parent ctx left some targets unattempted; the operator re-lists and retries). -func fanOutPairs(ctx context.Context, targets []*pairingpb.FleetNodePairTarget, creds *pairingpb.Credentials, concurrency int, pair func(context.Context, *pairingpb.FleetNodePairTarget, *pairingpb.Credentials) *pb.FleetNodePairResult, logger *slog.Logger) ([]*pb.FleetNodePairResult, bool) { - if len(targets) == 0 { - return nil, false - } +// results, whether the batch was truncated (a hung plugin or a cancelled parent +// ctx left some targets unattempted; the operator re-lists and retries), and a +// channel closed once every started worker has exited. A truncated batch abandons +// ctx-ignoring workers that may still be mutating miners; the caller must not +// admit another pair command until that channel closes. +func fanOutPairs(ctx context.Context, targets []*pairingpb.FleetNodePairTarget, creds *pairingpb.Credentials, concurrency int, pair func(context.Context, *pairingpb.FleetNodePairTarget, *pairingpb.Credentials) *pb.FleetNodePairResult, logger *slog.Logger) ([]*pb.FleetNodePairResult, bool, <-chan struct{}) { var ( mu sync.Mutex results []*pb.FleetNodePairResult wg sync.WaitGroup ) + // Called only after the spawn loop stops, so a transient wg zero-crossing + // mid-spawn can't close the channel while workers are still being added. + workersDone := func() <-chan struct{} { + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + return done + } + if len(targets) == 0 { + return nil, false, workersDone() + } sem := make(chan struct{}, concurrency) for _, t := range targets { select { case sem <- struct{}{}: case <-ctx.Done(): out, _ := waitSupervisor(&wg, &mu, &results, perPairTimeout*2, "pair", logger) - return out, true + return out, true, workersDone() } wg.Add(1) go func(target *pairingpb.FleetNodePairTarget) { @@ -276,7 +330,8 @@ func fanOutPairs(ctx context.Context, targets []*pairingpb.FleetNodePairTarget, mu.Unlock() }(t) } - return waitSupervisor(&wg, &mu, &results, perPairTimeout*2, "pair", logger) + out, truncated := waitSupervisor(&wg, &mu, &results, perPairTimeout*2, "pair", logger) + return out, truncated, workersDone() } // truncateUTF8 trims s to at most maxLen bytes on a rune boundary so it stays valid diff --git a/server/cmd/fleetnode/pair_test.go b/server/cmd/fleetnode/pair_test.go index 6824664c1..e6c1a2b19 100644 --- a/server/cmd/fleetnode/pair_test.go +++ b/server/cmd/fleetnode/pair_test.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "errors" "strings" + "sync" "testing" "time" @@ -18,6 +19,7 @@ import ( pb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" pairingpb "github.com/block/proto-fleet/server/generated/grpc/pairing/v1" + "github.com/block/proto-fleet/server/internal/domain/plugins" "github.com/block/proto-fleet/server/internal/domain/token" "github.com/block/proto-fleet/server/internal/fleetnode/bootstrap" sdk "github.com/block/proto-fleet/server/sdk/v1" @@ -215,6 +217,30 @@ func TestControlLoop_PairAcksAndReportsResults(t *testing.T) { assert.Equal(t, pb.PairOutcome_PAIR_OUTCOME_AUTH_NEEDED, got["mac:bb"]) } +func TestControlLoop_PairPartialPersistAcksPartial(t *testing.T) { + // Arrange: the gateway accepts the upload but reports it failed to persist one + // paired miner (RejectedCount > 0). + cmd := &RunCmd{pairer: &stubPairer{results: map[string]*pb.FleetNodePairResult{ + "mac:aa": {DeviceIdentifier: "mac:aa", Outcome: pb.PairOutcome_PAIR_OUTCOME_PAIRED, SerialNumber: "SN1"}, + }}} + fake := &controlFakeGateway{} + fake.setBehavior(controlFakeBehavior{pairRejected: 1}) + fake.queue(pairCmd(t, &pairingpb.FleetNodePairRequest{ + Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "antminer"}}, + })) + + // Act + runControlLoopOnce(t, cmd, fake) + + // Assert: a rejected result acks PARTIAL, not OK, so the cloud isn't told the + // command fully succeeded when a paired miner wasn't stored. + acks := fake.acksCopy() + require.Len(t, acks, 1) + assert.False(t, acks[0].GetSucceeded()) + assert.Equal(t, pb.AckCode_ACK_CODE_PARTIAL, acks[0].GetCode()) + assert.Contains(t, acks[0].GetErrorMessage(), "did not persist") +} + func TestControlLoop_PairAgentIncapableWithoutPairer(t *testing.T) { // Arrange: no pairer wired (plugins failed to load / discovery-only build). cmd := &RunCmd{} @@ -273,6 +299,66 @@ func TestControlLoop_PairReportFailureAcksReportFailed(t *testing.T) { require.Len(t, fake.pairReportsCopy(), 1, "REPORT_FAILED implies the report was attempted") } +// recordingAcker captures acks for direct handlePairCommand tests. +type recordingAcker struct { + mu sync.Mutex + acks []*pb.ControlAck +} + +func (a *recordingAcker) Send(req *pb.ControlStreamRequest) error { + a.mu.Lock() + defer a.mu.Unlock() + if ack := req.GetAck(); ack != nil { + a.acks = append(a.acks, ack) + } + return nil +} + +func TestHandlePairCommand_BusyWhileAbandonedWorkersStillRunning(t *testing.T) { + // Shrink the supervisor budget into a unit-test window. + prev := perPairTimeout + perPairTimeout = 50 * time.Millisecond + t.Cleanup(func() { perPairTimeout = prev }) + + // Arrange: a worker that ignores ctx and stays stuck past the supervisor + // budget, so the first command acks PARTIAL with the worker abandoned but + // still running. This exercises the window AFTER the handler returns (the + // receive loop's exclusive lane is already released) where only the pair + // gate prevents a second command from racing the mutating worker. + block := make(chan struct{}) + cmd := &RunCmd{pairer: &ctxIgnoringPairer{ + stuck: map[string]bool{"mac:stuck": true}, + block: block, + }} + client := newControlClient(t, &controlFakeGateway{}) + acks := &recordingAcker{} + target := func(id, ip string) *pairingpb.FleetNodePairRequest { + return &pairingpb.FleetNodePairRequest{ + Targets: []*pairingpb.FleetNodePairTarget{{DeviceIdentifier: id, IpAddress: ip, Port: "80", DriverName: "antminer"}}, + } + } + + // Act: the first command truncates and returns; the second arrives while the + // abandoned worker is still running. + cmd.handlePairCommand(context.Background(), client, acks, "pair-1", target("mac:stuck", "10.0.0.6"), discardLogger(t)) + cmd.handlePairCommand(context.Background(), client, acks, "pair-2", target("mac:other", "10.0.0.7"), discardLogger(t)) + + // Assert + require.Len(t, acks.acks, 2) + assert.Equal(t, pb.AckCode_ACK_CODE_PARTIAL, acks.acks[0].GetCode()) + assert.Equal(t, pb.AckCode_ACK_CODE_BUSY, acks.acks[1].GetCode()) + + // Releasing the stuck worker frees the gate for the next command. + close(block) + require.Eventually(t, func() bool { + if cmd.pairMu.TryLock() { + cmd.pairMu.Unlock() + return true + } + return false + }, 3*time.Second, 10*time.Millisecond, "gate must release once all workers exit") +} + func TestControlLoop_PairSupervisorTruncatedAcksPartial(t *testing.T) { // Shrink the supervisor budget into a unit-test window. prev := perPairTimeout @@ -315,6 +401,177 @@ func TestControlLoop_PairSupervisorTruncatedAcksPartial(t *testing.T) { assert.Contains(t, acks[0].GetErrorMessage(), "supervisor") } +// fakePairDriver is a minimal sdk.Driver for exercising pluginPairer.Pair: it +// records the bundles PairDevice was called with and returns a configurable +// result, and (as a DefaultCredentialsProvider) yields the configured defaults. +type fakePairDriver struct { + pairResult sdk.DeviceInfo + pairErr error + defaults []sdk.UsernamePassword + gotBundles []sdk.SecretBundle +} + +func (d *fakePairDriver) Handshake(context.Context) (sdk.DriverIdentifier, error) { + return sdk.DriverIdentifier{}, nil +} + +func (d *fakePairDriver) DescribeDriver(context.Context) (sdk.DriverIdentifier, sdk.Capabilities, error) { + return sdk.DriverIdentifier{}, sdk.Capabilities{}, nil +} + +func (d *fakePairDriver) DiscoverDevice(context.Context, string, string) (sdk.DeviceInfo, error) { + return sdk.DeviceInfo{}, nil +} + +func (d *fakePairDriver) PairDevice(_ context.Context, _ sdk.DeviceInfo, access sdk.SecretBundle) (sdk.DeviceInfo, error) { + d.gotBundles = append(d.gotBundles, access) + return d.pairResult, d.pairErr +} + +func (d *fakePairDriver) NewDevice(context.Context, string, sdk.DeviceInfo, sdk.SecretBundle) (sdk.NewDeviceResult, error) { + return sdk.NewDeviceResult{}, nil +} + +func (d *fakePairDriver) GetDefaultCredentials(context.Context, string, string) []sdk.UsernamePassword { + return d.defaults +} + +func newTestPairer(t *testing.T, caps sdk.Capabilities, driver sdk.Driver) *pluginPairer { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + p, err := newPluginPairer(plugins.NewManager(&plugins.Config{}), hex.EncodeToString(priv)) + require.NoError(t, err) + require.NoError(t, p.manager.RegisterPluginForTest(&plugins.LoadedPlugin{ + Name: "fake", + Identifier: sdk.DriverIdentifier{DriverName: "fakedrv"}, + Driver: driver, + Caps: caps, + })) + return p +} + +func fakePairTarget() *pairingpb.FleetNodePairTarget { + return &pairingpb.FleetNodePairTarget{DeviceIdentifier: "mac:aa", IpAddress: "10.0.0.5", Port: "80", DriverName: "fakedrv"} +} + +func TestPluginPairer_BasicAuthRejectsUnreportableCredentials(t *testing.T) { + longPw := strings.Repeat("p", maxUsedPasswordBytes+1) + longUser := strings.Repeat("u", maxPairIdentityBytes+1) + shortPw := "pw" + cases := []struct { + name string + creds *pairingpb.Credentials + }{ + {name: "password exceeds cap", creds: &pairingpb.Credentials{Username: "root", Password: &longPw}}, + {name: "username exceeds cap", creds: &pairingpb.Credentials{Username: longUser, Password: &shortPw}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + drv := &fakePairDriver{pairResult: sdk.DeviceInfo{SerialNumber: "SN1"}} + p := newTestPairer(t, sdk.Capabilities{sdk.CapabilityPairing: true}, drv) + + // Act + res := p.Pair(context.Background(), fakePairTarget(), tc.creds) + + // Assert: refused before any pair attempt so the cloud never stores an + // unusable secret. + assert.Equal(t, pb.PairOutcome_PAIR_OUTCOME_ERROR, res.GetOutcome()) + assert.Contains(t, res.GetErrorMessage(), "exceed the maximum reportable size") + assert.Empty(t, drv.gotBundles) + }) + } +} + +func TestPluginPairer_BasicAuthReportsUsedCredentials(t *testing.T) { + // Arrange + drv := &fakePairDriver{pairResult: sdk.DeviceInfo{SerialNumber: "SN1"}} + p := newTestPairer(t, sdk.Capabilities{sdk.CapabilityPairing: true}, drv) + pw := "hunter2" + + // Act + res := p.Pair(context.Background(), fakePairTarget(), &pairingpb.Credentials{Username: "root", Password: &pw}) + + // Assert: the node reports the credentials it authenticated with so the cloud persists them. + assert.Equal(t, pb.PairOutcome_PAIR_OUTCOME_PAIRED, res.GetOutcome()) + require.NotNil(t, res.GetUsedCredentials()) + assert.Equal(t, "root", res.GetUsedCredentials().GetUsername()) + assert.Equal(t, "hunter2", res.GetUsedCredentials().GetPassword()) +} + +func TestPluginPairer_AsymmetricReportsNoCredentials(t *testing.T) { + // Arrange: an asymmetric-auth driver pairs with the node's signing key. + drv := &fakePairDriver{pairResult: sdk.DeviceInfo{SerialNumber: "SN1"}} + p := newTestPairer(t, sdk.Capabilities{sdk.CapabilityPairing: true, sdk.CapabilityAsymmetricAuth: true}, drv) + pw := "ignored" + + // Act + res := p.Pair(context.Background(), fakePairTarget(), &pairingpb.Credentials{Username: "root", Password: &pw}) + + // Assert: paired with the node key, no credentials reported back. + assert.Equal(t, pb.PairOutcome_PAIR_OUTCOME_PAIRED, res.GetOutcome()) + assert.Nil(t, res.GetUsedCredentials()) + require.Len(t, drv.gotBundles, 1) + _, isAPIKey := drv.gotBundles[0].Kind.(sdk.APIKey) + assert.True(t, isAPIKey) +} + +func TestPluginPairer_DefaultCredentialsReportsUsedCredentials(t *testing.T) { + // Arrange: no operator creds; the driver provides a working default. + drv := &fakePairDriver{ + pairResult: sdk.DeviceInfo{SerialNumber: "SN1"}, + defaults: []sdk.UsernamePassword{{Username: "admin", Password: "admin"}}, + } + p := newTestPairer(t, sdk.Capabilities{sdk.CapabilityPairing: true}, drv) + + // Act + res := p.Pair(context.Background(), fakePairTarget(), nil) + + // Assert: the default that worked is reported so the cloud stores it. + assert.Equal(t, pb.PairOutcome_PAIR_OUTCOME_PAIRED, res.GetOutcome()) + require.NotNil(t, res.GetUsedCredentials()) + assert.Equal(t, "admin", res.GetUsedCredentials().GetUsername()) + assert.Equal(t, "admin", res.GetUsedCredentials().GetPassword()) +} + +func TestPluginPairer_DefaultCredentialsSkipsUnreportable(t *testing.T) { + // Arrange: the first default is unreportable (oversized), the second is usable. + drv := &fakePairDriver{ + pairResult: sdk.DeviceInfo{SerialNumber: "SN1"}, + defaults: []sdk.UsernamePassword{ + {Username: "big", Password: strings.Repeat("p", maxUsedPasswordBytes+1)}, + {Username: "admin", Password: "admin"}, + }, + } + p := newTestPairer(t, sdk.Capabilities{sdk.CapabilityPairing: true}, drv) + + // Act + res := p.Pair(context.Background(), fakePairTarget(), nil) + + // Assert: the oversized default is skipped without a pair attempt; the usable one pairs. + assert.Equal(t, pb.PairOutcome_PAIR_OUTCOME_PAIRED, res.GetOutcome()) + require.NotNil(t, res.GetUsedCredentials()) + assert.Equal(t, "admin", res.GetUsedCredentials().GetUsername()) + require.Len(t, drv.gotBundles, 1) + up, ok := drv.gotBundles[0].Kind.(sdk.UsernamePassword) + require.True(t, ok) + assert.Equal(t, "admin", up.Username) +} + +func TestPluginPairer_NoCredentialsNoDefaultsAuthNeeded(t *testing.T) { + // Arrange: basic-auth driver, no operator creds, no usable defaults. + drv := &fakePairDriver{} + p := newTestPairer(t, sdk.Capabilities{sdk.CapabilityPairing: true}, drv) + + // Act + res := p.Pair(context.Background(), fakePairTarget(), nil) + + // Assert + assert.Equal(t, pb.PairOutcome_PAIR_OUTCOME_AUTH_NEEDED, res.GetOutcome()) + assert.Empty(t, drv.gotBundles) +} + // Ignores ctx for identifiers in stuck (blocks on `block`); fast for the rest. type ctxIgnoringPairer struct { fast map[string]*pb.FleetNodePairResult diff --git a/server/cmd/fleetnode/run.go b/server/cmd/fleetnode/run.go index 176fb516d..6726b067f 100644 --- a/server/cmd/fleetnode/run.go +++ b/server/cmd/fleetnode/run.go @@ -38,6 +38,7 @@ type RunCmd struct { localSubnets func() ([]string, error) `kong:"-"` // test seam for local-subnet detection stateMu sync.Mutex `kong:"-"` // guards st.SessionToken across refreshAndSave + tokenSource. + pairMu sync.Mutex `kong:"-"` // serializes pair commands; held until every pair worker exits (see handlePairCommand). } type gatewayClient interface { diff --git a/server/generated/grpc/fleetnodeadmin/v1/fleetnodeadmin.pb.go b/server/generated/grpc/fleetnodeadmin/v1/fleetnodeadmin.pb.go index ea92a3882..eec7dddd5 100644 --- a/server/generated/grpc/fleetnodeadmin/v1/fleetnodeadmin.pb.go +++ b/server/generated/grpc/fleetnodeadmin/v1/fleetnodeadmin.pb.go @@ -956,8 +956,8 @@ type ListFleetNodeDiscoveredDevicesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // 0 = all fleet nodes in org; > 0 restricts to that fleet node. FleetNodeId int64 `protobuf:"varint,1,opt,name=fleet_node_id,json=fleetNodeId,proto3" json:"fleet_node_id,omitempty"` - // Max devices to return; 0 = no limit. A node can discover thousands of - // devices, so operators should page. + // Max devices to return; 0 = server default page size (1024, also the cap). + // A node can discover thousands of devices, so operators page via next_cursor. Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` // Forward cursor: pass the previous response's next_cursor; 0 = first page. Cursor int64 `protobuf:"varint,3,opt,name=cursor,proto3" json:"cursor,omitempty"` diff --git a/server/generated/grpc/fleetnodegateway/v1/fleetnodegateway.pb.go b/server/generated/grpc/fleetnodegateway/v1/fleetnodegateway.pb.go index 350bea7a4..391b94968 100644 --- a/server/generated/grpc/fleetnodegateway/v1/fleetnodegateway.pb.go +++ b/server/generated/grpc/fleetnodegateway/v1/fleetnodegateway.pb.go @@ -1054,13 +1054,20 @@ type FleetNodePairResult struct { state protoimpl.MessageState `protogen:"open.v1"` DeviceIdentifier string `protobuf:"bytes,1,opt,name=device_identifier,json=deviceIdentifier,proto3" json:"device_identifier,omitempty"` Outcome PairOutcome `protobuf:"varint,2,opt,name=outcome,proto3,enum=fleetnodegateway.v1.PairOutcome" json:"outcome,omitempty"` - // Identity learned during pairing; populated on PAIRED. Never carries credentials. + // Identity learned during pairing; populated on PAIRED. SerialNumber string `protobuf:"bytes,3,opt,name=serial_number,json=serialNumber,proto3" json:"serial_number,omitempty"` MacAddress string `protobuf:"bytes,4,opt,name=mac_address,json=macAddress,proto3" json:"mac_address,omitempty"` Model string `protobuf:"bytes,5,opt,name=model,proto3" json:"model,omitempty"` Manufacturer string `protobuf:"bytes,6,opt,name=manufacturer,proto3" json:"manufacturer,omitempty"` FirmwareVersion string `protobuf:"bytes,7,opt,name=firmware_version,json=firmwareVersion,proto3" json:"firmware_version,omitempty"` ErrorMessage string `protobuf:"bytes,8,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + // The credentials the node authenticated with. Set for basic-auth drivers + // (operator-supplied OR plugin defaults) and absent for asymmetric-auth + // drivers, which pair with the node's signing key and carry no credentials. + // Presence is meaningful: a present message -- even with an empty username or + // password -- means the cloud must persist it as the device's auth material; + // absent means store nothing. + UsedCredentials *UsedCredentials `protobuf:"bytes,11,opt,name=used_credentials,json=usedCredentials,proto3" json:"used_credentials,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1151,6 +1158,69 @@ func (x *FleetNodePairResult) GetErrorMessage() string { return "" } +func (x *FleetNodePairResult) GetUsedCredentials() *UsedCredentials { + if x != nil { + return x.UsedCredentials + } + return nil +} + +// UsedCredentials carries the basic-auth credentials a node authenticated with, +// echoed so the cloud can persist working auth material for the device. Capped to +// bound the ReportPairedDevices payload. The node refuses to pair (reports ERROR) +// rather than authenticate with a credential it cannot report within these caps. +type UsedCredentials struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UsedCredentials) Reset() { + *x = UsedCredentials{} + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UsedCredentials) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UsedCredentials) ProtoMessage() {} + +func (x *UsedCredentials) ProtoReflect() protoreflect.Message { + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UsedCredentials.ProtoReflect.Descriptor instead. +func (*UsedCredentials) Descriptor() ([]byte, []int) { + return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{16} +} + +func (x *UsedCredentials) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *UsedCredentials) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + type ReportPairedDevicesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Echoes the command_id of the ControlCommand that scheduled the pairing, @@ -1164,7 +1234,7 @@ type ReportPairedDevicesRequest struct { func (x *ReportPairedDevicesRequest) Reset() { *x = ReportPairedDevicesRequest{} - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[16] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1176,7 +1246,7 @@ func (x *ReportPairedDevicesRequest) String() string { func (*ReportPairedDevicesRequest) ProtoMessage() {} func (x *ReportPairedDevicesRequest) ProtoReflect() protoreflect.Message { - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[16] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1189,7 +1259,7 @@ func (x *ReportPairedDevicesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPairedDevicesRequest.ProtoReflect.Descriptor instead. func (*ReportPairedDevicesRequest) Descriptor() ([]byte, []int) { - return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{16} + return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{17} } func (x *ReportPairedDevicesRequest) GetCommandId() string { @@ -1216,7 +1286,7 @@ type ReportPairedDevicesResponse struct { func (x *ReportPairedDevicesResponse) Reset() { *x = ReportPairedDevicesResponse{} - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[17] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1228,7 +1298,7 @@ func (x *ReportPairedDevicesResponse) String() string { func (*ReportPairedDevicesResponse) ProtoMessage() {} func (x *ReportPairedDevicesResponse) ProtoReflect() protoreflect.Message { - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[17] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1241,7 +1311,7 @@ func (x *ReportPairedDevicesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportPairedDevicesResponse.ProtoReflect.Descriptor instead. func (*ReportPairedDevicesResponse) Descriptor() ([]byte, []int) { - return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{17} + return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{18} } func (x *ReportPairedDevicesResponse) GetAcceptedCount() int64 { @@ -1271,7 +1341,7 @@ type ControlStreamRequest struct { func (x *ControlStreamRequest) Reset() { *x = ControlStreamRequest{} - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[18] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1283,7 +1353,7 @@ func (x *ControlStreamRequest) String() string { func (*ControlStreamRequest) ProtoMessage() {} func (x *ControlStreamRequest) ProtoReflect() protoreflect.Message { - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[18] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1296,7 +1366,7 @@ func (x *ControlStreamRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlStreamRequest.ProtoReflect.Descriptor instead. func (*ControlStreamRequest) Descriptor() ([]byte, []int) { - return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{18} + return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{19} } func (x *ControlStreamRequest) GetKind() isControlStreamRequest_Kind { @@ -1353,7 +1423,7 @@ type ControlStreamResponse struct { func (x *ControlStreamResponse) Reset() { *x = ControlStreamResponse{} - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[19] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1365,7 +1435,7 @@ func (x *ControlStreamResponse) String() string { func (*ControlStreamResponse) ProtoMessage() {} func (x *ControlStreamResponse) ProtoReflect() protoreflect.Message { - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[19] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1378,7 +1448,7 @@ func (x *ControlStreamResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlStreamResponse.ProtoReflect.Descriptor instead. func (*ControlStreamResponse) Descriptor() ([]byte, []int) { - return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{19} + return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{20} } func (x *ControlStreamResponse) GetKind() isControlStreamResponse_Kind { @@ -1430,7 +1500,7 @@ type ControlHello struct { func (x *ControlHello) Reset() { *x = ControlHello{} - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[20] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1442,7 +1512,7 @@ func (x *ControlHello) String() string { func (*ControlHello) ProtoMessage() {} func (x *ControlHello) ProtoReflect() protoreflect.Message { - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[20] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1455,7 +1525,7 @@ func (x *ControlHello) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlHello.ProtoReflect.Descriptor instead. func (*ControlHello) Descriptor() ([]byte, []int) { - return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{20} + return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{21} } type ControlAccepted struct { @@ -1467,7 +1537,7 @@ type ControlAccepted struct { func (x *ControlAccepted) Reset() { *x = ControlAccepted{} - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[21] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1479,7 +1549,7 @@ func (x *ControlAccepted) String() string { func (*ControlAccepted) ProtoMessage() {} func (x *ControlAccepted) ProtoReflect() protoreflect.Message { - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[21] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1492,7 +1562,7 @@ func (x *ControlAccepted) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlAccepted.ProtoReflect.Descriptor instead. func (*ControlAccepted) Descriptor() ([]byte, []int) { - return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{21} + return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{22} } func (x *ControlAccepted) GetServerTime() *timestamppb.Timestamp { @@ -1512,7 +1582,7 @@ type ControlCommand struct { func (x *ControlCommand) Reset() { *x = ControlCommand{} - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[22] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1524,7 +1594,7 @@ func (x *ControlCommand) String() string { func (*ControlCommand) ProtoMessage() {} func (x *ControlCommand) ProtoReflect() protoreflect.Message { - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[22] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1537,7 +1607,7 @@ func (x *ControlCommand) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlCommand.ProtoReflect.Descriptor instead. func (*ControlCommand) Descriptor() ([]byte, []int) { - return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{22} + return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{23} } func (x *ControlCommand) GetCommandId() string { @@ -1566,7 +1636,7 @@ type ControlAck struct { func (x *ControlAck) Reset() { *x = ControlAck{} - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[23] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1578,7 +1648,7 @@ func (x *ControlAck) String() string { func (*ControlAck) ProtoMessage() {} func (x *ControlAck) ProtoReflect() protoreflect.Message { - mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[23] + mi := &file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1591,7 +1661,7 @@ func (x *ControlAck) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlAck.ProtoReflect.Descriptor instead. func (*ControlAck) Descriptor() ([]byte, []int) { - return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{23} + return file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP(), []int{24} } func (x *ControlAck) GetCommandId() string { @@ -1771,7 +1841,7 @@ var file_fleetnodegateway_v1_fleetnodegateway_proto_rawDesc = string([]byte{ 0x70, 0x74, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, - 0x22, 0x95, 0x03, 0x0a, 0x13, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x50, 0x61, + 0x22, 0x90, 0x04, 0x0a, 0x13, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x50, 0x61, 0x69, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x37, 0x0a, 0x11, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, 0x01, 0x18, 0xff, 0x01, 0x52, @@ -1796,183 +1866,197 @@ var file_fleetnodegateway_v1_fleetnodegateway_proto_rawDesc = string([]byte{ 0x72, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, 0x20, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x96, 0x01, 0x0a, 0x1a, 0x52, 0x65, 0x70, - 0x6f, 0x72, 0x74, 0x50, 0x61, 0x69, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x29, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, - 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, - 0x72, 0x05, 0x10, 0x01, 0x18, 0x80, 0x01, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, - 0x49, 0x64, 0x12, 0x4d, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, - 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x4e, - 0x6f, 0x64, 0x65, 0x50, 0x61, 0x69, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x42, 0x09, 0xba, - 0x48, 0x06, 0x92, 0x01, 0x03, 0x10, 0x80, 0x08, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x73, 0x22, 0x6b, 0x0a, 0x1b, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x50, 0x61, 0x69, 0x72, 0x65, - 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, - 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x72, 0x65, 0x6a, 0x65, 0x63, - 0x74, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0d, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x95, - 0x01, 0x0a, 0x14, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x39, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, - 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, - 0x74, 0x72, 0x6f, 0x6c, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, - 0x6c, 0x6f, 0x12, 0x33, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1f, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, - 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x41, 0x63, 0x6b, - 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x0d, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, - 0x05, 0xba, 0x48, 0x02, 0x08, 0x01, 0x22, 0xab, 0x01, 0x0a, 0x15, 0x43, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x42, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x4f, 0x0a, 0x10, 0x75, 0x73, 0x65, 0x64, + 0x5f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, - 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, - 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, - 0x70, 0x74, 0x65, 0x64, 0x12, 0x3f, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, - 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, - 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, - 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x42, 0x0d, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x05, 0xba, - 0x48, 0x02, 0x08, 0x01, 0x22, 0x0e, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x48, - 0x65, 0x6c, 0x6c, 0x6f, 0x22, 0x4e, 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x41, - 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x3b, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, - 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x54, 0x69, 0x6d, 0x65, 0x22, 0x60, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x43, - 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x29, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, - 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, - 0x05, 0x10, 0x01, 0x18, 0x80, 0x01, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, - 0x64, 0x12, 0x23, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0c, 0x42, 0x09, 0xba, 0x48, 0x06, 0x7a, 0x04, 0x18, 0x80, 0x80, 0x40, 0x52, 0x07, 0x70, - 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0xb6, 0x01, 0x0a, 0x0a, 0x43, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x41, 0x63, 0x6b, 0x12, 0x29, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, 0x05, - 0x10, 0x01, 0x18, 0x80, 0x01, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, - 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x12, 0x2d, - 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, 0x20, 0x52, - 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x30, 0x0a, - 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x66, 0x6c, - 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, - 0x31, 0x2e, 0x41, 0x63, 0x6b, 0x43, 0x6f, 0x64, 0x65, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x2a, - 0x94, 0x01, 0x0a, 0x10, 0x45, 0x6e, 0x72, 0x6f, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, 0x0a, 0x1d, 0x45, 0x4e, 0x52, 0x4f, 0x4c, 0x4c, 0x4d, 0x45, - 0x4e, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1d, 0x0a, 0x19, 0x45, 0x4e, 0x52, 0x4f, 0x4c, - 0x4c, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, - 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, 0x45, 0x4e, 0x52, 0x4f, 0x4c, 0x4c, - 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x4f, 0x4e, 0x46, - 0x49, 0x52, 0x4d, 0x45, 0x44, 0x10, 0x02, 0x12, 0x1d, 0x0a, 0x19, 0x45, 0x4e, 0x52, 0x4f, 0x4c, - 0x4c, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x52, 0x45, 0x56, - 0x4f, 0x4b, 0x45, 0x44, 0x10, 0x03, 0x2a, 0x98, 0x01, 0x0a, 0x0b, 0x50, 0x61, 0x69, 0x72, 0x4f, - 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x18, 0x50, 0x41, 0x49, 0x52, 0x5f, 0x4f, - 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, - 0x45, 0x44, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x41, 0x49, 0x52, 0x5f, 0x4f, 0x55, 0x54, - 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x50, 0x41, 0x49, 0x52, 0x45, 0x44, 0x10, 0x01, 0x12, 0x1c, 0x0a, - 0x18, 0x50, 0x41, 0x49, 0x52, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x41, 0x55, - 0x54, 0x48, 0x5f, 0x4e, 0x45, 0x45, 0x44, 0x45, 0x44, 0x10, 0x02, 0x12, 0x1c, 0x0a, 0x18, 0x50, - 0x41, 0x49, 0x52, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x41, 0x55, 0x54, 0x48, - 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x41, 0x49, - 0x52, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, - 0x04, 0x2a, 0xe2, 0x01, 0x0a, 0x07, 0x41, 0x63, 0x6b, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, - 0x14, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x41, 0x43, 0x4b, 0x5f, 0x43, - 0x4f, 0x44, 0x45, 0x5f, 0x4f, 0x4b, 0x10, 0x01, 0x12, 0x14, 0x0a, 0x10, 0x41, 0x43, 0x4b, 0x5f, - 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x50, 0x41, 0x52, 0x54, 0x49, 0x41, 0x4c, 0x10, 0x02, 0x12, 0x18, - 0x0a, 0x14, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x42, 0x41, 0x44, 0x5f, 0x52, - 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x03, 0x12, 0x1c, 0x0a, 0x18, 0x41, 0x43, 0x4b, 0x5f, - 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x41, 0x47, 0x45, 0x4e, 0x54, 0x5f, 0x49, 0x4e, 0x43, 0x41, 0x50, - 0x41, 0x42, 0x4c, 0x45, 0x10, 0x04, 0x12, 0x18, 0x0a, 0x14, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, - 0x44, 0x45, 0x5f, 0x53, 0x43, 0x41, 0x4e, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x05, - 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x52, 0x45, 0x50, - 0x4f, 0x52, 0x54, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x06, 0x12, 0x15, 0x0a, 0x11, - 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x49, 0x4e, 0x54, 0x45, 0x52, 0x4e, 0x41, - 0x4c, 0x10, 0x07, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, - 0x42, 0x55, 0x53, 0x59, 0x10, 0x08, 0x32, 0x9b, 0x08, 0x0a, 0x17, 0x46, 0x6c, 0x65, 0x65, 0x74, - 0x4e, 0x6f, 0x64, 0x65, 0x47, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x12, 0x57, 0x0a, 0x08, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x12, 0x24, + 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x64, 0x43, 0x72, 0x65, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x52, 0x0f, 0x75, 0x73, 0x65, 0x64, 0x43, 0x72, + 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x4a, + 0x04, 0x08, 0x0a, 0x10, 0x0b, 0x52, 0x0d, 0x75, 0x73, 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, + 0x6e, 0x61, 0x6d, 0x65, 0x52, 0x0d, 0x75, 0x73, 0x65, 0x64, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, + 0x6f, 0x72, 0x64, 0x22, 0x5d, 0x0a, 0x0f, 0x55, 0x73, 0x65, 0x64, 0x43, 0x72, 0x65, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x12, 0x24, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, + 0xff, 0x01, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x24, 0x0a, 0x08, + 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, + 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, 0x08, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x22, 0x96, 0x01, 0x0a, 0x1a, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x50, 0x61, 0x69, + 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x29, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, 0x01, 0x18, 0x80, + 0x01, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x4d, 0x0a, 0x07, + 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, + 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, + 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x50, 0x61, 0x69, + 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x42, 0x09, 0xba, 0x48, 0x06, 0x92, 0x01, 0x03, 0x10, + 0x80, 0x08, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x6b, 0x0a, 0x1b, 0x52, + 0x65, 0x70, 0x6f, 0x72, 0x74, 0x50, 0x61, 0x69, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, + 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x63, + 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x72, 0x65, 0x6a, 0x65, 0x63, + 0x74, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x95, 0x01, 0x0a, 0x14, 0x43, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x39, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x21, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, + 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x48, 0x65, + 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x33, 0x0a, 0x03, + 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x66, 0x6c, 0x65, 0x65, + 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, + 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, + 0x6b, 0x42, 0x0d, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x05, 0xba, 0x48, 0x02, 0x08, 0x01, + 0x22, 0xab, 0x01, 0x0a, 0x15, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x53, 0x74, 0x72, 0x65, + 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x42, 0x0a, 0x08, 0x61, 0x63, + 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x66, + 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, + 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, + 0x65, 0x64, 0x48, 0x00, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x3f, + 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x23, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, + 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x42, + 0x0d, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x05, 0xba, 0x48, 0x02, 0x08, 0x01, 0x22, 0x0e, + 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x22, 0x4e, + 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, + 0x64, 0x12, 0x3b, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x74, 0x69, 0x6d, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x60, + 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x12, 0x29, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, 0x01, 0x18, 0x80, 0x01, + 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x07, 0x70, + 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x42, 0x09, 0xba, 0x48, + 0x06, 0x7a, 0x04, 0x18, 0x80, 0x80, 0x40, 0x52, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, + 0x22, 0xb6, 0x01, 0x0a, 0x0a, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x41, 0x63, 0x6b, 0x12, + 0x29, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, 0x01, 0x18, 0x80, 0x01, 0x52, + 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x75, + 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, + 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x12, 0x2d, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, 0x20, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x30, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, + 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x63, 0x6b, 0x43, + 0x6f, 0x64, 0x65, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x2a, 0x94, 0x01, 0x0a, 0x10, 0x45, 0x6e, + 0x72, 0x6f, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, + 0x0a, 0x1d, 0x45, 0x4e, 0x52, 0x4f, 0x4c, 0x4c, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x53, 0x54, 0x41, + 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, + 0x00, 0x12, 0x1d, 0x0a, 0x19, 0x45, 0x4e, 0x52, 0x4f, 0x4c, 0x4c, 0x4d, 0x45, 0x4e, 0x54, 0x5f, + 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, + 0x12, 0x1f, 0x0a, 0x1b, 0x45, 0x4e, 0x52, 0x4f, 0x4c, 0x4c, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x53, + 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, 0x52, 0x4d, 0x45, 0x44, 0x10, + 0x02, 0x12, 0x1d, 0x0a, 0x19, 0x45, 0x4e, 0x52, 0x4f, 0x4c, 0x4c, 0x4d, 0x45, 0x4e, 0x54, 0x5f, + 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x52, 0x45, 0x56, 0x4f, 0x4b, 0x45, 0x44, 0x10, 0x03, + 0x2a, 0x98, 0x01, 0x0a, 0x0b, 0x50, 0x61, 0x69, 0x72, 0x4f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, + 0x12, 0x1c, 0x0a, 0x18, 0x50, 0x41, 0x49, 0x52, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, + 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x17, + 0x0a, 0x13, 0x50, 0x41, 0x49, 0x52, 0x5f, 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x50, + 0x41, 0x49, 0x52, 0x45, 0x44, 0x10, 0x01, 0x12, 0x1c, 0x0a, 0x18, 0x50, 0x41, 0x49, 0x52, 0x5f, + 0x4f, 0x55, 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x41, 0x55, 0x54, 0x48, 0x5f, 0x4e, 0x45, 0x45, + 0x44, 0x45, 0x44, 0x10, 0x02, 0x12, 0x1c, 0x0a, 0x18, 0x50, 0x41, 0x49, 0x52, 0x5f, 0x4f, 0x55, + 0x54, 0x43, 0x4f, 0x4d, 0x45, 0x5f, 0x41, 0x55, 0x54, 0x48, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, + 0x44, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x41, 0x49, 0x52, 0x5f, 0x4f, 0x55, 0x54, 0x43, + 0x4f, 0x4d, 0x45, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x2a, 0xe2, 0x01, 0x0a, 0x07, + 0x41, 0x63, 0x6b, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x41, 0x43, 0x4b, 0x5f, 0x43, + 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, + 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x4f, 0x4b, + 0x10, 0x01, 0x12, 0x14, 0x0a, 0x10, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x50, + 0x41, 0x52, 0x54, 0x49, 0x41, 0x4c, 0x10, 0x02, 0x12, 0x18, 0x0a, 0x14, 0x41, 0x43, 0x4b, 0x5f, + 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x42, 0x41, 0x44, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, + 0x10, 0x03, 0x12, 0x1c, 0x0a, 0x18, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x41, + 0x47, 0x45, 0x4e, 0x54, 0x5f, 0x49, 0x4e, 0x43, 0x41, 0x50, 0x41, 0x42, 0x4c, 0x45, 0x10, 0x04, + 0x12, 0x18, 0x0a, 0x14, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x53, 0x43, 0x41, + 0x4e, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x05, 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x43, + 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x52, 0x45, 0x50, 0x4f, 0x52, 0x54, 0x5f, 0x46, 0x41, + 0x49, 0x4c, 0x45, 0x44, 0x10, 0x06, 0x12, 0x15, 0x0a, 0x11, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, + 0x44, 0x45, 0x5f, 0x49, 0x4e, 0x54, 0x45, 0x52, 0x4e, 0x41, 0x4c, 0x10, 0x07, 0x12, 0x11, 0x0a, + 0x0d, 0x41, 0x43, 0x4b, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x42, 0x55, 0x53, 0x59, 0x10, 0x08, + 0x32, 0x9b, 0x08, 0x0a, 0x17, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x47, 0x61, + 0x74, 0x65, 0x77, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x57, 0x0a, 0x08, + 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x12, 0x24, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, + 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, - 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, - 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, - 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x75, 0x0a, 0x12, 0x42, - 0x65, 0x67, 0x69, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, - 0x65, 0x12, 0x2e, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, - 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x65, 0x67, 0x69, 0x6e, 0x41, 0x75, 0x74, - 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x2f, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, - 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x65, 0x67, 0x69, 0x6e, 0x41, 0x75, 0x74, - 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x7e, 0x0a, 0x15, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, - 0x74, 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x12, 0x31, 0x2e, 0x66, 0x6c, + 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x75, 0x0a, 0x12, 0x42, 0x65, 0x67, 0x69, 0x6e, 0x41, 0x75, + 0x74, 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x12, 0x2e, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, - 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x48, 0x61, - 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, - 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, - 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, - 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x6e, 0x0a, 0x0f, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x65, 0x6c, 0x65, - 0x6d, 0x65, 0x74, 0x72, 0x79, 0x12, 0x2b, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, - 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, 0x6f, - 0x61, 0x64, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, - 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x54, - 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x28, 0x01, 0x12, 0x65, 0x0a, 0x0c, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x45, 0x76, 0x65, 0x6e, - 0x74, 0x73, 0x12, 0x28, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, - 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x45, - 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x66, + 0x31, 0x2e, 0x42, 0x65, 0x67, 0x69, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, + 0x68, 0x61, 0x6b, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x66, 0x6c, + 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, + 0x31, 0x2e, 0x42, 0x65, 0x67, 0x69, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, + 0x68, 0x61, 0x6b, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7e, 0x0a, 0x15, + 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x48, 0x61, 0x6e, 0x64, + 0x73, 0x68, 0x61, 0x6b, 0x65, 0x12, 0x31, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, + 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, + 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, + 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x48, 0x61, 0x6e, 0x64, 0x73, + 0x68, 0x61, 0x6b, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6e, 0x0a, 0x0f, + 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x12, + 0x2b, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, + 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x65, 0x6c, 0x65, + 0x6d, 0x65, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, - 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x12, 0x6c, 0x0a, 0x0f, 0x55, 0x70, 0x6c, - 0x6f, 0x61, 0x64, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x12, 0x2b, 0x2e, 0x66, + 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, + 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x12, 0x65, 0x0a, 0x0c, + 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x28, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, - 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, - 0x61, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x66, 0x6c, 0x65, 0x65, - 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, - 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x84, 0x01, 0x0a, 0x17, 0x52, 0x65, 0x70, 0x6f, - 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, - 0x63, 0x65, 0x73, 0x12, 0x33, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, - 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, - 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, - 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, - 0x65, 0x70, 0x6f, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x65, 0x64, 0x44, - 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x78, - 0x0a, 0x13, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x50, 0x61, 0x69, 0x72, 0x65, 0x64, 0x44, 0x65, - 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, 0x2f, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, - 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6f, - 0x72, 0x74, 0x50, 0x61, 0x69, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, - 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, - 0x6f, 0x72, 0x74, 0x50, 0x61, 0x69, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x74, - 0x72, 0x6f, 0x6c, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x29, 0x2e, 0x66, 0x6c, 0x65, 0x65, - 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, - 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, - 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x28, 0x01, 0x30, 0x01, 0x42, 0xf8, 0x01, 0x0a, 0x17, 0x63, 0x6f, 0x6d, 0x2e, 0x66, 0x6c, 0x65, - 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, - 0x42, 0x15, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, - 0x61, 0x79, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x59, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2d, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x67, - 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 0x66, 0x6c, - 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2f, 0x76, - 0x31, 0x3b, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, - 0x61, 0x79, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x46, 0x58, 0x58, 0xaa, 0x02, 0x13, 0x46, 0x6c, 0x65, - 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x56, 0x31, - 0xca, 0x02, 0x13, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, - 0x77, 0x61, 0x79, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1f, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, - 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, - 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x14, 0x46, 0x6c, 0x65, 0x65, 0x74, - 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x3a, 0x3a, 0x56, 0x31, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, + 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, + 0x6f, 0x61, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x28, 0x01, 0x12, 0x6c, 0x0a, 0x0f, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x48, 0x65, 0x61, + 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x12, 0x2b, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, + 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, + 0x6f, 0x61, 0x64, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, + 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, + 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x84, 0x01, 0x0a, 0x17, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, + 0x6f, 0x76, 0x65, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, 0x33, 0x2e, + 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, + 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, + 0x65, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, + 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x44, + 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x78, 0x0a, 0x13, 0x52, 0x65, 0x70, 0x6f, + 0x72, 0x74, 0x50, 0x61, 0x69, 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, + 0x2f, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, + 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x50, 0x61, 0x69, 0x72, + 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x30, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, + 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x50, 0x61, 0x69, + 0x72, 0x65, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x53, 0x74, 0x72, + 0x65, 0x61, 0x6d, 0x12, 0x29, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, + 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, + 0x6c, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, + 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, + 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x53, 0x74, 0x72, 0x65, + 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0xf8, + 0x01, 0x0a, 0x17, 0x63, 0x6f, 0x6d, 0x2e, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, + 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x42, 0x15, 0x46, 0x6c, 0x65, 0x65, + 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x50, 0x72, 0x6f, 0x74, + 0x6f, 0x50, 0x01, 0x5a, 0x59, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2d, 0x66, 0x6c, 0x65, 0x65, + 0x74, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, + 0x65, 0x64, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, + 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2f, 0x76, 0x31, 0x3b, 0x66, 0x6c, 0x65, 0x65, + 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x76, 0x31, 0xa2, 0x02, + 0x03, 0x46, 0x58, 0x58, 0xaa, 0x02, 0x13, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, + 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x13, 0x46, 0x6c, 0x65, + 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x5c, 0x56, 0x31, + 0xe2, 0x02, 0x1f, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, 0x74, 0x65, + 0x77, 0x61, 0x79, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0xea, 0x02, 0x14, 0x46, 0x6c, 0x65, 0x65, 0x74, 0x6e, 0x6f, 0x64, 0x65, 0x67, 0x61, + 0x74, 0x65, 0x77, 0x61, 0x79, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, }) var ( @@ -1988,7 +2072,7 @@ func file_fleetnodegateway_v1_fleetnodegateway_proto_rawDescGZIP() []byte { } var file_fleetnodegateway_v1_fleetnodegateway_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes = make([]protoimpl.MessageInfo, 25) var file_fleetnodegateway_v1_fleetnodegateway_proto_goTypes = []any{ (EnrollmentStatus)(0), // 0: fleetnodegateway.v1.EnrollmentStatus (PairOutcome)(0), // 1: fleetnodegateway.v1.PairOutcome @@ -2009,56 +2093,58 @@ var file_fleetnodegateway_v1_fleetnodegateway_proto_goTypes = []any{ (*DiscoveredDeviceReport)(nil), // 16: fleetnodegateway.v1.DiscoveredDeviceReport (*ReportDiscoveredDevicesResponse)(nil), // 17: fleetnodegateway.v1.ReportDiscoveredDevicesResponse (*FleetNodePairResult)(nil), // 18: fleetnodegateway.v1.FleetNodePairResult - (*ReportPairedDevicesRequest)(nil), // 19: fleetnodegateway.v1.ReportPairedDevicesRequest - (*ReportPairedDevicesResponse)(nil), // 20: fleetnodegateway.v1.ReportPairedDevicesResponse - (*ControlStreamRequest)(nil), // 21: fleetnodegateway.v1.ControlStreamRequest - (*ControlStreamResponse)(nil), // 22: fleetnodegateway.v1.ControlStreamResponse - (*ControlHello)(nil), // 23: fleetnodegateway.v1.ControlHello - (*ControlAccepted)(nil), // 24: fleetnodegateway.v1.ControlAccepted - (*ControlCommand)(nil), // 25: fleetnodegateway.v1.ControlCommand - (*ControlAck)(nil), // 26: fleetnodegateway.v1.ControlAck - (*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp + (*UsedCredentials)(nil), // 19: fleetnodegateway.v1.UsedCredentials + (*ReportPairedDevicesRequest)(nil), // 20: fleetnodegateway.v1.ReportPairedDevicesRequest + (*ReportPairedDevicesResponse)(nil), // 21: fleetnodegateway.v1.ReportPairedDevicesResponse + (*ControlStreamRequest)(nil), // 22: fleetnodegateway.v1.ControlStreamRequest + (*ControlStreamResponse)(nil), // 23: fleetnodegateway.v1.ControlStreamResponse + (*ControlHello)(nil), // 24: fleetnodegateway.v1.ControlHello + (*ControlAccepted)(nil), // 25: fleetnodegateway.v1.ControlAccepted + (*ControlCommand)(nil), // 26: fleetnodegateway.v1.ControlCommand + (*ControlAck)(nil), // 27: fleetnodegateway.v1.ControlAck + (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp } var file_fleetnodegateway_v1_fleetnodegateway_proto_depIdxs = []int32{ 0, // 0: fleetnodegateway.v1.RegisterResponse.enrollment_status:type_name -> fleetnodegateway.v1.EnrollmentStatus - 27, // 1: fleetnodegateway.v1.BeginAuthHandshakeResponse.expires_at:type_name -> google.protobuf.Timestamp - 27, // 2: fleetnodegateway.v1.CompleteAuthHandshakeResponse.expires_at:type_name -> google.protobuf.Timestamp - 27, // 3: fleetnodegateway.v1.UploadTelemetryRequest.captured_at:type_name -> google.protobuf.Timestamp - 27, // 4: fleetnodegateway.v1.UploadEventsRequest.captured_at:type_name -> google.protobuf.Timestamp - 27, // 5: fleetnodegateway.v1.UploadHeartbeatRequest.sent_at:type_name -> google.protobuf.Timestamp - 27, // 6: fleetnodegateway.v1.UploadHeartbeatResponse.received_at:type_name -> google.protobuf.Timestamp + 28, // 1: fleetnodegateway.v1.BeginAuthHandshakeResponse.expires_at:type_name -> google.protobuf.Timestamp + 28, // 2: fleetnodegateway.v1.CompleteAuthHandshakeResponse.expires_at:type_name -> google.protobuf.Timestamp + 28, // 3: fleetnodegateway.v1.UploadTelemetryRequest.captured_at:type_name -> google.protobuf.Timestamp + 28, // 4: fleetnodegateway.v1.UploadEventsRequest.captured_at:type_name -> google.protobuf.Timestamp + 28, // 5: fleetnodegateway.v1.UploadHeartbeatRequest.sent_at:type_name -> google.protobuf.Timestamp + 28, // 6: fleetnodegateway.v1.UploadHeartbeatResponse.received_at:type_name -> google.protobuf.Timestamp 16, // 7: fleetnodegateway.v1.ReportDiscoveredDevicesRequest.devices:type_name -> fleetnodegateway.v1.DiscoveredDeviceReport 1, // 8: fleetnodegateway.v1.FleetNodePairResult.outcome:type_name -> fleetnodegateway.v1.PairOutcome - 18, // 9: fleetnodegateway.v1.ReportPairedDevicesRequest.results:type_name -> fleetnodegateway.v1.FleetNodePairResult - 23, // 10: fleetnodegateway.v1.ControlStreamRequest.hello:type_name -> fleetnodegateway.v1.ControlHello - 26, // 11: fleetnodegateway.v1.ControlStreamRequest.ack:type_name -> fleetnodegateway.v1.ControlAck - 24, // 12: fleetnodegateway.v1.ControlStreamResponse.accepted:type_name -> fleetnodegateway.v1.ControlAccepted - 25, // 13: fleetnodegateway.v1.ControlStreamResponse.command:type_name -> fleetnodegateway.v1.ControlCommand - 27, // 14: fleetnodegateway.v1.ControlAccepted.server_time:type_name -> google.protobuf.Timestamp - 2, // 15: fleetnodegateway.v1.ControlAck.code:type_name -> fleetnodegateway.v1.AckCode - 3, // 16: fleetnodegateway.v1.FleetNodeGatewayService.Register:input_type -> fleetnodegateway.v1.RegisterRequest - 5, // 17: fleetnodegateway.v1.FleetNodeGatewayService.BeginAuthHandshake:input_type -> fleetnodegateway.v1.BeginAuthHandshakeRequest - 7, // 18: fleetnodegateway.v1.FleetNodeGatewayService.CompleteAuthHandshake:input_type -> fleetnodegateway.v1.CompleteAuthHandshakeRequest - 9, // 19: fleetnodegateway.v1.FleetNodeGatewayService.UploadTelemetry:input_type -> fleetnodegateway.v1.UploadTelemetryRequest - 11, // 20: fleetnodegateway.v1.FleetNodeGatewayService.UploadEvents:input_type -> fleetnodegateway.v1.UploadEventsRequest - 13, // 21: fleetnodegateway.v1.FleetNodeGatewayService.UploadHeartbeat:input_type -> fleetnodegateway.v1.UploadHeartbeatRequest - 15, // 22: fleetnodegateway.v1.FleetNodeGatewayService.ReportDiscoveredDevices:input_type -> fleetnodegateway.v1.ReportDiscoveredDevicesRequest - 19, // 23: fleetnodegateway.v1.FleetNodeGatewayService.ReportPairedDevices:input_type -> fleetnodegateway.v1.ReportPairedDevicesRequest - 21, // 24: fleetnodegateway.v1.FleetNodeGatewayService.ControlStream:input_type -> fleetnodegateway.v1.ControlStreamRequest - 4, // 25: fleetnodegateway.v1.FleetNodeGatewayService.Register:output_type -> fleetnodegateway.v1.RegisterResponse - 6, // 26: fleetnodegateway.v1.FleetNodeGatewayService.BeginAuthHandshake:output_type -> fleetnodegateway.v1.BeginAuthHandshakeResponse - 8, // 27: fleetnodegateway.v1.FleetNodeGatewayService.CompleteAuthHandshake:output_type -> fleetnodegateway.v1.CompleteAuthHandshakeResponse - 10, // 28: fleetnodegateway.v1.FleetNodeGatewayService.UploadTelemetry:output_type -> fleetnodegateway.v1.UploadTelemetryResponse - 12, // 29: fleetnodegateway.v1.FleetNodeGatewayService.UploadEvents:output_type -> fleetnodegateway.v1.UploadEventsResponse - 14, // 30: fleetnodegateway.v1.FleetNodeGatewayService.UploadHeartbeat:output_type -> fleetnodegateway.v1.UploadHeartbeatResponse - 17, // 31: fleetnodegateway.v1.FleetNodeGatewayService.ReportDiscoveredDevices:output_type -> fleetnodegateway.v1.ReportDiscoveredDevicesResponse - 20, // 32: fleetnodegateway.v1.FleetNodeGatewayService.ReportPairedDevices:output_type -> fleetnodegateway.v1.ReportPairedDevicesResponse - 22, // 33: fleetnodegateway.v1.FleetNodeGatewayService.ControlStream:output_type -> fleetnodegateway.v1.ControlStreamResponse - 25, // [25:34] is the sub-list for method output_type - 16, // [16:25] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 19, // 9: fleetnodegateway.v1.FleetNodePairResult.used_credentials:type_name -> fleetnodegateway.v1.UsedCredentials + 18, // 10: fleetnodegateway.v1.ReportPairedDevicesRequest.results:type_name -> fleetnodegateway.v1.FleetNodePairResult + 24, // 11: fleetnodegateway.v1.ControlStreamRequest.hello:type_name -> fleetnodegateway.v1.ControlHello + 27, // 12: fleetnodegateway.v1.ControlStreamRequest.ack:type_name -> fleetnodegateway.v1.ControlAck + 25, // 13: fleetnodegateway.v1.ControlStreamResponse.accepted:type_name -> fleetnodegateway.v1.ControlAccepted + 26, // 14: fleetnodegateway.v1.ControlStreamResponse.command:type_name -> fleetnodegateway.v1.ControlCommand + 28, // 15: fleetnodegateway.v1.ControlAccepted.server_time:type_name -> google.protobuf.Timestamp + 2, // 16: fleetnodegateway.v1.ControlAck.code:type_name -> fleetnodegateway.v1.AckCode + 3, // 17: fleetnodegateway.v1.FleetNodeGatewayService.Register:input_type -> fleetnodegateway.v1.RegisterRequest + 5, // 18: fleetnodegateway.v1.FleetNodeGatewayService.BeginAuthHandshake:input_type -> fleetnodegateway.v1.BeginAuthHandshakeRequest + 7, // 19: fleetnodegateway.v1.FleetNodeGatewayService.CompleteAuthHandshake:input_type -> fleetnodegateway.v1.CompleteAuthHandshakeRequest + 9, // 20: fleetnodegateway.v1.FleetNodeGatewayService.UploadTelemetry:input_type -> fleetnodegateway.v1.UploadTelemetryRequest + 11, // 21: fleetnodegateway.v1.FleetNodeGatewayService.UploadEvents:input_type -> fleetnodegateway.v1.UploadEventsRequest + 13, // 22: fleetnodegateway.v1.FleetNodeGatewayService.UploadHeartbeat:input_type -> fleetnodegateway.v1.UploadHeartbeatRequest + 15, // 23: fleetnodegateway.v1.FleetNodeGatewayService.ReportDiscoveredDevices:input_type -> fleetnodegateway.v1.ReportDiscoveredDevicesRequest + 20, // 24: fleetnodegateway.v1.FleetNodeGatewayService.ReportPairedDevices:input_type -> fleetnodegateway.v1.ReportPairedDevicesRequest + 22, // 25: fleetnodegateway.v1.FleetNodeGatewayService.ControlStream:input_type -> fleetnodegateway.v1.ControlStreamRequest + 4, // 26: fleetnodegateway.v1.FleetNodeGatewayService.Register:output_type -> fleetnodegateway.v1.RegisterResponse + 6, // 27: fleetnodegateway.v1.FleetNodeGatewayService.BeginAuthHandshake:output_type -> fleetnodegateway.v1.BeginAuthHandshakeResponse + 8, // 28: fleetnodegateway.v1.FleetNodeGatewayService.CompleteAuthHandshake:output_type -> fleetnodegateway.v1.CompleteAuthHandshakeResponse + 10, // 29: fleetnodegateway.v1.FleetNodeGatewayService.UploadTelemetry:output_type -> fleetnodegateway.v1.UploadTelemetryResponse + 12, // 30: fleetnodegateway.v1.FleetNodeGatewayService.UploadEvents:output_type -> fleetnodegateway.v1.UploadEventsResponse + 14, // 31: fleetnodegateway.v1.FleetNodeGatewayService.UploadHeartbeat:output_type -> fleetnodegateway.v1.UploadHeartbeatResponse + 17, // 32: fleetnodegateway.v1.FleetNodeGatewayService.ReportDiscoveredDevices:output_type -> fleetnodegateway.v1.ReportDiscoveredDevicesResponse + 21, // 33: fleetnodegateway.v1.FleetNodeGatewayService.ReportPairedDevices:output_type -> fleetnodegateway.v1.ReportPairedDevicesResponse + 23, // 34: fleetnodegateway.v1.FleetNodeGatewayService.ControlStream:output_type -> fleetnodegateway.v1.ControlStreamResponse + 26, // [26:35] is the sub-list for method output_type + 17, // [17:26] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_fleetnodegateway_v1_fleetnodegateway_proto_init() } @@ -2066,11 +2152,11 @@ func file_fleetnodegateway_v1_fleetnodegateway_proto_init() { if File_fleetnodegateway_v1_fleetnodegateway_proto != nil { return } - file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[18].OneofWrappers = []any{ + file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[19].OneofWrappers = []any{ (*ControlStreamRequest_Hello)(nil), (*ControlStreamRequest_Ack)(nil), } - file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[19].OneofWrappers = []any{ + file_fleetnodegateway_v1_fleetnodegateway_proto_msgTypes[20].OneofWrappers = []any{ (*ControlStreamResponse_Accepted)(nil), (*ControlStreamResponse_Command)(nil), } @@ -2080,7 +2166,7 @@ func file_fleetnodegateway_v1_fleetnodegateway_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_fleetnodegateway_v1_fleetnodegateway_proto_rawDesc), len(file_fleetnodegateway_v1_fleetnodegateway_proto_rawDesc)), NumEnums: 3, - NumMessages: 24, + NumMessages: 25, NumExtensions: 0, NumServices: 1, }, diff --git a/server/generated/sqlc/db.go b/server/generated/sqlc/db.go index 352ebd546..a53487965 100644 --- a/server/generated/sqlc/db.go +++ b/server/generated/sqlc/db.go @@ -198,6 +198,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deviceHasActiveCloudPairingStmt, err = db.PrepareContext(ctx, deviceHasActiveCloudPairing); err != nil { return nil, fmt.Errorf("error preparing query DeviceHasActiveCloudPairing: %w", err) } + if q.deviceHasActivePairingStmt, err = db.PrepareContext(ctx, deviceHasActivePairing); err != nil { + return nil, fmt.Errorf("error preparing query DeviceHasActivePairing: %w", err) + } if q.deviceSetBelongsToOrgStmt, err = db.PrepareContext(ctx, deviceSetBelongsToOrg); err != nil { return nil, fmt.Errorf("error preparing query DeviceSetBelongsToOrg: %w", err) } @@ -816,6 +819,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.revokeSessionStmt, err = db.PrepareContext(ctx, revokeSession); err != nil { return nil, fmt.Errorf("error preparing query RevokeSession: %w", err) } + if q.setDevicePairingAuthNeededIfNotPairedStmt, err = db.PrepareContext(ctx, setDevicePairingAuthNeededIfNotPaired); err != nil { + return nil, fmt.Errorf("error preparing query SetDevicePairingAuthNeededIfNotPaired: %w", err) + } if q.setFleetNodeEnrollmentStatusStmt, err = db.PrepareContext(ctx, setFleetNodeEnrollmentStatus); err != nil { return nil, fmt.Errorf("error preparing query SetFleetNodeEnrollmentStatus: %w", err) } @@ -1360,6 +1366,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deviceHasActiveCloudPairingStmt: %w", cerr) } } + if q.deviceHasActivePairingStmt != nil { + if cerr := q.deviceHasActivePairingStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deviceHasActivePairingStmt: %w", cerr) + } + } if q.deviceSetBelongsToOrgStmt != nil { if cerr := q.deviceSetBelongsToOrgStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deviceSetBelongsToOrgStmt: %w", cerr) @@ -2390,6 +2401,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing revokeSessionStmt: %w", cerr) } } + if q.setDevicePairingAuthNeededIfNotPairedStmt != nil { + if cerr := q.setDevicePairingAuthNeededIfNotPairedStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing setDevicePairingAuthNeededIfNotPairedStmt: %w", cerr) + } + } if q.setFleetNodeEnrollmentStatusStmt != nil { if cerr := q.setFleetNodeEnrollmentStatusStmt.Close(); cerr != nil { err = fmt.Errorf("error closing setFleetNodeEnrollmentStatusStmt: %w", cerr) @@ -2902,6 +2918,7 @@ type Queries struct { deletePoolStmt *sql.Stmt deleteScheduleTargetsStmt *sql.Stmt deviceHasActiveCloudPairingStmt *sql.Stmt + deviceHasActivePairingStmt *sql.Stmt deviceSetBelongsToOrgStmt *sql.Stmt ensureCurtailmentOrgConfigStmt *sql.Stmt findDeviceSiteConflictsStmt *sql.Stmt @@ -3108,6 +3125,7 @@ type Queries struct { revokeApiKeysByFleetNodeIDStmt *sql.Stmt revokePermissionFromRoleStmt *sql.Stmt revokeSessionStmt *sql.Stmt + setDevicePairingAuthNeededIfNotPairedStmt *sql.Stmt setFleetNodeEnrollmentStatusStmt *sql.Stmt setRackBuildingPositionStmt *sql.Stmt setRackSlotPositionStmt *sql.Stmt @@ -3255,6 +3273,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { deletePoolStmt: q.deletePoolStmt, deleteScheduleTargetsStmt: q.deleteScheduleTargetsStmt, deviceHasActiveCloudPairingStmt: q.deviceHasActiveCloudPairingStmt, + deviceHasActivePairingStmt: q.deviceHasActivePairingStmt, deviceSetBelongsToOrgStmt: q.deviceSetBelongsToOrgStmt, ensureCurtailmentOrgConfigStmt: q.ensureCurtailmentOrgConfigStmt, findDeviceSiteConflictsStmt: q.findDeviceSiteConflictsStmt, @@ -3461,6 +3480,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { revokeApiKeysByFleetNodeIDStmt: q.revokeApiKeysByFleetNodeIDStmt, revokePermissionFromRoleStmt: q.revokePermissionFromRoleStmt, revokeSessionStmt: q.revokeSessionStmt, + setDevicePairingAuthNeededIfNotPairedStmt: q.setDevicePairingAuthNeededIfNotPairedStmt, setFleetNodeEnrollmentStatusStmt: q.setFleetNodeEnrollmentStatusStmt, setRackBuildingPositionStmt: q.setRackBuildingPositionStmt, setRackSlotPositionStmt: q.setRackSlotPositionStmt, diff --git a/server/generated/sqlc/device.sql.go b/server/generated/sqlc/device.sql.go index d670a8f93..bf76cb29d 100644 --- a/server/generated/sqlc/device.sql.go +++ b/server/generated/sqlc/device.sql.go @@ -1820,6 +1820,35 @@ func (q *Queries) ListMinerStateSnapshots(ctx context.Context) ([]ListMinerState return items, nil } +const setDevicePairingAuthNeededIfNotPaired = `-- name: SetDevicePairingAuthNeededIfNotPaired :execrows +INSERT INTO device_pairing ( + device_id, + pairing_status, + paired_at +) VALUES ( + $1, + 'AUTHENTICATION_NEEDED'::pairing_status_enum, + CURRENT_TIMESTAMP +) +ON CONFLICT (device_id) DO UPDATE SET + pairing_status = 'AUTHENTICATION_NEEDED'::pairing_status_enum, + paired_at = CURRENT_TIMESTAMP, + unpaired_at = NULL +WHERE device_pairing.pairing_status IS DISTINCT FROM 'PAIRED'::pairing_status_enum +` + +// Mark a device AUTHENTICATION_NEEDED without ever downgrading a PAIRED row: the +// WHERE guard no-ops the write when the row is already PAIRED, closing the race +// where a concurrent pair commits PAIRED between the caller's read and this write +// (the DO UPDATE branch re-reads the latest committed row). Zero rows means PAIRED won. +func (q *Queries) SetDevicePairingAuthNeededIfNotPaired(ctx context.Context, deviceID int64) (int64, error) { + result, err := q.exec(ctx, q.setDevicePairingAuthNeededIfNotPairedStmt, setDevicePairingAuthNeededIfNotPaired, deviceID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const softDeleteDevices = `-- name: SoftDeleteDevices :execrows UPDATE device SET deleted_at = NOW() WHERE device_identifier = ANY($1::text[]) @@ -1895,7 +1924,10 @@ const updateDeviceInfo = `-- name: UpdateDeviceInfo :exec UPDATE device SET mac_address = COALESCE(NULLIF($1::text, ''), mac_address), - serial_number = $2 + -- Preserve a previously learned serial when the report omits it (e.g. an + -- AUTH_NEEDED/AUTH_FAILED retry, or a plugin that returns only a MAC), matching + -- mac_address above; a blank arg must not erase the stored serial. + serial_number = COALESCE(NULLIF($2::text, ''), serial_number) WHERE device_identifier = $3 AND org_id = $4 AND deleted_at IS NULL @@ -1903,7 +1935,7 @@ WHERE device_identifier = $3 type UpdateDeviceInfoParams struct { MacAddress string - SerialNumber sql.NullString + SerialNumber string DeviceIdentifier string OrgID int64 } diff --git a/server/generated/sqlc/fleetnodepairing.sql.go b/server/generated/sqlc/fleetnodepairing.sql.go index 0f42a099e..3e6c18d53 100644 --- a/server/generated/sqlc/fleetnodepairing.sql.go +++ b/server/generated/sqlc/fleetnodepairing.sql.go @@ -9,6 +9,8 @@ import ( "context" "database/sql" "time" + + "github.com/lib/pq" ) const deletePairingsForFleetNode = `-- name: DeletePairingsForFleetNode :execrows @@ -65,6 +67,35 @@ func (q *Queries) DeviceHasActiveCloudPairing(ctx context.Context, arg DeviceHas return exists, err } +const deviceHasActivePairing = `-- name: DeviceHasActivePairing :one +SELECT EXISTS ( + SELECT 1 + FROM device_pairing dp + JOIN device d ON d.id = dp.device_id + WHERE dp.device_id = $1 + AND d.org_id = $2 + AND d.deleted_at IS NULL + AND dp.pairing_status = 'PAIRED' +) +` + +type DeviceHasActivePairingParams struct { + DeviceID int64 + OrgID int64 +} + +// True when the device is PAIRED, regardless of whether it is cloud-dialed or +// bound to a fleet node. Used to refuse downgrading an already-PAIRED device to +// AUTHENTICATION_NEEDED on a non-PAIRED node report: between target resolution +// and persistence, another node (or the cloud) may have paired the device, and a +// stale AUTH_NEEDED result must not clobber that PAIRED status. +func (q *Queries) DeviceHasActivePairing(ctx context.Context, arg DeviceHasActivePairingParams) (bool, error) { + row := q.queryRow(ctx, q.deviceHasActivePairingStmt, deviceHasActivePairing, arg.DeviceID, arg.OrgID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + const listFleetNodeDevices = `-- name: ListFleetNodeDevices :many SELECT fnd.fleet_node_id, fnd.device_id, @@ -150,26 +181,54 @@ WHERE dd.org_id = $1 SELECT 1 FROM device db JOIN fleet_node_device fnd ON fnd.device_id = db.id AND fnd.org_id = dd.org_id - WHERE db.discovered_device_id = dd.id AND db.deleted_at IS NULL + WHERE (db.discovered_device_id = dd.id + OR (db.device_identifier = dd.device_identifier AND db.org_id = dd.org_id)) + AND db.deleted_at IS NULL ) AND NOT EXISTS ( SELECT 1 FROM device dpd JOIN device_pairing dpp ON dpp.device_id = dpd.id - WHERE dpd.discovered_device_id = dd.id AND dpd.deleted_at IS NULL + WHERE (dpd.discovered_device_id = dd.id + OR (dpd.device_identifier = dd.device_identifier AND dpd.org_id = dd.org_id)) + AND dpd.deleted_at IS NULL AND dpp.pairing_status = 'PAIRED' ) AND ($2::bigint IS NULL OR dd.discovered_by_fleet_node_id = $2::bigint) - AND ($3::bigint IS NULL OR dd.id > $3::bigint) + -- pair-all without operator credentials can't satisfy AUTHENTICATION_NEEDED rows + -- (they were already attempted and need credentials). Excluding them keeps a + -- capped first page from filling with unsatisfiable rows and starving + -- never-attempted devices on re-issue for nodes with more than ` + "`" + `limit` + "`" + ` + -- candidates. NULL/false keeps them (listing for display, and pair-all WITH + -- credentials, which can retry them). + AND ( + NOT COALESCE($3::bool, FALSE) + OR NOT EXISTS ( + SELECT 1 + FROM device adn + JOIN device_pairing adp ON adp.device_id = adn.id + WHERE (adn.discovered_device_id = dd.id + OR (adn.device_identifier = dd.device_identifier AND adn.org_id = dd.org_id)) + AND adn.deleted_at IS NULL + AND adp.pairing_status = 'AUTHENTICATION_NEEDED' + ) + ) + -- Explicit pairing passes the requested identifiers so only those rows are + -- scanned, not the whole org. NULL = no filter (listing + pair-all); an empty + -- non-nil array matches nothing (explicit selection of none). + AND ($4::text[] IS NULL OR dd.device_identifier = ANY($4::text[])) + AND ($5::bigint IS NULL OR dd.id > $5::bigint) ORDER BY dd.id ASC, d.id DESC NULLS LAST -LIMIT $4::bigint +LIMIT $6::bigint ` type ListFleetNodeDiscoveredDevicesParams struct { - OrgID int64 - FleetNodeID sql.NullInt64 - CursorID sql.NullInt64 - Limit sql.NullInt64 + OrgID int64 + FleetNodeID sql.NullInt64 + ExcludeAuthNeeded sql.NullBool + Identifiers []string + CursorID sql.NullInt64 + Limit sql.NullInt64 } type ListFleetNodeDiscoveredDevicesRow struct { @@ -194,7 +253,11 @@ type ListFleetNodeDiscoveredDevicesRow struct { // attempt that needs credentials) surface for retry. Inverse of // GetActiveUnpairedDiscoveredDevices, which excludes fleet-node rows. // The exclusions use NOT EXISTS so a device with more than one live row is -// judged across all of them, not just the joined row. DISTINCT ON (dd.id) with +// judged across all of them, not just the joined row. They match by +// discovered_device_id OR device_identifier: a paired device whose original +// discovery row was soft-deleted and re-created by a node keeps the same +// identifier but a different linkage, and must not be dispatched for pairing +// (the node would mutate a miner persistence then rejects). DISTINCT ON (dd.id) with // the d.id DESC tie-breaker yields one deterministic row per discovered device // (the latest live device's pairing_status). Paginates by ascending id; a NULL // limit returns all rows (the pairing batch path needs every candidate). @@ -202,6 +265,8 @@ func (q *Queries) ListFleetNodeDiscoveredDevices(ctx context.Context, arg ListFl rows, err := q.query(ctx, q.listFleetNodeDiscoveredDevicesStmt, listFleetNodeDiscoveredDevices, arg.OrgID, arg.FleetNodeID, + arg.ExcludeAuthNeeded, + pq.Array(arg.Identifiers), arg.CursorID, arg.Limit, ) diff --git a/server/internal/domain/fleetnode/control/dispatch.go b/server/internal/domain/fleetnode/control/dispatch.go new file mode 100644 index 000000000..6321bf1a9 --- /dev/null +++ b/server/internal/domain/fleetnode/control/dispatch.go @@ -0,0 +1,112 @@ +package control + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "connectrpc.com/connect" + + gatewaypb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" +) + +// Sender dispatches one command to a node's ControlStream. *Registry implements it. +type Sender interface { + Send(ctx context.Context, fleetNodeID int64, cmd *gatewaypb.ControlCommand, scope ReportScope, kind ReportKind, pair *PairMeta) (*Session, error) +} + +// RunCommand dispatches cmd, drains result events through onData until the terminal +// ack, and maps the outcome to an error. Shared by discovery and pairing. kind/pair +// are as in Send; noun names the command in errors. onData returns terminal=true to +// stop early. Returns nil on an OK or PARTIAL ack, error otherwise (or onData's). +func RunCommand(ctx context.Context, sender Sender, fleetNodeID int64, cmd *gatewaypb.ControlCommand, scope ReportScope, kind ReportKind, pair *PairMeta, timeout time.Duration, noun string, onData func(CommandEvent) (terminal bool, err error)) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + session, err := sender.Send(ctx, fleetNodeID, cmd, scope, kind, pair) + if err != nil { + if errors.Is(err, ErrNoActiveStream) { + return fleeterror.NewFailedPreconditionError("fleet node has no active control stream") + } + return err + } + defer session.Close() + + handleEvent := func(ev CommandEvent) (terminal bool, err error) { + if ev.Ack != nil { + // PARTIAL: results already streamed, so treat it as usable, not a failure. + if ev.Ack.GetCode() == gatewaypb.AckCode_ACK_CODE_PARTIAL { + slog.Warn("fleet node command completed partially", + "fleet_node_id", fleetNodeID, "command", noun, "detail", ev.Ack.GetErrorMessage()) + return true, nil + } + // Require the OK code, not just succeeded=true, so an inconsistent ack + // can't pass a failed command off as success. + if ev.Ack.GetCode() != gatewaypb.AckCode_ACK_CODE_OK || !ev.Ack.GetSucceeded() { + return true, AckFailure(ev.Ack, noun) + } + return true, nil + } + return onData(ev) + } + + events := session.Events() + for { + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return connect.NewError(connect.CodeDeadlineExceeded, fmt.Errorf("%s command timed out after %s", noun, timeout)) + } + // Caller cancelled; report it as such, not a server-side Internal failure. + return fleeterror.NewCanceledError() + case ev := <-events: + if terminal, err := handleEvent(ev); terminal { + return err + } + case <-session.Done(): + // Stream died before an ack; drain buffered events first so select + // randomness doesn't drop a final ack or last batch. + for { + select { + case ev := <-events: + if terminal, err := handleEvent(ev); terminal { + return err + } + default: + return fleeterror.NewFailedPreconditionError("fleet node control stream closed before command completed") + } + } + } + } +} + +// AckFailure maps a non-OK terminal ack to an operator-facing error. The structured +// AckCode drives the gRPC code so BUSY, AGENT_INCAPABLE, and BAD_REQUEST stay +// distinguishable; anything else is an opaque Internal failure. +func AckFailure(ack *gatewaypb.ControlAck, noun string) error { + reason := ack.GetErrorMessage() + if reason == "" { + reason = "code " + ack.GetCode().String() + } + // if/else (not switch) so the exhaustive linter doesn't demand a case per AckCode. + code := ack.GetCode() + if code == gatewaypb.AckCode_ACK_CODE_BAD_REQUEST { + return fleeterror.NewInvalidArgumentErrorf("fleet node rejected %s command: %s", noun, reason) + } + if code == gatewaypb.AckCode_ACK_CODE_BUSY { + return fleeterror.NewPlainError( + fmt.Sprintf("fleet node is busy with another command; retry shortly: %s", reason), + connect.CodeResourceExhausted, + ) + } + if code == gatewaypb.AckCode_ACK_CODE_AGENT_INCAPABLE { + return fleeterror.NewFailedPreconditionErrorf("fleet node cannot service this %s command; try another node: %s", noun, reason) + } + if code == gatewaypb.AckCode_ACK_CODE_REPORT_FAILED { + return fleeterror.NewInternalErrorf("fleet node could not upload all %s results; some may have been applied, re-list to confirm: %s", noun, reason) + } + return fleeterror.NewInternalErrorf("fleet node reported %s failure: %s", noun, reason) +} diff --git a/server/internal/domain/fleetnode/discovery/ackfailure_test.go b/server/internal/domain/fleetnode/control/dispatch_test.go similarity index 88% rename from server/internal/domain/fleetnode/discovery/ackfailure_test.go rename to server/internal/domain/fleetnode/control/dispatch_test.go index f8caaeb37..c1b2b955e 100644 --- a/server/internal/domain/fleetnode/discovery/ackfailure_test.go +++ b/server/internal/domain/fleetnode/control/dispatch_test.go @@ -1,4 +1,4 @@ -package discovery +package control import ( "testing" @@ -7,15 +7,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/block/proto-fleet/server/internal/domain/fleeterror" - gatewaypb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" ) -// discoverAckFailure must translate each structured AckCode into a distinct, +// AckFailure must translate each structured AckCode into a distinct, // operator-meaningful gRPC code so a retryable BUSY and a capability gap // (AGENT_INCAPABLE) don't both surface as an opaque Internal error. -func TestDiscoverAckFailure_MapsCodes(t *testing.T) { +func TestAckFailure_MapsCodes(t *testing.T) { tests := []struct { name string ack *gatewaypb.ControlAck @@ -44,8 +43,11 @@ func TestDiscoverAckFailure_MapsCodes(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + // Arrange + ack := tc.ack + // Act - err := discoverAckFailure(tc.ack) + err := AckFailure(ack, "discovery") // Assert var fe fleeterror.FleetError diff --git a/server/internal/domain/fleetnode/control/registry.go b/server/internal/domain/fleetnode/control/registry.go index 3accdc549..ceeebbab8 100644 --- a/server/internal/domain/fleetnode/control/registry.go +++ b/server/internal/domain/fleetnode/control/registry.go @@ -63,19 +63,24 @@ var ( // command. Unexported; callers map it to FailedPrecondition like any non-quota admit failure. errNoInFlightCommand = errors.New("no in-flight command for fleet_node") - // ErrReportQuotaExceeded: a report would exceed maxReportsPerCommand. - ErrReportQuotaExceeded = errors.New("discovery report quota exceeded for command") + // ErrReportQuotaExceeded: a report would exceed the command's report quota. + ErrReportQuotaExceeded = errors.New("report quota exceeded for command") + + // ErrEmptyReport: a pair report carried no results (consumes no quota); + // callers map it to InvalidArgument. + ErrEmptyReport = errors.New("report carried no results") // errDuplicateCommandID: a command_id is already in flight for the fleet_node. // id.GenerateID() makes this practically impossible; callers map it to Internal. errDuplicateCommandID = errors.New("duplicate command_id in flight for fleet_node") ) -// CommandEvent is one message of a report-bearing command's result stream: exactly -// one of Batch or Ack is set. +// CommandEvent is one message of a command's result stream: exactly one of Batch, +// PairResults, or Ack is set. type CommandEvent struct { - Batch *pairingpb.DiscoverResponse - Ack *gatewaypb.ControlAck + Batch *pairingpb.DiscoverResponse + PairResults []*gatewaypb.FleetNodePairResult + Ack *gatewaypb.ControlAck } // ReportScope reports whether a device discovered at (ipAddress, port) falls @@ -84,6 +89,25 @@ type CommandEvent struct { // devices outside what it was asked to scan. A nil ReportScope is unconstrained. type ReportScope func(ipAddress, port string) bool +// ReportKind tags a report-bearing command so a discovery command_id admits only +// discovered-device reports and a pair command_id only pair results -- otherwise an +// authenticated node could poison inventory across the wrong command. +type ReportKind int + +const ( + ReportKindDiscovery ReportKind = iota + ReportKindPair +) + +// PairMeta is the operator context a pair command carries so the gateway can +// persist results authoritatively, scope them to the dispatched targets, and bound +// the report quota. nil for discovery. +type PairMeta struct { + OrgID int64 + AssignedBy *int64 // operator user id; nullable end-to-end + Targets map[string]struct{} // dispatched device identifiers; also the report quota +} + // connection is the server's view of one agent ControlStream. It can hold many // concurrent in-flight commands keyed by command_id. Fields guarded by Registry.mu. type connection struct { @@ -99,9 +123,15 @@ type inflightCommand struct { id string // report-bearing (events != nil): discovery/pairing batches + terminal ack. - scope ReportScope // admits only reported devices within the requested scope; nil = unconstrained - events chan CommandEvent // buffered, never closed - reported int // cumulative admitted device count, for quota + kind ReportKind // which gateway report RPC may admit reports for this command + scope ReportScope // admits only reported devices within the requested scope; nil = unconstrained + events chan CommandEvent // buffered, never closed + reported int // cumulative admitted device count, for quota + maxReports int // per-command report quota: scan ceiling for discovery, target count for pair + + // pair-only: gateway persistence metadata, scoped to the dispatched targets + // (consuming each to bar replay). nil for discovery/ack-only commands. + pair *PairMeta // ack-only (ack != nil): the terminal ack for a per-miner command. ack chan *gatewaypb.ControlAck // cap 1, never closed diff --git a/server/internal/domain/fleetnode/control/registry_test.go b/server/internal/domain/fleetnode/control/registry_test.go index 104c6b19a..bc0a82e25 100644 --- a/server/internal/domain/fleetnode/control/registry_test.go +++ b/server/internal/domain/fleetnode/control/registry_test.go @@ -18,7 +18,7 @@ func TestRegistry_ReRegisterEvictsPriorStream(t *testing.T) { // Arrange r := NewRegistry() first := r.Register(7) - session, err := r.Send(context.Background(), 7, &gatewaypb.ControlCommand{CommandId: "in-flight"}, nil) + session, err := r.Send(context.Background(), 7, &gatewaypb.ControlCommand{CommandId: "in-flight"}, nil, ReportKindDiscovery, nil) require.NoError(t, err) <-first.Outgoing @@ -44,7 +44,7 @@ func TestRegistry_ReRegisterEvictsPriorStream(t *testing.T) { // Assert: prior Unregister is a safe no-op (doesn't clobber new stream) first.Unregister() - _, err = r.Send(context.Background(), 7, &gatewaypb.ControlCommand{CommandId: "after-evict"}, nil) + _, err = r.Send(context.Background(), 7, &gatewaypb.ControlCommand{CommandId: "after-evict"}, nil, ReportKindDiscovery, nil) require.NoError(t, err) } @@ -53,7 +53,7 @@ func TestRegistry_SendWithoutStreamReturnsErrNoActiveStream(t *testing.T) { r := NewRegistry() // Act - _, err := r.Send(context.Background(), 9, &gatewaypb.ControlCommand{CommandId: "x"}, nil) + _, err := r.Send(context.Background(), 9, &gatewaypb.ControlCommand{CommandId: "x"}, nil, ReportKindDiscovery, nil) // Assert assert.True(t, errors.Is(err, ErrNoActiveStream)) @@ -66,7 +66,7 @@ func TestRegistry_SendDeliversCommandAndRoutesAck(t *testing.T) { defer s.Unregister() // Act - session, err := r.Send(context.Background(), 42, &gatewaypb.ControlCommand{CommandId: "cmd-1", Payload: []byte("p")}, nil) + session, err := r.Send(context.Background(), 42, &gatewaypb.ControlCommand{CommandId: "cmd-1", Payload: []byte("p")}, nil, ReportKindDiscovery, nil) require.NoError(t, err) defer session.Close() @@ -100,7 +100,7 @@ func TestRegistry_TerminalAckDeliveredWhenEventBufferFull(t *testing.T) { r := NewRegistry() s := r.Register(1) defer s.Unregister() - session, err := r.Send(context.Background(), 1, &gatewaypb.ControlCommand{CommandId: "discover"}, nil) + session, err := r.Send(context.Background(), 1, &gatewaypb.ControlCommand{CommandId: "discover"}, nil, ReportKindDiscovery, nil) require.NoError(t, err) defer session.Close() require.Equal(t, "discover", recvCommandID(t, s)) @@ -139,7 +139,7 @@ func TestRegistry_ConcurrentCommandsNotRejected(t *testing.T) { r := NewRegistry() s := r.Register(1) defer s.Unregister() - session, err := r.Send(context.Background(), 1, &gatewaypb.ControlCommand{CommandId: "discover"}, nil) + session, err := r.Send(context.Background(), 1, &gatewaypb.ControlCommand{CommandId: "discover"}, nil, ReportKindDiscovery, nil) require.NoError(t, err) defer session.Close() require.Equal(t, "discover", recvCommandID(t, s)) @@ -226,7 +226,7 @@ func TestRegistry_AckRoutesByKind(t *testing.T) { require.Equal(t, "mk", recvCommandID(t, s)) // Assert: an ack-only command is not a report channel; the report path rejects it. - assert.ErrorIs(t, r.AdmitReport(1, "mk", 1), errNoInFlightCommand) + assert.ErrorIs(t, r.AdmitReport(1, "mk", 1, ReportKindDiscovery), errNoInFlightCommand) _, ok := r.ReportScopeFor(1, "mk") assert.False(t, ok) @@ -241,7 +241,7 @@ func TestRegistry_TeardownClosesAllInFlightCommands(t *testing.T) { // Arrange: a discovery and an ack-only command are both in flight. r := NewRegistry() s := r.Register(1) - session, err := r.Send(context.Background(), 1, &gatewaypb.ControlCommand{CommandId: "discover"}, nil) + session, err := r.Send(context.Background(), 1, &gatewaypb.ControlCommand{CommandId: "discover"}, nil, ReportKindDiscovery, nil) require.NoError(t, err) require.Equal(t, "discover", recvCommandID(t, s)) results := make(chan cmdResult, 1) @@ -266,26 +266,151 @@ func TestRegistry_AdmitReportEnforcesQuota(t *testing.T) { r := NewRegistry() s := r.Register(77) defer s.Unregister() - session, err := r.Send(context.Background(), 77, &gatewaypb.ControlCommand{CommandId: "scan"}, nil) + session, err := r.Send(context.Background(), 77, &gatewaypb.ControlCommand{CommandId: "scan"}, nil, ReportKindDiscovery, nil) require.NoError(t, err) defer session.Close() <-s.Outgoing // Act + Assert: reports up to the cap are admitted; the batch crossing it is rejected. - require.NoError(t, r.AdmitReport(77, "scan", maxReportsPerCommand-1)) - require.NoError(t, r.AdmitReport(77, "scan", 1)) - assert.ErrorIs(t, r.AdmitReport(77, "scan", 1), ErrReportQuotaExceeded) + require.NoError(t, r.AdmitReport(77, "scan", maxReportsPerCommand-1, ReportKindDiscovery)) + require.NoError(t, r.AdmitReport(77, "scan", 1, ReportKindDiscovery)) + assert.ErrorIs(t, r.AdmitReport(77, "scan", 1, ReportKindDiscovery), ErrReportQuotaExceeded) // Assert: a command_id that is not in flight is rejected as such. - assert.ErrorIs(t, r.AdmitReport(77, "other", 1), errNoInFlightCommand) - assert.ErrorIs(t, r.AdmitReport(404, "scan", 1), errNoInFlightCommand) + assert.ErrorIs(t, r.AdmitReport(77, "other", 1, ReportKindDiscovery), errNoInFlightCommand) + assert.ErrorIs(t, r.AdmitReport(404, "scan", 1, ReportKindDiscovery), errNoInFlightCommand) +} + +func TestRegistry_AdmitReportRejectsCrossKind(t *testing.T) { + // Arrange: one discovery command and one pair command in flight on the same node. + r := NewRegistry() + s := r.Register(5) + defer s.Unregister() + discSession, err := r.Send(context.Background(), 5, &gatewaypb.ControlCommand{CommandId: "disc"}, nil, ReportKindDiscovery, nil) + require.NoError(t, err) + defer discSession.Close() + <-s.Outgoing + pairSession, err := r.Send(context.Background(), 5, &gatewaypb.ControlCommand{CommandId: "pair"}, nil, ReportKindPair, nil) + require.NoError(t, err) + defer pairSession.Close() + <-s.Outgoing + + // Act + Assert: each command_id admits only reports of its own kind. A node + // can't upload discovery rows against a pair command_id (or vice versa). + assert.NoError(t, r.AdmitReport(5, "disc", 1, ReportKindDiscovery)) + assert.ErrorIs(t, r.AdmitReport(5, "disc", 1, ReportKindPair), errNoInFlightCommand) + assert.NoError(t, r.AdmitReport(5, "pair", 1, ReportKindPair)) + assert.ErrorIs(t, r.AdmitReport(5, "pair", 1, ReportKindDiscovery), errNoInFlightCommand) +} + +func sendPair(t *testing.T, r *Registry, fleetNodeID int64, commandID string, pair *PairMeta) (*Session, *Stream) { + t.Helper() + s := r.Register(fleetNodeID) + session, err := r.Send(context.Background(), fleetNodeID, &gatewaypb.ControlCommand{CommandId: commandID}, nil, ReportKindPair, pair) + require.NoError(t, err) + <-s.Outgoing + return session, s +} + +func TestRegistry_AdmitAndScopePairResults_ScopesAndConsumes(t *testing.T) { + // Arrange: a pair command scoped to three targets. + r := NewRegistry() + pair := &PairMeta{OrgID: 9, AssignedBy: nil, Targets: map[string]struct{}{"a": {}, "b": {}, "c": {}}} + session, s := sendPair(t, r, 3, "p", pair) + defer s.Unregister() + defer session.Close() + + // Act: one in-scope ("a"), one out-of-scope ("zzz"), one replay of "a". + kept, meta, err := r.AdmitAndScopePairResults(3, "p", []*gatewaypb.FleetNodePairResult{ + {DeviceIdentifier: "a"}, {DeviceIdentifier: "zzz"}, {DeviceIdentifier: "a"}, + }) + + // Assert: only the first in-scope "a" is kept; out-of-scope + replay dropped; + // meta carries the operator context for the gateway to persist with. + require.NoError(t, err) + require.Len(t, kept, 1) + assert.Equal(t, "a", kept[0].GetDeviceIdentifier()) + assert.Equal(t, int64(9), meta.OrgID) +} + +func TestRegistry_AdmitAndScopePairResults_RejectsEmptyAndKind(t *testing.T) { + // Arrange + r := NewRegistry() + pair := &PairMeta{OrgID: 1, Targets: map[string]struct{}{"a": {}}} + session, s := sendPair(t, r, 4, "p", pair) + defer s.Unregister() + defer session.Close() + discSession, err := r.Send(context.Background(), 4, &gatewaypb.ControlCommand{CommandId: "d"}, nil, ReportKindDiscovery, nil) + require.NoError(t, err) + defer discSession.Close() + <-s.Outgoing + + // Act + Assert: empty batch rejected (would consume no quota). + _, _, err = r.AdmitAndScopePairResults(4, "p", nil) + assert.ErrorIs(t, err, ErrEmptyReport) + + // An oversized batch admits only the in-scope rows; quota is charged per + // consumed target, so the extra row is dropped rather than rejecting the batch. + kept, _, err := r.AdmitAndScopePairResults(4, "p", []*gatewaypb.FleetNodePairResult{ + {DeviceIdentifier: "a"}, {DeviceIdentifier: "b"}, + }) + require.NoError(t, err) + require.Len(t, kept, 1) + assert.Equal(t, "a", kept[0].GetDeviceIdentifier()) + + // A discovery command_id is not a pair command. + _, _, err = r.AdmitAndScopePairResults(4, "d", []*gatewaypb.FleetNodePairResult{{DeviceIdentifier: "a"}}) + assert.ErrorIs(t, err, errNoInFlightCommand) +} + +func TestRegistry_AdmitAndScopePairResults_DuplicatesDoNotStarveLaterTargets(t *testing.T) { + // Arrange: two targets; the node first reports a duplicated identifier. + r := NewRegistry() + pair := &PairMeta{OrgID: 1, Targets: map[string]struct{}{"a": {}, "b": {}}} + session, s := sendPair(t, r, 5, "p", pair) + defer s.Unregister() + defer session.Close() + + // Act: [a, a] consumes only "a"; a later report for "b" must still be admitted. + kept, _, err := r.AdmitAndScopePairResults(5, "p", []*gatewaypb.FleetNodePairResult{ + {DeviceIdentifier: "a"}, {DeviceIdentifier: "a"}, + }) + require.NoError(t, err) + require.Len(t, kept, 1) + later, _, err := r.AdmitAndScopePairResults(5, "p", []*gatewaypb.FleetNodePairResult{{DeviceIdentifier: "b"}}) + + // Assert + require.NoError(t, err) + require.Len(t, later, 1) + assert.Equal(t, "b", later[0].GetDeviceIdentifier()) +} + +func TestRegistry_ReinstatePairTargets_AllowsRetryAfterPersistFailure(t *testing.T) { + // Arrange: a consumed target whose persistence failed. + r := NewRegistry() + pair := &PairMeta{OrgID: 1, Targets: map[string]struct{}{"a": {}}} + session, s := sendPair(t, r, 6, "p", pair) + defer s.Unregister() + defer session.Close() + kept, _, err := r.AdmitAndScopePairResults(6, "p", []*gatewaypb.FleetNodePairResult{{DeviceIdentifier: "a"}}) + require.NoError(t, err) + require.Len(t, kept, 1) + + // Act: the gateway reinstates the target after the persist failure. + r.ReinstatePairTargets(6, "p", []string{"a"}) + + // Assert: a retried report for the same command re-admits the identifier. + retried, _, err := r.AdmitAndScopePairResults(6, "p", []*gatewaypb.FleetNodePairResult{{DeviceIdentifier: "a"}}) + require.NoError(t, err) + require.Len(t, retried, 1) + assert.Equal(t, "a", retried[0].GetDeviceIdentifier()) } func TestRegistry_UnregisterSignalsInFlightCommandDone(t *testing.T) { // Arrange r := NewRegistry() s := r.Register(99) - session, err := r.Send(context.Background(), 99, &gatewaypb.ControlCommand{CommandId: "drop"}, nil) + session, err := r.Send(context.Background(), 99, &gatewaypb.ControlCommand{CommandId: "drop"}, nil, ReportKindDiscovery, nil) require.NoError(t, err) <-s.Outgoing @@ -318,7 +443,7 @@ func TestPublish_DropsWhenChannelFullWithoutBlocking(t *testing.T) { s := r.Register(11) defer s.Unregister() - session, err := r.Send(context.Background(), 11, &gatewaypb.ControlCommand{CommandId: "flood"}, nil) + session, err := r.Send(context.Background(), 11, &gatewaypb.ControlCommand{CommandId: "flood"}, nil, ReportKindDiscovery, nil) require.NoError(t, err) defer session.Close() <-s.Outgoing @@ -372,7 +497,7 @@ func TestPublish_RaceWithCleanup(t *testing.T) { go func() { defer wg.Done() for range iters { - session, sendErr := r.Send(context.Background(), 101, &gatewaypb.ControlCommand{CommandId: "race-cmd"}, nil) + session, sendErr := r.Send(context.Background(), 101, &gatewaypb.ControlCommand{CommandId: "race-cmd"}, nil, ReportKindDiscovery, nil) if sendErr != nil { // Send only fails here if the connection was evicted mid-call; fine, race continues. continue @@ -448,7 +573,7 @@ func TestSend_RaceWithReRegister(t *testing.T) { for i := range iters * 4 { session, sendErr := r.Send(context.Background(), 202, &gatewaypb.ControlCommand{ CommandId: cmdID(i), - }, nil) + }, nil, ReportKindDiscovery, nil) if sendErr == nil { session.Close() } diff --git a/server/internal/domain/fleetnode/control/session.go b/server/internal/domain/fleetnode/control/session.go index 6dd9a1fed..23f70824e 100644 --- a/server/internal/domain/fleetnode/control/session.go +++ b/server/internal/domain/fleetnode/control/session.go @@ -30,15 +30,23 @@ func (s *Session) Close() { } // Send dispatches a report-bearing command and returns a Session for its batches + -// terminal ack. scope bounds which reported devices the report path will admit for -// this command (nil = unconstrained). Many commands may be in flight per node -// concurrently. -func (r *Registry) Send(ctx context.Context, fleetNodeID int64, cmd *gatewaypb.ControlCommand, scope ReportScope) (*Session, error) { +// terminal ack. scope bounds which reported devices are admitted (nil = +// unconstrained); kind tags the admitting report RPC; pair is non-nil only for +// pairing (gateway persistence context; its target set caps the report quota). +// Many commands may be in flight per node concurrently. +func (r *Registry) Send(ctx context.Context, fleetNodeID int64, cmd *gatewaypb.ControlCommand, scope ReportScope, kind ReportKind, pair *PairMeta) (*Session, error) { + maxReports := maxReportsPerCommand + if pair != nil { + maxReports = len(pair.Targets) + } c := &inflightCommand{ - id: cmd.GetCommandId(), - scope: scope, - events: make(chan CommandEvent, commandEventBuffer), - done: make(chan struct{}), + id: cmd.GetCommandId(), + kind: kind, + scope: scope, + events: make(chan CommandEvent, commandEventBuffer), + maxReports: maxReports, + pair: pair, + done: make(chan struct{}), } outgoing, connDone, err := r.addCmd(fleetNodeID, c) if err != nil { diff --git a/server/internal/domain/fleetnode/control/stream.go b/server/internal/domain/fleetnode/control/stream.go index a966542b6..714a75613 100644 --- a/server/internal/domain/fleetnode/control/stream.go +++ b/server/internal/domain/fleetnode/control/stream.go @@ -62,23 +62,90 @@ func (r *Registry) PublishBatch(fleetNodeID int64, commandID string, batch *pair r.deliverEvent(fleetNodeID, commandID, CommandEvent{Batch: batch}) } +// PublishPairResults routes an agent pairing batch to the in-flight command. +func (r *Registry) PublishPairResults(fleetNodeID int64, commandID string, results []*gatewaypb.FleetNodePairResult) { + r.deliverEvent(fleetNodeID, commandID, CommandEvent{PairResults: results}) +} + // AdmitReport reserves quota for deviceCount devices against the in-flight -// report-bearing command. Returns errNoInFlightCommand if commandID isn't an -// in-flight report-bearing command, or ErrReportQuotaExceeded past maxReportsPerCommand. -func (r *Registry) AdmitReport(fleetNodeID int64, commandID string, deviceCount int) error { +// report-bearing command of kind want (a discovery command_id can't admit pair +// results or vice versa). Returns errNoInFlightCommand or ErrReportQuotaExceeded. +func (r *Registry) AdmitReport(fleetNodeID int64, commandID string, deviceCount int, want ReportKind) error { r.mu.Lock() defer r.mu.Unlock() cmd := r.inflightFor(fleetNodeID, commandID) - if cmd == nil || !cmd.reportBearing() { + if cmd == nil || !cmd.reportBearing() || cmd.kind != want { return errNoInFlightCommand } - if cmd.reported+deviceCount > maxReportsPerCommand { + if cmd.reported+deviceCount > cmd.maxReports { return ErrReportQuotaExceeded } cmd.reported += deviceCount return nil } +// PairPersistMeta is the operator context the gateway needs to persist a pair +// result authoritatively, returned by AdmitAndScopePairResults. +type PairPersistMeta struct { + OrgID int64 + AssignedBy *int64 +} + +// AdmitAndScopePairResults is the single atomic gate for the gateway's +// authoritative pair persistence: it returns only results whose device_identifier +// was a dispatched target, consuming each so a node can't replay it. Quota is +// charged per consumed target (not raw rows), so duplicate or out-of-scope rows +// in a batch can't starve later valid reports; consumption itself caps total +// admissions at the dispatched target count. Returns ErrEmptyReport for an empty +// batch or errNoInFlightCommand if commandID isn't an in-flight pair command. +func (r *Registry) AdmitAndScopePairResults(fleetNodeID int64, commandID string, results []*gatewaypb.FleetNodePairResult) ([]*gatewaypb.FleetNodePairResult, PairPersistMeta, error) { + r.mu.Lock() + defer r.mu.Unlock() + cmd := r.inflightFor(fleetNodeID, commandID) + if cmd == nil || !cmd.reportBearing() || cmd.kind != ReportKindPair || cmd.pair == nil { + return nil, PairPersistMeta{}, errNoInFlightCommand + } + if len(results) == 0 { + return nil, PairPersistMeta{}, ErrEmptyReport + } + + kept := make([]*gatewaypb.FleetNodePairResult, 0, len(results)) + for _, res := range results { + id := res.GetDeviceIdentifier() + if _, ok := cmd.pair.Targets[id]; !ok { + // Outside the dispatched targets or already consumed; anomalous for a node. + slog.Warn("dropping fleet node pair result outside the requested targets or already seen", + "fleet_node_id", fleetNodeID, "device_identifier", id) + continue + } + delete(cmd.pair.Targets, id) + kept = append(kept, res) + } + cmd.reported += len(kept) + return kept, PairPersistMeta{OrgID: cmd.pair.OrgID, AssignedBy: cmd.pair.AssignedBy}, nil +} + +// ReinstatePairTargets returns identifiers to the in-flight pair command's target +// set after their persistence failed, so a retried report for the same command can +// be re-admitted; without this, the consume-on-admit replay bar would make a +// transient DB failure permanent for the command's lifetime. No-op for identifiers +// already present or commands no longer in flight. +func (r *Registry) ReinstatePairTargets(fleetNodeID int64, commandID string, identifiers []string) { + r.mu.Lock() + defer r.mu.Unlock() + cmd := r.inflightFor(fleetNodeID, commandID) + if cmd == nil || cmd.kind != ReportKindPair || cmd.pair == nil { + return + } + for _, id := range identifiers { + if _, ok := cmd.pair.Targets[id]; ok { + continue + } + cmd.pair.Targets[id] = struct{}{} + cmd.reported-- + } +} + // ReportScopeFor returns the scan-scope matcher for the in-flight report-bearing // command, or (nil, false) if commandID isn't one. ok=true with a nil matcher means // the command is in flight but unconstrained. Callers filter reported devices diff --git a/server/internal/domain/fleetnode/discovery/service.go b/server/internal/domain/fleetnode/discovery/service.go index 21fc83e7e..89dc53bb0 100644 --- a/server/internal/domain/fleetnode/discovery/service.go +++ b/server/internal/domain/fleetnode/discovery/service.go @@ -8,14 +8,10 @@ package discovery import ( "context" - "errors" - "fmt" - "log/slog" "net/netip" "strconv" "time" - "connectrpc.com/connect" "google.golang.org/protobuf/proto" gatewaypb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" @@ -46,7 +42,7 @@ type nodeLister interface { // makes the coupling explicit and lets tests inject a fake without a Registry. type nodeRegistry interface { ConnectedFleetNodeIDs() []int64 - Send(ctx context.Context, fleetNodeID int64, cmd *gatewaypb.ControlCommand, scope control.ReportScope) (*control.Session, error) + Send(ctx context.Context, fleetNodeID int64, cmd *gatewaypb.ControlCommand, scope control.ReportScope, kind control.ReportKind, pair *control.PairMeta) (*control.Session, error) } // Service runs discovery commands against connected fleet nodes. @@ -96,9 +92,6 @@ func (s *Service) RunOnNode(ctx context.Context, fleetNodeID int64, req *pairing return err } - commandID := id.GenerateID() - // Discovery rides the shared AgentCommand envelope so the node can tell command - // kinds apart from the single ControlCommand.payload byte field. payload, err := proto.Marshal(&pairingpb.AgentCommand{ Command: &pairingpb.AgentCommand_Discover{Discover: normalized}, }) @@ -106,107 +99,16 @@ func (s *Service) RunOnNode(ctx context.Context, fleetNodeID int64, req *pairing return fleeterror.NewInternalErrorf("marshal discover payload: %v", err) } - ctx, cancel := context.WithTimeout(ctx, DiscoverCommandTimeout) - defer cancel() - - session, err := s.registry.Send(ctx, fleetNodeID, &gatewaypb.ControlCommand{ - CommandId: commandID, - Payload: payload, - }, buildReportScope(normalized)) - if err != nil { - if errors.Is(err, control.ErrNoActiveStream) { - return fleeterror.NewFailedPreconditionError("fleet node has no active control stream") - } - return err - } - defer session.Close() - - // terminal=true stops the loop whether or not err is set; an OK/PARTIAL ack - // is terminal with a nil err. - handleEvent := func(ev control.CommandEvent) (terminal bool, err error) { - switch { - case ev.Batch != nil: - if sendErr := onBatch(ev.Batch); sendErr != nil { - return true, sendErr - } - return false, nil - case ev.Ack != nil: - // PARTIAL carries succeeded=false but its reports already streamed; - // treat it as a usable (incomplete) result, not a failure. - if ev.Ack.GetCode() == gatewaypb.AckCode_ACK_CODE_PARTIAL { - slog.Warn("fleet node discovery completed partially", - "fleet_node_id", fleetNodeID, "detail", ev.Ack.GetErrorMessage()) - return true, nil - } - // Require the structured OK code, not just the boolean, so an - // inconsistent ack (succeeded=true with a non-OK/unset code) can't - // pass a failed scan off as success. - if ev.Ack.GetCode() != gatewaypb.AckCode_ACK_CODE_OK || !ev.Ack.GetSucceeded() { - return true, discoverAckFailure(ev.Ack) - } - return true, nil - default: - return false, nil - } - } - - events := session.Events() - for { - select { - case <-ctx.Done(): - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return connect.NewError(connect.CodeDeadlineExceeded, fmt.Errorf("discovery command timed out after %s", DiscoverCommandTimeout)) - } - // Caller (operator or fan-out) cancelled; report it as such rather - // than a server-side Internal failure. - return fleeterror.NewCanceledError() - case ev := <-events: - if terminal, err := handleEvent(ev); terminal { - return err - } - case <-session.Done(): - // Stream died before an ack. Drain buffered events first (a final - // ack or last batch) so select randomness doesn't drop them. - for { - select { - case ev := <-events: - if terminal, err := handleEvent(ev); terminal { - return err - } - default: - return fleeterror.NewFailedPreconditionError("fleet node control stream closed before command completed") + cmd := &gatewaypb.ControlCommand{CommandId: id.GenerateID(), Payload: payload} + return control.RunCommand(ctx, s.registry, fleetNodeID, cmd, buildReportScope(normalized), control.ReportKindDiscovery, nil, DiscoverCommandTimeout, "discovery", + func(ev control.CommandEvent) (terminal bool, err error) { + if ev.Batch != nil { + if sendErr := onBatch(ev.Batch); sendErr != nil { + return true, sendErr } } - } - } -} - -// discoverAckFailure maps a non-OK ack to an operator-facing error, even when -// error_message is empty. The structured AckCode drives the gRPC code so the -// operator can tell a retryable condition (BUSY) and a capability gap -// (AGENT_INCAPABLE) apart from a malformed request (BAD_REQUEST); anything else -// is an opaque Internal failure. -func discoverAckFailure(ack *gatewaypb.ControlAck) error { - reason := ack.GetErrorMessage() - if reason == "" { - reason = "code " + ack.GetCode().String() - } - // if/else (not switch) so the exhaustive linter doesn't demand a case per - // AckCode; everything outside these three is an opaque Internal failure. - code := ack.GetCode() - if code == gatewaypb.AckCode_ACK_CODE_BAD_REQUEST { - return fleeterror.NewInvalidArgumentErrorf("fleet node rejected discovery command: %s", reason) - } - if code == gatewaypb.AckCode_ACK_CODE_BUSY { - return fleeterror.NewPlainError( - fmt.Sprintf("fleet node is busy with another command; retry shortly: %s", reason), - connect.CodeResourceExhausted, - ) - } - if code == gatewaypb.AckCode_ACK_CODE_AGENT_INCAPABLE { - return fleeterror.NewFailedPreconditionErrorf("fleet node cannot service this discovery request; try another node: %s", reason) - } - return fleeterror.NewInternalErrorf("fleet node reported discovery failure: %s", reason) + return false, nil + }) } func normalizeDiscoverRequest(in *pairingpb.DiscoverRequest) (*pairingpb.DiscoverRequest, error) { diff --git a/server/internal/domain/fleetnode/pairing/integration_test.go b/server/internal/domain/fleetnode/pairing/integration_test.go index b2b490ec8..3790a7c79 100644 --- a/server/internal/domain/fleetnode/pairing/integration_test.go +++ b/server/internal/domain/fleetnode/pairing/integration_test.go @@ -4,6 +4,7 @@ import ( "crypto/ed25519" "crypto/rand" "database/sql" + "encoding/base64" "fmt" "testing" "time" @@ -16,6 +17,7 @@ import ( fleetnodeenrollment "github.com/block/proto-fleet/server/internal/domain/fleetnode/enrollment" fleetnodepairing "github.com/block/proto-fleet/server/internal/domain/fleetnode/pairing" "github.com/block/proto-fleet/server/internal/domain/stores/sqlstores" + "github.com/block/proto-fleet/server/internal/infrastructure/encrypt" "github.com/block/proto-fleet/server/internal/testutil" ) @@ -37,7 +39,10 @@ func setupPairingTest(t *testing.T) (*sql.DB, int64, *fleetnodepairing.Service, enrollmentStore := sqlstores.NewSQLFleetNodeEnrollmentStore(db) enrollmentSvc := fleetnodeenrollment.NewService(enrollmentStore, apiKeySvc, transactor, nil) pairingStore := sqlstores.NewSQLFleetNodePairingStore(db) - pairingSvc := fleetnodepairing.NewService(pairingStore, enrollmentStore, transactor) + encryptSvc, err := encrypt.NewService(&encrypt.Config{ServiceMasterKey: base64.StdEncoding.EncodeToString(make([]byte, 32))}) + require.NoError(t, err) + pairingSvc := fleetnodepairing.NewService(pairingStore, enrollmentStore, transactor). + WithProvisioning(sqlstores.NewSQLDeviceStore(db), sqlstores.NewSQLDiscoveredDeviceStore(db), encryptSvc, nil) return db, 1, pairingSvc, enrollmentSvc } diff --git a/server/internal/domain/fleetnode/pairing/models.go b/server/internal/domain/fleetnode/pairing/models.go index cfae6f7f6..071230592 100644 --- a/server/internal/domain/fleetnode/pairing/models.go +++ b/server/internal/domain/fleetnode/pairing/models.go @@ -1,6 +1,18 @@ package pairing -import "time" +import ( + "time" + + domainpairing "github.com/block/proto-fleet/server/internal/domain/pairing" +) + +// device_pairing.pairing_status values this package writes, aliased from the +// canonical constants so the two pairing paths can't drift. +const ( + StatusPaired = domainpairing.StatusPaired + StatusAuthenticationNeeded = domainpairing.StatusAuthenticationNeeded + StatusFailed = domainpairing.StatusFailed +) type DiscoveredDeviceReport struct { DeviceIdentifier string diff --git a/server/internal/domain/fleetnode/pairing/pair_discovered.go b/server/internal/domain/fleetnode/pairing/pair_discovered.go new file mode 100644 index 000000000..576fe502a --- /dev/null +++ b/server/internal/domain/fleetnode/pairing/pair_discovered.go @@ -0,0 +1,245 @@ +package pairing + +import ( + "context" + "log/slog" + + gatewaypb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" + pairingpb "github.com/block/proto-fleet/server/generated/grpc/pairing/v1" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + minermodels "github.com/block/proto-fleet/server/internal/domain/miner/models" + discoverymodels "github.com/block/proto-fleet/server/internal/domain/minerdiscovery/models" + "github.com/block/proto-fleet/server/internal/infrastructure/db" + "github.com/block/proto-fleet/server/internal/infrastructure/networking" + "github.com/block/proto-fleet/server/internal/infrastructure/secrets" +) + +// maxPairBatch caps targets per pair command, matching FleetNodePairRequest.targets +// max_items so a pair_all on a huge fleet can't balloon one ControlCommand; the +// operator re-issues for the remainder (paired devices drop from the listing). +const maxPairBatch = 1024 + +// ResolvePairTargets returns the pairable targets for a batch request. It draws +// from the not-yet-paired devices the node discovered (the listing already +// excludes cloud-paired and already-bound devices), so a requested identifier +// that is not pairable is silently dropped. Explicit selections are filtered in +// SQL by identifier (no whole-org scan); pair_all is capped at one batch. +func (s *Service) ResolvePairTargets(ctx context.Context, fleetNodeID, orgID int64, identifiers []string, pairAllUnpaired bool, credentials *pairingpb.Credentials) ([]*pairingpb.FleetNodePairTarget, error) { + var ( + ids []string + limit *int64 + ) + if pairAllUnpaired { + l := int64(maxPairBatch) + limit = &l + } else { + // Non-nil (even empty) so the SQL filter means "only these", not "all". + ids = identifiers + if ids == nil { + ids = []string{} + } + } + // A pair-all without usable basic-auth credentials can't satisfy + // AUTHENTICATION_NEEDED rows, so excluding them stops unsatisfiable rows from + // starving never-attempted devices on re-issue. Usable = password set (matches + // the node's secretBundleFor); explicit selection still targets them. + usableCredentials := credentials != nil && credentials.Password != nil + excludeAuthNeeded := pairAllUnpaired && !usableCredentials + candidates, err := s.store.ListFleetNodeDiscoveredDevices(ctx, orgID, &fleetNodeID, ids, nil, limit, excludeAuthNeeded) + if err != nil { + return nil, fleeterror.LogInternal(component, "list pair candidates", clientErrList, err) + } + targets := make([]*pairingpb.FleetNodePairTarget, 0, len(candidates)) + for _, c := range candidates { + targets = append(targets, &pairingpb.FleetNodePairTarget{ + DeviceIdentifier: c.DeviceIdentifier, + IpAddress: c.IPAddress, + Port: c.Port, + UrlScheme: c.URLScheme, + DriverName: c.DriverName, + Manufacturer: c.Manufacturer, + FirmwareVersion: c.FirmwareVersion, + }) + } + return targets, nil +} + +// PersistFleetNodePairResult records one device's reported pairing outcome in a +// single transaction and returns the resulting status. PAIRED binds the device to +// the node and stores basic-auth credentials; AUTH_NEEDED/AUTH_FAILED record +// AUTHENTICATION_NEEDED for retry; ERROR/UNSPECIFIED persist nothing. +func (s *Service) PersistFleetNodePairResult(ctx context.Context, fleetNodeID, orgID int64, result *gatewaypb.FleetNodePairResult, assignedBy *int64) (string, error) { + if s.deviceStore == nil || s.discoveredDeviceStore == nil || s.encryptService == nil { + return "", fleeterror.NewInternalError("fleet node pairing provisioning is not configured") + } + identifier := result.GetDeviceIdentifier() + outcome := result.GetOutcome() + if outcome == gatewaypb.PairOutcome_PAIR_OUTCOME_ERROR || outcome == gatewaypb.PairOutcome_PAIR_OUTCOME_UNSPECIFIED { + return StatusFailed, nil + } + + doi := discoverymodels.DeviceOrgIdentifier{DeviceIdentifier: identifier, OrgID: orgID} + // Default to the outcome's status; the downgrade guard below may override it. + persisted := StatusAuthenticationNeeded + if outcome == gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED { + persisted = StatusPaired + } + conflict := false + txErr := s.transactor.RunInTx(ctx, func(ctx context.Context) error { + dd, err := s.discoveredDeviceStore.GetDevice(ctx, doi) + if err != nil { + if fleeterror.IsNotFoundError(err) { + return fleeterror.NewNotFoundError("discovered device not found") + } + return fleeterror.LogInternal(component, "load discovered device", clientErrPair, err) + } + // Only the owning node may report results for this device. + if dd.DiscoveredByFleetNodeID == nil || *dd.DiscoveredByFleetNodeID != fleetNodeID { + return fleeterror.NewFailedPreconditionError("device was not discovered by this fleet node") + } + + existing, err := s.deviceStore.GetDeviceByDeviceIdentifier(ctx, identifier, orgID) + if err != nil && !fleeterror.IsNotFoundError(err) { + return fleeterror.LogInternal(component, "lookup device", clientErrLookupDeviceForPairing, err) + } + + // A non-PAIRED report must not downgrade an already-PAIRED device: between + // target resolution and now, the cloud or another node may have paired it. + // Check before any write and return its real status untouched. A freshly + // inserted device (existing == nil) can't be paired yet. + if outcome != gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED && existing != nil { + deviceID, err := s.store.GetDeviceIDByDeviceIdentifier(ctx, identifier) + if err != nil { + return fleeterror.LogInternal(component, "resolve device id", clientErrPair, err) + } + paired, err := s.store.DeviceHasActivePairing(ctx, deviceID, orgID) + if err != nil { + return fleeterror.LogInternal(component, "check active pairing", clientErrPair, err) + } + if paired { + persisted = StatusPaired + return nil + } + } + + applyReportedIdentity(dd, result) + if _, err := s.discoveredDeviceStore.Save(ctx, doi, dd); err != nil { + return fleeterror.LogInternal(component, "save discovered device", clientErrPair, err) + } + if existing == nil { + if err := s.deviceStore.InsertDevice(ctx, &dd.Device, orgID, identifier); err != nil { + if db.IsUniqueViolationError(err) { + conflict = true + return err + } + return fleeterror.LogInternal(component, "insert device", clientErrPair, err) + } + } else { + if err := s.deviceStore.UpdateDeviceInfo(ctx, &dd.Device, orgID); err != nil { + if db.IsUniqueViolationError(err) { + conflict = true + return err + } + return fleeterror.LogInternal(component, "update device", clientErrPair, err) + } + } + + if outcome != gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED { + // Closes the TOCTOU left by the guard read above: if a concurrent pair + // turned this device PAIRED meanwhile, the write no-ops (applied == false) + // and we report the real PAIRED status instead of downgrading it. + applied, err := s.deviceStore.SetDevicePairingAuthNeededIfNotPaired(ctx, &dd.Device, orgID) + if err != nil { + return fleeterror.LogInternal(component, "set auth-needed", clientErrPair, err) + } + if !applied { + persisted = StatusPaired + } + return nil + } + + // PAIRED: bind to the node BEFORE marking PAIRED, so the cloud-paired guard + // (PAIRED AND NOT node-bound) never sees a PAIRED-but-unbound row. + deviceID, err := s.store.GetDeviceIDByDeviceIdentifier(ctx, identifier) + if err != nil { + return fleeterror.LogInternal(component, "resolve device id", clientErrPair, err) + } + if err := s.pairDeviceLocked(ctx, fleetNodeID, deviceID, orgID, assignedBy); err != nil { + return err + } + if creds := nodeUsedCredentials(result); creds != nil { + if err := s.saveMinerCredentials(ctx, &dd.Device, orgID, creds); err != nil { + return err + } + } + if err := s.deviceStore.UpsertDevicePairing(ctx, &dd.Device, orgID, StatusPaired); err != nil { + return fleeterror.LogInternal(component, "set paired", clientErrPair, err) + } + // Reachable during pairing, so seed an ACTIVE status. + if err := s.deviceStore.UpsertDeviceStatus(ctx, minermodels.DeviceIdentifier(identifier), minermodels.MinerStatusActive, ""); err != nil { + return fleeterror.LogInternal(component, "set device status", clientErrPair, err) + } + return nil + }) + if conflict { + // The reported serial/identifier already belongs to another non-deleted + // device (e.g. an auto:* probe that couldn't read the serial pre-auth now + // reports one already registered). Surface a clean FAILED so the operator can + // reconcile, rather than an opaque Internal error; retrying won't clear it. + slog.Warn("fleet node pair result conflicts with an existing device; not persisted", + "fleet_node_id", fleetNodeID, "device_identifier", identifier, "serial_number", result.GetSerialNumber()) + return StatusFailed, nil + } + if txErr != nil { + return "", txErr + } + return persisted, nil +} + +// nodeUsedCredentials returns the credentials to persist for a successful pairing. +// The cloud can't verify a driver's auth mechanism in server mode, so the node's +// used_credentials presence is the password-auth signal: present (even blank) -> +// store as reported; nil -> asymmetric auth, store nothing. +func nodeUsedCredentials(result *gatewaypb.FleetNodePairResult) *pairingpb.Credentials { + uc := result.GetUsedCredentials() + if uc == nil { + return nil + } + pw := uc.GetPassword() + return &pairingpb.Credentials{Username: uc.GetUsername(), Password: &pw} +} + +func (s *Service) saveMinerCredentials(ctx context.Context, device *pairingpb.Device, orgID int64, creds *pairingpb.Credentials) error { + encUser, err := s.encryptService.Encrypt([]byte(creds.GetUsername())) + if err != nil { + return fleeterror.NewInternalErrorf("encrypt username: %v", err) + } + encPass, err := s.encryptService.Encrypt([]byte(creds.GetPassword())) + if err != nil { + return fleeterror.NewInternalErrorf("encrypt password: %v", err) + } + if err := s.deviceStore.UpsertMinerCredentials(ctx, device, orgID, encUser, secrets.NewText(encPass)); err != nil { + return fleeterror.LogInternal(component, "save credentials", clientErrPair, err) + } + return nil +} + +// applyReportedIdentity folds the identity the node learned during pairing into +// the discovered device so the device row and discovery row reflect post-pair truth. +func applyReportedIdentity(dd *discoverymodels.DiscoveredDevice, result *gatewaypb.FleetNodePairResult) { + if v := result.GetSerialNumber(); v != "" { + dd.SerialNumber = v + } + if v := result.GetMacAddress(); v != "" { + dd.MacAddress = networking.NormalizeMAC(v) + } + if v := result.GetModel(); v != "" { + dd.Model = v + } + if v := result.GetManufacturer(); v != "" { + dd.Manufacturer = v + } + if v := result.GetFirmwareVersion(); v != "" { + dd.FirmwareVersion = v + } +} diff --git a/server/internal/domain/fleetnode/pairing/pair_discovered_test.go b/server/internal/domain/fleetnode/pairing/pair_discovered_test.go new file mode 100644 index 000000000..cc0c84cb4 --- /dev/null +++ b/server/internal/domain/fleetnode/pairing/pair_discovered_test.go @@ -0,0 +1,614 @@ +package pairing_test + +import ( + "database/sql" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gatewaypb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" + pairingpb "github.com/block/proto-fleet/server/generated/grpc/pairing/v1" + fleetnodepairing "github.com/block/proto-fleet/server/internal/domain/fleetnode/pairing" + "github.com/block/proto-fleet/server/internal/domain/stores/sqlstores" +) + +func pairResult(identifier string, outcome gatewaypb.PairOutcome) *gatewaypb.FleetNodePairResult { + return &gatewaypb.FleetNodePairResult{ + DeviceIdentifier: identifier, + Outcome: outcome, + SerialNumber: "sn-" + identifier, + MacAddress: "aa:bb:cc:" + identifier, + Model: "S19", + Manufacturer: "Bitmain", + FirmwareVersion: "v2", + } +} + +func devicePairingStatus(t *testing.T, db *sql.DB, orgID int64, identifier string) string { + t.Helper() + var status string + err := db.QueryRow( + `SELECT dp.pairing_status FROM device d + JOIN device_pairing dp ON dp.device_id = d.id + WHERE d.device_identifier=$1 AND d.org_id=$2 AND d.deleted_at IS NULL`, + identifier, orgID, + ).Scan(&status) + if errors.Is(err, sql.ErrNoRows) { + return "" + } + require.NoError(t, err) + return status +} + +func deviceBoundToNode(t *testing.T, db *sql.DB, orgID, fleetNodeID int64, identifier string) bool { + t.Helper() + var n int + require.NoError(t, db.QueryRow( + `SELECT count(*) FROM device d + JOIN fleet_node_device fnd ON fnd.device_id = d.id + WHERE d.device_identifier=$1 AND d.org_id=$2 AND fnd.fleet_node_id=$3`, + identifier, orgID, fleetNodeID, + ).Scan(&n)) + return n > 0 +} + +func hasMinerCredentials(t *testing.T, db *sql.DB, orgID int64, identifier string) bool { + t.Helper() + var n int + require.NoError(t, db.QueryRow( + `SELECT count(*) FROM device d + JOIN miner_credentials mc ON mc.device_id = d.id + WHERE d.device_identifier=$1 AND d.org_id=$2`, + identifier, orgID, + ).Scan(&n)) + return n > 0 +} + +func deviceExists(t *testing.T, db *sql.DB, orgID int64, identifier string) bool { + t.Helper() + var n int + require.NoError(t, db.QueryRow( + `SELECT count(*) FROM device WHERE device_identifier=$1 AND org_id=$2 AND deleted_at IS NULL`, + identifier, orgID, + ).Scan(&n)) + return n > 0 +} + +func TestPersistFleetNodePairResult_PairedAsymmetric(t *testing.T) { + // Arrange + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-persist-asym") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:p-asym") + assignedBy := int64(1) + + // Act: asymmetric pairing carries no credentials. + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, pairResult("mac:p-asym", gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED), &assignedBy) + + // Assert + require.NoError(t, err) + assert.Equal(t, fleetnodepairing.StatusPaired, status) + assert.Equal(t, "PAIRED", devicePairingStatus(t, db, orgID, "mac:p-asym")) + assert.True(t, deviceBoundToNode(t, db, orgID, node, "mac:p-asym"), "PAIRED device must be bound to the node") + assert.False(t, hasMinerCredentials(t, db, orgID, "mac:p-asym"), "asymmetric pairing stores no credentials") +} + +func TestPersistFleetNodePairResult_PairedBasicAuthStoresCredentials(t *testing.T) { + // Arrange + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-persist-basic") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:p-basic") + result := pairResult("mac:p-basic", gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED) + result.UsedCredentials = &gatewaypb.UsedCredentials{Username: "root", Password: "hunter2"} + assignedBy := int64(1) + + // Act: the node reports the basic-auth credentials it authenticated with. + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, result, &assignedBy) + + // Assert + require.NoError(t, err) + assert.Equal(t, fleetnodepairing.StatusPaired, status) + assert.Equal(t, "PAIRED", devicePairingStatus(t, db, orgID, "mac:p-basic")) + assert.True(t, deviceBoundToNode(t, db, orgID, node, "mac:p-basic")) + assert.True(t, hasMinerCredentials(t, db, orgID, "mac:p-basic"), "basic-auth credentials must be stored encrypted") +} + +func TestPersistFleetNodePairResult_StoresNodeReportedDefaultCredentials(t *testing.T) { + // Arrange: a basic-auth pairing with no operator credentials; the node + // reports the plugin default credentials it authenticated with. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-persist-default") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:p-default") + result := pairResult("mac:p-default", gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED) + result.UsedCredentials = &gatewaypb.UsedCredentials{Username: "root", Password: "admin"} + assignedBy := int64(1) + + // Act: nil operator credentials. + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, result, &assignedBy) + + // Assert: paired, bound, and the node-reported default creds are stored. + require.NoError(t, err) + assert.Equal(t, fleetnodepairing.StatusPaired, status) + assert.True(t, deviceBoundToNode(t, db, orgID, node, "mac:p-default")) + assert.True(t, hasMinerCredentials(t, db, orgID, "mac:p-default"), "default credentials the node used must be stored") +} + +func TestPersistFleetNodePairResult_StoresBlankPasswordCredentials(t *testing.T) { + // Arrange: a basic-auth pairing where the node authenticated with a blank + // password (common for miners). The node reports a username with an empty + // password; that is valid auth material and must be stored, not dropped. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-persist-blankpw") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:p-blankpw") + result := pairResult("mac:p-blankpw", gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED) + result.UsedCredentials = &gatewaypb.UsedCredentials{Username: "root", Password: ""} + assignedBy := int64(1) + + // Act + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, result, &assignedBy) + + // Assert: paired and the blank-password credential is stored. + require.NoError(t, err) + assert.Equal(t, fleetnodepairing.StatusPaired, status) + assert.True(t, hasMinerCredentials(t, db, orgID, "mac:p-blankpw"), "a valid blank-password basic-auth credential must be stored") +} + +func TestPersistFleetNodePairResult_AuthNeededThenRetrySucceeds(t *testing.T) { + // Arrange + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-persist-retry") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:p-retry") + assignedBy := int64(1) + + // Act 1: no credentials -> AUTHENTICATION_NEEDED, not bound. + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, pairResult("mac:p-retry", gatewaypb.PairOutcome_PAIR_OUTCOME_AUTH_NEEDED), &assignedBy) + require.NoError(t, err) + + // Assert 1 + assert.Equal(t, fleetnodepairing.StatusAuthenticationNeeded, status) + assert.Equal(t, "AUTHENTICATION_NEEDED", devicePairingStatus(t, db, orgID, "mac:p-retry")) + assert.False(t, deviceBoundToNode(t, db, orgID, node, "mac:p-retry"), "auth-needed device is not bound") + + // Act 2: retry; the node now reports the credentials it authenticated with -> + // PAIRED, bound, creds stored. + retryResult := pairResult("mac:p-retry", gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED) + retryResult.UsedCredentials = &gatewaypb.UsedCredentials{Username: "root", Password: "pw"} + status, err = pairing.PersistFleetNodePairResult(ctx, node, orgID, retryResult, &assignedBy) + require.NoError(t, err) + + // Assert 2 + assert.Equal(t, fleetnodepairing.StatusPaired, status) + assert.Equal(t, "PAIRED", devicePairingStatus(t, db, orgID, "mac:p-retry")) + assert.True(t, deviceBoundToNode(t, db, orgID, node, "mac:p-retry")) + assert.True(t, hasMinerCredentials(t, db, orgID, "mac:p-retry")) +} + +func TestPersistFleetNodePairResult_AuthNeededDoesNotDowngradeCloudPaired(t *testing.T) { + // Arrange: a node-discovered device that is also cloud-PAIRED (PAIRED with no + // fleet_node_device binding), simulating a device cloud-paired since target + // resolution. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-guard-authneeded") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:cloud-authneeded") + // A device keyed by the discovered identifier (as PersistFleetNodePairResult + // keys it), cloud-PAIRED with no fleet_node_device binding. + var ddID int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:cloud-authneeded").Scan(&ddID)) + var dev int64 + require.NoError(t, db.QueryRow( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + "mac:cloud-authneeded", "aa:bb:cc:00:cc:01", "sn-cloud-authneeded", orgID, ddID).Scan(&dev)) + setPairingStatus(t, db, dev, "PAIRED") + assignedBy := int64(1) + + // Act: the node reports AUTH_NEEDED with different identity fields (pairResult + // reports sn-/aa:bb:cc:, distinct from the seeded values). + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, pairResult("mac:cloud-authneeded", gatewaypb.PairOutcome_PAIR_OUTCOME_AUTH_NEEDED), &assignedBy) + require.NoError(t, err) + + // Assert: the cloud-dialed device keeps PAIRED (no downgrade), the returned + // status reflects reality rather than AUTHENTICATION_NEEDED, and the node + // report did not overwrite the device's learned identity. + assert.Equal(t, fleetnodepairing.StatusPaired, status) + assert.Equal(t, "PAIRED", devicePairingStatus(t, db, orgID, "mac:cloud-authneeded")) + var serial, mac string + require.NoError(t, db.QueryRow( + `SELECT serial_number, mac_address FROM device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:cloud-authneeded").Scan(&serial, &mac)) + assert.Equal(t, "sn-cloud-authneeded", serial, "node report must not overwrite a cloud-paired device's serial") + assert.Equal(t, "aa:bb:cc:00:cc:01", mac, "node report must not overwrite a cloud-paired device's MAC") +} + +func TestPersistFleetNodePairResult_AuthNeededDoesNotDowngradeNodeBound(t *testing.T) { + // Arrange: a device that became PAIRED and bound to a fleet node (node-dialed) + // since target resolution -- e.g. a re-issued command paired it while a stale + // AUTH_NEEDED from the first command is still in flight. DeviceHasActiveCloudPairing + // returns false for node-bound devices, so the broader "already paired" guard + // must catch this and refuse the downgrade. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-guard-nodebound") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:node-bound") + var ddID int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:node-bound").Scan(&ddID)) + var dev int64 + require.NoError(t, db.QueryRow( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + "mac:node-bound", "aa:bb:cc:00:nb:01", "sn-node-bound", orgID, ddID).Scan(&dev)) + setPairingStatus(t, db, dev, "PAIRED") + // Bound to the same node (node-dialed PAIRED), which the cloud-pairing guard ignores. + _, err := db.Exec(`INSERT INTO fleet_node_device (fleet_node_id, device_id, org_id) VALUES ($1, $2, $3)`, node, dev, orgID) + require.NoError(t, err) + assignedBy := int64(1) + + // Act: a stale AUTH_NEEDED for the now-paired device. + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, pairResult("mac:node-bound", gatewaypb.PairOutcome_PAIR_OUTCOME_AUTH_NEEDED), &assignedBy) + require.NoError(t, err) + + // Assert: the bound device stays PAIRED; the stale report did not downgrade it. + assert.Equal(t, fleetnodepairing.StatusPaired, status) + assert.Equal(t, "PAIRED", devicePairingStatus(t, db, orgID, "mac:node-bound")) +} + +func TestSetDevicePairingAuthNeededIfNotPaired_DoesNotDowngradePaired(t *testing.T) { + // Arrange: a PAIRED device -- the state a concurrent pair could commit during the + // PersistFleetNodePairResult guard window, after the read guard saw it unpaired. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-cond-paired") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:cond-paired") + var ddID int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:cond-paired").Scan(&ddID)) + var dev int64 + require.NoError(t, db.QueryRow( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + "mac:cond-paired", "aa:bb:cc:00:cp:01", "sn-cp", orgID, ddID).Scan(&dev)) + setPairingStatus(t, db, dev, "PAIRED") + store := sqlstores.NewSQLDeviceStore(db) + + // Act + applied, err := store.SetDevicePairingAuthNeededIfNotPaired(ctx, &pairingpb.Device{DeviceIdentifier: "mac:cond-paired"}, orgID) + require.NoError(t, err) + + // Assert: the conditional write is a no-op and the row stays PAIRED. + assert.False(t, applied) + assert.Equal(t, "PAIRED", devicePairingStatus(t, db, orgID, "mac:cond-paired")) +} + +func TestSetDevicePairingAuthNeededIfNotPaired_WritesWhenNotPaired(t *testing.T) { + // Arrange: an existing device with no pairing row yet. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-cond-unpaired") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:cond-unpaired") + var ddID int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:cond-unpaired").Scan(&ddID)) + _, err := db.Exec( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, $2, $3, $4, $5)`, + "mac:cond-unpaired", "aa:bb:cc:00:cu:01", "sn-cu", orgID, ddID) + require.NoError(t, err) + store := sqlstores.NewSQLDeviceStore(db) + + // Act + applied, err := store.SetDevicePairingAuthNeededIfNotPaired(ctx, &pairingpb.Device{DeviceIdentifier: "mac:cond-unpaired"}, orgID) + require.NoError(t, err) + + // Assert + assert.True(t, applied) + assert.Equal(t, "AUTHENTICATION_NEEDED", devicePairingStatus(t, db, orgID, "mac:cond-unpaired")) +} + +func TestPersistFleetNodePairResult_AuthNeededPreservesExistingSerial(t *testing.T) { + // Arrange: an existing unpaired device with a previously learned serial. A + // later AUTH_NEEDED report (which carries no serial) must not erase it. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-keep-serial") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:keep-serial") + var ddID int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:keep-serial").Scan(&ddID)) + _, err := db.Exec( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, $2, $3, $4, $5)`, + "mac:keep-serial", "aa:bb:cc:00:ks:01", "sn-learned-earlier", orgID, ddID) + require.NoError(t, err) + assignedBy := int64(1) + + // Act: an AUTH_NEEDED result with no serial_number (common for auth failures). + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, + &gatewaypb.FleetNodePairResult{DeviceIdentifier: "mac:keep-serial", Outcome: gatewaypb.PairOutcome_PAIR_OUTCOME_AUTH_NEEDED}, + &assignedBy) + require.NoError(t, err) + + // Assert: AUTHENTICATION_NEEDED recorded, but the prior serial is preserved. + assert.Equal(t, fleetnodepairing.StatusAuthenticationNeeded, status) + var serial string + require.NoError(t, db.QueryRow( + `SELECT serial_number FROM device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:keep-serial").Scan(&serial)) + assert.Equal(t, "sn-learned-earlier", serial, "an auth-needed report omitting the serial must not erase it") +} + +func TestPersistFleetNodePairResult_RejectsForeignNode(t *testing.T) { + // Arrange: device discovered by nodeA; nodeB reports a result for it. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + nodeA := createFleetNode(t, enrollment, orgID, "node-owner") + nodeB := createFleetNode(t, enrollment, orgID, "node-foreign") + upsertNodeDiscovered(t, pairing, orgID, nodeA, "mac:p-foreign") + assignedBy := int64(1) + + // Act + _, err := pairing.PersistFleetNodePairResult(ctx, nodeB, orgID, pairResult("mac:p-foreign", gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED), &assignedBy) + + // Assert + require.Error(t, err) + assert.Contains(t, err.Error(), "not discovered by this fleet node") + assert.False(t, deviceExists(t, db, orgID, "mac:p-foreign"), "a rejected result must not create a device") +} + +func TestPersistFleetNodePairResult_SerialConflictFailsCleanly(t *testing.T) { + // Arrange: an existing device already owns serial "sn-dup". A fleet-node device + // discovered under an auto: identifier (no serial known pre-auth) then pairs and + // reports that same serial, which collides with uq_device_serial_number. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-serial-conflict") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:owner") + var ownerDD int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:owner").Scan(&ownerDD)) + _, err := db.Exec( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, $2, $3, $4, $5)`, + "mac:owner", "aa:bb:cc:00:ow:01", "sn-dup", orgID, ownerDD) + require.NoError(t, err) + upsertNodeDiscovered(t, pairing, orgID, node, "auto:abcd1234") + assignedBy := int64(1) + + // Act: a PAIRED report for the auto: device carrying the already-registered serial. + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, + &gatewaypb.FleetNodePairResult{ + DeviceIdentifier: "auto:abcd1234", + Outcome: gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED, + SerialNumber: "sn-dup", + MacAddress: "aa:bb:cc:00:au:02", + }, &assignedBy) + + // Assert: the serial conflict surfaces as a clean FAILED (no error), and the tx + // rolled back so no device row was created for the auto: identifier. + require.NoError(t, err) + assert.Equal(t, fleetnodepairing.StatusFailed, status) + assert.False(t, deviceExists(t, db, orgID, "auto:abcd1234"), "a conflicting pair must not create a device") +} + +func TestPersistFleetNodePairResult_UpdateSerialConflictFailsCleanly(t *testing.T) { + // Arrange: device B owns serial "sn-taken"; device A already exists under its + // own identifier with no serial. A pair retry for A reporting "sn-taken" hits + // uq_device_serial_number via the UPDATE path (UpdateDeviceInfo), which must + // surface the same clean FAILED as the insert path. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-upd-serial-conflict") + devices := []struct { + id, mac string + serial sql.NullString + }{ + {id: "mac:serial-owner", mac: "aa:bb:cc:00:ud:01", serial: sql.NullString{String: "sn-taken", Valid: true}}, + {id: "mac:upd-dup", mac: "aa:bb:cc:00:ud:02"}, + } + for _, d := range devices { + upsertNodeDiscovered(t, pairing, orgID, node, d.id) + var ddID int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, d.id).Scan(&ddID)) + _, err := db.Exec( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, $2, $3, $4, $5)`, + d.id, d.mac, d.serial, orgID, ddID) + require.NoError(t, err) + } + assignedBy := int64(1) + + // Act: an AUTH_NEEDED retry for the existing device reports the taken serial. + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, + &gatewaypb.FleetNodePairResult{ + DeviceIdentifier: "mac:upd-dup", + Outcome: gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED, + SerialNumber: "sn-taken", + }, &assignedBy) + + // Assert: clean FAILED (no Internal error), and the device's serial is unchanged. + require.NoError(t, err) + assert.Equal(t, fleetnodepairing.StatusFailed, status) + var serial sql.NullString + require.NoError(t, db.QueryRow( + `SELECT serial_number FROM device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:upd-dup").Scan(&serial)) + assert.False(t, serial.Valid && serial.String == "sn-taken", "the conflicting serial must not be written") +} + +func TestPersistFleetNodePairResult_ErrorPersistsNothing(t *testing.T) { + // Arrange + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-persist-error") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:p-error") + assignedBy := int64(1) + + // Act + status, err := pairing.PersistFleetNodePairResult(ctx, node, orgID, pairResult("mac:p-error", gatewaypb.PairOutcome_PAIR_OUTCOME_ERROR), &assignedBy) + + // Assert + require.NoError(t, err) + assert.Equal(t, "FAILED", status) + assert.False(t, deviceExists(t, db, orgID, "mac:p-error"), "an ERROR outcome persists nothing") +} + +func TestResolvePairTargets(t *testing.T) { + // Arrange + ctx := t.Context() + _, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-resolve") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:r-1") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:r-2") + + // Act + all, err := pairing.ResolvePairTargets(ctx, node, orgID, nil, true, nil) + require.NoError(t, err) + explicit, err := pairing.ResolvePairTargets(ctx, node, orgID, []string{"mac:r-1"}, false, nil) + require.NoError(t, err) + + // Assert + allIDs := targetIdentifiers(all) + assert.Subset(t, allIDs, []string{"mac:r-1", "mac:r-2"}) + assert.Equal(t, []string{"mac:r-1"}, targetIdentifiers(explicit)) + for _, tg := range all { + assert.Equal(t, "virtual", tg.GetDriverName(), "targets carry the discovery driver for plugin routing") + } +} + +func TestResolvePairTargets_ExcludesPairedDeviceWithDivergedDiscoveryLinkage(t *testing.T) { + // Arrange: a PAIRED device whose original discovery row was soft-deleted and + // then re-discovered by a node under the same identifier. The device row links + // to the old discovery row, not the new one, so a linkage-only exclusion would + // dispatch it for pairing; the node would mutate the miner's credentials and + // persistence would then reject it as already paired. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-diverged-linkage") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:diverged") + var oldDD int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:diverged").Scan(&oldDD)) + var dev int64 + require.NoError(t, db.QueryRow( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + "mac:diverged", "aa:bb:cc:00:dl:01", "sn-dl", orgID, oldDD).Scan(&dev)) + setPairingStatus(t, db, dev, "PAIRED") + // Soft-delete the linked discovery row, then re-discover: the partial unique + // index lets a fresh live row with the same identifier coexist. + _, err := db.Exec(`UPDATE discovered_device SET deleted_at = now() WHERE id = $1`, oldDD) + require.NoError(t, err) + upsertNodeDiscovered(t, pairing, orgID, node, "mac:diverged") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:fresh") + + // Act + all, err := pairing.ResolvePairTargets(ctx, node, orgID, nil, true, nil) + require.NoError(t, err) + explicit, err := pairing.ResolvePairTargets(ctx, node, orgID, []string{"mac:diverged"}, false, nil) + require.NoError(t, err) + + // Assert: the already-paired identifier is never dispatched, by pair-all or + // explicit selection; the genuinely new device still is. + assert.NotContains(t, targetIdentifiers(all), "mac:diverged") + assert.Contains(t, targetIdentifiers(all), "mac:fresh") + assert.Empty(t, explicit) +} + +func TestResolvePairTargets_AuthNeededExclusionSurvivesDivergedLinkage(t *testing.T) { + // Arrange: an AUTHENTICATION_NEEDED device whose discovery row was soft-deleted + // and re-created by the node under the same identifier. The starvation guard + // must still recognize it by identifier, or credential-less pair-all re-selects + // the same unsatisfiable row and starves never-attempted devices. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-an-diverged") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:an-diverged") + var oldDD int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:an-diverged").Scan(&oldDD)) + var dev int64 + require.NoError(t, db.QueryRow( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + "mac:an-diverged", "aa:bb:cc:00:ad:01", "sn-ad", orgID, oldDD).Scan(&dev)) + setPairingStatus(t, db, dev, "AUTHENTICATION_NEEDED") + _, err := db.Exec(`UPDATE discovered_device SET deleted_at = now() WHERE id = $1`, oldDD) + require.NoError(t, err) + upsertNodeDiscovered(t, pairing, orgID, node, "mac:an-diverged") + + // Act + noCreds, err := pairing.ResolvePairTargets(ctx, node, orgID, nil, true, nil) + require.NoError(t, err) + pw := "pw" + withCreds, err := pairing.ResolvePairTargets(ctx, node, orgID, nil, true, &pairingpb.Credentials{Username: "root", Password: &pw}) + require.NoError(t, err) + + // Assert: excluded from credential-less pair-all, still retryable with creds. + assert.NotContains(t, targetIdentifiers(noCreds), "mac:an-diverged") + assert.Contains(t, targetIdentifiers(withCreds), "mac:an-diverged") +} + +func TestResolvePairTargets_PairAllExcludesAuthNeededWithoutCredentials(t *testing.T) { + // Arrange: one never-attempted device and one AUTHENTICATION_NEEDED device. + ctx := t.Context() + db, orgID, pairing, enrollment := setupPairingTest(t) + node := createFleetNode(t, enrollment, orgID, "node-resolve-authneeded") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:new") + upsertNodeDiscovered(t, pairing, orgID, node, "mac:authneeded") + var ddID int64 + require.NoError(t, db.QueryRow( + `SELECT id FROM discovered_device WHERE org_id=$1 AND device_identifier=$2 AND deleted_at IS NULL`, + orgID, "mac:authneeded").Scan(&ddID)) + var devID int64 + require.NoError(t, db.QueryRow( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, 'aa:bb:cc:00:an:01', 'sn-an', $2, $3) RETURNING id`, + "mac:authneeded", orgID, ddID).Scan(&devID)) + setPairingStatus(t, db, devID, "AUTHENTICATION_NEEDED") + + // Act + Assert: pair-all WITHOUT credentials can't satisfy the auth-needed row, + // so it's excluded (won't starve never-attempted devices on re-issue). + noCreds, err := pairing.ResolvePairTargets(ctx, node, orgID, nil, true, nil) + require.NoError(t, err) + ids := targetIdentifiers(noCreds) + assert.Contains(t, ids, "mac:new") + assert.NotContains(t, ids, "mac:authneeded") + + // Act + Assert: a username-only message (password unset) is unusable for + // basic-auth, so it must not re-enable the auth-needed row like real creds would. + userOnly, err := pairing.ResolvePairTargets(ctx, node, orgID, nil, true, &pairingpb.Credentials{Username: "root"}) + require.NoError(t, err) + assert.NotContains(t, targetIdentifiers(userOnly), "mac:authneeded") + + // Act + Assert: pair-all WITH credentials retries the auth-needed row. + pw := "pw" + withCreds, err := pairing.ResolvePairTargets(ctx, node, orgID, nil, true, &pairingpb.Credentials{Username: "root", Password: &pw}) + require.NoError(t, err) + assert.Contains(t, targetIdentifiers(withCreds), "mac:authneeded") +} + +func targetIdentifiers(targets []*pairingpb.FleetNodePairTarget) []string { + out := make([]string, 0, len(targets)) + for _, tg := range targets { + out = append(out, tg.GetDeviceIdentifier()) + } + return out +} diff --git a/server/internal/domain/fleetnode/pairing/pair_dispatch.go b/server/internal/domain/fleetnode/pairing/pair_dispatch.go new file mode 100644 index 000000000..1e7890f37 --- /dev/null +++ b/server/internal/domain/fleetnode/pairing/pair_dispatch.go @@ -0,0 +1,97 @@ +package pairing + +import ( + "context" + "slices" + "time" + + "google.golang.org/protobuf/proto" + + gatewaypb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" + pairingpb "github.com/block/proto-fleet/server/generated/grpc/pairing/v1" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/fleetnode/control" + "github.com/block/proto-fleet/server/internal/infrastructure/id" +) + +// PairCommandTimeout bounds how long PairOnNode waits for results and the final +// ack, mirroring DiscoverCommandTimeout: it must exceed the agent's pairing budget +// plus slack so a slow batch's ack isn't rejected as stale. Var for tests. +var PairCommandTimeout = 12 * time.Minute + +// PairOnNode dispatches a batch pair command over the node's ControlStream and +// invokes onResults per result batch for live operator display. Persistence is +// authoritative in the gateway ReportPairedDevices handler, not here, so onResults +// is best-effort display only. +// +// The command runs on a context detached from the operator's request: pairing +// mutates miners, so once dispatched it must finish server-side (the gateway keeps +// persisting even if the operator disconnects), bounded by PairCommandTimeout. We +// don't abort the node on cancel -- half-paired miners with no cloud record is worse. +func (s *Service) PairOnNode(ctx context.Context, fleetNodeID int64, targets []*pairingpb.FleetNodePairTarget, credentials *pairingpb.Credentials, orgID int64, assignedBy *int64, onResults func([]*gatewaypb.FleetNodePairResult) error) error { + if s.dispatcher == nil { + return fleeterror.NewInternalError("fleet node pairing dispatch is not configured") + } + payload, err := proto.Marshal(&pairingpb.AgentCommand{ + Command: &pairingpb.AgentCommand_Pair{Pair: &pairingpb.FleetNodePairRequest{ + Targets: targets, + Credentials: credentials, + }}, + }) + if err != nil { + return fleeterror.NewInternalErrorf("marshal pair payload: %v", err) + } + + // scopeTargets is consumed by the registry/gateway for persistence scoping; + // displayPending is a separate copy we consume as results arrive, to synthesize + // a terminal display status for any target the node never reported. + scopeTargets := make(map[string]struct{}, len(targets)) + displayPending := make(map[string]struct{}, len(targets)) + for _, t := range targets { + scopeTargets[t.GetDeviceIdentifier()] = struct{}{} + displayPending[t.GetDeviceIdentifier()] = struct{}{} + } + + pair := &control.PairMeta{OrgID: orgID, AssignedBy: assignedBy, Targets: scopeTargets} + cmd := &gatewaypb.ControlCommand{CommandId: id.GenerateID(), Payload: payload} + err = control.RunCommand(context.WithoutCancel(ctx), s.dispatcher, fleetNodeID, cmd, nil, control.ReportKindPair, pair, PairCommandTimeout, "pair", + func(ev control.CommandEvent) (terminal bool, err error) { + if len(ev.PairResults) == 0 { + return false, nil + } + for _, r := range ev.PairResults { + delete(displayPending, r.GetDeviceIdentifier()) + } + // Best-effort display: a callback error must never abort the command, + // which runs to completion server-side where persistence is authoritative. + _ = onResults(ev.PairResults) + return false, nil + }) + + // Give every un-reported target a terminal display status, even on a command + // error (timeout/disconnect); onResults is best-effort when the operator is gone. + _ = reportUnpaired(displayPending, onResults) + return err +} + +// reportUnpaired emits a synthesized ERROR result for each requested device the +// node never reported, so every selected device ends with a terminal status. +func reportUnpaired(requested map[string]struct{}, onResults func([]*gatewaypb.FleetNodePairResult) error) error { + if len(requested) == 0 { + return nil + } + missing := make([]string, 0, len(requested)) + for id := range requested { + missing = append(missing, id) + } + slices.Sort(missing) // deterministic order for callers/tests + results := make([]*gatewaypb.FleetNodePairResult, 0, len(missing)) + for _, id := range missing { + results = append(results, &gatewaypb.FleetNodePairResult{ + DeviceIdentifier: id, + Outcome: gatewaypb.PairOutcome_PAIR_OUTCOME_ERROR, + ErrorMessage: "device was not paired before the batch completed (timed out or truncated); retry", + }) + } + return onResults(results) +} diff --git a/server/internal/domain/fleetnode/pairing/service.go b/server/internal/domain/fleetnode/pairing/service.go index 2a8d080ca..054debad1 100644 --- a/server/internal/domain/fleetnode/pairing/service.go +++ b/server/internal/domain/fleetnode/pairing/service.go @@ -8,8 +8,10 @@ import ( "strconv" "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/fleetnode/control" "github.com/block/proto-fleet/server/internal/domain/fleetnode/enrollment" stores "github.com/block/proto-fleet/server/internal/domain/stores/interfaces" + "github.com/block/proto-fleet/server/internal/infrastructure/encrypt" ) const ( @@ -26,23 +28,43 @@ type Store interface { PairDeviceToFleetNode(ctx context.Context, fleetNodeID, deviceID, orgID int64, assignedBy *int64) (int64, error) TransferDiscoveredDeviceAttribution(ctx context.Context, fleetNodeID, deviceID, orgID int64) (int64, error) DeviceHasActiveCloudPairing(ctx context.Context, deviceID, orgID int64) (bool, error) + DeviceHasActivePairing(ctx context.Context, deviceID, orgID int64) (bool, error) UnpairDevice(ctx context.Context, deviceID, orgID int64) (int64, error) ListFleetNodeDevices(ctx context.Context, orgID int64, fleetNodeID *int64) ([]FleetNodeDevice, error) - ListFleetNodeDiscoveredDevices(ctx context.Context, orgID int64, fleetNodeID, cursorID, limit *int64) ([]FleetNodeDiscoveredDevice, error) + ListFleetNodeDiscoveredDevices(ctx context.Context, orgID int64, fleetNodeID *int64, identifiers []string, cursorID, limit *int64, excludeAuthNeeded bool) ([]FleetNodeDiscoveredDevice, error) UpsertDiscoveredDeviceFromFleetNode(ctx context.Context, orgID int64, fleetNodeID int64, report DiscoveredDeviceReport) (int64, error) DeviceExistsInOrg(ctx context.Context, deviceID, orgID int64) (bool, error) + GetDeviceIDByDeviceIdentifier(ctx context.Context, identifier string) (int64, error) } type Service struct { store Store enrollmentStore enrollment.AgentStore transactor stores.Transactor + + // Optional pair-flow collaborators (set by WithProvisioning); binding and + // listing work without them. + deviceStore stores.DeviceStore + discoveredDeviceStore stores.DiscoveredDeviceStore + encryptService *encrypt.Service + dispatcher control.Sender } func NewService(store Store, enrollmentStore enrollment.AgentStore, transactor stores.Transactor) *Service { return &Service{store: store, enrollmentStore: enrollmentStore, transactor: transactor} } +// WithProvisioning wires the collaborators the pair-discovered flow needs: the +// stores + encryptService PersistFleetNodePairResult uses, and the dispatcher +// PairOnNode sends pair commands through. Returns the service for chaining. +func (s *Service) WithProvisioning(deviceStore stores.DeviceStore, discoveredDeviceStore stores.DiscoveredDeviceStore, encryptService *encrypt.Service, dispatcher control.Sender) *Service { + s.deviceStore = deviceStore + s.discoveredDeviceStore = discoveredDeviceStore + s.encryptService = encryptService + s.dispatcher = dispatcher + return s +} + func (s *Service) PairDevice(ctx context.Context, fleetNodeID, deviceID, orgID int64, assignedBy *int64) error { exists, err := s.store.DeviceExistsInOrg(ctx, deviceID, orgID) if err != nil { @@ -52,46 +74,49 @@ func (s *Service) PairDevice(ctx context.Context, fleetNodeID, deviceID, orgID i return fleeterror.NewNotFoundError("device not found") } return s.transactor.RunInTx(ctx, func(ctx context.Context) error { - // Lock-and-recheck inside the TX so a concurrent revoke - // can't soft-delete the node between the status check and - // the INSERT. Matches the lock order Confirm/Revoke use. - node, lockErr := s.enrollmentStore.LockFleetNodeByID(ctx, fleetNodeID, orgID) - if lockErr != nil { - if fleeterror.IsNotFoundError(lockErr) { - return fleeterror.NewNotFoundError("fleet node not found") - } - return fleeterror.LogInternal(component, "lock fleet node", clientErrLookupFleetNodeForPairing, lockErr) - } - if node.EnrollmentStatus != enrollment.FleetNodeStatusConfirmed { - return fleeterror.NewFailedPreconditionError("fleet node is not confirmed; cannot pair until enrollment completes") - } - // Refuse a device the cloud actively dials (device_pairing PAIRED): the - // discovery upsert guard blocks refreshing a cloud-paired row, so pairing - // it here would leave the node unable to refresh while the API reports it - // as fleet-node paired. Operator must unpair from the cloud first. - if cloudPaired, cloudErr := s.store.DeviceHasActiveCloudPairing(ctx, deviceID, orgID); cloudErr != nil { - return fleeterror.LogInternal(component, "check cloud pairing", clientErrPair, cloudErr) - } else if cloudPaired { - return fleeterror.NewFailedPreconditionError("device is cloud-paired; unpair it from the cloud before pairing to a fleet node") - } - rows, pairErr := s.store.PairDeviceToFleetNode(ctx, fleetNodeID, deviceID, orgID, assignedBy) - if pairErr != nil { - return fleeterror.LogInternal(component, "pair device", clientErrPair, pairErr) - } - if rows == 0 { - return fleeterror.NewFailedPreconditionError("device already paired; unpair first") - } - // Make the paired node the discovery owner so its future reports refresh - // the row instead of being rejected by the upsert's attribution guard - // (e.g. after replacing a revoked node). No-op for devices with no - // discovered_device origin. - if _, attrErr := s.store.TransferDiscoveredDeviceAttribution(ctx, fleetNodeID, deviceID, orgID); attrErr != nil { - return fleeterror.LogInternal(component, "transfer discovery attribution", clientErrPair, attrErr) - } - return nil + return s.pairDeviceLocked(ctx, fleetNodeID, deviceID, orgID, assignedBy) }) } +// pairDeviceLocked binds a device to a fleet node within the caller's transaction: +// locks the node, refuses cloud-dialed devices, inserts the fleet_node_device row, +// and transfers discovery attribution. (PairDevice and PersistFleetNodePairResult +// both wrap it.) +func (s *Service) pairDeviceLocked(ctx context.Context, fleetNodeID, deviceID, orgID int64, assignedBy *int64) error { + // Lock-and-recheck in the TX so a concurrent revoke can't soft-delete the node + // between the status check and the INSERT. Matches Confirm/Revoke lock order. + node, lockErr := s.enrollmentStore.LockFleetNodeByID(ctx, fleetNodeID, orgID) + if lockErr != nil { + if fleeterror.IsNotFoundError(lockErr) { + return fleeterror.NewNotFoundError("fleet node not found") + } + return fleeterror.LogInternal(component, "lock fleet node", clientErrLookupFleetNodeForPairing, lockErr) + } + if node.EnrollmentStatus != enrollment.FleetNodeStatusConfirmed { + return fleeterror.NewFailedPreconditionError("fleet node is not confirmed; cannot pair until enrollment completes") + } + // Refuse a cloud-dialed device: the discovery upsert guard blocks refreshing a + // cloud-paired row, so the node could never refresh it. Unpair from cloud first. + if cloudPaired, cloudErr := s.store.DeviceHasActiveCloudPairing(ctx, deviceID, orgID); cloudErr != nil { + return fleeterror.LogInternal(component, "check cloud pairing", clientErrPair, cloudErr) + } else if cloudPaired { + return fleeterror.NewFailedPreconditionError("device is cloud-paired; unpair it from the cloud before pairing to a fleet node") + } + rows, pairErr := s.store.PairDeviceToFleetNode(ctx, fleetNodeID, deviceID, orgID, assignedBy) + if pairErr != nil { + return fleeterror.LogInternal(component, "pair device", clientErrPair, pairErr) + } + if rows == 0 { + return fleeterror.NewFailedPreconditionError("device already paired; unpair first") + } + // Make the paired node the discovery owner so its future reports refresh the row + // instead of being rejected by the attribution guard. No-op without a discovered_device. + if _, attrErr := s.store.TransferDiscoveredDeviceAttribution(ctx, fleetNodeID, deviceID, orgID); attrErr != nil { + return fleeterror.LogInternal(component, "transfer discovery attribution", clientErrPair, attrErr) + } + return nil +} + func (s *Service) UnpairDevice(ctx context.Context, deviceID, orgID int64) error { if _, err := s.store.UnpairDevice(ctx, deviceID, orgID); err != nil { return fleeterror.LogInternal(component, "unpair device", clientErrUnpair, err) @@ -122,7 +147,8 @@ func (s *Service) ListDevicesForFleetNode(ctx context.Context, fleetNodeID, orgI // needs the full set). nextCursor is non-nil only when a full page was returned // and more rows may remain. func (s *Service) ListDiscoveredDevicesForFleetNode(ctx context.Context, orgID int64, fleetNodeID, cursorID, limit *int64) ([]FleetNodeDiscoveredDevice, *int64, error) { - devices, err := s.store.ListFleetNodeDiscoveredDevices(ctx, orgID, fleetNodeID, cursorID, limit) + // The operator listing surfaces AUTHENTICATION_NEEDED rows for display/retry. + devices, err := s.store.ListFleetNodeDiscoveredDevices(ctx, orgID, fleetNodeID, nil, cursorID, limit, false) if err != nil { return nil, nil, fleeterror.LogInternal(component, "list discovered devices", clientErrList, err) } diff --git a/server/internal/domain/stores/interfaces/device.go b/server/internal/domain/stores/interfaces/device.go index 4dba7efe4..4de74bc2b 100644 --- a/server/internal/domain/stores/interfaces/device.go +++ b/server/internal/domain/stores/interfaces/device.go @@ -148,6 +148,9 @@ type DeviceStore interface { InsertDevice(ctx context.Context, device *pb.Device, orgID int64, discoveredDeviceIdentifier string) error UpsertMinerCredentials(ctx context.Context, device *pb.Device, orgID int64, usernameEnc string, passwordEnc *secrets.Text) error UpsertDevicePairing(ctx context.Context, device *pb.Device, orgID int64, pairingStatus string) error + // SetDevicePairingAuthNeededIfNotPaired marks the device AUTHENTICATION_NEEDED + // unless already PAIRED; returns false when a PAIRED row blocked the write. + SetDevicePairingAuthNeededIfNotPaired(ctx context.Context, device *pb.Device, orgID int64) (bool, error) UpdateDevicePairingStatusByIdentifier(ctx context.Context, deviceIdentifier string, pairingStatus string) error GetMinerCredentials(ctx context.Context, device *pb.Device, orgID int64) (*pb.Credentials, error) GetDeviceByDeviceIdentifier(ctx context.Context, identifier string, orgID int64) (*pb.Device, error) diff --git a/server/internal/domain/stores/interfaces/mocks/mock_device_store.go b/server/internal/domain/stores/interfaces/mocks/mock_device_store.go index 2f981f706..4bfc69e23 100644 --- a/server/internal/domain/stores/interfaces/mocks/mock_device_store.go +++ b/server/internal/domain/stores/interfaces/mocks/mock_device_store.go @@ -410,6 +410,21 @@ func (mr *MockDeviceStoreMockRecorder) ListMinerStateSnapshots(ctx, orgID, curso return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMinerStateSnapshots", reflect.TypeOf((*MockDeviceStore)(nil).ListMinerStateSnapshots), ctx, orgID, cursor, pageSize, filter, sortConfig) } +// SetDevicePairingAuthNeededIfNotPaired mocks base method. +func (m *MockDeviceStore) SetDevicePairingAuthNeededIfNotPaired(ctx context.Context, device *pairingv1.Device, orgID int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetDevicePairingAuthNeededIfNotPaired", ctx, device, orgID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SetDevicePairingAuthNeededIfNotPaired indicates an expected call of SetDevicePairingAuthNeededIfNotPaired. +func (mr *MockDeviceStoreMockRecorder) SetDevicePairingAuthNeededIfNotPaired(ctx, device, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDevicePairingAuthNeededIfNotPaired", reflect.TypeOf((*MockDeviceStore)(nil).SetDevicePairingAuthNeededIfNotPaired), ctx, device, orgID) +} + // SoftDeleteDevices mocks base method. func (m *MockDeviceStore) SoftDeleteDevices(ctx context.Context, deviceIdentifiers []string, orgID int64) (int64, error) { m.ctrl.T.Helper() diff --git a/server/internal/domain/stores/sqlstores/device.go b/server/internal/domain/stores/sqlstores/device.go index f0fced9e1..94d661cc9 100644 --- a/server/internal/domain/stores/sqlstores/device.go +++ b/server/internal/domain/stores/sqlstores/device.go @@ -137,16 +137,14 @@ func (s *SQLDeviceStore) GetDeviceByDeviceIdentifier(ctx context.Context, identi func (s *SQLDeviceStore) UpdateDeviceInfo(ctx context.Context, device *pb.Device, orgID int64) error { err := s.getQueries(ctx).UpdateDeviceInfo(ctx, sqlc.UpdateDeviceInfoParams{ - MacAddress: networking.NormalizeMAC(device.MacAddress), - SerialNumber: sql.NullString{ - String: device.SerialNumber, - Valid: device.SerialNumber != "", - }, + MacAddress: networking.NormalizeMAC(device.MacAddress), + SerialNumber: device.SerialNumber, DeviceIdentifier: device.DeviceIdentifier, OrgID: orgID, }) if err != nil { - return fleeterror.NewInternalErrorf("failed to update device info for identifier=%s org_id=%d: %v", device.DeviceIdentifier, orgID, err) + // %w so callers can recover the DB cause (e.g. a serial unique-violation). + return fleeterror.NewInternalErrorf("failed to update device info for identifier=%s org_id=%d: %w", device.DeviceIdentifier, orgID, err) } return nil } @@ -219,6 +217,23 @@ func (s *SQLDeviceStore) UpsertDevicePairing(ctx context.Context, device *pb.Dev return nil } +func (s *SQLDeviceStore) SetDevicePairingAuthNeededIfNotPaired(ctx context.Context, device *pb.Device, orgID int64) (bool, error) { + dbDevice, err := s.getQueries(ctx).GetDeviceByDeviceIdentifier(ctx, sqlc.GetDeviceByDeviceIdentifierParams{ + DeviceIdentifier: device.DeviceIdentifier, + OrgID: orgID, + }) + if err != nil { + return false, handleQueryError(err, + fmt.Sprintf("device not found for pairing update with identifier=%s org_id=%d", device.DeviceIdentifier, orgID), + "failed to query device") + } + rows, err := s.getQueries(ctx).SetDevicePairingAuthNeededIfNotPaired(ctx, dbDevice.ID) + if err != nil { + return false, fleeterror.NewInternalErrorf("failed to set auth-needed pairing: %v", err) + } + return rows > 0, nil +} + // UpdateDevicePairingStatusByIdentifier writes the new pairing_status for // the device when the device exists and is not soft-deleted. func (s *SQLDeviceStore) UpdateDevicePairingStatusByIdentifier(ctx context.Context, deviceIdentifier string, pairingStatus string) error { diff --git a/server/internal/domain/stores/sqlstores/fleetnodepairing.go b/server/internal/domain/stores/sqlstores/fleetnodepairing.go index 5cf039c4d..c49f22af6 100644 --- a/server/internal/domain/stores/sqlstores/fleetnodepairing.go +++ b/server/internal/domain/stores/sqlstores/fleetnodepairing.go @@ -47,6 +47,13 @@ func (s *SQLFleetNodePairingStore) DeviceHasActiveCloudPairing(ctx context.Conte }) } +func (s *SQLFleetNodePairingStore) DeviceHasActivePairing(ctx context.Context, deviceID, orgID int64) (bool, error) { + return s.q(ctx).DeviceHasActivePairing(ctx, sqlc.DeviceHasActivePairingParams{ + DeviceID: deviceID, + OrgID: orgID, + }) +} + func (s *SQLFleetNodePairingStore) UnpairDevice(ctx context.Context, deviceID, orgID int64) (int64, error) { return s.q(ctx).UnpairDevice(ctx, sqlc.UnpairDeviceParams{ DeviceID: deviceID, @@ -76,12 +83,14 @@ func (s *SQLFleetNodePairingStore) ListFleetNodeDevices(ctx context.Context, org return out, nil } -func (s *SQLFleetNodePairingStore) ListFleetNodeDiscoveredDevices(ctx context.Context, orgID int64, fleetNodeID, cursorID, limit *int64) ([]pairing.FleetNodeDiscoveredDevice, error) { +func (s *SQLFleetNodePairingStore) ListFleetNodeDiscoveredDevices(ctx context.Context, orgID int64, fleetNodeID *int64, identifiers []string, cursorID, limit *int64, excludeAuthNeeded bool) ([]pairing.FleetNodeDiscoveredDevice, error) { rows, err := s.q(ctx).ListFleetNodeDiscoveredDevices(ctx, sqlc.ListFleetNodeDiscoveredDevicesParams{ - OrgID: orgID, - FleetNodeID: ptrToNullInt64(fleetNodeID), - CursorID: ptrToNullInt64(cursorID), - Limit: ptrToNullInt64(limit), + OrgID: orgID, + FleetNodeID: ptrToNullInt64(fleetNodeID), + Identifiers: identifiers, + CursorID: ptrToNullInt64(cursorID), + Limit: ptrToNullInt64(limit), + ExcludeAuthNeeded: sql.NullBool{Bool: excludeAuthNeeded, Valid: excludeAuthNeeded}, }) if err != nil { return nil, err @@ -121,6 +130,10 @@ func (s *SQLFleetNodePairingStore) UpsertDiscoveredDeviceFromFleetNode(ctx conte }) } +func (s *SQLFleetNodePairingStore) GetDeviceIDByDeviceIdentifier(ctx context.Context, identifier string) (int64, error) { + return s.q(ctx).GetDeviceIDByDeviceIdentifier(ctx, identifier) +} + func (s *SQLFleetNodePairingStore) DeviceExistsInOrg(ctx context.Context, deviceID, orgID int64) (bool, error) { _, err := s.q(ctx).GetDeviceByID(ctx, sqlc.GetDeviceByIDParams{ID: deviceID, OrgID: orgID}) if err != nil { diff --git a/server/internal/handlers/fleetnode/admin/handler_discover_test.go b/server/internal/handlers/fleetnode/admin/handler_discover_test.go index fba8ae084..902a52046 100644 --- a/server/internal/handlers/fleetnode/admin/handler_discover_test.go +++ b/server/internal/handlers/fleetnode/admin/handler_discover_test.go @@ -601,7 +601,7 @@ func startAdminServerWithRole(t *testing.T, h *pairingHarness, role string) flee t.Helper() var perms []string if role == "ADMIN" || role == "SUPER_ADMIN" { - perms = []string{authz.PermFleetnodeManage, authz.PermFleetnodeRead} + perms = []string{authz.PermFleetnodeManage, authz.PermFleetnodeRead, authz.PermMinerPair} } injector := sessionInjector{role: role, orgID: h.orgID, userID: 1, perms: perms} mux := http.NewServeMux() diff --git a/server/internal/handlers/fleetnode/admin/handler_pair.go b/server/internal/handlers/fleetnode/admin/handler_pair.go new file mode 100644 index 000000000..e7edb7b90 --- /dev/null +++ b/server/internal/handlers/fleetnode/admin/handler_pair.go @@ -0,0 +1,142 @@ +package admin + +import ( + "context" + "log/slog" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + fleetmanagementv1 "github.com/block/proto-fleet/server/generated/grpc/fleetmanagement/v1" + pb "github.com/block/proto-fleet/server/generated/grpc/fleetnodeadmin/v1" + gatewaypb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" + "github.com/block/proto-fleet/server/internal/domain/authz" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/fleetnode/enrollment" + "github.com/block/proto-fleet/server/internal/handlers/middleware" +) + +// maxDiscoveredPageSize bounds one ListFleetNodeDiscoveredDevices response (and +// is the default when the request omits a limit). Matches the proto limit cap. +const maxDiscoveredPageSize = 1024 + +func (h *Handler) ListFleetNodeDiscoveredDevices(ctx context.Context, req *connect.Request[pb.ListFleetNodeDiscoveredDevicesRequest]) (*connect.Response[pb.ListFleetNodeDiscoveredDevicesResponse], error) { + info, err := middleware.RequirePermission(ctx, authz.PermFleetnodeRead, authz.ResourceContext{}) + if err != nil { + return nil, err + } + var nodeFilter *int64 + if id := req.Msg.GetFleetNodeId(); id > 0 { + nodeFilter = &id + } + var cursor *int64 + if c := req.Msg.GetCursor(); c > 0 { + cursor = &c + } + // Default + clamp so an omitted/zero limit can't return the whole org at once. + pageSize := int64(req.Msg.GetLimit()) + if pageSize <= 0 || pageSize > maxDiscoveredPageSize { + pageSize = maxDiscoveredPageSize + } + devices, nextCursor, err := h.pairing.ListDiscoveredDevicesForFleetNode(ctx, info.OrganizationID, nodeFilter, cursor, &pageSize) + if err != nil { + return nil, err + } + resp := &pb.ListFleetNodeDiscoveredDevicesResponse{Devices: make([]*pb.FleetNodeDiscoveredDevice, 0, len(devices))} + for _, d := range devices { + dev := &pb.FleetNodeDiscoveredDevice{ + FleetNodeId: d.FleetNodeID, + DeviceIdentifier: d.DeviceIdentifier, + IpAddress: d.IPAddress, + Port: d.Port, + UrlScheme: d.URLScheme, + DriverName: d.DriverName, + Model: d.Model, + Manufacturer: d.Manufacturer, + FirmwareVersion: d.FirmwareVersion, + PairingStatus: d.PairingStatus, + } + if !d.LastSeen.IsZero() { + dev.LastSeen = timestamppb.New(d.LastSeen) + } + resp.Devices = append(resp.Devices, dev) + } + if nextCursor != nil { + resp.NextCursor = *nextCursor + } + return connect.NewResponse(resp), nil +} + +func (h *Handler) PairDiscoveredDevicesOnFleetNode(ctx context.Context, req *connect.Request[pb.PairDiscoveredDevicesOnFleetNodeRequest], stream *connect.ServerStream[pb.PairDiscoveredDevicesOnFleetNodeResponse]) error { + info, err := middleware.RequirePermission(ctx, authz.PermMinerPair, authz.ResourceContext{}) + if err != nil { + return err + } + if _, err := middleware.RequirePermission(ctx, authz.PermFleetnodeManage, authz.ResourceContext{}); err != nil { + return err + } + fleetNodeID := req.Msg.GetFleetNodeId() + if fleetNodeID <= 0 { + return fleeterror.NewInvalidArgumentError("fleet_node_id is required") + } + + node, err := h.enrollment.GetFleetNodeByID(ctx, fleetNodeID, info.OrganizationID) + if err != nil { + return err + } + if node.EnrollmentStatus != enrollment.FleetNodeStatusConfirmed { + return fleeterror.NewFailedPreconditionError("fleet node is not CONFIRMED") + } + + targets, err := h.pairing.ResolvePairTargets(ctx, fleetNodeID, info.OrganizationID, req.Msg.GetDeviceIdentifiers(), req.Msg.GetPairAllUnpaired(), req.Msg.GetCredentials()) + if err != nil { + return err + } + if len(targets) == 0 { + return fleeterror.NewInvalidArgumentError("no pairable devices for the requested selection") + } + + credentials := req.Msg.GetCredentials() + assignedBy := info.UserID + + // The gateway persists results authoritatively; this callback only forwards them + // for live display, so a send failure (operator gone) must not abort the command. + return h.pairing.PairOnNode(ctx, fleetNodeID, targets, credentials, info.OrganizationID, &assignedBy, + func(results []*gatewaypb.FleetNodePairResult) error { + // Forward only while the operator is connected; once gone, skip the send so + // it can't block the command (which keeps persisting server-side). + if ctx.Err() == nil { + out := &pb.PairDiscoveredDevicesOnFleetNodeResponse{Results: make([]*pb.DevicePairingResult, 0, len(results))} + for _, r := range results { + res := &pb.DevicePairingResult{ + DeviceIdentifier: r.GetDeviceIdentifier(), + PairingStatus: pairOutcomeStatus(r.GetOutcome()), + } + if res.PairingStatus != fleetmanagementv1.PairingStatus_PAIRING_STATUS_PAIRED { + res.Error = r.GetErrorMessage() + } + out.Results = append(out.Results, res) + } + if sendErr := stream.Send(out); sendErr != nil { + slog.Warn("operator pair stream send failed; pairing continues server-side", + "fleet_node_id", fleetNodeID, "err", sendErr) + } + } + return nil + }) +} + +// pairOutcomeStatus maps a node pair outcome to the operator-facing enum, matching +// what PersistFleetNodePairResult records. +func pairOutcomeStatus(outcome gatewaypb.PairOutcome) fleetmanagementv1.PairingStatus { + switch outcome { + case gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED: + return fleetmanagementv1.PairingStatus_PAIRING_STATUS_PAIRED + case gatewaypb.PairOutcome_PAIR_OUTCOME_AUTH_NEEDED, gatewaypb.PairOutcome_PAIR_OUTCOME_AUTH_FAILED: + return fleetmanagementv1.PairingStatus_PAIRING_STATUS_AUTHENTICATION_NEEDED + case gatewaypb.PairOutcome_PAIR_OUTCOME_ERROR, gatewaypb.PairOutcome_PAIR_OUTCOME_UNSPECIFIED: + return fleetmanagementv1.PairingStatus_PAIRING_STATUS_FAILED + default: + return fleetmanagementv1.PairingStatus_PAIRING_STATUS_FAILED + } +} diff --git a/server/internal/handlers/fleetnode/admin/handler_pair_test.go b/server/internal/handlers/fleetnode/admin/handler_pair_test.go new file mode 100644 index 000000000..7ac8db37b --- /dev/null +++ b/server/internal/handlers/fleetnode/admin/handler_pair_test.go @@ -0,0 +1,400 @@ +package admin_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "connectrpc.com/authn" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + fleetmanagementv1 "github.com/block/proto-fleet/server/generated/grpc/fleetmanagement/v1" + pb "github.com/block/proto-fleet/server/generated/grpc/fleetnodeadmin/v1" + "github.com/block/proto-fleet/server/generated/grpc/fleetnodeadmin/v1/fleetnodeadminv1connect" + gatewaypb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" + pairingpb "github.com/block/proto-fleet/server/generated/grpc/pairing/v1" + "github.com/block/proto-fleet/server/internal/domain/authz" + "github.com/block/proto-fleet/server/internal/domain/fleetnode/auth" + "github.com/block/proto-fleet/server/internal/handlers/fleetnode/gateway" + "github.com/block/proto-fleet/server/internal/handlers/interceptors" +) + +func (h *pairingHarness) insertDiscoveredForNode(t *testing.T, fleetNodeID int64, identifier string) { + t.Helper() + _, err := h.db.Exec( + `INSERT INTO discovered_device (org_id, device_identifier, ip_address, port, url_scheme, driver_name, is_active, discovered_by_fleet_node_id) + VALUES ($1, $2, '10.0.0.7', '80', 'http', 'virtual', TRUE, $3)`, + h.orgID, identifier, fleetNodeID, + ) + require.NoError(t, err) +} + +// nodeReportPaired simulates the node uploading pair results via the gateway +// ReportPairedDevices RPC -- the authoritative persistence path -- rather than +// poking the registry directly, so tests exercise the real persist+forward flow. +func (h *pairingHarness) nodeReportPaired(t *testing.T, fleetNodeID int64, commandID string, results []*gatewaypb.FleetNodePairResult) { + t.Helper() + gw := gateway.NewHandler(nil, nil, h.pairing, h.registry) + ctx := authn.SetInfo(context.Background(), &auth.Subject{FleetNodeID: fleetNodeID, OrgID: h.orgID, Name: "agent"}) + _, err := gw.ReportPairedDevices(ctx, connect.NewRequest(&gatewaypb.ReportPairedDevicesRequest{CommandId: commandID, Results: results})) + require.NoError(t, err) +} + +func TestPairDiscoveredDevicesOnFleetNode_PairsAndStreamsResults(t *testing.T) { + // Arrange + h := newPairingHarness(t) + fleetNodeID := h.createFleetNode(t, "admin-pairdisc-1") + h.insertDiscoveredForNode(t, fleetNodeID, "mac:hp-1") + stream := h.registry.Register(fleetNodeID) + defer stream.Unregister() + client := startAdminServer(t, h) + + agentDone := make(chan struct{}) + go func() { + defer close(agentDone) + select { + case cmd, ok := <-stream.Outgoing: + if !ok { + return + } + var agentCmd pairingpb.AgentCommand + require.NoError(t, proto.Unmarshal(cmd.GetPayload(), &agentCmd)) + pairReq := agentCmd.GetPair() + require.NotNil(t, pairReq) + require.Len(t, pairReq.GetTargets(), 1) + assert.Equal(t, "mac:hp-1", pairReq.GetTargets()[0].GetDeviceIdentifier()) + + h.nodeReportPaired(t, fleetNodeID, cmd.GetCommandId(), []*gatewaypb.FleetNodePairResult{ + {DeviceIdentifier: "mac:hp-1", Outcome: gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED, SerialNumber: "sn-hp-1", MacAddress: "aa:bb:cc:11:22:33", Model: "S19", FirmwareVersion: "v2"}, + }) + stream.PublishAck(&gatewaypb.ControlAck{CommandId: cmd.GetCommandId(), Succeeded: true, Code: gatewaypb.AckCode_ACK_CODE_OK}) + case <-time.After(2 * time.Second): + t.Errorf("agent goroutine timed out waiting for pair command") + } + }() + + // Act + resp, err := client.PairDiscoveredDevicesOnFleetNode(context.Background(), connect.NewRequest(&pb.PairDiscoveredDevicesOnFleetNodeRequest{ + FleetNodeId: fleetNodeID, + DeviceIdentifiers: []string{"mac:hp-1"}, + })) + require.NoError(t, err) + + // Assert: streamed result is PAIRED, and the device is persisted paired + bound. + var results []*pb.DevicePairingResult + for resp.Receive() { + results = append(results, resp.Msg().GetResults()...) + } + require.NoError(t, resp.Err()) + require.NoError(t, resp.Close()) + require.Len(t, results, 1) + assert.Equal(t, "mac:hp-1", results[0].GetDeviceIdentifier()) + assert.Equal(t, fleetmanagementv1.PairingStatus_PAIRING_STATUS_PAIRED, results[0].GetPairingStatus()) + <-agentDone + + var status string + require.NoError(t, h.db.QueryRow( + `SELECT dp.pairing_status FROM device d JOIN device_pairing dp ON dp.device_id = d.id WHERE d.device_identifier=$1 AND d.org_id=$2`, + "mac:hp-1", h.orgID, + ).Scan(&status)) + assert.Equal(t, "PAIRED", status) + + var bound int + require.NoError(t, h.db.QueryRow( + `SELECT count(*) FROM device d JOIN fleet_node_device fnd ON fnd.device_id = d.id WHERE d.device_identifier=$1 AND fnd.fleet_node_id=$2`, + "mac:hp-1", fleetNodeID, + ).Scan(&bound)) + assert.Equal(t, 1, bound) +} + +func TestPairDiscoveredDevicesOnFleetNode_SynthesizesUnreportedTargets(t *testing.T) { + // Arrange: two devices requested, but the node reports only one before its + // (partial) ack. The operator must still get a terminal status for the device + // the node never reported, rather than a silently-complete RPC. + h := newPairingHarness(t) + fleetNodeID := h.createFleetNode(t, "admin-pairdisc-partial") + h.insertDiscoveredForNode(t, fleetNodeID, "mac:reported") + h.insertDiscoveredForNode(t, fleetNodeID, "mac:dropped") + stream := h.registry.Register(fleetNodeID) + defer stream.Unregister() + client := startAdminServer(t, h) + + agentDone := make(chan struct{}) + go func() { + defer close(agentDone) + select { + case cmd, ok := <-stream.Outgoing: + if !ok { + return + } + h.nodeReportPaired(t, fleetNodeID, cmd.GetCommandId(), []*gatewaypb.FleetNodePairResult{ + {DeviceIdentifier: "mac:reported", Outcome: gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED, SerialNumber: "sn-r", MacAddress: "aa:bb:cc:00:00:01"}, + }) + // PARTIAL: the batch timed out before "mac:dropped" was attempted. + stream.PublishAck(&gatewaypb.ControlAck{CommandId: cmd.GetCommandId(), Code: gatewaypb.AckCode_ACK_CODE_PARTIAL}) + case <-time.After(2 * time.Second): + t.Errorf("agent goroutine timed out waiting for pair command") + } + }() + + // Act + resp, err := client.PairDiscoveredDevicesOnFleetNode(context.Background(), connect.NewRequest(&pb.PairDiscoveredDevicesOnFleetNodeRequest{ + FleetNodeId: fleetNodeID, + DeviceIdentifiers: []string{"mac:reported", "mac:dropped"}, + })) + require.NoError(t, err) + statuses := map[string]fleetmanagementv1.PairingStatus{} + for resp.Receive() { + for _, r := range resp.Msg().GetResults() { + statuses[r.GetDeviceIdentifier()] = r.GetPairingStatus() + } + } + require.NoError(t, resp.Err()) + require.NoError(t, resp.Close()) + <-agentDone + + // Assert: the reported device is PAIRED; the unreported one gets a synthesized + // FAILED so the operator has a terminal status + retry signal for every target. + assert.Equal(t, fleetmanagementv1.PairingStatus_PAIRING_STATUS_PAIRED, statuses["mac:reported"]) + assert.Equal(t, fleetmanagementv1.PairingStatus_PAIRING_STATUS_FAILED, statuses["mac:dropped"]) +} + +func TestPairDiscoveredDevicesOnFleetNode_DropsResultsOutsideRequestedTargets(t *testing.T) { + // Arrange: two devices discovered by the node; the operator requests only one. + h := newPairingHarness(t) + fleetNodeID := h.createFleetNode(t, "admin-pairdisc-unrequested") + h.insertDiscoveredForNode(t, fleetNodeID, "mac:req") + h.insertDiscoveredForNode(t, fleetNodeID, "mac:sneaky") + stream := h.registry.Register(fleetNodeID) + defer stream.Unregister() + client := startAdminServer(t, h) + + agentDone := make(chan struct{}) + go func() { + defer close(agentDone) + select { + case cmd, ok := <-stream.Outgoing: + if !ok { + return + } + var agentCmd pairingpb.AgentCommand + require.NoError(t, proto.Unmarshal(cmd.GetPayload(), &agentCmd)) + // The dispatched command must carry only the requested target. + require.Len(t, agentCmd.GetPair().GetTargets(), 1) + assert.Equal(t, "mac:req", agentCmd.GetPair().GetTargets()[0].GetDeviceIdentifier()) + // A compromised node reports PAIRED for an unrequested device it + // previously discovered (not in the dispatched targets). + h.nodeReportPaired(t, fleetNodeID, cmd.GetCommandId(), []*gatewaypb.FleetNodePairResult{ + {DeviceIdentifier: "mac:sneaky", Outcome: gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED, SerialNumber: "sn-sneaky", MacAddress: "aa:bb:cc:11:22:02"}, + }) + stream.PublishAck(&gatewaypb.ControlAck{CommandId: cmd.GetCommandId(), Succeeded: true, Code: gatewaypb.AckCode_ACK_CODE_OK}) + case <-time.After(2 * time.Second): + t.Errorf("agent goroutine timed out waiting for pair command") + } + }() + + // Act + resp, err := client.PairDiscoveredDevicesOnFleetNode(context.Background(), connect.NewRequest(&pb.PairDiscoveredDevicesOnFleetNodeRequest{ + FleetNodeId: fleetNodeID, + DeviceIdentifiers: []string{"mac:req"}, + })) + require.NoError(t, err) + statuses := map[string]fleetmanagementv1.PairingStatus{} + for resp.Receive() { + for _, r := range resp.Msg().GetResults() { + statuses[r.GetDeviceIdentifier()] = r.GetPairingStatus() + } + } + require.NoError(t, resp.Err()) + require.NoError(t, resp.Close()) + <-agentDone + + // Assert: the unrequested device is dropped by the gateway scope (never created + // or persisted, never streamed); the requested device the node never reported + // gets a synthesized FAILED. + _, sneakyStreamed := statuses["mac:sneaky"] + assert.False(t, sneakyStreamed, "an out-of-scope result must not be streamed to the operator") + assert.Equal(t, fleetmanagementv1.PairingStatus_PAIRING_STATUS_FAILED, statuses["mac:req"]) + + var sneaky int + require.NoError(t, h.db.QueryRow( + `SELECT count(*) FROM device WHERE device_identifier=$1 AND org_id=$2 AND deleted_at IS NULL`, + "mac:sneaky", h.orgID, + ).Scan(&sneaky)) + assert.Equal(t, 0, sneaky, "a result outside the requested targets must not create or pair a device") +} + +func TestPairDiscoveredDevicesOnFleetNode_PersistsAfterOperatorDisconnect(t *testing.T) { + // Arrange: the regression for the HIGH split-brain finding. The operator + // dispatches, then disconnects before the node reports. + h := newPairingHarness(t) + fleetNodeID := h.createFleetNode(t, "admin-pairdisc-disconnect") + h.insertDiscoveredForNode(t, fleetNodeID, "mac:dc-1") + stream := h.registry.Register(fleetNodeID) + defer stream.Unregister() + client := startAdminServer(t, h) + + // The operator call blocks until the server streams, so drive it in a goroutine + // (a streaming client call doesn't return until the handler produces output). + ctx, cancel := context.WithCancel(context.Background()) + opDone := make(chan struct{}) + go func() { + defer close(opDone) + rs, err := client.PairDiscoveredDevicesOnFleetNode(ctx, connect.NewRequest(&pb.PairDiscoveredDevicesOnFleetNodeRequest{ + FleetNodeId: fleetNodeID, + DeviceIdentifiers: []string{"mac:dc-1"}, + })) + if err != nil { + return + } + for rs.Receive() { //nolint:revive // drain until the operator ctx is cancelled + } + _ = rs.Close() + }() + + // Act: wait for the command to dispatch, then the operator disconnects. + var cmd *gatewaypb.ControlCommand + select { + case c := <-stream.Outgoing: + cmd = c + case <-time.After(3 * time.Second): + t.Fatal("pair command was not dispatched") + } + cancel() + <-opDone + + // The node reports + acks AFTER the operator is gone. The command must still be + // in flight (dispatched on a disconnect-immune context) so the gateway persists. + h.nodeReportPaired(t, fleetNodeID, cmd.GetCommandId(), []*gatewaypb.FleetNodePairResult{ + {DeviceIdentifier: "mac:dc-1", Outcome: gatewaypb.PairOutcome_PAIR_OUTCOME_PAIRED, SerialNumber: "sn-dc-1", MacAddress: "aa:bb:cc:dc:00:01"}, + }) + stream.PublishAck(&gatewaypb.ControlAck{CommandId: cmd.GetCommandId(), Succeeded: true, Code: gatewaypb.AckCode_ACK_CODE_OK}) + + // Assert: the device is persisted (PAIRED + bound) despite the disconnect. + var status string + require.NoError(t, h.db.QueryRow( + `SELECT dp.pairing_status FROM device d JOIN device_pairing dp ON dp.device_id = d.id WHERE d.device_identifier=$1 AND d.org_id=$2`, + "mac:dc-1", h.orgID, + ).Scan(&status)) + assert.Equal(t, "PAIRED", status) + var bound int + require.NoError(t, h.db.QueryRow( + `SELECT count(*) FROM device d JOIN fleet_node_device fnd ON fnd.device_id = d.id WHERE d.device_identifier=$1 AND fnd.fleet_node_id=$2`, + "mac:dc-1", fleetNodeID, + ).Scan(&bound)) + assert.Equal(t, 1, bound, "pairing must persist even if the operator disconnected") +} + +func TestPairDiscoveredDevicesOnFleetNode_NoStreamReturnsFailedPrecondition(t *testing.T) { + // Arrange: discovered device exists but the node has no active control stream. + h := newPairingHarness(t) + fleetNodeID := h.createFleetNode(t, "admin-pairdisc-nostream") + h.insertDiscoveredForNode(t, fleetNodeID, "mac:ns-1") + client := startAdminServer(t, h) + + // Act + resp, err := client.PairDiscoveredDevicesOnFleetNode(context.Background(), connect.NewRequest(&pb.PairDiscoveredDevicesOnFleetNodeRequest{ + FleetNodeId: fleetNodeID, + DeviceIdentifiers: []string{"mac:ns-1"}, + })) + require.NoError(t, err) + for resp.Receive() { + } + + // Assert + require.Error(t, resp.Err()) + assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(resp.Err())) +} + +func TestPairDiscoveredDevicesOnFleetNode_NoPairableTargetsReturnsInvalidArgument(t *testing.T) { + // Arrange: a confirmed node with no discovered devices. + h := newPairingHarness(t) + fleetNodeID := h.createFleetNode(t, "admin-pairdisc-empty") + stream := h.registry.Register(fleetNodeID) + defer stream.Unregister() + client := startAdminServer(t, h) + + // Act + resp, err := client.PairDiscoveredDevicesOnFleetNode(context.Background(), connect.NewRequest(&pb.PairDiscoveredDevicesOnFleetNodeRequest{ + FleetNodeId: fleetNodeID, + DeviceIdentifiers: []string{"mac:does-not-exist"}, + })) + require.NoError(t, err) + for resp.Receive() { + } + + // Assert + require.Error(t, resp.Err()) + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(resp.Err())) +} + +func TestPairDiscoveredDevicesOnFleetNode_RequiresBothPermissions(t *testing.T) { + tests := []struct { + name string + perms []string + }{ + {name: "no permissions (VIEWER)", perms: nil}, + {name: "fleetnode:manage only", perms: []string{authz.PermFleetnodeManage}}, + {name: "miner:pair only", perms: []string{authz.PermMinerPair}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Arrange + h := newPairingHarness(t) + fleetNodeID := h.createFleetNode(t, "admin-pairdisc-perm") + h.insertDiscoveredForNode(t, fleetNodeID, "mac:perm-1") + injector := sessionInjector{role: "ADMIN", orgID: h.orgID, userID: 1, perms: tc.perms} + mux := http.NewServeMux() + mux.Handle(fleetnodeadminv1connect.NewFleetNodeAdminServiceHandler( + h.handler, + connect.WithInterceptors(interceptors.NewErrorMappingInterceptor(), injector), + )) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + client := fleetnodeadminv1connect.NewFleetNodeAdminServiceClient(http.DefaultClient, srv.URL) + + // Act + resp, err := client.PairDiscoveredDevicesOnFleetNode(context.Background(), connect.NewRequest(&pb.PairDiscoveredDevicesOnFleetNodeRequest{ + FleetNodeId: fleetNodeID, + DeviceIdentifiers: []string{"mac:perm-1"}, + })) + require.NoError(t, err) + for resp.Receive() { + } + + // Assert + require.Error(t, resp.Err()) + assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(resp.Err())) + }) + } +} + +func TestListFleetNodeDiscoveredDevices_ReturnsAndFilters(t *testing.T) { + // Arrange + h := newPairingHarness(t) + nodeA := h.createFleetNode(t, "admin-list-a") + nodeB := h.createFleetNode(t, "admin-list-b") + h.insertDiscoveredForNode(t, nodeA, "mac:la-1") + h.insertDiscoveredForNode(t, nodeB, "mac:lb-1") + client := startAdminServer(t, h) + + // Act + resp, err := client.ListFleetNodeDiscoveredDevices(context.Background(), connect.NewRequest(&pb.ListFleetNodeDiscoveredDevicesRequest{ + FleetNodeId: nodeA, + })) + + // Assert + require.NoError(t, err) + ids := make([]string, 0, len(resp.Msg.GetDevices())) + for _, d := range resp.Msg.GetDevices() { + ids = append(ids, d.GetDeviceIdentifier()) + assert.Equal(t, nodeA, d.GetFleetNodeId()) + } + assert.Contains(t, ids, "mac:la-1") + assert.NotContains(t, ids, "mac:lb-1") +} diff --git a/server/internal/handlers/fleetnode/admin/handler_pairing_test.go b/server/internal/handlers/fleetnode/admin/handler_pairing_test.go index c6d4602b5..d052006eb 100644 --- a/server/internal/handlers/fleetnode/admin/handler_pairing_test.go +++ b/server/internal/handlers/fleetnode/admin/handler_pairing_test.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "crypto/rand" "database/sql" + "encoding/base64" "fmt" "testing" "time" @@ -26,6 +27,7 @@ import ( "github.com/block/proto-fleet/server/internal/domain/stores/sqlstores" "github.com/block/proto-fleet/server/internal/handlers/fleetnode/admin" "github.com/block/proto-fleet/server/internal/handlers/middleware" + "github.com/block/proto-fleet/server/internal/infrastructure/encrypt" "github.com/block/proto-fleet/server/internal/testutil" ) @@ -35,6 +37,7 @@ type pairingHarness struct { orgID int64 enrollment *enrollment.Service registry *control.Registry + pairing *pairing.Service } func newPairingHarness(t *testing.T) *pairingHarness { @@ -55,9 +58,12 @@ func newPairingHarness(t *testing.T) *pairingHarness { enrollmentStore := sqlstores.NewSQLFleetNodeEnrollmentStore(db) enrollmentSvc := enrollment.NewService(enrollmentStore, apiKeySvc, transactor, nil) pairingStore := sqlstores.NewSQLFleetNodePairingStore(db) - pairingSvc := pairing.NewService(pairingStore, enrollmentStore, transactor) - + encryptSvc, err := encrypt.NewService(&encrypt.Config{ServiceMasterKey: base64.StdEncoding.EncodeToString(make([]byte, 32))}) + require.NoError(t, err) registry := control.NewRegistry() + pairingSvc := pairing.NewService(pairingStore, enrollmentStore, transactor). + WithProvisioning(sqlstores.NewSQLDeviceStore(db), sqlstores.NewSQLDiscoveredDeviceStore(db), encryptSvc, registry) + discoverySvc := discovery.NewService(registry, enrollmentSvc) return &pairingHarness{ handler: admin.NewHandler(enrollmentSvc, pairingSvc, discoverySvc), @@ -65,6 +71,7 @@ func newPairingHarness(t *testing.T) *pairingHarness { orgID: 1, enrollment: enrollmentSvc, registry: registry, + pairing: pairingSvc, } } diff --git a/server/internal/handlers/fleetnode/gateway/handler.go b/server/internal/handlers/fleetnode/gateway/handler.go index 244e1cb5f..b9840e35b 100644 --- a/server/internal/handlers/fleetnode/gateway/handler.go +++ b/server/internal/handlers/fleetnode/gateway/handler.go @@ -95,7 +95,7 @@ func (h *Handler) ReportDiscoveredDevices(ctx context.Context, req *connect.Requ in := req.Msg.GetDevices() // Bind to the in-flight command and reserve quota so an agent can't stream // unbounded batches against one command_id. - if admitErr := h.registry.AdmitReport(subject.FleetNodeID, commandID, len(in)); admitErr != nil { + if admitErr := h.registry.AdmitReport(subject.FleetNodeID, commandID, len(in), control.ReportKindDiscovery); admitErr != nil { if errors.Is(admitErr, control.ErrReportQuotaExceeded) { return nil, connect.NewError(connect.CodeResourceExhausted, admitErr) } @@ -157,6 +157,80 @@ func (h *Handler) ReportDiscoveredDevices(ctx context.Context, req *connect.Requ }), nil } +func (h *Handler) ReportPairedDevices(ctx context.Context, req *connect.Request[pb.ReportPairedDevicesRequest]) (*connect.Response[pb.ReportPairedDevicesResponse], error) { + subject, err := auth.GetSubject(ctx) + if err != nil { + return nil, err + } + commandID := req.Msg.GetCommandId() + if commandID == "" { + return nil, fleeterror.NewFailedPreconditionError("pairing report requires a command_id from a server-issued ControlCommand") + } + results := req.Msg.GetResults() + // Admit, scope to the dispatched targets (consuming each to bar replay), then + // persist authoritatively here -- the node-authenticated path is the source of + // truth, so a disconnected operator can't lose a paired miner. + kept, meta, admitErr := h.registry.AdmitAndScopePairResults(subject.FleetNodeID, commandID, results) + if admitErr != nil { + switch { + case errors.Is(admitErr, control.ErrReportQuotaExceeded): + return nil, connect.NewError(connect.CodeResourceExhausted, admitErr) + case errors.Is(admitErr, control.ErrEmptyReport): + return nil, fleeterror.NewInvalidArgumentError("pairing report carried no results") + default: + return nil, fleeterror.NewFailedPreconditionError("pairing report does not match an in-flight server-issued command") + } + } + + persisted := make([]*pb.FleetNodePairResult, 0, len(kept)) + var persistFailed []string + for _, r := range kept { + status, err := h.pairing.PersistFleetNodePairResult(ctx, subject.FleetNodeID, meta.OrgID, r, meta.AssignedBy) + if err != nil { + // Per-device isolation: one persist failure must not drop the others + // (already paired on the node). A failed result isn't forwarded as paired; + // the operator synthesizes a terminal FAILED so it surfaces for re-issue. + slog.Error("failed to persist fleet node pair result", + "fleet_node_id", subject.FleetNodeID, "device_identifier", r.GetDeviceIdentifier(), "err", err) + persistFailed = append(persistFailed, r.GetDeviceIdentifier()) + continue + } + // Forward the persisted status, not the raw report: a stale AUTH_NEEDED for + // an already-PAIRED device persists as PAIRED, so the operator must see PAIRED. + r.Outcome = pairOutcomeForStatus(status) + persisted = append(persisted, r) + } + // Admission consumed these targets; return them so a retried report for the + // same command can persist after a transient failure. + if len(persistFailed) > 0 { + h.registry.ReinstatePairTargets(subject.FleetNodeID, commandID, persistFailed) + } + + // Forward only persisted results for live display; lossy is fine now that + // persistence above is authoritative, like discovery's PublishBatch. + if len(persisted) > 0 { + h.registry.PublishPairResults(subject.FleetNodeID, commandID, persisted) + } + return connect.NewResponse(&pb.ReportPairedDevicesResponse{ + AcceptedCount: int64(len(persisted)), + RejectedCount: int64(len(results) - len(persisted)), + }), nil +} + +// pairOutcomeForStatus maps the persisted device_pairing status back to the pair +// outcome forwarded to the operator, so the live display reflects what was stored +// (PAIRED / AUTHENTICATION_NEEDED / FAILED) rather than the raw node report. +func pairOutcomeForStatus(status string) pb.PairOutcome { + switch status { + case pairing.StatusPaired: + return pb.PairOutcome_PAIR_OUTCOME_PAIRED + case pairing.StatusAuthenticationNeeded: + return pb.PairOutcome_PAIR_OUTCOME_AUTH_NEEDED + default: + return pb.PairOutcome_PAIR_OUTCOME_ERROR + } +} + func toPairingDevice(d *pb.DiscoveredDeviceReport) *pairingpb.Device { return &pairingpb.Device{ DeviceIdentifier: d.GetDeviceIdentifier(), diff --git a/server/internal/handlers/fleetnode/gateway/handler_controlstream_test.go b/server/internal/handlers/fleetnode/gateway/handler_controlstream_test.go index 86b42ad20..cf479d1a2 100644 --- a/server/internal/handlers/fleetnode/gateway/handler_controlstream_test.go +++ b/server/internal/handlers/fleetnode/gateway/handler_controlstream_test.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "crypto/tls" "database/sql" + "encoding/base64" "net" "net/http" "net/http/httptest" @@ -29,6 +30,7 @@ import ( "github.com/block/proto-fleet/server/internal/domain/stores/sqlstores" "github.com/block/proto-fleet/server/internal/handlers/fleetnode/gateway" "github.com/block/proto-fleet/server/internal/handlers/interceptors" + "github.com/block/proto-fleet/server/internal/infrastructure/encrypt" "github.com/block/proto-fleet/server/internal/testutil" ) @@ -59,8 +61,11 @@ func newControlHarness(t *testing.T) *controlHarness { authStore := sqlstores.NewSQLFleetNodeAuthStore(db) authSvc := auth.NewService(authStore, enrollmentStore, apiKeySvc) pairingStore := sqlstores.NewSQLFleetNodePairingStore(db) - pairingSvc := pairing.NewService(pairingStore, enrollmentStore, transactor) registry := control.NewRegistry() + encryptSvc, err := encrypt.NewService(&encrypt.Config{ServiceMasterKey: base64.StdEncoding.EncodeToString(make([]byte, 32))}) + require.NoError(t, err) + pairingSvc := pairing.NewService(pairingStore, enrollmentStore, transactor). + WithProvisioning(sqlstores.NewSQLDeviceStore(db), sqlstores.NewSQLDiscoveredDeviceStore(db), encryptSvc, registry) pubKey, _, _ := ed25519.GenerateKey(rand.Reader) signing, _, _ := ed25519.GenerateKey(rand.Reader) @@ -68,6 +73,10 @@ func newControlHarness(t *testing.T) *controlHarness { require.NoError(t, err) agent, _, err := enrollmentSvc.RegisterFleetNode(t.Context(), code, "agent-control", pubKey, signing) require.NoError(t, err) + // Confirm the node so pairDeviceLocked (which requires CONFIRMED) can bind + // devices during ReportPairedDevices persistence. + _, _, err = enrollmentSvc.Confirm(t.Context(), agent.ID, 1) + require.NoError(t, err) return &controlHarness{ handler: gateway.NewHandler(enrollmentSvc, authSvc, pairingSvc, registry), @@ -169,7 +178,7 @@ func waitForSend(t *testing.T, r *control.Registry, fleetNodeID int64, commandID t.Helper() deadline := time.Now().Add(2 * time.Second) for { - session, err := r.Send(context.Background(), fleetNodeID, &pb.ControlCommand{CommandId: commandID, Payload: payload}, nil) + session, err := r.Send(context.Background(), fleetNodeID, &pb.ControlCommand{CommandId: commandID, Payload: payload}, nil, control.ReportKindDiscovery, nil) if err == nil { return session } diff --git a/server/internal/handlers/fleetnode/gateway/handler_discovery_test.go b/server/internal/handlers/fleetnode/gateway/handler_discovery_test.go index 583663fe5..73f6f736a 100644 --- a/server/internal/handlers/fleetnode/gateway/handler_discovery_test.go +++ b/server/internal/handlers/fleetnode/gateway/handler_discovery_test.go @@ -12,6 +12,7 @@ import ( pb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" "github.com/block/proto-fleet/server/internal/domain/fleetnode/auth" + "github.com/block/proto-fleet/server/internal/domain/fleetnode/control" ) func TestReportDiscoveredDevices_RejectsMissingCommandID(t *testing.T) { @@ -64,7 +65,7 @@ func TestReportDiscoveredDevices_PublishesBatchToInFlightCommand(t *testing.T) { stream := h.registry.Register(h.fleetNodeID) defer stream.Unregister() - session, err := h.registry.Send(context.Background(), h.fleetNodeID, &pb.ControlCommand{CommandId: "operator-cmd"}, nil) + session, err := h.registry.Send(context.Background(), h.fleetNodeID, &pb.ControlCommand{CommandId: "operator-cmd"}, nil, control.ReportKindDiscovery, nil) require.NoError(t, err) defer session.Close() <-stream.Outgoing @@ -104,7 +105,7 @@ func TestReportDiscoveredDevices_PublishesOnlyAcceptedDevices(t *testing.T) { stream := h.registry.Register(h.fleetNodeID) defer stream.Unregister() - session, err := h.registry.Send(context.Background(), h.fleetNodeID, &pb.ControlCommand{CommandId: "partial-cmd"}, nil) + session, err := h.registry.Send(context.Background(), h.fleetNodeID, &pb.ControlCommand{CommandId: "partial-cmd"}, nil, control.ReportKindDiscovery, nil) require.NoError(t, err) defer session.Close() <-stream.Outgoing @@ -177,7 +178,7 @@ func TestReportDiscoveredDevices_DropsOutOfScopeDevices(t *testing.T) { stream := h.registry.Register(h.fleetNodeID) defer stream.Unregister() scope := func(ip, port string) bool { return ip == "10.0.0.50" && port == "4028" } - session, err := h.registry.Send(context.Background(), h.fleetNodeID, &pb.ControlCommand{CommandId: "scoped-cmd"}, scope) + session, err := h.registry.Send(context.Background(), h.fleetNodeID, &pb.ControlCommand{CommandId: "scoped-cmd"}, scope, control.ReportKindDiscovery, nil) require.NoError(t, err) defer session.Close() <-stream.Outgoing diff --git a/server/internal/handlers/fleetnode/gateway/handler_pair_report_test.go b/server/internal/handlers/fleetnode/gateway/handler_pair_report_test.go new file mode 100644 index 000000000..c5d13fb81 --- /dev/null +++ b/server/internal/handlers/fleetnode/gateway/handler_pair_report_test.go @@ -0,0 +1,176 @@ +package gateway_test + +import ( + "context" + "testing" + "time" + + "connectrpc.com/authn" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pb "github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/fleetnode/auth" + "github.com/block/proto-fleet/server/internal/domain/fleetnode/control" +) + +func TestReportPairedDevices_RejectsMissingCommandID(t *testing.T) { + // Arrange + handler, _, fleetNodeID := newHeartbeatHandler(t) + ctx := authn.SetInfo(t.Context(), &auth.Subject{FleetNodeID: fleetNodeID, OrgID: 1, Name: "agent-pair"}) + req := connect.NewRequest(&pb.ReportPairedDevicesRequest{ + Results: []*pb.FleetNodePairResult{{DeviceIdentifier: "x", Outcome: pb.PairOutcome_PAIR_OUTCOME_PAIRED}}, + }) + + // Act + _, err := handler.ReportPairedDevices(ctx, req) + + // Assert + require.Error(t, err) + assert.Contains(t, err.Error(), "command_id") +} + +func TestReportPairedDevices_RejectsUnknownCommandID(t *testing.T) { + // Arrange + h := newControlHarness(t) + subject := &auth.Subject{FleetNodeID: h.fleetNodeID, OrgID: 1, Name: "agent-pair-unknown"} + stream := h.registry.Register(h.fleetNodeID) + defer stream.Unregister() + ctx := authn.SetInfo(context.Background(), subject) + + // Act + _, err := h.handler.ReportPairedDevices(ctx, connect.NewRequest(&pb.ReportPairedDevicesRequest{ + CommandId: "never-sent", + Results: []*pb.FleetNodePairResult{{DeviceIdentifier: "x", Outcome: pb.PairOutcome_PAIR_OUTCOME_PAIRED}}, + })) + + // Assert + require.Error(t, err) + assert.Contains(t, err.Error(), "in-flight server-issued command") +} + +func TestReportPairedDevices_PersistsAuthoritativelyAndForwards(t *testing.T) { + // Arrange: a node-discovered device and an in-flight pair command scoped to it. + h := newControlHarness(t) + _, err := h.db.Exec( + `INSERT INTO discovered_device (org_id, device_identifier, ip_address, port, url_scheme, driver_name, is_active, discovered_by_fleet_node_id) + VALUES (1, $1, '10.0.0.7', '80', 'http', 'virtual', TRUE, $2)`, + "pair-corr-1", h.fleetNodeID) + require.NoError(t, err) + subject := &auth.Subject{FleetNodeID: h.fleetNodeID, OrgID: 1, Name: "agent-pair-correlation"} + stream := h.registry.Register(h.fleetNodeID) + defer stream.Unregister() + pair := &control.PairMeta{OrgID: 1, Targets: map[string]struct{}{"pair-corr-1": {}}} + session, err := h.registry.Send(context.Background(), h.fleetNodeID, &pb.ControlCommand{CommandId: "pair-cmd"}, nil, control.ReportKindPair, pair) + require.NoError(t, err) + defer session.Close() + <-stream.Outgoing + ctx := authn.SetInfo(context.Background(), subject) + + // Act + resp, err := h.handler.ReportPairedDevices(ctx, connect.NewRequest(&pb.ReportPairedDevicesRequest{ + CommandId: "pair-cmd", + Results: []*pb.FleetNodePairResult{ + {DeviceIdentifier: "pair-corr-1", Outcome: pb.PairOutcome_PAIR_OUTCOME_PAIRED}, + }, + })) + + // Assert: persisted authoritatively in the gateway path (independent of the + // operator stream) and also forwarded for live display. + require.NoError(t, err) + assert.Equal(t, int64(1), resp.Msg.GetAcceptedCount()) + var status string + require.NoError(t, h.db.QueryRow( + `SELECT dp.pairing_status FROM device d JOIN device_pairing dp ON dp.device_id = d.id WHERE d.device_identifier=$1 AND d.org_id=1`, + "pair-corr-1").Scan(&status)) + assert.Equal(t, "PAIRED", status) + select { + case ev := <-session.Events(): + require.Len(t, ev.PairResults, 1) + assert.Equal(t, "pair-corr-1", ev.PairResults[0].GetDeviceIdentifier()) + case <-time.After(time.Second): + t.Fatal("expected pair results on events channel") + } +} + +func TestReportPairedDevices_ForwardsPersistedStatusOnStaleAuthNeeded(t *testing.T) { + // Arrange: a device already PAIRED (e.g. paired by a re-issued command) with an + // in-flight pair command still scoped to it. A stale AUTH_NEEDED arrives. + h := newControlHarness(t) + var ddID int64 + require.NoError(t, h.db.QueryRow( + `INSERT INTO discovered_device (org_id, device_identifier, ip_address, port, url_scheme, driver_name, is_active, discovered_by_fleet_node_id) + VALUES (1, $1, '10.0.0.7', '80', 'http', 'virtual', TRUE, $2) RETURNING id`, + "mac:race", h.fleetNodeID).Scan(&ddID)) + var devID int64 + require.NoError(t, h.db.QueryRow( + `INSERT INTO device (device_identifier, mac_address, serial_number, org_id, discovered_device_id) + VALUES ($1, 'aa:bb:cc:00:ra:01', 'sn-race', 1, $2) RETURNING id`, + "mac:race", ddID).Scan(&devID)) + _, err := h.db.Exec(`INSERT INTO device_pairing (device_id, pairing_status) VALUES ($1, 'PAIRED')`, devID) + require.NoError(t, err) + + stream := h.registry.Register(h.fleetNodeID) + defer stream.Unregister() + pair := &control.PairMeta{OrgID: 1, Targets: map[string]struct{}{"mac:race": {}}} + session, err := h.registry.Send(context.Background(), h.fleetNodeID, &pb.ControlCommand{CommandId: "race-cmd"}, nil, control.ReportKindPair, pair) + require.NoError(t, err) + defer session.Close() + <-stream.Outgoing + ctx := authn.SetInfo(context.Background(), &auth.Subject{FleetNodeID: h.fleetNodeID, OrgID: 1, Name: "agent-race"}) + + // Act: a stale AUTH_NEEDED for the already-paired device. + _, err = h.handler.ReportPairedDevices(ctx, connect.NewRequest(&pb.ReportPairedDevicesRequest{ + CommandId: "race-cmd", + Results: []*pb.FleetNodePairResult{{DeviceIdentifier: "mac:race", Outcome: pb.PairOutcome_PAIR_OUTCOME_AUTH_NEEDED}}, + })) + require.NoError(t, err) + + // Assert: the forwarded result reflects the persisted PAIRED status, not the + // raw AUTH_NEEDED, so the operator display matches the DB. + select { + case ev := <-session.Events(): + require.Len(t, ev.PairResults, 1) + assert.Equal(t, pb.PairOutcome_PAIR_OUTCOME_PAIRED, ev.PairResults[0].GetOutcome()) + case <-time.After(time.Second): + t.Fatal("expected forwarded pair result") + } +} + +func TestReportPairedDevices_RejectsEmptyBatch(t *testing.T) { + // Arrange: an in-flight pair command with no results reported. + h := newControlHarness(t) + subject := &auth.Subject{FleetNodeID: h.fleetNodeID, OrgID: 1, Name: "agent-pair-empty"} + stream := h.registry.Register(h.fleetNodeID) + defer stream.Unregister() + pair := &control.PairMeta{OrgID: 1, Targets: map[string]struct{}{"x": {}}} + session, err := h.registry.Send(context.Background(), h.fleetNodeID, &pb.ControlCommand{CommandId: "pair-empty"}, nil, control.ReportKindPair, pair) + require.NoError(t, err) + defer session.Close() + <-stream.Outgoing + + // Act: an empty report (allowed by the proto, consumes no quota). + _, err = h.handler.ReportPairedDevices(authn.SetInfo(context.Background(), subject), connect.NewRequest(&pb.ReportPairedDevicesRequest{ + CommandId: "pair-empty", + })) + + // Assert: rejected as InvalidArgument rather than acked. + require.Error(t, err) + assert.True(t, fleeterror.IsInvalidArgumentError(err), "empty pair report must be InvalidArgument, got %v", err) +} + +func TestReportPairedDevices_RejectsMissingSubject(t *testing.T) { + // Arrange + handler, _, _ := newHeartbeatHandler(t) + req := connect.NewRequest(&pb.ReportPairedDevicesRequest{ + Results: []*pb.FleetNodePairResult{{DeviceIdentifier: "x", Outcome: pb.PairOutcome_PAIR_OUTCOME_PAIRED}}, + }) + + // Act + _, err := handler.ReportPairedDevices(context.Background(), req) + + // Assert + require.Error(t, err) +} diff --git a/server/internal/handlers/interceptors/config.go b/server/internal/handlers/interceptors/config.go index b81ebdc9b..074ec3e49 100644 --- a/server/internal/handlers/interceptors/config.go +++ b/server/internal/handlers/interceptors/config.go @@ -146,11 +146,9 @@ var SensitiveBodyProcedures = map[string]bool{ fleetnodegatewayv1connect.FleetNodeGatewayServiceUploadTelemetryProcedure: true, fleetnodegatewayv1connect.FleetNodeGatewayServiceUploadEventsProcedure: true, fleetnodegatewayv1connect.FleetNodeGatewayServiceReportDiscoveredDevicesProcedure: true, - // ReportPairedDevices carries pair result error strings that can echo credential - // fragments from plugin/miner error responses. - fleetnodegatewayv1connect.FleetNodeGatewayServiceReportPairedDevicesProcedure: true, - fleetnodeadminv1connect.FleetNodeAdminServiceDiscoverOnFleetNodeProcedure: true, - // PairDiscoveredDevicesOnFleetNode carries credentials in the request; response - // error strings from plugins/nodes can also echo secrets. + fleetnodegatewayv1connect.FleetNodeGatewayServiceReportPairedDevicesProcedure: true, + fleetnodeadminv1connect.FleetNodeAdminServiceDiscoverOnFleetNodeProcedure: true, + // PairDiscoveredDevicesOnFleetNode: credentials in the request, plugin/node error + // strings in responses that can echo secrets. fleetnodeadminv1connect.FleetNodeAdminServicePairDiscoveredDevicesOnFleetNodeProcedure: true, } diff --git a/server/migrations/000084_widen_device_identifier.down.sql b/server/migrations/000084_widen_device_identifier.down.sql new file mode 100644 index 000000000..f7072dd4c --- /dev/null +++ b/server/migrations/000084_widen_device_identifier.down.sql @@ -0,0 +1,2 @@ +-- Narrowing back to VARCHAR(36); rows longer than 36 chars would fail the cast. +ALTER TABLE device ALTER COLUMN device_identifier TYPE VARCHAR(36); diff --git a/server/migrations/000084_widen_device_identifier.up.sql b/server/migrations/000084_widen_device_identifier.up.sql new file mode 100644 index 000000000..b0fe19c7a --- /dev/null +++ b/server/migrations/000084_widen_device_identifier.up.sql @@ -0,0 +1,5 @@ +-- Widen device.device_identifier to match discovered_device (VARCHAR(255)). Fleet-node +-- pairing inserts a device keyed by the discovered identifier (a synthesized +-- "serial:<...>" can reach 255 chars), which overflowed the old VARCHAR(36) and failed +-- the persist after the node had already paired the miner. Lengthening doesn't rewrite. +ALTER TABLE device ALTER COLUMN device_identifier TYPE VARCHAR(255); diff --git a/server/sqlc/queries/device.sql b/server/sqlc/queries/device.sql index 11201433e..3b767ae37 100644 --- a/server/sqlc/queries/device.sql +++ b/server/sqlc/queries/device.sql @@ -71,6 +71,26 @@ ON CONFLICT (device_id) DO UPDATE SET paired_at = CURRENT_TIMESTAMP, unpaired_at = NULL; +-- Mark a device AUTHENTICATION_NEEDED without ever downgrading a PAIRED row: the +-- WHERE guard no-ops the write when the row is already PAIRED, closing the race +-- where a concurrent pair commits PAIRED between the caller's read and this write +-- (the DO UPDATE branch re-reads the latest committed row). Zero rows means PAIRED won. +-- name: SetDevicePairingAuthNeededIfNotPaired :execrows +INSERT INTO device_pairing ( + device_id, + pairing_status, + paired_at +) VALUES ( + $1, + 'AUTHENTICATION_NEEDED'::pairing_status_enum, + CURRENT_TIMESTAMP +) +ON CONFLICT (device_id) DO UPDATE SET + pairing_status = 'AUTHENTICATION_NEEDED'::pairing_status_enum, + paired_at = CURRENT_TIMESTAMP, + unpaired_at = NULL +WHERE device_pairing.pairing_status IS DISTINCT FROM 'PAIRED'::pairing_status_enum; + -- PostgreSQL equivalent of UPDATE with INNER JOIN. -- At most one row matches: -- device.device_identifier is partial-UNIQUE on deleted_at IS NULL and @@ -107,7 +127,10 @@ WHERE device_identifier = $1 UPDATE device SET mac_address = COALESCE(NULLIF(sqlc.arg('mac_address')::text, ''), mac_address), - serial_number = sqlc.arg('serial_number') + -- Preserve a previously learned serial when the report omits it (e.g. an + -- AUTH_NEEDED/AUTH_FAILED retry, or a plugin that returns only a MAC), matching + -- mac_address above; a blank arg must not erase the stored serial. + serial_number = COALESCE(NULLIF(sqlc.arg('serial_number')::text, ''), serial_number) WHERE device_identifier = sqlc.arg('device_identifier') AND org_id = sqlc.arg('org_id') AND deleted_at IS NULL; diff --git a/server/sqlc/queries/fleetnodepairing.sql b/server/sqlc/queries/fleetnodepairing.sql index 873985524..cd000d2a3 100644 --- a/server/sqlc/queries/fleetnodepairing.sql +++ b/server/sqlc/queries/fleetnodepairing.sql @@ -110,6 +110,22 @@ SELECT EXISTS ( ) ); +-- name: DeviceHasActivePairing :one +-- True when the device is PAIRED, regardless of whether it is cloud-dialed or +-- bound to a fleet node. Used to refuse downgrading an already-PAIRED device to +-- AUTHENTICATION_NEEDED on a non-PAIRED node report: between target resolution +-- and persistence, another node (or the cloud) may have paired the device, and a +-- stale AUTH_NEEDED result must not clobber that PAIRED status. +SELECT EXISTS ( + SELECT 1 + FROM device_pairing dp + JOIN device d ON d.id = dp.device_id + WHERE dp.device_id = $1 + AND d.org_id = $2 + AND d.deleted_at IS NULL + AND dp.pairing_status = 'PAIRED' +); + -- name: TransferDiscoveredDeviceAttribution :execrows -- Pairing makes the fleet node the discovery owner so its future reports refresh -- the row (the upsert keys refreshability on discovered_by_fleet_node_id); @@ -155,7 +171,11 @@ ORDER BY fnd.assigned_at DESC, fnd.device_id ASC; -- attempt that needs credentials) surface for retry. Inverse of -- GetActiveUnpairedDiscoveredDevices, which excludes fleet-node rows. -- The exclusions use NOT EXISTS so a device with more than one live row is --- judged across all of them, not just the joined row. DISTINCT ON (dd.id) with +-- judged across all of them, not just the joined row. They match by +-- discovered_device_id OR device_identifier: a paired device whose original +-- discovery row was soft-deleted and re-created by a node keeps the same +-- identifier but a different linkage, and must not be dispatched for pairing +-- (the node would mutate a miner persistence then rejects). DISTINCT ON (dd.id) with -- the d.id DESC tie-breaker yields one deterministic row per discovered device -- (the latest live device's pairing_status). Paginates by ascending id; a NULL -- limit returns all rows (the pairing batch path needs every candidate). @@ -184,16 +204,42 @@ WHERE dd.org_id = $1 SELECT 1 FROM device db JOIN fleet_node_device fnd ON fnd.device_id = db.id AND fnd.org_id = dd.org_id - WHERE db.discovered_device_id = dd.id AND db.deleted_at IS NULL + WHERE (db.discovered_device_id = dd.id + OR (db.device_identifier = dd.device_identifier AND db.org_id = dd.org_id)) + AND db.deleted_at IS NULL ) AND NOT EXISTS ( SELECT 1 FROM device dpd JOIN device_pairing dpp ON dpp.device_id = dpd.id - WHERE dpd.discovered_device_id = dd.id AND dpd.deleted_at IS NULL + WHERE (dpd.discovered_device_id = dd.id + OR (dpd.device_identifier = dd.device_identifier AND dpd.org_id = dd.org_id)) + AND dpd.deleted_at IS NULL AND dpp.pairing_status = 'PAIRED' ) AND (sqlc.narg('fleet_node_id')::bigint IS NULL OR dd.discovered_by_fleet_node_id = sqlc.narg('fleet_node_id')::bigint) + -- pair-all without operator credentials can't satisfy AUTHENTICATION_NEEDED rows + -- (they were already attempted and need credentials). Excluding them keeps a + -- capped first page from filling with unsatisfiable rows and starving + -- never-attempted devices on re-issue for nodes with more than `limit` + -- candidates. NULL/false keeps them (listing for display, and pair-all WITH + -- credentials, which can retry them). + AND ( + NOT COALESCE(sqlc.narg('exclude_auth_needed')::bool, FALSE) + OR NOT EXISTS ( + SELECT 1 + FROM device adn + JOIN device_pairing adp ON adp.device_id = adn.id + WHERE (adn.discovered_device_id = dd.id + OR (adn.device_identifier = dd.device_identifier AND adn.org_id = dd.org_id)) + AND adn.deleted_at IS NULL + AND adp.pairing_status = 'AUTHENTICATION_NEEDED' + ) + ) + -- Explicit pairing passes the requested identifiers so only those rows are + -- scanned, not the whole org. NULL = no filter (listing + pair-all); an empty + -- non-nil array matches nothing (explicit selection of none). + AND (sqlc.narg('identifiers')::text[] IS NULL OR dd.device_identifier = ANY(sqlc.narg('identifiers')::text[])) AND (sqlc.narg('cursor_id')::bigint IS NULL OR dd.id > sqlc.narg('cursor_id')::bigint) ORDER BY dd.id ASC, d.id DESC NULLS LAST LIMIT sqlc.narg('limit')::bigint;